@phnx-labs/agents-cli 1.20.52 → 1.20.54

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 (115) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +3 -0
  5. package/dist/commands/cloud.js +2 -1
  6. package/dist/commands/exec.js +65 -1
  7. package/dist/commands/feed.d.ts +29 -0
  8. package/dist/commands/feed.js +237 -32
  9. package/dist/commands/memory.d.ts +9 -0
  10. package/dist/commands/memory.js +164 -0
  11. package/dist/commands/message.d.ts +11 -6
  12. package/dist/commands/message.js +140 -5
  13. package/dist/commands/routines.js +12 -0
  14. package/dist/commands/secrets-migrate.d.ts +2 -1
  15. package/dist/commands/secrets-migrate.js +88 -13
  16. package/dist/commands/secrets.js +4 -1
  17. package/dist/commands/sessions.js +15 -4
  18. package/dist/commands/ssh.js +18 -6
  19. package/dist/commands/worktree.js +4 -2
  20. package/dist/index.js +16 -21
  21. package/dist/lib/agents.js +249 -17
  22. package/dist/lib/answer-router.d.ts +75 -0
  23. package/dist/lib/answer-router.js +149 -0
  24. package/dist/lib/ask-classifier.d.ts +71 -0
  25. package/dist/lib/ask-classifier.js +197 -0
  26. package/dist/lib/cloud/antigravity.d.ts +0 -2
  27. package/dist/lib/cloud/antigravity.js +2 -17
  28. package/dist/lib/cloud/codex.js +3 -18
  29. package/dist/lib/cloud/rush.js +3 -15
  30. package/dist/lib/cloud/stream.js +2 -0
  31. package/dist/lib/cloud/types.d.ts +21 -0
  32. package/dist/lib/cloud/types.js +81 -0
  33. package/dist/lib/crabbox/cli.d.ts +1 -1
  34. package/dist/lib/crabbox/cli.js +12 -2
  35. package/dist/lib/crabbox/lease.d.ts +13 -0
  36. package/dist/lib/crabbox/lease.js +11 -2
  37. package/dist/lib/crabbox/progress.d.ts +62 -0
  38. package/dist/lib/crabbox/progress.js +129 -0
  39. package/dist/lib/devices/resolve-target.d.ts +21 -4
  40. package/dist/lib/devices/resolve-target.js +82 -14
  41. package/dist/lib/devices/sync.d.ts +18 -0
  42. package/dist/lib/devices/sync.js +34 -1
  43. package/dist/lib/events.js +4 -1
  44. package/dist/lib/exec.js +19 -1
  45. package/dist/lib/feed-outcome.d.ts +101 -0
  46. package/dist/lib/feed-outcome.js +244 -0
  47. package/dist/lib/feed-policy.d.ts +30 -0
  48. package/dist/lib/feed-policy.js +133 -0
  49. package/dist/lib/feed.d.ts +127 -3
  50. package/dist/lib/feed.js +416 -40
  51. package/dist/lib/git.d.ts +17 -1
  52. package/dist/lib/git.js +20 -1
  53. package/dist/lib/hooks.js +522 -12
  54. package/dist/lib/hosts/passthrough.d.ts +3 -3
  55. package/dist/lib/hosts/passthrough.js +3 -4
  56. package/dist/lib/mailbox-gc.d.ts +22 -0
  57. package/dist/lib/mailbox-gc.js +161 -0
  58. package/dist/lib/mailbox.d.ts +26 -2
  59. package/dist/lib/mailbox.js +80 -5
  60. package/dist/lib/mcp.js +82 -0
  61. package/dist/lib/memory.d.ts +55 -0
  62. package/dist/lib/memory.js +274 -0
  63. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  64. package/dist/lib/notify.d.ts +16 -0
  65. package/dist/lib/notify.js +61 -0
  66. package/dist/lib/operator.d.ts +26 -0
  67. package/dist/lib/operator.js +107 -0
  68. package/dist/lib/plugins.d.ts +35 -0
  69. package/dist/lib/plugins.js +217 -0
  70. package/dist/lib/remote-agents-json.d.ts +14 -0
  71. package/dist/lib/remote-agents-json.js +94 -0
  72. package/dist/lib/resources/mcp.js +44 -0
  73. package/dist/lib/resources/memory.d.ts +15 -0
  74. package/dist/lib/resources/memory.js +46 -0
  75. package/dist/lib/resources/types.d.ts +2 -2
  76. package/dist/lib/runner.d.ts +43 -0
  77. package/dist/lib/runner.js +323 -74
  78. package/dist/lib/sandbox.js +6 -0
  79. package/dist/lib/secrets/bundles.js +38 -13
  80. package/dist/lib/secrets/icloud-import.d.ts +12 -3
  81. package/dist/lib/secrets/icloud-import.js +37 -7
  82. package/dist/lib/secrets/index.d.ts +106 -2
  83. package/dist/lib/secrets/index.js +603 -28
  84. package/dist/lib/session/active.d.ts +18 -0
  85. package/dist/lib/session/active.js +47 -17
  86. package/dist/lib/session/db.d.ts +9 -1
  87. package/dist/lib/session/db.js +18 -3
  88. package/dist/lib/session/discover.d.ts +13 -0
  89. package/dist/lib/session/discover.js +31 -0
  90. package/dist/lib/session/parse.d.ts +8 -0
  91. package/dist/lib/session/parse.js +42 -21
  92. package/dist/lib/session/remote-active.js +8 -89
  93. package/dist/lib/session/remote.d.ts +13 -0
  94. package/dist/lib/session/remote.js +27 -4
  95. package/dist/lib/session/state.d.ts +11 -0
  96. package/dist/lib/session/state.js +37 -0
  97. package/dist/lib/session/tail.d.ts +23 -4
  98. package/dist/lib/session/tail.js +34 -16
  99. package/dist/lib/session/throughput.d.ts +30 -0
  100. package/dist/lib/session/throughput.js +86 -0
  101. package/dist/lib/shim-heal.d.ts +12 -3
  102. package/dist/lib/shim-heal.js +12 -6
  103. package/dist/lib/staleness/detectors/subagents.js +57 -3
  104. package/dist/lib/staleness/writers/hooks.js +7 -3
  105. package/dist/lib/staleness/writers/subagents.js +37 -6
  106. package/dist/lib/startup/command-registry.d.ts +1 -0
  107. package/dist/lib/startup/command-registry.js +2 -0
  108. package/dist/lib/subagents.d.ts +52 -0
  109. package/dist/lib/subagents.js +315 -12
  110. package/dist/lib/teams/worktree.d.ts +8 -0
  111. package/dist/lib/teams/worktree.js +8 -0
  112. package/dist/lib/types.d.ts +10 -2
  113. package/dist/lib/versions.js +38 -48
  114. package/package.json +4 -3
  115. package/scripts/postinstall.js +61 -1
