@phnx-labs/agents-cli 1.22.47 → 1.22.50

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 (272) hide show
  1. package/CHANGELOG.md +503 -17
  2. package/README.md +76 -32
  3. package/dist/bootstrap.js +4 -2
  4. package/dist/cli/command-registry.d.ts +1 -0
  5. package/dist/cli/command-registry.js +2 -0
  6. package/dist/commands/accounts.d.ts +58 -0
  7. package/dist/commands/accounts.js +201 -39
  8. package/dist/commands/artifacts.js +7 -6
  9. package/dist/commands/attach.js +1 -1
  10. package/dist/commands/browser.js +501 -103
  11. package/dist/commands/config.d.ts +0 -1
  12. package/dist/commands/config.js +0 -20
  13. package/dist/commands/daemon-test-harness.d.ts +42 -0
  14. package/dist/commands/daemon-test-harness.js +90 -0
  15. package/dist/commands/daemon.d.ts +21 -0
  16. package/dist/commands/daemon.js +147 -12
  17. package/dist/commands/device-config-test-harness.d.ts +11 -0
  18. package/dist/commands/device-config-test-harness.js +59 -0
  19. package/dist/commands/exec.js +76 -16
  20. package/dist/commands/focus.d.ts +14 -0
  21. package/dist/commands/focus.js +48 -5
  22. package/dist/commands/go.d.ts +3 -0
  23. package/dist/commands/go.js +16 -3
  24. package/dist/commands/inspect.js +2 -2
  25. package/dist/commands/lease.d.ts +1 -1
  26. package/dist/commands/lease.js +4 -3
  27. package/dist/commands/mailboxes.js +4 -3
  28. package/dist/commands/menubar.js +11 -4
  29. package/dist/commands/projects.d.ts +16 -1
  30. package/dist/commands/projects.js +58 -64
  31. package/dist/commands/resume.js +1 -1
  32. package/dist/commands/route.js +4 -4
  33. package/dist/commands/routines.js +3 -2
  34. package/dist/commands/run-broadcast.d.ts +1 -1
  35. package/dist/commands/run-broadcast.js +1 -1
  36. package/dist/commands/sessions-picker.d.ts +22 -0
  37. package/dist/commands/sessions-picker.js +48 -9
  38. package/dist/commands/sessions-trace.js +1 -1
  39. package/dist/commands/sessions.js +1 -0
  40. package/dist/commands/setup-secrets.js +23 -0
  41. package/dist/commands/share.d.ts +20 -5
  42. package/dist/commands/share.js +158 -53
  43. package/dist/commands/ssh.js +54 -7
  44. package/dist/commands/teams.d.ts +12 -0
  45. package/dist/commands/teams.js +44 -67
  46. package/dist/commands/traces.d.ts +2 -0
  47. package/dist/commands/traces.js +188 -0
  48. package/dist/commands/view.d.ts +1 -1
  49. package/dist/commands/view.js +17 -34
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.js +5 -0
  52. package/dist/lib/account-provider-registry.d.ts +9 -0
  53. package/dist/lib/account-provider-registry.js +20 -0
  54. package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
  55. package/dist/lib/accounting/account-pool-collect.js +71 -0
  56. package/dist/lib/accounting/account-pool.d.ts +46 -0
  57. package/dist/lib/accounting/account-pool.js +33 -0
  58. package/dist/lib/accounting/capacity.d.ts +24 -0
  59. package/dist/lib/accounting/capacity.js +30 -0
  60. package/dist/lib/accounting/rotate.d.ts +11 -18
  61. package/dist/lib/accounting/rotate.js +6 -24
  62. package/dist/lib/accounting/usage.d.ts +14 -3
  63. package/dist/lib/accounting/usage.js +64 -18
  64. package/dist/lib/agent-spec/agents.js +11 -9
  65. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  66. package/dist/lib/browser/drivers/ssh.js +36 -9
  67. package/dist/lib/browser/ipc.js +8 -3
  68. package/dist/lib/browser/profiles.d.ts +35 -101
  69. package/dist/lib/browser/profiles.js +130 -301
  70. package/dist/lib/browser/registry.d.ts +39 -0
  71. package/dist/lib/browser/registry.js +149 -0
  72. package/dist/lib/browser/remote-control.d.ts +8 -6
  73. package/dist/lib/browser/remote-control.js +8 -6
  74. package/dist/lib/browser/resolve-target.d.ts +68 -0
  75. package/dist/lib/browser/resolve-target.js +250 -0
  76. package/dist/lib/browser/runtime-state.d.ts +40 -16
  77. package/dist/lib/browser/runtime-state.js +92 -25
  78. package/dist/lib/browser/service.d.ts +28 -9
  79. package/dist/lib/browser/service.js +139 -62
  80. package/dist/lib/browser/task-index.d.ts +70 -0
  81. package/dist/lib/browser/task-index.js +180 -0
  82. package/dist/lib/byok-usage.js +2 -2
  83. package/dist/lib/claude-statusline.d.ts +29 -0
  84. package/dist/lib/claude-statusline.js +139 -0
  85. package/dist/lib/cloud/host.d.ts +1 -1
  86. package/dist/lib/cloud/host.js +1 -1
  87. package/dist/lib/cloud/rush.js +1 -1
  88. package/dist/lib/computer/computer-rpc.js +2 -2
  89. package/dist/lib/computer/download.d.ts +15 -15
  90. package/dist/lib/computer/download.js +35 -98
  91. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  92. package/dist/lib/computer/ssh-tunnel.js +12 -22
  93. package/dist/lib/config-keys.d.ts +2 -8
  94. package/dist/lib/config-keys.js +2 -13
  95. package/dist/lib/crabbox/cli.d.ts +1 -1
  96. package/dist/lib/crabbox/cli.js +2 -2
  97. package/dist/lib/crabbox/lease.d.ts +9 -0
  98. package/dist/lib/crabbox/lease.js +70 -15
  99. package/dist/lib/crabbox/runtimes.js +1 -1
  100. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  101. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  102. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  103. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  104. package/dist/lib/daemon/daemon.d.ts +3 -0
  105. package/dist/lib/daemon/daemon.js +212 -444
  106. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  107. package/dist/lib/daemon/device-probe-service.js +47 -0
  108. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  109. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  110. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  111. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  112. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  113. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  114. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  115. package/dist/lib/daemon/self-heal-service.js +48 -0
  116. package/dist/lib/daemon/service.d.ts +102 -0
  117. package/dist/lib/daemon/service.js +98 -0
  118. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  119. package/dist/lib/daemon/session-index-service.js +40 -0
  120. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  121. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  122. package/dist/lib/daemon/supervisor.d.ts +87 -0
  123. package/dist/lib/daemon/supervisor.js +298 -0
  124. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  125. package/dist/lib/daemon/watchdog-service.js +40 -0
  126. package/dist/lib/daemon-health.d.ts +15 -0
  127. package/dist/lib/daemon-health.js +29 -5
  128. package/dist/lib/daemon-services.d.ts +8 -1
  129. package/dist/lib/daemon-services.js +55 -0
  130. package/dist/lib/daemon-ticks.d.ts +13 -2
  131. package/dist/lib/daemon-ticks.js +20 -18
  132. package/dist/lib/device-config.d.ts +0 -2
  133. package/dist/lib/device-config.js +1 -23
  134. package/dist/lib/devices/registry.d.ts +1 -1
  135. package/dist/lib/devices/registry.js +1 -1
  136. package/dist/lib/devices/stats-cache.d.ts +20 -0
  137. package/dist/lib/devices/stats-cache.js +49 -5
  138. package/dist/lib/devices/worker-pick.d.ts +48 -0
  139. package/dist/lib/devices/worker-pick.js +113 -0
  140. package/dist/lib/exec.d.ts +67 -13
  141. package/dist/lib/exec.js +85 -29
  142. package/dist/lib/harness/adapters/cursor.js +8 -11
  143. package/dist/lib/helper-download.d.ts +108 -0
  144. package/dist/lib/helper-download.js +194 -0
  145. package/dist/lib/helper-versions.d.ts +52 -0
  146. package/dist/lib/helper-versions.js +58 -0
  147. package/dist/lib/hooks/cache.js +11 -0
  148. package/dist/lib/hooks/install.js +2 -2
  149. package/dist/lib/hosts/credentials.d.ts +1 -1
  150. package/dist/lib/hosts/credentials.js +1 -1
  151. package/dist/lib/hosts/dispatch.d.ts +7 -1
  152. package/dist/lib/hosts/dispatch.js +24 -5
  153. package/dist/lib/hosts/passthrough.d.ts +8 -15
  154. package/dist/lib/hosts/passthrough.js +19 -16
  155. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  156. package/dist/lib/hosts/providers/devices.js +1 -1
  157. package/dist/lib/hosts/reconnect.d.ts +179 -21
  158. package/dist/lib/hosts/reconnect.js +266 -45
  159. package/dist/lib/installations/migrate.d.ts +4 -5
  160. package/dist/lib/installations/migrate.js +6 -7
  161. package/dist/lib/installations/shims.d.ts +1 -1
  162. package/dist/lib/installations/shims.js +23 -13
  163. package/dist/lib/installations/store.js +6 -3
  164. package/dist/lib/installations/versions.js +73 -48
  165. package/dist/lib/linear-autoclose.d.ts +1 -1
  166. package/dist/lib/linear-autoclose.js +1 -1
  167. package/dist/lib/loop.d.ts +2 -2
  168. package/dist/lib/loop.js +1 -1
  169. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  170. package/dist/lib/menubar/download-menubar.js +60 -0
  171. package/dist/lib/menubar/install-menubar.d.ts +31 -7
  172. package/dist/lib/menubar/install-menubar.js +79 -21
  173. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  174. package/dist/lib/menubar/notify-desktop.js +6 -6
  175. package/dist/lib/model-tiers.d.ts +1 -1
  176. package/dist/lib/project-probe.d.ts +1 -1
  177. package/dist/lib/project-probe.js +1 -1
  178. package/dist/lib/project-pull.d.ts +1 -1
  179. package/dist/lib/project-pull.js +2 -2
  180. package/dist/lib/projects.d.ts +1 -1
  181. package/dist/lib/projects.js +1 -1
  182. package/dist/lib/sandbox.js +6 -7
  183. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  184. package/dist/lib/secrets/download-keychain.js +70 -0
  185. package/dist/lib/secrets/install-helper.d.ts +20 -1
  186. package/dist/lib/secrets/install-helper.js +44 -3
  187. package/dist/lib/session/active.js +17 -1
  188. package/dist/lib/session/db.d.ts +11 -1
  189. package/dist/lib/session/db.js +138 -6
  190. package/dist/lib/session/discover.d.ts +21 -1
  191. package/dist/lib/session/discover.js +33 -5
  192. package/dist/lib/session/highlights.d.ts +49 -1
  193. package/dist/lib/session/highlights.js +95 -0
  194. package/dist/lib/session/host-link.d.ts +16 -2
  195. package/dist/lib/session/host-link.js +7 -1
  196. package/dist/lib/session/parse.js +33 -1
  197. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  198. package/dist/lib/session/remote/remote-list.js +19 -2
  199. package/dist/lib/session/remote/watch.js +11 -2
  200. package/dist/lib/session/session-cache.d.ts +53 -0
  201. package/dist/lib/session/session-cache.js +88 -0
  202. package/dist/lib/session/state.js +1 -1
  203. package/dist/lib/session/tool-calls.d.ts +2 -0
  204. package/dist/lib/session/tool-calls.js +1 -1
  205. package/dist/lib/session/trajectory-html.d.ts +5 -1
  206. package/dist/lib/session/trajectory-html.js +42 -22
  207. package/dist/lib/session/trajectory-text.js +9 -2
  208. package/dist/lib/session/trajectory.js +53 -4
  209. package/dist/lib/session/types.d.ts +15 -0
  210. package/dist/lib/sha256-asset.d.ts +26 -0
  211. package/dist/lib/sha256-asset.js +41 -0
  212. package/dist/lib/share/backend.d.ts +95 -0
  213. package/dist/lib/share/backend.js +139 -0
  214. package/dist/lib/share/delete.d.ts +6 -2
  215. package/dist/lib/share/delete.js +8 -9
  216. package/dist/lib/share/html.d.ts +22 -0
  217. package/dist/lib/share/html.js +88 -0
  218. package/dist/lib/share/provision.d.ts +18 -4
  219. package/dist/lib/share/provision.js +38 -4
  220. package/dist/lib/share/publish.d.ts +44 -15
  221. package/dist/lib/share/publish.js +58 -26
  222. package/dist/lib/share/worker-template.js +402 -25
  223. package/dist/lib/sqlite.js +16 -3
  224. package/dist/lib/ssh-exec.d.ts +65 -0
  225. package/dist/lib/ssh-exec.js +120 -4
  226. package/dist/lib/startup/command-registry.js +1 -1
  227. package/dist/lib/startup/dev-build.js +3 -3
  228. package/dist/lib/state.d.ts +1 -1
  229. package/dist/lib/state.js +7 -3
  230. package/dist/lib/subagents-registry.js +1 -1
  231. package/dist/lib/teams/agents.d.ts +14 -12
  232. package/dist/lib/teams/agents.js +188 -73
  233. package/dist/lib/teams/api.d.ts +6 -1
  234. package/dist/lib/teams/api.js +2 -0
  235. package/dist/lib/teams/scheduler.d.ts +7 -0
  236. package/dist/lib/teams/scheduler.js +26 -8
  237. package/dist/lib/terminal/index.d.ts +1 -1
  238. package/dist/lib/terminal/types.d.ts +1 -1
  239. package/dist/lib/terminal/types.js +1 -1
  240. package/dist/lib/tmux/orphan-reap.js +1 -1
  241. package/dist/lib/tmux/session.js +31 -0
  242. package/dist/lib/traces/backend.d.ts +29 -0
  243. package/dist/lib/traces/backend.js +46 -0
  244. package/dist/lib/traces/classify.d.ts +52 -0
  245. package/dist/lib/traces/classify.js +72 -0
  246. package/dist/lib/traces/config.d.ts +10 -0
  247. package/dist/lib/traces/config.js +2 -0
  248. package/dist/lib/traces/provision.d.ts +19 -0
  249. package/dist/lib/traces/provision.js +30 -0
  250. package/dist/lib/traces/sync.d.ts +150 -0
  251. package/dist/lib/traces/sync.js +459 -0
  252. package/dist/lib/traces/worker-template.d.ts +4 -0
  253. package/dist/lib/traces/worker-template.js +159 -0
  254. package/dist/lib/types.d.ts +18 -19
  255. package/dist/lib/types.js +14 -0
  256. package/dist/lib/usage-refresh.d.ts +1 -1
  257. package/dist/lib/usage-refresh.js +11 -8
  258. package/dist/lib/workflows.d.ts +1 -1
  259. package/package.json +4 -6
  260. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  261. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  262. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  263. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  264. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  265. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  266. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -26
  267. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  268. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  269. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -135
  270. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  271. package/dist/lib/usage-fleet.d.ts +0 -32
  272. package/dist/lib/usage-fleet.js +0 -125
