@phnx-labs/agents-cli 1.22.22 → 1.22.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +402 -0
  2. package/README.md +13 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/defaults.js +2 -0
  7. package/dist/commands/doctor.js +20 -7
  8. package/dist/commands/exec.js +100 -34
  9. package/dist/commands/feed.d.ts +18 -0
  10. package/dist/commands/feed.js +44 -1
  11. package/dist/commands/focus.d.ts +108 -5
  12. package/dist/commands/focus.js +455 -32
  13. package/dist/commands/fork.d.ts +20 -2
  14. package/dist/commands/fork.js +91 -64
  15. package/dist/commands/go.d.ts +39 -1
  16. package/dist/commands/go.js +112 -7
  17. package/dist/commands/harness-wizard.d.ts +206 -0
  18. package/dist/commands/harness-wizard.js +403 -0
  19. package/dist/commands/harness.d.ts +12 -0
  20. package/dist/commands/harness.js +97 -101
  21. package/dist/commands/insights.js +6 -2
  22. package/dist/commands/inspect.js +39 -5
  23. package/dist/commands/menubar.js +6 -1
  24. package/dist/commands/models.js +1 -0
  25. package/dist/commands/modes.d.ts +12 -0
  26. package/dist/commands/modes.js +147 -0
  27. package/dist/commands/resume.js +10 -3
  28. package/dist/commands/secrets-sync.js +11 -13
  29. package/dist/commands/secrets.d.ts +2 -0
  30. package/dist/commands/secrets.js +79 -47
  31. package/dist/commands/sessions-browser.d.ts +35 -0
  32. package/dist/commands/sessions-browser.js +140 -14
  33. package/dist/commands/sessions-resume.d.ts +23 -3
  34. package/dist/commands/sessions-resume.js +73 -19
  35. package/dist/commands/sessions.d.ts +91 -5
  36. package/dist/commands/sessions.js +359 -125
  37. package/dist/commands/setup-secrets.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/view.js +2 -0
  40. package/dist/commands/watchdog.js +13 -2
  41. package/dist/index.js +2 -1
  42. package/dist/lib/agent-modes.d.ts +49 -0
  43. package/dist/lib/agent-modes.js +70 -0
  44. package/dist/lib/agents.d.ts +1 -1
  45. package/dist/lib/agents.js +132 -0
  46. package/dist/lib/codex-policy.d.ts +17 -0
  47. package/dist/lib/codex-policy.js +48 -0
  48. package/dist/lib/crabbox/lease.d.ts +25 -0
  49. package/dist/lib/crabbox/lease.js +62 -0
  50. package/dist/lib/daemon.js +70 -0
  51. package/dist/lib/doctor-diff.d.ts +3 -0
  52. package/dist/lib/doctor-diff.js +15 -13
  53. package/dist/lib/event-stream.d.ts +3 -1
  54. package/dist/lib/event-stream.js +14 -1
  55. package/dist/lib/exec.d.ts +16 -0
  56. package/dist/lib/exec.js +175 -62
  57. package/dist/lib/feed-broadcast.d.ts +1 -20
  58. package/dist/lib/feed-broadcast.js +31 -1
  59. package/dist/lib/hooks/cache.js +36 -3
  60. package/dist/lib/hooks.d.ts +19 -7
  61. package/dist/lib/hooks.js +100 -40
  62. package/dist/lib/hosts/session-index.d.ts +4 -0
  63. package/dist/lib/hosts/session-index.js +7 -0
  64. package/dist/lib/manifest.d.ts +12 -2
  65. package/dist/lib/manifest.js +60 -5
  66. package/dist/lib/mcp.js +44 -0
  67. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  68. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  69. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  70. package/dist/lib/menubar/install-menubar.js +128 -6
  71. package/dist/lib/models.d.ts +0 -5
  72. package/dist/lib/models.js +48 -0
  73. package/dist/lib/plugin-marketplace.js +9 -0
  74. package/dist/lib/pricing/prices.json +119 -92
  75. package/dist/lib/pricing/table.js +13 -0
  76. package/dist/lib/refresh.d.ts +5 -0
  77. package/dist/lib/refresh.js +37 -33
  78. package/dist/lib/remote-agents-json.d.ts +29 -1
  79. package/dist/lib/remote-agents-json.js +47 -10
  80. package/dist/lib/resource-inventory.d.ts +79 -0
  81. package/dist/lib/resource-inventory.js +122 -0
  82. package/dist/lib/resources/mcp.js +2 -0
  83. package/dist/lib/resources/permissions.js +3 -0
  84. package/dist/lib/resources/types.d.ts +2 -1
  85. package/dist/lib/resources.js +8 -5
  86. package/dist/lib/run-defaults.d.ts +2 -0
  87. package/dist/lib/run-defaults.js +23 -2
  88. package/dist/lib/runner.js +50 -36
  89. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  90. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  91. package/dist/lib/secrets/bundles.js +157 -65
  92. package/dist/lib/secrets/filestore.d.ts +5 -3
  93. package/dist/lib/secrets/filestore.js +12 -8
  94. package/dist/lib/secrets/index.d.ts +15 -1
  95. package/dist/lib/secrets/index.js +148 -53
  96. package/dist/lib/secrets/reaper.d.ts +97 -0
  97. package/dist/lib/secrets/reaper.js +219 -0
  98. package/dist/lib/secrets/remote.d.ts +29 -0
  99. package/dist/lib/secrets/remote.js +37 -1
  100. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  101. package/dist/lib/secrets/sync-passphrase.js +78 -0
  102. package/dist/lib/session/active.d.ts +36 -1
  103. package/dist/lib/session/active.js +60 -19
  104. package/dist/lib/session/actor-sidecar.d.ts +14 -0
  105. package/dist/lib/session/actor-sidecar.js +67 -4
  106. package/dist/lib/session/db.d.ts +1 -1
  107. package/dist/lib/session/db.js +32 -1
  108. package/dist/lib/session/discover.js +168 -0
  109. package/dist/lib/session/parse.d.ts +10 -0
  110. package/dist/lib/session/parse.js +98 -0
  111. package/dist/lib/session/recovery.d.ts +37 -0
  112. package/dist/lib/session/recovery.js +95 -0
  113. package/dist/lib/session/remote-list.d.ts +10 -1
  114. package/dist/lib/session/remote-list.js +2 -8
  115. package/dist/lib/session/remote.d.ts +57 -3
  116. package/dist/lib/session/remote.js +90 -26
  117. package/dist/lib/session/resume-command.d.ts +6 -0
  118. package/dist/lib/session/resume-command.js +8 -0
  119. package/dist/lib/session/session-cache.d.ts +173 -0
  120. package/dist/lib/session/session-cache.js +399 -0
  121. package/dist/lib/session/types.d.ts +1 -1
  122. package/dist/lib/session/types.js +1 -1
  123. package/dist/lib/session/width.d.ts +1 -1
  124. package/dist/lib/session/width.js +12 -2
  125. package/dist/lib/shims.d.ts +2 -2
  126. package/dist/lib/shims.js +62 -8
  127. package/dist/lib/staleness/writers/hooks.js +1 -1
  128. package/dist/lib/startup/command-registry.d.ts +1 -0
  129. package/dist/lib/startup/command-registry.js +2 -0
  130. package/dist/lib/sync-umbrella.d.ts +5 -0
  131. package/dist/lib/sync-umbrella.js +5 -4
  132. package/dist/lib/tmux/session.d.ts +4 -2
  133. package/dist/lib/tmux/session.js +5 -5
  134. package/dist/lib/types.d.ts +3 -1
  135. package/dist/lib/usage.d.ts +13 -0
  136. package/dist/lib/usage.js +215 -0
  137. package/dist/lib/versions.js +13 -2
  138. package/package.json +1 -1
