@phnx-labs/agents-cli 1.22.75 → 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 (132) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +20 -8
  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/config.js +27 -4
  11. package/dist/commands/cost.js +6 -4
  12. package/dist/commands/doctor.d.ts +6 -5
  13. package/dist/commands/doctor.js +32 -274
  14. package/dist/commands/exec.d.ts +2 -0
  15. package/dist/commands/exec.js +9 -2
  16. package/dist/commands/harness.d.ts +1 -0
  17. package/dist/commands/harness.js +11 -3
  18. package/dist/commands/hooks.js +7 -6
  19. package/dist/commands/mcp.js +7 -6
  20. package/dist/commands/memory.js +7 -7
  21. package/dist/commands/monitors.js +3 -2
  22. package/dist/commands/open.d.ts +25 -12
  23. package/dist/commands/open.js +24 -10
  24. package/dist/commands/permissions.js +7 -6
  25. package/dist/commands/plugins.js +21 -17
  26. package/dist/commands/route.js +33 -16
  27. package/dist/commands/rules.js +7 -12
  28. package/dist/commands/sessions-share.js +1 -1
  29. package/dist/commands/setup-watchdog.js +2 -2
  30. package/dist/commands/setup.js +22 -1
  31. package/dist/commands/share.js +26 -10
  32. package/dist/commands/skills.js +7 -6
  33. package/dist/commands/subagents.js +7 -6
  34. package/dist/commands/sync.js +81 -10
  35. package/dist/commands/view.js +4 -1
  36. package/dist/commands/watchdog.d.ts +1 -1
  37. package/dist/commands/watchdog.js +10 -10
  38. package/dist/commands/webhook.d.ts +4 -0
  39. package/dist/commands/webhook.js +22 -4
  40. package/dist/commands/workflows.js +7 -6
  41. package/dist/lib/accounting/rotate.d.ts +3 -1
  42. package/dist/lib/accounting/rotate.js +8 -4
  43. package/dist/lib/auth-health.d.ts +2 -0
  44. package/dist/lib/auth-health.js +2 -0
  45. package/dist/lib/browser/chrome.d.ts +21 -0
  46. package/dist/lib/browser/chrome.js +60 -3
  47. package/dist/lib/browser/drivers/local.d.ts +21 -0
  48. package/dist/lib/browser/drivers/local.js +102 -9
  49. package/dist/lib/browser/profiles.d.ts +29 -1
  50. package/dist/lib/browser/profiles.js +50 -1
  51. package/dist/lib/browser/types.d.ts +18 -0
  52. package/dist/lib/config-keys.d.ts +7 -2
  53. package/dist/lib/config-keys.js +17 -2
  54. package/dist/lib/daemon/daemon.js +8 -0
  55. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  56. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  57. package/dist/lib/daemon-services.d.ts +1 -1
  58. package/dist/lib/daemon-services.js +5 -0
  59. package/dist/lib/daemon-ticks.d.ts +2 -2
  60. package/dist/lib/daemon-ticks.js +2 -1
  61. package/dist/lib/deeplink/register.js +10 -9
  62. package/dist/lib/deeplink/url.d.ts +4 -4
  63. package/dist/lib/deeplink/url.js +4 -4
  64. package/dist/lib/device-config.js +25 -0
  65. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  66. package/dist/lib/devices/doctor-findings.js +14 -8
  67. package/dist/lib/devices/registry.js +2 -0
  68. package/dist/lib/devices/stats-cache.d.ts +4 -0
  69. package/dist/lib/devices/stats-cache.js +19 -0
  70. package/dist/lib/drift-sync.d.ts +3 -1
  71. package/dist/lib/drift-sync.js +16 -5
  72. package/dist/lib/exec.d.ts +2 -0
  73. package/dist/lib/exec.js +16 -1
  74. package/dist/lib/fleet-shared-state.d.ts +8 -0
  75. package/dist/lib/heal.d.ts +4 -3
  76. package/dist/lib/heal.js +5 -4
  77. package/dist/lib/hosts/ready.d.ts +1 -1
  78. package/dist/lib/hosts/ready.js +16 -4
  79. package/dist/lib/hosts/reconnect.js +4 -2
  80. package/dist/lib/identity/client.d.ts +6 -0
  81. package/dist/lib/identity/index.d.ts +16 -0
  82. package/dist/lib/identity/index.js +25 -1
  83. package/dist/lib/profiles.d.ts +2 -0
  84. package/dist/lib/profiles.js +28 -9
  85. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  86. package/dist/lib/reconcile-and-repair.js +267 -0
  87. package/dist/lib/routers.d.ts +12 -1
  88. package/dist/lib/routers.js +30 -1
  89. package/dist/lib/scheduling/routines.js +8 -2
  90. package/dist/lib/session/active.d.ts +13 -0
  91. package/dist/lib/session/db.d.ts +47 -7
  92. package/dist/lib/session/db.js +114 -12
  93. package/dist/lib/session/mirror.js +58 -0
  94. package/dist/lib/session/remote/watch.js +22 -2
  95. package/dist/lib/session/session-cache.d.ts +19 -0
  96. package/dist/lib/session/session-cache.js +46 -0
  97. package/dist/lib/session/types.d.ts +34 -0
  98. package/dist/lib/share/backend.d.ts +6 -4
  99. package/dist/lib/share/backend.js +10 -8
  100. package/dist/lib/share/config.d.ts +4 -3
  101. package/dist/lib/share/config.js +10 -1
  102. package/dist/lib/share/delete.d.ts +1 -1
  103. package/dist/lib/share/delete.js +1 -1
  104. package/dist/lib/share/html.d.ts +1 -1
  105. package/dist/lib/share/html.js +1 -1
  106. package/dist/lib/share/provision.d.ts +1 -1
  107. package/dist/lib/share/provision.js +2 -2
  108. package/dist/lib/share/publish.d.ts +23 -7
  109. package/dist/lib/share/publish.js +58 -12
  110. package/dist/lib/share/worker-template.js +221 -60
  111. package/dist/lib/startup/command-registry.js +2 -2
  112. package/dist/lib/state.d.ts +15 -0
  113. package/dist/lib/state.js +29 -7
  114. package/dist/lib/summarizer/config.d.ts +46 -0
  115. package/dist/lib/summarizer/config.js +83 -0
  116. package/dist/lib/summarizer/pass.d.ts +45 -0
  117. package/dist/lib/summarizer/pass.js +112 -0
  118. package/dist/lib/summarizer/summarize.d.ts +68 -0
  119. package/dist/lib/summarizer/summarize.js +120 -0
  120. package/dist/lib/teams/agents.d.ts +4 -3
  121. package/dist/lib/teams/agents.js +12 -4
  122. package/dist/lib/teams/scheduler.d.ts +4 -2
  123. package/dist/lib/teams/scheduler.js +6 -6
  124. package/dist/lib/tmux/session.d.ts +2 -0
  125. package/dist/lib/tmux/session.js +7 -1
  126. package/dist/lib/types.d.ts +20 -0
  127. package/dist/lib/verbs.d.ts +23 -0
  128. package/dist/lib/verbs.js +24 -0
  129. package/dist/lib/view-types.d.ts +4 -0
  130. package/dist/lib/watchdog/rotate.d.ts +1 -1
  131. package/dist/lib/watchdog/rotate.js +1 -1
  132. package/package.json +1 -1
