@phnx-labs/agents-cli 1.22.46 → 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 (167) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/LICENSE +102 -182
  3. package/README.md +31 -18
  4. package/dist/cli/command-registry.d.ts +1 -1
  5. package/dist/cli/command-registry.js +4 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.js +7 -5
  8. package/dist/commands/browser-sessions-picker.js +9 -7
  9. package/dist/commands/browser.js +266 -15
  10. package/dist/commands/config.js +26 -4
  11. package/dist/commands/cost.d.ts +1 -1
  12. package/dist/commands/detach.d.ts +13 -0
  13. package/dist/commands/detach.js +11 -8
  14. package/dist/commands/exec.js +55 -38
  15. package/dist/commands/feed-watch.d.ts +2 -0
  16. package/dist/commands/feed-watch.js +28 -0
  17. package/dist/commands/feed.js +27 -0
  18. package/dist/commands/feedback.js +3 -19
  19. package/dist/commands/insights.d.ts +1 -1
  20. package/dist/commands/insights.js +2 -2
  21. package/dist/commands/lease.js +3 -3
  22. package/dist/commands/routines.js +1 -1
  23. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  24. package/dist/commands/sessions-picker-factory.js +1 -1
  25. package/dist/commands/sessions-stop.d.ts +24 -0
  26. package/dist/commands/sessions-stop.js +66 -0
  27. package/dist/commands/sessions-trace.d.ts +82 -0
  28. package/dist/commands/sessions-trace.js +387 -0
  29. package/dist/commands/sessions.js +4 -0
  30. package/dist/commands/ssh.d.ts +15 -0
  31. package/dist/commands/ssh.js +194 -16
  32. package/dist/commands/teams.js +34 -2
  33. package/dist/commands/view.d.ts +2 -0
  34. package/dist/commands/view.js +18 -31
  35. package/dist/commands/watchdog.js +9 -14
  36. package/dist/lib/account-capabilities.d.ts +4 -0
  37. package/dist/lib/account-capabilities.js +13 -9
  38. package/dist/lib/account-registry.d.ts +2 -0
  39. package/dist/lib/account-registry.js +30 -2
  40. package/dist/lib/accounting/rotate.d.ts +9 -0
  41. package/dist/lib/accounting/rotate.js +12 -2
  42. package/dist/lib/accounting/usage.d.ts +70 -3
  43. package/dist/lib/accounting/usage.js +235 -63
  44. package/dist/lib/agent-modes.js +29 -1
  45. package/dist/lib/agent-spec/agents.d.ts +4 -3
  46. package/dist/lib/agent-spec/agents.js +14 -6
  47. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  48. package/dist/lib/analytics/mix-commands.js +2 -2
  49. package/dist/lib/auth-health.d.ts +13 -3
  50. package/dist/lib/auth-health.js +50 -8
  51. package/dist/lib/browser/devices.d.ts +19 -0
  52. package/dist/lib/browser/devices.js +23 -0
  53. package/dist/lib/browser/ipc.d.ts +1 -1
  54. package/dist/lib/browser/ipc.js +33 -7
  55. package/dist/lib/browser/profiles.d.ts +106 -1
  56. package/dist/lib/browser/profiles.js +300 -17
  57. package/dist/lib/browser/remote-control.d.ts +26 -2
  58. package/dist/lib/browser/remote-control.js +33 -3
  59. package/dist/lib/browser/runtime-state.d.ts +9 -0
  60. package/dist/lib/browser/runtime-state.js +24 -8
  61. package/dist/lib/browser/service.d.ts +37 -0
  62. package/dist/lib/browser/service.js +78 -20
  63. package/dist/lib/browser/sessions-list.d.ts +1 -3
  64. package/dist/lib/browser/sessions-list.js +3 -16
  65. package/dist/lib/browser/types.d.ts +12 -1
  66. package/dist/lib/codex-policy.d.ts +11 -1
  67. package/dist/lib/codex-policy.js +20 -5
  68. package/dist/lib/config-keys.d.ts +4 -3
  69. package/dist/lib/config-keys.js +17 -7
  70. package/dist/lib/daemon/runner.js +15 -2
  71. package/dist/lib/daemon-ticks.js +2 -2
  72. package/dist/lib/device-config.d.ts +25 -8
  73. package/dist/lib/device-config.js +90 -17
  74. package/dist/lib/devices/config-migration.d.ts +7 -0
  75. package/dist/lib/devices/config-migration.js +67 -3
  76. package/dist/lib/devices/health.d.ts +27 -2
  77. package/dist/lib/devices/health.js +39 -9
  78. package/dist/lib/devices/interactive-host.d.ts +15 -0
  79. package/dist/lib/devices/interactive-host.js +53 -0
  80. package/dist/lib/devices/registry.d.ts +61 -4
  81. package/dist/lib/devices/registry.js +94 -38
  82. package/dist/lib/devices/stats-cache.d.ts +6 -0
  83. package/dist/lib/devices/stats-cache.js +10 -2
  84. package/dist/lib/exec.d.ts +5 -3
  85. package/dist/lib/exec.js +32 -16
  86. package/dist/lib/feed/answer.d.ts +29 -0
  87. package/dist/lib/feed/answer.js +145 -0
  88. package/dist/lib/feed/attention.d.ts +83 -0
  89. package/dist/lib/feed/attention.js +0 -0
  90. package/dist/lib/feed/feed.d.ts +109 -0
  91. package/dist/lib/feed/feed.js +154 -3
  92. package/dist/lib/feed/pr-status.d.ts +14 -0
  93. package/dist/lib/feed/pr-status.js +47 -0
  94. package/dist/lib/feed/watch.d.ts +59 -0
  95. package/dist/lib/feed/watch.js +180 -0
  96. package/dist/lib/fleet/capture.js +6 -0
  97. package/dist/lib/fleet/remote-login.js +8 -3
  98. package/dist/lib/fleet/types.d.ts +17 -0
  99. package/dist/lib/harness/adapter.d.ts +11 -0
  100. package/dist/lib/harness/adapters/claude.js +38 -24
  101. package/dist/lib/harness/adapters/codex.js +2 -4
  102. package/dist/lib/hooks/cache.js +8 -0
  103. package/dist/lib/hooks/match.d.ts +3 -0
  104. package/dist/lib/hooks/match.js +25 -0
  105. package/dist/lib/hosts/passthrough.js +16 -0
  106. package/dist/lib/hosts/registry.js +7 -0
  107. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  108. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  109. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  110. package/dist/lib/menubar/install-menubar.js +26 -2
  111. package/dist/lib/open-url.d.ts +60 -2
  112. package/dist/lib/open-url.js +167 -12
  113. package/dist/lib/refresh-coordinator.d.ts +1 -1
  114. package/dist/lib/refresh-coordinator.js +1 -1
  115. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  116. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  117. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  118. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  119. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  120. package/dist/lib/session/active.d.ts +67 -0
  121. package/dist/lib/session/active.js +69 -1
  122. package/dist/lib/session/db.js +4 -2
  123. package/dist/lib/session/discover.d.ts +2 -0
  124. package/dist/lib/session/discover.js +12 -6
  125. package/dist/lib/session/prompt.d.ts +35 -0
  126. package/dist/lib/session/prompt.js +56 -0
  127. package/dist/lib/session/remote/watch.d.ts +1 -1
  128. package/dist/lib/session/remote/watch.js +6 -1
  129. package/dist/lib/session/render.js +7 -3
  130. package/dist/lib/session/session-cache.js +5 -1
  131. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  132. package/dist/lib/session/trajectory-compare.js +116 -0
  133. package/dist/lib/session/trajectory-html.d.ts +15 -0
  134. package/dist/lib/session/trajectory-html.js +750 -0
  135. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  136. package/dist/lib/session/trajectory-lineage.js +195 -0
  137. package/dist/lib/session/trajectory-text.d.ts +37 -0
  138. package/dist/lib/session/trajectory-text.js +277 -0
  139. package/dist/lib/session/trajectory.d.ts +102 -0
  140. package/dist/lib/session/trajectory.js +335 -0
  141. package/dist/lib/startup/command-registry.d.ts +3 -1
  142. package/dist/lib/startup/command-registry.js +5 -2
  143. package/dist/lib/state.d.ts +1 -1
  144. package/dist/lib/state.js +4 -2
  145. package/dist/lib/teams/agents.js +9 -0
  146. package/dist/lib/terminal/inject.d.ts +19 -0
  147. package/dist/lib/terminal/inject.js +19 -9
  148. package/dist/lib/tmux/session.d.ts +46 -13
  149. package/dist/lib/tmux/session.js +173 -27
  150. package/dist/lib/types.d.ts +16 -0
  151. package/dist/lib/usage-backoff.d.ts +2 -1
  152. package/dist/lib/usage-backoff.js +60 -19
  153. package/dist/lib/usage-refresh.d.ts +17 -3
  154. package/dist/lib/usage-refresh.js +57 -11
  155. package/dist/lib/watchdog/log.d.ts +1 -1
  156. package/dist/lib/watchdog/log.js +1 -1
  157. package/dist/lib/watchdog/runner.d.ts +36 -45
  158. package/dist/lib/watchdog/runner.js +150 -119
  159. package/dist/lib/watchdog/service.d.ts +0 -1
  160. package/dist/lib/watchdog/service.js +0 -1
  161. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  162. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  163. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  164. package/dist/lib/watchdog/watchdog.js +37 -104
  165. package/package.json +2 -2
  166. package/dist/commands/usage.d.ts +0 -27
  167. package/dist/commands/usage.js +0 -96
