@phnx-labs/agents-cli 1.22.57 → 1.22.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/bootstrap.js +8 -1
  3. package/dist/commands/accounts.js +7 -3
  4. package/dist/commands/apply.js +10 -2
  5. package/dist/commands/fork.d.ts +23 -10
  6. package/dist/commands/fork.js +115 -58
  7. package/dist/commands/monitors.js +11 -0
  8. package/dist/commands/prune.js +5 -3
  9. package/dist/commands/routines.d.ts +8 -0
  10. package/dist/commands/routines.js +57 -3
  11. package/dist/commands/sessions-picker.d.ts +11 -0
  12. package/dist/commands/sessions-picker.js +16 -0
  13. package/dist/commands/sessions.js +1 -0
  14. package/dist/commands/share.d.ts +14 -0
  15. package/dist/commands/share.js +43 -2
  16. package/dist/commands/status.js +1 -1
  17. package/dist/commands/sync.js +83 -7
  18. package/dist/commands/traces.js +7 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +6 -1
  21. package/dist/lib/account-registry.d.ts +5 -1
  22. package/dist/lib/account-registry.js +47 -14
  23. package/dist/lib/accounting/capacity.d.ts +18 -7
  24. package/dist/lib/accounting/capacity.js +19 -8
  25. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  26. package/dist/lib/accounting/usage-sync.js +76 -2
  27. package/dist/lib/accounting/usage.js +7 -1
  28. package/dist/lib/auth-mint.d.ts +11 -1
  29. package/dist/lib/auth-mint.js +21 -6
  30. package/dist/lib/browser/ipc.d.ts +8 -0
  31. package/dist/lib/browser/ipc.js +87 -0
  32. package/dist/lib/browser/service.d.ts +19 -0
  33. package/dist/lib/browser/service.js +96 -11
  34. package/dist/lib/browser/sessions-list.js +10 -1
  35. package/dist/lib/daemon/runner.d.ts +3 -0
  36. package/dist/lib/daemon/runner.js +86 -45
  37. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  38. package/dist/lib/daemon/usage-sync-service.js +14 -8
  39. package/dist/lib/daemon-services.js +1 -1
  40. package/dist/lib/devices/connect.d.ts +17 -8
  41. package/dist/lib/devices/connect.js +31 -14
  42. package/dist/lib/doctor-diff.js +77 -7
  43. package/dist/lib/fleet/manifest.d.ts +17 -0
  44. package/dist/lib/fleet/manifest.js +26 -0
  45. package/dist/lib/hooks/install.d.ts +27 -11
  46. package/dist/lib/hooks/install.js +42 -17
  47. package/dist/lib/hosts/reconnect.d.ts +52 -203
  48. package/dist/lib/hosts/reconnect.js +64 -284
  49. package/dist/lib/installations/migrate.d.ts +6 -120
  50. package/dist/lib/installations/migrate.js +27 -259
  51. package/dist/lib/installations/shims.d.ts +13 -95
  52. package/dist/lib/installations/shims.js +22 -139
  53. package/dist/lib/installations/store.js +1 -1
  54. package/dist/lib/installations/versions.d.ts +26 -133
  55. package/dist/lib/installations/versions.js +41 -204
  56. package/dist/lib/plugins/skills.d.ts +8 -1
  57. package/dist/lib/plugins/skills.js +18 -2
  58. package/dist/lib/refresh.d.ts +9 -0
  59. package/dist/lib/refresh.js +3 -1
  60. package/dist/lib/routine-readiness.d.ts +15 -1
  61. package/dist/lib/routine-readiness.js +41 -0
  62. package/dist/lib/sandbox.d.ts +4 -1
  63. package/dist/lib/sandbox.js +30 -1
  64. package/dist/lib/secrets/agent.d.ts +80 -225
  65. package/dist/lib/secrets/agent.js +139 -401
  66. package/dist/lib/secrets/bundles.d.ts +73 -222
  67. package/dist/lib/secrets/bundles.js +168 -467
  68. package/dist/lib/secrets/reaper.d.ts +28 -70
  69. package/dist/lib/secrets/reaper.js +30 -85
  70. package/dist/lib/secrets/remote.d.ts +42 -129
  71. package/dist/lib/secrets/remote.js +55 -173
  72. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  73. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  74. package/dist/lib/self-heal/registry.js +2 -0
  75. package/dist/lib/self-heal/types.d.ts +1 -1
  76. package/dist/lib/self-update.d.ts +23 -0
  77. package/dist/lib/self-update.js +50 -0
  78. package/dist/lib/session/active.d.ts +13 -1
  79. package/dist/lib/session/active.js +2 -0
  80. package/dist/lib/session/db.d.ts +20 -1
  81. package/dist/lib/session/db.js +139 -9
  82. package/dist/lib/session/fork.d.ts +45 -26
  83. package/dist/lib/session/fork.js +32 -95
  84. package/dist/lib/session/tool-calls.d.ts +43 -1
  85. package/dist/lib/session/tool-calls.js +74 -44
  86. package/dist/lib/session/tool-store.d.ts +33 -2
  87. package/dist/lib/session/tool-store.js +56 -3
  88. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  89. package/dist/lib/staleness/writers/sources.js +2 -1
  90. package/dist/lib/sync-status.d.ts +22 -0
  91. package/dist/lib/sync-status.js +27 -0
  92. package/dist/lib/sync-umbrella.d.ts +9 -0
  93. package/dist/lib/sync-umbrella.js +21 -2
  94. package/dist/lib/traces/insights.d.ts +47 -14
  95. package/dist/lib/traces/insights.js +92 -21
  96. package/dist/lib/traces/phenotype.d.ts +23 -3
  97. package/dist/lib/traces/phenotype.js +72 -24
  98. package/dist/lib/traces/sync.d.ts +15 -0
  99. package/dist/lib/traces/sync.js +104 -19
  100. package/dist/lib/traces/worker-template.js +154 -1
  101. package/package.json +1 -1
