@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.
- package/CHANGELOG.md +162 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +58 -16
- package/dist/commands/routines.js +271 -13
- package/dist/commands/secrets.js +59 -17
- package/dist/commands/sessions-browser.js +11 -1
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +168 -15
- package/dist/commands/sessions.d.ts +23 -4
- package/dist/commands/sessions.js +128 -38
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +39 -2
- package/dist/index.js +2 -1
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- package/dist/lib/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +99 -12
- package/dist/lib/devices/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +31 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- package/dist/lib/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +150 -25
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +9 -4
- package/dist/lib/secrets/agent.js +30 -20
- package/dist/lib/secrets/bundles.d.ts +20 -6
- package/dist/lib/secrets/bundles.js +70 -34
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +10 -0
- package/dist/lib/session/active.js +67 -4
- package/dist/lib/session/db.d.ts +6 -0
- package/dist/lib/session/db.js +83 -6
- package/dist/lib/session/discover.d.ts +13 -1
- package/dist/lib/session/discover.js +91 -3
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +93 -11
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
return token.length > 0 ? token : null;
|
|
862
|
+
return env;
|
|
785
863
|
}
|
|
786
864
|
catch {
|
|
787
|
-
return
|
|
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,
|
|
1093
|
+
export function buildDetachedDaemonEnv(baseEnv = process.env, bundleEnv = readDaemonClaudeBundleEnv()) {
|
|
1016
1094
|
const env = { ...baseEnv };
|
|
1017
1095
|
if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
|
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
|
|
236
|
+
value: await probePromise,
|
|
229
237
|
};
|
|
230
238
|
}
|
|
231
239
|
catch (err) {
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
1182
|
-
//
|
|
1183
|
-
|
|
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);
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/lib/heal.d.ts
CHANGED
|
@@ -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
|
|
91
|
-
* noteworthy.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
/**
|