@@ -11,7 +11,7 @@ import { sanitizeForTerminal } from '../redact.js';
11
11
  import * as path from 'path';
12
12
  import Database from '../sqlite.js';
13
13
  import { isSyntheticUserMessage, extractSlashCommandName, extractSlashCommandFromToolInput } from './prompt.js';
14
- import { structuredToolResult } from './tool-calls.js';
14
+ import { structuredToolResult, commandsFromCodexExec } from './tool-calls.js';
15
15
  /**
16
16
  * Largest session file we will load into memory. Above this we throw a clean
17
17
  * error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
@@ -270,6 +270,15 @@ export function summarizeToolUse(tool, args) {
270
270
  // Codex tools
271
271
  case 'exec_command':
272
272
  return `Bash: ${truncate(String(args.command || args.cmd || '').replace(/\n/g, ' ').trim(), 120)}`;
273
+ // Newer Codex JS-cell shell wrapper: prefer the unwrapped command (set at parse
274
+ // time), else the extracted command, else the raw cell code.
275
+ case 'exec': {
276
+ const cmd = args.command || codexExecCommand(String(args.input || ''));
277
+ if (cmd)
278
+ return `Bash: ${truncate(String(cmd).replace(/\n/g, ' ').trim(), 120)}`;
279
+ const code = String(args.input || '').replace(/\n/g, ' ').trim();
280
+ return code ? `exec: ${truncate(code, 100)}` : 'exec';
281
+ }
273
282
  case 'read_file':
274
283
  return `Read ${shortenPath(args.file_path || args.path || '')}`;
275
284
  case 'write_file':
@@ -573,6 +582,23 @@ export function applyPatchTargetPaths(input) {
573
582
  function applyPatchTargetPath(input) {
574
583
  return applyPatchTargetPaths(input)[0];
575
584
  }
585
+ /**
586
+ * Newer Codex (gpt-5.6-sol / codex >=~0.145) runs every shell command inside a JS
587
+ * cell: a `custom_tool_call` named `exec` whose `input` is code like
588
+ * `const r = await tools.exec_command({cmd:"git status", "workdir":"…"});`. The real
589
+ * shell command(s) are buried in those `cmd:"…"` literals. Reuse the canonical
590
+ * acorn-based extractor (`commandsFromCodexExec` in `tool-calls.ts`, already used by
591
+ * the tool-call index): it walks the whole AST — so a cell that runs several via
592
+ * `Promise.all([tools.exec_command(...), tools.exec_command(...)])` yields every
593
+ * command, not just the first — and matches only real `tools.exec_command` calls,
594
+ * so a `cmd:"…"` string mentioned in a comment or docstring is never mistaken for an
595
+ * invocation. Returns `undefined` for a non-shell cell (`tools.view_image(...)`, a
596
+ * raw JS computation) so it stays labeled by its code, not faked into a shell step.
597
+ */
598
+ function codexExecCommand(input) {
599
+ const commands = commandsFromCodexExec(input);
600
+ return commands.length > 0 ? commands.join('\n') : undefined;
601
+ }
576
602
  /**
577
603
  * Parse Codex JSONL *content* (already read into a string) into normalized
578
604
  * events. Split from `parseCodex` so the tail reader can parse just the last
@@ -706,6 +732,9 @@ export function parseCodexContent(content) {
706
732
  const rawName = payload.name || 'unknown';
707
733
  const input = typeof payload.input === 'string' ? payload.input : '';
708
734
  const isApplyPatch = rawName === 'apply_patch';
735
+ // Newer Codex wraps shell in a JS `exec` cell — unwrap the real command so
736
+ // the step reads by its program instead of a bare "exec" (see extractor).
737
+ const execCommand = rawName === 'exec' ? codexExecCommand(input) : undefined;
709
738
  // Multi-file patches: one tool_use per file so artifact discovery sees
710
739
  // every path (RUSH-1410). Single-file / non-patch keep one event.
711
740
  const patchPaths = isApplyPatch ? applyPatchTargetPaths(input) : [];
@@ -715,6 +744,8 @@ export function parseCodexContent(content) {
715
744
  const args = { input: truncatedInput };
716
745
  if (patchPath)
717
746
  args.file_path = patchPath;
747
+ if (execCommand)
748
+ args.command = execCommand;
718
749
  const callId = payload.call_id || payload.id;
719
750
  if (callId)
720
751
  callMap.set(callId, { name: tool, args });
@@ -725,6 +756,7 @@ export function parseCodexContent(content) {
725
756
  tool,
726
757
  callId,
727
758
  args,
759
+ command: execCommand,
728
760
  path: patchPath,
729
761
  });
730
762
  };
@@ -141,8 +141,17 @@ export declare function fetchPeerPreviewDigest(sessionId: string, machine: strin
141
141
  * that would inherit it for its whole life — `agents browser start` inside it
142
142
  * would then be refused as a cross-machine drive. A one-shot `--device` command
143
143
  * can carry the marker; a session cannot.
144
+ *
145
+ * `opts.sessionId` (with `tty`) prints the session id and resume command when
146
+ * the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
147
+ * thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
144
148
  */
