@phnx-labs/agents-cli 1.22.114 → 1.22.116

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 (243) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +22 -102
  3. package/dist/browser.js +0 -0
  4. package/dist/cli/command-registry.js +0 -7
  5. package/dist/commands/accounts.d.ts +3 -77
  6. package/dist/commands/accounts.js +13 -366
  7. package/dist/commands/audit.js +1 -1
  8. package/dist/commands/auth.js +0 -2
  9. package/dist/commands/browser.d.ts +53 -0
  10. package/dist/commands/browser.js +210 -9
  11. package/dist/commands/doctor.d.ts +2 -3
  12. package/dist/commands/doctor.js +0 -11
  13. package/dist/commands/events.d.ts +1 -3
  14. package/dist/commands/events.js +4 -7
  15. package/dist/commands/exec.d.ts +0 -1
  16. package/dist/commands/exec.js +214 -148
  17. package/dist/commands/feed-watch.js +52 -8
  18. package/dist/commands/feed.js +65 -14
  19. package/dist/commands/focus.js +1 -1
  20. package/dist/commands/go.d.ts +0 -17
  21. package/dist/commands/go.js +2 -19
  22. package/dist/commands/logs.js +1 -1
  23. package/dist/commands/mcp.js +8 -83
  24. package/dist/commands/memory.js +4 -47
  25. package/dist/commands/message.js +4 -4
  26. package/dist/commands/plugins.js +1 -93
  27. package/dist/commands/repo.js +0 -44
  28. package/dist/commands/resume.js +4 -1
  29. package/dist/commands/secrets-passthrough.js +2 -2
  30. package/dist/commands/send.d.ts +4 -4
  31. package/dist/commands/send.js +6 -51
  32. package/dist/commands/sessions-backup-setup.js +1 -1
  33. package/dist/commands/sessions-picker.d.ts +11 -0
  34. package/dist/commands/sessions-picker.js +88 -7
  35. package/dist/commands/sessions-resume.js +0 -1
  36. package/dist/commands/sessions-share.d.ts +5 -7
  37. package/dist/commands/sessions-share.js +98 -49
  38. package/dist/commands/sessions.d.ts +21 -2
  39. package/dist/commands/sessions.js +158 -11
  40. package/dist/commands/setup-browser.js +18 -2
  41. package/dist/commands/setup-computer.js +20 -6
  42. package/dist/commands/setup-secrets.d.ts +2 -2
  43. package/dist/commands/setup-secrets.js +24 -4
  44. package/dist/commands/setup-term.d.ts +24 -0
  45. package/dist/commands/setup-term.js +70 -0
  46. package/dist/commands/setup-terminal.d.ts +3 -0
  47. package/dist/commands/setup-terminal.js +22 -0
  48. package/dist/commands/setup.d.ts +1 -1
  49. package/dist/commands/setup.js +29 -11
  50. package/dist/commands/skills.js +0 -8
  51. package/dist/commands/ssh.d.ts +6 -0
  52. package/dist/commands/ssh.js +93 -302
  53. package/dist/commands/sync.js +14 -5
  54. package/dist/commands/teams.js +1 -1
  55. package/dist/commands/traces.js +1 -1
  56. package/dist/lib/accounting/rotate.d.ts +63 -1
  57. package/dist/lib/accounting/rotate.js +56 -0
  58. package/dist/lib/accounts/add.d.ts +0 -5
  59. package/dist/lib/accounts/add.js +3 -9
  60. package/dist/lib/accounts/slots.js +32 -2
  61. package/dist/lib/answer-router.d.ts +11 -2
  62. package/dist/lib/answer-router.js +26 -2
  63. package/dist/lib/artifacts-client.d.ts +20 -0
  64. package/dist/lib/artifacts-client.js +46 -0
  65. package/dist/lib/auth-mint.d.ts +5 -4
  66. package/dist/lib/auth-mint.js +6 -5
  67. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  68. package/dist/lib/browser/runtime-state.d.ts +55 -0
  69. package/dist/lib/browser/runtime-state.js +99 -18
  70. package/dist/lib/browser/service.d.ts +10 -0
  71. package/dist/lib/browser/service.js +229 -37
  72. package/dist/lib/browser/types.d.ts +18 -0
  73. package/dist/lib/cli-resources.js +3 -1
  74. package/dist/lib/cloud/dispatch.js +1 -1
  75. package/dist/lib/cloudflare/creds.d.ts +10 -0
  76. package/dist/lib/cloudflare/creds.js +46 -0
  77. package/dist/lib/cloudflare/provision.d.ts +35 -0
  78. package/dist/lib/cloudflare/provision.js +144 -0
  79. package/dist/lib/computer/sessions-list.d.ts +55 -0
  80. package/dist/lib/computer/sessions-list.js +168 -1
  81. package/dist/lib/config-keys.d.ts +1 -1
  82. package/dist/lib/config-keys.js +5 -0
  83. package/dist/lib/daemon/daemon.js +8 -1
  84. package/dist/lib/daemon/feed-stream-service.d.ts +23 -0
  85. package/dist/lib/daemon/feed-stream-service.js +40 -0
  86. package/dist/lib/daemon-services.d.ts +1 -1
  87. package/dist/lib/daemon-services.js +5 -0
  88. package/dist/lib/device-config.js +61 -0
  89. package/dist/lib/devices/connect.d.ts +49 -5
  90. package/dist/lib/devices/connect.js +169 -21
  91. package/dist/lib/devices/doctor-findings.js +2 -6
  92. package/dist/lib/feed/answer.d.ts +153 -4
  93. package/dist/lib/feed/answer.js +716 -105
  94. package/dist/lib/feed/envelope.d.ts +79 -0
  95. package/dist/lib/feed/envelope.js +23 -0
  96. package/dist/lib/feed/events.d.ts +6 -0
  97. package/dist/lib/feed/events.js +8 -0
  98. package/dist/lib/feed/feed.d.ts +61 -1
  99. package/dist/lib/feed/feed.js +226 -14
  100. package/dist/lib/feed/hub-server.d.ts +141 -0
  101. package/dist/lib/feed/hub-server.js +586 -0
  102. package/dist/lib/feed/hub.d.ts +95 -0
  103. package/dist/lib/feed/hub.js +255 -0
  104. package/dist/lib/feed/pr-status.d.ts +8 -0
  105. package/dist/lib/feed/pr-status.js +9 -1
  106. package/dist/lib/feed/tool-activity.d.ts +108 -0
  107. package/dist/lib/feed/tool-activity.js +313 -0
  108. package/dist/lib/feed/tools.d.ts +198 -0
  109. package/dist/lib/feed/tools.js +265 -0
  110. package/dist/lib/feed/watch.d.ts +50 -50
  111. package/dist/lib/feed/watch.js +147 -16
  112. package/dist/lib/feed-outcome.d.ts +1 -1
  113. package/dist/lib/feed-outcome.js +9 -2
  114. package/dist/lib/feed-policy.js +9 -3
  115. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  116. package/dist/lib/fleet/auth-sync.js +2 -89
  117. package/dist/lib/format.d.ts +1 -1
  118. package/dist/lib/format.js +1 -1
  119. package/dist/lib/git.d.ts +0 -16
  120. package/dist/lib/git.js +0 -58
  121. package/dist/lib/harness-auth-capabilities.js +7 -2
  122. package/dist/lib/helper-versions.js +1 -1
  123. package/dist/lib/hosts/dispatch.d.ts +20 -1
  124. package/dist/lib/hosts/dispatch.js +52 -30
  125. package/dist/lib/hosts/remote-cmd.d.ts +72 -1
  126. package/dist/lib/hosts/remote-cmd.js +151 -10
  127. package/dist/lib/hosts/remote-cmd.test-fixture.d.ts +2 -0
  128. package/dist/lib/hosts/remote-cmd.test-fixture.js +22 -0
  129. package/dist/lib/mailbox.d.ts +12 -0
  130. package/dist/lib/mailbox.js +16 -2
  131. package/dist/lib/mcp.js +17 -11
  132. package/dist/lib/menubar/snapshot.d.ts +51 -0
  133. package/dist/lib/menubar/snapshot.js +42 -3
  134. package/dist/lib/open-url.js +2 -2
  135. package/dist/lib/probe.d.ts +4 -1
  136. package/dist/lib/probe.js +5 -2
  137. package/dist/lib/projects.d.ts +23 -0
  138. package/dist/lib/projects.js +78 -0
  139. package/dist/lib/pwsh.d.ts +33 -0
  140. package/dist/lib/pwsh.js +56 -0
  141. package/dist/lib/redact.d.ts +8 -0
  142. package/dist/lib/redact.js +11 -0
  143. package/dist/lib/refresh.d.ts +6 -2
  144. package/dist/lib/refresh.js +92 -72
  145. package/dist/lib/secrets-cli.d.ts +11 -0
  146. package/dist/lib/secrets-cli.js +30 -0
  147. package/dist/lib/secrets-client.js +3 -2
  148. package/dist/lib/session/active.d.ts +1 -0
  149. package/dist/lib/session/active.js +8 -0
  150. package/dist/lib/session/db.d.ts +67 -3
  151. package/dist/lib/session/db.js +381 -126
  152. package/dist/lib/session/detached.d.ts +7 -0
  153. package/dist/lib/session/detached.js +29 -0
  154. package/dist/lib/session/prompt.d.ts +23 -7
  155. package/dist/lib/session/prompt.js +46 -8
  156. package/dist/lib/session/remote/peer-stream.d.ts +24 -2
  157. package/dist/lib/session/remote/peer-stream.js +33 -6
  158. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  159. package/dist/lib/session/remote/remote-list.js +22 -6
  160. package/dist/lib/session/remote/watch.d.ts +12 -0
  161. package/dist/lib/session/remote/watch.js +9 -0
  162. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  163. package/dist/lib/session/remote-preview-cache.js +373 -0
  164. package/dist/lib/session/sync/backend.d.ts +3 -3
  165. package/dist/lib/session/sync/backend.js +3 -3
  166. package/dist/lib/session/sync/provision.d.ts +1 -1
  167. package/dist/lib/session/sync/provision.js +2 -2
  168. package/dist/lib/session/tail.d.ts +50 -0
  169. package/dist/lib/session/tail.js +219 -0
  170. package/dist/lib/sessions-client.js +0 -3
  171. package/dist/lib/setup-tool-install.d.ts +3 -0
  172. package/dist/lib/setup-tool-install.js +27 -0
  173. package/dist/lib/setup-tool-status.d.ts +22 -0
  174. package/dist/lib/setup-tool-status.js +220 -0
  175. package/dist/lib/share-runtime.d.ts +11 -0
  176. package/dist/lib/share-runtime.js +63 -0
  177. package/dist/lib/signin-badge.d.ts +19 -4
  178. package/dist/lib/signin-badge.js +29 -11
  179. package/dist/lib/smart-launch.d.ts +1 -5
  180. package/dist/lib/smart-launch.js +3 -11
  181. package/dist/lib/ssh-exec.d.ts +44 -0
  182. package/dist/lib/ssh-exec.js +119 -0
  183. package/dist/lib/startup/command-registry.d.ts +6 -4
  184. package/dist/lib/startup/command-registry.js +10 -7
  185. package/dist/lib/state.js +2 -2
  186. package/dist/lib/storage/selection.d.ts +2 -2
  187. package/dist/lib/storage/selection.js +2 -2
  188. package/dist/lib/sync-umbrella.d.ts +5 -0
  189. package/dist/lib/sync-umbrella.js +18 -10
  190. package/dist/lib/term-driver.d.ts +24 -0
  191. package/dist/lib/term-driver.js +36 -0
  192. package/dist/lib/terminal/index.d.ts +1 -1
  193. package/dist/lib/terminal/index.js +1 -1
  194. package/dist/lib/terminal/inject.d.ts +38 -0
  195. package/dist/lib/terminal/inject.js +55 -9
  196. package/dist/lib/terminal/transport.d.ts +15 -5
  197. package/dist/lib/terminal/transport.js +61 -11
  198. package/dist/lib/traces/backend.d.ts +1 -2
  199. package/dist/lib/traces/backend.js +1 -2
  200. package/dist/lib/traces/provision.d.ts +1 -1
  201. package/dist/lib/traces/provision.js +2 -2
  202. package/dist/lib/types.d.ts +8 -6
  203. package/package.json +2 -3
  204. package/dist/commands/artifacts-setup.d.ts +0 -53
  205. package/dist/commands/artifacts-setup.js +0 -161
  206. package/dist/commands/artifacts.d.ts +0 -18
  207. package/dist/commands/artifacts.js +0 -58
  208. package/dist/commands/attach.d.ts +0 -12
  209. package/dist/commands/attach.js +0 -86
  210. package/dist/commands/auth-mint.d.ts +0 -12
  211. package/dist/commands/auth-mint.js +0 -108
  212. package/dist/commands/reconnect.d.ts +0 -46
  213. package/dist/commands/reconnect.js +0 -115
  214. package/dist/commands/share.d.ts +0 -293
  215. package/dist/commands/share.js +0 -1424
  216. package/dist/lib/fleet/remote-login.d.ts +0 -170
  217. package/dist/lib/fleet/remote-login.js +0 -568
  218. package/dist/lib/share/analytics.d.ts +0 -13
  219. package/dist/lib/share/analytics.js +0 -45
  220. package/dist/lib/share/backend.d.ts +0 -120
  221. package/dist/lib/share/backend.js +0 -176
  222. package/dist/lib/share/capture.d.ts +0 -31
  223. package/dist/lib/share/capture.js +0 -174
  224. package/dist/lib/share/config.d.ts +0 -72
  225. package/dist/lib/share/config.js +0 -211
  226. package/dist/lib/share/delete.d.ts +0 -123
  227. package/dist/lib/share/delete.js +0 -173
  228. package/dist/lib/share/html.d.ts +0 -20
  229. package/dist/lib/share/html.js +0 -88
  230. package/dist/lib/share/http-error.d.ts +0 -53
  231. package/dist/lib/share/http-error.js +0 -65
  232. package/dist/lib/share/og.d.ts +0 -26
  233. package/dist/lib/share/og.js +0 -84
  234. package/dist/lib/share/provision.d.ts +0 -127
  235. package/dist/lib/share/provision.js +0 -285
  236. package/dist/lib/share/publish.d.ts +0 -379
  237. package/dist/lib/share/publish.js +0 -818
  238. package/dist/lib/share/worker-template.d.ts +0 -27
  239. package/dist/lib/share/worker-template.js +0 -2424
  240. package/dist/lib/storage/index.d.ts +0 -14
  241. package/dist/lib/storage/index.js +0 -14
  242. package/dist/lib/storage/visibility.d.ts +0 -82
  243. package/dist/lib/storage/visibility.js +0 -99
