@phnx-labs/agents-cli 1.20.51 → 1.20.53

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 (142) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +215 -7
  5. package/dist/commands/cloud.d.ts +3 -0
  6. package/dist/commands/cloud.js +8 -1
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +82 -3
  10. package/dist/commands/factory.js +8 -0
  11. package/dist/commands/feed.d.ts +38 -0
  12. package/dist/commands/feed.js +274 -0
  13. package/dist/commands/logs.d.ts +5 -1
  14. package/dist/commands/logs.js +248 -3
  15. package/dist/commands/mcp.js +7 -0
  16. package/dist/commands/memory.d.ts +9 -0
  17. package/dist/commands/memory.js +164 -0
  18. package/dist/commands/message.d.ts +11 -6
  19. package/dist/commands/message.js +140 -5
  20. package/dist/commands/routines.js +12 -0
  21. package/dist/commands/secrets-migrate.d.ts +2 -1
  22. package/dist/commands/secrets-migrate.js +88 -13
  23. package/dist/commands/secrets.d.ts +22 -0
  24. package/dist/commands/secrets.js +176 -42
  25. package/dist/commands/sessions.js +10 -1
  26. package/dist/commands/teams.js +4 -0
  27. package/dist/commands/worktree.js +4 -2
  28. package/dist/index.js +21 -22
  29. package/dist/lib/agents.js +249 -17
  30. package/dist/lib/answer-router.d.ts +75 -0
  31. package/dist/lib/answer-router.js +149 -0
  32. package/dist/lib/ask-classifier.d.ts +71 -0
  33. package/dist/lib/ask-classifier.js +197 -0
  34. package/dist/lib/browser/login-detection.d.ts +94 -0
  35. package/dist/lib/browser/login-detection.js +274 -0
  36. package/dist/lib/browser/profiles.d.ts +17 -8
  37. package/dist/lib/browser/profiles.js +27 -8
  38. package/dist/lib/browser/secret-ref.d.ts +10 -0
  39. package/dist/lib/browser/secret-ref.js +14 -0
  40. package/dist/lib/browser/service.js +14 -12
  41. package/dist/lib/cloud/antigravity.d.ts +0 -2
  42. package/dist/lib/cloud/antigravity.js +2 -17
  43. package/dist/lib/cloud/codex.js +3 -18
  44. package/dist/lib/cloud/rush.d.ts +15 -0
  45. package/dist/lib/cloud/rush.js +10 -16
  46. package/dist/lib/cloud/stream.js +2 -0
  47. package/dist/lib/cloud/types.d.ts +21 -0
  48. package/dist/lib/cloud/types.js +81 -0
  49. package/dist/lib/crabbox/cli.d.ts +1 -1
  50. package/dist/lib/crabbox/cli.js +12 -2
  51. package/dist/lib/crabbox/lease.d.ts +19 -0
  52. package/dist/lib/crabbox/lease.js +22 -11
  53. package/dist/lib/crabbox/progress.d.ts +62 -0
  54. package/dist/lib/crabbox/progress.js +129 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  56. package/dist/lib/crabbox/runtimes.js +98 -5
  57. package/dist/lib/daemon.d.ts +12 -9
  58. package/dist/lib/daemon.js +32 -17
  59. package/dist/lib/events.d.ts +31 -5
  60. package/dist/lib/events.js +291 -101
  61. package/dist/lib/exec.js +20 -1
  62. package/dist/lib/feed-outcome.d.ts +101 -0
  63. package/dist/lib/feed-outcome.js +244 -0
  64. package/dist/lib/feed-policy.d.ts +30 -0
  65. package/dist/lib/feed-policy.js +133 -0
  66. package/dist/lib/feed.d.ts +180 -0
  67. package/dist/lib/feed.js +627 -0
  68. package/dist/lib/git.d.ts +17 -1
  69. package/dist/lib/git.js +20 -1
  70. package/dist/lib/hooks.js +529 -14
  71. package/dist/lib/hosts/passthrough.d.ts +3 -3
  72. package/dist/lib/hosts/passthrough.js +3 -3
  73. package/dist/lib/mailbox-gc.d.ts +22 -0
  74. package/dist/lib/mailbox-gc.js +161 -0
  75. package/dist/lib/mailbox.d.ts +26 -2
  76. package/dist/lib/mailbox.js +80 -5
  77. package/dist/lib/mcp.js +82 -0
  78. package/dist/lib/memory.d.ts +55 -0
  79. package/dist/lib/memory.js +274 -0
  80. package/dist/lib/notify.d.ts +16 -0
  81. package/dist/lib/notify.js +61 -0
  82. package/dist/lib/operator.d.ts +26 -0
  83. package/dist/lib/operator.js +107 -0
  84. package/dist/lib/plugins.d.ts +35 -0
  85. package/dist/lib/plugins.js +217 -0
  86. package/dist/lib/remote-agents-json.d.ts +14 -0
  87. package/dist/lib/remote-agents-json.js +94 -0
  88. package/dist/lib/resources/mcp.js +44 -0
  89. package/dist/lib/resources/memory.d.ts +15 -0
  90. package/dist/lib/resources/memory.js +46 -0
  91. package/dist/lib/resources/types.d.ts +2 -2
  92. package/dist/lib/rotate.js +2 -0
  93. package/dist/lib/runner.d.ts +43 -0
  94. package/dist/lib/runner.js +323 -74
  95. package/dist/lib/sandbox.js +6 -0
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.d.ts +21 -0
  99. package/dist/lib/secrets/agent.js +63 -1
  100. package/dist/lib/secrets/bundles.d.ts +33 -1
  101. package/dist/lib/secrets/bundles.js +76 -21
  102. package/dist/lib/secrets/icloud-import.d.ts +79 -0
  103. package/dist/lib/secrets/icloud-import.js +203 -0
  104. package/dist/lib/secrets/index.d.ts +142 -2
  105. package/dist/lib/secrets/index.js +695 -30
  106. package/dist/lib/secrets/remote.js +1 -1
  107. package/dist/lib/secrets/sync.js +1 -1
  108. package/dist/lib/session/active.d.ts +18 -0
  109. package/dist/lib/session/active.js +47 -17
  110. package/dist/lib/session/db.d.ts +9 -1
  111. package/dist/lib/session/db.js +18 -3
  112. package/dist/lib/session/discover.d.ts +13 -0
  113. package/dist/lib/session/discover.js +32 -2
  114. package/dist/lib/session/parse.d.ts +8 -0
  115. package/dist/lib/session/parse.js +42 -21
  116. package/dist/lib/session/remote-active.js +8 -89
  117. package/dist/lib/session/state.d.ts +11 -0
  118. package/dist/lib/session/state.js +50 -1
  119. package/dist/lib/session/tail.d.ts +23 -4
  120. package/dist/lib/session/tail.js +34 -16
  121. package/dist/lib/session/throughput.d.ts +30 -0
  122. package/dist/lib/session/throughput.js +86 -0
  123. package/dist/lib/shim-heal.d.ts +12 -3
  124. package/dist/lib/shim-heal.js +12 -6
  125. package/dist/lib/staleness/detectors/subagents.js +57 -3
  126. package/dist/lib/staleness/writers/hooks.js +7 -3
  127. package/dist/lib/staleness/writers/subagents.js +37 -6
  128. package/dist/lib/startup/command-registry.d.ts +2 -0
  129. package/dist/lib/startup/command-registry.js +4 -0
  130. package/dist/lib/state.d.ts +2 -0
  131. package/dist/lib/state.js +25 -8
  132. package/dist/lib/subagents.d.ts +52 -0
  133. package/dist/lib/subagents.js +315 -12
  134. package/dist/lib/teams/agents.js +6 -3
  135. package/dist/lib/teams/worktree.d.ts +8 -0
  136. package/dist/lib/teams/worktree.js +8 -0
  137. package/dist/lib/types.d.ts +20 -2
  138. package/dist/lib/versions.js +38 -48
  139. package/dist/lib/whats-new.d.ts +5 -3
  140. package/dist/lib/whats-new.js +25 -5
  141. package/package.json +4 -3
  142. package/scripts/postinstall.js +61 -1