149
+ export declare function peerHopCloseNotice(opts: {
150
+ tty?: boolean;
151
+ sessionId?: string;
152
+ }, machine: string, code: number | null): string | undefined;
145
153
  export declare function runOnPeer(args: string[], machine: string, opts?: {
146
154
  tty?: boolean;
147
155
  env?: Record<string, string>;
156
+ sessionId?: string;
148
157
  }): Promise<'ok' | 'no-target'>;
@@ -15,7 +15,8 @@
15
15
  */
16
16
  import { spawn } from 'child_process';
17
17
  import chalk from 'chalk';
18
- import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../../ssh-exec.js';
18
+ import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, SSH_CONN_FAILURE_CODE, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from '../../ssh-exec.js';
19
+ import { connectionEndedNotice } from '../../hosts/reconnect.js';
19
20
  import { sshTargetFor } from '../../devices/connect.js';
20
21
  import { resolveExplicitTargetSet } from '../../devices/resolve-target.js';
21
22
  import { loadDevices, isDialableDevice } from '../../devices/registry.js';
@@ -577,7 +578,16 @@ export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEE
577
578
  * that would inherit it for its whole life — `agents browser start` inside it
578
579
  * would then be refused as a cross-machine drive. A one-shot `--device` command
579
580
  * can carry the marker; a session cannot.