@@ -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
+ }
@@ -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.
@@ -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.
@@ -26,6 +26,14 @@ export interface SessionMirrorRow {
26
26
  timestamp: string;
27
27
  ticketId?: string;
28
28
  prUrl?: string;
29
+ /** Daemon-computed goal (PHNX-3939) — carried so a peer renders it with no transcript. */
30
+ goal?: string;
31
+ /** Daemon-computed progress checkpoints, newest last (PHNX-3939). */
32
+ checkpoints?: import('./session/types.js').SessionCheckpoint[];
33
+ /** Daemon-computed detailed checklist (PHNX-3939). */
34
+ summaryChecklist?: import('./session/types.js').SessionChecklistItem[];
35
+ /** Lifecycle of the daemon-computed summary (PHNX-3939). */
36
+ summaryState?: import('./session/types.js').SummaryState;
29
37
  capturedAt: number;
30
38
  }
31
39
  export interface FleetSharedDeviceState {
@@ -3,9 +3,10 @@
3
3
  * what is actually present/valid in each installed agent home.
4
4
  *
5
5
  * Powers two callers:
6
- * - `agents doctor --fix` — explicit, operator-driven. Mode 'full': fills
7
- * missing, overwrites drifted content, and refreshes stale plugins even when
8
- * the baseline is unknown (the operator asked for it).
6
+ * - `agents sync` — the one fixer, via the post-reconcile repair pass
7
+ * (`lib/reconcile-and-repair.ts`, run at the tail of every sync). Mode
8
+ * 'full': fills missing, overwrites drifted content, and refreshes stale
9
+ * plugins even when the baseline is unknown (the user asked to sync).
9
10
  * - the routines daemon's periodic safety check — Mode 'safe': fixes only the
10
11
  * unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
11
12
  * provably-unmodified stale plugins). Drift and risky refreshes are reported,
