@phnx-labs/agents-cli 1.22.62 → 1.22.64

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.64
4
+
5
+ - **`agents accounts attach` bootstraps a keychain-less Linux worker (PHNX-3502).**
6
+ Attaching a Claude account to a version home on a headless worker now seeds the
7
+ home's identity and writes its `.oauth_token` from the account's non-rotating
8
+ setup-token already synced in the `auth` bundle, instead of refusing because the
9
+ home was never interactively signed in. This is what lets all accounts show as
10
+ logged in — and interactive worker runs authenticate (the shim's Linux
11
+ `.oauth_token` fallback) instead of dropping to the login screen. No rotating
12
+ OAuth credential is ever copied. Source: `apps/cli/src/commands/accounts.ts`,
13
+ `apps/cli/src/lib/claude-account-token.ts`.
14
+
15
+ - Fixed Rush-backed feed channel sinks failing on Linux workers by handing delivery to a reachable macOS fleet peer with the Keychain-bound transport.
16
+
17
+ - **Auto-produce the origin/main release attestation on merge (RUSH-2666).** A new
18
+ `attest-main.yml` workflow runs the full suite on every push to `main` and uploads
19
+ the exact-tree attestation + pretested tarball to a rolling `main-attestations`
20
+ GitHub Release, keyed by tree hash. `release.sh` now prefetches that proof into the
21
+ local store before waiting, so an ordinary release promotes without running the
22
+ suite inline — no more human running the suite by hand to unwedge a release. Purely
23
+ additive and fail-safe: on any fetch miss or error it falls back to exactly the
24
+ prior poll-then-`require` behavior, and a fetched proof is trusted only because the
25
+ existing exact-tree `require` re-verifies it. Source: `cli/scripts/release.sh`,
26
+ `.github/workflows/attest-main.yml`.
27
+
28
+ - **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`.
29
+
30
+ - **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`.
31
+
32
+ - **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`.
33
+
34
+ ## 1.22.63
35
+
36
+ - **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`.
37
+
38
+ - Let feed channel sinks customize their delivered body with existing post placeholders, including fail-closed `{ticket}` routing for clickable tracker links in team channels.
39
+
3
40
  ## 1.22.62
4
41
 
5
42
  - **Owner notifications fan out across the configured normal-severity channels (PHNX-3567).** `agents send --to owner`, deprecated `agents notify`, monitor notifications, and an important feed's owner sink now attempt every addressable entry named by `owner.policy.normal` in `humans.yaml`, instead of silently selecting only the first. Each Rush-backed destination that cannot deliver on a Linux worker forwards its explicit channel and target to a capable Mac, avoiding both shell quoting and policy re-expansion/duplicate sends. Partial failures stay visible while successful channels still deliver; legacy single-channel configs retain their old behavior. Source: `cli/src/lib/humans.ts`, `cli/src/lib/notify.ts`, `cli/src/lib/channels/owner-forward.ts`, `cli/src/lib/feed-broadcast.ts`.
@@ -37,7 +37,7 @@ export declare function classifyAttachTarget(target: string): AttachTarget;
37
37
  * the credential in the keychain, so `resolveClaudeSetupToken` returns null there and this
38
38
  * is a no-op off Linux.
39
39
  */
