@anhlp/alp-runtime 0.0.0-beta.1

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 (94) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +7 -0
  3. package/README.md +7 -0
  4. package/dist/acp-transport.d.ts +114 -0
  5. package/dist/acp-transport.js +478 -0
  6. package/dist/antigravity-transport.d.ts +80 -0
  7. package/dist/antigravity-transport.js +609 -0
  8. package/dist/board-domain.d.ts +56 -0
  9. package/dist/board-domain.js +157 -0
  10. package/dist/board.d.ts +21 -0
  11. package/dist/board.js +69 -0
  12. package/dist/catalog.d.ts +31 -0
  13. package/dist/catalog.js +38 -0
  14. package/dist/claude-transport.d.ts +157 -0
  15. package/dist/claude-transport.js +671 -0
  16. package/dist/contracts.d.ts +163 -0
  17. package/dist/contracts.js +5 -0
  18. package/dist/events.d.ts +1 -0
  19. package/dist/events.js +0 -0
  20. package/dist/github.d.ts +10 -0
  21. package/dist/github.js +39 -0
  22. package/dist/handoff.d.ts +36 -0
  23. package/dist/handoff.js +73 -0
  24. package/dist/index.d.ts +24 -0
  25. package/dist/index.js +64 -0
  26. package/dist/language.d.ts +41 -0
  27. package/dist/language.js +68 -0
  28. package/dist/live.d.ts +48 -0
  29. package/dist/live.js +45 -0
  30. package/dist/mailbox.d.ts +24 -0
  31. package/dist/mailbox.js +36 -0
  32. package/dist/mcp-tools.d.ts +115 -0
  33. package/dist/mcp-tools.js +71 -0
  34. package/dist/permission-path.d.ts +2 -0
  35. package/dist/permission-path.js +44 -0
  36. package/dist/process-info.d.ts +14 -0
  37. package/dist/process-info.js +32 -0
  38. package/dist/providers.d.ts +28 -0
  39. package/dist/providers.js +71 -0
  40. package/dist/recall.d.ts +42 -0
  41. package/dist/recall.js +60 -0
  42. package/dist/resolve.d.ts +121 -0
  43. package/dist/resolve.js +238 -0
  44. package/dist/runtime-context.d.ts +43 -0
  45. package/dist/runtime-context.js +61 -0
  46. package/dist/runtime.d.ts +4 -0
  47. package/dist/runtime.js +2979 -0
  48. package/dist/session-config.d.ts +1973 -0
  49. package/dist/session-config.js +205 -0
  50. package/dist/session-state.d.ts +140 -0
  51. package/dist/session-state.js +0 -0
  52. package/dist/tool-results.d.ts +12 -0
  53. package/dist/tool-results.js +25 -0
  54. package/dist/tools.d.ts +674 -0
  55. package/dist/tools.js +375 -0
  56. package/dist/transport.d.ts +47 -0
  57. package/dist/transport.js +169 -0
  58. package/dist/usage.d.ts +15 -0
  59. package/dist/usage.js +35 -0
  60. package/dist/workspace.d.ts +101 -0
  61. package/dist/workspace.js +259 -0
  62. package/dist/worktree-domain.d.ts +88 -0
  63. package/dist/worktree-domain.js +196 -0
  64. package/package.json +256 -0
  65. package/src/acp-transport.ts +473 -0
  66. package/src/antigravity-transport.ts +658 -0
  67. package/src/board-domain.ts +185 -0
  68. package/src/board.ts +80 -0
  69. package/src/catalog.ts +32 -0
  70. package/src/claude-transport.ts +771 -0
  71. package/src/contracts.ts +166 -0
  72. package/src/events.ts +1 -0
  73. package/src/github.ts +37 -0
  74. package/src/handoff.ts +86 -0
  75. package/src/index.ts +26 -0
  76. package/src/language.ts +102 -0
  77. package/src/live.ts +86 -0
  78. package/src/mailbox.ts +46 -0
  79. package/src/mcp-tools.ts +73 -0
  80. package/src/permission-path.ts +37 -0
  81. package/src/process-info.ts +40 -0
  82. package/src/providers.ts +84 -0
  83. package/src/recall.ts +85 -0
  84. package/src/resolve.ts +230 -0
  85. package/src/runtime-context.ts +49 -0
  86. package/src/runtime.ts +3579 -0
  87. package/src/session-config.ts +301 -0
  88. package/src/session-state.ts +141 -0
  89. package/src/tool-results.ts +26 -0
  90. package/src/tools.ts +379 -0
  91. package/src/transport.ts +116 -0
  92. package/src/usage.ts +42 -0
  93. package/src/workspace.ts +318 -0
  94. package/src/worktree-domain.ts +216 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,3579 @@
