@phnx-labs/agents-cli 1.20.51 → 1.20.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +215 -7
  5. package/dist/commands/cloud.d.ts +3 -0
  6. package/dist/commands/cloud.js +8 -1
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +82 -3
  10. package/dist/commands/factory.js +8 -0
  11. package/dist/commands/feed.d.ts +38 -0
  12. package/dist/commands/feed.js +274 -0
  13. package/dist/commands/logs.d.ts +5 -1
  14. package/dist/commands/logs.js +248 -3
  15. package/dist/commands/mcp.js +7 -0
  16. package/dist/commands/memory.d.ts +9 -0
  17. package/dist/commands/memory.js +164 -0
  18. package/dist/commands/message.d.ts +11 -6
  19. package/dist/commands/message.js +140 -5
  20. package/dist/commands/routines.js +12 -0
  21. package/dist/commands/secrets-migrate.d.ts +2 -1
  22. package/dist/commands/secrets-migrate.js +88 -13
  23. package/dist/commands/secrets.d.ts +22 -0
  24. package/dist/commands/secrets.js +176 -42
  25. package/dist/commands/sessions.js +10 -1
  26. package/dist/commands/teams.js +4 -0
  27. package/dist/commands/worktree.js +4 -2
  28. package/dist/index.js +21 -22
  29. package/dist/lib/agents.js +249 -17
  30. package/dist/lib/answer-router.d.ts +75 -0
  31. package/dist/lib/answer-router.js +149 -0
  32. package/dist/lib/ask-classifier.d.ts +71 -0
  33. package/dist/lib/ask-classifier.js +197 -0
  34. package/dist/lib/browser/login-detection.d.ts +94 -0
  35. package/dist/lib/browser/login-detection.js +274 -0
  36. package/dist/lib/browser/profiles.d.ts +17 -8
  37. package/dist/lib/browser/profiles.js +27 -8
  38. package/dist/lib/browser/secret-ref.d.ts +10 -0
  39. package/dist/lib/browser/secret-ref.js +14 -0
  40. package/dist/lib/browser/service.js +14 -12
  41. package/dist/lib/cloud/antigravity.d.ts +0 -2
  42. package/dist/lib/cloud/antigravity.js +2 -17
  43. package/dist/lib/cloud/codex.js +3 -18
  44. package/dist/lib/cloud/rush.d.ts +15 -0
  45. package/dist/lib/cloud/rush.js +10 -16
  46. package/dist/lib/cloud/stream.js +2 -0
  47. package/dist/lib/cloud/types.d.ts +21 -0
  48. package/dist/lib/cloud/types.js +81 -0
  49. package/dist/lib/crabbox/cli.d.ts +1 -1
  50. package/dist/lib/crabbox/cli.js +12 -2
  51. package/dist/lib/crabbox/lease.d.ts +19 -0
  52. package/dist/lib/crabbox/lease.js +22 -11
  53. package/dist/lib/crabbox/progress.d.ts +62 -0
  54. package/dist/lib/crabbox/progress.js +129 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  56. package/dist/lib/crabbox/runtimes.js +98 -5
  57. package/dist/lib/daemon.d.ts +12 -9
  58. package/dist/lib/daemon.js +32 -17
  59. package/dist/lib/events.d.ts +31 -5
  60. package/dist/lib/events.js +291 -101
  61. package/dist/lib/exec.js +20 -1
  62. package/dist/lib/feed-outcome.d.ts +101 -0
  63. package/dist/lib/feed-outcome.js +244 -0
  64. package/dist/lib/feed-policy.d.ts +30 -0
  65. package/dist/lib/feed-policy.js +133 -0
  66. package/dist/lib/feed.d.ts +180 -0
  67. package/dist/lib/feed.js +627 -0
  68. package/dist/lib/git.d.ts +17 -1
  69. package/dist/lib/git.js +20 -1
  70. package/dist/lib/hooks.js +529 -14
  71. package/dist/lib/hosts/passthrough.d.ts +3 -3
  72. package/dist/lib/hosts/passthrough.js +3 -3
  73. package/dist/lib/mailbox-gc.d.ts +22 -0
  74. package/dist/lib/mailbox-gc.js +161 -0
  75. package/dist/lib/mailbox.d.ts +26 -2
  76. package/dist/lib/mailbox.js +80 -5
  77. package/dist/lib/mcp.js +82 -0
  78. package/dist/lib/memory.d.ts +55 -0
  79. package/dist/lib/memory.js +274 -0
  80. package/dist/lib/notify.d.ts +16 -0
  81. package/dist/lib/notify.js +61 -0
  82. package/dist/lib/operator.d.ts +26 -0
  83. package/dist/lib/operator.js +107 -0
  84. package/dist/lib/plugins.d.ts +35 -0
  85. package/dist/lib/plugins.js +217 -0
  86. package/dist/lib/remote-agents-json.d.ts +14 -0
  87. package/dist/lib/remote-agents-json.js +94 -0
  88. package/dist/lib/resources/mcp.js +44 -0
  89. package/dist/lib/resources/memory.d.ts +15 -0
  90. package/dist/lib/resources/memory.js +46 -0
  91. package/dist/lib/resources/types.d.ts +2 -2
  92. package/dist/lib/rotate.js +2 -0
  93. package/dist/lib/runner.d.ts +43 -0
  94. package/dist/lib/runner.js +323 -74
  95. package/dist/lib/sandbox.js +6 -0
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.d.ts +21 -0
  99. package/dist/lib/secrets/agent.js +63 -1
  100. package/dist/lib/secrets/bundles.d.ts +33 -1
  101. package/dist/lib/secrets/bundles.js +76 -21
  102. package/dist/lib/secrets/icloud-import.d.ts +79 -0
  103. package/dist/lib/secrets/icloud-import.js +203 -0
  104. package/dist/lib/secrets/index.d.ts +142 -2
  105. package/dist/lib/secrets/index.js +695 -30
  106. package/dist/lib/secrets/remote.js +1 -1
  107. package/dist/lib/secrets/sync.js +1 -1
  108. package/dist/lib/session/active.d.ts +18 -0
  109. package/dist/lib/session/active.js +47 -17
  110. package/dist/lib/session/db.d.ts +9 -1
  111. package/dist/lib/session/db.js +18 -3
  112. package/dist/lib/session/discover.d.ts +13 -0
  113. package/dist/lib/session/discover.js +32 -2
  114. package/dist/lib/session/parse.d.ts +8 -0
  115. package/dist/lib/session/parse.js +42 -21
  116. package/dist/lib/session/remote-active.js +8 -89
  117. package/dist/lib/session/state.d.ts +11 -0
  118. package/dist/lib/session/state.js +50 -1
  119. package/dist/lib/session/tail.d.ts +23 -4
  120. package/dist/lib/session/tail.js +34 -16
  121. package/dist/lib/session/throughput.d.ts +30 -0
  122. package/dist/lib/session/throughput.js +86 -0
  123. package/dist/lib/shim-heal.d.ts +12 -3
  124. package/dist/lib/shim-heal.js +12 -6
  125. package/dist/lib/staleness/detectors/subagents.js +57 -3
  126. package/dist/lib/staleness/writers/hooks.js +7 -3
  127. package/dist/lib/staleness/writers/subagents.js +37 -6
  128. package/dist/lib/startup/command-registry.d.ts +2 -0
  129. package/dist/lib/startup/command-registry.js +4 -0
  130. package/dist/lib/state.d.ts +2 -0
  131. package/dist/lib/state.js +25 -8
  132. package/dist/lib/subagents.d.ts +52 -0
  133. package/dist/lib/subagents.js +315 -12
  134. package/dist/lib/teams/agents.js +6 -3
  135. package/dist/lib/teams/worktree.d.ts +8 -0
  136. package/dist/lib/teams/worktree.js +8 -0
  137. package/dist/lib/types.d.ts +20 -2
  138. package/dist/lib/versions.js +38 -48
  139. package/dist/lib/whats-new.d.ts +5 -3
  140. package/dist/lib/whats-new.js +25 -5
  141. package/package.json +4 -3
  142. package/scripts/postinstall.js +61 -1
