@phnx-labs/agents-cli 1.22.92 → 1.22.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -0
  3. package/dist/commands/browser.d.ts +1 -0
  4. package/dist/commands/browser.js +145 -11
  5. package/dist/lib/browser/arc-discovery.d.ts +60 -0
  6. package/dist/lib/browser/arc-discovery.js +187 -0
  7. package/dist/lib/browser/arc-dom.d.ts +14 -0
  8. package/dist/lib/browser/arc-dom.js +121 -0
  9. package/dist/lib/browser/drivers/arc.d.ts +57 -0
  10. package/dist/lib/browser/drivers/arc.js +281 -0
  11. package/dist/lib/browser/profiles.d.ts +5 -1
  12. package/dist/lib/browser/profiles.js +94 -9
  13. package/dist/lib/browser/refs.d.ts +2 -0
  14. package/dist/lib/browser/refs.js +2 -1
  15. package/dist/lib/browser/resolve-target.d.ts +2 -0
  16. package/dist/lib/browser/resolve-target.js +8 -0
  17. package/dist/lib/browser/runtime-state.js +9 -1
  18. package/dist/lib/browser/service.d.ts +37 -3
  19. package/dist/lib/browser/service.js +571 -41
  20. package/dist/lib/browser/types.d.ts +55 -1
  21. package/dist/lib/claude-account-token.d.ts +13 -0
  22. package/dist/lib/claude-account-token.js +29 -0
  23. package/dist/lib/daemon/attention-notify-service.d.ts +5 -2
  24. package/dist/lib/daemon/attention-notify-service.js +9 -2
  25. package/dist/lib/exec.js +11 -0
  26. package/dist/lib/feed/answer.js +9 -0
  27. package/dist/lib/feed/attention.d.ts +18 -2
  28. package/dist/lib/feed/attention.js +0 -0
  29. package/dist/lib/feed/feed.d.ts +6 -2
  30. package/dist/lib/feed/feed.js +10 -4
  31. package/dist/lib/harness/adapter.d.ts +23 -0
  32. package/dist/lib/harness/adapter.js +29 -0
  33. package/dist/lib/harness/adapters/claude.js +5 -3
  34. package/dist/lib/harness/adapters/copilot.js +4 -2
  35. package/dist/lib/harness/adapters/grok.js +4 -2
  36. package/dist/lib/harness/adapters/kimi.js +4 -2
  37. package/dist/lib/harness/adapters/muse.js +4 -3
  38. package/dist/lib/harness/adapters/opencode.js +4 -2
  39. package/dist/lib/helper-download.js +1 -1
  40. package/dist/lib/helper-versions.js +1 -1
  41. package/dist/lib/installations/shims.d.ts +2 -2
  42. package/dist/lib/installations/shims.js +16 -8
  43. package/dist/lib/menubar/download-menubar.js +4 -1
  44. package/dist/lib/menubar/install-menubar.d.ts +10 -9
  45. package/dist/lib/menubar/install-menubar.js +17 -15
  46. package/dist/lib/secrets-policy.d.ts +5 -2
  47. package/dist/lib/secrets-policy.js +18 -4
  48. package/dist/lib/session/active.d.ts +18 -11
  49. package/dist/lib/session/active.js +52 -44
  50. package/dist/lib/session/session-cache.d.ts +1 -1
  51. package/dist/lib/session/session-cache.js +1 -0
  52. package/dist/lib/session/state.d.ts +26 -0
  53. package/dist/lib/session/state.js +43 -27
  54. package/dist/lib/types.d.ts +7 -0
  55. package/package.json +1 -1
@@ -1,4 +1,53 @@
1
+ import type { ArcNativeTabRef } from './drivers/arc.js';
2
+ export type { ArcNativeTabRef } from './drivers/arc.js';
1
3
  export type BrowserType = 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | 'arc' | 'custom';
