@phnx-labs/agents-cli 1.20.74 → 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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -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 +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- 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 +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- 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 +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -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.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent status posts — deliberate progress messages into the activity stream.
|
|
3
|
+
*
|
|
4
|
+
* Surface: `agents feed post <text>` (agent-callable; humans watch via
|
|
5
|
+
* `agents feed` / `agents activity` / `agents events --module activity`).
|
|
6
|
+
*
|
|
7
|
+
* Identity is automatic: session id, agent, cwd, launch/pid/tmux provenance
|
|
8
|
+
* are resolved from the process environment and the per-pid launch registry
|
|
9
|
+
* (`lib/session/pid-registry.ts`). The agent only authors free-form text —
|
|
10
|
+
* no domain-specific flags (tickets, URLs, tracks).
|
|
11
|
+
*
|
|
12
|
+
* Storage: append-only activity log as a `status.posted` milestone. Does NOT
|
|
13
|
+
* open a feed block (blocks remain "needs you" state only).
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { spawnSync } from 'child_process';
|
|
18
|
+
import { appendActivityEvent, } from './activity.js';
|
|
19
|
+
import { machineId } from './machine-id.js';
|
|
20
|
+
import { isValidMailboxId } from './mailbox.js';
|
|
21
|
+
import { listPidSessionEntries, readPidSessionEntry, } from './session/pid-registry.js';
|
|
22
|
+
/** Soft cap so a runaway agent can't flood the activity lane with essays. */
|
|
23
|
+
export const STATUS_POST_MAX_CHARS = 500;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve who is posting. Order:
|
|
26
|
+
* 1. Explicit --session flag
|
|
27
|
+
* 2. Env: AGENT_SESSION_ID / AGENTS_SESSION_ID / basename(AGENTS_MAILBOX_DIR)
|
|
28
|
+
* 3. Env AGENT_LAUNCH_ID → match in pid registry
|
|
29
|
+
* 4. Walk parent PIDs from startPid (default process.ppid) through by-pid registry
|
|
30
|
+
*/
|
|
31
|
+
export function resolvePostIdentity(input) {
|
|
32
|
+
const env = input.env ?? process.env;
|
|
33
|
+
const readEntry = input.readEntry ?? readPidSessionEntry;
|
|
34
|
+
const listEntries = input.listEntries ?? listPidSessionEntries;
|
|
35
|
+
const getParent = input.getParentPid ?? parentPidOf;
|
|
36
|
+
const envSession = firstValidId([
|
|
37
|
+
input.sessionId,
|
|
38
|
+
env.AGENT_SESSION_ID,
|
|
39
|
+
env.AGENTS_SESSION_ID,
|
|
40
|
+
mailboxIdFromEnv(env),
|
|
41
|
+
]);
|
|
42
|
+
const launchId = env.AGENT_LAUNCH_ID?.trim() || undefined;
|
|
43
|
+
let registry;
|
|
44
|
+
if (launchId) {
|
|
45
|
+
registry = listEntries().find((e) => e.launchId === launchId);
|
|
46
|
+
}
|
|
47
|
+
if (!registry) {
|
|
48
|
+
const start = input.startPid ?? (typeof process.ppid === 'number' ? process.ppid : undefined);
|
|
49
|
+
if (start && start > 1) {
|
|
50
|
+
registry = walkPidRegistry(start, getParent, readEntry);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Prefer env session (explicit + managed run), fill gaps from registry.
|
|
54
|
+
const sessionId = envSession ?? registry?.sessionId;
|
|
55
|
+
if (!sessionId || !isValidMailboxId(sessionId))
|
|
56
|
+
return undefined;
|
|
57
|
+
const mailboxFromEnv = mailboxIdFromEnv(env);
|
|
58
|
+
const mailboxId = mailboxFromEnv && isValidMailboxId(mailboxFromEnv)
|
|
59
|
+
? mailboxFromEnv
|
|
60
|
+
: sessionId;
|
|
61
|
+
return {
|
|
62
|
+
sessionId,
|
|
63
|
+
mailboxId,
|
|
64
|
+
host: machineIdFromEnv(env),
|
|
65
|
+
runtime: env.AGENTS_RUNTIME?.trim() || 'headless',
|
|
66
|
+
agent: env.AGENTS_AGENT_NAME?.trim()
|
|
67
|
+
|| registry?.agent
|
|
68
|
+
|| detectAgentKind(env),
|
|
69
|
+
cwd: input.cwd
|
|
70
|
+
?? (env.AGENTS_CWD?.trim() || registry?.cwd || process.cwd()),
|
|
71
|
+
pid: registry?.pid,
|
|
72
|
+
launchId: launchId || registry?.launchId,
|
|
73
|
+
terminalId: env.AGENT_TERMINAL_ID?.trim() || registry?.terminalId,
|
|
74
|
+
tmuxPane: env.TMUX_PANE?.trim() || registry?.tmuxPane,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function firstValidId(candidates) {
|
|
78
|
+
for (const raw of candidates) {
|
|
79
|
+
const id = (raw ?? '').trim();
|
|
80
|
+
if (id && isValidMailboxId(id))
|
|
81
|
+
return id;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
function mailboxIdFromEnv(env) {
|
|
86
|
+
const dir = env.AGENTS_MAILBOX_DIR?.trim();
|
|
87
|
+
if (!dir)
|
|
88
|
+
return undefined;
|
|
89
|
+
const base = path.basename(dir.replace(/[/\\]+$/, ''));
|
|
90
|
+
return base || undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Walk up to 16 ancestors looking for a by-pid registry entry with a session. */
|
|
93
|
+
export function walkPidRegistry(startPid, getParent, readEntry) {
|
|
94
|
+
let pid = startPid;
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
let firstHit;
|
|
97
|
+
for (let i = 0; i < 16 && pid && pid > 1 && !seen.has(pid); i++) {
|
|
98
|
+
seen.add(pid);
|
|
99
|
+
const entry = readEntry(pid);
|
|
100
|
+
if (entry) {
|
|
101
|
+
if (entry.sessionId && isValidMailboxId(entry.sessionId))
|
|
102
|
+
return entry;
|
|
103
|
+
if (!firstHit)
|
|
104
|
+
firstHit = entry;
|
|
105
|
+
}
|
|
106
|
+
pid = getParent(pid);
|
|
107
|
+
}
|
|
108
|
+
// Entry without sessionId still carries agent/cwd/launchId — usable when
|
|
109
|
+
// session id comes from env.
|
|
110
|
+
return firstHit;
|
|
111
|
+
}
|
|
112
|
+
/** Best-effort parent pid of `pid` (Linux /proc, else `ps`). */
|
|
113
|
+
export function parentPidOf(pid) {
|
|
114
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (process.platform === 'linux') {
|
|
117
|
+
try {
|
|
118
|
+
const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8');
|
|
119
|
+
const m = status.match(/^PPid:\s*(\d+)/m);
|
|
120
|
+
if (m) {
|
|
121
|
+
const pp = Number(m[1]);
|
|
122
|
+
return Number.isInteger(pp) && pp > 0 ? pp : undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* fall through */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const r = spawnSync('ps', ['-o', 'ppid=', '-p', String(pid)], {
|
|
131
|
+
encoding: 'utf8',
|
|
132
|
+
timeout: 1000,
|
|
133
|
+
});
|
|
134
|
+
if (r.status === 0) {
|
|
135
|
+
const pp = Number((r.stdout || '').trim());
|
|
136
|
+
return Number.isInteger(pp) && pp > 0 ? pp : undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* best-effort */
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
export function normalizeStatusText(text) {
|
|
145
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
146
|
+
if (!collapsed)
|
|
147
|
+
return '';
|
|
148
|
+
if (collapsed.length <= STATUS_POST_MAX_CHARS)
|
|
149
|
+
return collapsed;
|
|
150
|
+
return `${collapsed.slice(0, STATUS_POST_MAX_CHARS - 1)}…`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Append a `status.posted` milestone for the calling agent.
|
|
154
|
+
* Throws if text is empty or session identity cannot be resolved.
|
|
155
|
+
*/
|
|
156
|
+
export function postFeedStatus(input) {
|
|
157
|
+
const detail = normalizeStatusText(input.text);
|
|
158
|
+
if (!detail) {
|
|
159
|
+
throw new Error('Status text is empty. Usage: agents feed post "what just happened"');
|
|
160
|
+
}
|
|
161
|
+
const identity = resolvePostIdentity(input);
|
|
162
|
+
if (!identity) {
|
|
163
|
+
throw new Error('No session id. Run from an agents-cli session '
|
|
164
|
+
+ '(AGENT_SESSION_ID / AGENTS_MAILBOX_DIR / pid registry), or pass --session <id>.');
|
|
165
|
+
}
|
|
166
|
+
const event = {
|
|
167
|
+
ts: input.ts ?? new Date().toISOString(),
|
|
168
|
+
event: 'status.posted',
|
|
169
|
+
sessionId: identity.sessionId,
|
|
170
|
+
mailboxId: identity.mailboxId,
|
|
171
|
+
host: identity.host,
|
|
172
|
+
runtime: identity.runtime,
|
|
173
|
+
cwd: identity.cwd,
|
|
174
|
+
agent: identity.agent,
|
|
175
|
+
tool: 'feed.post',
|
|
176
|
+
detail,
|
|
177
|
+
...(identity.pid !== undefined ? { pid: identity.pid } : {}),
|
|
178
|
+
...(identity.launchId ? { launchId: identity.launchId } : {}),
|
|
179
|
+
...(identity.terminalId ? { terminalId: identity.terminalId } : {}),
|
|
180
|
+
...(identity.tmuxPane ? { tmuxPane: identity.tmuxPane } : {}),
|
|
181
|
+
};
|
|
182
|
+
appendActivityEvent(event, input.activityRoot);
|
|
183
|
+
return {
|
|
184
|
+
event: {
|
|
185
|
+
v: 1,
|
|
186
|
+
tier: 'milestone',
|
|
187
|
+
...event,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function machineIdFromEnv(env) {
|
|
192
|
+
const raw = env.AGENTS_SYNC_MACHINE_ID || undefined;
|
|
193
|
+
if (raw) {
|
|
194
|
+
return raw.split('.')[0].trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'unknown';
|
|
195
|
+
}
|
|
196
|
+
return machineId();
|
|
197
|
+
}
|
|
198
|
+
function detectAgentKind(env) {
|
|
199
|
+
if (env.CLAUDECODE === '1')
|
|
200
|
+
return 'claude';
|
|
201
|
+
if (env.CODEX_CI || env.CODEX_HOME)
|
|
202
|
+
return 'codex';
|
|
203
|
+
return 'agent';
|
|
204
|
+
}
|
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,
|
|
@@ -11,6 +11,30 @@ import type { DeviceProfile } from '../devices/registry.js';
|
|
|
11
11
|
import type { DeviceDesired, DeviceProbe, DeviceDiff, FleetAction, FleetPlan, AuthFilePayload } from './types.js';
|
|
12
12
|
/** Strip a version suffix from an agent spec: `claude@latest` -> `claude`. */
|
|
13
13
|
export declare function agentIdOf(spec: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* The explicit pinned version of a spec, or undefined for an id-level spec.
|
|
16
|
+
* `claude@2.1.170` -> `2.1.170`; `claude`, `claude@latest`, `claude@oldest`, and
|
|
17
|
+
* `claude@all` all return undefined (the label channels install-latest / are
|
|
18
|
+
* expanded upstream, so they diff at id granularity, not per-version).
|
|
19
|
+
*/
|
|
20
|
+
export declare function pinnedVersion(spec: string): string | undefined;
|
|
21
|
+
/** True when any spec in the roster pins an explicit version (needs a version probe). */
|
|
22
|
+
export declare function rosterNeedsVersions(desired: DeviceDesired[]): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Expand any `<agent>@all` spec into one pinned spec per version installed on the
|
|
25
|
+
* source, so `--agent claude@all` replicates THIS machine's exact version set.
|
|
26
|
+
* `versionsOf` returns the source's installed versions for an agent id. Every
|
|
27
|
+
* other spec passes through unchanged; the result is de-duplicated in order.
|
|
28
|
+
* Throws if `@all` names an agent with no installed versions here (nothing to
|
|
29
|
+
* replicate — a clear misconfig, not a silent no-op).
|
|
30
|
+
*/
|
|
31
|
+
export declare function expandAllSpecs(specs: string[], versionsOf: (id: string) => string[]): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Parse `agents view --json` (the all-agents array form) into a map of agent id
|
|
34
|
+
* -> installed version strings. Tolerant: returns undefined on any parse failure
|
|
35
|
+
* so a version-pinned spec falls back to id-level presence rather than crashing.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseInstalledVersions(stdout: string): Record<string, string[]> | undefined;
|
|
14
38
|
/** Source-side auth availability, computed once from `snapshotAuth`. */
|
|
15
39
|
export interface SourceAuth {
|
|
16
40
|
/** Agent ids the source has a readable, propagatable credential file for. */
|
|
@@ -37,8 +61,14 @@ export interface DiffContext {
|
|
|
37
61
|
}
|
|
38
62
|
/** Pure: desired vs probed -> per-device diff + flat action list. */
|
|
39
63
|
export declare function diffFleet(desired: DeviceDesired[], probes: Map<string, DeviceProbe>, ctx: DiffContext): FleetPlan;
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
export interface ProbeOptions {
|
|
65
|
+
/** Also fetch per-agent installed versions (one extra `agents view --json`
|
|
66
|
+
* round-trip). Enable only when the plan has a version-pinned spec. */
|
|
67
|
+
withVersions?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** Probe one device: reachability + agents-cli version + installed agent ids
|
|
70
|
+
* (and, when `withVersions`, the installed version strings per agent). */
|
|
71
|
+
export declare function probeDevice(device: DeviceProfile, opts?: ProbeOptions): DeviceProbe;
|
|
42
72
|
export interface ApplyStep {
|
|
43
73
|
kind: FleetAction['kind'];
|
|
44
74
|
ok: boolean;
|
package/dist/lib/fleet/apply.js
CHANGED
|
@@ -17,6 +17,76 @@ import { isPropagatableAgent, KEYCHAIN_BOUND_ON_MAC, buildAuthBundle, } from './
|
|
|
17
17
|
export function agentIdOf(spec) {
|
|
18
18
|
return spec.split('@')[0].trim();
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The explicit pinned version of a spec, or undefined for an id-level spec.
|
|
22
|
+
* `claude@2.1.170` -> `2.1.170`; `claude`, `claude@latest`, `claude@oldest`, and
|
|
23
|
+
* `claude@all` all return undefined (the label channels install-latest / are
|
|
24
|
+
* expanded upstream, so they diff at id granularity, not per-version).
|
|
25
|
+
*/
|
|
26
|
+
export function pinnedVersion(spec) {
|
|
27
|
+
const at = spec.indexOf('@');
|
|
28
|
+
if (at < 0)
|
|
29
|
+
return undefined;
|
|
30
|
+
const v = spec.slice(at + 1).trim();
|
|
31
|
+
if (!v || v === 'latest' || v === 'oldest' || v === 'all')
|
|
32
|
+
return undefined;
|
|
33
|
+
return v;
|
|
34
|
+
}
|
|
35
|
+
/** True when any spec in the roster pins an explicit version (needs a version probe). */
|
|
36
|
+
export function rosterNeedsVersions(desired) {
|
|
37
|
+
return desired.some((d) => d.agents.some((s) => pinnedVersion(s) !== undefined));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Expand any `<agent>@all` spec into one pinned spec per version installed on the
|
|
41
|
+
* source, so `--agent claude@all` replicates THIS machine's exact version set.
|
|
42
|
+
* `versionsOf` returns the source's installed versions for an agent id. Every
|
|
43
|
+
* other spec passes through unchanged; the result is de-duplicated in order.
|
|
44
|
+
* Throws if `@all` names an agent with no installed versions here (nothing to
|
|
45
|
+
* replicate — a clear misconfig, not a silent no-op).
|
|
46
|
+
*/
|
|
47
|
+
export function expandAllSpecs(specs, versionsOf) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const spec of specs) {
|
|
50
|
+
const at = spec.indexOf('@');
|
|
51
|
+
const label = at >= 0 ? spec.slice(at + 1).trim() : '';
|
|
52
|
+
if (label !== 'all') {
|
|
53
|
+
out.push(spec);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const id = agentIdOf(spec);
|
|
57
|
+
const versions = versionsOf(id);
|
|
58
|
+
if (versions.length === 0) {
|
|
59
|
+
throw new Error(`--agent ${spec}: no ${id} versions installed on this machine to replicate.`);
|
|
60
|
+
}
|
|
61
|
+
for (const v of versions)
|
|
62
|
+
out.push(`${id}@${v}`);
|
|
63
|
+
}
|
|
64
|
+
return [...new Set(out)];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse `agents view --json` (the all-agents array form) into a map of agent id
|
|
68
|
+
* -> installed version strings. Tolerant: returns undefined on any parse failure
|
|
69
|
+
* so a version-pinned spec falls back to id-level presence rather than crashing.
|
|
70
|
+
*/
|
|
71
|
+
export function parseInstalledVersions(stdout) {
|
|
72
|
+
try {
|
|
73
|
+
const arr = JSON.parse(stdout);
|
|
74
|
+
if (!Array.isArray(arr))
|
|
75
|
+
return undefined;
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const a of arr) {
|
|
78
|
+
if (a && typeof a.agent === 'string' && Array.isArray(a.versions)) {
|
|
79
|
+
out[a.agent] = a.versions
|
|
80
|
+
.map((v) => v?.version)
|
|
81
|
+
.filter((v) => typeof v === 'string');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
20
90
|
/**
|
|
21
91
|
* Can we propagate `agent`'s login to a target on `targetPlatform`? False when
|
|
22
92
|
* the agent has no portable file, the source can't provide it (bound / not
|
|
@@ -53,21 +123,28 @@ export function diffFleet(desired, probes, ctx) {
|
|
|
53
123
|
else if (probe.cliVersion !== ctx.targetCliVersion) {
|
|
54
124
|
rowActions.push({ device: d.device, kind: 'upgrade-cli', detail: `agents-cli ${probe.cliVersion} -> ${ctx.targetCliVersion}` });
|
|
55
125
|
}
|
|
56
|
-
// agents.
|
|
126
|
+
// agents. A version-pinned spec (`claude@2.1.170`, or an expanded
|
|
127
|
+
// `claude@all` member) is present only when that exact version is on the
|
|
128
|
+
// device; a bare/latest spec diffs at id granularity. So `--agent claude@all`
|
|
129
|
+
// installs every missing version even when some claude is already there.
|
|
57
130
|
for (const spec of d.agents) {
|
|
58
131
|
const id = agentIdOf(spec);
|
|
59
|
-
|
|
60
|
-
|
|
132
|
+
const want = pinnedVersion(spec);
|
|
133
|
+
const present = want !== undefined
|
|
134
|
+
? (probe.installedVersions?.[id]?.includes(want) ?? false)
|
|
135
|
+
: probe.installedAgents.includes(id);
|
|
136
|
+
if (!present) {
|
|
137
|
+
rowActions.push({ device: d.device, kind: 'add-agent', agent: id, spec, detail: `install ${spec}` });
|
|
61
138
|
}
|
|
62
139
|
}
|
|
63
140
|
// config.
|
|
64
141
|
if (d.sync.length > 0) {
|
|
65
142
|
rowActions.push({ device: d.device, kind: 'sync-config', detail: `sync config (${d.sync.join(', ')})` });
|
|
66
143
|
}
|
|
67
|
-
// login
|
|
144
|
+
// login — per agent id, not per spec: `claude@all` names one id many times,
|
|
145
|
+
// but login propagates once per agent (its credential is version-shared).
|
|
68
146
|
if (d.login === 'sync') {
|
|
69
|
-
for (const
|
|
70
|
-
const id = agentIdOf(spec);
|
|
147
|
+
for (const id of [...new Set(d.agents.map(agentIdOf))]) {
|
|
71
148
|
if (canPushLogin(id, probe.platform, ctx.sourceAuth)) {
|
|
72
149
|
rowActions.push({ device: d.device, kind: 'push-login', agent: id, detail: `propagate ${id} login` });
|
|
73
150
|
}
|
|
@@ -107,8 +184,9 @@ function osHint(platform) {
|
|
|
107
184
|
function remoteEnv(platform) {
|
|
108
185
|
return platform === 'windows' ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
|
|
109
186
|
}
|
|
110
|
-
/** Probe one device: reachability + agents-cli version + installed agent ids
|
|
111
|
-
|
|
187
|
+
/** Probe one device: reachability + agents-cli version + installed agent ids
|
|
188
|
+
* (and, when `withVersions`, the installed version strings per agent). */
|
|
189
|
+
export function probeDevice(device, opts) {
|
|
112
190
|
let target;
|
|
113
191
|
try {
|
|
114
192
|
target = sshTargetFor(device);
|
|
@@ -133,12 +211,20 @@ export function probeDevice(device) {
|
|
|
133
211
|
/* agents-cli present but doctor output unparsable — treat as no agents */
|
|
134
212
|
}
|
|
135
213
|
}
|
|
214
|
+
let installedVersions;
|
|
215
|
+
if (opts?.withVersions) {
|
|
216
|
+
const viewCmd = buildRemoteAgentsInvocation(['view', '--json'], undefined, hint, remoteEnv(device.platform));
|
|
217
|
+
const vres = sshExec(target, viewCmd, { timeoutMs: 30000, multiplex: true });
|
|
218
|
+
if (vres.code === 0)
|
|
219
|
+
installedVersions = parseInstalledVersions(vres.stdout);
|
|
220
|
+
}
|
|
136
221
|
return {
|
|
137
222
|
device: device.name,
|
|
138
223
|
reachable: true,
|
|
139
224
|
platform: device.platform,
|
|
140
225
|
cliVersion: ready.version ?? undefined,
|
|
141
226
|
installedAgents: installed,
|
|
227
|
+
installedVersions,
|
|
142
228
|
};
|
|
143
229
|
}
|
|
144
230
|
/** Execute one device's planned actions in order. Real SSH — no mocks. */
|
|
@@ -165,9 +251,10 @@ export function reconcileDevice(row, device, ctx) {
|
|
|
165
251
|
steps.push({ kind: cliAction.kind, ok: r.ok, detail: cliAction.detail });
|
|
166
252
|
ok = ok && r.ok;
|
|
167
253
|
}
|
|
168
|
-
// 2. agents.
|
|
254
|
+
// 2. agents. Every add-agent action carries the full spec (set in diffFleet);
|
|
255
|
+
// install it directly rather than re-parsing the human-readable detail string.
|
|
169
256
|
for (const a of row.actions.filter((x) => x.kind === 'add-agent')) {
|
|
170
|
-
const spec = a.
|
|
257
|
+
const spec = a.spec;
|
|
171
258
|
const r = sshAgents(['add', spec, '--yes']);
|
|
172
259
|
steps.push({ kind: 'add-agent', ok: r.code === 0, detail: a.detail });
|
|
173
260
|
ok = ok && r.code === 0;
|