581
+ *
582
+ * `opts.sessionId` (with `tty`) prints the session id and resume command when
583
+ * the SSH hop ends, so OpenSSH's `Shared connection … closed.` is not the last
584
+ * thing on the local shell (RUSH-3227). Omit it for one-shot non-TTY renders.
580
585
  */
586
+ export function peerHopCloseNotice(opts, machine, code) {
587
+ if (!opts.tty || !opts.sessionId)
588
+ return undefined;
589
+ return connectionEndedNotice({ kind: 'session', id: opts.sessionId }, machine, { dropped: code === SSH_CONN_FAILURE_CODE });
590
+ }
581
591
  export async function runOnPeer(args, machine, opts = {}) {
582
592
  const peer = await resolvePeerTarget(machine);
583
593
  if (!peer)
@@ -602,6 +612,13 @@ export async function runOnPeer(args, machine, opts = {}) {
602
612
  process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
603
613
  resolve('ok');
604
614
  });
605
- child.on('close', () => resolve('ok'));
615
+ child.on('close', (code) => {
616
+ // Interactive TTY hop: OpenSSH prints "Shared connection … closed." and
617
+ // nothing else. Leave the session id on the local shell (RUSH-3227).
618
+ const notice = peerHopCloseNotice(opts, machine, code);
619
+ if (notice)
620
+ process.stderr.write(notice);
621
+ resolve('ok');
622
+ });
606
623
  });
607
624
  }
@@ -9,7 +9,7 @@ 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
11
  import { isReapableOrphan } from '../active.js';
12
- import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, } from '../session-cache.js';
12
+ import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, } from '../session-cache.js';
13
13
  export const SESSION_WATCH_VERSION = 1;
14
14
  export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
15
15
  /** Stable, opaque identity for one row within one device scope. */