1
+ import { constants as fsConstants } from 'node:fs';
2
+ import type { Session, Assignment, Waiter } from './session-state.js';
3
+ import { errorData, plainObject, toolResult } from './tool-results.js';
4
+ import { createLogger } from '@anhlp/alp-core/log';
5
+ import { createBoardDomain } from './board-domain.js';
6
+ import { createWorktreeDomain } from './worktree-domain.js';
7
+ import { DEFAULT_TEAM, type RuntimeTransport, type RuntimeOptions, type OpenOptions, type PromptContent, type PromptInput, type AlpRuntime, type SessionPreview, type RecoveryOutcome, type Pause, type PauseState, type RecallAnswer } from './contracts.js';
8
+ export type { RuntimeTransport, RuntimeOptions, OpenOptions, PromptContent, PromptInput, AlpRuntime, SessionPreview, RecoveryOutcome, Pause, PauseState, RecallAnswer } from './contracts.js';
9
+ export { DEFAULT_TEAM } from './contracts.js';
10
+ import { verdictLine, parseHandoff, type Handoff } from './handoff.js';
11
+ import { TASK_ACTIONS, TASK_PAST, TASK_FIELDS, mainOf, roleOf, taskActions, taskBrief, type TaskAction } from './tools.js';
12
+ import { nativeEnvironment, createTransport } from './providers.js';
13
+ import { nativeMode, codexApproval, profileEfforts, nativeSessionConfig } from './session-config.js';
14
+ import { createHash, randomUUID } from 'node:crypto';
15
+ import { readFileSync, realpathSync } from 'node:fs';
16
+ import { appendFile, mkdir, open, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises';
17
+ import { moveFile } from '@anhlp/alp-core/atomic';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { resolveDelegation } from '@anhlp/alp-core/delegation';
21
+ import { discoverAgents } from '@anhlp/alp-core/resolver';
22
+
23
+ import { acpDecision, acpWhat, type AcpPermission } from './acp-transport.js';
24
+
25
+ import { models, modes, thinkingOptionsFor, withinMode, writes } from './catalog.js';
26
+ import { runHook } from '@anhlp/alp-core/hook-run';
27
+ import { hookMatches } from '@anhlp/alp-core/hooks';
28
+ import { isTrusted, trustProject } from '@anhlp/alp-core/trust';
29
+ import { listTeams } from '@anhlp/alp-core/teams';
30
+ import { anyModel, offers, profileModel, selectedProfile } from '@anhlp/alp-core/profiles';
31
+ import { LESSONS_FILE, READ_ONLY_AGENTS, resolveSession, type ResolvedSession, type RuntimeKind, type SessionSpec } from './resolve.js';
32
+ import { MAIL_BODY_CHARS, publicEvent, renderMail, takeBatch, USER, type MailEvent } from './mailbox.js';
33
+ import type { AlpEvent, AssignmentSnapshot, Envelope, SessionSnapshot, TurnOrigin, UserQuestion } from './events.js';
34
+ import { live, normalizePaths, overlapping, renderBoard, type Pin } from './board.js';
35
+ import { verifyConfig } from '@anhlp/alp-core/verify';
36
+ import { createLiveBook, type LiveEntry } from './live.js';
37
+ import { promptSafe } from '@anhlp/alp-core/promptsafe';
38
+ import { CHOICES, words, type Words } from './language.js';
39
+ import { OWN_START, sameProcessAlive } from './process-info.js';
40
+ import { createRecallBook, recallPrompt, RECALL_KEEP_MS, RECALL_QUESTION_CHARS, RECALL_TIMEOUT_MS, type RecallEntry } from './recall.js';
41
+ import { branchExists, checkoutFingerprint, checkoutKey, createCopy, createWorktree, reattachWorktree, removeCopy, removeWorktree } from './workspace.js';
42
+ import { claudeSandboxAvailable, SKILL_PLUGIN } from './claude-transport.js';
43
+ import { ALP_REPO, gh, projectRepo } from './github.js';
44
+ import { optionalRead, usageAllows, COMPACT_SHARE } from './runtime-context.js';
45
+ import { providerUsage, type ProviderUsage } from './usage.js';
46
+ import { parse as toml } from 'smol-toml';
47
+ import { findFormula, formulaDirs, listFormulas, pourFormula } from '@anhlp/alp-core/formulas';
48
+ import { addAllowRule, capMode, commandDecision, profileFor, unwrapShell } from '@anhlp/alp-core/permissions';
49
+ import { addGate, checkGates, resolveGate, blockersOf, childrenOf, closeTask, epicReport, releaseOrphans, createTask, getTask, linkTask, listTasks, loadTasks, readyTasks, releaseTask, reopenTask, retakeTask, startRefusal, startTask, submitTask, summarize, taskDigest, updateTask, type Task } from '@anhlp/alp-core/tasks';
50
+
51
+ const MAX_WAKES = 8;
52
+ /** How long the user may wait for main's first words before its tool results remind it. */
53
+ const USER_REPLY_MS = 60_000;
54
+
55
+ /** What a waiting session hears when the user wrote to it. */
56
+ const USER_WROTE = 'The user just wrote to you; their message follows. Answer them first in a short message they can read (tool calls alone show them nothing), and steer an assignment with alp_send when their words change its brief. Your assignments keep running: then alp_wait again, or end your turn and ALP wakes you with their results.';
57
+ /** What a waiting session hears with a check-in, or mail from its requester or the user. */
58
+ const CHECKED_IN = 'Not a result: the assignment keeps running. Act on this mail first (a steer from your requester or the user\'s words may change what your assignments should do: pass that on with alp_send), then alp_wait again or end your turn.';
59
+ /** A session's native process is restarted at most RESTART_LIMIT times within RESTART_WINDOW_MS unless it makes progress meanwhile. */
60
+ const RESTART_LIMIT = 3;
61
+ const RESTART_WINDOW_MS = 10 * 60_000;
62
+ /** How often alpd asks a runtime a usage limit paused whether the limit lifted (ALPD §58). */
63
+ const LIMIT_CHECK_MS = 60_000;
64
+ /** A check this recent answers a delegation without asking the runtime again. */
65
+ const LIMIT_FRESH_MS = 15_000;
66
+ /**
67
+ * The context advisory (ALPD §57): the runtime compacts a full context and keeps going, so
68
+ * ALP says nothing until a session's fill reaches CONTEXT_SOON of the point where its
69
+ * runtime compacts, and then once. Below CONTEXT_RESET of it, as after a compaction, it may
70
+ * come again. Where the runtime does not say that point, it is COMPACT_SHARE of the window.
71
+ */
72
+ const CONTEXT_SOON = 0.9;
73
+ const CONTEXT_RESET = 0.5;
74
+
75
+ /** How much of an assignment's brief ALP repeats after a compaction. */
76
+ const BRIEF_CHARS = 8000;
77
+ // Placeholder delivery marks while a steer or a woken turn is starting.
78
+ const STEERING = '\u0000steering';
79
+ const STARTING = '\u0000starting';
80
+
81
+ /** Answers that approve a proposal; anything else is feedback. */
82
+ const APPROVALS = ['approve', 'approved', 'yes', 'y', 'ok', 'đồng ý', 'duyệt', 'có'];
83
+ const SKILL_BODY_CHARS = 20_000;
84
+ const STARTER_ROLES = ['main', 'lead', 'peer', 'oracle', 'reviewer', 'supervisor'];
85
+ const ISSUE_BODY_CHARS = 20_000;
86
+ const ISSUE_FOOTER = '\n\n---\n_Drafted by an ALP agent and posted with the user\'s approval._';
87
+
88
+ const LESSON_CHARS = 600;
89
+ const DIGEST_CHARS = 12_000;
90
+ const JOURNAL_LINE_CHARS = 400;
91
+ /** Journal lines carry the local time, so the supervisor sees how long the user waited. */
92
+ const clock = (at = Date.now()) => new Date(at).toTimeString().slice(0, 8);
93
+ const stamp = (line: string, at = Date.now()) => `[${clock(at)}] ${line}`;
94
+ const unstamped = (line: string) => line.replace(/^\[\d\d:\d\d:\d\d\] /, '');
95
+ const span = (ms: number) => ms < 90_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : `${Math.round(ms / 60_000)} min`;
96
+ /** A token count as people say it: 164k, 1M. */
97
+ const thousands = (tokens: number) => tokens >= 1_000_000 && tokens % 1_000_000 < 50_000 ? `${Math.round(tokens / 1_000_000)}M` : `${Math.round(tokens / 1000)}k`;
98
+
99
+ /** Cuts text to `limit` characters, keeping its lines. */
100
+ const cut = (text: string, limit: number) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
101
+ const clip = (text: string, limit = JOURNAL_LINE_CHARS) => {
102
+ const flat = text.replace(/\s+/g, ' ').trim();
103
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
104
+ };
105
+
106
+ /**
107
+ * The runtime speaks a normalized protocol to its transport.
108
+ *
109
+ * CodexTransport, ClaudeTransport and AcpTransport map it to each native harness.
110
+ *
111
+ * The runtime therefore owns orchestration only; the transport adapters
112
+ * preserve the full native harness underneath.
113
+ */
114
+ const isolationOf = (args: { isolation?: string }) => args.isolation ?? 'shared';
115
+
116
+ const log = createLogger({ component: 'runtime' });
117
+
118
+ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
119
+ const sessions = new Map<string, Session>();
120
+ /** What ALP writes for the user of a session, in their language (ALPD §54). */
121
+ const wordsFor = (session: Session) => words(session.mapping.language);
122
+
123
+ const childContexts = new Map<
124
+ string,
125
+ {
126
+ parent: string;
127
+ callId: string;
128
+ graph: Record<string, string[]>;
129
+ workflow: ResolvedSession['workflow'];
130
+ ancestry: string[];
131
+ role?: 'supervisor';
132
+ /** Reopened after alpd restarted: its requester may be idle. */
133
+ recovered?: boolean;
134
+ }
135
+ >();
136
+
137
+ const listeners = new Set<(envelope: Envelope) => void>();
138
+ const epoch = randomUUID();
139
+ /** Finished assignments whose native threads alp_recall can question. */
140
+ const recalls = createRecallBook(options.recallFile);
141
+ /** Running assignments, so the next alpd continues them (ALPD §31). */
142
+ const inFlight = createLiveBook(options.liveFile);
143
+ /** Those an earlier alpd left running; recover() takes them once their root is open again. */
144
+ let inherited = inFlight.all().filter(entry => entry.epoch !== epoch);
145
+ /** Pauses by runtime, or of everything; kept in pauseFile across restarts. */
146
+ let paused: { all?: Pause; runtimes: Partial<Record<RuntimeKind, Pause>> } = { runtimes: {} };
147
+ try {
148
+ const stored = options.pauseFile ? JSON.parse(readFileSync(options.pauseFile, 'utf8')) : undefined;
149
+ if (stored && typeof stored === 'object') paused = { ...(stored.all ? { all: stored.all } : {}), runtimes: stored.runtimes && typeof stored.runtimes === 'object' ? stored.runtimes : {} };
150
+ } catch {}
151
+ let pauseWrites = Promise.resolve();
152
+ /** The latest usage report of each runtime, and the warnings already given, by runtime and reset time. */
153
+ const usage = new Map<RuntimeKind, any>();
154
+ const warned = new Set<string>();
155
+ /** The check of each runtime's limit running now, and when the last one ended (ALPD §58). */
156
+ const limitChecks = new Map<RuntimeKind, { at: number; running?: Promise<void> }>();
157
+ let limitWatch: NodeJS.Timeout | undefined;
158
+ /** Limit pauses whose lifting the user was told of, when ALP does not resume by itself. */
159
+ const liftTold = new WeakSet<Pause>();
160
+ /** Write leases: one writing assignment per checkout across all trees, unless nested under the holder. */
161
+ const leases = new Map<string, { assignment: string; agent: string }>();
162
+ const worktreeRoot = options.worktreeDir ?? path.join(os.tmpdir(), 'alp-worktrees');
163
+ const copyRoot = options.copyDir ?? path.join(os.tmpdir(), 'alp-copies');
164
+ const sequences = new Map<string, number>();
165
+
166
+ let closed = false;
167
+ let queue = Promise.resolve();
168
+
169
+ const emit = (sessionId: string, event: AlpEvent) => {
170
+ const seq = (sequences.get(sessionId) ?? 0) + 1;
171
+ sequences.set(sessionId, seq);
172
+ const envelope: Envelope = { sessionId, epoch, seq, ts: new Date().toISOString(), event };
173
+ for (const listener of listeners) listener(envelope);
174
+ };
175
+
176
+ /** Public operations run one at a time, so a wake never races a client prompt or an interrupt. */
177
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
178
+ const run = queue.then(() => {
179
+ if (closed) throw new Error('Runtime is closed');
180
+ return work();
181
+ });
182
+ queue = run.then(() => {}, () => {});
183
+ return run;
184
+ }
185
+
186
+ function snapshot(sessionId: string, session: Session): SessionSnapshot {
187
+ return {
188
+ id: sessionId,
189
+ projectRoot: session.mapping.agent.projectRoot,
190
+ agent: session.mapping.agent.name,
191
+ runtime: session.runtimeKind,
192
+ model: session.mapping.model,
193
+ mode: session.mapping.mode,
194
+ thinking: session.mapping.thinking,
195
+ workflow: session.mapping.workflow,
196
+ ...(session.mapping.team ? { teamLabel: session.mapping.team.label } : {}),
197
+ threadId: session.threadId,
198
+ persistent: session.mapping.persist,
199
+ ...(session.parent ? { parentId: session.parent } : {}),
200
+ ...(session.toolCallId ? { toolCallId: session.toolCallId } : {}),
201
+ ...(session.active ? { activeTurnId: session.active } : {}),
202
+ ...(session.parked ? { parked: session.parked.reason } : {}),
203
+ ...(session.instructionsSha ? { instructionsSha: session.instructionsSha } : {}),
204
+ busy: !!(session.active || session.pending || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session)),
205
+ };
206
+ }
207
+
208
+ function terminal(
209
+ sessionId: string,
210
+ session: Session,
211
+ state: 'completed' | 'failed' | 'canceled',
212
+ error?: unknown,
213
+ ) {
214
+ if (!session.active) return;
215
+
216
+ emit(sessionId, {
217
+ type: 'turn.ended',
218
+ turnId: session.active,
219
+ state,
220
+ ...(error ? { error: errorData(error) } : {}),
221
+ });
222
+
223
+ const turnId = session.active;
224
+ session.active = undefined;
225
+ void runHooks(sessionId, session, 'turn.end', { turn: { id: turnId, state } });
226
+
227
+ // Tool calls of the ended turn stop waiting.
228
+ for (const waiter of session.waiters.splice(0)) {
229
+ clearTimeout(waiter.timer);
230
+ waiter.resolve(null);
231
+ }
232
+ cancelQuestions(sessionId);
233
+ if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: 'Turn ended' }));
234
+
235
+ // Mail is acknowledged only by a turn that completed; otherwise it is delivered again.
236
+ session.mail = session.mail.filter(event => !(event.deliveredTurn === turnId && state === 'completed'));
237
+ for (const event of session.mail) {
238
+ if (event.deliveredTurn === turnId) { event.deliveredTurn = undefined; event.redelivered = true; }
239
+ }
240
+
241
+ if (session.supervisor) {
242
+ const final = [...session.text.values()].at(-1);
243
+ if (final) session.journal.push(stamp(`${session.mapping.agent.name} final message: ${clip(final, 1500)}`));
244
+ if (session.userWaiting !== undefined && !final) session.journal.push(stamp(`the user's message of ${clock(session.userWaiting)} got no reply in this turn`));
245
+ session.journal.push(stamp(`turn ended: ${state}${error ? ` (${clip(errorData(error).message, 200)})` : ''}`));
246
+ // Answering the supervisor is not reviewed again, or the two would loop. A turn the user
247
+ // stopped is the user's choice, not main's mistake: a review would only send mail that an
248
+ // interrupted root never wakes for, and its tree would stay busy.
249
+ if (session.supervisorWake || (session.userStopped && state === 'canceled')) session.journal.length = 0;
250
+ else review(sessionId);
251
+ }
252
+ session.userStopped = false;
253
+ if (session.role === 'supervisor' && session.parent && sessions.get(session.parent)?.reviewPending) review(session.parent);
254
+ if (!session.parent) void showTasks(sessionId, session);
255
+
256
+ // An assignment a limit or a pause stopped waits, open, to continue instead of ending.
257
+ if (session.parkReason && state !== 'completed') {
258
+ park(sessionId, session);
259
+ return;
260
+ }
261
+ session.parkReason = undefined;
262
+
263
+ // A requester is not done while its assignments run or mail awaits it.
264
+ if (state === 'completed' && (session.assignments.size || hasActiveMail(session))) {
265
+ queueMicrotask(() => deliver(sessionId, session));
266
+ return;
267
+ }
268
+
269
+ session.settle?.(state, error);
270
+ }
271
+
272
+ function nativeItem(
273
+ sessionId: string,
274
+ session: Session,
275
+ item: any,
276
+ ) {
277
+ if (item.type === 'agentMessage') {
278
+ // The first words main shows after the user wrote: how long the user waited.
279
+ if (session.userWaiting !== undefined && String(item.text ?? '').trim()) {
280
+ if (session.supervisor) session.journal.push(stamp(`${session.mapping.agent.name} answered the user ${span(Date.now() - session.userWaiting)} after their message of ${clock(session.userWaiting)}`));
281
+ session.userWaiting = undefined;
282
+ }
283
+ session.text.set(item.id, item.text);
284
+ emit(sessionId, { type: 'item', item: { kind: 'assistant_message', id: item.id, text: item.text } });
285
+ return;
286
+ }
287
+
288
+ if (item.type === 'userMessage') {
289
+ const text = (item.content ?? [])
290
+ .filter((c: any) => c.type === 'text')
291
+ .map((c: any) => c.text)
292
+ .join('\n');
293
+ emit(sessionId, { type: 'item', item: { kind: 'user_message', id: item.id, text } });
294
+ return;
295
+ }
296
+
297
+ if (item.type === 'dynamicToolCall') {
298
+ const status =
299
+ item.status === 'inProgress'
300
+ ? 'running'
301
+ : item.success === false || item.status === 'failed'
302
+ ? 'failed'
303
+ : 'completed';
304
+ if (session.supervisor && status !== 'running' && item.tool !== 'alp_delegate') {
305
+ session.journal.push(stamp(`${session.mapping.agent.name} tool ${item.tool} ${status}: ${clip(JSON.stringify(item.arguments ?? {}))}`));
306
+ }
307
+
308
+ emit(sessionId, {
309
+ type: 'item',
310
+ item: {
311
+ kind: 'tool_call',
312
+ id: item.id,
313
+ callId: item.callId ?? item.id,
314
+ name: item.tool,
315
+ status,
316
+ ...(status === 'failed' ? { error: item.tool === 'alp_delegate' ? 'Delegation failed' : 'Tool call failed' } : {}),
317
+ detail: {
318
+ type: 'unknown',
319
+ input: item.arguments ?? {},
320
+ output: item.contentItems ?? null,
321
+ },
322
+ },
323
+ });
324
+ return;
325
+ }
326
+
327
+ if (item.type === 'commandExecution') {
328
+ const status =
329
+ item.status === 'inProgress'
330
+ ? 'running'
331
+ : item.status === 'completed'
332
+ ? 'completed'
333
+ : 'failed';
334
+ if (session.supervisor && status !== 'running') {
335
+ session.journal.push(stamp(`${session.mapping.agent.name} shell ${status}${item.exitCode != null ? ` (exit ${item.exitCode})` : ''}: ${clip(String(item.command ?? ''))}`));
336
+ }
337
+
338
+ emit(sessionId, {
339
+ type: 'item',
340
+ item: {
341
+ kind: 'tool_call',
342
+ id: item.id,
343
+ callId: item.id,
344
+ name: 'shell',
345
+ status,
346
+ ...(status === 'failed' ? { error: 'Command failed' } : {}),
347
+ detail: {
348
+ type: 'shell',
349
+ command: item.command,
350
+ cwd: item.cwd,
351
+ output: item.aggregatedOutput ?? '',
352
+ exitCode: item.exitCode,
353
+ },
354
+ },
355
+ });
356
+ }
357
+ }
358
+
359
+ function notification(
360
+ sessionId: string,
361
+ session: Session,
362
+ method: string,
363
+ params: any,
364
+ ) {
365
+ if (session.closed) return;
366
+
367
+ touch(sessionId, session);
368
+
369
+ if (session.pending) {
370
+ session.buffered.push([method, params]);
371
+ return;
372
+ }
373
+
374
+ if (params?.threadId && params.threadId !== session.threadId) return;
375
+
376
+ if (method === 'item/agentMessage/delta') {
377
+ const text =
378
+ (session.text.get(params.itemId) ?? '') + params.delta;
379
+
380
+ session.text.set(params.itemId, text);
381
+ emit(sessionId, { type: 'item', item: { kind: 'assistant_message', id: params.itemId, text } });
382
+ return;
383
+ }
384
+
385
+ if ((method === 'item/completed' || method === 'item/started') && params.item?.type === 'contextCompaction') {
386
+ compaction(sessionId, session, params.item, method === 'item/completed');
387
+ return;
388
+ }
389
+
390
+ if (method === 'item/completed' || method === 'item/started') {
391
+ if (method === 'item/completed') session.progressAt = Date.now();
392
+ if (params.item?.type !== 'userMessage') {
393
+ nativeItem(sessionId, session, params.item);
394
+ }
395
+ if (method === 'item/started' && params.item?.type === 'commandExecution') watchCopy(sessionId, session, params.item);
396
+ if (method === 'item/started' && params.item) skillUse(sessionId, session, params.item);
397
+ return;
398
+ }
399
+
400
+ if (method === 'account/rateLimits/updated') {
401
+ usageReport(session, params);
402
+ return;
403
+ }
404
+
405
+ if (method === 'thread/tokenUsage/updated') {
406
+ contextReport(sessionId, session, params?.tokenUsage);
407
+ return;
408
+ }
409
+
410
+ // A turn the native runtime started by itself (ALPD §56): ALP follows it as its own, so
411
+ // its tools work and its end wakes, reviews and settles as any turn's does.
412
+ if (method === 'turn/started') {
413
+ if (!session.active && params.turn?.id) {
414
+ session.active = params.turn.id;
415
+ emit(sessionId, { type: 'turn.started', turnId: params.turn.id, origin: 'runtime' });
416
+ }
417
+ return;
418
+ }
419
+
420
+ if (
421
+ method === 'turn/completed' &&
422
+ params.turn.id === session.active
423
+ ) {
424
+ const state =
425
+ params.turn.status === 'completed'
426
+ ? 'completed'
427
+ : params.turn.status === 'interrupted'
428
+ ? 'canceled'
429
+ : 'failed';
430
+
431
+ // A usage limit pauses its runtime; an assignment it stopped is parked to continue later.
432
+ if (state === 'failed' && LIMIT_ERRORS.includes(params.turn.error?.codexErrorInfo)) {
433
+ if (session.parent && session.role !== 'supervisor') session.parkReason = `the ${label(session.runtimeKind)} usage limit was reached`;
434
+ void limitReached(session, params.turn.error);
435
+ }
436
+
437
+ terminal(
438
+ sessionId,
439
+ session,
440
+ state,
441
+ params.turn.error?.message,
442
+ );
443
+ }
444
+ }
445
+
446
+ async function closeSession(sessionId: string) {
447
+ const session = sessions.get(sessionId);
448
+ if (!session || session.closed) return;
449
+
450
+ session.closed = true;
451
+ cancelQuestions(sessionId);
452
+ releaseClaims(sessionId, session.mapping.agent.projectRoot);
453
+
454
+ await Promise.all(
455
+ [...session.children, ...(session.supervisor ? [session.supervisor] : [])].map(closeSession),
456
+ );
457
+
458
+ terminal(sessionId, session, 'canceled');
459
+ session.settle?.('canceled', 'Session closed');
460
+
461
+ await session.runtime.close();
462
+
463
+ // Copies of assignments that did not finish hold nothing to keep.
464
+ for (const assignment of session.assignments.values()) if (assignment.copy && !assignment.finished) await removeCopy(assignment.copy).catch(() => {});
465
+
466
+ for (const [assignmentId, { worktree }] of session.worktrees) {
467
+ await removeWorktree(worktree).catch(() => {});
468
+ runLog(rootOf(sessionId), { event: 'worktree.kept', assignmentId, branch: worktree.branch });
469
+ }
470
+ session.worktrees.clear();
471
+
472
+ sessions.delete(sessionId);
473
+
474
+ if (session.parent) {
475
+ const parent = sessions.get(session.parent);
476
+ parent?.children.delete(sessionId);
477
+ if (parent?.supervisor === sessionId) parent.supervisor = undefined;
478
+ }
479
+
480
+ // Kept: a session resumed in this runtime goes on from its last seq, so (epoch, seq) never repeats.
481
+ emit(sessionId, { type: 'session.closed' });
482
+ }
483
+
484
+ let runLogWrites = Promise.resolve();
485
+
486
+ /** A session's root, also for an assignment already closed. */
487
+ const rootFor = (sessionId: string, session: Session) => sessions.has(sessionId) ? rootOf(sessionId) : session.parent ? rootOf(session.parent) : sessionId;
488
+
489
+ /** Answers about a project's hooks, one question per root tree and project (ALPD §45). */
490
+ const trustAsks = new Map<string, Promise<boolean>>();
491
+
492
+ /**
493
+ * Whether a project's hooks may run: the user trusts the workspace once, and ALP
494
+ * records it in $ALP_HOME/state/trust.json; library hooks never ask.
495
+ */
496
+ async function hooksTrusted(sessionId: string, session: Session) {
497
+ const project = session.mapping.agent.projectRoot;
498
+ if (options.libraryDir && await isTrusted(options.libraryDir, project)) return true;
499
+ const key = `${rootFor(sessionId, session)}\0${project}`;
500
+ const pending = trustAsks.get(key);
501
+ if (pending) return pending;
502
+ const asked = (async () => {
503
+ const names = session.mapping.hooks.filter(hook => hook.project).map(hook => `${hook.name} (${hook.event}: ${clip(hook.command, 120)})`);
504
+ const say = wordsFor(session);
505
+ const assignment = session.parent ? sessions.get(session.parent)?.assignments.get(sessionId) : undefined;
506
+ const result = await askUser(sessionId, session, assignment, say.trust(project, names.join('; ')), [say.trustYes, say.trustNo]) as { contentItems: Array<{ text: string }> };
507
+ let value: any;
508
+ try { value = JSON.parse(result.contentItems[0].text); } catch { value = {}; }
509
+ const answer = String(value.answer ?? '').trim().toLowerCase();
510
+ const trusted = value.status === 'answered' && (CHOICES.trust.includes(answer) || APPROVALS.includes(answer));
511
+ if (trusted && options.libraryDir) await trustProject(options.libraryDir, project).catch(() => {});
512
+ runLog(rootFor(sessionId, session), { event: 'hook.trust', project, trusted, ...(value.status !== 'answered' ? { outcome: value.status ?? 'canceled' } : {}) });
513
+ // A question its turn's end cancelled is asked again at the next hook.
514
+ if (value.status !== 'answered' && value.status !== 'dismissed') trustAsks.delete(key);
515
+ return trusted;
516
+ })();
517
+ trustAsks.set(key, asked);
518
+ return asked;
519
+ }
520
+
521
+ /**
522
+ * Runs a session's hooks for an event (ALPD §45). Hooks that cannot block run in the
523
+ * background; blocking ones run in turn, and the first lines of a failed one's output
524
+ * come back as the reason the action is refused.
525
+ */
526
+ async function runHooks(sessionId: string, session: Session, event: string, details: Record<string, unknown> = {}, taskId?: string): Promise<string | undefined> {
527
+ const candidates = session.mapping.hooks.filter(hook => hook.event === event);
528
+ if (!candidates.length) return undefined;
529
+ const project = session.mapping.agent.projectRoot;
530
+ const agent = session.mapping.agent.name;
531
+ const labels: string[] = taskId && candidates.some(hook => hook.match?.label) ? (await getTask(project, taskId).catch(() => undefined))?.labels ?? [] : [];
532
+ const hooks = candidates.filter(hook => hookMatches(hook, { agent, labels }));
533
+ if (!hooks.length) return undefined;
534
+ const rootId = rootFor(sessionId, session);
535
+ const payload = { event, project, session: sessionId, agent, ...(session.parentAgent ? { parentAgent: session.parentAgent } : {}), team: session.mapping.workflow.mode, ...(taskId ? { task: taskId } : {}), ...details };
536
+ const env = { ALP_EVENT: event, ALP_SESSION: sessionId, ALP_AGENT: agent, ALP_TASK: taskId ?? '', ALP_PROJECT: project };
537
+ const one = async (hook: typeof hooks[number]) => {
538
+ if (hook.project && !(await hooksTrusted(sessionId, session))) {
539
+ runLog(rootId, { event: 'hook', on: event, hook: hook.name, agent, sessionId, skipped: 'untrusted' });
540
+ return undefined;
541
+ }
542
+ try {
543
+ const result = await runHook(hook, payload, { cwd: session.mapping.workdir, env });
544
+ const failed = result.exitCode !== 0 || result.timedOut;
545
+ const blocked = !!hook.blocking && failed;
546
+ const output = (result.stderr.trim() || result.stdout.trim()).split('\n').slice(-20).join('\n');
547
+ runLog(rootId, { event: 'hook', on: event, hook: hook.name, agent, sessionId, exitCode: result.exitCode, durationMs: result.durationMs, ...(result.timedOut ? { timedOut: true } : {}), ...(blocked ? { blocked: true } : {}), ...(failed && output ? { output: clip(output, 400) } : {}) });
548
+ if (!blocked) return undefined;
549
+ return `${hook.name} ${result.timedOut ? `timed out after ${hook.timeoutSec ?? 60} s` : `exited ${result.exitCode ?? result.signal}`}${output ? `:\n${output.slice(0, 4000)}` : ''}`;
550
+ } catch (error) {
551
+ runLog(rootId, { event: 'hook', on: event, hook: hook.name, agent, sessionId, error: errorData(error).message, ...(hook.blocking ? { blocked: true } : {}) });
552
+ return hook.blocking ? `${hook.name} could not run: ${errorData(error).message}` : undefined;
553
+ }
554
+ };
555
+ for (const hook of hooks.filter(hook => !hook.blocking)) void one(hook);
556
+ const reasons: string[] = [];
557
+ for (const hook of hooks.filter(hook => hook.blocking)) {
558
+ const reason = await one(hook);
559
+ if (reason) reasons.push(reason);
560
+ }
561
+ return reasons.length ? reasons.join('\n\n') : undefined;
562
+ }
563
+
564
+ /** Best effort: an unwritable log never blocks or fails delegation. */
565
+ function runLog(rootId: string, entry: Record<string, unknown>) {
566
+ record(rootId, entry);
567
+ const directory = options.runLogDir;
568
+ if (!directory) return;
569
+ const file = path.join(directory, `${rootId.replace(/[^\w.-]/g, '_')}.jsonl`);
570
+ const line = JSON.stringify({ ts: new Date().toISOString(), rootSessionId: rootId, ...entry }) + '\n';
571
+ runLogWrites = runLogWrites
572
+ .then(() => mkdir(directory, { recursive: true, mode: 0o700 }))
573
+ .then(async () => {
574
+ const handle = await open(file, fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
575
+ try {
576
+ const info = await handle.stat();
577
+ if (!info.isFile() || (process.platform !== 'win32' && (info.uid !== process.getuid?.() || (info.mode & 0o077)))) return;
578
+ await handle.appendFile(line);
579
+ } finally { await handle.close(); }
580
+ })
581
+ .catch(() => {});
582
+ }
583
+
584
+ const silentForMs = options.silentForMs ?? 600_000;
585
+ const checkInMs = options.checkInMs ?? 600_000;
586
+ const askTimeoutMs = options.askTimeoutMs ?? 900_000;
587
+ const userAskTimeoutMs = options.userAskTimeoutMs ?? 1_800_000;
588
+ /** Project boards by project root, loaded from boardDir on first use. */
589
+ const boards = new Map<string, Promise<Pin[]>>();
590
+ /** The same boards once loaded, for synchronous status. */
591
+ const loadedBoards = new Map<string, Pin[]>();
592
+ const userQuestions = new Map<string, { question: UserQuestion; settle: (outcome: 'answered' | 'dismissed' | 'timeout' | 'canceled', answer?: string, reason?: string, result?: unknown) => void }>();
593
+ let mailSequence = 0;
594
+ let watchdog: NodeJS.Timeout | undefined;
595
+
596
+ /**
597
+ * Codex lets a command write the directory it runs in, so a session in a copy
598
+ * could still write the requester's tree by running there. ALP cannot stop it in
599
+ * time; it logs it and tells the requester in the assignment's result.
600
+ */
601
+ function watchCopy(sessionId: string, session: Session, item: { command?: string; cwd?: string }) {
602
+ if (!session.mapping.copy || typeof item.cwd !== 'string' || !session.parent) return;
603
+ const assignment = sessions.get(session.parent)?.assignments.get(sessionId);
604
+ if (!assignment?.copy) return;
605
+ // Paths may differ by symlinks (/var and /private/var on macOS).
606
+ const real = (target: string) => { try { return realpathSync(target); } catch { return path.resolve(target); } };
607
+ const cwd = real(item.cwd);
608
+ const within = (root: string) => { const relative = path.relative(root, cwd); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); };
609
+ if (within(real(assignment.copy.path)) || !within(real(assignment.copy.checkout))) return;
610
+ const command = unwrapShell(String(item.command ?? '')).slice(0, 300);
611
+ (assignment.escapes ??= []).push(`${command} (in ${cwd})`);
612
+ runLog(rootOf(sessionId), { event: 'copy.escape', assignmentId: sessionId, agent: session.mapping.agent.name, command, cwd });
613
+ }
614
+
615
+ /**
616
+ * Answers Codex when it asks to run a command or change files: a deny rule
617
+ * declines, an ask rule asks the user, an allow rule accepts, and otherwise full
618
+ * access accepts and a profile with beyondMode ask asks the user.
619
+ */
620
+ async function approve(sessionId: string, session: Session, method: string, params: any) {
621
+ if (method !== 'item/commandExecution/requestApproval' && method !== 'item/fileChange/requestApproval') throw new Error('Unsupported runtime request');
622
+ const profile = session.mapping.permissions;
623
+ const full = session.mapping.mode === 'full-access';
624
+ const command = method === 'item/commandExecution/requestApproval' && params?.kind !== 'writeStdin' && !params?.networkApprovalContext && typeof params?.command === 'string' ? params.command : undefined;
625
+ const rule = command !== undefined && profile ? commandDecision(profile, command) : undefined;
626
+ const log = (decision: string, extra: Record<string, unknown> = {}) => runLog(rootOf(sessionId), {
627
+ event: 'permission', agent: session.mapping.agent.name, sessionId,
628
+ request: command !== undefined ? 'command' : method === 'item/fileChange/requestApproval' ? 'file change' : 'other',
629
+ ...(command !== undefined ? { command: command.slice(0, 500) } : {}),
630
+ decision, ...(rule ? { rule } : {}), ...(profile ? { profile: profile.name } : {}), ...extra,
631
+ });
632
+ if (rule === 'deny' || rule === 'allow' || (rule === undefined && (full || profile?.beyondMode !== 'ask'))) {
633
+ const decision = rule === 'deny' ? 'decline' : rule === 'allow' || full ? 'accept' : 'decline';
634
+ log(decision);
635
+ return { decision };
636
+ }
637
+ // Codex proposes a command prefix to allow; else the exact command.
638
+ const prefix = Array.isArray(params?.proposedExecpolicyAmendment) && params.proposedExecpolicyAmendment.every((token: unknown) => typeof token === 'string') ? params.proposedExecpolicyAmendment.join(' ') : undefined;
639
+ const exact = command !== undefined ? unwrapShell(command) : undefined;
640
+ const always = rule === undefined && (prefix || exact) && !(prefix ?? exact)!.includes(')') ? (prefix ? `Bash(${prefix} *)` : `Bash(${exact})`) : undefined;
641
+ const what = command !== undefined ? `run \`${unwrapShell(command).slice(0, 400)}\`${params?.reason ? ` (${String(params.reason).slice(0, 200)})` : ''}` : `change files${params?.grantRoot ? ` under ${params.grantRoot}` : ''}${params?.reason ? ` (${String(params.reason).slice(0, 200)})` : ''}`;
642
+ const answer = await askPermission(sessionId, session, { what, reason: rule === 'ask' ? 'rule' : 'mode', always, recheck: command });
643
+ log(answer.allow ? 'accept' : 'decline', { asked: true, ...(answer.always ? { always: always } : {}) });
644
+ return { decision: answer.allow ? 'accept' : 'decline' };
645
+ }
646
+
647
+ /** Claude asks when an ask rule covers a tool use, or when a read-only mode refuses it and the profile asks beyond its mode. */
648
+ async function claudePermission(sessionId: string, session: Session, request: { tool: string; input: Record<string, unknown>; reason: 'rule' | 'mode'; rule?: string }) {
649
+ const command = request.tool === 'Bash' && typeof request.input?.command === 'string' ? request.input.command : undefined;
650
+ const target = command ?? (typeof request.input?.file_path === 'string' ? request.input.file_path : typeof request.input?.url === 'string' ? request.input.url : undefined);
651
+ const what = `use ${request.tool}${target ? `: \`${String(target).slice(0, 400)}\`` : ''}`;
652
+ const answer = await askPermission(sessionId, session, { what, reason: request.reason, always: request.reason === 'mode' ? request.rule : undefined, recheck: command });
653
+ runLog(rootOf(sessionId), {
654
+ event: 'permission', agent: session.mapping.agent.name, sessionId, request: request.tool, ...(target ? { command: String(target).slice(0, 500) } : {}),
655
+ decision: answer.allow ? 'accept' : 'decline', asked: true, ...(request.reason === 'rule' ? { rule: 'ask' } : {}), ...(answer.always ? { always: request.rule } : {}),
656
+ ...(session.mapping.permissions ? { profile: session.mapping.permissions.name } : {}),
657
+ });
658
+ return answer;
659
+ }
660
+
661
+ /**
662
+ * An ACP agent asks before using a tool (ALPD §46): ALP's tools, reading, and what the
663
+ * mode allows run; deny and allow rules hold for commands; an ask rule, or anything
664
+ * beyond the mode when the profile asks beyond it, goes to the user.
665
+ */
666
+ async function acpPermissionRequest(sessionId: string, session: Session, request: AcpPermission) {
667
+ const profile = session.mapping.permissions;
668
+ const decision = acpDecision(request, session.mapping.mode, profile, session.mapping.workdir);
669
+ const log = (result: string, extra: Record<string, unknown> = {}) => runLog(rootOf(sessionId), {
670
+ event: 'permission', agent: session.mapping.agent.name, sessionId, request: request.kind,
671
+ ...(request.command !== undefined ? { command: request.command.slice(0, 500) } : request.title ? { command: request.title.slice(0, 500) } : {}),
672
+ decision: result, ...(profile ? { profile: profile.name } : {}), ...extra,
673
+ });
674
+ if (decision === 'allow') return { allow: true };
675
+ if (decision === 'deny' || (decision === 'mode' && profile?.beyondMode !== 'ask')) {
676
+ log('decline', decision === 'deny' ? { rule: 'deny' } : {});
677
+ return { allow: false };
678
+ }
679
+ const exact = request.command !== undefined ? unwrapShell(request.command) : undefined;
680
+ const always = decision === 'mode' && exact && !exact.includes(')') ? `Bash(${exact})` : undefined;
681
+ const answer = await askPermission(sessionId, session, { what: acpWhat(request), reason: decision, always, recheck: request.command });
682
+ log(answer.allow ? 'accept' : 'decline', { asked: true, ...(decision === 'rule' ? { rule: 'ask' } : {}), ...(answer.always ? { always } : {}) });
683
+ return answer;
684
+ }
685
+
686
+ /**
687
+ * Asks the user about one permission, one question per session at a time.
688
+ * "Always allow" adds the rule to the profile in its settings file and to the
689
+ * open sessions that use it; an ask rule offers only once or no.
690
+ */
691
+ function askPermission(sessionId: string, session: Session, request: { what: string; reason: 'rule' | 'mode'; always?: string; recheck?: string }) {
692
+ const run = async (): Promise<{ allow: boolean; always?: boolean; message?: string }> => {
693
+ const profile = session.mapping.permissions;
694
+ // Another question may have allowed it always in the meantime.
695
+ if (request.reason === 'mode' && request.recheck && profile && commandDecision(profile, request.recheck) === 'allow') return { allow: true };
696
+ const say = wordsFor(session);
697
+ const choices = request.always && profile ? [say.allowOnce, say.alwaysAllow, say.deny] : [say.allowOnce, say.deny];
698
+ const body = say.permission(session.mapping.agent.name, request.what, request.reason === 'rule', profile?.name, session.mapping.mode, choices.length === 3 ? request.always : undefined);
699
+ const assignment = session.parent ? sessions.get(session.parent)?.assignments.get(sessionId) : undefined;
700
+ const result = await askUser(sessionId, session, assignment, body, choices) as { contentItems: Array<{ text: string }> };
701
+ let value: any;
702
+ try { value = JSON.parse(result.contentItems[0].text); } catch { value = {}; }
703
+ if (value.status !== 'answered') return { allow: false, message: `The user did not answer (${value.status ?? 'canceled'}); it was not allowed` };
704
+ const answer = String(value.answer).trim();
705
+ const choice = answer.toLowerCase();
706
+ if (CHOICES.alwaysAllow.includes(choice) && choices.length === 3) {
707
+ await addAllowRule(session.mapping.agent.projectRoot, options.libraryDir, profile!.name, request.always!);
708
+ for (const other of sessions.values()) {
709
+ const rules = other.mapping.permissions;
710
+ if (rules?.name === profile!.name && other.mapping.agent.projectRoot === session.mapping.agent.projectRoot && !rules.allow.includes(request.always!)) rules.allow.push(request.always!);
711
+ }
712
+ return { allow: true, always: true };
713
+ }
714
+ if (CHOICES.allowOnce.includes(choice) || APPROVALS.includes(choice)) return { allow: true };
715
+ return { allow: false, message: CHOICES.deny.includes(choice) ? 'The user refused it' : `The user refused it: ${answer}` };
716
+ };
717
+ const next = (session.permissionQueue ?? Promise.resolve()).then(run, run);
718
+ session.permissionQueue = next.catch(() => {});
719
+ return next;
720
+ }
721
+
722
+ function rootOf(sessionId: string) {
723
+ let id = sessionId;
724
+ for (let parent: string | undefined = sessions.get(id)?.parent; parent && sessions.has(parent); parent = sessions.get(id)?.parent) id = parent;
725
+ return id;
726
+ }
727
+
728
+ function hasActiveMail(session: Session) {
729
+ return session.mail.some(event => !event.deliveredTurn && !event.passive);
730
+ }
731
+
732
+ /** Main of a Phở or Cafe tree gets a supervisor, when the host shows child sessions. */
733
+ function supervises(session: Session) {
734
+ return options.supervisor !== false && !session.parent && session.mapping.agent.name === mainOf(session.mapping) && session.mapping.workflow.supervisor && session.delegation;
735
+ }
736
+
737
+ /** A root's supervisor is reviewing it, or a review waits to start. */
738
+ function reviewing(session: Session) {
739
+ const supervisor = session.supervisor ? sessions.get(session.supervisor) : undefined;
740
+ return !!supervisor && !supervisor.closed && !!(supervisor.active || supervisor.pending || session.reviewPending);
741
+ }
742
+
743
+ /** Notes a run-log entry in a supervised root's journal; the supervisor's own activity is left out. */
744
+ function record(rootId: string, entry: Record<string, any>) {
745
+ const root = sessions.get(rootId);
746
+ const watcher = root?.mapping.team?.supervisor ? root.mapping.team.supervisor.agent : 'supervisor';
747
+ if (!root?.supervisor || entry.from === 'supervisor' || entry.agent === watcher) return;
748
+ const line =
749
+ entry.event === 'assignment.started' ? `${entry.parentAgent} → ${entry.agent} assignment ${entry.assignmentId}${entry.taskId ? ` for task ${entry.taskId}` : ''} (${entry.model}, ${entry.thinking ?? 'default effort'}, ${entry.mode}, ${entry.isolation}${entry.wait ? '' : ', async'}): ${clip(entry.task)}`
750
+ : entry.event === 'assignment.finished' ? `${entry.agent} assignment ${entry.assignmentId} ${entry.status}${entry.handoff ? `, handoff ${entry.handoff.outcome}${entry.handoff.verdict ? `, ${verdictLine(entry.handoff.verdict)}` : ''}: ${clip(entry.handoff.summary)}` : ', no handoff'}${entry.error ? ` (${clip(entry.error, 200)})` : ''}${entry.handoff?.discovered?.length ? `; discovered: ${clip(entry.handoff.discovered.join('; '))}` : ''}`
751
+ : entry.event === 'mail' && entry.kind !== 'result' && entry.kind !== 'board' ? `mail ${entry.kind} from ${entry.from}${entry.replyTo ? ` (reply to ${entry.replyTo})` : ''}: ${clip(entry.body ?? '')}`
752
+ : entry.event === 'human.question' ? `${entry.agent} asked the user: ${clip(entry.body)}`
753
+ : entry.event === 'human.answer' ? `user ${entry.outcome} ${entry.questionId}${entry.answer ? `: ${clip(entry.answer)}` : ''}`
754
+ : entry.event === 'board.pin' ? `${entry.agent} pinned ${entry.kind}${entry.paths ? ` ${entry.paths.join(', ')}` : ''}: ${clip(entry.body)}`
755
+ : entry.event === 'lesson' ? `${entry.agent} recorded a ${entry.scope} lesson: ${clip(entry.lesson)}`
756
+ // Two entries share the name: alp_skill saving one (name, roles), and an agent using one (skill, via), which skillUse journals itself.
757
+ : entry.event === 'skill' && Array.isArray(entry.roles) ? `${entry.agent} saved skill ${entry.name} for ${entry.roles.join(', ')} with the user's approval`
758
+ : entry.event === 'task' ? `${entry.agent} ${TASK_PAST[entry.action] ?? entry.action} task ${entry.id} "${clip(entry.title, 120)}"${entry.status ? ` (now ${entry.status})` : ''}${entry.detail ? `: ${clip(entry.detail)}` : ''}`
759
+ : entry.event === 'epic.landed' ? `${entry.agent} closed ${entry.id} "${clip(entry.title, 120)}": ${entry.tasks} tasks, ${entry.reworked} reworked, ${entry.unverified?.length ?? 0} closed unverified`
760
+ : entry.event === 'recall' ? `${entry.agent} recalled ${entry.recalled} assignment ${entry.assignmentId}: ${clip(entry.question)}`
761
+ : entry.event === 'issue' ? `${entry.action === 'create' ? `opened issue "${clip(entry.title, 200)}"` : `commented on issue #${entry.issue}`} in ${entry.repo} with the user's approval: ${entry.url}`
762
+ : entry.event?.startsWith('worktree.') ? `${entry.event.slice('worktree.'.length)} worktree of ${entry.assignmentId} (${entry.branch})${entry.status ? `: ${entry.status}` : ''}`
763
+ : undefined;
764
+ if (line) root.journal.push(stamp(line));
765
+ }
766
+
767
+ /** Whether a journal holds more than the end of a turn that did nothing. */
768
+ const eventful = (journal: string[]) => journal.some(line => !unstamped(line).startsWith('turn ended:'));
769
+
770
+ /**
771
+ * Sends a root's journal to its supervisor, or keeps it until the supervisor's
772
+ * current review ends. reviewPending keeps the tree busy until the review starts,
773
+ * so an idle-tree reap cannot close it in between.
774
+ */
775
+ function review(rootId: string) {
776
+ const root = sessions.get(rootId);
777
+ const supervisorId = root?.supervisor;
778
+ const supervisor = supervisorId ? sessions.get(supervisorId) : undefined;
779
+ if (!root || !supervisorId || !supervisor || supervisor.closed) return;
780
+ if (!eventful(root.journal)) {
781
+ root.journal.length = 0;
782
+ return;
783
+ }
784
+ root.reviewPending = true;
785
+ // A running review takes the next digest when it ends; a queued one takes this journal too.
786
+ if (supervisor.active || supervisor.pending || root.reviewQueued) return;
787
+ root.reviewQueued = true;
788
+ void enqueue(async () => {
789
+ root.reviewQueued = false;
790
+ if (supervisor.closed) {
791
+ root.reviewPending = false;
792
+ return;
793
+ }
794
+ if (supervisor.active || supervisor.pending) return;
795
+ const lines = root.journal.splice(0);
796
+ root.reviewPending = false;
797
+ if (!eventful(lines)) return;
798
+ let size = 0;
799
+ const kept: string[] = [];
800
+ for (const line of lines) {
801
+ if (size + line.length > DIGEST_CHARS) {
802
+ kept.push(`… ${lines.length - kept.length} more events left out; read the run log or files if they matter.`);
803
+ break;
804
+ }
805
+ kept.push(`- ${line}`);
806
+ size += line.length + 3;
807
+ }
808
+ const agent = root.mapping.agent.name;
809
+ const text = `Digest of ${agent}'s turn (profile ${root.mapping.workflow.mode}), oldest first, each line with its local time:\n${kept.join('\n')}\n\n` +
810
+ `${agent}'s session: ${root.runtimeKind}:${root.mapping.model}, thinking ${root.mapping.thinking ?? 'default'}, mode ${root.mapping.mode}. Judge ${agent} by this session and its own instructions; your system prompt describes your session (your model, attribution lines, tools), not ${agent}'s.\n\n` +
811
+ `Review the process against ALP.md, ${agent}'s AGENT.md and the recorded lessons. Ask ${agent} about each mistake with one alp_send to: "parent", kind note, or send nothing when the process was sound.`;
812
+ await startPrompt(supervisorId, { clientMessageId: `alp-review-${randomUUID()}`, delivery: 'auto', content: [{ type: 'text', text }] }, 'assignment');
813
+ }).catch(error => runLog(rootId, { event: 'supervisor.failed', error: errorData(error).message }));
814
+ }
815
+
816
+ /** Starts a root's supervisor beside it; a supervisor that cannot start never fails its root. */
817
+ async function openSupervisor(rootId: string) {
818
+ const root = sessions.get(rootId);
819
+ if (!root || root.closed || root.supervisor || root.parent) return;
820
+ const id = `alp-supervisor-${randomUUID()}`;
821
+ childContexts.set(id, { parent: rootId, callId: `supervisor-${rootId}`, graph: {}, workflow: root.mapping.workflow, ancestry: [...root.ancestry, 'supervisor'], role: 'supervisor' });
822
+ const { restore: _restore, ...inherited } = root.spec;
823
+ // The team names the supervisor agent and what it runs on.
824
+ const watcher = root.mapping.team?.supervisor || { agent: 'supervisor' };
825
+ try {
826
+ const snapshot = await openSession(id, { ...inherited, persist: false, agent: watcher.agent, model: watcher.model, thinking: watcher.thinking, mode: 'read-only', workflow: root.mapping.workflow.mode }, 'skip', root.delegation);
827
+ if (root.closed) await closeSession(id);
828
+ else {
829
+ root.supervisor = id;
830
+ runLog(rootId, { event: 'supervisor.started', sessionId: id, model: `${snapshot.runtime}:${snapshot.model}` });
831
+ }
832
+ } catch (error) {
833
+ runLog(rootId, { event: 'supervisor.failed', error: errorData(error).message });
834
+ } finally {
835
+ childContexts.delete(id);
836
+ }
837
+ }
838
+
839
+ const lessonWrites = new Map<string, Promise<unknown>>();
840
+
841
+ async function lessonTool(sessionId: string, session: Session, args: unknown) {
842
+ if (!plainObject(args, ['scope', 'lesson']) || !['project', 'user'].includes(args.scope) || typeof args.lesson !== 'string' || !args.lesson.trim() || args.lesson.length > LESSON_CHARS) {
843
+ return toolResult(false, { error: `A lesson needs scope (project or user) and one rule of at most ${LESSON_CHARS} characters` });
844
+ }
845
+ if (args.scope === 'user' && !options.libraryDir) return toolResult(false, { error: 'This host keeps no user lessons; record it with scope project' });
846
+ const file = args.scope === 'user' ? path.join(options.libraryDir!, LESSONS_FILE) : path.join(session.mapping.agent.projectRoot, '.alp', LESSONS_FILE);
847
+ const line = `- ${new Date().toISOString().slice(0, 10)}: ${args.lesson.replace(/\s+/g, ' ').trim()}\n`;
848
+ const header = '# ALP lessons\n\nRules main recorded after supervisor reviews. Main follows them in every session; edit or delete them freely.\n\n';
849
+ const write = (lessonWrites.get(file) ?? Promise.resolve()).catch(() => {}).then(async () => {
850
+ await mkdir(path.dirname(file), { recursive: true });
851
+ const existing = await readFile(file, 'utf8').catch(() => undefined);
852
+ await appendFile(file, existing === undefined ? header + line : line);
853
+ });
854
+ lessonWrites.set(file, write);
855
+ try {
856
+ await write;
857
+ } catch (error) {
858
+ return toolResult(false, { error: errorData(error).message });
859
+ }
860
+ runLog(rootOf(sessionId), { event: 'lesson', scope: args.scope, agent: session.mapping.agent.name, lesson: args.lesson });
861
+ return toolResult(true, { recorded: true, scope: args.scope, file });
862
+ }
863
+
864
+ /** alp_task: main changes the task graph; other roles read it (taskActions). */
865
+ async function taskToolCall(sessionId: string, session: Session, args: unknown) {
866
+ const allowed = taskActions(session.mapping, session.parentAgent, session.role);
867
+ if (!plainObject(args, ['action', ...Object.values(TASK_FIELDS).flat()]) || !TASK_ACTIONS.includes(args.action)) {
868
+ return toolResult(false, { error: `action must be one of ${allowed.join(', ')}` });
869
+ }
870
+ const action = args.action as TaskAction;
871
+ if (!allowed.includes(action)) {
872
+ return toolResult(false, { error: allowed.includes('create') ? `Unknown action ${action}` : `Only main and the user change tasks; you may ${allowed.join(', ')}. Report work you find to your requester.` });
873
+ }
874
+ const extra = Object.keys(args).filter(key => key !== 'action' && !TASK_FIELDS[action].includes(key));
875
+ if (extra.length) return toolResult(false, { error: `${action} does not take ${extra.join(', ')}` });
876
+ if (!['create', 'list', 'ready', 'pour', 'formulas'].includes(action) && typeof args.id !== 'string') return toolResult(false, { error: `${action} needs id` });
877
+ if (args.limit !== undefined && (!Number.isSafeInteger(args.limit) || args.limit < 1)) return toolResult(false, { error: 'limit must be a positive integer' });
878
+ const project = session.mapping.agent.projectRoot;
879
+ const by = session.mapping.agent.name;
880
+ const limit = Math.min(args.limit ?? 20, 100);
881
+ try {
882
+ if (action === 'formulas') {
883
+ const found = await listFormulas(project, options.libraryDir, { toml });
884
+ return toolResult(true, {
885
+ formulas: found.map(entry => entry.formula
886
+ ? { name: entry.name, ...(entry.formula.description ? { description: entry.formula.description } : {}), vars: entry.formula.vars, steps: entry.formula.steps.map(step => `${step.id}${step.type === 'human' ? ' (the user)' : ''}: ${step.title}`) }
887
+ : { name: entry.name, error: entry.error }),
888
+ searched: formulaDirs(project, options.libraryDir),
889
+ });
890
+ }
891
+ if (action === 'show' || action === 'list' || action === 'ready') {
892
+ const { tasks, errors } = await loadTasks(project);
893
+ const warnings = errors.length ? { unreadable: errors } : {};
894
+ if (action === 'show') {
895
+ const task = tasks.find(candidate => candidate.id === args.id);
896
+ if (!task) return toolResult(false, { error: `No task ${args.id}` });
897
+ const blockers = task.status === 'closed' ? [] : blockersOf(task, tasks);
898
+ return toolResult(true, { task, ...(blockers.length ? { openBlockers: blockers } : {}), children: childrenOf(task.id, tasks).map(child => summarize(child, tasks)), ...warnings });
899
+ }
900
+ const rows = action === 'ready' ? readyTasks(tasks) : listTasks(tasks, { status: args.status, label: args.label });
901
+ return toolResult(true, { tasks: rows.slice(0, limit).map(task => summarize(task, tasks)), ...(rows.length > limit ? { more: rows.length - limit } : {}), ...warnings });
902
+ }
903
+ const { action: _action, id, note, reason, summary, unverified, add, remove, ...input } = args;
904
+ if (action === 'pour' && (typeof input.formula !== 'string' || (input.vars !== undefined && (!input.vars || typeof input.vars !== 'object' || Array.isArray(input.vars) || !Object.values(input.vars).every(value => typeof value === 'string'))))) {
905
+ return toolResult(false, { error: 'pour needs formula, and vars as an object of text values' });
906
+ }
907
+ const { kind, until, ref, gate, formula: _formula, vars: _vars, ...fields } = input;
908
+ if (action === 'clear') {
909
+ const current = (await loadTasks(project)).tasks.find(candidate => candidate.id === id);
910
+ if (current?.gates.find(entry => entry.id === gate)?.kind === 'human') return toolResult(false, { error: 'Only the user clears a human gate; ask them, and they approve it with alp task gate clear or in Paseo' });
911
+ }
912
+ let poured: Awaited<ReturnType<typeof pourFormula>> | undefined;
913
+ // A blocking task.close hook can refuse the close; its output is the reason.
914
+ const hookedClose = async (id: string) => {
915
+ const refused = await runHooks(sessionId, session, 'task.close', { reason, ...(summary ? { summary } : {}) }, id);
916
+ if (refused) throw new Error(`A task.close hook refused to close ${id}: ${refused}`);
917
+ return closeTask(project, id, { reason, summary, unverified }, by);
918
+ };
919
+ const task =
920
+ action === 'create' ? await createTask(project, fields as any, by)
921
+ : action === 'update' ? await updateTask(project, id, { ...fields, note }, by)
922
+ : action === 'pour' ? (poured = await pourFormula(project, (await findFormula(project, options.libraryDir, input.formula, { toml })).formula, input.vars, by, input.parent ? { parent: input.parent } : {})).epic
923
+ : action === 'gate' ? await addGate(project, id, { kind, note, until, ref }, by)
924
+ : action === 'clear' ? await resolveGate(project, id, gate, { by, note })
925
+ : action === 'link' ? await linkTask(project, id, { add, remove }, by)
926
+ : action === 'start' ? await startTask(project, id, { agent: by, session: sessionId }, by)
927
+ : action === 'close' ? await hookedClose(id)
928
+ : await reopenTask(project, id, { note }, by);
929
+ touchTask(rootOf(sessionId), task.id);
930
+ runLog(rootOf(sessionId), { event: 'task', action, agent: by, id: task.id, title: task.title, status: task.status, ...(summary ?? note ? { detail: summary ?? note } : {}) });
931
+ const { tasks } = await loadTasks(project);
932
+ const landed: Record<string, unknown> = {};
933
+ if (action === 'close') {
934
+ // Closing an epic reports what it came to, to main and to the user.
935
+ if (tasks.some(entry => entry.parent === task.id)) {
936
+ const report = epicReport(task.id, tasks);
937
+ landed.report = report.text;
938
+ notice('info', report.text, project);
939
+ runLog(rootOf(sessionId), { event: 'epic.landed', agent: by, id: task.id, title: task.title, tasks: report.tasks, durationMs: report.durationMs, reworked: report.reworked, verification: report.verification, unverified: report.unverified });
940
+ }
941
+ const parent = task.parent ? tasks.find(entry => entry.id === task.parent) : undefined;
942
+ const siblings = parent ? childrenOf(parent.id, tasks) : [];
943
+ if (parent && parent.status !== 'closed' && siblings.every(entry => entry.status === 'closed')) {
944
+ landed.next = `All ${siblings.length} children of ${parent.id} "${parent.title}" are closed; close it with a summary, and ALP reports it to the user`;
945
+ }
946
+ }
947
+ return toolResult(true, { task: summarize(task, tasks), rev: task.rev, ...(poured ? { steps: poured.tasks.map(step => summarize(step, tasks)) } : {}), ...landed });
948
+ } catch (error) {
949
+ return toolResult(false, { error: errorData(error).message });
950
+ }
951
+ }
952
+
953
+ const sameDirectory = async (a: string, b: string) => a === b || (await realpath(a).catch(() => a)) === (await realpath(b).catch(() => b));
954
+
955
+ /** The recallable assignment named, or the last one that worked on a task of the project. */
956
+ async function findRecall(target: { assignmentId?: string; taskId?: string; projectRoot?: string }) {
957
+ const entries = await recalls.all();
958
+ if (target.assignmentId) return entries.find(entry => entry.assignmentId === target.assignmentId);
959
+ for (const entry of entries.reverse()) {
960
+ if (entry.taskId === target.taskId && (!target.projectRoot || await sameDirectory(entry.project, target.projectRoot))) return entry;
961
+ }
962
+ return undefined;
963
+ }
964
+
965
+ /** Forks a finished assignment's native thread read-only, asks it one question, and drops the fork. */
966
+ async function askRecalled(entry: RecallEntry, asker: string, question: string) {
967
+ let cwd = entry.cwd;
968
+ let made = false;
969
+ if (!(await stat(cwd).then(info => info.isDirectory(), () => false))) {
970
+ // Claude finds a session by the directory it ran in; a removed worktree is recreated empty for the fork.
971
+ if (entry.runtime === 'claude') { await mkdir(cwd, { recursive: true }); made = true; } else cwd = entry.project;
972
+ }
973
+ const transport = createTransport(options, entry.runtime, cwd, nativeEnvironment(options));
974
+ let answer = '';
975
+ let timer: NodeJS.Timeout | undefined;
976
+ const done = new Promise<void>((resolve, reject) => {
977
+ timer = setTimeout(() => reject(new Error(`No answer within ${RECALL_TIMEOUT_MS / 1000} s`)), RECALL_TIMEOUT_MS);
978
+ transport.onNotification((method, params) => {
979
+ if (method === 'item/completed' && params?.item?.type === 'agentMessage' && typeof params.item.text === 'string') answer = params.item.text;
980
+ if (method === 'turn/completed') {
981
+ if (params?.turn?.status === 'failed') reject(new Error(params.turn.error?.message ?? 'The recalled session failed to answer'));
982
+ else resolve();
983
+ }
984
+ });
985
+ transport.onFailure(error => reject(error instanceof Error ? error : new Error(String(error))));
986
+ });
987
+ done.catch(() => {});
988
+ transport.onRequest?.(async method =>
989
+ method === 'item/tool/call' ? toolResult(false, { error: 'ALP tools are unavailable in a recall; answer the question' })
990
+ : method === 'item/permission/request' ? { allow: false, message: 'A recall only reads' }
991
+ : { decision: 'decline' });
992
+ try {
993
+ await transport.initialize();
994
+ const forked = await transport.request('thread/fork', {
995
+ threadId: entry.threadId, cwd, model: entry.model, sandbox: 'read-only', approvalPolicy: 'never', ephemeral: true,
996
+ // Codex forks an ephemeral thread only without returning its history, which ALP does not need.
997
+ excludeTurns: true,
998
+ // Claude starts the fork as a new query; Codex keeps the thread's own instructions and tools.
999
+ ...(entry.runtime === 'claude' ? { thinking: entry.thinking ?? 'medium', developerInstructions: `You are the ${entry.agent} agent of ALP, answering questions about an assignment you finished.`, mcpServers: {}, dynamicTools: [] } : {}),
1000
+ });
1001
+ await transport.request('turn/start', {
1002
+ threadId: forked.thread.id,
1003
+ input: [{ type: 'text', text: promptSafe(recallPrompt(asker, question)), text_elements: [] }],
1004
+ ...(entry.thinking ? { effort: entry.thinking } : {}),
1005
+ approvalPolicy: 'never',
1006
+ sandboxPolicy: { type: 'readOnly', networkAccess: false },
1007
+ });
1008
+ await done;
1009
+ return answer.trim();
1010
+ } finally {
1011
+ clearTimeout(timer);
1012
+ await transport.close().catch(() => {});
1013
+ if (made) await rm(cwd, { recursive: true, force: true }).catch(() => {});
1014
+ }
1015
+ }
1016
+
1017
+ async function recallTool(sessionId: string, session: Session, args: unknown) {
1018
+ if (!plainObject(args, ['assignmentId', 'taskId', 'question']) || typeof args.question !== 'string' || !args.question.trim() || args.question.length > RECALL_QUESTION_CHARS ||
1019
+ (typeof args.assignmentId === 'string') === (typeof args.taskId === 'string')) {
1020
+ return toolResult(false, { error: `Pass a question of at most ${RECALL_QUESTION_CHARS} characters, and either assignmentId or taskId` });
1021
+ }
1022
+ if (args.taskId !== undefined && (session.parent || session.mapping.agent.name !== mainOf(session.mapping))) return toolResult(false, { error: 'Only main recalls by task; pass the assignmentId from the result' });
1023
+ if (args.assignmentId !== undefined && sessions.has(args.assignmentId)) return toolResult(false, { error: 'That assignment is still running; ask it with alp_send' });
1024
+ const project = session.mapping.agent.projectRoot;
1025
+ const entry = await findRecall({ assignmentId: args.assignmentId, taskId: args.taskId, projectRoot: project });
1026
+ // A root of the project may ask any assignment made there; any other requester only those it or its assignments started.
1027
+ if (!entry || !(await sameDirectory(entry.project, project)) || (session.parent && !entry.requesters.includes(sessionId))) {
1028
+ return toolResult(false, { error: `${args.taskId ? `No recallable assignment worked on ${args.taskId}` : `No recallable assignment ${args.assignmentId} of yours`}; assignments stay recallable for ${RECALL_KEEP_MS / 86_400_000} days` });
1029
+ }
1030
+ try {
1031
+ const answer = await askRecalled(entry, session.mapping.agent.name, args.question);
1032
+ runLog(rootOf(sessionId), { event: 'recall', agent: session.mapping.agent.name, assignmentId: entry.assignmentId, recalled: entry.agent, question: clip(args.question, 500), answer: clip(answer, 500) });
1033
+ return toolResult(true, { assignmentId: entry.assignmentId, agent: entry.agent, ...(entry.taskId ? { taskId: entry.taskId } : {}), finishedAt: entry.finishedAt, answer });
1034
+ } catch (error) {
1035
+ return toolResult(false, { assignmentId: entry.assignmentId, error: errorData(error).message });
1036
+ }
1037
+ }
1038
+
1039
+ let forgetting = Promise.resolve();
1040
+
1041
+ /** Deletes the native threads of assignments past their recall time, one transport per runtime. */
1042
+ function forgetExpired() {
1043
+ forgetting = forgetting.then(async () => {
1044
+ const gone = await recalls.expire();
1045
+ for (const kind of new Set(gone.map(entry => entry.runtime))) {
1046
+ const transport = createTransport(options, kind, os.tmpdir(), nativeEnvironment(options));
1047
+ transport.onFailure(() => {});
1048
+ try {
1049
+ await transport.initialize();
1050
+ for (const entry of gone.filter(candidate => candidate.runtime === kind)) await transport.request('thread/delete', { threadId: entry.threadId }).catch(() => {});
1051
+ } catch {}
1052
+ finally { await transport.close().catch(() => {}); }
1053
+ }
1054
+ }).catch(() => {});
1055
+ return forgetting;
1056
+ }
1057
+
1058
+ const RUNTIMES: RuntimeKind[] = ['codex', 'claude', 'antigravity'];
1059
+ const LIMIT_ERRORS = ['usageLimitExceeded', 'rateLimitExceeded'];
1060
+ const label = (kind: RuntimeKind) => kind === 'claude' ? 'Claude' : kind === 'acp' ? 'ACP agent' : kind === 'antigravity' ? 'Antigravity' : 'Codex';
1061
+ const pauseOf = (kind: RuntimeKind) => paused.all ?? paused.runtimes[kind];
1062
+ /** Whether a session may not start a turn by itself now: parked, or on a paused runtime. */
1063
+ const held = (session: Session) => !!session.parked || !!pauseOf(session.runtimeKind);
1064
+ const describePause = (pause: Pause, kind?: RuntimeKind) =>
1065
+ `${kind ? label(kind) : 'ALP'} is paused (${pause.reason}${pause.by !== 'alpd' ? `, by ${pause.by}` : ''})${pause.resetsAt ? `; the limit resets ${pause.resetsAt}` : ''}`;
1066
+
1067
+ function pauseState(): PauseState {
1068
+ return {
1069
+ ...(paused.all ? { all: paused.all } : {}),
1070
+ runtimes: { ...paused.runtimes },
1071
+ parked: [...sessions].filter(([, session]) => session.parked && !session.closed).map(([id, session]) => ({
1072
+ assignmentId: id, agent: session.mapping.agent.name, runtime: session.runtimeKind, rootId: rootOf(id), reason: session.parked!.reason, since: new Date(session.parked!.since).toISOString(),
1073
+ })),
1074
+ };
1075
+ }
1076
+
1077
+ function savePauses() {
1078
+ const file = options.pauseFile;
1079
+ if (!file) return;
1080
+ const body = JSON.stringify(paused, null, 2) + '\n';
1081
+ pauseWrites = pauseWrites.then(async () => {
1082
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
1083
+ const temporary = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
1084
+ await writeFile(temporary, body, { mode: 0o600 });
1085
+ await moveFile(temporary, file);
1086
+ }).catch(() => {});
1087
+ }
1088
+
1089
+ /** Tells every open root, or those of one project, and so the user in each viewer. */
1090
+ function notice(level: 'info' | 'warning' | 'error', message: string | ((say: Words) => string), project?: string) {
1091
+ for (const [id, session] of sessions) {
1092
+ if (session.parent || session.closed || (project !== undefined && session.mapping.agent.projectRoot !== project)) continue;
1093
+ // ALPD §54: written in the user's language where ALP has the words for it.
1094
+ const text = typeof message === 'string' ? message : message(wordsFor(session));
1095
+ emit(id, { type: 'item', item: { kind: 'notice', id: `notice-${randomUUID().slice(0, 8)}`, level, text } });
1096
+ runLog(id, { event: 'notice', level, text });
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * While a usage limit pauses a runtime, alpd asks the runtime every LIMIT_CHECK_MS whether
1102
+ * the limit lifted (ALPD §58), so a reset, or a limit the user reset early, is seen without
1103
+ * anyone resuming it by hand. Pauses the user made are theirs to lift.
1104
+ */
1105
+ function watchLimits() {
1106
+ const needed = !closed && RUNTIMES.some(kind => paused.runtimes[kind]?.by === 'alpd');
1107
+ if (needed && !limitWatch) {
1108
+ limitWatch = setInterval(() => { for (const kind of RUNTIMES) void checkLimit(kind); }, options.limitCheckMs ?? LIMIT_CHECK_MS);
1109
+ limitWatch.unref?.();
1110
+ } else if (!needed && limitWatch) {
1111
+ clearInterval(limitWatch);
1112
+ limitWatch = undefined;
1113
+ }
1114
+ }
1115
+
1116
+ /** Asks the runtime about the limit that paused it, unless it was asked within `fresh` ms, and resumes it when lifted. */
1117
+ function checkLimit(kind: RuntimeKind, fresh = 0): Promise<void> {
1118
+ const pause = paused.runtimes[kind];
1119
+ const last = limitChecks.get(kind);
1120
+ if (!pause || pause.by !== 'alpd' || closed) return Promise.resolve();
1121
+ if (last?.running) return last.running;
1122
+ if (last && Date.now() - last.at < fresh) return Promise.resolve();
1123
+ const running = (async () => {
1124
+ const lifted = await limitLifted(kind).catch(() => undefined);
1125
+ limitChecks.set(kind, { at: Date.now() });
1126
+ if (paused.runtimes[kind] !== pause || closed) return;
1127
+ pause.checkedAt = new Date().toISOString();
1128
+ // When the runtime cannot say, the reset time it gave earlier decides, a minute after.
1129
+ const free = lifted ?? (pause.resetsAt ? Date.parse(pause.resetsAt) + 60_000 <= Date.now() : false);
1130
+ if (!free) { savePauses(); return; }
1131
+ if (options.autoResume !== false) { resumeRuntime(kind, 'alpd, after the limit lifted'); return; }
1132
+ savePauses();
1133
+ if (liftTold.has(pause)) return;
1134
+ liftTold.add(pause);
1135
+ notice('info', say => say.limitLifted(label(kind), kind));
1136
+ })();
1137
+ limitChecks.set(kind, { at: last?.at ?? 0, running });
1138
+ return running;
1139
+ }
1140
+
1141
+ /** Reads usage from an open session, or a short-lived process without sending a prompt. */
1142
+ async function readUsage(kind: RuntimeKind): Promise<unknown> {
1143
+ const open = [...sessions.values()].find(session => !session.closed && !session.pending && session.runtimeKind === kind);
1144
+ if (open) return ((await optionalRead(() => open.runtime.orchestrationContext?.() ?? Promise.resolve(undefined), 20_000)) as any)?.usage;
1145
+ const transport = createTransport(options, kind, os.tmpdir(), nativeEnvironment(options));
1146
+ transport.onFailure(() => {});
1147
+ try {
1148
+ return await optionalRead(async () => {
1149
+ await transport.initialize();
1150
+ return ((await transport.orchestrationContext?.()) as any)?.usage;
1151
+ }, 20_000);
1152
+ } catch {
1153
+ return undefined;
1154
+ } finally {
1155
+ await transport.close().catch(() => {});
1156
+ }
1157
+ }
1158
+
1159
+ async function limitLifted(kind: RuntimeKind): Promise<boolean | undefined> {
1160
+ return usageAllows(await readUsage(kind));
1161
+ }
1162
+
1163
+ const usageCache = new Map<'codex' | 'claude', { at: number; pending: boolean; value: Promise<ProviderUsage> }>();
1164
+ function cachedUsage(kind: 'codex' | 'claude', refresh = false) {
1165
+ const cached = usageCache.get(kind);
1166
+ if (cached && (cached.pending || (!refresh && Date.now() - cached.at < 30_000))) return cached.value;
1167
+ const value = readUsage(kind).then(usage => providerUsage(kind, usage), () => providerUsage(kind, undefined));
1168
+ const entry = { at: Date.now(), pending: true, value };
1169
+ usageCache.set(kind, entry);
1170
+ void value.then(() => { entry.pending = false; entry.at = Date.now(); });
1171
+ return value;
1172
+ }
1173
+
1174
+ function pauseRuntime(scope: RuntimeKind | 'all', pause: Pause, now = false) {
1175
+ if (scope === 'all') paused.all = pause;
1176
+ else { paused.runtimes[scope] = pause; limitChecks.delete(scope); }
1177
+ savePauses();
1178
+ watchLimits();
1179
+ if (now) {
1180
+ for (const [id, session] of sessions) {
1181
+ if (!session.parent || session.closed || !session.active || session.role === 'supervisor' || (scope !== 'all' && session.runtimeKind !== scope)) continue;
1182
+ session.parkReason = `paused by ${pause.by}`;
1183
+ void session.runtime.request('turn/interrupt', { threadId: session.threadId, turnId: session.active }).catch(() => {});
1184
+ runLog(rootOf(id), { event: 'assignment.parking', assignmentId: id, agent: session.mapping.agent.name, reason: session.parkReason });
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ function resumeRuntime(scope: RuntimeKind | 'all', by: string) {
1190
+ if (scope === 'all') paused = { runtimes: {} };
1191
+ else delete paused.runtimes[scope];
1192
+ savePauses();
1193
+ watchLimits();
1194
+ const free = RUNTIMES.filter(kind => !pauseOf(kind));
1195
+ if (free.length) notice('info', say => say.resumed(scope === 'all' ? 'ALP' : label(scope), by, pauseState().parked.length > 0));
1196
+ for (const [id, session] of sessions) {
1197
+ if (session.closed || pauseOf(session.runtimeKind)) continue;
1198
+ if (session.parked) continueParked(id, session);
1199
+ else if (session.wakeHeld) { session.wakeHeld = false; deliver(id, session); }
1200
+ }
1201
+ }
1202
+
1203
+ /** An assignment whose turn a limit or a pause ended stays open, and its requester learns why. */
1204
+ function park(sessionId: string, session: Session) {
1205
+ session.parked = { reason: session.parkReason!, since: Date.now() };
1206
+ const then = session.parkThen;
1207
+ session.parkReason = undefined;
1208
+ session.parkThen = undefined;
1209
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
1210
+ const parent = session.parent ? sessions.get(session.parent) : undefined;
1211
+ const assignment = parent?.assignments.get(sessionId);
1212
+ runLog(rootOf(sessionId), { event: 'assignment.parked', assignmentId: sessionId, agent: session.mapping.agent.name, reason: session.parked.reason });
1213
+ if (!parent || !assignment) return;
1214
+ emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'parked') });
1215
+ // One that continues by itself is information; one held until the user resumes needs the requester's decision, so it wakes it.
1216
+ if (then) post(session.parent!, { kind: 'note', from: assignment.agent, assignment: sessionId, passive: true, body: `Parked: ${session.parked.reason}. ${then} Wait for it, or start other work.` });
1217
+ else post(session.parent!, { kind: 'stalled', from: assignment.agent, assignment: sessionId, body: `Parked: ${session.parked.reason}. ALP continues this assignment where it stopped when ${label(session.runtimeKind)} is resumed (${options.autoResume !== false && (pauseOf(session.runtimeKind)?.by ?? 'alpd') === 'alpd' ? `ALP resumes it as soon as ${label(session.runtimeKind)} says the limit lifted` : 'the user runs alp resume'}). Wait for it, start other work, or stop it with alp_cancel and give the rest to an agent on another runtime (pass model claude:… or codex:…); what it changed stays in your checkout or its worktree.` });
1218
+ }
1219
+
1220
+ function continueParked(sessionId: string, session: Session) {
1221
+ const { reason, prompt } = session.parked!;
1222
+ session.parked = undefined;
1223
+ session.wakeHeld = false;
1224
+ session.lastActivity = Date.now();
1225
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
1226
+ const parent = session.parent ? sessions.get(session.parent) : undefined;
1227
+ const assignment = parent?.assignments.get(sessionId);
1228
+ runLog(rootOf(sessionId), { event: 'assignment.resumed', assignmentId: sessionId, agent: session.mapping.agent.name });
1229
+ if (assignment) emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'running') });
1230
+ void startPrompt(sessionId, {
1231
+ clientMessageId: `alp-resume-${randomUUID()}`,
1232
+ delivery: 'auto',
1233
+ content: [{ type: 'text', text: prompt ?? `ALP resumed this assignment; it had stopped because ${reason}. Continue where you left off: your earlier work in this session and its files are intact. Finish with alp_handoff as before.` }],
1234
+ }, session.parent ? 'assignment' : 'wake').catch(error => session.settle?.('failed', error));
1235
+ }
1236
+
1237
+ /** When a limit resets, from what the runtime last reported: the latest reset of a window that is used up. */
1238
+ async function limitReset(session: Session, error: any): Promise<string | undefined> {
1239
+ const at = (seconds: unknown) => typeof seconds === 'number' && seconds > 0 ? new Date(seconds * 1000).toISOString() : undefined;
1240
+ if (error?.resetsAt) return at(error.resetsAt);
1241
+ const report = usage.get(session.runtimeKind);
1242
+ if (session.runtimeKind === 'claude') return at(report?.claude?.resetsAt);
1243
+ let windows = [report?.rateLimits?.primary, report?.rateLimits?.secondary].filter(Boolean);
1244
+ if (!windows.some(window => window.usedPercent >= 100)) {
1245
+ const context: any = await session.runtime.orchestrationContext?.().catch(() => undefined);
1246
+ windows = (context?.usage?.limits ?? []).flatMap((limit: any) => limit.windows ?? []);
1247
+ }
1248
+ const resets = windows.filter(window => window.usedPercent >= 100 && window.resetsAt).map(window => window.resetsAt as number);
1249
+ return resets.length ? at(Math.max(...resets)) : undefined;
1250
+ }
1251
+
1252
+ /** A turn failed on a usage limit: pause that runtime for delegation and wakes, and tell the user. */
1253
+ async function limitReached(session: Session, error: any) {
1254
+ const kind = session.runtimeKind;
1255
+ if (paused.runtimes[kind]?.by === 'alpd') return;
1256
+ const resetsAt = await limitReset(session, error);
1257
+ const pause: Pause = { since: new Date().toISOString(), by: 'alpd', reason: `${label(kind)} usage limit reached`, ...(resetsAt ? { resetsAt } : {}) };
1258
+ pauseRuntime(kind, pause);
1259
+ const other = RUNTIMES.find(candidate => candidate !== kind && !pauseOf(candidate));
1260
+ notice('error', say => say.limitReached(label(kind), resetsAt, other && label(other), options.autoResume !== false, kind));
1261
+ }
1262
+
1263
+ /**
1264
+ * Tells a session once that its context nears compaction (ALPD §57), measured against
1265
+ * the fill at which its runtime compacts: Claude says where; otherwise COMPACT_SHARE of
1266
+ * the window, or of the context setting. Nothing earlier, and no push to hand off: the
1267
+ * runtime compacts and keeps going, and ALP gives the session its state again after.
1268
+ */
1269
+ function contextReport(sessionId: string, session: Session, usage: any) {
1270
+ const used = usage?.last?.totalTokens;
1271
+ const window = usage?.modelContextWindow;
1272
+ if (session.role === 'supervisor' || typeof used !== 'number' || typeof window !== 'number' || window <= 0) return;
1273
+ const compactAt = typeof usage.autoCompactTokens === 'number' && usage.autoCompactTokens > 0
1274
+ ? usage.autoCompactTokens
1275
+ : session.runtimeKind === 'codex' && session.mapping.autocompact
1276
+ ? Math.min(session.mapping.autocompact, Math.round(window * COMPACT_SHARE))
1277
+ : Math.round(Math.min(session.mapping.autocompact ?? session.mapping.context ?? window, window) * COMPACT_SHARE);
1278
+ const fill = used / compactAt;
1279
+ if (fill < CONTEXT_RESET) { session.contextLevel = 0; return; }
1280
+ if (fill < CONTEXT_SOON || session.contextLevel) return;
1281
+ session.contextLevel = 1;
1282
+ runLog(rootOf(sessionId), { event: 'context', sessionId, agent: session.mapping.agent.name, tokens: used, compactAt, level: 'soon' });
1283
+ const size = `Your context holds about ${thousands(used)} tokens; the runtime compacts it at about ${thousands(compactAt)} and keeps going.`;
1284
+ const body = session.parent
1285
+ ? `${size} No handoff is needed for that: keep working. After the compaction ALP gives you your brief again; write anything you will need later where it lasts, such as your task's notes.`
1286
+ : `${size} Make sure decisions and findings are pinned with alp_pin and task notes are current; after the compaction ALP gives you your assignments, open questions and tasks again.`;
1287
+ // During a turn it steers in; otherwise it waits for the next turn without starting one.
1288
+ post(sessionId, { kind: 'note', from: 'alp', assignment: sessionId, body, ...(session.active ? {} : { passive: true }) });
1289
+ }
1290
+
1291
+ /**
1292
+ * An agent starting to use one of its skills (ALPD §59): Claude's Skill tool naming it,
1293
+ * or a command or tool reading its SKILL.md. Logged once per session and skill, and
1294
+ * told to the supervisor, so how often skills are used can be read off the run log.
1295
+ */
1296
+ function skillUse(sessionId: string, session: Session, item: any) {
1297
+ const skills = session.mapping.agent.skills;
1298
+ if (!skills.length || item.type === 'agentMessage' || item.type === 'reasoning') return;
1299
+ const text = typeof item.command === 'string' ? item.command : JSON.stringify(item).slice(0, 20_000);
1300
+ if (!text.includes('SKILL.md') && !text.includes('Skill')) return;
1301
+ for (const skill of skills) {
1302
+ if (session.skillsUsed?.has(skill.name)) continue;
1303
+ const called = new RegExp(`^Skill \\{"skill":"(?:${SKILL_PLUGIN}:)?${skill.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`).test(text);
1304
+ const read = text.includes(skill.path) || text.includes(`/skills/${skill.name}/SKILL.md`);
1305
+ if (!called && !read) continue;
1306
+ (session.skillsUsed ??= new Set()).add(skill.name);
1307
+ runLog(rootOf(sessionId), { event: 'skill', sessionId, agent: session.mapping.agent.name, skill: skill.name, via: called ? 'skill' : 'read' });
1308
+ if (session.supervisor) session.journal.push(stamp(`${session.mapping.agent.name} used skill ${skill.name}`));
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * A compaction of the session's context, from its start to its end (ALPD §57): shown in
1314
+ * its timeline and the run log; once done, ALP tells the session what it holds of its
1315
+ * work, which the runtime's summary may have lost.
1316
+ */
1317
+ function compaction(sessionId: string, session: Session, item: any, done: boolean) {
1318
+ const failed = done && item.status === 'failed';
1319
+ const tokens = { ...(typeof item.preTokens === 'number' ? { preTokens: item.preTokens } : {}), ...(typeof item.postTokens === 'number' ? { postTokens: item.postTokens } : {}) };
1320
+ const trigger = item.trigger === 'manual' || item.trigger === 'auto' ? { trigger: item.trigger as 'manual' | 'auto' } : {};
1321
+ emit(sessionId, { type: 'item', item: { kind: 'compaction', id: String(item.id), status: !done ? 'running' : failed ? 'failed' : 'completed', ...trigger, ...tokens } });
1322
+ if (!done) return;
1323
+ const agent = session.mapping.agent.name;
1324
+ runLog(rootOf(sessionId), { event: 'compacted', sessionId, agent, ...trigger, ...tokens, ...(failed ? { failed: true, ...(item.error ? { error: String(item.error) } : {}) } : {}) });
1325
+ if (failed) return;
1326
+ session.contextLevel = 0;
1327
+ if (session.supervisor) session.journal.push(stamp(`${agent}'s context was compacted`));
1328
+ if (session.role !== 'supervisor') void restoreAfterCompaction(sessionId, session).catch(() => {});
1329
+ }
1330
+
1331
+ /** What ALP holds of a session's work, told to it after a compaction (ALPD §57). */
1332
+ async function restoreAfterCompaction(sessionId: string, session: Session) {
1333
+ const parts = ['Your context was just compacted. In case the summary lost any of it, this is what ALP holds of your work now.'];
1334
+ if (session.parent && session.brief) parts.push(`Your assignment from ${session.parentAgent ?? 'your requester'}, as it was given:\n${cut(session.brief, BRIEF_CHARS)}`);
1335
+ const running = [...session.assignments.values()].filter(assignment => !assignment.finished);
1336
+ if (running.length) {
1337
+ parts.push('Your assignments still open:\n' + running.map(assignment =>
1338
+ `- ${assignment.id}: ${assignment.agent}${assignment.taskId ? ` on ${assignment.taskId}` : ''}, ${sessions.get(assignment.id)?.parked ? 'parked' : 'running'} for ${span(Date.now() - assignment.startedAt)}`).join('\n'));
1339
+ }
1340
+ if (session.worktrees.size) {
1341
+ parts.push('Finished worktree changes waiting for alp_merge or alp_discard:\n' + [...session.worktrees].map(([id, change]) => `- ${id}: ${change.agent}${change.taskId ? ` on ${change.taskId}` : ''}`).join('\n'));
1342
+ }
1343
+ const asked = [...userQuestions.values()].filter(pending => pending.question.sessionId === sessionId);
1344
+ if (asked.length) parts.push('Waiting for the user to answer:\n' + asked.map(pending => `- ${clip(pending.question.body, 300)}`).join('\n'));
1345
+ if (!session.parent) {
1346
+ const project = session.mapping.agent.projectRoot;
1347
+ const tasks = await loadTasks(project).then(({ tasks, errors }) => taskDigest(tasks, errors), () => '');
1348
+ if (tasks) parts.push(tasks);
1349
+ const board = renderBoard(await boardOf(project));
1350
+ if (board) parts.push(board);
1351
+ }
1352
+ parts.push('Carry on where you were; alp_board and alp_task tell you more.');
1353
+ if (session.closed) return;
1354
+ post(sessionId, { kind: 'note', from: 'alp', assignment: sessionId, body: parts.join('\n\n'), ...(session.active ? {} : { passive: true }) });
1355
+ }
1356
+
1357
+ /** Remembers a runtime's usage report and warns once per window when it nears its limit. */
1358
+ function usageReport(session: Session, params: any) {
1359
+ const kind = session.runtimeKind;
1360
+ usage.set(kind, params);
1361
+ const claude = params?.claude;
1362
+ const windows = [params?.rateLimits?.primary, params?.rateLimits?.secondary].filter(Boolean);
1363
+ const high = claude ? (claude.status === 'allowed_warning' ? { used: claude.utilization, resetsAt: claude.resetsAt } : undefined)
1364
+ : windows.filter(window => window.usedPercent >= 90 && window.usedPercent < 100).map(window => ({ used: window.usedPercent, resetsAt: window.resetsAt }))[0];
1365
+ if (!high) return;
1366
+ const key = `${kind}:${high.resetsAt ?? ''}`;
1367
+ if (warned.has(key)) return;
1368
+ warned.add(key);
1369
+ const used = typeof high.used === 'number' ? `${Math.round(high.used <= 1 ? high.used * 100 : high.used)}%` : 'nearly all';
1370
+ notice('warning', say => say.usageWarning(label(kind), used, high.resetsAt ? new Date(high.resetsAt * 1000).toISOString() : undefined));
1371
+ }
1372
+
1373
+ // A limit pause an earlier alpd left is checked at once: the limit may have lifted meanwhile.
1374
+ watchLimits();
1375
+ for (const kind of RUNTIMES) void checkLimit(kind);
1376
+
1377
+ /** Projects whose tasks this runtime checked for assignments an earlier alpd left behind. */
1378
+ const orphanChecks = new Map<string, Promise<void>>();
1379
+
1380
+ /**
1381
+ * Once per project: tasks an assignment of an earlier alpd held go back to open,
1382
+ * since that assignment ended with it. Another live alpd's assignments are left alone.
1383
+ */
1384
+ function releaseOrphansOnce(project: string, rootId: string) {
1385
+ // Another alpd that is still running keeps its tasks; a pid reused by another process does not.
1386
+ const check = orphanChecks.get(project) ?? releaseOrphans(project,
1387
+ assignee => assignee.epoch !== epoch && !(assignee.pid && assignee.pid !== process.pid && sameProcessAlive(assignee.pid, assignee.pidStartedAt)),
1388
+ async assignee => await branchExists(project, `alp/${assignee.assignment}`) ? `work kept on branch alp/${assignee.assignment}` : undefined,
1389
+ 'alpd',
1390
+ ).then(released => {
1391
+ for (const task of released) {
1392
+ touchTask(rootId, task.id);
1393
+ runLog(rootId, { event: 'task', action: 'orphaned', agent: 'alpd', id: task.id, title: task.title, status: task.status, detail: task.log.at(-1)?.note ?? 'its assignment ended with an earlier alpd' });
1394
+ }
1395
+ }, () => {});
1396
+ orphanChecks.set(project, check);
1397
+ return check;
1398
+ }
1399
+
1400
+ const gateChecks = new Map<string, number>();
1401
+
1402
+ /** Clears GitHub gates whose pull request merged or run succeeded, at most once a minute per project. */
1403
+ async function checkGatesOften(project: string) {
1404
+ if (Date.now() - (gateChecks.get(project) ?? 0) < 60_000) return;
1405
+ const { tasks } = await loadTasks(project);
1406
+ if (!tasks.some(task => task.status !== 'closed' && task.gates.some(gate => (gate.kind === 'gh:pr' || gate.kind === 'gh:run') && !gate.resolved))) return;
1407
+ gateChecks.set(project, Date.now());
1408
+ const { cleared } = await checkGates(project, options.github ?? gh);
1409
+ for (const { task } of cleared) for (const [id, session] of sessions) if (!session.parent && session.mapping.agent.projectRoot === project && session.tasks.includes(task)) runLog(id, { event: 'task', action: 'clear', agent: 'github', id: task, title: tasks.find(entry => entry.id === task)?.title ?? '', status: 'open' });
1410
+ }
1411
+
1412
+ /** Records that a root's tree created or worked on a task, for the root's todo list. */
1413
+ function touchTask(rootId: string, id: string) {
1414
+ const root = sessions.get(rootId);
1415
+ if (root && !root.tasks.includes(id)) root.tasks.push(id);
1416
+ }
1417
+
1418
+ /** Shows the tasks a root's tree worked on as a todo list in its timeline, when the list changed. */
1419
+ async function showTasks(sessionId: string, session: Session) {
1420
+ if (!session.tasks.length) return;
1421
+ const { tasks } = await loadTasks(session.mapping.agent.projectRoot).catch(() => ({ tasks: [] as Task[] }));
1422
+ const index = new Map(tasks.map(task => [task.id, task]));
1423
+ const items = session.tasks.flatMap(id => {
1424
+ const task = index.get(id);
1425
+ return task ? [{
1426
+ id: task.id,
1427
+ text: `${task.id} · ${task.title}${task.status === 'review' ? ' (awaiting acceptance)' : ''}`,
1428
+ status: task.status === 'open' ? 'pending' as const : task.status === 'closed' ? 'completed' as const : 'in_progress' as const,
1429
+ }] : [];
1430
+ });
1431
+ const shown = JSON.stringify(items);
1432
+ if (!items.length || shown === session.tasksShown || session.closed) return;
1433
+ session.tasksShown = shown;
1434
+ emit(sessionId, { type: 'item', item: { kind: 'todo', id: `tasks-${randomUUID().slice(0, 8)}`, items } });
1435
+ }
1436
+
1437
+ /** The project's lessons file, then the user's when this host keeps a library. */
1438
+ function lessonFiles(mapping: ResolvedSession) {
1439
+ return [path.join(mapping.agent.projectRoot, '.alp', LESSONS_FILE), ...(options.libraryDir ? [path.join(options.libraryDir, LESSONS_FILE)] : [])];
1440
+ }
1441
+
1442
+ /**
1443
+ * Asks the user to approve a proposal and waits for the answer. Only an approving
1444
+ * answer approves; a dismissal, a timeout or another answer does not.
1445
+ */
1446
+ async function confirm(sessionId: string, session: Session, body: string): Promise<{ approved: boolean; feedback?: string; outcome?: string }> {
1447
+ if ([...userQuestions.values()].some(pending => pending.question.sessionId === sessionId)) return { approved: false, outcome: 'busy', feedback: 'A question is already waiting for the user' };
1448
+ const say = wordsFor(session);
1449
+ const result = await askUser(sessionId, session, undefined, body, [say.approve, say.reject]) as { contentItems: Array<{ text: string }> };
1450
+ let value: any;
1451
+ try { value = JSON.parse(result.contentItems[0].text); } catch { value = {}; }
1452
+ if (value.status !== 'answered') return { approved: false, outcome: value.status ?? 'canceled', ...(value.reason ? { feedback: value.reason } : {}) };
1453
+ const answer = String(value.answer).trim();
1454
+ return APPROVALS.includes(answer.toLowerCase()) ? { approved: true } : { approved: false, outcome: 'answered', feedback: answer };
1455
+ }
1456
+
1457
+ const declined = (decision: { feedback?: string; outcome?: string }) => toolResult(true, {
1458
+ approved: false,
1459
+ outcome: decision.outcome,
1460
+ ...(decision.feedback ? { feedback: decision.feedback } : {}),
1461
+ next: 'Nothing was saved or posted. Revise from the feedback and propose again, or drop it.',
1462
+ });
1463
+
1464
+ const text = (value: unknown, limit: number) => typeof value === 'string' && !!value.trim() && value.length <= limit;
1465
+
1466
+ async function skillTool(sessionId: string, session: Session, args: unknown) {
1467
+ if (
1468
+ !plainObject(args, ['name', 'description', 'body', 'roles', 'lessons', 'replace']) ||
1469
+ typeof args.name !== 'string' || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(args.name) ||
1470
+ !text(args.description, 300) || !text(args.body, SKILL_BODY_CHARS) ||
1471
+ !Array.isArray(args.roles) || !args.roles.length || args.roles.length > 10 || !args.roles.every((role: unknown) => typeof role === 'string' && /^[\w.-]+$/.test(role) && role !== '.' && role !== '..') ||
1472
+ (args.lessons !== undefined && (!Array.isArray(args.lessons) || args.lessons.length > 50 || !args.lessons.every((lesson: unknown) => text(lesson, LESSON_CHARS)))) ||
1473
+ (args.replace !== undefined && typeof args.replace !== 'boolean')
1474
+ ) {
1475
+ return toolResult(false, { error: `A skill needs a name (lowercase letters, digits, hyphens), a one-line description, a body of at most ${SKILL_BODY_CHARS} characters, and the roles that get it` });
1476
+ }
1477
+ if (!options.libraryDir) return toolResult(false, { error: 'This host keeps no user skill library' });
1478
+ const known = new Set([...STARTER_ROLES, ...await discoverAgents(session.mapping.agent.projectRoot, { library: options.libraryDir, templates: options.templates })]);
1479
+ const unknown = args.roles.filter((role: string) => !known.has(role));
1480
+ if (unknown.length) return toolResult(false, { error: `Unknown roles: ${unknown.join(', ')}. Roles are ${[...known].join(', ')}` });
1481
+ const file = path.join(options.libraryDir, 'skills', args.name, 'SKILL.md');
1482
+ const existing = await readFile(file, 'utf8').catch(() => undefined);
1483
+ if (existing !== undefined && !args.replace) return toolResult(false, { error: `The library already has a skill named ${args.name} (${file}); read it, then pass replace: true to propose a new version, or choose another name` });
1484
+ const rolesFile = path.join(options.libraryDir, 'role-skills.json');
1485
+ const readRoles = async () => {
1486
+ const raw = await readFile(rolesFile, 'utf8').catch(() => '{}');
1487
+ const roles = JSON.parse(raw);
1488
+ if (!roles || typeof roles !== 'object' || Array.isArray(roles)) throw new Error(`${rolesFile} must be an object of role names to skill lists`);
1489
+ return roles as Record<string, unknown>;
1490
+ };
1491
+ try { await readRoles(); } catch (error) { return toolResult(false, { error: errorData(error).message }); }
1492
+ const content = `---\nname: ${args.name}\ndescription: ${JSON.stringify(args.description.replace(/\s+/g, ' ').trim())}\n---\n\n${args.body.trim()}\n`;
1493
+ const lessons: string[] = (args.lessons ?? []).map((lesson: string) => lesson.replace(/\s+/g, ' ').trim());
1494
+ const say = wordsFor(session);
1495
+ const decision = await confirm(sessionId, session,
1496
+ say.skill(session.mapping.agent.name, existing !== undefined, file, args.roles.join(', '), lessons) +
1497
+ `\n${say.answerLine(session.mapping.agent.name)}\n\n${content}`);
1498
+ if (!decision.approved) return declined(decision);
1499
+ try {
1500
+ await mkdir(path.dirname(file), { recursive: true });
1501
+ await writeFile(file, content);
1502
+ const roles = await readRoles();
1503
+ for (const role of args.roles) {
1504
+ const list = Array.isArray(roles[role]) ? roles[role] as string[] : [];
1505
+ if (!list.includes(args.name)) list.push(args.name);
1506
+ roles[role] = list;
1507
+ }
1508
+ await writeFile(rolesFile, JSON.stringify(roles, null, 2) + '\n');
1509
+ } catch (error) {
1510
+ return toolResult(false, { error: errorData(error).message });
1511
+ }
1512
+ // The lessons the skill replaces leave both files; a lesson matches only by its exact text.
1513
+ const removed = new Set<string>();
1514
+ for (const lessonFile of lessonFiles(session.mapping)) {
1515
+ const current = await readFile(lessonFile, 'utf8').catch(() => undefined);
1516
+ if (current === undefined) continue;
1517
+ const kept = current.split('\n').filter(line => {
1518
+ const match = /^- \d{4}-\d\d-\d\d: (.*)$/.exec(line);
1519
+ if (!match || !lessons.includes(match[1].trim())) return true;
1520
+ removed.add(match[1].trim());
1521
+ return false;
1522
+ });
1523
+ if (kept.length !== current.split('\n').length) await writeFile(lessonFile, kept.join('\n')).catch(() => {});
1524
+ }
1525
+ runLog(rootOf(sessionId), { event: 'skill', agent: session.mapping.agent.name, name: args.name, roles: args.roles, replaced: existing !== undefined, lessons: [...removed] });
1526
+ return toolResult(true, {
1527
+ approved: true, saved: file, roles: args.roles, lessonsRemoved: [...removed],
1528
+ ...(lessons.length > removed.size ? { lessonsNotFound: lessons.filter(lesson => !removed.has(lesson)) } : {}),
1529
+ next: 'New sessions of those roles list the skill; read the file now if you need it in this one.',
1530
+ });
1531
+ }
1532
+
1533
+ async function issueTool(sessionId: string, session: Session, args: unknown) {
1534
+ if (!plainObject(args, ['action', 'target', 'query', 'title', 'body', 'issue', 'labels']) || !['search', 'create', 'comment'].includes(args.action) || !['project', 'alp'].includes(args.target)) {
1535
+ return toolResult(false, { error: 'An issue call needs action (search, create or comment) and target (project or alp)' });
1536
+ }
1537
+ const project = session.mapping.agent.projectRoot;
1538
+ let repo: string;
1539
+ try { repo = args.target === 'alp' ? ALP_REPO : await projectRepo(project); }
1540
+ catch (error) { return toolResult(false, { error: errorData(error).message }); }
1541
+ const github = options.github ?? gh;
1542
+ if (args.action === 'search') {
1543
+ if (!text(args.query, 200)) return toolResult(false, { error: 'search needs a query of at most 200 characters' });
1544
+ try {
1545
+ const output = await github(['issue', 'list', '-R', repo, '--state', 'all', '--limit', '10', '--search', args.query, '--json', 'number,title,state,url'], { cwd: project });
1546
+ return toolResult(true, { repo, issues: JSON.parse(output || '[]') });
1547
+ } catch (error) {
1548
+ return toolResult(false, { repo, error: errorData(error).message });
1549
+ }
1550
+ }
1551
+ if (!text(args.body, ISSUE_BODY_CHARS)) return toolResult(false, { error: `${args.action} needs a body of at most ${ISSUE_BODY_CHARS} characters` });
1552
+ if (args.action === 'create' && !text(args.title, 200)) return toolResult(false, { error: 'create needs a title of at most 200 characters' });
1553
+ if (args.action === 'comment' && (!Number.isSafeInteger(args.issue) || args.issue < 1)) return toolResult(false, { error: 'comment needs the issue number' });
1554
+ if (args.labels !== undefined && (args.action !== 'create' || !Array.isArray(args.labels) || args.labels.length > 10 || !args.labels.every((label: unknown) => text(label, 50)))) {
1555
+ return toolResult(false, { error: 'labels are up to 10 existing label names, for create' });
1556
+ }
1557
+ // Resolve labels before approval, so a draft with an unknown label never
1558
+ // consumes the user's approval and then fails during issue creation.
1559
+ if (args.action === 'create' && args.labels?.length) {
1560
+ try {
1561
+ const labels = JSON.parse(await github(['label', 'list', '-R', repo, '--limit', '1000', '--json', 'name'], { cwd: project }));
1562
+ const names = new Set(labels.map((label: { name: string }) => label.name.toLowerCase()));
1563
+ const missingLabels = args.labels.filter((label: string) => !names.has(label.toLowerCase()));
1564
+ if (missingLabels.length) return toolResult(false, { repo, error: `Unknown repository labels: ${missingLabels.join(', ')}`, missingLabels, next: 'Remove these labels or use existing labels, then submit the draft for approval.' });
1565
+ } catch (error) {
1566
+ return toolResult(false, { repo, error: `Could not validate issue labels: ${errorData(error).message}` });
1567
+ }
1568
+ }
1569
+ const where = `${repo}${args.target === 'alp' ? ' (ALP itself)' : ''}`;
1570
+ const say = wordsFor(session);
1571
+ const decision = await confirm(sessionId, session,
1572
+ say.issue(session.mapping.agent.name, args.action === 'create', args.issue, where) + '\n' +
1573
+ `${say.answerLine(session.mapping.agent.name)}\n\n` +
1574
+ (args.action === 'create' ? `${say.issueTitle}: ${args.title}\n${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(', ')}\n` : ''}\n` : '') + args.body);
1575
+ if (!decision.approved) return declined(decision);
1576
+ const body = args.body.trim() + ISSUE_FOOTER;
1577
+ try {
1578
+ const output = args.action === 'create'
1579
+ ? await github(['issue', 'create', '-R', repo, '--title', args.title, '--body-file', '-', ...(args.labels ?? []).flatMap((label: string) => ['--label', label])], { cwd: project, input: body })
1580
+ : await github(['issue', 'comment', String(args.issue), '-R', repo, '--body-file', '-'], { cwd: project, input: body });
1581
+ const url = output.trim().split('\n').at(-1) ?? '';
1582
+ runLog(rootOf(sessionId), { event: 'issue', action: args.action, repo, ...(args.title ? { title: args.title } : {}), ...(args.issue ? { issue: args.issue } : {}), url });
1583
+ return toolResult(true, { approved: true, posted: true, repo, url });
1584
+ } catch (error) {
1585
+ return toolResult(false, { approved: true, posted: false, repo, error: errorData(error).message, next: 'Tell the user; the GitHub CLI may need `gh auth login`.' });
1586
+ }
1587
+ }
1588
+
1589
+ /** Activity anywhere in a subtree keeps its ancestors' assignments alive. */
1590
+ function touch(sessionId: string, session: Session) {
1591
+ const now = Date.now();
1592
+ let id = sessionId;
1593
+ for (let current: Session | undefined = session; current; ) {
1594
+ current.lastActivity = now;
1595
+ const parent: Session | undefined = current.parent ? sessions.get(current.parent) : undefined;
1596
+ const assignment = parent?.assignments.get(id);
1597
+ if (assignment) assignment.warned = false;
1598
+ id = current.parent ?? '';
1599
+ current = parent;
1600
+ }
1601
+ }
1602
+
1603
+ function post(sessionId: string, event: Omit<MailEvent, 'id'>) {
1604
+ const mail: MailEvent = { id: `#${++mailSequence}`, ...event };
1605
+ const sender = event.kind === 'note' ? sessions.get(sessionId)?.assignments.get(event.assignment) : undefined;
1606
+ if (sender && event.body) sender.lastNote = clip(event.body, 240);
1607
+ const { result: _result, ...logged } = publicEvent(mail);
1608
+ runLog(rootOf(sessionId), { event: 'mail', to: sessionId, ...logged });
1609
+ const session = sessions.get(sessionId);
1610
+ if (session && !session.closed) {
1611
+ emit(sessionId, { type: 'mail', mail: publicEvent(mail) });
1612
+ session.mail.push(mail);
1613
+ deliver(sessionId, session);
1614
+ }
1615
+ return mail;
1616
+ }
1617
+
1618
+ /** Waiting tool calls first, then the running turn (steer), then an idle session (wake). */
1619
+ function deliver(sessionId: string, session: Session) {
1620
+ if (session.closed) return;
1621
+ for (const waiter of [...session.waiters]) {
1622
+ const batch = takeBatch(session.mail, event => event.kind !== 'board' && !event.defer && waiter.accept(event));
1623
+ if (!batch.length) continue;
1624
+ for (const event of batch) event.deliveredTurn = session.active;
1625
+ session.waiters.splice(session.waiters.indexOf(waiter), 1);
1626
+ clearTimeout(waiter.timer);
1627
+ waiter.resolve(batch);
1628
+ }
1629
+ if (!hasActiveMail(session)) return;
1630
+ // Notes from one's own assignments are information: they steer a running turn or ride
1631
+ // with the next one, but never wake an idle requester on their own.
1632
+ // Once none runs, what is left must still reach it, or a finished requester would never end.
1633
+ const waking = !session.assignments.size || session.mail.some(event => !event.deliveredTurn && !event.passive && !(event.kind === 'note' && event.assignment !== sessionId));
1634
+ if (session.pending) {
1635
+ void session.acknowledged.then(() => deliver(sessionId, session));
1636
+ } else if (session.active) {
1637
+ // Deferred mail waits for the turn to end.
1638
+ if (session.mail.some(event => !event.deliveredTurn && !event.passive && !event.defer)) void steerMail(sessionId, session);
1639
+ } else if (!waking) {
1640
+ return;
1641
+ } else if (!session.wakeBlocked && session.wakes < MAX_WAKES) {
1642
+ autoWake(sessionId, session);
1643
+ } else if (session.parent && !session.wakeBlocked && !held(session)) {
1644
+ // A child never gets the user prompt that resets its wakes; report instead of waiting out the watchdog.
1645
+ session.settle?.('failed', `Wake limit (${MAX_WAKES}) reached with mail outstanding`);
1646
+ }
1647
+ }
1648
+
1649
+ async function steerMail(sessionId: string, session: Session) {
1650
+ const turnId = session.active!;
1651
+ const batch = takeBatch(session.mail, event => !event.defer);
1652
+ if (!batch.length) return;
1653
+ for (const event of batch) event.deliveredTurn = STEERING;
1654
+ const id = `alp-mail-${randomUUID()}`;
1655
+ const text = promptSafe(renderMail(batch, session.parentAgent));
1656
+ let steered = false;
1657
+ try {
1658
+ await session.runtime.request('turn/steer', { threadId: session.threadId, expectedTurnId: turnId, clientUserMessageId: id, input: [{ type: 'text', text, text_elements: [] }] });
1659
+ steered = true;
1660
+ emit(sessionId, { type: 'item', item: { kind: 'user_message', id: `user:${id}`, clientMessageId: id, text } });
1661
+ } catch {}
1662
+ // If the turn ended meanwhile, receipt is uncertain: deliver again rather than lose mail.
1663
+ const received = steered && session.active === turnId;
1664
+ for (const event of batch) {
1665
+ if (event.deliveredTurn !== STEERING) continue;
1666
+ event.deliveredTurn = received ? turnId : undefined;
1667
+ if (!received && steered) event.redelivered = true;
1668
+ // A rejected steer cannot succeed again in this turn. Keep the mail for
1669
+ // its next prompt instead of starving the event loop with immediate retries.
1670
+ if (!steered && session.active === turnId) event.defer = true;
1671
+ }
1672
+ if (!received) deliver(sessionId, session);
1673
+ }
1674
+
1675
+ /** Queued behind client operations, so a wake never races a user prompt or an interrupt. */
1676
+ function autoWake(sessionId: string, session: Session) {
1677
+ // Its runtime is paused or it is parked: the mail waits for resume.
1678
+ if (held(session)) { session.wakeHeld = true; return; }
1679
+ const batch = takeBatch(session.mail, () => true);
1680
+ if (!batch.length) return;
1681
+ for (const event of batch) event.deliveredTurn = STARTING;
1682
+ const release = () => {
1683
+ for (const event of batch) if (event.deliveredTurn === STARTING) event.deliveredTurn = undefined;
1684
+ };
1685
+ const work = queue.then(async () => {
1686
+ if (closed || session.closed) return release();
1687
+ // Whatever ran first decides: a running turn gets the mail by steering, an interrupt holds it.
1688
+ if (session.wakeBlocked || session.active || session.pending) {
1689
+ release();
1690
+ return deliver(sessionId, session);
1691
+ }
1692
+ if (!batch.every(event => event.kind === 'checkin')) session.wakes++;
1693
+ const id = `alp-wake-${randomUUID()}`;
1694
+ try {
1695
+ await startPrompt(sessionId, { clientMessageId: id, delivery: 'auto', content: [{ type: 'text', text: renderMail(batch, session.parentAgent) }] }, 'wake', batch);
1696
+ } catch {
1697
+ release();
1698
+ deliver(sessionId, session);
1699
+ }
1700
+ });
1701
+ queue = work.catch(() => {});
1702
+ }
1703
+
1704
+ /**
1705
+ * Resolves with delivered mail, [] on timeout, or null when the turn ends.
1706
+ * A waiting delegate goes first so a concurrent catch-all alp_wait cannot take its result.
1707
+ */
1708
+ function waitFor(sessionId: string, session: Session, wanted: (event: MailEvent) => boolean, timeoutMs?: number, first = false) {
1709
+ // A check-in ends any wait, and so does mail to the session itself: its requester's
1710
+ // steer or the user's words must not sit behind a long wait for its own assignments.
1711
+ const accept = (event: MailEvent) => event.kind === 'checkin' || event.assignment === sessionId || wanted(event);
1712
+ const ready = takeBatch(session.mail, event => event.kind !== 'board' && !event.defer && accept(event));
1713
+ if (ready.length) {
1714
+ for (const event of ready) event.deliveredTurn = session.active;
1715
+ return Promise.resolve<MailEvent[] | null | 'user'>(ready);
1716
+ }
1717
+ return new Promise<MailEvent[] | null | 'user'>(resolve => {
1718
+ const waiter: Waiter = { accept, resolve };
1719
+ if (timeoutMs !== undefined) {
1720
+ waiter.timer = setTimeout(() => {
1721
+ const index = session.waiters.indexOf(waiter);
1722
+ if (index >= 0) session.waiters.splice(index, 1);
1723
+ resolve([]);
1724
+ }, timeoutMs);
1725
+ }
1726
+ if (first) session.waiters.unshift(waiter);
1727
+ else session.waiters.push(waiter);
1728
+ });
1729
+ }
1730
+
1731
+ function running(session: Session) {
1732
+ const now = Date.now();
1733
+ return [...session.assignments.values()].map(assignment => ({
1734
+ assignmentId: assignment.id,
1735
+ agent: assignment.agent,
1736
+ status: sessions.get(assignment.id)?.parked ? 'parked' : assignment.ask ? 'waiting_parent' : 'running',
1737
+ idleMs: now - (sessions.get(assignment.id)?.lastActivity ?? assignment.startedAt),
1738
+ }));
1739
+ }
1740
+
1741
+ /** Reports a silent assignment once, then fails it at twice the limit; time spent asking does not count. */
1742
+ function watch() {
1743
+ if (watchdog) return;
1744
+ watchdog = setInterval(() => {
1745
+ let live = 0;
1746
+ const now = Date.now();
1747
+ for (const [parentId, parent] of [...sessions]) {
1748
+ for (const assignment of [...parent.assignments.values()]) {
1749
+ live++;
1750
+ const child = sessions.get(assignment.id);
1751
+ if (!child || assignment.ask || assignment.finished) continue;
1752
+ // A parked assignment, or one on a paused runtime, is not stalled; its silence starts over on resume.
1753
+ if (held(child)) { child.lastActivity = now; continue; }
1754
+ const idle = now - child.lastActivity;
1755
+ if (idle >= 2 * silentForMs) {
1756
+ void finishAssignment(parentId, parent, assignment, 'failed', `Assignment timed out: no activity for ${2 * silentForMs} ms`);
1757
+ } else if (idle >= silentForMs && !assignment.warned) {
1758
+ assignment.warned = true;
1759
+ post(parentId, { kind: 'stalled', from: assignment.agent, assignment: assignment.id, passive: true, body: `No activity for ${Math.round(idle / 1000)} s; the assignment fails after ${Math.round(2 * silentForMs / 1000)} s without activity.` });
1760
+ }
1761
+ }
1762
+ checkIn(parentId, parent, now);
1763
+ }
1764
+ if (!live) {
1765
+ clearInterval(watchdog);
1766
+ watchdog = undefined;
1767
+ }
1768
+ }, options.watchMs ?? Math.max(5, Math.min(silentForMs / 4, checkInMs ? checkInMs / 4 : 30_000, 30_000)));
1769
+ watchdog.unref?.();
1770
+ }
1771
+
1772
+ /**
1773
+ * Main hears how its running assignments are doing every checkInMs, so it can tell
1774
+ * the user; any requester hears once when an assignment passes its ETA.
1775
+ */
1776
+ function checkIn(parentId: string, parent: Session, now: number) {
1777
+ const live = [...parent.assignments.values()].filter(assignment => !assignment.finished);
1778
+ if (!live.length || parent.closed) { parent.checkInAt = undefined; return; }
1779
+ parent.checkInAt ??= now;
1780
+ if (held(parent)) return;
1781
+ const late = live.filter(assignment => assignment.eta !== undefined && now >= assignment.eta && !assignment.overdue);
1782
+ const userFacing = !parent.parent && parent.mapping.agent.name === mainOf(parent.mapping);
1783
+ if (!late.length && !(userFacing && checkInMs > 0 && now - parent.checkInAt >= checkInMs)) return;
1784
+ for (const assignment of late) assignment.overdue = true;
1785
+ parent.checkInAt = now;
1786
+ const lines = live.map(assignment => {
1787
+ const child = sessions.get(assignment.id);
1788
+ const parts = [
1789
+ `${assignment.agent} (${assignment.id}${assignment.taskId ? `, task ${assignment.taskId}` : ''}): running ${span(now - assignment.startedAt)}`,
1790
+ assignment.ask ? 'waiting for your answer' : child && held(child) ? 'paused' : `last activity ${span(now - (child?.lastActivity ?? assignment.startedAt))} ago`,
1791
+ ...(assignment.eta !== undefined ? [now >= assignment.eta ? `past its ETA by ${span(now - assignment.eta)}` : `ETA in ${span(assignment.eta - now)}`] : []),
1792
+ ...(assignment.lastNote ? [`last note: ${assignment.lastNote}`] : []),
1793
+ ];
1794
+ return `- ${parts.join('; ')}`;
1795
+ });
1796
+ const next = userFacing
1797
+ ? 'Tell the user in one or two lines how the work is going, unless you told them moments ago and nothing changed. Act on work past its ETA or long silent: ask it with alp_send, steer it, or tell the user. Then keep waiting or end your turn; ALP wakes you with results.'
1798
+ : 'Act on work past its ETA: ask it with alp_send, steer it, or tell your requester. Then keep waiting.';
1799
+ post(parentId, { kind: 'checkin', from: 'alp', assignment: '', body: [`${live.length} ${live.length === 1 ? 'assignment' : 'assignments'} still running${late.length ? `; ${late.map(assignment => assignment.agent).join(', ')} past the ETA you gave` : ''}:`, ...lines, next].join('\n') });
1800
+ }
1801
+
1802
+ /** The user wrote to a waiting session: its waits return so it answers them now. */
1803
+ function releaseWaiters(session: Session) {
1804
+ for (const waiter of session.waiters.splice(0)) {
1805
+ clearTimeout(waiter.timer);
1806
+ waiter.resolve('user');
1807
+ }
1808
+ }
1809
+
1810
+ const assignmentSnapshot = (assignment: Assignment, status: string): AssignmentSnapshot => ({
1811
+ id: assignment.id,
1812
+ agent: assignment.agent,
1813
+ mode: assignment.mode,
1814
+ status,
1815
+ startedAt: new Date(assignment.startedAt).toISOString(),
1816
+ ...(assignment.worktree ? { worktree: { branch: assignment.worktree.branch, path: assignment.worktree.workdir } } : {}),
1817
+ });
1818
+
1819
+ /** The session and its requesters, nearest first. */
1820
+ function lineage(sessionId: string) {
1821
+ const ids: string[] = [];
1822
+ for (let id: string | undefined = sessionId; id && sessions.has(id); id = sessions.get(id)?.parent) ids.push(id);
1823
+ return ids;
1824
+ }
1825
+
1826
+ const { settleWorktree, worktreeTool, inCheckout, verifyIn, verificationResult, noteVerification, continuedBrief, verifyTool } = createWorktreeDomain({
1827
+ options, leases, rootOf, lineage, runLog, runHooks, touchTask,
1828
+ });
1829
+
1830
+ /**
1831
+ * Moves an assignment's task on when it ends: a complete or partial handoff
1832
+ * sends it to review for main to accept; anything else opens it again.
1833
+ */
1834
+ async function settleTask(parent: Session, assignment: Assignment, state: string, handoff: Handoff | undefined) {
1835
+ const project = parent.mapping.agent.projectRoot;
1836
+ const id = assignment.taskId!;
1837
+ // Its claim on the task's paths ends with it, also when its session never opened.
1838
+ releaseClaims(assignment.id, project);
1839
+ try {
1840
+ const done = !!handoff && state === 'completed' && (handoff.outcome === 'complete' || handoff.outcome === 'partial');
1841
+ const task = done
1842
+ ? await submitTask(project, id, { assignment: assignment.id, handoff: handoff!, agent: assignment.agent }, assignment.agent)
1843
+ : await releaseTask(project, id, { assignment: assignment.id, handoff: handoff ?? null, agent: assignment.agent, reason: handoff ? `handoff ${handoff.outcome}` : `assignment ${state} without a handoff` }, assignment.agent);
1844
+ touchTask(assignment.rootId, id);
1845
+ const action = task.status === 'review' ? 'submit' : task.status === 'open' ? 'release' : undefined;
1846
+ if (action) runLog(assignment.rootId, { event: 'task', action, agent: assignment.agent, id, title: task.title, status: task.status, detail: handoff ? `handoff ${handoff.outcome}: ${handoff.summary}` : `${state} without a handoff` });
1847
+ return { id, status: task.status, ...(task.status === 'review' ? { next: 'Verify the handoff, then accept it with alp_task close, or delegate the task again for rework' } : {}) };
1848
+ } catch (error) {
1849
+ return { id, error: errorData(error).message };
1850
+ }
1851
+ }
1852
+
1853
+ async function finishAssignment(parentId: string, parent: Session, assignment: Assignment, state: string, error?: unknown, quiet = false) {
1854
+ if (assignment.finished) return;
1855
+ assignment.finished = true;
1856
+ // alpd is stopping: the assignment stays in the live file and its task stays in progress, for the next alpd to continue.
1857
+ if (closed && inFlight.get(assignment.id)) {
1858
+ runLog(assignment.rootId, { event: 'assignment.interrupted', assignmentId: assignment.id, agent: assignment.agent, reason: 'alpd stopped' });
1859
+ await closeSession(assignment.id);
1860
+ if (assignment.lease && leases.get(assignment.lease)?.assignment === assignment.id) leases.delete(assignment.lease);
1861
+ childContexts.delete(assignment.id);
1862
+ parent.children.delete(assignment.id);
1863
+ parent.assignments.delete(assignment.id);
1864
+ return;
1865
+ }
1866
+ // Removed before anything else, so a crash while finishing never runs finished work again.
1867
+ void inFlight.remove(assignment.id);
1868
+ const child = sessions.get(assignment.id);
1869
+ if (child && state === 'failed' && child.active) terminal(assignment.id, child, 'failed', error);
1870
+ assignment.ask?.resolve(toolResult(false, { error: 'Assignment ended' }));
1871
+
1872
+ // Interim commentary stays in the child timeline; the final message is the answer.
1873
+ const result: Record<string, unknown> = {
1874
+ agent: assignment.agent,
1875
+ ...(child ? { runtime: child.runtimeKind, threadId: child.threadId } : {}),
1876
+ sessionId: assignment.id,
1877
+ status: state,
1878
+ handoff: child?.handoff ?? null,
1879
+ output: child ? [...child.text.values()].at(-1) ?? '' : '',
1880
+ ...(error ? { error: errorData(error).message } : {}),
1881
+ };
1882
+
1883
+ await closeSession(assignment.id);
1884
+ // Its native thread stays on disk, so its requesters can ask it about its work later.
1885
+ // An ACP agent cannot fork a session to answer questions about it.
1886
+ if (child?.threadId && child.mapping.keepThread && child.runtimeKind !== 'acp') {
1887
+ void recalls.add({
1888
+ assignmentId: assignment.id, rootId: assignment.rootId, requesters: lineage(parentId), agent: assignment.agent,
1889
+ project: child.mapping.agent.projectRoot, runtime: child.runtimeKind, threadId: child.threadId, cwd: child.mapping.workdir,
1890
+ model: child.mapping.model, ...(child.mapping.thinking ? { thinking: child.mapping.thinking } : {}),
1891
+ ...(assignment.taskId ? { taskId: assignment.taskId } : {}), status: state, finishedAt: new Date().toISOString(),
1892
+ ...(assignment.isolation === 'shared' && !assignment.copy ? { shared: {
1893
+ brief: cut(child.brief ?? '', BRIEF_CHARS),
1894
+ summary: cut(JSON.stringify({ handoff: child.handoff ?? null, output: result.output }), BRIEF_CHARS),
1895
+ ...(child.spec.writeRoot ? { writeRoot: child.spec.writeRoot } : {}),
1896
+ } } : {}),
1897
+ }).then(forgetExpired);
1898
+ }
1899
+ if (assignment.copy) {
1900
+ if (assignment.escapes?.length) result.copyWarning = `${assignment.agent} ran commands in your tree, not its copy; they may have changed files there: ${assignment.escapes.join('; ')}`;
1901
+ await removeCopy(assignment.copy).catch(() => {});
1902
+ runLog(rootOf(parentId), { event: 'copy.removed', assignmentId: assignment.id, path: assignment.copy.path });
1903
+ }
1904
+ // A writer in the shared checkout left its change there: check it while still holding the lease, so no other writer changes the tree meanwhile.
1905
+ if (state === 'completed' && assignment.isolation === 'shared' && !assignment.copy && writes(assignment.mode) && child) {
1906
+ const project = child.spec.writeRoot ?? child.mapping.agent.projectRoot;
1907
+ const config = await verifyConfig(project).catch(() => undefined);
1908
+ const checkout = await checkoutKey(child.mapping.workdir);
1909
+ const changed = !assignment.fingerprint || assignment.fingerprint !== await checkoutFingerprint(checkout).catch(() => undefined);
1910
+ if (config && changed) {
1911
+ const verification = await inCheckout(checkout, () => verifyIn(project, child.mapping.workdir, config));
1912
+ result.verification = verificationResult(verification);
1913
+ noteVerification(rootOf(parentId), child.mapping.agent.projectRoot, assignment.taskId, assignment.id, 'checkout', verification);
1914
+ }
1915
+ }
1916
+ if (assignment.lease && leases.get(assignment.lease)?.assignment === assignment.id) leases.delete(assignment.lease);
1917
+ if (assignment.worktree) result.worktree = await settleWorktree(parentId, parent, assignment);
1918
+ if (assignment.taskId) result.task = await settleTask(parent, assignment, state, child?.handoff);
1919
+
1920
+ if (child) void runHooks(assignment.id, child, 'assignment.end', { assignment: assignment.id, status: state, handoff: child.handoff ?? null }, assignment.taskId);
1921
+ runLog(rootOf(parentId), {
1922
+ event: 'assignment.finished',
1923
+ assignmentId: assignment.id,
1924
+ durationMs: Date.now() - assignment.startedAt,
1925
+ ...result,
1926
+ });
1927
+ if (!parent.closed) emit(parentId, { type: 'assignment', assignment: assignmentSnapshot(assignment, state) });
1928
+
1929
+ childContexts.delete(assignment.id);
1930
+ parent.children.delete(assignment.id);
1931
+ parent.assignments.delete(assignment.id);
1932
+ if (roleOf(parent.mapping, assignment.agent) === 'peer') {
1933
+ const root = sessions.get(assignment.rootId);
1934
+ if (root) root.peerCount--;
1935
+ }
1936
+ parent.mail = parent.mail.filter(event => event.deliveredTurn || event.kind !== 'question' || event.assignment !== assignment.id);
1937
+ if (!quiet) post(parentId, { kind: 'result', from: assignment.agent, assignment: assignment.id, result });
1938
+ }
1939
+
1940
+ async function toolCall(
1941
+ sessionId: string,
1942
+ session: Session,
1943
+ params: any,
1944
+ ): Promise<unknown> {
1945
+ await session.acknowledged;
1946
+
1947
+ if (
1948
+ session.closed ||
1949
+ !session.active ||
1950
+ params.threadId !== session.threadId ||
1951
+ params.turnId !== session.active
1952
+ ) {
1953
+ return toolResult(false, {
1954
+ error: 'ALP tools require the current active turn',
1955
+ });
1956
+ }
1957
+
1958
+ if (
1959
+ !['alp_delegate', 'alp_handoff', 'alp_wait', 'alp_cancel', 'alp_send', 'alp_ask', 'alp_merge', 'alp_discard', 'alp_recall', 'alp_verify', 'alp_pin', 'alp_board', 'alp_unpin', 'alp_lesson', 'alp_skill', 'alp_issue', 'alp_task'].includes(params.tool) ||
1960
+ params.namespace != null ||
1961
+ typeof params.callId !== 'string'
1962
+ ) {
1963
+ return toolResult(false, {
1964
+ error: 'Unknown ALP tool',
1965
+ });
1966
+ }
1967
+
1968
+ const cached = session.toolCalls.get(params.callId);
1969
+ if (cached) return cached;
1970
+
1971
+ const args = params.arguments;
1972
+ // The supervisor only reads the board and tasks, and writes to main.
1973
+ if (session.role === 'supervisor' && !['alp_send', 'alp_board', 'alp_task'].includes(params.tool)) return toolResult(false, { error: 'The supervisor only uses alp_send, alp_board and alp_task' });
1974
+ if ((params.tool === 'alp_lesson' || params.tool === 'alp_skill') && !supervises(session)) return toolResult(false, { error: 'Only a supervised main records lessons and skills' });
1975
+ if (params.tool === 'alp_issue' && (session.parent || session.mapping.agent.name !== mainOf(session.mapping))) return toolResult(false, { error: 'Only main files issues; report the problem to your requester' });
1976
+ const work =
1977
+ params.tool === 'alp_lesson' ? lessonTool(sessionId, session, args)
1978
+ : params.tool === 'alp_skill' ? skillTool(sessionId, session, args)
1979
+ : params.tool === 'alp_issue' ? issueTool(sessionId, session, args)
1980
+ : params.tool === 'alp_task' ? taskToolCall(sessionId, session, args)
1981
+ : params.tool === 'alp_delegate' ? runDelegation(sessionId, session, params)
1982
+ : params.tool === 'alp_wait' ? waitTool(sessionId, session, args)
1983
+ : params.tool === 'alp_ask' ? askTool(sessionId, session, args)
1984
+ : params.tool === 'alp_pin' ? pinTool(sessionId, session, args)
1985
+ : params.tool === 'alp_board' ? boardTool(session, args)
1986
+ : params.tool === 'alp_unpin' ? unpinTool(sessionId, session, args)
1987
+ : params.tool === 'alp_recall' ? recallTool(sessionId, session, args)
1988
+ : params.tool === 'alp_verify' ? verifyTool(sessionId, session, args)
1989
+ : params.tool === 'alp_cancel' ? cancelTool(sessionId, session, args)
1990
+ : params.tool === 'alp_merge' || params.tool === 'alp_discard' ? worktreeTool(sessionId, session, args, params.tool === 'alp_merge' ? 'merge' : 'discard')
1991
+ : params.tool === 'alp_send' ? Promise.resolve(sendTool(sessionId, session, args))
1992
+ : hookedHandoff(sessionId, session, args);
1993
+
1994
+ // A root that keeps calling tools while the user waits for an answer is reminded on each result.
1995
+ const reminded = !session.parent && params.tool !== 'alp_ask' ? work.then(result => remindUser(session, result)) : work;
1996
+ session.toolCalls.set(params.callId, reminded);
1997
+ return reminded;
1998
+ }
1999
+
2000
+ /** Adds `userWaiting` to an ALP tool's result while the user has had no reply for a minute. */
2001
+ function remindUser(session: Session, result: any) {
2002
+ const since = session.userWaiting;
2003
+ if (since === undefined || Date.now() - since < USER_REPLY_MS || !result?.contentItems?.[0]?.text) return result;
2004
+ try {
2005
+ const value = JSON.parse(result.contentItems[0].text);
2006
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return result;
2007
+ value.userWaiting = `The user wrote to you at ${clock(since)} and has had no reply for ${span(Date.now() - since)}. Before more tool calls, answer them in a short message they can read; tool calls alone show them nothing.`;
2008
+ return { ...result, contentItems: [{ ...result.contentItems[0], text: JSON.stringify(value) }, ...result.contentItems.slice(1)] };
2009
+ } catch {
2010
+ return result;
2011
+ }
2012
+ }
2013
+
2014
+ /** A blocking handoff hook can refuse the handoff, for example until the tests pass. */
2015
+ async function hookedHandoff(sessionId: string, session: Session, args: unknown) {
2016
+ const checked = session.parentAgent ? parseHandoff(args) : undefined;
2017
+ if (!session.mapping.hooks.some(hook => hook.event === 'handoff') || !checked || typeof checked === 'string') return recordHandoff(sessionId, session, args);
2018
+ const taskId = session.parent ? sessions.get(session.parent)?.assignments.get(sessionId)?.taskId : undefined;
2019
+ const refused = await runHooks(sessionId, session, 'handoff', { assignment: sessionId, handoff: checked }, taskId);
2020
+ if (refused) return toolResult(false, { error: `A handoff hook refused it:\n${refused}`, next: 'Fix what it reports, then call alp_handoff again.' });
2021
+ return recordHandoff(sessionId, session, args);
2022
+ }
2023
+
2024
+ async function recordHandoff(sessionId: string, session: Session, args: unknown) {
2025
+ if (!session.parentAgent) {
2026
+ return toolResult(false, { error: 'Only assignment sessions can file a handoff' });
2027
+ }
2028
+ const handoff = parseHandoff(args);
2029
+ if (typeof handoff === 'string') return toolResult(false, { error: handoff });
2030
+ if (session.mapping.agent.name === 'reviewer' && handoff.outcome === 'complete' && !handoff.verdict) {
2031
+ return toolResult(false, { error: 'A complete review needs a verdict: each criterion with pass, fail or not_checked and its evidence, the findings by severity, and the result they lead to' });
2032
+ }
2033
+ // A partial handoff explicitly returns unfinished work to the requester. Parked
2034
+ // children cannot finish it now; stop them so they cannot hold this handoff open
2035
+ // or resume later after ownership has moved back to the requester.
2036
+ const parked = handoff.outcome === 'partial'
2037
+ ? [...session.assignments.values()].filter(assignment => sessions.get(assignment.id)?.parked)
2038
+ : [];
2039
+ for (const assignment of parked) {
2040
+ await finishAssignment(sessionId, session, assignment, 'canceled', new Error('Requester filed a partial handoff'), true);
2041
+ }
2042
+ session.handoff = handoff;
2043
+ return toolResult(true, { recorded: true, to: session.parentAgent, ...(parked.length ? { stoppedParked: parked.map(assignment => assignment.id), preservedChanges: 'Their changes stay in the checkout or on their worktree branch for your requester to continue.' } : {}), next: 'End your turn with a one-line final message.' });
2044
+ }
2045
+
2046
+ /** A requester stops one of its assignments; a parked one too, so the rest can go to another runtime. */
2047
+ async function cancelTool(sessionId: string, session: Session, args: unknown) {
2048
+ if (!plainObject(args, ['assignmentId', 'reason']) || typeof args.assignmentId !== 'string' || (args.reason !== undefined && typeof args.reason !== 'string')) {
2049
+ return toolResult(false, { error: 'Cancel needs the assignmentId of one of your assignments' });
2050
+ }
2051
+ const named = [...session.assignments.values()].filter(candidate => candidate.agent === args.assignmentId);
2052
+ const assignment = session.assignments.get(args.assignmentId) ?? (named.length === 1 ? named[0] : undefined);
2053
+ if (!assignment || assignment.finished) return toolResult(false, { error: 'Not one of your running assignments' });
2054
+ const reason = `Canceled by ${session.mapping.agent.name}${args.reason ? `: ${clip(args.reason, 300)}` : ''}`;
2055
+ runLog(rootOf(sessionId), { event: 'assignment.canceled', assignmentId: assignment.id, agent: assignment.agent, by: session.mapping.agent.name, ...(args.reason ? { reason: clip(args.reason, 300) } : {}) });
2056
+ // Quiet: the requester asked for it, so no result mail follows.
2057
+ await finishAssignment(sessionId, session, assignment, 'canceled', new Error(reason), true);
2058
+ return toolResult(true, { assignmentId: assignment.id, agent: assignment.agent, status: 'canceled', next: 'Its changes stay where it made them. Check them, then give the rest to another agent with alp_delegate, on another runtime when this one is limited or paused.' });
2059
+ }
2060
+
2061
+ async function waitTool(sessionId: string, session: Session, args: unknown) {
2062
+ if (!plainObject(args, ['assignments', 'timeoutMs'])) return toolResult(false, { error: 'Invalid wait' });
2063
+ const known = (id: unknown) => typeof id === 'string' && (session.assignments.has(id) || session.mail.some(event => event.assignment === id));
2064
+ if (args.assignments !== undefined && (!Array.isArray(args.assignments) || !args.assignments.every(known))) {
2065
+ return toolResult(false, { error: 'Unknown assignment, or its result was already delivered' });
2066
+ }
2067
+ if (args.timeoutMs !== undefined && (!Number.isSafeInteger(args.timeoutMs) || args.timeoutMs < 1)) {
2068
+ return toolResult(false, { error: 'timeoutMs must be a positive integer' });
2069
+ }
2070
+ const ids: Set<string> | undefined = args.assignments?.length ? new Set(args.assignments) : undefined;
2071
+ const accept = (event: MailEvent) => !ids || ids.has(event.assignment);
2072
+ const pending = [...session.assignments.keys()].some(id => !ids || ids.has(id)) || takeBatch(session.mail, event => event.kind !== 'board' && !event.defer && accept(event)).length > 0;
2073
+ const events = pending ? await waitFor(sessionId, session, accept, Math.min(args.timeoutMs ?? 300_000, 900_000)) : [];
2074
+ if (events === 'user') return toolResult(true, { events: [], userMessage: true, running: running(session), next: USER_WROTE });
2075
+ if (!events) return toolResult(false, { error: 'Turn ended' });
2076
+ return toolResult(true, { events: events.map(publicEvent), running: running(session) });
2077
+ }
2078
+
2079
+ function sendTool(sessionId: string, session: Session, args: unknown) {
2080
+ if (
2081
+ !plainObject(args, ['to', 'kind', 'body', 'replyTo']) ||
2082
+ typeof args.to !== 'string' ||
2083
+ !['answer', 'note', 'steer'].includes(args.kind) ||
2084
+ typeof args.body !== 'string' || !args.body.trim() || args.body.length > MAIL_BODY_CHARS ||
2085
+ (args.replyTo !== undefined && typeof args.replyTo !== 'string')
2086
+ ) {
2087
+ return toolResult(false, { error: `Mail needs to, kind (answer, note, or steer), and a body of at most ${MAIL_BODY_CHARS} characters` });
2088
+ }
2089
+ const from = session.mapping.agent.name;
2090
+ if (args.to === 'parent') {
2091
+ if (!session.parent || !session.parentAgent) return toolResult(false, { error: 'This session has no requester' });
2092
+ if (args.kind !== 'note') return toolResult(false, { error: 'Mail to your requester is a note; ask with alp_ask and report with alp_handoff' });
2093
+ // The supervisor's questions reach main between turns, never in the middle of its work.
2094
+ return toolResult(true, { sent: post(session.parent, { kind: 'note', from, assignment: sessionId, body: args.body, ...(session.role === 'supervisor' ? { defer: true } : {}) }).id });
2095
+ }
2096
+ // Models often address an assignment by its agent name; accept that when it is unambiguous.
2097
+ const named = [...session.assignments.values()].filter(candidate => candidate.agent === args.to);
2098
+ const assignment = session.assignments.get(args.to) ?? (named.length === 1 ? named[0] : undefined);
2099
+ if (named.length > 1) return toolResult(false, { error: `Several live ${args.to} assignments; use the assignment id` });
2100
+ // Models (GPT especially) answer the supervisor's note by mail; it reads main's reply instead.
2101
+ const watcher = session.supervisor ? sessions.get(session.supervisor) : undefined;
2102
+ if (!assignment && watcher && (args.to === session.supervisor || args.to === watcher.mapping.agent.name || args.to === 'supervisor')) {
2103
+ return toolResult(false, { error: 'The supervisor takes no mail: answer its note in your reply, where it reads it, and record what you learned with alp_lesson' });
2104
+ }
2105
+ if (!assignment) return toolResult(false, { error: 'Not one of your live assignments; other agents are reached through your requester' });
2106
+ if (args.kind === 'answer') {
2107
+ if (!assignment.ask || args.replyTo !== assignment.ask.id) return toolResult(false, { error: 'No pending question with that replyTo on this assignment' });
2108
+ runLog(rootOf(sessionId), { event: 'mail', to: assignment.id, kind: 'answer', from, assignment: assignment.id, replyTo: args.replyTo, body: args.body });
2109
+ emit(assignment.id, { type: 'mail', mail: { id: `answer:${args.replyTo}`, kind: 'answer', from, assignment: assignment.id, replyTo: args.replyTo, body: args.body } });
2110
+ assignment.ask.resolve(toolResult(true, { status: 'answered', from, answer: args.body }));
2111
+ return toolResult(true, { sent: true, replyTo: args.replyTo });
2112
+ }
2113
+ return toolResult(true, { sent: post(assignment.id, { kind: args.kind, from, assignment: assignment.id, body: args.body, ...(args.replyTo ? { replyTo: args.replyTo } : {}) }).id });
2114
+ }
2115
+
2116
+ /** Asks the user; viewers show the question, and answer() or a timeout resolves it. */
2117
+ function askUser(sessionId: string, session: Session, assignment: Assignment | undefined, body: string, options?: string[]) {
2118
+ const rootId = rootOf(sessionId);
2119
+ const question: UserQuestion = {
2120
+ id: `q-${randomUUID().slice(0, 8)}`,
2121
+ sessionId,
2122
+ rootId,
2123
+ agent: session.mapping.agent.name,
2124
+ body,
2125
+ ...(options?.length ? { options } : {}),
2126
+ askedAt: new Date().toISOString(),
2127
+ };
2128
+ return new Promise<unknown>(resolve => {
2129
+ let timer: NodeJS.Timeout | undefined;
2130
+ const settle = (outcome: 'answered' | 'dismissed' | 'timeout' | 'canceled', answer?: string, reason?: string, result?: unknown) => {
2131
+ if (!userQuestions.delete(question.id)) return;
2132
+ clearTimeout(timer);
2133
+ if (assignment?.ask?.id === question.id) assignment.ask = undefined;
2134
+ session.lastActivity = Date.now();
2135
+ emit(sessionId, { type: 'question.resolved', questionId: question.id, outcome, ...(answer !== undefined ? { answer } : {}) });
2136
+ // The requester learns what its assignment settled with the user.
2137
+ if (outcome === 'answered' && session.parent) tellRequester(sessionId, session, `I asked the user: "${body}"\nThe user answered: "${answer}"`);
2138
+ runLog(rootId, { event: 'human.answer', questionId: question.id, sessionId, agent: question.agent, outcome, ...(answer !== undefined ? { answer } : {}), ...(reason ? { reason } : {}) });
2139
+ resolve(result ?? (outcome === 'answered'
2140
+ ? toolResult(true, { status: 'answered', from: 'user', answer })
2141
+ : toolResult(true, {
2142
+ status: outcome === 'dismissed' ? 'dismissed' : 'unanswered',
2143
+ question: question.id,
2144
+ ...(reason ? { reason } : {}),
2145
+ next: 'Decide, and record the assumption in your handoff or final message; or report that you are blocked.',
2146
+ })));
2147
+ };
2148
+ userQuestions.set(question.id, { question, settle });
2149
+ // The watchdog does not count time spent waiting for an answer.
2150
+ if (assignment) assignment.ask = { id: question.id, resolve: result => settle('canceled', undefined, undefined, result) };
2151
+ emit(sessionId, { type: 'question', question });
2152
+ runLog(rootId, { event: 'human.question', questionId: question.id, sessionId, agent: question.agent, body, ...(options?.length ? { options } : {}) });
2153
+ timer = setTimeout(() => settle('timeout'), userAskTimeoutMs);
2154
+ });
2155
+ }
2156
+
2157
+ const boardDomain = createBoardDomain({ options, sessions, boards, loadedBoards, rootOf, lineage, runLog, emit, post, steerMail });
2158
+ const { boardOf, claimConflicts, taskOf, addPin, pinTool, boardTool, unpinTool, releaseClaims } = boardDomain;
2159
+
2160
+ /** A note from an assignment to its requester, sent by ALP so it is never forgotten. */
2161
+ function tellRequester(sessionId: string, session: Session, body: string) {
2162
+ if (!session.parent || !sessions.has(session.parent)) return;
2163
+ post(session.parent, { kind: 'note', from: session.mapping.agent.name, assignment: sessionId, body: `[ALP, on behalf of ${session.mapping.agent.name}] ${body}` });
2164
+ }
2165
+
2166
+ /** Questions to the user end with the turn or session that asked them. */
2167
+ function cancelQuestions(sessionId: string) {
2168
+ for (const { question, settle } of [...userQuestions.values()]) {
2169
+ if (question.sessionId === sessionId) settle('canceled', undefined, undefined, toolResult(false, { error: 'Turn ended' }));
2170
+ }
2171
+ }
2172
+
2173
+ function askTool(sessionId: string, session: Session, args: unknown) {
2174
+ const parent = session.parent ? sessions.get(session.parent) : undefined;
2175
+ const assignment = parent?.assignments.get(sessionId);
2176
+ if (!plainObject(args, ['question', 'to', 'options']) || typeof args.question !== 'string' || !args.question.trim() || args.question.length > MAIL_BODY_CHARS) {
2177
+ return Promise.resolve(toolResult(false, { error: `question is required, at most ${MAIL_BODY_CHARS} characters` }));
2178
+ }
2179
+ const to = args.to ?? (assignment ? 'parent' : 'user');
2180
+ if (to !== 'parent' && to !== 'user') return Promise.resolve(toolResult(false, { error: 'to must be parent or user' }));
2181
+ if (args.options !== undefined && (to !== 'user' || !Array.isArray(args.options) || args.options.length > 10 || !args.options.every((option: unknown) => typeof option === 'string' && option.trim() && option.length <= 200))) {
2182
+ return Promise.resolve(toolResult(false, { error: 'options are up to 10 short answers, for questions to the user' }));
2183
+ }
2184
+ if (assignment?.ask || [...userQuestions.values()].some(pending => pending.question.sessionId === sessionId)) {
2185
+ return Promise.resolve(toolResult(false, { error: 'A question is already waiting for an answer' }));
2186
+ }
2187
+ // Only main talks to the user, unless the user has written to this assignment first.
2188
+ if (to === 'user' && session.parent && !session.userOpened) {
2189
+ return Promise.resolve(toolResult(false, { error: `Only main talks to the user; ask ${session.parentAgent ?? 'your requester'} with alp_ask instead` }));
2190
+ }
2191
+ if (to === 'user') return askUser(sessionId, session, assignment, args.question, args.options);
2192
+ if (!session.parent || !parent || !assignment) return Promise.resolve(toolResult(false, { error: 'This session has no requester; ask the user with to: "user"' }));
2193
+ const parentId = session.parent;
2194
+ return new Promise<unknown>(resolve => {
2195
+ const question = post(parentId, { kind: 'question', from: session.mapping.agent.name, assignment: sessionId, body: args.question });
2196
+ let timer: NodeJS.Timeout | undefined;
2197
+ const settle = (result: unknown) => {
2198
+ if (assignment.ask?.id !== question.id) return;
2199
+ clearTimeout(timer);
2200
+ assignment.ask = undefined;
2201
+ session.lastActivity = Date.now();
2202
+ // An unread question is stale once answered, timed out, or abandoned.
2203
+ parent.mail = parent.mail.filter(event => event !== question || event.deliveredTurn);
2204
+ resolve(result);
2205
+ };
2206
+ assignment.ask = { id: question.id, resolve: settle };
2207
+ timer = setTimeout(() => settle(toolResult(true, {
2208
+ status: 'unanswered',
2209
+ question: question.id,
2210
+ next: 'Decide, and record the assumption in your handoff; or file outcome blocked.',
2211
+ })), askTimeoutMs);
2212
+ });
2213
+ }
2214
+
2215
+ async function runDelegation(
2216
+ sessionId: string,
2217
+ session: Session,
2218
+ params: any,
2219
+ ): Promise<unknown> {
2220
+ const args = params.arguments;
2221
+
2222
+ const targets = Object.hasOwn(
2223
+ session.graph,
2224
+ session.mapping.agent.name,
2225
+ )
2226
+ ? session.graph[session.mapping.agent.name]
2227
+ : [];
2228
+
2229
+ if (
2230
+ !args ||
2231
+ typeof args !== 'object' ||
2232
+ Array.isArray(args) ||
2233
+ Object.keys(args).some(
2234
+ (key) => !['agent', 'task', 'mode', 'model', 'thinking', 'modelReason', 'wait', 'isolation', 'taskId', 'continueFrom', 'etaMinutes', 'writeRoot'].includes(key),
2235
+ ) ||
2236
+ !targets.includes(args.agent) ||
2237
+ typeof args.task !== 'string' ||
2238
+ !args.task.trim() ||
2239
+ args.task.length > 32000 ||
2240
+ (
2241
+ args.mode !== undefined &&
2242
+ !modes.some(mode => mode.id === args.mode)
2243
+ ) ||
2244
+ (args.wait !== undefined && typeof args.wait !== 'boolean') ||
2245
+ (args.etaMinutes !== undefined && (!Number.isSafeInteger(args.etaMinutes) || args.etaMinutes < 1 || args.etaMinutes > 1440)) ||
2246
+ (args.isolation !== undefined && !['shared', 'worktree'].includes(args.isolation))
2247
+ ) {
2248
+ return toolResult(false, {
2249
+ error: 'Invalid assignment or unauthorized target',
2250
+ });
2251
+ }
2252
+
2253
+ for (const key of ['model', 'thinking', 'modelReason']) {
2254
+ if (args[key] !== undefined && (typeof args[key] !== 'string' || !args[key].trim())) return toolResult(false, { error: `Invalid ${key}` });
2255
+ }
2256
+ if (args.model !== undefined && !/^(codex|claude|acp|antigravity):[^\s]+$/.test(args.model)) return toolResult(false, { error: 'Use a runtime-prefixed model ID' });
2257
+ if (args.agent === 'oracle' && !session.mapping.oracleModels.includes(args.model)) return toolResult(false, { error: `Oracle runs on ${session.mapping.oracleModels.join(' or ')}; pass one as model. For two opinions, start one on each with wait: false` });
2258
+ const configuredModels = args.agent === 'oracle' ? [] : session.mapping.modelProfiles[args.agent] ?? [];
2259
+ const listed = configuredModels.map(profileModel).filter((id): id is string => Boolean(id));
2260
+ // The requester picks a model and effort for each task among several profiles, or any model of a provider:*; one named profile needs no choice.
2261
+ const choosing = configuredModels.length > 1 || configuredModels.some(anyModel);
2262
+ if (choosing && (args.model === undefined || args.thinking === undefined)) return toolResult(false, { error: `${args.agent} has several model profiles: choose one for this task as model, its effort as thinking, and say why in modelReason`, models: listed });
2263
+ if (choosing && !offers(configuredModels, args.model)) return toolResult(false, { error: `${args.model} is not one of ${args.agent}'s model profiles`, models: listed });
2264
+ const efforts = choosing ? profileEfforts(args.model) : [];
2265
+ if (efforts.length && !efforts.includes(args.thinking)) return toolResult(false, { error: `${args.model} takes effort ${efforts.join(', ')}`, efforts });
2266
+ const configuredDefault = !choosing && listed.length === 1 ? listed[0] : undefined;
2267
+ const delegatedModel = args.model ?? configuredDefault ?? `${session.runtimeKind}:${session.mapping.model}`;
2268
+ const delegatedThinking = args.thinking ?? selectedProfile(configuredModels, delegatedModel)?.effort ?? (args.model || configuredDefault ? undefined : session.mapping.thinking);
2269
+ // A paused runtime takes no new assignments; another runtime may.
2270
+ const childRuntime = delegatedModel.split(':')[0] as RuntimeKind;
2271
+ // A limit may have lifted since the last check: ask the runtime before refusing (ALPD §58).
2272
+ if (!paused.all && paused.runtimes[childRuntime]?.by === 'alpd') await checkLimit(childRuntime, LIMIT_FRESH_MS);
2273
+ const hold = pauseOf(childRuntime);
2274
+ if (hold) {
2275
+ const other = RUNTIMES.find(kind => kind !== childRuntime && !pauseOf(kind));
2276
+ const instead = listed.filter(id => other && id.startsWith(`${other}:`)).map(id => id.endsWith(':*') ? `any ${id.slice(0, -1)} model` : id);
2277
+ const next = !other ? 'Wait until the user resumes ALP'
2278
+ : !choosing ? `Pass a model of ${other}: from the catalog to run it on ${label(other)}, or wait`
2279
+ : instead.length ? `Pass ${instead.join(' or ')} to run it on ${label(other)}, or wait` : `None of ${args.agent}'s model profiles runs on ${label(other)}: wait`;
2280
+ return toolResult(false, { error: describePause(hold, paused.all ? undefined : childRuntime), next });
2281
+ }
2282
+ // The child's permission profile caps its mode, as resolving its session will.
2283
+ let childProfile;
2284
+ try { childProfile = await profileFor(session.mapping.agent.projectRoot, options.libraryDir, args.agent); }
2285
+ catch (error) { return toolResult(false, { error: errorData(error).message }); }
2286
+ const childMode = childProfile ? capMode(args.mode ?? session.mapping.mode, childProfile.base) : args.mode ?? session.mapping.mode;
2287
+ const capped = {
2288
+ ...(childProfile && childMode !== (args.mode ?? session.mapping.mode) ? { mode: childMode, modeNote: `${args.agent} runs ${childMode}: its permission profile ${childProfile.name} caps it` } : {}),
2289
+ ...(childProfile?.workdir === 'copy' && isolationOf(args) === 'shared' ? { workdirNote: `${args.agent} works in a disposable copy of your tree; nothing it changes reaches yours` } : {}),
2290
+ };
2291
+ let sharedContinuation: RecallEntry | undefined;
2292
+ if (args.continueFrom !== undefined) {
2293
+ if (typeof args.continueFrom !== 'string') return toolResult(false, { error: 'continueFrom must name a finished assignment of yours' });
2294
+ if (session.assignments.has(args.continueFrom)) return toolResult(false, { error: 'That assignment is still running or parked; wait for its handoff, or use alp_cancel before delegating the remaining work' });
2295
+ if (session.worktrees.has(args.continueFrom)) {
2296
+ if (args.isolation === 'shared') return toolResult(false, { error: 'This assignment used a worktree; leave out isolation or pass "worktree"' });
2297
+ } else {
2298
+ const previous = await findRecall({ assignmentId: args.continueFrom });
2299
+ if (!previous?.shared || previous.requesters[0] !== sessionId || !(await sameDirectory(previous.project, session.mapping.agent.projectRoot))) {
2300
+ return toolResult(false, { error: 'continueFrom names a finished shared assignment of yours or an unmerged worktree assignment. If its record is unavailable, delegate again without continueFrom and include its handoff; shared changes already remain in the checkout and need no merge or discard' });
2301
+ }
2302
+ if (args.isolation === 'worktree') return toolResult(false, { error: 'This assignment used the shared checkout; leave out isolation or pass "shared"' });
2303
+ if (childProfile?.workdir === 'copy') return toolResult(false, { error: 'A shared assignment must continue in its checkout, not a disposable copy' });
2304
+ sharedContinuation = previous;
2305
+ }
2306
+ }
2307
+ const isolation: Assignment['isolation'] = args.isolation ?? (args.continueFrom !== undefined && !sharedContinuation ? 'worktree' : 'shared');
2308
+ if (isolation === 'worktree' && !writes(childMode)) return toolResult(false, { error: 'Worktree isolation is for writing assignments (mode workspace-write or full-access)' });
2309
+ const project = session.mapping.agent.projectRoot;
2310
+ if (args.writeRoot !== undefined && (typeof args.writeRoot !== 'string' || !args.writeRoot.trim())) return toolResult(false, { error: 'writeRoot must name an existing directory' });
2311
+ if (args.writeRoot !== undefined && (!writes(childMode) || isolation !== 'shared' || childProfile?.workdir === 'copy')) return toolResult(false, { error: 'writeRoot is for shared writing assignments, without worktree or copy isolation' });
2312
+ let writeRoot: string | undefined;
2313
+ try {
2314
+ if (args.writeRoot !== undefined) {
2315
+ writeRoot = await realpath(path.resolve(project, args.writeRoot));
2316
+ if (!(await stat(writeRoot)).isDirectory()) throw new Error('not a directory');
2317
+ }
2318
+ } catch {
2319
+ return toolResult(false, { error: 'writeRoot must name an existing directory' });
2320
+ }
2321
+ if (sharedContinuation) {
2322
+ writeRoot ??= sharedContinuation.shared?.writeRoot;
2323
+ if (!(await sameDirectory(writeRoot ?? session.mapping.workdir, sharedContinuation.cwd))) return toolResult(false, { error: 'Continue a shared assignment in its original checkout; use the same writeRoot' });
2324
+ }
2325
+ const checkout = await checkoutKey(writeRoot ?? session.mapping.workdir);
2326
+ let task: Task | undefined;
2327
+ if (args.taskId !== undefined) {
2328
+ if (session.parent || session.mapping.agent.name !== mainOf(session.mapping)) return toolResult(false, { error: 'Only main gives tasks to assignments' });
2329
+ if (READ_ONLY_AGENTS.includes(args.agent)) return toolResult(false, { error: 'taskId gives a task to lead or peer; for advice about a task, name it in the brief' });
2330
+ if (typeof args.taskId !== 'string') return toolResult(false, { error: 'taskId must be a task id' });
2331
+ // Checked here for a clear refusal; the start below checks again under the task lock.
2332
+ try {
2333
+ const { tasks } = await loadTasks(project);
2334
+ task = tasks.find(candidate => candidate.id === args.taskId);
2335
+ if (!task) return toolResult(false, { error: `No task ${args.taskId}` });
2336
+ const refusal = startRefusal(task, tasks);
2337
+ if (refusal) return toolResult(false, { error: refusal });
2338
+ } catch (error) {
2339
+ return toolResult(false, { error: errorData(error).message });
2340
+ }
2341
+ if (task.paths.length && writes(childMode)) {
2342
+ if (writeRoot && typeof normalizePaths(task.paths, project, [writeRoot]) === 'string') return toolResult(false, { error: 'Task paths are outside writeRoot; use a task scoped to this repository' });
2343
+ const conflicts = claimConflicts(sessionId, await boardOf(project), task.paths);
2344
+ if (conflicts.length) return toolResult(false, { error: `Another agent has claimed paths of ${task.id}; wait for it, or ask the user`, conflicts });
2345
+ }
2346
+ }
2347
+ // From here every check runs without yielding again, so parallel calls cannot race.
2348
+ const parallel = (mode: string, kind: Assignment['isolation']) => mode === 'read-only' || kind === 'worktree';
2349
+ const sameCheckout = (other: string) => overlapping([checkout], [other]).length > 0;
2350
+ // Read-only peers and advisors (reviewer, oracle) never change files, so they run beside anything;
2351
+ // Writers of the same repository need separate worktrees. Different repositories
2352
+ // are independent; a parked writer still owns its repository.
2353
+ const writers = [...session.assignments.values()].filter(assignment => writes(assignment.mode));
2354
+ if (session.assignments.size && (!['peer', 'advisor', 'reviewer'].includes(roleOf(session.mapping, args.agent) ?? '') || (writes(childMode) && writers.some(assignment => (!parallel(assignment.mode, assignment.isolation) || !parallel(childMode, isolation)) && (!assignment.writeCheckout || sameCheckout(assignment.writeCheckout)))))) {
2355
+ const parkedOnes = [...session.assignments.values()].filter(assignment => sessions.get(assignment.id)?.parked);
2356
+ return toolResult(false, {
2357
+ error: 'A child assignment is already running; wait for its handoff. Only read-only peers and advisors (reviewer, oracle), and writers in different repositories or their own worktrees, run beside it',
2358
+ ...(parkedOnes.length ? { next: `${parkedOnes.map(assignment => `${assignment.agent} ${assignment.id}`).join(', ')} is parked; to go on without it, stop it with alp_cancel and delegate the rest, on another runtime if its runtime is limited` } : {}),
2359
+ });
2360
+ }
2361
+
2362
+ if (
2363
+ session.ancestry.includes(args.agent) ||
2364
+ session.ancestry.length >= 4
2365
+ ) {
2366
+ return toolResult(false, {
2367
+ error: 'Delegation depth/cycle limit',
2368
+ });
2369
+ }
2370
+
2371
+ if (
2372
+ args.mode !== undefined &&
2373
+ !withinMode(args.mode, session.mapping.mode)
2374
+ ) {
2375
+ return toolResult(false, {
2376
+ error: 'Child cannot exceed parent permissions',
2377
+ });
2378
+ }
2379
+
2380
+ const rootId = rootOf(sessionId);
2381
+ const root = sessions.get(rootId)!;
2382
+
2383
+ if (root.calls >= 16) {
2384
+ return toolResult(false, {
2385
+ error: 'Delegation limit reached for this turn',
2386
+ });
2387
+ }
2388
+
2389
+ const peer = roleOf(session.mapping, args.agent) === 'peer';
2390
+ if (peer && root.peerCount >= session.mapping.workflow.maxPeers) return toolResult(false, { error: 'Concurrent peer limit reached; wait or ask the user to increase workflow.maxPeers for a new session' });
2391
+ const sharedWriter = writes(childMode) && isolation === 'shared';
2392
+ const holder = sharedWriter ? [...leases].find(([key]) => sameCheckout(key))?.[1] : undefined;
2393
+ if (holder && !lineage(sessionId).includes(holder.assignment)) {
2394
+ return toolResult(false, { error: `${holder.agent} in another session is writing ${checkout}; wait for it, or use isolation "worktree"` });
2395
+ }
2396
+ // Taken now, so a second delegation or a merge cannot use the same change.
2397
+ const continued = args.continueFrom !== undefined ? session.worktrees.get(args.continueFrom) : undefined;
2398
+ if (args.continueFrom !== undefined && !continued && !sharedContinuation) return toolResult(false, { error: `${args.continueFrom} was merged or discarded meanwhile` });
2399
+ if (continued) session.worktrees.delete(args.continueFrom);
2400
+ root.calls++;
2401
+ if (peer) root.peerCount++;
2402
+
2403
+ const childId = `alp-child-${randomUUID()}`;
2404
+ const startedAt = Date.now();
2405
+ const assignment: Assignment = { id: childId, agent: args.agent, mode: childMode, isolation, rootId, startedAt, warned: false, finished: false, ...(writes(childMode) ? { writeCheckout: checkout } : {}), ...(args.etaMinutes ? { eta: startedAt + args.etaMinutes * 60_000 } : {}) };
2406
+ if (sharedWriter && !holder) {
2407
+ assignment.lease = checkout;
2408
+ leases.set(checkout, { assignment: childId, agent: args.agent });
2409
+ }
2410
+
2411
+ session.children.add(childId);
2412
+ session.assignments.set(childId, assignment);
2413
+
2414
+ childContexts.set(childId, {
2415
+ parent: sessionId,
2416
+ callId: params.callId,
2417
+ graph: session.graph,
2418
+ workflow: session.mapping.workflow,
2419
+ ancestry: [
2420
+ ...session.ancestry,
2421
+ args.agent,
2422
+ ],
2423
+ });
2424
+
2425
+ const model = delegatedModel;
2426
+ const thinking = delegatedThinking;
2427
+
2428
+ runLog(rootId, {
2429
+ event: 'assignment.started',
2430
+ assignmentId: childId,
2431
+ parentSessionId: sessionId,
2432
+ parentAgent: session.mapping.agent.name,
2433
+ agent: args.agent,
2434
+ project: session.mapping.agent.projectRoot,
2435
+ mode: childMode,
2436
+ isolation,
2437
+ ...(writeRoot ? { writeRoot } : {}),
2438
+ model,
2439
+ thinking: thinking ?? null,
2440
+ ...(args.modelReason ? { modelReason: args.modelReason } : {}),
2441
+ wait: args.wait === true,
2442
+ task: args.task,
2443
+ ...(task ? { taskId: task.id } : {}),
2444
+ });
2445
+ try {
2446
+ if (task) {
2447
+ const started = await startTask(project, task.id, { agent: args.agent, assignment: childId, pid: process.pid, pidStartedAt: OWN_START, epoch }, session.mapping.agent.name);
2448
+ assignment.taskId = started.id;
2449
+ task = started;
2450
+ touchTask(rootId, started.id);
2451
+ runLog(rootId, { event: 'task', action: 'delegate', agent: session.mapping.agent.name, id: started.id, title: started.title, status: started.status, detail: `to ${args.agent}` });
2452
+ }
2453
+ if (isolation === 'worktree') {
2454
+ try {
2455
+ assignment.worktree = await createWorktree(session.mapping.workdir, worktreeRoot, childId, continued ? { commit: continued.change.commit, base: continued.worktree.base, fingerprint: continued.worktree.fingerprint } : undefined);
2456
+ } catch (error) {
2457
+ if (continued) session.worktrees.set(args.continueFrom, continued);
2458
+ throw error;
2459
+ }
2460
+ // The new branch holds the earlier change, so its worktree and branch can go.
2461
+ if (continued) await removeWorktree(continued.worktree, { deleteBranch: true }).catch(() => {});
2462
+ runLog(rootId, { event: 'worktree.created', assignmentId: childId, branch: assignment.worktree.branch, base: assignment.worktree.base, ...(continued ? { continuedFrom: args.continueFrom } : {}) });
2463
+ } else if (sharedWriter) {
2464
+ assignment.fingerprint = await checkoutFingerprint(checkout).catch(() => undefined);
2465
+ }
2466
+ if (isolation !== 'worktree' && childProfile?.workdir === 'copy') {
2467
+ assignment.copy = await createCopy(session.mapping.workdir, copyRoot, childId);
2468
+ runLog(rootId, { event: 'copy.created', assignmentId: childId, agent: args.agent, path: assignment.copy.path });
2469
+ }
2470
+ emit(sessionId, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'running') });
2471
+
2472
+ const digest = renderBoard(await boardOf(session.mapping.agent.projectRoot));
2473
+ // A child inherits the requester's client configuration, never its native thread.
2474
+ const { restore: _restore, ...inherited } = session.spec;
2475
+ const childSpec: SessionSpec = {
2476
+ ...inherited,
2477
+ writeRoot: isolation === 'shared' && !assignment.copy ? writeRoot ?? session.spec.writeRoot : undefined,
2478
+ ...(assignment.worktree ? { workdir: assignment.worktree.workdir } : assignment.copy ? { workdir: assignment.copy.workdir, copy: true, copyOf: session.mapping.workdir } : writeRoot ? { workdir: writeRoot } : {}),
2479
+ persist: false,
2480
+ keepThread: true,
2481
+ workflow: session.mapping.workflow.mode === 'custom' ? undefined : session.mapping.workflow.mode,
2482
+ agent: args.agent,
2483
+ model,
2484
+ thinking,
2485
+ mode: childMode,
2486
+ };
2487
+ await openSession(childId, childSpec, 'skip', session.delegation);
2488
+
2489
+ const child = sessions.get(childId);
2490
+ if (child) void runHooks(childId, child, 'assignment.start', { assignment: childId, brief: clip(args.task, 2000) }, args.taskId);
2491
+
2492
+ if (
2493
+ !child ||
2494
+ session.closed ||
2495
+ session.active !== params.turnId
2496
+ ) {
2497
+ throw new Error(
2498
+ 'Parent stopped before child became ready',
2499
+ );
2500
+ }
2501
+
2502
+ child.settle = (state, error) =>
2503
+ void finishAssignment(sessionId, session, assignment, state, error);
2504
+ const brief =
2505
+ (task ? `${taskBrief(task, writes(childMode))}\n\n` : '') +
2506
+ (continued ? `${continuedBrief(args.continueFrom, continued)}\n\n` : '') +
2507
+ (sharedContinuation ? `Continue ${sharedContinuation.agent}'s shared assignment ${args.continueFrom} in this checkout. Its files remain as they are now; inspect them before continuing. Ask your requester if you need more context from the previous conversation.\nPrevious brief:\n${sharedContinuation.shared!.brief}\nPrevious result:\n${sharedContinuation.shared!.summary}\n\n` : '') +
2508
+ args.task;
2509
+ child.brief = brief;
2510
+ // From here a restarted alpd can continue it: its thread exists.
2511
+ void inFlight.put({
2512
+ brief: cut(brief, BRIEF_CHARS),
2513
+ assignmentId: childId, rootId, parentId: sessionId, callId: params.callId, agent: args.agent, project, ancestry: [...session.ancestry, args.agent],
2514
+ runtime: child.runtimeKind, model: child.mapping.model, threadId: child.threadId, spec: { ...childSpec, thinking: child.mapping.thinking }, delegation: session.delegation,
2515
+ mode: childMode, isolation, ...(assignment.taskId ? { taskId: assignment.taskId } : {}), ...(assignment.worktree ? { worktree: assignment.worktree } : {}),
2516
+ ...(assignment.copy ? { copyOf: session.mapping.workdir } : {}), ...(assignment.lease ? { lease: assignment.lease } : {}),
2517
+ ...(assignment.fingerprint ? { fingerprint: assignment.fingerprint } : {}), startedAt: assignment.startedAt, epoch,
2518
+ });
2519
+
2520
+ // A writing assignment holds the task's paths for as long as it runs.
2521
+ if (task?.paths.length && writes(childMode)) {
2522
+ const pins = await boardOf(project);
2523
+ const conflicts = claimConflicts(childId, pins, task.paths);
2524
+ if (conflicts.length) throw new Error(`Another agent claimed paths of ${task.id} meanwhile: ${conflicts.map(pin => `${pin.agent} [${pin.paths.join(', ')}]`).join('; ')}`);
2525
+ addPin(childId, child, pins, { kind: 'claim', body: `Task ${task.id}: ${task.title}`, paths: task.paths, task: task.id });
2526
+ }
2527
+
2528
+ await startPrompt(childId, {
2529
+ clientMessageId: `task-${childId}`,
2530
+ delivery: 'auto',
2531
+ content: [
2532
+ {
2533
+ type: 'text',
2534
+ text:
2535
+ `Assignment from ${session.mapping.agent.name}. ` +
2536
+ 'Finish by filing your handoff for that agent with alp_handoff.\n\n' +
2537
+ brief +
2538
+ (digest ? `\n\n${digest}` : ''),
2539
+ },
2540
+ ],
2541
+ }, 'assignment');
2542
+ } catch (error) {
2543
+ // A child that failed while starting may already have reported by mail; report once.
2544
+ session.mail = session.mail.filter(event => event.deliveredTurn || event.kind !== 'result' || event.assignment !== childId);
2545
+ await finishAssignment(sessionId, session, assignment, 'failed', error, true);
2546
+ return toolResult(false, {
2547
+ agent: args.agent,
2548
+ error: errorData(error).message,
2549
+ });
2550
+ }
2551
+
2552
+ watch();
2553
+
2554
+ // Background by default: only an explicit wait: true holds the requester's turn.
2555
+ if (args.wait !== true) {
2556
+ return toolResult(true, { assignmentId: childId, agent: args.agent, status: 'running', ...(writeRoot ? { writeRoot } : {}), ...capped });
2557
+ }
2558
+
2559
+ // Waiting returns the result, or the child's first question so it can be answered.
2560
+ // The user's words and check-ins end the wait early; the assignment keeps running.
2561
+ const events = await waitFor(sessionId, session, event => event.assignment === childId && (event.kind === 'result' || event.kind === 'question'), undefined, true);
2562
+
2563
+ if (events === 'user') return toolResult(true, { assignmentId: childId, agent: args.agent, status: 'running', userMessage: true, next: USER_WROTE });
2564
+
2565
+ if (!events) {
2566
+ return toolResult(false, {
2567
+ agent: args.agent,
2568
+ assignmentId: childId,
2569
+ status: 'canceled',
2570
+ error: 'Parent assignment stopped',
2571
+ });
2572
+ }
2573
+
2574
+ // A result that came with a check-in makes the check-in moot.
2575
+ const event = events.find(candidate => candidate.kind === 'result') ?? events.find(candidate => candidate.kind === 'question');
2576
+ if (!event) return toolResult(true, { assignmentId: childId, agent: args.agent, status: 'running', events: events.map(publicEvent), next: CHECKED_IN });
2577
+ if (event.kind === 'result') return toolResult(event.result!.status === 'completed', { ...event.result, ...capped });
2578
+ return toolResult(true, {
2579
+ assignmentId: childId,
2580
+ agent: args.agent,
2581
+ status: 'running',
2582
+ events: [publicEvent(event)],
2583
+ next: 'Answer with alp_send kind answer, then alp_wait for this assignment.',
2584
+ });
2585
+ }
2586
+
2587
+ async function openSession(sessionId: string, spec: SessionSpec, history: 'replay' | 'skip', delegation = true): Promise<SessionSnapshot> {
2588
+ if (sessions.has(sessionId)) {
2589
+ throw new Error(
2590
+ 'Session is already open',
2591
+ );
2592
+ }
2593
+
2594
+ const mapping = await resolveSession(spec, { templates: options.templates, library: options.libraryDir, language: options.language });
2595
+
2596
+ const runtimeKind = mapping.runtimeKind;
2597
+
2598
+ const context =
2599
+ childContexts.get(sessionId);
2600
+
2601
+ if (context) mapping.workflow = context.workflow;
2602
+
2603
+ const graph =
2604
+ context?.graph ??
2605
+ (mapping.team ? mapping.team.delegation : await resolveDelegation(mapping.agent.projectRoot));
2606
+
2607
+ const targets: string[] =
2608
+ Object.hasOwn(
2609
+ graph,
2610
+ mapping.agent.name,
2611
+ )
2612
+ ? graph[mapping.agent.name]
2613
+ : [];
2614
+
2615
+ // An assignment opens inside its requester's turn; a supervisor opens beside an idle root, and so does a recovered assignment.
2616
+ if (
2617
+ context &&
2618
+ (
2619
+ !sessions.has(context.parent) ||
2620
+ sessions.get(context.parent)!.closed ||
2621
+ (!sessions.get(context.parent)!.active && context.role !== 'supervisor' && !context.recovered)
2622
+ )
2623
+ ) {
2624
+ throw new Error(
2625
+ 'Parent assignment stopped during child open',
2626
+ );
2627
+ }
2628
+
2629
+ const environment = nativeEnvironment(options, mapping.env);
2630
+
2631
+ const runtime = createTransport(
2632
+ options,
2633
+ runtimeKind,
2634
+ mapping.workdir,
2635
+ environment,
2636
+ mapping.acp,
2637
+ mapping.antigravity,
2638
+ );
2639
+
2640
+ const session: Session = {
2641
+ runtimeKind,
2642
+ runtime,
2643
+ mapping,
2644
+ threadId: '',
2645
+ pending: true,
2646
+ buffered: [],
2647
+ closed: false,
2648
+ seen: new Set(),
2649
+ text: new Map(),
2650
+
2651
+ spec,
2652
+ delegation,
2653
+ graph,
2654
+ ancestry:
2655
+ context?.ancestry ??
2656
+ [mapping.agent.name],
2657
+ parent: context?.parent,
2658
+ toolCallId: context?.callId,
2659
+ parentAgent: context
2660
+ ? sessions.get(context.parent)?.mapping.agent.name
2661
+ : undefined,
2662
+ ...(context?.role ? { role: context.role } : {}),
2663
+ journal: [],
2664
+ tasks: [],
2665
+
2666
+ children: new Set(),
2667
+ peerCount: 0,
2668
+ assignments: new Map(),
2669
+ calls: 0,
2670
+ mail: [],
2671
+ waiters: [],
2672
+ lastActivity: Date.now(),
2673
+ wakes: 0,
2674
+ wakeBlocked: false,
2675
+
2676
+ toolCalls: new Map(),
2677
+ acknowledged: Promise.resolve(),
2678
+ worktrees: new Map(),
2679
+ };
2680
+
2681
+ sessions.set(
2682
+ sessionId,
2683
+ session,
2684
+ );
2685
+
2686
+ wire(sessionId, session, runtime);
2687
+
2688
+ try {
2689
+ await runtime.initialize();
2690
+
2691
+ if (session.closed) {
2692
+ throw new Error(
2693
+ 'Session closed during initialization',
2694
+ );
2695
+ }
2696
+
2697
+ if (
2698
+ (targets.length || session.parentAgent) &&
2699
+ !runtime.onRequest
2700
+ ) {
2701
+ throw new Error(
2702
+ `${runtimeKind} runtime transport does not support delegation`,
2703
+ );
2704
+ }
2705
+
2706
+ if (
2707
+ targets.length &&
2708
+ !delegation
2709
+ ) {
2710
+ throw new Error(
2711
+ 'Host does not support delegated sessions',
2712
+ );
2713
+ }
2714
+
2715
+ const nativeConfig = await configOf(session);
2716
+ noteInstructions(sessionId, session, nativeConfig.developerInstructions);
2717
+
2718
+ const result = mapping.threadId
2719
+ ? await runtime.request(
2720
+ 'thread/resume',
2721
+ {
2722
+ ...nativeConfig,
2723
+ threadId:
2724
+ mapping.threadId,
2725
+ },
2726
+ )
2727
+ : await runtime.request(
2728
+ 'thread/start',
2729
+ {
2730
+ ...nativeConfig,
2731
+ ephemeral:
2732
+ !mapping.persist && !mapping.keepThread,
2733
+ },
2734
+ );
2735
+
2736
+ session.threadId =
2737
+ result.thread.id;
2738
+
2739
+ if (session.closed) {
2740
+ throw new Error(
2741
+ 'Session closed during thread creation',
2742
+ );
2743
+ }
2744
+
2745
+ session.pending = false;
2746
+ session.buffered = [];
2747
+
2748
+ emit(sessionId, {
2749
+ type: 'session.opened',
2750
+ session: snapshot(sessionId, session),
2751
+ cwd: result.cwd ?? mapping.workdir,
2752
+ effective: {
2753
+ model: result.model ?? mapping.model,
2754
+ thinking: result.reasoningEffort ?? mapping.thinking,
2755
+ },
2756
+ });
2757
+
2758
+ if (
2759
+ history === 'replay'
2760
+ ) {
2761
+ for (
2762
+ const turn of
2763
+ result.thread.turns ?? []
2764
+ ) {
2765
+ for (
2766
+ const item of
2767
+ turn.items ?? []
2768
+ ) {
2769
+ nativeItem(
2770
+ sessionId,
2771
+ session,
2772
+ item,
2773
+ );
2774
+ }
2775
+ }
2776
+ }
2777
+
2778
+ emit(sessionId, { type: 'session.ready' });
2779
+ void runHooks(sessionId, session, 'session.start', { resumed: !!spec.restore });
2780
+ return snapshot(sessionId, session);
2781
+ } catch (error) {
2782
+ log.error(`${session.mapping.agent.name} could not start its ${session.runtimeKind} session`, { code: 'ALP_PROVIDER_START_FAILED', sessionId, rootId: rootOf(sessionId), agent: session.mapping.agent.name, provider: session.runtimeKind, error });
2783
+ session.closed = true;
2784
+ sessions.delete(
2785
+ sessionId,
2786
+ );
2787
+
2788
+ await runtime.close();
2789
+
2790
+ throw error;
2791
+ }
2792
+ }
2793
+
2794
+ /** Connects a session to its transport: tool calls, notifications, and what a failure of its process does. */
2795
+ function wire(sessionId: string, session: Session, transport: RuntimeTransport) {
2796
+ transport.onRequest?.(
2797
+ async (method, params) =>
2798
+ method === 'item/tool/call' ? toolCall(sessionId, session, params)
2799
+ : method === 'item/permission/request' ? claudePermission(sessionId, session, params)
2800
+ : method === 'item/acp/permission' ? acpPermissionRequest(sessionId, session, params)
2801
+ : approve(sessionId, session, method, params),
2802
+ );
2803
+ // What ALP makes of a notification is its own bookkeeping: a slip in it is logged, never thrown into the
2804
+ // transport's read loop, where it would end the runtime's process in the middle of a turn (#69).
2805
+ transport.onNotification((method, params) => {
2806
+ try {
2807
+ notification(sessionId, session, method, params);
2808
+ } catch (error: any) {
2809
+ log.error(`handling ${method} failed`, { code: 'ALP_RUNTIME_SLIP', sessionId, agent: session.mapping.agent.name, method, error });
2810
+ try { runLog(rootOf(sessionId), { event: 'runtime.slip', sessionId, agent: session.mapping.agent.name, method, error: String(error?.message ?? error).slice(0, 500) }); } catch {}
2811
+ }
2812
+ });
2813
+ transport.onFailure((error) => {
2814
+ // A transport ALP already replaced reports nothing.
2815
+ if (session.closed || session.runtime !== transport) return;
2816
+ if (revivable(session)) void revive(sessionId, session, error);
2817
+ else failSession(sessionId, session, error);
2818
+ });
2819
+ }
2820
+
2821
+ function failSession(sessionId: string, session: Session, error: unknown) {
2822
+ log.error(`${session.mapping.agent.name}'s ${session.runtimeKind} session failed`, { code: 'ALP_PROVIDER_FAILED', sessionId, rootId: rootOf(sessionId), agent: session.mapping.agent.name, provider: session.runtimeKind, error });
2823
+ terminal(sessionId, session, 'failed', error);
2824
+ session.settle?.('failed', error);
2825
+ void closeSession(sessionId);
2826
+ emit(sessionId, { type: 'session.failed', error: errorData(error) });
2827
+ }
2828
+
2829
+ /**
2830
+ * Records which instructions a session runs with (ALPD §34): a short digest of the
2831
+ * whole text, and of the project's ALP.md and the agent's AGENT.md that go into it,
2832
+ * so a change in behaviour can be traced to the file that changed.
2833
+ */
2834
+ function noteInstructions(sessionId: string, session: Session, instructions: string) {
2835
+ const digest = (text: string) => createHash('sha256').update(text).digest('hex').slice(0, 12);
2836
+ const { project, agent } = session.mapping.agent.instructions;
2837
+ session.instructionsSha = digest(instructions);
2838
+ runLog(rootOf(sessionId), {
2839
+ event: 'instructions', sessionId, agent: session.mapping.agent.name, sha: session.instructionsSha, chars: instructions.length,
2840
+ parts: { project: digest(project), agent: digest(agent) },
2841
+ });
2842
+ }
2843
+
2844
+ /** The native thread configuration of a session: its instructions, tools and sandbox. */
2845
+ async function configOf(session: Session) {
2846
+ const { mapping } = session;
2847
+ const targets = Object.hasOwn(session.graph, mapping.agent.name) ? session.graph[mapping.agent.name] : [];
2848
+ const config = nativeSessionConfig(
2849
+ session.runtimeKind,
2850
+ mapping,
2851
+ targets,
2852
+ session.parentAgent,
2853
+ session.role,
2854
+ supervises(session),
2855
+ lessonFiles(mapping),
2856
+ Object.fromEntries(await Promise.all(targets.map(async target => [target, await profileFor(mapping.agent.projectRoot, options.libraryDir, target).catch(() => null)] as const))),
2857
+ );
2858
+ if (session.spec.writeRoot && session.runtimeKind === 'claude' && mapping.mode === 'workspace-write' && claudeSandboxAvailable()) Object.assign(config, { floor: 'workspace-write' });
2859
+ if (session.spec.writeRoot) config.developerInstructions += `\nWrite scope: change files only under ${session.spec.writeRoot}. Your working directory is there; the ALP project, tasks and board remain ${mapping.agent.projectRoot}. Board paths are project-relative or absolute; use absolute paths for this write root. This scope does not grant permission to write outside your mode.`;
2860
+ return config;
2861
+ }
2862
+
2863
+ /**
2864
+ * Whether ALP restarts a session whose native process died (ALPD §31): one with a
2865
+ * thread to resume, unless its last RESTART_LIMIT restarts within RESTART_WINDOW_MS
2866
+ * each brought no progress. Progress since the last restart clears the count.
2867
+ */
2868
+ function revivable(session: Session) {
2869
+ if (closed || session.closed || session.pending || !session.threadId || session.role === 'supervisor') return false;
2870
+ if (!session.mapping.persist && !session.mapping.keepThread) return false;
2871
+ return fruitlessRestarts(session).length < RESTART_LIMIT;
2872
+ }
2873
+
2874
+ function fruitlessRestarts(session: Session) {
2875
+ const restarts = session.restarts ?? [];
2876
+ if ((session.progressAt ?? 0) > (restarts.at(-1) ?? 0)) return [];
2877
+ const now = Date.now();
2878
+ return restarts.filter(at => now - at < RESTART_WINDOW_MS);
2879
+ }
2880
+
2881
+ /** Starts the session's native process again and resumes its thread; a turn it interrupted continues. */
2882
+ async function revive(sessionId: string, session: Session, error: unknown) {
2883
+ session.restarts = [...fruitlessRestarts(session), Date.now()];
2884
+ const harness = label(session.runtimeKind);
2885
+ log.warn(`${harness} process of ${session.mapping.agent.name} stopped; restarting it`, { code: 'ALP_PROVIDER_RESTARTED', sessionId, rootId: rootOf(sessionId), agent: session.mapping.agent.name, provider: session.runtimeKind, error });
2886
+ runLog(rootOf(sessionId), { event: 'session.restarted', sessionId, agent: session.mapping.agent.name, error: errorData(error).message, restarts: session.restarts.length });
2887
+ if (session.active) {
2888
+ session.parkReason = `its ${harness} process stopped`;
2889
+ session.parkThen = `ALP is restarting the ${harness} process; this assignment continues by itself.`;
2890
+ terminal(sessionId, session, 'failed', error);
2891
+ }
2892
+ const old = session.runtime;
2893
+ void old.close().catch(() => {});
2894
+ try {
2895
+ const transport = createTransport(options, session.runtimeKind, session.mapping.workdir, nativeEnvironment(options, session.mapping.env), session.mapping.acp, session.mapping.antigravity);
2896
+ session.runtime = transport;
2897
+ session.pending = true;
2898
+ wire(sessionId, session, transport);
2899
+ await transport.initialize();
2900
+ const config = await configOf(session);
2901
+ if (session.closed) return void transport.close().catch(() => {});
2902
+ await transport.request('thread/resume', { ...config, threadId: session.threadId });
2903
+ if (session.closed) return void transport.close().catch(() => {});
2904
+ session.pending = false;
2905
+ session.buffered = [];
2906
+ runLog(rootOf(sessionId), { event: 'session.revived', sessionId, agent: session.mapping.agent.name });
2907
+ if (session.parked && !pauseOf(session.runtimeKind)) {
2908
+ session.parked.prompt = `ALP restarted your ${harness} process after it stopped in the middle of your turn. Continue where you left off: your earlier work in this session and its files are intact.${session.parent ? ' Finish with alp_handoff as before.' : ''}`;
2909
+ continueParked(sessionId, session);
2910
+ }
2911
+ } catch (failure) {
2912
+ session.pending = false;
2913
+ session.parked = undefined;
2914
+ runLog(rootOf(sessionId), { event: 'session.revive_failed', sessionId, agent: session.mapping.agent.name, error: errorData(failure).message });
2915
+ failSession(sessionId, session, error);
2916
+ }
2917
+ }
2918
+
2919
+ /** Why recovered sessions were reopened, for what they are told. */
2920
+ const restartCause = () => options.previousExit?.kind === 'crash' ? 'alpd stopped unexpectedly' : 'alpd restarted';
2921
+
2922
+ /**
2923
+ * Opens an assignment an earlier alpd left running again, under its own id, in
2924
+ * its requester's tree: its thread, worktree or copy, write lease, task and claims.
2925
+ * Throws, leaving nothing behind but the worktree's branch, when it cannot continue.
2926
+ */
2927
+ async function resumeAssignment(entry: LiveEntry) {
2928
+ const parent = sessions.get(entry.parentId);
2929
+ if (!parent || parent.closed) throw new Error('its requester did not come back');
2930
+ const id = entry.assignmentId;
2931
+ const project = entry.project;
2932
+ const assignment: Assignment = {
2933
+ id, agent: entry.agent, mode: entry.mode, isolation: entry.isolation, rootId: entry.rootId, startedAt: entry.startedAt, warned: false, finished: false,
2934
+ ...(entry.fingerprint ? { fingerprint: entry.fingerprint } : {}),
2935
+ };
2936
+ let spec = entry.spec;
2937
+ // An older alpd may have accepted an effort removed from the catalog since.
2938
+ // Migrate only persisted assignments; new explicit requests stay strictly validated.
2939
+ if (entry.runtime !== 'acp' && spec.thinking) {
2940
+ const available = thinkingOptionsFor(entry.runtime, entry.model);
2941
+ if (available.length && !available.some(option => option.id === spec.thinking)) {
2942
+ const thinking = models.find(model => model.id === `${entry.runtime}:${entry.model}`)?.defaultThinkingOptionId ?? available[0].id;
2943
+ runLog(entry.rootId, { event: 'assignment.thinking_migrated', assignmentId: id, model: entry.model, from: spec.thinking, to: thinking });
2944
+ post(entry.parentId, { kind: 'note', from: entry.agent, assignment: id, passive: true, body: `Resuming ${id} with thinking ${thinking}: the current ALP catalog no longer accepts its saved thinking ${spec.thinking} for ${entry.runtime}:${entry.model}.` });
2945
+ spec = { ...spec, thinking };
2946
+ }
2947
+ }
2948
+ if (writes(entry.mode)) assignment.writeCheckout = entry.worktree?.checkout ?? await checkoutKey(spec.workdir ?? parent.mapping.workdir);
2949
+ const undo = async () => {
2950
+ parent.children.delete(id);
2951
+ parent.assignments.delete(id);
2952
+ childContexts.delete(id);
2953
+ if (assignment.lease && leases.get(assignment.lease)?.assignment === id) leases.delete(assignment.lease);
2954
+ if (roleOf(parent.mapping, entry.agent) === 'peer') { const root = sessions.get(entry.rootId); if (root) root.peerCount--; }
2955
+ if (assignment.copy) await removeCopy(assignment.copy).catch(() => {});
2956
+ if (assignment.worktree) await removeWorktree(assignment.worktree).catch(() => {});
2957
+ };
2958
+ try {
2959
+ if (entry.lease) {
2960
+ const holder = [...leases].find(([key]) => overlapping([entry.lease!], [key]).length)?.[1];
2961
+ if (holder && !lineage(entry.parentId).includes(holder.assignment)) throw new Error(`${holder.agent} writes ${entry.lease} now`);
2962
+ if (!holder) { assignment.lease = entry.lease; leases.set(entry.lease, { assignment: id, agent: entry.agent }); }
2963
+ }
2964
+ if (entry.worktree) assignment.worktree = await reattachWorktree(entry.worktree);
2965
+ if (entry.copyOf) {
2966
+ assignment.copy = await createCopy(entry.copyOf, copyRoot, id);
2967
+ spec = { ...spec, workdir: assignment.copy.workdir };
2968
+ }
2969
+ parent.children.add(id);
2970
+ parent.assignments.set(id, assignment);
2971
+ childContexts.set(id, { parent: entry.parentId, callId: entry.callId, graph: parent.graph, workflow: parent.mapping.workflow, ancestry: entry.ancestry, recovered: true });
2972
+ if (roleOf(parent.mapping, entry.agent) === 'peer') { const root = sessions.get(entry.rootId); if (root) root.peerCount++; }
2973
+ if (entry.taskId) {
2974
+ await retakeTask(project, entry.taskId, { assignment: id, pid: process.pid, pidStartedAt: OWN_START, epoch }, 'alpd');
2975
+ assignment.taskId = entry.taskId;
2976
+ touchTask(entry.rootId, entry.taskId);
2977
+ }
2978
+ await openSession(id, { ...spec, restore: { agent: entry.agent, threadId: entry.threadId, runtime: entry.runtime, model: entry.model, workflow: parent.mapping.workflow } }, 'skip', entry.delegation);
2979
+ const child = sessions.get(id)!;
2980
+ child.settle = (state, error) => void finishAssignment(entry.parentId, parent, assignment, state, error);
2981
+ if (entry.brief) child.brief = entry.brief;
2982
+ if (assignment.taskId && writes(entry.mode)) {
2983
+ const task = await getTask(project, assignment.taskId).catch(() => undefined);
2984
+ if (task?.paths.length) {
2985
+ const pins = await boardOf(project);
2986
+ if (!claimConflicts(id, pins, task.paths).length) addPin(id, child, pins, { kind: 'claim', body: `Task ${task.id}: ${task.title}`, paths: task.paths, task: task.id });
2987
+ }
2988
+ }
2989
+ } catch (error) {
2990
+ await undo();
2991
+ throw error;
2992
+ }
2993
+ void inFlight.put({ ...entry, epoch, spec: { ...spec, thinking: sessions.get(id)!.mapping.thinking } });
2994
+ emit(entry.parentId, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'running') });
2995
+ return assignment;
2996
+ }
2997
+
2998
+ /** An assignment an earlier alpd left running that cannot continue: its task goes back to open, its work stays on its branch. */
2999
+ async function abandonEntry(entry: LiveEntry, reason: string) {
3000
+ await inFlight.remove(entry.assignmentId);
3001
+ const parent = sessions.get(entry.parentId);
3002
+ let task: Record<string, unknown> | undefined;
3003
+ if (entry.taskId) {
3004
+ const released = await releaseTask(entry.project, entry.taskId, { assignment: entry.assignmentId, handoff: null, agent: entry.agent, reason: `${restartCause()} and it could not continue: ${reason}` }, 'alpd').catch(() => undefined);
3005
+ if (released) { task = { id: released.id, status: released.status }; touchTask(entry.rootId, released.id); }
3006
+ }
3007
+ if (entry.worktree) runLog(entry.rootId, { event: 'worktree.kept', assignmentId: entry.assignmentId, branch: entry.worktree.branch });
3008
+ const result = {
3009
+ agent: entry.agent, sessionId: entry.assignmentId, status: 'failed', handoff: null, output: '',
3010
+ error: `${restartCause()} while it worked, and it could not continue: ${reason}`,
3011
+ ...(entry.worktree ? { worktree: { branch: entry.worktree.branch, kept: true } } : {}), ...(task ? { task } : {}),
3012
+ };
3013
+ runLog(entry.rootId, { event: 'assignment.finished', assignmentId: entry.assignmentId, reconciled: true, ...result });
3014
+ if (parent && !parent.closed) post(entry.parentId, { kind: 'result', from: entry.agent, assignment: entry.assignmentId, result });
3015
+ }
3016
+
3017
+ /**
3018
+ * Continues what an earlier alpd left running in an open root's tree, nearest the
3019
+ * root first, since each assignment needs its requester open. Each one continues
3020
+ * its turn, or waits parked when its runtime is paused or the user resumes recovery.
3021
+ */
3022
+ async function recoverTree(rootId: string, continueRoot: boolean): Promise<RecoveryOutcome[]> {
3023
+ const root = sessions.get(rootId);
3024
+ if (!root || root.closed) throw new Error(`Session ${rootId} is not open`);
3025
+ const entries = inherited.filter(entry => entry.rootId === rootId).sort((a, b) => a.ancestry.length - b.ancestry.length);
3026
+ inherited = inherited.filter(entry => entry.rootId !== rootId);
3027
+ const outcomes: RecoveryOutcome[] = [];
3028
+ const resumed = new Map<string, Assignment[]>();
3029
+ for (const entry of entries) {
3030
+ try {
3031
+ const assignment = await resumeAssignment(entry);
3032
+ resumed.set(entry.parentId, [...(resumed.get(entry.parentId) ?? []), assignment]);
3033
+ outcomes.push({ assignmentId: entry.assignmentId, agent: entry.agent, outcome: 'resumed' });
3034
+ } catch (error) {
3035
+ const message = errorData(error).message;
3036
+ await abandonEntry(entry, message);
3037
+ outcomes.push({ assignmentId: entry.assignmentId, agent: entry.agent, outcome: 'failed', error: message });
3038
+ }
3039
+ }
3040
+ const cause = restartCause();
3041
+ const listed = (assignments: Assignment[] = []) => assignments.length
3042
+ ? ` These assignments of yours continue and report to you: ${assignments.map(assignment => `${assignment.id} (${assignment.agent}${assignment.taskId ? `, task ${assignment.taskId}` : ''})`).join(', ')}. Wait for them with alp_wait, or keep working.`
3043
+ : '';
3044
+ const proceed = (sessionId: string, session: Session, text: string) => {
3045
+ if (options.recoveryResume === false || pauseOf(session.runtimeKind)) {
3046
+ session.parked = { reason: cause, since: Date.now(), prompt: text };
3047
+ emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
3048
+ runLog(rootId, { event: 'assignment.parked', assignmentId: sessionId, agent: session.mapping.agent.name, reason: cause });
3049
+ const outcome = outcomes.find(entry => entry.assignmentId === sessionId);
3050
+ if (outcome) outcome.outcome = 'parked';
3051
+ return;
3052
+ }
3053
+ void startPrompt(sessionId, { clientMessageId: `alp-recover-${randomUUID()}`, delivery: 'auto', content: [{ type: 'text', text }] }, session.parent ? 'assignment' : 'wake')
3054
+ .catch(error => session.settle?.('failed', error));
3055
+ };
3056
+ for (const entry of entries) {
3057
+ const child = sessions.get(entry.assignmentId);
3058
+ if (!child || child.closed || !outcomes.some(outcome => outcome.assignmentId === entry.assignmentId && outcome.outcome === 'resumed')) continue;
3059
+ runLog(rootId, { event: 'assignment.recovered', assignmentId: entry.assignmentId, agent: entry.agent, ...(entry.taskId ? { taskId: entry.taskId } : {}) });
3060
+ proceed(entry.assignmentId, child, `ALP: ${cause} while you worked on this assignment, and reopened this session. Your earlier work in it${entry.worktree ? ' and your worktree' : ''} is intact; check where you stopped (for example with git status), then continue. Finish with alp_handoff as before.${listed(resumed.get(entry.assignmentId))}`);
3061
+ }
3062
+ if (continueRoot) proceed(rootId, root, `ALP: ${cause} during your turn, and reopened this session. Continue the user's request where you stopped.${listed(resumed.get(rootId))}`);
3063
+ else for (const assignment of resumed.get(rootId) ?? []) post(rootId, { kind: 'note', from: assignment.agent, assignment: assignment.id, passive: true, body: `${cause}; ALP reopened this assignment and it continues. It reports when done.` });
3064
+ const failed = outcomes.filter(outcome => outcome.outcome === 'failed');
3065
+ if (entries.length || continueRoot) {
3066
+ const parked = outcomes.some(outcome => outcome.outcome === 'parked') || (continueRoot && !!root.parked);
3067
+ notice(failed.length ? 'warning' : 'info',
3068
+ `ALP: ${cause}${options.previousExit?.at ? ` (around ${options.previousExit.at})` : ''}. ` +
3069
+ `${entries.length - failed.length} of ${entries.length} running assignments reopened${continueRoot ? ', and this session' : ''}` +
3070
+ `${parked ? '; they wait parked: run alp resume to continue them' : ''}.` +
3071
+ `${failed.length ? ` Could not continue: ${failed.map(outcome => `${outcome.agent} ${outcome.assignmentId} (${outcome.error})`).join('; ')}; their tasks went back to open.` : ''}`,
3072
+ root.mapping.agent.projectRoot);
3073
+ }
3074
+ return outcomes;
3075
+ }
3076
+
3077
+ function openOf(sessionId: string) {
3078
+ const session =
3079
+ sessions.get(sessionId);
3080
+
3081
+ if (
3082
+ !session ||
3083
+ session.closed
3084
+ ) {
3085
+ throw new Error(
3086
+ 'Session is not open',
3087
+ );
3088
+ }
3089
+ return session;
3090
+ }
3091
+
3092
+ async function configureSession(sessionId: string, changes: { mode?: string }) {
3093
+ const session = openOf(sessionId);
3094
+ if (session.active || session.pending || session.children.size) throw new Error('Wait for the current turn and child sessions to finish before changing permissions');
3095
+ const mode = changes.mode ?? session.mapping.mode;
3096
+ if (!modes.some(candidate => candidate.id === mode)) throw new Error(`Unsupported mode '${mode}'`);
3097
+ const profile = session.mapping.permissions;
3098
+ if (profile && !withinMode(mode, profile.base)) {
3099
+ throw new Error(READ_ONLY_AGENTS.includes(session.mapping.agent.name) ? 'Advisors and the supervisor must remain read-only' : `Permission profile ${profile.name} allows at most ${profile.base}`);
3100
+ }
3101
+ const parent = session.parent ? sessions.get(session.parent) : undefined;
3102
+ if (parent && !withinMode(mode, parent.mapping.mode)) throw new Error('Child cannot exceed parent permissions');
3103
+ if (session.runtimeKind === 'antigravity' && mode !== session.mapping.mode) throw new Error('Antigravity uses full-access natively; its permission instructions are fixed for the session. Open a new session to change mode');
3104
+ if (session.runtimeKind === 'claude') await session.runtime.request('session/configure', { sandbox: nativeMode({ mode, copy: session.mapping.copy }) });
3105
+ session.mapping.mode = mode;
3106
+ session.spec = { ...session.spec, mode };
3107
+ const updated = snapshot(sessionId, session);
3108
+ emit(sessionId, { type: 'session.updated', session: updated });
3109
+ return updated;
3110
+ }
3111
+
3112
+ /** Codex threads whose last turn the user stopped: what it had written, for the next prompt. */
3113
+ const interruptedThreads = new Map<string, string>();
3114
+
3115
+ async function interruptSession(sessionId: string) {
3116
+ const session = openOf(sessionId);
3117
+ const activeTurn =
3118
+ session.active;
3119
+
3120
+ session.wakeBlocked = true;
3121
+ session.userStopped = true;
3122
+ // Codex keeps no output of an interrupted turn in its thread: remember what the user saw, by
3123
+ // thread, since a client that leaves closes the session and the next prompt opens it again.
3124
+ if (activeTurn && session.runtimeKind === 'codex' && session.threadId) {
3125
+ const partial = [...session.text.values()].join('\n').trim();
3126
+ if (partial) interruptedThreads.set(session.threadId, partial.slice(-4000));
3127
+ }
3128
+
3129
+ terminal(
3130
+ sessionId,
3131
+ session,
3132
+ 'canceled',
3133
+ );
3134
+
3135
+ await Promise.all(
3136
+ [...session.children].map(
3137
+ closeSession,
3138
+ ),
3139
+ );
3140
+
3141
+ // An idle requester waiting on its assignments has no turn to cancel; end its assignment now.
3142
+ if (!activeTurn) session.settle?.('canceled', 'Interrupted');
3143
+
3144
+ if (activeTurn) {
3145
+ await session.runtime.request(
3146
+ 'turn/interrupt',
3147
+ {
3148
+ threadId:
3149
+ session.threadId,
3150
+ turnId: activeTurn,
3151
+ },
3152
+ );
3153
+ }
3154
+ }
3155
+
3156
+ /** Starts a turn or steers the running one; throws when the prompt was not delivered. */
3157
+ async function startPrompt(sessionId: string, prompt: PromptInput, origin: TurnOrigin, wake?: MailEvent[]) {
3158
+ const session = openOf(sessionId);
3159
+
3160
+ if (
3161
+ session.seen.has(
3162
+ prompt.clientMessageId,
3163
+ )
3164
+ ) {
3165
+ return;
3166
+ }
3167
+
3168
+ if (
3169
+ prompt.content.some(
3170
+ (content) =>
3171
+ content.type !== 'text',
3172
+ )
3173
+ ) {
3174
+ throw new Error(
3175
+ 'Only text prompts are supported',
3176
+ );
3177
+ }
3178
+
3179
+ if (
3180
+ prompt.delivery === 'steer' &&
3181
+ !session.active
3182
+ ) {
3183
+ throw new Error(
3184
+ 'No active turn to steer',
3185
+ );
3186
+ }
3187
+
3188
+ if (
3189
+ prompt.delivery !== 'steer' &&
3190
+ session.active
3191
+ ) {
3192
+ throw new Error(
3193
+ 'Turn already active; use steering or interrupt first',
3194
+ );
3195
+ }
3196
+
3197
+ const text = prompt.content
3198
+ .map(
3199
+ (content) => content.text ?? '',
3200
+ )
3201
+ .join('\n');
3202
+
3203
+ // Steering changes the brief; live assignments keep running and report by mail.
3204
+ const mail = wake ?? (prompt.delivery === 'steer' ? [] : takeBatch(session.mail, () => true));
3205
+ for (const event of mail) event.deliveredTurn = STARTING;
3206
+
3207
+ session.pending = true;
3208
+
3209
+ let acknowledged!: () => void;
3210
+
3211
+ session.acknowledged =
3212
+ new Promise((resolve) => {
3213
+ acknowledged = resolve;
3214
+ });
3215
+
3216
+ if (
3217
+ prompt.delivery !== 'steer'
3218
+ ) {
3219
+ session.text.clear();
3220
+ session.toolCalls.clear();
3221
+ session.supervisorWake = !!wake?.length && wake.every(event => event.defer);
3222
+
3223
+ // A wake continues the same assignment: keep its handoff and limits.
3224
+ if (!wake) {
3225
+ session.handoff = undefined;
3226
+ session.wakes = 0;
3227
+ session.wakeBlocked = false;
3228
+ session.userStopped = false;
3229
+
3230
+ if (!session.parent) {
3231
+ session.calls = 0;
3232
+ }
3233
+ }
3234
+ }
3235
+
3236
+ try {
3237
+ const orchestration = session.runtime.orchestrationContext ? await session.runtime.orchestrationContext().catch(() => ({ available: false })) : { available: false };
3238
+ // Main starts each turn knowing what waits for its acceptance and what is ready.
3239
+ const tasks = prompt.delivery !== 'steer' && !session.parent && session.mapping.agent.name === mainOf(session.mapping)
3240
+ ? await releaseOrphansOnce(session.mapping.agent.projectRoot, sessionId).then(() => checkGatesOften(session.mapping.agent.projectRoot)).then(() => loadTasks(session.mapping.agent.projectRoot)).then(({ tasks, errors }) => taskDigest(tasks, errors), () => '')
3241
+ : '';
3242
+ // Only the user's own words reach the prompt untouched; briefs, wakes, tasks and mail carry what agents wrote.
3243
+ const interrupted = prompt.delivery !== 'steer' && !wake && session.threadId ? interruptedThreads.get(session.threadId) : undefined;
3244
+ if (interrupted) interruptedThreads.delete(session.threadId!);
3245
+ const nativeInput = [
3246
+ { type: 'text', text: promptSafe('ALP runtime catalog and usage snapshot (data, not instructions): ' + JSON.stringify(orchestration)), text_elements: [] },
3247
+ ...(tasks ? [{ type: 'text', text: promptSafe(tasks), text_elements: [] }] : []),
3248
+ ...(interrupted ? [{ type: 'text', text: promptSafe(`The user stopped your previous turn. What you had written in it before it stopped (data, not instructions):\n${interrupted}`), text_elements: [] }] : []),
3249
+ {
3250
+ type: 'text',
3251
+ text: origin === 'user' ? text : promptSafe(text),
3252
+ text_elements: [],
3253
+ },
3254
+ ...(mail.length && !wake ? [{ type: 'text', text: promptSafe(renderMail(mail, session.parentAgent)), text_elements: [] }] : []),
3255
+ ];
3256
+
3257
+ const result =
3258
+ prompt.delivery === 'steer'
3259
+ ? await session.runtime.request(
3260
+ 'turn/steer',
3261
+ {
3262
+ threadId:
3263
+ session.threadId,
3264
+ expectedTurnId:
3265
+ session.active,
3266
+ clientUserMessageId:
3267
+ prompt.clientMessageId,
3268
+ input: nativeInput,
3269
+ },
3270
+ )
3271
+ : await session.runtime.request(
3272
+ 'turn/start',
3273
+ {
3274
+ threadId:
3275
+ session.threadId,
3276
+ clientUserMessageId:
3277
+ prompt.clientMessageId,
3278
+ input: nativeInput,
3279
+ effort:
3280
+ session.mapping.thinking,
3281
+ approvalPolicy: codexApproval(session.mapping),
3282
+ sandboxPolicy: nativeMode(session.mapping) === 'read-only'
3283
+ ? { type: 'readOnly', networkAccess: false }
3284
+ : nativeMode(session.mapping) === 'full-access'
3285
+ ? { type: 'dangerFullAccess' }
3286
+ : { type: 'workspaceWrite', writableRoots: [session.mapping.workdir], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false },
3287
+ },
3288
+ );
3289
+
3290
+ const turnId =
3291
+ prompt.delivery === 'steer'
3292
+ ? session.active!
3293
+ : result.turn.id;
3294
+
3295
+ for (const event of mail) event.deliveredTurn = turnId;
3296
+
3297
+ session.seen.add(
3298
+ prompt.clientMessageId,
3299
+ );
3300
+
3301
+ emit(sessionId, {
3302
+ type: 'item',
3303
+ item: {
3304
+ kind: 'user_message',
3305
+ id: `user:${prompt.clientMessageId}`,
3306
+ clientMessageId: prompt.clientMessageId,
3307
+ text,
3308
+ },
3309
+ });
3310
+ if (origin === 'user' && !session.parent) session.userWaiting ??= Date.now();
3311
+ if (session.supervisor && origin === 'user') session.journal.push(stamp(`user ${prompt.delivery === 'steer' ? 'steered' : 'asked'} ${session.mapping.agent.name}: ${clip(text, 1500)}`));
3312
+ // The steer reaches the model once its waiting tool returns; return it now, not when the work ends.
3313
+ if (prompt.delivery === 'steer' && origin === 'user' && !session.parent) releaseWaiters(session);
3314
+
3315
+ emit(sessionId, {
3316
+ type: 'prompt.accepted',
3317
+ clientMessageId: prompt.clientMessageId,
3318
+ result: prompt.delivery === 'steer' ? 'steer' : 'turn',
3319
+ turnId,
3320
+ });
3321
+
3322
+ if (
3323
+ prompt.delivery !== 'steer'
3324
+ ) {
3325
+ session.active = turnId;
3326
+ emit(sessionId, { type: 'turn.started', turnId, origin });
3327
+ }
3328
+ } finally {
3329
+ // A turn that never started did not receive its mail.
3330
+ for (const event of mail) if (event.deliveredTurn === STARTING) event.deliveredTurn = undefined;
3331
+
3332
+ session.pending = false;
3333
+ acknowledged();
3334
+
3335
+ for (
3336
+ const [method, params] of
3337
+ session.buffered.splice(0)
3338
+ ) {
3339
+ notification(
3340
+ sessionId,
3341
+ session,
3342
+ method,
3343
+ params,
3344
+ );
3345
+ }
3346
+
3347
+ // Mail beyond the first batch follows into the running turn.
3348
+ if (hasActiveMail(session)) deliver(sessionId, session);
3349
+ }
3350
+ }
3351
+
3352
+ // Threads past their recall time go when the host starts, and after each assignment.
3353
+ if (options.recallFile) void forgetExpired();
3354
+
3355
+ return {
3356
+ onEvent(listener) {
3357
+ listeners.add(listener);
3358
+ return () => {
3359
+ listeners.delete(listener);
3360
+ };
3361
+ },
3362
+
3363
+ open: (sessionId, spec, { history = 'skip', delegation = true } = {}) => enqueue(async () => {
3364
+ const opened = await openSession(sessionId, spec, history, delegation);
3365
+ const root = sessions.get(sessionId)!;
3366
+ // Main starts its supervisor beside it, without delaying its own open.
3367
+ if (supervises(root)) void enqueue(() => openSupervisor(sessionId));
3368
+ return opened;
3369
+ }),
3370
+
3371
+ prompt: (sessionId, input) => enqueue(async () => {
3372
+ try {
3373
+ await startPrompt(sessionId, input, 'user');
3374
+ } catch (error) {
3375
+ const session = sessions.get(sessionId);
3376
+ if (session?.seen.has(input.clientMessageId)) return;
3377
+ session?.seen.add(input.clientMessageId);
3378
+ emit(sessionId, { type: 'prompt.failed', clientMessageId: input.clientMessageId, error: errorData(error) });
3379
+ }
3380
+ }),
3381
+
3382
+ interrupt: sessionId => enqueue(() => interruptSession(sessionId)),
3383
+
3384
+ configure: (sessionId, changes) => enqueue(() => configureSession(sessionId, changes)),
3385
+
3386
+ close: sessionId => enqueue(async () => {
3387
+ openOf(sessionId);
3388
+ await closeSession(sessionId);
3389
+ }),
3390
+
3391
+ snapshot(sessionId) {
3392
+ const session = sessions.get(sessionId);
3393
+ return session && !session.closed ? snapshot(sessionId, session) : undefined;
3394
+ },
3395
+
3396
+ status(sessionId) {
3397
+ const rootId = rootOf(sessionId);
3398
+ const root = sessions.get(rootId);
3399
+ if (!root || root.closed) return undefined;
3400
+ const now = Date.now();
3401
+ const tree: Array<[string, Session]> = [];
3402
+ const visit = (id: string) => {
3403
+ const session = sessions.get(id);
3404
+ if (!session || session.closed) return;
3405
+ tree.push([id, session]);
3406
+ for (const child of session.children) visit(child);
3407
+ if (session.supervisor) visit(session.supervisor);
3408
+ };
3409
+ visit(rootId);
3410
+ const questions = [...userQuestions.values()].map(pending => pending.question).filter(question => question.rootId === rootId);
3411
+ const asking = new Set(questions.map(question => question.sessionId));
3412
+ const ids = new Set(tree.map(([id]) => id));
3413
+ return {
3414
+ rootId,
3415
+ sessions: tree.map(([id, session]) => {
3416
+ const parent = session.parent ? sessions.get(session.parent) : undefined;
3417
+ const waitingParent = !!parent?.assignments.get(id)?.ask && !asking.has(id);
3418
+ const snap = snapshot(id, session);
3419
+ return {
3420
+ ...snap,
3421
+ state: asking.has(id) ? 'waiting_user' as const : waitingParent ? 'waiting_parent' as const : session.active ? 'running' as const : snap.busy ? 'waiting' as const : 'idle' as const,
3422
+ idleMs: now - session.lastActivity,
3423
+ workdir: session.mapping.workdir,
3424
+ unreadMail: session.mail.filter(event => !event.deliveredTurn && !event.passive).length,
3425
+ };
3426
+ }),
3427
+ assignments: tree.flatMap(([id, session]) => [...session.assignments.values()].map(assignment => ({
3428
+ ...assignmentSnapshot(assignment, asking.has(assignment.id) ? 'waiting_user' : assignment.ask ? 'waiting_parent' : 'running'),
3429
+ requester: id,
3430
+ isolation: assignment.isolation,
3431
+ idleMs: now - (sessions.get(assignment.id)?.lastActivity ?? assignment.startedAt),
3432
+ }))),
3433
+ questions,
3434
+ worktrees: tree.flatMap(([id, session]) => [...session.worktrees].map(([assignmentId, pending]) => ({
3435
+ assignmentId, requester: id, agent: pending.agent, branch: pending.worktree.branch, files: pending.change.files, stat: pending.change.stat,
3436
+ }))),
3437
+ leases: [...leases].filter(([, holder]) => ids.has(holder.assignment)).map(([checkout, holder]) => ({ checkout, assignmentId: holder.assignment, agent: holder.agent })),
3438
+ claims: (loadedBoards.get(root.mapping.agent.projectRoot) ?? []).filter(pin => pin.kind === 'claim' && live(pin) && ids.has(pin.sessionId)),
3439
+ };
3440
+ },
3441
+
3442
+ pause(input = {}) {
3443
+ const { runtime, now = false, reason } = input;
3444
+ if (runtime !== undefined && !RUNTIMES.includes(runtime)) throw new Error('runtime must be codex or claude');
3445
+ if (reason !== undefined && (typeof reason !== 'string' || reason.length > 500)) throw new Error('reason must be text of at most 500 characters');
3446
+ const pause: Pause = { since: new Date().toISOString(), by: 'the user', reason: reason?.trim() || 'paused by the user' };
3447
+ pauseRuntime(runtime ?? 'all', pause, now);
3448
+ notice('warning', say => say.paused(runtime ? label(runtime) : 'ALP', reason?.trim() || undefined, runtime && label(runtime), Boolean(now)));
3449
+ return pauseState();
3450
+ },
3451
+
3452
+ resume(input = {}) {
3453
+ const { runtime } = input;
3454
+ if (runtime !== undefined && !RUNTIMES.includes(runtime)) throw new Error('runtime must be codex or claude');
3455
+ if (runtime && paused.all) throw new Error('All of ALP is paused; alp resume without a runtime lifts it');
3456
+ resumeRuntime(runtime ?? 'all', 'the user');
3457
+ return pauseState();
3458
+ },
3459
+
3460
+ pauses: () => pauseState(),
3461
+ async usage(refresh = false) {
3462
+ const providers = await Promise.all((['codex', 'claude'] as const).map(kind => cachedUsage(kind, refresh)));
3463
+ const state = pauseState();
3464
+ return { providers: providers.map(provider => ({ ...provider, pause: state.all ?? state.runtimes[provider.runtime] ?? null, parked: state.parked.filter(entry => entry.runtime === provider.runtime).length })) };
3465
+ },
3466
+
3467
+ async recall(target, question) {
3468
+ if (typeof question !== 'string' || !question.trim() || question.length > RECALL_QUESTION_CHARS) throw new Error(`The question must be text of at most ${RECALL_QUESTION_CHARS} characters`);
3469
+ if (!target || (typeof target.assignmentId === 'string') === (typeof target.taskId === 'string')) throw new Error('Name either an assignment or a task');
3470
+ if (target.assignmentId && sessions.has(target.assignmentId)) throw new Error('That assignment is still running; write to it with alp send');
3471
+ const entry = await findRecall(target);
3472
+ if (!entry) throw new Error(`${target.taskId ? `No recallable assignment worked on ${target.taskId}` : `No recallable assignment ${target.assignmentId}`}; assignments stay recallable for ${RECALL_KEEP_MS / 86_400_000} days`);
3473
+ const answer = await askRecalled(entry, 'The user', question);
3474
+ runLog(entry.rootId, { event: 'recall', agent: 'user', assignmentId: entry.assignmentId, recalled: entry.agent, question: clip(question, 500), answer: clip(answer, 500) });
3475
+ return { assignmentId: entry.assignmentId, agent: entry.agent, ...(entry.taskId ? { taskId: entry.taskId } : {}), finishedAt: entry.finishedAt, answer };
3476
+ },
3477
+
3478
+ async preview(spec) {
3479
+ const resolveOptions = { templates: options.templates, library: options.libraryDir, language: options.language };
3480
+ // As Paseo: the team settings name, else Phở; a new project's settings name Phở.
3481
+ const settings = JSON.parse(await readFile(path.join(spec.cwd, '.alp', 'settings.json'), 'utf8').catch(() => '{}'));
3482
+ const named = typeof settings?.workflow?.mode === 'string' && settings.workflow.mode !== 'custom' ? settings.workflow.mode : undefined;
3483
+ const workflow = spec.workflow ?? named ?? DEFAULT_TEAM;
3484
+ // A preview only reads: a folder the user has not run anything in stays as it is.
3485
+ const mapping = await resolveSession({ ...spec, workflow }, { ...resolveOptions, initialize: false });
3486
+ const teams = (await listTeams(spec.cwd, { library: options.libraryDir, templates: options.templates }))
3487
+ .filter(team => !team.error)
3488
+ .map(team => ({ id: team.id, label: team.label ?? team.id, ...(team.description ? { description: team.description } : {}) }));
3489
+ return {
3490
+ teams,
3491
+ team: mapping.workflow.mode,
3492
+ ...(mapping.team?.label ? { teamLabel: mapping.team.label } : {}),
3493
+ agent: mapping.agent.name,
3494
+ runtime: mapping.runtimeKind,
3495
+ model: mapping.model,
3496
+ mode: mapping.mode,
3497
+ thinking: mapping.thinking,
3498
+ };
3499
+ },
3500
+
3501
+ async board(projectRoot) {
3502
+ if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) throw new Error('board needs an absolute projectRoot');
3503
+ return (await boardOf(path.resolve(projectRoot))).filter(live);
3504
+ },
3505
+
3506
+ questions() {
3507
+ return [...userQuestions.values()].map(pending => pending.question);
3508
+ },
3509
+
3510
+ message(sessionId, text) {
3511
+ const session = openOf(sessionId);
3512
+ if (typeof text !== 'string' || !text.trim() || text.length > MAIL_BODY_CHARS) throw new Error(`A message needs text of at most ${MAIL_BODY_CHARS} characters`);
3513
+ // Writing to an assignment opens it to the user, and its requester is told.
3514
+ if (session.parent) session.userOpened = true;
3515
+ post(sessionId, { kind: 'note', from: USER, assignment: sessionId, body: text });
3516
+ if (session.parent) tellRequester(sessionId, session, `The user wrote to me directly: "${text}"`);
3517
+ },
3518
+
3519
+ answer(questionId, reply) {
3520
+ const pending = userQuestions.get(questionId);
3521
+ if (!pending) throw new Error(`No question ${questionId} waits for an answer`);
3522
+ if (reply.dismiss) {
3523
+ pending.settle('dismissed', undefined, reply.reason);
3524
+ return;
3525
+ }
3526
+ if (typeof reply.text !== 'string' || !reply.text.trim()) throw new Error('An answer needs text');
3527
+ if (reply.text.length > MAIL_BODY_CHARS) throw new Error(`An answer is at most ${MAIL_BODY_CHARS} characters`);
3528
+ pending.settle('answered', reply.text);
3529
+ },
3530
+
3531
+ list() {
3532
+ const open = [...sessions].filter(([, session]) => !session.closed && session.threadId);
3533
+ const depth = (session: Session) => session.ancestry.length;
3534
+ return open.sort(([, a], [, b]) => depth(a) - depth(b)).map(([id, session]) => snapshot(id, session));
3535
+ },
3536
+
3537
+ recoverable() {
3538
+ return [...inherited];
3539
+ },
3540
+
3541
+ recover(rootId, { continueRoot = false } = {}) {
3542
+ return enqueue(() => recoverTree(rootId, continueRoot));
3543
+ },
3544
+
3545
+ async abandon(rootId, reason) {
3546
+ const entries = inherited.filter(entry => entry.rootId === rootId);
3547
+ inherited = inherited.filter(entry => entry.rootId !== rootId);
3548
+ for (const entry of entries) await abandonEntry(entry, reason);
3549
+ return entries.map(entry => ({ assignmentId: entry.assignmentId, agent: entry.agent, outcome: 'failed' as const, error: reason }));
3550
+ },
3551
+
3552
+ async shutdown() {
3553
+ closed = true;
3554
+
3555
+ await queue;
3556
+
3557
+ await Promise.all(
3558
+ [...sessions.keys()].map(
3559
+ closeSession,
3560
+ ),
3561
+ );
3562
+
3563
+ clearInterval(watchdog);
3564
+
3565
+ // Claims released by the closes above are written before the board is left.
3566
+ await Promise.all(boards.values());
3567
+ await boardDomain.flush();
3568
+ await runLogWrites;
3569
+ await forgetting;
3570
+ await recalls.flush();
3571
+ await inFlight.flush();
3572
+ if (limitWatch) clearInterval(limitWatch);
3573
+ await Promise.all([...limitChecks.values()].map(check => check.running));
3574
+ await pauseWrites;
3575
+
3576
+ listeners.clear();
3577
+ },
3578
+ };
3579
+ }