···88### Client API
99- 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.
1010- 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.
1111+- 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.
1212+- 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.
11131214## 0.9.0
1315
+15
docs/client.md
···4040console.log(`Cleaned up ${removed.length} sessions`);
4141```
42424343+### `pruneOrphanLayoutTags(): Promise<PrunedTagResult[]>`
4444+4545+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.
4646+4747+```typescript
4848+interface PrunedTagResult {
4949+ name: string;
5050+ removedKeys: string[];
5151+}
5252+```
5353+5454+### `isReservedTagKey(key: string): boolean`
5555+5656+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.
5757+4358### `cleanupSocket(name: string): void`
44594560Remove a session's `.sock` and `.pid` files.
+26-12
src/cli.ts
···1010 listSessions,
1111 getSession,
1212 gc,
1313+ pruneOrphanLayoutTags,
1314 cleanupAll,
1415 cleanupSocket,
1516 validateName,
···2728import { EventFollower, EventWriter, EventType, readRecentEvents, formatEvent } from "./events.ts";
2829import { readPtyFile, type PtySessionDef } from "./ptyfile.ts";
2930import { getSupervisorDir } from "./supervisor.ts";
3030-import { extractFilterTags as extractFilterTagsImpl, matchesAllTags } from "./tags.ts";
3131+import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts";
31323233// Lazy-load the interactive TUI so non-interactive commands don't crash when
3334// the caller's cwd was deleted (the TUI module evaluates process.cwd() at load).
···11601161 const running = sessions.filter((s) => s.status === "running");
11611162 const exited = sessions.filter((s) => s.status === "exited");
1162116311631163- // Render tags as hashtags. When `showAll` is false, hide internal bookkeeping
11641164- // keys (ptyfile*, supervisor.status) since those have dedicated markers or
11651165- // aren't meaningful to users. `--tags` (showAll=true) includes everything.
11641164+ // Render tags as hashtags. When `showAll` is false, hide reserved keys
11651165+ // (pty-internal bookkeeping like `ptyfile*`/`strategy`, plus any key
11661166+ // starting with `:` which is the tool-owned-tag convention — e.g.,
11671167+ // pty-layout's `:l<pid>-<rand>` view membership markers). `--tags`
11681168+ // (showAll=true) shows everything.
11661169 const renderTags = (tags: Record<string, string> | undefined, showAll: boolean): string => {
11671170 if (!tags) return "";
11681168- const entries = Object.entries(tags).filter(([k]) =>
11691169- showAll || (k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && k !== "supervisor.status" && k !== "strategy"),
11701170- );
11711171+ const entries = Object.entries(tags).filter(([k]) => showAll || !isReservedTagKey(k));
11711172 return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : "";
11721173 };
11731174···1586158715871588async function cmdGc(): Promise<void> {
15881589 const removed = await gc();
15901590+ const prunedTags = await pruneOrphanLayoutTags();
1589159115901590- if (removed.length === 0) {
15911591- console.log("No exited sessions to clean up.");
15921592+ for (const name of removed) {
15931593+ console.log(`Removed: ${name}`);
15941594+ }
15951595+ for (const { name, removedKeys } of prunedTags) {
15961596+ console.log(`Pruned orphan tags on ${name}: ${removedKeys.map((k) => `#${k}`).join(" ")}`);
15971597+ }
15981598+15991599+ const totalTags = prunedTags.reduce((sum, r) => sum + r.removedKeys.length, 0);
16001600+ if (removed.length === 0 && totalTags === 0) {
16011601+ console.log("Nothing to clean up.");
15921602 return;
15931603 }
1594160415951595- for (const name of removed) {
15961596- console.log(`Removed: ${name}`);
16051605+ const parts: string[] = [];
16061606+ if (removed.length > 0) {
16071607+ parts.push(`${removed.length} exited session${removed.length === 1 ? "" : "s"}`);
16081608+ }
16091609+ if (totalTags > 0) {
16101610+ parts.push(`${totalTags} orphan tag${totalTags === 1 ? "" : "s"}`);
15971611 }
15981598- console.log(`Cleaned up ${removed.length} exited session${removed.length === 1 ? "" : "s"}.`);
16121612+ console.log(`Cleaned up ${parts.join(" and ")}.`);
15991613}
1600161416011615async function cmdSupervisorStart(): Promise<void> {
+4-3
src/client-api.ts
···3344// Session management
55export {
66- listSessions, getSession, gc, validateName, updateTags, setDisplayName,
66+ listSessions, getSession, gc, pruneOrphanLayoutTags,
77+ validateName, updateTags, setDisplayName,
78 getSessionDir, getSocketPath,
89 cleanupSocket, cleanupAll,
99- type SessionInfo, type SessionMetadata,
1010+ type SessionInfo, type SessionMetadata, type PrunedTagResult,
1011} from "./sessions.ts";
11121213// Session creation
···4344export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts";
44454546// Tag filter helpers (used by --filter-tag; shared with pty-relay)
4646-export { extractFilterTags, matchesAllTags } from "./tags.ts";
4747+export { extractFilterTags, matchesAllTags, isReservedTagKey } from "./tags.ts";
47484849// Keys
4950export { resolveKey, parseSeqValue } from "./keys.ts";
+50
src/sessions.ts
···250250 return exited.map((s) => s.name);
251251}
252252253253+/**
254254+ * Layout tool tag keys follow `:l<pid>-<rand>` where the PID is the
255255+ * pty-layout process that owns the view. When that process dies the
256256+ * tag becomes an orphan. Same shape as the `:` reserved prefix
257257+ * documented in `isReservedTagKey`.
258258+ */
259259+const ORPHAN_LAYOUT_TAG_RE = /^:l(\d+)-[a-z0-9]+$/;
260260+261261+export interface PrunedTagResult {
262262+ name: string;
263263+ removedKeys: string[];
264264+}
265265+266266+/**
267267+ * Walk **running** sessions and remove `:l<pid>-<rand>` tag keys whose
268268+ * encoded PID no longer exists. Called by `pty gc` to clean up after a
269269+ * pty-layout process that exited without clearing its tags.
270270+ *
271271+ * Returns a list of sessions that had at least one tag pruned, and
272272+ * which keys were removed from each.
273273+ */
274274+export async function pruneOrphanLayoutTags(): Promise<PrunedTagResult[]> {
275275+ const sessions = await listSessions();
276276+ const results: PrunedTagResult[] = [];
277277+ for (const s of sessions) {
278278+ if (s.status !== "running") continue;
279279+ const tags = s.metadata?.tags;
280280+ if (!tags) continue;
281281+ const toRemove: string[] = [];
282282+ for (const key of Object.keys(tags)) {
283283+ const match = ORPHAN_LAYOUT_TAG_RE.exec(key);
284284+ if (!match) continue;
285285+ const pid = parseInt(match[1], 10);
286286+ if (!Number.isFinite(pid) || pid <= 0) {
287287+ toRemove.push(key);
288288+ continue;
289289+ }
290290+ if (!isProcessAlive(pid)) toRemove.push(key);
291291+ }
292292+ if (toRemove.length === 0) continue;
293293+ try {
294294+ updateTags(s.name, {}, toRemove);
295295+ results.push({ name: s.name, removedKeys: toRemove });
296296+ } catch {
297297+ // Session metadata vanished between listing and update — ignore.
298298+ }
299299+ }
300300+ return results;
301301+}
302302+253303function readPid(name: string): number | null {
254304 try {
255305 const content = fs.readFileSync(getPidPath(name), "utf-8").trim();
+32
src/tags.ts
···4444 }
4545 return true;
4646}
4747+4848+/**
4949+ * Keys that pty itself treats as internal bookkeeping. These drive
5050+ * dedicated markers (`[permanent]`, `[failed]`) or wire up the
5151+ * supervisor / ptyfile plumbing — they're visible in `pty list --tags`
5252+ * but hidden from the default listing.
5353+ */
5454+const EXACT_RESERVED = new Set([
5555+ "ptyfile",
5656+ "ptyfile.session",
5757+ "ptyfile.tags",
5858+ "supervisor.status",
5959+ "strategy",
6060+]);
6161+6262+/**
6363+ * Returns `true` if the tag key is "reserved" — either one of pty's
6464+ * internal bookkeeping keys (see above) or any key starting with `:`.
6565+ *
6666+ * The `:` prefix is a convention for **tool-owned tags** (e.g.,
6767+ * pty-layout stamps `:l<pid>-<rand>` keys on sessions it owns a view
6868+ * of). Consumers should hide reserved keys from user-facing listings
6969+ * by default but still allow writes — tools need to set and unset them.
7070+ *
7171+ * Exposed on `@myobie/pty/client` so downstream tools (pty-relay,
7272+ * pty-layout) can use the same rule without duplicating deny-lists.
7373+ */
7474+export function isReservedTagKey(key: string): boolean {
7575+ if (EXACT_RESERVED.has(key)) return true;
7676+ if (key.startsWith(":")) return true;
7777+ return false;
7878+}
+5-7
src/tui/interactive.ts
···1010 cleanupAll, getSession, getSessionDir, type SessionInfo,
1111} from "../sessions.ts";
1212import { spawnDaemon } from "../spawn.ts";
1313-import { matchesAllTags } from "../tags.ts";
1313+import { matchesAllTags, isReservedTagKey } from "../tags.ts";
1414import {
1515 app, screen, signal, computed, batch,
1616 text, panel, footer,
···267267// List screen
268268// ============================================================
269269270270-/** Render user-facing tags as a " #key=value" string. Hides internal
271271- * bookkeeping keys that already have dedicated markers or aren't meaningful. */
270270+/** Render user-facing tags as a " #key=value" string. Hides reserved
271271+ * keys (pty-internal bookkeeping + any key starting with `:`, the
272272+ * tool-owned-tag convention). */
272273function renderTagsInline(tags: Record<string, string> | undefined): string {
273274 if (!tags) return "";
274274- const entries = Object.entries(tags).filter(([k]) =>
275275- k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" &&
276276- k !== "supervisor.status" && k !== "strategy",
277277- );
275275+ const entries = Object.entries(tags).filter(([k]) => !isReservedTagKey(k));
278276 return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : "";
279277}
280278
+188
tests/gc.test.ts
···11+import { describe, it, expect, afterEach, afterAll } from "vitest";
22+import * as fs from "node:fs";
33+import * as os from "node:os";
44+import * as path from "node:path";
55+import { fileURLToPath } from "node:url";
66+import { spawn, spawnSync } from "node:child_process";
77+88+const __dirname = path.dirname(fileURLToPath(import.meta.url));
99+const nodeBin = process.execPath;
1010+const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1111+const serverModule = path.join(__dirname, "..", "dist", "server.js");
1212+1313+const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-gc-"));
1414+afterAll(() => {
1515+ fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1616+});
1717+1818+let bgPids: number[] = [];
1919+let sessionDirs: string[] = [];
2020+2121+function makeSessionDir(): string {
2222+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
2323+ sessionDirs.push(dir);
2424+ return dir;
2525+}
2626+2727+let nameCounter = 0;
2828+function uniqueName(): string {
2929+ return `gc${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`;
3030+}
3131+3232+async function startDaemon(
3333+ sessionDir: string,
3434+ name: string,
3535+ command: string,
3636+ args: string[] = [],
3737+ tags?: Record<string, string>,
3838+): Promise<number> {
3939+ const config = JSON.stringify({
4040+ name, command, args, displayCommand: command,
4141+ cwd: os.tmpdir(), rows: 24, cols: 80, tags,
4242+ });
4343+ const child = spawn(nodeBin, [serverModule], {
4444+ detached: true,
4545+ stdio: ["ignore", "ignore", "pipe"],
4646+ env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir },
4747+ });
4848+ let stderr = "";
4949+ child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
5050+ let exitCode: number | null = null;
5151+ child.on("exit", (code) => { exitCode = code; });
5252+ (child.stderr as any)?.unref?.();
5353+ child.unref();
5454+5555+ const socketPath = path.join(sessionDir, `${name}.sock`);
5656+ const start = Date.now();
5757+ while (Date.now() - start < 5000) {
5858+ if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
5959+ try {
6060+ fs.statSync(socketPath);
6161+ await new Promise((r) => setTimeout(r, 100));
6262+ bgPids.push(child.pid!);
6363+ return child.pid!;
6464+ } catch {}
6565+ await new Promise((r) => setTimeout(r, 50));
6666+ }
6767+ throw new Error("Timeout waiting for daemon");
6868+}
6969+7070+function runCli(sessionDir: string, ...args: string[]) {
7171+ return spawnSync(nodeBin, [cliPath, ...args], {
7272+ env: { ...process.env, PTY_SESSION_DIR: sessionDir },
7373+ encoding: "utf-8",
7474+ timeout: 10000,
7575+ });
7676+}
7777+7878+function readMeta(sessionDir: string, name: string) {
7979+ return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8"));
8080+}
8181+8282+/** Find an unused PID so ESRCH is deterministic. We probe from 999999
8383+ * downward since typical systems reuse low PIDs. */
8484+function findDeadPid(): number {
8585+ for (let p = 999999; p > 900000; p -= 7) {
8686+ try { process.kill(p, 0); } catch { return p; }
8787+ }
8888+ throw new Error("Could not find an unused PID for the test");
8989+}
9090+9191+afterEach(() => {
9292+ for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} }
9393+ bgPids = [];
9494+ for (const dir of sessionDirs) {
9595+ try {
9696+ for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} }
9797+ } catch {}
9898+ }
9999+ sessionDirs = [];
100100+});
101101+102102+describe("pty gc", () => {
103103+ it("removes exited sessions", async () => {
104104+ const dir = makeSessionDir();
105105+ const name = uniqueName();
106106+ await startDaemon(dir, name, "true");
107107+ await new Promise((r) => setTimeout(r, 1000));
108108+109109+ const before = fs.existsSync(path.join(dir, `${name}.json`));
110110+ expect(before).toBe(true);
111111+112112+ const result = runCli(dir, "gc");
113113+ expect(result.status).toBe(0);
114114+ expect(result.stdout).toContain(`Removed: ${name}`);
115115+ expect(fs.existsSync(path.join(dir, `${name}.json`))).toBe(false);
116116+ }, 15000);
117117+118118+ it("prunes `:l<pid>-<rand>` tags whose PID is dead", async () => {
119119+ const dir = makeSessionDir();
120120+ const name = uniqueName();
121121+ const deadPid = findDeadPid();
122122+ const tagKey = `:l${deadPid}-abc`;
123123+124124+ await startDaemon(dir, name, "cat", [], {
125125+ role: "web",
126126+ [tagKey]: "1",
127127+ });
128128+129129+ // Sanity: tag is on the session before gc.
130130+ expect(readMeta(dir, name).tags[tagKey]).toBe("1");
131131+132132+ const result = runCli(dir, "gc");
133133+ expect(result.status).toBe(0);
134134+ expect(result.stdout).toContain(`Pruned orphan tags on ${name}: #${tagKey}`);
135135+136136+ // Session still exists (it's running); the orphan tag is gone;
137137+ // normal tags are untouched.
138138+ const meta = readMeta(dir, name);
139139+ expect(meta.tags[tagKey]).toBeUndefined();
140140+ expect(meta.tags.role).toBe("web");
141141+ }, 15000);
142142+143143+ it("keeps `:l<pid>-<rand>` tags whose PID is still alive", async () => {
144144+ const dir = makeSessionDir();
145145+ const name = uniqueName();
146146+ const liveTagKey = `:l${process.pid}-xyz`;
147147+148148+ await startDaemon(dir, name, "cat", [], {
149149+ [liveTagKey]: "1",
150150+ });
151151+152152+ const result = runCli(dir, "gc");
153153+ expect(result.status).toBe(0);
154154+ expect(result.stdout).not.toContain(liveTagKey);
155155+156156+ const meta = readMeta(dir, name);
157157+ expect(meta.tags[liveTagKey]).toBe("1");
158158+ }, 15000);
159159+160160+ it("does not prune non-layout `:` tags", async () => {
161161+ const dir = makeSessionDir();
162162+ const name = uniqueName();
163163+164164+ // `:` prefix alone is reserved/display-hidden but NOT an orphan
165165+ // layout tag — gc should leave it alone even if no PID is encoded.
166166+ await startDaemon(dir, name, "cat", [], {
167167+ ":layout": "grid",
168168+ ":other": "x",
169169+ });
170170+171171+ const result = runCli(dir, "gc");
172172+ expect(result.status).toBe(0);
173173+174174+ const meta = readMeta(dir, name);
175175+ expect(meta.tags[":layout"]).toBe("grid");
176176+ expect(meta.tags[":other"]).toBe("x");
177177+ }, 15000);
178178+179179+ it("reports nothing to clean up when no exited sessions and no orphan tags", async () => {
180180+ const dir = makeSessionDir();
181181+ const name = uniqueName();
182182+ await startDaemon(dir, name, "cat");
183183+184184+ const result = runCli(dir, "gc");
185185+ expect(result.status).toBe(0);
186186+ expect(result.stdout).toContain("Nothing to clean up.");
187187+ }, 15000);
188188+});
+29-1
tests/tags-helpers.test.ts
···11import { describe, it, expect } from "vitest";
22-import { extractFilterTags, matchesAllTags } from "../src/tags.ts";
22+import { extractFilterTags, matchesAllTags, isReservedTagKey } from "../src/tags.ts";
3344describe("extractFilterTags", () => {
55 it("returns empty object when no --filter-tag flags present", () => {
···5858 expect(matchesAllTags({ role: "web" }, { role: "" })).toBe(false);
5959 });
6060});
6161+6262+describe("isReservedTagKey", () => {
6363+ it("flags pty's internal bookkeeping keys", () => {
6464+ expect(isReservedTagKey("ptyfile")).toBe(true);
6565+ expect(isReservedTagKey("ptyfile.session")).toBe(true);
6666+ expect(isReservedTagKey("ptyfile.tags")).toBe(true);
6767+ expect(isReservedTagKey("supervisor.status")).toBe(true);
6868+ expect(isReservedTagKey("strategy")).toBe(true);
6969+ });
7070+7171+ it("flags any key starting with `:` (tool-owned convention)", () => {
7272+ expect(isReservedTagKey(":l12345-abc")).toBe(true);
7373+ expect(isReservedTagKey(":layout")).toBe(true);
7474+ expect(isReservedTagKey(":x")).toBe(true);
7575+ });
7676+7777+ it("does not flag ordinary user tag keys", () => {
7878+ expect(isReservedTagKey("role")).toBe(false);
7979+ expect(isReservedTagKey("env")).toBe(false);
8080+ expect(isReservedTagKey("owner")).toBe(false);
8181+ expect(isReservedTagKey("ptyfile-extra")).toBe(false); // not an exact match
8282+ expect(isReservedTagKey("strategy.extra")).toBe(false);
8383+ });
8484+8585+ it("empty string is not reserved", () => {
8686+ expect(isReservedTagKey("")).toBe(false);
8787+ });
8888+});