@phnx-labs/agents-cli 1.22.63 → 1.22.65

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 (40) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/commands/accounts.d.ts +8 -7
  3. package/dist/commands/accounts.js +40 -13
  4. package/dist/commands/exec.js +40 -0
  5. package/dist/commands/feed.js +2 -0
  6. package/dist/commands/resume.d.ts +8 -0
  7. package/dist/commands/resume.js +66 -4
  8. package/dist/lib/accounting/account-pool-collect.js +7 -2
  9. package/dist/lib/accounting/account-pool.d.ts +12 -0
  10. package/dist/lib/accounting/account-pool.js +1 -0
  11. package/dist/lib/accounting/rotate.d.ts +57 -8
  12. package/dist/lib/accounting/rotate.js +57 -10
  13. package/dist/lib/accounting/usage.d.ts +12 -0
  14. package/dist/lib/accounting/usage.js +119 -15
  15. package/dist/lib/agent-spec/agents.js +21 -6
  16. package/dist/lib/claude-account-token.d.ts +26 -0
  17. package/dist/lib/claude-account-token.js +65 -11
  18. package/dist/lib/daemon-ticks.js +6 -2
  19. package/dist/lib/event-families.js +1 -1
  20. package/dist/lib/exec.d.ts +66 -1
  21. package/dist/lib/exec.js +104 -8
  22. package/dist/lib/feed/events.d.ts +1 -1
  23. package/dist/lib/feed/events.js +5 -0
  24. package/dist/lib/feed-broadcast.d.ts +2 -0
  25. package/dist/lib/feed-broadcast.js +31 -4
  26. package/dist/lib/harness/adapters/claude.js +33 -27
  27. package/dist/lib/hosts/passthrough.d.ts +44 -0
  28. package/dist/lib/hosts/passthrough.js +66 -8
  29. package/dist/lib/identity/client.d.ts +5 -4
  30. package/dist/lib/identity/client.js +6 -5
  31. package/dist/lib/session/active.d.ts +10 -1
  32. package/dist/lib/session/active.js +8 -0
  33. package/dist/lib/session/digest.js +8 -2
  34. package/dist/lib/session/parse.d.ts +4 -0
  35. package/dist/lib/session/parse.js +16 -11
  36. package/dist/lib/session/state.d.ts +5 -0
  37. package/dist/lib/session/state.js +6 -0
  38. package/dist/lib/usage-refresh.d.ts +100 -1
  39. package/dist/lib/usage-refresh.js +195 -4
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.65
4
+
5
+ - **`agents view` shows the last-known usage number with its age instead of hiding it (PHNX-3453).** When the usage endpoint 429-backs-off an account so a window is not refreshed within its own lifetime (Claude's 5h session window) or a billing period rolls over (Grok's weekly credit window), the view now renders the last reading with a staleness suffix — `S: █▌░░░ 30% · 6h old`, `W: ██▏░░ 42% · stale (period ended 1h ago)` — instead of `S: ┄┄┄┄┄ unavailable` or a numberless `run grok once to refresh usage`. The dropped windows ride a new VIEW-ONLY `UsageSnapshot.staleWindows` field; routing still reads only `windows`, so `isUsageVerified`/`hasStaleUsage`/`deriveUsageStatusFromSnapshot` continue to treat a stale/expired number as unverified and never route on it. Source: `cli/src/lib/accounting/usage.ts`.
6
+
7
+ - **A worker's interactive Claude run no longer lands on the login screen (PHNX-3502).** `claudeAdapter.applyExecConfigEnv` deferred to the per-version native login for EVERY interactive run (`ctx.interactive || headedDevice`), even on a `worker`-role device that has no such login — only a headless run on a worker injected the `auth`-bundle setup-token. A keychain-less worker with no `.credentials.json`/`.oauth_token` therefore got no credential at all on `agents run claude --interactive --device <worker>`, landing on Claude Code's theme-picker/login screen. The condition now keys purely on device role: only a `personal`/`desktop` (headed) device defers to its native login; every worker run, interactive or headless, injects the setup-token. The `credentialPresence` routing gate that feeds `--strategy balanced` is also fixed to stop reading a false-healthy signal: its claude entry now requires the REAL credential (`.credentials.json`/`.oauth_token`, via the same floor `getAccountInfo` applies) rather than the bare presence of `.claude.json` (stale account metadata a version can carry with no usable credential behind it). Relatedly, a provider account folded into the run-candidate pool now derives `signedIn` from its record's `secretPresent` field instead of a hardcoded `true` — keeping that field honest rather than changing today's routing, since `localRegistryRecords` already filters out token-less accounts before the map. Source: `cli/src/lib/harness/adapters/claude.ts`, `cli/src/lib/agent-spec/agents.ts`, `cli/src/lib/accounting/account-pool.ts`, `cli/src/lib/accounting/account-pool-collect.ts`.
8
+
9
+ - **Include clickable ticket links in every feed broadcast (PHNX-3572).** Ticket-backed posts now put the canonical Linear URL in the shared message delivered to private owner destinations and configured Slack channel sinks; repeated URL lines are deduplicated. Source: `cli/src/lib/feed-broadcast.ts`, `cli/src/commands/feed.ts`.
10
+
11
+ - **`agents view <agent> --device <host>` no longer flashes and vanishes from a real terminal (PHNX-3583).** The `--device` passthrough set `interactive = process.stdout.isTTY` and forwarded read-only renders over `ssh -tt` — a forced PTY. On the render's clean exit the PTY teardown + local-terminal restore (`restoreLocalTerminal` / `TERMINAL_MODE_RESET`) wiped the just-drawn output, so the whole view flashed and disappeared, leaving the prior screen. The piped (non-TTY) path never had this problem. Pure read-only render commands (`view`, `inspect`, `insights`, `doctor`) now forward over a plain pipe instead of a PTY, and inject `FORCE_COLOR=1` plus `COLUMNS`/`LINES` into the remote env so the piped render still comes back colored and correctly wrapped (`terminalWidth()` already reads `$COLUMNS` for exactly this reason); `FORCE_COLOR` is withheld under `--json` so machine output stays clean. A render command's narrow interactive sub-path — `view --prune`'s confirm — keeps the PTY, and every genuinely interactive command (`run`, `secrets browse`, pickers) is untouched. Source: `cli/src/lib/hosts/passthrough.ts`, `cli/src/lib/hosts/passthrough.test.ts`.
12
+
13
+ - **Remote provisional Claude tabs reopen once instead of bouncing across the fleet (PHNX-3587).** When VS Code reloaded before an offloaded Claude launch had written its first transcript, `agents sessions resume` found the dispatcher's synthetic session row, hopped to the target, then let the target search the fleet and rediscover the same synthetic row. The repeated routing printed reconnect banners and eventually returned to the shell without Claude. An owner hop now performs one local lookup: it resumes a materialized transcript, or recreates the exact forced Claude session identity when the transcript has not materialized yet. Source: `cli/src/commands/resume.ts`.
14
+
15
+ - **Cap and smooth the daemon's usage-refresh traffic with a per-provider budget.** The
16
+ refresher enforced only a per-account hourly cap, so endpoint load scaled linearly
17
+ with account count: 8 Claude accounts × 12/hr = ~96 usage calls/hr from one box,
18
+ against Anthropic's ~100/hr `/api/oauth/usage` ceiling — which 429'd accounts into
19
+ Retry-After parks (measured on `zion`: 7 of 8 accounts parked, never refreshed in
20
+ their window, so `agents view` showed `S: unavailable` and balanced routing read
21
+ stale usage). A per-provider budget (`PROVIDER_HOURLY_BUDGET`, 30/hr across all of a
22
+ network provider's accounts) now bounds aggregate traffic at a fixed rate that no
23
+ longer grows with account count, paced smoothly (one fetch per ~2 min, round-robin,
24
+ stalest account first) so it never bursts-then-stalls. Actively-used accounts stay
25
+ fresh for free via the statusline ingest, which now also re-derives their headroom
26
+ and suppresses a redundant API refresh. Source: `cli/src/lib/usage-refresh.ts`,
27
+ `cli/src/lib/daemon-ticks.ts`.
28
+ - **Route-refuse only on genuinely stale usage, not merely budget-paced usage.** Because
29
+ proactive refreshes are now paced, an idle account is deliberately refreshed on a
30
+ stretched cadence. Routing keeps its tight 5-min bar for *weighting* an account, but
31
+ the fail-loud `NO_VERIFIED_USAGE` refusal now fires on a wider 40-min bar
32
+ (`USAGE_STALE_REFUSAL_MAX_AGE_MS`) — so a budget-paced fleet routes normally while a
33
+ genuinely broken refresh (hours-old readings) still refuses. Source:
34
+ `cli/src/lib/accounting/rotate.ts`.
35
+
36
+ ## 1.22.64
37
+
38
+ - **`agents accounts attach` bootstraps a keychain-less Linux worker (PHNX-3502).**
39
+ Attaching a Claude account to a version home on a headless worker now seeds the
40
+ home's identity and writes its `.oauth_token` from the account's non-rotating
41
+ setup-token already synced in the `auth` bundle, instead of refusing because the
42
+ home was never interactively signed in. This is what lets all accounts show as
43
+ logged in — and interactive worker runs authenticate (the shim's Linux
44
+ `.oauth_token` fallback) instead of dropping to the login screen. No rotating
45
+ OAuth credential is ever copied. Source: `apps/cli/src/commands/accounts.ts`,
46
+ `apps/cli/src/lib/claude-account-token.ts`.
47
+
48
+ - Fixed Rush-backed feed channel sinks failing on Linux workers by handing delivery to a reachable macOS fleet peer with the Keychain-bound transport.
49
+
50
+ - **Auto-produce the origin/main release attestation on merge (RUSH-2666).** A new
51
+ `attest-main.yml` workflow runs the full suite on every push to `main` and uploads
52
+ the exact-tree attestation + pretested tarball to a rolling `main-attestations`
53
+ GitHub Release, keyed by tree hash. `release.sh` now prefetches that proof into the
54
+ local store before waiting, so an ordinary release promotes without running the
55
+ suite inline — no more human running the suite by hand to unwedge a release. Purely
56
+ additive and fail-safe: on any fetch miss or error it falls back to exactly the
57
+ prior poll-then-`require` behavior, and a fetched proof is trusted only because the
58
+ existing exact-tree `require` re-verifies it. Source: `cli/scripts/release.sh`,
59
+ `.github/workflows/attest-main.yml`.
60
+
61
+ - **Phoenix ID now uses the branded `id.byphoenix.com` API hostname by default (PHNX-3543).** New CLI sessions start and poll device authorization, resolve `whoami`, and bind managed share/traces Workers against the same canonical Phoenix ID base. The legacy hostname remains live for older installed clients. `PHOENIX_ID_BASE` remains an environment override for development and private deployments. Source: `src/lib/identity/client.ts`.
62
+
63
+ - **A pre-launch `run.launch` event makes a launch into a logged-out version visible instead of silent.** `agents run <agent> --device auto --strategy balanced` (the VS Code "New Claude" flow) launched a version that was LOGGED OUT on the target box: `--device auto` (`applyDeviceAutoToOptions`) only ensures SOME account is ready on the device, not that the SPECIFIC version launched is signed in there, and the failure was invisible — the pinned/default path in `resolveRunVersion` returns without emitting `rotation.resolved`, and `run.dispatched` only fires at run FINALIZE (post-exit), but a logged-out agent sits at the login screen and never finalizes, so nothing was recorded. A new `run.launch` event is now emitted RIGHT BEFORE the harness child is spawned, on the device that will run it, so it fires even when the agent then sits stuck at a login screen. Both live launch paths are covered by one shared emitter: `spawnAgent` (after the tmux-durability gate, for the tmux-wrapped AND bare spawns) and the Windows `execShimPassthrough` shim (`resolvedVia: 'shim'`), which was the second blind spot. Payload: `module: 'run'`, `agent`, `version`, `strategy`, `signedIn` (the launchable-signed-in verdict for the SPECIFIC launched version on THIS device — REUSED from the rotated pick's `rotationResult.picked.signedIn` when the command already computed it, else derived via the new `isVersionLaunchableHere` helper, which applies the same `getVersionHomePath` -> `getAccountInfo` -> `isLaunchableSignedIn` gate as `collectRunCandidates`), `launchedLoggedOut` (the headline flag, `signedIn === false`), `email`, and `resolvedVia`; the device hostname is auto-stamped by `emit()`. It sits in the AUDIT lane — the sibling of `run.dispatched` and the more reliable stuck-launch signal — so `agents events --level audit --include runs` surfaces it. Purely additive observability — the emit is best-effort (it can never break a launch) and no routing/launch behavior changes; refusing to launch a logged-out version is a separate follow-up. Source: `cli/src/lib/exec.ts`, `cli/src/lib/accounting/rotate.ts`, `cli/src/lib/feed/events.ts`, `cli/src/lib/event-families.ts`, `cli/src/commands/exec.ts`.
64
+
65
+ - **Live session rows carry outcome-card metrics and deliverables (PHNX-3574).** `agents sessions --active --json` and `agents sessions watch --json` now enrich each live row with indexed `tokenCount`, `durationMs`, and `subAgentCount`, plus created plan/artifact documents detected by the existing bounded transcript-tail state engine. Thin clients such as AGI EXT can render the initial request, progress, deliverables, team fan-out, runtime, and token use from the one canonical stream without polling or parsing transcripts themselves. Source: `cli/src/lib/session/active.ts`, `cli/src/lib/session/state.ts`.
66
+
3
67
  ## 1.22.63
4
68
 
5
69
  - **Managed share endpoint enforces a per-user storage quota, object limit, per-file size cap, and publish rate limit (PHNX-3542).** The managed `share.agents-cli.sh` Worker authenticated any Phoenix ID bearer and then accepted **unbounded** writes into shared R2 — no quota, no rate limit, no size cap — which blocked opening publishing to third parties. Each managed (Phoenix-identity) publish now charges a per-user usage ledger stored in R2 at `__usage/<owner>` (a conditional-put CAS object, mirroring the existing `__views`/`__handles` precedent — no Durable Object, no new binding): free tier is 200 MiB total, 150 canonical pages, 20 MiB per file, and 60 publishes/hour. Enforcement measures the **real request body** (bounded-buffered so a streaming body can't exceed the cap) and rejects on the true size **before any write**, so a spoofed-low declared size can't bypass the caps or destroy an existing page. It **fails loud** — `413` for a file, object-count, or byte-quota overage, `429` (with `Retry-After`) for the rate limit — and refunds bytes + object count on delete and on lazy expiry. Covers/views are server-generated overhead and excluded from the quota. BYO (`WRITE_TOKEN`) publishes write to the operator's own bucket at their own cost and are **unaffected** (a deliberate, documented policy). A `SHARE_PLANS` map is the seam for future paid tiers (billing follow-up PHNX-3569). Source: `cli/src/lib/share/worker-template.ts`.
@@ -30,14 +30,15 @@ export declare function classifyAttachTarget(target: string): AttachTarget;
30
30
  /**
31
31
  * Persist the attached setup-token to a per-version `.oauth_token` file so an
32
32
  * INTERACTIVE Claude launch on a keychain-less Linux worker can authenticate from it.
33
- * Headless runs inject the token via `buildExecEnv`, but an interactive launch defers to
34
- * the per-version login (`claudeAdapter.applyExecConfigEnv`), and the shim's Linux fallback
35
- * (`claudeAdapter.shimConfigEnvBash`) reads exactly this file. Without writing it, a
36
- * freshly-attached setup-token was invisible to interactive runs on a worker. macOS keeps
37
- * the credential in the keychain, so `resolveClaudeSetupToken` returns null there and this
38
- * is a no-op off Linux.
33
+ * Headless runs inject the token via `buildExecEnv`. Since PHNX-3502 an interactive
34
+ * launch on a worker-role device ALSO injects it through `claudeAdapter.applyExecConfigEnv`
35
+ * (only a headed `personal`/`desktop` device defers to its per-version native login), and
36
+ * the shim's Linux fallback (`claudeAdapter.shimConfigEnvBash`) reads exactly this file —
37
+ * so writing it is what makes a freshly-attached setup-token visible to that shim fallback
38
+ * on a worker. macOS keeps the credential in the keychain, so `resolveClaudeSetupToken`
39
+ * returns null there and this is a no-op off Linux.
39
40
  */
40
- export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId): void;
41
+ export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId, email?: string): void;
41
42
  export declare function parseBundleKey(raw: string): {
42
43
  bundle: string;
43
44
  key: string;
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { password, select } from '@inquirer/prompts';
5
- import { resolveClaudeSetupToken } from '../lib/claude-account-token.js';
5
+ import { readClaudeAccountEmail, resolveClaudeSetupToken, resolveClaudeSetupTokenForEmail, seedClaudeWorkerHomeIdentity } from '../lib/claude-account-token.js';
6
6
  import { setHelpSections } from '../lib/help.js';
7
7
  import { readMeta, updateMeta } from '../lib/state.js';
8
8
  import { ALL_AGENT_IDS, getAccountInfo, resolveAgentName } from '../lib/agents.js';
@@ -84,19 +84,25 @@ export function classifyAttachTarget(target) {
84
84
  /**
85
85
  * Persist the attached setup-token to a per-version `.oauth_token` file so an
86
86
  * INTERACTIVE Claude launch on a keychain-less Linux worker can authenticate from it.
87
- * Headless runs inject the token via `buildExecEnv`, but an interactive launch defers to
88
- * the per-version login (`claudeAdapter.applyExecConfigEnv`), and the shim's Linux fallback
89
- * (`claudeAdapter.shimConfigEnvBash`) reads exactly this file. Without writing it, a
90
- * freshly-attached setup-token was invisible to interactive runs on a worker. macOS keeps
91
- * the credential in the keychain, so `resolveClaudeSetupToken` returns null there and this
92
- * is a no-op off Linux.
87
+ * Headless runs inject the token via `buildExecEnv`. Since PHNX-3502 an interactive
88
+ * launch on a worker-role device ALSO injects it through `claudeAdapter.applyExecConfigEnv`
89
+ * (only a headed `personal`/`desktop` device defers to its per-version native login), and
90
+ * the shim's Linux fallback (`claudeAdapter.shimConfigEnvBash`) reads exactly this file —
91
+ * so writing it is what makes a freshly-attached setup-token visible to that shim fallback
92
+ * on a worker. macOS keeps the credential in the keychain, so `resolveClaudeSetupToken`
93
+ * returns null there and this is a no-op off Linux.
93
94
  */
94
- export function writeClaudeInteractiveOauthToken(target, targetAgent) {
95
+ export function writeClaudeInteractiveOauthToken(target, targetAgent, email) {
95
96
  if (process.platform !== 'linux' || targetAgent !== 'claude' || target.kind !== 'installation')
96
97
  return;
97
98
  const versionHome = getVersionHomePath('claude', target.version);
98
99
  const tokenPath = path.join(versionHome, '.claude', '.oauth_token');
99
- const token = resolveClaudeSetupToken(versionHome);
100
+ // Resolve by the attached account's email when known (a freshly-seeded worker
101
+ // home the `.claude.json` read below could not key on yet), else by the home's
102
+ // own recorded identity for a re-point/detach.
103
+ const token = email
104
+ ? resolveClaudeSetupTokenForEmail(email, versionHome)
105
+ : resolveClaudeSetupToken(versionHome);
100
106
  // A re-point (attach B over A, or a detach) can leave no setup-token resolving for
101
107
  // this version — B's may not be minted yet. A leftover file from the previous binding
102
108
  // would silently authenticate interactive runs as the OLD account (the shim's Linux
@@ -536,9 +542,30 @@ agents run codex#work`,
536
542
  else {
537
543
  if (t.kind !== 'installation')
538
544
  throw new Error(`${account.agent} authentication is per-version. Attach '${account.name}' to a specific ${account.agent}@<version>.`);
539
- const identity = await nativeIdentityFromSource(target);
540
- if (identity.identityKey !== account.identityKey)
541
- throw new Error(`'${target}' is signed in to a different identity than account '${account.name}'.`);
545
+ const versionHome = getVersionHomePath(t.agent, t.version);
546
+ // The literal email keys the account's `auth`-bundle setup-token. It lives
547
+ // in `identityLabel` — `identityKey` is a synthetic composite
548
+ // (`claude:account=<uuid>:org=<uuid>`, agents.ts nativeIdentityKey), never
549
+ // the address, so it must NOT be used to derive the token key.
550
+ const accountEmail = account.identityLabel;
551
+ // Headless-worker bootstrap: a keychain-less Linux worker home never had
552
+ // an interactive login, so its `.claude.json` carries no identity and
553
+ // `nativeIdentityFromSource` would reject the attach — yet the account's
554
+ // non-rotating setup-token is already fleet-synced in the `auth` bundle.
555
+ // Seed the identity (email only, no rotating credential) so the token
556
+ // resolves; `writeClaudeInteractiveOauthToken` then writes `.oauth_token`.
557
+ if (process.platform === 'linux' &&
558
+ account.agent === 'claude' &&
559
+ accountEmail &&
560
+ !readClaudeAccountEmail(versionHome) &&
561
+ resolveClaudeSetupTokenForEmail(accountEmail)) {
562
+ seedClaudeWorkerHomeIdentity(versionHome, accountEmail);
563
+ }
564
+ else {
565
+ const identity = await nativeIdentityFromSource(target);
566
+ if (identity.identityKey !== account.identityKey)
567
+ throw new Error(`'${target}' is signed in to a different identity than account '${account.name}'.`);
568
+ }
542
569
  }
543
570
  }
544
571
  else {
@@ -546,7 +573,7 @@ agents run codex#work`,
546
573
  getAccountProvider(account.provider).envFor(targetAgent, account.auth);
547
574
  }
548
575
  bindAccount(name, target);
549
- writeClaudeInteractiveOauthToken(t, targetAgent);
576
+ writeClaudeInteractiveOauthToken(t, targetAgent, account.kind === 'native' && account.agent === 'claude' ? account.identityLabel : undefined);
550
577
  console.log(chalk.green(`Attached ${account.name} to ${target}.`));
551
578
  });
552
579
  });