4
+ /**
5
+ * The transport backend a live browser connection uses (PHNX-2399).
6
+ * - `cdp` — Chrome DevTools Protocol (the existing path for all Chromium-family browsers).
7
+ * - `arc-native` — Apple Events via `osascript` (the native Arc path, no CDP port required).
8
+ *
9
+ * Load-bearing: every action method on `BrowserService` that calls `conn.cdp.send()`
10
+ * must check `conn.backend` and route to the native driver instead when it is
11
+ * `arc-native`. Unsupported native verbs throw `ArcNativeCapabilityError`.
12
+ */
13
+ export type BackendKind = 'cdp' | 'arc-native';
14
+ /**
15
+ * Stable native identity carried by an Arc profile declaration (PHNX-2399).
16
+ * One agents-cli profile is one Arc Space: the Space already carries its Arc
17
+ * profile (cookies, logins), so agents never learn a second "space" concept.
18
+ */
19
+ export interface ArcNativeProfileIdentity {
20
+ /** Arc's profile directory basename the Space belongs to. Authoritative id. */
21
+ profileId: string;
22
+ /** Display-only Arc profile name from Local State. Never used for addressing. */
23
+ profileName: string;
24
+ /** Stable Space id used for every native operation. */
25
+ spaceId: string;
26
+ /** Display-only Space title. */
27
+ spaceTitle: string;
28
+ }
29
+ /** A crash-safe native create intent persisted before Arc is asked to mutate. */
30
+ export interface ArcNativeCreateIntent {
31
+ tabId: string;
32
+ markerUrl: string;
33
+ targetUrl: string;
34
+ createdAt: number;
35
+ /** Active tab before creation, restored only if the owned tab stayed active. */
36
+ previousTabId?: string;
37
+ /** Written immediately after the driver returns, before final navigation. */
38
+ ref?: ArcNativeTabRef;
39
+ }
40
+ /** Durable Arc state owned by one browser task. */
41
+ export interface ArcNativeTaskState {
42
+ profileId: string;
43
+ /** Original Arc window id. A tab moved elsewhere is never adopted. */
44
+ windowId: string;
45
+ spaceId: string;
46
+ /** Display-only snapshot for status output. */
47
+ spaceTitle: string;
48
+ tabs: Record<string, ArcNativeTabRef>;
49
+ createIntents?: Record<string, ArcNativeCreateIntent>;
50
+ }
2
51
  /**
3
52
  * The user-facing name of a profile — what `agents browser profiles list`
4
53
  * prints and what `--profile <name>` takes. ALWAYS bare: it never carries an
@@ -139,6 +188,8 @@ export interface BrowserProfile {
139
188
  logDir?: string;
140
189
  /** Optional SSH host where logDir lives, e.g. "user@remote-host". */
141
190
  logHost?: string;
191
+ /** Native Arc identity. Present only for an `arc-native:` profile. */
192
+ arc?: ArcNativeProfileIdentity;
142
193
  }
143
194
  /** Parsed form of `BrowserProfile.targetFilter`. */
144
195
  export interface TargetFilter {
@@ -176,11 +227,12 @@ export interface Task {
176
227
  * {@link parseConnectionKey} to get the user-facing name out of it.
177
228
  */
178
229
  profile: ConnectionKey;
230
+ /** shortId -> CDP target id, or native tab id mirrored from arcNative.tabs. */
179
231
  tabs: Record<string, string>;
180
232
  /**
181
233
  * Tabs this task DRIVES but did not create, by shortId — a tab that already
182
234
  * existed in the browser and was reused because the browser cannot open new
183
- * ones (Arc: `Target.createTarget` crashes it, #2778/#2786). Every close path
235
+ * ones (legacy CDP Arc is one example). Every close path
184
236
  * skips these: the task never opened the tab, so closing it on `done` would
185
237
  * take away something that was there first. Same rule `adoptTabShowing`
186
238
  * states for unowned pages, kept when reuse is unavoidable rather than
@@ -235,6 +287,8 @@ export interface Task {
235
287
  * it. See RefSnapshot / RefDescriptor.
236
288
  */
237
289
  refDescriptors?: Record<string, import('./refs.js').RefSnapshot>;
290
+ /** Durable stable native ids and crash-intent ledger for an Arc task. */
291
+ arcNative?: ArcNativeTaskState;
238
292
  }
239
293
  export interface TabInfo {
240
294
  id: string;
@@ -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), and
11
- * route the answer back through `agents feed answer <key> --choice <id>`.
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), and
11
- * route the answer back through `agents feed answer <key> --choice <id>`.
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();
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.
@@ -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
- /** What an attention item is asking of the operator. */
4
- export type AttentionKind = 'question' | 'permission' | 'plan_review' | 'declared' | 'failure' | 'stall' | 'review';
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
@@ -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 permission/idle prompt the harness raised
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;
@@ -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|idle_prompt|elicitation_dialog',
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|idle_prompt|elicitation_dialog',
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/idle_prompt/elicitation_dialog) the moment any later
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
- export CLAUDE_CONFIG_DIR="$VERSION_DIR/home/${ctx.configDirName}"
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
- export COPILOT_HOME="$VERSION_DIR/home/${ctx.configDirName}"
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
- export GROK_HOME="$VERSION_DIR/home/.grok"
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
  };
