@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 +31 -0
- package/dist/commands/accounts.d.ts +1 -1
- package/dist/commands/accounts.js +33 -7
- package/dist/commands/exec.js +40 -0
- package/dist/lib/accounting/rotate.d.ts +22 -0
- package/dist/lib/accounting/rotate.js +20 -0
- package/dist/lib/claude-account-token.d.ts +26 -0
- package/dist/lib/claude-account-token.js +65 -11
- package/dist/lib/event-families.js +1 -1
- package/dist/lib/exec.d.ts +66 -1
- package/dist/lib/exec.js +104 -8
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +5 -0
- package/dist/lib/feed-broadcast.js +9 -0
- package/dist/lib/identity/client.d.ts +5 -4
- package/dist/lib/identity/client.js +6 -5
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/digest.js +8 -2
- package/dist/lib/session/parse.d.ts +4 -0
- package/dist/lib/session/parse.js +16 -11
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +6 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
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
|
});
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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
|
|
96
|
-
const
|
|
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(
|
|
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(
|
|
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[
|
|
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(
|
|
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, …)
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
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. */
|
package/dist/lib/feed/events.js
CHANGED
|
@@ -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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
559
|
-
const
|
|
560
|
-
const re = /^\*\*\* (
|
|
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[
|
|
562
|
+
const p = m[2].trim();
|
|
563
563
|
if (p)
|
|
564
|
-
|
|
564
|
+
targets.push({ path: p, op: m[1] });
|
|
565
565
|
}
|
|
566
|
-
return
|
|
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
|
|
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 &&
|
|
751
|
-
for (const
|
|
752
|
-
emitOne(
|
|
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.
|
|
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",
|