@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.
- package/CHANGELOG.md +63 -2
- package/README.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/exec.js +65 -1
- package/dist/commands/feed.d.ts +29 -0
- package/dist/commands/feed.js +237 -32
- package/dist/commands/memory.d.ts +9 -0
- package/dist/commands/memory.js +164 -0
- package/dist/commands/message.d.ts +11 -6
- package/dist/commands/message.js +140 -5
- package/dist/commands/routines.js +12 -0
- package/dist/commands/secrets-migrate.d.ts +2 -1
- package/dist/commands/secrets-migrate.js +88 -13
- package/dist/commands/secrets.js +4 -1
- package/dist/commands/sessions.js +15 -4
- package/dist/commands/ssh.js +18 -6
- package/dist/commands/worktree.js +4 -2
- package/dist/index.js +16 -21
- package/dist/lib/agents.js +249 -17
- package/dist/lib/answer-router.d.ts +75 -0
- package/dist/lib/answer-router.js +149 -0
- package/dist/lib/ask-classifier.d.ts +71 -0
- package/dist/lib/ask-classifier.js +197 -0
- package/dist/lib/cloud/antigravity.d.ts +0 -2
- package/dist/lib/cloud/antigravity.js +2 -17
- package/dist/lib/cloud/codex.js +3 -18
- package/dist/lib/cloud/rush.js +3 -15
- package/dist/lib/cloud/stream.js +2 -0
- package/dist/lib/cloud/types.d.ts +21 -0
- package/dist/lib/cloud/types.js +81 -0
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +12 -2
- package/dist/lib/crabbox/lease.d.ts +13 -0
- package/dist/lib/crabbox/lease.js +11 -2
- package/dist/lib/crabbox/progress.d.ts +62 -0
- package/dist/lib/crabbox/progress.js +129 -0
- package/dist/lib/devices/resolve-target.d.ts +21 -4
- package/dist/lib/devices/resolve-target.js +82 -14
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +34 -1
- package/dist/lib/events.js +4 -1
- package/dist/lib/exec.js +19 -1
- package/dist/lib/feed-outcome.d.ts +101 -0
- package/dist/lib/feed-outcome.js +244 -0
- package/dist/lib/feed-policy.d.ts +30 -0
- package/dist/lib/feed-policy.js +133 -0
- package/dist/lib/feed.d.ts +127 -3
- package/dist/lib/feed.js +416 -40
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.js +20 -1
- package/dist/lib/hooks.js +522 -12
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +3 -4
- package/dist/lib/mailbox-gc.d.ts +22 -0
- package/dist/lib/mailbox-gc.js +161 -0
- package/dist/lib/mailbox.d.ts +26 -2
- package/dist/lib/mailbox.js +80 -5
- package/dist/lib/mcp.js +82 -0
- package/dist/lib/memory.d.ts +55 -0
- package/dist/lib/memory.js +274 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/notify.d.ts +16 -0
- package/dist/lib/notify.js +61 -0
- package/dist/lib/operator.d.ts +26 -0
- package/dist/lib/operator.js +107 -0
- package/dist/lib/plugins.d.ts +35 -0
- package/dist/lib/plugins.js +217 -0
- package/dist/lib/remote-agents-json.d.ts +14 -0
- package/dist/lib/remote-agents-json.js +94 -0
- package/dist/lib/resources/mcp.js +44 -0
- package/dist/lib/resources/memory.d.ts +15 -0
- package/dist/lib/resources/memory.js +46 -0
- package/dist/lib/resources/types.d.ts +2 -2
- package/dist/lib/runner.d.ts +43 -0
- package/dist/lib/runner.js +323 -74
- package/dist/lib/sandbox.js +6 -0
- package/dist/lib/secrets/bundles.js +38 -13
- package/dist/lib/secrets/icloud-import.d.ts +12 -3
- package/dist/lib/secrets/icloud-import.js +37 -7
- package/dist/lib/secrets/index.d.ts +106 -2
- package/dist/lib/secrets/index.js +603 -28
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +47 -17
- package/dist/lib/session/db.d.ts +9 -1
- package/dist/lib/session/db.js +18 -3
- package/dist/lib/session/discover.d.ts +13 -0
- package/dist/lib/session/discover.js +31 -0
- package/dist/lib/session/parse.d.ts +8 -0
- package/dist/lib/session/parse.js +42 -21
- package/dist/lib/session/remote-active.js +8 -89
- package/dist/lib/session/remote.d.ts +13 -0
- package/dist/lib/session/remote.js +27 -4
- package/dist/lib/session/state.d.ts +11 -0
- package/dist/lib/session/state.js +37 -0
- package/dist/lib/session/tail.d.ts +23 -4
- package/dist/lib/session/tail.js +34 -16
- package/dist/lib/session/throughput.d.ts +30 -0
- package/dist/lib/session/throughput.js +86 -0
- package/dist/lib/shim-heal.d.ts +12 -3
- package/dist/lib/shim-heal.js +12 -6
- package/dist/lib/staleness/detectors/subagents.js +57 -3
- package/dist/lib/staleness/writers/hooks.js +7 -3
- package/dist/lib/staleness/writers/subagents.js +37 -6
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/subagents.d.ts +52 -0
- package/dist/lib/subagents.js +315 -12
- package/dist/lib/teams/worktree.d.ts +8 -0
- package/dist/lib/teams/worktree.js +8 -0
- package/dist/lib/types.d.ts +10 -2
- package/dist/lib/versions.js +38 -48
- package/package.json +4 -3
- 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
|
-
|
|
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
|
-
"""
|
|
90
|
-
AskUserQuestion, so \`agents feed\` can aggregate pending decisions.
|
|
277
|
+
"""Publish and clear open-block records for \`agents feed\`.
|
|
91
278
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
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
|
-
|
|
234
|
-
|
|
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
|
*/
|