-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathcli.ts
More file actions
4041 lines (3679 loc) · 149 KB
/
Copy pathcli.ts
File metadata and controls
4041 lines (3679 loc) · 149 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import * as readline from "node:readline/promises";
import { spawnSync, execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult } from "./client.ts";
import { printVersion } from "./version.ts";
import { parseSeqValue } from "./keys.ts";
import {
listSessions,
getSession,
getSessionByName,
gc,
pruneOrphanLayoutTags,
isGone,
cleanupAll,
cleanupAllWhileLocked,
cleanupSocket,
cleanupOwnedAll,
waitForProcessExit,
validateName,
validateDisplayName,
acquireLock,
acquireRecoveryLock,
isLockOwnedByPid,
releaseLock,
releaseRecoveryLock,
updateTags,
setDisplayName,
patchMetadataById,
mutateMetadataUnderLock,
allSessionNames,
readMetadata,
readSessionPid,
writeMetadata,
atomicWriteFileSync,
getSessionDir,
getSocketPath,
getPidPath,
getMetadataPath,
DEFAULT_SESSION_DIR,
type SessionInfo,
type SessionMetadata,
} from "./sessions.ts";
import { spawnDaemon, resolveCommand } from "./spawn.ts";
import {
acquireEventLock, appendEventSyncLocked, EventFollower, EventWriter, EventType, releaseEventLock,
readRecentEvents, formatEvent,
emitUserEvent,
} from "./events.ts";
import { readPtyFile, type PtySessionDef } from "./ptyfile.ts";
import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts";
import { parseDuration, formatDuration } from "./duration.ts";
import { serveRemoteControl, runRemoteServeStdio, fetchRemoteList, dialAndRoute, RouteRefusedError, PTY_REMOTE_ALPN, FABRIC_BIN } from "./remote.ts";
import {
RECOVERY_PROTOCOL,
assertPrivateRecoveryPaths,
atomicWritePrivate,
readBoundedJson,
readProcessStartToken,
recoveryLockContents,
recoveryLockIdentity,
recoveryRequestPath,
recoveryResultPath,
signRecoveryRequest,
verifyRecoveryResult,
type RecoveryResult,
} from "./recovery.ts";
// Name this process so it shows up meaningfully in ps/top/htop/btm instead of
// "MainThread" (V8's default main-thread name under Node 24+). `process.title`
// is the only thing that overrides /proc/<pid>/comm, and only when set from
// within the running process after V8 init — launch flags like `node --title`
// or `exec -a` do not work. Linux truncates comm at 15 chars (TASK_COMM_LEN).
// This module is only ever an entrypoint (it calls main() on load), so setting
// the title at module scope is safe.
try { process.title = "pty"; } catch {}
// Lazy-load the interactive TUI so non-interactive commands don't crash when
// the caller's cwd was deleted (the TUI module evaluates process.cwd() at load).
async function runInteractive(options?: { preselectNew?: boolean; filterTags?: Record<string, string>; force?: boolean }): Promise<void> {
ensureNotNested("interactive", {
force: options?.force,
hint:
" The interactive picker would render inside your current session and detach would route to the outer client.\n" +
" Detach first (Ctrl+\\) and run `pty` from outside, or pass --force to open the picker anyway.",
});
const mod = await import("./tui/interactive.ts");
await mod.runInteractive(options);
}
/** CLI wrapper around `extractFilterTags` that exits on invalid input. */
function extractFilterTags(args: string[]): Record<string, string> {
try {
return extractFilterTagsImpl(args);
} catch (e: any) {
console.error(e.message);
process.exit(1);
}
}
// Per-subcommand help. `pty <cmd> --help` (or `-h`) prints the matching entry:
// usage synopsis, every flag, and at least one concrete example. Kept here as
// the single source so the deprecation/error paths and --help never drift.
// A test (tests/help.test.ts) asserts every subcommand has an entry.
const COMMAND_HELP: Record<string, string> = {
run: `Usage: pty run [flags] -- <command> [args...]
Create a session and attach to it (use -d to leave it running in the background).
Flags:
--id <id> Pin the on-disk id (sock/json filename; charset-validated, ≤ 104-byte sock path)
--name <label> Display label (trimmed, single-line, ≤ 160 Unicode scalars)
--no-display-name Skip the auto cwd+command label — just the id
-d, --detach Create in the background; don't attach
-a, --attach Create, OR attach if a session with the same id already exists
-e, --ephemeral Force self-removal at exit even for strategy=permanent
(non-permanent sessions already self-remove by default)
--tag key=value Tag the session (repeatable)
--env KEY=VALUE Overlay a child environment variable (repeatable)
--unset-env KEY Remove an inherited environment variable (repeatable)
--tag keep=true Exempt from reaping: keep metadata/logs after exit
--cwd <path> Working directory for the command
--isolate-env Scrub the child env to a safe allow-list (for remote-reachable sessions)
--force Create even from inside another pty session (bypass the nesting guard)
Examples:
pty run -- node server.js
pty run -d --name "API" --tag role=web --env PORT=3000 -- node server.js`,
attach: `Usage: pty attach [-r|--no-restart] [--force] [--remote <peer>] [--attach-stream-fd-v1 <fd>] <ref>
Reconnect to a session (alias: pty a). Detach again with Ctrl+\\.
Flags:
-r, --auto-restart Auto-restart the session if it has exited
--no-restart Attach only while the session is running; never prompt
or execute its stored command
--force Attach even from inside another pty session (nested)
--remote <peer> Attach a session on a fabric peer (over fabric); <ref> is
the session's name/id ON THE REMOTE
--attach-stream-fd-v1 <fd>
Machine mode for a running session. Write ordered framed
GEOMETRY, SCREEN, DATA, and terminal EXIT or DETACH outcome
to inherited fd (>= 3); keep stdin/stdout controlling TTY
Examples:
pty attach myserver
pty attach -r myserver
pty attach --no-restart myserver
pty attach --remote hetzner myshell`,
exec: `Usage: pty exec -- <command> [args...]
Replace the current session's leaf process with a new command. Run INSIDE a
session (uses $PTY_SESSION); the session keeps its id and metadata.
Examples:
pty exec -- codex
pty exec -- bash -l`,
peek: `Usage: pty peek [-f] [--plain] [--full] [--wait <text> [-t <sec>]] [--remote <peer>] <ref>
Print a session's screen (or follow it, or wait for text) without attaching.
Flags:
--plain Plain text, no ANSI escapes (best for scripts / agents)
--full Full scrollback, not just the visible viewport
-f, --follow Follow output read-only (Ctrl+\\ to stop)
--wait <text> Block until <text> appears on screen
-t, --timeout <sec> Timeout (seconds) for --wait
--remote <peer> Peek a session on a fabric peer (over fabric); <ref> is
the session's name/id ON THE REMOTE (--wait not yet supported)
Examples:
pty peek --plain myserver
pty peek --remote hetzner myserver
pty peek --wait "Listening" -t 10 --plain myserver`,
send: `Usage: pty send <ref> "text"
pty send <ref> --seq <chunk> [--seq key:<name>] ...
pty send --remote <peer> <ref> "text"
Send text or key events to a session. Raw text is sent with NO implicit newline —
to send text followed by Enter, use --seq (see the second example).
Flags:
--seq <value> Ordered chunk or key event (repeatable). key:<name> sends a
key, e.g. key:return, key:ctrl+c, key:tab
--with-delay <sec> Delay (seconds) between --seq items. DEFAULT 0.3s so a
trailing key:return doesn't race ahead of the program
parsing the text. --with-delay 0 = straight stream (no gap).
--paste "<text>" Wrap the payload in bracketed-paste markers
--remote <peer> Send to a session on a fabric peer (over fabric); <ref> is
the session's name/id ON THE REMOTE
Examples:
pty send myserver "hello"
pty send myserver --seq "git status" --seq key:return # 0.3s gap by default
pty send --remote hetzner myserver --seq "ls" --seq key:return`,
events: `Usage: pty events [--all | <ref>] [--recent] [--json] [--wait <type> [-t <sec>]]
Follow a session's event log (bell, title, notifications, tag/rename changes, user.* events).
Flags:
--all Follow every session, interleaved (omit <ref>)
--recent Print recent events and exit (don't follow)
--json Emit raw JSONL
--wait <type> Block until an event of <type> appears
-t, --timeout <sec> Timeout (seconds) for --wait
Examples:
pty events myserver
pty events --recent --json myserver`,
list: `Usage: pty list [--json] [--tags] [--filter-tag k=v] [--remote [<peer>]] [--status <s>] [--summary]
List sessions (alias: pty ls). User tags show by default.
Flags:
--json Emit JSON
--tags Include internal bookkeeping tags (ptyfile*, strategy.*)
--filter-tag k=v Only sessions with the tag (repeatable, ALL must match)
--remote <peer> Also list a fabric peer's sessions (over fabric; the peer
runs 'pty remote-serve' exposed as 'fabric expose pty-remote')
--remote Bare (no peer): include pty-relay hosts (when installed)
--status <state> Filter by status: running | exited | vanished
--older-than <dur> Only sessions older than a duration (e.g. 30m, 2h, 3d)
--newer-than <dur> Only sessions newer than a duration
--summary Print a one-line count summary instead of the list
Examples:
pty list
pty list --remote hetzner
pty list --filter-tag role=web --json`,
"remote-serve": `Usage: pty remote-serve (--stdio | --socket <path>)
Serve the remote-access control protocol so a fabric peer can expose pty and
other machines can 'pty <cmd> --remote <this-peer>'. Reads sessions from the
ambient PTY_ROOT — run it in the same env the sessions use. Two forms:
--stdio On-demand: serve ONE connection over stdin/stdout, then exit.
fabric spawns it per dial and owns accept + persistence +
roaming (a drop/reconnect reuses the SAME process). No
persistent pty daemon. The recommended fabric form.
--socket <path> Listening daemon: bind a Unix socket for a fabric peer to
expose. Pick a path OUTSIDE PTY_ROOT (a control socket inside
it is mis-scanned as a phantom session). Run it WRAPPED —
'setsid sh -c "…"', systemd, launchd — so pty is a CHILD of
the session leader (exec'd as a bare session leader without a
TTY it can exit on detach). Being retired in favor of --stdio.
Flags:
PTY_REMOTE_SERVE_DEBUG=1 Env: log signal/exit/exception lifecycle to stderr
Examples:
fabric expose pty-remote --exec -- pty remote-serve --stdio # on-demand (recommended)
pty remote-serve --socket ~/.local/state/pty-remote.sock # listening daemon
setsid sh -c 'pty remote-serve --socket ~/.local/state/pty-remote.sock' </dev/null & # wrapped
fabric expose pty-remote --socket ~/.local/state/pty-remote.sock # expose the listening form`,
stats: `Usage: pty stats [--json] [--all] [<ref>]
Live CPU / memory / PIDs. Omit <ref> for every session.
Flags:
--json Emit stats as JSON (one snapshot)
--all Include every session (with an explicit <ref> given)
Examples:
pty stats
pty stats --json myserver`,
restart: `Usage: pty restart [-y] [--force] <ref>
SIGTERM the session's daemon and respawn it from stored metadata (command, cwd,
tags, displayName). Prompts first if it's still running.
Flags:
-y, --yes Skip the "kill and restart?" prompt
--force Attach after restart even from inside another pty session
Examples:
pty restart myserver
pty restart -y myserver`,
kill: `Usage: pty kill <ref>
SIGTERM a running session's daemon. Metadata is kept — restart or \`pty rm\` it later.
Examples:
pty kill myserver`,
recover: `Usage: pty recover <name> --snapshot <metadata.json>
Ask the original supporting daemon to republish an externally unlinked socket
and registry without signaling or restarting its PTY child.
The snapshot must have been captured from the same selected PTY_ROOT before
the registry was unlinked and must advertise a recovery capability.
Example:
pty --root /state/pty recover myserver --snapshot ./myserver.json`,
rm: `Usage: pty rm <ref>
Remove an exited session's files (socket/pid/json/events) (alias: pty remove).
Won't remove a running session — kill it first.
Examples:
pty rm myserver`,
gc: `Usage: pty gc [-n] [--idle-days N] [--fast-fail-window=N] [--fast-fail-limit=N]
pty gc --print-launchd-plist [--interval=N]
One reconciliation pass: sweep exited/vanished, orphan-kill \`parent=<name>\` children,
reap abandoned permanents, respawn \`strategy=permanent\` sessions.
Non-permanent sessions remove themselves as they exit, so the sweep is a backstop:
it mainly catches \`vanished\` sessions, whose daemon was killed outright and so
never ran its own cleanup. Sessions tagged \`keep\` are never swept.
Flags:
-n, --dry-run Preview without changing anything
--idle-days N Also reap permanents with no attach in N days
--fast-fail-window=N Fast-fail window seconds (default 60; per-session tag wins)
--fast-fail-limit=N Consecutive fast fails before flapping (default 3; per-session tag wins)
--print-launchd-plist Print a macOS launchd plist that runs 'pty gc' on an interval
--interval=N Plist StartInterval seconds (default 30)
Examples:
pty gc --dry-run
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.compoundingtech.pty.gc.plist`,
tag: `Usage: pty tag <ref> Show tags
pty tag <ref> key=value [key=value...] Set tags
pty tag <ref> --rm key [--rm key...] Remove tags
Read or write tags on one session. Updates apply before removals.
Flags:
--rm <key> Remove a tag key (repeatable)
Examples:
pty tag myserver role=web env=prod
pty tag myserver --rm env`,
"tag-multi": `Usage: pty tag-multi <selector> [ops...]
Bulk read / write tags across many sessions.
Selector (one of): --all | --filter-tag k=v (repeatable) | <ref>...
Ops (any of): key=value | --rm key
Flags:
--all Select every session
--filter-tag k=v Select sessions with the tag (repeatable)
--rm <key> Remove a tag key (repeatable)
--json Read mode: emit tags as JSON
-y, --yes Required to write when the selector is --all
Examples:
pty tag-multi --filter-tag role=web env=prod
pty tag-multi --all --json`,
emit: `Usage: pty emit <type> [--json <payload>] [--text <string>]
pty emit <ref> <type> [--json <payload>] [--text <string>]
Publish a user.* event to a session's event log. Inside a session the ref
defaults to $PTY_SESSION. Types must start with "user." — "session_*", "state.*",
"bell", etc. are reserved.
Flags:
--json <payload> Attach a JSON payload
--text <string> Attach a text payload
Examples:
pty emit user.build-done
pty emit user.progress --json '{"pct": 40}'
pty emit myserver user.tests-passed --json '{"n": 42}'`,
rename: `Usage: pty rename <new-display-name> Inside a session: set displayName
pty rename <ref> <new-display-name> Outside: set displayName on <ref>
pty rename --show <ref> Show the current displayName
pty rename --clear [ref] Clear the displayName
displayName is a mutable, non-unique label; the session's stable id (name) never changes.
An ambiguous displayName must be replaced with one of the reported stable ids.
Examples:
pty rename my-friendly-name
pty rename webapp "Web Frontend"
pty rename --show webapp`,
metadata: `Usage: pty metadata patch --id <stable-id>
Atomically merge displayName and tags for one exact stable session id. Reads
one JSON object from stdin; it never resolves display-name aliases.
Patch fields:
displayName string to set, null to clear, omitted to preserve
tags object of string values to set and null values to remove
Examples:
pty metadata patch --id a1b2c3d4 < patch.json
printf '%s' '{"displayName":"Worker","tags":{"role":"worker"}}' | pty metadata patch --id a1b2c3d4
printf '%s' '{"displayName":null,"tags":{"temporary":null}}' | pty metadata patch --id a1b2c3d4`,
up: `Usage: pty up [<dir>] [<name>...]
Start sessions declared in a pty.toml. With no args, reads ./pty.toml and starts all.
Examples:
pty up
pty up ./backend
pty up web worker`,
down: `Usage: pty down [<dir>] [<name>...]
Stop sessions declared in a pty.toml.
Examples:
pty down
pty down web`,
test: `Usage: pty test [watch | -t "<pattern>"]
Run the pty test suite (a thin vitest passthrough).
Examples:
pty test
pty test -t "peek"`,
};
/** Print a subcommand's focused help. Resolves aliases; returns false for an
* unknown command so the caller can fall through. */
function printCommandHelp(cmd: string): boolean {
const canonical = ({ a: "attach", ls: "list", remove: "rm" } as Record<string, string>)[cmd] ?? cmd;
const help = COMMAND_HELP[canonical];
if (!help) return false;
console.log(help);
return true;
}
function usage(): void {
console.log(`Usage:
pty Interactive session manager (fullscreen TUI)
pty --preselect-new Open the TUI with "Create new session..." pre-selected
pty --filter-tag key=value Filter the TUI to sessions matching the tag (repeatable);
new sessions inherit the tag
Create sessions:
pty run -- <command> [args...] Create a session and attach (random id + auto display label)
pty run --id <id> -- <command> Pin the on-disk id (sock / json filename; charset-validated)
pty run --name <label> -- <command> Set a trimmed, single-line display label (≤ 160 Unicode scalars)
pty run --no-display-name -- <cmd> Skip the friendly cwd+command label (just an id)
pty run -d -- <command> Create in the background (detached)
pty run -a -- <command> Create OR attach if a session with the same id already exists
pty run -e -- <command> Ephemeral: auto-remove metadata on clean exit
pty run --tag key=value -- <command> Tag a session (repeatable)
pty run --env KEY=VALUE -- <command> Overlay child environment (repeatable; persisted for restart)
pty run --unset-env KEY -- <command> Remove inherited environment (repeatable; persisted for restart)
pty run --cwd /path -- <command> Run in a specific directory
pty run --isolate-env -- <command> Scrub the child env to a safe allow-list
(intended for remote-reachable sessions)
pty run --force -- <command> Create even from inside another pty session (nested)
Attach & interact:
pty attach <ref> Attach to an existing session (alias: pty a)
pty attach --force <ref> Attach even from inside another pty session (nested)
pty attach -r <ref> Attach, auto-restart if the session is exited
pty attach --no-restart <ref> Attach only; fail if the session is not running
pty attach --remote <peer> <ref> Attach a session on a fabric peer (over fabric)
pty exec -- <command> [args...] Replace the current session's process (inside a session)
pty send <ref> "text" Send raw text (no implicit newline)
pty send <ref> --seq "text" --seq key:return Send an ordered sequence of chunks / key events
(0.3s gap between items by default)
pty send <ref> --with-delay <sec> --seq ... Override the gap; --with-delay 0 = straight stream
pty send <ref> --paste "<big text>" Wrap the payload in bracketed-paste markers
pty send --remote <peer> <ref> "text" Send to a session on a fabric peer (over fabric)
Observe:
pty peek <ref> Print current screen and exit
pty peek --plain <ref> Print current screen as plain text (no ANSI)
pty peek --full <ref> Print full scrollback (not just the viewport)
pty peek --wait "text" [-t N] <ref> Wait until text appears (optional timeout in seconds)
pty peek -f <ref> Follow output read-only (Ctrl+\\ to stop)
pty peek --remote <peer> <ref> Peek a session on a fabric peer (over fabric)
pty events <ref> Follow events from a session
pty events --all Follow events from every session, interleaved
pty events --recent <ref> Print recent events and exit
pty events --json <ref> Emit raw JSONL
pty stats Live CPU / memory / PIDs for every session
pty stats <ref> Live metrics for a single session
pty stats --json Emit stats as JSON (one snapshot)
pty list List sessions (text; alias: pty ls)
pty list --json List sessions as JSON
pty list --tags Include internal bookkeeping tags (ptyfile*, strategy.*)
pty list --filter-tag key=value Filter to sessions with the tag (repeatable, ALL must match)
pty list --remote <peer> List a fabric peer's sessions (over fabric)
pty list --remote Include remote sessions via pty-relay (when installed)
pty remote-serve --stdio Serve remote access on-demand (fabric --exec spawns it per dial)
pty remote-serve --socket <path> Serve remote access as a listening daemon (being retired)
Modify:
pty metadata patch --id <id> Atomically merge displayName/tags from JSON stdin
pty rename <label> Inside a session: set its displayName
pty rename <ref> <label> Outside: set displayName on <ref>
pty rename --show <ref> Print the current displayName
pty rename --clear [ref] Remove the displayName
pty tag <ref> Show tags on a session
pty tag <ref> key=value [key=value...] Set tags
pty tag <ref> --rm key [--rm key...] Remove tags
pty tag-multi <selector> [ops...] Bulk read / write tags across sessions
Selector (one of): --all | --filter-tag k=v | <ref>...
Ops (any of): key=value | --rm key
--all + write requires --yes
pty emit user.<type> [--json <p>] [--text <s>] Publish a user.* event (inside a session)
pty emit <ref> user.<type> [...] Same, targeting a specific session
Lifecycle:
pty restart <ref> SIGTERM + respawn using stored metadata (prompts if running)
pty restart -y <ref> Same, no prompt
pty kill <ref> SIGTERM a running session's daemon
pty recover <name> --snapshot <file> Rebind a supporting live daemon after registry unlink
pty rm <ref> Remove an exited session's metadata (alias: pty remove)
pty gc Reconciliation pass: orphan-kill, abandoned-reap,
permanent-respawn, exited-sweep
pty gc --dry-run Preview without changing anything (alias: -n)
pty gc --idle-days N Also reap permanents with no attach in N days
pty gc --fast-fail-window=N Fast-fail window (seconds) for the respawn cap
(default 60; per-session strategy.fast-fail-window wins)
pty gc --fast-fail-limit=N Consecutive fast fails before a permanent is flagged
flapping (default 3; per-session tag wins)
pty gc --print-launchd-plist [--interval=N]
Print a launchd plist that runs 'pty gc' every N seconds
(default 30); Label + logPath derived from PTY_ROOT
Multi (pty.toml):
pty up Start every session in ./pty.toml
pty up <dir> Start sessions in <dir>/pty.toml
pty up <name> [<name>...] Start specific sessions from ./pty.toml
pty down Stop every session in ./pty.toml
pty down <dir> Stop sessions in <dir>/pty.toml
pty down <name> [<name>...] Stop specific sessions
Global:
pty --root <path> <subcommand> [...] Pin the state registry for this call (== PTY_ROOT env)
pty help | pty --help | pty -h Show this usage
pty version | pty --version | pty -v Print the version (<semver>+<short-sha>)
pty test [watch | -t "pattern"] Run the pty test suite (vitest passthrough)
Session references (<ref>): the on-disk id (validated: [A-Za-z0-9._-], ≤ 255 chars,
socket path ≤ 104 bytes), or a displayName. Stable ids always win; a displayName
resolves only when unique. Inside a session, most commands default to $PTY_SESSION
when the ref is omitted (see 'pty rename', 'pty exec', 'pty emit').
Env:
PTY_ROOT Registry dir (default ~/.local/state/pty). Canonical.
PTY_SESSION_DIR Deprecated alias for PTY_ROOT; still works, one-time notice.
PTY_ROOT_LEGACY_SILENT Suppress the PTY_SESSION_DIR deprecation notice.
PTY_SESSION Set by the daemon inside a session; drives nesting detection.
Detach from an attached session with Ctrl+\\ (press twice to send Ctrl+\\ to the child).`);
}
/** Resolve a user-supplied session reference (name OR displayName) to the
* stable `name`. Errors and exits if no session matches. Use this whenever
* a command is about to hit the socket, metadata file, or anything else
* keyed by the stable id — it ensures typing the displayName works the
* same as typing the underlying name. */
async function resolveRef(ref: string): Promise<string> {
const session = await getSession(ref);
if (!session) {
console.error(`Session "${ref}" not found.`);
process.exit(1);
}
return session.name;
}
/** Refuse a command that would start a nested client inside an existing
* pty session. Several commands (attach, restart-then-attach, the
* interactive picker, run -a when the target is running) silently created
* a client-inside-a-client, routing detach keybindings to the outer
* client and tangling the user up. `--force` opts back into the old
* behavior for the rare cases where nesting is intentional (debugging,
* screen-sharing demos). Prints + exits; does not return on refusal. */
function ensureNotNested(
cmd: string,
opts: { force?: boolean; hint?: string } = {},
): void {
if (opts.force) return;
const nested = process.env.PTY_SESSION;
if (!nested) return;
console.error(`pty ${cmd}: already inside pty session "${nested}".`);
if (opts.hint) console.error(opts.hint);
else console.error(" Pass --force to override.");
process.exit(1);
}
/** Generate a short random session id. Base32 (Crockford-ish, no 0/O/1/I
* confusion). 8 chars = 40 bits — plenty of headroom against collisions
* even with thousands of sessions per machine. */
function randomSessionName(): string {
const alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
const bytes = randomBytes(8);
let out = "";
for (const b of bytes) out += alphabet[b % alphabet.length];
return out;
}
/** Generate a session name from the cwd and command. */
function autoName(cmd: string, cmdArgs: string[]): string {
// Directory component: last part of cwd
const dirPart = path.basename(process.cwd());
// Command component: base name of the command + first meaningful arg
const cmdBase = path.basename(cmd);
const firstArg = cmdArgs.find(a => !a.startsWith("-") && a.length < 30);
let cmdPart = cmdBase;
if (firstArg) {
// Strip extension and path, keep only alphanumeric/dash/dot
const argBase = path.basename(firstArg).replace(/\.[^.]+$/, "");
if (argBase && /^[a-zA-Z0-9._-]+$/.test(argBase)) {
cmdPart = `${cmdBase}-${argBase}`;
}
}
return `${dirPart}-${cmdPart}`;
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Global --root <path>: pin the state registry for this invocation.
// Consumed here so every subcommand transparently scopes via
// getSessionDir(). Equivalent to PTY_ROOT=<path> for one call.
// Scanned across the full argv because no subcommand uses --root.
const rootIdx = args.indexOf("--root");
if (rootIdx !== -1) {
const val = args[rootIdx + 1];
if (!val || val.startsWith("-")) {
console.error("pty: --root requires a path (e.g. pty --root /var/lib/pty-eval list)");
process.exit(1);
}
process.env.PTY_ROOT = val;
args.splice(rootIdx, 2);
}
// Fail-loud backstop for the sockaddr_un.sun_path 104-byte kernel limit.
// `validateName()` already catches a too-long root at spawn time by
// computing the full socket path, but its error message reads as if
// the name were the problem, and it fires per-invocation only when a
// spawn happens. This check catches the pathological deep-PTY_ROOT
// case at startup — before any subcommand runs — and points the
// finger at the root, not the name.
//
// Threshold: an 8-char random session id (the default `pty run`
// shape) produces a socket suffix of `/xxxxxxxx.sock` = 14 bytes.
// A root whose length + 14 exceeds 104 can't host a default-id
// session and is unusable. Callers who intentionally want tiny
// 1-char names on a nearly-full root can side-step by shortening
// the root; there's no correct behavior for a genuinely-too-long
// root, so we fail rather than limp.
const resolvedRoot = process.env.PTY_ROOT ?? process.env.PTY_SESSION_DIR;
if (resolvedRoot && resolvedRoot.length > 0) {
const SUN_PATH_MAX = 104;
const SOCK_SUFFIX_BYTES = "/".length + 8 + ".sock".length;
const rootBytes = Buffer.byteLength(resolvedRoot, "utf-8");
if (rootBytes + SOCK_SUFFIX_BYTES > SUN_PATH_MAX) {
const usable = SUN_PATH_MAX - SOCK_SUFFIX_BYTES;
console.error(
`pty: PTY_ROOT is too long — ${rootBytes} bytes; must be ≤ ${usable} bytes for the socket path to fit the ${SUN_PATH_MAX}-byte kernel limit.\n` +
` root: ${resolvedRoot}\n` +
` Shorten the root (or use \`pty --root <shorter-path>\` for a one-off).`
);
process.exit(1);
}
}
// Interactive-mode flags (--preselect-new, --filter-tag) can appear before
// the subcommand. Peek at the subcommand without consuming flags; if it's
// the interactive TUI (none, "i", or "interactive"), consume those flags
// here. Otherwise leave them in args for the subcommand to parse itself.
//
// Detect the subcommand: first positional that isn't a flag or a value for
// a known flag that takes a value (currently just --filter-tag).
let subcommand = "";
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--filter-tag") { i++; continue; }
if (a.startsWith("-")) continue;
subcommand = a;
break;
}
let preselectNew = false;
let interactiveFilterTags: Record<string, string> = {};
let interactiveForce = false;
if (!subcommand || subcommand === "i" || subcommand === "interactive") {
preselectNew = args.includes("--preselect-new");
interactiveForce = args.includes("--force");
interactiveFilterTags = extractFilterTags(args);
}
const dispatchArgs = args.filter((a) => a !== "--preselect-new" && a !== "--force");
if (dispatchArgs.length === 0) {
await runInteractive({ preselectNew, filterTags: interactiveFilterTags, force: interactiveForce });
return;
}
const command = dispatchArgs[0];
// A subcommand's own `--help` / `-h` (in the first position after the command)
// prints that command's focused help and exits 0. `--root <path>` is already
// spliced out of `args` above, so `args[1]` is the token after the subcommand.
// First-position only, so `pty send <ref> --help` still sends "--help" as text.
if ((args[1] === "-h" || args[1] === "--help") && printCommandHelp(command)) {
return;
}
switch (command) {
case "interactive":
case "i": {
await runInteractive({ preselectNew, filterTags: interactiveFilterTags, force: interactiveForce });
break;
}
case "run": {
// Parse flags before the -- separator. The flag model:
// --id <id> explicit on-disk id (sock/json filename). Validated:
// charset, sock-path length, no existing-ref collision.
// --name <dn> explicit display label (arbitrary length / chars,
// within the permissive validateDisplayName rules).
// Replaces the auto-generated cwd+cmd label.
// --no-display-name skip displayName entirely.
// Both omitted → random short id + auto-generated displayName.
let detach = false;
let attachExisting = false;
let ephemeral = false;
let isolateEnv = false;
let noDisplayName = false;
let force = false;
let explicitId: string | null = null;
let explicitDisplayName: string | null = null;
let cwd: string | null = null;
const tags: Record<string, string> = {};
const extraEnv: Record<string, string> = {};
const unsetEnv: string[] = [];
let i = 1;
while (i < args.length && args[i] !== "--") {
if (args[i] === "-d" || args[i] === "--detach") { detach = true; i++; }
else if (args[i] === "-a" || args[i] === "--attach") { attachExisting = true; i++; }
else if (args[i] === "-e" || args[i] === "--ephemeral") { ephemeral = true; i++; }
else if (args[i] === "--isolate-env") { isolateEnv = true; i++; }
else if (args[i] === "--no-display-name") { noDisplayName = true; i++; }
else if (args[i] === "--force") { force = true; i++; }
else if (args[i] === "--id" && i + 1 < args.length) { explicitId = args[i + 1]; i += 2; }
else if (args[i] === "--name" && i + 1 < args.length) { explicitDisplayName = args[i + 1]; i += 2; }
else if (args[i] === "--cwd" && i + 1 < args.length) { cwd = args[i + 1]; i += 2; }
else if (args[i] === "--tag" && i + 1 < args.length) {
const eq = args[i + 1].indexOf("=");
if (eq === -1) {
console.error(`Invalid tag format: "${args[i + 1]}". Use --tag key=value`);
process.exit(1);
}
tags[args[i + 1].slice(0, eq)] = args[i + 1].slice(eq + 1);
i += 2;
}
else if (args[i] === "--env" && i + 1 < args.length) {
const assignment = args[i + 1];
const eq = assignment.indexOf("=");
if (eq <= 0) {
console.error(`Invalid env format: "${assignment}". Use --env KEY=VALUE`);
process.exit(1);
}
extraEnv[assignment.slice(0, eq)] = assignment.slice(eq + 1);
i += 2;
}
else if (args[i] === "--unset-env" && i + 1 < args.length) {
const key = args[i + 1];
if (key.length === 0 || key.includes("=")) {
console.error(`Invalid env key: "${key}". Use --unset-env KEY`);
process.exit(1);
}
if (!unsetEnv.includes(key)) unsetEnv.push(key);
i += 2;
}
else break;
// Note: unknown flags or positional args before -- break the loop
}
// Everything after -- is the command
const dashDash = args.indexOf("--", i);
let cmd: string;
let cmdArgs: string[];
if (dashDash !== -1) {
// Anything between flags and -- that isn't a flag is a legacy
// positional that's now interpreted as the display name. The
// previous semantics (positional = on-disk id) is gone — use --id.
const between = args.slice(i, dashDash);
if (between.length > 0 && !explicitDisplayName) {
explicitDisplayName = between[0];
console.error(`Hint: use --name instead: pty run --name ${between[0]} -- ...`);
}
cmd = args[dashDash + 1];
cmdArgs = args.slice(dashDash + 2);
} else {
// No -- separator: legacy positional format
// pty run mydisplayname node server.js
const rest = args.slice(i);
if (!explicitDisplayName && rest.length >= 2) {
explicitDisplayName = rest[0];
cmd = rest[1];
cmdArgs = rest.slice(2);
console.error(`Hint: use --name instead: pty run --name ${rest[0]} -- ${cmd} ${cmdArgs.join(" ")}`.trimEnd());
} else {
cmd = rest[0];
cmdArgs = rest.slice(1);
}
}
if (!cmd) {
console.error("Usage: pty run [--id <id>] [--name <displayName>] [-d] [-a] -- <command> [args...]");
process.exit(1);
}
const autoNameCmd = cmd;
const displayCmd = [cmd, ...cmdArgs].join(" ");
try {
cmd = resolveCommand(cmd);
} catch (e: any) {
console.error(e.message);
process.exit(1);
}
// Nesting prevention: if inside a pty session and not detaching, exec
// directly — a plain nested `run` runs the command in-place rather than
// spawning a background session the caller can't see. Two escape hatches
// bypass this and create a real (nested) session:
// * -d/--detach — create a background session and return.
// * --force — create a nested session and attach to it. This is the
// documented `--force` contract ("Create even from
// inside another pty session") and mirrors attach's /
// restart's --force symmetry. Nested clients tangle
// detach keys, so it's opt-in, but when explicitly
// asked for we honor it instead of silently running
// in-place.
if (process.env.PTY_SESSION && !detach && !force) {
// Nested + no --force: a plain `run` execs directly. The narrower -a
// branch refuses instead when the caller asked to attach-if-running
// and the target IS running (attaching would nest a client) — the
// --force path above is the documented way to override that.
const lookupRef = explicitId ?? explicitDisplayName;
if (attachExisting && lookupRef) {
const existing = explicitId
? await getSessionByName(explicitId)
: await getSession(lookupRef);
if (existing && existing.status === "running") {
ensureNotNested("run -a", {
force: false,
hint:
` Target session "${lookupRef}" is already running; attaching would nest a client inside the current session.\n` +
" Pass --force to attach anyway, or detach first (Ctrl+\\) and re-run from outside.",
});
}
}
console.error(
`Already inside pty session "${process.env.PTY_SESSION}", running directly.`
);
const directEnv = { ...process.env };
for (const key of unsetEnv) delete directEnv[key];
Object.assign(directEnv, extraEnv);
const result = spawnSync(cmd, cmdArgs, {
stdio: "inherit",
env: directEnv,
});
process.exit(result.status ?? 1);
}
const existingNames = await allSessionNames();
// Resolve `name` (the on-disk id). If --id was passed, validate and use
// it verbatim; otherwise generate a short random id. Charset, length,
// and stable-id uniqueness checks are all done up front so automation fails
// loudly rather than hitting EINVAL/ENAMETOOLONG deep in spawn.
//
// Uniqueness exception: under `-a` (attach-or-create), a collision
// with an existing session is the *expected* path — cmdRun attaches
// a running session or recreates an exited one. Defer to cmdRun.
let name: string;
if (explicitId) {
try {
validateName(explicitId);
} catch (e: any) {
console.error(e.message);
process.exit(1);
}
if (existingNames.has(explicitId) && !attachExisting) {
console.error(`Session id "${explicitId}" is already in use.`);
process.exit(1);
}
name = explicitId;
} else {
let candidate: string | null = null;
for (let attempt = 0; attempt < 8; attempt++) {
const c = randomSessionName();
if (!existingNames.has(c)) { candidate = c; break; }
}
if (!candidate) {
console.error("Could not generate a unique session id after 8 attempts.");
process.exit(1);
}
name = candidate;
}
// Resolve `displayName`. Precedence:
// 1. --no-display-name → null
// 2. --name <x> → x (validated permissively)
// 3. otherwise → auto cwd+cmd label (sanitized)
let displayName: string | null = null;
if (!noDisplayName) {
if (explicitDisplayName) {
try {
validateDisplayName(explicitDisplayName);
} catch (e: any) {
console.error(`Invalid displayName: ${e.message}`);
process.exit(1);
}
displayName = explicitDisplayName;
} else {
let candidate = autoName(autoNameCmd, cmdArgs);
candidate = candidate.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
displayName = candidate;
}
}
await cmdRun(
name, cmd, cmdArgs, detach, attachExisting, displayCmd, ephemeral,
tags, cwd, isolateEnv, displayName, extraEnv, unsetEnv,
);
break;
}
case "attach":
case "a": {
let autoRestart = false;
let noRestart = false;
let force = false;
let attachName: string | null = null;
let attachRemotePeer: string | null = null;
let attachStreamFdV1: number | undefined;
for (let ai = 1; ai < args.length; ai++) {
const a = args[ai];
if (a === "--auto-restart" || a === "-r") autoRestart = true;
else if (a === "--no-restart") noRestart = true;
else if (a === "--force") force = true;
else if (a === "--remote" && ai + 1 < args.length) { attachRemotePeer = args[++ai]; }
else if (a === "--attach-stream-fd-v1") {
if (ai + 1 >= args.length) {
console.error("pty attach: --attach-stream-fd-v1 requires a file descriptor");
process.exit(1);
}
attachStreamFdV1 = Number(args[++ai]);
}
else if (!attachName) attachName = a;
else {
console.error(`pty attach: unexpected argument "${a}"`);
process.exit(1);
}
}
if (!attachName) {
console.error("Usage: pty attach [-r|--auto-restart|--no-restart] [--force] [--remote <peer>] <name>");
process.exit(1);
}
if (autoRestart && noRestart) {
console.error("pty attach: --auto-restart and --no-restart are mutually exclusive");
process.exit(1);
}
if (attachStreamFdV1 !== undefined) {
try {
validateAttachStreamFdV1(attachStreamFdV1);
} catch (error) {
console.error(`pty attach: ${(error as Error).message}`);
process.exit(1);
}
if (autoRestart) {
console.error("pty attach: --attach-stream-fd-v1 and --auto-restart are mutually exclusive");
process.exit(1);
}
}
// Nesting guard runs BEFORE name validation / ref resolution. A nested
// caller gets the informative nesting message even if they mistyped
// the session name — otherwise they'd fix the typo, try again, and
// only then discover they shouldn't attach at all. Applies to --remote
// too: a nested remote attach tangles detach keys just the same.
ensureNotNested("attach", {