@phnx-labs/agents-cli 1.22.68 → 1.22.69

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 (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +14 -6
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/exec.js +31 -21
  5. package/dist/commands/feed.js +20 -7
  6. package/dist/commands/monitors.js +3 -0
  7. package/dist/commands/projects.d.ts +26 -6
  8. package/dist/commands/projects.js +55 -22
  9. package/dist/commands/send.js +29 -2
  10. package/dist/commands/sessions-inject.d.ts +58 -0
  11. package/dist/commands/sessions-inject.js +143 -7
  12. package/dist/commands/sessions-picker.js +1 -0
  13. package/dist/commands/share.js +43 -14
  14. package/dist/commands/ssh.js +205 -2
  15. package/dist/lib/accounting/usage.d.ts +7 -2
  16. package/dist/lib/accounting/usage.js +142 -10
  17. package/dist/lib/boot-profile.d.ts +14 -0
  18. package/dist/lib/boot-profile.js +66 -0
  19. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  20. package/dist/lib/channels/providers/desktop.js +5 -4
  21. package/dist/lib/claude-account-token.js +108 -4
  22. package/dist/lib/devices/health.d.ts +38 -2
  23. package/dist/lib/devices/health.js +43 -5
  24. package/dist/lib/devices/worker-pick.d.ts +1 -1
  25. package/dist/lib/devices/worker-pick.js +4 -1
  26. package/dist/lib/exec.js +4 -0
  27. package/dist/lib/feed-broadcast.d.ts +64 -5
  28. package/dist/lib/feed-broadcast.js +124 -22
  29. package/dist/lib/monitors/engine.js +18 -0
  30. package/dist/lib/monitors/sources/command.js +13 -3
  31. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  32. package/dist/lib/monitors/sources/failure.js +52 -0
  33. package/dist/lib/monitors/sources/types.d.ts +9 -0
  34. package/dist/lib/owner-message.d.ts +12 -0
  35. package/dist/lib/owner-message.js +44 -0
  36. package/dist/lib/run-trace-sync.d.ts +15 -0
  37. package/dist/lib/run-trace-sync.js +43 -21
  38. package/dist/lib/secrets/filestore.d.ts +4 -0
  39. package/dist/lib/secrets/filestore.js +164 -3
  40. package/dist/lib/session/active.d.ts +10 -0
  41. package/dist/lib/session/active.js +3 -0
  42. package/dist/lib/session/db.d.ts +12 -1
  43. package/dist/lib/session/db.js +20 -1
  44. package/dist/lib/session/discover.js +81 -1
  45. package/dist/lib/session/linear.d.ts +13 -0
  46. package/dist/lib/session/linear.js +44 -0
  47. package/dist/lib/session/live-metadata.js +1 -0
  48. package/dist/lib/session/parse.js +2 -3
  49. package/dist/lib/session/prompt.d.ts +7 -1
  50. package/dist/lib/session/prompt.js +12 -2
  51. package/dist/lib/session/recovery.d.ts +21 -12
  52. package/dist/lib/session/recovery.js +29 -11
  53. package/dist/lib/session/remote/watch.js +5 -2
  54. package/dist/lib/session/state.js +11 -13
  55. package/dist/lib/share/backend.d.ts +2 -2
  56. package/dist/lib/share/backend.js +20 -9
  57. package/dist/lib/share/delete.d.ts +5 -1
  58. package/dist/lib/share/delete.js +7 -2
  59. package/dist/lib/share/http-error.d.ts +52 -0
  60. package/dist/lib/share/http-error.js +65 -0
  61. package/dist/lib/share/publish.d.ts +13 -3
  62. package/dist/lib/share/publish.js +19 -15
  63. package/dist/lib/share/worker-template.js +5 -1
  64. package/dist/lib/smart-launch.js +27 -4
  65. package/dist/lib/storage/index.d.ts +14 -0
  66. package/dist/lib/storage/index.js +14 -0
  67. package/dist/lib/storage/selection.d.ts +48 -0
  68. package/dist/lib/storage/selection.js +39 -0
  69. package/dist/lib/storage/visibility.d.ts +82 -0
  70. package/dist/lib/storage/visibility.js +99 -0
  71. package/dist/lib/teams/agents.js +3 -1
  72. package/dist/lib/teams/placement-probe.js +1 -0
  73. package/dist/lib/teams/scheduler.d.ts +8 -1
  74. package/dist/lib/teams/scheduler.js +4 -1
  75. package/dist/lib/traces/backend.js +13 -2
  76. package/dist/lib/worktree/held.d.ts +166 -0
  77. package/dist/lib/worktree/held.js +368 -0
  78. package/package.json +2 -2
@@ -1900,6 +1900,17 @@ function writeClaudeUsageCacheFile(cache, cachePath) {
1900
1900
  }
1901
1901
  /** Convert a live UsageSnapshot to its JSON-serializable cached form. */
1902
1902
  function serializeClaudeUsageSnapshot(snapshot) {
1903
+ // Persist the union of fresh `windows` and last-known `staleWindows`.
1904
+ // `deserializeClaudeUsageSnapshot` re-runs the freshness gate on read and
1905
+ // re-partitions the serialized windows into fresh vs. stale, so what matters
1906
+ // is that every last-known reading reaches disk. Claude's collector returns
1907
+ // raw windows (no `staleWindows`) and relies on that read-side partition. But
1908
+ // Grok's collector pre-partitions in the fetch, moving an ended-period reading
1909
+ // onto `staleWindows` — serializing only `windows` dropped it, so the very
1910
+ // number a daemon `--refresh` just captured was gone from the next cached
1911
+ // `agents view grok`, which rendered the plan alone (no bar). Include the
1912
+ // stale windows here so the round-trip preserves them for any collector.
1913
+ const persistedWindows = [...snapshot.windows, ...(snapshot.staleWindows ?? [])];
1903
1914
  return {
1904
1915
  capturedAt: snapshot.capturedAt?.toISOString() || null,
1905
1916
  plan: snapshot.plan ?? null,
@@ -1910,7 +1921,7 @@ function serializeClaudeUsageSnapshot(snapshot) {
1910
1921
  resetsAt: snapshot.unavailable.resetsAt?.toISOString(),
1911
1922
  }
1912
1923
  : undefined,
1913
- windows: snapshot.windows.map((window) => ({
1924
+ windows: persistedWindows.map((window) => ({
1914
1925
  key: window.key,
1915
1926
  label: window.label,
1916
1927
  shortLabel: window.shortLabel,
@@ -2400,17 +2411,131 @@ function safeStatSync(filePath) {
2400
2411
  return null;
2401
2412
  }
2402
2413
  }
2414
+ /**
2415
+ * Resolve the Grok billing log to read usage from.
2416
+ *
2417
+ * `agents view grok` reads usage per INSTALLED VERSION, passing each version's
2418
+ * isolated home (`~/.agents/.history/versions/grok/<ver>`). Grok writes
2419
+ * `unified.jsonl` only to the shared real home `~/.grok/logs/unified.jsonl`
2420
+ * even though GROK_HOME isolates auth/config/models per version. A per-version
2421
+ * log, if one ever appears, still wins; otherwise we return the shared path
2422
+ * marked `shared: true` so the caller can attribute it to at most one identity.
2423
+ * Grok accounts are version-scoped (`NATIVE_ACCOUNT_CAPABILITIES.grok.scope ===
2424
+ * 'version'`) — the shared file has no owner, so it must not be stamped onto
2425
+ * every version home.
2426
+ */
2427
+ function resolveGrokBillingLogPath(home) {
2428
+ const rel = ['.grok', 'logs', 'unified.jsonl'];
2429
+ const perVersion = path.join(home || os.homedir(), ...rel);
2430
+ try {
2431
+ if (fs.existsSync(perVersion))
2432
+ return { logPath: perVersion, shared: false };
2433
+ }
2434
+ catch { /* unreadable */ }
2435
+ // `AGENTS_REAL_HOME` is the seam every version-home consumer honors: a
2436
+ // daemon/service-manager child's HOME can be baked to something other than
2437
+ // the account's real home, so os.homedir() alone is not a reliable stand-in.
2438
+ const shared = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...rel);
2439
+ if (shared !== perVersion) {
2440
+ try {
2441
+ if (fs.existsSync(shared))
2442
+ return { logPath: shared, shared: true };
2443
+ }
2444
+ catch { /* unreadable */ }
2445
+ }
2446
+ return null;
2447
+ }
2448
+ /** This home's own `.grok/auth.json` only — never the shared-home fallback. */
2449
+ function readGrokAuthIdentity(home) {
2450
+ const authPath = path.join(home, '.grok', 'auth.json');
2451
+ try {
2452
+ if (!fs.existsSync(authPath))
2453
+ return null;
2454
+ const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
2455
+ const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
2456
+ .filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
2457
+ const account = records
2458
+ .filter((r) => typeof r.refresh_token === 'string' || typeof r.email === 'string' || typeof r.user_id === 'string')
2459
+ .sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
2460
+ if (!account)
2461
+ return null;
2462
+ const userRaw = account.user_id ?? account.principal_id;
2463
+ const userId = typeof userRaw === 'string' && userRaw.trim() ? userRaw.trim() : null;
2464
+ const email = typeof account.email === 'string' && account.email.trim()
2465
+ ? account.email.trim().toLowerCase()
2466
+ : null;
2467
+ if (!userId && !email)
2468
+ return null;
2469
+ return { userId, email };
2470
+ }
2471
+ catch {
2472
+ return null;
2473
+ }
2474
+ }
2475
+ function grokIdentitiesMatch(a, b) {
2476
+ if (!a || !b)
2477
+ return false;
2478
+ if (a.userId && b.userId)
2479
+ return a.userId === b.userId;
2480
+ if (a.email && b.email)
2481
+ return a.email === b.email;
2482
+ return false;
2483
+ }
2484
+ function grokIdentityFromBillingPayload(parsed) {
2485
+ const ctx = parsed.ctx && typeof parsed.ctx === 'object' && !Array.isArray(parsed.ctx)
2486
+ ? parsed.ctx
2487
+ : null;
2488
+ const config = ctx?.config && typeof ctx.config === 'object' && !Array.isArray(ctx.config)
2489
+ ? ctx.config
2490
+ : null;
2491
+ const pick = (...cands) => {
2492
+ for (const c of cands) {
2493
+ if (typeof c === 'string' && c.trim())
2494
+ return c.trim();
2495
+ }
2496
+ return null;
2497
+ };
2498
+ const userId = pick(ctx?.user_id, ctx?.userId, ctx?.principal_id, config?.user_id, parsed.user_id);
2499
+ const emailRaw = pick(ctx?.email, config?.email, parsed.email);
2500
+ if (!userId && !emailRaw)
2501
+ return null;
2502
+ return { userId, email: emailRaw ? emailRaw.toLowerCase() : null };
2503
+ }
2504
+ function sameHomePath(a, b) {
2505
+ return (safeRealpathSync(a) ?? path.resolve(a)) === (safeRealpathSync(b) ?? path.resolve(b));
2506
+ }
2507
+ /**
2508
+ * Whether the shared `~/.grok` billing log may be attached to this home.
2509
+ * Grok logins are per version home; the shared last line is one account's
2510
+ * meter. Fail loud: never copy it onto every installed identity.
2511
+ */
2512
+ function sharedGrokLogAppliesToHome(home, match) {
2513
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
2514
+ const requestedHome = home || os.homedir();
2515
+ const thisId = readGrokAuthIdentity(requestedHome);
2516
+ if (match.identity) {
2517
+ return grokIdentitiesMatch(match.identity, thisId);
2518
+ }
2519
+ // No identity on the line (the live Grok shape). Attribute the reading to
2520
+ // exactly one canonical identity: the real home itself, or the version home
2521
+ // whose own auth.json matches the shared `~/.grok/auth.json`.
2522
+ if (sameHomePath(requestedHome, realHome))
2523
+ return true;
2524
+ return grokIdentitiesMatch(thisId, readGrokAuthIdentity(realHome));
2525
+ }
2403
2526
  /** Parse the latest billing info from Grok's unified log. */
2404
2527
  async function getGrokUsageInfo(options) {
2405
2528
  try {
2406
- const base = options?.home || os.homedir();
2407
- const logPath = path.join(base, '.grok', 'logs', 'unified.jsonl');
2529
+ const resolved = resolveGrokBillingLogPath(options?.home);
2408
2530
  // No log yet: a benign "nothing recorded here", not a failure (RUSH-3040).
2409
- if (!fs.existsSync(logPath))
2531
+ if (!resolved)
2410
2532
  return usageNoRecentUsageInfo();
2411
- const match = await readLatestGrokBilling(logPath);
2533
+ const match = await readLatestGrokBilling(resolved.logPath);
2412
2534
  if (!match)
2413
2535
  return usageNoRecentUsageInfo();
2536
+ if (resolved.shared && !sharedGrokLogAppliesToHome(options?.home, match)) {
2537
+ return usageNoRecentUsageInfo();
2538
+ }
2414
2539
  // Grok has no live usage API (`network: false`) — bars are last-seen from
2415
2540
  // this machine's unified.jsonl only. Drop windows whose billing period has
2416
2541
  // already ended so a stale 100% does not paint "rate-limited" after reset,
@@ -2703,10 +2828,16 @@ async function readLatestGrokBilling(filePath) {
2703
2828
  return;
2704
2829
  try {
2705
2830
  const parsed = JSON.parse(line);
2706
- if (parsed.msg === 'billing: fetched credits config' && parsed.ctx?.config) {
2707
- const config = parsed.ctx.config;
2831
+ const ctx = parsed.ctx && typeof parsed.ctx === 'object' && !Array.isArray(parsed.ctx)
2832
+ ? parsed.ctx
2833
+ : null;
2834
+ if (parsed.msg === 'billing: fetched credits config' && ctx?.config) {
2835
+ const config = ctx.config;
2708
2836
  const windows = [];
2709
- if (config.currentPeriod?.end && typeof config.creditUsagePercent === 'number') {
2837
+ const currentPeriod = config.currentPeriod && typeof config.currentPeriod === 'object'
2838
+ ? config.currentPeriod
2839
+ : null;
2840
+ if (currentPeriod?.end && typeof config.creditUsagePercent === 'number') {
2710
2841
  // `creditUsagePercent` is Grok's weekly credit consumption (0-100);
2711
2842
  // the billing period's `end` is when that window resets.
2712
2843
  // Do NOT coerce a missing percent to 0 — a new period often lands a
@@ -2718,14 +2849,15 @@ async function readLatestGrokBilling(filePath) {
2718
2849
  label: 'Current week',
2719
2850
  shortLabel: 'W',
2720
2851
  usedPercent: Math.max(0, Math.min(100, rawPercent)),
2721
- resetsAt: parseDateValue(config.currentPeriod.end),
2852
+ resetsAt: parseDateValue(currentPeriod.end),
2722
2853
  windowMinutes: inferWindowMinutes('week'),
2723
2854
  });
2724
2855
  }
2725
2856
  latest = {
2726
2857
  capturedAt: parseDateValue(parsed.ts),
2727
- subscriptionTier: parsed.ctx.subscriptionTier || null,
2858
+ subscriptionTier: typeof ctx.subscriptionTier === 'string' ? ctx.subscriptionTier : null,
2728
2859
  windows,
2860
+ identity: grokIdentityFromBillingPayload(parsed),
2729
2861
  };
2730
2862
  }
2731
2863
  }
@@ -0,0 +1,14 @@
1
+ /** True when `AGENTS_PROFILE_BOOT` is set — callers can skip building label strings. */
2
+ export declare function bootProfileEnabled(): boolean;
3
+ /**
4
+ * Record a named stage boundary. No-op unless `AGENTS_PROFILE_BOOT` is set, so
5
+ * this is free to call unconditionally on the launch path.
6
+ */
7
+ export declare function bootMark(label: string): void;
8
+ /**
9
+ * Print the collected timeline to stderr, once. Called right before the harness
10
+ * child is spawned (the end of the pre-exec window) and again as an
11
+ * `process.on('exit')` backstop for launch paths that error out before spawn.
12
+ * `reason` labels the final boundary (e.g. `spawn`, `exit`).
13
+ */
14
+ export declare function flushBootProfile(reason: string): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Boot-time profiler for the `agents run` pre-exec phase (PHNX-3585).
3
+ *
4
+ * The AGI EXT "New Claude" boot spends the whole `agents run` wrapper cost
5
+ * BEFORE the harness prints anything — version resolution, account rotation,
6
+ * config sync, login preflight. This module makes that window measurable
7
+ * without a debugger: gate it on `AGENTS_PROFILE_BOOT=1` and the run path
8
+ * stamps named marks, then flushes a per-stage timeline to stderr the instant
9
+ * before the child is spawned (the moment the wrapper's work ends).
10
+ *
11
+ * All marks are `performance.now()` values, which Node measures from
12
+ * `performance.timeOrigin` ≈ process start — so a mark's absolute value is
13
+ * "ms since the `agents` process began", and consecutive marks give the cost
14
+ * of each stage. When the env flag is off, every function here is a couple of
15
+ * cheap branches and pushes nothing, so it is safe to leave wired on the hot
16
+ * path (the committed `scripts/bench-boot.sh` benchmark drives it).
17
+ */
18
+ import { performance } from 'node:perf_hooks';
19
+ const ENABLED = process.env.AGENTS_PROFILE_BOOT === '1' || process.env.AGENTS_PROFILE_BOOT === 'true';
20
+ const marks = [];
21
+ let flushed = false;
22
+ /** True when `AGENTS_PROFILE_BOOT` is set — callers can skip building label strings. */
23
+ export function bootProfileEnabled() {
24
+ return ENABLED;
25
+ }
26
+ /**
27
+ * Record a named stage boundary. No-op unless `AGENTS_PROFILE_BOOT` is set, so
28
+ * this is free to call unconditionally on the launch path.
29
+ */
30
+ export function bootMark(label) {
31
+ if (!ENABLED)
32
+ return;
33
+ marks.push({ label, at: performance.now() });
34
+ }
35
+ /**
36
+ * Print the collected timeline to stderr, once. Called right before the harness
37
+ * child is spawned (the end of the pre-exec window) and again as an
38
+ * `process.on('exit')` backstop for launch paths that error out before spawn.
39
+ * `reason` labels the final boundary (e.g. `spawn`, `exit`).
40
+ */
41
+ export function flushBootProfile(reason) {
42
+ if (!ENABLED || flushed)
43
+ return;
44
+ flushed = true;
45
+ bootMark(reason);
46
+ if (marks.length === 0)
47
+ return;
48
+ const start = 0; // process start
49
+ const end = marks[marks.length - 1].at;
50
+ const width = Math.max(...marks.map((m) => m.label.length));
51
+ const lines = [];
52
+ lines.push(`[boot-profile] pre-exec timeline (total ${(end - start).toFixed(1)}ms since process start)`);
53
+ let prev = start;
54
+ for (const m of marks) {
55
+ const delta = m.at - prev;
56
+ prev = m.at;
57
+ lines.push(` ${m.label.padEnd(width)} +${delta.toFixed(1).padStart(7)}ms @${m.at.toFixed(1).padStart(8)}ms`);
58
+ }
59
+ process.stderr.write(lines.join('\n') + '\n');
60
+ }
61
+ // Backstop: a run that exits before reaching the spawn (a login dead-end, a
62
+ // missing install) still emits whatever stages it reached, so the profile is
63
+ // never silently empty.
64
+ if (ENABLED) {
65
+ process.on('exit', () => flushBootProfile('exit'));
66
+ }
@@ -2,10 +2,11 @@ import type { ChannelProvider } from '../registry.js';
2
2
  /**
3
3
  * Split one message into the notification's title and body.
4
4
  *
5
- * A broadcast sink hands us `composeBroadcastMessage`'s shape — `<project> · <text>`
6
- * with any link on a second line — so honouring the newline puts the human
7
- * sentence in the title and the URL underneath, which is how the existing
8
- * daemon notifications already read.
5
+ * A broadcast sink hands us `composeBroadcastMessage`'s shape — a title line, a
6
+ * blank line, then the body (and a `Sent from …` footer) — so honouring the first
7
+ * newline puts the scannable head in the title and the rest in the body, which is
8
+ * how the existing daemon notifications already read. (Desktop is a plain sink, so
9
+ * the message carries no URLs to split off.)
9
10
  *
10
11
  * Notification banners show roughly two lines before an ellipsis, so a long
11
12
  * single-line message is split at the title boundary rather than truncated away:
@@ -28,10 +28,11 @@ const TITLE_MAX = 64;
28
28
  /**
29
29
  * Split one message into the notification's title and body.
30
30
  *
31
- * A broadcast sink hands us `composeBroadcastMessage`'s shape — `<project> · <text>`
32
- * with any link on a second line — so honouring the newline puts the human
33
- * sentence in the title and the URL underneath, which is how the existing
34
- * daemon notifications already read.
31
+ * A broadcast sink hands us `composeBroadcastMessage`'s shape — a title line, a
32
+ * blank line, then the body (and a `Sent from …` footer) — so honouring the first
33
+ * newline puts the scannable head in the title and the rest in the body, which is
34
+ * how the existing daemon notifications already read. (Desktop is a plain sink, so
35
+ * the message carries no URLs to split off.)
35
36
  *
36
37
  * Notification banners show roughly two lines before an ellipsis, so a long
37
38
  * single-line message is split at the title boundary rather than truncated away:
@@ -79,11 +79,100 @@ export function resolveClaudeSetupToken(home) {
79
79
  // Require a known account (email) up front: without it we cannot key a
80
80
  // per-account token, and we must NOT fall back to a bare shared key that
81
81
  // would misapply one account's setup-token to another.
82
- const email = readClaudeAccountEmail(home);
82
+ const email = readClaudeAccountEmail(home)
83
+ // Self-heal (PHNX-3660): a home provisioned before seed-on-attach carries an
84
+ // `.oauth_token` but no identity. Recover the email from the bundle and
85
+ // write it back, so the home converges instead of needing a re-attach.
86
+ // Explicit-home only: with no home the probe targets the operator's real
87
+ // ~/.claude.json, which a library read must never rewrite.
88
+ ?? (home ? discoverClaudeAccountEmailFromOauthToken(home) : null);
83
89
  if (!email)
84
90
  return null;
85
91
  return resolveClaudeSetupTokenForEmail(email, home ?? os.homedir());
86
92
  }
93
+ /** Negative/positive discovery cache, keyed by home + .oauth_token fingerprint (SHOULD-2). */
94
+ const discoveryCache = new Map();
95
+ /**
96
+ * Recover a home's account email from its `.claude/.oauth_token` by matching
97
+ * the token VALUE against the `auth` bundle (the slug encodes the email, and
98
+ * the re-encode check makes the decode lossless or fail). On a match the
99
+ * identity is written back via {@link seedClaudeWorkerHomeIdentity}, so this
100
+ * runs at most once per home. Returns null — and writes nothing — when the
101
+ * file is missing, malformed, or matches no bundle key: a token that no longer
102
+ * exists in the bundle must not resurrect an account mapping. A no-match
103
+ * result is cached against the token file's fingerprint so a rotated-out home
104
+ * does not re-decrypt the bundle on every probe.
105
+ */
106
+ function discoverClaudeAccountEmailFromOauthToken(home) {
107
+ try {
108
+ const tokenPath = path.join(home, '.claude', '.oauth_token');
109
+ const fingerprint = credentialFingerprint(tokenPath);
110
+ if (fingerprint === 'missing')
111
+ return null;
112
+ const cached = discoveryCache.get(home);
113
+ if (cached && cached.fingerprint === fingerprint)
114
+ return cached.email;
115
+ const email = discoverEmailUncached(home, tokenPath);
116
+ discoveryCache.set(home, { fingerprint, email });
117
+ return email;
118
+ }
119
+ catch (err) {
120
+ if (err instanceof ReservedBundleWrongBackendError)
121
+ throw err;
122
+ return null;
123
+ }
124
+ }
125
+ function discoverEmailUncached(home, tokenPath) {
126
+ let token;
127
+ try {
128
+ token = fs.readFileSync(tokenPath, 'utf-8').trim();
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ if (!isValidClaudeSetupToken(token))
134
+ return null;
135
+ if (!bundleExists(AUTH_BUNDLE))
136
+ return null;
137
+ if (bundleBackend(AUTH_BUNDLE) !== 'file') {
138
+ throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, bundleBackend(AUTH_BUNDLE));
139
+ }
140
+ const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
141
+ for (const [key, value] of Object.entries(env)) {
142
+ if (value.trim() !== token)
143
+ continue;
144
+ const email = emailFromTokenKey(key);
145
+ if (!email)
146
+ continue;
147
+ seedClaudeWorkerHomeIdentity(home, email);
148
+ return email;
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * Decode the email a `CLAUDE_CODE_OAUTH_TOKEN_<slug>` key encodes — or null
154
+ * when the decode is ambiguous. The mapping collapses every non-alphanumeric
155
+ * to `_`, so a `_` surviving in the decoded address cannot be told apart from
156
+ * a folded `.`/`+`/etc. (`FIRST_DOT_LAST_AT_GMAIL_DOT_COM` decodes to
157
+ * `first_dot_last@gmail.com`, which round-trips — wrongly — under the bare
158
+ * re-encode check; review BLOCKER 1). Only a fully unambiguous decode — local
159
+ * and domain pure `[a-z0-9]`, dots in the domain only — is trusted.
160
+ */
161
+ function emailFromTokenKey(key) {
162
+ const prefix = 'CLAUDE_CODE_OAUTH_TOKEN_';
163
+ if (!key.startsWith(prefix))
164
+ return null;
165
+ const slug = key.slice(prefix.length);
166
+ const [local, domain, ...rest] = slug.split('_AT_');
167
+ if (!local || !domain || rest.length > 0)
168
+ return null;
169
+ const email = `${local}@${domain.replace(/_DOT_/g, '.')}`.toLowerCase();
170
+ if (!/^[a-z0-9]+@[a-z0-9.]+$/.test(email))
171
+ return null;
172
+ if (claudeAccountTokenKey(email) !== key)
173
+ return null;
174
+ return email;
175
+ }
87
176
  /**
88
177
  * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
89
178
  * any version home's `.claude.json`. This is what lets `agents accounts attach`
@@ -167,14 +256,29 @@ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
167
256
  try {
168
257
  doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
169
258
  }
170
- catch {
171
- // Missing or unreadable at this location — write a fresh minimal document.
259
+ catch (err) {
260
+ // Missing file → write a fresh minimal document. A file that EXISTS but
261
+ // does not parse is being concurrently rewritten by Claude Code itself —
262
+ // skip it rather than overwrite a live config with the minimal doc.
263
+ if (err.code !== 'ENOENT')
264
+ continue;
172
265
  }
173
266
  const existing = (doc.oauthAccount && typeof doc.oauthAccount === 'object'
174
267
  ? doc.oauthAccount
175
268
  : {});
176
269
  doc.oauthAccount = { ...existing, emailAddress: trimmed };
177
270
  fs.mkdirSync(path.dirname(p), { recursive: true });
178
- fs.writeFileSync(p, JSON.stringify(doc));
271
+ // Temp-write + rename: a reader mid-write never sees a truncated doc.
272
+ const tmp = `${p}.agents-${process.pid}.tmp`;
273
+ try {
274
+ fs.writeFileSync(tmp, JSON.stringify(doc));
275
+ fs.renameSync(tmp, p);
276
+ }
277
+ catch {
278
+ try {
279
+ fs.rmSync(tmp, { force: true });
280
+ }
281
+ catch { /* best effort */ }
282
+ }
179
283
  }
180
284
  }
@@ -12,9 +12,37 @@
12
12
  * separate copy on purpose: the CLI does not import across packages.
13
13
  */
14
14
  import type { DeviceProfile } from './registry.js';
15
- /** Default per-device probe budget. Short enough that the list never hangs on a
16
- * wedged box, long enough for a cold relayed SSH handshake. */
15
+ /** Default per-device probe budget, for a device reachable over a DIRECT
16
+ * Tailscale path. Short enough that the list never hangs on a wedged box. */
17
17
  export declare const PROBE_TIMEOUT_MS = 2500;
18
+ /**
19
+ * Probe budget for a device whose last handshake was DERP-relayed
20
+ * ({@link DeviceTailscale.direct} === false).
21
+ *
22
+ * This constant used to not exist: 2.5s was applied to every device and its
23
+ * docstring claimed to be "long enough for a cold relayed SSH handshake". That
24
+ * was false, and on a fleet with no direct paths it broke `--device auto`
25
+ * outright (PHNX-3682). Measured on a 9-box relayed fleet, probed in parallel
26
+ * with cold paths: 1686/1805/1914/2688/2706/2749/3082/5586/6588 ms — six of nine
27
+ * over budget, every one of them healthy (rc=0 within 10s). Warm, the same
28
+ * probes take 512-872ms, which is what made the failure intermittent.
29
+ *
30
+ * A relayed hop pays DERP path setup on top of the TCP+SSH handshake, so it
31
+ * gets the same budget the readiness probe already allows
32
+ * (`READY_PROBE_TIMEOUT_MS`) rather than the direct-path one.
33
+ */
34
+ export declare const RELAYED_PROBE_TIMEOUT_MS = 8000;
35
+ /**
36
+ * The probe budget for one device. A relayed peer gets
37
+ * {@link RELAYED_PROBE_TIMEOUT_MS}; a direct (or unknown-path) peer keeps the
38
+ * tight {@link PROBE_TIMEOUT_MS}. Windows keeps its own larger budget, which
39
+ * already exceeds both.
40
+ *
41
+ * `direct` is only meaningful when a tailscale snapshot exists — a
42
+ * `via:"manual"` device never gets a peer entry, so absence is "unknown path",
43
+ * not "relayed", and must not silently widen every manual device's budget.
44
+ */
45
+ export declare function probeBudgetMs(device: DeviceProfile): number;
18
46
  /** Windows probe budget. The first CIM query of a PowerShell session pays a
19
47
  * "Preparing modules for first use" cost on top of PowerShell startup, which
20
48
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
@@ -35,6 +63,14 @@ export declare function localProbeInvocation(platform: NodeJS.Platform): {
35
63
  export interface DeviceStats {
36
64
  host: string;
37
65
  reachable: boolean;
66
+ /**
67
+ * The probe exceeded its budget rather than being refused or unresolvable.
68
+ * Only meaningful when `reachable` is false — it separates "this box did not
69
+ * answer in time" from "this box actively could not be reached", so callers
70
+ * report a slow link honestly instead of calling a healthy device offline
71
+ * (PHNX-3682).
72
+ */
73
+ timedOut?: boolean;
38
74
  loadAvg1?: number;
39
75
  ncpu?: number;
40
76
  /** Load normalized to core count (the "has room" number): loadAvg1 / ncpu *
@@ -13,9 +13,43 @@
13
13
  */
14
14
  import { execFile } from 'child_process';
15
15
  import { buildSshInvocation, writeAskpassShim } from './connect.js';
16
- /** Default per-device probe budget. Short enough that the list never hangs on a
17
- * wedged box, long enough for a cold relayed SSH handshake. */
16
+ /** Default per-device probe budget, for a device reachable over a DIRECT
17
+ * Tailscale path. Short enough that the list never hangs on a wedged box. */
18
18
  export const PROBE_TIMEOUT_MS = 2_500;
19
+ /**
20
+ * Probe budget for a device whose last handshake was DERP-relayed
21
+ * ({@link DeviceTailscale.direct} === false).
22
+ *
23
+ * This constant used to not exist: 2.5s was applied to every device and its
24
+ * docstring claimed to be "long enough for a cold relayed SSH handshake". That
25
+ * was false, and on a fleet with no direct paths it broke `--device auto`
26
+ * outright (PHNX-3682). Measured on a 9-box relayed fleet, probed in parallel
27
+ * with cold paths: 1686/1805/1914/2688/2706/2749/3082/5586/6588 ms — six of nine
28
+ * over budget, every one of them healthy (rc=0 within 10s). Warm, the same
29
+ * probes take 512-872ms, which is what made the failure intermittent.
30
+ *
31
+ * A relayed hop pays DERP path setup on top of the TCP+SSH handshake, so it
32
+ * gets the same budget the readiness probe already allows
33
+ * (`READY_PROBE_TIMEOUT_MS`) rather than the direct-path one.
34
+ */
35
+ export const RELAYED_PROBE_TIMEOUT_MS = 8_000;
36
+ /**
37
+ * The probe budget for one device. A relayed peer gets
38
+ * {@link RELAYED_PROBE_TIMEOUT_MS}; a direct (or unknown-path) peer keeps the
39
+ * tight {@link PROBE_TIMEOUT_MS}. Windows keeps its own larger budget, which
40
+ * already exceeds both.
41
+ *
42
+ * `direct` is only meaningful when a tailscale snapshot exists — a
43
+ * `via:"manual"` device never gets a peer entry, so absence is "unknown path",
44
+ * not "relayed", and must not silently widen every manual device's budget.
45
+ */
46
+ export function probeBudgetMs(device) {
47
+ if (device.shell === 'powershell')
48
+ return WIN_PROBE_TIMEOUT_MS;
49
+ return device.tailscale && device.tailscale.direct === false
50
+ ? RELAYED_PROBE_TIMEOUT_MS
51
+ : PROBE_TIMEOUT_MS;
52
+ }
19
53
  /** Windows probe budget. The first CIM query of a PowerShell session pays a
20
54
  * "Preparing modules for first use" cost on top of PowerShell startup, which
21
55
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
@@ -227,10 +261,14 @@ export function probeDeviceStats(device, opts = {}) {
227
261
  execFile('ssh', args, {
228
262
  encoding: 'utf-8',
229
263
  env: { ...process.env, ...env },
230
- timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS),
264
+ timeout: opts.timeoutMs ?? probeBudgetMs(device),
231
265
  }, (err, stdout) => {
232
- if (err || !stdout)
233
- return resolve({ host, reachable: false, fetchedAt });
266
+ // execFile kills an over-budget child with a signal; that is a slow link,
267
+ // not an unreachable box, and the two must not read the same downstream.
268
+ if (err || !stdout) {
269
+ const timedOut = Boolean(err && err.killed);
270
+ return resolve(timedOut ? { host, reachable: false, timedOut, fetchedAt } : { host, reachable: false, fetchedAt });
271
+ }
234
272
  resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
235
273
  });
236
274
  });
@@ -2,7 +2,7 @@ import { type DevicePlacementSignal } from '../teams/scheduler.js';
2
2
  /** Why a candidate was dropped, for the fail-loud error message. */
3
3
  export interface WorkerExclusion {
4
4
  device: string;
5
- reason: 'unreachable' | 'overloaded' | 'wrong-platform' | 'interactive';
5
+ reason: 'unreachable' | 'probe timed out' | 'overloaded' | 'wrong-platform' | 'interactive';
6
6
  }
7
7
  export interface WorkerPickPlan {
8
8
  /** The chosen device name. Never the local box unless it is an auto-pool member. */
@@ -79,7 +79,10 @@ export async function resolveWorkerDevice(opts = {}) {
79
79
  const eligible = onPlatform.filter((device) => {
80
80
  const signal = signals.get(device);
81
81
  if (signal?.reachable !== true) {
82
- excluded.push({ device, reason: 'unreachable' });
82
+ // A probe killed for exceeding its budget says nothing about whether the
83
+ // box is up — on a relayed fleet it usually is. Report the two apart so
84
+ // the message points at the link, not at a fleet outage (PHNX-3682).
85
+ excluded.push({ device, reason: signal?.timedOut ? 'probe timed out' : 'unreachable' });
83
86
  return false;
84
87
  }
85
88
  if (signal.headroom === 'loaded') {
package/dist/lib/exec.js CHANGED
@@ -40,6 +40,7 @@ import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js
40
40
  import { resolveConfigVersion } from './harness/exec-config-version.js';
41
41
  import { getAccountInfo } from './agents.js';
42
42
  import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
43
+ import { bootMark, flushBootProfile } from './boot-profile.js';
43
44
  /**
44
45
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
45
46
  *
@@ -1733,6 +1734,7 @@ async function emitRunLaunch(ctx) {
1733
1734
  }
1734
1735
  }
1735
1736
  async function spawnAgent(options) {
1737
+ bootMark('spawn-agent:enter');
1736
1738
  // Assign a known session id up front for agents that accept one, so the
1737
1739
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
1738
1740
  // otherwise the headless `ag sessions --active` path can only guess
@@ -1867,6 +1869,7 @@ async function spawnAgent(options) {
1867
1869
  });
1868
1870
  if (tmuxWrap.kind === 'wrap') {
1869
1871
  timer.mark('startup');
1872
+ flushBootProfile('spawn');
1870
1873
  try {
1871
1874
  const result = await runInTmux(options, executable, args);
1872
1875
  timer.end({ exitCode: result.exitCode, status: result.exitCode === 0 ? 'success' : 'failed' });
@@ -1898,6 +1901,7 @@ async function spawnAgent(options) {
1898
1901
  const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
1899
1902
  const spawnCommand = useShell ? composeWin32CommandLine(executable, args) : executable;
1900
1903
  const spawnArgs = useShell ? [] : args;
1904
+ flushBootProfile('spawn');
1901
1905
  const child = spawn(spawnCommand, spawnArgs, {
1902
1906
  cwd: options.cwd || process.cwd(),
1903
1907
  stdio,