@phnx-labs/agents-cli 1.22.56 → 1.22.58

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -4,11 +4,13 @@
4
4
  * These used to live as the top-level `agents trends` tree. That name was a
5
5
  * peer of `agents insights` with overlapping "analytics" meaning, so agents and
6
6
  * humans kept picking the wrong verb. The cheap counter path (sessions index +
7
- * usage.db) still exists — it is now `agents insights mix` and the recipe
8
- * subcommands below. Latency stays on `agents perf`; quota on `agents view`.
7
+ * usage.db) still exists — it is now `agents insights mix`. Latency stays on
8
+ * `agents insights perf`; quota on `agents view`.
9
9
  *
10
- * Former top-level `agents trends` is gone. The nested spelling
11
- * `agents insights trends` is an alias of `agents insights mix`.
10
+ * One surface, not five: the board is `agents insights mix`, one section is
11
+ * `agents insights mix <recipe>`, and `--list` names the recipe ids. The former
12
+ * per-recipe shortcut commands (`harness-mix`, `model-mix`, …), the `recipes`
13
+ * lister, and the `trends` alias were removed — `mix` already did all three.
12
14
  */
13
15
  import type { Command } from 'commander';
14
16
  export declare function parseMixDays(raw: string | undefined): number;
@@ -23,9 +25,8 @@ export declare function renderMixDashboard(days: number, asJson: boolean, banner
23
25
  *
24
26
  * Layout:
25
27
  * <parent> mix multi-recipe board
26
- * <parent> trends alias of mix (former top-level `agents trends`)
27
- * <parent> recipes list recipe ids
28
+ * <parent> mix <recipe> one baked recipe (harness-mix, model-mix, …)
29
+ * <parent> mix --list list recipe ids
28
30
  * <parent> query raw usage.db rows
29
- * <parent> harness-mix|… one baked recipe
30
31
  */
31
32
  export declare function registerMixCommands(parent: Command): void;
@@ -4,11 +4,13 @@
4
4
  * These used to live as the top-level `agents trends` tree. That name was a
5
5
  * peer of `agents insights` with overlapping "analytics" meaning, so agents and
6
6
  * humans kept picking the wrong verb. The cheap counter path (sessions index +
7
- * usage.db) still exists — it is now `agents insights mix` and the recipe
8
- * subcommands below. Latency stays on `agents perf`; quota on `agents view`.
7
+ * usage.db) still exists — it is now `agents insights mix`. Latency stays on
8
+ * `agents insights perf`; quota on `agents view`.
9
9
  *
10
- * Former top-level `agents trends` is gone. The nested spelling
11
- * `agents insights trends` is an alias of `agents insights mix`.
10
+ * One surface, not five: the board is `agents insights mix`, one section is
11
+ * `agents insights mix <recipe>`, and `--list` names the recipe ids. The former
12
+ * per-recipe shortcut commands (`harness-mix`, `model-mix`, …), the `recipes`
13
+ * lister, and the `trends` alias were removed — `mix` already did all three.
12
14
  */
13
15
  import chalk from 'chalk';
14
16
  import { setHelpSections } from '../help.js';
