@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
@@ -21,6 +21,18 @@ export interface FleetSharedRepoSyncResult {
21
21
  timedOut: boolean;
22
22
  skipped: string | null;
23
23
  error: string | null;
24
+ /**
25
+ * Untracked working-tree files that collided with origin and were dropped
26
+ * (byte-identical to origin) before rebasing. See clearCollidingUntracked.
27
+ */
28
+ untrackedCleared?: number;
29
+ /**
30
+ * Untracked working-tree files that collided with origin, differed from it,
31
+ * and were moved to untrackedBackupDir before rebasing.
32
+ */
33
+ untrackedBackedUp?: string[];
34
+ /** Directory the differing untracked collisions were moved to, if any. */
35
+ untrackedBackupDir?: string;
24
36
  }
25
37
  /** Run one real process asynchronously with a hard wall-clock/process-tree bound. */
26
38
  export declare function runBoundedProcess(command: string, args: string[], options: {
@@ -161,6 +161,72 @@ function retainedAutostash(stashList, objectId) {
161
161
  ref: null,
162
162
  };
163
163
  }
164
+ /**
165
+ * `git rebase` checks out its base (origin/<branch>) and aborts when an
166
+ * untracked working-tree file would be overwritten by a file that origin
167
+ * tracks. `--autostash` only sets aside *tracked* changes, so these untracked
168
+ * collisions wedge the rebase on every sync and the device silently falls
169
+ * hundreds of commits behind — the fleet-wide drift root cause (PHNX-3923).
170
+ *
171
+ * In this shared-state repo an untracked file that also exists on origin is a
172
+ * stale local snapshot: the canonical copy is on origin and every device
173
+ * republishes its own state (this device's own owned file is already committed
174
+ * before we get here, so it is tracked and never appears below). Drop the ones
175
+ * byte-identical to origin (lossless) and move any that differ into a backup
176
+ * dir beside the repo root — outside the repo's tracked tree, so the move
177
+ * cannot create a fresh collision — so nothing is silently destroyed. Then let
178
+ * the rebase check out origin's version. A file we cannot clear is left in
179
+ * place; the rebase may still abort, no worse than today and never losing data.
180
+ */
181
+ async function clearCollidingUntracked(git, root, branch) {
182
+ const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
183
+ if (others.code !== 0)
184
+ return { error: others };
185
+ const relPaths = others.stdout.split('\0').filter(Boolean);
186
+ let cleared = 0;
187
+ const backedUp = [];
188
+ let backupDir = null;
189
+ for (const rel of relPaths) {
190
+ // rev-parse doubles as the "does origin track this path" test (non-zero =
191
+ // absent) — only paths origin tracks are overwritten by the rebase checkout.
192
+ const originHash = await git(['rev-parse', `origin/${branch}:${rel}`]);
193
+ if (originHash.code !== 0 || !originHash.stdout.trim())
194
+ continue;
195
+ const abs = path.join(root, rel);
196
+ // Byte-exact identity via git blob SHAs. Comparing UTF-8-decoded strings
197
+ // would collapse distinct invalid bytes to U+FFFD and could delete
198
+ // non-identical content. `--no-filters` hashes the raw on-disk bytes (no
199
+ // clean/autocrlf normalization), so `identical` is true only when the local
200
+ // bytes exactly equal origin's stored blob — never a filter-normalized
201
+ // near-match. A hash-object failure (unreadable / vanished under a
202
+ // concurrent writer) means "leave it in place".
203
+ const localHash = await git(['hash-object', '--no-filters', '--', abs]);
204
+ if (localHash.code !== 0 || !localHash.stdout.trim())
205
+ continue;
206
+ const identical = originHash.stdout.trim() === localHash.stdout.trim();
207
+ try {
208
+ if (identical) {
209
+ fs.rmSync(abs, { force: true });
210
+ cleared++;
211
+ }
212
+ else {
213
+ if (!backupDir) {
214
+ // A sibling of the repo root: outside the tracked tree (so the move
215
+ // cannot create a fresh collision) yet colocated for easy recovery.
216
+ backupDir = path.join(`${root}-fleet-sync-backups`, String(Date.now()));
217
+ }
218
+ const dest = path.join(backupDir, rel);
219
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
220
+ fs.renameSync(abs, dest);
221
+ backedUp.push(rel);
222
+ }
223
+ }
224
+ catch {
225
+ // Could not clear this collision; leave it in place.
226
+ }
227
+ }
228
+ return { cleared, backedUp, backupDir };
229
+ }
164
230
  async function performFleetSharedRepoSync(root, device, timeoutMs) {
165
231
  if (!fs.existsSync(path.join(root, '.git'))) {
166
232
  return {
@@ -201,29 +267,57 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
201
267
  }
202
268
  const ownedFile = fleetSharedStatePath(device, root);
203
269
  const relativeOwnedFile = path.relative(root, ownedFile).split(path.sep).join('/');
270
+ // The central `agents.yaml` is fleet-shared state too — `agents accounts label`
271
+ // writes version-scoped native account rows there (lib/state.ts writeMetaUnlocked),
272
+ // as a plain file write with no commit. Publishing only the per-device file and
273
+ // then `rebase --autostash`-ing over a dirty central file silently destroyed
274
+ // those labels: every box lost its account labels on its next daemon publish
275
+ // (PHNX-3887). Commit it alongside the device doc so the rebase carries the
276
+ // rows instead of stashing them, which is also what `accounts label --help`
277
+ // already promises ("labels live on the central account rows in agents.yaml,
278
+ // which repo push/pull already syncs fleet-wide").
279
+ const centralFile = path.join(root, 'agents.yaml');
280
+ const publishPaths = [relativeOwnedFile];
281
+ if (fs.existsSync(centralFile))
282
+ publishPaths.push('agents.yaml');
283
+ const existingPaths = publishPaths.filter(rel => fs.existsSync(path.join(root, rel)));
204
284
  let committed = false;
205
- if (fs.existsSync(ownedFile)) {
206
- const status = await git(['status', '--porcelain=v1', '--', relativeOwnedFile]);
285
+ if (existingPaths.length > 0) {
286
+ const status = await git(['status', '--porcelain=v1', '--', ...existingPaths]);
207
287
  if (status.code !== 0)
208
288
  return failure('git status', status);
209
289
  if (status.stdout.trim()) {
210
- const add = await git(['add', '--', relativeOwnedFile]);
290
+ const add = await git(['add', '--', ...existingPaths]);
211
291
  if (add.code !== 0)
212
292
  return failure('git add', add);
213
293
  const commit = await git([
214
294
  '-c', 'commit.gpgsign=false',
215
295
  'commit', '--no-verify', '-m', `chore(devices): publish ${device} daemon state`,
216
- '--', relativeOwnedFile,
296
+ '--', ...existingPaths,
217
297
  ]);
218
298
  if (commit.code !== 0)
219
299
  return failure('git commit', commit);
220
300
  committed = true;
221
301
  }
222
302
  }
303
+ let untrackedCleared = 0;
304
+ const untrackedBackedUp = [];
305
+ let untrackedBackupDir;
223
306
  for (let attempt = 1; attempt <= FLEET_SHARED_REPO_PUSH_ATTEMPTS; attempt++) {
224
307
  const fetch = await git(['fetch', 'origin']);
225
308
  if (fetch.code !== 0)
226
309
  return { ...failure('git fetch', fetch), committed };
310
+ // Untracked files that origin tracks would abort the rebase's checkout
311
+ // (autostash only covers tracked changes). Clear them first — this is the
312
+ // fleet-drift root cause (PHNX-3923).
313
+ const reconcile = await clearCollidingUntracked(git, root, branch);
314
+ if ('error' in reconcile) {
315
+ return { ...failure('git ls-files --others', reconcile.error), committed };
316
+ }
317
+ untrackedCleared += reconcile.cleared;
318
+ untrackedBackedUp.push(...reconcile.backedUp);
319
+ if (reconcile.backupDir)
320
+ untrackedBackupDir = reconcile.backupDir;
227
321
  // Keep enough of the same wall-clock bound available to remove a botched
228
322
  // autostash pop from the operator's live checkout before returning.
229
323
  const rebase = await git(['rebase', '--autostash', `origin/${branch}`], FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS);
@@ -283,6 +377,9 @@ async function performFleetSharedRepoSync(root, device, timeoutMs) {
283
377
  timedOut: false,
284
378
  skipped: null,
285
379
  error: null,
380
+ untrackedCleared,
381
+ untrackedBackedUp,
382
+ untrackedBackupDir,
286
383
  };
287
384
  }
288
385
  if (attempt === FLEET_SHARED_REPO_PUSH_ATTEMPTS || !isPushRace(push)) {
@@ -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
@@ -30,6 +30,12 @@ export interface PhoenixSession {
30
30
  access_token: string;
31
31
  email?: string;
32
32
  userId?: string;
33
+ /**
34
+ * Hosted OAuth profile image (https URL), when Phoenix ID exposes one. Wins
35
+ * over the email-Gravatar fallback in share attribution. Optional forever —
36
+ * a missing value just means the Gravatar/initials fallback.
37
+ */
38
+ avatarUrl?: string;
33
39
  /** Unix ms; absent means the server did not scope the token's lifetime. */
34
40
  expires_at?: number;
35
41
  }
@@ -16,6 +16,8 @@ export interface WhoAmI {
16
16
  userId: string;
17
17
  email: string;
18
18
  valid: true;
19
+ /** Hosted OAuth profile image, when Phoenix ID stores one for this user. */
20
+ avatar_url?: string;
19
21
  }
20
22
  /**
21
23
  * RFC 8628 poll outcomes. `pending` and `slow_down` are normal states of a
@@ -28,6 +30,10 @@ export type DevicePoll = {
28
30
  user: {
29
31
  email: string;
30
32
  id: string;
33
+ /** Hosted OAuth profile image, when the provider exposed one to Phoenix ID. */
34
+ avatar_url?: string;
35
+ /** Google-style alias some providers use for the same field. */
36
+ picture?: string;
31
37
  };
32
38
  } | {
33
39
  status: 'pending';
@@ -41,6 +47,16 @@ export type DevicePoll = {
41
47
  export declare function startDeviceAuthorization(): Promise<DeviceAuthorization>;
42
48
  export declare function pollDeviceToken(deviceCode: string): Promise<DevicePoll>;
43
49
  export declare function fetchWhoAmI(token?: string): Promise<WhoAmI>;
50
+ /**
51
+ * Best-effort fill of the session's hosted avatar (PHNX-3547): sessions written
52
+ * before the CLI tracked avatars have none, so ask `/api/v1/auth/me` and merge
53
+ * a hosted `avatar_url` into the persisted session — future publishes then
54
+ * stamp the OAuth profile image instead of only a Gravatar hash. A no-op when
55
+ * signed out, when an avatar is already stored, or when the server exposes
56
+ * none; network/server failures are swallowed (the share attribution falls
57
+ * back to Gravatar/initials either way).
58
+ */
59
+ export declare function refreshSessionAvatar(): Promise<void>;
44
60
  export interface SpaceSummary {
45
61
  id: string;
46
62
  slug: string;
@@ -2,7 +2,7 @@
2
2
  * Phoenix ID — the typed surface commands use. Every route the account backend
3
3
  * exposes is a function here; no command builds a URL or reads a token itself.
4
4
  */
5
- import { phoenixRequest, PhoenixApiError } from './client.js';
5
+ import { phoenixRequest, PhoenixApiError, readSession, writeSession } from './client.js';
6
6
  export { PHOENIX_ID_BASE, PhoenixApiError, clearSession, readSession, sessionFilePath, writeSession, } from './client.js';
7
7
  export function startDeviceAuthorization() {
8
8
  return phoenixRequest('POST', '/api/v1/auth/device/authorization', {
@@ -38,6 +38,30 @@ export async function pollDeviceToken(deviceCode) {
38
38
  export function fetchWhoAmI(token) {
39
39
  return phoenixRequest('GET', '/api/v1/auth/me', { token });
40
40
  }
41
+ /**
42
+ * Best-effort fill of the session's hosted avatar (PHNX-3547): sessions written
43
+ * before the CLI tracked avatars have none, so ask `/api/v1/auth/me` and merge
44
+ * a hosted `avatar_url` into the persisted session — future publishes then
45
+ * stamp the OAuth profile image instead of only a Gravatar hash. A no-op when
46
+ * signed out, when an avatar is already stored, or when the server exposes
47
+ * none; network/server failures are swallowed (the share attribution falls
48
+ * back to Gravatar/initials either way).
49
+ */
50
+ export async function refreshSessionAvatar() {
51
+ const session = readSession();
52
+ if (!session || session.avatarUrl)
53
+ return;
54
+ try {
55
+ const me = await fetchWhoAmI();
56
+ const hosted = me.avatar_url?.trim();
57
+ if (hosted && /^https:\/\//i.test(hosted)) {
58
+ writeSession({ ...session, avatarUrl: hosted });
59
+ }
60
+ }
61
+ catch {
62
+ // Offline or server without the field — the Gravatar fallback covers it.
63
+ }
64
+ }
41
65
  export const listSpaces = () => phoenixRequest('GET', '/api/v1/spaces');
42
66
  export const createSpace = (input) => phoenixRequest('POST', '/api/v1/spaces', { body: input });
43
67
  export const getSpace = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}`);
@@ -177,6 +177,8 @@ export interface HostModelOptions {
177
177
  export declare function profileFromHostModel(name: string, host: AgentId, model: string, opts?: HostModelOptions): Profile;
178
178
  /** Overrides applied on top of the source when forking a harness. */
179
179
  export interface ForkProfileOptions {
180
+ /** Translate the fork onto a different native harness host. */
181
+ host?: AgentId;
180
182
  /** Swap the pinned model. Written onto the source's model env key when it has
181
183
  * one, else onto the host's canonical model var. */
182
184
  model?: string;
@@ -404,31 +404,50 @@ export function profileFromHostModel(name, host, model, opts = {}) {
404
404
  */
405
405
  export function forkProfile(source, name, opts = {}) {
406
406
  validateProfileName(name);
407
- const host = source.host.agent;
407
+ const sourceHost = source.host.agent;
408
+ const host = opts.host ?? sourceHost;
408
409
  const env = { ...source.env };
409
- if (opts.model) {
410
- env[profileModelEnvKey(source) ?? modelEnvKeyForHost(host)] = opts.model;
410
+ const sourceModelKey = profileModelEnvKey(source) ?? modelEnvKeyForHost(sourceHost);
411
+ const targetModelKey = modelEnvKeyForHost(host);
412
+ const model = opts.model ?? env[sourceModelKey];
413
+ if (host !== sourceHost && sourceModelKey !== targetModelKey)
414
+ delete env[sourceModelKey];
415
+ if (model) {
416
+ env[targetModelKey] = model;
411
417
  }
412
- if (opts.baseUrl) {
413
- const key = baseUrlEnvKeyForHost(host);
418
+ const sourceBaseKey = baseUrlEnvKeyForHost(sourceHost);
419
+ const targetBaseKey = baseUrlEnvKeyForHost(host);
420
+ const baseUrl = opts.baseUrl ?? (sourceBaseKey ? env[sourceBaseKey] : undefined);
421
+ if (host !== sourceHost && sourceBaseKey && sourceBaseKey !== targetBaseKey)
422
+ delete env[sourceBaseKey];
423
+ if (baseUrl) {
424
+ const key = targetBaseKey;
414
425
  if (!key) {
415
426
  throw new Error(`Host '${host}' has no known base-URL env var; drop --base-url or fork onto a claude/codex host.`);
416
427
  }
417
- env[key] = opts.baseUrl;
428
+ env[key] = baseUrl;
418
429
  }
419
430
  const forked = {
420
431
  ...source,
421
432
  name,
422
- host: { agent: host, ...(opts.version ? { version: opts.version } : source.host.version ? { version: source.host.version } : {}) },
433
+ host: { agent: host, ...(opts.version ? { version: opts.version } : host === sourceHost && source.host.version ? { version: source.host.version } : {}) },
423
434
  env,
424
435
  // The source's description names the source's model, so inheriting it
425
436
  // across a model swap would describe the fork wrongly.
426
- description: opts.description ?? (opts.model ? `Forked from ${source.name}: ${opts.model}` : source.description),
437
+ description: opts.description ?? (opts.model || host !== sourceHost
438
+ ? `Forked from ${source.name}: ${model ?? host}`
439
+ : source.description),
427
440
  forkedFrom: source.name,
428
441
  };
442
+ if (forked.auth && host !== sourceHost) {
443
+ const envVar = authEnvKeyForHost(host);
444
+ if (!envVar)
445
+ throw new Error(`Host '${host}' has no known auth env var; the source auth binding cannot be translated.`);
446
+ forked.auth = { ...forked.auth, envVar };
447
+ }
429
448
  // A fork that repoints the model or endpoint is no longer that preset — keep
430
449
  // the preset link only while the fork still matches what the preset defines.
431
- if (opts.model || opts.baseUrl)
450
+ if (opts.model || opts.baseUrl || host !== sourceHost)
432
451
  delete forked.preset;
433
452
  if (opts.provider) {
434
453
  const envVar = opts.authEnvVar ?? source.auth?.envVar ?? authEnvKeyForHost(host);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Post-reconcile repair — the shared "make it whole" pass `agents sync` runs
3
+ * after its prune+write, so sync is a true superset of the old `doctor --fix`.
4
+ *
5
+ * `syncResourcesToVersion` reconciles resource FILES (and, with a caller
6
+ * selection, prunes orphans). It does NOT: fill live-home gaps the staleness
7
+ * manifest can't see, generate/repair the managed hook runtime shims, or re-wire
8
+ * a present-but-unwired hook into settings.json. `doctor --fix` used to do all of
9
+ * that; now sync does, by calling this one routine at the tail of every reconcile.
10
+ * (The destructive stale-CLI purge `doctor --fix` also ran is NOT automatic here
11
+ * — it runs only via the explicit `agents sync --prune-clis`, see `pruneClis`.)
12
+ *
13
+ * Ordering matters: sync's own prune+write happens FIRST (unchanged), THEN this
14
+ * repair pass. Prune stays sync's job; heal only FILLS and FIXES (never deletes),
15
+ * so the two never fight. Pure of stdout — returns a structured report;
16
+ * `renderRepairAfterSync` prints it only when the caller is neither `--json` nor
17
+ * `--quiet`.
18
+ */
19
+ import type { AgentId } from './types.js';
20
+ import { type HealResult } from './heal.js';
21
+ import { type HookRuntimeRepairReport } from './hooks/install.js';
22
+ import { type RemediateStaleInstallsResult, type FindAgentsCliInstallsOptions } from './self-update.js';
23
+ export interface HookRewireResult {
24
+ agent: AgentId;
25
+ version: string;
26
+ /** Hooks newly wired into settings.json by this pass. */
27
+ rewired: number;
28
+ /** Hooks still unwired after re-registering (source/home mismatch). */
29
+ remaining: number;
30
+ /** Stable failure class; never expose a generated temporary shim pathname. */
31
+ failure?: 'register-failed';
32
+ }
33
+ export interface RepairAfterSyncReport {
34
+ heal: HealResult;
35
+ hookRewire: HookRewireResult[];
36
+ hookRuntimeRepair: HookRuntimeRepairReport;
37
+ /** Only populated on the umbrella (no-agent) path; null otherwise. */
38
+ staleInstallPurge: RemediateStaleInstallsResult | null;
39
+ }
40
+ /**
41
+ * Test/override hook that scopes the destructive stale-CLI purge to injected
42
+ * paths so it NEVER touches real machine installs. `findAgentsCliInstalls`
43
+ * hard-codes `/usr/local/lib/node_modules` et al. when `globalNodeModulesDirs` is
44
+ * absent, so a purge is unsafe to run unscoped in a test — inject sandbox paths.
45
+ */
46
+ export interface PurgeInjection {
47
+ runningRoot?: string;
48
+ runningVersion?: string;
49
+ pathEnv?: string;
50
+ findOpts?: FindAgentsCliInstallsOptions;
51
+ dryRun?: boolean;
52
+ }
53
+ export interface RepairAfterSyncOptions {
54
+ /** Scope the repair to one agent; omit for the umbrella sweep across all
55
+ * installed agents. (The purge is NOT tied to this — see `pruneClis`.) */
56
+ agent?: AgentId;
57
+ /** The versions of `agent` the sync just reconciled. Passing them scopes
58
+ * heal + rewire + runtime-repair to exactly those homes; omitting lets heal
59
+ * apply its own non-isolated sweep. */
60
+ versions?: string[];
61
+ /** Resolution cwd for heal's diff. Defaults to heal's neutral home dir. */
62
+ cwd?: string;
63
+ /** Run the DESTRUCTIVE machine-wide stale-CLI purge (`fs.rmSync`s other
64
+ * agents-cli installs). Default false — the purge is NEVER automatic; it runs
65
+ * only on the umbrella (no-agent) path AND only when the caller passed
66
+ * `agents sync --prune-clis`. */
67
+ pruneClis?: boolean;
68
+ /** Scope the purge scan+delete to injected paths (tests only). */
69
+ purgeInjection?: PurgeInjection;
70
+ }
71
+ /**
72
+ * Run the post-reconcile repair pass over the agents/versions a sync just
73
+ * touched: heal live-home gaps (mode 'full', matching the old `doctor --fix`),
74
+ * re-wire hooks the diff-driven heal leaves behind, run one bounded managed-hook
75
+ * runtime repair, and — ONLY when `pruneClis` is set on the umbrella (no-agent)
76
+ * path — purge stale/legacy agents-cli copies. The purge is never automatic.
77
+ * Returns a full account; never writes to stdout.
78
+ */
79
+ export declare function repairAfterSync(opts: RepairAfterSyncOptions): Promise<RepairAfterSyncReport>;
80
+ /** True when the repair pass changed (or attempted to change) anything. */
81
+ export declare function repairChangedAnything(report: RepairAfterSyncReport): boolean;
82
+ /**
83
+ * True when the RECONCILE repair left a per-version problem a human must fix — an
84
+ * unresolvable managed hook runtime shim, or a hook that could not be re-wired.
85
+ * These gate a sync's `ok`/exit code (the yosemite-s1 class the old
86
+ * `doctor --fix` surfaced).
87
+ *
88
+ * The machine-wide stale-CLI purge is deliberately EXCLUDED: a purge that could
89
+ * not delete a system-wide install (typically `EACCES` on `/usr/local/...`, which
90
+ * needs sudo) is a hygiene issue unrelated to whether the reconcile succeeded, so
91
+ * it must not flip a fleet peer's `agents sync --json` to `ok: false`. Purge
92
+ * failures stay fully visible — in the JSON `repair.staleInstallPurge.failed` and
93
+ * the rendered `hold`/`manual` lines.
94
+ */
95
+ export declare function repairHadFailures(report: RepairAfterSyncReport): boolean;
96
+ /**
97
+ * Serialize the repair pass for a `--json` sync payload — the machine surface the
98
+ * deleted `doctor --fix --json` used to carry (heal detail + hook rewire + hook
99
+ * runtime repair + the umbrella stale-CLI purge). Attached under the `repair` key
100
+ * so fleet fan-out sees what the reconcile fixed and whether anything still needs
101
+ * a human (mirror `repairHadFailures`).
102
+ */
103
+ export declare function repairAfterSyncJson(report: RepairAfterSyncReport): Record<string, unknown>;
104
+ /**
105
+ * Print the repair pass's human-readable detail. Sync calls this only on the
106
+ * interactive / non-json path; the header is emitted only when something actually
107
+ * changed, so a clean sync stays quiet.
108
+ */
109
+ export declare function renderRepairAfterSync(report: RepairAfterSyncReport, log?: (s: string) => void): void;