package/dist/lib/feed.js CHANGED
@@ -13,11 +13,19 @@
13
13
  * A block carries enough identity (sessionId, mailboxId, host, runtime) for
14
14
  * `agents feed` to aggregate across hosts and for `agents message` to route
15
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.
16
23
  */
17
24
  import * as fs from 'fs';
18
25
  import * as path from 'path';
19
26
  import * as yaml from 'yaml';
20
27
  import { getFeedDir, getUserAgentsDir } from './state.js';
28
+ import { isHighConsequenceAllowed } from './operator.js';
21
29
  /**
22
30
  * Stable block id for a session. One block per session -- a new question
23
31
  * replaces the previous one (the agent can only ask one question at a time).
@@ -32,7 +40,186 @@ function blockPath(root, blockId) {
32
40
  }
33
41
  return path.join(root, `${blockId}.json`);
34
42
  }
35
- /** Atomic write a block record to the feed store. */
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. */
36
223
  export function publishBlock(block, root) {
37
224
  const dir = root ?? getFeedDir();
38
225
  fs.mkdirSync(dir, { recursive: true });
@@ -66,9 +253,10 @@ export function listBlocks(root) {
66
253
  }
67
254
  return blocks;
68
255
  }
69
- /** Remove a block record. Returns true if the file was deleted. */
256
+ /** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
70
257
  export function removeBlock(blockId, root) {
71
258
  const dir = root ?? getFeedDir();
259
+ clearBlockLifecycle(blockId, dir);
72
260
  try {
73
261
  fs.unlinkSync(blockPath(dir, blockId));
74
262
  return true;
@@ -86,12 +274,12 @@ export function removeBlock(blockId, root) {
86
274
  * CLI-writable user hooks dir without a separate file in the npm tarball.
87
275
  */