@@ -96,15 +96,31 @@ export declare function classifyUserPrompt(raw: string | undefined, opts?: {
96
96
  hasImageAttachment?: boolean;
97
97
  }): ClassifiedPrompt;
98
98
  /**
99
- * Collapse a harness-generated session title when it is injected skill
100
- * scaffolding; otherwise leave the title as the harness wrote it.
99
+ * Reduce a harness-generated session title to something worth showing as a
100
+ * headline, or `undefined` when it is not (PHNX-3999 F26/F27).
101
101
  *
102
102
  * Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
103
- * first turn, so a session opened with a skill gets named after the injected
104
- * "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
105
- * `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
106
- * whitespace-only input yields `undefined` so the caller falls through to the
107
- * first-prompt topic.
103
+ * FIRST turn of the transcript, so whatever the harness injected there becomes
104
+ * the session's name: a skill's install-directory preamble, a `!`-prefixed shell
105
+ * echo wrapped in `<bash-input>`, a `/clear`. The owner's recording shows both
106
+ * failures — one row titled with shell-command XML wrappers, others reading
107
+ * `/clear`. Three rungs, in order:
108
+ *
109
+ * 1. A skill invocation collapses to `/<skill>` ({@link classifyUserPrompt}
110
+ * reports `kind: 'skill'` only for that injected line).
111
+ * 2. Harness scaffolding is REJECTED, not cleaned: `<bash-input>`,
112
+ * `<command-name>`, `<local-command-stdout>`, `<system-reminder>` and the
113
+ * rest of {@link isSyntheticUserMessage}'s cross-harness list, plus a title
114
+ * that is only tags. Stripping the tags would leave the shell command
115
+ * itself as the headline, which is the same wrong answer with tidier
116
+ * punctuation.
117
+ * 3. A bare control command is rejected ({@link BARE_CONTROL_COMMAND_RE}).
118
+ *
119
+ * A rejected label returns `undefined`, so the row falls to the next rung of the
120
+ * canonical ladder — the daemon-generated title, then the user's own first
121
+ * prompt (`sessionHeadline`, `session/title.ts`). Nothing is deleted: the
122
+ * original turn stays on the row as `firstUserMessage` / `request`, so the
123
+ * details view still shows exactly what the agent was told.
108
124
  *
109
125
  * A user-authored title (Claude `/rename` / `custom-title`) is never passed
110
126
  * here — the caller keeps it verbatim.
@@ -295,15 +295,45 @@ export function classifyUserPrompt(raw, opts = {}) {
295
295
  return { clean: display, kind: 'text' };
296
296
  }
297
297
  /**
298
- * Collapse a harness-generated session title when it is injected skill
299
- * scaffolding; otherwise leave the title as the harness wrote it.
298
+ * A label that is nothing but a control command — `/clear`, `/compact`,
299
+ * `/model`, `/exit` — with no words of its own after it.
300
+ *
301
+ * One rule instead of a per-harness list of control verbs: a title that is a
302
+ * bare slash token describes an action taken ON the session, never what the
303
+ * session is about, so whatever it names it is not a headline. A command WITH an
304
+ * argument (`/continue fix the parser`, `/code:commit the retry fix`) carries
305
+ * real intent and is kept.
306
+ *
307
+ * Skill collapse runs first in {@link cleanGeneratedSessionLabel} and returns
308
+ * its own `/<skill>` form, so this never swallows that deliberate shape.
309
+ */
310
+ const BARE_CONTROL_COMMAND_RE = /^\/[\w:-]+$/;
311
+ /**
312
+ * Reduce a harness-generated session title to something worth showing as a
313
+ * headline, or `undefined` when it is not (PHNX-3999 F26/F27).
300
314
  *
301
315
  * Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
302
- * first turn, so a session opened with a skill gets named after the injected
303
- * "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
304
- * `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
305
- * whitespace-only input yields `undefined` so the caller falls through to the
306
- * first-prompt topic.
316
+ * FIRST turn of the transcript, so whatever the harness injected there becomes
317
+ * the session's name: a skill's install-directory preamble, a `!`-prefixed shell
318
+ * echo wrapped in `<bash-input>`, a `/clear`. The owner's recording shows both
319
+ * failures — one row titled with shell-command XML wrappers, others reading
320
+ * `/clear`. Three rungs, in order:
321
+ *
322
+ * 1. A skill invocation collapses to `/<skill>` ({@link classifyUserPrompt}
323
+ * reports `kind: 'skill'` only for that injected line).
324
+ * 2. Harness scaffolding is REJECTED, not cleaned: `<bash-input>`,
325
+ * `<command-name>`, `<local-command-stdout>`, `<system-reminder>` and the
326
+ * rest of {@link isSyntheticUserMessage}'s cross-harness list, plus a title
327
+ * that is only tags. Stripping the tags would leave the shell command
328
+ * itself as the headline, which is the same wrong answer with tidier
329
+ * punctuation.
330
+ * 3. A bare control command is rejected ({@link BARE_CONTROL_COMMAND_RE}).
331
+ *
332
+ * A rejected label returns `undefined`, so the row falls to the next rung of the
333
+ * canonical ladder — the daemon-generated title, then the user's own first
334
+ * prompt (`sessionHeadline`, `session/title.ts`). Nothing is deleted: the
335
+ * original turn stays on the row as `firstUserMessage` / `request`, so the
336
+ * details view still shows exactly what the agent was told.
307
337
  *
308
338
  * A user-authored title (Claude `/rename` / `custom-title`) is never passed
309
339
  * here — the caller keeps it verbatim.
@@ -313,7 +343,15 @@ export function cleanGeneratedSessionLabel(title) {
313
343
  if (!trimmed)
314
344
  return undefined;
315
345
  const classified = classifyUserPrompt(trimmed);
316
- return classified.kind === 'skill' ? classified.clean : trimmed;
346
+ if (classified.kind === 'skill')
347
+ return classified.clean;
348
+ if (isSyntheticUserMessage(trimmed))
349
+ return undefined;
350
+ if (!stripXmlLikeTags(trimmed).trim())
351
+ return undefined;
352
+ if (BARE_CONTROL_COMMAND_RE.test(trimmed))
353
+ return undefined;
354
+ return trimmed;
317
355
  }
318
356
  /**
319
357
  * Extract a one-line topic from a raw user message, or undefined if the message
@@ -5,11 +5,29 @@ export declare const PEER_BACKOFF_BASE_MS = 2000;
5
5
  export declare const PEER_BACKOFF_CAP_MS = 60000;
6
6
  /** Consecutive failed spawns before the peer is parked. */
