@phnx-labs/agents-cli 1.22.83 → 1.22.85

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 (291) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +80 -53
  3. package/dist/bootstrap.d.ts +7 -6
  4. package/dist/bootstrap.js +20 -27
  5. package/dist/cli/command-registry.js +5 -1
  6. package/dist/commands/accounts.d.ts +6 -13
  7. package/dist/commands/accounts.js +330 -143
  8. package/dist/commands/apply.js +1 -1
  9. package/dist/commands/auth-mint.d.ts +7 -3
  10. package/dist/commands/auth-mint.js +21 -9
  11. package/dist/commands/auth.js +2 -3
  12. package/dist/commands/browser.js +10 -10
  13. package/dist/commands/daemon.d.ts +7 -3
  14. package/dist/commands/daemon.js +40 -25
  15. package/dist/commands/doctor.js +28 -2
  16. package/dist/commands/exec.js +87 -39
  17. package/dist/commands/fleet-capture.js +21 -2
  18. package/dist/commands/harness-wizard.js +2 -2
  19. package/dist/commands/lease.js +7 -12
  20. package/dist/commands/profiles.d.ts +2 -2
  21. package/dist/commands/profiles.js +20 -13
  22. package/dist/commands/repo.js +3 -3
  23. package/dist/commands/run-account-picker.d.ts +3 -3
  24. package/dist/commands/run-account-picker.js +29 -30
  25. package/dist/commands/secrets-passthrough.d.ts +20 -0
  26. package/dist/commands/secrets-passthrough.js +43 -0
  27. package/dist/commands/sessions-trace.d.ts +11 -0
  28. package/dist/commands/sessions-trace.js +71 -0
  29. package/dist/commands/setup-accounts.d.ts +3 -2
  30. package/dist/commands/setup-accounts.js +4 -4
  31. package/dist/commands/setup-secrets.d.ts +16 -21
  32. package/dist/commands/setup-secrets.js +43 -224
  33. package/dist/commands/ssh.js +2 -2
  34. package/dist/commands/sync.js +1 -1
  35. package/dist/commands/update.d.ts +10 -0
  36. package/dist/commands/update.js +43 -40
  37. package/dist/commands/versions.d.ts +11 -11
  38. package/dist/commands/versions.js +131 -83
  39. package/dist/commands/view.d.ts +1 -9
  40. package/dist/commands/view.js +41 -55
  41. package/dist/commands/webhook.js +5 -5
  42. package/dist/commands/workflows.js +2 -1
  43. package/dist/index.d.ts +12 -12
  44. package/dist/index.js +13 -42
  45. package/dist/lib/account-capabilities.d.ts +1 -1
  46. package/dist/lib/account-capabilities.js +1 -1
  47. package/dist/lib/account-catalog.d.ts +121 -6
  48. package/dist/lib/account-catalog.js +421 -7
  49. package/dist/lib/account-registry.d.ts +8 -30
  50. package/dist/lib/account-registry.js +36 -79
  51. package/dist/lib/account-schema.d.ts +1 -1
  52. package/dist/lib/account-schema.js +1 -1
  53. package/dist/lib/accounting/account-pool-collect.js +16 -4
  54. package/dist/lib/accounting/rotate.d.ts +20 -7
  55. package/dist/lib/accounting/rotate.js +89 -13
  56. package/dist/lib/accounting/usage.js +11 -16
  57. package/dist/lib/accounts/add.d.ts +138 -0
  58. package/dist/lib/accounts/add.js +651 -0
  59. package/dist/lib/accounts/migrate.d.ts +117 -0
  60. package/dist/lib/accounts/migrate.js +536 -0
  61. package/dist/lib/accounts/slots.d.ts +13 -0
  62. package/dist/lib/accounts/slots.js +100 -0
  63. package/dist/lib/agent-spec/agents.d.ts +16 -0
  64. package/dist/lib/agent-spec/agents.js +32 -4
  65. package/dist/lib/app-bundle-install.js +5 -4
  66. package/dist/lib/auth-health.d.ts +3 -0
  67. package/dist/lib/auth-health.js +11 -0
  68. package/dist/lib/auth-mint.d.ts +52 -16
  69. package/dist/lib/auth-mint.js +117 -39
  70. package/dist/lib/browser/chrome.d.ts +1 -1
  71. package/dist/lib/browser/chrome.js +5 -5
  72. package/dist/lib/byok-usage.js +3 -3
  73. package/dist/lib/claude-account-token.d.ts +47 -3
  74. package/dist/lib/claude-account-token.js +172 -50
  75. package/dist/lib/cloud/antigravity.js +4 -4
  76. package/dist/lib/cloud/cursor.js +4 -4
  77. package/dist/lib/crabbox/cli.d.ts +1 -1
  78. package/dist/lib/crabbox/cli.js +6 -6
  79. package/dist/lib/crabbox/runtimes.d.ts +3 -3
  80. package/dist/lib/crabbox/runtimes.js +5 -5
  81. package/dist/lib/daemon/account-state-daemon-service.d.ts +37 -1
  82. package/dist/lib/daemon/account-state-daemon-service.js +199 -3
  83. package/dist/lib/daemon/auth-sync-service.js +28 -1
  84. package/dist/lib/daemon/daemon.d.ts +6 -16
  85. package/dist/lib/daemon/daemon.js +52 -97
  86. package/dist/lib/daemon/daemon.test-fixture.d.ts +7 -9
  87. package/dist/lib/daemon/daemon.test-fixture.js +22 -40
  88. package/dist/lib/daemon/harness-update-service.d.ts +1 -1
  89. package/dist/lib/daemon/harness-update-service.js +1 -1
  90. package/dist/lib/daemon/runner.d.ts +15 -3
  91. package/dist/lib/daemon/runner.js +49 -24
  92. package/dist/lib/daemon-health.d.ts +1 -2
  93. package/dist/lib/daemon-health.js +7 -6
  94. package/dist/lib/daemon-services.d.ts +1 -1
  95. package/dist/lib/daemon-services.js +1 -11
  96. package/dist/lib/daemon-ticks.js +23 -2
  97. package/dist/lib/daemon-webhooks.d.ts +8 -7
  98. package/dist/lib/daemon-webhooks.js +10 -9
  99. package/dist/lib/device-config.js +1 -1
  100. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  101. package/dist/lib/devices/harness-inventory.d.ts +39 -0
  102. package/dist/lib/devices/harness-inventory.js +126 -4
  103. package/dist/lib/doctor-diff.js +2 -1
  104. package/dist/lib/exec-account-home.d.ts +38 -0
  105. package/dist/lib/exec-account-home.js +164 -0
  106. package/dist/lib/exec.d.ts +30 -0
  107. package/dist/lib/exec.js +101 -31
  108. package/dist/lib/feed/activity-stream.d.ts +60 -0
  109. package/dist/lib/feed/activity-stream.js +271 -0
  110. package/dist/lib/feed/activity.d.ts +7 -0
  111. package/dist/lib/feed/activity.js +10 -4
  112. package/dist/lib/feed/watch.d.ts +9 -3
  113. package/dist/lib/feed/watch.js +72 -36
  114. package/dist/lib/fleet/apply.d.ts +2 -2
  115. package/dist/lib/fleet/apply.js +4 -4
  116. package/dist/lib/fleet/auth-sync.js +4 -3
  117. package/dist/lib/fleet-shared-repo-sync.js +6 -9
  118. package/dist/lib/fleet-shared-state.d.ts +6 -0
  119. package/dist/lib/harness/adapter.d.ts +7 -7
  120. package/dist/lib/harness/adapter.js +10 -6
  121. package/dist/lib/harness/adapters/grok.js +8 -3
  122. package/dist/lib/harness/adapters/muse.js +1 -1
  123. package/dist/lib/harness/adapters/opencode.js +12 -3
  124. package/dist/lib/harness-auth-capabilities.d.ts +61 -0
  125. package/dist/lib/harness-auth-capabilities.js +52 -0
  126. package/dist/lib/helper-versions.d.ts +6 -4
  127. package/dist/lib/helper-versions.js +5 -4
  128. package/dist/lib/hosts/credential-transport.d.ts +10 -0
  129. package/dist/lib/hosts/credential-transport.js +37 -0
  130. package/dist/lib/hosts/dispatch.d.ts +7 -0
  131. package/dist/lib/hosts/dispatch.js +13 -6
  132. package/dist/lib/identity/client.d.ts +3 -3
  133. package/dist/lib/identity/client.js +3 -3
  134. package/dist/lib/installations/index.d.ts +1 -1
  135. package/dist/lib/installations/index.js +1 -1
  136. package/dist/lib/installations/migrate.js +9 -0
  137. package/dist/lib/installations/resolve.js +1 -1
  138. package/dist/lib/installations/shims.d.ts +15 -0
  139. package/dist/lib/installations/shims.js +69 -0
  140. package/dist/lib/installations/store.d.ts +43 -0
  141. package/dist/lib/installations/store.js +75 -7
  142. package/dist/lib/installations/versions.js +1 -1
  143. package/dist/lib/menubar/install-menubar.d.ts +3 -3
  144. package/dist/lib/menubar/install-menubar.js +3 -3
  145. package/dist/lib/native-accounts.d.ts +35 -0
  146. package/dist/lib/native-accounts.js +41 -0
  147. package/dist/lib/net-close.d.ts +11 -0
  148. package/dist/lib/net-close.js +25 -0
  149. package/dist/lib/openclaw-keychain.js +27 -1
  150. package/dist/lib/profiles.js +22 -9
  151. package/dist/lib/project-resources.js +1 -1
  152. package/dist/lib/reserved-stores.d.ts +53 -0
  153. package/dist/lib/reserved-stores.js +120 -0
  154. package/dist/lib/secrets-client.d.ts +191 -0
  155. package/dist/lib/secrets-client.js +710 -0
  156. package/dist/lib/secrets-policy.d.ts +222 -0
  157. package/dist/lib/secrets-policy.js +483 -0
  158. package/dist/lib/secrets-types.d.ts +188 -0
  159. package/dist/lib/secrets-types.js +24 -0
  160. package/dist/lib/service-manifest.js +3 -4
  161. package/dist/lib/session/active.d.ts +38 -4
  162. package/dist/lib/session/active.js +36 -4
  163. package/dist/lib/session/bash-command.js +10 -0
  164. package/dist/lib/session/db.d.ts +65 -2
  165. package/dist/lib/session/db.js +178 -18
  166. package/dist/lib/session/mirror.d.ts +5 -0
  167. package/dist/lib/session/mirror.js +166 -0
  168. package/dist/lib/session/parse.d.ts +49 -0
  169. package/dist/lib/session/parse.js +324 -30
  170. package/dist/lib/session/prompt.d.ts +93 -2
  171. package/dist/lib/session/prompt.js +266 -15
  172. package/dist/lib/session/remote/peer-stream.d.ts +47 -0
  173. package/dist/lib/session/remote/peer-stream.js +142 -0
  174. package/dist/lib/session/remote/watch.d.ts +1 -1
  175. package/dist/lib/session/remote/watch.js +41 -42
  176. package/dist/lib/session/session-cache.d.ts +9 -0
  177. package/dist/lib/session/session-cache.js +40 -2
  178. package/dist/lib/session/sync/config.js +2 -2
  179. package/dist/lib/session/timeline-pass.d.ts +129 -0
  180. package/dist/lib/session/timeline-pass.js +323 -0
  181. package/dist/lib/session/timeline.d.ts +182 -0
  182. package/dist/lib/session/timeline.js +636 -0
  183. package/dist/lib/session/types.d.ts +155 -1
  184. package/dist/lib/sha256-asset.d.ts +11 -16
  185. package/dist/lib/sha256-asset.js +11 -16
  186. package/dist/lib/share/config.js +14 -5
  187. package/dist/lib/signin-badge.d.ts +26 -0
  188. package/dist/lib/signin-badge.js +42 -0
  189. package/dist/lib/staleness/detectors/workflows.js +3 -105
  190. package/dist/lib/staleness/writers/workflows.js +2 -1
  191. package/dist/lib/state.d.ts +7 -4
  192. package/dist/lib/state.js +18 -7
  193. package/dist/lib/summarizer/pass.d.ts +2 -0
  194. package/dist/lib/summarizer/pass.js +14 -1
  195. package/dist/lib/summarizer/summarize.d.ts +7 -0
  196. package/dist/lib/summarizer/summarize.js +3 -0
  197. package/dist/lib/sync-umbrella.js +2 -2
  198. package/dist/lib/teams/agents.js +1 -1
  199. package/dist/lib/types.d.ts +74 -33
  200. package/dist/lib/view-types.d.ts +6 -3
  201. package/dist/lib/workflows-registry.d.ts +71 -0
  202. package/dist/lib/workflows-registry.js +280 -0
  203. package/dist/lib/workflows.d.ts +21 -21
  204. package/dist/lib/workflows.js +12 -327
  205. package/package.json +2 -3
  206. package/scripts/postinstall.js +4 -29
  207. package/dist/commands/secrets-import.d.ts +0 -18
  208. package/dist/commands/secrets-import.js +0 -74
  209. package/dist/commands/secrets-migrate.d.ts +0 -25
  210. package/dist/commands/secrets-migrate.js +0 -334
  211. package/dist/commands/secrets-rotate-passphrase.d.ts +0 -17
  212. package/dist/commands/secrets-rotate-passphrase.js +0 -96
  213. package/dist/commands/secrets-sync.d.ts +0 -11
  214. package/dist/commands/secrets-sync.js +0 -153
  215. package/dist/commands/secrets-vault.d.ts +0 -10
  216. package/dist/commands/secrets-vault.js +0 -130
  217. package/dist/commands/secrets.d.ts +0 -212
  218. package/dist/commands/secrets.js +0 -3030
  219. package/dist/lib/accounts/connect.d.ts +0 -170
  220. package/dist/lib/accounts/connect.js +0 -383
  221. package/dist/lib/daemon/keychain-reap-service.d.ts +0 -17
  222. package/dist/lib/daemon/keychain-reap-service.js +0 -32
  223. package/dist/lib/daemon/secrets-broker-service.d.ts +0 -21
  224. package/dist/lib/daemon/secrets-broker-service.js +0 -51
  225. package/dist/lib/secrets/agent.d.ts +0 -358
  226. package/dist/lib/secrets/agent.js +0 -1291
  227. package/dist/lib/secrets/audit.d.ts +0 -46
  228. package/dist/lib/secrets/audit.js +0 -101
  229. package/dist/lib/secrets/bundles.d.ts +0 -290
  230. package/dist/lib/secrets/bundles.js +0 -1547
  231. package/dist/lib/secrets/download-keychain.d.ts +0 -47
  232. package/dist/lib/secrets/download-keychain.js +0 -70
  233. package/dist/lib/secrets/drivers/rush.d.ts +0 -14
  234. package/dist/lib/secrets/drivers/rush.js +0 -90
  235. package/dist/lib/secrets/fallback.d.ts +0 -48
  236. package/dist/lib/secrets/fallback.js +0 -48
  237. package/dist/lib/secrets/filestore.d.ts +0 -222
  238. package/dist/lib/secrets/filestore.js +0 -1099
  239. package/dist/lib/secrets/headless.d.ts +0 -43
  240. package/dist/lib/secrets/headless.js +0 -56
  241. package/dist/lib/secrets/icloud-import.d.ts +0 -79
  242. package/dist/lib/secrets/icloud-import.js +0 -206
  243. package/dist/lib/secrets/index.d.ts +0 -451
  244. package/dist/lib/secrets/index.js +0 -1568
  245. package/dist/lib/secrets/install-helper.d.ts +0 -72
  246. package/dist/lib/secrets/install-helper.js +0 -245
  247. package/dist/lib/secrets/lease.d.ts +0 -25
  248. package/dist/lib/secrets/lease.js +0 -44
  249. package/dist/lib/secrets/linux.d.ts +0 -77
  250. package/dist/lib/secrets/linux.js +0 -393
  251. package/dist/lib/secrets/list-filter.d.ts +0 -109
  252. package/dist/lib/secrets/list-filter.js +0 -261
  253. package/dist/lib/secrets/mcp.d.ts +0 -93
  254. package/dist/lib/secrets/mcp.js +0 -211
  255. package/dist/lib/secrets/profiles.d.ts +0 -10
  256. package/dist/lib/secrets/profiles.js +0 -13
  257. package/dist/lib/secrets/push.d.ts +0 -133
  258. package/dist/lib/secrets/push.js +0 -273
  259. package/dist/lib/secrets/rc-hygiene.d.ts +0 -63
  260. package/dist/lib/secrets/rc-hygiene.js +0 -143
  261. package/dist/lib/secrets/read-backoff.d.ts +0 -27
  262. package/dist/lib/secrets/read-backoff.js +0 -64
  263. package/dist/lib/secrets/reaper.d.ts +0 -90
  264. package/dist/lib/secrets/reaper.js +0 -243
  265. package/dist/lib/secrets/remote.d.ts +0 -152
  266. package/dist/lib/secrets/remote.js +0 -344
  267. package/dist/lib/secrets/reserved-sync.d.ts +0 -66
  268. package/dist/lib/secrets/reserved-sync.js +0 -147
  269. package/dist/lib/secrets/scope.d.ts +0 -26
  270. package/dist/lib/secrets/scope.js +0 -29
  271. package/dist/lib/secrets/session-store.d.ts +0 -107
  272. package/dist/lib/secrets/session-store.js +0 -342
  273. package/dist/lib/secrets/sync-backend.d.ts +0 -48
  274. package/dist/lib/secrets/sync-backend.js +0 -13
  275. package/dist/lib/secrets/sync-commands.d.ts +0 -21
  276. package/dist/lib/secrets/sync-commands.js +0 -21
  277. package/dist/lib/secrets/sync.d.ts +0 -48
  278. package/dist/lib/secrets/sync.js +0 -237
  279. package/dist/lib/secrets/unlock-hints.d.ts +0 -27
  280. package/dist/lib/secrets/unlock-hints.js +0 -36
  281. package/dist/lib/secrets/usage-db.d.ts +0 -46
  282. package/dist/lib/secrets/usage-db.js +0 -96
  283. package/dist/lib/secrets/vault-age-helper.d.ts +0 -1
  284. package/dist/lib/secrets/vault-age-helper.js +0 -34
  285. package/dist/lib/secrets/vault.d.ts +0 -49
  286. package/dist/lib/secrets/vault.js +0 -399
  287. package/dist/lib/secrets/windows.d.ts +0 -81
  288. package/dist/lib/secrets/windows.js +0 -556
  289. package/scripts/install-helper.js +0 -97
  290. /package/dist/lib/{secrets/sync-passphrase.d.ts → sync-passphrase.d.ts} +0 -0
  291. /package/dist/lib/{secrets/sync-passphrase.js → sync-passphrase.js} +0 -0