@@ -0,0 +1,627 @@
1
+ /**
2
+ * Feed store -- structured block records published by agents waiting on user
3
+ * input (AskUserQuestion). The outbound counterpart to the inbound mailbox:
4
+ * the mailbox delivers messages TO agents; the feed surfaces decisions agents
5
+ * need FROM the user.
6
+ *
7
+ * Layout: <feedDir>/<blockId>.json
8
+ * Each file is one open block -- a question the agent asked. One block per
9
+ * session: a new AskUserQuestion in the same session replaces the previous
10
+ * block (an agent can only ask one question at a time). Removed when the
11
+ * session advances past the block.
12
+ *
13
+ * A block carries enough identity (sessionId, mailboxId, host, runtime) for
14
+ * `agents feed` to aggregate across hosts and for `agents message` to route
15
+ * a reply back to the right agent.
16
+ *
17
+ * Answer lifecycle:
18
+ * - A block may be answered from any surface (feed, terminal, tmux, cloud).
19
+ * - The first answer wins: `recordAnswer` atomically checks an answered
20
+ * marker so exactly one surface can claim the block.
21
+ * - Answered blocks stay visible until the agent consumes the message and
22
+ * continues, so the UI can show delivered/consumed/continued receipts.
23
+ */
24
+ import * as fs from 'fs';
25
+ import * as path from 'path';
26
+ import * as yaml from 'yaml';
27
+ import { getFeedDir, getUserAgentsDir } from './state.js';
28
+ import { isHighConsequenceAllowed } from './operator.js';
29
+ /**
30
+ * Stable block id for a session. One block per session -- a new question
31
+ * replaces the previous one (the agent can only ask one question at a time).
32
+ */
33
+ export function blockIdForSession(sessionId) {
34
+ const safeSessionId = sessionId.replace(/[^A-Za-z0-9._-]/g, '-');
35
+ return `block-${safeSessionId}`;
36
+ }
37
+ function blockPath(root, blockId) {
38
+ if (!/^[A-Za-z0-9._-]+$/.test(blockId)) {
39
+ throw new Error(`Invalid feed block id: ${blockId}`);
40
+ }
41
+ return path.join(root, `${blockId}.json`);
42
+ }
43
+ function answeredDir(root) { return path.join(root, 'answered'); }
44
+ function receiptDir(root) { return path.join(root, 'receipts'); }
45
+ function ensureDir(dir) {
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ }
48
+ function safeReadJson(file) {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ function atomicWriteJson(file, value) {
57
+ const tmp = `${file}.${process.pid}.tmp`;
58
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf-8');
59
+ fs.renameSync(tmp, file);
60
+ }
61
+ /** Read one block record. Returns undefined when missing or corrupt. */
62
+ export function readBlock(blockId, root) {
63
+ const parsed = safeReadJson(blockPath(root ?? getFeedDir(), blockId));
64
+ if (!parsed || !parsed.blockId || !parsed.sessionId || !parsed.questions?.length)
65
+ return undefined;
66
+ return parsed;
67
+ }
68
+ /**
69
+ * Atomically claim the first answer for a block. Returns `{ ok: true }` when
70
+ * this call is the first to answer; returns `{ ok: false, existing }` when a
71
+ * different surface already answered the block. The marker file is created
72
+ * with `O_EXCL` so two concurrent claimers cannot both succeed.
73
+ *
74
+ * High-consequence blocks require a verified operator identity. Unverified
75
+ * answers (no operatorId or not in the registry/allowed list) are refused.
76
+ */
77
+ export function recordAnswer(blockId, answer, root) {
78
+ const dir = root ?? getFeedDir();
79
+ const block = readBlock(blockId, dir);
80
+ const operatorId = answer.operatorId;
81
+ if (block?.consequence && block.consequence !== 'normal') {
82
+ // Operators live in ~/.agents/operators.yaml — never the feed store root.
83
+ if (!operatorId || answer.verified !== true || !isHighConsequenceAllowed(block.consequence, operatorId)) {
84
+ return {
85
+ ok: false,
86
+ unauthorized: true,
87
+ reason: `High-consequence block '${block.consequence}' requires a verified, authorized operator.`,
88
+ };
89
+ }
90
+ }
91
+ ensureDir(answeredDir(dir));
92
+ const marker = path.join(answeredDir(dir), `${blockId}.json`);
93
+ const record = {
94
+ answeredAt: new Date().toISOString(),
95
+ answeredFrom: answer.answeredFrom,
96
+ answeredBy: answer.answeredBy,
97
+ operatorId: answer.operatorId,
98
+ verified: answer.verified,
99
+ };
100
+ // Try to create the answered marker atomically.
101
+ try {
102
+ const fd = fs.openSync(marker, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
103
+ try {
104
+ const buf = Buffer.from(JSON.stringify(record, null, 2), 'utf-8');
105
+ fs.writeSync(fd, buf, 0, buf.length);
106
+ }
107
+ finally {
108
+ fs.closeSync(fd);
109
+ }
110
+ }
111
+ catch (err) {
112
+ const code = err.code;
113
+ if (code === 'EEXIST') {
114
+ const existing = safeReadJson(marker);
115
+ return { ok: false, existing: existing ?? { answeredAt: '', answeredFrom: 'unknown' } };
116
+ }
117
+ throw err;
118
+ }
119
+ // Marker created successfully -- mirror the answer into the block file.
120
+ if (block) {
121
+ block.answer = record;
122
+ publishBlock(block, dir);
123
+ }
124
+ return { ok: true };
125
+ }
126
+ /** Read the answer record for a block, if one exists. */
127
+ export function getAnswerRecord(blockId, root) {
128
+ return safeReadJson(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
129
+ }
130
+ /** True when the block has already been answered. */
131
+ export function isBlockAnswered(blockId, root) {
132
+ return fs.existsSync(path.join(answeredDir(root ?? getFeedDir()), `${blockId}.json`));
133
+ }
134
+ /** Receipt lifecycle rank — higher means further along; never regress. */
135
+ const RECEIPT_STATUS_RANK = {
136
+ queued: 0,
137
+ consumed: 1,
138
+ continued: 2,
139
+ };
140
+ /**
141
+ * Record a delivery-receipt transition for a message tied to a block.
142
+ * Updates the receipts list in the block file. Status is monotonic
143
+ * (queued → consumed → continued): a late `queued` write cannot overwrite
144
+ * an already-recorded `consumed`/`continued` (race with mailbox drain).
145
+ */
146
+ export function recordMessageReceipt(blockId, receipt, root) {
147
+ const dir = root ?? getFeedDir();
148
+ const block = readBlock(blockId, dir);
149
+ if (!block)
150
+ return;
151
+ const receipts = block.receipts ?? [];
152
+ const idx = receipts.findIndex((r) => r.msgId === receipt.msgId);
153
+ if (idx >= 0) {
154
+ const prev = receipts[idx];
155
+ if (RECEIPT_STATUS_RANK[receipt.status] < RECEIPT_STATUS_RANK[prev.status]) {
156
+ return; // do not regress
157
+ }
158
+ receipts[idx] = receipt;
159
+ }
160
+ else {
161
+ receipts.push(receipt);
162
+ }
163
+ block.receipts = receipts;
164
+ publishBlock(block, dir);
165
+ }
166
+ /** Read the receipt list for a block. */
167
+ export function getBlockReceipts(blockId, root) {
168
+ return readBlock(blockId, root)?.receipts ?? [];
169
+ }
170
+ /** Mark a block as "continued" -- the agent consumed the answer and moved on. */
171
+ export function recordContinued(blockId, root) {
172
+ const dir = root ?? getFeedDir();
173
+ const block = readBlock(blockId, dir);
174
+ if (!block)
175
+ return;
176
+ block.continuedAt = new Date().toISOString();
177
+ publishBlock(block, dir);
178
+ }
179
+ /** Mark a decision-class block as hard-parked (no safe default existed). */
180
+ export function recordParked(blockId, root) {
181
+ const dir = root ?? getFeedDir();
182
+ const block = readBlock(blockId, dir);
183
+ if (!block)
184
+ return;
185
+ block.parkedAt = new Date().toISOString();
186
+ publishBlock(block, dir);
187
+ }
188
+ /** Mark that the approval safe-default was applied by policy. */
189
+ export function recordDefaulted(blockId, root) {
190
+ const dir = root ?? getFeedDir();
191
+ const block = readBlock(blockId, dir);
192
+ if (!block)
193
+ return;
194
+ block.defaultedAt = new Date().toISOString();
195
+ publishBlock(block, dir);
196
+ }
197
+ /** Mark that an urgent block was paged to the phone. */
198
+ export function recordNotified(blockId, root) {
199
+ const dir = root ?? getFeedDir();
200
+ const block = readBlock(blockId, dir);
201
+ if (!block)
202
+ return;
203
+ block.notifiedAt = new Date().toISOString();
204
+ publishBlock(block, dir);
205
+ }
206
+ /** Convenience: record that a terminal answer closed the block. */
207
+ export function recordTerminalAnswer(blockId, root) {
208
+ recordAnswer(blockId, { answeredFrom: 'terminal' }, root);
209
+ }
210
+ /** Remove answered marker and receipts for a block (used by block removal/GC). */
211
+ export function clearBlockLifecycle(blockId, root) {
212
+ const dir = root ?? getFeedDir();
213
+ for (const sub of [answeredDir(dir), receiptDir(dir)]) {
214
+ try {
215
+ fs.unlinkSync(path.join(sub, `${blockId}.json`));
216
+ }
217
+ catch {
218
+ // ignore missing
219
+ }
220
+ }
221
+ }
222
+ /** Atomic write a block record to the feed store. Clears stale lifecycle state. */
223
+ export function publishBlock(block, root) {
224
+ const dir = root ?? getFeedDir();
225
+ fs.mkdirSync(dir, { recursive: true });
226
+ const target = blockPath(dir, block.blockId);
227
+ const tmp = `${target}.${process.pid}.tmp`;
228
+ fs.writeFileSync(tmp, JSON.stringify(block, null, 2), 'utf-8');
229
+ fs.renameSync(tmp, target);
230
+ }
231
+ /** Read all block records. Returns them sorted by stable block filename. */
232
+ export function listBlocks(root) {
233
+ const dir = root ?? getFeedDir();
234
+ let names;
235
+ try {
236
+ names = fs.readdirSync(dir);
237
+ }
238
+ catch {
239
+ return [];
240
+ }
241
+ const blocks = [];
242
+ for (const name of names.filter(n => n.endsWith('.json')).sort()) {
243
+ try {
244
+ const raw = fs.readFileSync(path.join(dir, name), 'utf-8');
245
+ const parsed = JSON.parse(raw);
246
+ if (parsed.blockId && parsed.sessionId && parsed.questions?.length) {
247
+ blocks.push(parsed);
248
+ }
249
+ }
250
+ catch {
251
+ // skip corrupt / partial files
252
+ }
253
+ }
254
+ return blocks;
255
+ }
256
+ /** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
257
+ export function removeBlock(blockId, root) {
258
+ const dir = root ?? getFeedDir();
259
+ clearBlockLifecycle(blockId, dir);
260
+ try {
261
+ fs.unlinkSync(blockPath(dir, blockId));
262
+ return true;
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ }
268
+ // ---------------------------------------------------------------------------
269
+ // Hook installation
270
+ // ---------------------------------------------------------------------------
271
+ /**
272
+ * The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
273
+ * Embedded so it ships with the compiled CLI and can be installed to the
274
+ * CLI-writable user hooks dir without a separate file in the npm tarball.
275
+ */
276
+ export const FEED_PUBLISH_HOOK_SCRIPT = `#!/usr/bin/env python3
277
+ """Publish and clear open-block records for \`agents feed\`.
278
+
279
+ The manifest invokes this script for top-level AskUserQuestion calls, waiting
280
+ notifications, question answers, and session lifecycle events. One atomic file
281
+ per session means a new block replaces the previous block. Answer/resume/stop
282
+ events remove it so \`agents feed\` only lists decisions that are still open.
283
+
284
+ Sub-agent gate: when the PreToolUse payload carries \`agent_type\`, this is a
285
+ Task/Agent subagent -- skip. Only the top-level agent publishes. Verified on
286
+ Claude Code 2.1.170 (2026-07).
287
+
288
+ Fail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.
289
+ """
290
+ import os
291
+ import sys
292
+ import json
293
+ import re
294
+ import socket
295
+ import tempfile
296
+ from datetime import datetime, timezone
297
+
298
+ WAITING_NOTIFICATION_TYPES = {
299
+ "permission_prompt",
300
+ "idle_prompt",
301
+ "elicitation_dialog",
302
+ }
303
+ CLEAR_EVENTS = {
304
+ "PostToolUse",
305
+ "Stop",
306
+ "SessionEnd",
307
+ }
308
+
309
+
310
+ def read_json(path):
311
+ try:
312
+ with open(path) as f:
313
+ return json.load(f)
314
+ except Exception:
315
+ return None
316
+
317
+
318
+ def write_json(path, value):
319
+ dir_name = os.path.dirname(path)
320
+ os.makedirs(dir_name, exist_ok=True)
321
+ fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
322
+ try:
323
+ with os.fdopen(fd, "w") as f:
324
+ json.dump(value, f, indent=2)
325
+ os.replace(tmp, path)
326
+ except Exception:
327
+ try:
328
+ os.unlink(tmp)
329
+ except Exception:
330
+ pass
331
+
332
+
333
+ def main():
334
+ raw = sys.stdin.read()
335
+ try:
336
+ payload = json.loads(raw) if raw.strip() else {}
337
+ except Exception:
338
+ return
339
+
340
+ # Sub-agent gate.
341
+ if payload.get("agent_type"):
342
+ return
343
+
344
+ session_id = payload.get("session_id", "")
345
+ if not session_id:
346
+ return
347
+
348
+ safe_session_id = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
349
+ block_id = f"block-{safe_session_id}"
350
+ home = os.environ.get("HOME") or os.path.expanduser("~")
351
+ feed_dir = os.path.join(home, ".agents", ".history", "feed")
352
+ answered_dir = os.path.join(feed_dir, "answered")
353
+ target = os.path.join(feed_dir, f"{block_id}.json")
354
+ hook_event = payload.get("hook_event_name", "PreToolUse")
355
+
356
+ if hook_event in CLEAR_EVENTS:
357
+ try:
358
+ os.unlink(target)
359
+ except FileNotFoundError:
360
+ pass
361
+ except Exception:
362
+ pass
363
+ # Also clear the answered marker so a future question for this session
364
+ # is not permanently locked.
365
+ try:
366
+ os.unlink(os.path.join(answered_dir, f"{block_id}.json"))
367
+ except FileNotFoundError:
368
+ pass
369
+ except Exception:
370
+ pass
371
+ return
372
+
373
+ # Terminal answers (human typed in the TUI) record an answered marker and
374
+ # remove the block file so the feed stops showing it within one poll cycle.
375
+ # The marker stays behind so a concurrent surface cannot double-answer.
376
+ if hook_event == "UserPromptSubmit":
377
+ os.makedirs(answered_dir, exist_ok=True)
378
+ marker = os.path.join(answered_dir, f"{block_id}.json")
379
+ try:
380
+ fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
381
+ record = {
382
+ "answeredAt": datetime.now(timezone.utc).isoformat(),
383
+ "answeredFrom": "terminal",
384
+ }
385
+ with os.fdopen(fd, "w") as f:
386
+ json.dump(record, f, indent=2)
387
+ except FileExistsError:
388
+ pass
389
+ except Exception:
390
+ pass
391
+ # Remove the visible block so the feed drops the answered question.
392
+ try:
393
+ os.unlink(target)
394
+ except FileNotFoundError:
395
+ pass
396
+ except Exception:
397
+ pass
398
+ return
399
+
400
+ notification_type = None
401
+ if hook_event == "Notification":
402
+ notification_type = payload.get("notification_type", "")
403
+ if notification_type not in WAITING_NOTIFICATION_TYPES:
404
+ return
405
+ # Claude emits a generic permission notification after presenting an
406
+ # AskUserQuestion. Keep the structured questions and options already
407
+ # published for this session instead of replacing them with that less
408
+ # useful notification text.
409
+ try:
410
+ with open(target) as existing_file:
411
+ existing = json.load(existing_file)
412
+ if existing.get("kind") == "question":
413
+ return
414
+ except Exception:
415
+ pass
416
+ message = payload.get("message", "")
417
+ if not message:
418
+ return
419
+ normalized_questions = [{
420
+ "text": message,
421
+ "header": payload.get("title") or notification_type.replace("_", " ").title(),
422
+ "multiSelect": False,
423
+ }]
424
+ kind = "notification"
425
+ else:
426
+ tool_input = payload.get("tool_input", {})
427
+ questions = tool_input.get("questions", [])
428
+ if not questions:
429
+ return
430
+ normalized_questions = []
431
+ for q in questions:
432
+ if not isinstance(q, dict):
433
+ continue
434
+ question = {
435
+ "text": q.get("question", q.get("header", "")),
436
+ "header": q.get("header"),
437
+ "multiSelect": q.get("multiSelect", False),
438
+ }
439
+ raw_opts = q.get("options", [])
440
+ if raw_opts:
441
+ question["options"] = [
442
+ {"label": o.get("label", ""), "description": o.get("description")}
443
+ for o in raw_opts
444
+ if isinstance(o, dict)
445
+ ]
446
+ normalized_questions.append(question)
447
+ if not normalized_questions:
448
+ return
449
+ kind = "question"
450
+
451
+ # Identity from env (set by agents-cli at spawn).
452
+ mailbox_id = os.path.basename(
453
+ os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
454
+ ) or session_id
455
+
456
+ hostname = os.environ.get("AGENTS_SYNC_MACHINE_ID") or socket.gethostname()
457
+ host = hostname.split(".")[0].strip().lower()
458
+ host = re.sub(r"[^a-z0-9_-]", "-", host) or "unknown"
459
+
460
+ runtime = os.environ.get("AGENTS_RUNTIME", "headless")
461
+
462
+ block = {
463
+ "blockId": block_id,
464
+ "sessionId": session_id,
465
+ "mailboxId": mailbox_id,
466
+ "host": host,
467
+ "runtime": runtime,
468
+ "ts": datetime.now(timezone.utc).isoformat(),
469
+ "questions": normalized_questions,
470
+ "kind": kind,
471
+ }
472
+ if notification_type:
473
+ block["notificationType"] = notification_type
474
+
475
+ # Optional multi-operator control metadata passed by the agent in the
476
+ # AskUserQuestion tool_input. Defaults keep the existing behavior.
477
+ controls = payload.get("tool_input", {}) if hook_event != "Notification" else {}
478
+ block_class = controls.get("blockClass") if isinstance(controls, dict) else None
479
+ if block_class in ("approval", "decision"):
480
+ block["blockClass"] = block_class
481
+ consequence = controls.get("consequence") if isinstance(controls, dict) else None
482
+ if consequence:
483
+ block["consequence"] = consequence
484
+ allowed = controls.get("allowedOperators") if isinstance(controls, dict) else None
485
+ if isinstance(allowed, list):
486
+ block["allowedOperators"] = [str(a) for a in allowed]
487
+ timeout = controls.get("timeoutMinutes") if isinstance(controls, dict) else None
488
+ if isinstance(timeout, (int, float)) and timeout > 0:
489
+ block["timeoutMinutes"] = int(timeout)
490
+ safe_default = controls.get("safeDefault") if isinstance(controls, dict) else None
491
+ if isinstance(safe_default, str):
492
+ block["safeDefault"] = safe_default
493
+ cost = controls.get("costOfDelay") if isinstance(controls, dict) else None
494
+ if cost in ("low", "medium", "high"):
495
+ block["costOfDelay"] = cost
496
+
497
+ # Publishing a new question clears any stale answered marker from the
498
+ # previous question in this session.
499
+ try:
500
+ os.unlink(os.path.join(answered_dir, f"{block_id}.json"))
501
+ except FileNotFoundError:
502
+ pass
503
+ except Exception:
504
+ pass
505
+
506
+ # Python's expanduser() ignores HOME on Windows, while agents-cli honors a
507
+ # HOME override on every platform. Use the same anchor so hooks and the CLI
508
+ # always read/write one feed store (including temp-home and sandbox runs).
509
+ os.makedirs(feed_dir, exist_ok=True)
510
+
511
+ fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=".tmp")
512
+ try:
513
+ with os.fdopen(fd, "w") as f:
514
+ json.dump(block, f, indent=2)
515
+ os.replace(tmp, target)
516
+ except Exception:
517
+ try:
518
+ os.unlink(tmp)
519
+ except Exception:
520
+ pass
521
+
522
+
523
+ if __name__ == "__main__":
524
+ try:
525
+ main()
526
+ except Exception:
527
+ pass # fail open
528
+ `;
529
+ /** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
530
+ export const FEED_PUBLISH_HOOK_MANIFEST = {
531
+ name: 'feed-publish',
532
+ events: ['PreToolUse'],
533
+ matcher: 'AskUserQuestion',
534
+ script: '10-feed-publish.py',
535
+ timeout: 5,
536
+ };
537
+ export const FEED_NOTIFICATION_HOOK_MANIFEST = {
538
+ name: 'feed-publish-notification',
539
+ events: ['Notification'],
540
+ matcher: 'permission_prompt|idle_prompt|elicitation_dialog',
541
+ script: '10-feed-publish.py',
542
+ timeout: 5,
543
+ };
544
+ export const FEED_ANSWERED_HOOK_MANIFEST = {
545
+ name: 'feed-clear-answered',
546
+ events: ['PostToolUse'],
547
+ matcher: 'AskUserQuestion',
548
+ script: '10-feed-publish.py',
549
+ timeout: 5,
550
+ };
551
+ export const FEED_LIFECYCLE_HOOK_MANIFEST = {
552
+ name: 'feed-clear-lifecycle',
553
+ events: ['Stop', 'UserPromptSubmit', 'SessionEnd'],
554
+ script: '10-feed-publish.py',
555
+ timeout: 5,
556
+ };
557
+ /**
558
+ * Install the feed-publish hook script into the user hooks dir and add its
559
+ * manifest entry to the user agents.yaml. The system repo is an auto-pulled,
560
+ * read-only mirror, so runtime-managed hooks must never write there.
561
+ * Idempotent -- skips if the script is already present and up to date.
562
+ */
563
+ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
564
+ try {
565
+ const hooksDir = path.join(userAgentsDir, 'hooks');
566
+ const scriptPath = path.join(hooksDir, '10-feed-publish.py');
567
+ fs.mkdirSync(hooksDir, { recursive: true });
568
+ let installed = false;
569
+ if (!fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, 'utf-8') !== FEED_PUBLISH_HOOK_SCRIPT) {
570
+ const tmpScript = `${scriptPath}.${process.pid}.tmp`;
571
+ fs.writeFileSync(tmpScript, FEED_PUBLISH_HOOK_SCRIPT, { mode: 0o755 });
572
+ fs.renameSync(tmpScript, scriptPath);
573
+ installed = true;
574
+ }
575
+ const agentsYamlPath = path.join(userAgentsDir, 'agents.yaml');
576
+ const yamlDoc = fs.existsSync(agentsYamlPath)
577
+ ? yaml.parseDocument(fs.readFileSync(agentsYamlPath, 'utf-8'))
578
+ : new yaml.Document({});
579
+ if (yamlDoc.errors.length > 0) {
580
+ throw new Error(`Cannot install feed hook: ${agentsYamlPath} is invalid YAML`);
581
+ }
582
+ const desiredHooks = {
583
+ 'feed-publish': {
584
+ agents: ['claude'],
585
+ events: ['PreToolUse'],
586
+ matcher: 'AskUserQuestion',
587
+ script: '10-feed-publish.py',
588
+ timeout: 5,
589
+ },
590
+ 'feed-publish-notification': {
591
+ agents: ['claude'],
592
+ events: ['Notification'],
593
+ matcher: 'permission_prompt|idle_prompt|elicitation_dialog',
594
+ script: '10-feed-publish.py',
595
+ timeout: 5,
596
+ },
597
+ 'feed-clear-answered': {
598
+ agents: ['claude'],
599
+ events: ['PostToolUse'],
600
+ matcher: 'AskUserQuestion',
601
+ script: '10-feed-publish.py',
602
+ timeout: 5,
603
+ },
604
+ 'feed-clear-lifecycle': {
605
+ agents: ['claude'],
606
+ events: ['Stop', 'UserPromptSubmit', 'SessionEnd'],
607
+ script: '10-feed-publish.py',
608
+ timeout: 5,
609
+ },
610
+ };
611
+ for (const [name, definition] of Object.entries(desiredHooks)) {
612
+ if (!yamlDoc.getIn(['hooks', name])) {
613
+ yamlDoc.setIn(['hooks', name], definition);
614
+ installed = true;
615
+ }
616
+ }
617
+ if (installed) {
618
+ const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
619
+ fs.writeFileSync(tmpYaml, String(yamlDoc));
620
+ fs.renameSync(tmpYaml, agentsYamlPath);
621
+ }
622
+ return { installed };
623
+ }
624
+ catch (err) {
625
+ return { installed: false, error: err.message };
626
+ }
627
+ }
package/dist/lib/git.d.ts CHANGED
@@ -88,9 +88,25 @@ export declare function commitAndPush(repoPath: string, message: string): Promis
88
88
  */
89
89
  export declare function hasUncommittedChanges(repoPath: string): Promise<boolean>;
90
90
  /**
91
- * Check if a directory is a git repository.
91
+ * Check if a directory is a git repository (**synchronous, root-only**).
92
+ *
93
+ * Tests for a `.git` entry directly under `dir`, so it recognizes only a
94
+ * repository *root* — it returns false inside a subdirectory and for linked
95
+ * worktrees (whose `.git` is a file pointing elsewhere is caught, but a nested
96
+ * cwd is not). This is deliberate: the system-repo sync callers here always
97
+ * pass a known root. For the async, worktree-correct predicate used by teams,
98
+ * see `isGitRepo` in `lib/teams/worktree.ts` (which shells out to
99
+ * `git rev-parse --git-dir`). The two are intentionally **not** merged.
92
100
  */
93
101
  export declare function isGitRepo(dir: string): boolean;
102
+ /**
103
+ * Return the absolute path to the git working-tree root containing `dir`.
104
+ *
105
+ * Shells out to `git rev-parse --show-toplevel`, so it resolves correctly from
106
+ * any subdirectory and for linked worktrees (unlike the root-only, synchronous
107
+ * {@link isGitRepo} above). Throws if `dir` is not inside a git repository.
108
+ */
109
+ export declare function getGitRoot(dir: string): Promise<string>;
94
110
  /**
95
111
  * Initialize a git repo in an existing directory.
96
112
  */
package/dist/lib/git.js CHANGED
@@ -403,11 +403,30 @@ export async function hasUncommittedChanges(repoPath) {
403
403
  }
404
404
  }
