This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

reserved-tag-key helper + tag-aware pty gc

Nathan Herald (Apr 17, 2026, 6:46 PM +0200) 336902ba 8a93e2da

+370 -23
+2
CHANGELOG.md
··· 8 8 ### Client API 9 9 - Add `env?: Record<string, string>` to `SpawnDaemonOptions` and `ServerOptions` — use this to spawn a session child with a verbatim environment (no inheritance from the daemon's `process.env`, no allow-list). `PTY_SESSION` is always injected on top so nesting detection and `pty exec` keep working. Mutually exclusive with `isolateEnv` / `extraEnv` (throws at daemon startup if both are passed). Requested by the pty-layout dev to spawn a launcher shell with a shim `tmux` on `PATH`, a custom `TMUX` marker, and a filter-tag env var. 10 10 - Add `PtyHandle.readWrappedFlags(scrollOffset?): boolean[]` — per-row flags aligned with `readCells`, where `true` means the row continues the previous row because xterm wrapped a long line (not because the child emitted `\n`). Intended for consumers reconstructing logical lines from a visually-multi-row text selection (e.g., copying a wrapped URL to the clipboard without a spurious newline). Passes through xterm.js's `IBufferLine.isWrapped`; works for both `createPty` embedded terminals and `attachPty` remote sessions since the serialize/replay path preserves wrap state. 11 + - Add `isReservedTagKey(key)` to `@myobie/pty/client`. Returns `true` for pty's internal bookkeeping keys (`ptyfile`, `ptyfile.session`, `ptyfile.tags`, `supervisor.status`, `strategy`) **and** for any key starting with `:`. The `:` prefix is a new convention for **tool-owned tags** — downstream tools (pty-relay, pty-layout) can set and read tags like `:l<pid>-<rand>` or `:layout=grid` and have them hidden from `pty list` and the interactive TUI by default, while still being visible under `pty list --tags`. Replaces five copies of a hand-maintained deny-list across `cli.ts`, `interactive.ts`, and downstream consumers. 12 + - Add `pruneOrphanLayoutTags(): Promise<PrunedTagResult[]>` and wire it into `pty gc`. Walks **running** sessions, matches tag keys against `/^:l(\d+)-[a-z0-9]+$/`, and removes any whose encoded PID is no longer alive (ESRCH from `kill(pid, 0)`). Use case: pty-layout stamps `:l<pid>-<rand>` on each session it owns a view of, and if the layout process crashes the tag persists. `pty gc` now cleans these up alongside exited sessions in one pass. 11 13 12 14 ## 0.9.0 13 15
+15
docs/client.md
··· 40 40 console.log(`Cleaned up ${removed.length} sessions`); 41 41 ``` 42 42 43 + ### `pruneOrphanLayoutTags(): Promise<PrunedTagResult[]>` 44 + 45 + Walks running sessions and removes tag keys of the form `:l<pid>-<rand>` whose encoded PID is no longer alive. `pty gc` calls this after removing exited sessions. 46 + 47 + ```typescript 48 + interface PrunedTagResult { 49 + name: string; 50 + removedKeys: string[]; 51 + } 52 + ``` 53 + 54 + ### `isReservedTagKey(key: string): boolean` 55 + 56 + Returns `true` for pty's internal bookkeeping keys (`ptyfile`, `ptyfile.session`, `ptyfile.tags`, `supervisor.status`, `strategy`) and for any key starting with `:` (the tool-owned-tag convention). Downstream tools should hide reserved keys from user-facing listings by default but still allow writes — set and unset them as needed. 57 + 43 58 ### `cleanupSocket(name: string): void` 44 59 45 60 Remove a session's `.sock` and `.pid` files.
+26 -12
src/cli.ts
··· 10 10 listSessions, 11 11 getSession, 12 12 gc, 13 + pruneOrphanLayoutTags, 13 14 cleanupAll, 14 15 cleanupSocket, 15 16 validateName, ··· 27 28 import { EventFollower, EventWriter, EventType, readRecentEvents, formatEvent } from "./events.ts"; 28 29 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 29 30 import { getSupervisorDir } from "./supervisor.ts"; 30 - import { extractFilterTags as extractFilterTagsImpl, matchesAllTags } from "./tags.ts"; 31 + import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; 31 32 32 33 // Lazy-load the interactive TUI so non-interactive commands don't crash when 33 34 // the caller's cwd was deleted (the TUI module evaluates process.cwd() at load). ··· 1160 1161 const running = sessions.filter((s) => s.status === "running"); 1161 1162 const exited = sessions.filter((s) => s.status === "exited"); 1162 1163 1163 - // Render tags as hashtags. When `showAll` is false, hide internal bookkeeping 1164 - // keys (ptyfile*, supervisor.status) since those have dedicated markers or 1165 - // aren't meaningful to users. `--tags` (showAll=true) includes everything. 1164 + // Render tags as hashtags. When `showAll` is false, hide reserved keys 1165 + // (pty-internal bookkeeping like `ptyfile*`/`strategy`, plus any key 1166 + // starting with `:` which is the tool-owned-tag convention — e.g., 1167 + // pty-layout's `:l<pid>-<rand>` view membership markers). `--tags` 1168 + // (showAll=true) shows everything. 1166 1169 const renderTags = (tags: Record<string, string> | undefined, showAll: boolean): string => { 1167 1170 if (!tags) return ""; 1168 - const entries = Object.entries(tags).filter(([k]) => 1169 - showAll || (k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && k !== "supervisor.status" && k !== "strategy"), 1170 - ); 1171 + const entries = Object.entries(tags).filter(([k]) => showAll || !isReservedTagKey(k)); 1171 1172 return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; 1172 1173 }; 1173 1174 ··· 1586 1587 1587 1588 async function cmdGc(): Promise<void> { 1588 1589 const removed = await gc(); 1590 + const prunedTags = await pruneOrphanLayoutTags(); 1589 1591 1590 - if (removed.length === 0) { 1591 - console.log("No exited sessions to clean up."); 1592 + for (const name of removed) { 1593 + console.log(`Removed: ${name}`); 1594 + } 1595 + for (const { name, removedKeys } of prunedTags) { 1596 + console.log(`Pruned orphan tags on ${name}: ${removedKeys.map((k) => `#${k}`).join(" ")}`); 1597 + } 1598 + 1599 + const totalTags = prunedTags.reduce((sum, r) => sum + r.removedKeys.length, 0); 1600 + if (removed.length === 0 && totalTags === 0) { 1601 + console.log("Nothing to clean up."); 1592 1602 return; 1593 1603 } 1594 1604 1595 - for (const name of removed) { 1596 - console.log(`Removed: ${name}`); 1605 + const parts: string[] = []; 1606 + if (removed.length > 0) { 1607 + parts.push(`${removed.length} exited session${removed.length === 1 ? "" : "s"}`); 1608 + } 1609 + if (totalTags > 0) { 1610 + parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`); 1597 1611 } 1598 - console.log(`Cleaned up ${removed.length} exited session${removed.length === 1 ? "" : "s"}.`); 1612 + console.log(`Cleaned up ${parts.join(" and ")}.`); 1599 1613 } 1600 1614 1601 1615 async function cmdSupervisorStart(): Promise<void> {
+4 -3
src/client-api.ts
··· 3 3 4 4 // Session management 5 5 export { 6 - listSessions, getSession, gc, validateName, updateTags, setDisplayName, 6 + listSessions, getSession, gc, pruneOrphanLayoutTags, 7 + validateName, updateTags, setDisplayName, 7 8 getSessionDir, getSocketPath, 8 9 cleanupSocket, cleanupAll, 9 - type SessionInfo, type SessionMetadata, 10 + type SessionInfo, type SessionMetadata, type PrunedTagResult, 10 11 } from "./sessions.ts"; 11 12 12 13 // Session creation ··· 43 44 export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts"; 44 45 45 46 // Tag filter helpers (used by --filter-tag; shared with pty-relay) 46 - export { extractFilterTags, matchesAllTags } from "./tags.ts"; 47 + export { extractFilterTags, matchesAllTags, isReservedTagKey } from "./tags.ts"; 47 48 48 49 // Keys 49 50 export { resolveKey, parseSeqValue } from "./keys.ts";
+50
src/sessions.ts
··· 250 250 return exited.map((s) => s.name); 251 251 } 252 252 253 + /** 254 + * Layout tool tag keys follow `:l<pid>-<rand>` where the PID is the 255 + * pty-layout process that owns the view. When that process dies the 256 + * tag becomes an orphan. Same shape as the `:` reserved prefix 257 + * documented in `isReservedTagKey`. 258 + */ 259 + const ORPHAN_LAYOUT_TAG_RE = /^:l(\d+)-[a-z0-9]+$/; 260 + 261 + export interface PrunedTagResult { 262 + name: string; 263 + removedKeys: string[]; 264 + } 265 + 266 + /** 267 + * Walk **running** sessions and remove `:l<pid>-<rand>` tag keys whose 268 + * encoded PID no longer exists. Called by `pty gc` to clean up after a 269 + * pty-layout process that exited without clearing its tags. 270 + * 271 + * Returns a list of sessions that had at least one tag pruned, and 272 + * which keys were removed from each. 273 + */ 274 + export async function pruneOrphanLayoutTags(): Promise<PrunedTagResult[]> { 275 + const sessions = await listSessions(); 276 + const results: PrunedTagResult[] = []; 277 + for (const s of sessions) { 278 + if (s.status !== "running") continue; 279 + const tags = s.metadata?.tags; 280 + if (!tags) continue; 281 + const toRemove: string[] = []; 282 + for (const key of Object.keys(tags)) { 283 + const match = ORPHAN_LAYOUT_TAG_RE.exec(key); 284 + if (!match) continue; 285 + const pid = parseInt(match[1], 10); 286 + if (!Number.isFinite(pid) || pid <= 0) { 287 + toRemove.push(key); 288 + continue; 289 + } 290 + if (!isProcessAlive(pid)) toRemove.push(key); 291 + } 292 + if (toRemove.length === 0) continue; 293 + try { 294 + updateTags(s.name, {}, toRemove); 295 + results.push({ name: s.name, removedKeys: toRemove }); 296 + } catch { 297 + // Session metadata vanished between listing and update — ignore. 298 + } 299 + } 300 + return results; 301 + } 302 + 253 303 function readPid(name: string): number | null { 254 304 try { 255 305 const content = fs.readFileSync(getPidPath(name), "utf-8").trim();
+32
src/tags.ts
··· 44 44 } 45 45 return true; 46 46 } 47 + 48 + /** 49 + * Keys that pty itself treats as internal bookkeeping. These drive 50 + * dedicated markers (`[permanent]`, `[failed]`) or wire up the 51 + * supervisor / ptyfile plumbing — they're visible in `pty list --tags` 52 + * but hidden from the default listing. 53 + */ 54 + const EXACT_RESERVED = new Set([ 55 + "ptyfile", 56 + "ptyfile.session", 57 + "ptyfile.tags", 58 + "supervisor.status", 59 + "strategy", 60 + ]); 61 + 62 + /** 63 + * Returns `true` if the tag key is "reserved" — either one of pty's 64 + * internal bookkeeping keys (see above) or any key starting with `:`. 65 + * 66 + * The `:` prefix is a convention for **tool-owned tags** (e.g., 67 + * pty-layout stamps `:l<pid>-<rand>` keys on sessions it owns a view 68 + * of). Consumers should hide reserved keys from user-facing listings 69 + * by default but still allow writes — tools need to set and unset them. 70 + * 71 + * Exposed on `@myobie/pty/client` so downstream tools (pty-relay, 72 + * pty-layout) can use the same rule without duplicating deny-lists. 73 + */ 74 + export function isReservedTagKey(key: string): boolean { 75 + if (EXACT_RESERVED.has(key)) return true; 76 + if (key.startsWith(":")) return true; 77 + return false; 78 + }
+5 -7
src/tui/interactive.ts
··· 10 10 cleanupAll, getSession, getSessionDir, type SessionInfo, 11 11 } from "../sessions.ts"; 12 12 import { spawnDaemon } from "../spawn.ts"; 13 - import { matchesAllTags } from "../tags.ts"; 13 + import { matchesAllTags, isReservedTagKey } from "../tags.ts"; 14 14 import { 15 15 app, screen, signal, computed, batch, 16 16 text, panel, footer, ··· 267 267 // List screen 268 268 // ============================================================ 269 269 270 - /** Render user-facing tags as a " #key=value" string. Hides internal 271 - * bookkeeping keys that already have dedicated markers or aren't meaningful. */ 270 + /** Render user-facing tags as a " #key=value" string. Hides reserved 271 + * keys (pty-internal bookkeeping + any key starting with `:`, the 272 + * tool-owned-tag convention). */ 272 273 function renderTagsInline(tags: Record<string, string> | undefined): string { 273 274 if (!tags) return ""; 274 - const entries = Object.entries(tags).filter(([k]) => 275 - k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && 276 - k !== "supervisor.status" && k !== "strategy", 277 - ); 275 + const entries = Object.entries(tags).filter(([k]) => !isReservedTagKey(k)); 278 276 return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; 279 277 } 280 278
+188
tests/gc.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 12 + 13 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gc-")); 14 + afterAll(() => { 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + let bgPids: number[] = []; 19 + let sessionDirs: string[] = []; 20 + 21 + function makeSessionDir(): string { 22 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 23 + sessionDirs.push(dir); 24 + return dir; 25 + } 26 + 27 + let nameCounter = 0; 28 + function uniqueName(): string { 29 + return `gc${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 30 + } 31 + 32 + async function startDaemon( 33 + sessionDir: string, 34 + name: string, 35 + command: string, 36 + args: string[] = [], 37 + tags?: Record<string, string>, 38 + ): Promise<number> { 39 + const config = JSON.stringify({ 40 + name, command, args, displayCommand: command, 41 + cwd: os.tmpdir(), rows: 24, cols: 80, tags, 42 + }); 43 + const child = spawn(nodeBin, [serverModule], { 44 + detached: true, 45 + stdio: ["ignore", "ignore", "pipe"], 46 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 47 + }); 48 + let stderr = ""; 49 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 50 + let exitCode: number | null = null; 51 + child.on("exit", (code) => { exitCode = code; }); 52 + (child.stderr as any)?.unref?.(); 53 + child.unref(); 54 + 55 + const socketPath = path.join(sessionDir, `${name}.sock`); 56 + const start = Date.now(); 57 + while (Date.now() - start < 5000) { 58 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 59 + try { 60 + fs.statSync(socketPath); 61 + await new Promise((r) => setTimeout(r, 100)); 62 + bgPids.push(child.pid!); 63 + return child.pid!; 64 + } catch {} 65 + await new Promise((r) => setTimeout(r, 50)); 66 + } 67 + throw new Error("Timeout waiting for daemon"); 68 + } 69 + 70 + function runCli(sessionDir: string, ...args: string[]) { 71 + return spawnSync(nodeBin, [cliPath, ...args], { 72 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 73 + encoding: "utf-8", 74 + timeout: 10000, 75 + }); 76 + } 77 + 78 + function readMeta(sessionDir: string, name: string) { 79 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 80 + } 81 + 82 + /** Find an unused PID so ESRCH is deterministic. We probe from 999999 83 + * downward since typical systems reuse low PIDs. */ 84 + function findDeadPid(): number { 85 + for (let p = 999999; p > 900000; p -= 7) { 86 + try { process.kill(p, 0); } catch { return p; } 87 + } 88 + throw new Error("Could not find an unused PID for the test"); 89 + } 90 + 91 + afterEach(() => { 92 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 93 + bgPids = []; 94 + for (const dir of sessionDirs) { 95 + try { 96 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 97 + } catch {} 98 + } 99 + sessionDirs = []; 100 + }); 101 + 102 + describe("pty gc", () => { 103 + it("removes exited sessions", async () => { 104 + const dir = makeSessionDir(); 105 + const name = uniqueName(); 106 + await startDaemon(dir, name, "true"); 107 + await new Promise((r) => setTimeout(r, 1000)); 108 + 109 + const before = fs.existsSync(path.join(dir, `${name}.json`)); 110 + expect(before).toBe(true); 111 + 112 + const result = runCli(dir, "gc"); 113 + expect(result.status).toBe(0); 114 + expect(result.stdout).toContain(`Removed: ${name}`); 115 + expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false); 116 + }, 15000); 117 + 118 + it("prunes `:l<pid>-<rand>` tags whose PID is dead", async () => { 119 + const dir = makeSessionDir(); 120 + const name = uniqueName(); 121 + const deadPid = findDeadPid(); 122 + const tagKey = `:l${deadPid}-abc`; 123 + 124 + await startDaemon(dir, name, "cat", [], { 125 + role: "web", 126 + [tagKey]: "1", 127 + }); 128 + 129 + // Sanity: tag is on the session before gc. 130 + expect(readMeta(dir, name).tags[tagKey]).toBe("1"); 131 + 132 + const result = runCli(dir, "gc"); 133 + expect(result.status).toBe(0); 134 + expect(result.stdout).toContain(`Pruned orphan tags on ${name}: #${tagKey}`); 135 + 136 + // Session still exists (it's running); the orphan tag is gone; 137 + // normal tags are untouched. 138 + const meta = readMeta(dir, name); 139 + expect(meta.tags[tagKey]).toBeUndefined(); 140 + expect(meta.tags.role).toBe("web"); 141 + }, 15000); 142 + 143 + it("keeps `:l<pid>-<rand>` tags whose PID is still alive", async () => { 144 + const dir = makeSessionDir(); 145 + const name = uniqueName(); 146 + const liveTagKey = `:l${process.pid}-xyz`; 147 + 148 + await startDaemon(dir, name, "cat", [], { 149 + [liveTagKey]: "1", 150 + }); 151 + 152 + const result = runCli(dir, "gc"); 153 + expect(result.status).toBe(0); 154 + expect(result.stdout).not.toContain(liveTagKey); 155 + 156 + const meta = readMeta(dir, name); 157 + expect(meta.tags[liveTagKey]).toBe("1"); 158 + }, 15000); 159 + 160 + it("does not prune non-layout `:` tags", async () => { 161 + const dir = makeSessionDir(); 162 + const name = uniqueName(); 163 + 164 + // `:` prefix alone is reserved/display-hidden but NOT an orphan 165 + // layout tag — gc should leave it alone even if no PID is encoded. 166 + await startDaemon(dir, name, "cat", [], { 167 + ":layout": "grid", 168 + ":other": "x", 169 + }); 170 + 171 + const result = runCli(dir, "gc"); 172 + expect(result.status).toBe(0); 173 + 174 + const meta = readMeta(dir, name); 175 + expect(meta.tags[":layout"]).toBe("grid"); 176 + expect(meta.tags[":other"]).toBe("x"); 177 + }, 15000); 178 + 179 + it("reports nothing to clean up when no exited sessions and no orphan tags", async () => { 180 + const dir = makeSessionDir(); 181 + const name = uniqueName(); 182 + await startDaemon(dir, name, "cat"); 183 + 184 + const result = runCli(dir, "gc"); 185 + expect(result.status).toBe(0); 186 + expect(result.stdout).toContain("Nothing to clean up."); 187 + }, 15000); 188 + });
+29 -1
tests/tags-helpers.test.ts
··· 1 1 import { describe, it, expect } from "vitest"; 2 - import { extractFilterTags, matchesAllTags } from "../src/tags.ts"; 2 + import { extractFilterTags, matchesAllTags, isReservedTagKey } from "../src/tags.ts"; 3 3 4 4 describe("extractFilterTags", () => { 5 5 it("returns empty object when no --filter-tag flags present", () => { ··· 58 58 expect(matchesAllTags({ role: "web" }, { role: "" })).toBe(false); 59 59 }); 60 60 }); 61 + 62 + describe("isReservedTagKey", () => { 63 + it("flags pty's internal bookkeeping keys", () => { 64 + expect(isReservedTagKey("ptyfile")).toBe(true); 65 + expect(isReservedTagKey("ptyfile.session")).toBe(true); 66 + expect(isReservedTagKey("ptyfile.tags")).toBe(true); 67 + expect(isReservedTagKey("supervisor.status")).toBe(true); 68 + expect(isReservedTagKey("strategy")).toBe(true); 69 + }); 70 + 71 + it("flags any key starting with `:` (tool-owned convention)", () => { 72 + expect(isReservedTagKey(":l12345-abc")).toBe(true); 73 + expect(isReservedTagKey(":layout")).toBe(true); 74 + expect(isReservedTagKey(":x")).toBe(true); 75 + }); 76 + 77 + it("does not flag ordinary user tag keys", () => { 78 + expect(isReservedTagKey("role")).toBe(false); 79 + expect(isReservedTagKey("env")).toBe(false); 80 + expect(isReservedTagKey("owner")).toBe(false); 81 + expect(isReservedTagKey("ptyfile-extra")).toBe(false); // not an exact match 82 + expect(isReservedTagKey("strategy.extra")).toBe(false); 83 + }); 84 + 85 + it("empty string is not reserved", () => { 86 + expect(isReservedTagKey("")).toBe(false); 87 + }); 88 + });
+19
tests/tags.test.ts
··· 199 199 expect(output).toContain("#strategy=permanent"); 200 200 }, 15000); 201 201 202 + it("pty list hides `:` tool-owned tags by default, shows them with --tags", async () => { 203 + const dir = makeSessionDir(); 204 + const name = uniqueName(); 205 + await startDaemon(dir, name, "cat", [], { 206 + role: "web", 207 + ":l1234-abc": "1", 208 + ":layout": "grid", 209 + }); 210 + 211 + const defaultOutput = runCli(dir, "list"); 212 + expect(defaultOutput).toContain("#role=web"); 213 + expect(defaultOutput).not.toContain(":l1234-abc"); 214 + expect(defaultOutput).not.toContain(":layout"); 215 + 216 + const tagsOutput = runCli(dir, "list", "--tags"); 217 + expect(tagsOutput).toContain("#:l1234-abc=1"); 218 + expect(tagsOutput).toContain("#:layout=grid"); 219 + }, 15000); 220 + 202 221 it("pty list --filter-tag filters text output too", async () => { 203 222 const dir = makeSessionDir(); 204 223 const matchName = uniqueName();