40
- export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId): void;
40
+ export declare function writeClaudeInteractiveOauthToken(target: AttachTarget, targetAgent: AgentId, email?: string): void;
41
41
  export declare function parseBundleKey(raw: string): {
42
42
  bundle: string;
43
43
  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';
@@ -91,12 +91,17 @@ export function classifyAttachTarget(target) {
91
91
  * the credential in the keychain, so `resolveClaudeSetupToken` returns null there and this
92
92
  * is a no-op off Linux.
93
93
  */
94
- export function writeClaudeInteractiveOauthToken(target, targetAgent) {
94
+ export function writeClaudeInteractiveOauthToken(target, targetAgent, email) {
95
95
  if (process.platform !== 'linux' || targetAgent !== 'claude' || target.kind !== 'installation')
96
96
  return;
97
97
  const versionHome = getVersionHomePath('claude', target.version);
98
98
  const tokenPath = path.join(versionHome, '.claude', '.oauth_token');
99
- const token = resolveClaudeSetupToken(versionHome);
99
+ // Resolve by the attached account's email when known (a freshly-seeded worker
100
+ // home the `.claude.json` read below could not key on yet), else by the home's
101
+ // own recorded identity for a re-point/detach.
102
+ const token = email
103
+ ? resolveClaudeSetupTokenForEmail(email, versionHome)
104
+ : resolveClaudeSetupToken(versionHome);
100
105
  // A re-point (attach B over A, or a detach) can leave no setup-token resolving for
101
106
  // this version — B's may not be minted yet. A leftover file from the previous binding
102
107
  // would silently authenticate interactive runs as the OLD account (the shim's Linux
@@ -536,9 +541,30 @@ agents run codex#work`,
536
541
  else {
537
542
  if (t.kind !== 'installation')
538
543
  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}'.`);
544
+ const versionHome = getVersionHomePath(t.agent, t.version);
545
+ // The literal email keys the account's `auth`-bundle setup-token. It lives
546
+ // in `identityLabel` — `identityKey` is a synthetic composite
547
+ // (`claude:account=<uuid>:org=<uuid>`, agents.ts nativeIdentityKey), never
548
+ // the address, so it must NOT be used to derive the token key.
549
+ const accountEmail = account.identityLabel;
550
+ // Headless-worker bootstrap: a keychain-less Linux worker home never had
551
+ // an interactive login, so its `.claude.json` carries no identity and
552
+ // `nativeIdentityFromSource` would reject the attach — yet the account's
553
+ // non-rotating setup-token is already fleet-synced in the `auth` bundle.
554
+ // Seed the identity (email only, no rotating credential) so the token
555
+ // resolves; `writeClaudeInteractiveOauthToken` then writes `.oauth_token`.
556
+ if (process.platform === 'linux' &&
557
+ account.agent === 'claude' &&
558
+ accountEmail &&
559
+ !readClaudeAccountEmail(versionHome) &&
560
+ resolveClaudeSetupTokenForEmail(accountEmail)) {
561
+ seedClaudeWorkerHomeIdentity(versionHome, accountEmail);
562
+ }
563
+ else {
564
+ const identity = await nativeIdentityFromSource(target);
565
+ if (identity.identityKey !== account.identityKey)
566
+ throw new Error(`'${target}' is signed in to a different identity than account '${account.name}'.`);
567
+ }
542
568
  }
543
569
  }
544
570
  else {
@@ -546,7 +572,7 @@ agents run codex#work`,
546
572
  getAccountProvider(account.provider).envFor(targetAgent, account.auth);
547
573
  }
548
574
  bindAccount(name, target);
549
- writeClaudeInteractiveOauthToken(t, targetAgent);
575
+ writeClaudeInteractiveOauthToken(t, targetAgent, account.kind === 'native' && account.agent === 'claude' ? account.identityLabel : undefined);
550
576
  console.log(chalk.green(`Attached ${account.name} to ${target}.`));
551
577
  });
552
578
  });
@@ -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).'));
@@ -20,7 +20,7 @@ import { type OpenBlock } from '../lib/feed/feed.js';
20
20
  import { type OutcomeGroup, type SessionOutcomeHint } from '../lib/feed-outcome.js';
21
21
  import { filterBlocksForFeed } from '../lib/ask-classifier.js';
22
22
  import { type FeedSessionSignal } from '../lib/feed-ranking.js';
