This repository has no description
0

Configure Feed

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

Add displayName and pty rename

Every session now has an immutable `name` (the stable id) and an
optional, mutable `displayName`. Default `pty run` generates a random
8-char id for `name` and the old human-friendly cwd+command label for
`displayName`. `--no-display-name` opts out of the label. `pty rename`
sets/changes/clears the displayName, either from inside a session
(using PTY_SESSION) or against an explicit ref from outside.

Every command that takes a session reference now accepts either the
name or the displayName. The list and interactive TUI render the
displayName primarily, with the stable id in parens when both exist.

Existing sessions on disk are unchanged (displayName just stays
absent). New sessions created after this release have random ids in
PTY_SESSION, events, and ptyfile.session — this is a behavioral change
for anyone comparing those values as strings.

✻ Cogitated for 13m 45s

Nathan Herald (Apr 16, 2026, 11:55 AM +0200) 26c8dce6 ba8e6dca

+577 -40
+15
CHANGELOG.md
··· 2 2 3 3 ## Unreleased 4 4 5 + ### Session naming 6 + - **Breaking (default behaviour):** `pty run` without `--name` now assigns a short random id (Crockford-ish base32, 8 chars) to the session's `name` field, and stores the old human-friendly cwd+command label in a new optional `displayName` field. `PTY_SESSION`, events, `ptyfile.session`, and anything else that references a session by its stable id will see the random id for sessions created after this release. Sessions created before this release continue to work unchanged (their `name` stays what it was). 7 + - Add `pty run --no-display-name` — generates the random id but skips the `displayName` auto-gen. Useful for throwaway shells you might promote later. 8 + - Add `pty rename`: 9 + - `pty rename <new>` inside a session — sets `displayName` on the current session (uses `PTY_SESSION`) 10 + - `pty rename <ref> <new>` outside — sets `displayName` on `<ref>` 11 + - `pty rename --show <ref>` — prints the current `displayName` 12 + - `pty rename --clear [ref]` — removes the `displayName` 13 + - `name` is immutable; rename only ever writes `displayName`. 14 + - Lookup-by-ref (`pty attach`, `peek`, `send`, `stats`, `kill`, `rm`, `tag`, `restart`, `events`) now accepts either the stable `name` or the mutable `displayName`. Collisions between a name and a displayName across live sessions are rejected at create/rename time. 15 + - `pty list` and the interactive TUI now render `displayName` as the primary label when set, with the stable `name` shown in parens for disambiguation; when no `displayName` is set, just the `name`. 16 + - New workflow this enables: `pty run --no-display-name -- bash` → work in the shell → `pty rename my-claude` → `pty exec -- claude` (promote an anonymous shell into a named, long-lived agent session without exiting). 17 + 5 18 ### Security 6 19 - BUG-1: `validateName` now rejects session names whose Unix-socket path would exceed the 104-byte `sun_path` limit; previously the daemon's `listen()` failed silently inside an error handler and the `ready` Promise never resolved, hanging every caller. `server.ready` also now rejects on listen errors. 7 20 - BUG-2: `acquireLock` is now built on `open(O_CREAT|O_EXCL)` (`openSync(path, "wx")`); two processes racing to steal a stale lock can no longer both win. ··· 31 44 - Add optional `tags?: Record<string, string>` on remote session entries so pty-relay can surface tags in `ls --json` and have the interactive TUI filter remote sessions by them 32 45 - Add `launcher?: { command: string; args?: string[] }` to `SpawnDaemonOptions` so non-Node callers (Bun, Deno) can route the detached daemon launch through a Node binary — the daemon needs Node to load the `node-pty` native addon (closes #17) 33 46 - Add `PtyHandle.alternateScreen: boolean` and `PtyHandle.kittyKeyboardFlags: number[]` so hosts like pty-layout can proxy alternate-screen state and kitty keyboard protocol enable/disable to the outer terminal (for Shift+Enter and friends in the focused pane) 47 + - Export `setDisplayName(name, displayName | null)` from `@myobie/pty/client` for programmatic renames 48 + - Add `displayName?: string` to `SessionMetadata`, `ServerOptions`, and `SpawnDaemonOptions` — set at spawn time or later via `setDisplayName` / `pty rename` 34 49 35 50 ### Project files 36 51 - `pty up` now removes tags that were removed from `pty.toml` — toml-managed tag keys are tracked in a `ptyfile.tags` meta tag so manually-added tags (set via `pty tag`) are preserved
+8 -2
README.md
··· 50 50 pty # interactive session manager 51 51 pty --preselect-new # open the TUI with "Create new session..." selected 52 52 pty --filter-tag layout=work # TUI filtered by tag; new sessions inherit the tag 53 - pty run -- node server.js # start a session (auto-named) 54 - pty run --name myserver -- node server.js # start with an explicit name 53 + pty run -- node server.js # start a session (random name + auto displayName) 54 + pty run --name myserver -- node server.js # start with an explicit name (still gets a displayName) 55 + pty run --no-display-name -- bash # random name, no friendly label (good for throwaway shells) 55 56 pty run -d -- node server.js # start in the background 56 57 pty run -a -- node server.js # create or attach if already running 57 58 pty run -e -- npm test # ephemeral: auto-remove on exit 58 59 pty run --tag owner=forge -- node srv.js # tag a session with metadata 59 60 pty run --cwd /path -- node server.js # run in a specific directory 61 + 62 + pty rename my-label # inside a session: add/change its displayName 63 + pty rename <ref> my-label # outside: set displayName on <ref> 64 + pty rename --show <ref> # show current displayName 65 + pty rename --clear [ref] # remove displayName 60 66 61 67 pty list # show active sessions (tags shown by default) 62 68 pty list --tags # include internal bookkeeping tags (ptyfile*, strategy, etc.)
+258 -33
src/cli.ts
··· 3 3 import * as path from "node:path"; 4 4 import * as readline from "node:readline/promises"; 5 5 import { spawnSync, execFileSync } from "node:child_process"; 6 + import { randomBytes } from "node:crypto"; 6 7 import { attach, peek, send, queryStats, type StatsResult } from "./client.ts"; 7 8 import { parseSeqValue } from "./keys.ts"; 8 9 import { ··· 15 16 acquireLock, 16 17 releaseLock, 17 18 updateTags, 19 + setDisplayName, 20 + allRefs, 18 21 readMetadata, 19 22 writeMetadata, 20 23 getSessionDir, ··· 55 58 pty run --tag key=value -- <command> Tag a session with metadata 56 59 pty run --cwd /path -- <command> Run in a specific directory 57 60 pty run --isolate-env -- <command> Scrub env down to a safe allow-list (for remote-reachable sessions) 61 + pty run --no-display-name -- <command> Skip the friendly cwd+command label (just a random id) 62 + pty rename <new> Inside a session, set its displayName 63 + pty rename <ref> <new> Outside, set displayName on <ref> 64 + pty rename --show <ref> Show current displayName for <ref> 65 + pty rename --clear [ref] Remove displayName (ref required outside a session) 58 66 pty attach <name> Attach to an existing session 59 67 pty exec -- <command> [args...] Replace the current session's command 60 68 pty attach -r <name> Attach, auto-restart if exited ··· 103 111 Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`); 104 112 } 105 113 114 + /** Resolve a user-supplied session reference (name OR displayName) to the 115 + * stable `name`. Errors and exits if no session matches. Use this whenever 116 + * a command is about to hit the socket, metadata file, or anything else 117 + * keyed by the stable id — it ensures typing the displayName works the 118 + * same as typing the underlying name. */ 119 + async function resolveRef(ref: string): Promise<string> { 120 + const session = await getSession(ref); 121 + if (!session) { 122 + console.error(`Session "${ref}" not found.`); 123 + process.exit(1); 124 + } 125 + return session.name; 126 + } 127 + 128 + /** Generate a short random session id. Base32 (Crockford-ish, no 0/O/1/I 129 + * confusion). 8 chars = 40 bits — plenty of headroom against collisions 130 + * even with thousands of sessions per machine. */ 131 + function randomSessionName(): string { 132 + const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"; 133 + const bytes = randomBytes(8); 134 + let out = ""; 135 + for (const b of bytes) out += alphabet[b % alphabet.length]; 136 + return out; 137 + } 138 + 106 139 /** Generate a session name from the cwd and command. */ 107 140 function autoName(cmd: string, cmdArgs: string[]): string { 108 141 // Directory component: last part of cwd ··· 170 203 let attachExisting = false; 171 204 let ephemeral = false; 172 205 let isolateEnv = false; 206 + let noDisplayName = false; 173 207 let name: string | null = null; 174 208 let cwd: string | null = null; 175 209 const tags: Record<string, string> = {}; ··· 179 213 else if (args[i] === "-a" || args[i] === "--attach") { attachExisting = true; i++; } 180 214 else if (args[i] === "-e" || args[i] === "--ephemeral") { ephemeral = true; i++; } 181 215 else if (args[i] === "--isolate-env") { isolateEnv = true; i++; } 216 + else if (args[i] === "--no-display-name") { noDisplayName = true; i++; } 182 217 else if (args[i] === "--name" && i + 1 < args.length) { name = args[i + 1]; i += 2; } 183 218 else if (args[i] === "--cwd" && i + 1 < args.length) { cwd = args[i + 1]; i += 2; } 184 219 else if (args[i] === "--tag" && i + 1 < args.length) { ··· 250 285 process.exit(result.status ?? 1); 251 286 } 252 287 253 - // Auto-generate name if not provided 288 + const existingRefs = await allRefs(); 289 + 290 + // Resolve `name` (stable id). If the user didn't pass --name, generate 291 + // a short random id (6 chars of Crockford-ish base32). Collisions are 292 + // astronomically unlikely but we retry if one shows up. 254 293 if (!name) { 255 - const sessions = await listSessions(); 256 - const existing = new Set(sessions.map(s => s.name)); 257 - let candidate = autoName(autoNameCmd, cmdArgs); 258 - // Sanitize: validateName allows [a-zA-Z0-9._-] 259 - candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 260 - // Dedup 261 - if (existing.has(candidate)) { 262 - for (let n = 2; ; n++) { 263 - const c = `${candidate}-${n}`; 264 - if (!existing.has(c)) { candidate = c; break; } 265 - } 294 + for (let attempt = 0; attempt < 8; attempt++) { 295 + const candidate = randomSessionName(); 296 + if (!existingRefs.has(candidate)) { name = candidate; break; } 266 297 } 267 - name = candidate; 298 + if (!name) { 299 + console.error("Could not generate a unique session id after 8 attempts."); 300 + process.exit(1); 301 + } 268 302 } 269 303 270 304 try { ··· 274 308 process.exit(1); 275 309 } 276 310 277 - await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv); 311 + // Resolve `displayName`. Skip if --no-display-name. Otherwise 312 + // auto-generate the old human-friendly cwd+command label and dedup 313 + // against existing names/displayNames. 314 + let displayName: string | null = null; 315 + if (!noDisplayName) { 316 + let candidate = autoName(autoNameCmd, cmdArgs); 317 + candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 318 + if (existingRefs.has(candidate)) { 319 + for (let n = 2; ; n++) { 320 + const c = `${candidate}-${n}`; 321 + if (!existingRefs.has(c)) { candidate = c; break; } 322 + } 323 + } 324 + displayName = candidate; 325 + } 326 + 327 + await cmdRun(name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral, tags, cwd, isolateEnv, displayName); 278 328 break; 279 329 } 280 330 ··· 293 343 console.error(e.message); 294 344 process.exit(1); 295 345 } 296 - await cmdAttach(attachName, autoRestart); 346 + const resolvedAttachName = await resolveRef(attachName); 347 + await cmdAttach(resolvedAttachName, autoRestart); 297 348 break; 298 349 } 299 350 ··· 336 387 console.error(e.message); 337 388 process.exit(1); 338 389 } 390 + const resolvedPeekName = await resolveRef(peekName); 339 391 if (waitPatterns.length > 0) { 340 - await cmdPeekWait(peekName, waitPatterns, timeoutSec, plain); 392 + await cmdPeekWait(resolvedPeekName, waitPatterns, timeoutSec, plain); 341 393 } else { 342 - cmdPeek(peekName, follow, plain, full); 394 + cmdPeek(resolvedPeekName, follow, plain, full); 343 395 } 344 396 break; 345 397 } ··· 401 453 process.exit(1); 402 454 } 403 455 404 - send({ name: sendName, data, delayMs: delaySecs != null ? delaySecs * 1000 : undefined }); 456 + const resolvedSendName = await resolveRef(sendName); 457 + send({ name: resolvedSendName, data, delayMs: delaySecs != null ? delaySecs * 1000 : undefined }); 405 458 break; 406 459 } 407 460 ··· 427 480 process.exit(1); 428 481 } 429 482 483 + let resolvedEventsName: string | null = null; 430 484 if (eventsName) { 431 485 try { 432 486 validateName(eventsName); ··· 434 488 console.error(e.message); 435 489 process.exit(1); 436 490 } 491 + resolvedEventsName = await resolveRef(eventsName); 437 492 } 438 493 439 - await cmdEvents(eventsName ?? null, { all, recent, json, waitEventType, timeout: eventsTimeout }); 494 + await cmdEvents(resolvedEventsName, { all, recent, json, waitEventType, timeout: eventsTimeout }); 440 495 break; 441 496 } 442 497 ··· 471 526 console.error("Usage: pty restart [-y] <name>"); 472 527 process.exit(1); 473 528 } 474 - await cmdRestart(restartName, forceRestart); 529 + const resolvedRestartName = await resolveRef(restartName); 530 + await cmdRestart(resolvedRestartName, forceRestart); 475 531 break; 476 532 } 477 533 ··· 486 542 console.error(e.message); 487 543 process.exit(1); 488 544 } 489 - await cmdKill(args[1]); 545 + const resolvedKillName = await resolveRef(args[1]); 546 + await cmdKill(resolvedKillName); 490 547 break; 491 548 } 492 549 ··· 507 564 console.error(e.message); 508 565 process.exit(1); 509 566 } 567 + const resolvedTagName = await resolveRef(tagName); 510 568 511 569 const updates: Record<string, string> = {}; 512 570 const removals: string[] = []; ··· 526 584 527 585 // No updates or removals — show current tags 528 586 if (Object.keys(updates).length === 0 && removals.length === 0) { 529 - const meta = readMetadata(tagName); 587 + const meta = readMetadata(resolvedTagName); 530 588 if (!meta) { 531 589 console.error(`Session "${tagName}" not found.`); 532 590 process.exit(1); 533 591 } 534 592 if (!meta.tags || Object.keys(meta.tags).length === 0) { 535 - console.log(`No tags on "${tagName}".`); 593 + console.log(`No tags on "${resolvedTagName}".`); 536 594 } else { 537 595 for (const [k, v] of Object.entries(meta.tags)) { 538 596 console.log(` ${k}=${v}`); ··· 543 601 544 602 try { 545 603 // Check if session is managed by a pty.toml before modifying 546 - const beforeMeta = readMetadata(tagName); 604 + const beforeMeta = readMetadata(resolvedTagName); 547 605 const ptyfilePath = beforeMeta?.tags?.ptyfile; 548 606 549 - updateTags(tagName, updates, removals); 550 - const meta = readMetadata(tagName); 607 + updateTags(resolvedTagName, updates, removals); 608 + const meta = readMetadata(resolvedTagName); 551 609 if (!meta?.tags || Object.keys(meta.tags).length === 0) { 552 - console.log(`Tags cleared on "${tagName}".`); 610 + console.log(`Tags cleared on "${resolvedTagName}".`); 553 611 } else { 554 - console.log(`Tags on "${tagName}":`); 612 + console.log(`Tags on "${resolvedTagName}":`); 555 613 for (const [k, v] of Object.entries(meta.tags)) { 556 614 console.log(` ${k}=${v}`); 557 615 } ··· 615 673 break; 616 674 } 617 675 676 + case "rename": { 677 + await cmdRename(args.slice(1)); 678 + break; 679 + } 680 + 618 681 case "rm": 619 682 case "remove": { 620 683 if (args.length < 2) { ··· 627 690 console.error(e.message); 628 691 process.exit(1); 629 692 } 630 - await cmdRm(args[1]); 693 + const resolvedRmName = await resolveRef(args[1]); 694 + await cmdRm(resolvedRmName); 631 695 break; 632 696 } 633 697 ··· 766 830 tags: Record<string, string> = {}, 767 831 explicitCwd: string | null = null, 768 832 isolateEnv = false, 833 + displayName: string | null = null, 769 834 ): Promise<void> { 770 835 const session = await getSession(name); 771 836 if (session?.status === "running") { ··· 798 863 try { 799 864 const tagOpt = Object.keys(tags).length > 0 ? tags : previousTags; 800 865 const cwdOpt = explicitCwd ?? previousCwd; 801 - await spawnDaemon({ name, command, args, displayCommand, cwd: cwdOpt, ephemeral, tags: tagOpt, ...(isolateEnv ? { isolateEnv: true } : {}) }); 866 + // If the session had a previous displayName (e.g., it was renamed before 867 + // exiting), prefer preserving it over the fresh auto-generated label so 868 + // `pty run -a` re-creates the session feeling-identical to the last one. 869 + const prevDisplayName = session?.status === "exited" ? session.metadata?.displayName : undefined; 870 + const displayNameOpt = displayName ?? prevDisplayName; 871 + await spawnDaemon({ 872 + name, command, args, displayCommand, cwd: cwdOpt, ephemeral, tags: tagOpt, 873 + ...(displayNameOpt ? { displayName: displayNameOpt } : {}), 874 + ...(isolateEnv ? { isolateEnv: true } : {}), 875 + }); 802 876 } finally { 803 877 releaseLock(name); 804 878 } ··· 1044 1118 exitCode: s.metadata?.exitCode ?? null, 1045 1119 exitedAt: s.metadata?.exitedAt ?? null, 1046 1120 ...(s.metadata?.tags ? { tags: s.metadata.tags } : {}), 1121 + ...(s.metadata?.displayName ? { displayName: s.metadata.displayName } : {}), 1047 1122 })); 1048 1123 if (remote && remoteHosts.length > 0) { 1049 1124 console.log(JSON.stringify({ local: localOutput, remote: remoteHosts })); ··· 1070 1145 showAll || (k !== "ptyfile" && k !== "ptyfile.session" && k !== "ptyfile.tags" && k !== "supervisor.status" && k !== "strategy"), 1071 1146 ); 1072 1147 return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; 1148 + }; 1149 + 1150 + // Render the session's primary label. If displayName is set, it appears 1151 + // first with the stable id in parens for disambiguation; otherwise just 1152 + // the id. Users can match either in any CLI command. 1153 + const renderLabel = (session: SessionInfo, boldCode: string): string => { 1154 + const dn = session.metadata?.displayName; 1155 + if (dn) { 1156 + return `${boldCode}${dn}\x1b[0m \x1b[2m(${session.name})\x1b[0m`; 1157 + } 1158 + return `${boldCode}${session.name}\x1b[0m`; 1073 1159 }; 1074 1160 1075 1161 if (running.length > 0) { ··· 1083 1169 : ""; 1084 1170 const tagStr = renderTags(session.metadata?.tags, showTags); 1085 1171 const marker = strategyMarker(session.metadata?.tags); 1086 - console.log(` \x1b[1;36m${session.name}\x1b[0m${marker}${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1172 + const label = renderLabel(session, "\x1b[1;36m"); 1173 + console.log(` ${label}${marker}${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1087 1174 } 1088 1175 } 1089 1176 ··· 1100 1187 : ""; 1101 1188 const tagStr = renderTags(meta?.tags, showTags); 1102 1189 const marker = strategyMarker(meta?.tags); 1103 - console.log(` \x1b[1m${session.name}\x1b[0m${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1190 + const label = renderLabel(session, "\x1b[1m"); 1191 + console.log(` ${label}${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); 1104 1192 } 1105 1193 } 1106 1194 ··· 1149 1237 } 1150 1238 1151 1239 try { 1152 - const stats = await queryStats(name); 1240 + // Use the resolved stable `name` so queryStats connects to the right 1241 + // socket when the caller passed a `displayName`. 1242 + const stats = await queryStats(session.name); 1153 1243 if (json) { 1154 1244 console.log(JSON.stringify(stats)); 1155 1245 } else { ··· 1309 1399 if (wasSupervised && session.metadata?.tags?.ptyfile) { 1310 1400 console.error(`Note: this session is managed by ${session.metadata.tags.ptyfile}`); 1311 1401 console.error("The strategy tag will be restored on the next 'pty up'."); 1402 + } 1403 + } 1404 + 1405 + function renameUsage(): void { 1406 + console.error( 1407 + "Usage:\n" + 1408 + " pty rename <new-display-name> Inside a session: set displayName on the current session\n" + 1409 + " pty rename <ref> <new-display-name> Outside: set displayName on <ref>\n" + 1410 + " pty rename --show <ref> Show the current displayName for <ref>\n" + 1411 + " pty rename --clear Inside a session: clear displayName\n" + 1412 + " pty rename --clear <ref> Outside: clear displayName on <ref>\n" + 1413 + "\n" + 1414 + "displayName is a mutable alias. The session's stable id (name) never changes." 1415 + ); 1416 + } 1417 + 1418 + async function cmdRename(rawArgs: string[]): Promise<void> { 1419 + const insideSession = !!process.env.PTY_SESSION; 1420 + 1421 + // Parse flags 1422 + let show = false; 1423 + let clear = false; 1424 + const positional: string[] = []; 1425 + for (const a of rawArgs) { 1426 + if (a === "--show") show = true; 1427 + else if (a === "--clear") clear = true; 1428 + else if (a === "-h" || a === "--help") { renameUsage(); return; } 1429 + else positional.push(a); 1430 + } 1431 + 1432 + // --show <ref> 1433 + if (show) { 1434 + if (positional.length !== 1) { 1435 + console.error("pty rename --show requires exactly one ref."); 1436 + renameUsage(); 1437 + process.exit(1); 1438 + } 1439 + const session = await getSession(positional[0]); 1440 + if (!session) { 1441 + console.error(`Session "${positional[0]}" not found.`); 1442 + process.exit(1); 1443 + } 1444 + const dn = session.metadata?.displayName; 1445 + if (dn) { 1446 + console.log(dn); 1447 + } else { 1448 + console.log(`(no displayName; session is referenced by its id: ${session.name})`); 1449 + } 1450 + return; 1451 + } 1452 + 1453 + // --clear 1454 + if (clear) { 1455 + let targetName: string; 1456 + if (positional.length === 0) { 1457 + if (!insideSession) { 1458 + console.error("pty rename --clear with no ref requires being inside a pty session (PTY_SESSION not set)."); 1459 + renameUsage(); 1460 + process.exit(1); 1461 + } 1462 + targetName = process.env.PTY_SESSION!; 1463 + } else if (positional.length === 1) { 1464 + const session = await getSession(positional[0]); 1465 + if (!session) { 1466 + console.error(`Session "${positional[0]}" not found.`); 1467 + process.exit(1); 1468 + } 1469 + targetName = session.name; 1470 + } else { 1471 + console.error("pty rename --clear takes at most one ref."); 1472 + renameUsage(); 1473 + process.exit(1); 1474 + } 1475 + try { 1476 + setDisplayName(targetName, null); 1477 + console.log(`Cleared displayName on "${targetName}".`); 1478 + } catch (e: any) { 1479 + console.error(e.message); 1480 + process.exit(1); 1481 + } 1482 + return; 1483 + } 1484 + 1485 + // Set form — resolve target and new display 1486 + let targetName: string; 1487 + let newDisplay: string; 1488 + if (positional.length === 1) { 1489 + if (!insideSession) { 1490 + console.error("pty rename with a single arg is only allowed inside a pty session."); 1491 + console.error("Outside, use: pty rename <ref> <new-display-name>"); 1492 + renameUsage(); 1493 + process.exit(1); 1494 + } 1495 + targetName = process.env.PTY_SESSION!; 1496 + newDisplay = positional[0]; 1497 + } else if (positional.length === 2) { 1498 + const session = await getSession(positional[0]); 1499 + if (!session) { 1500 + console.error(`Session "${positional[0]}" not found.`); 1501 + process.exit(1); 1502 + } 1503 + targetName = session.name; 1504 + newDisplay = positional[1]; 1505 + } else { 1506 + renameUsage(); 1507 + process.exit(1); 1508 + } 1509 + 1510 + // Validate the new display like a name — same charset rules keep things 1511 + // predictable in URLs, file names, and CLI args. 1512 + try { 1513 + validateName(newDisplay); 1514 + } catch (e: any) { 1515 + console.error(`Invalid displayName: ${e.message}`); 1516 + process.exit(1); 1517 + } 1518 + 1519 + // Uniqueness across (name ∪ displayName), excluding the target session itself. 1520 + const refs = await allRefs(); 1521 + const currentDn = (await getSession(targetName))?.metadata?.displayName; 1522 + if (newDisplay === targetName) { 1523 + console.error(`displayName cannot equal the session's id ("${targetName}").`); 1524 + process.exit(1); 1525 + } 1526 + if (refs.has(newDisplay) && newDisplay !== currentDn) { 1527 + console.error(`"${newDisplay}" is already in use by another session (as a name or displayName).`); 1528 + process.exit(1); 1529 + } 1530 + 1531 + try { 1532 + setDisplayName(targetName, newDisplay); 1533 + console.log(`Set displayName on "${targetName}" → "${newDisplay}".`); 1534 + } catch (e: any) { 1535 + console.error(e.message); 1536 + process.exit(1); 1312 1537 } 1313 1538 } 1314 1539
+1 -1
src/client-api.ts
··· 3 3 4 4 // Session management 5 5 export { 6 - listSessions, getSession, gc, validateName, updateTags, 6 + listSessions, getSession, gc, validateName, updateTags, setDisplayName, 7 7 getSessionDir, getSocketPath, 8 8 cleanupSocket, cleanupAll, 9 9 type SessionInfo, type SessionMetadata,
+5
src/server.ts
··· 47 47 rows: number; 48 48 cols: number; 49 49 tags?: Record<string, string>; 50 + /** Optional human-friendly alias recorded in SessionMetadata.displayName. 51 + * Mutable via `pty rename`; `name` stays the immutable stable id. */ 52 + displayName?: string; 50 53 onExit?: (code: number) => void; 51 54 /** When true, spawn the child with a scrubbed environment containing only 52 55 * a small allow-list of variables (plus any entries in `extraEnv`). ··· 479 482 cwd: options.cwd, 480 483 createdAt: new Date().toISOString(), 481 484 ...(options.tags && Object.keys(options.tags).length > 0 ? { tags: options.tags } : {}), 485 + ...(options.displayName ? { displayName: options.displayName } : {}), 482 486 }); 483 487 this.emitEvent(EventType.SESSION_START, { 484 488 ...(options.tags && Object.keys(options.tags).length > 0 ? { tags: options.tags } : {}), ··· 865 869 rows: config.rows ?? 24, 866 870 cols: config.cols ?? 80, 867 871 tags: config.tags, 872 + displayName: config.displayName, 868 873 isolateEnv: config.isolateEnv === true, 869 874 extraEnv: config.extraEnv, 870 875 onExit: (code) => {
+40 -2
src/sessions.ts
··· 75 75 exitedAt?: string; 76 76 lastLines?: string[]; 77 77 tags?: Record<string, string>; 78 + /** Optional human-friendly alias for the session. Mutable via `pty rename`. 79 + * The immutable stable id is always `SessionInfo.name`. Most code should 80 + * keep using `name`; `displayName` is purely for presentation and as an 81 + * additional lookup key alongside `name`. */ 82 + displayName?: string; 78 83 } 79 84 80 85 export interface SessionInfo { ··· 91 96 const tmp = target + ".tmp"; 92 97 fs.writeFileSync(tmp, JSON.stringify(metadata, null, 2)); 93 98 fs.renameSync(tmp, target); 99 + } 100 + 101 + /** Set or clear the displayName on an existing session. Atomic read-modify-write. 102 + * Pass `null` to remove the alias. Throws if `name` doesn't exist. */ 103 + export function setDisplayName(name: string, displayName: string | null): void { 104 + const metadata = readMetadata(name); 105 + if (!metadata) { 106 + throw new Error(`Session "${name}" not found.`); 107 + } 108 + if (displayName === null || displayName === "") { 109 + delete metadata.displayName; 110 + } else { 111 + metadata.displayName = displayName; 112 + } 113 + writeMetadata(name, metadata); 94 114 } 95 115 96 116 /** Update tags on an existing session. Performs an atomic read-modify-write. */ ··· 197 217 return sessions; 198 218 } 199 219 200 - export async function getSession(name: string): Promise<SessionInfo | null> { 220 + /** Look up a session by either its stable `name` (immutable id) or its 221 + * mutable `displayName` alias. Name match takes precedence over displayName 222 + * match so the stable id always wins in case both happen to resolve. */ 223 + export async function getSession(ref: string): Promise<SessionInfo | null> { 201 224 const sessions = await listSessions(); 202 - return sessions.find((s) => s.name === name) ?? null; 225 + const byName = sessions.find((s) => s.name === ref); 226 + if (byName) return byName; 227 + const byDisplay = sessions.find((s) => s.metadata?.displayName === ref); 228 + return byDisplay ?? null; 229 + } 230 + 231 + /** Return every reference (name or displayName) currently claimed by a live 232 + * or exited session. Used for uniqueness checks at creation/rename time. */ 233 + export async function allRefs(): Promise<Set<string>> { 234 + const sessions = await listSessions(); 235 + const refs = new Set<string>(); 236 + for (const s of sessions) { 237 + refs.add(s.name); 238 + if (s.metadata?.displayName) refs.add(s.metadata.displayName); 239 + } 240 + return refs; 203 241 } 204 242 205 243 /** Remove all exited sessions. Returns the names of removed sessions. */
+4
src/spawn.ts
··· 21 21 rows?: number; 22 22 cols?: number; 23 23 tags?: Record<string, string>; 24 + /** Optional human-friendly alias for the session, stored in 25 + * SessionMetadata.displayName. `name` stays the immutable id. */ 26 + displayName?: string; 24 27 /** When true, strip the daemon's env down to a small allow-list before 25 28 * spawning the session child — prevents cloud tokens / OAuth / SSH agent 26 29 * vars from leaking into a session that may be reached via pty-relay. ··· 66 69 cols, 67 70 ephemeral: options.ephemeral ?? false, 68 71 ...(options.tags && Object.keys(options.tags).length > 0 ? { tags: options.tags } : {}), 72 + ...(options.displayName ? { displayName: options.displayName } : {}), 69 73 ...(options.isolateEnv ? { isolateEnv: true } : {}), 70 74 ...(options.extraEnv && Object.keys(options.extraEnv).length > 0 ? { extraEnv: options.extraEnv } : {}), 71 75 });
+11 -2
src/tui/interactive.ts
··· 168 168 const matches: { item: ListItem; score: number }[] = []; 169 169 for (const item of items) { 170 170 let name = ""; 171 + let displayName = ""; 171 172 let cmd = ""; 172 173 let cwd = ""; 173 174 if (item.type === "session" && item.session) { 174 175 name = item.session.name; 176 + displayName = item.session.metadata?.displayName ?? ""; 175 177 cmd = item.session.metadata?.displayCommand ?? ""; 176 178 cwd = item.session.metadata?.cwd ?? ""; 177 179 } else if (item.type === "remote" && item.remote) { ··· 182 184 continue; // skip "create" items during filter 183 185 } 184 186 const nameResult = fuzzyMatch(filter, name); 187 + const displayNameResult = displayName ? fuzzyMatch(filter, displayName) : { match: false, score: 0 }; 185 188 const cwdResult = fuzzyMatch(filter, cwd); 186 189 const cmdResult = fuzzyMatch(filter, cmd); 187 - if (!nameResult.match && !cwdResult.match && !cmdResult.match) continue; 190 + if (!nameResult.match && !displayNameResult.match && !cwdResult.match && !cmdResult.match) continue; 188 191 const runningBonus = (item.type === "session" && item.session?.status === "running") || (item.type === "remote" && item.remote?.session.status === "running") ? 100000 : 0; 189 192 const score = runningBonus + Math.max( 193 + displayNameResult.match ? displayNameResult.score + 10000 : 0, 190 194 nameResult.match ? nameResult.score + 10000 : 0, 191 195 cwdResult.match ? cwdResult.score : 0, 192 196 cmdResult.match ? cmdResult.score : 0, ··· 330 334 const marker = supStatus === "failed" ? " [failed]" : strategy === "permanent" ? " [permanent]" : strategy === "temporary" ? " [temporary]" : ""; 331 335 const tagStr = renderTagsInline(s.metadata?.tags); 332 336 333 - const nameStr = `${sel}${icon} ${s.name}${marker}${tagStr}`; 337 + // Prefer displayName for the primary label; show the stable id secondarily 338 + // when both exist, so users can still type either in CLI commands. 339 + const displayName = s.metadata?.displayName; 340 + const labelStr = displayName ? `${displayName} (${s.name})` : s.name; 341 + 342 + const nameStr = `${sel}${icon} ${labelStr}${marker}${tagStr}`; 334 343 const detailStr = ` ${pathStr} ${cmd}`; 335 344 const line = nameStr + detailStr; 336 345
+235
tests/display-name.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 { 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 + 12 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-dn-")); 13 + afterAll(() => { 14 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 15 + }); 16 + 17 + let sessionDirs: string[] = []; 18 + let bgPids: number[] = []; 19 + 20 + function makeSessionDir(): string { 21 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 22 + sessionDirs.push(dir); 23 + return dir; 24 + } 25 + 26 + function runCli(sessionDir: string, env: Record<string, string>, ...args: string[]) { 27 + // Strip PTY_SESSION from the parent environment so tests can explicitly 28 + // decide whether the CLI invocation should look like it's "inside" a 29 + // session. Otherwise the test runner's own PTY_SESSION (if any) leaks in. 30 + const baseEnv = { ...process.env } as Record<string, string | undefined>; 31 + delete baseEnv.PTY_SESSION; 32 + return spawnSync(nodeBin, [cliPath, ...args], { 33 + cwd: os.tmpdir(), 34 + env: { ...baseEnv, PTY_SESSION_DIR: sessionDir, ...env } as Record<string, string>, 35 + encoding: "utf-8", 36 + timeout: 15000, 37 + }); 38 + } 39 + 40 + function listJson(sessionDir: string): any[] { 41 + const r = runCli(sessionDir, {}, "list", "--json"); 42 + if (!r.stdout.trim()) return []; 43 + return JSON.parse(r.stdout); 44 + } 45 + 46 + function collectPid(dir: string, name: string) { 47 + try { 48 + const pidFile = path.join(dir, `${name}.pid`); 49 + const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10); 50 + if (!isNaN(pid)) bgPids.push(pid); 51 + } catch {} 52 + } 53 + 54 + afterEach(() => { 55 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 56 + bgPids = []; 57 + for (const dir of sessionDirs) { 58 + try { 59 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 60 + } catch {} 61 + } 62 + sessionDirs = []; 63 + }); 64 + 65 + describe("pty run default: random name + auto displayName", () => { 66 + it("generates a short random name and a cwd+command-style displayName", () => { 67 + const dir = makeSessionDir(); 68 + const r = runCli(dir, {}, "run", "-d", "--", "cat"); 69 + expect(r.status).toBe(0); 70 + 71 + const sessions = listJson(dir); 72 + expect(sessions).toHaveLength(1); 73 + const s = sessions[0]; 74 + // Random names from randomSessionName() are 8 chars, all lowercase alnum 75 + expect(s.name).toMatch(/^[a-z0-9]{6,12}$/); 76 + expect(typeof s.displayName).toBe("string"); 77 + expect(s.displayName.length).toBeGreaterThan(0); 78 + collectPid(dir, s.name); 79 + }); 80 + }); 81 + 82 + describe("--no-display-name: random name, no displayName", () => { 83 + it("leaves displayName unset", () => { 84 + const dir = makeSessionDir(); 85 + const r = runCli(dir, {}, "run", "-d", "--no-display-name", "--", "cat"); 86 + expect(r.status).toBe(0); 87 + 88 + const sessions = listJson(dir); 89 + expect(sessions).toHaveLength(1); 90 + const s = sessions[0]; 91 + expect(s.name).toMatch(/^[a-z0-9]{6,12}$/); 92 + expect(s.displayName).toBeUndefined(); 93 + collectPid(dir, s.name); 94 + }); 95 + }); 96 + 97 + describe("--name: explicit name + auto displayName (unless --no-display-name)", () => { 98 + it("honors --name and generates a displayName", () => { 99 + const dir = makeSessionDir(); 100 + const r = runCli(dir, {}, "run", "-d", "--name", "mysvc", "--", "cat"); 101 + expect(r.status).toBe(0); 102 + 103 + const sessions = listJson(dir); 104 + const s = sessions.find((s: any) => s.name === "mysvc")!; 105 + expect(s).toBeDefined(); 106 + expect(s.displayName).toBeTruthy(); 107 + collectPid(dir, "mysvc"); 108 + }); 109 + 110 + it("--name combined with --no-display-name skips displayName", () => { 111 + const dir = makeSessionDir(); 112 + const r = runCli(dir, {}, "run", "-d", "--name", "raw", "--no-display-name", "--", "cat"); 113 + expect(r.status).toBe(0); 114 + 115 + const sessions = listJson(dir); 116 + const s = sessions.find((s: any) => s.name === "raw")!; 117 + expect(s.displayName).toBeUndefined(); 118 + collectPid(dir, "raw"); 119 + }); 120 + }); 121 + 122 + describe("pty rename (outside a session)", () => { 123 + it("pty rename <ref> <new> sets displayName", () => { 124 + const dir = makeSessionDir(); 125 + runCli(dir, {}, "run", "-d", "--name", "webapp", "--no-display-name", "--", "cat"); 126 + 127 + const r = runCli(dir, {}, "rename", "webapp", "my-label"); 128 + expect(r.status).toBe(0); 129 + expect(r.stdout).toContain("my-label"); 130 + 131 + const s = listJson(dir).find((s: any) => s.name === "webapp")!; 132 + expect(s.displayName).toBe("my-label"); 133 + collectPid(dir, "webapp"); 134 + }); 135 + 136 + it("pty rename --show <ref> prints current displayName", () => { 137 + const dir = makeSessionDir(); 138 + runCli(dir, {}, "run", "-d", "--name", "api", "--no-display-name", "--", "cat"); 139 + runCli(dir, {}, "rename", "api", "friendly-api"); 140 + 141 + const r = runCli(dir, {}, "rename", "--show", "api"); 142 + expect(r.status).toBe(0); 143 + expect(r.stdout.trim()).toBe("friendly-api"); 144 + collectPid(dir, "api"); 145 + }); 146 + 147 + it("pty rename --show <ref> without displayName prints a hint", () => { 148 + const dir = makeSessionDir(); 149 + runCli(dir, {}, "run", "-d", "--name", "bare", "--no-display-name", "--", "cat"); 150 + 151 + const r = runCli(dir, {}, "rename", "--show", "bare"); 152 + expect(r.status).toBe(0); 153 + expect(r.stdout).toContain("no displayName"); 154 + collectPid(dir, "bare"); 155 + }); 156 + 157 + it("pty rename --clear <ref> removes displayName", () => { 158 + const dir = makeSessionDir(); 159 + runCli(dir, {}, "run", "-d", "--name", "svc", "--no-display-name", "--", "cat"); 160 + runCli(dir, {}, "rename", "svc", "pretty"); 161 + expect(listJson(dir).find((s: any) => s.name === "svc")!.displayName).toBe("pretty"); 162 + 163 + const r = runCli(dir, {}, "rename", "--clear", "svc"); 164 + expect(r.status).toBe(0); 165 + expect(listJson(dir).find((s: any) => s.name === "svc")!.displayName).toBeUndefined(); 166 + collectPid(dir, "svc"); 167 + }); 168 + 169 + it("pty rename with one positional arg OUTSIDE a session errors with usage", () => { 170 + const dir = makeSessionDir(); 171 + const r = runCli(dir, {}, "rename", "only-one"); 172 + expect(r.status).not.toBe(0); 173 + expect(r.stderr).toContain("only allowed inside a pty session"); 174 + }); 175 + 176 + it("rejects displayName that collides with another session's name", () => { 177 + const dir = makeSessionDir(); 178 + runCli(dir, {}, "run", "-d", "--name", "aaa", "--no-display-name", "--", "cat"); 179 + runCli(dir, {}, "run", "-d", "--name", "bbb", "--no-display-name", "--", "cat"); 180 + 181 + const r = runCli(dir, {}, "rename", "aaa", "bbb"); 182 + expect(r.status).not.toBe(0); 183 + expect(r.stderr).toContain("already in use"); 184 + collectPid(dir, "aaa"); 185 + collectPid(dir, "bbb"); 186 + }); 187 + 188 + it("rejects displayName equal to the session's own name", () => { 189 + const dir = makeSessionDir(); 190 + runCli(dir, {}, "run", "-d", "--name", "same", "--no-display-name", "--", "cat"); 191 + const r = runCli(dir, {}, "rename", "same", "same"); 192 + expect(r.status).not.toBe(0); 193 + expect(r.stderr).toContain("cannot equal"); 194 + collectPid(dir, "same"); 195 + }); 196 + }); 197 + 198 + describe("pty rename (inside a session)", () => { 199 + it("pty rename <new> with PTY_SESSION set renames the current session", () => { 200 + const dir = makeSessionDir(); 201 + runCli(dir, {}, "run", "-d", "--name", "insider", "--no-display-name", "--", "cat"); 202 + 203 + const r = runCli(dir, { PTY_SESSION: "insider" }, "rename", "from-inside"); 204 + expect(r.status).toBe(0); 205 + const s = listJson(dir).find((s: any) => s.name === "insider")!; 206 + expect(s.displayName).toBe("from-inside"); 207 + collectPid(dir, "insider"); 208 + }); 209 + 210 + it("pty rename --clear with PTY_SESSION clears the current session's displayName", () => { 211 + const dir = makeSessionDir(); 212 + runCli(dir, {}, "run", "-d", "--name", "i2", "--no-display-name", "--", "cat"); 213 + runCli(dir, { PTY_SESSION: "i2" }, "rename", "has-a-display"); 214 + 215 + const r = runCli(dir, { PTY_SESSION: "i2" }, "rename", "--clear"); 216 + expect(r.status).toBe(0); 217 + expect(listJson(dir).find((s: any) => s.name === "i2")!.displayName).toBeUndefined(); 218 + collectPid(dir, "i2"); 219 + }); 220 + }); 221 + 222 + describe("lookup by displayName", () => { 223 + it("pty list references a session by its displayName for peek/stats/etc", () => { 224 + const dir = makeSessionDir(); 225 + runCli(dir, {}, "run", "-d", "--name", "raw1", "--no-display-name", "--", "cat"); 226 + runCli(dir, {}, "rename", "raw1", "friendly"); 227 + 228 + // stats by displayName should resolve the same session as by name 229 + const r = runCli(dir, {}, "stats", "friendly", "--json"); 230 + expect(r.status).toBe(0); 231 + const stats = JSON.parse(r.stdout); 232 + expect(stats.name).toBe("raw1"); 233 + collectPid(dir, "raw1"); 234 + }); 235 + });