@@ -2356,6 +2356,18 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2356
2356
  // synthesize a same-agent fallback chain from the other healthy accounts
2357
2357
  // (issue #348). Stays null unless a non-pinned strategy actually rotated.
2358
2358
  let rotationResult = null;
2359
+ // Precomputed launchable-signed-in verdict for the ACTUAL launched
2360
+ // candidate, fed to the pre-launch `run.launch` event so it need not
2361
+ // re-probe. Sourced per resolution branch from the candidate that WON, not
2362
+ // the original auto-pick: the interactive picker (RUSH-2334 / PHNX-2526)
2363
+ // deliberately lets the user launch a LOGGED-OUT account, which is a
2364
+ // different candidate than `rotationResult.picked` — reading the verdict off
2365
+ // the auto-pick there would report `launchedLoggedOut:false` for a version
2366
+ // that is actually logged out, the exact false-negative this event exists to
2367
+ // prevent. Left undefined for pinned-default / explicit-pin so emitRunLaunch
2368
+ // falls back to probing the version home itself.
2369
+ let launchSignedIn;
2370
+ let launchEmail;
2359
2371
  // Set when the zero-healthy path already announced a deliberate
2360
2372
  // launch-to-sign-in, so the login preflight below does not repeat it.
2361
2373
  let signInLaunch = false;
