@phnx-labs/agents-cli 1.20.52 → 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 +57 -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 +10 -1
- 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/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/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/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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome keys for the agent feed.
|
|
3
|
+
*
|
|
4
|
+
* 1,100 agents is not 1,100 things the operator cares about — dozens of agents
|
|
5
|
+
* map onto each real deliverable (a Linear ticket, a PR, a worktree/epic). The
|
|
6
|
+
* feed groups by **outcome** so one human reasons about initiatives, not
|
|
7
|
+
* processes. Every block is attributed to exactly one outcome; orphans land in
|
|
8
|
+
* the shared "Unassigned" bucket.
|
|
9
|
+
*
|
|
10
|
+
* Precedence (first match wins):
|
|
11
|
+
* 1. ticket — RUSH-1125 / explicit `ticket` field / ticket-shaped branch
|
|
12
|
+
* 2. pr — #534 / PR#534 / github.com/.../pull/534
|
|
13
|
+
* 3. worktree — `.agents/worktrees/<slug>` slug (or epic label)
|
|
14
|
+
* 4. unassigned
|
|
15
|
+
*
|
|
16
|
+
* Pure functions, no I/O — unit-testable and shared by `agents feed` rendering
|
|
17
|
+
* and any UI that collapses blocks under deliverables.
|
|
18
|
+
*/
|
|
19
|
+
import { detectTicket, extractPrUrl } from './session/state.js';
|
|
20
|
+
const UNASSIGNED = {
|
|
21
|
+
key: 'unassigned',
|
|
22
|
+
kind: 'unassigned',
|
|
23
|
+
label: 'Unassigned',
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a PR ref.
|
|
27
|
+
* - Full GitHub URL → `owner/repo#N` (repo identity included)
|
|
28
|
+
* - Bare `#123` / `PR#123` → `#123` (no repo known)
|
|
29
|
+
*/
|
|
30
|
+
export function normalizePrRef(raw) {
|
|
31
|
+
if (!raw)
|
|
32
|
+
return undefined;
|
|
33
|
+
const trimmed = raw.trim();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
return undefined;
|
|
36
|
+
const fromUrl = extractPrUrl(trimmed);
|
|
37
|
+
if (fromUrl?.number != null) {
|
|
38
|
+
const repo = repoFromPrUrl(fromUrl.url);
|
|
39
|
+
return repo ? `${repo}#${fromUrl.number}` : `#${fromUrl.number}`;
|
|
40
|
+
}
|
|
41
|
+
const m = /(?:^|\bpr\s*#?\s*|pull\/|#)(\d{1,7})\b/i.exec(trimmed);
|
|
42
|
+
if (m)
|
|
43
|
+
return `#${m[1]}`;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
/** `https://github.com/owner/repo/pull/N` → `owner/repo`. */
|
|
47
|
+
export function repoFromPrUrl(url) {
|
|
48
|
+
if (!url)
|
|
49
|
+
return undefined;
|
|
50
|
+
const m = /github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/i.exec(url);
|
|
51
|
+
if (!m)
|
|
52
|
+
return undefined;
|
|
53
|
+
return `${m[1]}/${m[2]}`;
|
|
54
|
+
}
|
|
55
|
+
/** Canonical ticket id (uppercase team key). */
|
|
56
|
+
export function normalizeTicketRef(raw) {
|
|
57
|
+
if (!raw)
|
|
58
|
+
return undefined;
|
|
59
|
+
const m = raw.trim().match(/\b([A-Za-z]{2,6}-\d{1,6})\b/);
|
|
60
|
+
if (!m)
|
|
61
|
+
return undefined;
|
|
62
|
+
const id = m[1].toUpperCase();
|
|
63
|
+
// Reuse the session state's denylist via detectTicket on the id alone.
|
|
64
|
+
return detectTicket(id)?.id ?? id;
|
|
65
|
+
}
|
|
66
|
+
function ticketFromSignals(s) {
|
|
67
|
+
const direct = normalizeTicketRef(s.ticket ?? undefined);
|
|
68
|
+
if (direct)
|
|
69
|
+
return direct;
|
|
70
|
+
const fromText = detectTicket(s.text ?? undefined, s.branch ?? undefined)?.id;
|
|
71
|
+
return fromText;
|
|
72
|
+
}
|
|
73
|
+
function prFromSignals(s) {
|
|
74
|
+
const direct = normalizePrRef(s.pr ?? undefined);
|
|
75
|
+
if (direct)
|
|
76
|
+
return direct;
|
|
77
|
+
if (s.text) {
|
|
78
|
+
// Prefer full URL so keys include owner/repo (RUSH-1630).
|
|
79
|
+
const fromUrl = extractPrUrl(s.text);
|
|
80
|
+
if (fromUrl?.number != null) {
|
|
81
|
+
return normalizePrRef(fromUrl.url) ?? `#${fromUrl.number}`;
|
|
82
|
+
}
|
|
83
|
+
const m = /(?:\bpr\s*#?\s*|#)(\d{1,7})\b/i.exec(s.text);
|
|
84
|
+
if (m)
|
|
85
|
+
return `#${m[1]}`;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive the single outcome a block/agent belongs to.
|
|
91
|
+
* Exactly one outcome per call — never ambiguous, never empty.
|
|
92
|
+
*/
|
|
93
|
+
export function deriveOutcome(signals) {
|
|
94
|
+
const ticket = ticketFromSignals(signals);
|
|
95
|
+
if (ticket) {
|
|
96
|
+
return { key: `ticket:${ticket}`, kind: 'ticket', label: ticket };
|
|
97
|
+
}
|
|
98
|
+
const pr = prFromSignals(signals);
|
|
99
|
+
if (pr) {
|
|
100
|
+
// pr is either `owner/repo#N` or `#N`
|
|
101
|
+
const hash = pr.includes('#') ? pr.slice(pr.indexOf('#')) : pr;
|
|
102
|
+
const n = hash.replace(/^#/, '');
|
|
103
|
+
const label = pr.includes('/') ? `PR ${pr}` : `PR#${n}`;
|
|
104
|
+
return { key: `pr:${pr}`, kind: 'pr', label };
|
|
105
|
+
}
|
|
106
|
+
const wt = (signals.worktreeSlug ?? '').trim();
|
|
107
|
+
if (wt) {
|
|
108
|
+
return { key: `worktree:${wt}`, kind: 'worktree', label: wt };
|
|
109
|
+
}
|
|
110
|
+
const epic = (signals.epic ?? '').trim();
|
|
111
|
+
if (epic) {
|
|
112
|
+
// Epic folds into the worktree kind: both are soft deliverable labels
|
|
113
|
+
// without a tracker id. Key prefix keeps them disjoint from worktree slugs.
|
|
114
|
+
return { key: `epic:${epic}`, kind: 'worktree', label: epic };
|
|
115
|
+
}
|
|
116
|
+
return UNASSIGNED;
|
|
117
|
+
}
|
|
118
|
+
/** Join question headers + bodies into one scan target for ticket/PR detection. */
|
|
119
|
+
export function blockScanText(block) {
|
|
120
|
+
return block.questions
|
|
121
|
+
.map((q) => [q.header, q.text].filter(Boolean).join(' '))
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join('\n');
|
|
124
|
+
}
|
|
125
|
+
/** Outcome for a stored open block (uses stamped fields + question text). */
|
|
126
|
+
export function outcomeForBlock(block) {
|
|
127
|
+
return deriveOutcome({
|
|
128
|
+
ticket: block.ticket,
|
|
129
|
+
pr: block.pr,
|
|
130
|
+
worktreeSlug: block.worktreeSlug,
|
|
131
|
+
epic: block.epic,
|
|
132
|
+
text: blockScanText(block),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function isOpen(block) {
|
|
136
|
+
return !block.answer && !block.parkedAt && !block.continuedAt && !block.defaultedAt;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Collapse blocks under their outcome. Order: needs-you outcomes first
|
|
140
|
+
* (open count desc), then by label. Unassigned always last among ties of 0 open.
|
|
141
|
+
*/
|
|
142
|
+
export function groupBlocksByOutcome(blocks) {
|
|
143
|
+
const byKey = new Map();
|
|
144
|
+
for (const block of blocks) {
|
|
145
|
+
const outcome = outcomeForBlock(block);
|
|
146
|
+
const bucket = byKey.get(outcome.key);
|
|
147
|
+
if (bucket)
|
|
148
|
+
bucket.blocks.push(block);
|
|
149
|
+
else
|
|
150
|
+
byKey.set(outcome.key, { outcome, blocks: [block] });
|
|
151
|
+
}
|
|
152
|
+
const groups = [];
|
|
153
|
+
for (const { outcome, blocks: members } of byKey.values()) {
|
|
154
|
+
const mailboxes = new Set(members.map((b) => b.mailboxId));
|
|
155
|
+
let open = 0;
|
|
156
|
+
let answered = 0;
|
|
157
|
+
let parked = 0;
|
|
158
|
+
for (const b of members) {
|
|
159
|
+
if (b.parkedAt)
|
|
160
|
+
parked += 1;
|
|
161
|
+
else if (b.answer || b.defaultedAt || b.continuedAt)
|
|
162
|
+
answered += 1;
|
|
163
|
+
else
|
|
164
|
+
open += 1;
|
|
165
|
+
}
|
|
166
|
+
groups.push({
|
|
167
|
+
outcome,
|
|
168
|
+
blocks: members,
|
|
169
|
+
counts: { agents: mailboxes.size, open, answered, parked },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
groups.sort((a, b) => {
|
|
173
|
+
// Unassigned always last.
|
|
174
|
+
if (a.outcome.kind === 'unassigned' && b.outcome.kind !== 'unassigned')
|
|
175
|
+
return 1;
|
|
176
|
+
if (b.outcome.kind === 'unassigned' && a.outcome.kind !== 'unassigned')
|
|
177
|
+
return -1;
|
|
178
|
+
if (b.counts.open !== a.counts.open)
|
|
179
|
+
return b.counts.open - a.counts.open;
|
|
180
|
+
return a.outcome.label.localeCompare(b.outcome.label);
|
|
181
|
+
});
|
|
182
|
+
return groups;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Stamp each block with its derived outcome for JSON consumers.
|
|
186
|
+
* Does not mutate the input records.
|
|
187
|
+
*/
|
|
188
|
+
export function stampBlockOutcomes(blocks) {
|
|
189
|
+
return blocks.map((b) => ({ ...b, outcome: outcomeForBlock(b) }));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* True when every still-open block under the outcome asks the same question
|
|
193
|
+
* (same cluster of header+text). Fan-out answers are only safe when this holds
|
|
194
|
+
* — otherwise the operator must pick a specific agent.
|
|
195
|
+
*/
|
|
196
|
+
export function isUnambiguousOutcomeAnswer(group) {
|
|
197
|
+
const open = group.blocks.filter(isOpen);
|
|
198
|
+
if (open.length <= 1)
|
|
199
|
+
return open.length === 1;
|
|
200
|
+
const keys = new Set(open.map((b) => b.questions.map((q) => `${q.header ?? ''}\0${q.text}`).join('\n')));
|
|
201
|
+
return keys.size === 1;
|
|
202
|
+
}
|
|
203
|
+
/** Still-open blocks under an outcome (candidates for a fan-out reply). */
|
|
204
|
+
export function openBlocksForOutcome(group) {
|
|
205
|
+
return group.blocks.filter(isOpen);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Overlay session meta onto a block when the block itself is missing ticket/PR/
|
|
209
|
+
* worktree. Never overwrites a field the block already carries.
|
|
210
|
+
*/
|
|
211
|
+
export function enrichBlockFromSession(block, hint) {
|
|
212
|
+
const next = { ...block };
|
|
213
|
+
if (!next.ticket && hint.ticketId)
|
|
214
|
+
next.ticket = hint.ticketId;
|
|
215
|
+
if (!next.pr) {
|
|
216
|
+
if (hint.prUrl)
|
|
217
|
+
next.pr = hint.prUrl;
|
|
218
|
+
else if (hint.prNumber != null)
|
|
219
|
+
next.pr = `#${hint.prNumber}`;
|
|
220
|
+
}
|
|
221
|
+
if (!next.worktreeSlug && hint.worktreeSlug)
|
|
222
|
+
next.worktreeSlug = hint.worktreeSlug;
|
|
223
|
+
return next;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build a mailboxId → session-hint index and enrich every block. Pure.
|
|
227
|
+
* Matching order: mailboxId, then sessionId, then agentId.
|
|
228
|
+
*/
|
|
229
|
+
export function enrichBlocksFromSessions(blocks, sessions) {
|
|
230
|
+
const byMailbox = new Map();
|
|
231
|
+
const bySession = new Map();
|
|
232
|
+
for (const s of sessions) {
|
|
233
|
+
if (s.mailboxId)
|
|
234
|
+
byMailbox.set(s.mailboxId, s);
|
|
235
|
+
if (s.sessionId)
|
|
236
|
+
bySession.set(s.sessionId, s);
|
|
237
|
+
if (s.agentId)
|
|
238
|
+
bySession.set(s.agentId, s);
|
|
239
|
+
}
|
|
240
|
+
return blocks.map((b) => {
|
|
241
|
+
const hint = byMailbox.get(b.mailboxId) ?? bySession.get(b.sessionId);
|
|
242
|
+
return hint ? enrichBlockFromSession(b, hint) : b;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type OpenBlock, type AnswerRecord } from './feed.js';
|
|
2
|
+
export type BlockClass = 'approval' | 'decision';
|
|
3
|
+
export interface ClassPolicy {
|
|
4
|
+
timeoutMinutes: number;
|
|
5
|
+
/** For approval class only: the answer to apply when the timeout fires. */
|
|
6
|
+
safeDefault?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FeedPolicy {
|
|
9
|
+
approval: ClassPolicy;
|
|
10
|
+
decision: ClassPolicy;
|
|
11
|
+
/** High-cost-of-delay blocks below this threshold do not page the phone. */
|
|
12
|
+
phoneNotifyThreshold: 'low' | 'medium' | 'high';
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_POLICY: FeedPolicy;
|
|
15
|
+
export declare function getPolicyPath(root?: string): string;
|
|
16
|
+
export declare function loadPolicy(root?: string): FeedPolicy;
|
|
17
|
+
export declare function blockClass(block: OpenBlock): BlockClass;
|
|
18
|
+
export declare function isPhoneUrgent(block: OpenBlock, policy: FeedPolicy): boolean;
|
|
19
|
+
export declare function minutesElapsed(block: OpenBlock, now: Date): number;
|
|
20
|
+
export declare function isTimedOut(block: OpenBlock, policy: FeedPolicy, now: Date): boolean;
|
|
21
|
+
export interface PolicyResult {
|
|
22
|
+
blockId: string;
|
|
23
|
+
action: 'none' | 'defaulted' | 'parked';
|
|
24
|
+
answer?: AnswerRecord;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
|
|
28
|
+
* Caller is responsible for persistence/logging side effects not owned by feed.ts.
|
|
29
|
+
*/
|
|
30
|
+
export declare function applyPolicyToBlock(block: OpenBlock, policy: FeedPolicy, now: Date, root?: string): PolicyResult;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed timeout policy — unattended default-on-no-answer behavior.
|
|
3
|
+
*
|
|
4
|
+
* Two block classes:
|
|
5
|
+
* - Approval: has a safe default (e.g. 'deny'). After the configured timeout with
|
|
6
|
+
* no operator answer, the policy auto-records that default and queues it to the
|
|
7
|
+
* agent. Logged so the operator can audit later.
|
|
8
|
+
* - Decision: no safe default (a real choice). After the timeout the block is
|
|
9
|
+
* hard-parked: a parked marker is recorded and, if we can locate the session
|
|
10
|
+
* process, it is stopped so the agent cannot proceed on a stale default.
|
|
11
|
+
*
|
|
12
|
+
* Policy is loaded from ~/.agents/feed-policy.yaml. Missing file uses the built-in
|
|
13
|
+
* conservative defaults.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import * as yaml from 'yaml';
|
|
18
|
+
import { getUserAgentsDir } from './state.js';
|
|
19
|
+
import { recordAnswer, recordDefaulted, recordMessageReceipt, recordParked, } from './feed.js';
|
|
20
|
+
import { enqueue, mailboxDir } from './mailbox.js';
|
|
21
|
+
const POLICY_FILE = 'feed-policy.yaml';
|
|
22
|
+
const COST_RANK = { low: 0, medium: 1, high: 2 };
|
|
23
|
+
export const DEFAULT_POLICY = {
|
|
24
|
+
approval: { timeoutMinutes: 30, safeDefault: 'deny' },
|
|
25
|
+
decision: { timeoutMinutes: 60 },
|
|
26
|
+
phoneNotifyThreshold: 'medium',
|
|
27
|
+
};
|
|
28
|
+
export function getPolicyPath(root) {
|
|
29
|
+
return path.join(root ?? getUserAgentsDir(), POLICY_FILE);
|
|
30
|
+
}
|
|
31
|
+
function normalizeClassPolicy(raw) {
|
|
32
|
+
const p = (raw ?? {});
|
|
33
|
+
const timeout = typeof p.timeoutMinutes === 'number' ? p.timeoutMinutes : DEFAULT_POLICY.approval.timeoutMinutes;
|
|
34
|
+
return {
|
|
35
|
+
timeoutMinutes: Math.max(1, Math.round(timeout)),
|
|
36
|
+
safeDefault: typeof p.safeDefault === 'string' ? p.safeDefault : undefined,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function loadPolicy(root) {
|
|
40
|
+
const file = getPolicyPath(root);
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
43
|
+
const parsed = yaml.parse(raw);
|
|
44
|
+
if (parsed && typeof parsed === 'object') {
|
|
45
|
+
const p = parsed;
|
|
46
|
+
const threshold = p.phoneNotifyThreshold;
|
|
47
|
+
return {
|
|
48
|
+
approval: normalizeClassPolicy(p.approval ?? { timeoutMinutes: DEFAULT_POLICY.approval.timeoutMinutes, safeDefault: DEFAULT_POLICY.approval.safeDefault }),
|
|
49
|
+
decision: normalizeClassPolicy(p.decision ?? { timeoutMinutes: DEFAULT_POLICY.decision.timeoutMinutes }),
|
|
50
|
+
phoneNotifyThreshold: threshold === 'low' || threshold === 'medium' || threshold === 'high' ? threshold : DEFAULT_POLICY.phoneNotifyThreshold,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// missing or malformed -> defaults
|
|
56
|
+
}
|
|
57
|
+
return DEFAULT_POLICY;
|
|
58
|
+
}
|
|
59
|
+
export function blockClass(block) {
|
|
60
|
+
return block.blockClass === 'decision' ? 'decision' : 'approval';
|
|
61
|
+
}
|
|
62
|
+
export function isPhoneUrgent(block, policy) {
|
|
63
|
+
if (block.answer)
|
|
64
|
+
return false; // already answered
|
|
65
|
+
const cost = block.costOfDelay ?? 'low';
|
|
66
|
+
return COST_RANK[cost] >= COST_RANK[policy.phoneNotifyThreshold];
|
|
67
|
+
}
|
|
68
|
+
export function minutesElapsed(block, now) {
|
|
69
|
+
const ts = Date.parse(block.ts);
|
|
70
|
+
if (Number.isNaN(ts))
|
|
71
|
+
return 0;
|
|
72
|
+
return (now.getTime() - ts) / 60_000;
|
|
73
|
+
}
|
|
74
|
+
export function isTimedOut(block, policy, now) {
|
|
75
|
+
const cls = blockClass(block);
|
|
76
|
+
const minutes = minutesElapsed(block, now);
|
|
77
|
+
return minutes >= policy[cls].timeoutMinutes;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Apply policy to a single open block. Returns the action taken (none/defaulted/parked).
|
|
81
|
+
* Caller is responsible for persistence/logging side effects not owned by feed.ts.
|
|
82
|
+
*/
|
|
83
|
+
export function applyPolicyToBlock(block, policy, now, root) {
|
|
84
|
+
if (block.answer || block.parkedAt || block.defaultedAt) {
|
|
85
|
+
return { blockId: block.blockId, action: 'none' };
|
|
86
|
+
}
|
|
87
|
+
if (!isTimedOut(block, policy, now)) {
|
|
88
|
+
return { blockId: block.blockId, action: 'none' };
|
|
89
|
+
}
|
|
90
|
+
const cls = blockClass(block);
|
|
91
|
+
if (cls === 'approval') {
|
|
92
|
+
const safeDefault = block.safeDefault ?? policy.approval.safeDefault;
|
|
93
|
+
if (!safeDefault) {
|
|
94
|
+
return { blockId: block.blockId, action: 'none' };
|
|
95
|
+
}
|
|
96
|
+
const claim = recordAnswer(block.blockId, { answeredFrom: 'policy', answeredBy: 'default-on-no-answer', operatorId: 'policy', verified: true }, root);
|
|
97
|
+
if (!claim.ok) {
|
|
98
|
+
return { blockId: block.blockId, action: 'none' };
|
|
99
|
+
}
|
|
100
|
+
const msgId = enqueue(mailboxDir(block.mailboxId, root ?? undefined), {
|
|
101
|
+
to: block.mailboxId,
|
|
102
|
+
text: safeDefault,
|
|
103
|
+
from: 'policy',
|
|
104
|
+
blockId: block.blockId,
|
|
105
|
+
});
|
|
106
|
+
recordMessageReceipt(block.blockId, { msgId, status: 'queued', at: now.toISOString(), from: 'policy' }, root);
|
|
107
|
+
recordDefaulted(block.blockId, root);
|
|
108
|
+
return {
|
|
109
|
+
blockId: block.blockId,
|
|
110
|
+
action: 'defaulted',
|
|
111
|
+
answer: {
|
|
112
|
+
answeredAt: now.toISOString(),
|
|
113
|
+
answeredFrom: 'policy',
|
|
114
|
+
answeredBy: 'default-on-no-answer',
|
|
115
|
+
operatorId: 'policy',
|
|
116
|
+
verified: true,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Decision class: hard-park.
|
|
121
|
+
recordParked(block.blockId, root);
|
|
122
|
+
return {
|
|
123
|
+
blockId: block.blockId,
|
|
124
|
+
action: 'parked',
|
|
125
|
+
answer: {
|
|
126
|
+
answeredAt: now.toISOString(),
|
|
127
|
+
answeredFrom: 'policy',
|
|
128
|
+
answeredBy: 'hard-park',
|
|
129
|
+
operatorId: 'policy',
|
|
130
|
+
verified: true,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
package/dist/lib/feed.d.ts
CHANGED
|
@@ -8,6 +8,28 @@ export interface BlockQuestion {
|
|
|
8
8
|
options?: BlockOption[];
|
|
9
9
|
multiSelect?: boolean;
|
|
10
10
|
}
|
|
11
|
+
export interface MessageReceipt {
|
|
12
|
+
/** The message id this receipt describes. */
|
|
13
|
+
msgId: string;
|
|
14
|
+
/** Delivery lifecycle state. */
|
|
15
|
+
status: 'queued' | 'consumed' | 'continued';
|
|
16
|
+
/** ISO-8601 timestamp of the state transition. */
|
|
17
|
+
at: string;
|
|
18
|
+
/** Optional sender label for the message. */
|
|
19
|
+
from?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AnswerRecord {
|
|
22
|
+
/** ISO-8601 timestamp of when the answer was recorded. */
|
|
23
|
+
answeredAt: string;
|
|
24
|
+
/** Surface that recorded the answer (e.g. 'feed', 'terminal', 'tmux', 'cloud', 'policy'). */
|
|
25
|
+
answeredFrom: string;
|
|
26
|
+
/** Optional operator/agent label recorded as the sender. */
|
|
27
|
+
answeredBy?: string;
|
|
28
|
+
/** Operator id from the local registry, if verified. */
|
|
29
|
+
operatorId?: string;
|
|
30
|
+
/** Whether the operator identity was verified against the registry. */
|
|
31
|
+
verified?: boolean;
|
|
32
|
+
}
|
|
11
33
|
export interface OpenBlock {
|
|
12
34
|
blockId: string;
|
|
13
35
|
sessionId: string;
|
|
@@ -16,26 +38,108 @@ export interface OpenBlock {
|
|
|
16
38
|
runtime: string;
|
|
17
39
|
ts: string;
|
|
18
40
|
questions: BlockQuestion[];
|
|
41
|
+
kind?: 'question' | 'notification';
|
|
42
|
+
notificationType?: string;
|
|
19
43
|
ticket?: string;
|
|
20
44
|
pr?: string;
|
|
45
|
+
/** Worktree slug under `.agents/worktrees/` — soft outcome when no ticket/PR. */
|
|
46
|
+
worktreeSlug?: string;
|
|
47
|
+
/** Epic / initiative label when no ticket/PR/worktree is known. */
|
|
48
|
+
epic?: string;
|
|
49
|
+
/** Block class: approval has a safe default; decision requires human choice. */
|
|
50
|
+
blockClass?: 'approval' | 'decision';
|
|
51
|
+
/** Consequence tag for authz. 'high' gates merge/deploy/admin-style answers. */
|
|
52
|
+
consequence?: 'normal' | 'high' | string;
|
|
53
|
+
/** Operator ids allowed to answer a high-consequence block. Admins always pass. */
|
|
54
|
+
allowedOperators?: string[];
|
|
55
|
+
/** Timeout in minutes before default-on-no-answer policy fires. */
|
|
56
|
+
timeoutMinutes?: number;
|
|
57
|
+
/** Safe default answer for approval-class blocks. */
|
|
58
|
+
safeDefault?: string;
|
|
59
|
+
/** Cost-of-delay for notification routing: low/medium/high. */
|
|
60
|
+
costOfDelay?: 'low' | 'medium' | 'high';
|
|
61
|
+
/** Set once the block has been answered; see `recordAnswer`. */
|
|
62
|
+
answer?: AnswerRecord;
|
|
63
|
+
/** Per-message delivery receipts for answers to this block. */
|
|
64
|
+
receipts?: MessageReceipt[];
|
|
65
|
+
/** ISO-8601 timestamp when the agent continued past the block. */
|
|
66
|
+
continuedAt?: string;
|
|
67
|
+
/** ISO-8601 timestamp when an urgent block was paged to the phone. */
|
|
68
|
+
notifiedAt?: string;
|
|
69
|
+
/** ISO-8601 timestamp when the approval safe-default was applied. */
|
|
70
|
+
defaultedAt?: string;
|
|
71
|
+
/** ISO-8601 timestamp when a decision block was hard-parked. */
|
|
72
|
+
parkedAt?: string;
|
|
21
73
|
}
|
|
22
74
|
/**
|
|
23
75
|
* Stable block id for a session. One block per session -- a new question
|
|
24
76
|
* replaces the previous one (the agent can only ask one question at a time).
|
|
25
77
|
*/
|
|
26
78
|
export declare function blockIdForSession(sessionId: string): string;
|
|
27
|
-
/**
|
|
79
|
+
/** Read one block record. Returns undefined when missing or corrupt. */
|
|
80
|
+
export declare function readBlock(blockId: string, root?: string): OpenBlock | undefined;
|
|
81
|
+
export type RecordAnswerResult = {
|
|
82
|
+
ok: true;
|
|
83
|
+
} | {
|
|
84
|
+
ok: false;
|
|
85
|
+
existing: AnswerRecord;
|
|
86
|
+
} | {
|
|
87
|
+
ok: false;
|
|
88
|
+
unauthorized: true;
|
|
89
|
+
reason: string;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Atomically claim the first answer for a block. Returns `{ ok: true }` when
|
|
93
|
+
* this call is the first to answer; returns `{ ok: false, existing }` when a
|
|
94
|
+
* different surface already answered the block. The marker file is created
|
|
95
|
+
* with `O_EXCL` so two concurrent claimers cannot both succeed.
|
|
96
|
+
*
|
|
97
|
+
* High-consequence blocks require a verified operator identity. Unverified
|
|
98
|
+
* answers (no operatorId or not in the registry/allowed list) are refused.
|
|
99
|
+
*/
|
|
100
|
+
export declare function recordAnswer(blockId: string, answer: {
|
|
101
|
+
answeredBy?: string;
|
|
102
|
+
answeredFrom: string;
|
|
103
|
+
operatorId?: string;
|
|
104
|
+
verified?: boolean;
|
|
105
|
+
}, root?: string): RecordAnswerResult;
|
|
106
|
+
/** Read the answer record for a block, if one exists. */
|
|
107
|
+
export declare function getAnswerRecord(blockId: string, root?: string): AnswerRecord | undefined;
|
|
108
|
+
/** True when the block has already been answered. */
|
|
109
|
+
export declare function isBlockAnswered(blockId: string, root?: string): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Record a delivery-receipt transition for a message tied to a block.
|
|
112
|
+
* Updates the receipts list in the block file. Status is monotonic
|
|
113
|
+
* (queued → consumed → continued): a late `queued` write cannot overwrite
|
|
114
|
+
* an already-recorded `consumed`/`continued` (race with mailbox drain).
|
|
115
|
+
*/
|
|
116
|
+
export declare function recordMessageReceipt(blockId: string, receipt: MessageReceipt, root?: string): void;
|
|
117
|
+
/** Read the receipt list for a block. */
|
|
118
|
+
export declare function getBlockReceipts(blockId: string, root?: string): MessageReceipt[];
|
|
119
|
+
/** Mark a block as "continued" -- the agent consumed the answer and moved on. */
|
|
120
|
+
export declare function recordContinued(blockId: string, root?: string): void;
|
|
121
|
+
/** Mark a decision-class block as hard-parked (no safe default existed). */
|
|
122
|
+
export declare function recordParked(blockId: string, root?: string): void;
|
|
123
|
+
/** Mark that the approval safe-default was applied by policy. */
|
|
124
|
+
export declare function recordDefaulted(blockId: string, root?: string): void;
|
|
125
|
+
/** Mark that an urgent block was paged to the phone. */
|
|
126
|
+
export declare function recordNotified(blockId: string, root?: string): void;
|
|
127
|
+
/** Convenience: record that a terminal answer closed the block. */
|
|
128
|
+
export declare function recordTerminalAnswer(blockId: string, root?: string): void;
|
|
129
|
+
/** Remove answered marker and receipts for a block (used by block removal/GC). */
|
|
130
|
+
export declare function clearBlockLifecycle(blockId: string, root?: string): void;
|
|
131
|
+
/** Atomic write a block record to the feed store. Clears stale lifecycle state. */
|
|
28
132
|
export declare function publishBlock(block: OpenBlock, root?: string): void;
|
|
29
133
|
/** Read all block records. Returns them sorted by stable block filename. */
|
|
30
134
|
export declare function listBlocks(root?: string): OpenBlock[];
|
|
31
|
-
/** Remove a block record. Returns true if the file was deleted. */
|
|
135
|
+
/** Remove a block record and its lifecycle sidecars. Returns true if the file was deleted. */
|
|
32
136
|
export declare function removeBlock(blockId: string, root?: string): boolean;
|
|
33
137
|
/**
|
|
34
138
|
* The feed-publish PreToolUse hook script (Python, mirroring 09-mailbox-inject.py).
|
|
35
139
|
* Embedded so it ships with the compiled CLI and can be installed to the
|
|
36
140
|
* CLI-writable user hooks dir without a separate file in the npm tarball.
|
|
37
141
|
*/
|
|
38
|
-
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"
|
|
142
|
+
export declare const FEED_PUBLISH_HOOK_SCRIPT = "#!/usr/bin/env python3\n\"\"\"Publish and clear open-block records for `agents feed`.\n\nThe manifest invokes this script for top-level AskUserQuestion calls, waiting\nnotifications, question answers, and session lifecycle events. One atomic file\nper session means a new block replaces the previous block. Answer/resume/stop\nevents remove it so `agents feed` only lists decisions that are still open.\n\nSub-agent gate: when the PreToolUse payload carries `agent_type`, this is a\nTask/Agent subagent -- skip. Only the top-level agent publishes. Verified on\nClaude Code 2.1.170 (2026-07).\n\nFail-open: ANY error is swallowed so a feed hiccup never blocks a tool call.\n\"\"\"\nimport os\nimport sys\nimport json\nimport re\nimport socket\nimport tempfile\nfrom datetime import datetime, timezone\n\nWAITING_NOTIFICATION_TYPES = {\n \"permission_prompt\",\n \"idle_prompt\",\n \"elicitation_dialog\",\n}\nCLEAR_EVENTS = {\n \"PostToolUse\",\n \"Stop\",\n \"SessionEnd\",\n}\n\n\ndef read_json(path):\n try:\n with open(path) as f:\n return json.load(f)\n except Exception:\n return None\n\n\ndef write_json(path, value):\n dir_name = os.path.dirname(path)\n os.makedirs(dir_name, exist_ok=True)\n fd, tmp = tempfile.mkstemp(dir=dir_name, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(value, f, indent=2)\n os.replace(tmp, path)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\ndef main():\n raw = sys.stdin.read()\n try:\n payload = json.loads(raw) if raw.strip() else {}\n except Exception:\n return\n\n # Sub-agent gate.\n if payload.get(\"agent_type\"):\n return\n\n session_id = payload.get(\"session_id\", \"\")\n if not session_id:\n return\n\n safe_session_id = re.sub(r\"[^A-Za-z0-9._-]\", \"-\", session_id)\n block_id = f\"block-{safe_session_id}\"\n home = os.environ.get(\"HOME\") or os.path.expanduser(\"~\")\n feed_dir = os.path.join(home, \".agents\", \".history\", \"feed\")\n answered_dir = os.path.join(feed_dir, \"answered\")\n target = os.path.join(feed_dir, f\"{block_id}.json\")\n hook_event = payload.get(\"hook_event_name\", \"PreToolUse\")\n\n if hook_event in CLEAR_EVENTS:\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n # Also clear the answered marker so a future question for this session\n # is not permanently locked.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n # Terminal answers (human typed in the TUI) record an answered marker and\n # remove the block file so the feed stops showing it within one poll cycle.\n # The marker stays behind so a concurrent surface cannot double-answer.\n if hook_event == \"UserPromptSubmit\":\n os.makedirs(answered_dir, exist_ok=True)\n marker = os.path.join(answered_dir, f\"{block_id}.json\")\n try:\n fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)\n record = {\n \"answeredAt\": datetime.now(timezone.utc).isoformat(),\n \"answeredFrom\": \"terminal\",\n }\n with os.fdopen(fd, \"w\") as f:\n json.dump(record, f, indent=2)\n except FileExistsError:\n pass\n except Exception:\n pass\n # Remove the visible block so the feed drops the answered question.\n try:\n os.unlink(target)\n except FileNotFoundError:\n pass\n except Exception:\n pass\n return\n\n notification_type = None\n if hook_event == \"Notification\":\n notification_type = payload.get(\"notification_type\", \"\")\n if notification_type not in WAITING_NOTIFICATION_TYPES:\n return\n # Claude emits a generic permission notification after presenting an\n # AskUserQuestion. Keep the structured questions and options already\n # published for this session instead of replacing them with that less\n # useful notification text.\n try:\n with open(target) as existing_file:\n existing = json.load(existing_file)\n if existing.get(\"kind\") == \"question\":\n return\n except Exception:\n pass\n message = payload.get(\"message\", \"\")\n if not message:\n return\n normalized_questions = [{\n \"text\": message,\n \"header\": payload.get(\"title\") or notification_type.replace(\"_\", \" \").title(),\n \"multiSelect\": False,\n }]\n kind = \"notification\"\n else:\n tool_input = payload.get(\"tool_input\", {})\n questions = tool_input.get(\"questions\", [])\n if not questions:\n return\n normalized_questions = []\n for q in questions:\n if not isinstance(q, dict):\n continue\n question = {\n \"text\": q.get(\"question\", q.get(\"header\", \"\")),\n \"header\": q.get(\"header\"),\n \"multiSelect\": q.get(\"multiSelect\", False),\n }\n raw_opts = q.get(\"options\", [])\n if raw_opts:\n question[\"options\"] = [\n {\"label\": o.get(\"label\", \"\"), \"description\": o.get(\"description\")}\n for o in raw_opts\n if isinstance(o, dict)\n ]\n normalized_questions.append(question)\n if not normalized_questions:\n return\n kind = \"question\"\n\n # Identity from env (set by agents-cli at spawn).\n mailbox_id = os.path.basename(\n os.environ.get(\"AGENTS_MAILBOX_DIR\", \"\").rstrip(\"/\")\n ) or session_id\n\n hostname = os.environ.get(\"AGENTS_SYNC_MACHINE_ID\") or socket.gethostname()\n host = hostname.split(\".\")[0].strip().lower()\n host = re.sub(r\"[^a-z0-9_-]\", \"-\", host) or \"unknown\"\n\n runtime = os.environ.get(\"AGENTS_RUNTIME\", \"headless\")\n\n block = {\n \"blockId\": block_id,\n \"sessionId\": session_id,\n \"mailboxId\": mailbox_id,\n \"host\": host,\n \"runtime\": runtime,\n \"ts\": datetime.now(timezone.utc).isoformat(),\n \"questions\": normalized_questions,\n \"kind\": kind,\n }\n if notification_type:\n block[\"notificationType\"] = notification_type\n\n # Optional multi-operator control metadata passed by the agent in the\n # AskUserQuestion tool_input. Defaults keep the existing behavior.\n controls = payload.get(\"tool_input\", {}) if hook_event != \"Notification\" else {}\n block_class = controls.get(\"blockClass\") if isinstance(controls, dict) else None\n if block_class in (\"approval\", \"decision\"):\n block[\"blockClass\"] = block_class\n consequence = controls.get(\"consequence\") if isinstance(controls, dict) else None\n if consequence:\n block[\"consequence\"] = consequence\n allowed = controls.get(\"allowedOperators\") if isinstance(controls, dict) else None\n if isinstance(allowed, list):\n block[\"allowedOperators\"] = [str(a) for a in allowed]\n timeout = controls.get(\"timeoutMinutes\") if isinstance(controls, dict) else None\n if isinstance(timeout, (int, float)) and timeout > 0:\n block[\"timeoutMinutes\"] = int(timeout)\n safe_default = controls.get(\"safeDefault\") if isinstance(controls, dict) else None\n if isinstance(safe_default, str):\n block[\"safeDefault\"] = safe_default\n cost = controls.get(\"costOfDelay\") if isinstance(controls, dict) else None\n if cost in (\"low\", \"medium\", \"high\"):\n block[\"costOfDelay\"] = cost\n\n # Publishing a new question clears any stale answered marker from the\n # previous question in this session.\n try:\n os.unlink(os.path.join(answered_dir, f\"{block_id}.json\"))\n except FileNotFoundError:\n pass\n except Exception:\n pass\n\n # Python's expanduser() ignores HOME on Windows, while agents-cli honors a\n # HOME override on every platform. Use the same anchor so hooks and the CLI\n # always read/write one feed store (including temp-home and sandbox runs).\n os.makedirs(feed_dir, exist_ok=True)\n\n fd, tmp = tempfile.mkstemp(dir=feed_dir, suffix=\".tmp\")\n try:\n with os.fdopen(fd, \"w\") as f:\n json.dump(block, f, indent=2)\n os.replace(tmp, target)\n except Exception:\n try:\n os.unlink(tmp)\n except Exception:\n pass\n\n\nif __name__ == \"__main__\":\n try:\n main()\n except Exception:\n pass # fail open\n";
|
|
39
143
|
/** Manifest entry for the feed-publish hook, matching the ManifestHook shape. */
|
|
40
144
|
export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
41
145
|
name: string;
|
|
@@ -44,6 +148,26 @@ export declare const FEED_PUBLISH_HOOK_MANIFEST: {
|
|
|
44
148
|
script: string;
|
|
45
149
|
timeout: number;
|
|
46
150
|
};
|
|
151
|
+
export declare const FEED_NOTIFICATION_HOOK_MANIFEST: {
|
|
152
|
+
name: string;
|
|
153
|
+
events: string[];
|
|
154
|
+
matcher: string;
|
|
155
|
+
script: string;
|
|
156
|
+
timeout: number;
|
|
157
|
+
};
|
|
158
|
+
export declare const FEED_ANSWERED_HOOK_MANIFEST: {
|
|
159
|
+
name: string;
|
|
160
|
+
events: string[];
|
|
161
|
+
matcher: string;
|
|
162
|
+
script: string;
|
|
163
|
+
timeout: number;
|
|
164
|
+
};
|
|
165
|
+
export declare const FEED_LIFECYCLE_HOOK_MANIFEST: {
|
|
166
|
+
name: string;
|
|
167
|
+
events: string[];
|
|
168
|
+
script: string;
|
|
169
|
+
timeout: number;
|
|
170
|
+
};
|
|
47
171
|
/**
|
|
48
172
|
* Install the feed-publish hook script into the user hooks dir and add its
|
|
49
173
|
* manifest entry to the user agents.yaml. The system repo is an auto-pulled,
|