@@ -5,16 +5,6 @@
5
5
  * wrapper tags, team-spawn boilerplate) so that only the real user intent
6
6
  * remains. Used by the session picker to show a human-readable topic line.
7
7
  */
8
- /** Patterns that cause the entire message to be skipped for topic extraction. */
9
- const WHOLE_MESSAGE_SKIP_PATTERNS = [
10
- /<permissions instructions>/i,
11
- /<collaboration_mode>/i,
12
- /^# AGENTS\.md instructions for\b/im,
13
- /<local-command-caveat>/i,
14
- // Slash-command invocations (e.g. /continue, /done) — actual user intent
15
- // lives in the next user message, not in these wrapper tags.
16
- /<command-(message|name|args)>/i,
17
- ];
18
8
  /** Per-line noise patterns stripped during prompt cleaning (env context, bare paths, etc). */
19
9
  const NOISE_LINE_PATTERNS = [
20
10
  /^(cwd|shell|current_date|timezone|os|platform|arch|home|user)\b\s*:/i,
@@ -58,6 +48,18 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
58
48
  /^\s*\[Request interrupted/i,
59
49
  /^\s*Base directory for this skill:/i,
60
50
  /^\s*[A-Za-z][A-Za-z-]* hook feedback:/,
51
+ /^\s*<\/?notification\b/i,
52
+ /^\s*<\/?hook_?result\b/i,
53
+ /^\s*\[SYSTEM NOTIFICATION/i,
54
+ /^\s*\[Image: original/i,
55
+ /^\s*Skill tool loaded instructions/i,
56
+ // A skill expansion the harness injects when a `/name` command resolves: the
57
+ // routing line the loader prepends to the skill body.
58
+ /^\s*\*\*`\/[\w:-]+` routes to/,
59
+ // The AGENTS.md preamble Claude injects ahead of a turn. `m` because it can
60
+ // follow other injected blocks rather than opening the message; this is the
61
+ // one pattern that only `extractSessionTopic` used to carry.
62
+ /^# AGENTS\.md instructions for\b/im,
61
63
  ];
62
64
  /**
63
65
  * True when a `role=user` message is harness-injected scaffolding, not a genuine
@@ -92,6 +94,25 @@ export function cleanFirstUserMessage(raw) {
92
94
  const clean = unwrapUserQuery(raw);
93
95
  return clean || undefined;
94
96
  }
97
+ /**
98
+ * LATEST genuine user message from an already-normalized event stream.
99
+ *
100
+ * The operative request of a `/continue`d, redirected, or interrupted-and-
101
+ * restated session is the last thing the user said, not the first (PHNX-3939).
102
+ * Same rejection rules as {@link firstUserMessageFromEvents} — walking from the
103
+ * end so a long transcript stops at the first genuine turn it meets.
104
+ */
105
+ export function lastUserMessageFromEvents(events) {
106
+ for (let i = events.length - 1; i >= 0; i--) {
107
+ const event = events[i];
108
+ if (event.type !== 'message' || event.role !== 'user' || event._synthetic)
109
+ continue;
110
+ const latest = cleanFirstUserMessage(event.content);
111
+ if (latest)
112
+ return latest;
113
+ }
114
+ return undefined;
115
+ }
95
116
  /** First genuine user message from an already-normalized event stream. */
96
117
  export function firstUserMessageFromEvents(events) {
97
118
  for (const event of events) {
@@ -257,19 +278,249 @@ export function cleanGeneratedSessionLabel(title) {
257
278
  const classified = classifyUserPrompt(trimmed);
258
279
  return classified.kind === 'skill' ? classified.clean : trimmed;
259
280
  }
260
- /** Extract a one-line topic from a raw user message, or undefined if the message is pure noise. */
281
+ /**
282
+ * Extract a one-line topic from a raw user message, or undefined if the message
283
+ * is pure noise.
284
+ *
285
+ * Rejection is {@link isSyntheticUserMessage} — the SAME list the turn cleaner
286
+ * uses. This function used to consult a shorter, looser copy of it
287
+ * (permissions / collaboration / AGENTS.md / local-command-caveat /
288
+ * `<command-*>` only), which is how a `/model` echo (`<local-command-stdout>`)
289
+ * or a skill body ("Base directory for this skill:") became a session's topic —
290
+ * and then its displayed title — while `cleanFirstUserMessage` rejected the same
291
+ * text (PHNX-3939). Whatever is not a genuine user turn is not a topic either.
292
+ *
293
+ * The check runs twice on purpose: once on the raw message, and again after
294
+ * {@link cleanSessionPrompt} strips wrapper tags, since stripping can expose a
295
+ * marker (a skill body) that the tags were hiding.
296
+ */
261
297
  export function extractSessionTopic(raw) {
262
298
  if (!raw.trim())
263
299
  return undefined;
264
- if (WHOLE_MESSAGE_SKIP_PATTERNS.some(pattern => pattern.test(raw))) {
300
+ if (isSyntheticUserMessage(raw))
265
301
  return undefined;
266
- }
267
302
  const cleaned = cleanSessionPrompt(raw);
268
303
  if (!cleaned)
269
304
  return undefined;
270
- if (WHOLE_MESSAGE_SKIP_PATTERNS.some(pattern => pattern.test(cleaned))) {
305
+ if (isSyntheticUserMessage(cleaned))
271
306
  return undefined;
272
- }
273
307
  const firstLine = cleaned.split('\n').map(line => line.trim()).find(Boolean);
274
308
  return firstLine || undefined;
275
309
  }
310
+ /** `host:/abs/path.png`, `/abs/path.pdf`, or a bare `@rel/path` mention. */
311
+ const REQUEST_ATTACHMENT_RE = /^(?:[\w.-]+:)?\/\S.*\.(?:png|jpe?g|gif|pdf|mov|mp4)$|^@\S+$/i;
312
+ /**
313
+ * A line the user pasted out of a terminal rather than typed: a shell prompt
314
+ * echo (`box % `, `$ `), the CLI's own run banners, a resume hint, or the
315
+ * box-drawing frame of a TUI panel — a pasted `│ script (hooks, daemon, ext)`
316
+ * row is chrome, and it read as the request's first sentence before this.
317
+ */
318
+ const REQUEST_ECHO_RE = /^\S+ % |^\[agents\]|^Running: |^Session [0-9a-f]{8}|^\s*Resume later:|^✔ |^\s*Week \d+% left|^\s*\$ |^[\u2500-\u257F\u2580-\u259F]/;
319
+ /** The full `<command-name>…</command-name> … <command-args>…</command-args>` pair. */
320
+ const COMMAND_WRAPPER_PAIR_RE = /<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>[\s\S]*?<command-args>([\s\S]*?)<\/command-args>/;
321
+ /** `/name <id> [prose]` — a slash command typed with a session id argument. */
322
+ const SLASH_WITH_ID_RE = /^\/([\w:-]+)\s+([A-Za-z0-9_-]{8,})\s*([\s\S]*)$/;
323
+ /** `$name <id>` — the same invocation as the resume banner echoes it. */
324
+ const DOLLAR_WITH_ID_RE = /^\s*\$(\w[\w:-]*)\s+([0-9a-f-]{8,})\s*([\s\S]*)$/;
325
+ /** The stock body `/continue` and friends append when there is nothing else to say. */
326
+ const CONTINUE_FILLER = "Let's continue this session.";
327
+ /** A sentence ends, or continues past, a terminator — the tell that a line is prose. */
328
+ const SENTENCE_PUNCTUATION_RE = /[.?!]\s|[.?!]$/;
329
+ /**
330
+ * An absolute or `~`-rooted image path appearing INSIDE a line of prose. Same
331
+ * shape as {@link IMAGE_PATH_RE}, global so every path on the line is pulled
332
+ * out; matched across spaces because a CleanShot filename carries them.
333
+ */
334
+ const INLINE_IMAGE_PATH_RE = /[\/~][^\n]*?\.(?:png|jpe?g|gif|webp|heic|bmp|svg)\b/gi;
335
+ /**
336
+ * A bare session UUID opening the turn — how `agents message <id> <text>`
337
+ * arrives. Full 8-4-4-4-12 only, so an ordinary word is never mistaken for one.
338
+ */
339
+ const LEADING_SESSION_UUID_RE = /^\s*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\s+/i;
340
+ /** Leading tokens that mark a multi-line paste as terminal output, not a question. */
341
+ const PASTED_OUTPUT_HEAD_RE = /^(agents|git|gh|npm|bun|ls|cat|curl|ssh|scp)\b/;
342
+ /**
343
+ * First sentence of a block of text, flattened and capped.
344
+ *
345
+ * Leading markdown headings are skipped so the headline is the ask, not the
346
+ * section label. A block that then opens with markdown structure (a table row, a
347
+ * bullet, a numbered item) is reduced to its first LINE — a sentence-terminator
348
+ * scan across a table would otherwise splice two cells together. Markdown
349
+ * emphasis characters are dropped so a headline never renders as raw syntax. A
350
+ * "sentence" shorter than 12 characters is not one (an abbreviation, a version
351
+ * number), so the whole flattened text is kept instead.
352
+ */
353
+ export function firstSentence(text, cap = 96) {
354
+ // A block that opens with markdown HEADINGS names its section before it says
355
+ // anything: `## Mission` / `Ship the CLI half of …` headlines as "Mission",
356
+ // which is the label, not the ask. Skip leading heading lines and headline the
357
+ // first real line under them. A heading-only block keeps the heading — there
358
+ // is nothing else, and inventing a line is not an option.
359
+ let body = text.replace(/\r/g, '').trimStart();
360
+ while (/^#{1,6}\s/.test(body)) {
361
+ const newline = body.indexOf('\n');
362
+ if (newline < 0)
363
+ break;
364
+ const rest = body.slice(newline + 1).trimStart();
365
+ if (!rest)
366
+ break;
367
+ body = rest;
368
+ }
369
+ const trimmed = body.trim() || text.trim();
370
+ const structured = /^([|\-*]|1\.)/.test(trimmed);
371
+ const source = structured ? (trimmed.split('\n')[0] ?? '') : trimmed;
372
+ const flat = source
373
+ // A leading list marker is syntax, not words.
374
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/, '')
375
+ .replace(/[*`#|_]+/g, '')
376
+ .replace(/\s+/g, ' ')
377
+ .trim();
378
+ const match = flat.match(/^(.+?[.!?])(\s|$)/);
379
+ const sentence = match && match[1].length >= 12 ? match[1] : flat;
380
+ return sentence.length <= cap ? sentence : `${sentence.slice(0, cap - 1).trimEnd()}…`;
381
+ }
382
+ /** A `/name <id>` invocation with no prose of its own. */
383
+ function bareCommandRequest(command) {
384
+ return { kind: 'skill', command, text: '', headline: command, attachments: [], pastedLines: 0 };
385
+ }
386
+ /** Image attachments the row carries that the prose did not already name. */
387
+ function attachmentsFromRow(attachments, already) {
388
+ if (!attachments?.length)
389
+ return [];
390
+ const seen = new Set(already.map((a) => a.name));
391
+ const extra = [];
392
+ for (const attachment of attachments) {
393
+ const name = (attachment.name || attachment.path || '').split(/[\\/]/).pop()?.trim();
394
+ if (!name || seen.has(name))
395
+ continue;
396
+ seen.add(name);
397
+ extra.push({ kind: attachment.mediaType?.startsWith('image/') ? 'image' : 'file', name });
398
+ }
399
+ return extra;
400
+ }
401
+ /**
402
+ * Split one raw user turn into prose, attachments and pasted terminal echo
403
+ * WITHOUT rewriting a word of it (PHNX-3939). Returns `undefined` when the turn
404
+ * is harness-injected scaffolding or carries nothing to show.
405
+ *
406
+ * Order matters and mirrors how a turn is actually assembled:
407
+ * 1. a `<user_query>` wrapper is unwrapped (Grok/Cursor);
408
+ * 2. a `/name <id>` or `<command-name>` invocation becomes `command`, and any
409
+ * prose inside `<command-args>` becomes the request itself — this is what
410
+ * makes a `/plan -- Wait hold on…` turn read as the user's sentence rather
411
+ * than as a bare command name;
412
+ * 3. whole-message scaffolding is rejected;
413
+ * 4. a multi-line paste that opens with a command and asks nothing is pasted
414
+ * output, not a request;
415
+ * 5. every remaining segment (split on ` -- ` and per line) is classified as
416
+ * an attachment, terminal echo, or prose.
417
+ *
418
+ * `opts.attachments` are the row's own recorded attachments, folded in so an
419
+ * image the harness captured but the text never named still shows as a chip.
420
+ */
421
+ export function tidyRequest(raw, opts = {}, depth = 0) {
422
+ let text = (raw ?? '').trim().replace(/^"+/, '');
423
+ if (!text)
424
+ return undefined;
425
+ text = unwrapUserQuery(text);
426
+ const wrapped = COMMAND_WRAPPER_PAIR_RE.exec(text);
427
+ let prefix;
428
+ if (!wrapped && depth < 3) {
429
+ const slash = SLASH_WITH_ID_RE.exec(text);
430
+ if (slash) {
431
+ const command = `/${slash[1]} ${slash[2].slice(0, 8)}`;
432
+ const prose = slash[3].trim();
433
+ if (!prose || prose === CONTINUE_FILLER)
434
+ return bareCommandRequest(command);
435
+ const inner = tidyRequest(prose, opts, depth + 1);
436
+ if (!inner)
437
+ return undefined;
438
+ return { ...inner, command, headline: `/${slash[1]} · ${inner.headline}` };
439
+ }
440
+ const dollar = DOLLAR_WITH_ID_RE.exec(text);
441
+ if (dollar && !dollar[3].replace(CONTINUE_FILLER, '').trim()) {
442
+ return bareCommandRequest(`/${dollar[1]} ${dollar[2].slice(0, 8)}`);
443
+ }
444
+ }
445
+ if (wrapped) {
446
+ const name = wrapped[1].startsWith('/') ? wrapped[1] : `/${wrapped[1]}`;
447
+ const args = wrapped[2].trim().replace(/^--\s*/, '');
448
+ if (!args || /^[0-9a-f-]{8,}$/i.test(args)) {
449
+ return bareCommandRequest(`${name} ${args.slice(0, 8)}`.trim());
450
+ }
451
+ text = args;
452
+ prefix = name;
453
+ }
454
+ if (isSyntheticUserMessage(text))
455
+ return undefined;
456
+ const lines = text.split('\n').filter((line) => line.trim());
457
+ if (lines.length >= 3 && PASTED_OUTPUT_HEAD_RE.test(lines[0].trim()) && !text.slice(0, 200).includes('?')) {
458
+ return {
459
+ kind: 'command',
460
+ text: '',
461
+ headline: `pasted terminal output (${lines.length} lines)`,
462
+ attachments: [],
463
+ pastedLines: lines.length,
464
+ };
465
+ }
466
+ const prose = [];
467
+ const attachments = [];
468
+ let pastedLines = 0;
469
+ for (const segment of text.split(/\s+--\s+/)) {
470
+ for (const rawLine of segment.split('\n')) {
471
+ const line = rawLine.trim();
472
+ if (!line)
473
+ continue;
474
+ if (REQUEST_ATTACHMENT_RE.test(line)) {
475
+ const kind = line.startsWith('@')
476
+ ? 'dir'
477
+ : /\.(png|jpe?g|gif)$/i.test(line) ? 'image' : 'file';
478
+ const name = line.startsWith('@') ? line : (line.split(':').pop() ?? line).split('/').pop() ?? line;
479
+ attachments.push({ kind, name });
480
+ continue;
481
+ }
482
+ // A long line that is mostly flags and never punctuates is a pasted
483
+ // command invocation, not a sentence — the `agents run … --device auto
484
+ // --cwd … --session-id … --strategy balanced` banner a dispatch echoes.
485
+ const flagSoup = line.length > 80
486
+ && (line.match(/\s--\w/g) ?? []).length >= 3
487
+ && !SENTENCE_PUNCTUATION_RE.test(line);
488
+ // The `<id> --flag` shape a banner continues on, and any flag-bearing
489
+ // continuation once a pasted line has already been seen — so a sentence
490
+ // containing a dash survives when nothing was pasted.
491
+ const continuesPaste = pastedLines > 0 && /--\w+/.test(line) && !SENTENCE_PUNCTUATION_RE.test(line);
492
+ if (REQUEST_ECHO_RE.test(line) || /^[0-9a-f-]{8,}\s+--/.test(line) || flagSoup || continuesPaste) {
493
+ pastedLines++;
494
+ continue;
495
+ }
496
+ // An image path INSIDE a prose line (a screenshot dropped mid-sentence)
497
+ // becomes a chip and leaves the sentence intact, rather than sitting in
498
+ // the middle of the request text as a wall of path.
499
+ const withoutImages = line.replace(INLINE_IMAGE_PATH_RE, (match) => {
500
+ attachments.push({ kind: 'image', name: match.split('/').pop() ?? match });
501
+ return '';
502
+ }).replace(/\s{2,}/g, ' ').trim();
503
+ if (withoutImages)
504
+ prose.push(withoutImages);
505
+ }
506
+ }
507
+ attachments.push(...attachmentsFromRow(opts.attachments, attachments));
508
+ const joined = prose.join(' ').trim();
509
+ if (!joined && attachments.length === 0)
510
+ return undefined;
511
+ // Headline from the LINES, not the joined text: `firstSentence` skips a leading
512
+ // markdown heading, which it can only see while the line breaks survive. A
513
+ // leading bare session UUID is addressing, not the ask (`agents message
514
+ // <id> <text>` lands as `<uuid> Hey Claude…`), so it is dropped from the
515
+ // headline — the full id stays in `text`.
516
+ const headlineSource = prose.join('\n').replace(LEADING_SESSION_UUID_RE, '');
517
+ const headline = joined ? firstSentence(headlineSource || prose.join('\n'), 120) : '[image]';
518
+ return {
519
+ kind: joined ? 'text' : 'image',
520
+ text: joined,
521
+ headline: prefix ? `${prefix} · ${headline}` : headline,
522
+ ...(prefix ? { command: prefix } : {}),
523
+ attachments,
524
+ pastedLines,
525
+ };
526
+ }
@@ -0,0 +1,47 @@
1
+ import type { DeviceProfile } from '../../devices/registry.js';
2
+ /** First reconnect delay after a failed subscription. */
3
+ export declare const PEER_BACKOFF_BASE_MS = 2000;
4
+ /** Ceiling the doubling delay saturates at. */
5
+ export declare const PEER_BACKOFF_CAP_MS = 60000;
6
+ /** Consecutive failed spawns before the peer is parked. */
7
+ export declare const PEER_PARK_AFTER_FAILURES = 3;
8
+ /** Bytes of a peer's stderr retained for the `unavailable` reason. */
9
+ export declare const PEER_STDERR_BYTES = 2048;
10
+ /** How often a parked peer re-checks the device registry for a refresh. */
11
+ export declare const PEER_REGISTRY_POLL_MS = 5000;
12
+ /**
13
+ * Reconnect delay for `failures` consecutive failed spawns: 0 for a healthy
14
+ * peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS}.
15
+ */
16
+ export declare function peerBackoffDelayMs(failures: number, base?: number, cap?: number): number;
17
+ export interface PeerStreamOptions {
18
+ /** The device to subscribe to. */
19
+ device: DeviceProfile;
20
+ /** Remote command to run over ssh, already shell-quoted for the peer's OS. */
21
+ command: string;
22
+ signal: AbortSignal;
23
+ /**
24
+ * One line of the peer's stdout. Return `true` when the line was a valid
25
+ * protocol event — that is the health signal that resets the backoff.
26
+ */
27
+ onLine: (line: string) => boolean;
28
+ /** Report the peer as unavailable, with the captured reason. */
29
+ onUnavailable: (reason: string) => void;
30
+ /** Override the first backoff delay (tests). */
31
+ backoffBaseMs?: number;
32
+ /** Override the backoff ceiling (tests). */
33
+ backoffCapMs?: number;
34
+ /** Override the park threshold (tests). */
35
+ parkAfterFailures?: number;
36
+ /** Override the ssh binary (tests). */
37
+ sshBin?: string;
38
+ /** Override the parked peer's registry re-check cadence (tests). */
39
+ registryPollMs?: number;
40
+ /** Override the registry file a parked peer waits on (tests). */
41
+ registryPath?: string;
42
+ }
43
+ /**
44
+ * Hold one peer subscription open for the life of the signal, reconnecting with
45
+ * backoff. Resolves when the signal aborts or the peer cannot be addressed.
46
+ */
47
+ export declare function streamFromPeer(options: PeerStreamOptions): Promise<void>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * One peer's `--local` watch subscription, with connection hygiene.
3
+ *
4
+ * Both fleet fan-outs (`watchFleetSessions`, `watchFleetFeed`) subscribe to
5
+ * every dialable device with a long-lived `ssh <peer> agents … watch --json
6
+ * --local`. Both used to respawn that child on a bare 2 s timer with the
7
+ * child's stderr set to `'ignore'`, so an offline peer — and `isDialableDevice`
8
+ * deliberately never excludes one — got a fresh ssh every ConnectTimeout + 2 s
9
+ * for the whole life of the watcher, with the reason discarded. That is the
10
+ * ssh-child churn the 2026-09-03 incident reported.
11
+ *
12
+ * This module is the single implementation both fan-outs call:
13
+ *
14
+ * - exponential backoff per peer, {@link PEER_BACKOFF_BASE_MS} doubling to
15
+ * {@link PEER_BACKOFF_CAP_MS}, reset the moment the peer delivers a healthy
16
+ * protocol event;
17
+ * - the child's stderr captured (bounded to {@link PEER_STDERR_BYTES}) and
18
+ * surfaced as the `unavailable` reason instead of being thrown away;
19
+ * - a peer that fails {@link PEER_PARK_AFTER_FAILURES} spawns in a row parked —
20
+ * it stops the reconnect cycle and re-dials only when the device registry
21
+ * changes or the capped backoff elapses;
22
+ * - abort listeners removed per iteration, so a watcher open for hours does not
23
+ * accumulate one per reconnect on the caller's AbortSignal.
24
+ */
25
+ import { spawn } from 'node:child_process';
26
+ import * as fs from 'node:fs';
27
+ import { createInterface } from 'node:readline';
28
+ import { deviceIdentityArgs, sshTargetFor } from '../../devices/connect.js';
29
+ import { SSH_OPTS, controlOpts } from '../../ssh-exec.js';
30
+ import { getDevicesRegistryPath } from '../../state.js';
31
+ /** First reconnect delay after a failed subscription. */
32
+ export const PEER_BACKOFF_BASE_MS = 2_000;
33
+ /** Ceiling the doubling delay saturates at. */
34
+ export const PEER_BACKOFF_CAP_MS = 60_000;
35
+ /** Consecutive failed spawns before the peer is parked. */
36
+ export const PEER_PARK_AFTER_FAILURES = 3;
37
+ /** Bytes of a peer's stderr retained for the `unavailable` reason. */
38
+ export const PEER_STDERR_BYTES = 2_048;
39
+ /** How often a parked peer re-checks the device registry for a refresh. */
40
+ export const PEER_REGISTRY_POLL_MS = 5_000;
41
+ /**
42
+ * Reconnect delay for `failures` consecutive failed spawns: 0 for a healthy
43
+ * peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS}.
44
+ */
45
+ export function peerBackoffDelayMs(failures, base = PEER_BACKOFF_BASE_MS, cap = PEER_BACKOFF_CAP_MS) {
46
+ if (failures <= 0)
47
+ return 0;
48
+ return Math.min(cap, base * 2 ** (failures - 1));
49
+ }
50
+ /** Bounded tail of a child's stderr, kept for the unavailable reason. */
51
+ function stderrTail(chunks, next, limit) {
52
+ const joined = chunks + next;
53
+ return joined.length > limit ? joined.slice(joined.length - limit) : joined;
54
+ }
55
+ /** Collapse captured stderr to one quotable line. */
56
+ function reasonFor(exit, stderr) {
57
+ const detail = stderr.split('\n').map((line) => line.trim()).filter(Boolean).pop();
58
+ return detail ? `${exit}: ${detail}` : exit;
59
+ }
60
+ /** Await a delay, a device-registry change, or abort — whichever comes first. */
61
+ async function parkedWait(options, delayMs) {
62
+ const registry = options.registryPath ?? getDevicesRegistryPath();
63
+ const pollMs = options.registryPollMs ?? PEER_REGISTRY_POLL_MS;
64
+ const stampOf = () => {
65
+ try {
66
+ const st = fs.statSync(registry);
67
+ return `${st.size}:${st.mtimeMs}`;
68
+ }
69
+ catch {
70
+ return '';
71
+ }
72
+ };
73
+ const before = stampOf();
74
+ await new Promise((resolve) => {
75
+ let done = false;
76
+ const finish = () => {
77
+ if (done)
78
+ return;
79
+ done = true;
80
+ clearTimeout(deadline);
81
+ clearInterval(poll);
82
+ options.signal.removeEventListener('abort', finish);
83
+ resolve();
84
+ };
85
+ const deadline = setTimeout(finish, delayMs);
86
+ const poll = setInterval(() => { if (stampOf() !== before)
87
+ finish(); }, Math.min(pollMs, Math.max(delayMs, 1)));
88
+ options.signal.addEventListener('abort', finish, { once: true });
89
+ });
90
+ }
91
+ /**
92
+ * Hold one peer subscription open for the life of the signal, reconnecting with
93
+ * backoff. Resolves when the signal aborts or the peer cannot be addressed.
94
+ */
95
+ export async function streamFromPeer(options) {
96
+ const parkAfter = options.parkAfterFailures ?? PEER_PARK_AFTER_FAILURES;
97
+ let failures = 0;
98
+ while (!options.signal.aborted) {
99
+ let target;
100
+ try {
101
+ target = sshTargetFor(options.device);
102
+ }
103
+ catch (error) {
104
+ // No address at all is not a transient failure: retrying cannot fix it.
105
+ options.onUnavailable(error instanceof Error ? error.message : String(error));
106
+ return;
107
+ }
108
+ const child = spawn(options.sshBin ?? 'ssh', [
109
+ ...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(options.device), target, options.command,
110
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
111
+ const stop = () => child.kill('SIGTERM');
112
+ options.signal.addEventListener('abort', stop, { once: true });
113
+ let stderr = '';
114
+ child.stderr?.setEncoding('utf-8');
115
+ child.stderr?.on('data', (chunk) => { stderr = stderrTail(stderr, chunk, PEER_STDERR_BYTES); });
116
+ const reader = createInterface({ input: child.stdout });
117
+ reader.on('line', (line) => {
118
+ // A peer that is delivering protocol is healthy, so the next disconnect
119
+ // starts the backoff over rather than inheriting an old failure streak.
120
+ if (options.onLine(line))
121
+ failures = 0;
122
+ });
123
+ const code = await new Promise((resolve) => {
124
+ child.once('error', () => resolve(null));
125
+ child.once('close', resolve);
126
+ });
127
+ reader.close();
128
+ // Removed per iteration: a watcher open for hours would otherwise leak one
129
+ // listener per reconnect onto the caller's signal.
130
+ options.signal.removeEventListener('abort', stop);
131
+ if (options.signal.aborted)
132
+ return;
133
+ failures += 1;
134
+ const exit = code == null ? 'ssh failed' : `ssh exited ${code}`;
135
+ const parked = failures >= parkAfter;
136
+ const delay = peerBackoffDelayMs(failures, options.backoffBaseMs, options.backoffCapMs);
137
+ options.onUnavailable(parked
138
+ ? `${reasonFor(exit, stderr)} — parked after ${failures} failed connections, retrying in ${Math.round(delay / 1000)}s or on a device refresh`
139
+ : reasonFor(exit, stderr));
140
+ await parkedWait(options, delay);
141
+ }
142
+ }
@@ -1,5 +1,5 @@
1
1
  import { type ActiveSession } from '../active.js';
2
- import type { SessionMeta } from '../types.js';
2
+ import { type SessionMeta } from '../types.js';
3
3
  import { readActiveSessionsCache } from '../session-cache.js';
4
4
  export declare const SESSION_WATCH_VERSION: 1;
5
5
  export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
@@ -1,17 +1,16 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { spawn } from 'node:child_process';
3
- import { createInterface } from 'node:readline';
4
2
  import * as fs from 'node:fs';
5
3
  import * as path from 'node:path';
6
4
  import { loadDevices, isDialableDevice } from '../../devices/registry.js';
7
- import { deviceIdentityArgs, sshTargetFor } from '../../devices/connect.js';
8
5
  import { machineId, normalizeHost } from '../../machine-id.js';
9
- import { SSH_OPTS, controlOpts, shellQuote } from '../../ssh-exec.js';
6
+ import { shellQuote } from '../../ssh-exec.js';
7
+ import { streamFromPeer } from './peer-stream.js';
10
8
  import { buildWindowsAgentsCommand, remoteShellFor } from '../../hosts/remote-cmd.js';
11
9
  import { isReapableOrphan } from '../active.js';
12
- import { querySessions, readSessionSummaryAny } from '../db.js';
10
+ import { querySessions, readSessionSummaryAny, readSessionTimelineAny } from '../db.js';
13
11
  import { linearIssueUrl } from '../linear.js';
14
12
  import { sessionAgentSupportsResume } from '../recovery.js';
13
+ import { SESSION_AGENTS } from '../types.js';
15
14
  import { activeSessionsJournalPath, activeSessionJournalIdentity, readActiveSessionsCache, noteActiveSessionsJournalReader, resolveStreamSummaryState, } from '../session-cache.js';
16
15
  export const SESSION_WATCH_VERSION = 1;
17
16
  export const SESSION_WATCH_HEARTBEAT_MS = 15_000;
@@ -83,6 +82,10 @@ export function toPreviousSessionWatchRow(scope, session) {
83
82
  summaryState: session.summaryState,
84
83
  }
85
84
  : readSessionSummaryAny(session.id);
85
+ // Project the daemon-folded timeline onto the history row from the same
86
+ // transcript-keyed cache the live merge reads (PHNX-3939), so a closed session
87
+ // still shows the request it was given and what the agent did — no re-parse.
88
+ const folded = readSessionTimelineAny(session.id);
86
89
  return {
87
90
  context: 'recent',
88
91
  kind: session.agent,
@@ -90,7 +93,14 @@ export function toPreviousSessionWatchRow(scope, session) {
90
93
  sessionId: session.id,
91
94
  ...(session.cwd ? { cwd: session.cwd } : {}),
92
95
  ...(session.project ? { project: session.project } : {}),
93
- ...(session.label ? { label: session.label, title: session.label } : session.topic ? { title: session.topic } : {}),
96
+ // `request.headline` is the user's own sentence with the attachment noise
97
+ // pulled out, so it beats the raw `topic` for the row title — a `/model` echo
98
+ // or a skill body can no longer become a session's name (PHNX-3939).
99
+ ...(session.label
100
+ ? { label: session.label, title: session.label }
101
+ : folded?.request?.headline
102
+ ? { title: folded.request.headline }
103
+ : session.topic ? { title: session.topic } : {}),
94
104
  ...(session.topic ? { topic: session.topic } : {}),
95
105
  ...(session.firstUserMessage ? { firstUserMessage: session.firstUserMessage } : {}),
96
106
  ...(session.version ? { version: session.version } : {}),
@@ -108,6 +118,9 @@ export function toPreviousSessionWatchRow(scope, session) {
108
118
  ...(summary?.checkpoints ? { checkpoints: summary.checkpoints } : {}),
109
119
  ...(summary?.summaryChecklist ? { summaryChecklist: summary.summaryChecklist } : {}),
110
120
  summaryState: resolveStreamSummaryState(summary?.summaryState),
121
+ ...(folded?.request ? { request: folded.request } : {}),
122
+ ...(folded?.timeline ? { timeline: folded.timeline } : {}),
123
+ ...(folded?.files ? { files: folded.files } : {}),
111
124
  startedAtMs,
112
125
  lastActivityMs,
113
126
  status: 'closed',
@@ -285,7 +298,12 @@ export function readPreviousSessionsForWatch(scope) {
285
298
  try {
286
299
  return querySessions({
287
300
  machine: normalizeHost(scope),
288
- agents: ['claude', 'codex', 'muse', 'opencode'],
301
+ // Every harness that writes a discoverable transcript. The list used to be
302
+ // four, so a Kimi/Grok/Cursor/Droid session simply never appeared as a
303
+ // Previous row — and after PHNX-3939 it would also have been the only rows
304
+ // with no request/timeline. Kept explicit (not `SESSION_AGENTS`) so
305
+ // `openclaw`, which has no transcript to project, stays out.
306
+ agents: SESSION_AGENTS.filter((agent) => agent !== 'openclaw'),
289
307
  sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
290
308
  excludeTeamOrigin: true,
291
309
  })
@@ -406,47 +424,28 @@ export async function watchFleetSessions(options) {
406
424
  const peers = Object.values(devices).filter((device) => isDialableDevice(device)
407
425
  && normalizeHost(device.name) !== self
408
426
  && ['windows', 'linux', 'macos'].includes(device.platform));
409
- const reconnectMs = options.reconnectMs ?? 2_000;
410
427
  const peerTasks = peers.map(async (device) => {
411
428
  const scope = normalizeHost(device.name);
412
429
  const state = new SessionWatchState();
413
- while (!options.signal.aborted) {
414
- let target;
415
- try {
416
- target = sshTargetFor(device);
417
- }
418
- catch (error) {
419
- options.emit(state.scope(scope, 'unavailable', error instanceof Error ? error.message : String(error)));
420
- break;
421
- }
422
- const child = spawn('ssh', [
423
- ...SSH_OPTS, ...controlOpts(), ...deviceIdentityArgs(device), target, remoteWatchCommand(device.platform),
424
- ], { stdio: ['ignore', 'pipe', 'ignore'] });
425
- const stop = () => child.kill('SIGTERM');
426
- options.signal.addEventListener('abort', stop, { once: true });
427
- const reader = createInterface({ input: child.stdout });
428
- reader.on('line', (line) => {
430
+ await streamFromPeer({
431
+ device,
432
+ signal: options.signal,
433
+ command: remoteWatchCommand(device.platform),
434
+ backoffBaseMs: options.reconnectMs,
435
+ onLine: (line) => {
429
436
  try {
430
437
  const event = JSON.parse(line);
431
- if (event.version === SESSION_WATCH_VERSION && typeof event.sequence === 'number')
432
- options.emit(event);
438
+ if (event.version !== SESSION_WATCH_VERSION || typeof event.sequence !== 'number')
439
+ return false;
440
+ options.emit(event);
441
+ return true;
433
442
  }
434
- catch { /* incomplete/non-protocol peer output is not state */ }
435
- });
436
- const code = await new Promise((resolve) => {
437
- child.once('error', () => resolve(null));
438
- child.once('close', resolve);
439
- });
440
- reader.close();
441
- options.signal.removeEventListener('abort', stop);
442
- if (options.signal.aborted)
443
- break;
444
- options.emit(state.scope(scope, 'unavailable', code === null ? 'ssh failed' : `ssh exited ${code}`));
445
- await new Promise((resolve) => {
446
- const timer = setTimeout(resolve, reconnectMs);
447
- options.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
448
- });
449
- }
443
+ catch {
444
+ return false; /* incomplete/non-protocol peer output is not state */
445
+ }
446
+ },
447
+ onUnavailable: (reason) => options.emit(state.scope(scope, 'unavailable', reason)),
448
+ });
450
449
  });
451
450
  await Promise.all([local, ...peerTasks]);
452
451
  }
@@ -190,6 +190,15 @@ export declare function mergeSessionSummary(s: ActiveSession): ActiveSession;
190
190
  * the readiness flag is read on the read path, and it is memoized (PHNX-3939).
191
191
  */
192
192
  export declare function resolveStreamSummaryState(current: import('./types.js').SummaryState | undefined, nowMs?: number): import('./types.js').SummaryState;
193
+ /**
194
+ * Merge the daemon-folded timeline (PHNX-3939) onto a live row from the
195
+ * transcript-keyed `session_timelines` cache. Sibling of
196
+ * {@link mergeSessionSummary} and the same contract: one indexed by-id read of
197
+ * the BOUNDED projection (never the fold's resume state), never a parse, never
198
+ * a model call. `runTimelinePass` in the daemon tick is the only producer.
199
+ * Best-effort — a DB error leaves the row untouched.
200
+ */
201
+ export declare function mergeSessionTimeline(s: ActiveSession): ActiveSession;
193
202
  export declare function applyImmutableMemo(s: ActiveSession): ActiveSession;
194
203
  export interface LoadLocalActiveSessionsOptions {
195
204
  /** Skip the cache and re-gather (the force-refresh path). */