@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.
Files changed (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. 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
+ }
@@ -172,7 +172,7 @@ export declare function removeBlock(blockId: string, root?: string): boolean;
172
172
  * Embedded so it ships with the compiled CLI and can be installed to the
173
173
  * CLI-writable user hooks dir without a separate file in the npm tarball.
174
174
  */
175
- export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior.\n controls = payload.get(\"tool_input\", {}) if hook_event != \"Notification\" else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
175
+ export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n# Codex emits a PermissionRequest event (not Claude's Notification) when it\n# blocks on an approval prompt. Claude never fires PermissionRequest, so the\n# same script handles both: PermissionRequest maps to an approval-class block\n# with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n asks_dir = os.path.join(feed_dir, \"asks\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n # A matcher-less PostToolUse clear (registered for Codex so an approved\n # tool clears its approval card) must NOT wipe an open AskUserQuestion\n # while an unrelated tool runs mid-question -- those are cleared only by\n # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a\n # 'question' block; approval/notification blocks clear once the tool runs.\n if hook_event == \"PostToolUse\":\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\" and payload.get(\"tool_name\") != \"AskUserQuestion\":\n return\n except Exception:\n pass\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n codex_approval = False\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n elif hook_event == \"PermissionRequest\":\n # Codex approval prompt. The payload mirrors PreToolUse (tool_name,\n # tool_input) but carries no questions -- Codex is asking to run a tool,\n # not asking the operator a multiple-choice question. Publish it as a\n # notification-kind approval block naming the tool so the feed and the\n # phone notifier can surface it, and so the Factory extension can bridge\n # it to a VS Code notification.\n tool_name = payload.get(\"tool_name\") or \"a tool\"\n tool_input = payload.get(\"tool_input\", {})\n command = \"\"\n if isinstance(tool_input, dict):\n command = (\n tool_input.get(\"command\")\n or tool_input.get(\"cmd\")\n or tool_input.get(\"path\")\n or \"\"\n )\n if isinstance(command, list):\n command = \" \".join(str(c) for c in command)\n detail = f\": {command}\" if command else \"\"\n normalized_questions = [{\n \"text\": f\"Codex needs approval to run {tool_name}{detail}\",\n \"header\": \"Approval needed\",\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n notification_type = \"permission_prompt\"\n codex_approval = True\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n now_iso = datetime.now(timezone.utc).isoformat()\n stats_path = os.path.join(asks_dir, f\"{safe_session_id}.json\")\n stats = read_json(stats_path) or {}\n recent = stats.get(\"recentAskTimestamps\") if isinstance(stats, dict) else []\n if not isinstance(recent, list):\n recent = []\n recent.append(now_iso)\n # Keep enough history for rolling one-hour needy detection without unbounded\n # per-session files. The TypeScript reader applies the exact time window.\n recent = recent[-200:]\n write_json(stats_path, {\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"firstAskAt\": stats.get(\"firstAskAt\") or now_iso,\n \"lastAskAt\": now_iso,\n \"totalAskCount\": int(stats.get(\"totalAskCount\") or 0) + 1,\n \"recentAskTimestamps\": recent,\n })\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": now_iso,\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # A Codex PermissionRequest is a real approval gate: mark it approval-class\n # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent\n # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default\n # 'medium') and pages the phone. A plain 'deny' is the safe default.\n if codex_approval:\n block[\"blockClass\"] = \"approval\"\n block[\"costOfDelay\"] = \"high\"\n block[\"safeDefault\"] = \"deny\"\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex\n # PermissionRequest carries tool ARGS in tool_input (command/path), not\n # operator controls, so it is excluded here -- its class/cost is stamped\n # above from codex_approval.\n controls = payload.get(\"tool_input\", {}) if hook_event not in (\"Notification\", \"PermissionRequest\") else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
176
176
  /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
177
177
  export declare const FEED_PUBLISH_HOOK_MANIFEST: {
178
178
  name: string;
@@ -188,6 +188,12 @@ export declare const FEED_NOTIFICATION_HOOK_MANIFEST: {
188
188
  script: string;
189
189
  timeout: number;
190
190
  };
191
+ export declare const FEED_PERMISSION_HOOK_MANIFEST: {
192
+ name: string;
193
+ events: string[];
194
+ script: string;
195
+ timeout: number;
196
+ };
191
197
  export declare const FEED_ANSWERED_HOOK_MANIFEST: {
192
198
  name: string;
193
199
  events: string[];
package/dist/lib/feed.js CHANGED
@@ -348,6 +348,10 @@ CLEAR_EVENTS = {
348
348
  "Stop",
349
349
  "SessionEnd",
350
350
  }
351
+ # Codex emits a PermissionRequest event (not Claude's Notification) when it
352
+ # blocks on an approval prompt. Claude never fires PermissionRequest, so the
353
+ # same script handles both: PermissionRequest maps to an approval-class block
354
+ # with a high cost-of-delay so 'agents feed --dispatch' pages it as urgent.
351
355
 
352
356
 
353
357
  def read_json(path):
@@ -398,6 +402,19 @@ def main():
398
402
  hook_event = payload.get("hook_event_name", "PreToolUse")
399
403
 
400
404
  if hook_event in CLEAR_EVENTS:
405
+ # A matcher-less PostToolUse clear (registered for Codex so an approved
406
+ # tool clears its approval card) must NOT wipe an open AskUserQuestion
407
+ # while an unrelated tool runs mid-question -- those are cleared only by
408
+ # the AskUserQuestion-matched PostToolUse. So on PostToolUse, keep a
409
+ # 'question' block; approval/notification blocks clear once the tool runs.
410
+ if hook_event == "PostToolUse":
411
+ try:
412
+ with open(target) as existing_file:
413
+ existing = json.load(existing_file)
414
+ if existing.get("kind") == "question" and payload.get("tool_name") != "AskUserQuestion":
415
+ return
416
+ except Exception:
417
+ pass
401
418
  try:
402
419
  os.unlink(target)
403
420
  except FileNotFoundError:
@@ -442,6 +459,7 @@ def main():
442
459
  return
443
460
 
444
461
  notification_type = None
462
+ codex_approval = False
445
463
  if hook_event == "Notification":
446
464
  notification_type = payload.get("notification_type", "")
447
465
  if notification_type not in WAITING_NOTIFICATION_TYPES:
@@ -466,6 +484,34 @@ def main():
466
484
  "multiSelect": False,
467
485
  }]
468
486
  kind = "notification"
487
+ elif hook_event == "PermissionRequest":
488
+ # Codex approval prompt. The payload mirrors PreToolUse (tool_name,
489
+ # tool_input) but carries no questions -- Codex is asking to run a tool,
490
+ # not asking the operator a multiple-choice question. Publish it as a
491
+ # notification-kind approval block naming the tool so the feed and the
492
+ # phone notifier can surface it, and so the Factory extension can bridge
493
+ # it to a VS Code notification.
494
+ tool_name = payload.get("tool_name") or "a tool"
495
+ tool_input = payload.get("tool_input", {})
496
+ command = ""
497
+ if isinstance(tool_input, dict):
498
+ command = (
499
+ tool_input.get("command")
500
+ or tool_input.get("cmd")
501
+ or tool_input.get("path")
502
+ or ""
503
+ )
504
+ if isinstance(command, list):
505
+ command = " ".join(str(c) for c in command)
506
+ detail = f": {command}" if command else ""
507
+ normalized_questions = [{
508
+ "text": f"Codex needs approval to run {tool_name}{detail}",
509
+ "header": "Approval needed",
510
+ "multiSelect": False,
511
+ }]
512
+ kind = "notification"
513
+ notification_type = "permission_prompt"
514
+ codex_approval = True
469
515
  else:
470
516
  tool_input = payload.get("tool_input", {})
471
517
  questions = tool_input.get("questions", [])
@@ -535,9 +581,21 @@ def main():
535
581
  if notification_type:
536
582
  block["notificationType"] = notification_type
537
583
 
584
+ # A Codex PermissionRequest is a real approval gate: mark it approval-class
585
+ # with a high cost-of-delay so 'agents feed --dispatch' classifies it urgent
586
+ # (isPhoneUrgent gates on costOfDelay >= phoneNotifyThreshold, default
587
+ # 'medium') and pages the phone. A plain 'deny' is the safe default.
588
+ if codex_approval:
589
+ block["blockClass"] = "approval"
590
+ block["costOfDelay"] = "high"
591
+ block["safeDefault"] = "deny"
592
+
538
593
  # Optional multi-operator control metadata passed by the agent in the
539
- # AskUserQuestion tool_input. Defaults keep the existing behavior.
540
- controls = payload.get("tool_input", {}) if hook_event != "Notification" else {}
594
+ # AskUserQuestion tool_input. Defaults keep the existing behavior. A Codex
595
+ # PermissionRequest carries tool ARGS in tool_input (command/path), not
596
+ # operator controls, so it is excluded here -- its class/cost is stamped
597
+ # above from codex_approval.
598
+ controls = payload.get("tool_input", {}) if hook_event not in ("Notification", "PermissionRequest") else {}
541
599
  block_class = controls.get("blockClass") if isinstance(controls, dict) else None
542
600
  if block_class in ("approval", "decision"):
543
601
  block["blockClass"] = block_class
@@ -604,6 +662,15 @@ export const FEED_NOTIFICATION_HOOK_MANIFEST = {
604
662
  script: '10-feed-publish.py',
605
663
  timeout: 5,
606
664
  };
665
+ // Codex fires PermissionRequest (not Claude's Notification) when it blocks on an
666
+ // approval prompt. The same script handles it, publishing a high-cost approval
667
+ // block so the feed dispatch pages the phone. PermissionRequest has no matcher.
668
+ export const FEED_PERMISSION_HOOK_MANIFEST = {
669
+ name: 'feed-publish-permission',
670
+ events: ['PermissionRequest'],
671
+ script: '10-feed-publish.py',
672
+ timeout: 5,
673
+ };
607
674
  export const FEED_ANSWERED_HOOK_MANIFEST = {
608
675
  name: 'feed-clear-answered',
609
676
  events: ['PostToolUse'],
@@ -644,28 +711,51 @@ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
644
711
  }
645
712
  const desiredHooks = {
646
713
  'feed-publish': {
647
- agents: ['claude'],
714
+ agents: ['claude', 'codex'],
648
715
  events: ['PreToolUse'],
649
716
  matcher: 'AskUserQuestion',
650
717
  script: '10-feed-publish.py',
651
718
  timeout: 5,
652
719
  },
653
720
  'feed-publish-notification': {
654
- agents: ['claude'],
721
+ agents: ['claude', 'codex'],
655
722
  events: ['Notification'],
656
723
  matcher: 'permission_prompt|idle_prompt|elicitation_dialog',
657
724
  script: '10-feed-publish.py',
658
725
  timeout: 5,
659
726
  },
727
+ // Codex-specific approval gate: Codex emits PermissionRequest (Claude does
728
+ // not), so this hook is where a blocked Codex agent surfaces to the feed.
729
+ 'feed-publish-permission': {
730
+ agents: ['claude', 'codex'],
731
+ events: ['PermissionRequest'],
732
+ script: '10-feed-publish.py',
733
+ timeout: 5,
734
+ },
660
735
  'feed-clear-answered': {
661
- agents: ['claude'],
736
+ agents: ['claude', 'codex'],
662
737
  events: ['PostToolUse'],
663
738
  matcher: 'AskUserQuestion',
664
739
  script: '10-feed-publish.py',
665
740
  timeout: 5,
666
741
  },
742
+ // Matcher-less PostToolUse clear: after Codex runs an approved tool, the
743
+ // approval card is stale, so clear it. Codex-only on purpose -- Claude
744
+ // never fires PermissionRequest, so it has no approval card to clear here,
745
+ // and a matcher-less PostToolUse for Claude would (1) re-run the script on
746
+ // every tool completion and (2) wipe Claude's notification-kind blocks
747
+ // (permission_prompt/idle_prompt/elicitation_dialog) the moment any later
748
+ // tool runs, instead of letting them persist to Stop/SessionEnd like they
749
+ // did before RUSH-2039. Registering it for codex alone keeps Claude's
750
+ // card lifetime exactly as it was.
751
+ 'feed-clear-permission': {
752
+ agents: ['codex'],
753
+ events: ['PostToolUse'],
754
+ script: '10-feed-publish.py',
755
+ timeout: 5,
756
+ },
667
757
  'feed-clear-lifecycle': {
668
- agents: ['claude'],
758
+ agents: ['claude', 'codex'],
669
759
  events: ['Stop', 'UserPromptSubmit', 'SessionEnd'],
670
760
  script: '10-feed-publish.py',
671
761
  timeout: 5,
@@ -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
- /** Probe one device: reachability + agents-cli version + installed agent ids. */
41
- export declare function probeDevice(device: DeviceProfile): DeviceProbe;
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;
@@ -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
- if (!probe.installedAgents.includes(id)) {
60
- rowActions.push({ device: d.device, kind: 'add-agent', agent: id, detail: `install ${spec}` });
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 spec of d.agents) {
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
- export function probeDevice(device) {
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.detail.replace(/^install\s+/, '');
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;