@@ -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 kimiAdapter = {
4
4
  id: 'kimi',
5
5
  // Kimi honors KIMI_CODE_HOME (relocates ~/.kimi-code, including config,
@@ -14,7 +14,9 @@ export const kimiAdapter = {
14
14
  return `
15
15
  # Kimi Code CLI honors KIMI_CODE_HOME to relocate ~/.kimi-code (config.toml,
16
16
  # mcp.json, sessions, skills, hooks). Point it at the versioned home.
17
- export KIMI_CODE_HOME="$VERSION_DIR/home/${ctx.configDirName}"
17
+ # An account-slot launch has already chosen the home (AGENTS_EXEC_HOME); the pin
18
+ # yields to it — see slotAwareConfigEnvBash.
19
+ ${slotAwareConfigEnvBash([{ env: 'KIMI_CODE_HOME', rel: ctx.configDirName }], '$VERSION_DIR/home')}
18
20
  `;
19
21
  },
20
22
  execModeArgs(ctx) {
@@ -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 museAdapter = {
4
4
  id: 'muse',
5
5
  // Muse has no MUSE_CONFIG_DIR. Config is XDG-based:
@@ -23,8 +23,9 @@ export const museAdapter = {
23
23
  # Pin XDG into the version home so managed runs never walk the adopt-time
24
24
  # ~/.config/muse -> version-home symlink — Muse refuses agent-definition
25
25
  # sources that are SymlinkOrReparse (exit 1). Same idea as CLAUDE_CONFIG_DIR.
26
- export XDG_CONFIG_HOME="$VERSION_DIR/home/.config"
27
- export XDG_DATA_HOME="$VERSION_DIR/home/.local/share"
26
+ # An account-slot launch has already chosen the home (AGENTS_EXEC_HOME); the pins
27
+ # yield to it — see slotAwareConfigEnvBash.
28
+ ${slotAwareConfigEnvBash([{ env: 'XDG_CONFIG_HOME', rel: '.config' }, { env: 'XDG_DATA_HOME', rel: '.local/share' }], '$VERSION_DIR/home')}
28
29
  `;
29
30
  },
30
31
  // muse exec: plan ≈ no non-shell writes; auto skips approval prompts but
@@ -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
  // OpenCode reads plugins/agents/commands from OPENCODE_CONFIG_DIR and auth from
4
4
  // $XDG_DATA_HOME/opencode (HARNESS_AUTH.slotEnv). Pin both at the slot / version
5
5
  // home so two accounts in one install never share a credential (PHNX-3940 T5).
@@ -17,7 +17,9 @@ export const opencodeAdapter = {
17
17
  # OpenCode reads plugins, agents, commands, and other config-directory
18
18
  # resources from OPENCODE_CONFIG_DIR. Point it at the versioned global config
19
19
  # tree where agents-cli syncs OpenCode resources.
20
- export OPENCODE_CONFIG_DIR="$VERSION_DIR/home/.config/opencode"
20
+ # An account-slot launch has already chosen the home (AGENTS_EXEC_HOME); the pin
21
+ # yields to it — see slotAwareConfigEnvBash.
22
+ ${slotAwareConfigEnvBash([{ env: 'OPENCODE_CONFIG_DIR', rel: '.config/opencode' }], '$VERSION_DIR/home')}
21
23
  `;
22
24
  },
23
25
  };
@@ -170,7 +170,7 @@ export async function downloadHelperApp(spec, version) {
170
170
  const { zip: zipUrl, sha256: shaUrl } = helperAssetUrls(spec, version);
171
171
  const missing = (status, url) => new Error(`no ${spec.assetName} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
172
172
  `The macOS helper ships as a GitHub release asset on its own helper tag; ` +
173
- `from a repo checkout you can build it locally instead: ${spec.localBuildHint}`);
173
+ `from a repo checkout you can stage it locally instead: ${spec.localBuildHint}`);
174
174
  // Checksum first: it is tiny and 404s fast when the tag has no assets.
175
175
  const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
176
176
  if (!shaRes.ok)
@@ -36,7 +36,7 @@
36
36
  * helper release now, off this table entirely.
37
37
  */
38
38
  export const HELPER_RELEASES = {
39
- menubar: { tagPrefix: 'menubar', floor: '1.1.0' },
39
+ menubar: { tagPrefix: 'menubar', floor: '1.1.1' },
40
40
  'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
41
41
  // The Windows helper is a bare .exe, not an .app bundle, so it does not share
42
42
  // helper-download.ts's zip/codesign/notarize machinery -- but it has the same