@phnx-labs/agents-cli 1.22.74 → 1.22.76

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 (148) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +21 -9
  3. package/dist/bootstrap.js +7 -7
  4. package/dist/cli/command-registry.js +5 -0
  5. package/dist/commands/artifacts-setup.js +1 -1
  6. package/dist/commands/artifacts.js +1 -1
  7. package/dist/commands/auth.js +7 -1
  8. package/dist/commands/browser.js +104 -10
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/computer.d.ts +1 -0
  11. package/dist/commands/computer.js +26 -7
  12. package/dist/commands/config.js +27 -4
  13. package/dist/commands/cost.js +6 -4
  14. package/dist/commands/doctor.d.ts +6 -5
  15. package/dist/commands/doctor.js +32 -274
  16. package/dist/commands/exec.d.ts +2 -0
  17. package/dist/commands/exec.js +9 -2
  18. package/dist/commands/harness.d.ts +1 -0
  19. package/dist/commands/harness.js +11 -3
  20. package/dist/commands/hooks.js +7 -6
  21. package/dist/commands/mcp.js +7 -6
  22. package/dist/commands/memory.js +7 -7
  23. package/dist/commands/monitors.js +3 -2
  24. package/dist/commands/open.d.ts +25 -12
  25. package/dist/commands/open.js +24 -10
  26. package/dist/commands/permissions.js +7 -6
  27. package/dist/commands/plugins.js +21 -17
  28. package/dist/commands/route.js +33 -16
  29. package/dist/commands/rules.js +7 -12
  30. package/dist/commands/sessions-share.js +1 -1
  31. package/dist/commands/setup-watchdog.js +2 -2
  32. package/dist/commands/setup.js +22 -1
  33. package/dist/commands/share.js +26 -10
  34. package/dist/commands/skills.js +7 -6
  35. package/dist/commands/subagents.js +7 -6
  36. package/dist/commands/sync.js +81 -10
  37. package/dist/commands/view.js +4 -1
  38. package/dist/commands/watchdog.d.ts +1 -1
  39. package/dist/commands/watchdog.js +10 -10
  40. package/dist/commands/webhook.d.ts +4 -0
  41. package/dist/commands/webhook.js +22 -4
  42. package/dist/commands/workflows.js +7 -6
  43. package/dist/lib/account-registry.js +27 -6
  44. package/dist/lib/accounting/rotate.d.ts +3 -1
  45. package/dist/lib/accounting/rotate.js +8 -4
  46. package/dist/lib/auth-health.d.ts +2 -0
  47. package/dist/lib/auth-health.js +2 -0
  48. package/dist/lib/browser/chrome.d.ts +21 -0
  49. package/dist/lib/browser/chrome.js +60 -3
  50. package/dist/lib/browser/drivers/local.d.ts +21 -0
  51. package/dist/lib/browser/drivers/local.js +102 -9
  52. package/dist/lib/browser/profiles.d.ts +29 -1
  53. package/dist/lib/browser/profiles.js +50 -1
  54. package/dist/lib/browser/types.d.ts +18 -0
  55. package/dist/lib/computer/computer-rpc.d.ts +6 -1
  56. package/dist/lib/computer/computer-rpc.js +23 -3
  57. package/dist/lib/computer/des.d.ts +1 -0
  58. package/dist/lib/computer/des.js +114 -0
  59. package/dist/lib/computer/rfb-client.d.ts +53 -0
  60. package/dist/lib/computer/rfb-client.js +562 -0
  61. package/dist/lib/config-keys.d.ts +7 -2
  62. package/dist/lib/config-keys.js +17 -2
  63. package/dist/lib/daemon/auth-sync-service.js +3 -0
  64. package/dist/lib/daemon/daemon.js +17 -10
  65. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  66. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  67. package/dist/lib/daemon/usage-sync-service.js +3 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/daemon-ticks.d.ts +2 -2
  71. package/dist/lib/daemon-ticks.js +2 -1
  72. package/dist/lib/daemon-webhooks.js +15 -2
  73. package/dist/lib/deeplink/register.js +10 -9
  74. package/dist/lib/deeplink/url.d.ts +4 -4
  75. package/dist/lib/deeplink/url.js +4 -4
  76. package/dist/lib/device-config.js +25 -0
  77. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  78. package/dist/lib/devices/doctor-findings.js +14 -8
  79. package/dist/lib/devices/registry.js +2 -0
  80. package/dist/lib/devices/stats-cache.d.ts +4 -0
  81. package/dist/lib/devices/stats-cache.js +19 -0
  82. package/dist/lib/drift-sync.d.ts +3 -1
  83. package/dist/lib/drift-sync.js +16 -5
  84. package/dist/lib/exec.d.ts +2 -0
  85. package/dist/lib/exec.js +16 -1
  86. package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
  87. package/dist/lib/fleet-shared-repo-sync.js +101 -4
  88. package/dist/lib/fleet-shared-state.d.ts +8 -0
  89. package/dist/lib/heal.d.ts +4 -3
  90. package/dist/lib/heal.js +5 -4
  91. package/dist/lib/hosts/ready.d.ts +1 -1
  92. package/dist/lib/hosts/ready.js +16 -4
  93. package/dist/lib/hosts/reconnect.js +4 -2
  94. package/dist/lib/identity/client.d.ts +6 -0
  95. package/dist/lib/identity/index.d.ts +16 -0
  96. package/dist/lib/identity/index.js +25 -1
  97. package/dist/lib/profiles.d.ts +2 -0
  98. package/dist/lib/profiles.js +28 -9
  99. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  100. package/dist/lib/reconcile-and-repair.js +267 -0
  101. package/dist/lib/routers.d.ts +12 -1
  102. package/dist/lib/routers.js +30 -1
  103. package/dist/lib/scheduling/routines.js +8 -2
  104. package/dist/lib/session/active.d.ts +13 -0
  105. package/dist/lib/session/db.d.ts +47 -7
  106. package/dist/lib/session/db.js +114 -12
  107. package/dist/lib/session/mirror.js +58 -0
  108. package/dist/lib/session/remote/remote-list.d.ts +2 -0
  109. package/dist/lib/session/remote/remote-list.js +4 -0
  110. package/dist/lib/session/remote/watch.js +22 -2
  111. package/dist/lib/session/session-cache.d.ts +19 -0
  112. package/dist/lib/session/session-cache.js +46 -0
  113. package/dist/lib/session/types.d.ts +34 -0
  114. package/dist/lib/share/backend.d.ts +6 -4
  115. package/dist/lib/share/backend.js +10 -8
  116. package/dist/lib/share/config.d.ts +4 -3
  117. package/dist/lib/share/config.js +10 -1
  118. package/dist/lib/share/delete.d.ts +1 -1
  119. package/dist/lib/share/delete.js +1 -1
  120. package/dist/lib/share/html.d.ts +1 -1
  121. package/dist/lib/share/html.js +1 -1
  122. package/dist/lib/share/provision.d.ts +1 -1
  123. package/dist/lib/share/provision.js +2 -2
  124. package/dist/lib/share/publish.d.ts +23 -7
  125. package/dist/lib/share/publish.js +58 -12
  126. package/dist/lib/share/worker-template.js +221 -60
  127. package/dist/lib/startup/command-registry.js +2 -2
  128. package/dist/lib/state.d.ts +15 -0
  129. package/dist/lib/state.js +29 -7
  130. package/dist/lib/summarizer/config.d.ts +46 -0
  131. package/dist/lib/summarizer/config.js +83 -0
  132. package/dist/lib/summarizer/pass.d.ts +45 -0
  133. package/dist/lib/summarizer/pass.js +112 -0
  134. package/dist/lib/summarizer/summarize.d.ts +68 -0
  135. package/dist/lib/summarizer/summarize.js +120 -0
  136. package/dist/lib/teams/agents.d.ts +4 -3
  137. package/dist/lib/teams/agents.js +12 -4
  138. package/dist/lib/teams/scheduler.d.ts +4 -2
  139. package/dist/lib/teams/scheduler.js +6 -6
  140. package/dist/lib/tmux/session.d.ts +2 -0
  141. package/dist/lib/tmux/session.js +7 -1
  142. package/dist/lib/types.d.ts +20 -0
  143. package/dist/lib/verbs.d.ts +23 -0
  144. package/dist/lib/verbs.js +24 -0
  145. package/dist/lib/view-types.d.ts +4 -0
  146. package/dist/lib/watchdog/rotate.d.ts +1 -1
  147. package/dist/lib/watchdog/rotate.js +1 -1
  148. package/package.json +1 -1
