@xl0/pi-lovely-agents 0.1.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/extensions/lovely-agents/agent.ts +1374 -0
  5. package/extensions/lovely-agents/bash.ts +599 -0
  6. package/extensions/lovely-agents/child-session.ts +296 -0
  7. package/extensions/lovely-agents/config.ts +221 -0
  8. package/extensions/lovely-agents/coordinator.ts +506 -0
  9. package/extensions/lovely-agents/definitions.ts +380 -0
  10. package/extensions/lovely-agents/index.ts +400 -0
  11. package/extensions/lovely-agents/lifecycle.ts +251 -0
  12. package/extensions/lovely-agents/management.ts +638 -0
  13. package/extensions/lovely-agents/notifications.ts +220 -0
  14. package/extensions/lovely-agents/provider-limits.ts +13 -0
  15. package/extensions/lovely-agents/rendering.ts +90 -0
  16. package/extensions/lovely-agents/state.ts +1179 -0
  17. package/extensions/lovely-agents/task-panel.ts +192 -0
  18. package/extensions/lovely-agents/tools.ts +635 -0
  19. package/extensions/lovely-agents/updates.ts +45 -0
  20. package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
  21. package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
  22. package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
  23. package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
  24. package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
  25. package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
  26. package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
  27. package/package.json +68 -0
  28. package/skills/agent/SKILL.md +21 -0
  29. package/skills/agent-creator/SKILL.md +35 -0