23
- export declare const FEED_POST_HELP = "\nExamples:\n # Title (subject) + body. Phone broadcasts put title first, body after a\n # blank line, then a \"Sent from agent/session on host\" footer.\n agents feed post --title \"CHANGELOG pushed\" \"Watching CI and mac-mini E2E\"\n agents feed post --title \"Cover ready\" \"render at ./out/cover.png\" --attach ./out/cover.png\n agents feed post --title \"Ready for review\" \"PR opened, waiting on prix-cloud\" --json\n\n # Worth interrupting someone over - reaches sinks gated on minLevel: important:\n agents feed post --title \"npm token expired\" \"Cannot publish the release\" --level important\n\n # Also raise a local desktop banner on THIS machine (same notifier as run\n # --notify), on top of any configured broadcast - useful when you are at the box:\n agents feed post --title \"Build green\" \"all checks passed\" --notify\n\n # Stuck: opens a needs-you block and always broadcasts at important:\n agents feed post --title \"Force-push denied\" \"git-guard blocked PR #1749\" --blocked\n agents feed post --title \"Publish or wait?\" \"npm publish now or after review\" --blocked --option publish --option wait\n agents feed post --title \"Delete preview env?\" \"stale preview still running\" --blocked --default \"leave it\"\n\n # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:\n # a credential only the user holds, a decision only they can make, an\n # approval only they can give. Not \"should I do the obvious next step?\".\n\n # Outside a run, pass the session explicitly:\n agents feed post --title \"Manual note\" \"context for the next agent\" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47\n\nIdentity (session, agent, host, runtime, pid, launchId) is stamped automatically\nand rides the phone footer of feed.broadcast {message}. Domain facts (tickets,\nPRs) are not CLI flags - the ticket is joined from the session index at post\ntime. No em-dashes in title/body - they are scrubbed on the way out.\n\nConfigure where a post is mirrored under feed.broadcast in agents.yaml - see\ndocs/observability.md. A milestone is always recorded, but it does not text\nthe owner when the sink has minLevel: important. Add --level important for a\nphone-worthy successful update. Use --blocked only when work cannot continue.\nThe owner destination comes from humans.yaml; do not duplicate it in agents.yaml.\n";
23
+ export declare const FEED_POST_HELP = "\nExamples:\n # Title (subject) + body. Phone broadcasts put title first, body after a\n # blank line, then a \"Sent from agent/session on host\" footer.\n agents feed post --title \"CHANGELOG pushed\" \"Watching CI and mac-mini E2E\"\n agents feed post --title \"Cover ready\" \"render at ./out/cover.png\" --attach ./out/cover.png\n agents feed post --title \"Ready for review\" \"PR opened, waiting on prix-cloud\" --json\n\n # Worth interrupting someone over - reaches sinks gated on minLevel: important:\n agents feed post --title \"npm token expired\" \"Cannot publish the release\" --level important\n\n # Also raise a local desktop banner on THIS machine (same notifier as run\n # --notify), on top of any configured broadcast - useful when you are at the box:\n agents feed post --title \"Build green\" \"all checks passed\" --notify\n\n # Stuck: opens a needs-you block and always broadcasts at important:\n agents feed post --title \"Force-push denied\" \"git-guard blocked PR #1749\" --blocked\n agents feed post --title \"Publish or wait?\" \"npm publish now or after review\" --blocked --option publish --option wait\n agents feed post --title \"Delete preview env?\" \"stale preview still running\" --blocked --default \"leave it\"\n\n # Exhaust self-serve FIRST. A block is for what you genuinely cannot do:\n # a credential only the user holds, a decision only they can make, an\n # approval only they can give. Not \"should I do the obvious next step?\".\n\n # Outside a run, pass the session explicitly:\n agents feed post --title \"Manual note\" \"context for the next agent\" --session 00998b0e-2d15-4d2f-a58b-974a886c9b47\n\nIdentity (session, agent, host, runtime, pid, launchId) is stamped automatically\nand rides the phone footer of feed.broadcast {message}. Domain facts (tickets,\nPRs) are not CLI flags - the ticket is joined from the session index at post\ntime. No em-dashes in title/body - they are scrubbed on the way out.\n\nConfigure where a post is mirrored under feed.broadcast in agents.yaml - see\ndocs/observability.md. A channel sink may set message: with placeholders such\nas {message} and {ticket}; a missing placeholder skips that sink. A milestone is always recorded, but it does not text\nthe owner when the sink has minLevel: important. Add --level important for a\nphone-worthy successful update. Use --blocked only when work cannot continue.\nThe owner destination comes from humans.yaml; do not duplicate it in agents.yaml.\n";
24
24
  export declare const FEED_NO_FANOUT_ENV = "AGENTS_FEED_LOCAL";