@@ -32,6 +32,7 @@ import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from '../
32
32
  import { recordSubsystemOk, recordSubsystemError, recordSubsystemErrorReason, readSubsystemHealth, SUBSYSTEM_DAEMON_START } from '../daemon-health.js';
33
33
  import { ServiceSupervisor } from './supervisor.js';
34
34
  import { SessionIndexService } from './session-index-service.js';
35
+ import { SessionSummarizerService } from './session-summarizer-service.js';
35
36
  import { SecretsBrokerService } from './secrets-broker-service.js';
36
37
  import { MonitorEngineService } from './monitor-engine-service.js';
37
38
  import { AccountUsageService, AccountAuthService } from './account-state-daemon-service.js';
@@ -271,7 +272,7 @@ function acquireStartLock() {
271
272
  */
272
273
  const STOP_LOCK_WAIT_MS = 10_000;
273
274
  const STOP_LOCK_POLL_MS = 50;
274
- function acquireStopLock() {
275
+ function acquireLifecycleLock() {
275
276
  const deadline = Date.now() + STOP_LOCK_WAIT_MS;
276
277
  for (;;) {
277
278
  const release = acquireStartLock();
@@ -481,14 +482,13 @@ export function isDaemonRunning() {
481
482
  * liveness check and the write.
482
483
  */
483
484
  export function claimDaemonInstance() {
484
- const release = acquireStartLock();
485
- // acquireStartLock() returns null only when another __daemon-run currently
486
- // holds the O_EXCL lock — a dead holder's lock is reclaimed and retried inside
487
- // acquireStartLock, so null means a *live* claimer is mid-claim. Bail rather
488
- // than run the read-evict-write unlocked: otherwise two first-start processes
489
- // could each see no pid file (before either writes one) and both claim,
490
- // running the concurrent JobScheduler this guard exists to prevent. The live
491
- // claimer we bailed for becomes the singleton, so last-wins still holds.
485
+ // A stop owns this same lock through teardown. Waiting here is load-bearing:
486
+ // returning false while stopDaemon() holds it lets this replacement exit 0,
487
+ // then the stop completes with no singleton left alive. The bounded lifecycle
488
+ // acquisition also preserves concurrent-start serialization: after the first
489
+ // claimer publishes its pid, the waiter takes the lock and performs the normal
490
+ // last-wins takeover rather than ever running the read-evict-write unlocked.
491
+ const release = acquireLifecycleLock();
492
492
  if (!release)
493
493
  return false;
494
494
  try {
@@ -1063,6 +1063,13 @@ export async function runDaemon() {
1063
1063
  supervisor.register(new SessionIndexService());
1064
1064
  else
1065
1065
  log('INFO', 'Session-index warm service disabled');
1066
+ // Session summarizer (PHNX-3939) — registered when the service toggle is on,
1067
+ // but each tick is a no-op unless the operator also set summarizer.enabled and
1068
+ // a model endpoint, so registering it costs nothing while unconfigured.
1069
+ if (isEnabled('session-summarizer'))
1070
+ supervisor.register(new SessionSummarizerService());
1071
+ else
1072
+ log('INFO', 'Session summarizer service disabled');
1066
1073
  // Watchdog, device-probe, self-heal, and keychain-reap are all periodic
1067
1074
  // services managed by the ServiceSupervisor (RUSH-3193 P3). Each is gated
1068
1075
  // the same way as the socket services above; state-dir-check is registered
@@ -2205,7 +2212,7 @@ export function findSurvivingStateDirDaemons(exclude) {
2205
2212
  * never reports success on an unverified stop.
2206
2213
  */
2207
2214
  export function stopDaemon() {
2208
- const releaseLock = acquireStopLock();
2215
+ const releaseLock = acquireLifecycleLock();
2209
2216
  if (!releaseLock) {
2210
2217
  return {
2211
2218
  ok: false,
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Session-summarizer service (PHNX-3939).
3
+ *
4
+ * The single daemon-owned executor that computes a per-session goal / progress
5
+ * checkpoints / checklist and writes them to the transcript-keyed
6
+ * `session_summaries` cache, from which the display/merge path serves them onto
7
+ * the `sessions watch` stream. It NEVER runs on a request path.
8
+ *
9
+ * Off by default: `runSummarizerPass` no-ops unless `summarizer.enabled` and a
10
+ * model endpoint (`summarizer.baseUrl` + `summarizer.model`) are configured, so a
11
+ * daemon with the feature unconfigured makes zero model calls. Reader-gated and
12
+ * bounded per tick like the other session services, so it costs nothing while no
13
+ * one is watching.
14
+ */
15
+ import { BasePeriodicService, type DaemonContext } from './service.js';
16
+ import type { DaemonServiceId } from '../daemon-services.js';
17
+ export declare class SessionSummarizerService extends BasePeriodicService {
18
+ readonly id: DaemonServiceId;
19
+ readonly intervalMs = 20000;
20
+ readonly deadlineMs = 60000;
21
+ protected onStart(_ctx: DaemonContext): Promise<void>;
22
+ protected onStop(): Promise<void>;
23
+ protected onTick(ctx: DaemonContext, signal: AbortSignal): Promise<void>;
24
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Session-summarizer service (PHNX-3939).
3
+ *
4
+ * The single daemon-owned executor that computes a per-session goal / progress
5
+ * checkpoints / checklist and writes them to the transcript-keyed
6
+ * `session_summaries` cache, from which the display/merge path serves them onto
7
+ * the `sessions watch` stream. It NEVER runs on a request path.
8
+ *
9
+ * Off by default: `runSummarizerPass` no-ops unless `summarizer.enabled` and a
10
+ * model endpoint (`summarizer.baseUrl` + `summarizer.model`) are configured, so a
11
+ * daemon with the feature unconfigured makes zero model calls. Reader-gated and
12
+ * bounded per tick like the other session services, so it costs nothing while no
13
+ * one is watching.
14
+ */
15
+ import { BasePeriodicService } from './service.js';
16
+ import { runSummarizerPass } from '../summarizer/pass.js';
17
+ /** Slower than the state tick — a summary is a coarse signal, not live status. */
18
+ const SESSION_SUMMARIZER_TICK_MS = 20_000;
19
+ /** Hard cap per tick; a local model call is bounded, this leaves generous headroom. */
20
+ const SESSION_SUMMARIZER_DEADLINE_MS = 60_000;
21
+ export class SessionSummarizerService extends BasePeriodicService {
22
+ id = 'session-summarizer';
23
+ intervalMs = SESSION_SUMMARIZER_TICK_MS;
24
+ deadlineMs = SESSION_SUMMARIZER_DEADLINE_MS;
25
+ async onStart(_ctx) {
26
+ // No handles to open — each tick reads config + the warm session cache fresh.
27
+ }
28
+ async onStop() {
29
+ // Nothing to release.
30
+ }
31
+ async onTick(ctx, signal) {
32
+ const r = await runSummarizerPass({ signal });
33
+ if (r.disabled)
34
+ return; // off / unconfigured — stay silent
35
+ if (r.computed > 0 || r.skipped > 0) {
36
+ ctx.log('INFO', `session-summarizer: computed ${r.computed}, skipped ${r.skipped}, reused ${r.reused}`);
37
+ }
38
+ }
39
+ }
@@ -48,6 +48,9 @@ export class UsageSyncService extends BasePeriodicService {
48
48
  ctx.log('WARN', `usage-sync: ${transport.skipped}`);
49
49
  if (transport.error)
50
50
  ctx.log('WARN', `usage-sync: shared-store transport: ${transport.error}`);
51
+ if (transport.untrackedBackedUp?.length) {
52
+ ctx.log('WARN', `usage-sync: backed up ${transport.untrackedBackedUp.length} untracked shared-store collision(s) to ${transport.untrackedBackupDir}: ${transport.untrackedBackedUp.join(', ')}`);
53
+ }
51
54
  if (!transport.success)
52
55
  return;
53
56
  const consumed = consumeUsageSnapshotsFromSharedStore();
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
10
+ export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'catchup' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'self-update' | 'keychain-reap' | 'account-state' | 'account-auth' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state' | 'session-summarizer';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -107,6 +107,11 @@ export const DAEMON_SERVICES = [
107
107
  title: 'Session-index warm',
108
108
  description: 'Keeps this host\'s transcript index current so a locally-started session is discoverable within seconds.',
109
109
  },
110
+ {
111
+ id: 'session-summarizer',
112
+ title: 'Session summarizer',
113
+ description: 'Computes a per-session goal / progress checkpoints / checklist off the request path and delivers them on the session stream. Off unless summarizer.enabled and a local model endpoint are configured (PHNX-3939).',
114
+ },
110
115
  {
111
116
  id: 'auth-sync',
112
117
  title: 'Auth bundle sync',
@@ -15,7 +15,8 @@
15
15
  * timer converge on the same published result.
16
16
  */
17
17
  import type { FleetStatusRow } from './fleet-status.js';
18
- import type { AuthProbeRow } from './auth-health.js';
18
+ import { type AuthProbeRow } from './auth-health.js';
19
+ export { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
19
20
  export declare function isFreshFleetAuthSnapshot(value: {
20
21
  row: FleetStatusRow;
21
22
  authRows: AuthProbeRow[];
@@ -34,7 +35,6 @@ export declare function isFreshFleetAuthSnapshot(value: {
34
35
  * that would blind every non-primary box to revocation. Fleet status still
35
36
  * publishes every tick — it does not ride that endpoint.
36
37
  */
37
- export declare const AUTH_PROBE_MAX_AGE_MS: number;
38
38
  /**
39
39
  * True when every cached auth row was probed within {@link AUTH_PROBE_MAX_AGE_MS}
40
40
  * — i.e. reusing them would not let a verdict get staler than one probe window.
@@ -14,6 +14,8 @@
14
14
  * by the cross-process refresh lease so an explicit CLI refresh and the daemon
15
15
  * timer converge on the same published result.
16
16
  */
17
+ import { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
18
+ export { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
17
19
  export function isFreshFleetAuthSnapshot(value, minimumCapturedAt) {
18
20
  return value.row.capturedAt >= minimumCapturedAt
19
21
  && value.authRows.length > 0
@@ -33,7 +35,6 @@ export function isFreshFleetAuthSnapshot(value, minimumCapturedAt) {
33
35
  * that would blind every non-primary box to revocation. Fleet status still
34
36
  * publishes every tick — it does not ride that endpoint.
35
37
  */
36
- export const AUTH_PROBE_MAX_AGE_MS = 20 * 60_000;
37
38
  /**
38
39
  * True when every cached auth row was probed within {@link AUTH_PROBE_MAX_AGE_MS}
39
40
  * — i.e. reusing them would not let a verdict get staler than one probe window.
@@ -18,6 +18,7 @@ import * as yaml from 'yaml';
18
18
  import { exec } from 'child_process';
19
19
  import { getDaemonConfigDir, getRuntimeStateDir } from './state.js';
20
20
  import { atomicWriteFileSync } from './fs-atomic.js';
21
+ import { closeServerBounded } from './secrets/agent.js';
21
22
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
22
23
  import { startWebhookServer, createFileDeliveryStore, waitForListening } from './triggers/webhook.js';
23
24
  import { buildFunnelUpCommand, FUNNEL_PORTS } from './funnel.js';
@@ -195,8 +196,13 @@ export async function startHostedWebhookReceivers(opts) {
195
196
  log('WARN', `webhook ${webhook.source}:${webhook.event} dispatch failed after ack: ${err.message}`);
196
197
  },
197
198
  });
199
+ const sockets = new Set();
200
+ server.on('connection', (socket) => {
201
+ sockets.add(socket);
202
+ socket.once('close', () => sockets.delete(socket));
203
+ });
198
204
  await waitForListening(server);
199
- servers.push(server);
205
+ servers.push({ server, sockets });
200
206
  log('INFO', `webhook receiver bound on 127.0.0.1:${port} (bundle ${receiver.bundle})`);
201
207
  if (receiver.funnel)
202
208
  reconcileFunnel(receiver.funnel.publicPort, port, log);
@@ -210,6 +216,13 @@ export async function startHostedWebhookReceivers(opts) {
210
216
  }
211
217
  return {
212
218
  count: servers.length,
213
- close: () => Promise.all(servers.map((s) => new Promise((resolve) => s.close(() => resolve())))).then(() => undefined),
219
+ close: () => Promise.all(servers.map(async ({ server, sockets }) => {
220
+ // Stop accepting first, then force every persistent HTTP connection
221
+ // closed so keep-alive cannot hold daemon shutdown open indefinitely.
222
+ const closing = closeServerBounded(server);
223
+ for (const socket of sockets)
224
+ socket.destroy();
225
+ await closing;
226
+ })).then(() => undefined),
214
227
  };
215
228
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Register the `agents://` URL scheme with the OS so a click in an artifact
3
- * routes to `agents open <url>` (see url.ts + commands/open.ts).
3
+ * routes to the machine-only `agents _callback <url>` verb (see url.ts +
4
+ * commands/open.ts). Humans manage the handler with `agents setup url-scheme`.
4
5
  *
5
6
  * A browser page cannot spawn a shell; a registered URL scheme is the
6
7
  * OS-sanctioned hand-off. Each platform gets its own handler:
@@ -13,8 +14,8 @@
13
14
  *
14
15
  * The content generators below are pure and unit-tested. The `register*` /
15
16
  * `unregister*` / `status*` functions apply them and never throw — they return a
16
- * {@link SchemeStatus} so callers (setup, `agents open register`, doctor) can
17
- * report without a try/catch.
17
+ * {@link SchemeStatus} so callers (setup, `agents setup url-scheme register`,
18
+ * doctor) can report without a try/catch.
18
19
  */
19
20
  import { execFileSync } from 'node:child_process';
20
21
  import * as fs from 'node:fs';
@@ -73,7 +74,7 @@ export function linuxDesktopEntry(invocation) {
73
74
  'Type=Application',
74
75
  'Name=Agents URL Handler',
75
76
  'Comment=Resume an agents session from an agents:// deep link',
76
- `Exec=${invocation} open %u`,
77
+ `Exec=${invocation} _callback %u`,
77
78
  'Terminal=false',
78
79
  'NoDisplay=true',
79
80
  'MimeType=x-scheme-handler/agents;',
@@ -91,7 +92,7 @@ export function macAppleScriptSource(invocation) {
91
92
  const literal = invocation.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
92
93
  return [
93
94
  'on open location this_URL',
94
- `\tdo shell script "${literal} open " & quoted form of this_URL`,
95
+ `\tdo shell script "${literal} _callback " & quoted form of this_URL`,
95
96
  'end open location',
96
97
  ].join('\n');
97
98
  }
@@ -113,7 +114,7 @@ export function windowsRegistryCommands(invocation) {
113
114
  return [
114
115
  ['add', base, '/ve', '/d', 'URL:agents Protocol', '/f'],
115
116
  ['add', base, '/v', 'URL Protocol', '/d', '', '/f'],
116
- ['add', `${base}\\shell\\open\\command`, '/ve', '/d', `${invocation} open "%1"`, '/f'],
117
+ ['add', `${base}\\shell\\open\\command`, '/ve', '/d', `${invocation} _callback "%1"`, '/f'],
117
118
  ];
118
119
  }
119
120
  // ---------------------------------------------------------------------------
@@ -134,19 +135,19 @@ export function agentsUrlSchemeStatus(platform = os.platform(), home = os.homedi
134
135
  const p = linuxDesktopPath(home);
135
136
  return fs.existsSync(p)
136
137
  ? { registered: true, platform, detail: `handler: ${p}` }
137
- : { registered: false, platform, detail: `no handler (${p}) — run: agents open register` };
138
+ : { registered: false, platform, detail: `no handler (${p}) — run: agents setup url-scheme register` };
138
139
  }
139
140
  if (platform === 'darwin') {
140
141
  const p = macAppPath(home);
141
142
  return fs.existsSync(p)
142
143
  ? { registered: true, platform, detail: `handler: ${p}` }
143
- : { registered: false, platform, detail: `no handler (${p}) — run: agents open register` };
144
+ : { registered: false, platform, detail: `no handler (${p}) — run: agents setup url-scheme register` };
144
145
  }
145
146
  if (platform === 'win32') {
146
147
  const ok = windowsSchemeRegistered();
147
148
  return ok
148
149
  ? { registered: true, platform, detail: 'handler: HKCU\\Software\\Classes\\agents' }
149
- : { registered: false, platform, detail: 'no handler — run: agents open register' };
150
+ : { registered: false, platform, detail: 'no handler — run: agents setup url-scheme register' };
150
151
  }
151
152
  return { registered: false, platform, detail: `unsupported platform: ${platform}` };
152
153
  }
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * A rendered artifact (a plan or report) embeds `agents://session/<id>` in its
5
5
  * provenance line. Clicking it hands the URL to the OS, which routes it to the
6
- * registered handler (see register.ts) that runs `agents open <url>`. This module
7
- * turns that URL into a validated {@link AgentsSessionLink} the resume dispatcher
8
- * consumes.
6
+ * registered handler (see register.ts) that runs the machine-only `agents _callback
7
+ * <url>` verb (`open` remains a hidden back-compat alias). This module turns that URL
8
+ * into a validated {@link AgentsSessionLink} the resume dispatcher consumes.
9
9
  *
10
10
  * Parsing is deliberately strict: the session id is the only thing that ever
11
- * reaches a child process, and the `open` command passes it as argv (never
11
+ * reaches a child process, and the `_callback` command passes it as argv (never
12
12
  * interpolated into a shell), so a hostile URL cannot inject a command. Anything
13
13
  * that is not `agents://session/<valid-id>` is rejected with a reason.
14
14
  *
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * A rendered artifact (a plan or report) embeds `agents://session/<id>` in its
5
5
  * provenance line. Clicking it hands the URL to the OS, which routes it to the
6
- * registered handler (see register.ts) that runs `agents open <url>`. This module
7
- * turns that URL into a validated {@link AgentsSessionLink} the resume dispatcher
8
- * consumes.
6
+ * registered handler (see register.ts) that runs the machine-only `agents _callback
7
+ * <url>` verb (`open` remains a hidden back-compat alias). This module turns that URL
8
+ * into a validated {@link AgentsSessionLink} the resume dispatcher consumes.
9
9
  *
10
10
  * Parsing is deliberately strict: the session id is the only thing that ever
11
- * reaches a child process, and the `open` command passes it as argv (never
11
+ * reaches a child process, and the `_callback` command passes it as argv (never
12
12
  * interpolated into a shell), so a hostile URL cannot inject a command. Anything
13
13
  * that is not `agents://session/<valid-id>` is rejected with a reason.
14
14
  *
@@ -75,6 +75,31 @@ export const CONFIG_KEYS = [
75
75
  ? null
76
76
  : `auto.pool must be one of ${AUTO_POOL_MODES.join(' | ')}.`,
77
77
  },
78
+ {
79
+ name: 'summarizer.enabled',
80
+ yamlKey: 'summarizerEnabled',
81
+ scope: 'user',
82
+ type: 'bool',
83
+ defaultValue: false,
84
+ description: 'Whether the daemon computes a per-session goal / progress checkpoints / checklist and delivers them on the ' +
85
+ 'session stream (PHNX-3939). Off by default — zero model calls, no behavior change until enabled. Needs a local ' +
86
+ 'Anthropic-wire model endpoint via summarizer.baseUrl + summarizer.model.',
87
+ },
88
+ {
89
+ name: 'summarizer.baseUrl',
90
+ yamlKey: 'summarizerBaseUrl',
91
+ scope: 'user',
92
+ type: 'string',
93
+ description: 'Base URL of the Anthropic-wire model endpoint the summarizer calls (Ollama / vLLM / LiteLLM), ' +
94
+ 'e.g. http://localhost:11434. Overridden per-process by AGENTS_SUMMARIZER_BASEURL.',
95
+ },
96
+ {
97
+ name: 'summarizer.model',
98
+ yamlKey: 'summarizerModel',
99
+ scope: 'user',
100
+ type: 'string',
101
+ description: 'Model the summarizer requests from summarizer.baseUrl, e.g. qwen2.5:3b. Overridden per-process by AGENTS_SUMMARIZER_MODEL.',
102
+ },
78
103
  {
79
104
  name: 'browser.viewer',
80
105
  yamlKey: 'browserViewer',
@@ -113,7 +113,7 @@ export interface LocalFindingInputs {
113
113
  /** Read-only Windows OpenSSH AuthorizedKeysFile/content/ACL audit. */
114
114
  windowsSshEnrollment?: WindowsSshEnrollmentAudit | null;
115
115
  /** `<agent>@<version>` keys whose home is an isolated copy. Their findings are
116
- * never collapsed across versions: the agent-wide `agents doctor <agent> --fix`
116
+ * never collapsed across versions: the agent-wide `agents sync <agent>@all`
117
117
  * sweep deliberately skips isolated copies, so a collapsed row would print a
118
118
  * remediation that does not fix them. */
119
119
  isolatedVersions?: string[];
@@ -135,14 +135,14 @@ export declare function buildLocalFindings(input: LocalFindingInputs): DoctorFin
135
135
  /**
136
136
  * Fold findings that say the SAME thing about several versions of one agent into
137
137
  * a single row carrying `versions`, and widen its remediation to the agent-wide
138
- * sweep (`agents doctor claude --fix` heals every non-isolated version in one
138
+ * sweep (`agents sync claude@all --yes` heals every non-isolated version in one
139
139
  * go). Five identical `plugin 'code' — mirror missing` rows, one per installed
140
140
  * claude, is the same fact five times.
141
141
  *
142
142
  * Three things never merge, because for each of them the widened remediation
143
143
  * would be wrong:
144
- * - **Isolated copies** — the agent-wide sweep deliberately skips them
145
- * (`runFix`), so a folded row would print a command that leaves one broken.
144
+ * - **Isolated copies** — the agent-wide sweep deliberately skips them, so a
145
+ * folded row would print a command that leaves one broken.
146
146
  * - **Findings with no agent** (repo-behind, rc-secret-export, …) — their
147
147
  * `version` field is an alias, not a version.
148
148
  * - **Logouts** ({@link NEVER_COLLAPSED}) — a login is inherently per-version:
@@ -235,7 +235,13 @@ export function remediationFor(finding) {
235
235
  case 'missing-resource':
236
236
  case 'content-drift':
237
237
  case 'stale':
238
- return idLabel ? `agents doctor ${idLabel} --fix` : 'agents doctor --fix';
238
+ // doctor diagnoses; `agents sync` fixes (the superset of the old
239
+ // `doctor --fix`). A bare `agents sync <agent>` hits only the default
240
+ // version, so an agent-only row collapsed across versions must ask for
241
+ // @all to reach every one it covers.
242
+ if (agent && version)
243
+ return `agents sync ${agent}@${version} --yes`;
244
+ return agent ? `agents sync ${agent}@all --yes` : 'agents sync';
239
245
  case 'hook-runtime-visibility-unavailable':
240
246
  return 'upgrade agents-cli on this device';
241
247
  case 'never-synced':
@@ -261,8 +267,8 @@ export function remediationFor(finding) {
261
267
  return `agents repo pull ${version ?? 'user'}`;
262
268
  case 'fleet-resource-gap':
263
269
  // The resource is absent from this box's CENTRAL repos, not from a version
264
- // home, so `agents doctor --fix` (which reconciles central -> homes) has
265
- // nothing to copy. The divergence row cannot say WHICH repo declares it,
270
+ // home, so `agents sync` (which reconciles central -> homes) has nothing to
271
+ // copy. The divergence row cannot say WHICH repo declares it,
266
272
  // and neither `agents repo pull` nor the sync umbrella touches the system
267
273
  // repo (`commands/repo.ts:1186`, `lib/sync-umbrella.ts:104`) — that one is
268
274
  // npm-shipped and moves with the CLI. So name both paths rather than a
@@ -317,8 +323,8 @@ function finding(f) {
317
323
  * or more collapse into a count plus the first two subjects
318
324
  * (`32 hooks missing (incl. 'git-guard', 'rm-guard')`). Naming every item — the
319
325
  * pre-RUSH-2069-review behavior — flooded the section with dozens of near-identical
320
- * rows for one root cause; the count carries the same signal and `--fix` is the
321
- * same command either way.
326
+ * rows for one root cause; the count carries the same signal and `agents sync`
327
+ * is the same command either way.
322
328
  */
323
329
  function emitGroup(out, items, severity, kind, device, agent, version, noun, verb) {
324
330
  if (items.length === 0)
@@ -735,14 +741,14 @@ function execPolicyFinding(device, execPolicy) {
735
741
  /**
736
742
  * Fold findings that say the SAME thing about several versions of one agent into
737
743
  * a single row carrying `versions`, and widen its remediation to the agent-wide
738
- * sweep (`agents doctor claude --fix` heals every non-isolated version in one
744
+ * sweep (`agents sync claude@all --yes` heals every non-isolated version in one
739
745
  * go). Five identical `plugin 'code' — mirror missing` rows, one per installed
740
746
  * claude, is the same fact five times.
741
747
  *
742
748
  * Three things never merge, because for each of them the widened remediation
743
749
  * would be wrong:
744
- * - **Isolated copies** — the agent-wide sweep deliberately skips them
745
- * (`runFix`), so a folded row would print a command that leaves one broken.
750
+ * - **Isolated copies** — the agent-wide sweep deliberately skips them, so a
751
+ * folded row would print a command that leaves one broken.
746
752
  * - **Findings with no agent** (repo-behind, rc-secret-export, …) — their
747
753
  * `version` field is an alias, not a version.
748
754
  * - **Logouts** ({@link NEVER_COLLAPSED}) — a login is inherently per-version:
@@ -22,6 +22,7 @@ import { atomicWriteJsonSync } from '../fs-atomic.js';
22
22
  import { machineId } from '../machine-id.js';
23
23
  import { addIgnoredEntry, unionDeviceIgnored } from './device-docs.js';
24
24
  import { logAndContinueOnLockCompromised } from '../lock-compromise.js';
25
+ import { removeStatsCacheEntry } from './stats-cache.js';
25
26
  /**
26
27
  * Whether a fan-out should dial this device, honouring the preference stated on
27
28
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -281,6 +282,7 @@ export async function removeDevice(name) {
281
282
  return false;
282
283
  delete reg[name];
283
284
  await saveDevices(reg);
285
+ removeStatsCacheEntry(name);
284
286
  return true;
285
287
  });
286
288
  }
@@ -38,6 +38,10 @@ export declare function readStatsCache(): Record<string, DeviceStats>;
38
38
  * single-device refresh) never drops the rest of the fleet's cached stats.
39
39
  */
40
40
  export declare function writeStatsCache(entries: Record<string, DeviceStats>): void;
41
+ /** Drop one removed device so cache-only rows cannot outlive the registry. */
42
+ export declare function removeStatsCacheEntry(name: string): void;
43
+ /** Immutable cache pruning primitive, exported for lifecycle regression tests. */
44
+ export declare function pruneStatsCache(entries: Record<string, DeviceStats>, name: string): Record<string, DeviceStats>;
41
45
  export interface FleetStatsResult {
42
46
  /** name → stats for every requested device (cache-served + freshly probed). */
43
47
  stats: Map<string, DeviceStats>;
@@ -114,6 +114,25 @@ export function writeStatsCache(entries) {
114
114
  // best-effort; a failed write just means the next read falls back to a live probe
115
115
  }
116
116
  }
117
+ /** Drop one removed device so cache-only rows cannot outlive the registry. */
118
+ export function removeStatsCacheEntry(name) {
119
+ try {
120
+ const current = readStatsCache();
121
+ if (!(name in current))
122
+ return;
123
+ const entries = pruneStatsCache(current, name);
124
+ fs.writeFileSync(cacheFilePath(), JSON.stringify({ version: 1, entries }, null, 2));
125
+ }
126
+ catch {
127
+ // Cache cleanup is best-effort; registry removal remains authoritative.
128
+ }
129
+ }
130
+ /** Immutable cache pruning primitive, exported for lifecycle regression tests. */
131
+ export function pruneStatsCache(entries, name) {
132
+ const { [name]: _removed, ...remaining } = entries;
133
+ void _removed;
134
+ return remaining;
135
+ }
117
136
  /**
118
137
  * Load fleet stats cache-first. See the module doc for the default vs
119
138
  * `--refresh` behaviour. Never throws — an unreachable box degrades to a
@@ -7,7 +7,9 @@
7
7
  * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
8
  * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
9
  * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
- * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
10
+ * - repairAfterSync() — the shared post-reconcile repair pass
11
+ * `agents sync` runs (heal + hook rewire +
12
+ * managed hook runtime shim repair)
11
13
  *
12
14
  * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
15
  * a single "Sync all detected" both pulls `.system` and reconciles the chosen
@@ -7,7 +7,9 @@
7
7
  * - computeSyncStatus() — the unified detection engine (sync-status.ts)
8
8
  * - pullRepo() — fast-forward the `.system` repo (git.ts)
9
9
  * - promptAgentVersionSelection() — the "which agent types / versions?" picker
10
- * - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
10
+ * - repairAfterSync() — the shared post-reconcile repair pass
11
+ * `agents sync` runs (heal + hook rewire +
12
+ * managed hook runtime shim repair)
11
13
  *
12
14
  * Combined flow (one confirmation): if `.system` is behind AND resources drifted,
13
15
  * a single "Sync all detected" both pulls `.system` and reconciles the chosen
@@ -19,7 +21,7 @@ import chalk from 'chalk';
19
21
  import { select, confirm } from '@inquirer/prompts';
20
22
  import { AGENTS } from './agents.js';
21
23
  import { pullRepo } from './git.js';
22
- import { heal } from './heal.js';
24
+ import { repairAfterSync, renderRepairAfterSync } from './reconcile-and-repair.js';
23
25
  import { promptAgentVersionSelection } from './installations/versions.js';
24
26
  import { isInteractiveTerminal, isPromptCancelled } from './format.js';
25
27
  import { computeSyncStatus, } from './sync-status.js';
@@ -58,14 +60,23 @@ async function pullSystem(status) {
58
60
  console.log(chalk.red(`Could not pull .system: ${res.error ?? 'unknown error'}`));
59
61
  return false;
60
62
  }
61
- /** Reconcile a set of versions grouped by agent via the shared heal engine. */
63
+ /**
64
+ * Reconcile a set of versions grouped by agent through the SHARED post-reconcile
65
+ * repair pass (`repairAfterSync`) — the same superset the three `agents sync`
66
+ * handlers run. Beyond the resources `heal()` fills, this also re-wires hooks
67
+ * left unwired and repairs broken managed hook runtime shims, so drift-sync is
68
+ * not a third orchestrator that silently skips shim repair. Renders each pass's
69
+ * rewire / shim-repair detail; the heal rollup is printed separately by the
70
+ * caller via `reportHealed`.
71
+ */
62
72
  async function healVersions(versionsByAgent, cwd) {
63
73
  const out = [];
64
74
  for (const [agent, versions] of versionsByAgent) {
65
75
  if (versions.length === 0)
66
76
  continue;
67
- const res = await heal({ mode: 'full', cwd, agent, versions });
68
- out.push(...res.versions);
77
+ const repair = await repairAfterSync({ agent, versions, cwd });
78
+ out.push(...repair.heal.versions);
79
+ renderRepairAfterSync(repair, (line) => console.log(line));
69
80
  }
70
81
  return out;
71
82
  }
@@ -311,6 +311,8 @@ export declare function resolveLaunchId(envLaunchId: string | undefined): string
311
311
  * into unrelated invocations.
312
312
  */
313
313
  export declare function buildExecEnv(options: ExecOptions): NodeJS.ProcessEnv;
314
+ /** Materialize config roots for vendor CLIs that do not create parents recursively. */
315
+ export declare function ensureVendorHomeDir(agent: AgentId, versionHome: string): string | null;
314
316
  /**
315
317
  * Describes how to translate ExecOptions into CLI arguments for a specific agent.
316
318
  *
package/dist/lib/exec.js CHANGED
@@ -9,7 +9,7 @@ import { randomUUID } from 'crypto';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { ALL_MODES, REMOTE_INTERACTIVE_ENV } from './types.js';
12
- import { AGENTS, findInPath } from './agents.js';
12
+ import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
13
13
  import { parseTimeout } from './scheduling/routines.js';
14
14
  import { compareVersions, getBinaryPath, getVersionHomePath, isVersionInstalled, listInstalledVersions, resolveVersion } from './installations/versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
@@ -447,6 +447,19 @@ export function buildExecEnv(options) {
447
447
  ...options.env,
448
448
  };
449
449
  }
450
+ /** Materialize config roots for vendor CLIs that do not create parents recursively. */
451
+ export function ensureVendorHomeDir(agent, versionHome) {
452
+ if (agent !== 'cursor' && agent !== 'grok' && agent !== 'copilot')
453
+ return null;
454
+ const vendorHome = path.join(versionHome, agentConfigDirName(agent));
455
+ fs.mkdirSync(vendorHome, { recursive: true });
456
+ return vendorHome;
457
+ }
458
+ function ensureVendorHomeForSpawn(options) {
459
+ const { versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.configVersion ?? options.version);
460
+ if (versionHome)
461
+ ensureVendorHomeDir(options.agent, versionHome);
462
+ }
450
463
  /**
451
464
  * CLI command templates for every supported agent.
452
465
  *
@@ -1131,6 +1144,7 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1131
1144
  // practice (POSIX uses the bash shim, which execs the binary and never reaches
1132
1145
  // buildExecEnv — `installations/shims.ts` generateShimScript).
1133
1146
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
1147
+ ensureVendorHomeDir(agent, getVersionHomePath(agent, version));
1134
1148
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
1135
1149
  // Pre-launch marker for the SECOND live launch path: the Windows generated
1136
1150
  // `.cmd` shim delegates here and spawns the harness directly, so without this
@@ -1715,6 +1729,7 @@ async function spawnAgent(options) {
1715
1729
  if (options.agent === 'claude' && !options.resume && !options.sessionId) {
1716
1730
  options = { ...options, sessionId: randomUUID() };
1717
1731
  }
1732
+ ensureVendorHomeForSpawn(options);
1718
1733
  // Record the run's --name against its session id (when both are known at
1719
1734
  // launch) so `agents sessions <name>` resolves it. Best-effort; unnamed runs
1720
1735
  // and agents whose id isn't known up front simply skip this.