@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.
- package/CHANGELOG.md +71 -0
- package/README.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +215 -7
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.js +8 -1
- package/dist/commands/events.d.ts +1 -1
- package/dist/commands/events.js +2 -3
- package/dist/commands/exec.js +82 -3
- package/dist/commands/factory.js +8 -0
- package/dist/commands/feed.d.ts +38 -0
- package/dist/commands/feed.js +274 -0
- package/dist/commands/logs.d.ts +5 -1
- package/dist/commands/logs.js +248 -3
- package/dist/commands/mcp.js +7 -0
- 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.d.ts +22 -0
- package/dist/commands/secrets.js +176 -42
- package/dist/commands/sessions.js +10 -1
- package/dist/commands/teams.js +4 -0
- package/dist/commands/worktree.js +4 -2
- package/dist/index.js +21 -22
- 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/browser/login-detection.d.ts +94 -0
- package/dist/lib/browser/login-detection.js +274 -0
- package/dist/lib/browser/profiles.d.ts +17 -8
- package/dist/lib/browser/profiles.js +27 -8
- package/dist/lib/browser/secret-ref.d.ts +10 -0
- package/dist/lib/browser/secret-ref.js +14 -0
- package/dist/lib/browser/service.js +14 -12
- 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.d.ts +15 -0
- package/dist/lib/cloud/rush.js +10 -16
- 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 +19 -0
- package/dist/lib/crabbox/lease.js +22 -11
- package/dist/lib/crabbox/progress.d.ts +62 -0
- package/dist/lib/crabbox/progress.js +129 -0
- package/dist/lib/crabbox/runtimes.d.ts +38 -1
- package/dist/lib/crabbox/runtimes.js +98 -5
- package/dist/lib/daemon.d.ts +12 -9
- package/dist/lib/daemon.js +32 -17
- package/dist/lib/events.d.ts +31 -5
- package/dist/lib/events.js +291 -101
- package/dist/lib/exec.js +20 -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 +180 -0
- package/dist/lib/feed.js +627 -0
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.js +20 -1
- package/dist/lib/hooks.js +529 -14
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +3 -3
- 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/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/rotate.js +2 -0
- 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/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +21 -0
- package/dist/lib/secrets/agent.js +63 -1
- package/dist/lib/secrets/bundles.d.ts +33 -1
- package/dist/lib/secrets/bundles.js +76 -21
- package/dist/lib/secrets/icloud-import.d.ts +79 -0
- package/dist/lib/secrets/icloud-import.js +203 -0
- package/dist/lib/secrets/index.d.ts +142 -2
- package/dist/lib/secrets/index.js +695 -30
- package/dist/lib/secrets/remote.js +1 -1
- package/dist/lib/secrets/sync.js +1 -1
- 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 +32 -2
- 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/state.d.ts +11 -0
- package/dist/lib/session/state.js +50 -1
- 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 +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +25 -8
- package/dist/lib/subagents.d.ts +52 -0
- package/dist/lib/subagents.js +315 -12
- package/dist/lib/teams/agents.js +6 -3
- package/dist/lib/teams/worktree.d.ts +8 -0
- package/dist/lib/teams/worktree.js +8 -0
- package/dist/lib/types.d.ts +20 -2
- package/dist/lib/versions.js +38 -48
- package/dist/lib/whats-new.d.ts +5 -3
- package/dist/lib/whats-new.js +25 -5
- package/package.json +4 -3
- package/scripts/postinstall.js +61 -1
package/dist/lib/feed.js
ADDED
|
@@ -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
|
*/
|