@tduyng/pi-subagent 0.0.0-stage → 0.0.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.
@@ -0,0 +1,853 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { appendFileSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
4
+ import { mkdir } from 'node:fs/promises'
5
+ import * as path from 'node:path'
6
+
7
+ import type { JsonValue, Model } from '@earendil-works/pi-ai'
8
+ import {
9
+ DEFAULT_MAX_BYTES,
10
+ DEFAULT_MAX_LINES,
11
+ DefaultResourceLoader,
12
+ getAgentDir,
13
+ truncateHead,
14
+ type ExtensionAPI,
15
+ type ExtensionContext
16
+ } from '@earendil-works/pi-coding-agent'
17
+ import { Text, isKeyRelease, matchesKey } from '@earendil-works/pi-tui'
18
+ import { type Infer, type JSONSchema, Validator } from 'ata-validator'
19
+ import { t } from 'ata-validator/t'
20
+
21
+
22
+ import { createChildModelRuntime } from '../lib/child-runtime.js'
23
+ import { loadAgents, parseModelName, toThinking } from './agent.js'
24
+ import { drivePrompt } from './drive.js'
25
+ import { claimRun, findRunForTask, saveRun, syncRecord, type DurableRun, type DurableTask } from './durable.js'
26
+ import { recoverRuns, resumeTask } from './recover.js'
27
+ import { createChildSession } from './session.js'
28
+ import { normalizeJsonSchema } from './structured.js'
29
+ import {
30
+ acquirePermit,
31
+ changedPaths,
32
+ createWorktree,
33
+ findDuplicateTask,
34
+ gitStatus,
35
+ failureReason,
36
+ releasePermit,
37
+ resolveMaxTurns,
38
+ resolveTimeoutMs,
39
+ salvageFromMessages,
40
+ salvageOutput,
41
+ validateCwd,
42
+ worktreeBranch,
43
+ MIN_TIMEOUT_MS
44
+ } from './helpers.js'
45
+ import {
46
+ countByStatus,
47
+ extractForkText,
48
+ findLastAssistant,
49
+ formatDuration,
50
+ partialText,
51
+ resultText,
52
+ statusIcon,
53
+ taskHeadline,
54
+ toLabel
55
+ } from './format.js'
56
+ import { dismissTask, liveSessions, nextExpiryAt, registry, roster } from './state.js'
57
+ import {
58
+ DEFAULT_THINKING,
59
+ EMIT_INTERVAL_MS,
60
+ MAX_CONCURRENT,
61
+ MAX_TASKS,
62
+ RUNS_DIR,
63
+ WIDGET_KEY,
64
+ type RunDetails,
65
+ type RunStatus,
66
+ type TaskDetails,
67
+ type TaskSpec,
68
+ type ThinkingLevel
69
+ } from './types.js'
70
+ import { SubagentViewer, widgetLines } from './viewer.js'
71
+
72
+ export { parseFrontmatter, loadAgentsFrom, parseModelName } from './agent.js'
73
+
74
+ const MAX_FORK_CONTEXT_CHARS = 120_000
75
+ const RUN_SAVE_INTERVAL_MS = 5_000
76
+ const RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000
77
+
78
+ /** The child bills to the parent's session, and `/cost` reads this line to say so. */
79
+ function recordOverhead(
80
+ sessionId: string,
81
+ purpose: string,
82
+ usage: { input: number; output: number; cost: number }
83
+ ): void {
84
+ try {
85
+ appendFileSync(
86
+ path.join(getAgentDir(), 'overhead.jsonl'),
87
+ `${JSON.stringify({
88
+ at: Date.now(),
89
+ extension: 'subagent',
90
+ sessionId,
91
+ purpose,
92
+ input: usage.input,
93
+ output: usage.output,
94
+ costUsd: usage.cost
95
+ })}\n`,
96
+ 'utf8'
97
+ )
98
+ } catch {}
99
+ }
100
+
101
+ function removeWorktree(worktree: string): void {
102
+ try {
103
+ const repo = path.dirname(
104
+ execFileSync('git', ['-C', worktree, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
105
+ encoding: 'utf8'
106
+ }).trim()
107
+ )
108
+ execFileSync('git', ['-C', repo, 'worktree', 'remove', '--force', worktree], { stdio: 'ignore' })
109
+ execFileSync('git', ['-C', repo, 'branch', '-d', worktreeBranch(worktree)], { stdio: 'ignore' })
110
+ } catch {
111
+ rmSync(worktree, { recursive: true, force: true })
112
+ }
113
+ }
114
+
115
+ function removeStaleRunDirectories(currentSessionId: string): void {
116
+ const root = path.join(getAgentDir(), RUNS_DIR)
117
+ let sessions: string[]
118
+ try {
119
+ sessions = readdirSync(root)
120
+ } catch {
121
+ return
122
+ }
123
+ const now = Date.now()
124
+ for (const sessionId of sessions) {
125
+ if (sessionId === currentSessionId) continue
126
+ const sessionDir = path.join(root, sessionId)
127
+ try {
128
+ if (now - statSync(sessionDir).mtimeMs <= RUN_RETENTION_MS) continue
129
+ for (const task of readdirSync(sessionDir)) {
130
+ const worktree = path.join(sessionDir, task, 'worktree')
131
+ if (statSync(worktree, { throwIfNoEntry: false })) removeWorktree(worktree)
132
+ }
133
+ rmSync(sessionDir, { recursive: true, force: true })
134
+ } catch {}
135
+ }
136
+ }
137
+
138
+ export interface ChildConfig {
139
+ model?: string
140
+ thinking?: ThinkingLevel
141
+ }
142
+
143
+ const ChildConfigFile = t.object({ model: t.optional(t.string()), thinking: t.optional(t.string()) })
144
+ const isChildConfigFile = new Validator(ChildConfigFile).isValidObject
145
+
146
+ function readChildConfig(file: string): ChildConfig {
147
+ try {
148
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
149
+ if (!isChildConfigFile(parsed)) return {}
150
+ return { model: parsed.model?.trim() || undefined, thinking: toThinking(parsed.thinking) }
151
+ } catch {
152
+ return {}
153
+ }
154
+ }
155
+
156
+ export function defaultChildConfig(agentDir = getAgentDir(), projectDir = process.cwd()): ChildConfig {
157
+ const agent = readChildConfig(path.join(agentDir, 'subagent.json'))
158
+ const project = readChildConfig(path.join(projectDir, '.pi', 'subagent.json'))
159
+ return { model: project.model ?? agent.model, thinking: project.thinking ?? agent.thinking }
160
+ }
161
+
162
+ export function resolveChildThinking(
163
+ requested: ThinkingLevel | undefined,
164
+ profile: ThinkingLevel | undefined,
165
+ configured: ThinkingLevel | undefined
166
+ ): ThinkingLevel {
167
+ return requested ?? profile ?? configured ?? DEFAULT_THINKING
168
+ }
169
+
170
+ export function resolveChildModelName(
171
+ requested: string | undefined,
172
+ profile: string | undefined,
173
+ configured: string | undefined
174
+ ): string | undefined {
175
+ return profile || configured || requested?.trim() || undefined
176
+ }
177
+ export { toLabel, extractForkText } from './format.js'
178
+ export {
179
+ changedPaths,
180
+ createWorktree,
181
+ failureReason,
182
+ resolveMaxTurns,
183
+ resolveTimeoutMs,
184
+ worktreeBranch,
185
+ wrapUpMessage
186
+ } from './helpers.js'
187
+
188
+ export type { AgentProfile, TaskDetails, RunDetails, ThinkingLevel } from './types.js'
189
+
190
+ export type LoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0]
191
+
192
+ const TASK_FIELDS = {
193
+ task: t.string({ description: 'Instructions.' }),
194
+ agent: t.optional(t.string({ description: 'Profile; omit for scout.' })),
195
+ cwd: t.optional(t.string()),
196
+ isolation: t.optional(t.string({ enum: ['worktree'] })),
197
+ schema: t.optional({
198
+ ...t.union([t.string(), t.object({}, { additionalProperties: true })]),
199
+ description: 'Final JSON Schema; call structured_output once.'
200
+ }),
201
+ context: t.optional(t.string({ enum: ['fork'] })),
202
+ resume: t.optional(t.string({ description: 'Resume task id or all.' }))
203
+ }
204
+
205
+ function undescribed<T extends JSONSchema>(schema: T): T {
206
+ const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(schema))
207
+ delete (clone as { description?: string }).description
208
+ return clone as T
209
+ }
210
+
211
+ export const TASK_ITEM_FIELDS = Object.fromEntries(
212
+ Object.entries(TASK_FIELDS).map(([name, schema]) => [name, undescribed(schema)])
213
+ ) as typeof TASK_FIELDS
214
+
215
+ const subagentParams = t.object({
216
+ ...TASK_FIELDS,
217
+ task: t.optional(TASK_FIELDS.task),
218
+ tasks: t.optional(
219
+ t.array(t.object(TASK_ITEM_FIELDS), {
220
+ description: `Run up to ${MAX_TASKS} children; same fields.`
221
+ })
222
+ )
223
+ })
224
+ type SubagentParams = Infer<typeof subagentParams>
225
+
226
+ export default function subagentExtension(pi: ExtensionAPI): void {
227
+ let mountedCtx: ExtensionContext | undefined
228
+ let unregisterInput: (() => void) | undefined
229
+ let highlightIndex: number | null = null
230
+ let viewerOpen = false
231
+ let sweepTimer: ReturnType<typeof setTimeout> | undefined
232
+
233
+ function scheduleSweep(): void {
234
+ if (sweepTimer) clearTimeout(sweepTimer)
235
+ sweepTimer = undefined
236
+ const due = nextExpiryAt()
237
+ if (due === undefined) return
238
+ sweepTimer = setTimeout(
239
+ () => {
240
+ sweepTimer = undefined
241
+ syncWidget()
242
+ },
243
+ Math.max(1_000, due - Date.now())
244
+ )
245
+ sweepTimer.unref()
246
+ }
247
+
248
+ function syncWidget(): void {
249
+ const ctx = mountedCtx
250
+ if (!ctx?.hasUI) return
251
+ const items = roster()
252
+ if (highlightIndex !== null)
253
+ highlightIndex = items.length === 0 ? null : Math.min(highlightIndex, items.length - 1)
254
+ ctx.ui.setWidget(WIDGET_KEY, items.length === 0 ? undefined : widgetLines(ctx.ui.theme, highlightIndex))
255
+ scheduleSweep()
256
+ }
257
+
258
+ async function openViewer(ctx: ExtensionContext, task: TaskDetails): Promise<void> {
259
+ const session = liveSessions.get(task.id)
260
+ if (!session) {
261
+ ctx.ui.notify('That agent session is no longer available.', 'warning')
262
+ return
263
+ }
264
+ viewerOpen = true
265
+ try {
266
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => new SubagentViewer(tui, theme, session, task, done))
267
+ } catch (error) {
268
+ try {
269
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), 'error')
270
+ } catch {}
271
+ } finally {
272
+ viewerOpen = false
273
+ }
274
+ }
275
+
276
+ function createNavHandler(ctx: ExtensionContext) {
277
+ return (data: string): { consume: boolean } | undefined => {
278
+ if (isKeyRelease(data) || viewerOpen) return undefined
279
+ const items = roster()
280
+
281
+ if (highlightIndex === null) {
282
+ if (matchesKey(data, 'down') && items.length > 0 && ctx.ui.getEditorText() === '') {
283
+ highlightIndex = 0
284
+ syncWidget()
285
+ return { consume: true }
286
+ }
287
+ return undefined
288
+ }
289
+
290
+ if (matchesKey(data, 'down')) {
291
+ highlightIndex = Math.min(highlightIndex + 1, items.length - 1)
292
+ syncWidget()
293
+ return { consume: true }
294
+ }
295
+ if (matchesKey(data, 'up')) {
296
+ highlightIndex = Math.max(highlightIndex - 1, 0)
297
+ syncWidget()
298
+ return { consume: true }
299
+ }
300
+ if (matchesKey(data, 'escape')) {
301
+ highlightIndex = null
302
+ syncWidget()
303
+ return { consume: true }
304
+ }
305
+ if (matchesKey(data, 'd') || matchesKey(data, 'x')) {
306
+ const task = items[highlightIndex]
307
+ if (task) dismissTask(task.id)
308
+ syncWidget()
309
+ return { consume: true }
310
+ }
311
+ if (matchesKey(data, 'enter')) {
312
+ const task = items[highlightIndex]
313
+ highlightIndex = null
314
+ syncWidget()
315
+ if (task) void openViewer(ctx, task)
316
+ return { consume: true }
317
+ }
318
+ highlightIndex = null
319
+ syncWidget()
320
+ return undefined
321
+ }
322
+ }
323
+
324
+ pi.on('session_start', async (_event, ctx) => {
325
+ setTimeout(() => removeStaleRunDirectories(ctx.sessionManager.getSessionId()), 0).unref?.()
326
+ if (ctx.hasUI) {
327
+ mountedCtx = ctx
328
+ unregisterInput ??= ctx.ui.onTerminalInput(createNavHandler(ctx))
329
+ syncWidget()
330
+ }
331
+ void recoverRuns({ pi, ctx, onEmit: syncWidget }).catch(() => {})
332
+ })
333
+
334
+ pi.on('session_shutdown', async () => {
335
+ if (sweepTimer) clearTimeout(sweepTimer)
336
+ sweepTimer = undefined
337
+ unregisterInput?.()
338
+ unregisterInput = undefined
339
+ mountedCtx?.ui.setWidget(WIDGET_KEY, undefined)
340
+ mountedCtx = undefined
341
+ for (const session of liveSessions.values()) session.dispose()
342
+ liveSessions.clear()
343
+ registry.length = 0
344
+ })
345
+
346
+ pi.registerTool({
347
+ name: 'subagent',
348
+ label: 'Subagent',
349
+ description:
350
+ 'Delegate read-only analysis to a child Pi session, on a cheap model: search, reading code and docs, research. ' +
351
+ 'Keep implementation, debugging, builds and lint in the parent. ' +
352
+ 'No profile has web access, so run web_search and web_fetch yourself in the parent; hand a scout text already saved to disk. ' +
353
+ 'Use `schema` for structured output, `resume` for failed children, including ones kept from a crashed session. ' +
354
+ `Up to ${MAX_TASKS} tasks, ${MAX_CONCURRENT} concurrent.`,
355
+ promptSnippet: 'delegate bulk reads, parallel or worktree-isolated work to a child session',
356
+ exposure: 'direct',
357
+ namespace: {
358
+ name: 'subagent',
359
+ description: 'Delegation to a child Pi session, by profile, with worktree isolation.'
360
+ },
361
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
362
+ executionMode: 'parallel',
363
+ parameters: subagentParams,
364
+
365
+ async execute(_toolCallId, params: SubagentParams, signal, onUpdate, ctx) {
366
+ const resumeKey = (params.resume as string | undefined)?.trim() || undefined
367
+ const inputs = resumeKey
368
+ ? []
369
+ : params.tasks?.length
370
+ ? params.tasks
371
+ : params.task
372
+ ? [{ ...params, task: params.task }]
373
+ : []
374
+ if (inputs.length === 0 && !resumeKey) throw new Error('Pass "task", "tasks", or "resume".')
375
+ if (!resumeKey && inputs.length > MAX_TASKS)
376
+ throw new Error(`At most ${MAX_TASKS} tasks per subagent call.`)
377
+ const duplicate = findDuplicateTask(inputs.map((input) => input.task))
378
+ if (duplicate) {
379
+ throw new Error(
380
+ `Tasks ${duplicate.first + 1} and ${duplicate.second + 1} are the same request. Drop one, or give each a distinct part of the job.`
381
+ )
382
+ }
383
+
384
+ if (!ctx.model)
385
+ throw new Error('No model is active in this session, so there is nothing to run children with.')
386
+ const activeModel = ctx.model
387
+
388
+ const agents = loadAgents(ctx.cwd)
389
+
390
+ const runId = randomUUID()
391
+ const runDir = path.join(getAgentDir(), RUNS_DIR, ctx.sessionManager.getSessionId(), runId)
392
+ const startedAt = Date.now()
393
+
394
+ const config = defaultChildConfig()
395
+ const specs: TaskSpec[] = inputs.map((raw, index) => {
396
+ const input = raw as typeof raw & {
397
+ label?: string
398
+ thinking?: ThinkingLevel
399
+ maxTurns?: number
400
+ model?: string
401
+ timeoutMs?: number
402
+ }
403
+ if (!input.task.trim()) throw new Error(`Task ${index + 1} must not be empty.`)
404
+ const agentName = input.agent?.trim() || 'scout'
405
+ const agent = agents.get(agentName)
406
+ if (!agent) {
407
+ throw new Error(`Unknown agent "${agentName}". Known: ${[...agents.keys()].join(', ') || 'none'}`)
408
+ }
409
+ const thinking = resolveChildThinking(input.thinking, agent?.thinking, config.thinking)
410
+ let childModel: Model<any> | undefined
411
+ const modelName = resolveChildModelName(input.model, agent?.model, config.model)
412
+ if (modelName) {
413
+ const parsed = parseModelName(modelName)
414
+ if (!parsed) throw new Error(`Invalid model "${modelName}"; use provider/model-id.`)
415
+ childModel = ctx.modelRegistry.find(parsed.provider, parsed.id)
416
+ if (!childModel)
417
+ throw new Error(`Model not found: ${modelName}. Check /model for registered providers.`)
418
+ }
419
+ const timeoutMs =
420
+ resolveTimeoutMs(input.timeoutMs) ?? resolveTimeoutMs(agent?.maxExecutionTimeMs) ?? MIN_TIMEOUT_MS
421
+ return {
422
+ index,
423
+ id: `${runId}:${index}`,
424
+ label: input.label?.trim()
425
+ ? toLabel(input.label, index)
426
+ : toLabel(agent?.name ?? input.task, index),
427
+ task: input.task,
428
+ agent,
429
+ cwd: path.resolve(ctx.cwd, input.cwd?.trim() || '.'),
430
+ isolation: input.isolation === 'worktree' ? 'worktree' : undefined,
431
+ dir: path.join(runDir, `task-${index + 1}`),
432
+ thinking,
433
+ model: childModel,
434
+ timeoutMs,
435
+ maxTurns: resolveMaxTurns(input.maxTurns as number | undefined, agent?.maxTurns),
436
+ forkContext: input.context === 'fork',
437
+ schema: normalizeJsonSchema(input.schema)
438
+ }
439
+ })
440
+
441
+ const writers = specs.filter((spec) =>
442
+ spec.agent?.tools.some((tool) => tool === 'write' || tool === 'edit')
443
+ )
444
+ if (writers.length > 1) {
445
+ throw new Error('Parallel write-capable subagents are disabled. Run one writer at a time.')
446
+ }
447
+
448
+ const tasks: TaskDetails[] = specs.map((spec) => ({
449
+ id: spec.id,
450
+ index: spec.index,
451
+ label: spec.label,
452
+ agent: spec.agent?.name,
453
+ status: 'queued',
454
+ cwd: spec.cwd,
455
+ provider: (spec.model ?? activeModel).provider,
456
+ model: (spec.model ?? activeModel).id,
457
+ thinking: spec.thinking,
458
+ color: spec.agent?.color,
459
+ forkContext: spec.forkContext,
460
+ hasSchema: spec.schema !== undefined,
461
+ maxTurns: spec.maxTurns,
462
+ partial: undefined,
463
+ structured: undefined
464
+ }))
465
+ registry.push(...tasks)
466
+
467
+ const snapshot = (status: RunStatus, finishedAt?: number): RunDetails => ({
468
+ status,
469
+ runId,
470
+ startedAt,
471
+ finishedAt,
472
+ tasks: tasks.map((task) => ({ ...task }))
473
+ })
474
+
475
+ let durableRun: DurableRun | undefined
476
+ let lastRunSave = 0
477
+ let lastEmit = 0
478
+ const emit = (force = false): void => {
479
+ const now = Date.now()
480
+ if (!force && now - lastEmit < EMIT_INTERVAL_MS) return
481
+ lastEmit = now
482
+ if (durableRun && now - lastRunSave >= RUN_SAVE_INTERVAL_MS) {
483
+ lastRunSave = now
484
+ saveRun(durableRun)
485
+ }
486
+ onUpdate?.({
487
+ content: [{ type: 'text', text: partialText(snapshot('running')) }],
488
+ details: snapshot('running')
489
+ })
490
+ syncWidget()
491
+ }
492
+
493
+ for (const spec of specs) await validateCwd(spec.cwd)
494
+ emit(true)
495
+
496
+ const modelRuntime = await createChildModelRuntime(ctx)
497
+
498
+ const runTask = async (spec: TaskSpec, detail: TaskDetails, record: DurableTask): Promise<void> => {
499
+ let acquired = false
500
+ try {
501
+ await acquirePermit(signal)
502
+ acquired = true
503
+ await mkdir(spec.dir, { recursive: true, mode: 0o700 })
504
+
505
+ if (spec.isolation === 'worktree') {
506
+ const worktree = path.join(spec.dir, 'worktree')
507
+ await createWorktree(spec.cwd, worktree)
508
+ spec.cwd = worktree
509
+ detail.worktree = worktree
510
+ detail.cwd = worktree
511
+ record.worktree = worktree
512
+ record.cwd = worktree
513
+ saveRun(run)
514
+ emit(true)
515
+ }
516
+
517
+ const session = await createChildSession({
518
+ cwd: spec.cwd,
519
+ sessionDir: path.join(spec.dir, 'session'),
520
+ model: spec.model ?? activeModel,
521
+ modelRuntime,
522
+ thinking: spec.thinking,
523
+ parentSystemPrompt: ctx.getSystemPrompt(),
524
+ allTools: pi.getAllTools().map((tool) => tool.name),
525
+ agent: spec.agent,
526
+ schema: spec.schema,
527
+ onStructured: (value) => {
528
+ detail.structured = value
529
+ }
530
+ })
531
+ liveSessions.set(spec.id, session)
532
+ record.sessionFile = session.sessionFile
533
+ saveRun(run)
534
+
535
+ detail.status = 'running'
536
+ detail.startedAt = Date.now()
537
+ emit(true)
538
+
539
+ if (spec.forkContext) {
540
+ const parentEntries = ctx.sessionManager.getEntries()
541
+ let forkChars = 0
542
+ for (const entry of parentEntries) {
543
+ if (forkChars >= MAX_FORK_CONTEXT_CHARS) break
544
+ if (signal?.aborted) break
545
+ if (entry.type !== 'message') continue
546
+ const msg = entry.message as { role?: string; content?: JsonValue }
547
+ if (msg.role !== 'user' && msg.role !== 'assistant') continue
548
+ const text = extractForkText(msg.content)
549
+ if (text) {
550
+ const remaining = MAX_FORK_CONTEXT_CHARS - forkChars
551
+ const clipped =
552
+ text.length > remaining
553
+ ? `${text.slice(0, remaining)}\n[fork context truncated]`
554
+ : text
555
+ forkChars += clipped.length
556
+ await session.sendCustomMessage(
557
+ {
558
+ customType: `fork-${msg.role}`,
559
+ content: [{ type: 'text', text: clipped }],
560
+ display: true
561
+ },
562
+ { triggerTurn: false }
563
+ )
564
+ if (forkChars >= MAX_FORK_CONTEXT_CHARS) break
565
+ }
566
+ }
567
+ }
568
+
569
+ const {
570
+ output: rawOutput,
571
+ assistant,
572
+ turnCount,
573
+ timedOut,
574
+ touched: newTouched,
575
+ schemaFallback
576
+ } = await drivePrompt({
577
+ session,
578
+ prompt: spec.task,
579
+ maxTurns: spec.maxTurns,
580
+ schema: spec.schema,
581
+ cwd: spec.cwd,
582
+ timeoutMs: spec.timeoutMs,
583
+ abortSignal: signal,
584
+ onEmit: emit,
585
+ detail
586
+ })
587
+ const assistantMsg = assistant ?? findLastAssistant(session.messages)
588
+ detail.output = rawOutput || undefined
589
+ if (newTouched.length > 0) detail.touched = newTouched
590
+ if (detail.worktree) detail.changes = changedPaths(await gitStatus(detail.worktree))
591
+ detail.sessionFile = session.sessionFile
592
+ detail.finishedAt = Date.now()
593
+ if (!detail.output || timedOut) {
594
+ const salvaged = salvageOutput(assistantMsg) ?? salvageFromMessages(session.messages)
595
+ if (salvaged && !detail.output) {
596
+ detail.output = salvaged
597
+ detail.partial = true
598
+ }
599
+ }
600
+ detail.hasSchema = spec.schema !== undefined && !schemaFallback
601
+ if (detail.hasSchema && detail.structured === undefined) detail.partial = true
602
+ const reason = failureReason({
603
+ hasAssistant: assistantMsg !== undefined,
604
+ timedOut,
605
+ duration: formatDuration(detail.startedAt),
606
+ hitTurnLimit: turnCount >= spec.maxTurns,
607
+ maxTurns: spec.maxTurns,
608
+ aborted: signal?.aborted === true,
609
+ hasSchema: spec.schema !== undefined && !schemaFallback,
610
+ hasStructured: detail.structured !== undefined,
611
+ output: detail.output ?? '',
612
+ stopReason: assistantMsg?.stopReason,
613
+ errorMessage: assistantMsg?.errorMessage
614
+ })
615
+ if (reason) {
616
+ detail.status = 'failed'
617
+ detail.error = reason
618
+ } else {
619
+ detail.status = 'completed'
620
+ }
621
+ } catch (error) {
622
+ detail.status = 'failed'
623
+ detail.error = error instanceof Error ? error.message : String(error)
624
+ detail.finishedAt ??= Date.now()
625
+ } finally {
626
+ if (detail.status === 'queued' || detail.status === 'running') {
627
+ detail.status = 'failed'
628
+ detail.error ??= 'Subagent aborted.'
629
+ detail.finishedAt ??= Date.now()
630
+ }
631
+ syncRecord(record, detail)
632
+ if (detail.usage !== undefined) {
633
+ recordOverhead(ctx.sessionManager.getSessionId(), spec.agent?.name ?? 'scout', detail.usage)
634
+ }
635
+ saveRun(run)
636
+ if (acquired) releasePermit()
637
+ emit(true)
638
+ }
639
+ }
640
+
641
+ const resumeModelRuntime = resumeKey ? await createChildModelRuntime(ctx) : undefined
642
+
643
+ const resumeKnownTask = async (task: TaskDetails): Promise<void> => {
644
+ const sessionId = ctx.sessionManager.getSessionId()
645
+ const run = findRunForTask(sessionId, task.id)
646
+ const record = run?.tasks.find((candidate) => candidate.id === task.id)
647
+ if (!run || !record) throw new Error(`No durable record for "${task.label}"; start the task again.`)
648
+ const knobs = params as { maxTurns?: number; timeoutMs?: number }
649
+ await resumeTask({
650
+ pi,
651
+ ctx,
652
+ run,
653
+ record,
654
+ task,
655
+ signal,
656
+ followUp: (params.task as string | undefined)?.trim() || undefined,
657
+ maxTurns: knobs.maxTurns,
658
+ timeoutMs: knobs.timeoutMs,
659
+ modelRuntime: resumeModelRuntime,
660
+ onEmit: emit
661
+ })
662
+ }
663
+
664
+ if (resumeKey) {
665
+ if (resumeKey === 'all') {
666
+ const failed = registry.filter((run) => run.status === 'failed')
667
+ if (!failed.length)
668
+ return {
669
+ content: [{ type: 'text', text: 'No failed tasks to resume.' }],
670
+ details: snapshot('completed', Date.now())
671
+ }
672
+ await Promise.allSettled(failed.map((task) => resumeKnownTask(task)))
673
+ if (signal?.aborted) throw new Error('Subagent aborted.')
674
+ const details: RunDetails = {
675
+ status: 'completed',
676
+ runId,
677
+ startedAt,
678
+ finishedAt: Date.now(),
679
+ tasks: failed.map((task) => ({ ...task }))
680
+ }
681
+ return { content: [{ type: 'text', text: resultText(details) }], details }
682
+ }
683
+ const task = registry.find((run) => run.id === resumeKey || run.label === resumeKey)
684
+ if (!task)
685
+ throw new Error(
686
+ `Task "${resumeKey}" not found. Failed ids: ${
687
+ registry
688
+ .filter((run) => run.status === 'failed')
689
+ .map((run) => run.id)
690
+ .join(', ') || 'none'
691
+ }.`
692
+ )
693
+ if (task.status !== 'failed')
694
+ throw new Error(
695
+ `Task "${task.label}" is ${task.status}, not failed, so there is nothing to resume.`
696
+ )
697
+ await resumeKnownTask(task)
698
+ const details: RunDetails = {
699
+ status: task.status === 'failed' ? 'failed' : 'completed',
700
+ runId,
701
+ startedAt,
702
+ finishedAt: Date.now(),
703
+ tasks: [{ ...task }]
704
+ }
705
+ return { content: [{ type: 'text', text: resultText(details) }], details }
706
+ }
707
+
708
+ const run: DurableRun = {
709
+ version: 1,
710
+ runId,
711
+ sessionId: ctx.sessionManager.getSessionId(),
712
+ toolCallId: _toolCallId,
713
+ startedAt,
714
+ tasks: specs.map((spec) => ({
715
+ id: spec.id,
716
+ index: spec.index,
717
+ label: spec.label,
718
+ task: spec.task,
719
+ cwd: spec.cwd,
720
+ provider: (spec.model ?? activeModel).provider,
721
+ model: (spec.model ?? activeModel).id,
722
+ thinking: spec.thinking,
723
+ maxTurns: spec.maxTurns,
724
+ timeoutMs: spec.timeoutMs,
725
+ isolation: spec.isolation,
726
+ forkContext: spec.forkContext === true,
727
+ sessionDir: path.join(spec.dir, 'session'),
728
+ agent: spec.agent?.name,
729
+ schema: spec.schema,
730
+ status: 'queued'
731
+ }))
732
+ }
733
+ durableRun = run
734
+ claimRun(run.sessionId, run.runId)
735
+ saveRun(run)
736
+
737
+ pi.events.emit('subagent:started', { runId, count: tasks.length })
738
+ await Promise.allSettled(
739
+ specs.map((spec, index) => runTask(spec, tasks[index] as TaskDetails, run.tasks[index] as DurableTask))
740
+ )
741
+ if (signal?.aborted) throw new Error('Subagent aborted.')
742
+
743
+ for (const task of tasks) {
744
+ if (!task.output) continue
745
+ const truncated = truncateHead(task.output, {
746
+ maxBytes: Math.floor(DEFAULT_MAX_BYTES / tasks.length),
747
+ maxLines: Math.floor(DEFAULT_MAX_LINES / tasks.length)
748
+ })
749
+ task.output = truncated.truncated
750
+ ? `${truncated.content}\n\n[Output truncated. Full output is in the child session file.]`
751
+ : truncated.content
752
+ }
753
+
754
+ run.finishedAt = Date.now()
755
+ for (const [index, task] of tasks.entries()) {
756
+ const record = run.tasks[index]
757
+ if (record) syncRecord(record, task)
758
+ }
759
+ saveRun(run)
760
+
761
+ const failed = tasks.filter((task) => task.status === 'failed')
762
+ pi.events.emit('subagent:completed', { runId, failed: failed.length })
763
+ const details = snapshot(failed.length === tasks.length ? 'failed' : 'completed', Date.now())
764
+ return { content: [{ type: 'text', text: resultText(details) }], details }
765
+ },
766
+
767
+ renderCall(args, theme) {
768
+ const inputs = args.tasks?.length ? args.tasks : [args]
769
+ let text = theme.fg('toolTitle', theme.bold('subagent '))
770
+ text += theme.fg('muted', inputs.length > 1 ? `${inputs.length} tasks` : '')
771
+ for (const input of inputs) {
772
+ const first = (input.task ?? '').split('\n', 1)[0]?.trim() ?? ''
773
+ const preview = first.length > 90 ? `${first.slice(0, 90)}…` : first || '...'
774
+ const forkTag = input.context === 'fork' ? theme.fg('accent', ' fork') : ''
775
+ const schemaTag = input.schema !== undefined ? theme.fg('accent', ' structured') : ''
776
+ text += `\n ${theme.fg('accent', input.agent ?? 'default')} ${theme.fg('dim', preview)}${forkTag}${schemaTag}`
777
+ const thinking = (input as { thinking?: string }).thinking
778
+ if (thinking !== undefined) text += `\n ${theme.fg('muted', thinking)}`
779
+ }
780
+ return new Text(text, 0, 0)
781
+ },
782
+
783
+ renderResult(result, { expanded, isPartial }, theme) {
784
+ const details = result.details as RunDetails | undefined
785
+ if (!details) {
786
+ const content = result.content.find((part) => part.type === 'text')
787
+ return new Text(content?.type === 'text' ? content.text : '(no output)', 0, 0)
788
+ }
789
+
790
+ const running = isPartial || details.status === 'queued' || details.status === 'running'
791
+ const duration = formatDuration(details.startedAt, details.finishedAt)
792
+ let text = theme.fg('toolTitle', theme.bold('subagent'))
793
+ text += theme.fg('muted', ` · ${countByStatus(details.tasks)}${duration ? ` · ${duration}` : ''}`)
794
+
795
+ for (const task of details.tasks) {
796
+ text += `\n\n${statusIcon(task.status, theme)} ${theme.fg('dim', taskHeadline(task))}`
797
+ if (task.error) text += `\n ${theme.fg('error', task.error.split('\n', 1)[0] ?? '')}`
798
+ if (task.sessionFile && !running) text += `\n ${theme.fg('dim', task.sessionFile)}`
799
+ if (task.worktree && !running)
800
+ text += `\n ${theme.fg('dim', task.changes?.length ? `${task.changes.length} changed file(s), branch ${worktreeBranch(task.worktree)}` : 'clean worktree')}`
801
+
802
+ if (task.structured !== undefined && !running) {
803
+ text += `\n ${theme.fg('accent', 'structured:')} ${theme.fg('toolOutput', JSON.stringify(task.structured))}`
804
+ }
805
+
806
+ const body = task.status === 'completed' || task.status === 'failed' ? task.output : undefined
807
+ if (!body) continue
808
+ const lines = body.split('\n')
809
+ const visible = expanded ? lines : lines.slice(0, 8)
810
+ text += `\n${visible.map((line) => theme.fg('toolOutput', line)).join('\n')}`
811
+ if (!expanded && lines.length > visible.length) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
812
+ }
813
+ return new Text(text, 0, 0)
814
+ }
815
+ })
816
+
817
+ pi.registerCommand('agents', {
818
+ description: 'List agent profiles (tools, model, limits). Useful after adding or editing profile files.',
819
+ handler: async (_args, ctx) => {
820
+ const agents = [...loadAgents(ctx.cwd).values()]
821
+ const lines =
822
+ agents.length === 0
823
+ ? [
824
+ 'No agent profiles found. Add markdown files with YAML frontmatter under ~/.pi/agent/agents/ or .pi/agents/.'
825
+ ]
826
+ : [
827
+ `${agents.length} agent profile${agents.length !== 1 ? 's' : ''}:`,
828
+ ...agents.map(profileLines).flat()
829
+ ]
830
+ ctx.ui.notify(lines.join('\n'), 'info')
831
+ }
832
+ })
833
+ }
834
+
835
+ function profileLines(a: {
836
+ name: string
837
+ description: string
838
+ model?: string
839
+ tools: string[]
840
+ disallowedTools: string[]
841
+ maxTurns?: number
842
+ maxExecutionTimeMs?: number
843
+ thinking?: string
844
+ }): string[] {
845
+ const bits = [`- ${a.name}: ${a.description}`]
846
+ if (a.model) bits.push(` model: ${a.model}`)
847
+ if (a.tools.length) bits.push(` tools: ${a.tools.join(', ')}`)
848
+ if (a.disallowedTools.length) bits.push(` blocked: ${a.disallowedTools.join(', ')}`)
849
+ if (a.maxTurns) bits.push(` maxTurns: ${a.maxTurns}`)
850
+ if (a.maxExecutionTimeMs) bits.push(` timeout: ${a.maxExecutionTimeMs}ms`)
851
+ if (a.thinking) bits.push(` thinking: ${a.thinking}`)
852
+ return bits
853
+ }