88
276
  export const FEED_PUBLISH_HOOK_SCRIPT = `#!/usr/bin/env python3
89
- """PreToolUse hook: publish an open-block record when the agent calls
90
- AskUserQuestion, so \`agents feed\` can aggregate pending decisions.
277
+ """Publish and clear open-block records for \`agents feed\`.
91
278
 
92
- Outbound counterpart to the inbound mailbox-inject hook. Fires only on
93
- AskUserQuestion (matcher-gated in agents.yaml). Writes one block per session
94
- to ~/.agents/.history/feed/. A new question replaces the previous block.
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.
95
283
 
96
284
  Sub-agent gate: when the PreToolUse payload carries \`agent_type\`, this is a
97
285
  Task/Agent subagent -- skip. Only the top-level agent publishes. Verified on
@@ -107,6 +295,40 @@ import socket
107
295
  import tempfile
108
296
  from datetime import datetime, timezone
109
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
+
110
332
 
111
333
  def main():
112
334
  raw = sys.stdin.read()
@@ -119,35 +341,113 @@ def main():
119
341
  if payload.get("agent_type"):
120
342
  return
121
343
 
122
- tool_input = payload.get("tool_input", {})
123
- questions = tool_input.get("questions", [])
124
- if not questions:
125
- return
126
-
127
344
  session_id = payload.get("session_id", "")
128
345
  if not session_id:
129
346
  return
130
347
 
131
- normalized_questions = []
132
- for q in questions:
133
- if not isinstance(q, dict):
134
- continue
135
- question = {
136
- "text": q.get("question", q.get("header", "")),
137
- "header": q.get("header"),
138
- "multiSelect": q.get("multiSelect", False),
139
- }
140
- raw_opts = q.get("options", [])
141
- if raw_opts:
142
- question["options"] = [
143
- {"label": o.get("label", ""), "description": o.get("description")}
144
- for o in raw_opts
145
- if isinstance(o, dict)
146
- ]
147
- normalized_questions.append(question)
148
- if not normalized_questions:
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
149
371
  return
150
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
+
151
451
  # Identity from env (set by agents-cli at spawn).
152
452
  mailbox_id = os.path.basename(
153
453
  os.environ.get("AGENTS_MAILBOX_DIR", "").rstrip("/")
@@ -159,8 +459,6 @@ def main():
159
459
 
160
460
  runtime = os.environ.get("AGENTS_RUNTIME", "headless")
161
461
 
162
- safe_session_id = re.sub(r"[^A-Za-z0-9._-]", "-", session_id)
163
- block_id = f"block-{safe_session_id}"
164
462
  block = {
165
463
  "blockId": block_id,
166
464
  "sessionId": session_id,
@@ -169,21 +467,52 @@ def main():
169
467
  "runtime": runtime,
170
468
  "ts": datetime.now(timezone.utc).isoformat(),
171
469
  "questions": normalized_questions,
470
+ "kind": kind,
172
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
173
505
 
174
506
  # Python's expanduser() ignores HOME on Windows, while agents-cli honors a
175
507
  # HOME override on every platform. Use the same anchor so hooks and the CLI
176
508
  # always read/write one feed store (including temp-home and sandbox runs).
177
- home = os.environ.get("HOME") or os.path.expanduser("~")
178
- feed_dir = os.path.join(home, ".agents", ".history", "feed")
179
509
  os.makedirs(feed_dir, exist_ok=True)
180
510
 
181
- target = os.path.join(feed_dir, f"{block_id}.json")
182
511
  fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=".tmp")
183
512
  try:
184
513
  with os.fdopen(fd, "w") as f:
185
514
  json.dump(block, f, indent=2)
186
- os.rename(tmp, target)
515
+ os.replace(tmp, target)
187
516
  except Exception:
188
517
  try:
189
518
  os.unlink(tmp)
@@ -205,6 +534,26 @@ export const FEED_PUBLISH_HOOK_MANIFEST = {
205
534
  script: '10-feed-publish.py',
206
535
  timeout: 5,
207
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
+ };
208
557
  /**
209
558
  * Install the feed-publish hook script into the user hooks dir and add its
210
559
  * manifest entry to the user agents.yaml. The system repo is an auto-pulled,
@@ -230,18 +579,45 @@ export function ensureFeedPublishHook(userAgentsDir = getUserAgentsDir()) {
230
579
  if (yamlDoc.errors.length > 0) {
231
580
  throw new Error(`Cannot install feed hook: ${agentsYamlPath} is invalid YAML`);
232
581
  }
233
- if (!yamlDoc.getIn(['hooks', 'feed-publish'])) {
234
- yamlDoc.setIn(['hooks', 'feed-publish'], {
582
+ const desiredHooks = {
583
+ 'feed-publish': {
235
584
  agents: ['claude'],
236
585
  events: ['PreToolUse'],
237
586
  matcher: 'AskUserQuestion',
238
587
  script: '10-feed-publish.py',
239
588
  timeout: 5,
240
- });
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) {
241
618
  const tmpYaml = `${agentsYamlPath}.${process.pid}.tmp`;
242
619
  fs.writeFileSync(tmpYaml, String(yamlDoc));
243
620
  fs.renameSync(tmpYaml, agentsYamlPath);
244
- installed = true;
245
621
  }
246
622
  return { installed };
247
623
  }
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
  */