@@ -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;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Diff two {@link SessionTrajectory} models for `agents sessions trace <a> <b>` —
3
+ * the second selector turns the single-session trajectory into a **compare**.
4
+ *
5
+ * Aligns the two sessions' TOOL steps (thinking steps carry no comparable
6
+ * identity) with the `diff` package's `diffArrays` (already a dependency,
7
+ * `lib/diff-text.ts`), comparator-keyed on tool name, in order — cheap,
8
+ * deterministic, and matches how a human reads "where did these two runs
9
+ * diverge": the first point their tool sequence stops lining up.
10
+ *
11
+ * Pure and framework-free, exactly like `trajectory.ts`: takes two already-built
12
+ * `SessionTrajectory` models (never re-parses a transcript) and returns a plain
13
+ * diff object the HTML/text/JSON renderers project.
14
+ */
15
+ import { diffArrays } from 'diff';
16
+ const DEFAULT_MAX_DIFF_STEPS = 1000;
17
+ /**
18
+ * Turn `diffArrays`' run-length change objects into a per-step edit script.
19
+ *
20
+ * `diffArrays(a, b, { comparator })` (Myers' diff under the hood) returns
21
+ * consecutive-run chunks, not individual elements, and — per its own docs —
22
+ * an unchanged chunk's `value` is drawn from `b`, not `a`. Neither shape is
23
+ * what a divergence marker needs (a stepA/stepB PAIR per matched step, with
24
+ * both sides' own ordinals/timestamps), so this walks two cursors over the
25
+ * original arrays in lockstep with the run lengths `diffArrays` reports,
26
+ * recovering the exact `a[i]`/`b[j]` pairing for every `same` step.
27
+ */
28
+ function computeEditScript(a, b) {
29
+ const changes = diffArrays(a, b, { comparator: (x, y) => x.tool === y.tool });
30
+ const ops = [];
31
+ let i = 0;
32
+ let j = 0;
33
+ for (const change of changes) {
34
+ const count = change.count ?? change.value.length;
35
+ if (change.removed) {
36
+ for (let k = 0; k < count; k++)
37
+ ops.push({ type: 'remove', stepA: a[i++] });
38
+ }
39
+ else if (change.added) {
40
+ for (let k = 0; k < count; k++)
41
+ ops.push({ type: 'add', stepB: b[j++] });
42
+ }
43
+ else {
44
+ for (let k = 0; k < count; k++)
45
+ ops.push({ type: 'same', stepA: a[i++], stepB: b[j++] });
46
+ }
47
+ }
48
+ return ops;
49
+ }
50
+ function describeDivergence(op) {
51
+ if (op.type === 'remove')
52
+ return `${op.stepA.tool ?? op.stepA.kind} — only in the first session`;
53
+ if (op.type === 'add')
54
+ return `${op.stepB.tool ?? op.stepB.kind} — only in the second session`;
55
+ return '';
56
+ }
57
+ function summarize(t) {
58
+ return {
59
+ session: t.session,
60
+ toolCount: t.stats.toolCount,
61
+ errorCount: t.errorCount,
62
+ spanMs: t.spanMs,
63
+ outputTokens: t.stats.outputTokens,
64
+ };
65
+ }
66
+ /**
67
+ * Diff two trajectories: align their tool-step sequences, find the first
68
+ * divergence, and collect the steps each session ran that the other never
69
+ * did. Never mutates either input.
70
+ */
71
+ export function diffTrajectories(a, b, options = {}) {
72
+ const maxDiffSteps = options.maxDiffSteps ?? DEFAULT_MAX_DIFF_STEPS;
73
+ const allToolStepsA = a.steps.filter((s) => s.kind === 'tool');
74
+ const allToolStepsB = b.steps.filter((s) => s.kind === 'tool');
75
+ const toolStepsA = allToolStepsA.slice(0, maxDiffSteps);
76
+ const toolStepsB = allToolStepsB.slice(0, maxDiffSteps);
77
+ const truncatedA = allToolStepsA.length - toolStepsA.length;
78
+ const truncatedB = allToolStepsB.length - toolStepsB.length;
79
+ const ops = computeEditScript(toolStepsA, toolStepsB);
80
+ const added = [];
81
+ const removed = [];
82
+ let divergence;
83
+ let lastSameA;
84
+ let lastSameB;
85
+ for (const op of ops) {
86
+ if (op.type === 'same') {
87
+ lastSameA = op.stepA;
88
+ lastSameB = op.stepB;
89
+ continue;
90
+ }
91
+ if (op.type === 'remove')
92
+ removed.push(op.stepA);
93
+ else
94
+ added.push(op.stepB);
95
+ if (!divergence) {
96
+ divergence = {
97
+ afterOrdinalA: lastSameA?.ordinal ?? 0,
98
+ afterOrdinalB: lastSameB?.ordinal ?? 0,
99
+ startMsA: op.type === 'remove' ? op.stepA.startMs : (lastSameA?.startMs ?? 0),
100
+ startMsB: op.type === 'add' ? op.stepB.startMs : (lastSameB?.startMs ?? 0),
101
+ detail: describeDivergence(op),
102
+ };
103
+ }
104
+ }
105
+ return {
106
+ a,
107
+ b,
108
+ divergence,
109
+ added,
110
+ removed,
111
+ summaryA: summarize(a),
112
+ summaryB: summarize(b),
113
+ truncatedA,
114
+ truncatedB,
115
+ };
116
+ }
@@ -0,0 +1,15 @@
1
+ import type { SessionTrajectory } from './trajectory.js';
2
+ import type { TrajectoryComparison } from './trajectory-compare.js';
3
+ import type { SessionLineage } from './trajectory-lineage.js';
4
+ /** Render one session's trajectory as a self-contained HTML page. */
5
+ export declare function renderTrajectoryHtml(model: SessionTrajectory): string;
6
+ /** Render a two-session {@link TrajectoryComparison} as a self-contained HTML page. */
7
+ export declare function renderTrajectoryCompareHtml(cmp: TrajectoryComparison): string;
8
+ /**
9
+ * Render a {@link SessionLineage} as ONE self-contained HTML page: the inline-SVG
10
+ * delegation graph (orchestrator on top, the sessions it spawned below, edges
11
+ * auto-discovered from the team records) plus a per-node summary card revealed
12
+ * by clicking a node. Same shell, redaction, and no-CDN rule as the other two
13
+ * layouts; local paths (`filePath`/`cwd`) are deliberately never rendered.
14
+ */
15
+ export declare function renderLineageHtml(lineage: SessionLineage): string;