@phnx-labs/agents-cli 1.22.63 → 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,36 @@
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
+
3
34
  ## 1.22.63
4
35
 
5
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,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).'));
@@ -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
  /**
package/dist/lib/exec.js CHANGED
@@ -14,7 +14,7 @@ import { parseTimeout } from './scheduling/routines.js';
14
14
  import { compareVersions, getBinaryPath, getVersionHomePath, isVersionInstalled, listInstalledVersions, resolveVersion } from './installations/versions.js';
15
15
  import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { isTierToken, resolveTier } from './model-tiers.js';
17
- import { createTimer, redactPrompt, redactArgs } from './feed/events.js';
17
+ import { emit, createTimer, redactPrompt, redactArgs } from './feed/events.js';
18
18
  import { sanitizeProcessEnv } from './secrets/bundles.js';
19
19
  import { resolveActor, actorEnv } from './actor.js';
20
20
  import { expandLocalHome } from './project-root.js';
@@ -1151,6 +1151,12 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1151
1151
  // buildExecEnv — `installations/shims.ts` generateShimScript).
1152
1152
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
1153
1153
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
1154
+ // Pre-launch marker for the SECOND live launch path: the Windows generated
1155
+ // `.cmd` shim delegates here and spawns the harness directly, so without this
1156
+ // a pinned, logged-out version launched via the shim would be invisible —
1157
+ // exactly the blind spot this event closes. Best-effort; re-derives the
1158
+ // signed-in verdict from the pinned version home (no rotated pick here).
1159
+ await emitRunLaunch({ agent, version, resolvedVia: 'shim' });
1154
1160
  return new Promise((resolve) => {
1155
1161
  const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
1156
1162
  // Record the launch so `ag sessions --active` can attribute the agent
@@ -1633,15 +1639,88 @@ function emitResolvedSessionId(options, launchId, childPid) {
1633
1639
  process.stdout.write(sessionIdMarkerLine(sessionId));
1634
1640
  }
1635
1641
  /**
1636
- * Spawn an agent process and return its exit code plus a tee'd copy of stderr.
1642
+ * Build the `run.launch` event payload. Pure and exported so the signedIn ->
1643
+ * launchedLoggedOut mapping is unit-testable without spawning. Mirrors the
1644
+ * buildRotationDecisionEvent / emitRotationDecision split in rotate.ts.
1637
1645
  *
1638
- * Stderr is always piped so the caller can inspect it (e.g., for rate-limit
1639
- * detection) while also forwarding every chunk to process.stderr in real time --
1640
- * the user sees the same output they would with stdio: 'inherit'. Stdout keeps
1641
- * the original behavior: 'pipe' when downstream output is piped (so `agents
1642
- * run ... | ...` composes cleanly), otherwise 'inherit' so TTY output is
1643
- * unbuffered.
1646
+ * `launchedLoggedOut` is the headline flag — true ONLY when `signedIn` was
1647
+ * resolved to false (a launch into a logged-out version, the yosemite-m3 2.1.219
1648
+ * incident). An UNKNOWN verdict (`signedIn === null`) is never treated as
1649
+ * logged out.
1644
1650
  */