@@ -2460,6 +2472,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2460
2472
  if (!selected)
2461
2473
  return;
2462
2474
  version = selected.version;
2475
+ // Source the run.launch verdict from the account the user ACTUALLY
2476
+ // picked — the picker may deliberately return a logged-out one
2477
+ // (RUSH-2334), so it can differ from rotationResult.picked.
2478
+ launchSignedIn = selected.signedIn;
2479
+ launchEmail = selected.email;
2463
2480
  // Keep the rotation so mid-run failover can still cascade across
2464
2481
  // the other (stale) healthy accounts after a real rejection.
2465
2482
  rotationResult = resolved.rotation;
@@ -2476,6 +2493,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2476
2493
  else if (resolved.version) {
2477
2494
  version = resolved.version;
2478
2495
  rotationResult = resolved.rotation;
2496
+ // The auto-pick already computed the launchable-signed-in verdict for
2497
+ // this exact version via the same gate — reuse it for run.launch.
2498
+ if (resolved.rotation) {
2499
+ launchSignedIn = resolved.rotation.picked.signedIn;
2500
+ launchEmail = resolved.rotation.picked.email;
2501
+ }
2479
2502
  // A balanced/available pick of a PROVIDER account (setup-token /
2480
2503
  // API-key) carries `providerAccount`. Resolve its env through the
2481
2504
  // same `resolveSpawnAccount` path an explicit `--account` uses, so
@@ -2818,6 +2841,23 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2818
2841
  // forwards `--emit-session-id`): print the resolved session id as a
2819
2842
  // stdout sentinel so the launcher captures the id this run coined.
2820
2843
  emitSessionId: options.emitSessionId === true,
2844
+ // Observability-only: carried onto the pre-launch `run.launch` event so
2845
+ // the stream records HOW this version was chosen. Neither affects the
2846
+ // spawn. `resolvedVia` attributes the version source cheaply — an
2847
+ // explicit @version pin, a strategy rotation, or the pinned default.
2848
+ strategy,
2849
+ resolvedVia: rawVersion
2850
+ ? 'explicit-pin'
2851
+ : rotationResult
2852
+ ? 'rotated'
2853
+ : 'pinned-default',
2854
+ // Launchable-signed-in verdict for the ACTUAL launched candidate, set per
2855
+ // resolution branch above (auto-pick from rotation.picked; interactive
2856
+ // picker from the user's `selected`, which may be logged out). Undefined
2857
+ // for a pinned-default / explicit-pin launch, where spawnAgent probes the
2858
+ // version home itself.
2859
+ launchSignedIn,
2860
+ launchEmail,
2821
2861
  };
