@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.
- package/CHANGELOG.md +30 -0
- package/README.md +14 -6
- package/dist/bootstrap.js +3 -0
- package/dist/commands/exec.js +31 -21
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +3 -0
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/share.js +43 -14
- package/dist/commands/ssh.js +205 -2
- package/dist/lib/accounting/usage.d.ts +7 -2
- package/dist/lib/accounting/usage.js +142 -10
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +4 -0
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- package/dist/lib/monitors/engine.js +18 -0
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/run-trace-sync.d.ts +15 -0
- package/dist/lib/run-trace-sync.js +43 -21
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/db.d.ts +12 -1
- package/dist/lib/session/db.js +20 -1
- package/dist/lib/session/discover.js +81 -1
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +7 -1
- package/dist/lib/session/prompt.js +12 -2
- package/dist/lib/session/recovery.d.ts +21 -12
- package/dist/lib/session/recovery.js +29 -11
- package/dist/lib/session/remote/watch.js +5 -2
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +13 -3
- package/dist/lib/share/publish.js +19 -15
- package/dist/lib/share/worker-template.js +5 -1
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- 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:
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
2707
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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 —
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 —
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
16
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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 ?? (
|
|
264
|
+
timeout: opts.timeoutMs ?? probeBudgetMs(device),
|
|
231
265
|
}, (err, stdout) => {
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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,
|