@@ -2608,9 +2608,45 @@ export class BrowserService {
2608
2608
  if (port === undefined)
2609
2609
  return null;
2610
2610
  const host = parsed?.host && parsed.host !== 'localhost' ? parsed.host : 'localhost';
2611
- // ssh:// endpoints need a tunnel; soft rehydrate only handles local CDP.
2612
- if (resolved.target.startsWith('ssh:'))
2613
- return null;
2611
+ // ssh:// endpoints: a daemon restart killed this box's tunnel, but the
2612
+ // browser is still running on the FAR side — so the caller's tasks are
2613
+ // valid, only the local hop is gone. Re-establish the tunnel and reconnect
2614
+ // CDP (connectSSH attaches to the already-running remote browser via
2615
+ // isOwnTunnel — it never launches one, so this stays a soft-attach), then
2616
+ // merge the disk tasks. Without this, a remote agent driving a browser host
2617
+ // after a restart got "Unknown browser task" for a tab that was still alive
2618
+ // (PHNX-2663). A failure returns null exactly like the local-CDP path, so
2619
+ // the caller falls through to disk reconcile rather than crashing.
2620
+ if (resolved.target.startsWith('ssh:')) {
2621
+ try {
2622
+ const conn = await connectSSH(resolved.target, profile, key, { persistRemote: true });
2623
+ await this.enableDomains(conn.cdp);
2624
+ const tasks = this.loadTaskState(key);
2625
+ for (const [k, t] of diskTasks) {
2626
+ if (!tasks.has(k))
2627
+ tasks.set(k, t);
2628
+ }
2629
+ return {
2630
+ cdp: conn.cdp,
2631
+ port: conn.port,
2632
+ pid: conn.pid,
2633
+ electron: profile.electron,
2634
+ browserType: profile.browser,
2635
+ targetFilter: resolved.targetFilter ?? profile.targetFilter,
2636
+ key,
2637
+ profile: bare,
2638
+ tasks,
2639
+ sessionCache: new Map(),
2640
+ // Carry the tunnel teardown so removing this connection kills the
2641
+ // ssh hop — otherwise it leaks across the next restart (see the
2642
+ // `cleanup` docblock on ProfileConnection).
2643
+ cleanup: conn.cleanup,
2644
+ };
2645
+ }
2646
+ catch {
2647
+ return null;
2648
+ }
2649
+ }
2614
2650
  try {
2615
2651
  const { wsUrl, browser } = await discoverBrowserWsUrl(port, host, bare);
2616
2652
  verifyBrowserIdentity(browser, profile.browser, port, host);
@@ -2645,6 +2681,48 @@ export class BrowserService {
2645
2681
  * tasks.json entries. Used before identity-based resolution so a daemon
2646
2682
  * restart does not make the caller's tasks invisible.
2647
2683
  */
2684
+ /**
2685
+ * Register a freshly-rehydrated connection, unless a concurrent rehydrate won
2686
+ * the race and already registered one for this key. `attachRunningProfile`
2687
+ * awaits an ssh-tunnel spawn / CDP connect, so two commands landing right
2688
+ * after a daemon restart can each build a connection for the same key; the
2689
+ * loser must be released instead of silently overwriting the winner (whose
2690
+ * `cleanup` would then never run).
2691
+ *
2692
+ * The subtlety is the SSH tunnel: when the loser's `connectSSH` landed after
2693
+ * the winner's tunnel had already bound the local port, `isOwnTunnel` makes it
2694
+ * **reuse** that same OS process (`drivers/ssh.ts`) — so both connections carry
2695
+ * the same `pid`/`port`. Calling the loser's `cleanup()` then does
2696
+ * `tunnel.kill()` on the SHARED process and breaks the winner's live CDP. So
2697
+ * when the loser shares the winner's tunnel we close ONLY the loser's own CDP
2698
+ * socket and leave the tunnel to the winner; only a loser that spawned its own
2699
+ * distinct tunnel gets the full `cleanup()` (else it would leak). Returns the
2700
+ * winning connection to use.
2701
+ */
2702
+ registerRehydratedConnection(key, conn) {
2703
+ const existing = this.connections.get(key);
2704
+ if (!existing) {
2705
+ this.connections.set(key, conn);
2706
+ return conn;
2707
+ }
2708
+ const sharesTunnel = conn.pid !== 0 && conn.pid === existing.pid && conn.port === existing.port;
2709
+ if (conn.pid !== 0 && !sharesTunnel && conn.cleanup) {
2710
+ // Distinct tunnel — full teardown so this loser's tunnel doesn't leak.
2711
+ try {
2712
+ conn.cleanup();
2713
+ }
2714
+ catch { /* best effort — the winner stays live */ }
2715
+ }
2716
+ else {
2717
+ // Shared/borrowed tunnel (or a local connection with no tunnel): close
2718
+ // only our own CDP client; killing the tunnel would break the winner.
2719
+ try {
2720
+ conn.cdp.close();
2721
+ }
2722
+ catch { /* best effort */ }
2723
+ }
2724
+ return existing;
2725
+ }
2648
2726
  async rehydrateAllFromDisk() {
2649
2727
  const runtimeRoot = getBrowserRuntimeDir();
2650
2728
  let dirNames = [];
@@ -2676,7 +2754,11 @@ export class BrowserService {
2676
2754
  const conn = await this.attachRunningProfile(key, tasks);
2677
2755
  if (!conn)
2678
2756
  continue;
2679
- this.connections.set(key, conn);
2757
+ // A concurrent rehydrate may have registered one for this key while we
2758
+ // awaited the attach; keep the winner and tear our loser's tunnel down.
2759
+ const registered = this.registerRehydratedConnection(key, conn);
2760
+ if (registered !== conn)
2761
+ continue;
2680
2762
  try {
2681
2763
  await this.applyDefaultDownloadBehavior(conn, key);
2682
2764
  }
@@ -2734,13 +2816,16 @@ export class BrowserService {
2734
2816
  // CDP down — cannot rehydrate a live connection; caller will error.
2735
2817
  continue;
2736
2818
  }
2737
- conn = attached;
2738
- this.connections.set(key, conn);
2739
- try {
2740
- await this.applyDefaultDownloadBehavior(conn, key);
2741
- }
2742
- catch {
2743
- // Non-fatal.
2819
+ // Keep whichever connection a concurrent rehydrate registered first and
2820
+ // tear our loser's tunnel down instead of leaking it (see the helper).
2821
+ conn = this.registerRehydratedConnection(key, attached);
2822
+ if (conn === attached) {
2823
+ try {
2824
+ await this.applyDefaultDownloadBehavior(conn, key);
2825
+ }
2826
+ catch {
2827
+ // Non-fatal.
2828
+ }
2744
2829
  }
2745
2830
  }
2746
2831
  const task = this.lookupTaskOnConn(conn, taskId) ?? found;
@@ -21,6 +21,7 @@ import { formatBytes } from '../format.js';
21
21
  export { formatBytes };
22
22
  import * as path from 'path';
23
23
  import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
24
+ import { listProfileCacheDirs } from './runtime-state.js';
24
25
  import { formatRelativeTime } from '../session/relative-time.js';
25
26
  import { getSessionById, listBrowserSessionRecords, pruneToolSessions } from '../session/db.js';
26
27
  import { listPidSessionEntries } from '../session/pid-registry.js';
@@ -99,7 +100,15 @@ export function listProfileArtifacts(profile) {
99
100
  export function listBrowserSessions(only) {
100
101
  let profiles;
101
102
  if (only) {
102
- profiles = [only];
103
+ // A profile's live tasks/captures may live under a composite runtime dir
104
+ // (`<name>@<device>`, `<name>@endpoint-N`, forks) — NOT the bare `<name>`
105
+ // dir. Resolve `only` to every cache dir that belongs to it, the same rule
106
+ // status()/findTask use (keyBelongsToProfile), so `--profile comet-local`
107
+ // surfaces the real `comet-local@zion` store instead of the empty legacy
108
+ // dir. Keep the requested name when nothing exists on disk yet so a fresh
109
+ // profile still returns its (empty) entry rather than vanishing.
110
+ const dirs = listProfileCacheDirs(only).map((d) => path.basename(d));
111
+ profiles = dirs.length > 0 ? dirs : [only];
103
112
  }
104
113
  else {
105
114
  try {
@@ -113,6 +113,8 @@ export interface RoutineLaunchPlan {
113
113
  /** False when `account` names a harness-native login rather than a durable credential. */
114
114
  forwardAccount?: boolean;
115
115
  }
116
+ /** Claude's own local auth check; unlike account metadata it cannot mistake identity for a usable login. */
117
+ export declare function claudeVersionIsAuthenticated(version: string): boolean;
116
118
  /**
117
119
  * Resolve the version/account chain for a routine the same way `agents run`
118
120
  * does: honor an explicit `version:` pin; otherwise use the configured run
@@ -130,6 +132,7 @@ export declare function resolveRoutineLaunch(config: JobConfig, cwd?: string, de
130
132
  resolveCredentialAccount?: (name: string, host: AgentId) => {
131
133
  env: Record<string, string>;
132
134
  };
135
+ claudeVersionIsAuthenticated?: typeof claudeVersionIsAuthenticated;
133
136
  }): Promise<RoutineLaunchPlan>;
134
137
  /**
135
138
  * Rewrite `cmd[0]` to the absolute binary for `agent@version` when installed.
@@ -21,7 +21,7 @@ import { getCliLaunch, getAgentsBinDir } from '../cli-entry.js';
21
21
  import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, resolveJobExecutionContext, slotRunId, claimRunSlot, readRunMeta, resolveHostStrategy, } from '../scheduling/routines.js';
22
22
  import { getRunsDir, getUserAgentsDir, readMeta, getDaemonDir } from '../state.js';
23
23
  import { shortCodexHome } from '../codex-home.js';
24
- import { prepareJobHome, buildSpawnEnv, getJobHomePath, assertSandboxForwardsHostGhAuth } from '../sandbox.js';
24
+ import { prepareJobHome, buildSpawnEnv, getJobHomePath, assertSandboxForwardsHostGhAuth, linkVersionAuth } from '../sandbox.js';
25
25
  import { resolveModel, buildReasoningFlags } from '../models.js';
26
26
  import { createTimer, redactPrompt, emitRoutineEnd } from '../feed/events.js';
27
27
  import { resolveHarnessAdapter } from '../harness/index.js';
@@ -37,8 +37,6 @@ import { walkForFiles } from '../fs-walk.js';
37
37
  import { getBinaryPath, isVersionInstalled, resolveVersion, getVersionHomePath } from '../installations/versions.js';
38
38
  import { resolveClaudeSetupToken } from '../claude-account-token.js';
39
39
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from '../accounting/rotate.js';
40
- import { readAuthHealth, isDeadVerdict } from '../auth-health.js';
41
- import { machineId } from '../machine-id.js';
42
40
  import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
43
41
  import { isSelfUpdatingAgent, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
44
42
  import { isCustomHarnessName, readProfile } from '../profiles.js';
@@ -860,6 +858,27 @@ function readCommandExitCode(runDir) {
860
858
  return null;
861
859
  }
862
860
  }
861
+ /** Claude's own local auth check; unlike account metadata it cannot mistake identity for a usable login. */
862
+ export function claudeVersionIsAuthenticated(version) {
863
+ if (!isVersionInstalled('claude', version))
864
+ return true; // synthetic/unit plans are validated at spawn
865
+ const binary = getBinaryPath('claude', version);
866
+ if (!binary)
867
+ return false;
868
+ const home = getVersionHomePath('claude', version);
869
+ try {
870
+ const raw = execFileSync(binary, ['auth', 'status', '--json'], {
871
+ encoding: 'utf8',
872
+ timeout: 5_000,
873
+ env: { ...process.env, HOME: process.env.AGENTS_REAL_HOME || os.homedir(), CLAUDE_CONFIG_DIR: path.join(home, '.claude') },
874
+ stdio: ['ignore', 'pipe', 'ignore'],
875
+ });
876
+ return JSON.parse(raw).loggedIn === true;
877
+ }
878
+ catch {
879
+ return false;
880
+ }
881
+ }
863
882
  /**
864
883
  * Resolve the version/account chain for a routine the same way `agents run`
865
884
  * does: honor an explicit `version:` pin; otherwise use the configured run
@@ -890,7 +909,7 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
890
909
  : false;
891
910
  const selectedCredential = config.account
892
911
  ? (explicitCredential ? config.account : undefined)
893
- : resolveAccountSelection(undefined, agent, meta);
912
+ : resolveAccountSelection(undefined, agent, meta)?.id;
894
913
  if (selectedCredential) {
895
914
  // A default/binding may resolve to a native account — never route that
896
915
  // through the provider credential path (it has no bundle to resolve).
@@ -980,6 +999,24 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
980
999
  if (!version) {
981
1000
  version = resolveVersion(agent, cwd) ?? undefined;
982
1001
  }
1002
+ if (agent === 'claude' && version) {
1003
+ const checkClaudeAuth = deps.claudeVersionIsAuthenticated ?? claudeVersionIsAuthenticated;
1004
+ const authenticatedHealthy = rotation?.healthy.filter((candidate) => checkClaudeAuth(candidate.version));
1005
+ const authenticated = authenticatedHealthy?.find((candidate) => candidate.version === version) ?? authenticatedHealthy?.[0];
1006
+ if (rotation && (!authenticated || authenticatedHealthy.length === 0)) {
1007
+ throw new Error(`Routine '${config.name}' found no authenticated Claude account; run \`claude /login\` for an installed version or pin a provider profile.`);
1008
+ }
1009
+ if (!rotation && !checkClaudeAuth(version)) {
1010
+ throw new Error(`Routine '${config.name}' found no authenticated Claude account at claude@${version}; run \`claude /login\` for that version or remove the pin.`);
1011
+ }
1012
+ if (rotation && authenticated) {
1013
+ const rejected = rotation.healthy.filter((candidate) => !authenticatedHealthy.includes(candidate));
1014
+ if (rejected.length > 0 || rotation.picked !== authenticated) {
1015
+ rotation = { ...rotation, picked: authenticated, healthy: authenticatedHealthy, excluded: [...rotation.excluded, ...rejected] };
1016
+ }
1017
+ version = authenticated.version;
1018
+ }
1019
+ }
983
1020
  if (!version) {
984
1021
  process.stderr.write(`[agents] routine ${config.name}: no version of ${agent} configured — ` +
985
1022
  `run: agents add ${agent}@<version> && agents use ${agent} <version>\n`);
@@ -1143,6 +1180,10 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
1143
1180
  // to the overlay so an ambient value cannot bypass the routine sandbox.
1144
1181
  out.XDG_CONFIG_HOME = path.join(overlayHome, '.config');
1145
1182
  }
1183
+ if (overlayHome && agent === 'claude')
1184
+ out.HOME = process.env.AGENTS_REAL_HOME || os.homedir();
1185
+ if (overlayHome && agent === 'codex')
1186
+ out.CODEX_HOME = path.join(overlayHome, '.codex');
1146
1187
  if (timezone)
1147
1188
  out.TZ = timezone;
1148
1189
  return out;
@@ -1304,7 +1345,7 @@ async function executeJobPlaced(config, deps, attempt) {
1304
1345
  // sentinel, version homes) from HOME, which the overlay would hide. Exec
1305
1346
  // still isolates the run in the host version home it swaps HOME into.
1306
1347
  const useSandbox = config.sandbox !== false && !config.resume && !(config.agent && isCustomHarnessName(config.agent));
1307
- const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
1348
+ const overlayHome = useSandbox ? prepareJobHome(config, primaryVersion) : undefined;
1308
1349
  const runId = attempt.runId;
1309
1350
  const runDir = getRunDir(config.name, runId);
1310
1351
  fs.mkdirSync(runDir, { recursive: true });
@@ -1329,7 +1370,7 @@ async function executeJobPlaced(config, deps, attempt) {
1329
1370
  if (!dispatchesViaAgentsRun(config)) {
1330
1371
  const { findUnifiedAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
1331
1372
  const meta = readMeta();
1332
- const selectedAccount = resolveAccountSelection(config.account, effectiveAgent, meta);
1373
+ const selectedAccount = resolveAccountSelection(config.account, effectiveAgent, meta)?.id;
1333
1374
  if (selectedAccount) {
1334
1375
  // Only a provider account injects env; a native account (explicit or via a
1335
1376
  // device-scoped binding) is read from the harness home and forwards nothing.
@@ -1360,24 +1401,24 @@ async function executeJobPlaced(config, deps, attempt) {
1360
1401
  // Baseline the child's transcript dirs BEFORE spawning so archiveRoutineTranscripts
1361
1402
  // copies only THIS run's transcript out of a shared per-version home (RUSH-2271).
1362
1403
  snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1363
- // Auth preflight: if the last live probe rejected this (agent, version)'s
1364
- // token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
1365
- // before spawning instead of producing a doomed run + poisoned report. Cache-
1366
- // only (the daemon refreshes it periodically); fail OPEN on any non-revoked or
1367
- // missing verdict so a stale/absent probe or a network blip never blocks a
1368
- // run, and agents with no live probe (codex/gemini/grok) are never blocked.
1404
+ // Auth preflight: if the resolved account is provably signed out (revoked or
1405
+ // unconfigured), record a terminal `blocked`/`agent_auth_failed` run with the
1406
+ // re-login repair instead of spawning a doomed run that 401s and burns a
1407
+ // session (PHNX-3415). `blocked` (readiness rejection, no body ran) is kept
1408
+ // distinct from `failed` (a body ran and errored) per RT-7. Cache-only + fails
1409
+ // OPEN — see fireTimeAuthReadiness.
1369
1410
  const preflightVersion = launch.chain[0]?.version;
1370
- if (preflightVersion) {
1371
- const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1372
- if (health && isDeadVerdict(health.verdict)) {
1373
- const reason = `auth_preflight: ${health.verdict}`;
1374
- process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1375
- finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1376
- writeRunMeta(meta);
1377
- timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
1378
- archiveRoutineTranscripts(meta, runDir, overlayHome);
1379
- return { meta, reportPath: null };
1380
- }
1411
+ // Dynamic import breaks the routine-readiness -> scheduling/routines -> runner cycle.
1412
+ const { fireTimeAuthReadiness } = await import('../routine-readiness.js');
1413
+ const authBlocker = preflightVersion ? fireTimeAuthReadiness(effectiveAgent, preflightVersion) : null;
1414
+ if (authBlocker) {
1415
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} ${authBlocker.code} — skipping run (${authBlocker.repair})\n`);
1416
+ meta.readiness = authBlocker;
1417
+ finalizeRunMeta(meta, 'blocked', null, { errorMessage: `blocked: ${authBlocker.code} — ${authBlocker.message}` });
1418
+ writeRunMeta(meta);
1419
+ timer.end({ status: 'blocked', runId, error: authBlocker.code });
1420
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1421
+ return { meta, reportPath: null };
1381
1422
  }
1382
1423
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
1383
1424
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
@@ -1444,6 +1485,8 @@ async function executeJobPlaced(config, deps, attempt) {
1444
1485
  meta.version = attemptVersion;
1445
1486
  snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1446
1487
  const viaAgentsRun = dispatchesViaAgentsRun(config);
1488
+ if (overlayHome)
1489
+ linkVersionAuth(overlayHome, attemptAgent, attemptVersion);
1447
1490
  const cmd = viaAgentsRun
1448
1491
  ? baseCmd
1449
1492
  : pinJobBinary(baseCmd, attemptAgent, attemptVersion);
@@ -1837,7 +1880,7 @@ async function executeJobDetachedClaimed(config, attempt, hooks) {
1837
1880
  // sentinel, version homes) from HOME, which the overlay would hide. Exec
1838
1881
  // still isolates the run in the host version home it swaps HOME into.
1839
1882
  const useSandbox = config.sandbox !== false && !config.resume && !(config.agent && isCustomHarnessName(config.agent));
1840
- const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
1883
+ const overlayHome = useSandbox ? prepareJobHome(config, version) : undefined;
1841
1884
  const runId = attempt.runId;
1842
1885
  const runDir = getRunDir(config.name, runId);
1843
1886
  fs.mkdirSync(runDir, { recursive: true });
@@ -1850,7 +1893,7 @@ async function executeJobDetachedClaimed(config, attempt, hooks) {
1850
1893
  const { findAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
1851
1894
  const selectedAccount = config.account && !findAccount(config.account)
1852
1895
  ? undefined
1853
- : resolveAccountSelection(config.account, config.agent, readMeta());
1896
+ : resolveAccountSelection(config.account, config.agent, readMeta())?.id;
1854
1897
  if (selectedAccount)
1855
1898
  Object.assign(baseEnv, resolveCredentialAccount(selectedAccount, config.agent).env);
1856
1899
  }
@@ -1891,28 +1934,26 @@ async function executeJobDetachedClaimed(config, attempt, hooks) {
1891
1934
  // Baseline the child's transcript dirs BEFORE spawning so archiveRoutineTranscripts
1892
1935
  // copies only THIS run's transcript out of a shared per-version home (RUSH-2271).
1893
1936
  snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1894
- // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1895
- // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1896
- // login. If the last live probe rejected that (agent, version)'s token, the run
1897
- // is guaranteed to 401 — fail fast with a re-login hint instead of spawning a
1898
- // doomed run + poisoned report. Cache-only; fails OPEN on any non-dead/missing
1899
- // verdict, and agents with no live probe (codex/gemini/grok) are never blocked.
1937
+ // Auth preflight (mirrors executeJob): a provably signed-out resolved account
1938
+ // records a terminal `blocked`/`agent_auth_failed` run with the re-login repair
1939
+ // instead of spawning a doomed run that 401s and burns a session (PHNX-3415).
1940
+ // Cache-only + fails OPEN — see fireTimeAuthReadiness.
1900
1941
  const preflightVersion = launch.chain[0]?.version;
1901
- if (preflightVersion) {
1902
- const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1903
- if (health && isDeadVerdict(health.verdict)) {
1904
- const reason = `auth_preflight: ${health.verdict}`;
1905
- process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1906
- try {
1907
- fs.closeSync(stdoutFd);
1908
- }
1909
- catch { /* already closed */ }
1910
- finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1911
- writeRunMeta(meta);
1912
- archiveRoutineTranscripts(meta, runDir, overlayHome);
1913
- timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
1914
- return meta;
1942
+ // Dynamic import breaks the routine-readiness -> scheduling/routines -> runner cycle.
1943
+ const { fireTimeAuthReadiness } = await import('../routine-readiness.js');
1944
+ const authBlocker = preflightVersion ? fireTimeAuthReadiness(effectiveAgent, preflightVersion) : null;
1945
+ if (authBlocker) {
1946
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} ${authBlocker.code} — skipping run (${authBlocker.repair})\n`);
1947
+ try {
1948
+ fs.closeSync(stdoutFd);
1915
1949
  }
1950
+ catch { /* already closed */ }
1951
+ meta.readiness = authBlocker;
1952
+ finalizeRunMeta(meta, 'blocked', null, { errorMessage: `blocked: ${authBlocker.code} — ${authBlocker.message}` });
1953
+ writeRunMeta(meta);
1954
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1955
+ timer.end({ status: 'blocked', runId, error: authBlocker.code });
1956
+ return meta;
1916
1957
  }
1917
1958
  const child = spawn(cmd[0], cmd.slice(1), {
1918
1959
  stdio: ['ignore', stdoutFd, stdoutFd],
@@ -2,9 +2,9 @@
2
2
  * Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
3
3
  *
4
4
  * On a headed box (personal/desktop) each tick pushes the local identity-keyed
5
- * Claude usage rows to worker peers that cannot read usage themselves. A no-op on
6
- * a worker/unmarked box or when the local cache is empty — the driver
7
- * ({@link syncFleetUsageSnapshots}) gates on the self role, so this service is
5
+ * Claude usage rows to worker peers that cannot read usage themselves. On a
6
+ * worker whose cache is empty or stale, the same tick pulls those rows from the
7
+ * primary headed device. Both drivers gate on the self role, so this service is
8
8
  * safe to register everywhere. Single-executor per destination: each daemon only
9
9
  * writes the DESTINATION's own cache, and the merge is newest-wins + idempotent.
10
10
  */
@@ -2,9 +2,9 @@
2
2
  * Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
3
3
  *
4
4
  * On a headed box (personal/desktop) each tick pushes the local identity-keyed
5
- * Claude usage rows to worker peers that cannot read usage themselves. A no-op on
6
- * a worker/unmarked box or when the local cache is empty — the driver
7
- * ({@link syncFleetUsageSnapshots}) gates on the self role, so this service is
5
+ * Claude usage rows to worker peers that cannot read usage themselves. On a
6
+ * worker whose cache is empty or stale, the same tick pulls those rows from the
7
+ * primary headed device. Both drivers gate on the self role, so this service is
8
8
  * safe to register everywhere. Single-executor per destination: each daemon only
9
9
  * writes the DESTINATION's own cache, and the merge is newest-wins + idempotent.
10
10
  */
@@ -24,13 +24,19 @@ export class UsageSyncService extends BasePeriodicService {
24
24
  // Nothing to release — the supervisor's timer teardown is the only cleanup.
25
25
  }
26
26
  async onTick(ctx) {
27
- const { syncFleetUsageSnapshots } = await import('../accounting/usage-sync.js');
28
- const result = syncFleetUsageSnapshots();
29
- if (result.pushed.length > 0) {
30
- ctx.log('INFO', `usage-sync: pushed usage to ${result.pushed.join(', ')}`);
27
+ const { pullUsageFromPrimary, syncFleetUsageSnapshots } = await import('../accounting/usage-sync.js');
28
+ const pushResult = syncFleetUsageSnapshots();
29
+ if (pushResult.pushed.length > 0) {
30
+ ctx.log('INFO', `usage-sync: pushed usage to ${pushResult.pushed.join(', ')}`);
31
31
  }
32
- for (const err of result.errors) {
32
+ for (const err of pushResult.errors) {
33
33
  ctx.log('WARN', `usage-sync: ${err.device}: ${err.message}`);
34
34
  }
35
+ const pullResult = pullUsageFromPrimary();
36
+ if (pullResult.pulledFrom) {
37
+ ctx.log('INFO', `usage-sync: pulled usage from ${pullResult.pulledFrom}; merged ${pullResult.merged} row(s)`);
38
+ }
39
+ if (pullResult.error)
40
+ ctx.log('WARN', `usage-sync: pull: ${pullResult.error}`);
35
41
  }
36
42
  }
@@ -100,7 +100,7 @@ export const DAEMON_SERVICES = [
100
100
  {
101
101
  id: 'usage-sync',
102
102
  title: 'Usage snapshot sync',
103
- description: 'From a personal/desktop box, pushes the per-account usage snapshot to worker peers that cannot read it themselves.',
103
+ description: 'Bidirectional per-account usage sync: a headed personal/desktop box pushes its snapshot to worker peers, and a worker with a stale cache pulls from the primary — so workers that cannot read the endpoint themselves still route by real capacity.',
104
104
  },
105
105
  ];
106
106
  /** Stable order of service IDs. */
@@ -47,16 +47,25 @@ export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]):
47
47
  */
48
48
  export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
49
49
  /**
50
- * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
51
- * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
52
- * with spaces (POSIX) or base64-encodes it for PowerShell, so a
53
- * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
54
- * `$env:VAR='1'; …` on PowerShell.
50
+ * Prefix a remote command with the provenance the far-side browser needs: the
51
+ * `AGENTS_FLEET_REMOTE=1` consent marker AND the caller's resolved actor
52
+ * (`AGENTS_ACTOR*`/`GIT_*`). Without the actor, a task created over
53
+ * `agents ssh <host> 'agents browser …'` is stamped `UNRESOLVED@<host>` because
54
+ * the peer re-resolves identity from its own (empty) env — the same RUSH-2028
55
+ * gap the `--device` dispatch already closes via `withActorEnv`, applied here to
56
+ * the `agents ssh` browser-drive seam so ownership is honest across BOTH paths.
55
57
  *
56
- * Already-marked argv (the `--device` fan-out stamps this before
57
- * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
58
+ * `wrapRemoteCommand` joins the argv with spaces (POSIX) or base64-encodes it for
59
+ * PowerShell, so a shell-appropriate prelude rides through both: `env VAR=v …` on
60
+ * POSIX, `$env:VAR='v'; …` on PowerShell. The marker stays the FIRST token so the
61
+ * consent gate and the idempotency guard below both still key on it.
62
+ *
63
+ * `provenanceEnv` is injectable so tests can pin a deterministic actor; it
64
+ * defaults to the live resolved actor. Already-marked argv (the `--device`
65
+ * fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
66
+ * so nothing is doubled.
58
67
  */
59
- export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>): string[];
68
+ export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>, provenanceEnv?: Record<string, string>): string[];
60
69
  /**
61
70
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
62
71
  * mirrored project directory, falling back to the remote home when that
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { resolveActor, actorEnv } from '../actor.js';
19
20
  import { getCliLaunch } from '../cli-entry.js';
20
21
  import { encodePwshBase64 } from '../pwsh.js';
21
22
  import { homeRemainder, remoteCdPrefix } from '../project-root.js';
@@ -104,24 +105,40 @@ export function isAgentsBrowserDrive(cmd) {
104
105
  return (bin === 'agents' || bin === 'ag') && tokens[1] === 'browser';
105
106
  }
106
107
  /**
107
- * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
108
- * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
109
- * with spaces (POSIX) or base64-encodes it for PowerShell, so a
110
- * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
111
- * `$env:VAR='1'; …` on PowerShell.
108
+ * Prefix a remote command with the provenance the far-side browser needs: the
109
+ * `AGENTS_FLEET_REMOTE=1` consent marker AND the caller's resolved actor
110
+ * (`AGENTS_ACTOR*`/`GIT_*`). Without the actor, a task created over
111
+ * `agents ssh <host> 'agents browser …'` is stamped `UNRESOLVED@<host>` because
112
+ * the peer re-resolves identity from its own (empty) env — the same RUSH-2028
113
+ * gap the `--device` dispatch already closes via `withActorEnv`, applied here to
114
+ * the `agents ssh` browser-drive seam so ownership is honest across BOTH paths.
112
115
  *
113
- * Already-marked argv (the `--device` fan-out stamps this before
114
- * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
116
+ * `wrapRemoteCommand` joins the argv with spaces (POSIX) or base64-encodes it for
117
+ * PowerShell, so a shell-appropriate prelude rides through both: `env VAR=v …` on
118
+ * POSIX, `$env:VAR='v'; …` on PowerShell. The marker stays the FIRST token so the
119
+ * consent gate and the idempotency guard below both still key on it.
120
+ *
121
+ * `provenanceEnv` is injectable so tests can pin a deterministic actor; it
122
+ * defaults to the live resolved actor. Already-marked argv (the `--device`
123
+ * fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
124
+ * so nothing is doubled.
115
125
  */
116
- export function markFleetRemote(cmd, device) {
126
+ export function markFleetRemote(cmd, device, provenanceEnv = actorEnv(resolveActor())) {
117
127
  if (device.shell === 'powershell') {
118
- return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`
119
- ? cmd
120
- : [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd];
128
+ // Exact-match guard, symmetric with the POSIX branch below: the marker token
129
+ // is always this literal, so `startsWith` would only loosen it for no gain.
130
+ if (cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`)
131
+ return cmd;
132
+ const prelude = [
133
+ `$env:AGENTS_FLEET_REMOTE='1';`,
134
+ ...Object.entries(provenanceEnv).map(([k, v]) => `$env:${k}='${v.replace(/'/g, "''")}';`),
135
+ ];
136
+ return [...prelude, ...cmd];
121
137
  }
122
- return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1'
123
- ? cmd
124
- : ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
138
+ if (cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1')
139
+ return cmd;
140
+ const actorTokens = Object.entries(provenanceEnv).map(([k, v]) => shellQuote(`${k}=${v}`));
141
+ return ['env', 'AGENTS_FLEET_REMOTE=1', ...actorTokens, ...cmd];
125
142
  }
126
143
  /**
127
144
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a