7
7
  export declare const PEER_PARK_AFTER_FAILURES = 3;
8
+ /**
9
+ * Consecutive failed spawns before the peer is RETIRED — the capped 60 s ladder
10
+ * gives way to {@link PEER_RETIRED_RECHECK_MS}.
11
+ *
12
+ * The cap alone bounds the delay but not the total work: a box that is off for a
13
+ * weekend was dialed every 60 s for two days, ~2,880 ssh children per watcher
14
+ * per peer, each one already known to fail. Ten consecutive failures is well
15
+ * past any transient network event, so past that point the peer is treated as
16
+ * genuinely absent and re-dialed on the slow cadence instead.
17
+ */
18
+ export declare const PEER_RETIRE_AFTER_FAILURES = 10;
19
+ /**
20
+ * Re-dial cadence for a retired peer. Still bounded rather than never, because a
21
+ * box can come back without anything touching the device registry — waiting
22
+ * only on a registry change would leave it unreachable until an operator acted.
23
+ */
24
+ export declare const PEER_RETIRED_RECHECK_MS: number;
8
25
  /**
9
26
  * Reconnect delay for `failures` consecutive failed spawns: 0 for a healthy
10
- * peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS}.
27
+ * peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS},
28
+ * and {@link PEER_RETIRED_RECHECK_MS} once the peer is retired.
11
29
  */