@@ -0,0 +1,1374 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import { rm } from "node:fs/promises"
3
+ import type { AgentSessionEvent, ExtensionAPI, ExtensionContext, ScopedModel } from "@earendil-works/pi-coding-agent"
4
+ import { Container, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
5
+ import { Type } from "typebox"
6
+ import {
7
+ type ChildSessionHandle,
8
+ childPromptOptions,
9
+ createChildSession,
10
+ resolveChildSessionSelection,
11
+ resolveChildToolPolicy
12
+ } from "./child-session.js"
13
+ import type { AgentsConfig } from "./config.js"
14
+ import { resolveConfiguredModels } from "./config.js"
15
+ import {
16
+ type AgentReservation,
17
+ getAgentCoordinator,
18
+ type ModelTuple,
19
+ type ResidentAgent,
20
+ type ResidentInputOptions,
21
+ type ResidentInputResult
22
+ } from "./coordinator.js"
23
+ import { type AgentDefinition, discoverAgentDefinitions } from "./definitions.js"
24
+ import { discardTask, stopOwnedTaskTree, stopTask } from "./lifecycle.js"
25
+ import { appendTaskNotification, deliverTaskNotifications, prepareTaskNotification } from "./notifications.js"
26
+ import { isProviderLimitError } from "./provider-limits.js"
27
+ import { renderExpandableResult } from "./rendering.js"
28
+ import {
29
+ type AgentTaskMetadata,
30
+ acquireParentLease,
31
+ appendHistoryLog,
32
+ archivedTaskStoragePaths,
33
+ displayWorkspacePath,
34
+ ensureParentStorage,
35
+ initializeRetainedLogs,
36
+ MAX_AGENT_INPUT_BYTES,
37
+ MAX_AGENT_LABEL_BYTES,
38
+ MAX_QUEUED_FOLLOWUPS,
39
+ mutateTaskMetadata,
40
+ readRetainedOutput,
41
+ readTaskMetadata,
42
+ reserveTaskStorage,
43
+ retainedOutputSnapshot,
44
+ retainedPaths,
45
+ TASK_METADATA_VERSION,
46
+ TASK_REFERENCE_PATTERN,
47
+ type TaskMetadata,
48
+ type TaskStoragePaths,
49
+ taskStoragePaths,
50
+ writeTaskMetadata,
51
+ writeTaskProgress
52
+ } from "./state.js"
53
+ import { loadTaskList } from "./tools.js"
54
+
55
+ const ThinkingLevel = Type.Union([
56
+ Type.Literal("off"),
57
+ Type.Literal("minimal"),
58
+ Type.Literal("low"),
59
+ Type.Literal("medium"),
60
+ Type.Literal("high"),
61
+ Type.Literal("xhigh"),
62
+ Type.Literal("max")
63
+ ])
64
+
65
+ type RecoveryMode = "automatic" | "manual" | "stop"
66
+ type SuspendedRuntime = {
67
+ wakeProviderRecovery(mode: RecoveryMode): boolean
68
+ }
69
+ const SUSPENDED_RUNTIMES_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/suspended-runtimes/v1")
70
+
71
+ /** Opens one tuple and wakes every process-resident run suspended on it. */
72
+ export function recoverProviderTuple(tuple: ModelTuple): number {
73
+ getAgentCoordinator().openTuple(tuple)
74
+ const runtimes = suspendedRuntimes().get(modelTupleKey(tuple))
75
+ if (!runtimes) return 0
76
+ let recovered = 0
77
+ for (const runtime of [...runtimes]) {
78
+ if (runtime.wakeProviderRecovery("automatic")) recovered++
79
+ }
80
+ return recovered
81
+ }
82
+
83
+ export type AgentCreationResult = {
84
+ id: string
85
+ label: string
86
+ definition: string
87
+ state: TaskMetadata["state"]
88
+ latestOutcome: TaskMetadata["latestOutcome"]
89
+ model: string
90
+ thinking: AgentTaskMetadata["thinking"]
91
+ depth: number
92
+ allowAgents: boolean
93
+ detached: boolean
94
+ queuedFollowUps: number
95
+ output: Awaited<ReturnType<typeof readRetainedOutput>>
96
+ tasks: Awaited<ReturnType<typeof loadTaskList>>["details"]
97
+ }
98
+
99
+ export type TaskInputResult = {
100
+ id: string
101
+ requestedDelivery: "followup" | "steer" | undefined
102
+ effectiveDelivery: "followup" | "steer" | "stdin"
103
+ queuePosition: number | null
104
+ state: TaskMetadata["state"]
105
+ latestOutcome: TaskMetadata["latestOutcome"]
106
+ queuedFollowUps: number
107
+ output?: Awaited<ReturnType<typeof readRetainedOutput>>
108
+ }
109
+
110
+ export type AgentToolOptions = {
111
+ getConfig: () => AgentsConfig
112
+ createChild?: typeof createChildSession
113
+ getAgentDir?: () => string
114
+ canDelegate?: () => boolean
115
+ /** Input-schema visibility from allowed producers plus retained owned kinds; not execution gates. */
116
+ agentInputEnabled?: () => boolean
117
+ bashInputEnabled?: () => boolean
118
+ }
119
+
120
+ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions): void {
121
+ const background = options.getConfig().backgroundAgents
122
+ pi.registerTool({
123
+ name: "agent",
124
+ label: "Agent",
125
+ description: background
126
+ ? "Create a durable Lovely Agent session and start its initial run. waitMs permits background detachment."
127
+ : "Create a durable Lovely Agent session and wait for its terminal result. Cancellation stops the accepted work.",
128
+ promptSnippet: "Create or delegate work to a durable agent",
129
+ promptGuidelines: ["Call agent_roster before creating an agent and use task tools for existing work."],
130
+ parameters: Type.Object(
131
+ {
132
+ definition: Type.String({ minLength: 1, description: "Agent Definition name" }),
133
+ label: Type.String({ minLength: 1, description: "Short task label" }),
134
+ prompt: Type.String({ minLength: 1, description: "Initial task prompt" }),
135
+ ...(background ? { waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 600_000 })) } : {}),
136
+ model: Type.Optional(
137
+ Type.String({ minLength: 1, description: "Configured provider/model ID or an enabled alias from agent_roster" })
138
+ ),
139
+ thinking: Type.Optional(ThinkingLevel),
140
+ ...(options.canDelegate?.() === false
141
+ ? {}
142
+ : { allowAgents: Type.Optional(Type.Boolean({ description: "Allow this child to create descendants" })) })
143
+ },
144
+ { additionalProperties: false }
145
+ ),
146
+ renderCall(args, theme, context) {
147
+ return {
148
+ render(width) {
149
+ // The result supplies the ID after renderCall; read shared state at paint time.
150
+ const header = `${theme.fg("toolTitle", theme.bold("agent"))}${args.definition ? ` ${theme.fg("muted", args.definition)}` : ""}${args.label ? ` ${theme.fg("dim", `label=${JSON.stringify(args.label)}`)}` : ""}`
151
+ const suffix = context.state.taskRef ? `${theme.fg("muted", " -> ")}${theme.fg("accent", context.state.taskRef)}` : ""
152
+ if (context.expanded) {
153
+ const lines = new Text(header + suffix, 0, 0).render(width)
154
+ if (args.prompt) lines.push(...new Text(`${theme.fg("muted", "Input: ")}${args.prompt}`, 0, 0).render(width))
155
+ return lines
156
+ }
157
+ const available = Math.max(0, width - visibleWidth(suffix))
158
+ const promptWidth = available - visibleWidth(header) - visibleWidth(' prompt=""')
159
+ const preview =
160
+ args.prompt && promptWidth > 0
161
+ ? `${header}${theme.fg("muted", ' prompt="')}${truncateToWidth(JSON.stringify(args.prompt.replace(/\s+/g, " ").trim()).slice(1, -1), promptWidth)}${theme.fg("muted", '"')}`
162
+ : truncateToWidth(header, available)
163
+ // Pi's truncation emits full resets; preserve the surrounding tool background.
164
+ return [truncateToWidth(preview + suffix, width).replaceAll("\x1b[0m", "\x1b[22;39m")]
165
+ },
166
+ invalidate() {}
167
+ }
168
+ },
169
+ renderResult(result, { expanded }, theme, context) {
170
+ const details = result.details as Partial<AgentCreationResult> | undefined
171
+ if (typeof details?.id === "string") context.state.taskRef = details.id
172
+ const output = new Container()
173
+ if (expanded || context.isError) {
174
+ output.addChild(new Text(theme.fg("muted", "── Result ──"), 0, 0))
175
+ output.addChild(renderExpandableResult(result, true, theme))
176
+ }
177
+ return output
178
+ },
179
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
180
+ if (signal?.aborted) throw abortError(signal)
181
+ const config = options.getConfig()
182
+ const background = config.backgroundAgents
183
+ if (!background && "waitMs" in params) throw new Error("waitMs requires backgroundAgents")
184
+ if (
185
+ "waitMs" in params &&
186
+ (typeof params.waitMs !== "number" || !Number.isInteger(params.waitMs) || params.waitMs < 0 || params.waitMs > 600_000)
187
+ ) {
188
+ throw new Error("waitMs must be an integer from 0 to 600000")
189
+ }
190
+ if ("allowAgents" in params && typeof params.allowAgents !== "boolean") throw new Error("allowAgents must be boolean")
191
+ const allowAgents = params.allowAgents === true
192
+ if (allowAgents && options.canDelegate?.() === false) throw new Error("This child cannot delegate at the current maximum depth")
193
+ validateInput(params.label, "label", MAX_AGENT_LABEL_BYTES)
194
+ validateInput(params.prompt, "prompt", MAX_AGENT_INPUT_BYTES)
195
+ const parentSessionId = ctx.sessionManager.getSessionId()
196
+ const coordinator = getAgentCoordinator(config.maxConcurrency)
197
+ const parentContext = coordinator.getSessionContext(parentSessionId)
198
+ if (parentContext && !parentContext.allowAgents) throw new Error("This parent agent is not allowed to delegate")
199
+ const parentDepth = parentContext?.depth ?? 0
200
+ resolveChildToolPolicy({
201
+ parentDepth,
202
+ maximumDepth: config.maxDepth,
203
+ allowAgents
204
+ })
205
+ const discovered = discoverAgentDefinitions({
206
+ cwd: ctx.cwd,
207
+ projectTrusted: ctx.isProjectTrusted(),
208
+ toolNames: pi.getAllTools().map(tool => tool.name),
209
+ models: ctx.modelRegistry.getAll(),
210
+ ...(options.getAgentDir ? { agentDir: options.getAgentDir() } : {})
211
+ })
212
+ const definition = discovered.definitions.find(candidate => candidate.name === params.definition)
213
+ if (!definition) throw new Error(`Unknown or invalid Agent Definition: ${params.definition}`)
214
+ const configuredModels = await resolveConfiguredModels(config, ctx)
215
+ const selection = resolveChildSessionSelection({
216
+ ...(params.model ? { callModel: params.model } : {}),
217
+ ...(params.thinking ? { callThinking: params.thinking } : {}),
218
+ definition,
219
+ configuredModels: configuredModels.models,
220
+ aliases: configuredModels.aliases,
221
+ availableModels: ctx.modelRegistry.getAvailable(),
222
+ parentModel: ctx.model,
223
+ parentThinking: ctx.thinkingLevel ?? "medium"
224
+ })
225
+ await acquireParentLease(ctx.cwd, parentSessionId)
226
+ const paths = await reserveTaskStorage(await ensureParentStorage(ctx.cwd, parentSessionId))
227
+ let child: ChildSessionHandle | undefined
228
+ let accepted = false
229
+ try {
230
+ await initializeRetainedLogs(paths)
231
+ child = await (options.createChild ?? createChildSession)({
232
+ cwd: ctx.cwd,
233
+ paths,
234
+ definition,
235
+ selection,
236
+ scopedModels: configuredModels.models,
237
+ parentDepth,
238
+ maximumDepth: config.maxDepth,
239
+ allowAgents,
240
+ projectTrusted: ctx.isProjectTrusted()
241
+ })
242
+ if (signal?.aborted) throw abortError(signal)
243
+ const acceptedAt = Date.now()
244
+ const runId = createRunId()
245
+ const acceptanceOrder = coordinator.nextAcceptanceOrder()
246
+ const metadata: TaskMetadata = {
247
+ version: TASK_METADATA_VERSION,
248
+ kind: "agent",
249
+ taskRef: paths.taskRef,
250
+ parentSessionId,
251
+ childSessionId: child.session.sessionId,
252
+ definitionName: definition.name,
253
+ label: params.label.trim(),
254
+ model: { provider: child.session.model?.provider ?? selection.model.provider, id: child.session.model?.id ?? selection.model.id },
255
+ thinking: child.session.thinkingLevel,
256
+ depth: child.depth,
257
+ allowAgents: child.allowAgents,
258
+ sessionConfig: {
259
+ systemPrompt: definition.systemPrompt,
260
+ tools: definition.tools ?? null,
261
+ excludeAgentsMd: definition.excludeAgentsMd ?? false,
262
+ scopedModels: configuredModels.models.map(choice => ({
263
+ provider: choice.model.provider,
264
+ id: choice.model.id,
265
+ ...(choice.thinkingLevel ? { thinkingLevel: choice.thinkingLevel } : {})
266
+ }))
267
+ },
268
+ state: "queued",
269
+ latestOutcome: null,
270
+ latestReply: null,
271
+ lastActivity: { at: acceptedAt, action: "queued" },
272
+ lastRunSequence: 1,
273
+ activeRun: {
274
+ id: runId,
275
+ sequence: 1,
276
+ acceptanceOrder,
277
+ background,
278
+ kind: "initial",
279
+ state: "queued",
280
+ input: params.prompt,
281
+ acceptedAt
282
+ },
283
+ queuedFollowUps: [],
284
+ notifications: [],
285
+ discardedAt: null,
286
+ createdAt: acceptedAt,
287
+ updatedAt: acceptedAt
288
+ }
289
+ await appendHistoryLog(paths, { type: "run-start", sequence: 1, kind: "initial", timestamp: acceptedAt })
290
+ await appendHistoryLog(paths, { type: "input", delivery: "initial", timestamp: acceptedAt, content: params.prompt })
291
+ await writeTaskMetadata(paths, metadata)
292
+ accepted = true
293
+
294
+ const runtime = new AgentRuntime(paths, child, metadata, config.expandPromptTemplates)
295
+ child = undefined
296
+ runtime.start()
297
+ if (!background) {
298
+ const completed = await runtime.waitForRun(runId, signal)
299
+ const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
300
+ return buildAgentCreationToolResult(completed, false, retainedOutputSnapshot(paths, completed), tasks)
301
+ }
302
+ const waitMs = (params.waitMs as number | undefined) ?? config.waitMs
303
+ const wait = () => runtime.wait(waitMs, signal)
304
+ const detached = waitMs === 0 ? await wait() : await coordinator.withLentPermit(wait, signal)
305
+ const loaded = await readTaskMetadata(paths)
306
+ if (loaded.status !== "ok") throw new Error(`Could not read accepted task ${paths.taskRef}`)
307
+ const output = await readRetainedOutput(paths)
308
+ const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
309
+ return buildAgentCreationToolResult(loaded.metadata, detached, output, tasks)
310
+ } finally {
311
+ child?.dispose()
312
+ if (!accepted) await rm(paths.taskDirectory, { recursive: true, force: true })
313
+ }
314
+ }
315
+ })
316
+ }
317
+
318
+ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOptions): void {
319
+ const background = options.getConfig().backgroundAgents
320
+ // Foreground agents remain a producer by default; the parent knows allowed producers and owned kinds.
321
+ const agentInput = options.agentInputEnabled?.() ?? true
322
+ const bashInput = options.bashInputEnabled?.() ?? options.getConfig().backgroundBash
323
+ pi.registerTool({
324
+ name: "task_input",
325
+ label: "Task Input",
326
+ description:
327
+ [
328
+ ...(agentInput
329
+ ? [
330
+ background
331
+ ? "Send a durable Follow-up or live Steer to an owned Lovely Agent task."
332
+ : "Run a Follow-up on an idle owned task and wait for its terminal reply, or send a run-scoped live Steer. Busy tasks reject Follow-ups."
333
+ ]
334
+ : []),
335
+ ...(bashInput ? ["Write literal stdin to a running owned Bash task; eof closes stdin. Empty content requires eof:true."] : [])
336
+ ].join(" ") || "Send input to an owned task.",
337
+ promptSnippet: agentInput
338
+ ? `Send Follow-up work or a live Steer${bashInput ? ", or literal Bash stdin" : ""} to a durable task`
339
+ : bashInput
340
+ ? "Write literal stdin to a running Bash task"
341
+ : "Send input to an owned task",
342
+ promptGuidelines: [
343
+ ...(agentInput ? ["Use Follow-up for later work; use Steer only to redirect a currently running agent."] : []),
344
+ ...(bashInput ? ["For Bash, omit delivery and write literal stdin; eof closes stdin without restarting the command."] : [])
345
+ ],
346
+ parameters: Type.Object(
347
+ {
348
+ id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }),
349
+ content: Type.String({ minLength: bashInput ? 0 : 1, description: "Input text" }),
350
+ ...(agentInput ? { delivery: Type.Optional(Type.Union([Type.Literal("followup"), Type.Literal("steer")])) } : {}),
351
+ ...(bashInput ? { eof: Type.Optional(Type.Boolean({ description: "Close Bash stdin after writing content" })) } : {})
352
+ },
353
+ { additionalProperties: false }
354
+ ),
355
+ renderCall(args, theme) {
356
+ const delivery = args.delivery ?? (args.id?.startsWith("b_") ? "stdin" : "followup")
357
+ const content = args.content ?? ""
358
+ const preview = content.length > 60 ? `${content.slice(0, 57)}...` : content
359
+ return new Text(
360
+ `${theme.fg("toolTitle", theme.bold("task_input"))}${args.id ? ` ${theme.fg("muted", args.id)}` : ""} ${theme.fg("dim", `${delivery}${preview ? ` ${JSON.stringify(preview)}` : ""}`)}`,
361
+ 0,
362
+ 0
363
+ )
364
+ },
365
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
366
+ if ("waitMs" in params) throw new Error("task_input does not accept waitMs")
367
+ if (params.delivery !== undefined && params.delivery !== "followup" && params.delivery !== "steer") {
368
+ throw new Error("delivery must be followup or steer")
369
+ }
370
+ if (params.eof !== undefined && typeof params.eof !== "boolean") throw new Error("eof must be boolean")
371
+ const result = await sendTaskInput(
372
+ ctx,
373
+ options,
374
+ params.id,
375
+ params.content,
376
+ params.delivery,
377
+ signal,
378
+ params.eof !== undefined ? { eof: params.eof } : {}
379
+ )
380
+ return {
381
+ content: [
382
+ {
383
+ type: "text",
384
+ text:
385
+ result.effectiveDelivery === "stdin"
386
+ ? `${result.id}: stdin delivered${params.eof ? " (EOF)" : ""}`
387
+ : result.effectiveDelivery === "steer"
388
+ ? `${result.id}: steer delivered (${result.state}; ${result.queuedFollowUps} Follow-ups queued)`
389
+ : result.output
390
+ ? `${result.id}: followup ${result.latestOutcome}\n${result.output.text}`
391
+ : `${result.id}: followup accepted (position ${result.queuePosition}; ${result.state}; ${result.queuedFollowUps} queued)`
392
+ }
393
+ ],
394
+ details: result
395
+ }
396
+ }
397
+ })
398
+
399
+ for (const action of ["stop", "discard"] as const) {
400
+ pi.registerTool({
401
+ name: `task_${action}`,
402
+ label: action === "stop" ? "Task Stop" : "Task Discard",
403
+ description:
404
+ action === "stop"
405
+ ? "Stop active or queued work for an owned Agent or Bash task while preserving its files."
406
+ : "Stop and permanently discard an owned task subtree, archiving its files. Unsupported metadata versions can also be archived.",
407
+ promptSnippet: action === "stop" ? "Stop work for a durable task" : "Discard a durable task",
408
+ promptGuidelines:
409
+ action === "discard"
410
+ ? [
411
+ "After consuming an agent's results, use task_discard if no Follow-up is expected. Keep reusable specialists; do not discard agents whose results are still needed."
412
+ ]
413
+ : [],
414
+ parameters: Type.Object(
415
+ { id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }) },
416
+ { additionalProperties: false }
417
+ ),
418
+ renderCall(args, theme) {
419
+ return new Text(`${theme.fg("toolTitle", theme.bold(`task_${action}`))}${args.id ? ` ${theme.fg("muted", args.id)}` : ""}`, 0, 0)
420
+ },
421
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
422
+ const details = await controlTaskLifecycle(ctx, params.id, action)
423
+ return {
424
+ content: [
425
+ {
426
+ type: "text",
427
+ text:
428
+ action === "discard"
429
+ ? `${details.id}: discarded (files archived at ${details.archiveDirectory})`
430
+ : `${details.id}: stop complete (${details.state}; outcome ${details.latestOutcome ?? "none"}; ${details.queuedFollowUps} queued)`
431
+ }
432
+ ],
433
+ details
434
+ }
435
+ }
436
+ })
437
+ }
438
+ }
439
+
440
+ export async function sendTaskInput(
441
+ ctx: ExtensionContext,
442
+ options: AgentToolOptions,
443
+ id: string,
444
+ content: string,
445
+ requestedDelivery: "followup" | "steer" | undefined = undefined,
446
+ signal?: AbortSignal,
447
+ inputOptions: { eof?: boolean } = {}
448
+ ): Promise<TaskInputResult> {
449
+ if (signal?.aborted) throw abortError(signal)
450
+ if (typeof content !== "string") throw new Error("content must be a string")
451
+ if (inputOptions.eof !== undefined && typeof inputOptions.eof !== "boolean") throw new Error("eof must be boolean")
452
+ if (requestedDelivery !== undefined && requestedDelivery !== "followup" && requestedDelivery !== "steer") {
453
+ throw new Error("delivery must be followup or steer")
454
+ }
455
+ if (!id.startsWith("b_")) validateInput(content, "content", MAX_AGENT_INPUT_BYTES)
456
+ const lease = await acquireParentLease(ctx.cwd, ctx.sessionManager.getSessionId())
457
+ const paths = taskStoragePaths(lease.paths, id)
458
+ const loaded = await readTaskMetadata(paths)
459
+ if (loaded.status === "missing") throw new Error(`Unknown Task Reference: ${id}`)
460
+ if (loaded.status === "invalid") throw new Error(loaded.diagnostic.message)
461
+ if (loaded.metadata.discardedAt !== null) throw new Error(`Task ${id} has been discarded`)
462
+ if (loaded.metadata.kind === "bash") {
463
+ if (requestedDelivery !== undefined) throw new Error("Bash input does not accept delivery; omit it to write stdin")
464
+ if (Buffer.byteLength(content, "utf8") > MAX_AGENT_INPUT_BYTES)
465
+ throw new Error(`content must be at most ${MAX_AGENT_INPUT_BYTES} UTF-8 bytes`)
466
+ if (content.length === 0 && !inputOptions.eof) throw new Error("Empty Bash input requires eof")
467
+ const resident = getAgentCoordinator().getResident(paths.taskDirectory)
468
+ if (loaded.metadata.state !== "running" || !resident?.input)
469
+ throw new Error(`Bash task ${id} requires a live running resident for stdin`)
470
+ const accepted = await resident.input(content, "stdin", { ...inputOptions, ...(signal ? { signal } : {}) })
471
+ const current = await readTaskMetadata(paths)
472
+ if (current.status !== "ok") throw new Error(`Could not read accepted task ${id}`)
473
+ return {
474
+ id,
475
+ requestedDelivery,
476
+ effectiveDelivery: accepted.delivery,
477
+ queuePosition: accepted.queuePosition,
478
+ state: current.metadata.state,
479
+ latestOutcome: current.metadata.latestOutcome,
480
+ queuedFollowUps: accepted.queuedFollowUps
481
+ }
482
+ }
483
+ if (inputOptions.eof !== undefined) throw new Error("eof is only supported for Bash stdin")
484
+ const delivery = requestedDelivery ?? "followup"
485
+ let accepted: ResidentInputResult | undefined
486
+ while (!accepted) {
487
+ if (signal?.aborted) throw abortError(signal)
488
+ const runtime = await controllableRuntime(ctx, paths, options)
489
+ try {
490
+ accepted = await runtime.input(content, delivery, {
491
+ background: options.getConfig().backgroundAgents,
492
+ ...(signal ? { signal } : {})
493
+ })
494
+ } catch (error) {
495
+ if (!isRuntimeClosingError(error)) throw error
496
+ await new Promise(resolve => setTimeout(resolve, 0))
497
+ }
498
+ }
499
+ const current = await readTaskMetadata(paths)
500
+ if (current.status !== "ok") throw new Error(`Could not read accepted task ${id}`)
501
+ return {
502
+ id,
503
+ requestedDelivery: delivery,
504
+ effectiveDelivery: accepted.delivery,
505
+ queuePosition: accepted.queuePosition,
506
+ state: accepted.completed?.state ?? current.metadata.state,
507
+ latestOutcome: accepted.completed?.latestOutcome ?? current.metadata.latestOutcome,
508
+ queuedFollowUps: accepted.completed?.queuedFollowUps.length ?? current.metadata.queuedFollowUps.length,
509
+ ...(accepted.completed ? { output: retainedOutputSnapshot(paths, accepted.completed) } : {})
510
+ }
511
+ }
512
+
513
+ export async function controlTaskLifecycle(ctx: ExtensionContext, id: string, action: "stop" | "discard") {
514
+ const lease = await acquireParentLease(ctx.cwd, ctx.sessionManager.getSessionId())
515
+ const paths = taskStoragePaths(lease.paths, id)
516
+ if (action === "discard") {
517
+ await discardTask(paths)
518
+ const archived = archivedTaskStoragePaths(paths)
519
+ return {
520
+ id,
521
+ action,
522
+ state: "archived",
523
+ latestOutcome: null,
524
+ discarded: true,
525
+ queuedFollowUps: 0,
526
+ archiveDirectory: displayWorkspacePath(ctx.cwd, archived.taskDirectory)
527
+ }
528
+ }
529
+ let loaded = await readTaskMetadata(paths)
530
+ if (loaded.status === "missing") throw new Error(`Unknown Task Reference: ${id}`)
531
+ if (loaded.status === "invalid") throw new Error(loaded.diagnostic.message)
532
+ if (loaded.metadata.discardedAt !== null) throw new Error(`Task ${id} has been discarded`)
533
+ await stopTask(paths)
534
+ if (loaded.metadata.kind === "agent") await stopOwnedTaskTree(ctx.cwd, loaded.metadata.childSessionId)
535
+ loaded = await readTaskMetadata(paths)
536
+ if (loaded.status !== "ok") throw new Error(`Could not read task ${id}`)
537
+ const metadata = loaded.metadata
538
+ return {
539
+ id: metadata.taskRef,
540
+ action,
541
+ state: metadata.state,
542
+ latestOutcome: metadata.latestOutcome,
543
+ discarded: metadata.discardedAt !== null,
544
+ queuedFollowUps: metadata.queuedFollowUps.length,
545
+ archiveDirectory: undefined,
546
+ paths: retainedPaths(paths)
547
+ }
548
+ }
549
+
550
+ class AgentRuntime implements ResidentAgent {
551
+ readonly #paths: TaskStoragePaths
552
+ readonly #child: ChildSessionHandle
553
+ readonly #expandPromptTemplates: boolean
554
+ readonly #tuple: ModelTuple
555
+ readonly #scheduleAbort = new AbortController()
556
+ readonly #initialRunId: string | undefined
557
+ readonly #initialCompletion = deferred<void>()
558
+ readonly #runtimeCompletion = deferred<void>()
559
+ readonly #toolArguments = new Map<string, { name: string; arguments: string }>()
560
+ readonly #pendingSteers: Array<{ content: string }> = []
561
+ readonly #reservations = new Map<string, AgentReservation>()
562
+ readonly #completions = new Map<string, ReturnType<typeof deferred<TaskMetadata | undefined>>>()
563
+ #unbindResident: (() => void) | undefined
564
+ #unbindSuspended: (() => void) | undefined
565
+ #unsubscribe: (() => void) | undefined
566
+ #eventWrites: Promise<void> = Promise.resolve()
567
+ #eventWriteFailed = false
568
+ #recordingRunId: string | undefined
569
+ #pendingProgress: ({ runId: string } & Partial<Pick<TaskMetadata, "latestReply" | "lastActivity">>) | undefined
570
+ #lastHeartbeatAt = 0
571
+ #progressWriteQueued = false
572
+ #started = false
573
+ #stopRequested = false
574
+ #accepting = true
575
+ #disposed = false
576
+ #awaitingPrimaryInput = false
577
+ #lastAssistantOutcome: NonNullable<TaskMetadata["latestOutcome"]> | undefined
578
+ #providerLimitReached = false
579
+ #recoveryWait: (ReturnType<typeof deferred<RecoveryMode>> & { mode: RecoveryMode | undefined }) | undefined
580
+ #recoveryMode: Exclude<RecoveryMode, "stop"> | undefined
581
+
582
+ constructor(paths: TaskStoragePaths, child: ChildSessionHandle, metadata: AgentTaskMetadata, expandPromptTemplates: boolean) {
583
+ this.#paths = paths
584
+ this.#child = child
585
+ this.#initialRunId = metadata.activeRun?.kind === "initial" ? metadata.activeRun.id : undefined
586
+ this.#expandPromptTemplates = expandPromptTemplates
587
+ this.#tuple = { provider: metadata.model.provider, model: metadata.model.id }
588
+ if (metadata.activeRun && !metadata.activeRun.background) this.#completions.set(metadata.activeRun.id, deferred())
589
+ this.#unbindResident = getAgentCoordinator().bindResident(paths.taskDirectory, this)
590
+ this.#unsubscribe = child.session.subscribe(event => this.recordEvent(event))
591
+ }
592
+
593
+ start(): void {
594
+ if (this.#started) return
595
+ this.#started = true
596
+ void this.run().catch(() => {})
597
+ }
598
+
599
+ /** Run-specific synchronous wait; cancellation owns the accepted run and its descendants. */
600
+ async waitForRun(runId: string, signal?: AbortSignal): Promise<TaskMetadata> {
601
+ const completion = this.#completions.get(runId)
602
+ if (!completion) throw new Error(`Missing completion for run ${runId}`)
603
+ return getAgentCoordinator().withLentPermit(async () => {
604
+ const aborted = deferred<void>()
605
+ const onAbort = () => aborted.resolve(undefined)
606
+ signal?.addEventListener("abort", onAbort, { once: true })
607
+ if (signal?.aborted) onAbort()
608
+ try {
609
+ const result = await Promise.race([completion.promise, aborted.promise.then(() => undefined)])
610
+ if (signal?.aborted) {
611
+ await this.stop()
612
+ await stopOwnedTaskTree(this.#paths.workspace, this.#child.session.sessionId)
613
+ throw abortError(signal)
614
+ }
615
+ if (!result) throw new Error(`Agent run ${runId} ended without a retained terminal result`)
616
+ return result
617
+ } finally {
618
+ signal?.removeEventListener("abort", onAbort)
619
+ this.#completions.delete(runId)
620
+ }
621
+ }, signal)
622
+ }
623
+
624
+ async wait(waitMs: number, signal?: AbortSignal): Promise<boolean> {
625
+ if (waitMs === 0) {
626
+ await this.detach()
627
+ return true
628
+ }
629
+ if (signal?.aborted) await this.stop()
630
+ let timer: ReturnType<typeof setTimeout> | undefined
631
+ let onAbort: (() => void) | undefined
632
+ const timeout = new Promise<"timeout">(resolve => {
633
+ timer = setTimeout(() => resolve("timeout"), waitMs)
634
+ timer.unref()
635
+ })
636
+ const aborted = signal
637
+ ? new Promise<"aborted">(resolve => {
638
+ onAbort = () => resolve("aborted")
639
+ signal.addEventListener("abort", onAbort, { once: true })
640
+ })
641
+ : undefined
642
+ const result = await Promise.race([
643
+ this.#initialCompletion.promise.then(() => "completed" as const),
644
+ timeout,
645
+ ...(aborted ? [aborted] : [])
646
+ ])
647
+ if (timer) clearTimeout(timer)
648
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort)
649
+ if (result === "aborted") {
650
+ await this.stop()
651
+ return false
652
+ }
653
+ if (result === "timeout") {
654
+ await this.detach()
655
+ return true
656
+ }
657
+ return false
658
+ }
659
+
660
+ async stop(): Promise<void> {
661
+ if (this.#stopRequested) return this.#runtimeCompletion.promise
662
+ this.#stopRequested = true
663
+ this.#accepting = false
664
+ this.wakeProviderRecovery("stop")
665
+ this.#scheduleAbort.abort(new Error("Agent run stopped"))
666
+ await this.#child.session.abort()
667
+ const loaded = await readTaskMetadata(this.#paths)
668
+ if (loaded.status === "ok" && loaded.metadata.activeRun) {
669
+ await this.settle(loaded.metadata.activeRun, "stopped", true)
670
+ } else if (loaded.status === "ok" && loaded.metadata.queuedFollowUps.length > 0) {
671
+ await mutateTaskMetadata(this.#paths, metadata => ({
672
+ ...metadata,
673
+ queuedFollowUps: [],
674
+ updatedAt: Date.now()
675
+ }))
676
+ }
677
+ if (!this.#started) {
678
+ this.dispose()
679
+ this.#runtimeCompletion.resolve(undefined)
680
+ this.#initialCompletion.resolve(undefined)
681
+ }
682
+ await this.#runtimeCompletion.promise
683
+ }
684
+
685
+ dispose(): void {
686
+ if (this.#disposed) return
687
+ this.#disposed = true
688
+ this.#unsubscribe?.()
689
+ this.#unsubscribe = undefined
690
+ this.#unbindResident?.()
691
+ this.#unbindResident = undefined
692
+ this.#unbindSuspended?.()
693
+ this.#unbindSuspended = undefined
694
+ this.#child.dispose()
695
+ }
696
+
697
+ async input(content: string, delivery: "followup" | "steer" | "stdin", options: ResidentInputOptions = {}): Promise<ResidentInputResult> {
698
+ if (delivery === "stdin" || options.eof !== undefined) throw new Error("Agent tasks do not accept stdin or eof")
699
+ if (!this.#accepting || this.#disposed) throw new RuntimeClosingError()
700
+ let result: ResidentInputResult | undefined
701
+ let acceptedRun: NonNullable<TaskMetadata["activeRun"]> | undefined
702
+ try {
703
+ await mutateTaskMetadata(this.#paths, async metadata => {
704
+ if (options.signal?.aborted) throw abortError(options.signal)
705
+ if (!this.#accepting || this.#disposed) throw new RuntimeClosingError()
706
+ if (metadata.discardedAt !== null) throw new Error(`Task ${metadata.taskRef} has been discarded`)
707
+ if (delivery === "steer" && metadata.state === "running" && metadata.activeRun && this.#child.session.isStreaming) {
708
+ const pending = { content }
709
+ this.#pendingSteers.push(pending)
710
+ try {
711
+ // prompt() can yield in input hooks and start a new run after streaming ends.
712
+ if (this.#expandPromptTemplates) await this.#child.session.steer(content)
713
+ else this.#child.session.agent.steer({ role: "user", content, timestamp: Date.now() })
714
+ } catch (error) {
715
+ const index = this.#pendingSteers.indexOf(pending)
716
+ if (index >= 0) this.#pendingSteers.splice(index, 1)
717
+ throw error
718
+ }
719
+ result = { delivery: "steer", queuePosition: null, queuedFollowUps: metadata.queuedFollowUps.length }
720
+ return metadata
721
+ }
722
+ if (
723
+ (!options.background && (metadata.activeRun || metadata.queuedFollowUps.length)) ||
724
+ (metadata.activeRun && !metadata.activeRun.background)
725
+ ) {
726
+ throw new Error(`Task ${metadata.taskRef} is busy; foreground Follow-ups require an idle task`)
727
+ }
728
+ if (metadata.queuedFollowUps.length >= MAX_QUEUED_FOLLOWUPS) {
729
+ throw new Error(`Task ${metadata.taskRef} already has ${MAX_QUEUED_FOLLOWUPS} queued Follow-ups`)
730
+ }
731
+ const acceptedAt = Date.now()
732
+ const sequence = metadata.lastRunSequence + 1
733
+ const runId = createRunId()
734
+ const acceptanceOrder = getAgentCoordinator().nextAcceptanceOrder()
735
+ const queuePosition = metadata.activeRun ? metadata.queuedFollowUps.length + 1 : 1
736
+ result = { delivery: "followup", queuePosition, queuedFollowUps: metadata.queuedFollowUps.length + 1 }
737
+ acceptedRun = {
738
+ id: runId,
739
+ sequence,
740
+ acceptanceOrder,
741
+ background: options.background ?? false,
742
+ kind: "followup",
743
+ state: "queued",
744
+ input: content,
745
+ acceptedAt
746
+ }
747
+ if (!metadata.activeRun) {
748
+ result.queuedFollowUps = 0
749
+ return {
750
+ ...metadata,
751
+ state: "queued",
752
+ activeRun: acceptedRun,
753
+ lastRunSequence: sequence,
754
+ updatedAt: acceptedAt
755
+ }
756
+ }
757
+ return {
758
+ ...metadata,
759
+ lastRunSequence: sequence,
760
+ queuedFollowUps: [
761
+ ...metadata.queuedFollowUps,
762
+ { id: runId, sequence, acceptanceOrder, background: options.background ?? false, content, acceptedAt }
763
+ ],
764
+ updatedAt: acceptedAt
765
+ }
766
+ })
767
+ } catch (error) {
768
+ if (!this.#started) {
769
+ this.#accepting = false
770
+ this.dispose()
771
+ this.#runtimeCompletion.resolve(undefined)
772
+ this.#initialCompletion.resolve(undefined)
773
+ }
774
+ throw error
775
+ }
776
+ if (!result) throw new Error("Task input was not accepted")
777
+ if (acceptedRun) {
778
+ if (!acceptedRun.background) this.#completions.set(acceptedRun.id, deferred())
779
+ // Inactive background reservations preserve acceptance order across promotion.
780
+ if (acceptedRun.background) this.reserveRun(acceptedRun)
781
+ }
782
+ this.start()
783
+ if (acceptedRun && !acceptedRun.background) result.completed = await this.waitForRun(acceptedRun.id, options.signal)
784
+ return result
785
+ }
786
+
787
+ recover(): boolean {
788
+ return this.wakeProviderRecovery("manual")
789
+ }
790
+
791
+ wakeProviderRecovery(mode: RecoveryMode): boolean {
792
+ const wait = this.#recoveryWait
793
+ if (!wait || wait.mode || this.#disposed || (this.#stopRequested && mode !== "stop")) return false
794
+ wait.mode = mode
795
+ this.#unbindSuspended?.()
796
+ this.#unbindSuspended = undefined
797
+ wait.resolve(mode)
798
+ return true
799
+ }
800
+
801
+ private async run(): Promise<void> {
802
+ try {
803
+ while (!this.#stopRequested) {
804
+ const activeRun = await this.nextRun()
805
+ if (!activeRun) break
806
+ if (await this.executeRun(activeRun)) {
807
+ const wait = this.#recoveryWait
808
+ if (!wait) throw new Error("Suspended run has no recovery wait")
809
+ const mode = await wait.promise
810
+ if (this.#recoveryWait === wait) this.#recoveryWait = undefined
811
+ if (mode === "stop") break
812
+ this.#recoveryMode = mode
813
+ }
814
+ }
815
+ } finally {
816
+ this.dispose()
817
+ for (const completion of this.#completions.values()) completion.resolve(undefined)
818
+ this.#initialCompletion.resolve(undefined)
819
+ this.#runtimeCompletion.resolve(undefined)
820
+ }
821
+ }
822
+
823
+ private async nextRun(): Promise<NonNullable<TaskMetadata["activeRun"]> | null> {
824
+ const selected: { run: NonNullable<TaskMetadata["activeRun"]> | null } = { run: null }
825
+ await mutateTaskMetadata(this.#paths, metadata => {
826
+ if (metadata.activeRun) {
827
+ selected.run = metadata.activeRun
828
+ return metadata
829
+ }
830
+ const [next, ...remaining] = metadata.queuedFollowUps
831
+ if (!next) {
832
+ this.#accepting = false
833
+ return metadata
834
+ }
835
+ selected.run = {
836
+ id: next.id,
837
+ sequence: next.sequence,
838
+ ...(next.acceptanceOrder ? { acceptanceOrder: next.acceptanceOrder } : {}),
839
+ background: next.background ?? false,
840
+ kind: "followup",
841
+ state: "queued",
842
+ input: next.content,
843
+ acceptedAt: next.acceptedAt
844
+ }
845
+ return { ...metadata, state: "queued", activeRun: selected.run, queuedFollowUps: remaining, updatedAt: Date.now() }
846
+ })
847
+ return selected.run
848
+ }
849
+
850
+ private async executeRun(run: NonNullable<TaskMetadata["activeRun"]>): Promise<boolean> {
851
+ this.#recordingRunId = run.id
852
+ this.#lastHeartbeatAt = 0
853
+ let outcome: NonNullable<TaskMetadata["latestOutcome"]> = "failed"
854
+ const recovering = run.state === "suspended"
855
+ this.#lastAssistantOutcome = undefined
856
+ this.#providerLimitReached = false
857
+ let settled = false
858
+ let suspended = false
859
+ try {
860
+ await this.reserveRun(run).run(async () => {
861
+ if (this.#stopRequested) return
862
+ let promptCompleted = false
863
+ try {
864
+ let won = false
865
+ const startedAt = Date.now()
866
+ await mutateTaskMetadata(this.#paths, metadata => {
867
+ const activeRun = metadata.activeRun
868
+ if (!activeRun || activeRun.id !== run.id) return metadata
869
+ won = true
870
+ return {
871
+ ...metadata,
872
+ state: "running",
873
+ activeRun: { ...activeRun, state: "running", startedAt },
874
+ lastActivity: { at: startedAt, action: "started" },
875
+ updatedAt: startedAt
876
+ }
877
+ })
878
+ if (!won) return
879
+ this.#recoveryMode = undefined
880
+ if (run.kind === "followup" && !recovering) {
881
+ await appendHistoryLog(this.#paths, {
882
+ type: "run-start",
883
+ sequence: run.sequence,
884
+ kind: "followup",
885
+ timestamp: startedAt
886
+ })
887
+ await appendHistoryLog(this.#paths, {
888
+ type: "input",
889
+ delivery: "followup",
890
+ timestamp: startedAt,
891
+ content: run.input
892
+ })
893
+ }
894
+ this.#awaitingPrimaryInput = true
895
+ await this.#child.session.prompt(
896
+ recovering ? "Continue." : run.input,
897
+ childPromptOptions(recovering ? false : this.#expandPromptTemplates)
898
+ )
899
+ promptCompleted = true
900
+ await this.#eventWrites
901
+ if (this.#eventWriteFailed) throw new Error("Could not retain one or more child session events")
902
+ outcome = this.#stopRequested ? "stopped" : (this.#lastAssistantOutcome ?? "failed")
903
+ } catch {
904
+ if (!promptCompleted) this.#providerLimitReached = false
905
+ await this.#eventWrites
906
+ outcome = this.#stopRequested ? "stopped" : "failed"
907
+ }
908
+ if (!this.#stopRequested && this.#providerLimitReached) {
909
+ suspended = await this.suspend(run)
910
+ if (suspended) return
911
+ }
912
+ const promoted = await this.settle(run, outcome, this.#stopRequested)
913
+ settled = true
914
+ if (promoted) this.reserveRun(promoted).activate()
915
+ })
916
+ } catch (error) {
917
+ await this.#eventWrites
918
+ outcome = this.#stopRequested ? "stopped" : "failed"
919
+ if (!run.background && !this.#stopRequested) {
920
+ await mutateTaskMetadata(this.#paths, metadata =>
921
+ metadata.activeRun?.id === run.id
922
+ ? {
923
+ ...metadata,
924
+ latestReply: { text: error instanceof Error ? error.message : String(error), streaming: false },
925
+ updatedAt: Date.now()
926
+ }
927
+ : metadata
928
+ )
929
+ }
930
+ }
931
+ this.#child.session.clearQueue()
932
+ this.#pendingSteers.length = 0
933
+ this.#reservations.delete(run.id)
934
+ if (!settled && !suspended) {
935
+ const promoted = await this.settle(run, outcome, this.#stopRequested)
936
+ if (promoted) this.reserveRun(promoted).activate()
937
+ }
938
+ return suspended
939
+ }
940
+
941
+ private async suspend(run: NonNullable<TaskMetadata["activeRun"]>): Promise<boolean> {
942
+ // Provider evidence is tuple-global even if another task mutation wins.
943
+ getAgentCoordinator().closeTuple(this.#tuple)
944
+ if (!run.background) {
945
+ await writeTaskProgress(this.#paths, run.id, {
946
+ latestReply: { text: "Provider limit reached; foreground run failed and will not restart automatically.", streaming: false }
947
+ })
948
+ return false
949
+ }
950
+ const wait = Object.assign(deferred<RecoveryMode>(), { mode: undefined as RecoveryMode | undefined })
951
+ const unbind = bindSuspendedRuntime(this.#tuple, this)
952
+ this.#recoveryWait = wait
953
+ this.#unbindSuspended = unbind
954
+ let won = false
955
+ let notification: TaskMetadata["notifications"][number] | undefined
956
+ const timestamp = Date.now()
957
+ await mutateTaskMetadata(this.#paths, async metadata => {
958
+ const activeRun = metadata.activeRun
959
+ if (!activeRun || activeRun.id !== run.id) return metadata
960
+ won = true
961
+ if (run.kind === "followup" || activeRun.detachedAt !== undefined) {
962
+ notification = await prepareTaskNotification(this.#paths, metadata, activeRun, "suspension")
963
+ }
964
+ return {
965
+ ...metadata,
966
+ state: "suspended",
967
+ activeRun: { ...activeRun, state: "suspended" },
968
+ ...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
969
+ updatedAt: timestamp
970
+ }
971
+ })
972
+ if (won) {
973
+ if (run.id === this.#initialRunId) this.#initialCompletion.resolve(undefined)
974
+ if (notification) await deliverTaskNotifications(this.#paths).catch(() => {})
975
+ } else {
976
+ unbind()
977
+ if (this.#recoveryWait === wait) this.#recoveryWait = undefined
978
+ if (this.#unbindSuspended === unbind) this.#unbindSuspended = undefined
979
+ }
980
+ return won
981
+ }
982
+
983
+ private async detach(): Promise<void> {
984
+ const detachedAt = Date.now()
985
+ await mutateTaskMetadata(this.#paths, metadata => {
986
+ const activeRun = metadata.activeRun
987
+ if (!activeRun?.background || activeRun.id !== this.#initialRunId || activeRun.detachedAt !== undefined) return metadata
988
+ return { ...metadata, activeRun: { ...activeRun, detachedAt }, updatedAt: detachedAt }
989
+ })
990
+ }
991
+
992
+ private async settle(
993
+ run: NonNullable<TaskMetadata["activeRun"]>,
994
+ outcome: NonNullable<TaskMetadata["latestOutcome"]>,
995
+ clearFollowUps = false
996
+ ): Promise<NonNullable<TaskMetadata["activeRun"]> | null> {
997
+ let won = false
998
+ const promoted: { run: NonNullable<TaskMetadata["activeRun"]> | null } = { run: null }
999
+ let notification: TaskMetadata["notifications"][number] | undefined
1000
+ let completed: TaskMetadata | undefined
1001
+ const timestamp = Date.now()
1002
+ await mutateTaskMetadata(this.#paths, async metadata => {
1003
+ const activeRun = metadata.activeRun
1004
+ if (activeRun?.id !== run.id) return clearFollowUps ? { ...metadata, queuedFollowUps: [], updatedAt: timestamp } : metadata
1005
+ won = true
1006
+ completed = { ...metadata, state: "idle", latestOutcome: outcome, activeRun: null, queuedFollowUps: [], updatedAt: timestamp }
1007
+ if (
1008
+ activeRun.background &&
1009
+ (outcome === "succeeded" || outcome === "failed") &&
1010
+ (run.kind === "followup" || activeRun.detachedAt !== undefined)
1011
+ ) {
1012
+ notification = await prepareTaskNotification(this.#paths, metadata, activeRun, "completion", outcome)
1013
+ }
1014
+ const [next, ...remaining] = clearFollowUps ? [] : metadata.queuedFollowUps
1015
+ if (next) {
1016
+ promoted.run = {
1017
+ id: next.id,
1018
+ sequence: next.sequence,
1019
+ ...(next.acceptanceOrder ? { acceptanceOrder: next.acceptanceOrder } : {}),
1020
+ background: next.background ?? false,
1021
+ kind: "followup",
1022
+ state: "queued",
1023
+ input: next.content,
1024
+ acceptedAt: next.acceptedAt
1025
+ }
1026
+ return {
1027
+ ...metadata,
1028
+ state: "queued",
1029
+ latestOutcome: outcome,
1030
+ activeRun: promoted.run,
1031
+ queuedFollowUps: remaining,
1032
+ ...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
1033
+ updatedAt: timestamp
1034
+ }
1035
+ }
1036
+ return {
1037
+ ...metadata,
1038
+ state: "idle",
1039
+ latestOutcome: outcome,
1040
+ activeRun: null,
1041
+ ...(clearFollowUps ? { queuedFollowUps: [] } : {}),
1042
+ ...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
1043
+ updatedAt: timestamp
1044
+ }
1045
+ })
1046
+ if (won) await appendHistoryLog(this.#paths, { type: "run-end", sequence: run.sequence, outcome, timestamp })
1047
+ if (won && notification) await deliverTaskNotifications(this.#paths).catch(() => {})
1048
+ if (won) this.#completions.get(run.id)?.resolve(completed)
1049
+ if (run.id === this.#initialRunId) this.#initialCompletion.resolve(undefined)
1050
+ return promoted.run
1051
+ }
1052
+
1053
+ private reserveRun(run: NonNullable<TaskMetadata["activeRun"]>): AgentReservation {
1054
+ const existing = this.#reservations.get(run.id)
1055
+ if (existing) return existing
1056
+ const tuple =
1057
+ run.state === "suspended" && this.#recoveryMode === "manual"
1058
+ ? { provider: `${this.#tuple.provider}#manual#${this.#paths.taskRef}`, model: this.#tuple.model }
1059
+ : this.#tuple
1060
+ const reservation = getAgentCoordinator().reserve({
1061
+ tuple,
1062
+ signal: this.#scheduleAbort.signal,
1063
+ rejectOnClosedTuple: !run.background,
1064
+ ...(run.acceptanceOrder ? { acceptanceOrder: run.acceptanceOrder } : {})
1065
+ })
1066
+ this.#reservations.set(run.id, reservation)
1067
+ return reservation
1068
+ }
1069
+
1070
+ private recordEvent(event: AgentSessionEvent): void {
1071
+ const at = Date.now()
1072
+ const runId = this.#recordingRunId
1073
+ if (runId && event.type === "agent_start") {
1074
+ // before_agent_start hooks have finished; capture now, not when the queued write runs.
1075
+ const effectiveSystemPrompt = this.#child.session.systemPrompt
1076
+ this.queueEventWrite(() => writeTaskProgress(this.#paths, runId, { effectiveSystemPrompt }))
1077
+ }
1078
+ let action: string | undefined
1079
+ let latestReply: TaskMetadata["latestReply"] | undefined
1080
+ if (
1081
+ runId &&
1082
+ ((event.type === "message_update" && event.assistantMessageEvent.type === "thinking_delta") ||
1083
+ event.type === "tool_execution_update") &&
1084
+ at - this.#lastHeartbeatAt >= 1_000
1085
+ ) {
1086
+ // Non-reply heartbeats are event-driven and capped at one durable write per second.
1087
+ this.#lastHeartbeatAt = at
1088
+ action = event.type === "tool_execution_update" ? `tool ${event.toolName.slice(0, 160)}` : "thinking"
1089
+ }
1090
+ if (runId && (event.type === "tool_execution_start" || event.type === "tool_execution_end")) {
1091
+ action = `tool ${event.toolName.slice(0, 160)}${event.type === "tool_execution_end" ? (event.isError ? " failed" : " complete") : ""}`
1092
+ }
1093
+ if (
1094
+ (event.type === "message_start" || event.type === "message_update" || event.type === "message_end") &&
1095
+ event.message.role === "assistant" &&
1096
+ (event.type !== "message_update" || event.assistantMessageEvent.type === "text_delta")
1097
+ ) {
1098
+ const text = event.message.content
1099
+ .filter(part => part.type === "text")
1100
+ .map(part => part.text)
1101
+ .join("")
1102
+ latestReply = { text, streaming: event.type !== "message_end" }
1103
+ action = latestReply.streaming ? "responding" : "reply complete"
1104
+ }
1105
+ if (runId && action) {
1106
+ this.#pendingProgress = {
1107
+ ...this.#pendingProgress,
1108
+ runId,
1109
+ ...(latestReply ? { latestReply } : {}),
1110
+ lastActivity: { at, action }
1111
+ }
1112
+ if (!this.#progressWriteQueued) {
1113
+ this.#progressWriteQueued = true
1114
+ this.queueEventWrite(async () => {
1115
+ try {
1116
+ // Coalesce replies and activity together so bursts cannot reorder their last action.
1117
+ while (this.#pendingProgress) {
1118
+ const { runId, ...progress } = this.#pendingProgress
1119
+ this.#pendingProgress = undefined
1120
+ await writeTaskProgress(this.#paths, runId, progress)
1121
+ }
1122
+ } finally {
1123
+ this.#progressWriteQueued = false
1124
+ }
1125
+ })
1126
+ }
1127
+ }
1128
+ if (event.type === "tool_execution_start") {
1129
+ this.#toolArguments.set(event.toolCallId, { name: event.toolName, arguments: renderUnknown(event.args) })
1130
+ return
1131
+ }
1132
+ if (event.type === "message_end" && event.message.role === "assistant") {
1133
+ this.#lastAssistantOutcome = event.message.stopReason === "error" || event.message.stopReason === "aborted" ? "failed" : "succeeded"
1134
+ this.#providerLimitReached = event.message.stopReason === "error" && isProviderLimitError(event.message.errorMessage)
1135
+ const content = event.message.content
1136
+ .filter(part => part.type === "text")
1137
+ .map(part => part.text)
1138
+ .join("")
1139
+ if (content) this.queueEventWrite(() => appendHistoryLog(this.#paths, { type: "assistant", content }))
1140
+ return
1141
+ }
1142
+ if (event.type === "message_start" && event.message.role === "user" && this.#awaitingPrimaryInput) {
1143
+ this.#awaitingPrimaryInput = false
1144
+ return
1145
+ }
1146
+ if (event.type === "message_start" && event.message.role === "user" && this.#pendingSteers.length > 0) {
1147
+ const delivered = this.#pendingSteers.shift()
1148
+ if (delivered) {
1149
+ const content =
1150
+ typeof event.message.content === "string"
1151
+ ? event.message.content
1152
+ : event.message.content
1153
+ .filter(part => part.type === "text")
1154
+ .map(part => part.text)
1155
+ .join("")
1156
+ this.queueEventWrite(() =>
1157
+ appendHistoryLog(this.#paths, {
1158
+ type: "input",
1159
+ delivery: "steer",
1160
+ timestamp: Date.now(),
1161
+ content: content || delivered.content
1162
+ })
1163
+ )
1164
+ }
1165
+ return
1166
+ }
1167
+ if (event.type === "tool_execution_end") {
1168
+ const started = this.#toolArguments.get(event.toolCallId)
1169
+ this.#toolArguments.delete(event.toolCallId)
1170
+ this.queueEventWrite(() =>
1171
+ appendHistoryLog(this.#paths, {
1172
+ type: "tool",
1173
+ tool: started?.name ?? event.toolName,
1174
+ arguments: started?.arguments ?? "(unavailable)",
1175
+ result: renderUnknown(event.result),
1176
+ isError: event.isError
1177
+ })
1178
+ )
1179
+ }
1180
+ }
1181
+
1182
+ private queueEventWrite(write: () => Promise<void>): void {
1183
+ this.#eventWrites = this.#eventWrites.then(write).catch(() => {
1184
+ this.#eventWriteFailed = true
1185
+ })
1186
+ }
1187
+ }
1188
+
1189
+ class RuntimeClosingError extends Error {
1190
+ constructor() {
1191
+ super("Agent runtime is closing")
1192
+ this.name = "LovelyAgentRuntimeClosingError"
1193
+ }
1194
+ }
1195
+
1196
+ function isRuntimeClosingError(error: unknown): boolean {
1197
+ return error instanceof RuntimeClosingError || (error instanceof Error && error.name === "LovelyAgentRuntimeClosingError")
1198
+ }
1199
+
1200
+ type ControllableResident = ResidentAgent & {
1201
+ input(content: string, delivery: "followup" | "steer", options?: ResidentInputOptions): Promise<ResidentInputResult>
1202
+ }
1203
+
1204
+ const COLD_RUNTIME_LOADS = Symbol.for("@xl0/pi-lovely-agents/cold-runtime-loads/v1")
1205
+
1206
+ async function controllableRuntime(
1207
+ ctx: ExtensionContext,
1208
+ paths: TaskStoragePaths,
1209
+ options: AgentToolOptions
1210
+ ): Promise<ControllableResident> {
1211
+ const resident = getAgentCoordinator().getResident(paths.taskDirectory)
1212
+ if (resident) {
1213
+ if (!resident.input) throw new Error(`Task ${paths.taskRef} does not accept input`)
1214
+ return resident as ControllableResident
1215
+ }
1216
+ const loads = coldRuntimeLoads()
1217
+ let pending = loads.get(paths.taskDirectory)
1218
+ if (pending) return pending
1219
+ pending = openColdRuntime(ctx, paths, options)
1220
+ loads.set(paths.taskDirectory, pending)
1221
+ try {
1222
+ return await pending
1223
+ } finally {
1224
+ if (loads.get(paths.taskDirectory) === pending) loads.delete(paths.taskDirectory)
1225
+ }
1226
+ }
1227
+
1228
+ async function openColdRuntime(ctx: ExtensionContext, paths: TaskStoragePaths, options: AgentToolOptions): Promise<AgentRuntime> {
1229
+ const loaded = await readTaskMetadata(paths)
1230
+ if (loaded.status !== "ok") throw new Error(`Could not load task ${paths.taskRef}`)
1231
+ const metadata = loaded.metadata
1232
+ if (metadata.kind !== "agent") throw new Error("Bash tasks cannot be cold reopened")
1233
+ if (metadata.discardedAt !== null) throw new Error(`Task ${paths.taskRef} has been discarded`)
1234
+ if (metadata.state !== "idle" && metadata.state !== "interrupted") {
1235
+ throw new Error(`Task ${paths.taskRef} has active state ${metadata.state} but no resident runtime`)
1236
+ }
1237
+ const definition: AgentDefinition = {
1238
+ name: metadata.definitionName,
1239
+ description: `Retained configuration for ${metadata.definitionName}`,
1240
+ systemPrompt: metadata.sessionConfig.systemPrompt,
1241
+ source: "project",
1242
+ filePath: paths.metadata,
1243
+ displayPath: paths.metadata,
1244
+ ...(metadata.sessionConfig.tools ? { tools: [...metadata.sessionConfig.tools] } : {}),
1245
+ excludeAgentsMd: metadata.sessionConfig.excludeAgentsMd
1246
+ }
1247
+ const model = ctx.modelRegistry
1248
+ .getAvailable()
1249
+ .find(candidate => candidate.provider === metadata.model.provider && candidate.id === metadata.model.id)
1250
+ if (!model) throw new Error(`Task model "${metadata.model.provider}/${metadata.model.id}" is not authenticated`)
1251
+ const config = options.getConfig()
1252
+ const scopedModels: ScopedModel[] = metadata.sessionConfig.scopedModels.map(saved => {
1253
+ const savedModel = ctx.modelRegistry.getAll().find(candidate => candidate.provider === saved.provider && candidate.id === saved.id)
1254
+ if (!savedModel) throw new Error(`Retained scoped model "${saved.provider}/${saved.id}" is no longer available`)
1255
+ return { model: savedModel, ...(saved.thinkingLevel ? { thinkingLevel: saved.thinkingLevel } : {}) }
1256
+ })
1257
+ const child = await (options.createChild ?? createChildSession)({
1258
+ cwd: ctx.cwd,
1259
+ paths,
1260
+ definition,
1261
+ selection: { model, thinking: metadata.thinking },
1262
+ scopedModels,
1263
+ parentDepth: metadata.depth - 1,
1264
+ maximumDepth: metadata.depth + (metadata.allowAgents ? 1 : 0),
1265
+ allowAgents: metadata.allowAgents,
1266
+ projectTrusted: ctx.isProjectTrusted(),
1267
+ expectedSessionId: metadata.childSessionId,
1268
+ ...(options.getAgentDir ? { agentDir: options.getAgentDir() } : {})
1269
+ })
1270
+ return new AgentRuntime(paths, child, metadata, config.expandPromptTemplates)
1271
+ }
1272
+
1273
+ function coldRuntimeLoads(): Map<string, Promise<AgentRuntime>> {
1274
+ const global = globalThis as typeof globalThis & { [COLD_RUNTIME_LOADS]?: Map<string, Promise<AgentRuntime>> }
1275
+ global[COLD_RUNTIME_LOADS] ??= new Map()
1276
+ return global[COLD_RUNTIME_LOADS]
1277
+ }
1278
+
1279
+ function buildAgentCreationToolResult(
1280
+ metadata: TaskMetadata,
1281
+ detached: boolean,
1282
+ output: Awaited<ReturnType<typeof readRetainedOutput>>,
1283
+ tasks: Awaited<ReturnType<typeof loadTaskList>>["details"]
1284
+ ): { content: [{ type: "text"; text: string }]; details: AgentCreationResult } {
1285
+ if (metadata.kind !== "agent") throw new Error("Expected an Agent task")
1286
+ const result: AgentCreationResult = {
1287
+ id: metadata.taskRef,
1288
+ label: metadata.label,
1289
+ definition: metadata.definitionName,
1290
+ state: metadata.state,
1291
+ latestOutcome: metadata.latestOutcome,
1292
+ model: `${metadata.model.provider}/${metadata.model.id}`,
1293
+ thinking: metadata.thinking,
1294
+ depth: metadata.depth,
1295
+ allowAgents: metadata.allowAgents,
1296
+ detached,
1297
+ queuedFollowUps: metadata.queuedFollowUps.length,
1298
+ output,
1299
+ tasks
1300
+ }
1301
+ const pending = detached && (result.state === "queued" || result.state === "running" || result.state === "suspended")
1302
+ return {
1303
+ content: [
1304
+ {
1305
+ type: "text",
1306
+ text: [
1307
+ `task: ${result.id}`,
1308
+ `state: ${result.state}`,
1309
+ ...(output.queueReason
1310
+ ? [`waiting: ${output.queueReason} (${output.capacity.active}/${output.capacity.limit} execution permits)`]
1311
+ : []),
1312
+ `outcome: ${result.latestOutcome ?? "none"}`,
1313
+ `detached: ${result.detached}`,
1314
+ `queued_followups: ${result.queuedFollowUps}`,
1315
+ "output:",
1316
+ pending ? "(pending; use task_output)" : result.output.text || "(no output)"
1317
+ ].join("\n")
1318
+ }
1319
+ ],
1320
+ details: result
1321
+ }
1322
+ }
1323
+
1324
+ function validateInput(value: string, name: string, maximumBytes: number): void {
1325
+ if (!value.trim()) throw new Error(`${name} must be nonblank`)
1326
+ if (Buffer.byteLength(value, "utf8") > maximumBytes) throw new Error(`${name} must be at most ${maximumBytes} UTF-8 bytes`)
1327
+ }
1328
+
1329
+ function createRunId(): string {
1330
+ return `r_${randomBytes(8).toString("hex")}`
1331
+ }
1332
+
1333
+ function renderUnknown(value: unknown): string {
1334
+ if (typeof value === "string") return value
1335
+ try {
1336
+ return JSON.stringify(value) ?? String(value)
1337
+ } catch {
1338
+ return String(value)
1339
+ }
1340
+ }
1341
+
1342
+ function suspendedRuntimes(): Map<string, Set<SuspendedRuntime>> {
1343
+ const global = globalThis as typeof globalThis & { [SUSPENDED_RUNTIMES_SYMBOL]?: Map<string, Set<SuspendedRuntime>> }
1344
+ global[SUSPENDED_RUNTIMES_SYMBOL] ??= new Map()
1345
+ return global[SUSPENDED_RUNTIMES_SYMBOL]
1346
+ }
1347
+
1348
+ function bindSuspendedRuntime(tuple: ModelTuple, runtime: SuspendedRuntime): () => void {
1349
+ const registry = suspendedRuntimes()
1350
+ const key = modelTupleKey(tuple)
1351
+ const runtimes = registry.get(key) ?? new Set()
1352
+ runtimes.add(runtime)
1353
+ registry.set(key, runtimes)
1354
+ return () => {
1355
+ runtimes.delete(runtime)
1356
+ if (runtimes.size === 0 && registry.get(key) === runtimes) registry.delete(key)
1357
+ }
1358
+ }
1359
+
1360
+ function modelTupleKey(tuple: ModelTuple): string {
1361
+ return `${tuple.provider}\0${tuple.model}`
1362
+ }
1363
+
1364
+ function abortError(signal: AbortSignal): Error {
1365
+ return signal.reason instanceof Error ? signal.reason : new Error("Agent creation aborted")
1366
+ }
1367
+
1368
+ function deferred<T>(): { promise: Promise<T>; resolve(value: T | PromiseLike<T>): void } {
1369
+ let resolve!: (value: T | PromiseLike<T>) => void
1370
+ const promise = new Promise<T>(resolvePromise => {
1371
+ resolve = resolvePromise
1372
+ })
1373
+ return { promise, resolve }
1374
+ }