@@ -60,23 +62,53 @@ export function renderMixDashboard(days, asJson, bannerLabel = 'agents insights
60
62
  *
61
63
  * Layout:
62
64
  * <parent> mix multi-recipe board
63
- * <parent> trends alias of mix (former top-level `agents trends`)
64
- * <parent> recipes list recipe ids
65
+ * <parent> mix <recipe> one baked recipe (harness-mix, model-mix, …)
66
+ * <parent> mix --list list recipe ids
65
67
  * <parent> query raw usage.db rows
66
- * <parent> harness-mix|… one baked recipe
67
68
  */
68
69
  export function registerMixCommands(parent) {
69
70
  const banner = 'agents insights mix';
70
71
  const mix = parent
71
- .command('mix')
72
- .description('Counter recipes — harness/model mix, token ratios, resource frequency (sessions index + usage.db)')
72
+ .command('mix [recipe]')
73
+ .description('Counter recipes — harness/model mix, token ratios, resource frequency (sessions index + usage.db). Bare shows the board; pass a recipe id for one section.')
73
74
  .option('--days <n>', 'Days of history to include', '7')
75
+ .option('--list', 'List baked recipe ids and exit')
74
76
  .option('--json', 'Emit JSON instead of tables')
75
- .action(function summary() {
77
+ .action(function summary(recipe) {
76
78
  // optsWithGlobals(): --json collides by name with the `insights` parent, so
77
79
  // commander binds it to the parent and this.opts() never sees it. Merging
78
- // ancestor opts is what the per-recipe leaves below already do.
80
+ // ancestor opts is what the per-recipe path below already does.
79
81
  const o = this.optsWithGlobals();
82
+ if (o.list) {
83
+ const list = listRecipes();
84
+ if (o.json) {
85
+ console.log(JSON.stringify(list, null, 2));
86
+ return;
87
+ }
88
+ for (const r of list) {
89
+ console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
90
+ }
91
+ return;
92
+ }
93
+ if (recipe) {
94
+ if (!RECIPE_IDS.includes(recipe)) {
95
+ console.error(`Unknown recipe '${recipe}'. Known: ${RECIPE_IDS.join(', ')} (or 'agents insights mix --list').`);
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+ const win = analyticsWindow(parseMixDays(o.days));
100
+ const section = runRecipe(recipe, win);
101
+ if (o.json) {
102
+ console.log(JSON.stringify({ window: win, section }, null, 2));
103
+ return;
104
+ }
105
+ if (section.empty) {
106
+ console.log(chalk.gray(`No data for recipe '${recipe}' in the last ${win.days} days.`));
107
+ return;
108
+ }
109
+ printMixSection(section);
110
+ return;
111
+ }
80
112
  renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
81
113
  });
82
114
  setHelpSections(mix, {
@@ -88,36 +120,23 @@ export function registerMixCommands(parent) {
88
120
  agents insights mix --days 30
89
121
 
90
122
  # One recipe as JSON
91
- agents insights harness-mix --json
123
+ agents insights mix harness-mix --json
124
+
125
+ # List baked recipe ids
126
+ agents insights mix --list
92
127
 
93
128
  # Raw usage events
94
129
  agents insights query --kind secret --days 7
95
-
96
- # List baked recipe ids
97
- agents insights recipes
98
130
  `,
99
131
  notes: `
100
132
  Session recipes read sessions.db; resource recipes read ~/.agents/.history/analytics/usage.db.
101
- Empty recipes are skipped on the default mix board.
133
+ Empty recipes are skipped on the default mix board. Pass one recipe id
134
+ (\`agents insights mix harness-mix\`) for just that section; \`--list\` names them.
102
135
  This is the cheap counter path. Behavioural report (transcript content, account split)
103
- is bare \`agents insights\`. Latency is \`agents perf\`; quota is \`agents view\`.
136
+ is bare \`agents insights\`. Latency is \`agents insights perf\`; quota is \`agents view\`.
104
137
  Skill/slash-command popularity is \`agents sessions stats\`.
105
138
  `,
106
139
  });
107
- parent.command('recipes')
108
- .description('List baked mix-recipe ids')
109
- .option('--json', 'Emit JSON')
110
- .action(function recipes() {
111
- const o = this.optsWithGlobals();
112
- const list = listRecipes();
113
- if (o.json) {
114
- console.log(JSON.stringify(list, null, 2));
115
- return;
116
- }
117
- for (const r of list) {
118
- console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
119
- }
120
- });
121
140
  parent.command('query')
122
141
  .description('Raw usage-event query (usage.db)')
123
142
  .option('--kind <kind>', `One of: ${USAGE_KINDS.join(', ')}`)
@@ -164,46 +183,4 @@ export function registerMixCommands(parent) {
164
183
  })),
165
184
  });
166
185
  });
167
- for (const id of RECIPE_IDS) {
168
- parent.command(id)
169
- .description(`Mix recipe: ${id}`)
170
- .option('--days <n>', 'Days of history', '7')
171
- .option('--json', 'Emit JSON')
172
- .action(function recipeAction() {
173
- // optsWithGlobals() merges the `insights` parent opts, so the name-colliding
174
- // --json/--since reach this leaf (see the sibling commands above).
175
- const o = this.optsWithGlobals();
176
- const win = analyticsWindow(parseMixDays(o.days));
177
- const section = runRecipe(id, win);
178
- if (o.json) {
179
- console.log(JSON.stringify({ window: win, section }, null, 2));
180
- return;
181
- }
182
- if (section.empty) {
183
- console.log(chalk.gray(`No data for recipe '${id}' in the last ${win.days} days.`));
184
- return;
185
- }
186
- printMixSection(section);
187
- });
188
- }
189
- // Nested home for the former top-level `agents trends` spelling.
190
- const trends = parent
191
- .command('trends')
192
- .description('Alias of `mix` — former top-level `agents trends`')
193
- .option('--days <n>', 'Days of history to include', '7')
194
- .option('--json', 'Emit JSON instead of tables')
195
- .action(function summary() {
196
- const o = this.optsWithGlobals();
197
- renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
198
- });
199
- setHelpSections(trends, {
200
- examples: `
201
- agents insights trends
202
- agents insights mix
203
- `,
204
- notes: `
205
- \`agents insights trends\` is the nested spelling of the former top-level
206
- \`agents trends\`. Prefer \`agents insights mix\`.
207
- `,
208
- });
209
186
  }
