This repository has no description
0

Configure Feed

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

Add session tags for ownership and metadata

Sessions can be tagged at creation with key-value pairs via
pty run --tag owner=forge -- command or the tags field in
SpawnDaemonOptions. Tags are persisted, survive process exit,
and appear in pty list --json. Closes #12.

Nathan Herald (Apr 10, 2026, 2:31 PM +0200) 28812ef7 101abd8f

+331 -13
+3
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Features 6 + - Add session tags: `pty run --tag owner=forge --tag env=dev -- command` sets key-value metadata on sessions, visible in `pty list --json` and persisted across exits. Tags are available in `SpawnDaemonOptions`, `ServerOptions`, and `SessionMetadata` for programmatic use (#12) 7 + 5 8 ### Fixes 6 9 - Fix `resolveKey` silently dropping shift modifier for non-letter keys: `shift+return` now correctly produces CSI u encoding (`\x1b[13;2u`), `shift+up` produces `\x1b[1;2A`, etc. All modifier combinations (ctrl+shift, alt+shift, ctrl+alt+shift) now work for arrows, navigation keys, and control chars (#13, #14, thanks @schickling) 7 10 - Validate session `cwd` before spawning and surface explicit errors (`Working directory does not exist`, `Working directory is not a directory`, `Working directory is not searchable`) instead of failing silently with exit code 1 or misleading `posix_spawnp failed` messages (#9, #10, thanks @schickling)
+1
README.md
··· 53 53 pty run -d -- node server.js # start in the background 54 54 pty run -a -- node server.js # create or attach if already running 55 55 pty run -e -- npm test # ephemeral: auto-remove on exit 56 + pty run --tag owner=forge -- node srv.js # tag a session with metadata 56 57 57 58 pty list # show active sessions 58 59 pty list --json # show as JSON
+1 -1
completions/pty.bash
··· 70 70 done 71 71 # Before --, complete flags 72 72 if [[ "${cur}" == -* ]]; then 73 - COMPREPLY=($(compgen -W "--detach -d --attach -a --ephemeral -e --name" -- "${cur}")) 73 + COMPREPLY=($(compgen -W "--detach -d --attach -a --ephemeral -e --name --tag" -- "${cur}")) 74 74 fi 75 75 ;; 76 76 esac
+1
completions/pty.fish
··· 68 68 complete -c pty -n '__pty_using_command run' -s a -l attach -d 'Attach if already running' 69 69 complete -c pty -n '__pty_using_command run' -s e -l ephemeral -d 'Auto-remove on exit' 70 70 complete -c pty -n '__pty_using_command run' -l name -x -d 'Session name (auto-generated if omitted)' 71 + complete -c pty -n '__pty_using_command run' -l tag -x -d 'Tag session (key=value, repeatable)' 71 72 complete -c pty -n '__pty_using_command run' -F 72 73 73 74 # attach: session names and flags
+2 -1
completions/pty.zsh
··· 122 122 '(-d --detach)'{-d,--detach}'[Create in background]' \ 123 123 '(-a --attach)'{-a,--attach}'[Attach if already running]' \ 124 124 '(-e --ephemeral)'{-e,--ephemeral}'[Auto-remove on exit]' \ 125 - '--name[Session name]:name:' 125 + '--name[Session name]:name:' \ 126 + '*--tag[Tag session with key=value]:tag:' 126 127 ;; 127 128 esac 128 129 ;;
+6 -4
docs/client.md
··· 65 65 exitCode?: number; 66 66 exitedAt?: string; 67 67 lastLines?: string[]; 68 + tags?: Record<string, string>; 68 69 } 69 70 ``` 70 71 ··· 80 81 command: string; 81 82 args: string[]; 82 83 displayCommand: string; 83 - cwd?: string; // defaults to process.cwd() 84 - ephemeral?: boolean; // auto-remove on exit 85 - rows?: number; // defaults to process.stdout.rows ?? 24 86 - cols?: number; // defaults to process.stdout.columns ?? 80 84 + cwd?: string; // defaults to process.cwd() 85 + ephemeral?: boolean; // auto-remove on exit 86 + rows?: number; // defaults to process.stdout.rows ?? 24 87 + cols?: number; // defaults to process.stdout.columns ?? 80 88 + tags?: Record<string, string>; // key-value metadata (e.g. { owner: "forge" }) 87 89 } 88 90 ``` 89 91
+23 -6
src/cli.ts
··· 33 33 pty run --name <n> -- <command> [args...] Create a named session and attach 34 34 pty run -d -- <command> [args...] Create in the background 35 35 pty run -a -- <command> [args...] Create or attach if already running 36 + pty run --tag key=value -- <command> Tag a session with metadata 36 37 pty attach <name> Attach to an existing session 37 38 pty attach -r <name> Attach, auto-restart if exited 38 39 pty peek <name> Print current screen and exit ··· 104 105 let attachExisting = false; 105 106 let ephemeral = false; 106 107 let name: string | null = null; 108 + const tags: Record<string, string> = {}; 107 109 let i = 1; 108 110 while (i < args.length && args[i] !== "--") { 109 111 if (args[i] === "-d" || args[i] === "--detach") { detach = true; i++; } 110 112 else if (args[i] === "-a" || args[i] === "--attach") { attachExisting = true; i++; } 111 113 else if (args[i] === "-e" || args[i] === "--ephemeral") { ephemeral = true; i++; } 112 114 else if (args[i] === "--name" && i + 1 < args.length) { name = args[i + 1]; i += 2; } 115 + else if (args[i] === "--tag" && i + 1 < args.length) { 116 + const eq = args[i + 1].indexOf("="); 117 + if (eq === -1) { 118 + console.error(`Invalid tag format: "${args[i + 1]}". Use --tag key=value`); 119 + process.exit(1); 120 + } 121 + tags[args[i + 1].slice(0, eq)] = args[i + 1].slice(eq + 1); 122 + i += 2; 123 + } 113 124 else break; 114 125 // Note: unknown flags or positional args before -- break the loop 115 126 } ··· 193 204 process.exit(1); 194 205 } 195 206 196 - await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral); 207 + await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags); 197 208 break; 198 209 } 199 210 ··· 464 475 attachExisting = false, 465 476 displayCommand: string, 466 477 ephemeral = false, 478 + tags: Record<string, string> = {}, 467 479 ): Promise<void> { 468 480 const session = await getSession(name); 469 481 if (session?.status === "running") { ··· 485 497 process.exit(1); 486 498 } 487 499 488 - // Clean up any dead session with the same name, but preserve cwd 489 - // so that `run -a` re-creates the session in the original directory. 500 + // Clean up any dead session with the same name, but preserve cwd and tags 501 + // so that `run -a` re-creates the session in the original directory with original tags. 490 502 const previousCwd = session?.status === "exited" ? session.metadata?.cwd : undefined; 503 + const previousTags = session?.status === "exited" ? session.metadata?.tags : undefined; 491 504 if (session?.status === "exited") { 492 505 cleanupAll(name); 493 506 } 494 507 495 508 try { 496 - await spawnDaemon({ name, command, args, displayCommand, cwd: previousCwd, ephemeral }); 509 + const tagOpt = Object.keys(tags).length > 0 ? tags : previousTags; 510 + await spawnDaemon({ name, command, args, displayCommand, cwd: previousCwd, ephemeral, tags: tagOpt }); 497 511 } finally { 498 512 releaseLock(name); 499 513 } ··· 564 578 565 579 // Restart 566 580 cleanupAll(session.name); 567 - await spawnDaemon({ name: session.name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd }); 581 + await spawnDaemon({ name: session.name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd, tags: meta.tags }); 568 582 console.log(`Session "${session.name}" restarted.`); 569 583 doAttach(session.name); 570 584 } ··· 602 616 createdAt: s.metadata?.createdAt ?? null, 603 617 exitCode: s.metadata?.exitCode ?? null, 604 618 exitedAt: s.metadata?.exitedAt ?? null, 619 + ...(s.metadata?.tags ? { tags: s.metadata.tags } : {}), 605 620 })); 606 621 console.log(JSON.stringify(output)); 607 622 return; ··· 662 677 status: "exited", 663 678 exitCode: session.metadata?.exitCode ?? null, 664 679 exitedAt: session.metadata?.exitedAt ?? null, 680 + ...(session.metadata?.tags ? { tags: session.metadata.tags } : {}), 665 681 })); 666 682 } else { 667 683 const code = session.metadata?.exitCode ?? "?"; ··· 715 731 status: "exited" as const, 716 732 exitCode: s.metadata?.exitCode ?? null, 717 733 exitedAt: s.metadata?.exitedAt ?? null, 734 + ...(s.metadata?.tags ? { tags: s.metadata.tags } : {}), 718 735 })) 719 736 : []), 720 737 ]; ··· 887 904 } 888 905 889 906 cleanupAll(name); 890 - await spawnDaemon({ name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd }); 907 + await spawnDaemon({ name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd, tags: meta.tags }); 891 908 console.log(`Session "${name}" restarted.`); 892 909 doAttach(name); 893 910 }
+4
src/server.ts
··· 46 46 cwd: string; 47 47 rows: number; 48 48 cols: number; 49 + tags?: Record<string, string>; 49 50 onExit?: (code: number) => void; 50 51 } 51 52 ··· 315 316 displayCommand: options.displayCommand, 316 317 cwd: options.cwd, 317 318 createdAt: new Date().toISOString(), 319 + ...(options.tags && Object.keys(options.tags).length > 0 ? { tags: options.tags } : {}), 318 320 }); 319 321 resolve(); 320 322 }); ··· 603 605 exitCode, 604 606 exitedAt: new Date().toISOString(), 605 607 lastLines: this.getLastLines(), 608 + ...(existing?.tags ? { tags: existing.tags } : {}), 606 609 }); 607 610 } 608 611 ··· 648 651 cwd: config.cwd ?? process.cwd(), 649 652 rows: config.rows ?? 24, 650 653 cols: config.cols ?? 80, 654 + tags: config.tags, 651 655 onExit: (code) => { 652 656 // Give clients a moment to receive the exit message, then shut down 653 657 setTimeout(() => cleanShutdown(code), 500);
+1
src/sessions.ts
··· 56 56 exitCode?: number; 57 57 exitedAt?: string; 58 58 lastLines?: string[]; 59 + tags?: Record<string, string>; 59 60 } 60 61 61 62 export interface SessionInfo {
+2
src/spawn.ts
··· 16 16 ephemeral?: boolean; 17 17 rows?: number; 18 18 cols?: number; 19 + tags?: Record<string, string>; 19 20 } 20 21 21 22 export async function spawnDaemon(options: SpawnDaemonOptions): Promise<void> { ··· 33 34 rows, 34 35 cols, 35 36 ephemeral: options.ephemeral ?? false, 37 + ...(options.tags && Object.keys(options.tags).length > 0 ? { tags: options.tags } : {}), 36 38 }); 37 39 38 40 const child = spawn(process.execPath, [serverModule], {
+1 -1
src/tui/interactive.ts
··· 506 506 } 507 507 cleanupAll(session.name); 508 508 try { 509 - await spawnDaemon({ name: session.name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd }); 509 + await spawnDaemon({ name: session.name, command: meta.command, args: meta.args, displayCommand: meta.displayCommand, cwd: meta.cwd, tags: meta.tags }); 510 510 } catch { 511 511 // Refresh list to show updated state 512 512 const updated = await listSessions();
+286
tests/tags.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-tags-")); 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 `tag${++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, 41 + command, 42 + args, 43 + displayCommand: command, 44 + cwd: os.tmpdir(), 45 + rows: 24, 46 + cols: 80, 47 + tags, 48 + }); 49 + 50 + const child = spawn(nodeBin, [serverModule], { 51 + detached: true, 52 + stdio: ["ignore", "ignore", "pipe"], 53 + env: { 54 + ...process.env, 55 + PTY_SERVER_CONFIG: config, 56 + PTY_SESSION_DIR: sessionDir, 57 + }, 58 + }); 59 + 60 + let stderr = ""; 61 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 62 + let exitCode: number | null = null; 63 + child.on("exit", (code) => { exitCode = code; }); 64 + (child.stderr as any)?.unref?.(); 65 + child.unref(); 66 + 67 + const socketPath = path.join(sessionDir, `${name}.sock`); 68 + const start = Date.now(); 69 + while (Date.now() - start < 5000) { 70 + if (exitCode !== null) { 71 + throw new Error(`Daemon exited with code ${exitCode}. stderr:\n${stderr}`); 72 + } 73 + try { 74 + fs.statSync(socketPath); 75 + await new Promise((r) => setTimeout(r, 100)); 76 + bgPids.push(child.pid!); 77 + return child.pid!; 78 + } catch {} 79 + await new Promise((r) => setTimeout(r, 50)); 80 + } 81 + throw new Error(`Timeout waiting for daemon socket: ${socketPath}`); 82 + } 83 + 84 + function runCli(sessionDir: string, ...args: string[]): string { 85 + return spawnSync(nodeBin, [cliPath, ...args], { 86 + env: { ...process.env, PTY_SESSION_DIR: sessionDir }, 87 + encoding: "utf-8", 88 + timeout: 10000, 89 + }).stdout; 90 + } 91 + 92 + afterEach(() => { 93 + for (const pid of bgPids) { 94 + try { process.kill(pid, "SIGTERM"); } catch {} 95 + } 96 + bgPids = []; 97 + for (const dir of sessionDirs) { 98 + try { 99 + const entries = fs.readdirSync(dir); 100 + for (const e of entries) { 101 + try { fs.unlinkSync(path.join(dir, e)); } catch {} 102 + } 103 + } catch {} 104 + } 105 + sessionDirs = []; 106 + }); 107 + 108 + describe("session tags", () => { 109 + it("tags are persisted in session metadata", async () => { 110 + const dir = makeSessionDir(); 111 + const name = uniqueName(); 112 + await startDaemon(dir, name, "cat", [], { owner: "forge", env: "dev" }); 113 + 114 + const metaPath = path.join(dir, `${name}.json`); 115 + const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); 116 + expect(meta.tags).toEqual({ owner: "forge", env: "dev" }); 117 + }, 15000); 118 + 119 + it("tags appear in pty list --json", async () => { 120 + const dir = makeSessionDir(); 121 + const name = uniqueName(); 122 + await startDaemon(dir, name, "cat", [], { owner: "myapp" }); 123 + 124 + const output = runCli(dir, "list", "--json"); 125 + const sessions = JSON.parse(output); 126 + const session = sessions.find((s: any) => s.name === name); 127 + expect(session).toBeDefined(); 128 + expect(session.tags).toEqual({ owner: "myapp" }); 129 + }, 15000); 130 + 131 + it("sessions without tags have no tags field in metadata", async () => { 132 + const dir = makeSessionDir(); 133 + const name = uniqueName(); 134 + await startDaemon(dir, name, "cat"); 135 + 136 + const metaPath = path.join(dir, `${name}.json`); 137 + const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); 138 + expect(meta.tags).toBeUndefined(); 139 + }, 15000); 140 + 141 + it("tags survive process exit (persisted in exit metadata)", async () => { 142 + const dir = makeSessionDir(); 143 + const name = uniqueName(); 144 + await startDaemon(dir, name, "true", [], { owner: "ci" }); // exits immediately 145 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit + metadata write 146 + 147 + const metaPath = path.join(dir, `${name}.json`); 148 + const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); 149 + expect(meta.tags).toEqual({ owner: "ci" }); 150 + expect(meta.exitCode).toBe(0); 151 + }, 15000); 152 + 153 + it("CLI --tag flag sets tags on session", () => { 154 + const dir = makeSessionDir(); 155 + const name = uniqueName(); 156 + 157 + const result = spawnSync(nodeBin, [ 158 + cliPath, "run", "-d", "--name", name, 159 + "--tag", "owner=forge", "--tag", "env=staging", 160 + "--", "cat", 161 + ], { 162 + env: { ...process.env, PTY_SESSION_DIR: dir }, 163 + encoding: "utf-8", 164 + timeout: 10000, 165 + }); 166 + 167 + expect(result.status).toBe(0); 168 + 169 + const metaPath = path.join(dir, `${name}.json`); 170 + const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); 171 + expect(meta.tags).toEqual({ owner: "forge", env: "staging" }); 172 + 173 + // Clean up daemon 174 + const pidFile = path.join(dir, `${name}.pid`); 175 + const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 176 + try { process.kill(pid, "SIGTERM"); } catch {} 177 + }, 15000); 178 + 179 + it("tags survive restart via CLI", async () => { 180 + const dir = makeSessionDir(); 181 + const name = uniqueName(); 182 + 183 + // Create a session with tags that exits immediately 184 + await startDaemon(dir, name, "true", [], { owner: "forge", env: "prod" }); 185 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit + metadata 186 + 187 + // Verify tags are on the exited session 188 + const metaBefore = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); 189 + expect(metaBefore.tags).toEqual({ owner: "forge", env: "prod" }); 190 + expect(metaBefore.exitCode).toBe(0); 191 + 192 + // Restart via CLI 193 + const result = spawnSync(nodeBin, [cliPath, "restart", "-y", name], { 194 + env: { ...process.env, PTY_SESSION_DIR: dir }, 195 + encoding: "utf-8", 196 + timeout: 10000, 197 + }); 198 + // restart attaches, so it will exit with non-zero since there's no TTY 199 + // but the session should be created 200 + 201 + // Wait for the restarted session to write metadata 202 + await new Promise((r) => setTimeout(r, 500)); 203 + 204 + const metaAfter = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); 205 + expect(metaAfter.tags).toEqual({ owner: "forge", env: "prod" }); 206 + // createdAt should differ from the original session (it was recreated) 207 + expect(metaAfter.createdAt).not.toBe(metaBefore.createdAt); 208 + }, 15000); 209 + 210 + it("tags preserved when run -a recreates exited session", async () => { 211 + const dir = makeSessionDir(); 212 + const name = uniqueName(); 213 + 214 + // Create a tagged session that exits immediately 215 + await startDaemon(dir, name, "true", [], { owner: "ci" }); 216 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit 217 + 218 + // Recreate with run -a (no new --tag flags) 219 + const result = spawnSync(nodeBin, [cliPath, "run", "-a", "-d", "--name", name, "--", "cat"], { 220 + env: { ...process.env, PTY_SESSION_DIR: dir }, 221 + encoding: "utf-8", 222 + timeout: 10000, 223 + }); 224 + expect(result.status).toBe(0); 225 + 226 + // Verify tags carried over 227 + const meta = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); 228 + expect(meta.tags).toEqual({ owner: "ci" }); 229 + 230 + // Clean up daemon 231 + const pidFile = path.join(dir, `${name}.pid`); 232 + try { 233 + const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 234 + process.kill(pid, "SIGTERM"); 235 + } catch {} 236 + }, 15000); 237 + 238 + it("new --tag flags override previous tags on run -a", async () => { 239 + const dir = makeSessionDir(); 240 + const name = uniqueName(); 241 + 242 + // Create a tagged session that exits immediately 243 + await startDaemon(dir, name, "true", [], { owner: "old" }); 244 + await new Promise((r) => setTimeout(r, 1000)); // wait for exit 245 + 246 + // Recreate with run -a with NEW tags 247 + const result = spawnSync(nodeBin, [ 248 + cliPath, "run", "-a", "-d", "--name", name, 249 + "--tag", "owner=new", 250 + "--", "cat", 251 + ], { 252 + env: { ...process.env, PTY_SESSION_DIR: dir }, 253 + encoding: "utf-8", 254 + timeout: 10000, 255 + }); 256 + expect(result.status).toBe(0); 257 + 258 + // Verify new tags replaced old ones 259 + const meta = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf-8")); 260 + expect(meta.tags).toEqual({ owner: "new" }); 261 + 262 + // Clean up daemon 263 + const pidFile = path.join(dir, `${name}.pid`); 264 + try { 265 + const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 266 + process.kill(pid, "SIGTERM"); 267 + } catch {} 268 + }, 15000); 269 + 270 + it("CLI --tag with invalid format is rejected", () => { 271 + const dir = makeSessionDir(); 272 + 273 + const result = spawnSync(nodeBin, [ 274 + cliPath, "run", "-d", "--name", "bad-tag", 275 + "--tag", "no-equals-sign", 276 + "--", "cat", 277 + ], { 278 + env: { ...process.env, PTY_SESSION_DIR: dir }, 279 + encoding: "utf-8", 280 + timeout: 10000, 281 + }); 282 + 283 + expect(result.status).not.toBe(0); 284 + expect(result.stderr).toContain("key=value"); 285 + }, 15000); 286 + });