@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -13,10 +13,13 @@ import * as os from 'os';
13
13
  import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
14
  import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
+ import { syncAllProjectRoutines } from './routines-project.js';
16
17
  import { JobScheduler } from './scheduler.js';
17
18
  import { MonitorEngine } from './monitors/engine.js';
18
19
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
19
- import { detectOverdueJobs, notifyOverdue, notifyDesktop } from './overdue.js';
20
+ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
+ import { notifyDesktop } from './menubar/notify-desktop.js';
22
+ import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
20
23
  import { BrowserService } from './browser/service.js';
21
24
  import { BrowserIPCServer } from './browser/ipc.js';
22
25
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
@@ -315,10 +318,23 @@ export async function runDaemon() {
315
318
  // secrets-agent and is otherwise absent (leaving the daemon on its existing
316
319
  // interactive OAuth session), matching the detached-start path. Never blocks.
317
320
  if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
318
- const oauthToken = readDaemonClaudeOAuthToken();
321
+ const bundleEnv = readDaemonClaudeBundleEnv();
322
+ const oauthToken = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
319
323
  if (oauthToken) {
320
324
  process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
321
- log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
325
+ // Also inject each per-account CLAUDE_CODE_OAUTH_TOKEN_<slug> present in the
326
+ // bundle, so a routine authenticates its rotation-pinned account via that
327
+ // account's own long-lived, non-rotating setup-token (runner.ts
328
+ // buildRoutineSpawnEnv) instead of the interactive session that rotates and
329
+ // logs the fleet out (Claude Code #25609 / #56339).
330
+ let perAccount = 0;
331
+ for (const [k, v] of Object.entries(bundleEnv)) {
332
+ if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !process.env[k]) {
333
+ process.env[k] = v.trim();
334
+ perAccount++;
335
+ }
336
+ }
337
+ log('INFO', `Loaded Claude OAuth token from secrets bundle for routine runs${perAccount ? ` (+${perAccount} per-account)` : ''}`);
322
338
  }
323
339
  else {
324
340
  // No token available (e.g. a headless macOS daemon whose keychain was
@@ -327,7 +343,11 @@ export async function runDaemon() {
327
343
  // with no signal. Make it loud: WARN in the log and fire a desktop alert.
328
344
  log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
329
345
  'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
330
- notifyDesktop('agents daemon: no Claude credential', 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.');
346
+ notifyDesktop({
347
+ title: 'agents daemon: no Claude credential',
348
+ body: 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.',
349
+ action: 'routines:list',
350
+ });
331
351
  }
332
352
  }
333
353
  // Reap any stray duplicate daemon of this install that slipped past the start
@@ -372,14 +392,50 @@ export async function runDaemon() {
372
392
  ? `workflow: ${config.workflow}`
373
393
  : `agent: ${config.agent}`;
374
394
  log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
395
+ // RUSH-2030: branded desktop notification on start (agent/workflow routines;
396
+ // suppressed for command housekeeping). Finish/output is fired from the
397
+ // onFinish hook below — executeJobDetached finalizes the run in-process, so
398
+ // the monitor tick never sees the live transition. Never let a notification
399
+ // failure break the trigger.
400
+ try {
401
+ notifyRoutineStart(config);
402
+ }
403
+ catch { /* best-effort */ }
375
404
  try {
376
- const meta = await executeJobDetached(config);
405
+ const meta = await executeJobDetached(config, {
406
+ onFinish: (final) => {
407
+ try {
408
+ notifyRoutineFinish(final);
409
+ }
410
+ catch { /* best-effort */ }
411
+ },
412
+ });
377
413
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
378
414
  }
379
415
  catch (err) {
380
- log('ERROR', `Job '${config.name}' failed to spawn: ${err.message}`);
416
+ const message = err.message;
417
+ log('ERROR', `Job '${config.name}' failed to spawn: ${message}`);
418
+ // RUSH-2030: the START ping already fired unconditionally above. A pre-spawn
419
+ // failure produces no run record and thus no onFinish, so send a synthetic
420
+ // "failed to start" finish here — otherwise the user is left with an orphaned
421
+ // "Routine started" and never told it failed.
422
+ try {
423
+ notifyRoutineStartFailed(config, message);
424
+ }
425
+ catch { /* best-effort */ }
381
426
  }
382
427
  });