package/dist/lib/heal.js CHANGED
@@ -3,9 +3,10 @@
3
3
  * what is actually present/valid in each installed agent home.
4
4
  *
5
5
  * Powers two callers:
6
- * - `agents doctor --fix` — explicit, operator-driven. Mode 'full': fills
7
- * missing, overwrites drifted content, and refreshes stale plugins even when
8
- * the baseline is unknown (the operator asked for it).
6
+ * - `agents sync` — the one fixer, via the post-reconcile repair pass
7
+ * (`lib/reconcile-and-repair.ts`, run at the tail of every sync). Mode
8
+ * 'full': fills missing, overwrites drifted content, and refreshes stale
9
+ * plugins even when the baseline is unknown (the user asked to sync).
9
10
  * - the routines daemon's periodic safety check — Mode 'safe': fixes only the
10
11
  * unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
11
12
  * provably-unmodified stale plugins). Drift and risky refreshes are reported,
@@ -86,7 +87,7 @@ export function notifyHeal(r) {
86
87
  ? `${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
87
88
  : 'agents: auto-healed config gaps';
88
89
  const body = needsAttention > 0
89
- ? `${summarizeHeal(r)}. Run: agents doctor --fix`
90
+ ? `${summarizeHeal(r)}. Run: agents sync`
90
91
  : summarizeHeal(r);
91
92
  notifyDesktop({ title, body, action: 'routines:list' });
92
93
  }
@@ -109,7 +109,7 @@ export interface ViewAgentAccountEligibility {
109
109
  * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
110
110
  * pre-fix behavior, so a rolling fleet does not regress.
111
111
  */
112
- export declare function viewAgentAccountEligibility(view: string, agent: string): ViewAgentAccountEligibility;
112
+ export declare function viewAgentAccountEligibility(view: string, agent: string, now?: number): ViewAgentAccountEligibility;
113
113
  export declare function viewAgentSignedIn(view: string, agent: string): boolean | undefined;
114
114
  export interface EnsureReadyOptions {
115
115
  agent: string;
@@ -12,7 +12,8 @@ import { sshExec, shellQuote } from '../ssh-exec.js';
12
12
  import { hostIdentityArgs, sshTargetFor } from './types.js';
13
13
  import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
14
14
  import { resolveRemoteOsSync } from './remote-os.js';
15
- import { isDeadVerdict } from '../auth-health.js';
15
+ import { AUTH_PROBE_MAX_AGE_MS, isDeadVerdict } from '../auth-health.js';
16
+ import { USAGE_STALE_REFUSAL_MAX_AGE_MS } from '../accounting/rotate.js';
16
17
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
17
18
  export function localCliVersion() {
18
19
  let dir = path.dirname(fileURLToPath(import.meta.url));
@@ -222,7 +223,7 @@ export function missingPinnedVersionMessage(hostName, agent, version, installed)
222
223
  * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
223
224
  * pre-fix behavior, so a rolling fleet does not regress.
224
225
  */
225
- export function viewAgentAccountEligibility(view, agent) {
226
+ export function viewAgentAccountEligibility(view, agent, now = Date.now()) {
226
227
  try {
227
228
  const rows = JSON.parse(view);
228
229
  const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
@@ -234,11 +235,22 @@ export function viewAgentAccountEligibility(view, agent) {
234
235
  // Prefer the strict per-version launch signal; fall back to the display
235
236
  // `signedIn` for an older remote CLI that does not emit `launchable`.
236
237
  const launchable = typeof version.launchable === 'boolean' ? version.launchable : version.signedIn;
237
- const throttled = version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits';
238
+ const usageCapturedAt = version.usageCapturedAt ? Date.parse(version.usageCapturedAt) : Number.NaN;
239
+ const usageFresh = Number.isFinite(usageCapturedAt)
240
+ ? now - usageCapturedAt <= USAGE_STALE_REFUSAL_MAX_AGE_MS
241
+ : version.usageCapturedAt === undefined;
242
+ const authFresh = typeof version.authCheckedAt === 'number'
243
+ ? now - version.authCheckedAt <= AUTH_PROBE_MAX_AGE_MS
244
+ : version.authCheckedAt === undefined;
245
+ const throttled = usageFresh
246
+ && (version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits');
238
247
  const authBlocked = version.authVerdict !== null
239
248
  && version.authVerdict !== undefined
249
+ && authFresh
240
250
  && isDeadVerdict(version.authVerdict);
241
- const ready = launchable && !authBlocked && !throttled;
251
+ const freshnessKnown = version.usageCapturedAt !== undefined || version.authCheckedAt !== undefined;
252
+ const fresh = !freshnessKnown || (usageFresh && authFresh);
253
+ const ready = launchable && fresh && !authBlocked && !throttled;
242
254
  return [{ ready, pickerEligible: ready || !launchable || authBlocked }];
243
255
  });
244
256
  if (verdicts.length === 0)
@@ -81,7 +81,9 @@ export function formatDuration(ms) {
81
81
  export function reconnectNotice(target, host, attempt, waitMs, remainingMs) {
82
82
  const secs = Math.round(waitMs / 1000);
83
83
  const when = secs <= 1 ? 'now' : `in ${secs}s`;
84
- return `\nConnection to ${host} dropped — ${targetLabel(target)} is still running there.`
84
+ // A tmux-wrapped run is still live; a bare run was SIGHUPed and focus will
85
+ // resume it from disk. This wording is truthful for either peer configuration.
86
+ return `\nConnection to ${host} dropped — reconnecting to ${targetLabel(target)}.`
85
87
  + `\n Reconnecting ${when} · ${formatDuration(remainingMs)} left · attempt ${attempt} · Ctrl-C to stop\n`;
86
88
  }
87
89
  /** Notice shown once the retry budget is spent on an unreachable host. */
@@ -99,7 +101,7 @@ export function remoteExitNotice(target, host) {
99
101
  }
100
102
  /** Notice shown when the user stops the wait with Ctrl-C. */
101
103
  export function interruptedNotice(target, host) {
102
- return `\nStopped reconnecting. ${targetLabel(target)} is still running on ${host}:\n${recoveryHint(target, host)}`;
104
+ return `\nStopped reconnecting to ${targetLabel(target)} on ${host}. Recover it when the link is stable:\n${recoveryHint(target, host)}`;
103
105
  }
104
106
  /**
105
107
  * Wrap `cmd` in `bash -lc` with an exit-code remap: 255 becomes