@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Spawn helpers for CAPABILITY PROBES — short-lived invocations of third-party
3
+ * binaries (`copilot --version`, a manifest's `check:` command) whose only job
4
+ * is an exit status or a line of stdout.
5
+ *
6
+ * A probed binary may fork children of its own: the copilot npm wrapper forks
7
+ * a platform-binary downloader into `~/Library/Caches/copilot` on first run
8
+ * under a fresh HOME. Node's `timeout:` option kills only the DIRECT child, so
9
+ * such grandchildren outlive the probe and keep writing — under a test's temp
10
+ * HOME that race is the ENOTEMPTY teardown class (RUSH-3028; residual after
11
+ * RUSH-3021 gated the daemon autostart). Every probe here therefore runs in
12
+ * its OWN process group (`detached`), and the whole group is reaped once the
13
+ * probe settles, so nothing a probe spawned can outlive it.
14
+ *
15
+ * Group semantics are POSIX only: on win32 `detached` means a new console and
16
+ * negative-pid group kills are unsupported, so there the DIRECT child is
17
+ * killed on settle instead — the same guarantee `execFileAsync`'s `timeout:`
18
+ * gave (the grandchild leak class is a darwin/linux temp-HOME teardown race).
19
+ *
20
+ * The parent dying mid-probe is covered too: a detached probe leaves the
21
+ * terminal's foreground group, so a Ctrl-C that hard-exits the CLI
22
+ * (`process.exit(130)` in index.ts) would strand it. Live probe groups are
23
+ * tracked in LIVE_GROUPS and a `process.on('exit')` hook — which runs on
24
+ * every `process.exit` path, including that SIGINT handler — reaps them
25
+ * synchronously.
26
+ */
27
+ import { spawn } from 'child_process';
28
+ const GROUP_REAP = process.platform !== 'win32';
29
+ const LIVE_GROUPS = new Set();
30
+ let exitHookInstalled = false;
31
+ function ensureExitHook() {
32
+ if (exitHookInstalled)
33
+ return;
34
+ exitHookInstalled = true;
35
+ process.on('exit', () => {
36
+ for (const pid of LIVE_GROUPS) {
37
+ try {
38
+ process.kill(-pid, 'SIGKILL');
39
+ }
40
+ catch {
41
+ /* group already fully exited */
42
+ }
43
+ }
44
+ });
45
+ }
46
+ function reapGroup(pid) {
47
+ if (!GROUP_REAP || !pid)
48
+ return;
49
+ LIVE_GROUPS.delete(pid);
50
+ try {
51
+ process.kill(-pid, 'SIGKILL');
52
+ }
53
+ catch {
54
+ /* group already fully exited */
55
+ }
56
+ }
57
+ /**
58
+ * Async probe capturing stdout. Rejects on spawn error, non-zero exit, or
59
+ * timeout — matching the `execFileAsync` contract the version probe had — and
60
+ * reaps the probe's whole process group on every settle path.
61
+ */
62
+ export function probeCapture(cmd, args, timeoutMs) {
63
+ return new Promise((resolve, reject) => {
64
+ const child = spawn(cmd, args, {
65
+ detached: GROUP_REAP,
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ windowsHide: true,
68
+ });
69
+ if (GROUP_REAP && child.pid) {
70
+ LIVE_GROUPS.add(child.pid);
71
+ ensureExitHook();
72
+ }
73
+ let out = '';
74
+ let settled = false;
75
+ const settle = (err) => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ clearTimeout(timer);
80
+ reapGroup(child.pid);
81
+ // win32 has no group to reap: kill the direct child so a timed-out
82
+ // probe still dies, matching execFile's `timeout:` behavior. No-op
83
+ // after a clean exit.
84
+ if (!GROUP_REAP)
85
+ child.kill('SIGKILL');
86
+ if (err)
87
+ reject(err);
88
+ else
89
+ resolve({ stdout: out });
90
+ };
91
+ const timer = setTimeout(() => settle(new Error(`probe timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`)), timeoutMs);
92
+ child.stdout?.setEncoding('utf8');
93
+ child.stdout?.on('data', (d) => {
94
+ out += d;
95
+ });
96
+ child.on('error', (e) => settle(e));
97
+ // 'exit', not 'close': a forked grandchild inherits the stdout pipe, and
98
+ // 'close' waits for EVERY holder of that pipe to exit — exactly the
99
+ // process this helper exists to reap. Settle when the probed binary
100
+ // itself exits; one tick's grace lets its final stdout chunks land.
101
+ child.on('exit', (code) => {
102
+ setImmediate(() => settle(code === 0 ? null : new Error(`probe exited ${code}: ${cmd} ${args.join(' ')}`)));
103
+ });
104
+ });
105
+ }
@@ -15,7 +15,7 @@ export interface RefreshLeaseOptions<T> {
15
15
  * Serialize refresh work across every agents-cli process on this device.
16
16
  *
17
17
  * In-process promise maps only protect one Node process. Factory, the daemon,
18
- * `agents view`, and `agents usage` are separate processes, so they need an
18
+ * and `agents view` are separate processes, so they need an
19
19
  * OS-visible lease. The result is re-read after lock acquisition: a waiter
20
20
  * consumes the winner's publication and never calls the provider a second
21
21
  * time. `proper-lockfile` supplies heartbeat + stale-owner recovery while an
@@ -18,7 +18,7 @@ function lockTarget(scope, key) {
18
18
  * Serialize refresh work across every agents-cli process on this device.
19
19
  *
20
20
  * In-process promise maps only protect one Node process. Factory, the daemon,
21
- * `agents view`, and `agents usage` are separate processes, so they need an
21
+ * and `agents view` are separate processes, so they need an
22
22
  * OS-visible lease. The result is re-read after lock acquisition: a waiter
23
23
  * consumes the winner's publication and never calls the provider a second
24
24
  * time. `proper-lockfile` supplies heartbeat + stale-owner recovery while an
@@ -8,6 +8,8 @@
8
8
  <string>Agents CLI</string>
9
9
  <key>CFBundleDisplayName</key>
10
10
  <string>Agents CLI</string>
11
+ <key>CFBundleIconFile</key>
12
+ <string>AppIcon</string>
11
13
  <key>CFBundleExecutable</key>
12
14
  <string>Agents CLI</string>
13
15
  <key>CFBundlePackageType</key>
@@ -3,9 +3,21 @@
3
3
  <plist version="1.0">
4
4
  <dict>
5
5
  <key>files</key>
6
- <dict/>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ DFq5H08EkhgWIC3UvGMR9B58BZw=
10
+ </data>
11
+ </dict>
7
12
  <key>files2</key>
8
13
  <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ mBSjM6jlvN7J1jQowsvqTl7CHM0pur0e6qsHMuzk5k0=
19
+ </data>
20
+ </dict>
9
21
  <key>embedded.provisionprofile</key>
10
22
  <dict>
11
23
  <key>hash2</key>
@@ -1,6 +1,7 @@
1
1
  import type { CloudTaskStatus } from '../cloud/types.js';
2
2
  import { type PidSessionEntry } from './pid-registry.js';
3
3
  import { type HookSessionIndex } from './hook-sessions.js';
4
+ import { type UserPromptKind } from './prompt.js';
4
5
  import { type SessionState, type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
5
6
  import { type SessionAgentId, type SessionAttachment, type SessionMeta } from './types.js';
6
7
  import { type SessionProvenance } from './provenance.js';
@@ -99,6 +100,15 @@ export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required' | 'c
99
100
  | 'orphaned'
100
101
  /** The host window died and took the agent with it — an unclean exit, not a normal close. */
101
102
  | 'crashed' | 'unknown';
103
+ /**
104
+ * Which rung of the recap ladder produced a row's shown {@link ActiveSession.title}
105
+ * (RUSH-3011), best-first:
106
+ * - `label` — a `/rename` or harness-set label (incl. an agent-generated
107
+ * title, which lands in `label`); always wins.
108
+ * - `last` — the last assistant line from the transcript tail; agent-derived.
109
+ * - `prompt` — the first-user-prompt topic; the last-resort fallback.
110
+ */
111
+ export type RecapSource = 'label' | 'last' | 'prompt';
102
112
  export interface ActiveSession {
103
113
  context: ActiveContext;
104
114
  kind: string;
@@ -115,6 +125,30 @@ export interface ActiveSession {
115
125
  name?: string;
116
126
  /** First meaningful line of the initial prompt (extracted topic). */
117
127
  topic?: string;
128
+ /**
129
+ * The row's shown title — WHAT the session is, best-source-wins (RUSH-3011).
130
+ * The ladder ({@link deriveSessionRecap}): a `/rename` or harness `label` →
131
+ * the last agent line (`lastAgentLine`) → the first-prompt `topic`. A session
132
+ * whose agent did work shows an agent-derived line, not the stale first
133
+ * prompt. Folded on at the end of {@link getActiveSessions}; `recapSource`
134
+ * names which rung produced it.
135
+ */
136
+ title?: string;
137
+ /** Which ladder rung produced {@link title}. */
138
+ recapSource?: RecapSource;
139
+ /**
140
+ * The first user turn cleaned for a "You" line — a screenshot path folds to
141
+ * `[image]`, a pasted `$ cmd` to the command, a `/skill` install path to
142
+ * `/<name>`, so path noise never shows. See {@link classifyUserPrompt}.
143
+ */
144
+ userPromptClean?: string;
145
+ /** What kind of first turn {@link userPromptClean} was. */
146
+ userPromptKind?: UserPromptKind;
147
+ /**
148
+ * The most recent assistant line (from the transcript tail) — the free,
149
+ * always-current signal of what the agent last said/did. Ladder rung 3.
150
+ */
151
+ lastAgentLine?: string;
118
152
  /** Live preview: the latest turn (agent message or tool action), from the state engine. */
119
153
  preview?: string;
120
154
  /** Inferred activity: working / waiting_input / idle (from the transcript tail). */
@@ -787,6 +821,39 @@ export declare function sessionProcessIsLocal(s: Pick<ActiveSession, 'machine' |
787
821
  */
788
822
  export declare function sessionProcessHost(s: Pick<ActiveSession, 'machine' | 'offloadedFrom'>, self: string): string | undefined;
789
823
  export declare function foldHostLink(rows: ActiveSession[]): void;
824
+ /**
825
+ * The recap ladder (RUSH-3011): compute a row's shown {@link ActiveSession.title}
826
+ * + {@link RecapSource} from the best available source, plus the cleaned first
827
+ * prompt (`userPromptClean`/`userPromptKind`) and the `lastAgentLine`. Pure over
828
+ * one row; exported for tests and folded in by {@link foldRecap}.
829
+ *
830
+ * Ladder, best-first: a `/rename`/harness `label` → the last assistant line →
831
+ * the first-prompt topic. The `last` rung is agent-derived, so a session that
832
+ * produced work stops showing its stale first prompt as the title. (`topic` is
833
+ * the row's already-extracted first line, so image detection here is
834
+ * path-based; a pure-attachment turn with no first-line text stays on `prompt`.)
835
+ */
836
+ export declare function deriveSessionRecap(row: Pick<ActiveSession, 'label' | 'topic' | 'tail'>): {
837
+ title?: string;
838
+ recapSource?: RecapSource;
839
+ userPromptClean?: string;
840
+ userPromptKind?: UserPromptKind;
841
+ lastAgentLine?: string;
842
+ };
843
+ /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
844
+ export declare function foldRecap(rows: ActiveSession[]): void;
845
+ /**
846
+ * True when a crash-leaked orphan is genuinely DEAD and should be reaped from the
847
+ * reconnectable set rather than shown as resumable forever (RUSH-3011 / issue #3b).
848
+ *
849
+ * The gate is `abandoned` (no transcript write in {@link ABANDONED_STALE_MS}) AND
850
+ * a dead pid — exactly "past the stale threshold whose pid is gone". A live pid
851
+ * (an idle-but-unfinished session, the highest-risk state) is NEVER reaped, and
852
+ * neither is a recently-`closed`/`crashed` session that just exited (still
853
+ * resumable). `pidAlive` absent (a cloud row or an older peer that can't prove
854
+ * death) also stays un-reaped — reaping is fail-safe, never a guess.
855
+ */
856
+ export declare function isReapableOrphan(row: Pick<ActiveSession, 'status' | 'pidAlive'>): boolean;
790
857
  /**
791
858
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
792
859
  * when that orchestrator session is itself in the active set (it usually is — the
@@ -30,7 +30,7 @@ import { loadHookSessionIndex, resolveHookSessionRecord, readStateSessionRecord
30
30
  import { buildClaudeLabelMap, getAgentSessionDirs } from './discover.js';
31
31
  import { buildRunNameMap } from './run-names.js';
32
32
  import { latestSessionFileForCwd, findSessionsByShortIds, findSessionMachinesByIds, getSessionById } from './db.js';
33
- import { extractSessionTopic } from './prompt.js';
33
+ import { extractSessionTopic, classifyUserPrompt } from './prompt.js';
34
34
  import { readSessionTailWithRaw } from './tail.js';
35
35
  import { parseSession } from './parse.js';
36
36
  import { computeTokPerSec } from './throughput.js';
@@ -1831,6 +1831,9 @@ export async function getActiveSessions(opts = {}) {
1831
1831
  // the EXECUTION host rather than the dispatcher (RUSH-2479).
1832
1832
  foldExecutionMachine(merged, recordedMachineLookup(merged), machineId());
1833
1833
  annotateOrchestratorLabels(merged);
1834
+ // Last: derive the shown title (recap ladder) from label/tail/topic now that
1835
+ // every row's live tail + label are resolved (RUSH-3011).
1836
+ foldRecap(merged);
1834
1837
  return merged;
1835
1838
  }
1836
1839
  /**
@@ -2074,6 +2077,71 @@ export function foldHostLink(rows) {
2074
2077
  }
2075
2078
  }
2076
2079
  }
2080
+ /** One display line, trimmed and capped, or undefined when empty. */
2081
+ function recapLine(s, max = 120) {
2082
+ const t = s?.replace(/\s+/g, ' ').trim();
2083
+ if (!t)
2084
+ return undefined;
2085
+ return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
2086
+ }
2087
+ /**
2088
+ * The recap ladder (RUSH-3011): compute a row's shown {@link ActiveSession.title}
2089
+ * + {@link RecapSource} from the best available source, plus the cleaned first
2090
+ * prompt (`userPromptClean`/`userPromptKind`) and the `lastAgentLine`. Pure over
2091
+ * one row; exported for tests and folded in by {@link foldRecap}.
2092
+ *
2093
+ * Ladder, best-first: a `/rename`/harness `label` → the last assistant line →
2094
+ * the first-prompt topic. The `last` rung is agent-derived, so a session that
2095
+ * produced work stops showing its stale first prompt as the title. (`topic` is
2096
+ * the row's already-extracted first line, so image detection here is
2097
+ * path-based; a pure-attachment turn with no first-line text stays on `prompt`.)
2098
+ */
2099
+ export function deriveSessionRecap(row) {
2100
+ const lastAgentLine = recapLine(row.tail?.length ? row.tail[row.tail.length - 1] : undefined);
2101
+ const { clean: userPromptClean, kind: userPromptKind } = classifyUserPrompt(row.topic ?? '');
2102
+ const label = recapLine(row.label);
2103
+ const prompt = recapLine(userPromptClean || row.topic);
2104
+ let title;
2105
+ let recapSource;
2106
+ if (label) {
2107
+ title = label;
2108
+ recapSource = 'label';
2109
+ }
2110
+ else if (lastAgentLine) {
2111
+ title = lastAgentLine;
2112
+ recapSource = 'last';
2113
+ }
2114
+ else if (prompt) {
2115
+ title = prompt;
2116
+ recapSource = 'prompt';
2117
+ }
2118
+ return { title, recapSource, userPromptClean: recapLine(userPromptClean), userPromptKind, lastAgentLine };
2119
+ }
2120
+ /** Fold the recap ladder onto every row (see {@link deriveSessionRecap}). */
2121
+ export function foldRecap(rows) {
2122
+ for (const s of rows) {
2123
+ const recap = deriveSessionRecap(s);
2124
+ s.title = recap.title;
2125
+ s.recapSource = recap.recapSource;
2126
+ s.userPromptClean = recap.userPromptClean;
2127
+ s.userPromptKind = recap.userPromptKind;
2128
+ s.lastAgentLine = recap.lastAgentLine;
2129
+ }
2130
+ }
2131
+ /**
2132
+ * True when a crash-leaked orphan is genuinely DEAD and should be reaped from the
2133
+ * reconnectable set rather than shown as resumable forever (RUSH-3011 / issue #3b).
2134
+ *
2135
+ * The gate is `abandoned` (no transcript write in {@link ABANDONED_STALE_MS}) AND
2136
+ * a dead pid — exactly "past the stale threshold whose pid is gone". A live pid
2137
+ * (an idle-but-unfinished session, the highest-risk state) is NEVER reaped, and
2138
+ * neither is a recently-`closed`/`crashed` session that just exited (still
2139
+ * resumable). `pidAlive` absent (a cloud row or an older peer that can't prove
2140
+ * death) also stays un-reaped — reaping is fail-safe, never a guess.
2141
+ */
2142
+ export function isReapableOrphan(row) {
2143
+ return row.status === 'abandoned' && row.pidAlive === false;
2144
+ }
2077
2145
  /**
2078
2146
  * Resolve each teams row's `orchestratorLabel` from the orchestrator's own row,
2079
2147
  * when that orchestrator session is itself in the active set (it usually is — the
@@ -2112,8 +2112,10 @@ export function syncLabels(labelMap) {
2112
2112
  .prepare(`SELECT id, label FROM sessions WHERE id IN (${placeholders})`)
2113
2113
  .all(...chunk);
2114
2114
  for (const row of rows) {
2115
- const live = labelMap.get(row.id) ?? null;
2116
- if ((live ?? '') !== (row.label ?? '')) {
2115
+ const live = labelMap.get(row.id)?.trim() || null;
2116
+ // A missing/empty live label means "no refinement yet", not "erase the
2117
+ // generated title or launch handle already stored for this session".
2118
+ if (live && live !== (row.label ?? '')) {
2117
2119
  updates.push({ id: row.id, label: live });
2118
2120
  }
2119
2121
  }
@@ -78,6 +78,8 @@ interface ClaudeSessionScan {
78
78
  version?: string;
79
79
  model?: string;
80
80
  topic?: string;
81
+ /** Harness-owned session name (`/rename` or Claude's generated `ai-title`). */
82
+ label?: string;
81
83
  messageCount: number;
82
84
  tokenCount?: number;
83
85
  /** Real generated (output) tokens, excluding cache-read/-write context. */
@@ -1215,7 +1215,9 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1215
1215
  accountKey: acct.key,
1216
1216
  accountOrg: acct.orgName ?? undefined,
1217
1217
  topic: scan.topic,
1218
- label,
1218
+ // The live sessions metadata file contains only user `/rename` values;
1219
+ // prefer it when present, then fall back to the title parsed from JSONL.
1220
+ label: label || scan.label,
1219
1221
  messageCount: scan.messageCount,
1220
1222
  toolCallCount: scan.toolCallCount,
1221
1223
  tokenCount: scan.tokenCount,
@@ -1253,7 +1255,9 @@ async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, label) {
1253
1255
  accountKey: acct.key,
1254
1256
  accountOrg: acct.orgName ?? undefined,
1255
1257
  model: scan.model,
1256
- label,
1258
+ // Keep parsed ai-title/custom-title values even when the transcript has
1259
+ // no timestamp and therefore takes this stat-backed fallback path.
1260
+ label: label || scan.label,
1257
1261
  messageCount: scan.messageCount,
1258
1262
  toolCallCount: scan.toolCallCount,
1259
1263
  tokenCount: scan.tokenCount,
@@ -3247,9 +3251,10 @@ export function finalizeClaudeScan(state) {
3247
3251
  const durationMs = state.firstTsMs !== undefined && state.lastTsMs !== undefined && state.lastTsMs > state.firstTsMs
3248
3252
  ? state.lastTsMs - state.firstTsMs
3249
3253
  : undefined;
3250
- // Prefer an explicit session title (user `/rename` > Claude auto-title) over
3251
- // the first-prompt topic.
3252
- const resolvedTopic = state.customTitle || state.aiTitle || state.topic;
3254
+ // A topic is the first meaningful prompt. Harness-owned names travel in the
3255
+ // separate label field so consumers can replace an early topic once Claude's
3256
+ // generated title (or a later `/rename`) arrives.
3257
+ const label = state.customTitle || state.aiTitle;
3253
3258
  const worktree = detectWorktree(state.cwd, state.gitBranch);
3254
3259
  const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
3255
3260
  return {
@@ -3258,7 +3263,8 @@ export function finalizeClaudeScan(state) {
3258
3263
  gitBranch: state.gitBranch,
3259
3264
  version: state.version,
3260
3265
  model: state.model,
3261
- topic: resolvedTopic,
3266
+ topic: state.topic,
3267
+ label,
3262
3268
  entrypoint: state.entrypoint,
3263
3269
  messageCount: state.messageCount,
3264
3270
  toolCallCount: state.toolCallCount,
@@ -34,5 +34,40 @@ export declare function extractSlashCommandFromToolInput(toolInput: Record<strin
34
34
  export declare const HEADLESS_PLAN_MODE_PREFIX = "You are running in HEADLESS PLAN MODE.";
35
35
  /** Strip framework noise from a raw session prompt, returning only meaningful user text. */
36
36
  export declare function cleanSessionPrompt(raw: string): string;
37
+ /**
38
+ * How a session's first user turn reads once its framework noise is stripped —
39
+ * so a UI row can show `[image]` / `$ cmd` / `/skill` instead of a screenshot
40
+ * path, a pasted shell line, or a skill's install directory.
41
+ */
42
+ export type UserPromptKind = 'text' | 'image' | 'command' | 'skill';
43
+ export interface ClassifiedPrompt {
44
+ /**
45
+ * The one-line, display-ready form of the prompt — never a bare path. NOT
46
+ * length-capped: the recap card shows it in full; row consumers cap it
47
+ * themselves (`deriveSessionRecap`).
48
+ */
49
+ clean: string;
50
+ kind: UserPromptKind;
51
+ }
52
+ /**
53
+ * Classify a raw first user turn into a display-ready `{ clean, kind }`, so a
54
+ * Fleet/session row shows the user's actual intent rather than path noise:
55
+ *
56
+ * - `skill` — a `/skill` invocation's injected "Base directory for this
57
+ * skill: …" line collapses to `/<name>`.
58
+ * - `command` — a `$ `-prefixed shell paste keeps only the first command.
59
+ * - `image` — a screenshot/image path standing alone, or an image
60
+ * attachment with no real accompanying text, becomes `[image]`.
61
+ * - `text` — everything else: wrapper tags stripped (via
62
+ * {@link cleanSessionPrompt}) and any inline image path replaced
63
+ * with `[image]` so a mixed prompt still drops the path.
64
+ *
65
+ * Pure and cross-harness (text markers, no per-agent branch). `hasImageAttachment`
66
+ * lets a caller that only has an already-extracted topic line still detect an
67
+ * image-only turn from the row's attachment list.
68
+ */
69
+ export declare function classifyUserPrompt(raw: string | undefined, opts?: {
70
+ hasImageAttachment?: boolean;
71
+ }): ClassifiedPrompt;
37
72
  /** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
38
73
  export declare function extractSessionTopic(raw: string): string | undefined;
@@ -145,6 +145,62 @@ export function cleanSessionPrompt(raw) {
145
145
  .filter(line => !NOISE_LINE_PATTERNS.some(pattern => pattern.test(line)));
146
146
  return meaningful.join('\n').trim();
147
147
  }
148
+ /**
149
+ * A skill invocation injects a system line naming the skill's install directory:
150
+ * "Base directory for this skill: /home/.../skills/<name>"
151
+ * which collapses to "/<name>" so a row shows the skill, not the path. Anchored
152
+ * to that exact injected line — a bare `.../skills/<name>` mention in ordinary
153
+ * prose is NOT a skill invocation and must keep its real text.
154
+ */
155
+ const SKILL_BASEDIR_RE = /Base directory for this skill:\s*\S*[\/\\]skills[\/\\]([\w.-]+)/i;
156
+ /**
157
+ * An explicit shell-prompt paste (`$ cmd`). Only the command survives. Deliberately
158
+ * `$`-only: a leading `>` is a markdown blockquote far more often than a shell
159
+ * prompt, so matching it misread quoted prose as a command.
160
+ */
161
+ const COMMAND_PREFIX_RE = /^\$\s+(\S.*)$/;
162
+ /**
163
+ * A filesystem path (absolute or `~`-rooted) ending in an image extension —
164
+ * matched even across the spaces a CleanShot filename carries. Used both to
165
+ * detect an image-only prompt and to drop the path from a mixed one.
166
+ */
167
+ const IMAGE_PATH_RE = /[\/~][^\n]*?\.(?:png|jpe?g|gif|webp|heic|bmp|svg)\b/i;
168
+ /**
169
+ * Classify a raw first user turn into a display-ready `{ clean, kind }`, so a
170
+ * Fleet/session row shows the user's actual intent rather than path noise:
171
+ *
172
+ * - `skill` — a `/skill` invocation's injected "Base directory for this
173
+ * skill: …" line collapses to `/<name>`.
174
+ * - `command` — a `$ `-prefixed shell paste keeps only the first command.
175
+ * - `image` — a screenshot/image path standing alone, or an image
176
+ * attachment with no real accompanying text, becomes `[image]`.
177
+ * - `text` — everything else: wrapper tags stripped (via
178
+ * {@link cleanSessionPrompt}) and any inline image path replaced
179
+ * with `[image]` so a mixed prompt still drops the path.
180
+ *
181
+ * Pure and cross-harness (text markers, no per-agent branch). `hasImageAttachment`
182
+ * lets a caller that only has an already-extracted topic line still detect an
183
+ * image-only turn from the row's attachment list.
184
+ */
185
+ export function classifyUserPrompt(raw, opts = {}) {
186
+ const text = (raw ?? '').replace(/\r/g, '').trim();
187
+ const skill = text.match(SKILL_BASEDIR_RE);
188
+ if (skill)
189
+ return { clean: `/${skill[1]}`, kind: 'skill' };
190
+ const firstLine = text.split('\n').map(line => line.trim()).find(Boolean) ?? '';
191
+ const cmd = firstLine.match(COMMAND_PREFIX_RE);
192
+ if (cmd)
193
+ return { clean: `$ ${cmd[1]}`.trim(), kind: 'command' };
194
+ const cleaned = cleanSessionPrompt(text);
195
+ const cleanedFirst = cleaned.split('\n').map(line => line.trim()).find(Boolean) ?? '';
196
+ const imgMatch = firstLine.match(IMAGE_PATH_RE);
197
+ const imageOnly = !!imgMatch && firstLine.replace(imgMatch[0], '').replace(/[^\w]/g, '').length <= 8;
198
+ if (imageOnly || (opts.hasImageAttachment && !cleanedFirst)) {
199
+ return { clean: '[image]', kind: 'image' };
200
+ }
201
+ const display = (cleanedFirst || firstLine).replace(IMAGE_PATH_RE, '[image]').trim();
202
+ return { clean: display, kind: 'text' };
203
+ }
148
204
  /** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
149
205
  export function extractSessionTopic(raw) {
150
206
  if (!raw.trim())
@@ -1,4 +1,4 @@
1
- import type { ActiveSession } from '../active.js';
1
+ import { type ActiveSession } from '../active.js';
2
2
  import { readActiveSessionsCache } from '../session-cache.js';
3
3
  export declare const SESSION_WATCH_VERSION: 1;
4
4
  export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
@@ -8,6 +8,7 @@ import { deviceIdentityArgs, sshTargetFor } from '../../devices/connect.js';
8
8
  import { machineId, normalizeHost } from '../../machine-id.js';
9
9
  import { SSH_OPTS, controlOpts, shellQuote } from '../../ssh-exec.js';
10
10
  import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
11
+ import { isReapableOrphan } from '../active.js';
11
12
  import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, } from '../session-cache.js';
12
13
  export const SESSION_WATCH_VERSION = 1;
13
14
  export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
@@ -18,7 +19,11 @@ export function sessionWatchRowKey(scope, row) {
18
19
  }
19
20
  export function toSessionWatchRow(scope, row) {
20
21
  const rowKey = sessionWatchRowKey(scope, row);
21
- const resumable = Boolean(row.sessionId);
22
+ // A dead, days-stale crash-orphan is folded OUT of the reconnectable set — it
23
+ // is not resumable, so the "Needs reconnecting" list stops ballooning with
24
+ // leaked --device tunnel sessions (RUSH-3011 / issue #3b). A live or
25
+ // recently-exited session stays resumable.
26
+ const resumable = Boolean(row.sessionId) && !isReapableOrphan(row);
22
27
  const viewingIn = row.viewingIn
23
28
  ? [row.viewingIn.app, row.viewingIn.tab ? `tab ${row.viewingIn.tab}` : undefined].filter(Boolean).join(' ')
24
29
  : null;
@@ -9,7 +9,7 @@
9
9
  import chalk from 'chalk';
10
10
  import { truncate } from '../format.js';
11
11
  import { summarizeToolUse } from './parse.js';
12
- import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
12
+ import { cleanSessionPrompt, classifyUserPrompt, extractSessionTopic } from './prompt.js';
13
13
  import { renderMarkdown } from '../markdown.js';
14
14
  import { redactSecrets } from '../redact.js';
15
15
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
@@ -635,8 +635,12 @@ export function renderSummary(events, cwd) {
635
635
  // 1. Prompt
636
636
  if (firstUserMessage) {
637
637
  const cleaned = cleanSessionPrompt(firstUserMessage);
638
- if (cleaned) {
639
- lines.push(chalk.bold('Prompt:') + ' ' + cleaned.split('\n')[0]);
638
+ // Show the classified prompt (a screenshot path folds to `[image]`, a
639
+ // pasted `$ cmd` to the command, a skill install path to `/<name>`) so the
640
+ // recap's first line is intent, never path noise (RUSH-3011).
641
+ const { clean } = classifyUserPrompt(firstUserMessage, { hasImageAttachment: attachments.length > 0 });
642
+ if (clean || cleaned) {
643
+ lines.push(chalk.bold('Prompt:') + ' ' + (clean || cleaned.split('\n')[0]));
640
644
  const secondLine = cleaned.split('\n')[1]?.trim();
641
645
  if (secondLine)
642
646
  lines.push(' ' + secondLine);
@@ -27,7 +27,7 @@ import * as fs from 'fs';
27
27
  import * as path from 'path';
28
28
  import { createMemoryCache } from '../memory-cache.js';
29
29
  import { getCacheDir } from '../state.js';
30
- import { sessionProcessIsLocal } from './active.js';
30
+ import { backfillActiveRowsFromIndex, sessionProcessIsLocal } from './active.js';
31
31
  /** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
32
32
  const SNAPSHOT_FILE = '.active-sessions.json';
33
33
  /** Immutable-field memo file (keyed by sessionId + transcript mtime). */
@@ -376,6 +376,10 @@ export async function loadLocalActiveSessions(opts = {}) {
376
376
  return getActiveSessions({ localOnly: true });
377
377
  });
378
378
  const sessions = await gather();
379
+ // The index owns transcript-derived labels (Claude custom-title / ai-title).
380
+ // Enrich before publishing so the canonical watch snapshot carries the same
381
+ // label as one-shot `sessions --active` output.
382
+ backfillActiveRowsFromIndex(sessions);
379
383
  for (const s of sessions)
380
384
  applyImmutableMemo(s);
381
385
  updateImmutableMemos(sessions, now);
@@ -0,0 +1,47 @@
1
+ import type { SessionMeta } from './types.js';
2
+ import type { SessionTrajectory, TrajectoryStep } from './trajectory.js';
3
+ /** Where two sessions' tool sequences first stop lining up. */
4
+ export interface TrajectoryDivergence {
5
+ /** Ordinal of the last step both sessions still agree on (0 if none). */
6
+ afterOrdinalA: number;
7
+ afterOrdinalB: number;
8
+ /** ms into each session's own timeline where the divergence begins. */
9
+ startMsA: number;
10
+ startMsB: number;
11
+ /** One-line description of what differs — the next tool step on each side. */
12
+ detail: string;
13
+ }
14
+ /** Aggregate numbers for one side of a compare — the summary table row. */
15
+ export interface TrajectorySummary {
16
+ session: SessionMeta;
17
+ toolCount: number;
18
+ errorCount: number;
19
+ spanMs: number;
20
+ outputTokens: number;
21
+ }
22
+ /** The result of diffing two trajectories. */
23
+ export interface TrajectoryComparison {
24
+ a: SessionTrajectory;
25
+ b: SessionTrajectory;
26
+ /** Undefined only when the tool sequences are identical (or one/both are empty and equal). */
27
+ divergence?: TrajectoryDivergence;
28
+ /** Tool steps present in `b` with no counterpart in `a`, in `b`'s order. */
29
+ added: TrajectoryStep[];
30
+ /** Tool steps present in `a` with no counterpart in `b`, in `a`'s order. */
31
+ removed: TrajectoryStep[];
32
+ summaryA: TrajectorySummary;
33
+ summaryB: TrajectorySummary;
34
+ /** Tool steps dropped from the diff computation by the `maxDiffSteps` cap, per side. */
35
+ truncatedA: number;
36
+ truncatedB: number;
37
+ }
38
+ export interface DiffTrajectoriesOptions {
39
+ /** Cap on tool steps considered per side (the LCS table is O(n*m)). Default 1000. */
40
+ maxDiffSteps?: number;
41
+ }
42
+ /**
43
+ * Diff two trajectories: align their tool-step sequences, find the first
44
+ * divergence, and collect the steps each session ran that the other never
45
+ * did. Never mutates either input.
46
+ */
47
+ export declare function diffTrajectories(a: SessionTrajectory, b: SessionTrajectory, options?: DiffTrajectoriesOptions): TrajectoryComparison;