@phnx-labs/agents-cli 1.22.91 → 1.22.93
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 +21 -0
- package/README.md +5 -0
- package/dist/commands/exec.js +6 -1
- package/dist/lib/claude-account-token.d.ts +13 -0
- package/dist/lib/claude-account-token.js +29 -0
- package/dist/lib/daemon/attention-notify-service.d.ts +5 -2
- package/dist/lib/daemon/attention-notify-service.js +9 -2
- package/dist/lib/exec-account-home.d.ts +19 -0
- package/dist/lib/exec-account-home.js +35 -1
- package/dist/lib/exec.js +11 -0
- package/dist/lib/feed/answer.js +9 -0
- package/dist/lib/feed/attention.d.ts +18 -2
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +6 -2
- package/dist/lib/feed/feed.js +10 -4
- package/dist/lib/harness/adapter.d.ts +23 -0
- package/dist/lib/harness/adapter.js +29 -0
- package/dist/lib/harness/adapters/claude.js +5 -3
- package/dist/lib/harness/adapters/copilot.js +4 -2
- package/dist/lib/harness/adapters/grok.js +4 -2
- package/dist/lib/harness/adapters/kimi.js +4 -2
- package/dist/lib/harness/adapters/muse.js +4 -3
- package/dist/lib/harness/adapters/opencode.js +4 -2
- package/dist/lib/helper-download.js +1 -1
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/installations/shims.d.ts +2 -2
- package/dist/lib/installations/shims.js +16 -8
- package/dist/lib/menubar/download-menubar.js +4 -1
- package/dist/lib/menubar/install-menubar.d.ts +10 -9
- package/dist/lib/menubar/install-menubar.js +17 -15
- package/dist/lib/secrets-policy.d.ts +5 -2
- package/dist/lib/secrets-policy.js +18 -4
- package/dist/lib/session/active.d.ts +18 -11
- package/dist/lib/session/active.js +52 -44
- package/dist/lib/session/session-cache.d.ts +1 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/state.d.ts +26 -0
- package/dist/lib/session/state.js +43 -27
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.93
|
|
4
|
+
|
|
5
|
+
- **An idle reminder is not a permission request — attention is classified from explicit harness evidence, and time-based signals expire (PHNX-3999).** The fleet feed was rendering Claude's `idle_prompt` Notification ("the turn ended and you have been idle") as a `permission` with Approve/Deny — a session whose whole transcript was "reply with exactly: pong" → "pong" got an approval banner — and the state engine labelled any tool call that had run quietly for two minutes a permission too. Now a `notification` block is a `permission` only when its recorded subtype is `permission_prompt` and the session's transcript corroborates it (no tool result or new turn stamped after the block, process alive); the feed-publish hook no longer publishes `idle_prompt` at all, an `idle_prompt` block left on disk yields no request, an `elicitation_dialog` is a question with no invented buttons, and a pending tool call reads as `working` however long it has run. One unresolved record per `host/session/generation` is resolved by later evidence — a transcript event after the block, or a dead process — never by dismissing the banner. What the CLI cannot confirm (a stale hook block with no transcript cursor to check, a notification with no recorded subtype, an older peer's elapsed-time `permission` claim) is the new `unverified` kind: the banner and the `feed watch` stream say "Could not verify request" with an Open terminal action only, and `agents feed answer` refuses it. `computeLiveSignals` now memoizes only the parsed transcript tail on mtime and re-classifies against the current clock every call, so the 30-minute prose-question decay expires on schedule instead of freezing until someone types; rows carry `lastEventMs`, the harness stamp on the last meaningful event. Source: `cli/src/lib/feed/attention.ts`, `cli/src/lib/session/state.ts`, `cli/src/lib/session/active.ts`, `cli/src/lib/feed/feed.ts`, `cli/src/lib/feed/answer.ts`, `cli/src/lib/daemon/attention-notify-service.ts`.
|
|
6
|
+
|
|
7
|
+
- **AGI Menu 1.1.1: three fixed tabs (PHNX-3999, PHNX-4036).** The `menubar` helper floor moves to 1.1.1, the first release cut from phnx-labs/agi-menu: the status item opens a popover with Sessions (working first, then confirmed requests), Projects (the tracker's projects → milestones with cycle chips → issues, expanding in place, from one `linear projects overview --json` call) and Settings, replacing the long dropdown. Fresh installs and `agents menubar setup` fetch it from the public `menubar/v1.1.1` release. Source: `cli/src/lib/helper-versions.ts`.
|
|
8
|
+
|
|
9
|
+
- **AGI Menu moved out — the menu-bar helper now lives in `phnx-labs/agi-menu` (PHNX-4036).** `cli/menubar/` (the SwiftPM source, its build/test scripts, and the build-text test) is gone from this repo, the same split AGI EXT got in RUSH-3189. Nothing user-facing changes: `agents menubar setup`/`enable` still download and verify the signed, notarized `MenubarHelper.app.zip` from the `menubar/v<floor>` release tag (`src/lib/helper-versions.ts`), at the same URL, with the same bundle identity and designated-requirement pin — that release is now cut by agi-menu's `scripts/release.sh`, which also uploads a `menubar-source.txt` provenance sidecar. Release tooling never builds the helper again: a new `scripts/stage-menubar-helper.sh` downloads the published `menubar/v<floor>` asset, verifies its sha256, and (on macOS) extracts it into `bin/MenubarHelper.app` behind `codesign --verify --deep --strict`, `spctl --assess`, and the DR-pin gate (`scripts/verify-menubar-helper.sh`, no longer a prepack gate); `--fetch-only` downloads and sha-verifies on any OS. `release-attestation-produce.sh --with-helpers` records the menubar helper-manifest row from that published asset (asset digest = sha256 of the zip, `source` = the sidecar's repo/commit/tag when the release carries one) instead of a fresh build, keyed on `src/lib/helper-versions.ts` as its input — a floor bump is what re-records it; a missing or corrupt release fails closed naming the agi-menu publish step. `remote-sign-mac.sh` stages the published helper on the home base instead of running `swift build`. The installer's dead `cli/menubar/dist/` source candidate is removed; `bin/MenubarHelper.app` (staged, or copied from an agi-menu checkout) remains the working-tree source. Source: `cli/scripts/stage-menubar-helper.sh`, `cli/scripts/release-manifest.sh`, `cli/scripts/release-attestation-produce.sh`, `cli/scripts/remote-sign-mac.sh`, `cli/src/lib/menubar/download-menubar.ts`.
|
|
10
|
+
|
|
11
|
+
- **A Claude account-slot launch now runs in its own slot home, and worker slots come pre-onboarded (PHNX-3940 follow-up).** `agents run claude --device <worker>` picked an account slot and pinned `CLAUDE_CONFIG_DIR` to it, but the versioned alias (`~/.agents/.cache/shims/claude@<version>`) re-exported the shared version home unconditionally, so the run authenticated as the picked account (the setup-token env survived) while reading and writing config, onboarding state and history in whatever home the version carried — on yosemite-m1 that home belonged to another account and had never onboarded, so every dispatched run opened on Claude Code's theme picker. `buildExecEnv` now stamps a slot launch in `AGENTS_EXEC_HOME`; every shim that pins a config-dir env (claude, grok, opencode, kimi, muse, copilot — bare shim and direct alias, schema 32 / alias 21 — the daemon self-heal or `agents doctor --fix` regenerates on-disk copies) yields its pin to that slot through one shared `slotAwareConfigEnvBash` and consumes the marker before `exec`, so a nested launch never inherits its parent's slot and a bare `<agent>@<version>` from a terminal still gets the version home; the same override was live for all six, codex and cursor already yielded. A re-seed that still reads as unseeded (a slot config file that exists but cannot be parsed) is reported as an error each tick instead of a false "provisioned". Separately, `provisionWorkerSlot` seeded only the identity email into a slot's `.claude.json`, never `hasCompletedOnboarding` — a worker has no human to finish onboarding — so every provisioned slot re-onboarded; it now seeds both, and the daemon's `reconcileLocalWorkerSlots` re-provisions a durable claude slot that lacks either instead of skipping it as "already provisioned", so the slots provisioned before this converge on the next tick. Source: `cli/src/lib/harness/adapter.ts`, `cli/src/lib/harness/adapters/*.ts`, `cli/src/lib/exec.ts`, `cli/src/lib/installations/shims.ts`, `cli/src/lib/claude-account-token.ts`, `cli/src/lib/secrets-policy.ts`, `cli/docs/credential-management.md`.
|
|
12
|
+
|
|
13
|
+
## 1.22.92
|
|
14
|
+
|
|
15
|
+
- **AGI Menu 1.1.0 is the helper the CLI installs (PHNX-4007).** The menu-bar
|
|
16
|
+
helper floor moves from 1.0.0 to 1.1.0, so `agents menubar setup` and
|
|
17
|
+
`agents menubar enable` download and install the release that carries the
|
|
18
|
+
dispatch form, the Sessions window, actionable notification banners, the
|
|
19
|
+
screenshot OCR index, and the FeedStream data layer (PHNX-3999). The startup
|
|
20
|
+
self-heal stays network-free: it reinstalls only from a bundled or cached copy,
|
|
21
|
+
so an existing 1.0.0 install upgrades on the next `agents menubar setup`.
|
|
22
|
+
Source: `src/lib/helper-versions.ts`.
|
|
23
|
+
|
|
3
24
|
## 1.22.91
|
|
4
25
|
|
|
5
26
|
- **`agents sessions` search/list execs the standalone `sessions` CLI (PHNX-4012).** Read queries intercept in `index.ts` before bootstrap so they skip the 6K-line in-process engine. Lifecycle verbs (`resume`, `stop`, `inject`, `watch`, `--active`, `--markdown`) still load the in-repo command. The `sessions` alias shim is retired like `secrets` so it cannot recurse. Install: `npm i -g @phnx-labs/sessions-cli`. Source: `cli/src/index.ts`, `cli/src/lib/sessions-client.ts`, `cli/scripts/postinstall.js`.
|
package/README.md
CHANGED
|
@@ -1577,6 +1577,11 @@ duplicate icon. `agents menubar setup` is the recovery command when a machine is
|
|
|
1577
1577
|
already wrong -- it ends any duplicate, installs the bundle, wires the login
|
|
1578
1578
|
item, and verifies exactly one helper came back up.
|
|
1579
1579
|
|
|
1580
|
+
The helper itself is AGI Menu, developed in
|
|
1581
|
+
[phnx-labs/agi-menu](https://github.com/phnx-labs/agi-menu) and published as a
|
|
1582
|
+
signed, notarized build on this repo's `menubar/v<x.y.z>` releases; the CLI
|
|
1583
|
+
downloads and verifies it on demand, so nothing is built on your machine.
|
|
1584
|
+
|
|
1580
1585
|
The dropdown surfaces a **NEEDS YOU** queue (agents waiting on a question, a plan review, or a permission prompt), the running roster, and a routines summary -- the same live state as `agents sessions --active`, one click away.
|
|
1581
1586
|
|
|
1582
1587
|
### Quick-issue bar (⌘⇧O)
|
package/dist/commands/exec.js
CHANGED
|
@@ -2234,7 +2234,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2234
2234
|
// dispatch time; the peer resolves ITS slot. This block is local-only.
|
|
2235
2235
|
const remoteTarget = options.host || options.device;
|
|
2236
2236
|
if (!remoteTarget) {
|
|
2237
|
-
const { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } = await import('../lib/exec-account-home.js');
|
|
2237
|
+
const { adoptedConfigPointsAtHome, adoptedSymlinkMismatchError, durableSlotEnv, isSymlinkAdoptedHarness, resolveNativeSpawnHome, symlinkAdoptedAccountError, } = await import('../lib/exec-account-home.js');
|
|
2238
2238
|
const { readMeta } = await import('../lib/state.js');
|
|
2239
2239
|
const meta = readMeta();
|
|
2240
2240
|
if (isSymlinkAdoptedHarness(agent)) {
|
|
@@ -2251,6 +2251,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2251
2251
|
process.exit(1);
|
|
2252
2252
|
}
|
|
2253
2253
|
execHome = resolved.execHome;
|
|
2254
|
+
// A worker's durable api-key slot holds no file: the pushed key
|
|
2255
|
+
// rides the harness env var (CURSOR_API_KEY, …) on this launch.
|
|
2256
|
+
const durable = durableSlotEnv(agent, spawnAccount, resolved, meta);
|
|
2257
|
+
if (Object.keys(durable).length > 0)
|
|
2258
|
+
accountEnv = { ...accountEnv, ...durable };
|
|
2254
2259
|
if (resolved.source === 'legacy-home') {
|
|
2255
2260
|
accountConfigVersion = resolved.label;
|
|
2256
2261
|
// A leftover version-labeled home is both the spawn HOME and
|
|
@@ -91,4 +91,17 @@ export declare function readReservedCredential(bundle: string, key: string): str
|
|
|
91
91
|
* token on this device rather than recording a slot that cannot authenticate.
|
|
92
92
|
*/
|
|
93
93
|
export declare function provisionWorkerSlot(account: NativeAccountRecord): DeviceAccountSlot;
|
|
94
|
+
/**
|
|
95
|
+
* True when a claude worker slot carries everything provisioning seeds: the
|
|
96
|
+
* identity email AND the completed-onboarding flag. A slot provisioned before
|
|
97
|
+
* onboarding was seeded answers false, so the daemon's reconcile re-seeds it.
|
|
98
|
+
*/
|
|
99
|
+
export declare function isClaudeWorkerHomeSeeded(home: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Seed a worker slot's `.claude.json` (both locations Claude Code reads) with
|
|
102
|
+
* the account identity AND `hasCompletedOnboarding`. A worker never has a human
|
|
103
|
+
* at it, so nothing else can complete Claude Code's first-run onboarding (theme
|
|
104
|
+
* picker, "Let's get started"); without the flag every slot launch re-onboarded.
|
|
105
|
+
* Everything else in the document is preserved.
|
|
106
|
+
*/
|
|
94
107
|
export declare function seedClaudeWorkerHomeIdentity(versionHome: string, email: string): void;
|
|
@@ -366,6 +366,34 @@ export function provisionWorkerSlot(account) {
|
|
|
366
366
|
recordSlot(account.id, record);
|
|
367
367
|
return record;
|
|
368
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* True when a claude worker slot carries everything provisioning seeds: the
|
|
371
|
+
* identity email AND the completed-onboarding flag. A slot provisioned before
|
|
372
|
+
* onboarding was seeded answers false, so the daemon's reconcile re-seeds it.
|
|
373
|
+
*/
|
|
374
|
+
export function isClaudeWorkerHomeSeeded(home) {
|
|
375
|
+
for (const p of [path.join(home, '.claude', '.claude.json'), path.join(home, '.claude.json')]) {
|
|
376
|
+
try {
|
|
377
|
+
const doc = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
378
|
+
const email = doc.oauthAccount?.emailAddress;
|
|
379
|
+
if (doc.hasCompletedOnboarding !== true)
|
|
380
|
+
return false;
|
|
381
|
+
if (typeof email !== 'string' || email.trim().length === 0)
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Seed a worker slot's `.claude.json` (both locations Claude Code reads) with
|
|
392
|
+
* the account identity AND `hasCompletedOnboarding`. A worker never has a human
|
|
393
|
+
* at it, so nothing else can complete Claude Code's first-run onboarding (theme
|
|
394
|
+
* picker, "Let's get started"); without the flag every slot launch re-onboarded.
|
|
395
|
+
* Everything else in the document is preserved.
|
|
396
|
+
*/
|
|
369
397
|
export function seedClaudeWorkerHomeIdentity(versionHome, email) {
|
|
370
398
|
const trimmed = email.trim();
|
|
371
399
|
if (!trimmed)
|
|
@@ -389,6 +417,7 @@ export function seedClaudeWorkerHomeIdentity(versionHome, email) {
|
|
|
389
417
|
? doc.oauthAccount
|
|
390
418
|
: {});
|
|
391
419
|
doc.oauthAccount = { ...existing, emailAddress: trimmed };
|
|
420
|
+
doc.hasCompletedOnboarding = true;
|
|
392
421
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
393
422
|
// Temp-write + rename: a reader mid-write never sees a truncated doc.
|
|
394
423
|
const tmp = `${p}.agents-${process.pid}.tmp`;
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* banner carries the category, the attention key, the session id, and the
|
|
8
8
|
* answerable choices, so the macOS companion can offer Approve / Approve for
|
|
9
9
|
* session / Deny (permission), the options plus a typed reply (question),
|
|
10
|
-
* Approve / Send back (plan review), or Open terminal (a stall/failure
|
|
11
|
-
*
|
|
10
|
+
* Approve / Send back (plan review), or Open terminal (a stall/failure, or a
|
|
11
|
+
* request the CLI could not verify is still pending), and route the answer back
|
|
12
|
+
* through `agents feed answer <key> --choice <id>`. The kind — and so the button
|
|
13
|
+
* set — is the reconciler's verdict from explicit harness evidence; an idle
|
|
14
|
+
* reminder never reaches here as a permission (PHNX-3999).
|
|
12
15
|
*
|
|
13
16
|
* Idempotency is a filesystem ledger, not memory: one sidecar file per notified
|
|
14
17
|
* key under `~/.agents/.history/feed/notified/`, so a daemon restart never
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* banner carries the category, the attention key, the session id, and the
|
|
8
8
|
* answerable choices, so the macOS companion can offer Approve / Approve for
|
|
9
9
|
* session / Deny (permission), the options plus a typed reply (question),
|
|
10
|
-
* Approve / Send back (plan review), or Open terminal (a stall/failure
|
|
11
|
-
*
|
|
10
|
+
* Approve / Send back (plan review), or Open terminal (a stall/failure, or a
|
|
11
|
+
* request the CLI could not verify is still pending), and route the answer back
|
|
12
|
+
* through `agents feed answer <key> --choice <id>`. The kind — and so the button
|
|
13
|
+
* set — is the reconciler's verdict from explicit harness evidence; an idle
|
|
14
|
+
* reminder never reaches here as a permission (PHNX-3999).
|
|
12
15
|
*
|
|
13
16
|
* Idempotency is a filesystem ledger, not memory: one sidecar file per notified
|
|
14
17
|
* key under `~/.agents/.history/feed/notified/`, so a daemon restart never
|
|
@@ -47,6 +50,10 @@ const BANNER_KINDS = {
|
|
|
47
50
|
plan_review: { category: 'plan_review', label: 'Plan review' },
|
|
48
51
|
stall: { category: 'failure', label: 'Failed' },
|
|
49
52
|
failure: { category: 'failure', label: 'Failed' },
|
|
53
|
+
// A request record the CLI could not confirm is still pending: the `failure`
|
|
54
|
+
// category is the companion's one button set with no approval action — just
|
|
55
|
+
// Open terminal — which is exactly the honest offer here.
|
|
56
|
+
unverified: { category: 'failure', label: 'Could not verify request' },
|
|
50
57
|
};
|
|
51
58
|
function shorten(text, max = BODY_MAX) {
|
|
52
59
|
const flat = text.replace(/\s+/g, ' ').trim();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selfConfiguredDeviceRole } from './device-config.js';
|
|
1
2
|
import { isSymlinkAdoptedHarness } from './installations/shims.js';
|
|
2
3
|
import type { AgentId, DeviceAccountSlot, Meta } from './types.js';
|
|
3
4
|
export { isAccountSlotDir } from './agents.js';
|
|
@@ -10,6 +11,24 @@ export interface NativeSpawnHome {
|
|
|
10
11
|
/** Installation label when `source` is `legacy-home`. */
|
|
11
12
|
label?: string;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The env a DURABLE slot injects at spawn (PHNX-3940 T5/T6). A worker's slot
|
|
16
|
+
* for an api-key harness (codex/grok/cursor/opencode/droid) holds no file — the
|
|
17
|
+
* daemon pushed the account's worker API key into the reserved
|
|
18
|
+
* `__<harness>__` store and `provisionWorkerSlot` recorded the slot as
|
|
19
|
+
* `durable`; the key rides the harness's own env var (`CURSOR_API_KEY`, …)
|
|
20
|
+
* on every launch. Anything else — a native login in the slot on a headed
|
|
21
|
+
* device, a claude durable slot (its setup-token is a `.oauth_token` file the
|
|
22
|
+
* adapter reads), a harness with no api-key worker kind, a headed device — injects
|
|
23
|
+
* nothing.
|
|
24
|
+
* Without this, `agents run cursor#gmail` on a worker reached Cursor with an
|
|
25
|
+
* empty env and got `Authentication required … set CURSOR_API_KEY`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function durableSlotEnv(agent: AgentId, account: {
|
|
28
|
+
id: string;
|
|
29
|
+
}, resolved: Pick<NativeSpawnHome, 'slot'>, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, deps?: {
|
|
30
|
+
selfRole?: () => ReturnType<typeof selfConfiguredDeviceRole>;
|
|
31
|
+
}): Record<string, string>;
|
|
13
32
|
export declare function missingSlotHint(agent: AgentId, name: string): string;
|
|
14
33
|
export declare function symlinkAdoptedAccountError(agent: AgentId, name: string, defaultName: string | undefined): string;
|
|
15
34
|
export declare function adoptedSymlinkMismatchError(agent: AgentId, name: string, execHome: string): string;
|
|
@@ -13,7 +13,7 @@ import * as os from 'node:os';
|
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import { listNativeAccounts, nativeAccountHome } from './account-registry.js';
|
|
15
15
|
import { readSlots } from './accounts/slots.js';
|
|
16
|
-
import { workerProvisioningHint } from './accounts/add.js';
|
|
16
|
+
import { workerProvisioningHint, workerApiKeyEnv } from './accounts/add.js';
|
|
17
17
|
import { AGENTS, credentialPresence, getAccountInfo } from './agents.js';
|
|
18
18
|
import { AUTH_BUNDLE, claudeAccountTokenKey, provisionWorkerSlot, readReservedCredential } from './claude-account-token.js';
|
|
19
19
|
import { isHeadedDeviceRole, selfConfiguredDeviceRole } from './device-config.js';
|
|
@@ -22,6 +22,40 @@ import { getAgentConfigPath, isSymlinkAdoptedHarness, repointAdoptedConfigToHome
|
|
|
22
22
|
import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
|
|
23
23
|
export { isAccountSlotDir } from './agents.js';
|
|
24
24
|
export { isSymlinkAdoptedHarness };
|
|
25
|
+
/**
|
|
26
|
+
* The env a DURABLE slot injects at spawn (PHNX-3940 T5/T6). A worker's slot
|
|
27
|
+
* for an api-key harness (codex/grok/cursor/opencode/droid) holds no file — the
|
|
28
|
+
* daemon pushed the account's worker API key into the reserved
|
|
29
|
+
* `__<harness>__` store and `provisionWorkerSlot` recorded the slot as
|
|
30
|
+
* `durable`; the key rides the harness's own env var (`CURSOR_API_KEY`, …)
|
|
31
|
+
* on every launch. Anything else — a native login in the slot on a headed
|
|
32
|
+
* device, a claude durable slot (its setup-token is a `.oauth_token` file the
|
|
33
|
+
* adapter reads), a harness with no api-key worker kind, a headed device — injects
|
|
34
|
+
* nothing.
|
|
35
|
+
* Without this, `agents run cursor#gmail` on a worker reached Cursor with an
|
|
36
|
+
* empty env and got `Authentication required … set CURSOR_API_KEY`.
|
|
37
|
+
*/
|
|
38
|
+
export function durableSlotEnv(agent, account, resolved, meta, deps = {}) {
|
|
39
|
+
if (resolved.slot?.authMode !== 'durable')
|
|
40
|
+
return {};
|
|
41
|
+
// A headed device authenticates from its own native login (invariant 7). A
|
|
42
|
+
// `durable` record can outlive a role change (`agents devices role … personal`
|
|
43
|
+
// never rewrites slots), so the gate is the box's CURRENT role, the same
|
|
44
|
+
// predicate `isProvisionableWorker` applies — never a record on disk.
|
|
45
|
+
if (isHeadedDeviceRole((deps.selfRole ?? selfConfiguredDeviceRole)()))
|
|
46
|
+
return {};
|
|
47
|
+
const envName = workerApiKeyEnv(agent);
|
|
48
|
+
if (!envName)
|
|
49
|
+
return {};
|
|
50
|
+
const row = nativeRow(account.id, meta);
|
|
51
|
+
const cred = row?.workerCredential;
|
|
52
|
+
if (!cred || cred.kind !== 'api-key')
|
|
53
|
+
return {};
|
|
54
|
+
const value = readReservedCredential(cred.bundle, cred.key);
|
|
55
|
+
if (!value)
|
|
56
|
+
return {};
|
|
57
|
+
return { [envName]: value };
|
|
58
|
+
}
|
|
25
59
|
export function missingSlotHint(agent, name) {
|
|
26
60
|
const role = selfConfiguredDeviceRole();
|
|
27
61
|
if (!isHeadedDeviceRole(role)) {
|
package/dist/lib/exec.js
CHANGED
|
@@ -425,6 +425,17 @@ export function buildExecEnv(options) {
|
|
|
425
425
|
if (options.cwd) {
|
|
426
426
|
result.AGENTS_CWD = options.cwd;
|
|
427
427
|
}
|
|
428
|
+
// An account-slot launch (PHNX-3940 T5) tells the versioned alias which
|
|
429
|
+
// HOME-shaped dir owns this run's config, so the alias's version-home pin
|
|
430
|
+
// (claude: CLAUDE_CONFIG_DIR) yields to the slot instead of overriding the
|
|
431
|
+
// adapter's pin above. Set only for a slot launch and cleared otherwise, so a
|
|
432
|
+
// run spawned from inside a slot session never inherits its parent's slot.
|
|
433
|
+
if (options.execHome) {
|
|
434
|
+
result.AGENTS_EXEC_HOME = options.execHome;
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
delete result.AGENTS_EXEC_HOME;
|
|
438
|
+
}
|
|
428
439
|
// Export the run's durable name (companion to AGENT_SESSION_ID) so a
|
|
429
440
|
// SessionStart hook / the agent can associate its transcript with the handle
|
|
430
441
|
// the user gave the run. Only set when --name was passed.
|
package/dist/lib/feed/answer.js
CHANGED
|
@@ -19,6 +19,10 @@ function blockFromAttention(attention, session) {
|
|
|
19
19
|
state: 'open', sourceCursor: attention.sourceCursor, project: attention.project,
|
|
20
20
|
ts: attention.openedAt, questions: [attention.question ?? { text: 'Continue from this attention item.' }],
|
|
21
21
|
kind: attention.kind === 'permission' ? 'notification' : attention.kind === 'declared' ? 'declared' : attention.source === 'system' ? 'control' : 'question',
|
|
22
|
+
// A notification block is a permission only by its recorded subtype
|
|
23
|
+
// (attention.ts kindFromNotification), so the reconstructed block must carry
|
|
24
|
+
// it or a re-read classifies the same item as unverified.
|
|
25
|
+
...(attention.kind === 'permission' ? { notificationType: 'permission_prompt' } : {}),
|
|
22
26
|
safeDefault: attention.safeDefault,
|
|
23
27
|
};
|
|
24
28
|
}
|
|
@@ -30,6 +34,11 @@ async function resolveBlock(attentionKey, sessions, root) {
|
|
|
30
34
|
let block = readBlock(blockIdForSession(session.sessionId), root);
|
|
31
35
|
const projectedSession = { ...session, host: ownerHost };
|
|
32
36
|
const attention = reconcileAttention({ block, session: projectedSession, pullRequest: await readPullRequestStatus(session), resolution: readResolution(blockIdForSession(session.sessionId), root), nowMs: Date.now() });
|
|
37
|
+
if (attention?.key === attentionKey && attention.kind === 'unverified') {
|
|
38
|
+
// No confirmed prompt to land a reply in: an answer typed into the session
|
|
39
|
+
// could hit an empty prompt line or a different dialog (PHNX-3999).
|
|
40
|
+
throw new Error(`'${attentionKey}' could not be verified as a pending request — open the session and answer it there.`);
|
|
41
|
+
}
|
|
33
42
|
if (attention?.key === attentionKey && block)
|
|
34
43
|
return { block, attention };
|
|
35
44
|
// The winning caller advances the block to answered before a concurrent
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { ActiveSession } from '../session/active.js';
|
|
2
2
|
import { type AttentionResolution, type AttentionSource, type AttentionState, type BlockOption, type BlockQuestion, type OpenBlock, type SourceCursor } from './feed.js';
|
|
3
|
-
/**
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* What an attention item is asking of the operator. `unverified` is a request
|
|
5
|
+
* record whose pending state this CLI could not confirm — a hook-raised prompt
|
|
6
|
+
* with no transcript cursor to check it against that has aged past
|
|
7
|
+
* {@link UNVERIFIED_PROMPT_AGE_MS}, a notification whose subtype the writer did
|
|
8
|
+
* not record, or a lifecycle `permission` claim from a peer running an older
|
|
9
|
+
* state engine. It carries no choices: the only honest action is to open the
|
|
10
|
+
* session and look (PHNX-3999).
|
|
11
|
+
*/
|
|
12
|
+
export type AttentionKind = 'question' | 'permission' | 'plan_review' | 'declared' | 'failure' | 'stall' | 'review' | 'unverified';
|
|
5
13
|
/** How an answer can be routed back to the waiting agent. */
|
|
6
14
|
export type ReplyCapability = 'terminal' | 'tmux' | 'cloud' | 'team' | 'none';
|
|
7
15
|
/**
|
|
@@ -70,6 +78,14 @@ export interface PullRequestAttentionSignal {
|
|
|
70
78
|
export declare function attentionFingerprint(kind: AttentionKind, question?: BlockQuestion): string;
|
|
71
79
|
/** Harness id behind a session — the profile name when set, else the host process. */
|
|
72
80
|
export declare function harnessOf(session: ActiveSession): string;
|
|
81
|
+
/**
|
|
82
|
+
* How long a hook-raised permission prompt is trusted on the hook's word alone
|
|
83
|
+
* when the session offers no transcript cursor to verify it against (a cloud or
|
|
84
|
+
* remote row, or a peer running an older CLI). Past this age with nothing to
|
|
85
|
+
* check, the record is `unverified`: the banner keeps the session findable but
|
|
86
|
+
* offers no Approve.
|
|
87
|
+
*/
|
|
88
|
+
export declare const UNVERIFIED_PROMPT_AGE_MS: number;
|
|
73
89
|
/**
|
|
74
90
|
* Reconcile the feed block ledger, the session lifecycle, a CLI-supplied PR
|
|
75
91
|
* signal, and the latest resolution tombstone into one attention item — or
|
|
Binary file
|
package/dist/lib/feed/feed.d.ts
CHANGED
|
@@ -94,7 +94,11 @@ export interface OpenBlock {
|
|
|
94
94
|
/**
|
|
95
95
|
* How this block came to exist.
|
|
96
96
|
* question — an AskUserQuestion the harness surfaced
|
|
97
|
-
* notification — a
|
|
97
|
+
* notification — a prompt the harness raised; `notificationType` names
|
|
98
|
+
* which (`permission_prompt`, `elicitation_dialog`). An
|
|
99
|
+
* `idle_prompt` is not published — it says the turn ended,
|
|
100
|
+
* not that anything is pending (PHNX-3999); a block of that
|
|
101
|
+
* type left on disk by an older hook is not a request either.
|
|
98
102
|
* control — a synthetic card the feed itself computed (runaway, needy)
|
|
99
103
|
* declared — the AGENT decided it is stuck and said so (`feed post --blocked`)
|
|
100
104
|
*
|
|
@@ -342,7 +346,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
|
342
346
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
343
347
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
344
348
|
*/
|
|
345
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n # A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)\n # so a stale lifecycle re-read cannot resurrect this generation.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n now_iso = datetime.now(timezone.utc).isoformat()\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": now_iso,\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Tombstone first, then drop the open-block view. A missing/corrupt\n # block means there is nothing to resolve; fail open.\n existing = read_json(target)\n if isinstance(existing, dict):\n generation = existing.get(\"generation\") or existing.get(\"ts\")\n if generation:\n tombstone = {\n \"blockId\": block_id,\n \"generation\": generation,\n \"resolvedAt\": now_iso,\n \"reason\": \"answered\",\n }\n source_cursor = existing.get(\"sourceCursor\")\n if source_cursor:\n tombstone[\"sourceCursor\"] = source_cursor\n write_json(\n os.path.join(feed_dir, \"resolutions\", f\"{block_id}.json\"),\n tombstone,\n )\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so AGI EXT can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
349
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\n# Notification subtypes that mean something is PENDING. Claude's idle_prompt is\n# deliberately absent: it fires a minute after the turn ended with the operator\n# idle, which is a finished turn, not a request -- publishing it put an\n# Approve/Deny banner on a session that had already answered \"pong\" (PHNX-3999).\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef project_from_cwd(cwd):\n \"\"\"Basename of cwd, with worktree paths resolved to their repo name.\"\"\"\n if not cwd:\n return None\n norm = cwd.replace(\"\\\\\", \"/\").rstrip(\"/\")\n if not norm:\n return None\n marker = \"/.agents/worktrees/\"\n idx = norm.find(marker)\n if idx > 0:\n repo_path = norm[:idx]\n base = repo_path[repo_path.rfind(\"/\") + 1:]\n if base:\n return base\n base = norm[norm.rfind(\"/\") + 1:]\n return base or None\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A declared block (`agents feed post --blocked`) is the agent explicitly\n # saying it is stuck. Unlike a question/notification/approval block -- which\n # tracks an in-flight harness prompt that a lifecycle event resolves -- a\n # declared block stays open until it is actually ANSWERED. So while it is\n # still UNANSWERED, Stop/SessionEnd/PostToolUse must never silently drop it:\n # otherwise the needs-you record vanishes the moment the agent parks the block\n # and its turn ends -- exactly when the owner still needs to see and answer it.\n # Once it IS answered (an answered marker exists), it clears like any other\n # block by falling through below -- which frees that marker too, so a later\n # `--blocked` in the same session is not falsely locked as already-answered\n # (recordAnswer creates the marker with O_EXCL).\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n answered = os.path.exists(os.path.join(answered_dir, f\"{block_id}.json\"))\n if existing.get(\"kind\") == \"declared\" and not answered:\n return\n except Exception:\n pass\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n # A resolution tombstone is written BEFORE unlink (matching TS recordAnswer)\n # so a stale lifecycle re-read cannot resurrect this generation.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n now_iso = datetime.now(timezone.utc).isoformat()\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": now_iso,\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Tombstone first, then drop the open-block view. A missing/corrupt\n # block means there is nothing to resolve; fail open.\n existing = read_json(target)\n if isinstance(existing, dict):\n generation = existing.get(\"generation\") or existing.get(\"ts\")\n if generation:\n tombstone = {\n \"blockId\": block_id,\n \"generation\": generation,\n \"resolvedAt\": now_iso,\n \"reason\": \"answered\",\n }\n source_cursor = existing.get(\"sourceCursor\")\n if source_cursor:\n tombstone[\"sourceCursor\"] = source_cursor\n write_json(\n os.path.join(feed_dir, \"resolutions\", f\"{block_id}.json\"),\n tombstone,\n )\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so AGI EXT can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now = datetime.now(timezone.utc)\n now_iso = now.isoformat()\n now_ms = int(now.timestamp() * 1000)\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n cwd = payload.get(\"cwd\") or os.environ.get(\"AGENTS_CWD\")\n project = project_from_cwd(cwd)\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n # Write-time cursor so a new generation is not suppressed when\n # session.lastActivityMs is unresolvable (cloud / remote / index-lag).\n \"sourceCursor\": {\"lastActivityMs\": now_ms},\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if project:\n block[\"project\"] = project\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
346
350
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
347
351
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
348
352
|
name: string;
|
package/dist/lib/feed/feed.js
CHANGED
|
@@ -533,9 +533,12 @@ import socket
|
|
|
533
533
|
import tempfile
|
|
534
534
|
from datetime import datetime, timezone
|
|
535
535
|
|
|
536
|
+
# Notification subtypes that mean something is PENDING. Claude's idle_prompt is
|
|
537
|
+
# deliberately absent: it fires a minute after the turn ended with the operator
|
|
538
|
+
# idle, which is a finished turn, not a request -- publishing it put an
|
|
539
|
+
# Approve/Deny banner on a session that had already answered "pong" (PHNX-3999).
|
|
536
540
|
WAITING_NOTIFICATION_TYPES = {
|
|
537
541
|
"permission_prompt",
|
|
538
|
-
"idle_prompt",
|
|
539
542
|
"elicitation_dialog",
|
|
540
543
|
}
|
|
541
544
|
CLEAR_EVENTS = {
|
|
@@ -921,7 +924,7 @@ export const FEED_PUBLISH_HOOK_MANIFEST = {
|
|
|
921
924
|
export const FEED_NOTIFICATION_HOOK_MANIFEST = {
|
|
922
925
|
name: 'feed-publish-notification',
|
|
923
926
|
events: ['Notification'],
|
|
924
|
-
matcher: 'permission_prompt|
|
|
927
|
+
matcher: 'permission_prompt|elicitation_dialog',
|
|
925
928
|
script: '10-feed-publish.py',
|
|
926
929
|
timeout: 5,
|
|
927
930
|
};
|
|
@@ -980,10 +983,13 @@ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
|
|
|
980
983
|
script: '10-feed-publish.py',
|
|
981
984
|
timeout: 5,
|
|
982
985
|
},
|
|
986
|
+
// idle_prompt is not matched: an idle reminder is a finished turn, not a
|
|
987
|
+
// pending request (PHNX-3999). An installed agents.yaml that still carries
|
|
988
|
+
// the old matcher is harmless -- the script drops the subtype itself.
|
|
983
989
|
'feed-publish-notification': {
|
|
984
990
|
agents: ['claude', 'codex'],
|
|
985
991
|
events: ['Notification'],
|
|
986
|
-
matcher: 'permission_prompt|
|
|
992
|
+
matcher: 'permission_prompt|elicitation_dialog',
|
|
987
993
|
script: '10-feed-publish.py',
|
|
988
994
|
timeout: 5,
|
|
989
995
|
},
|
|
@@ -1007,7 +1013,7 @@ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
|
|
|
1007
1013
|
// never fires PermissionRequest, so it has no approval card to clear here,
|
|
1008
1014
|
// and a matcher-less PostToolUse for Claude would (1) re-run the script on
|
|
1009
1015
|
// every tool completion and (2) wipe Claude's notification-kind blocks
|
|
1010
|
-
// (permission_prompt/
|
|
1016
|
+
// (permission_prompt/elicitation_dialog) the moment any later
|
|
1011
1017
|
// tool runs, instead of letting them persist to Stop/SessionEnd like they
|
|
1012
1018
|
// did before RUSH-2039. Registering it for codex alone keeps Claude's
|
|
1013
1019
|
// card lifetime exactly as it was.
|
|
@@ -162,6 +162,29 @@ export declare const CONFIG_DIR_ENV_KEYS: readonly ["CLAUDE_CONFIG_DIR", "CODEX_
|
|
|
162
162
|
* (the default / no-config-dir harness) deletes all of them — the old `else` arm.
|
|
163
163
|
*/
|
|
164
164
|
export declare function stripForeignConfigDir(result: NodeJS.ProcessEnv, keep?: readonly string[]): void;
|
|
165
|
+
/**
|
|
166
|
+
* Bash for a harness's config-dir pin that yields to an account-slot launch.
|
|
167
|
+
*
|
|
168
|
+
* A shim (bare or `<agent>@<version>` alias) pins the harness's config-dir env
|
|
169
|
+
* at the version home. An account-slot launch (PHNX-3940 T5) has already chosen
|
|
170
|
+
* the HOME-shaped slot: `agents run` pins the same env at the slot in
|
|
171
|
+
* buildExecEnv and stamps the slot in AGENTS_EXEC_HOME. The shim used to
|
|
172
|
+
* re-export the version home unconditionally, so a run picked as one account
|
|
173
|
+
* read and wrote another account's home (claude on yosemite-m1, 2026-09-10;
|
|
174
|
+
* the same override for every harness below). The pin now yields to the slot
|
|
175
|
+
* — the way the cursor alias's HOME swap yields to a spawner-chosen HOME — and
|
|
176
|
+
* consumes the marker so the launched harness never inherits it into a nested
|
|
177
|
+
* launch; a bare `<agent>@<version>` from a terminal still gets the version home.
|
|
178
|
+
*
|
|
179
|
+
* `pins` are `{ env, rel }` with `rel` the HOME-relative config path (the slot
|
|
180
|
+
* and the version home are both HOME-shaped); `versionHome` is the bash
|
|
181
|
+
* expression for the version home (`$VERSION_DIR/home` in the shared block, the
|
|
182
|
+
* absolute versions path in a direct alias).
|
|
183
|
+
*/
|
|
184
|
+
export declare function slotAwareConfigEnvBash(pins: ReadonlyArray<{
|
|
185
|
+
env: string;
|
|
186
|
+
rel: string;
|
|
187
|
+
}>, versionHome: string): string;
|
|
165
188
|
export declare function registerHarnessAdapter(adapter: HarnessAdapter): void;
|
|
166
189
|
/**
|
|
167
190
|
* The behavior adapter for a harness. Every `AgentId` resolves to an adapter —
|
|
@@ -25,6 +25,35 @@ export function stripForeignConfigDir(result, keep = []) {
|
|
|
25
25
|
delete result[key];
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Bash for a harness's config-dir pin that yields to an account-slot launch.
|
|
30
|
+
*
|
|
31
|
+
* A shim (bare or `<agent>@<version>` alias) pins the harness's config-dir env
|
|
32
|
+
* at the version home. An account-slot launch (PHNX-3940 T5) has already chosen
|
|
33
|
+
* the HOME-shaped slot: `agents run` pins the same env at the slot in
|
|
34
|
+
* buildExecEnv and stamps the slot in AGENTS_EXEC_HOME. The shim used to
|
|
35
|
+
* re-export the version home unconditionally, so a run picked as one account
|
|
36
|
+
* read and wrote another account's home (claude on yosemite-m1, 2026-09-10;
|
|
37
|
+
* the same override for every harness below). The pin now yields to the slot
|
|
38
|
+
* — the way the cursor alias's HOME swap yields to a spawner-chosen HOME — and
|
|
39
|
+
* consumes the marker so the launched harness never inherits it into a nested
|
|
40
|
+
* launch; a bare `<agent>@<version>` from a terminal still gets the version home.
|
|
41
|
+
*
|
|
42
|
+
* `pins` are `{ env, rel }` with `rel` the HOME-relative config path (the slot
|
|
43
|
+
* and the version home are both HOME-shaped); `versionHome` is the bash
|
|
44
|
+
* expression for the version home (`$VERSION_DIR/home` in the shared block, the
|
|
45
|
+
* absolute versions path in a direct alias).
|
|
46
|
+
*/
|
|
47
|
+
export function slotAwareConfigEnvBash(pins, versionHome) {
|
|
48
|
+
const slot = pins.map((p) => ` export ${p.env}="$AGENTS_EXEC_HOME/${p.rel}"`).join('\n');
|
|
49
|
+
const version = pins.map((p) => ` export ${p.env}="${versionHome}/${p.rel}"`).join('\n');
|
|
50
|
+
return `if [ -n "\${AGENTS_EXEC_HOME:-}" ]; then
|
|
51
|
+
${slot}
|
|
52
|
+
unset AGENTS_EXEC_HOME
|
|
53
|
+
else
|
|
54
|
+
${version}
|
|
55
|
+
fi`;
|
|
56
|
+
}
|
|
28
57
|
const REGISTRY = new Map();
|
|
29
58
|
/**
|
|
30
59
|
* The no-behavior adapter — a harness with no managed config-dir env and no
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
|
-
import { stripForeignConfigDir } from '../adapter.js';
|
|
2
|
+
import { slotAwareConfigEnvBash, stripForeignConfigDir } from '../adapter.js';
|
|
3
3
|
import { isHeadedDeviceRole } from '../../device-config.js';
|
|
4
4
|
export const claudeAdapter = {
|
|
5
5
|
id: 'claude',
|
|
@@ -95,8 +95,10 @@ export const claudeAdapter = {
|
|
|
95
95
|
return `
|
|
96
96
|
# Claude stores OAuth credentials in the macOS keychain. Scope them to the
|
|
97
97
|
# selected version's config directory so switching versions also switches the
|
|
98
|
-
# live Claude account.
|
|
99
|
-
|
|
98
|
+
# live Claude account. An account-slot launch (PHNX-3940 T5) has already chosen
|
|
99
|
+
# the config dir (AGENTS_EXEC_HOME) and the pin yields to it — see
|
|
100
|
+
# slotAwareConfigEnvBash.
|
|
101
|
+
${slotAwareConfigEnvBash([{ env: 'CLAUDE_CONFIG_DIR', rel: ctx.configDirName }], '$VERSION_DIR/home')}
|
|
100
102
|
# Managed installs are pinned in a per-version dir; Claude Code's background
|
|
101
103
|
# auto-updater would rewrite the pinned binary in place. Disable it so a pin
|
|
102
104
|
# stays a pin. An explicit user value always wins.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
|
-
import { stripForeignConfigDir } from '../adapter.js';
|
|
2
|
+
import { stripForeignConfigDir, slotAwareConfigEnvBash } from '../adapter.js';
|
|
3
3
|
export const copilotAdapter = {
|
|
4
4
|
id: 'copilot',
|
|
5
5
|
// Copilot honors COPILOT_HOME (relocates ~/.copilot, including settings,
|
|
@@ -17,7 +17,9 @@ export const copilotAdapter = {
|
|
|
17
17
|
# (settings.json, mcp-config.json, session-state/, logs/, plugins/). Point
|
|
18
18
|
# it at the versioned home so MCP servers, custom agents, and session
|
|
19
19
|
# history are isolated per copilot version.
|
|
20
|
-
|
|
20
|
+
# An account-slot launch has already chosen the home (AGENTS_EXEC_HOME); the pin
|
|
21
|
+
# yields to it — see slotAwareConfigEnvBash.
|
|
22
|
+
${slotAwareConfigEnvBash([{ env: 'COPILOT_HOME', rel: ctx.configDirName }], '$VERSION_DIR/home')}
|
|
21
23
|
`;
|
|
22
24
|
},
|
|
23
25
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
|
-
import { stripForeignConfigDir } from '../adapter.js';
|
|
2
|
+
import { stripForeignConfigDir, slotAwareConfigEnvBash } from '../adapter.js';
|
|
3
3
|
export const grokAdapter = {
|
|
4
4
|
id: 'grok',
|
|
5
5
|
applyExecConfigEnv(result, ctx) {
|
|
@@ -13,7 +13,9 @@ export const grokAdapter = {
|
|
|
13
13
|
# Grok Build uses GROK_HOME to isolate its entire configuration tree
|
|
14
14
|
# (skills, hooks, plugins, agents, memory, sessions, config.toml, MCP, etc.).
|
|
15
15
|
# This gives agents-cli full versioned isolation + resource sync for grok.
|
|
16
|
-
|
|
16
|
+
# An account-slot launch has already chosen the home (AGENTS_EXEC_HOME); the pin
|
|
17
|
+
# yields to it — see slotAwareConfigEnvBash.
|
|
18
|
+
${slotAwareConfigEnvBash([{ env: 'GROK_HOME', rel: '.grok' }], '$VERSION_DIR/home')}
|
|
17
19
|
`;
|
|
18
20
|
},
|
|
19
21
|
};
|