@@ -116,9 +116,15 @@ export async function watchLocalSessions(options) {
116
116
  catch { /* first publication */ }
117
117
  const initial = readCache('local');
118
118
  options.emit(state.reset(options.scope, initial?.sessions ?? []));
119
+ // Known gap (out of scope for RUSH-2484): a present cache alone marks
120
+ // 'available' with no staleness/age check, so a reconnect can briefly render
121
+ // a stale snapshot as live before the out-of-band gather this file triggers
122
+ // (see watchActiveSessionsReaderPresence in session-cache.ts) replaces it.
119
123
  options.emit(state.scope(options.scope, initial ? 'available' : 'unavailable', initial ? undefined : 'awaiting publisher'));
120
124
  if (options.signal.aborted)
121
125
  return;
126
+ // Signal the daemon that a consumer is live so it does not skip the gather.
127
+ noteActiveSessionsJournalReader();
122
128
  await new Promise((resolve) => {
123
129
  let partial = '';
124
130
  let reading = false;
@@ -167,7 +173,10 @@ export async function watchLocalSessions(options) {
167
173
  fs.mkdirSync(path.dirname(journal), { recursive: true });
168
174
  const journalListener = () => readAppended();
169
175
  fs.watchFile(journal, { interval: options.journalPollMs ?? 250 }, journalListener);
170
- const heartbeatTimer = setInterval(() => options.emit(state.heartbeat(options.scope)), heartbeatMs);
176
+ const heartbeatTimer = setInterval(() => {
177
+ noteActiveSessionsJournalReader();
178
+ options.emit(state.heartbeat(options.scope));
179
+ }, heartbeatMs);
171
180
  const stop = () => { fs.unwatchFile(journal, journalListener); clearInterval(heartbeatTimer); resolve(); };
172
181
  options.signal.addEventListener('abort', stop, { once: true });
173
182
  // Close the offset/read/watch handoff: a publisher may append after the
@@ -59,6 +59,16 @@ export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "
59
59
  * only cache that may carry them — and only as a whole-row snapshot.
60
60
  */
61
61
  export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
62
+ /**
63
+ * How long after the last reader heartbeat the daemon considers the journal
64
+ * to have no active consumer and skips the expensive `ps`+`lsof` gather.
65
+ *
66
+ * Must be significantly larger than {@link SESSION_WATCH_HEARTBEAT_MS} (15s)
67
+ * so a single delayed heartbeat does not cause a false-idle. Three tick-lengths
68
+ * gives adequate margin while still cutting load within ~45s of the last
69
+ * watcher disconnecting.
70
+ */
71
+ export declare const ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS = 45000;
62
72
  /** Test seam: redirect the snapshot file. Returns the previous override. */
63
73
  export declare function setActiveSessionsSnapshotPathForTest(p: string | null): string | null;
64
74
  /** Test seam: isolate process-local entries between fixtures. */
@@ -67,7 +77,50 @@ export declare function clearActiveSnapshotMemoryForTest(): void;
67
77
  export declare function setImmutableMemoPathForTest(p: string | null): string | null;
68
78
  /** Test seam: redirect the append-only live-state journal. */
69
79
  export declare function setActiveSessionsJournalPathForTest(p: string | null): string | null;
80
+ /** Test seam: redirect the reader-presence file. Returns the previous override. */
81
+ export declare function setActiveSessionsReaderPresencePathForTest(p: string | null): string | null;
70
82
  export declare function activeSessionsJournalPath(): string;
83
+ /**
84
+ * Record that a journal consumer is active right now.
85
+ *
86
+ * Called by {@link watchLocalSessions} on startup and on each heartbeat so
87
+ * the daemon warm tick can gate the expensive `ps`+`lsof` gather on reader
88
+ * presence and skip it when no watcher has checked in recently.
89
+ */
90
+ export declare function noteActiveSessionsJournalReader(nowMs?: number): void;
91
+ /**
92
+ * True when a journal consumer has signalled its presence within
93
+ * {@link ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS}.
94
+ *
95
+ * Used by the daemon warm tick to decide whether to run the expensive
96
+ * `ps`+`lsof` gather. On an idle box with no watcher the tick skips the
97
+ * gather entirely; the moment a new watcher connects it calls
98
+ * {@link noteActiveSessionsJournalReader} and the following tick gathers.
99
+ */
100
+ export declare function isActiveSessionsJournalReaderRecent(nowMs?: number, idleWindowMs?: number): boolean;
101
+ /** Default poll cadence for {@link watchActiveSessionsReaderPresence}. */
102
+ export declare const ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS = 1000;
103
+ /**
104
+ * Watch for a reader going from absent/idle to present and fire `onConnect`
105
+ * out of band, instead of waiting for the next scheduled warm tick (RUSH-2484).
106
+ *
107
+ * {@link noteActiveSessionsJournalReader} only writes a timestamp — it has no
108
+ * path back to the daemon process that owns the warm tick's `setInterval`. A
109
+ * watcher connecting to a cold or long-idle daemon therefore used to wait up
110
+ * to one full {@link ACTIVE_SESSIONS_WARM_TICK_MS} tick for its first fresh
111
+ * rows. This polls the tiny presence file (not the expensive `ps`+`lsof`
112
+ * gather) at {@link ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS} and calls
113
+ * `onConnect` only on the idle→recent edge, so a steadily-connected reader's
114
+ * repeated heartbeats never re-trigger it and an idle box with no reader never
115
+ * gathers — the CPU win from RUSH-3193 is preserved.
116
+ *
117
+ * Returns a disposer that stops the poll.
118
+ */
119
+ export declare function watchActiveSessionsReaderPresence(onConnect: () => void, opts?: {
120
+ pollMs?: number;
121
+ idleWindowMs?: number;
122
+ nowMs?: () => number;
123
+ }): () => void;
71
124
  /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
72
125
  export declare function readActiveSessionsCache(scope: ActiveCacheScope): ActiveSessionsSnapshot | null;
73
126
  /**
@@ -94,10 +94,21 @@ export const LIVE_STATUS_KEYS = [
94
94
  'rateLimited',
95
95
  'plan',
96
96
  ];
97
+ /**
98
+ * How long after the last reader heartbeat the daemon considers the journal
99
+ * to have no active consumer and skips the expensive `ps`+`lsof` gather.
100
+ *
101
+ * Must be significantly larger than {@link SESSION_WATCH_HEARTBEAT_MS} (15s)
102
+ * so a single delayed heartbeat does not cause a false-idle. Three tick-lengths
103
+ * gives adequate margin while still cutting load within ~45s of the last
104
+ * watcher disconnecting.
105
+ */
106
+ export const ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS = 45_000;
97
107
  // ── path overrides (test seam) ─────────────────────────────────────────────
98
108
  let snapshotPathOverride = null;
99
109
  let immutablePathOverride = null;
100
110
  let journalPathOverride = null;
111
+ let readerPresencePathOverride = null;
101
112
  /** Test seam: redirect the snapshot file. Returns the previous override. */
102
113
  export function setActiveSessionsSnapshotPathForTest(p) {
103
114
  const prev = snapshotPathOverride;
@@ -121,6 +132,12 @@ export function setActiveSessionsJournalPathForTest(p) {
121
132
  journalPathOverride = p;
122
133
  return prev;
123
134
  }
135
+ /** Test seam: redirect the reader-presence file. Returns the previous override. */
136
+ export function setActiveSessionsReaderPresencePathForTest(p) {
137
+ const prev = readerPresencePathOverride;
138
+ readerPresencePathOverride = p;
139
+ return prev;
140
+ }
124
141
  function snapshotPath() {
125
142
  return snapshotPathOverride ?? path.join(getCacheDir(), SNAPSHOT_FILE);
126
143
  }
@@ -131,6 +148,77 @@ export function activeSessionsJournalPath() {
131
148
  return journalPathOverride
132
149
  ?? (snapshotPathOverride ? `${snapshotPathOverride}.journal.jsonl` : path.join(getCacheDir(), JOURNAL_FILE));
133
150
  }
151
+ const READER_PRESENCE_FILE = '.active-sessions-reader.presence';
152
+ function readerPresencePath() {
153
+ return readerPresencePathOverride ?? path.join(getCacheDir(), READER_PRESENCE_FILE);
154
+ }
155
+ /**
156
+ * Record that a journal consumer is active right now.
157
+ *
158
+ * Called by {@link watchLocalSessions} on startup and on each heartbeat so
159
+ * the daemon warm tick can gate the expensive `ps`+`lsof` gather on reader
160
+ * presence and skip it when no watcher has checked in recently.
161
+ */
162
+ export function noteActiveSessionsJournalReader(nowMs = Date.now()) {
163
+ try {
164
+ const p = readerPresencePath();
165
+ fs.mkdirSync(path.dirname(p), { recursive: true });
166
+ fs.writeFileSync(p, String(nowMs));
167
+ }
168
+ catch { /* best-effort: a failed write does not block the caller */ }
169
+ }
170
+ /**
171
+ * True when a journal consumer has signalled its presence within
172
+ * {@link ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS}.
173
+ *
174
+ * Used by the daemon warm tick to decide whether to run the expensive
175
+ * `ps`+`lsof` gather. On an idle box with no watcher the tick skips the
176
+ * gather entirely; the moment a new watcher connects it calls
177
+ * {@link noteActiveSessionsJournalReader} and the following tick gathers.
178
+ */
179
+ export function isActiveSessionsJournalReaderRecent(nowMs = Date.now(), idleWindowMs = ACTIVE_SESSIONS_READER_IDLE_WINDOW_MS) {
180
+ try {
181
+ const content = fs.readFileSync(readerPresencePath(), 'utf8').trim();
182
+ const ts = Number(content);
183
+ if (!Number.isFinite(ts))
184
+ return false;
185
+ return nowMs - ts < idleWindowMs;
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
191
+ /** Default poll cadence for {@link watchActiveSessionsReaderPresence}. */
192
+ export const ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS = 1_000;
193
+ /**
194
+ * Watch for a reader going from absent/idle to present and fire `onConnect`
195
+ * out of band, instead of waiting for the next scheduled warm tick (RUSH-2484).
196
+ *
197
+ * {@link noteActiveSessionsJournalReader} only writes a timestamp — it has no
198
+ * path back to the daemon process that owns the warm tick's `setInterval`. A
199
+ * watcher connecting to a cold or long-idle daemon therefore used to wait up
200
+ * to one full {@link ACTIVE_SESSIONS_WARM_TICK_MS} tick for its first fresh
201
+ * rows. This polls the tiny presence file (not the expensive `ps`+`lsof`
202
+ * gather) at {@link ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS} and calls
203
+ * `onConnect` only on the idle→recent edge, so a steadily-connected reader's
204
+ * repeated heartbeats never re-trigger it and an idle box with no reader never
205
+ * gathers — the CPU win from RUSH-3193 is preserved.
206
+ *
207
+ * Returns a disposer that stops the poll.
208
+ */
209
+ export function watchActiveSessionsReaderPresence(onConnect, opts = {}) {
210
+ const pollMs = opts.pollMs ?? ACTIVE_SESSIONS_READER_TRANSITION_POLL_MS;
211
+ const now = opts.nowMs ?? Date.now;
212
+ let lastRecent = isActiveSessionsJournalReaderRecent(now(), opts.idleWindowMs);
213
+ const timer = setInterval(() => {
214
+ const recent = isActiveSessionsJournalReaderRecent(now(), opts.idleWindowMs);
215
+ if (recent && !lastRecent)
216
+ onConnect();
217
+ lastRecent = recent;
218
+ }, pollMs);
219
+ timer.unref?.();
220
+ return () => clearInterval(timer);
221
+ }
134
222
  // ── snapshot read / write ──────────────────────────────────────────────────
135
223
  /** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
136
224
  export function readActiveSessionsCache(scope) {
@@ -174,7 +174,7 @@ export function extractRecentDirectoriesTouched(events, cwd) {
174
174
  if (['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write'].includes(tool)) {
175
175
  add(args.file_path ?? args.filePath ?? args.path ?? event.path, true);
176
176
  }
177
- else if (['Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute'].includes(tool)) {
177
+ else if (['Bash', 'exec_command', 'exec', 'run_shell_command', 'shell', 'Execute'].includes(tool)) {
178
178
  add(args.cwd ?? args.Cwd ?? args.workdir ?? args.working_directory ?? cwd);
179
179
  }
180
180
  }
@@ -34,6 +34,8 @@ export interface ToolCallCollectorSnapshot {
34
34
  pending: IndexedToolCall[];
35
35
  }
36
36
  export declare function sanitizeToolEvidenceText(value: string, maxBytes: number, knownValues?: readonly string[]): string;
37
+ /** Extract literal shell commands from the Codex orchestration tool without evaluating transcript code. */
38
+ export declare function commandsFromCodexExec(source: string): string[];
37
39
  export declare function structuredToolResult(value: unknown): {
38
40
  text: string;
39
41
  outcome: ToolCallOutcome;
@@ -181,7 +181,7 @@ function canonicalInput(tool, args, command) {
181
181
  }
182
182
  }
183
183
  /** Extract literal shell commands from the Codex orchestration tool without evaluating transcript code. */
184
- function commandsFromCodexExec(source) {
184
+ export function commandsFromCodexExec(source) {
185
185
  const commands = [];
186
186
  let root;
187
187
  try {
@@ -11,5 +11,9 @@ export declare function renderTrajectoryCompareHtml(cmp: TrajectoryComparison):
11
11
  * auto-discovered from the team records) plus a per-node summary card revealed
12
12
  * by clicking a node. Same shell, redaction, and no-CDN rule as the other two
13
13
  * layouts; local paths (`filePath`/`cwd`) are deliberately never rendered.
14
+ *
15
+ * `redacted` follows the trace's `--no-redact` flag so the footer states the
16
+ * honest label, exactly as the single-trajectory and compare renderers do; it
17
+ * defaults to the redacted claim to keep the shared/safe default.
14
18
  */
15
- export declare function renderLineageHtml(lineage: SessionLineage): string;
19
+ export declare function renderLineageHtml(lineage: SessionLineage, redacted?: boolean): string;
@@ -14,6 +14,15 @@
14
14
  */
15
15
  import { formatDuration, formatTokenCount } from './render.js';
16
16
  import { escapeHtml } from './share-html.js';
17
+ /**
18
+ * The honest footer prefix for a rendered trace: the redacted label only when
19
+ * something was actually redacted, else the "local only" disclaimer under
20
+ * `--no-redact`. One source for all three layouts (single / compare / lineage)
21
+ * so the label never drifts back to a hardcoded "Secret-redacted" claim.
22
+ */
23
+ function redactionLabel(redacted) {
24
+ return redacted ? 'Secret-redacted' : 'Unredacted (local only)';
25
+ }
17
26
  /** Color a tool bar by family; an error outcome overrides to red. */
18
27
  function toolColor(step) {
19
28
  if (step.outcome === 'error')
@@ -31,7 +40,7 @@ function toolColor(step) {
31
40
  return '#b98cff';
32
41
  return '#8b98a5';
33
42
  }
34
- /** Precise per-step duration for the waterfall: "8m04s", "1.6s", "320ms". */
43
+ /** Precise per-step duration for the waterfall: "2h01m", "8m04s", "1.6s", "320ms". */
35
44
  function formatStepDuration(ms) {
36
45
  if (ms <= 0)
37
46
  return '0s';
@@ -40,6 +49,13 @@ function formatStepDuration(ms) {
40
49
  if (ms < 60_000)
41
50
  return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
42
51
  const totalSec = Math.round(ms / 1000);
52
+ // Beyond an hour, minutes-only reads as "1441m18s" for an overnight idle gap —
53
+ // roll into hours so a long stall is legible at a glance.
54
+ if (totalSec >= 3600) {
55
+ const h = Math.floor(totalSec / 3600);
56
+ const m = Math.floor((totalSec % 3600) / 60);
57
+ return `${h}h${String(m).padStart(2, '0')}m`;
58
+ }
43
59
  const m = Math.floor(totalSec / 60);
44
60
  const s = totalSec % 60;
45
61
  return `${m}m${String(s).padStart(2, '0')}s`;
@@ -242,7 +258,7 @@ export function renderTrajectoryHtml(model) {
242
258
  </div>
243
259
  </main>
244
260
  <footer>
245
- ${model.truncatedSteps > 0 ? 'Truncated · ' : ''}${model.redacted ? 'Secret-redacted' : 'Unredacted (local only)'} trajectory rendered by agents-cli &middot; <code>agents sessions trace</code>
261
+ ${model.truncatedSteps > 0 ? 'Truncated · ' : ''}${redactionLabel(model.redacted)} trajectory rendered by agents-cli &middot; <code>agents sessions trace</code>
246
262
  </footer>
247
263
  <script>${THEME_SCRIPT}</script>
248
264
  </body>
@@ -335,23 +351,7 @@ const BASE_STYLE = `
335
351
  color: var(--dim); font-size: 12px;
336
352
  font-family: ui-monospace, "JetBrains Mono", Menlo, monospace;
337
353
  }
338
- `;
339
- /** Compare-only rules layered on top of {@link BASE_STYLE} — lanes, divergence marker, summary table. */
340
- const COMPARE_STYLE = `
341
- svg .diverge { stroke: #e0b341; stroke-width: 1.4; stroke-dasharray: 4 3; }
342
- .diverge-note { color: #e0b341; font-size: 12.5px; margin: 6px 0; }
343
- table.cmp-table { border-collapse: collapse; width: 100%; font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12.5px; }
344
- table.cmp-table th, table.cmp-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); }
345
- table.cmp-table th { color: var(--dim); font-weight: 600; text-transform: uppercase; font-size: 10.5px; letter-spacing: .5px; }
346
- .diff-cols { display: flex; gap: 24px; flex-wrap: wrap; }
347
- .diff-col { flex: 1; min-width: 260px; }
348
- .diff-col h3 { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .5px; margin: 0 0 8px; }
349
- .diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
350
- .diff-list li {
351
- font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12px;
352
- border: 1px solid var(--border); border-radius: 5px; padding: 5px 8px; background: var(--panel);
353
- }
354
- /* trace v2 — analysis hero + program-aware step list */
354
+ /* trace v2 — analysis hero + program-aware step list (single-session view; compare/lineage layer on top of BASE_STYLE, so this must live here) */
355
355
  main { padding: 20px; }
356
356
  .analysis { display: grid; grid-template-columns: 1.25fr 1fr; gap: 20px; margin-bottom: 26px; }
357
357
  @media (max-width: 720px) { .analysis { grid-template-columns: 1fr; } }
@@ -391,6 +391,22 @@ const COMPARE_STYLE = `
391
391
  .gap-divider { text-align: center; color: #7a5c3a; font-size: 11px; margin: 8px 0; letter-spacing: .06em; font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; }
392
392
  details.step .detail { white-space: pre-wrap; color: var(--dim); font-size: 11px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; margin: 2px 0 8px 54px; max-height: 220px; overflow: auto; }
393
393
  `;
394
+ /** Compare-only rules layered on top of {@link BASE_STYLE} — lanes, divergence marker, summary table. */
395
+ const COMPARE_STYLE = `
396
+ svg .diverge { stroke: #e0b341; stroke-width: 1.4; stroke-dasharray: 4 3; }
397
+ .diverge-note { color: #e0b341; font-size: 12.5px; margin: 6px 0; }
398
+ table.cmp-table { border-collapse: collapse; width: 100%; font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12.5px; }
399
+ table.cmp-table th, table.cmp-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); }
400
+ table.cmp-table th { color: var(--dim); font-weight: 600; text-transform: uppercase; font-size: 10.5px; letter-spacing: .5px; }
401
+ .diff-cols { display: flex; gap: 24px; flex-wrap: wrap; }
402
+ .diff-col { flex: 1; min-width: 260px; }
403
+ .diff-col h3 { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: .5px; margin: 0 0 8px; }
404
+ .diff-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
405
+ .diff-list li {
406
+ font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12px;
407
+ border: 1px solid var(--border); border-radius: 5px; padding: 5px 8px; background: var(--panel);
408
+ }
409
+ `;
394
410
  const THEME_SCRIPT = `
395
411
  (function () {
396
412
  var root = document.documentElement;
@@ -521,7 +537,7 @@ export function renderTrajectoryCompareHtml(cmp) {
521
537
  ${renderCompareDiffLists(cmp)}
522
538
  </main>
523
539
  <footer>
524
- Secret-redacted compare rendered by agents-cli &middot; <code>agents sessions trace</code>
540
+ ${redactionLabel(cmp.a.redacted || cmp.b.redacted)} compare rendered by agents-cli &middot; <code>agents sessions trace</code>
525
541
  </footer>
526
542
  <script>${THEME_SCRIPT}</script>
527
543
  </body>
@@ -696,8 +712,12 @@ const LINEAGE_SCRIPT = `
696
712
  * auto-discovered from the team records) plus a per-node summary card revealed
697
713
  * by clicking a node. Same shell, redaction, and no-CDN rule as the other two
698
714
  * layouts; local paths (`filePath`/`cwd`) are deliberately never rendered.
715
+ *
716
+ * `redacted` follows the trace's `--no-redact` flag so the footer states the
717
+ * honest label, exactly as the single-trajectory and compare renderers do; it
718
+ * defaults to the redacted claim to keep the shared/safe default.
699
719
  */
700
- export function renderLineageHtml(lineage) {
720
+ export function renderLineageHtml(lineage, redacted = true) {
701
721
  const root = lineage.nodes[0];
702
722
  const title = root ? `${root.agent} · ${root.shortId}` : 'lineage';
703
723
  const spawned = Math.max(0, lineage.nodes.length - 1);
@@ -740,7 +760,7 @@ export function renderLineageHtml(lineage) {
740
760
  </div>
741
761
  </main>
742
762
  <footer>
743
- Secret-redacted lineage rendered by agents-cli &middot; <code>agents sessions trace --tree</code>
763
+ ${redactionLabel(redacted)} lineage rendered by agents-cli &middot; <code>agents sessions trace --tree</code>
744
764
  </footer>
745
765
  <script>${THEME_SCRIPT}</script>
746
766
  <script>${LINEAGE_SCRIPT}</script>
@@ -12,7 +12,7 @@
12
12
  import { formatTokenCount } from './render.js';
13
13
  const DEFAULT_MAX_STEPS = 80;
14
14
  const LABEL_COL = 40;
15
- /** Precise, compact per-step duration: "8m04s", "1.6s", "320ms", "—" for zero. */
15
+ /** Precise, compact per-step duration: "2h01m", "8m04s", "1.6s", "320ms", "—" for zero. */
16
16
  function dur(ms, estimated) {
17
17
  if (ms <= 0)
18
18
  return estimated ? '~0s' : '0s';
@@ -23,7 +23,14 @@ function dur(ms, estimated) {
23
23
  s = `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
24
24
  else {
25
25
  const totalSec = Math.round(ms / 1000);
26
- s = `${Math.floor(totalSec / 60)}m${String(totalSec % 60).padStart(2, '0')}s`;
26
+ // Beyond an hour, roll into hours so an overnight stall reads "24h01m", not
27
+ // "1441m18s" (matches the HTML renderer's formatStepDuration).
28
+ if (totalSec >= 3600) {
29
+ s = `${Math.floor(totalSec / 3600)}h${String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0')}m`;
30
+ }
31
+ else {
32
+ s = `${Math.floor(totalSec / 60)}m${String(totalSec % 60).padStart(2, '0')}s`;
33
+ }
27
34
  }
28
35
  return estimated ? `~${s}` : s;
29
36
  }
@@ -32,10 +32,12 @@ const INLINE_TASK_TOOLS = new Set(['Task', 'Agent']);
32
32
  /**
33
33
  * Shell tools whose command is worth resolving to an effective program — every
34
34
  * harness's shell-exec tool, kept in lockstep with the canonical set in
35
- * `state.ts` (`['Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute']`)
36
- * so Codex's `exec_command` and the rest are covered, not just Claude's `Bash`.
35
+ * `state.ts` (`['Bash', 'exec_command', 'exec', 'run_shell_command', 'shell', 'Execute']`)
36
+ * so Codex's `exec_command`/`exec` and the rest are covered, not just Claude's `Bash`.
37
+ * `exec` is newer Codex (gpt-5.6-sol) whose real command the parser unwraps from a
38
+ * JS cell into the event's `command` before this runs.
37
39
  */
38
- const SHELL_TOOLS = new Set(['Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute']);
40
+ const SHELL_TOOLS = new Set(['Bash', 'exec_command', 'exec', 'run_shell_command', 'shell', 'Execute']);
39
41
  /**
40
42
  * Shell builtins/assignments that are rarely the POINT of a command — the action
41
43
  * is whatever runs after them (`export X=Y; git push` → `git`, `cd dir && bun test`
@@ -83,6 +85,52 @@ function stringArg(args, ...keys) {
83
85
  }
84
86
  return undefined;
85
87
  }
88
+ /**
89
+ * Strip a shell command's leading throwaway statements so its label reads by what
90
+ * it DID, not by its setup. Real agent commands overwhelmingly open with a
91
+ * `cd <repo> &&` (or a multi-line script whose first line is `cd <repo>`), plus the
92
+ * occasional `export X=Y;`, `set -e`, `source …`, or bare `VAR=val` assignment —
93
+ * exactly the {@link SHELL_NOISE_PROGRAMS} the badge already skips when resolving
94
+ * the effective program. Without this, every `cd /long/path && git …` row renders
95
+ * an identical `cd /long/path` and the trajectory is unreadable; here we drop that
96
+ * prefix so the label agrees with the badge (`git …`, `gh …`, `bun …`).
97
+ *
98
+ * Conservative on purpose: only a LEADING run of noise statements separated by
99
+ * `&&`, `;`, or a newline is removed (an assignment PREFIX like `VAR=val cmd` too),
100
+ * pipelines (`|`) are left intact, and a command that is nothing but noise (a lone
101
+ * `cd dir`) is returned unchanged rather than emptied. The separator match is a
102
+ * plain negated class, not a shell tokenizer, so a separator INSIDE a quoted arg
103
+ * (`cd "a; b" && x`) would cut mid-quote — guarded by refusing any strip that
104
+ * leaves the label with unbalanced quotes and falling back to the whole command.
105
+ * Display-only — the value is still clipped and secret-redacted downstream.
106
+ */
107
+ function quotesBalanced(text) {
108
+ const dq = (text.match(/(?<!\\)"/g) ?? []).length;
109
+ const sq = (text.match(/(?<!\\)'/g) ?? []).length;
110
+ return dq % 2 === 0 && sq % 2 === 0;
111
+ }
112
+ function stripLeadingShellNoise(command) {
113
+ // A leading `cd`/`export`/`set`/`source`/`unset`/`local`/`eval` statement, or an
114
+ // assignment, terminated by a statement separator (`&&`, `;`, or newline).
115
+ const noiseStatement = /^\s*(?:cd|export|set|source|unset|local|eval)\b[^\n&;|]*(?:&&|;|\n)+/i;
116
+ // A bare `VAR=val` — either a full statement (with a separator) or a prefix
117
+ // (followed by whitespace before the real program: `FOO=bar git push`).
118
+ const assignment = /^\s*[A-Za-z_][A-Za-z0-9_]*=[^\s&;|]*(?:\s*(?:&&|;|\n)+|\s+)/;
119
+ let cmd = command;
120
+ let prev = '';
121
+ while (cmd !== prev) {
122
+ prev = cmd;
123
+ cmd = cmd.replace(noiseStatement, '').replace(assignment, '');
124
+ }
125
+ const trimmed = cmd.trim();
126
+ if (trimmed.length === 0)
127
+ return command.trim();
128
+ // A strip that severed a quoted argument (a separator lived inside quotes) leaves
129
+ // a dangling quote — keep the whole command rather than show a mangled fragment.
130
+ if (!quotesBalanced(trimmed) && quotesBalanced(command))
131
+ return command.trim();
132
+ return trimmed;
133
+ }
86
134
  /**
87
135
  * A redacted one-line label for a tool call, derived from the raw arguments.
88
136
  *
@@ -94,7 +142,8 @@ function toolLabel(tool, args, command, redact, knownSecrets) {
94
142
  let raw;
95
143
  const lower = tool.toLowerCase();
96
144
  if (lower === 'bash' || lower === 'shell' || lower === 'run_command' || lower.includes('exec')) {
97
- raw = command ?? stringArg(args, 'command', 'cmd', 'script');
145
+ const cmd = command ?? stringArg(args, 'command', 'cmd', 'script');
146
+ raw = cmd ? stripLeadingShellNoise(cmd) : cmd;
98
147
  }
99
148
  else if (lower === 'read' || lower === 'edit' || lower === 'write' || lower === 'notebookedit' || lower === 'multiedit') {
100
149
  raw = stringArg(args, 'file_path', 'path', 'notebook_path', 'filePath');
@@ -247,6 +247,21 @@ export interface SessionMeta {
247
247
  * `isTeamOrigin` (which marks sessions spawned BY a team).
248
248
  */
249
249
  spawnedTeam?: string;
250
+ /**
251
+ * Fan-out this session left behind, captured at scan time so the preview can
252
+ * show it WITHOUT re-parsing the transcript — which is the only way a remote
253
+ * or unindexed row (rendered from `SessionMeta` alone) can show it at all.
254
+ *
255
+ * `undefined` and `0` are different claims and must render the same way (the
256
+ * segment omitted): `undefined` means the row predates the field or the
257
+ * harness cannot report it, `0` means scanned and none found. Rendering a
258
+ * literal "0 background shells" for a harness that cannot report them would
259
+ * assert "none running" where the truth is "unknown".
260
+ *
261
+ * Counts are "started", never "still running" — see `extractBackgroundShells`.
262
+ */
263
+ subAgentCount?: number;
264
+ backgroundShellCount?: number;
250
265
  /**
251
266
  * The plan markdown from the LAST `ExitPlanMode` tool call in the transcript
252
267
  * (Claude sessions only), captured at scan time. Present whenever the session