1651
+ export function buildRunLaunchPayload(input) {
1652
+ return {
1653
+ module: 'run',
1654
+ agent: input.agent,
1655
+ ...(input.harnessName ? { harnessName: input.harnessName } : {}),
1656
+ // Omitted only when no version could be resolved at all — the typed `version`
1657
+ // field can't carry null, and an absent version is honest.
1658
+ version: input.version,
1659
+ strategy: input.strategy ?? null,
1660
+ signedIn: input.signedIn,
1661
+ launchedLoggedOut: input.signedIn === false,
1662
+ email: input.email,
1663
+ ...(input.resolvedVia ? { resolvedVia: input.resolvedVia } : {}),
1664
+ };
1665
+ }
1666
+ /**
1667
+ * Emit the pre-launch `run.launch` observability event just before the harness
1668
+ * child is spawned. Best-effort by construction — the whole point is that it can
1669
+ * NEVER break a launch, so every step (the signed-in probe, the emit itself) is
1670
+ * wrapped and a failure is swallowed. Mirrors the non-fatal pattern of
1671
+ * recordDispatchedRun and emitRotationDecision.
1672
+ *
1673
+ * The `signedIn` verdict is for the SPECIFIC launched version on THIS device, so
1674
+ * `launchedLoggedOut` catches a launch into a logged-out version that
1675
+ * `--device auto` readiness let through (the yosemite-m3 2.1.219 incident). It is
1676
+ * REUSED from `ctx.launchSignedIn` when the caller already computed it via the
1677
+ * identical gate (the rotated pick — no double fs read, no disagreement window),
1678
+ * and only otherwise re-derived via {@link isVersionLaunchableHere} (dynamically
1679
+ * imported so the spawn path takes on no static dependency on the accounting
1680
+ * layer).
1681
+ */
1682
+ async function emitRunLaunch(ctx) {
1683
+ try {
1684
+ let signedIn;
1685
+ let email;
1686
+ if (ctx.launchSignedIn !== undefined) {
1687
+ // Caller already computed the verdict for this exact version via the same
1688
+ // gate — reuse it verbatim.
1689
+ signedIn = ctx.launchSignedIn;
1690
+ email = ctx.launchEmail ?? null;
1691
+ }
1692
+ else {
1693
+ // Pinned-default / shim paths: probe the version home now. Without a
1694
+ // concrete version we cannot probe, so leave the verdict unknown (null)
1695
+ // rather than guess.
1696
+ signedIn = null;
1697
+ email = null;
1698
+ if (ctx.version) {
1699
+ try {
1700
+ const { isVersionLaunchableHere } = await import('./accounting/rotate.js');
1701
+ const state = await isVersionLaunchableHere(ctx.agent, ctx.version);
1702
+ signedIn = state.launchable;
1703
+ email = state.email;
1704
+ }
1705
+ catch {
1706
+ /* probe unavailable — record the launch without the signed-in verdict */
1707
+ }
1708
+ }
1709
+ }
1710
+ emit('run.launch', buildRunLaunchPayload({
1711
+ agent: ctx.agent,
1712
+ harnessName: ctx.harnessName,
1713
+ version: ctx.version,
1714
+ strategy: ctx.strategy,
1715
+ signedIn,
1716
+ email,
1717
+ resolvedVia: ctx.resolvedVia,
1718
+ }));
1719
+ }
1720
+ catch {
1721
+ /* observability must never break a launch */
1722
+ }
1723
+ }
1645
1724
  async function spawnAgent(options) {
1646
1725
  // Assign a known session id up front for agents that accept one, so the
1647
1726
  // launcher can record an EXACT pid -> session mapping (see pid-registry) —
@@ -1758,6 +1837,23 @@ async function spawnAgent(options) {
1758
1837
  timer.end({ exitCode: 1, status: 'failed', error: 'remote interactive run has no tmux for durability' });
1759
1838
  return { exitCode: 1, stdout: '', stderr: msg };
1760
1839
  }
1840
+ // Pre-launch marker (`run.launch`), fired on THIS device right before the
1841
+ // harness child is spawned — for BOTH the tmux-wrapped and bare paths below,
1842
+ // and only once the undurable refusal above has passed (a refused run never
1843
+ // spawns). Unlike `run.dispatched` (recordDispatchedRun, at FINALIZE), this
1844
+ // records a launch that then sits stuck at a login screen and never finalizes,
1845
+ // so a launch into a logged-out version is visible instead of silent. The
1846
+ // signed-in verdict is REUSED from the rotated pick when the command threaded
1847
+ // it (options.launchSignedIn), else re-derived from the version home.
1848
+ await emitRunLaunch({
1849
+ agent: options.agent,
1850
+ harnessName: options.harnessName,
1851
+ version: options.version ?? resolveVersion(options.agent, options.cwd || process.cwd()) ?? undefined,
1852
+ strategy: options.strategy,
1853
+ resolvedVia: options.resolvedVia,
1854
+ launchSignedIn: options.launchSignedIn,
1855
+ launchEmail: options.launchEmail,
1856
+ });
1761
1857
  if (tmuxWrap.kind === 'wrap') {
1762
1858
  timer.mark('startup');
1763
1859
  try {
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from '../actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'run.dispatched' | 'run.launch' | 'daemon.start' | 'daemon.stop' | 'daemon.error' | 'daemon.info' | 'routine.start' | 'routine.end' | 'watchdog.action' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'rotation.unresolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.failed' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -121,6 +121,7 @@ const FILE_MODE = 0o600;
121
121
  const EVENT_TYPE_TABLE = {
122
122
  'agent.run.start': true, 'agent.run.end': true, 'agent.spawn.start': true, 'agent.spawn.end': true,
123
123
  'run.dispatched': true,
124
+ 'run.launch': true,
124
125
  'daemon.start': true, 'daemon.stop': true, 'daemon.error': true, 'daemon.info': true,
125
126
  'routine.start': true, 'routine.end': true,
126
127
  'watchdog.action': true,
@@ -158,6 +159,10 @@ export function isEventType(value) {
158
159
  const AUDIT_EVENTS = new Set([
159
160
  'command.start', 'command.end',
160
161
  'run.dispatched',
162
+ // Sibling of run.dispatched, and the MORE reliable signal for a stuck /
163
+ // logged-out launch (it fires pre-spawn, before an agent can hang at a login
164
+ // screen and never finalize). Must surface under --level audit --include runs.
165
+ 'run.launch',
161
166
  'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view', 'secrets.lease-denied', 'secrets.lease-expire',
162
167
  'secrets.set', 'secrets.delete', 'secrets.rename',
163
168
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
@@ -38,6 +38,7 @@ import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } fro
38
38
  import { lookupTransport } from './channels/resolve.js';
39
39
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
40
  import { sendToOwner } from './notify.js';
41
+ import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
41
42
  const LEVEL_RANK = { milestone: 0, important: 1 };
42
43
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
43
44
  export function parseFeedPostLevel(raw) {
@@ -500,6 +501,14 @@ async function runChannelSink(sink, meta) {
500
501
  const result = await deliverEnvelope(resolved.envelope, meta);
501
502
  if (result.ok)
502
503
  return { name, ok: true };
504
+ // Explicit rush-backed channel sinks need the same cross-device handoff as
505
+ // the owner alias. Linux workers do not carry the macOS Keychain-bound Rush
506
+ // transport, but feed posts originate on those workers routinely. Keep the
507
+ // destination explicit so the peer delivers exactly this sink once instead
508
+ // of expanding the owner's multi-channel policy.
509
+ const forwarded = await forwardOwnerNotifyToPeer(resolved.envelope.text, resolved.envelope.channel, resolved.envelope.to, meta);
510
+ if (forwarded?.ok)
511
+ return { name, ok: true };
503
512
  return { name, ok: false, error: result.error };
504
513
  }
505
514
  /**
@@ -17,11 +17,12 @@
17
17
  /**
18
18
  * Where the account backend lives. Config, never a literal at a call site.
19
19
  *
20
- * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
21
- * rather than a vanity hostname because no custom domain is attached yet — and
22
- * a default naming an unregistered domain is worse than an ugly one: every
23
- * `agents auth login` would fail DNS with nothing to point at.
20
+ * The default is Phoenix ID's branded custom domain. The legacy workers.dev
21
+ * hostname remains live for already-released clients, but new clients and
22
+ * managed Workers must share this canonical base so token verification cannot
23
+ * drift across the release boundary.
24
24
  */
25
+ export declare const DEFAULT_PHOENIX_ID_BASE = "https://id.byphoenix.com";
25
26
  export declare const PHOENIX_ID_BASE: string;
26
27
  /** Our own session file. agents-cli never reads another product's credentials. */
27
28
  export declare function sessionFilePath(): string;
@@ -20,12 +20,13 @@ import { getRuntimeStateDir } from '../state.js';
20
20
  /**
21
21
  * Where the account backend lives. Config, never a literal at a call site.
22
22
  *
23
- * The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
24
- * rather than a vanity hostname because no custom domain is attached yet — and
25
- * a default naming an unregistered domain is worse than an ugly one: every
26
- * `agents auth login` would fail DNS with nothing to point at.
23
+ * The default is Phoenix ID's branded custom domain. The legacy workers.dev
24
+ * hostname remains live for already-released clients, but new clients and
25
+ * managed Workers must share this canonical base so token verification cannot
26
+ * drift across the release boundary.
27
27
  */
28
- export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? 'https://phoenix-id.muqsitnawaz.workers.dev';
28
+ export const DEFAULT_PHOENIX_ID_BASE = 'https://id.byphoenix.com';
29
+ export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? DEFAULT_PHOENIX_ID_BASE;
29
30
  /** Our own session file. agents-cli never reads another product's credentials. */
30
31
  export function sessionFilePath() {
31
32
  return path.join(getRuntimeStateDir(), 'phoenix-session.json');
@@ -55,7 +55,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
55
55
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
56
56
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
57
57
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
58
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
58
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'account' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness' | 'tokenCount' | 'durationMs' | 'subAgentCount'>;
59
59
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
60
60
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
61
61
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -205,6 +205,15 @@ export interface ActiveSession {
205
205
  spawnedTeam?: string;
206
206
  /** Files/screenshots attached to the session prompt. */
207
207
  attachments?: SessionAttachment[];
208
+ /** Total tokens and elapsed duration from the indexed transcript. */
209
+ tokenCount?: number;
210
+ durationMs?: number;
211
+ /** Number of subagents launched, persisted by the index scanner. */
212
+ subAgentCount?: number;
213
+ /** Durable documents created in the live transcript tail. */
214
+ artifacts?: import('./highlights.js').ProducedArtifact[];
215
+ /** Created plan document singled out for plan-first consumers. */
216
+ planFile?: string;
208
217
  sessionFile?: string;
209
218
  startedAtMs?: number;
210
219
  /**
@@ -164,6 +164,12 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
164
164
  s.routineName = m.routineName;
165
165
  if (!s.harness && m.harness)
166
166
  s.harness = m.harness;
167
+ if (s.tokenCount == null && m.tokenCount != null)
168
+ s.tokenCount = m.tokenCount;
169
+ if (s.durationMs == null && m.durationMs != null)
170
+ s.durationMs = m.durationMs;
171
+ if (s.subAgentCount == null && m.subAgentCount != null)
172
+ s.subAgentCount = m.subAgentCount;
167
173
  }
168
174
  }
169
175
  function loadBackfillMetaFor(sessions) {
@@ -851,6 +857,8 @@ function applyState(base, state, fallbackFile, pidAlive) {
851
857
  createdTickets: state.createdTickets,
852
858
  spawnedTeam: state.spawnedTeam,
853
859
  attachments: state.attachments,
860
+ artifacts: state.artifacts,
861
+ planFile: state.planFile,
854
862
  rateLimited: state.rateLimited,
855
863
  };
856
864
  }
@@ -98,9 +98,15 @@ export function classifyFileChanges(events) {
98
98
  deleted.delete(p); // a write after a delete recreates the file
99
99
  }
100
100
  else if (EDIT_TOOLS.has(tool)) {
101
- modified.add(p);
101
+ if (args.patch_op === 'Add')
102
+ created.add(p);
103
+ else if (args.patch_op === 'Delete')
104
+ deleted.add(p);
105
+ else
106
+ modified.add(p);
102
107
  seen.add(p);
103
- deleted.delete(p);
108
+ if (args.patch_op !== 'Delete')
109
+ deleted.delete(p);
104
110
  }
105
111
  }
106
112
  const out = [];
@@ -70,6 +70,10 @@ export declare function parseCodex(filePath: string): SessionEvent[];
70
70
  * Returns every path in order (a multi-file patch emits multiple paths so
71
71
  * artifact discovery sees each file — RUSH-1410). Empty when unparseable.
72
72
  */
73
+ export declare function applyPatchTargets(input: string): Array<{
74
+ path: string;
75
+ op: 'Add' | 'Update' | 'Delete';
76
+ }>;
73
77
  export declare function applyPatchTargetPaths(input: string): string[];
74
78
  /**
75
79
  * Parse Codex JSONL *content* (already read into a string) into normalized
@@ -555,15 +555,18 @@ export function parseCodex(filePath) {
555
555
  * Returns every path in order (a multi-file patch emits multiple paths so
556
556
  * artifact discovery sees each file — RUSH-1410). Empty when unparseable.
557
557
  */
558
- export function applyPatchTargetPaths(input) {
559
- const paths = [];
560
- const re = /^\*\*\* (?:Update|Add|Delete) File: (.+)$/gm;
558
+ export function applyPatchTargets(input) {
559
+ const targets = [];
560
+ const re = /^\*\*\* (Update|Add|Delete) File: (.+)$/gm;
561
561
  for (const m of input.matchAll(re)) {
562
- const p = m[1].trim();
562
+ const p = m[2].trim();
563
563
  if (p)
564
- paths.push(p);
564
+ targets.push({ path: p, op: m[1] });
565
565
  }
566
- return paths;
566
+ return targets;
567
+ }
568
+ export function applyPatchTargetPaths(input) {
569
+ return applyPatchTargets(input).map((target) => target.path);
567
570
  }
568
571
  /** @deprecated Prefer applyPatchTargetPaths — kept for single-file call sites. */
569
572
  function applyPatchTargetPath(input) {
@@ -724,13 +727,15 @@ export function parseCodexContent(content) {
724
727
  const execCommand = rawName === 'exec' ? codexExecCommand(input) : undefined;
725
728
  // Multi-file patches: one tool_use per file so artifact discovery sees
726
729
  // every path (RUSH-1410). Single-file / non-patch keep one event.
727
- const patchPaths = isApplyPatch ? applyPatchTargetPaths(input) : [];
730
+ const patchTargets = isApplyPatch ? applyPatchTargets(input) : [];
728
731
  const tool = isApplyPatch ? 'Edit' : rawName;
729
732
  const truncatedInput = input.length > 500 ? input.slice(0, 497) + '...' : input;
730
- const emitOne = (patchPath) => {
733
+ const emitOne = (patchPath, patchOp) => {
731
734
  const args = { input: truncatedInput };
732
735
  if (patchPath)
733
736
  args.file_path = patchPath;
737
+ if (patchOp)
738
+ args.patch_op = patchOp;
734
739
  if (execCommand)
735
740
  args.command = execCommand;
736
741
  const callId = payload.call_id || payload.id;
@@ -747,9 +752,9 @@ export function parseCodexContent(content) {
747
752
  path: patchPath,
748
753
  });
749
754
  };
750
- if (isApplyPatch && patchPaths.length > 0) {
751
- for (const p of patchPaths)
752
- emitOne(p);
755
+ if (isApplyPatch && patchTargets.length > 0) {
756
+ for (const target of patchTargets)
757
+ emitOne(target.path, target.op);
753
758
  }
754
759
  else {
755
760
  emitOne(undefined);
@@ -15,6 +15,7 @@
15
15
  * shape + mtime — same function, driven off the normalized events.
16
16
  */
17
17
  import type { SessionAttachment, SessionEvent, TodoItem, TodoProgress } from './types.js';
18
+ import { type ProducedArtifact } from './highlights.js';
18
19
  export type { TodoItem, TodoProgress };
19
20
  export type SessionActivity = 'working' | 'waiting_input' | 'idle';
20
21
  export type AwaitingReason = 'question' | 'plan_review' | 'permission';
@@ -95,6 +96,10 @@ export interface SessionState {
95
96
  * checklist, notably for remote/device-dispatched agents with no local stream.
96
97
  */
97
98
  todos?: TodoProgress;
99
+ /** Durable documents created in the bounded transcript slice. */
100
+ artifacts?: ProducedArtifact[];
101
+ /** First created plan document, for consumers that give plans special treatment. */
102
+ planFile?: string;
98
103
  /** Last few assistant turns (most-recent last), one line each — panel context. */
99
104
  tail?: string[];
100
105
  lastActivityMs?: number;
@@ -17,6 +17,8 @@
17
17
  import * as path from 'path';
18
18
  import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './parse.js';
19
19
  import { isShellExecTool } from './shell-programs.js';
20
+ import { classifyFileChanges } from './digest.js';
21
+ import { extractArtifacts } from './highlights.js';
20
22
  /**
21
23
  * Detect per-session rate-limit / usage-limit signals in assistant or error
22
24
  * text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
@@ -626,6 +628,8 @@ export function inferSessionState(events, ctx = {}) {
626
628
  const state = inferActivity(events, ctx);
627
629
  const { pr, ticket, createdTickets, spawnedTeam, attachments } = detectDurableSignals(events);
628
630
  const worktree = detectWorktree(ctx.cwd, ctx.gitBranch);
631
+ const artifacts = extractArtifacts(classifyFileChanges(events));
632
+ const planFile = artifacts.find((artifact) => artifact.bucket === 'plans')?.path;
629
633
  // Rate-limit: scan the most recent assistant messages + tool errors (tail-first).
630
634
  let rateLimited = false;
631
635
  for (let i = events.length - 1; i >= 0 && i >= events.length - 12; i--) {
@@ -651,6 +655,8 @@ export function inferSessionState(events, ctx = {}) {
651
655
  createdTickets,
652
656
  spawnedTeam,
653
657
  attachments,
658
+ artifacts: artifacts.length > 0 ? artifacts : undefined,
659
+ planFile,
654
660
  rateLimited: rateLimited || undefined,
655
661
  };
656
662
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.63",
3
+ "version": "1.22.64",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",