12
- export declare function peerBackoffDelayMs(failures: number, base?: number, cap?: number): number;
30
+ export declare function peerBackoffDelayMs(failures: number, base?: number, cap?: number, retireAfter?: number, retiredMs?: number): number;
13
31
  interface PeerStreamOptions {
14
32
  /** The device to subscribe to. */
15
33
  device: DeviceProfile;
@@ -29,6 +47,10 @@ interface PeerStreamOptions {
29
47
  backoffCapMs?: number;
30
48
  /** Override the park threshold (tests). */
31
49
  parkAfterFailures?: number;
50
+ /** Override the retire threshold (tests). */
51
+ retireAfterFailures?: number;
52
+ /** Override the retired re-dial cadence (tests). */
53
+ retiredRecheckMs?: number;
32
54
  /** Override the ssh binary (tests). */
33
55
  sshBin?: string;
34
56
  /** Override the parked peer's registry re-check cadence (tests). */
@@ -19,6 +19,9 @@
19
19
  * - a peer that fails {@link PEER_PARK_AFTER_FAILURES} spawns in a row parked —
20
20
  * it stops the reconnect cycle and re-dials only when the device registry
21
21
  * changes or the capped backoff elapses;
22
+ * - a peer that fails {@link PEER_RETIRE_AFTER_FAILURES} in a row RETIRED — the
23
+ * capped 60 s ladder is itself unbounded in total work, so past that point the
24
+ * re-dial drops to {@link PEER_RETIRED_RECHECK_MS};
22
25
  * - abort listeners removed per iteration, so a watcher open for hours does not
23
26
  * accumulate one per reconnect on the caller's AbortSignal.
24
27
  */
@@ -34,17 +37,37 @@ export const PEER_BACKOFF_BASE_MS = 2_000;
34
37
  export const PEER_BACKOFF_CAP_MS = 60_000;
35
38
  /** Consecutive failed spawns before the peer is parked. */
36
39
  export const PEER_PARK_AFTER_FAILURES = 3;
40
+ /**
41
+ * Consecutive failed spawns before the peer is RETIRED — the capped 60 s ladder
42
+ * gives way to {@link PEER_RETIRED_RECHECK_MS}.
43
+ *
44
+ * The cap alone bounds the delay but not the total work: a box that is off for a
45
+ * weekend was dialed every 60 s for two days, ~2,880 ssh children per watcher
46
+ * per peer, each one already known to fail. Ten consecutive failures is well
47
+ * past any transient network event, so past that point the peer is treated as
48
+ * genuinely absent and re-dialed on the slow cadence instead.
49
+ */
50
+ export const PEER_RETIRE_AFTER_FAILURES = 10;
51
+ /**
52
+ * Re-dial cadence for a retired peer. Still bounded rather than never, because a
53
+ * box can come back without anything touching the device registry — waiting
54
+ * only on a registry change would leave it unreachable until an operator acted.
55
+ */
56
+ export const PEER_RETIRED_RECHECK_MS = 15 * 60_000;
37
57
  /** Bytes of a peer's stderr retained for the `unavailable` reason. */
38
58
  const PEER_STDERR_BYTES = 2_048;
39
59
  /** How often a parked peer re-checks the device registry for a refresh. */
40
60
  const PEER_REGISTRY_POLL_MS = 5_000;
41
61
  /**
42
62
  * 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}.
63
+ * peer, then {@link PEER_BACKOFF_BASE_MS} doubling to {@link PEER_BACKOFF_CAP_MS},
64
+ * and {@link PEER_RETIRED_RECHECK_MS} once the peer is retired.
44
65
  */
45
- export function peerBackoffDelayMs(failures, base = PEER_BACKOFF_BASE_MS, cap = PEER_BACKOFF_CAP_MS) {
66
+ export function peerBackoffDelayMs(failures, base = PEER_BACKOFF_BASE_MS, cap = PEER_BACKOFF_CAP_MS, retireAfter = PEER_RETIRE_AFTER_FAILURES, retiredMs = PEER_RETIRED_RECHECK_MS) {
46
67
  if (failures <= 0)
47
68
  return 0;
69
+ if (failures >= retireAfter)
70
+ return retiredMs;
48
71
  return Math.min(cap, base * 2 ** (failures - 1));
49
72
  }
50
73
  /** Bounded tail of a child's stderr, kept for the unavailable reason. */
@@ -133,10 +156,14 @@ export async function streamFromPeer(options) {
133
156
  failures += 1;
134
157
  const exit = code == null ? 'ssh failed' : `ssh exited ${code}`;
135
158
  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));
159
+ const retireAfter = options.retireAfterFailures ?? PEER_RETIRE_AFTER_FAILURES;
160
+ const retired = failures >= retireAfter;
161
+ const delay = peerBackoffDelayMs(failures, options.backoffBaseMs, options.backoffCapMs, retireAfter, options.retiredRecheckMs);
162
+ options.onUnavailable(retired
163
+ ? `${reasonFor(exit, stderr)} — retired after ${failures} failed connections, re-dialing in ${Math.round(delay / 60_000)}min or on a device refresh`
164
+ : parked
165
+ ? `${reasonFor(exit, stderr)} — parked after ${failures} failed connections, retrying in ${Math.round(delay / 1000)}s or on a device refresh`
166
+ : reasonFor(exit, stderr));
140
167
  await parkedWait(options, delay);
141
168
  }
142
169
  }
