pi-subagents-j0k3r 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manager.ts ADDED
@@ -0,0 +1,533 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { loadSubagents, readSubagentsConfig } from './config.js';
3
+ import { writeSubagentsDebugLog } from './debug.js';
4
+ import { sdkSubagentRunner } from './runner.js';
5
+ import { SubagentHistoryStore } from './history.js';
6
+ import { publishInteractionResponse, sanitizeInteractionTransportText } from './interaction-channel.js';
7
+ import { resolveEffectiveSubagentProfile } from './profile-resolver.js';
8
+ import type { SubagentInteractionRequest, SubagentInteractionResponse } from './interaction-channel.js';
9
+ import type { ModelRef, SubagentDefinition, SubagentRunInput, SubagentsConfig, SubagentRunner, SubagentTask } from './types.js';
10
+
11
+ function nowIso(): string { return new Date().toISOString(); }
12
+ function taskId(agent: string): string { return `subtask_${agent}_${Date.now()}_${randomUUID().replace(/-/g, '').slice(0, 8)}`; }
13
+ function subagentAuditLog(cwd: string | undefined, event: string, data: Record<string, unknown>): void {
14
+ writeSubagentsDebugLog(cwd, event, data);
15
+ }
16
+
17
+ function interactionLogFields(request: SubagentInteractionRequest | undefined): Record<string, unknown> {
18
+ if (!request) return { hasInteractionRequest: false };
19
+ return {
20
+ hasInteractionRequest: true,
21
+ requestId: request.requestId,
22
+ kind: request.kind,
23
+ origin: request.origin,
24
+ reasonCode: request.reasonCode,
25
+ riskLevel: request.riskLevel,
26
+ requester: request.requester,
27
+ hasPrompt: Boolean(request.prompt),
28
+ hasPayload: request.payload !== undefined,
29
+ };
30
+ }
31
+
32
+ function compactOutput(text: string, limit = 800): string {
33
+ const normalized = sanitizeInteractionTransportText(text).replace(/\s+/g, ' ').trim();
34
+ return normalized.length > limit ? `…${normalized.slice(-limit)}` : normalized;
35
+ }
36
+
37
+ function isSqliteBusyError(error: unknown): boolean {
38
+ if (!error || typeof error !== 'object') return false;
39
+ const candidate = error as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown };
40
+ return candidate.code === 'ERR_SQLITE_ERROR'
41
+ && (candidate.errcode === 5 || candidate.errstr === 'database is locked' || candidate.message === 'database is locked');
42
+ }
43
+
44
+ function modelRefLabel(model: ModelRef | undefined): string | undefined {
45
+ return model ? `${model.provider}/${model.id}` : undefined;
46
+ }
47
+
48
+ function sessionIdFromContext(ctx: any): string | undefined {
49
+ const direct = ctx?.sessionManager?.getSessionId?.() ?? ctx?.sessionId;
50
+ if (typeof direct === 'string' && direct.length > 0) return direct;
51
+ const file = ctx?.sessionManager?.getSessionFile?.();
52
+ return typeof file === 'string' && file.length > 0 ? file : undefined;
53
+ }
54
+
55
+ function sanitizeUnknown<T>(value: T): T {
56
+ if (typeof value === 'string') return sanitizeInteractionTransportText(value) as T;
57
+ if (Array.isArray(value)) return value.map((item) => sanitizeUnknown(item)) as T;
58
+ if (!value || typeof value !== 'object') return value;
59
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, sanitizeUnknown(entry)])) as T;
60
+ }
61
+
62
+ function interactionPromptMessage(request: SubagentInteractionRequest): string {
63
+ const prompt = request.prompt ?? {};
64
+ const lines = [prompt.title ?? `Subagent interaction: ${request.kind}`, '', prompt.message ?? request.reason ?? 'A subagent requested main-thread interaction.'];
65
+ const requester = request.requester?.subagentName ?? request.requester?.subagentId;
66
+ if (requester) lines.push('', `Requested by: ${requester}`);
67
+ if (prompt.safeTarget) lines.push('', `Target: ${prompt.safeTarget}`);
68
+ if (prompt.safeCommandSummary) lines.push('', `Command: ${prompt.safeCommandSummary}`);
69
+ if (prompt.workspaceRoot) lines.push('', `Workspace: ${prompt.workspaceRoot}`);
70
+ if (prompt.limitations?.length) lines.push('', ...prompt.limitations);
71
+ if (request.payload !== undefined) lines.push('', 'Payload:', JSON.stringify(request.payload, null, 2));
72
+ if (request.response?.instructions) lines.push('', 'Expected response:', request.response.instructions);
73
+ return lines.join('\n');
74
+ }
75
+
76
+ function editorInitialValue(request: SubagentInteractionRequest): string {
77
+ return JSON.stringify({
78
+ kind: request.kind,
79
+ prompt: request.prompt,
80
+ payload: request.payload,
81
+ response: request.response,
82
+ }, null, 2);
83
+ }
84
+
85
+ function parseEditorResponse(raw: string, request: SubagentInteractionRequest): unknown {
86
+ if (request.response?.expected === 'json') return JSON.parse(raw);
87
+ return raw;
88
+ }
89
+
90
+ async function promptMainThreadForInteraction(ctx: any, request: SubagentInteractionRequest): Promise<SubagentInteractionResponse> {
91
+ const prompt = request.prompt ?? {};
92
+ const message = interactionPromptMessage(request);
93
+ const choices = Array.isArray(prompt.choices) ? prompt.choices.filter((choice): choice is string => typeof choice === 'string') : [];
94
+
95
+ if (choices.length && typeof ctx?.ui?.select === 'function') {
96
+ const value = await ctx.ui.select(message, choices);
97
+ return { type: 'interaction_response', requestId: request.requestId, status: value === undefined ? 'cancelled' : 'answered', value };
98
+ }
99
+
100
+ if (request.kind === 'confirm' && typeof ctx?.ui?.confirm === 'function') {
101
+ const value = await ctx.ui.confirm(prompt.title ?? 'Subagent interaction', message);
102
+ return { type: 'interaction_response', requestId: request.requestId, status: 'answered', value: Boolean(value) };
103
+ }
104
+
105
+ if (request.kind === 'input' && typeof ctx?.ui?.input === 'function') {
106
+ const value = await ctx.ui.input(message, prompt.placeholder ?? prompt.defaultValue ?? '');
107
+ return { type: 'interaction_response', requestId: request.requestId, status: value === undefined ? 'cancelled' : 'answered', value };
108
+ }
109
+
110
+ if (typeof ctx?.ui?.editor === 'function') {
111
+ try {
112
+ const value = await ctx.ui.editor(message, editorInitialValue(request));
113
+ if (value === undefined) return { type: 'interaction_response', requestId: request.requestId, status: 'cancelled' };
114
+ return { type: 'interaction_response', requestId: request.requestId, status: 'answered', value: parseEditorResponse(value, request) };
115
+ } catch (error) {
116
+ return { type: 'interaction_response', requestId: request.requestId, status: 'failed', error: error instanceof Error ? error.message : String(error) };
117
+ }
118
+ }
119
+
120
+ throw new Error(`Subagent interaction ${request.requestId} (${request.kind}) requires main-thread UI support for select, confirm, input, or editor.`);
121
+ }
122
+
123
+ function createLimiter(max: number) {
124
+ let active = 0;
125
+ const queue: Array<() => void> = [];
126
+ return {
127
+ async acquire() {
128
+ if (active < max) {
129
+ active += 1;
130
+ return;
131
+ }
132
+ await new Promise<void>((resolve) => queue.push(resolve));
133
+ active += 1;
134
+ },
135
+ release() {
136
+ active = Math.max(0, active - 1);
137
+ queue.shift()?.();
138
+ },
139
+ };
140
+ }
141
+
142
+ const ACTIVITY_RECORD_FLUSH_MS = 250;
143
+ const ACTIVITY_UPDATE_FLUSH_MS = 150;
144
+ const SESSION_TASK_CACHE_MS = 1500;
145
+
146
+ type PendingRecord = { cwd: string; task: SubagentTask; activity: string; timer: NodeJS.Timeout };
147
+
148
+ export class SubagentManager {
149
+ private tasks = new Map<string, SubagentTask>();
150
+ private taskCwds = new Map<string, string>();
151
+ private controllers = new Map<string, AbortController>();
152
+ private limiters = new Map<string, ReturnType<typeof createLimiter>>();
153
+ private pendingRecords = new Map<string, PendingRecord>();
154
+ private pendingUpdates = new Map<string, NodeJS.Timeout>();
155
+ private sessionTaskCache = new Map<string, { expiresAt: number; tasks: SubagentTask[] }>();
156
+
157
+ constructor(
158
+ private runner: SubagentRunner = sdkSubagentRunner,
159
+ private history = new SubagentHistoryStore(),
160
+ private onTerminalBackgroundTask?: (task: SubagentTask) => void,
161
+ ) {}
162
+
163
+ listAgents(cwd: string) {
164
+ return loadSubagents(cwd).map((a) => ({ name: a.name, description: a.description, filePath: a.filePath, tools: a.tools, model: a.model, effort: a.effort }));
165
+ }
166
+
167
+ listTasks(cwd?: string) {
168
+ const active = [...this.tasks.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
169
+ if (!cwd) return active;
170
+ const activeIds = new Set(active.map((task) => task.id));
171
+ const persisted = this.history.listTasks(cwd).filter((task) => !activeIds.has(task.id));
172
+ return [...active, ...persisted].sort((a, b) => b.created_at.localeCompare(a.created_at));
173
+ }
174
+
175
+ listSessionTasks(cwd?: string, sessionId?: string) {
176
+ const active = [...this.tasks.values()]
177
+ .filter((task) => (!cwd || this.taskCwds.get(task.id) === cwd) && (!sessionId || task.session_id === sessionId))
178
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
179
+ if (!cwd || !sessionId) return active;
180
+ const activeIds = new Set(active.map((task) => task.id));
181
+ const persisted = this.cachedPersistedSessionTasks(cwd, sessionId).filter((task) => !activeIds.has(task.id));
182
+ return [...active, ...persisted].sort((a, b) => b.created_at.localeCompare(a.created_at));
183
+ }
184
+
185
+ getTask(id: string, cwd?: string) {
186
+ return this.tasks.get(id) ?? (cwd ? this.history.getTask(cwd, id) : undefined);
187
+ }
188
+
189
+ cancelRunning(reason = 'cancelled'): SubagentTask[] {
190
+ return [...this.tasks.values()]
191
+ .filter((task) => task.status === 'queued' || task.status === 'running')
192
+ .map((task) => this.cancel(task.id, reason));
193
+ }
194
+
195
+ sendToBackground(ids: string[]): SubagentTask[] {
196
+ const changed: SubagentTask[] = [];
197
+ for (const id of ids) {
198
+ const task = this.tasks.get(id);
199
+ const cwd = this.taskCwds.get(id);
200
+ if (!task || !cwd) continue;
201
+ if (task.mode === 'background') continue;
202
+ if (task.status !== 'queued' && task.status !== 'running') continue;
203
+ task.mode = 'background';
204
+ this.record(cwd, task, task.last_activity ?? 'running', true);
205
+ changed.push(task);
206
+ }
207
+ return changed;
208
+ }
209
+
210
+ hasRunning(): boolean {
211
+ return [...this.tasks.values()].some((task) => task.status === 'queued' || task.status === 'running');
212
+ }
213
+
214
+ private limiter(cwd: string, maxConcurrency: number): ReturnType<typeof createLimiter> {
215
+ const key = `${cwd}:${maxConcurrency}`;
216
+ let limiter = this.limiters.get(key);
217
+ if (!limiter) {
218
+ limiter = createLimiter(maxConcurrency);
219
+ this.limiters.set(key, limiter);
220
+ }
221
+ return limiter;
222
+ }
223
+
224
+ async run(
225
+ input: SubagentRunInput,
226
+ ctx: any,
227
+ parentSignal?: AbortSignal,
228
+ onTaskUpdate?: (tasks: SubagentTask[]) => void,
229
+ ): Promise<{ mode: 'task' | 'background'; task_ids: string[]; results?: SubagentTask[] }> {
230
+ const cwd = ctx?.cwd ?? process.cwd();
231
+ const agents = input.agents?.length ? input.agents : input.agent ? [input.agent] : [];
232
+ if (!agents.length) throw new Error('subagent_run requires agent or agents.');
233
+ const mode = input.mode ?? 'task';
234
+ const config = readSubagentsConfig(cwd);
235
+ const definitions = new Map(loadSubagents(cwd).map((definition) => [definition.name, definition]));
236
+ const limiter = this.limiter(cwd, config.max_concurrency);
237
+ let ids: string[] = [];
238
+ const notifyUpdate = () => onTaskUpdate?.(ids.map((id) => this.tasks.get(id)!).filter(Boolean));
239
+ ids = agents.map((agent) => {
240
+ const definition = definitions.get(agent.toLowerCase());
241
+ if (!definition) throw new Error(`Subagent not found: ${agent}`);
242
+ return this.startOne(definition, input.task, input.context, mode, ctx, config, parentSignal, notifyUpdate, limiter);
243
+ });
244
+ notifyUpdate();
245
+ if (mode === 'background') return { mode, task_ids: ids };
246
+ await Promise.all(ids.map((id) => this.wait(id)));
247
+ if (parentSignal?.aborted) throw new Error('Subagent run aborted');
248
+ return { mode, task_ids: ids, results: ids.map((id) => this.tasks.get(id)!) };
249
+ }
250
+
251
+ cancel(id: string, reason = 'cancelled'): SubagentTask {
252
+ const task = this.tasks.get(id);
253
+ if (!task) throw new Error(`Subagent task not found: ${id}`);
254
+ if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') return task;
255
+ this.controllers.get(id)?.abort();
256
+ task.status = 'cancelled';
257
+ task.last_activity = task.output_preview ? `${reason}; partial output preserved` : reason;
258
+ task.last_activity_at = nowIso();
259
+ task.ended_at = task.last_activity_at;
260
+ const cwd = this.taskCwds.get(id);
261
+ if (cwd) this.record(cwd, task, task.last_activity, true);
262
+ return task;
263
+ }
264
+
265
+ private startOne(
266
+ definition: SubagentDefinition,
267
+ taskText: string,
268
+ context: string | undefined,
269
+ mode: 'task' | 'background',
270
+ ctx: any,
271
+ config: SubagentsConfig,
272
+ parentSignal?: AbortSignal,
273
+ onTaskUpdate?: () => void,
274
+ limiter = createLimiter(1),
275
+ ): string {
276
+ const cwd = ctx?.cwd ?? process.cwd();
277
+ const session_id = sessionIdFromContext(ctx);
278
+ const effectiveProfile = resolveEffectiveSubagentProfile({ agentName: definition.name, definition, config, ctx });
279
+ const id = taskId(definition.name);
280
+ const controller = new AbortController();
281
+ const task: SubagentTask = {
282
+ id,
283
+ agent: definition.name,
284
+ mode,
285
+ status: 'queued',
286
+ task: taskText,
287
+ context,
288
+ model: modelRefLabel(effectiveProfile.model.value),
289
+ effort: effectiveProfile.effort.value,
290
+ model_source: effectiveProfile.model.source,
291
+ effort_source: effectiveProfile.effort.source,
292
+ created_at: nowIso(),
293
+ session_id,
294
+ last_activity_at: nowIso(),
295
+ last_activity: 'queued',
296
+ };
297
+ this.tasks.set(id, task);
298
+ this.taskCwds.set(id, cwd);
299
+ this.controllers.set(id, controller);
300
+ const abortFromParent = () => this.cancel(id, 'cancelled by parent abort');
301
+ if (parentSignal?.aborted) abortFromParent();
302
+ else parentSignal?.addEventListener('abort', abortFromParent, { once: true });
303
+ this.record(cwd, task, 'queued', true);
304
+ this.notifyTaskUpdate(id, onTaskUpdate, true);
305
+
306
+ const run = async () => {
307
+ let timeout: NodeJS.Timeout | undefined;
308
+ let timedOut = false;
309
+ let acquired = false;
310
+ try {
311
+ await limiter.acquire();
312
+ acquired = true;
313
+ if (controller.signal.aborted) return;
314
+ task.status = 'running';
315
+ task.started_at = nowIso();
316
+ task.last_activity_at = task.started_at;
317
+ task.last_activity = 'started';
318
+ this.record(cwd, task, 'started', true);
319
+ this.notifyTaskUpdate(id, onTaskUpdate, true);
320
+ let interactionsHandled = 0;
321
+ let result: Awaited<ReturnType<SubagentRunner>> | undefined;
322
+ while (true) {
323
+ const runnerPromise = this.runner({
324
+ definition,
325
+ task: taskText,
326
+ taskId: id,
327
+ parentPiSessionId: session_id,
328
+ context,
329
+ cwd,
330
+ ctx,
331
+ config,
332
+ signal: controller.signal,
333
+ effectiveProfile,
334
+ onActivity: (activity) => {
335
+ task.last_activity_at = nowIso();
336
+ task.last_activity = activity.message;
337
+ if (activity.output) task.output_preview = compactOutput(activity.output);
338
+ if (activity.prompt) task.prompt = sanitizeInteractionTransportText(activity.prompt);
339
+ if (activity.system_prompt) task.system_prompt = sanitizeInteractionTransportText(activity.system_prompt);
340
+ if (activity.transcript) task.transcript = sanitizeInteractionTransportText(activity.transcript);
341
+ if (activity.usage) task.usage = activity.usage;
342
+ if (activity.effort) task.effort = activity.effort;
343
+ if (activity.thread_snapshot) task.thread_snapshot = sanitizeUnknown(activity.thread_snapshot);
344
+ if (activity.interaction_request) task.interaction_request = activity.interaction_request;
345
+ const importantActivity = activity.message === 'interaction required'
346
+ || Boolean(activity.interaction_request)
347
+ || (Boolean(activity.thread_snapshot) && !activity.message.startsWith('streaming '));
348
+ this.record(cwd, task, activity.message, importantActivity);
349
+ this.notifyTaskUpdate(id, onTaskUpdate, importantActivity);
350
+ },
351
+ });
352
+ runnerPromise.catch(() => {});
353
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
354
+ timeout = setTimeout(() => {
355
+ timedOut = true;
356
+ controller.abort();
357
+ reject(new Error(`timed out after ${config.timeout_ms}ms`));
358
+ }, config.timeout_ms);
359
+ });
360
+ const abortPromise = new Promise<never>((_resolve, reject) => {
361
+ if (controller.signal.aborted) reject(new Error('Subagent was aborted'));
362
+ else controller.signal.addEventListener('abort', () => reject(new Error('Subagent was aborted')), { once: true });
363
+ });
364
+ result = await Promise.race([runnerPromise, timeoutPromise, abortPromise]);
365
+ if (timeout) {
366
+ clearTimeout(timeout);
367
+ timeout = undefined;
368
+ }
369
+ if ((task as SubagentTask).status === 'cancelled') return;
370
+
371
+ const interactionRequest = result.interaction_request;
372
+ if (!interactionRequest) break;
373
+ subagentAuditLog(cwd, 'interaction_bridge_request_detected', { taskId: id, agent: definition.name, ...interactionLogFields(interactionRequest) });
374
+ if (task.mode === 'background') {
375
+ subagentAuditLog(cwd, 'interaction_bridge_background_blocked', { taskId: id, agent: definition.name, ...interactionLogFields(interactionRequest) });
376
+ throw new Error('Subagent interaction requires main-thread handling; rerun in task mode to answer it.');
377
+ }
378
+ interactionsHandled += 1;
379
+ if (interactionsHandled > 5) throw new Error('Subagent interaction retry limit exceeded.');
380
+
381
+ task.result = sanitizeInteractionTransportText(result.result);
382
+ task.output_preview = compactOutput(result.result);
383
+ task.transcript = sanitizeInteractionTransportText(`${task.transcript ?? ''}\n\n# interaction request surfaced to orchestrator\n\n${result.result}`.trim());
384
+ task.last_activity = 'interaction required; awaiting main-thread response';
385
+ task.last_activity_at = nowIso();
386
+ task.usage = result.usage ?? task.usage;
387
+ if (result.system_prompt ?? task.system_prompt) task.system_prompt = sanitizeInteractionTransportText(result.system_prompt ?? task.system_prompt!);
388
+ task.model = result.model;
389
+ task.effort = result.effort ?? task.effort;
390
+ task.fallback_used = result.fallback_used;
391
+ if (result.thread_snapshot) task.thread_snapshot = sanitizeUnknown(result.thread_snapshot);
392
+ task.interaction_request = interactionRequest;
393
+ this.record(cwd, task, task.last_activity, true);
394
+ this.notifyTaskUpdate(id, onTaskUpdate, true);
395
+
396
+ subagentAuditLog(cwd, 'interaction_bridge_prompt_main_thread', { taskId: id, agent: definition.name, ...interactionLogFields(interactionRequest) });
397
+ const response = publishInteractionResponse(await promptMainThreadForInteraction(ctx, interactionRequest));
398
+ subagentAuditLog(cwd, 'interaction_bridge_user_response', { taskId: id, agent: definition.name, requestId: interactionRequest.requestId, status: response.status });
399
+ if (response.status === 'cancelled') throw new Error(`Subagent interaction cancelled by main user: ${interactionRequest.requestId}`);
400
+ if (response.status === 'failed') throw new Error(`Subagent interaction failed: ${response.error ?? interactionRequest.requestId}`);
401
+ task.last_activity = `interaction answered by main user; retrying subagent`;
402
+ delete task.interaction_request;
403
+ task.last_activity_at = nowIso();
404
+ this.record(cwd, task, task.last_activity, true);
405
+ this.notifyTaskUpdate(id, onTaskUpdate, true);
406
+ }
407
+
408
+ if (!result) throw new Error('Subagent finished without a result.');
409
+ const finalResult = sanitizeInteractionTransportText(result.result ?? '');
410
+ if (!finalResult.trim()) throw new Error('Subagent finished without a final response.');
411
+ task.status = 'completed';
412
+ task.result = finalResult;
413
+ task.output_preview = compactOutput(finalResult);
414
+ task.transcript = sanitizeInteractionTransportText(`${task.transcript ?? ''}\n\n# response sent to orchestrator\n\n${finalResult}`.trim());
415
+ task.last_activity = 'completed';
416
+ task.last_activity_at = nowIso();
417
+ task.usage = result.usage ?? task.usage;
418
+ if (result.system_prompt ?? task.system_prompt) task.system_prompt = sanitizeInteractionTransportText(result.system_prompt ?? task.system_prompt!);
419
+ task.model = result.model;
420
+ task.effort = result.effort ?? task.effort;
421
+ task.fallback_used = result.fallback_used;
422
+ if (result.thread_snapshot) task.thread_snapshot = sanitizeUnknown(result.thread_snapshot);
423
+ delete task.interaction_request;
424
+ task.ended_at = task.last_activity_at;
425
+ this.record(cwd, task, 'completed', true);
426
+ this.notifyTaskUpdate(id, onTaskUpdate, true);
427
+ if (task.mode === 'background') {
428
+ ctx?.ui?.notify?.(`Subagent ${definition.name} completed: ${id}`, 'info');
429
+ this.onTerminalBackgroundTask?.(task);
430
+ }
431
+ } catch (error) {
432
+ if ((task as SubagentTask).status === 'cancelled') return;
433
+ task.status = 'failed';
434
+ task.error = timedOut ? `timed out after ${config.timeout_ms}ms` : error instanceof Error ? error.message : String(error);
435
+ task.last_activity = `failed: ${task.error}`;
436
+ task.last_activity_at = nowIso();
437
+ task.ended_at = task.last_activity_at;
438
+ this.record(cwd, task, task.last_activity, true);
439
+ this.notifyTaskUpdate(id, onTaskUpdate, true);
440
+ ctx?.ui?.notify?.(`Subagent ${definition.name} failed: ${task.error}`, 'warning');
441
+ if (task.mode === 'background') this.onTerminalBackgroundTask?.(task);
442
+ } finally {
443
+ if (timeout) clearTimeout(timeout);
444
+ if (acquired) limiter.release();
445
+ parentSignal?.removeEventListener('abort', abortFromParent);
446
+ this.controllers.delete(id);
447
+ }
448
+ };
449
+ void run();
450
+ return id;
451
+ }
452
+
453
+ private cachedPersistedSessionTasks(cwd: string, sessionId: string): SubagentTask[] {
454
+ const key = `${cwd}\0${sessionId}`;
455
+ const cached = this.sessionTaskCache.get(key);
456
+ if (cached && cached.expiresAt > Date.now()) return cached.tasks;
457
+ try {
458
+ const tasks = this.history.listSessionTasks(cwd, sessionId, 100, { includeSnapshots: false });
459
+ this.sessionTaskCache.set(key, { expiresAt: Date.now() + SESSION_TASK_CACHE_MS, tasks });
460
+ return tasks;
461
+ } catch (error) {
462
+ if (isSqliteBusyError(error)) return cached?.tasks ?? [];
463
+ throw error;
464
+ }
465
+ }
466
+
467
+ private invalidateSessionTaskCache(cwd: string, task: SubagentTask): void {
468
+ if (task.session_id) this.sessionTaskCache.delete(`${cwd}\0${task.session_id}`);
469
+ }
470
+
471
+ private record(cwd: string, task: SubagentTask, activity: string, immediate = false): void {
472
+ if (immediate) {
473
+ this.flushRecord(task.id);
474
+ this.recordNow(cwd, task, activity);
475
+ return;
476
+ }
477
+ const pending = this.pendingRecords.get(task.id);
478
+ if (pending) {
479
+ pending.cwd = cwd;
480
+ pending.task = task;
481
+ pending.activity = activity;
482
+ return;
483
+ }
484
+ const timer = setTimeout(() => this.flushRecord(task.id), ACTIVITY_RECORD_FLUSH_MS);
485
+ timer.unref?.();
486
+ this.pendingRecords.set(task.id, { cwd, task, activity, timer });
487
+ }
488
+
489
+ private flushRecord(taskId: string): void {
490
+ const pending = this.pendingRecords.get(taskId);
491
+ if (!pending) return;
492
+ clearTimeout(pending.timer);
493
+ this.pendingRecords.delete(taskId);
494
+ this.recordNow(pending.cwd, pending.task, pending.activity);
495
+ }
496
+
497
+ private recordNow(cwd: string, task: SubagentTask, activity: string): void {
498
+ try {
499
+ this.invalidateSessionTaskCache(cwd, task);
500
+ this.history.upsertTask(cwd, task);
501
+ this.history.addEvent(cwd, task, activity);
502
+ } catch {
503
+ // History should never break delegation.
504
+ }
505
+ }
506
+
507
+ private notifyTaskUpdate(taskId: string, onTaskUpdate: (() => void) | undefined, immediate = false): void {
508
+ if (!onTaskUpdate) return;
509
+ if (immediate) {
510
+ const pending = this.pendingUpdates.get(taskId);
511
+ if (pending) clearTimeout(pending);
512
+ this.pendingUpdates.delete(taskId);
513
+ onTaskUpdate();
514
+ return;
515
+ }
516
+ if (this.pendingUpdates.has(taskId)) return;
517
+ const timer = setTimeout(() => {
518
+ this.pendingUpdates.delete(taskId);
519
+ onTaskUpdate();
520
+ }, ACTIVITY_UPDATE_FLUSH_MS);
521
+ timer.unref?.();
522
+ this.pendingUpdates.set(taskId, timer);
523
+ }
524
+
525
+ private async wait(id: string): Promise<void> {
526
+ while (true) {
527
+ const task = this.tasks.get(id);
528
+ if (!task) throw new Error(`Subagent task not found: ${id}`);
529
+ if (['completed', 'failed', 'cancelled'].includes(task.status)) return;
530
+ await new Promise((resolve) => setTimeout(resolve, 50));
531
+ }
532
+ }
533
+ }