@@ -143,7 +143,17 @@ export interface MintAndSeedResult {
143
143
  */
144
144
  export declare function mintAndSeed(input: MintAndSeedInput): Promise<MintAndSeedResult>;
145
145
  export declare function resolveSyncTargets(fleet: boolean, devices: string[]): Promise<string[]>;
146
- /** True when a Claude setup-token is already seeded on this box (setup status). */
146
+ /**
147
+ * True when a Claude setup-token is already seeded on this box (setup status).
148
+ *
149
+ * Read-only status probe (`agents setup`, `agents doctor`) — the reserved
150
+ * auth-bundle check below MUST NOT crash the whole command because the
151
+ * keychain could not be reached (e.g. the macOS Keychain helper source is
152
+ * unavailable — PHNX-3385); `bundleExists()`/`hasKeychainToken()` document
153
+ * themselves as failing loud, but that contract is for destructive-write
154
+ * guards, not a diagnostic. An unreachable keychain here just means "cannot
155
+ * confirm the reserved bundle," reported honestly rather than propagated.
156
+ */
147
157
  export declare function hasMintedSetupToken(): {
148
158
  ready: boolean;
149
159
  detail: string;
@@ -414,18 +414,33 @@ async function syncMintedBundles(accountName, device) {
414
414
  return { device, ok: false, message: err instanceof Error ? err.message : String(err) };
415
415
  }
416
416
  }
417
- /** True when a Claude setup-token is already seeded on this box (setup status). */
417
+ /**
418
+ * True when a Claude setup-token is already seeded on this box (setup status).
419
+ *
420
+ * Read-only status probe (`agents setup`, `agents doctor`) — the reserved
421
+ * auth-bundle check below MUST NOT crash the whole command because the
422
+ * keychain could not be reached (e.g. the macOS Keychain helper source is
423
+ * unavailable — PHNX-3385); `bundleExists()`/`hasKeychainToken()` document
424
+ * themselves as failing loud, but that contract is for destructive-write
425
+ * guards, not a diagnostic. An unreachable keychain here just means "cannot
426
+ * confirm the reserved bundle," reported honestly rather than propagated.
427
+ */
418
428
  export function hasMintedSetupToken() {
419
429
  const records = Object.values(readAccountRegistry().accounts);
420
430
  const setup = records.filter((a) => a.auth === 'setup-token' && a.provider === 'anthropic');
421
431
  if (setup.length) {
422
432
  return { ready: true, detail: `${setup.length} Claude setup-token account${setup.length === 1 ? '' : 's'}` };
423
433
  }
424
- if (bundleExists(AUTH_BUNDLE) && bundleBackend(AUTH_BUNDLE) === 'file') {
425
- const bundle = readBundle(AUTH_BUNDLE);
426
- const keys = Object.keys(bundle.vars).filter((k) => k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_'));
427
- if (keys.length)
428
- return { ready: true, detail: `reserved auth bundle (${keys.length} account key${keys.length === 1 ? '' : 's'})` };
434
+ try {
435
+ if (bundleExists(AUTH_BUNDLE) && bundleBackend(AUTH_BUNDLE) === 'file') {
436
+ const bundle = readBundle(AUTH_BUNDLE);
437
+ const keys = Object.keys(bundle.vars).filter((k) => k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_'));
438
+ if (keys.length)
439
+ return { ready: true, detail: `reserved auth bundle (${keys.length} account key${keys.length === 1 ? '' : 's'})` };
440
+ }
441
+ }
442
+ catch (err) {
443
+ return { ready: false, detail: `could not check the reserved auth bundle: ${err.message}` };
429
444
  }
430
445
  return { ready: false, detail: 'no Claude setup-token minted — agents accounts mint claude' };
431
446
  }
@@ -12,6 +12,14 @@ export declare function getSocketPath(): string;
12
12
  /** Is the daemon reachable? A real connect probe on every platform — a socket
13
13
  * file existing on disk is not proof a daemon is listening on it. */
14
14
  export declare function isDaemonReachable(): Promise<boolean>;
15
+ /**
16
+ * Is a *reachable* daemon actually responsive, or is its event loop wedged
17
+ * (PHNX-3411)? Retries {@link probeDaemonResponsive} up to
18
+ * {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
19
+ * pause) never condemns a healthy daemon; returns true as soon as any attempt
20
+ * gets a reply, and false only when every attempt fails.
21
+ */
22
+ export declare function isDaemonResponsive(endpoint?: string, attempts?: number): Promise<boolean>;
15
23
  /**
16
24
  * Wait until the browser daemon is genuinely reachable, or throw.
17
25
  *
@@ -92,6 +92,31 @@ const SOCKET_WAIT_TIMEOUT_MS = 15_000;
92
92
  * doesn't race a restart it happened to probe mid-flight.
93
93
  */
94
94
  const SOCKET_WAIT_STABLE_PROBES = 2;
95
+ /**
96
+ * How long a single {@link probeDaemonResponsive} attempt waits for the daemon to
97
+ * answer a trivial `version` request before giving up (PHNX-3411).
98
+ *
99
+ * A unix-socket `connect` succeeds at the KERNEL level the moment the connection
100
+ * is queued in the listen backlog — it does NOT require the server's event loop
101
+ * to run. So a daemon whose event loop is blocked (e.g. a long synchronous burst
102
+ * on the shared loop) still passes {@link isDaemonReachable}: the socket accepts,
103
+ * but the request is never serviced. The live symptom on zion was exactly this —
104
+ * `browser.sock` accepted every connection while the daemon re-indexed sessions,
105
+ * then never replied, so cross-device browser drives hung or surfaced a confusing
106
+ * bare socket timeout. Requiring an actual reply is the only probe that tells a
107
+ * healthy daemon apart from a wedged one.
108
+ */
109
+ const RESPONSIVENESS_PROBE_TIMEOUT_MS = 1_500;
110
+ /**
111
+ * Consecutive missed {@link probeDaemonResponsive} attempts before a *reachable*
112
+ * daemon is declared wedged. One missed reply can be a transient GC pause on an
113
+ * otherwise-healthy loop, so a single failure never condemns the daemon; a daemon
114
+ * that cannot answer a trivial `version` request across this whole window has a
115
+ * genuinely blocked event loop. The added latency (attempts × timeout) is paid
116
+ * ONLY on the wedged path — where the alternative was an indefinite hang — while
117
+ * a healthy daemon answers the first attempt in ~1ms.
118
+ */
119
+ const RESPONSIVENESS_PROBE_ATTEMPTS = 3;
95
120
  export class BrowserDaemonNotRunningError extends Error {
96
121
  constructor() {
97
122
  super(formatBrowserDaemonNotRunningError());
@@ -140,6 +165,57 @@ function probeDaemon(endpoint, timeoutMs = 500) {
140
165
  export async function isDaemonReachable() {
141
166
  return probeDaemon(getIpcEndpoint());
142
167
  }
168
+ /**
169
+ * Can the daemon actually REPLY right now? Opens a connection and requires a
170
+ * parseable response to a `version` request within `timeoutMs`. Unlike
171
+ * {@link probeDaemon} (which resolves on the kernel-level `connect`), this only
172
+ * succeeds when the daemon's event loop is running and services the request — so
173
+ * it is the one probe that distinguishes a healthy daemon from a wedged one whose
174
+ * loop is blocked (PHNX-3411). Resolves false on connect error, timeout, an early
175
+ * close, or an unparseable reply. `version` is the right probe: its handler is
176
+ * trivial and synchronous (never touches the browser), so a slow reply means the
177
+ * loop is blocked, not that a real action is in flight.
178
+ */
179
+ function probeDaemonResponsive(endpoint, timeoutMs = RESPONSIVENESS_PROBE_TIMEOUT_MS) {
180
+ return new Promise((resolve) => {
181
+ const sock = net.createConnection(endpoint);
182
+ let buffer = '';
183
+ let done = false;
184
+ const finish = (ok) => { if (done)
185
+ return; done = true; clearTimeout(timer); sock.destroy(); resolve(ok); };
186
+ const timer = setTimeout(() => finish(false), timeoutMs);
187
+ sock.on('connect', () => { sock.write(JSON.stringify({ action: 'version' }) + '\n'); });
188
+ sock.on('data', (data) => {
189
+ buffer += data.toString();
190
+ const idx = buffer.indexOf('\n');
191
+ if (idx === -1)
192
+ return;
193
+ try {
194
+ const resp = JSON.parse(buffer.slice(0, idx));
195
+ finish(resp.ok === true);
196
+ }
197
+ catch {
198
+ finish(false);
199
+ }
200
+ });
201
+ sock.on('error', () => finish(false));
202
+ sock.on('close', () => finish(false));
203
+ });
204
+ }
205
+ /**
206
+ * Is a *reachable* daemon actually responsive, or is its event loop wedged
207
+ * (PHNX-3411)? Retries {@link probeDaemonResponsive} up to
208
+ * {@link RESPONSIVENESS_PROBE_ATTEMPTS} times so a single transient miss (a GC
209
+ * pause) never condemns a healthy daemon; returns true as soon as any attempt
210
+ * gets a reply, and false only when every attempt fails.
211
+ */
212
+ export async function isDaemonResponsive(endpoint = getIpcEndpoint(), attempts = RESPONSIVENESS_PROBE_ATTEMPTS) {
213
+ for (let i = 0; i < attempts; i++) {
214
+ if (await probeDaemonResponsive(endpoint))
215
+ return true;
216
+ }
217
+ return false;
218
+ }
143
219
  /**
144
220
  * Wait until the browser daemon is genuinely reachable, or throw.
145
221
  *
@@ -1087,6 +1163,17 @@ async function prepareIPC(action, opts) {
1087
1163
  }
1088
1164
  await new Promise((r) => setTimeout(r, 300));
1089
1165
  }
1166
+ // The socket accepts connections — but a unix-socket `connect` succeeds at the
1167
+ // kernel level even when the daemon's event loop is blocked and never services
1168
+ // the request (PHNX-3411). Require a real reply before treating the daemon as
1169
+ // usable, so a wedged daemon fails LOUD with an accurate, actionable error
1170
+ // instead of the confusing bare socket timeout / indefinite hang the browser
1171
+ // verb would otherwise hit (including inside reconcileDaemonVersion's own
1172
+ // version probe, which has no response timeout). Skipped for callers that opt
1173
+ // out of auto-start — they want the clean BrowserDaemonNotRunningError instead.
1174
+ if (autoStartDaemon && !(await isDaemonResponsive(getIpcEndpoint()))) {
1175
+ throw new Error(actionable('Browser daemon is running but unresponsive — its event loop is blocked, so it accepts the connection but never replies.', `Endpoint: ${getIpcEndpoint()}`, `Log: ${getDaemonLogPath()}`, 'Next: agents browser stop --daemon (resets the wedged daemon; the next browser command restarts it)'));
1176
+ }
1090
1177
  // Before serving a real request, make sure the daemon isn't running stale
1091
1178
  // code. Skips the internal `version` probe (avoids recursion) and callers
1092
1179
  // that opt out of auto-start. No-ops once reconciled or when versions match.
@@ -534,6 +534,25 @@ export declare class BrowserService {
534
534
  * tasks.json entries. Used before identity-based resolution so a daemon
535
535
  * restart does not make the caller's tasks invisible.
536
536
  */
537
+ /**
538
+ * Register a freshly-rehydrated connection, unless a concurrent rehydrate won
539
+ * the race and already registered one for this key. `attachRunningProfile`
540
+ * awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
541
+ * after a daemon restart can each build a connection for the same key; the
542
+ * loser must be released instead of silently overwriting the winner (whose
543
+ * `cleanup` would then never run).
544
+ *
545
+ * The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
546
+ * the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
547
+ * **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
548
+ * the same `pid`/`port`. Calling the loser's `cleanup()` then does
549
+ * `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
550
+ * when the loser shares the winner's tunnel we close ONLY the loser's own CDP
551
+ * socket and leave the tunnel to the winner; only a loser that spawned its own
552
+ * distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
553
+ * winning connection to use.
554
+ */
555
+ private registerRehydratedConnection;
537
556
  private rehydrateAllFromDisk;
538
557
  /**
539
558
  * On a RAM miss, scan profile runtime dirs for a tasks.json entry matching
@@ -2608,9 +2608,45 @@ export class BrowserService {
2608
2608
  if (port === undefined)
2609
2609
  return null;
2610
2610
  const host = parsed?.host && parsed.host !== 'localhost' ? parsed.host : 'localhost';
2611
- // ssh:// endpoints need a tunnel; soft rehydrate only handles local CDP.
2612
- if (resolved.target.startsWith('ssh:'))
2613
- return null;
2611
+ // ssh:// endpoints: a daemon restart killed this box's tunnel, but the
2612
+ // browser is still running on the FAR side — so the caller's tasks are
2613
+ // valid, only the local hop is gone. Re-establish the tunnel and reconnect
2614
+ // CDP (connectSSH attaches to the already-running remote browser via
2615
+ // isOwnTunnel — it never launches one, so this stays a soft-attach), then
2616
+ // merge the disk tasks. Without this, a remote agent driving a browser host
2617
+ // after a restart got "Unknown browser task" for a tab that was still alive
2618
+ // (PHNX-2663). A failure returns null exactly like the local-CDP path, so
2619
+ // the caller falls through to disk reconcile rather than crashing.
2620
+ if (resolved.target.startsWith('ssh:')) {
2621
+ try {
2622
+ const conn = await connectSSH(resolved.target, profile, key, { persistRemote: true });
2623
+ await this.enableDomains(conn.cdp);
2624
+ const tasks = this.loadTaskState(key);
2625
+ for (const [k, t] of diskTasks) {
2626
+ if (!tasks.has(k))
2627
+ tasks.set(k, t);
2628
+ }
2629
+ return {
2630
+ cdp: conn.cdp,
2631
+ port: conn.port,
2632
+ pid: conn.pid,
2633
+ electron: profile.electron,
2634
+ browserType: profile.browser,
2635
+ targetFilter: resolved.targetFilter ?? profile.targetFilter,
2636
+ key,
2637
+ profile: bare,
2638
+ tasks,
2639
+ sessionCache: new Map(),
2640
+ // Carry the tunnel teardown so removing this connection kills the
2641
+ // ssh hop — otherwise it leaks across the next restart (see the
2642
+ // `cleanup` docblock on ProfileConnection).
2643
+ cleanup: conn.cleanup,
2644
+ };
2645
+ }
2646
+ catch {
2647
+ return null;
2648
+ }
2649
+ }
2614
2650
  try {
2615
2651
  const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
2616
2652
  verifyBrowserIdentity(browser, profile.browser, port, host);
@@ -2645,6 +2681,48 @@ export class BrowserService {
2645
2681
  * tasks.json entries. Used before identity-based resolution so a daemon
2646
2682
  * restart does not make the caller's tasks invisible.
2647
2683
  */
2684
+ /**
2685
+ * Register a freshly-rehydrated connection, unless a concurrent rehydrate won
2686
+ * the race and already registered one for this key. `attachRunningProfile`
2687
+ * awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
2688
+ * after a daemon restart can each build a connection for the same key; the
2689
+ * loser must be released instead of silently overwriting the winner (whose
2690
+ * `cleanup` would then never run).
2691
+ *
2692
+ * The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
2693
+ * the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
2694
+ * **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
2695
+ * the same `pid`/`port`. Calling the loser's `cleanup()` then does
2696
+ * `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
2697
+ * when the loser shares the winner's tunnel we close ONLY the loser's own CDP
2698
+ * socket and leave the tunnel to the winner; only a loser that spawned its own
2699
+ * distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
2700
+ * winning connection to use.
2701
+ */
2702
+ registerRehydratedConnection(key, conn) {
2703
+ const existing = this.connections.get(key);
2704
+ if (!existing) {
2705
+ this.connections.set(key, conn);
2706
+ return conn;
2707
+ }
2708
+ const sharesTunnel = conn.pid !== 0 && conn.pid === existing.pid && conn.port === existing.port;
2709
+ if (conn.pid !== 0 && !sharesTunnel && conn.cleanup) {
2710
+ // Distinct tunnel — full teardown so this loser's tunnel doesn't leak.
2711
+ try {
2712
+ conn.cleanup();
2713
+ }
2714
+ catch { /* best effort — the winner stays live */ }
2715
+ }
2716
+ else {
2717
+ // Shared/borrowed tunnel (or a local connection with no tunnel): close
2718
+ // only our own CDP client; killing the tunnel would break the winner.
2719
+ try {
2720
+ conn.cdp.close();
2721
+ }
2722
+ catch { /* best effort */ }
2723
+ }
2724
+ return existing;
2725
+ }
2648
2726
  async rehydrateAllFromDisk() {
2649
2727
  const runtimeRoot = getBrowserRuntimeDir();
2650
2728
  let dirNames = [];
@@ -2676,7 +2754,11 @@ export class BrowserService {
2676
2754
  const conn = await this.attachRunningProfile(key, tasks);
2677
2755
  if (!conn)
2678
2756
  continue;
2679
- this.connections.set(key, conn);
2757
+ // A concurrent rehydrate may have registered one for this key while we
2758
+ // awaited the attach; keep the winner and tear our loser's tunnel down.
2759
+ const registered = this.registerRehydratedConnection(key, conn);
2760
+ if (registered !== conn)
2761
+ continue;
2680
2762
  try {
2681
2763
  await this.applyDefaultDownloadBehavior(conn, key);
2682
2764
  }
@@ -2734,13 +2816,16 @@ export class BrowserService {
2734
2816
  // CDP down — cannot rehydrate a live connection; caller will error.
2735
2817
  continue;
2736
2818
  }
2737
- conn = attached;
2738
- this.connections.set(key, conn);
2739
- try {
2740
- await this.applyDefaultDownloadBehavior(conn, key);
2741
- }
2742
- catch {
2743
- // Non-fatal.
2819
+ // Keep whichever connection a concurrent rehydrate registered first and
2820
+ // tear our loser's tunnel down instead of leaking it (see the helper).
2821
+ conn = this.registerRehydratedConnection(key, attached);
2822
+ if (conn === attached) {
2823
+ try {
2824
+ await this.applyDefaultDownloadBehavior(conn, key);
2825
+ }
2826
+ catch {
2827
+ // Non-fatal.
2828
+ }
2744
2829
  }
2745
2830
  }
2746
2831
  const task = this.lookupTaskOnConn(conn, taskId) ?? found;
@@ -21,6 +21,7 @@ import { formatBytes } from '../format.js';
21
21
  export { formatBytes };
22
22
  import * as path from 'path';
23
23
  import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
24
+ import { listProfileCacheDirs } from './runtime-state.js';
24
25
  import { formatRelativeTime } from '../session/relative-time.js';
25
26
  import { getSessionById, listBrowserSessionRecords, pruneToolSessions } from '../session/db.js';
26
27
  import { listPidSessionEntries } from '../session/pid-registry.js';
@@ -99,7 +100,15 @@ export function listProfileArtifacts(profile) {
99
100
  export function listBrowserSessions(only) {
100
101
  let profiles;
101
102
  if (only) {
102
- profiles = [only];
103
+ // A profile's live tasks/captures may live under a composite runtime dir
104
+ // (`<name>@<device>`, `<name>@endpoint-N`, forks) — NOT the bare `<name>`
105
+ // dir. Resolve `only` to every cache dir that belongs to it, the same rule
106
+ // status()/findTask use (keyBelongsToProfile), so `--profile comet-local`
107
+ // surfaces the real `comet-local@zion` store instead of the empty legacy
108
+ // dir. Keep the requested name when nothing exists on disk yet so a fresh
109
+ // profile still returns its (empty) entry rather than vanishing.
110
+ const dirs = listProfileCacheDirs(only).map((d) => path.basename(d));
111
+ profiles = dirs.length > 0 ? dirs : [only];
103
112
  }
104
113
  else {
105
114
  try {
@@ -41,6 +41,7 @@ import { DeviceProbeService } from './device-probe-service.js';
41
41
  import { SelfHealService } from './self-heal-service.js';
42
42
  import { KeychainReapService } from './keychain-reap-service.js';
43
43
  import { AuthSyncService } from './auth-sync-service.js';
44
+ import { UsageSyncService } from './usage-sync-service.js';
44
45
  import { StateDirCheckService } from './state-dir-check-service.js';
45
46
  import { SessionStateService } from './session-state-service.js';
46
47
  import { WebhookReceiverService } from './webhook-receiver-service.js';
@@ -858,6 +859,10 @@ export async function runDaemon() {
858
859
  supervisor.register(new AuthSyncService());
859
860
  else
860
861
  log('INFO', 'Auth-sync service disabled');
862
+ if (isEnabled('usage-sync'))
863
+ supervisor.register(new UsageSyncService());
864
+ else
865
+ log('INFO', 'Usage-sync service disabled');
861
866
  if (isEnabled('webhook-receiver'))
862
867
  supervisor.register(new WebhookReceiverService());
863
868
  else
@@ -113,6 +113,8 @@ export interface RoutineLaunchPlan {
113
113
  /** False when `account` names a harness-native login rather than a durable credential. */
114
114
  forwardAccount?: boolean;
115
115
  }
116
+ /** Claude's own local auth check; unlike account metadata it cannot mistake identity for a usable login. */
117
+ export declare function claudeVersionIsAuthenticated(version: string): boolean;
116
118
  /**
117
119
  * Resolve the version/account chain for a routine the same way `agents run`
118
120
  * does: honor an explicit `version:` pin; otherwise use the configured run
@@ -130,6 +132,7 @@ export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, de
130
132
  resolveCredentialAccount?: (name: string, host: AgentId) => {
131
133
  env: Record<string, string>;
132
134
  };
135
+ claudeVersionIsAuthenticated?: typeof claudeVersionIsAuthenticated;
133
136
  }): Promise<RoutineLaunchPlan>;
134
137
  /**
135
138
  * Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.