405
405
  /**
406
- * Check if a directory is a git repository.
406
+ * Check if a directory is a git repository (**synchronous, root-only**).
407
+ *
408
+ * Tests for a `.git` entry directly under `dir`, so it recognizes only a
409
+ * repository *root* — it returns false inside a subdirectory and for linked
410
+ * worktrees (whose `.git` is a file pointing elsewhere is caught, but a nested
411
+ * cwd is not). This is deliberate: the system-repo sync callers here always
412
+ * pass a known root. For the async, worktree-correct predicate used by teams,
413
+ * see `isGitRepo` in `lib/teams/worktree.ts` (which shells out to
414
+ * `git rev-parse --git-dir`). The two are intentionally **not** merged.
407
415
  */
408
416
  export function isGitRepo(dir) {
409
417
  return fs.existsSync(path.join(dir, '.git'));
410
418
  }
419
+ /**
420
+ * Return the absolute path to the git working-tree root containing `dir`.
421
+ *
422
+ * Shells out to `git rev-parse --show-toplevel`, so it resolves correctly from
423
+ * any subdirectory and for linked worktrees (unlike the root-only, synchronous
424
+ * {@link isGitRepo} above). Throws if `dir` is not inside a git repository.
425
+ */
426
+ export async function getGitRoot(dir) {
427
+ const root = await simpleGit(dir).revparse(['--show-toplevel']);
428
+ return root.trim();
429
+ }
411
430
  /**
412
431
  * Initialize a git repo in an existing directory.
413
432
  */