@@ -3,71 +3,98 @@ import { setHelpSections } from '../lib/help.js';
3
3
  import { findSessionsById } from '../lib/session/db.js';
4
4
  import { discoverSessions } from '../lib/session/discover.js';
5
5
  import { forkSession, isForkableAgent, FORKABLE_AGENTS } from '../lib/session/fork.js';
6
- /** Register the top-level `agents fork` command. */
7
- export function registerForkCommand(program) {
8
- const cmd = program
6
+ const FORK_HELP = {
7
+ examples: `
8
+ # Fork a session by (partial) id, then continue the fork
9
+ agents sessions fork 4f3a9c21
10
+ agents sessions resume <new-id>
11
+
12
+ # Give the fork a name
13
+ agents sessions fork 4f3a9c21 --name "try redis instead"
14
+ `,
15
+ notes: `
16
+ - 'resume' continues the SAME conversation; 'fork' copies it under a new id so the two diverge.
17
+ - The fork is a full copy of the conversation so far; continuing it never touches the original.
18
+ - Resolve the session the same way as resume: an exact or prefix id fragment.
19
+ - Native copy currently supports: ${FORKABLE_AGENTS.join(', ')}. For other harnesses, branch by
20
+ starting a fresh agent and seeding it with '/continue <id>' — the source stays put.
21
+ `,
22
+ };
23
+ /**
24
+ * Resolve the source session, copy it under a fresh id, and print how to
25
+ * continue the fork. Shared by `agents sessions fork` and the `agents fork` alias.
26
+ */
27
+ export async function runFork(sessionArg, options) {
28
+ // Resolve the source. Try the index first; only pay for a rescan if the id
29
+ // isn't found yet (mirrors the resume path's freshen-then-lookup).
30
+ let matches = findSessionsById(sessionArg, {});
31
+ if (matches.length === 0) {
32
+ await discoverSessions({});
33
+ matches = findSessionsById(sessionArg, {});
34
+ }
35
+ if (matches.length === 0) {
36
+ // Errors go to stderr and set a non-zero exit code so a script chaining on
37
+ // `agents sessions fork <id> && agents sessions resume <new>` doesn't proceed
38
+ // on a failed fork.
39
+ console.error(chalk.red(`No session matching "${sessionArg}".`));
40
+ console.error(chalk.gray('List candidates with: agents sessions'));
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ if (matches.length > 1) {
45
+ console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
46
+ for (const m of matches.slice(0, 8)) {
47
+ console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
48
+ }
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ const source = matches[0];
53
+ if (!isForkableAgent(source.agent)) {
54
+ // A native copy needs the agent's transcript to be resumable by id; harnesses
55
+ // without that can still be branched by hand. Fail loud with the manual path
56
+ // rather than a silent no-op or a fake copy.
57
+ console.error(chalk.yellow(`A native fork copy isn't supported for ${source.agent} sessions yet (supported: ${FORKABLE_AGENTS.join(', ')}).`));
58
+ console.error(chalk.gray(` Branch it by hand — start a fresh ${source.agent} and seed it with the source's context:`));
59
+ console.error(chalk.gray(` agents run ${source.agent} --terminal # then, in the new session:`));
60
+ console.error(chalk.gray(` /continue ${source.shortId}`));
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ let result;
65
+ try {
66
+ result = forkSession(source, { name: options.name });
67
+ }
68
+ catch (err) {
69
+ console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
74
+ console.log(chalk.gray(` Label: ${result.label}`));
75
+ console.log(chalk.gray(` Continue: agents sessions resume ${result.shortId}`));
76
+ console.log(chalk.gray(` Original ${source.shortId} is untouched.`));
77
+ }
78
+ /**
79
+ * Register `agents sessions fork <session>` — the canonical surface (fork is a
80
+ * session operation, so it lives under the `sessions` group).
81
+ */
82
+ export function registerSessionsForkCommand(sessionsCmd) {
83
+ const cmd = sessionsCmd
9
84
  .command('fork <session>')
10
85
  .description('Branch a session into a new, independent copy you can continue separately. The original is untouched.')
11
86
  .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
12
- setHelpSections(cmd, {
13
- examples: `
14
- # Fork a session by (partial) id, then continue the fork
15
- agents fork 4f3a9c21
16
- agents resume <new-id>
17
-
18
- # Give the fork a name
19
- agents fork 4f3a9c21 --name "try redis instead"
20
- `,
21
- notes: `
22
- - 'resume' continues the SAME conversation; 'fork' copies it under a new id so the two diverge.
23
- - The fork is a full copy of the conversation so far; continuing it never touches the original.
24
- - Resolve the session the same way as resume: an exact or prefix id fragment.
25
- - Currently supports: ${FORKABLE_AGENTS.join(', ')}. Other agents are a planned follow-up.
26
- `,
27
- });
28
- cmd.action(async (sessionArg, options) => {
29
- // Resolve the source. Try the index first; only pay for a rescan if the id
30
- // isn't found yet (mirrors the resume path's freshen-then-lookup).
31
- let matches = findSessionsById(sessionArg, {});
32
- if (matches.length === 0) {
33
- await discoverSessions({});
34
- matches = findSessionsById(sessionArg, {});
35
- }
36
- if (matches.length === 0) {
37
- // Errors go to stderr and set a non-zero exit code so a script chaining on
38
- // `agents fork <id> && agents resume <new>` doesn't proceed on a failed fork.
39
- console.error(chalk.red(`No session matching "${sessionArg}".`));
40
- console.error(chalk.gray('List candidates with: agents sessions'));
41
- process.exitCode = 1;
42
- return;
43
- }
44
- if (matches.length > 1) {
45
- console.error(chalk.yellow(`"${sessionArg}" is ambiguous — ${matches.length} sessions match. Use a longer id:`));
46
- for (const m of matches.slice(0, 8)) {
47
- console.error(chalk.gray(` ${m.shortId} ${m.agent} ${m.label || m.topic || ''}`));
48
- }
49
- process.exitCode = 1;
50
- return;
51
- }
52
- const source = matches[0];
53
- if (!isForkableAgent(source.agent)) {
54
- console.error(chalk.yellow(`fork does not support ${source.agent} sessions yet.`));
55
- console.error(chalk.gray(` Supported: ${FORKABLE_AGENTS.join(', ')}.`));
56
- process.exitCode = 1;
57
- return;
58
- }
59
- let result;
60
- try {
61
- result = forkSession(source, { name: options.name });
62
- }
63
- catch (err) {
64
- console.error(chalk.red(`Could not fork ${source.shortId}: ${err.message}`));
65
- process.exitCode = 1;
66
- return;
67
- }
68
- console.log(chalk.green(`Forked ${source.shortId} -> ${result.shortId}`));
69
- console.log(chalk.gray(` Label: ${result.label}`));
70
- console.log(chalk.gray(` Continue: agents resume ${result.shortId}`));
71
- console.log(chalk.gray(` Original ${source.shortId} is untouched.`));
72
- });
87
+ setHelpSections(cmd, FORK_HELP);
88
+ cmd.action(runFork);
89
+ }
90
+ /**
91
+ * Register the hidden top-level `agents fork` alias. Kept working for back-compat
92
+ * and muscle memory; the canonical, discoverable surface is `agents sessions fork`.
93
+ */
94
+ export function registerForkCommand(program) {
95
+ const cmd = program
96
+ .command('fork <session>', { hidden: true })
97
+ .description('Alias for `agents sessions fork` branch a session into a new, independent copy.')
98
+ .option('--name <label>', 'Label for the fork (default: "fork of <original>")');
99
+ cmd.action(runFork);
73
100
  }
@@ -16,21 +16,46 @@
16
16
  import type { Command } from 'commander';
17
17
  import { type ActiveSession } from '../lib/session/active.js';
18
18
  import type { SessionMeta } from '../lib/session/types.js';
19
+ import { type LiveStatusFilter } from './sessions.js';
19
20
  export declare function registerGoCommand(program: Command): void;
21
+ /**
22
+ * Scope a live-session pool by device and live status. Pure so the `focus`
23
+ * device/status filters are unit-testable without touching the sweep. `hosts`
24
+ * keeps only sessions whose `machine` is in the set (local rows carry `self`,
25
+ * remote rows carry their peer tag); `statuses` reuses `--active`'s exact
26
+ * `matchesLiveStatus` derivation rather than a parallel status table.
27
+ */
28
+ export declare function filterLivePool(sessions: ActiveSession[], opts?: {
29
+ hosts?: string[];
30
+ statuses?: LiveStatusFilter[];
31
+ }): ActiveSession[];
20
32
  /**
21
33
  * Live jump targets (local + remote), keyed by session id. Cloud is excluded by
22
34
  * default (it has no local pid to attach), but `detach` opts in with
23
35
  * `includeCloud` so it can resolve a cloud id and refuse it with a clear message
24
36
  * instead of a bare "no live session".
37
+ *
38
+ * `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
39
+ * pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
40
+ * `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
25
41
  */
26
42
  export declare function gatherLiveTargets(local: boolean, opts?: {
27
43
  includeCloud?: boolean;
44
+ hosts?: string[];
45
+ statuses?: LiveStatusFilter[];
28
46
  }): Promise<{
29
47
  self: string;
30
48
  activeById: Map<string, ActiveSession>;
31
49
  }>;
32
50
  /** Interactive pick over the live sessions' rich SessionMeta; returns the chosen live session. */
33
51
  export declare function pickLiveTarget(activeById: Map<string, ActiveSession>, self: string, message: string, enterHint: string): Promise<ActiveSession | null>;
52
+ /**
53
+ * Multi-select over the live sessions' rich SessionMeta (same rows as
54
+ * `pickLiveTarget`, but a checkbox picker) — the plural sibling that lets `focus`
55
+ * open several sessions at once. Mirrors `sessions resume`'s `multiItemPicker`
56
+ * wiring; returns the chosen live sessions in pick order, or `[]` on cancel.
57
+ */
58
+ export declare function pickLiveTargets(activeById: Map<string, ActiveSession>, self: string, message: string): Promise<ActiveSession[]>;
34
59
  /**
35
60
  * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
36
61
  * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
@@ -53,6 +78,19 @@ export declare function describeWhere(s: ActiveSession, self: string): Where;
53
78
  * the session lives on another machine, else undefined.
54
79
  */
55
80
  export type UnreachableFallback = (s: ActiveSession, remote: string | undefined) => void | Promise<void>;
56
- /** Default (attach-only): open a login shell on the remote, or refuse locally. */
81
+ export type AttachRailLiveness = {
82
+ state: 'alive';
83
+ } | {
84
+ state: 'dead';
85
+ exitStatus?: number;
86
+ } | {
87
+ state: 'missing';
88
+ };
89
+ /** Probe the tmux process, not just retained provenance. This is deliberately
90
+ * called immediately before an attach so remain-on-exit panes cannot masquerade
91
+ * as living agent sessions. */
92
+ export declare function probeAttachRail(s: ActiveSession, self: string): Promise<AttachRailLiveness>;
93
+ /** Strict attach-only fallback: no pane means no attach. Never open a shell or
94
+ * start recovery, because both would violate the caller's no-fork intent. */
57
95
  export declare function refuseFallback(s: ActiveSession, remote: string | undefined): Promise<void>;
58
96
  export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback): Promise<void>;
@@ -21,12 +21,16 @@ import { getActiveSessions, findSessionFileForKind } from '../lib/session/active
21
21
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
22
  import { discoverSessions } from '../lib/session/discover.js';
23
23
  import { deriveShortId } from '../lib/session/short-id.js';
24
- import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive } from './sessions.js';
24
+ import { dedupeByMachineSession, mergeLocalFirst, pickSessionInteractive, matchesLiveStatus, filterSessionsByQuery, formatPickerLabel, pickerColumnsFor, } from './sessions.js';
25
+ import { buildPreview } from './sessions-picker.js';
26
+ import { multiItemPicker } from '../lib/picker.js';
27
+ import { isPromptCancelled } from './utils.js';
25
28
  import { focusAction } from './focus.js';
26
29
  import { machineId } from '../lib/session/sync/config.js';
27
30
  import { attachTmux, runTmux } from '../lib/tmux/binary.js';
31
+ import { paneExitStatus } from '../lib/tmux/session.js';
28
32
  import { getDefaultSocketPath } from '../lib/tmux/paths.js';
29
- import { sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
33
+ import { sshExec, sshStream, assertValidSshTarget, shellQuote } from '../lib/ssh-exec.js';
30
34
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
31
35
  const execFileAsync = promisify(execFile);
32
36
  export function registerGoCommand(program) {
@@ -40,11 +44,33 @@ export function registerGoCommand(program) {
40
44
  await focusAction(id, { local: opts.local, attachOnly: true });
41
45
  });
42
46
  }
47
+ /**
48
+ * Scope a live-session pool by device and live status. Pure so the `focus`
49
+ * device/status filters are unit-testable without touching the sweep. `hosts`
50
+ * keeps only sessions whose `machine` is in the set (local rows carry `self`,
51
+ * remote rows carry their peer tag); `statuses` reuses `--active`'s exact
52
+ * `matchesLiveStatus` derivation rather than a parallel status table.
53
+ */
54
+ export function filterLivePool(sessions, opts = {}) {
55
+ let out = sessions;
56
+ if (opts.hosts?.length) {
57
+ const set = new Set(opts.hosts);
58
+ out = out.filter((s) => !!s.machine && set.has(s.machine));
59
+ }
60
+ if (opts.statuses?.length) {
61
+ out = out.filter((s) => opts.statuses.some((status) => matchesLiveStatus(s, status)));
62
+ }
63
+ return out;
64
+ }
43
65
  /**
44
66
  * Live jump targets (local + remote), keyed by session id. Cloud is excluded by
45
67
  * default (it has no local pid to attach), but `detach` opts in with
46
68
  * `includeCloud` so it can resolve a cloud id and refuse it with a clear message
47
69
  * instead of a bare "no live session".
70
+ *
71
+ * `hosts` scopes the sweep to named devices — the fan-out only dials them, and the
72
+ * pool is then filtered to `s.machine ∈ hosts` so a stray local row can't leak in.
73
+ * `statuses` narrows to the live-state words `--active` uses (orphan/crashed/…).
48
74
  */
49
75
  export async function gatherLiveTargets(local, opts = {}) {
50
76
  const self = machineId();
@@ -55,11 +81,12 @@ export async function gatherLiveTargets(local, opts = {}) {
55
81
  let active = localActive;
56
82
  if (!local) {
57
83
  try {
58
- const remote = await gatherRemoteActive();
84
+ const remote = await gatherRemoteActive(opts.hosts);
59
85
  active = dedupeByMachineSession([...localActive, ...remote.sessions]);
60
86
  }
61
87
  catch { /* remote sweep is best-effort */ }
62
88
  }
89
+ active = filterLivePool(active, { hosts: opts.hosts, statuses: opts.statuses });
63
90
  const activeById = new Map();
64
91
  for (const s of active) {
65
92
  if (!s.sessionId)
@@ -80,6 +107,41 @@ export async function pickLiveTarget(activeById, self, message, enterHint) {
80
107
  return null;
81
108
  return activeById.get(picked.session.id) ?? null;
82
109
  }
110
+ /**
111
+ * Multi-select over the live sessions' rich SessionMeta (same rows as
112
+ * `pickLiveTarget`, but a checkbox picker) — the plural sibling that lets `focus`
113
+ * open several sessions at once. Mirrors `sessions resume`'s `multiItemPicker`
114
+ * wiring; returns the chosen live sessions in pick order, or `[]` on cancel.
115
+ */
116
+ export async function pickLiveTargets(activeById, self, message) {
117
+ const pool = await buildLivePool(activeById, self);
118
+ if (pool.length === 0)
119
+ return [];
120
+ // gutter: 6 = the multi-select cursor + checkbox ('> [x] ') multiItemPicker prepends.
121
+ const cols = { ...pickerColumnsFor(pool), gutter: 6 };
122
+ let chosen;
123
+ try {
124
+ chosen = await multiItemPicker({
125
+ message,
126
+ items: pool,
127
+ filter: (q) => (q.trim() ? filterSessionsByQuery(pool, q) : pool),
128
+ labelFor: (s, q) => formatPickerLabel(s, q, cols),
129
+ keyFor: (s) => s.id,
130
+ buildPreview,
131
+ pageSize: 15,
132
+ emptyMessage: 'No live sessions match.',
133
+ enterHint: 'focus',
134
+ });
135
+ }
136
+ catch (err) {
137
+ if (isPromptCancelled(err))
138
+ return [];
139
+ throw err;
140
+ }
141
+ if (!chosen)
142
+ return [];
143
+ return chosen.map((m) => activeById.get(m.id)).filter((s) => !!s);
144
+ }
83
145
  /**
84
146
  * Map each live session to its rich SessionMeta (worktree/PR/changes/tools/tests
85
147
  * via the shared picker), reusing `discoverSessions`. Remote or unindexed live
@@ -147,15 +209,48 @@ export function describeWhere(s, self) {
147
209
  return { label: `${s.host ?? 'shell'} on ${remote}`, action: `open a shell on ${remote}` };
148
210
  return { label: s.host ?? 'unknown terminal', action: 'resume it (no live attach rail)' };
149
211
  }
150
- /** Default (attach-only): open a login shell on the remote, or refuse locally. */
212
+ /** Probe the tmux process, not just retained provenance. This is deliberately
213
+ * called immediately before an attach so remain-on-exit panes cannot masquerade
214
+ * as living agent sessions. */
215
+ export async function probeAttachRail(s, self) {
216
+ const mux = s.provenance?.mux;
217
+ if (mux?.kind !== 'tmux' || !mux.pane)
218
+ return { state: 'missing' };
219
+ const remote = s.machine && s.machine !== self ? s.machine : undefined;
220
+ if (!remote) {
221
+ const pane = await paneExitStatus(mux.pane, mux.socket ?? getDefaultSocketPath());
222
+ if (!pane.found)
223
+ return { state: 'missing' };
224
+ return pane.dead ? { state: 'dead', exitStatus: pane.status } : { state: 'alive' };
225
+ }
226
+ assertValidSshTarget(remote);
227
+ const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
228
+ const pane = shellQuote(mux.pane);
229
+ const command = `v=$(tmux ${sock}display-message -pt ${pane} -p '#{pane_dead} #{pane_dead_status}' 2>/dev/null) || { echo missing; exit 0; }; ` +
230
+ `printf '%s\\n' "$v"`;
231
+ const result = sshExec(remote, command, { timeoutMs: 15_000, multiplex: true });
232
+ if (result.code !== 0)
233
+ return { state: 'missing' };
234
+ const value = result.stdout.trim();
235
+ if (value === 'missing' || !value)
236
+ return { state: 'missing' };
237
+ const [dead, rawStatus] = value.split(/\s+/);
238
+ const exitStatus = Number.parseInt(rawStatus ?? '', 10);
239
+ return dead === '1'
240
+ ? { state: 'dead', exitStatus: Number.isFinite(exitStatus) ? exitStatus : undefined }
241
+ : { state: 'alive' };
242
+ }
243
+ /** Strict attach-only fallback: no pane means no attach. Never open a shell or
244
+ * start recovery, because both would violate the caller's no-fork intent. */
151
245
  export async function refuseFallback(s, remote) {
152
246
  if (remote) {
153
- console.log(chalk.yellow(`${shortId(s)} on ${remote} isn't inside tmux opening a shell on ${remote} instead.`));
154
- assertValidSshTarget(remote);
155
- process.exit(sshStream(remote, 'exec "${SHELL:-/bin/sh}" -l', { tty: true }));
247
+ console.log(chalk.yellow(`Can't attach ${shortId(s)} on ${remote} — it has no living tmux pane.`));
248
+ process.exitCode = 1;
249
+ return;
156
250
  }
157
251
  console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
158
252
  chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
253
+ process.exitCode = 1;
159
254
  }
160
255
  export async function jumpTo(s, self, fallback = refuseFallback) {
161
256
  const remote = s.machine && s.machine !== self ? s.machine : undefined;
@@ -163,6 +258,11 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
163
258
  // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
164
259
  if (remote) {
165
260
  if (mux?.kind === 'tmux' && mux.pane) {
261
+ const liveness = await probeAttachRail(s, self);
262
+ if (liveness.state !== 'alive') {
263
+ await fallback(s, remote);
264
+ return;
265
+ }
166
266
  assertValidSshTarget(remote);
167
267
  const sock = mux.socket ? `-S ${shellQuote(mux.socket)} ` : '';
168
268
  const p = shellQuote(mux.pane);
@@ -179,6 +279,11 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
179
279
  }
180
280
  // Path B: local tmux — attach (or switch-client if we're already inside tmux).
181
281
  if (mux?.kind === 'tmux' && mux.pane) {
282
+ const liveness = await probeAttachRail(s, self);
283
+ if (liveness.state !== 'alive') {
284
+ await fallback(s, undefined);
285
+ return;
286
+ }
182
287
  const socket = mux.socket ?? getDefaultSocketPath();
183
288
  const { session, window } = await resolveLocalPane(socket, mux.pane);
184
289
  if (session && window != null) {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared interactive step engine for `agents harness` create + edit.
3
+ *
4
+ * A single engine drives BOTH the create wizard (`agents harness add`/`fork`,
5
+ * previously `runHarnessWizard`) and the new edit wizard (`agents harness edit`,
6
+ * which was flag-only before). A "step" is a self-contained unit — decide whether
7
+ * it runs for the current draft, then prompt/validate/apply — so the two modes
8
+ * differ only in their step list, not in the runner.
9
+ *
10
+ * Design goals (RUSH-2219, parent RUSH-2218):
11
+ * - One runner, two modes. `create` builds a new harness from a source; `edit`
12
+ * loads an existing profile and re-asks each field pre-filled with its value.
13
+ * - Every step is skippable. A flag that already supplied the value pre-fills
14
+ * the draft and its step is not re-asked — so non-interactive scripting via
15
+ * flags is unchanged and the wizard only asks for what is missing.
16
+ * - Pure and injectable. The engine talks to the user through {@link WizardIO},
17
+ * an injected seam. Tests drive a scripted fake IO and assert which steps ran,
18
+ * with no TTY. {@link defaultWizardIO} is the production driver over
19
+ * `@inquirer/prompts`.
20
+ * - Typed extension points, not stubs. The three sibling subtasks plug in via
21
+ * {@link WizardHooks} without editing the engine: RUSH-2220 (model catalog +
22
+ * secrets surface) via `pickModel`, RUSH-2221 (connection test) via
23
+ * `connectionTest`, RUSH-2222 (edit matrix) via `editable`. Each hook is a
24
+ * real no-op extension point — absent, the scaffold falls back to today's
25
+ * behavior (free-text model, no test, resolver-sourced editability). None of
26
+ * them fakes a result. RUSH-2223 (cross-host portability, `fork --to-host`)
27
+ * extends the create source step; the seam is `HarnessDraft.toHost`.
28
+ */
29
+ import type { AgentId } from '../lib/types.js';
30
+ import { type Profile } from '../lib/profiles.js';
31
+ /** Whether the wizard is creating a new harness or editing an existing one. */
32
+ export type WizardMode = 'create' | 'edit';
33
+ /** A single `select` choice. `disabled` greys the row (used by the edit matrix). */
34
+ export interface WizardChoice<T> {
35
+ name: string;
36
+ value: T;
37
+ disabled?: boolean | string;
38
+ }
39
+ /**
40
+ * The prompt seam the engine drives. Injected so the engine is testable with no
41
+ * TTY: the production implementation ({@link defaultWizardIO}) wraps
42
+ * `@inquirer/prompts`; a test passes a scripted fake that records calls and
43
+ * returns canned answers.
44
+ */
45
+ export interface WizardIO {
46
+ select<T>(opts: {
47
+ message: string;
48
+ choices: WizardChoice<T>[];
49
+ default?: T;
50
+ }): Promise<T>;
51
+ input(opts: {
52
+ message: string;
53
+ default?: string;
54
+ validate?: (v: string) => true | string;
55
+ }): Promise<string>;
56
+ password(opts: {
57
+ message: string;
58
+ }): Promise<string>;
59
+ confirm(opts: {
60
+ message: string;
61
+ default?: boolean;
62
+ }): Promise<boolean>;
63
+ /** Emit an informational line — a disabled field's reason, or a preset note. */
64
+ note(message: string): void;
65
+ }
66
+ /**
67
+ * The mutable draft threaded through every step. It is a superset of both modes'
68
+ * fields; a step reads what it needs and records its answer here. `create` maps
69
+ * the finished draft into `{ source, name, opts }` for `runForkFlow`; `edit` maps
70
+ * it into the `EditOptions` shape the flag-driven edit path already persists — so
71
+ * a wizard-built and a hand-written harness are byte-identical after save.
72
+ */
73
+ export interface HarnessDraft {
74
+ readonly mode: WizardMode;
75
+ /** create: the fork source (a native agent id or an existing harness name). */
76
+ source?: string;
77
+ /** The resolved host CLI — drives the editability seams. edit: the profile host. */
78
+ host?: AgentId;
79
+ name?: string;
80
+ model?: string;
81
+ baseUrl?: string;
82
+ authProvider?: string;
83
+ /** `<bundle>` or `<bundle>:<key>` — a value copied out of an agents secrets bundle. */
84
+ fromSecrets?: string;
85
+ version?: string;
86
+ description?: string;
87
+ fallbackModel?: string;
88
+ /** edit: the profile being edited, providing current values. Read-only. */
89
+ readonly original?: Profile;
90
+ /**
91
+ * RUSH-2223 seam (cross-host portability): the target host to re-key a cloned
92
+ * harness onto (`fork --to-host <host>`). The scaffold never sets it; the
93
+ * portability subtask reads it in an extended source step. Left here so the
94
+ * draft shape is stable when that lands.
95
+ */
96
+ toHost?: AgentId;
97
+ /** The user chose "build custom" over a preset (create only). */
98
+ custom?: boolean;
99
+ /** The preset a create run selected, if any. */
100
+ preset?: string;
101
+ /** Pre-computed default for the name prompt (source or preset name). */
102
+ defaultName?: string;
103
+ /** The provider step has run (distinguishes "no auth chosen" from "not asked"). */
104
+ providerAsked?: boolean;
105
+ }
106
+ /**
107
+ * What the engine does with a step for a given draft:
108
+ * - `'run'` prompt the user, validate, and apply into the draft.
109
+ * - `'skip'` value already supplied (by a flag) or step N/A — silent.
110
+ * - `{ disabled: … }` the step does not apply to this host; surface the reason
111
+ * (the field reads as greyed/disabled) but do not prompt.
112
+ * This is the seam the edit matrix (RUSH-2222) drives.
113
+ */
114
+ export type StepDecision = 'run' | 'skip' | {
115
+ disabled: string;
116
+ };
117
+ /** One wizard step: decide, then (only when decided `'run'`) prompt + apply. */
118
+ export interface WizardStep {
119
+ readonly id: string;
120
+ decide(draft: HarnessDraft): StepDecision;
121
+ run(io: WizardIO, draft: HarnessDraft, hooks: WizardHooks): Promise<void>;
122
+ }
123
+ /** Per-host editability — which params this host's API format lets you change. */
124
+ export interface HarnessEditable {
125
+ model: boolean;
126
+ baseUrl: boolean;
127
+ auth: boolean;
128
+ version: boolean;
129
+ fallback: boolean;
130
+ }
131
+ /**
132
+ * Resolver-sourced editability, the scaffold default behind the RUSH-2222 seam.
133
+ * Every field is read from the same maps the run-time resolver uses
134
+ * (`baseUrlEnvKeyForHost` / `authEnvKeyForHost` / `isSelfUpdatingAgent`), never a
135
+ * table hardcoded alongside them — so the wizard's enable/disable can never drift
136
+ * from what a run actually honors (repo rule: the capability table stays truthful,
137
+ * in lockstep with the code). RUSH-2222 may replace this via {@link WizardHooks.editable}
138
+ * to add per-param reasons; it must stay sourced from the resolver.
139
+ */
140
+ export declare function defaultEditable(host: AgentId): HarnessEditable;
141
+ /** Outcome of a connection test (RUSH-2221 fills the real classifier). */
142
+ export interface ConnectionTestResult {
143
+ ok: boolean;
144
+ /** Machine-readable class when it failed (auth / endpoint / model / unknown). */
145
+ reason?: 'auth' | 'endpoint' | 'model' | 'unknown';
146
+ message?: string;
147
+ }
148
+ /**
149
+ * Extension points the sibling subtasks fill without touching the engine. Each is
150
+ * a real no-op-by-default seam: absent, the scaffold uses today's behavior; none
151
+ * fabricates a result.
152
+ */
153
+ export interface WizardHooks {
154
+ /**
155
+ * RUSH-2220 — model catalog pick. Given the resolved host (+ version and the
156
+ * current value in edit), return a chosen model id, or `null` to fall through
157
+ * to the free-text prompt. Absent → always free-text (today's behavior).
158
+ */
159
+ pickModel?: (io: WizardIO, host: AgentId | undefined, version: string | undefined, current: string | undefined) => Promise<string | null>;
160
+ /**
161
+ * RUSH-2221 — connection test after configure, before save. Absent → no test
162
+ * (the wizard saves without one, exactly as today).
163
+ */
164
+ connectionTest?: (draft: HarnessDraft) => Promise<ConnectionTestResult>;
165
+ /**
166
+ * RUSH-2222 — per-host editability matrix. Absent → {@link defaultEditable}.
167
+ */
168
+ editable?: (host: AgentId) => HarnessEditable;
169
+ }
170
+ /** Resolve the host CLI a fork `source` runs under (the host `buildFork` will use). */
171
+ export declare function hostForSource(source: string | undefined): AgentId | undefined;
172
+ /**
173
+ * The engine. Walk the steps in order; for each, ask `decide` what to do, then
174
+ * prompt only when it says `'run'`. A `{ disabled }` decision surfaces the reason
175
+ * and moves on; `'skip'` is silent. Returns the finished draft.
176
+ */
177
+ export declare function runWizardSteps(steps: WizardStep[], draft: HarnessDraft, io: WizardIO, hooks?: WizardHooks): Promise<HarnessDraft>;
178
+ /**
179
+ * The create step list — the shape of `agents harness add`/`fork`. Faithful to
180
+ * the previous `runHarnessWizard` sequence (source → preset|custom → model →
181
+ * provider → base URL → name → key source), re-expressed as engine steps so the
182
+ * sibling subtasks can gate/replace individual steps. Produces the same
183
+ * `{ source, name, opts }` draft the fork flow already persists.
184
+ */
185
+ export declare function createSteps(): WizardStep[];
186
+ /**
187
+ * The edit step list — `agents harness edit <name>` with no flags on a TTY. Each
188
+ * step is pre-filled with the profile's current value and gated by the host's
189
+ * editability matrix (RUSH-2222 seam): an unsupported param reads as disabled with
190
+ * a reason instead of being silently accepted. Records into the same draft, which
191
+ * `runEditWizard` maps to the `EditOptions` shape the flag path already persists.
192
+ */
193
+ export declare function editSteps(original: Profile): WizardStep[];
194
+ /**
195
+ * Run the connection-test hook against a finished draft, if one is wired
196
+ * (RUSH-2221). Returns `null` when no hook is present (no test performed) so the
197
+ * caller can distinguish "not tested" from "tested and passed". Separated from the
198
+ * step list because the test needs the assembled profile, which the caller builds.
199
+ */
200
+ export declare function runConnectionTest(draft: HarnessDraft, hooks: WizardHooks): Promise<ConnectionTestResult | null>;
201
+ /**
202
+ * Production {@link WizardIO} over `@inquirer/prompts`, lazy-imported so the
203
+ * dependency loads only when a wizard actually runs. `note` prints to stderr so it
204
+ * never contaminates a `--json`/piped stdout.
205
+ */
206
+ export declare function defaultWizardIO(): Promise<WizardIO>;