@@ -124,6 +124,26 @@ export declare function resolvePeerTarget(machine: string): Promise<{
124
124
  * error, timeout, version-skewed peer with no `--json` preview envelope.
125
125
  */
126
126
  export declare function fetchPeerPreviewDigest(sessionId: string, machine: string, timeoutMs?: number): Promise<unknown | undefined>;
127
+ export type PeerPreviewEnvelopeResult = {
128
+ ok: true;
129
+ envelope: unknown;
130
+ } | {
131
+ ok: false;
132
+ reason: 'no-target' | 'unreachable' | 'invalid-json';
133
+ };
134
+ /**
135
+ * Fetch the FULL `agents sessions preview <id> --local --json` envelope
136
+ * (session/active/preview/error, not just the `.preview` slice
137
+ * {@link fetchPeerPreviewDigest} narrows to) from a session's owning peer in
138
+ * exactly ONE bounded {@link sshCapture} hop. This is the canonical exact
139
+ * ID+owner preview loader (PHNX-3999): the peer's own command already runs the
140
+ * owner-side bounded parsers/fold (`loadSessionPreviewDigest`) and existing
141
+ * redaction, so a caller that already knows the owning device needs no
142
+ * separate metadata round trip before this one — unlike the general fleet
143
+ * resolver, which fans out a `sessions <id> --json --all` metadata query first
144
+ * because it does NOT yet know which peer (if any) holds the id.
145
+ */
146
+ export declare function fetchPeerPreviewEnvelope(sessionId: string, machine: string, timeoutMs?: number): Promise<PeerPreviewEnvelopeResult>;
127
147
  /** Parse the JSON envelope returned by `sessions preview --json`. */
128
148
  export declare function parsePeerPreviewDigest(parsed: unknown): unknown | undefined;
129
149
  /**
@@ -540,21 +540,37 @@ const PEER_PREVIEW_TIMEOUT_MS = 15_000;
540
540
  * error, timeout, version-skewed peer with no `--json` preview envelope.
541
541
  */
542
542
  export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEER_PREVIEW_TIMEOUT_MS) {
543
+ const envelope = await fetchPeerPreviewEnvelope(sessionId, machine, timeoutMs);
544
+ if (!envelope.ok)
545
+ return undefined;
546
+ return parsePeerPreviewDigest(envelope.envelope);
547
+ }
548
+ /**
549
+ * Fetch the FULL `agents sessions preview <id> --local --json` envelope
550
+ * (session/active/preview/error, not just the `.preview` slice
551
+ * {@link fetchPeerPreviewDigest} narrows to) from a session's owning peer in
552
+ * exactly ONE bounded {@link sshCapture} hop. This is the canonical exact
553
+ * ID+owner preview loader (PHNX-3999): the peer's own command already runs the
554
+ * owner-side bounded parsers/fold (`loadSessionPreviewDigest`) and existing
555
+ * redaction, so a caller that already knows the owning device needs no
556
+ * separate metadata round trip before this one — unlike the general fleet
557
+ * resolver, which fans out a `sessions <id> --json --all` metadata query first
558
+ * because it does NOT yet know which peer (if any) holds the id.
559
+ */
560
+ export async function fetchPeerPreviewEnvelope(sessionId, machine, timeoutMs = PEER_PREVIEW_TIMEOUT_MS) {
543
561
  const peer = await resolvePeerTarget(machine);
544
562
  if (!peer)
545
- return undefined;
563
+ return { ok: false, reason: 'no-target' };
546
564
  const cmd = remoteListCommand(['sessions', 'preview', sessionId, '--local', '--json'], peer.os);
547
565
  const capture = await sshCapture(peer.target, cmd, timeoutMs);
548
566
  if (capture.code !== 0)
549
- return undefined;
550
- let parsed;
567
+ return { ok: false, reason: 'unreachable' };
551
568
  try {
552
- parsed = JSON.parse(stripClixml(capture.stdout));
569
+ return { ok: true, envelope: JSON.parse(stripClixml(capture.stdout)) };
553
570
  }
554
571
  catch {
555
- return undefined;
572
+ return { ok: false, reason: 'invalid-json' };
556
573
  }
557
- return parsePeerPreviewDigest(parsed);
558
574
  }