428
+ // Materialise opted-in project routines into the user layer on every start
429
+ // so a fresh daemon picks up project YAML without a separate sync step.
430
+ try {
431
+ const result = syncAllProjectRoutines();
432
+ const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
433
+ if (n > 0)
434
+ log('INFO', `Project routines sync: ${n} job(s) from ${result.projects.length} project(s)`);
435
+ }
436
+ catch (err) {
437
+ log('WARN', `Project routines sync failed: ${err.message}`);
438
+ }
383
439
  scheduler.loadAll();
384
440
  const scheduled = scheduler.listScheduled();
385
441
  log('INFO', `Loaded ${scheduled.length} jobs`);
@@ -721,6 +777,19 @@ export async function runDaemon() {
721
777
  const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
722
778
  const handleReload = () => {
723
779
  log('INFO', 'Reloading jobs (SIGHUP)');
780
+ // Refresh user-layer copies of opted-in project routines BEFORE the
781
+ // scheduler reloads, so YAML edits under `<project>/.agents/routines/`
782
+ // take effect on the next fire without a manual `routines sync`.
783
+ try {
784
+ const result = syncAllProjectRoutines();
785
+ const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
786
+ if (n > 0 || result.missing.length > 0) {
787
+ log('INFO', `Project routines sync: ${n} updated, ${result.missing.length} missing roots`);
788
+ }
789
+ }
790
+ catch (err) {
791
+ log('WARN', `Project routines sync failed: ${err.message}`);
792
+ }
724
793
  scheduler.reloadAll();
725
794
  const reloaded = scheduler.listScheduled();
726
795
  log('INFO', `Reloaded ${reloaded.length} jobs`);
@@ -774,17 +843,26 @@ export async function runDaemon() {
774
843
  * token leaves the daemon on its existing interactive OAuth session.
775
844
  */
776
845
  export function readDaemonClaudeOAuthToken(opts = {}) {
846
+ const token = (readDaemonClaudeBundleEnv(opts)[DAEMON_OAUTH_KEY] ?? '').trim();
847
+ return token.length > 0 ? token : null;
848
+ }
849
+ /**
850
+ * Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
851
+ * per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
852
+ * never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
853
+ * the bundle can't be read (broker-only headless miss, absent bundle, etc.).
854
+ */
855
+ export function readDaemonClaudeBundleEnv(opts = {}) {
777
856
  try {
778
857
  const allowPrompt = opts.allowPrompt ?? Boolean(process.stdin.isTTY);
779
858
  const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, {
780
859
  caller: 'daemon',
781
860
  agentOnly: !allowPrompt,
782
861
  });
783
- const token = (env[DAEMON_OAUTH_KEY] ?? '').trim();
784
- return token.length > 0 ? token : null;
862
+ return env;
785
863
  }
786
864
  catch {
787
- return null;
865
+ return {};
788
866
  }
789
867
  }
790
868
  /** Escape a string for safe inclusion in an XML <string> node. */
@@ -1012,11 +1090,20 @@ function startDaemonLocked(agentsBin) {
1012
1090
  * the daemon then passes it to every routine run it spawns. An already-set
1013
1091
  * value (e.g. inherited from launchd) is left untouched.
1014
1092
  */
1015
- export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readDaemonClaudeOAuthToken()) {
1093
+ export function buildDetachedDaemonEnv(baseEnv = process.env, bundleEnv = readDaemonClaudeBundleEnv()) {
1016
1094
  const env = { ...baseEnv };
1017
1095
  if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
1018
- if (oauthToken)
1019
- env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
1096
+ const main = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
1097
+ if (main)
1098
+ env.CLAUDE_CODE_OAUTH_TOKEN = main;
1099
+ }
1100
+ // Per-account setup-tokens — same rationale as the runDaemon startup injection:
1101
+ // a routine authenticates its rotation-pinned account via that account's own
1102
+ // long-lived, non-rotating token. An already-set value wins.
1103
+ for (const [k, v] of Object.entries(bundleEnv)) {
1104
+ if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !env[k]) {
1105
+ env[k] = v.trim();
1106
+ }
1020
1107
  }
1021
1108
  return env;
1022
1109
  }
@@ -136,5 +136,20 @@ export interface RunFleetOptions {
136
136
  * recorded as `failed` so one bad device never aborts the rest.
137
137
  */
138
138
  export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
139
+ export interface FanOutDeviceOptions {
140
+ /**
141
+ * Per-device deadline in milliseconds. When set, any probe that does not
142
+ * settle within this window is abandoned via `Promise.race` against a
143
+ * rejection timer and recorded as a `failed` result with the message
144
+ * `'timed out'`. There is no AbortController — the underlying probe
145
+ * continues running in the background; cancellation of the in-flight work
146
+ * is the caller's responsibility. In practice `probeRemoteAuth` relies on
147
+ * `sshExecAsync`'s own 15 s timer to kill the ssh child independently.
148
+ * The per-device ssh timeout passed directly to {@link sshExecAsync} is
149
+ * the first line of defence; this acts as a hard backstop so one slow
150
+ * device can never stall the entire fan-out past its budget.
151
+ */
152
+ perDeviceTimeoutMs?: number;
153
+ }
139
154
  /** Run one async probe per device in parallel, preserving input order. */
140
- export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
155
+ export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>, opts?: FanOutDeviceOptions): Promise<FanOutDeviceResult<T>[]>;
@@ -212,7 +212,7 @@ export function runFleet(targets, cmd, opts = {}) {
212
212
  return results;
213
213
  }
214
214
  /** Run one async probe per device in parallel, preserving input order. */
215
- export async function fanOutDevices(targets, probe) {
215
+ export async function fanOutDevices(targets, probe, opts = {}) {
216
216
  return Promise.all(targets.map(async (target) => {
217
217
  if (target.skip) {
218
218
  return {
@@ -222,10 +222,18 @@ export async function fanOutDevices(targets, probe) {
222
222
  };
223
223
  }
224
224
  try {
225
+ let probePromise = probe(target);
226
+ if (opts.perDeviceTimeoutMs) {
227
+ const timeoutMs = opts.perDeviceTimeoutMs;
228
+ probePromise = Promise.race([
229
+ probePromise,
230
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), timeoutMs)),
231
+ ]);
232
+ }
225
233
  return {
226
234
  name: target.name,
227
235
  status: 'ok',
228
- value: await probe(target),
236
+ value: await probePromise,
229
237
  };
230
238
  }
231
239
  catch (err) {
@@ -12,7 +12,7 @@
12
12
  * - Performance tracking: withTiming() wrapper for any async function
13
13
  */
14
14
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
15
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
15
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
16
  export declare function levelFor(event: EventType): EventLevel;
17
17
  export interface EventMeta {
18
18
  ts: string;
@@ -197,6 +197,22 @@ export declare function inferredInteractiveWithoutTty(options: Pick<ExecOptions,
197
197
  export declare function shouldTapStdout(interactive: boolean, piped: boolean, capsActive: boolean, captureTail?: boolean): boolean;
198
198
  /** Parse an array of KEY=VALUE strings into an env record. Returns undefined for empty input. */
199
199
  export declare function parseExecEnv(entries: string[]): Record<string, string> | undefined;
200
+ /**
201
+ * Resolve the launch id a run exports as `AGENT_LAUNCH_ID`.
202
+ *
203
+ * The launch id is the stable correlation key the SessionStart hook records
204
+ * alongside the agent's real session id (terminals/sessions/<pid>.json), so it
205
+ * is what maps a launch to its exact session even when the hook runs under a
206
+ * different pid (tmux pane leaf / cmd.exe wrapper) — and, across an SSH hop, what
207
+ * lets a `--host` launcher resolve the remote-coined id for agents that never
208
+ * accept a forced `--session-id`.
209
+ *
210
+ * ADOPT a caller-supplied `AGENT_LAUNCH_ID` (a `--host` launcher forwards one via
211
+ * `--env` so it controls the key end-to-end); MINT a fresh one otherwise (every
212
+ * local run, which passes none). A malformed inbound value is ignored in favour
213
+ * of a fresh mint — the key must be a real correlation id, never an empty string.
214
+ */
215
+ export declare function resolveLaunchId(envLaunchId: string | undefined): string;
200
216
  /**
201
217
  * Build the process environment for an agent invocation.
202
218
  * Pins CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, and COPILOT_HOME
package/dist/lib/exec.js CHANGED
@@ -218,6 +218,25 @@ export function parseExecEnv(entries) {
218
218
  }
219
219
  return Object.fromEntries(entries.map(parseExecEnvEntry));
220
220
  }
221
+ /**
222
+ * Resolve the launch id a run exports as `AGENT_LAUNCH_ID`.
223
+ *
224
+ * The launch id is the stable correlation key the SessionStart hook records
225
+ * alongside the agent's real session id (terminals/sessions/<pid>.json), so it
226
+ * is what maps a launch to its exact session even when the hook runs under a
227
+ * different pid (tmux pane leaf / cmd.exe wrapper) — and, across an SSH hop, what
228
+ * lets a `--host` launcher resolve the remote-coined id for agents that never
229
+ * accept a forced `--session-id`.
230
+ *
231
+ * ADOPT a caller-supplied `AGENT_LAUNCH_ID` (a `--host` launcher forwards one via
232
+ * `--env` so it controls the key end-to-end); MINT a fresh one otherwise (every
233
+ * local run, which passes none). A malformed inbound value is ignored in favour
234
+ * of a fresh mint — the key must be a real correlation id, never an empty string.
235
+ */
236
+ export function resolveLaunchId(envLaunchId) {
237
+ const inbound = envLaunchId?.trim();
238
+ return inbound ? inbound : randomUUID();
239
+ }
221
240
  /**
222
241
  * Build the process environment for an agent invocation.
223
242
  * Pins CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, and COPILOT_HOME
@@ -1172,15 +1191,19 @@ async function spawnAgent(options) {
1172
1191
  // timeout. Spend is recorded to the shared ledger in the close handler. The
1173
1192
  // watcher is dormant (and zero-cost) when no caps are configured.
1174
1193
  const cwd = options.cwd || process.cwd();
1175
- // Mint the launch id once. It doubles as the budget watcher's run id AND is
1194
+ // Resolve the launch id once. It doubles as the budget watcher's run id AND is
1176
1195
  // exported to the child as AGENT_LAUNCH_ID, so the agent's SessionStart hook
1177
1196
  // records the SAME id in its own state file (terminals/sessions/<pid>.json).
1178
1197
  // That id is the join key that reconciles this launch's pid-registry entry
1179
1198
  // with the hook's authoritative session id even when the hook runs under a
1180
1199
  // different pid (tmux pane leaf / cmd.exe wrapper) — see pid-registry.ts and
1181
- // session/hook-sessions.ts. Injected into options.env so every downstream env
1182
- // build (the bare spawn below AND the tmux env prefix in runInTmux) carries it.
1183
- const launchId = randomUUID();
1200
+ // session/hook-sessions.ts. ADOPT a launch id a `--host` launcher already
1201
+ // forwarded (via `--env AGENT_LAUNCH_ID=…`) so ONE correlation key spans the
1202
+ // SSH hop and the launcher can resolve this run's real session id for every
1203
+ // agent, not just Claude (RUSH-2034); mint a fresh one for every local run.
1204
+ // Injected into options.env so every downstream env build (the bare spawn
1205
+ // below AND the tmux env prefix in runInTmux) carries it.
1206
+ const launchId = resolveLaunchId(options.env?.AGENT_LAUNCH_ID);
1184
1207
  const runId = launchId;
1185
1208
  options = { ...options, env: { ...options.env, AGENT_LAUNCH_ID: launchId } };
1186
1209
  const watcherState = await setupBudgetWatcher(options, cwd, runId);
@@ -172,7 +172,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
172
172
  * Embedded so it ships with the compiled CLI and can be installed to the
173
173
  * CLI-writable user hooks dir without a separate file in the npm tarball.
174
174
  */
175
- 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\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 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 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 if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\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 # 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 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 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_iso = datetime.now(timezone.utc).isoformat()\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\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 \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior.\n controls = payload.get(\"tool_input\", {}) if hook_event != \"Notification\" 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";
175
+ 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 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 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 if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\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 # 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 the Factory extension 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_iso = datetime.now(timezone.utc).isoformat()\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\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 \"questions\": normalized_questions,\n \"kind\": kind,\n }\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";
176
176
  /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
177
177
  export declare const FEED_PUBLISH_HOOK_MANIFEST: {
178
178
  name: string;
@@ -188,6 +188,12 @@ export declare const FEED_NOTIFICATION_HOOK_MANIFEST: {
188
188
  script: string;
189
189
  timeout: number;
190
190
  };
191
+ export declare const FEED_PERMISSION_HOOK_MANIFEST: {
192
+ name: string;
193
+ events: string[];
194
+ script: string;
195
+ timeout: number;
196
+ };
191
197
  export declare const FEED_ANSWERED_HOOK_MANIFEST: {
192
198
  name: string;
193
199
  events: string[];
package/dist/lib/feed.js CHANGED
@@ -348,6 +348,10 @@ CLEAR_EVENTS = {
348
348
  "Stop",
349
349
  "SessionEnd",
350
350
  }
351
+ # Codex emits a PermissionRequest event (not Claude's Notification) when it
352
+ # blocks on an approval prompt. Claude never fires PermissionRequest, so the
353
+ # same script handles both: PermissionRequest maps to an approval-class block
354
+ # with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.
351
355
 
352
356
 
353
357
  def read_json(path):
@@ -398,6 +402,19 @@ def main():
398
402
  hook_event = payload.get("hook_event_name", "PreToolUse")
399
403
 
400
404
  if hook_event in CLEAR_EVENTS:
405
+ # A matcher-less PostToolUse clear (registered for Codex so an approved
406
+ # tool clears its approval card) must NOT wipe an open AskUserQuestion
407
+ # while an unrelated tool runs mid-question -- those are cleared only by
408
+ # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a
409
+ # 'question' block; approval/notification blocks clear once the tool runs.
410
+ if hook_event == "PostToolUse":
411
+ try:
412
+ with open(target) as existing_file:
413
+ existing = json.load(existing_file)
414
+ if existing.get("kind") == "question" and payload.get("tool_name") != "AskUserQuestion":
415
+ return
416
+ except Exception:
417
+ pass
401
418
  try:
402
419
  os.unlink(target)
403
420
  except FileNotFoundError:
@@ -442,6 +459,7 @@ def main():
442
459
  return
443
460
 
444
461
  notification_type = None
462
+ codex_approval = False
445
463
  if hook_event == "Notification":
446
464
  notification_type = payload.get("notification_type", "")
447
465
  if notification_type not in WAITING_NOTIFICATION_TYPES:
@@ -466,6 +484,34 @@ def main():
466
484
  "multiSelect": False,
467
485
  }]
468
486
  kind = "notification"
487
+ elif hook_event == "PermissionRequest":
488
+ # Codex approval prompt. The payload mirrors PreToolUse (tool_name,
489
+ # tool_input) but carries no questions -- Codex is asking to run a tool,
490
+ # not asking the operator a multiple-choice question. Publish it as a
491
+ # notification-kind approval block naming the tool so the feed and the
492
+ # phone notifier can surface it, and so the Factory extension can bridge
493
+ # it to a VS Code notification.
494
+ tool_name = payload.get("tool_name") or "a tool"
495
+ tool_input = payload.get("tool_input", {})
496
+ command = ""
497
+ if isinstance(tool_input, dict):
498
+ command = (
499
+ tool_input.get("command")
500
+ or tool_input.get("cmd")
501
+ or tool_input.get("path")
502
+ or ""
503
+ )
504
+ if isinstance(command, list):
505
+ command = " ".join(str(c) for c in command)
506
+ detail = f": {command}" if command else ""
507
+ normalized_questions = [{
508
+ "text": f"Codex needs approval to run {tool_name}{detail}",
509
+ "header": "Approval needed",
510
+ "multiSelect": False,
511
+ }]
512
+ kind = "notification"
513
+ notification_type = "permission_prompt"
514
+ codex_approval = True
469
515
  else:
470
516
  tool_input = payload.get("tool_input", {})
471
517
  questions = tool_input.get("questions", [])
@@ -535,9 +581,21 @@ def main():
535
581
  if notification_type:
536
582
  block["notificationType"] = notification_type
537
583
 
584
+ # A Codex PermissionRequest is a real approval gate: mark it approval-class
585
+ # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent
586
+ # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default
587
+ # 'medium') and pages the phone. A plain 'deny' is the safe default.
588
+ if codex_approval:
589
+ block["blockClass"] = "approval"
590
+ block["costOfDelay"] = "high"
591
+ block["safeDefault"] = "deny"
592
+
538
593
  # Optional multi-operator control metadata passed by the agent in the
539
- # AskUserQuestion tool_input. Defaults keep the existing behavior.
540
- controls = payload.get("tool_input", {}) if hook_event != "Notification" else {}
594
+ # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex
595
+ # PermissionRequest carries tool ARGS in tool_input (command/path), not
596
+ # operator controls, so it is excluded here -- its class/cost is stamped
597
+ # above from codex_approval.
598
+ controls = payload.get("tool_input", {}) if hook_event not in ("Notification", "PermissionRequest") else {}
541
599
  block_class = controls.get("blockClass") if isinstance(controls, dict) else None
542
600
  if block_class in ("approval", "decision"):
543
601
  block["blockClass"] = block_class
@@ -604,6 +662,15 @@ export const FEED_NOTIFICATION_HOOK_MANIFEST = {
604
662
  script: '10-feed-publish.py',
605
663
  timeout: 5,
606
664
  };
665
+ // Codex fires PermissionRequest (not Claude's Notification) when it blocks on an
666
+ // approval prompt. The same script handles it, publishing a high-cost approval
667
+ // block so the feed dispatch pages the phone. PermissionRequest has no matcher.
668
+ export const FEED_PERMISSION_HOOK_MANIFEST = {
669
+ name: 'feed-publish-permission',
670
+ events: ['PermissionRequest'],
671
+ script: '10-feed-publish.py',
672
+ timeout: 5,
673
+ };
607
674
  export const FEED_ANSWERED_HOOK_MANIFEST = {
608
675
  name: 'feed-clear-answered',
609
676
  events: ['PostToolUse'],
@@ -644,28 +711,51 @@ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
644
711
  }
645
712
  const desiredHooks = {
646
713
  'feed-publish': {
647
- agents: ['claude'],
714
+ agents: ['claude', 'codex'],
648
715
  events: ['PreToolUse'],
649
716
  matcher: 'AskUserQuestion',
650
717
  script: '10-feed-publish.py',
651
718
  timeout: 5,
652
719
  },
653
720
  'feed-publish-notification': {
654
- agents: ['claude'],
721
+ agents: ['claude', 'codex'],
655
722
  events: ['Notification'],
656
723
  matcher: 'permission_prompt|idle_prompt|elicitation_dialog',
657
724
  script: '10-feed-publish.py',
658
725
  timeout: 5,
659
726
  },
727
+ // Codex-specific approval gate: Codex emits PermissionRequest (Claude does
728
+ // not), so this hook is where a blocked Codex agent surfaces to the feed.
729
+ 'feed-publish-permission': {
730
+ agents: ['claude', 'codex'],
731
+ events: ['PermissionRequest'],
732
+ script: '10-feed-publish.py',
733
+ timeout: 5,
734
+ },
660
735
  'feed-clear-answered': {
661
- agents: ['claude'],
736
+ agents: ['claude', 'codex'],
662
737
  events: ['PostToolUse'],
663
738
  matcher: 'AskUserQuestion',
664
739
  script: '10-feed-publish.py',
665
740
  timeout: 5,
666
741
  },
742
+ // Matcher-less PostToolUse clear: after Codex runs an approved tool, the
743
+ // approval card is stale, so clear it. Codex-only on purpose -- Claude
744
+ // never fires PermissionRequest, so it has no approval card to clear here,
745
+ // and a matcher-less PostToolUse for Claude would (1) re-run the script on
746
+ // every tool completion and (2) wipe Claude's notification-kind blocks
747
+ // (permission_prompt/idle_prompt/elicitation_dialog) the moment any later
748
+ // tool runs, instead of letting them persist to Stop/SessionEnd like they
749
+ // did before RUSH-2039. Registering it for codex alone keeps Claude's
750
+ // card lifetime exactly as it was.
751
+ 'feed-clear-permission': {
752
+ agents: ['codex'],
753
+ events: ['PostToolUse'],
754
+ script: '10-feed-publish.py',
755
+ timeout: 5,
756
+ },
667
757
  'feed-clear-lifecycle': {
668
- agents: ['claude'],
758
+ agents: ['claude', 'codex'],
669
759
  events: ['Stop', 'UserPromptSubmit', 'SessionEnd'],
670
760
  script: '10-feed-publish.py',
671
761
  timeout: 5,
@@ -87,10 +87,13 @@ export declare function healChangedAnything(r: HealResult): boolean;
87
87
  /** One-line summary of a heal pass for daemon logs. */
88
88
  export declare function summarizeHeal(r: HealResult): string;
89
89
  /**
90
- * Fire a native desktop notification when a background heal did something
91
- * noteworthy. Best-effort missing `osascript`/`notify-send` or no display is
92
- * swallowed. Silent when the pass auto-fixed everything and nothing needs the
93
- * operator (no point pinging them for routine self-healing).
90
+ * Fire a branded desktop notification when a background heal did something
91
+ * noteworthy. Routed through the MenubarHelper companion (notify-desktop.ts) so
92
+ * it carries the agents-cli mark; clicking opens the runs folder
93
+ * (~/.agents/.history/runs, via the `routines:list` action the companion
94
+ * understands). Best-effort — a missing
95
+ * notifier or no display is swallowed. Silent when the pass auto-fixed everything
96
+ * and nothing needs the operator (no point pinging them for routine self-healing).
94
97
  */
95
98
  export declare function notifyHeal(r: HealResult): void;
96
99
  /**