@@ -0,0 +1,101 @@
1
+ import type { OpenBlock } from './feed.js';
2
+ export type OutcomeKind = 'ticket' | 'pr' | 'worktree' | 'unassigned';
3
+ /** Stable attribution of a block (or agent) to one deliverable. */
4
+ export interface OutcomeRef {
5
+ /** Map/JSON key, e.g. `ticket:RUSH-1125`, `pr:#534`, `worktree:headless-secrets`, `unassigned`. */
6
+ key: string;
7
+ kind: OutcomeKind;
8
+ /** Human header label, e.g. `RUSH-1125`, `PR#534`, `headless-secrets`, `Unassigned`. */
9
+ label: string;
10
+ }
11
+ /**
12
+ * Signals used to derive an outcome. All optional — callers pass whatever they
13
+ * already know (block fields, session meta, free-text questions).
14
+ */
15
+ export interface OutcomeSignals {
16
+ ticket?: string | null;
17
+ pr?: string | null;
18
+ worktreeSlug?: string | null;
19
+ /** Branch name — scanned for `rush-1125`-style ticket slugs. */
20
+ branch?: string | null;
21
+ /** Epic / team label when no ticket/PR/worktree is known. */
22
+ epic?: string | null;
23
+ /** Free text (question headers + bodies) scanned for ticket/PR refs. */
24
+ text?: string | null;
25
+ }
26
+ /**
27
+ * Normalize a PR ref.
28
+ * - Full GitHub URL → `owner/repo#N` (repo identity included)
29
+ * - Bare `#123` / `PR#123` → `#123` (no repo known)
30
+ */
31
+ export declare function normalizePrRef(raw: string | null | undefined): string | undefined;
32
+ /** `https://github.com/owner/repo/pull/N` → `owner/repo`. */
33
+ export declare function repoFromPrUrl(url: string | null | undefined): string | undefined;
34
+ /** Canonical ticket id (uppercase team key). */
35
+ export declare function normalizeTicketRef(raw: string | null | undefined): string | undefined;
36
+ /**
37
+ * Derive the single outcome a block/agent belongs to.
38
+ * Exactly one outcome per call — never ambiguous, never empty.
39
+ */
40
+ export declare function deriveOutcome(signals: OutcomeSignals): OutcomeRef;
41
+ /** Join question headers + bodies into one scan target for ticket/PR detection. */
42
+ export declare function blockScanText(block: Pick<OpenBlock, 'questions'>): string;
43
+ /** Outcome for a stored open block (uses stamped fields + question text). */
44
+ export declare function outcomeForBlock(block: OpenBlock): OutcomeRef;
45
+ /** Per-outcome rollup used by the default `agents feed` view. */
46
+ export interface OutcomeGroup {
47
+ outcome: OutcomeRef;
48
+ blocks: OpenBlock[];
49
+ counts: {
50
+ /** Distinct mailbox ids under this outcome. */
51
+ agents: number;
52
+ /** Still open: unanswered and not hard-parked. */
53
+ open: number;
54
+ answered: number;
55
+ parked: number;
56
+ };
57
+ }
58
+ /**
59
+ * Collapse blocks under their outcome. Order: needs-you outcomes first
60
+ * (open count desc), then by label. Unassigned always last among ties of 0 open.
61
+ */
62
+ export declare function groupBlocksByOutcome(blocks: OpenBlock[]): OutcomeGroup[];
63
+ /**
64
+ * Stamp each block with its derived outcome for JSON consumers.
65
+ * Does not mutate the input records.
66
+ */
67
+ export declare function stampBlockOutcomes(blocks: OpenBlock[]): Array<OpenBlock & {
68
+ outcome: OutcomeRef;
69
+ }>;
70
+ /**
71
+ * True when every still-open block under the outcome asks the same question
72
+ * (same cluster of header+text). Fan-out answers are only safe when this holds
73
+ * — otherwise the operator must pick a specific agent.
74
+ */
75
+ export declare function isUnambiguousOutcomeAnswer(group: OutcomeGroup): boolean;
76
+ /** Still-open blocks under an outcome (candidates for a fan-out reply). */
77
+ export declare function openBlocksForOutcome(group: OutcomeGroup): OpenBlock[];
78
+ /**
79
+ * Lightweight session signals used to fill missing ticket/PR/worktree on a
80
+ * block at list time (the publish hook may not have had them yet).
81
+ */
82
+ export interface SessionOutcomeHint {
83
+ sessionId?: string | null;
84
+ agentId?: string | null;
85
+ mailboxId?: string | null;
86
+ ticketId?: string | null;
87
+ prNumber?: number | null;
88
+ prUrl?: string | null;
89
+ worktreeSlug?: string | null;
90
+ branch?: string | null;
91
+ }
92
+ /**
93
+ * Overlay session meta onto a block when the block itself is missing ticket/PR/
94
+ * worktree. Never overwrites a field the block already carries.
95
+ */
96
+ export declare function enrichBlockFromSession(block: OpenBlock, hint: SessionOutcomeHint): OpenBlock;
97
+ /**
98
+ * Build a mailboxId → session-hint index and enrich every block. Pure.
99
+ * Matching order: mailboxId, then sessionId, then agentId.
100
+ */
101
+ export declare function enrichBlocksFromSessions(blocks: OpenBlock[], sessions: SessionOutcomeHint[]): OpenBlock[];
@@ -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
+ }