2822
2862
  if (options.interactive && options.headless) {
2823
2863
  console.error(chalk.red('--interactive and --headless are mutually exclusive. Pass one, or neither (mode is inferred from prompt presence).'));
@@ -3,6 +3,7 @@ import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildD
3
3
  import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/feed/activity.js';
4
4
  import { projectKeyFromCwd } from '../lib/project-key.js';
5
5
  import { postFeedStatus } from '../lib/feed-post.js';
6
+ import { linearIssueUrl } from '../lib/session/linear.js';
6
7
  import { parseFeedPostLevel, planFeedBroadcast, runFeedBroadcast, effectiveBroadcastConfig, withDesktopNotify, blockBroadcastContext, blockDeliveryFailure, } from '../lib/feed-broadcast.js';
7
8
  import { getSessionById } from '../lib/session/db.js';
8
9
  import { readMeta } from '../lib/state.js';
@@ -697,6 +698,7 @@ async function broadcastPostedEvent(event, level, meta, notify = false) {
697
698
  text: event.detail ?? '',
698
699
  level,
699
700
  ticket,
701
+ ticketUrl: linearIssueUrl(ticket),
700
702
  project: event.project,
701
703
  agent: event.agent,
702
704
  host: event.host,
@@ -1,3 +1,4 @@
1
+ export declare const RESUME_SOURCE_ENV = "AGENTS_RESUME_SOURCE_JSON";
1
2
  export interface StrictResumeOptions {
2
3
  mode?: string;
3
4
  interactive?: boolean;
@@ -23,6 +24,13 @@ export declare function buildResumeRunArgs(session: {
23
24
  agent: string;
24
25
  version?: string;
25
26
  }, prompt: string | undefined, options: StrictResumeOptions): string[];
27
+ /** Recreate a remote Claude launch whose forced id never materialized a transcript. */
28
+ export declare function buildProvisionalRunArgs(session: {
29
+ id: string;
30
+ agent: string;
31
+ version?: string;
32
+ cwd?: string;
33
+ }, prompt: string | undefined, options: StrictResumeOptions): string[];
26
34
  /** True when the caller asked for the strict resume path (prompt and/or flags). */
27
35
  export declare function wantsStrictResume(prompt: string | undefined, options: StrictResumeOptions): boolean;
28
36
  /**
@@ -7,6 +7,7 @@ import { spawn } from 'child_process';
7
7
  import chalk from 'chalk';
8
8
  import { resolveSessionMetadataValue } from './sessions.js';
9
9
  import { sessionOwnerDevice, consumeResumePinned, RESUME_PINNED_ENV } from '../lib/session/resume-owner.js';
10
+ export const RESUME_SOURCE_ENV = 'AGENTS_RESUME_SOURCE_JSON';
10
11
  /**
11
12
  * The argv to re-run this resume on the machine that owns the session.
12
13
  *
@@ -46,6 +47,38 @@ export function buildResumeRunArgs(session, prompt, options) {
46
47
  args.push('--quiet');
47
48
  return args;
48
49
  }
50
+ /** Recreate a remote Claude launch whose forced id never materialized a transcript. */
51
+ export function buildProvisionalRunArgs(session, prompt, options) {
52
+ const spec = session.version ? `${session.agent}@${session.version}` : session.agent;
53
+ const args = ['run', spec, ...(prompt === undefined ? [] : [prompt]), '--session-id', session.id];
54
+ if (options.mode)
55
+ args.push('--mode', options.mode);
56
+ if (options.interactive)
57
+ args.push('--interactive');
58
+ if (options.headless)
59
+ args.push('--headless');
60
+ const cwd = options.cwd ?? session.cwd;
61
+ if (cwd)
62
+ args.push('--cwd', cwd);
63
+ if (options.quiet)
64
+ args.push('--quiet');
65
+ return args;
66
+ }
67
+ function consumeResumeSource() {
68
+ const raw = process.env[RESUME_SOURCE_ENV];
69
+ delete process.env[RESUME_SOURCE_ENV];
70
+ if (!raw)
71
+ return undefined;
72
+ try {
73
+ const value = JSON.parse(raw);
74
+ if (typeof value?.id === 'string' && typeof value?.agent === 'string')
75
+ return value;
76
+ }
77
+ catch {
78
+ // The routing pin is authoritative; malformed optional context is ignored.
79
+ }
80
+ return undefined;
81
+ }
49
82
  /** True when the caller asked for the strict resume path (prompt and/or flags). */
50
83
  export function wantsStrictResume(prompt, options) {
51
84
  return (prompt !== undefined ||
@@ -63,8 +96,17 @@ export function wantsStrictResume(prompt, options) {
63
96
  export async function runStrictResume(sessionId, prompt, options) {
64
97
  // Read (and clear) the routing pin before anything else, so it can never
65
98
  // reach the agent's own children.
66
- const pinnedHere = consumeResumePinned() || !!options.here;
67
- const outcome = await resolveSessionMetadataValue(sessionId.trim());
99
+ const routedHop = consumeResumePinned();
100
+ const pinnedHere = routedHop || !!options.here;
101
+ const routedSource = routedHop ? consumeResumeSource() : undefined;
102
+ if (routedSource && routedSource.id !== sessionId.trim()) {
103
+ console.error(chalk.red(`Resume routing metadata names session ${routedSource.id}, not requested session ${sessionId.trim()}.`));
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ // An owner hop must inspect only the owner's index. Fleet fan-out here can
108
+ // rediscover the dispatcher's synthetic row and bounce the same id forever.
109
+ const outcome = await resolveSessionMetadataValue(sessionId.trim(), pinnedHere ? { local: true } : {});
68
110
  if (outcome.kind === 'partial') {
69
111
  // RUSH-2492: an unreachable peer is a warning, not a hard failure. The
70
112
  // resolver already resolves an id found on the reachable fleet (SES-9a),
@@ -78,6 +120,19 @@ export async function runStrictResume(sessionId, prompt, options) {
78
120
  return;
79
121
  }
80
122
  if (outcome.kind === 'not-found') {
123
+ if (routedHop && routedSource?.filePath === '' && routedSource.agent === 'claude') {
124
+ const args = buildProvisionalRunArgs(routedSource, prompt, options);
125
+ const child = spawn(process.execPath, [process.argv[1], ...args], {
126
+ stdio: 'inherit',
127
+ env: process.env,
128
+ });
129
+ const exitCode = await new Promise((resolve) => {
130
+ child.once('error', () => resolve(127));
131
+ child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
132
+ });
133
+ process.exitCode = exitCode;
134
+ return;
135
+ }
81
136
  console.error(chalk.red(`No session matching "${sessionId}".`));
82
137
  process.exitCode = 1;
83
138
  return;
@@ -102,7 +157,14 @@ export async function runStrictResume(sessionId, prompt, options) {
102
157
  // one re-discovers locally and marks the run AGENTS_FLEET_REMOTE, which
103
158
  // a long-lived resumed session must not inherit.
104
159
  const { runOnPeer } = await import('../lib/session/remote-list.js');
105
- const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, { tty: !!process.stdout.isTTY, env: { [RESUME_PINNED_ENV]: '1' }, sessionId: outcome.session.id });
160
+ const rc = await runOnPeer(buildResumeRemoteArgs(outcome.session.id, prompt, options), owner, {
161
+ tty: !!process.stdout.isTTY,
162
+ env: {
163
+ [RESUME_PINNED_ENV]: '1',
164
+ [RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
165
+ },
166
+ sessionId: outcome.session.id,
167
+ });
106
168
  if (rc === 'no-target') {
107
169
  console.error(chalk.red(`Session ${outcome.session.shortId} lives on ${owner}, which isn't a reachable device right now.`));
108
170
  console.error(chalk.gray(`Register/wake it (agents devices), or run there: agents ssh ${owner}`));
@@ -118,7 +180,7 @@ export async function runStrictResume(sessionId, prompt, options) {
118
180
  // Avoid repeating the fleet lookup in the delegated local `run`
119
181
  // process. The value is metadata-only and is not forwarded over SSH;
120
182
  // the owner performs its own local SQLite lookup.
121
- AGENTS_RESUME_SOURCE_JSON: JSON.stringify(outcome.session),
183
+ [RESUME_SOURCE_ENV]: JSON.stringify(outcome.session),
122
184
  },
123
185
  });
124
186
  const exitCode = await new Promise((resolve) => {
@@ -13,7 +13,7 @@ import { registryPoolCandidates } from './account-pool.js';
13
13
  function localRegistryRecords() {
14
14
  return Object.values(readAccountRegistry().accounts)
15
15
  .filter((a) => hasKeychainToken(a.secretRef))
16
- .map((a) => ({ name: a.name, provider: a.provider, auth: a.auth }));
16
+ .map((a) => ({ name: a.name, provider: a.provider, auth: a.auth, secretPresent: true }));
17
17
  }
18
18
  /**
19
19
  * PURE: fold provider-account candidates into the native version-home list.
@@ -45,7 +45,12 @@ export function foldRegistryCandidates(agent, inputs) {
45
45
  usageError: null,
46
46
  usageMinutesToLimit: null,
47
47
  plan: null,
48
- signedIn: true,
48
+ // Derive from the record's real secret-presence field rather than a bare
49
+ // `true` literal (PHNX-3502). Inert on today's only producer
50
+ // (`localRegistryRecords` already filters token-less accounts before the
51
+ // map), but keeps the field honest for any injected record that carries
52
+ // `secretPresent: false`.
53
+ signedIn: r.secretPresent,
49
54
  authVerdict: null,
50
55
  lastActive: null,
51
56
  providerAccount: r.name,
@@ -16,6 +16,16 @@ export interface RegistryAccountRecord {
16
16
  name: string;
17
17
  provider: string;
18
18
  auth: AccountAuthKind;
19
+ /**
20
+ * Whether this account's secret is actually present on THIS device
21
+ * (`hasKeychainToken(secretRef)`, checked by the record's builder). Carried
22
+ * through rather than assumed, so a candidate's `signedIn` reflects a real
23
+ * check instead of a literal disconnected from it (PHNX-3502) — a registry
24
+ * entry can exist with no local secret (added on another device, or
25
+ * revoked), and folding it in as unconditionally signed-in would let
26
+ * `--strategy balanced` pick an account that fails at spawn.
27
+ */
28
+ secretPresent: boolean;
19
29
  }
20
30
  /** A registry account eligible to run one harness, ready to map to a candidate. */
21
31
  export interface RegistryAccountInput {
@@ -25,6 +35,8 @@ export interface RegistryAccountInput {
25
35
  name: string;
26
36
  provider: string;
27
37
  auth: AccountAuthKind;
38
+ /** See {@link RegistryAccountRecord.secretPresent}. */
39
+ secretPresent: boolean;
28
40
  }
29
41
  /**
30
42
  * Which provider accounts can authenticate `agent`. An account is a candidate for
@@ -27,6 +27,7 @@ export function registryPoolCandidates(records, agent) {
27
27
  name: r.name,
28
28
  provider: r.provider,
29
29
  auth: r.auth,
30
+ secretPresent: r.secretPresent,
30
31
  });
31
32
  }
32
33
  return out;
@@ -131,6 +131,28 @@ export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrate
131
131
  * existing `signedIn` signal.
132
132
  */
133
133
  export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<CredentialPresence, 'knownLocation' | 'perVersion'>): boolean;
134
+ /** Launchable-signed-in verdict for ONE specific version on THIS device. */
135
+ export interface VersionLaunchState {
136
+ /** True iff this exact version home can spawn a signed-in agent right now. */
137
+ launchable: boolean;
138
+ /** The version home's account email when launchable, else null. */
139
+ email: string | null;
140
+ }
141
+ /**
142
+ * Whether a SPECIFIC installed version is launchable-signed-in on THIS device,
143
+ * plus the account email when it is. Mirrors EXACTLY the per-version gate
144
+ * {@link collectRunCandidates} applies (getVersionHomePath -> getAccountInfo ->
145
+ * {@link isLaunchableSignedIn} over {@link credentialPresence}), so the
146
+ * pre-launch `run.launch` event can report the same signed-in verdict the
147
+ * balanced router computes for that version.
148
+ *
149
+ * The point is to make a launch into a logged-out version VISIBLE at spawn time:
150
+ * `--device auto` only guarantees SOME account is ready on the device, not that
151
+ * the specific version launched is signed in there (the yosemite-m3 2.1.219
152
+ * incident — 2.1.219 was logged out, the router correctly excluded it, yet it
153
+ * launched). Non-fatal by construction: callers wrap it best-effort.
154
+ */
155
+ export declare function isVersionLaunchableHere(agent: AgentId, version: string): Promise<VersionLaunchState>;
134
156
  /**
135
157
  * How old a usage snapshot may be and still settle a routing DECISION.
136
158
  *
@@ -141,6 +163,27 @@ export declare function isLaunchableSignedIn(signedIn: boolean, presence: Pick<C
141
163
  * launched into it while the account was actually at its weekly cap.
142
164
  */
143
165
  export declare const USAGE_DECISION_MAX_AGE_MS: number;
166
+ /**
167
+ * How old a usage snapshot may be before routing REFUSES to run at all
168
+ * (NO_VERIFIED_USAGE), as opposed to merely declining to *weight* by its number.
169
+ *
170
+ * These are two different risks and now two different bars. Weighting on a
171
+ * slightly-old number is cheap to get wrong (a floored weight, {@link
172
+ * USAGE_DECISION_MAX_AGE_MS} = 5 min); refusing to launch at all is expensive to
173
+ * get wrong — it fails the user's `agents run` outright. The daemon's usage
174
+ * refresher paces proactive fetches under a fixed per-provider budget
175
+ * (`usage-refresh.ts`, PROVIDER_HOURLY_BUDGET), so on a multi-account fleet an
176
+ * IDLE account is deliberately refreshed on a stretched round-robin cadence
177
+ * (bounded at N × spacing — ~16 min at 8 accounts, ~32 min at 16) rather than
178
+ * every 5 min, which would 429 the endpoint and park it for up to an hour. A
179
+ * budget-paced idle reading of 10–30 min is NOT the failure this refusal exists
180
+ * to catch. That failure is the `yosemite-s1` case: a box whose refresh is
181
+ * genuinely BROKEN, holding readings 26 h – 2.7 d old. 40 min sits comfortably
182
+ * above the worst-case budget cadence and still an order of magnitude below the
183
+ * multi-hour staleness of a broken box — and actively-used accounts refresh for
184
+ * free via the statusline ingest, so a *busy* account is never even this old.
185
+ */
186
+ export declare const USAGE_STALE_REFUSAL_MAX_AGE_MS: number;
144
187
  /**
145
188
  * Whether this candidate's usage number is recent enough to route on. A missing
146
189
  * snapshot is unverified by definition — there is no number to trust.
@@ -156,17 +199,23 @@ export declare const USAGE_DECISION_MAX_AGE_MS: number;
156
199
  */
157
200
  export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
158
201
  /**
159
- * Whether this candidate carries a STALE-but-present usage number: a snapshot
160
- * with windows whose capture time is older than {@link USAGE_DECISION_MAX_AGE_MS}.
202
+ * Whether this candidate carries a GENUINELY-STALE usage number: a snapshot with
203
+ * windows whose capture time is older than {@link USAGE_STALE_REFUSAL_MAX_AGE_MS}.
161
204
  *
162
205
  * This is the misleading case the initial route must refuse — the number reads
163
206
  * "48% used" with the same confidence whether captured a minute or three days
164
- * ago, and a box whose refresh is failing stays wrong indefinitely. It is
165
- * deliberately NARROWER than "not verified": a BLIND candidate with no snapshot
166
- * (or a plan-only meterless one with no windows) carries no number to be misled
167
- * by — a worker box whose usage endpoint 403s (RUSH-2392), or a meterless Grok
168
- * login — so it is not "stale", and an entirely-blind pool still draws a pick
169
- * (PHNX-3392) rather than fail loud with NO_VERIFIED_USAGE.
207
+ * ago, and a box whose refresh is failing stays wrong indefinitely. Two things
208
+ * make it NARROWER than "not verified":
209
+ * 1. It uses the wider REFUSAL bar, not the 5-min weighting bar. A merely
210
+ * budget-paced idle account (10–30 min old) is not-verified — so it weights
211
+ * at the floor, conservatively — but it is NOT "stale" and must not, on its
212
+ * own, drive the whole provider to a NO_VERIFIED_USAGE refusal. Only a
213
+ * genuinely broken refresh (hours old) trips this.
214
+ * 2. A BLIND candidate with no snapshot (or a plan-only meterless one with no
215
+ * windows) carries no number to be misled by — a worker box whose usage
216
+ * endpoint 403s (RUSH-2392), or a meterless Grok login — so it is not
217
+ * "stale", and an entirely-blind pool still draws a pick (PHNX-3392) rather
218
+ * than fail loud with NO_VERIFIED_USAGE.
170
219
  */
171
220
  export declare function hasStaleUsage(candidate: RotateCandidate, nowMs?: number): boolean;
172
221
  /**