25
25
  /** Right-hand masthead summary: `N blocks · M agents`. */
26
26
  export declare function formatFeedMastheadRight(blocks: OpenBlock[]): string;
@@ -57,7 +57,8 @@ PRs) are not CLI flags - the ticket is joined from the session index at post
57
57
  time. No em-dashes in title/body - they are scrubbed on the way out.
58
58
 
59
59
  Configure where a post is mirrored under feed.broadcast in agents.yaml - see
60
- docs/observability.md. A milestone is always recorded, but it does not text
60
+ docs/observability.md. A channel sink may set message: with placeholders such
61
+ as {message} and {ticket}; a missing placeholder skips that sink. A milestone is always recorded, but it does not text
61
62
  the owner when the sink has minLevel: important. Add --level important for a
62
63
  phone-worthy successful update. Use --blocked only when work cannot continue.
63
64
  The owner destination comes from humans.yaml; do not duplicate it in agents.yaml.
@@ -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
  *
@@ -98,6 +98,26 @@ export function isLaunchableSignedIn(signedIn, presence) {
98
98
  return true;
99
99
  return presence.perVersion;
100
100
  }
101
+ /**
102
+ * Whether a SPECIFIC installed version is launchable-signed-in on THIS device,
103
+ * plus the account email when it is. Mirrors EXACTLY the per-version gate
104
+ * {@link collectRunCandidates} applies (getVersionHomePath -> getAccountInfo ->
105
+ * {@link isLaunchableSignedIn} over {@link credentialPresence}), so the
106
+ * pre-launch `run.launch` event can report the same signed-in verdict the
107
+ * balanced router computes for that version.
108
+ *
109
+ * The point is to make a launch into a logged-out version VISIBLE at spawn time:
110
+ * `--device auto` only guarantees SOME account is ready on the device, not that
111
+ * the specific version launched is signed in there (the yosemite-m3 2.1.219
112
+ * incident — 2.1.219 was logged out, the router correctly excluded it, yet it
113
+ * launched). Non-fatal by construction: callers wrap it best-effort.
114
+ */
115
+ export async function isVersionLaunchableHere(agent, version) {
116
+ const home = getVersionHomePath(agent, version);
117
+ const info = await getAccountInfo(agent, home);
118
+ const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
119
+ return { launchable, email: launchable ? info.email : null };
120
+ }
101
121
  function isAvailableEligible(candidate) {
102
122
  return isRotationEligible(candidate);
103
123
  }
@@ -22,3 +22,29 @@ export declare function readClaudeAccountEmail(home?: string): string | null;
22
22
  * authenticate with the shareable setup-token, not the ACL-bound login item.
23
23
  */
24
24
  export declare function resolveClaudeSetupToken(home?: string): string | null;