559
575
  /** Parse the JSON envelope returned by `sessions preview --json`. */
560
576
  export function parsePeerPreviewDigest(parsed) {
@@ -59,6 +59,18 @@ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn' | 'cont
59
59
  branch?: string;
60
60
  rowKey: string;
61
61
  sourceDevice: string;
62
+ /**
63
+ * The registered project this session's working directory belongs to, or
64
+ * `null` when the association is not confirmed (PHNX-3999 F08/F09).
65
+ *
66
+ * This is what a UI groups by. It is deliberately not `project`: that field is
67
+ * a bucket KEY derived from the path (the basename of the cwd, so it always
68
+ * answers something), which is how a loose or unrelated directory became its
69
+ * own project group. `null` means Uncategorized — the row is still listed, it
70
+ * just is not filed under a project nobody bound it to. Resolved on the device
71
+ * that owns the path, since a definition's root is a local path.
72
+ */
73
+ confirmedProject: string | null;
62
74
  /** Durable index rows are kept on the stream under a distinct identity so a
63
75
  * live row can replace/disappear without erasing its recoverable history. */
64
76
  previous: boolean;
@@ -1,4 +1,5 @@
1
1
  import { SessionProjection } from '../projection.js';
2
+ import { confirmedProjectForCwd } from '../../projects.js';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import * as fs from 'node:fs';
4
5
  import * as path from 'node:path';
@@ -40,6 +41,11 @@ export function toSessionWatchRow(scope, row) {
40
41
  // no computed summary reads `pending` when the summarizer is on, `skipped`
41
42
  // when it is off (PHNX-3939). goal/checkpoints/summaryChecklist rode `...row`.
42
43
  summaryState: resolveStreamSummaryState(row.summaryState),
44
+ // The CONFIRMED project this work belongs to, or null for Uncategorized
45
+ // (PHNX-3999 F08/F09). Only a registered project definition counts — a
46
+ // consumer must never group by a directory basename, which is how unrelated
47
+ // and unbound directories became their own "projects".
48
+ confirmedProject: confirmedProjectForCwd(row.cwd) ?? null,
43
49
  rowKey,
44
50
  sourceDevice: scope,
45
51
  previous,
@@ -104,7 +110,10 @@ export function toPreviousSessionWatchRow(scope, session) {
104
110
  ...(session.harness ? { harness: session.harness } : {}),
105
111
  sessionId: session.id,
106
112
  ...(session.cwd ? { cwd: session.cwd } : {}),
113
+ // `project` stays the historical bucket KEY rows are joined on; the grouping a
114
+ // person sees comes from the confirmed association (PHNX-3999 F08/F09).
107
115
  ...(session.project ? { project: session.project } : {}),
116
+ confirmedProject: confirmedProjectForCwd(session.cwd) ?? null,
108
117
  // Same headline ladder as a live row (`deriveSessionRecap`, PHNX-3797):
109
118
  // `/rename` label → the daemon-generated title → `request.headline` (the
110
119
  // user's own sentence with attachment noise pulled out, PHNX-3939) →
@@ -0,0 +1,29 @@
1
+ /** Bounded, single-owner preview fetch with a durable requester cache and cross-process coalescing. */
2
+ import { fetchPeerPreviewEnvelope } from './remote/remote-list.js';
3
+ export type RemotePreviewCacheState = 'fresh' | 'stale-offline' | 'stale-error' | 'no-cache-offline' | 'no-cache-error' | 'invalid-id';
4
+ export interface RemotePreviewOutcome {
5
+ envelope?: unknown;
6
+ cache: {
7
+ source: 'live' | 'cache';
8
+ fetchedAt: number | null;
9
+ stale: boolean;
10
+ state: RemotePreviewCacheState;
11
+ device: string;
12
+ reason: string | null;
13
+ };
14
+ }
15
+ /** A changed caller cursor invalidates a good copy; failed attempts preserve both its content and cursor. */
16
+ export interface RemotePreviewDeps {
17
+ /** Defaults to the real bounded SSH transport ({@link fetchPeerPreviewEnvelope}).
18
+ * Overridable so the cache/backoff/revision/lease STATE MACHINE can be tested
19
+ * against a real SQLite DB without a live peer — this repo has no fleet in
20
+ * CI, and the actual bounded-transport behavior (timeout, byte cap) is
21
+ * already covered where `fetchPeerPreviewEnvelope`/`sshCapture` are defined. */
22
+ fetchEnvelope: typeof fetchPeerPreviewEnvelope;
23
+ }
24
+ export declare function getRemoteSessionPreview(sessionId: string, device: string, opts?: {
25
+ refresh?: boolean;
26
+ revision?: string;
27
+ now?: number;
28
+ timeoutMs?: number;
29
+ }, deps?: RemotePreviewDeps): Promise<RemotePreviewOutcome>;