25
+ /**
26
+ * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
27
+ * any version home's `.claude.json`. This is what lets `agents accounts attach`
28
+ * provision a headless worker home that has never had an interactive login: the
29
+ * account's non-rotating setup-token is already fleet-synced in the file-based
30
+ * `auth` bundle, keyed by email ({@link claudeAccountTokenKey}), so we can write
31
+ * the home's `.oauth_token` from it without the circular
32
+ * "read the home's email to resolve the home's token" dependency that
33
+ * {@link resolveClaudeSetupToken} has. Same file-backed-only, fail-closed,
34
+ * fingerprint-stable read as the home-keyed path — it is the shared core.
35
+ *
36
+ * `cacheKey` scopes the process-local token cache; callers pass a version home
37
+ * so a home-keyed and email-keyed read of the same account share nothing stale.
38
+ */
39
+ export declare function resolveClaudeSetupTokenForEmail(email: string, cacheKey?: string): string | null;
40
+ /**
41
+ * Seed a keychain-less Linux worker's Claude version-home identity so an account's
42
+ * fleet-synced setup-token resolves for it. A worker home never had an interactive
43
+ * browser login, so its `.claude.json` carries no `oauthAccount.emailAddress` and
44
+ * the account reads "signed out" even though its non-rotating setup-token is present
45
+ * in the `auth` bundle. This writes ONLY the descriptive identity (the email), merged
46
+ * into both `.claude.json` locations Claude Code reads, preserving every other field.
47
+ * It never copies a rotating OAuth credential (`.credentials.json`) — the setup-token
48
+ * stays the credential of record.
49
+ */
50
+ export declare function seedClaudeWorkerHomeIdentity(versionHome: string, email: string): void;
@@ -76,12 +76,32 @@ export function readClaudeAccountEmail(home) {
76
76
  * authenticate with the shareable setup-token, not the ACL-bound login item.
77
77
  */
78
78
  export function resolveClaudeSetupToken(home) {
79
+ // Require a known account (email) up front: without it we cannot key a
80
+ // per-account token, and we must NOT fall back to a bare shared key that
81
+ // would misapply one account's setup-token to another.
82
+ const email = readClaudeAccountEmail(home);
83
+ if (!email)
84
+ return null;
85
+ return resolveClaudeSetupTokenForEmail(email, home ?? os.homedir());
86
+ }
87
+ /**
88
+ * Resolve a long-lived setup-token for an EXPLICIT account email, independent of
89
+ * any version home's `.claude.json`. This is what lets `agents accounts attach`
90
+ * provision a headless worker home that has never had an interactive login: the
91
+ * account's non-rotating setup-token is already fleet-synced in the file-based
92
+ * `auth` bundle, keyed by email ({@link claudeAccountTokenKey}), so we can write
93
+ * the home's `.oauth_token` from it without the circular
94
+ * "read the home's email to resolve the home's token" dependency that
95
+ * {@link resolveClaudeSetupToken} has. Same file-backed-only, fail-closed,
96
+ * fingerprint-stable read as the home-keyed path — it is the shared core.
97
+ *
98
+ * `cacheKey` scopes the process-local token cache; callers pass a version home
99
+ * so a home-keyed and email-keyed read of the same account share nothing stale.
100
+ */
101
+ export function resolveClaudeSetupTokenForEmail(email, cacheKey) {
79
102
  try {
80
- // Require a known account (email) up front: without it we cannot key a
81
- // per-account token, and we must NOT fall back to a bare shared key that
82
- // would misapply one account's setup-token to another.
83
- const email = readClaudeAccountEmail(home);
84
- if (!email)
103
+ const trimmed = email.trim();
104
+ if (!trimmed)
85
105
  return null;
86
106
  if (!bundleExists(AUTH_BUNDLE))
87
107
  return null;
@@ -92,25 +112,26 @@ export function resolveClaudeSetupToken(home) {
92
112
  // hint that the seeded setup-token was being ignored.
93
113
  throw new ReservedBundleWrongBackendError(AUTH_BUNDLE, backend);
94
114
  }
95
- const cacheKey = home ?? os.homedir();
96
- const item = secretsKeychainItem(AUTH_BUNDLE, claudeAccountTokenKey(email));
115
+ const key = claudeAccountTokenKey(trimmed);
116
+ const ck = cacheKey ?? `email:${trimmed}`;
117
+ const item = secretsKeychainItem(AUTH_BUNDLE, key);
97
118
  const credentialPath = fileStoreItemPath(item);
98
119
  for (let attempt = 0; attempt < 2; attempt++) {
99
120
  const before = credentialFingerprint(credentialPath);
100
- const cached = setupTokenCache.get(cacheKey);
121
+ const cached = setupTokenCache.get(ck);
101
122
  if (cached?.credentialPath === credentialPath && cached.fingerprint === before) {
102
123
  return cached.token;
103
124
  }
104
125
  if (before === 'missing') {
105
- setupTokenCache.set(cacheKey, { credentialPath, fingerprint: before, token: null });
126
+ setupTokenCache.set(ck, { credentialPath, fingerprint: before, token: null });
106
127
  return null;
107
128
  }
108
129
  const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
109
- const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
130
+ const v = (env[key] ?? '').trim();
110
131
  const token = v.length > 0 && isValidClaudeSetupToken(v) ? v : null;
111
132
  const after = credentialFingerprint(credentialPath);
112
133
  if (before === after) {
113
- setupTokenCache.set(cacheKey, { credentialPath, fingerprint: after, token });
134
+ setupTokenCache.set(ck, { credentialPath, fingerprint: after, token });
114
135
  return token;
115
136
  }
116
137
  }
@@ -124,3 +145,36 @@ export function resolveClaudeSetupToken(home) {
124
145
  return null;
125
146
  }
126
147
  }
148
+ /**
149
+ * Seed a keychain-less Linux worker's Claude version-home identity so an account's
150
+ * fleet-synced setup-token resolves for it. A worker home never had an interactive
151
+ * browser login, so its `.claude.json` carries no `oauthAccount.emailAddress` and
152
+ * the account reads "signed out" even though its non-rotating setup-token is present
153
+ * in the `auth` bundle. This writes ONLY the descriptive identity (the email), merged
154
+ * into both `.claude.json` locations Claude Code reads, preserving every other field.
155
+ * It never copies a rotating OAuth credential (`.credentials.json`) — the setup-token
156
+ * stays the credential of record.
157
+ */
158
+ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
159
+ const trimmed = email.trim();
160
+ if (!trimmed)
161
+ return;
162
+ for (const p of [
163
+ path.join(versionHome, '.claude', '.claude.json'),
164
+ path.join(versionHome, '.claude.json'),
165
+ ]) {
166
+ let doc = {};
167
+ try {
168
+ doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
169
+ }
170
+ catch {
171
+ // Missing or unreadable at this location — write a fresh minimal document.
172
+ }
173
+ const existing = (doc.oauthAccount && typeof doc.oauthAccount === 'object'
174
+ ? doc.oauthAccount
175
+ : {});
176
+ doc.oauthAccount = { ...existing, emailAddress: trimmed };
177
+ fs.mkdirSync(path.dirname(p), { recursive: true });
178
+ fs.writeFileSync(p, JSON.stringify(doc));
179
+ }
180
+ }
@@ -36,7 +36,7 @@ export function parseFamilyList(raw, flagName) {
36
36
  /** Command-churn event kinds. */
37
37
  export const COMMAND_EVENT_TYPES = ['command.start', 'command.end'];
38
38
  /** Run-dispatch outcome kinds (replaces the separate audit/log.jsonl product). */
39
- export const RUN_EVENT_TYPES = ['run.dispatched', 'agent.run.end'];
39
+ export const RUN_EVENT_TYPES = ['run.dispatched', 'run.launch', 'agent.run.end'];
40
40
  /**
41
41
  * Fold family include/exclude into a UnifiedQuery.
42
42
  * Precedence: family narrows sources/types; field filters (module, event, …)
@@ -1,4 +1,5 @@
1
- import type { AgentId, Mode } from './types.js';
1
+ import type { AgentId, Mode, RunStrategy } from './types.js';
2
+ import { type EventPayload } from './feed/events.js';
2
3
  import { type UsernsStatus } from './linux-userns.js';
3
4
  /**
4
5
  * Agent execution modes. Canonical name `skip` (dangerously skip permissions);
@@ -212,6 +213,31 @@ export interface ExecOptions {
212
213
  * session. Also forced off by AGENTS_NO_TMUX=1. No effect on headless runs.
213
214
  */
214
215
  raw?: boolean;
216
+ /**
217
+ * The run strategy that resolved this launch (pinned/available/balanced).
218
+ * Observability-only: threaded from the `agents run` command purely so the
219
+ * pre-launch `run.launch` event can record HOW the version was chosen. Never
220
+ * read by the spawn itself.
221
+ */
222
+ strategy?: RunStrategy;
223
+ /**
224
+ * How the launched version was resolved (e.g. 'pinned-default', 'rotated',
225
+ * 'explicit-pin'), when cheaply determinable. Observability-only, carried on
226
+ * `run.launch`. Optional — omitted when the caller can't attribute it.
227
+ */
228
+ resolvedVia?: string;
229
+ /**
230
+ * Precomputed launchable-signed-in verdict for the launched version, supplied
231
+ * by a caller that already computed it via the identical gate (a rotated pick
232
+ * carries `rotationResult.picked.signedIn`). When present, `run.launch` uses it
233
+ * instead of re-probing the version home — removing a double fs read on the hot
234
+ * path and any disagreement window. `undefined` means "not precomputed" (the
235
+ * pinned-default / shim paths), which makes `emitRunLaunch` fall back to
236
+ * {@link isVersionLaunchableHere}. Observability-only.
237
+ */
238
+ launchSignedIn?: boolean | null;
239
+ /** Precomputed account email companion to {@link launchSignedIn}. */
240
+ launchEmail?: string | null;
215
241
  }
216
242
  /**
217
243
  * Identity a custom-harness run stamps on env / pid-registry / sidecars.
@@ -550,6 +576,45 @@ export declare function writeTmuxEnvFile(env: NodeJS.ProcessEnv, filePath: strin
550
576
  * `[detached]` the pane-died hook otherwise leaves behind.
551
577
  */
552
578
  export declare function formatPaneTail(raw: string, maxLines?: number): string;
579
+ /**
580
+ * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
581
+ *
582
+ * Stderr is always piped so the caller can inspect it (e.g., for rate-limit
583
+ * detection) while also forwarding every chunk to process.stderr in real time --
584
+ * the user sees the same output they would with stdio: 'inherit'. Stdout keeps
585
+ * the original behavior: 'pipe' when downstream output is piped (so `agents
586
+ * run ... | ...` composes cleanly), otherwise 'inherit' so TTY output is
587
+ * unbuffered.
588
+ */
589
+ /** Inputs the pre-launch `run.launch` payload is built from. */
590
+ export interface RunLaunchInput {
591
+ agent: AgentId;
592
+ harnessName?: string;
593
+ /** The version being launched, or undefined when none could be resolved. */
594
+ version?: string;
595
+ strategy?: RunStrategy;
596
+ /**
597
+ * Whether the launched version is launchable-signed-in on THIS device
598
+ * ({@link isVersionLaunchableHere}). `null` when the verdict is unknown — a
599
+ * missing verdict must NOT be read as logged out.
600
+ */
601
+ signedIn: boolean | null;
602
+ /** Account email of the version home when signed in, else null. */
603
+ email: string | null;
604
+ /** How the version was resolved (explicit-pin / rotated / pinned-default). */
605
+ resolvedVia?: string;
606
+ }
607
+ /**
608
+ * Build the `run.launch` event payload. Pure and exported so the signedIn ->
609
+ * launchedLoggedOut mapping is unit-testable without spawning. Mirrors the
610
+ * buildRotationDecisionEvent / emitRotationDecision split in rotate.ts.
611
+ *
612
+ * `launchedLoggedOut` is the headline flag — true ONLY when `signedIn` was
613
+ * resolved to false (a launch into a logged-out version, the yosemite-m3 2.1.219
614
+ * incident). An UNKNOWN verdict (`signedIn === null`) is never treated as
615
+ * logged out.
616
+ */
617
+ export declare function buildRunLaunchPayload(input: RunLaunchInput): EventPayload;
553
618
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
554
619
  export declare const BUDGET_KILL_EXIT_CODE = 7;
555
620
  /**