@xl0/pi-lovely-agents 0.1.2 → 0.1.3

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.
@@ -12,18 +12,10 @@ import {
12
12
  } from "./child-session.js"
13
13
  import type { AgentsConfig } from "./config.js"
14
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"
15
+ import { getAgentCoordinator, type ResidentAgent, type ResidentInputOptions, type ResidentInputResult } from "./coordinator.js"
16
+ import { type AgentDefinition, discoverAgentDefinitions, projectResourcesTrusted } from "./definitions.js"
24
17
  import { discardTask, stopOwnedTaskTree, stopTask } from "./lifecycle.js"
25
18
  import { appendTaskNotification, deliverTaskNotifications, prepareTaskNotification } from "./notifications.js"
26
- import { isProviderLimitError } from "./provider-limits.js"
27
19
  import { renderExpandableResult } from "./rendering.js"
28
20
  import {
29
21
  type AgentTaskMetadata,
@@ -36,10 +28,10 @@ import {
36
28
  MAX_AGENT_LABEL_BYTES,
37
29
  MAX_QUEUED_FOLLOWUPS,
38
30
  mutateTaskMetadata,
31
+ RETAINED_OUTPUT_MAX_WAIT_MS,
39
32
  readRetainedOutput,
40
33
  readTaskMetadata,
41
34
  reserveTaskStorage,
42
- retainedOutputSnapshot,
43
35
  retainedPaths,
44
36
  TASK_METADATA_VERSION,
45
37
  TASK_REFERENCE_PATTERN,
@@ -49,7 +41,7 @@ import {
49
41
  writeTaskMetadata,
50
42
  writeTaskProgress
51
43
  } from "./state.js"
52
- import { loadTaskList } from "./tools.js"
44
+ import { deferred } from "./utils.js"
53
45
 
54
46
  const ThinkingLevel = Type.Union([
55
47
  Type.Literal("off"),
@@ -61,24 +53,6 @@ const ThinkingLevel = Type.Union([
61
53
  Type.Literal("max")
62
54
  ])
63
55
 
64
- type RecoveryMode = "automatic" | "manual" | "stop"
65
- type SuspendedRuntime = {
66
- wakeProviderRecovery(mode: RecoveryMode): boolean
67
- }
68
- const SUSPENDED_RUNTIMES_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/suspended-runtimes/v1")
69
-
70
- /** Opens one tuple and wakes every process-resident run suspended on it. */
71
- export function recoverProviderTuple(tuple: ModelTuple): number {
72
- getAgentCoordinator().openTuple(tuple)
73
- const runtimes = suspendedRuntimes().get(modelTupleKey(tuple))
74
- if (!runtimes) return 0
75
- let recovered = 0
76
- for (const runtime of [...runtimes]) {
77
- if (runtime.wakeProviderRecovery("automatic")) recovered++
78
- }
79
- return recovered
80
- }
81
-
82
56
  export type AgentCreationResult = {
83
57
  id: string
84
58
  run: number
@@ -93,7 +67,6 @@ export type AgentCreationResult = {
93
67
  detached: boolean
94
68
  queuedFollowUps: number
95
69
  output: Awaited<ReturnType<typeof readRetainedOutput>>
96
- tasks: Awaited<ReturnType<typeof loadTaskList>>["details"]
97
70
  }
98
71
 
99
72
  export type TaskInputResult = {
@@ -106,27 +79,21 @@ export type TaskInputResult = {
106
79
  state: TaskMetadata["state"]
107
80
  latestOutcome: TaskMetadata["latestOutcome"]
108
81
  queuedFollowUps: number
109
- output?: Awaited<ReturnType<typeof readRetainedOutput>>
110
82
  }
111
83
 
112
84
  export type AgentToolOptions = {
113
85
  getConfig: () => AgentsConfig
114
86
  createChild?: typeof createChildSession
115
87
  getAgentDir?: () => string
116
- canDelegate?: () => boolean
117
- /** Input-schema visibility from allowed producers plus retained owned kinds; not execution gates. */
118
- agentInputEnabled?: () => boolean
119
- bashInputEnabled?: () => boolean
120
88
  }
121
89
 
90
+ /** Schemas and descriptions are static; config and depth limits are enforced at execution. */
122
91
  export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions): void {
123
- const background = options.getConfig().backgroundAgents
124
92
  pi.registerTool({
125
93
  name: "agent",
126
94
  label: "Agent",
127
- description: background
128
- ? "Create a durable Lovely Agent session and start its initial run. waitMs permits background detachment."
129
- : "Create a durable Lovely Agent session and wait for its terminal result. Cancellation stops the accepted work.",
95
+ description:
96
+ "Create a durable Lovely Agent session and start its initial run. The call waits up to waitMs for the result, then detaches; a detached run notifies you when it ends. Cancellation before detachment stops the accepted work.",
130
97
  promptSnippet: "Create or delegate work to a durable agent",
131
98
  promptGuidelines: ["Call agent_roster before creating an agent and use task tools for existing work."],
132
99
  parameters: Type.Object(
@@ -134,14 +101,14 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
134
101
  definition: Type.String({ minLength: 1, description: "Agent Definition name" }),
135
102
  label: Type.String({ minLength: 1, description: "Short task label" }),
136
103
  prompt: Type.String({ minLength: 1, description: "Initial task prompt" }),
137
- ...(background ? { waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 600_000 })) } : {}),
104
+ waitMs: Type.Optional(
105
+ Type.Integer({ minimum: 0, maximum: RETAINED_OUTPUT_MAX_WAIT_MS, description: "Wait before detaching; 0 detaches immediately" })
106
+ ),
138
107
  model: Type.Optional(
139
108
  Type.String({ minLength: 1, description: "Configured provider/model ID or an enabled alias from agent_roster" })
140
109
  ),
141
110
  thinking: Type.Optional(ThinkingLevel),
142
- ...(options.canDelegate?.() === false
143
- ? {}
144
- : { allowAgents: Type.Optional(Type.Boolean({ description: "Allow this child to create descendants" })) })
111
+ allowAgents: Type.Optional(Type.Boolean({ description: "Allow this child to create descendants, depth permitting" }))
145
112
  },
146
113
  { additionalProperties: false }
147
114
  ),
@@ -181,17 +148,7 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
181
148
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
182
149
  if (signal?.aborted) throw abortError(signal)
183
150
  const config = options.getConfig()
184
- const background = config.backgroundAgents
185
- if (!background && "waitMs" in params) throw new Error("waitMs requires backgroundAgents")
186
- if (
187
- "waitMs" in params &&
188
- (typeof params.waitMs !== "number" || !Number.isInteger(params.waitMs) || params.waitMs < 0 || params.waitMs > 600_000)
189
- ) {
190
- throw new Error("waitMs must be an integer from 0 to 600000")
191
- }
192
- if ("allowAgents" in params && typeof params.allowAgents !== "boolean") throw new Error("allowAgents must be boolean")
193
151
  const allowAgents = params.allowAgents === true
194
- if (allowAgents && options.canDelegate?.() === false) throw new Error("This child cannot delegate at the current maximum depth")
195
152
  validateInput(params.label, "label", MAX_AGENT_LABEL_BYTES)
196
153
  validateInput(params.prompt, "prompt", MAX_AGENT_INPUT_BYTES)
197
154
  const parentSessionId = ctx.sessionManager.getSessionId()
@@ -199,6 +156,7 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
199
156
  const parentContext = coordinator.getSessionContext(parentSessionId)
200
157
  if (parentContext && !parentContext.allowAgents) throw new Error("This parent agent is not allowed to delegate")
201
158
  const parentDepth = parentContext?.depth ?? 0
159
+ if (allowAgents && parentDepth + 1 >= config.maxDepth) throw new Error("This child cannot delegate at the current maximum depth")
202
160
  resolveChildToolPolicy({
203
161
  parentDepth,
204
162
  maximumDepth: config.maxDepth,
@@ -206,7 +164,7 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
206
164
  })
207
165
  const discovered = discoverAgentDefinitions({
208
166
  cwd: ctx.cwd,
209
- projectTrusted: ctx.isProjectTrusted(),
167
+ projectTrusted: projectResourcesTrusted(ctx),
210
168
  toolNames: pi.getAllTools().map(tool => tool.name),
211
169
  models: ctx.modelRegistry.getAll(),
212
170
  ...(options.getAgentDir ? { agentDir: options.getAgentDir() } : {})
@@ -276,7 +234,6 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
276
234
  id: runId,
277
235
  sequence: 1,
278
236
  acceptanceOrder,
279
- background,
280
237
  kind: "initial",
281
238
  state: "queued",
282
239
  input: params.prompt,
@@ -288,27 +245,18 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
288
245
  createdAt: acceptedAt,
289
246
  updatedAt: acceptedAt
290
247
  }
291
- await appendHistoryLog(paths, { type: "run-start", sequence: 1, kind: "initial", timestamp: acceptedAt })
292
- await appendHistoryLog(paths, { type: "input", delivery: "initial", timestamp: acceptedAt, content: params.prompt })
293
248
  await writeTaskMetadata(paths, metadata)
294
249
  accepted = true
295
250
 
296
251
  const runtime = new AgentRuntime(paths, child, metadata, config.expandPromptTemplates)
297
252
  child = undefined
298
253
  runtime.start()
299
- if (!background) {
300
- const completed = await runtime.waitForRun(runId, signal)
301
- const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
302
- return buildAgentCreationToolResult(completed, false, retainedOutputSnapshot(paths, completed), tasks)
303
- }
304
- const waitMs = (params.waitMs as number | undefined) ?? config.waitMs
254
+ const waitMs = params.waitMs ?? config.waitMs
305
255
  const wait = () => runtime.wait(waitMs, signal)
306
256
  const detached = waitMs === 0 ? await wait() : await coordinator.withLentPermit(wait, signal)
307
257
  const loaded = await readTaskMetadata(paths)
308
258
  if (loaded.status !== "ok") throw new Error(`Could not read accepted task ${paths.taskRef}`)
309
- const output = await readRetainedOutput(paths, { run: 1 })
310
- const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
311
- return buildAgentCreationToolResult(loaded.metadata, detached, output, tasks)
259
+ return buildAgentCreationToolResult(loaded.metadata, detached, await readRetainedOutput(paths, { run: 1 }))
312
260
  } finally {
313
261
  child?.dispose()
314
262
  if (!accepted) await rm(paths.taskDirectory, { recursive: true, force: true })
@@ -317,44 +265,25 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
317
265
  })
318
266
  }
319
267
 
268
+ const TaskReference = Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" })
269
+
320
270
  export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOptions): void {
321
- const background = options.getConfig().backgroundAgents
322
- // Foreground agents remain a producer by default; the parent knows allowed producers and owned kinds.
323
- const agentInput = options.agentInputEnabled?.() ?? true
324
- const bashInput = options.bashInputEnabled?.() ?? options.getConfig().backgroundBash
325
271
  pi.registerTool({
326
272
  name: "task_input",
327
273
  label: "Task Input",
328
274
  description:
329
- [
330
- ...(agentInput
331
- ? [
332
- background
333
- ? "Send a durable Follow-up or live Steer to an owned Lovely Agent task."
334
- : "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."
335
- ]
336
- : []),
337
- ...(bashInput ? ["Write literal stdin to a running owned Bash task; eof closes stdin. Empty content requires eof:true."] : [])
338
- ].join(" ") || "Send input to an owned task.",
339
- promptSnippet: agentInput
340
- ? `Send Follow-up work or a live Steer${bashInput ? ", or literal Bash stdin" : ""} to a durable task`
341
- : bashInput
342
- ? "Write literal stdin to a running Bash task"
343
- : "Send input to an owned task",
275
+ "Send a durable Follow-up or live Steer to an owned Lovely Agent task, or write literal stdin to a running owned Bash task; eof closes stdin. Empty content requires eof:true.",
276
+ promptSnippet: "Send Follow-up work or a live Steer, or literal Bash stdin, to a durable task",
344
277
  promptGuidelines: [
345
- ...(agentInput
346
- ? [
347
- "Use task_input Follow-up for a new run; Steer queues input for a live streaming run. Without a live steering target, Steer becomes a Follow-up (foreground busy tasks reject it). Acceptance does not guarantee observation before stop."
348
- ]
349
- : []),
350
- ...(bashInput ? ["For Bash, omit delivery and write literal stdin; eof closes stdin without restarting the command."] : [])
278
+ "Use task_input Follow-up for a new run; Steer queues input for a live streaming run. Without a live steering target, Steer becomes a Follow-up. Acceptance does not guarantee observation before stop.",
279
+ "For Bash, omit delivery and write literal stdin; eof closes stdin without restarting the command."
351
280
  ],
352
281
  parameters: Type.Object(
353
282
  {
354
283
  id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }),
355
- content: Type.String({ minLength: bashInput ? 0 : 1, description: "Input text" }),
356
- ...(agentInput ? { delivery: Type.Optional(Type.Union([Type.Literal("followup"), Type.Literal("steer")])) } : {}),
357
- ...(bashInput ? { eof: Type.Optional(Type.Boolean({ description: "Close Bash stdin after writing content" })) } : {})
284
+ content: Type.String({ description: "Input text" }),
285
+ delivery: Type.Optional(Type.Union([Type.Literal("followup"), Type.Literal("steer")], { description: "Agent tasks only" })),
286
+ eof: Type.Optional(Type.Boolean({ description: "Bash only: close stdin after writing content" }))
358
287
  },
359
288
  { additionalProperties: false }
360
289
  ),
@@ -369,11 +298,6 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
369
298
  )
370
299
  },
371
300
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
372
- if ("waitMs" in params) throw new Error("task_input does not accept waitMs")
373
- if (params.delivery !== undefined && params.delivery !== "followup" && params.delivery !== "steer") {
374
- throw new Error("delivery must be followup or steer")
375
- }
376
- if (params.eof !== undefined && typeof params.eof !== "boolean") throw new Error("eof must be boolean")
377
301
  const result = await sendTaskInput(
378
302
  ctx,
379
303
  options,
@@ -392,9 +316,7 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
392
316
  ? `${result.id} run=${result.run}: stdin delivered${params.eof ? " (EOF)" : ""}`
393
317
  : result.effectiveDelivery === "steer"
394
318
  ? `${result.id} run=${result.run}: steer queued for current run`
395
- : result.output
396
- ? `${result.id} run=${result.run}: ${result.conversionReason ? `requested steer -> followup (${result.conversionReason}); ` : ""}followup ${result.latestOutcome}\n${result.output.text}`
397
- : `${result.id} run=${result.run}: ${result.conversionReason ? `requested steer -> ` : ""}followup accepted${result.conversionReason ? `; ${result.conversionReason}` : ""} (position ${result.queuePosition}; task=${result.state}; ${result.queuedFollowUps} queued)`
319
+ : `${result.id} run=${result.run}: ${result.conversionReason ? `requested steer -> ` : ""}followup accepted${result.conversionReason ? `; ${result.conversionReason}` : ""} (position ${result.queuePosition}; task=${result.state}; ${result.queuedFollowUps} queued)`
398
320
  }
399
321
  ],
400
322
  details: result
@@ -408,8 +330,8 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
408
330
  label: action === "stop" ? "Task Stop" : "Task Discard",
409
331
  description:
410
332
  action === "stop"
411
- ? "Stop active or queued work for an owned Agent or Bash task while preserving its files."
412
- : "Stop and permanently discard an owned task subtree from active work. Files stay at their original paths for read-only inspection. Unsupported metadata versions can also be discarded.",
333
+ ? "Stop active or queued work for owned Agent or Bash tasks while preserving their files; id accepts one Task Reference or a list."
334
+ : "Stop and permanently discard owned task subtrees from active work; id accepts one Task Reference or a list. Files stay at their original paths for read-only inspection.",
413
335
  promptSnippet: action === "stop" ? "Stop work for a durable task" : "Discard a durable task",
414
336
  promptGuidelines:
415
337
  action === "discard"
@@ -418,26 +340,39 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
418
340
  ]
419
341
  : [],
420
342
  parameters: Type.Object(
421
- { id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }) },
343
+ {
344
+ id: Type.Union([TaskReference, Type.Array(TaskReference, { minItems: 1, maxItems: 64, uniqueItems: true })], {
345
+ description: "One Task Reference, or a list to handle in one call"
346
+ })
347
+ },
422
348
  { additionalProperties: false }
423
349
  ),
424
350
  renderCall(args, theme) {
425
- return new Text(`${theme.fg("toolTitle", theme.bold(`task_${action}`))}${args.id ? ` ${theme.fg("muted", args.id)}` : ""}`, 0, 0)
351
+ const ids = [args.id ?? []].flat().join(" ")
352
+ return new Text(`${theme.fg("toolTitle", theme.bold(`task_${action}`))}${ids ? ` ${theme.fg("muted", ids)}` : ""}`, 0, 0)
426
353
  },
427
354
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
428
- const details = await controlTaskLifecycle(ctx, params.id, action)
429
- return {
430
- content: [
431
- {
432
- type: "text",
433
- text:
434
- action === "discard"
435
- ? `${details.id}: discarded (files retained at ${details.taskDirectory})`
436
- : `${details.id}: stop complete (${details.state}; outcome ${details.latestOutcome ?? "none"}; ${details.queuedFollowUps} queued)`
437
- }
438
- ],
439
- details
355
+ const describe = (details: Awaited<ReturnType<typeof controlTaskLifecycle>>) =>
356
+ action === "discard"
357
+ ? `${details.id}: discarded (files retained at ${details.taskDirectory})`
358
+ : `${details.id}: stop complete (${details.state}; outcome ${details.latestOutcome ?? "none"}; ${details.queuedFollowUps} queued)`
359
+ if (typeof params.id === "string") {
360
+ const details = await controlTaskLifecycle(ctx, params.id, action)
361
+ return { content: [{ type: "text", text: describe(details) }], details }
362
+ }
363
+ // One bad reference must not abandon the rest of a batch; only a total failure is a tool error.
364
+ const tasks: Array<Awaited<ReturnType<typeof controlTaskLifecycle>>> = []
365
+ const errors: Array<{ id: string; message: string }> = []
366
+ for (const id of params.id) {
367
+ try {
368
+ tasks.push(await controlTaskLifecycle(ctx, id, action))
369
+ } catch (error) {
370
+ errors.push({ id, message: error instanceof Error ? error.message : String(error) })
371
+ }
440
372
  }
373
+ const text = [...tasks.map(describe), ...errors.map(error => `${error.id}: failed: ${error.message}`)].join("\n")
374
+ if (tasks.length === 0) throw new Error(text)
375
+ return { content: [{ type: "text", text }], details: { tasks, errors } }
441
376
  }
442
377
  })
443
378
  }
@@ -453,11 +388,6 @@ export async function sendTaskInput(
453
388
  inputOptions: { eof?: boolean } = {}
454
389
  ): Promise<TaskInputResult> {
455
390
  if (signal?.aborted) throw abortError(signal)
456
- if (typeof content !== "string") throw new Error("content must be a string")
457
- if (inputOptions.eof !== undefined && typeof inputOptions.eof !== "boolean") throw new Error("eof must be boolean")
458
- if (requestedDelivery !== undefined && requestedDelivery !== "followup" && requestedDelivery !== "steer") {
459
- throw new Error("delivery must be followup or steer")
460
- }
461
391
  if (!id.startsWith("b_")) validateInput(content, "content", MAX_AGENT_INPUT_BYTES)
462
392
  const lease = await acquireParentLease(ctx.cwd, ctx.sessionManager.getSessionId())
463
393
  const paths = taskStoragePaths(lease.paths, id)
@@ -494,10 +424,7 @@ export async function sendTaskInput(
494
424
  if (signal?.aborted) throw abortError(signal)
495
425
  const runtime = await controllableRuntime(ctx, paths, options)
496
426
  try {
497
- accepted = await runtime.input(content, delivery, {
498
- background: options.getConfig().backgroundAgents,
499
- ...(signal ? { signal } : {})
500
- })
427
+ accepted = await runtime.input(content, delivery, signal ? { signal } : {})
501
428
  } catch (error) {
502
429
  if (!isRuntimeClosingError(error)) throw error
503
430
  await new Promise(resolve => setTimeout(resolve, 0))
@@ -512,10 +439,9 @@ export async function sendTaskInput(
512
439
  requestedDelivery: delivery,
513
440
  effectiveDelivery: accepted.delivery,
514
441
  queuePosition: accepted.queuePosition,
515
- state: accepted.completed?.state ?? current.metadata.state,
516
- latestOutcome: accepted.completed?.latestOutcome ?? current.metadata.latestOutcome,
517
- queuedFollowUps: accepted.completed?.queuedFollowUps.length ?? current.metadata.queuedFollowUps.length,
518
- ...(accepted.completed ? { output: retainedOutputSnapshot(paths, accepted.completed) } : {})
442
+ state: current.metadata.state,
443
+ latestOutcome: current.metadata.latestOutcome,
444
+ queuedFollowUps: current.metadata.queuedFollowUps.length
519
445
  }
520
446
  }
521
447
 
@@ -559,41 +485,32 @@ class AgentRuntime implements ResidentAgent {
559
485
  readonly #paths: TaskStoragePaths
560
486
  readonly #child: ChildSessionHandle
561
487
  readonly #expandPromptTemplates: boolean
562
- readonly #tuple: ModelTuple
563
488
  readonly #scheduleAbort = new AbortController()
564
489
  readonly #initialRunId: string | undefined
565
490
  readonly #initialCompletion = deferred<void>()
566
491
  readonly #runtimeCompletion = deferred<void>()
567
492
  readonly #toolArguments = new Map<string, { name: string; arguments: string }>()
568
- readonly #pendingSteers: Array<{ content: string }> = []
569
- readonly #reservations = new Map<string, AgentReservation>()
570
- readonly #completions = new Map<string, ReturnType<typeof deferred<TaskMetadata | undefined>>>()
571
493
  #unbindResident: (() => void) | undefined
572
- #unbindSuspended: (() => void) | undefined
573
494
  #unsubscribe: (() => void) | undefined
574
495
  #eventWrites: Promise<void> = Promise.resolve()
575
- #eventWriteFailed = false
576
496
  #recordingRunId: string | undefined
577
497
  #pendingProgress: ({ runId: string } & Partial<Pick<TaskMetadata, "latestReply" | "lastActivity">>) | undefined
578
498
  #lastHeartbeatAt = 0
579
499
  #progressWriteQueued = false
500
+ #progressTimer: ReturnType<typeof setTimeout> | undefined
501
+ #lastProgressWriteAt = 0
580
502
  #started = false
581
503
  #stopRequested = false
582
504
  #accepting = true
583
505
  #disposed = false
584
- #awaitingPrimaryInput = false
506
+ #sawInput = false
585
507
  #lastAssistantOutcome: NonNullable<TaskMetadata["latestOutcome"]> | undefined
586
- #providerLimitReached = false
587
- #recoveryWait: (ReturnType<typeof deferred<RecoveryMode>> & { mode: RecoveryMode | undefined }) | undefined
588
- #recoveryMode: Exclude<RecoveryMode, "stop"> | undefined
589
508
 
590
509
  constructor(paths: TaskStoragePaths, child: ChildSessionHandle, metadata: AgentTaskMetadata, expandPromptTemplates: boolean) {
591
510
  this.#paths = paths
592
511
  this.#child = child
593
512
  this.#initialRunId = metadata.activeRun?.kind === "initial" ? metadata.activeRun.id : undefined
594
513
  this.#expandPromptTemplates = expandPromptTemplates
595
- this.#tuple = { provider: metadata.model.provider, model: metadata.model.id }
596
- if (metadata.activeRun && !metadata.activeRun.background) this.#completions.set(metadata.activeRun.id, deferred())
597
514
  this.#unbindResident = getAgentCoordinator().bindResident(paths.taskDirectory, this)
598
515
  this.#unsubscribe = child.session.subscribe(event => this.recordEvent(event))
599
516
  }
@@ -604,37 +521,12 @@ class AgentRuntime implements ResidentAgent {
604
521
  void this.run().catch(() => {})
605
522
  }
606
523
 
607
- /** Run-specific synchronous wait; cancellation owns the accepted run and its descendants. */
608
- async waitForRun(runId: string, signal?: AbortSignal): Promise<TaskMetadata> {
609
- const completion = this.#completions.get(runId)
610
- if (!completion) throw new Error(`Missing completion for run ${runId}`)
611
- return getAgentCoordinator().withLentPermit(async () => {
612
- const aborted = deferred<void>()
613
- const onAbort = () => aborted.resolve(undefined)
614
- signal?.addEventListener("abort", onAbort, { once: true })
615
- if (signal?.aborted) onAbort()
616
- try {
617
- const result = await Promise.race([completion.promise, aborted.promise.then(() => undefined)])
618
- if (signal?.aborted) {
619
- await this.stop()
620
- await stopOwnedTaskTree(this.#paths.workspace, this.#child.session.sessionId)
621
- throw abortError(signal)
622
- }
623
- if (!result) throw new Error(`Agent run ${runId} ended without a retained terminal result`)
624
- return result
625
- } finally {
626
- signal?.removeEventListener("abort", onAbort)
627
- this.#completions.delete(runId)
628
- }
629
- }, signal)
630
- }
631
-
632
524
  async wait(waitMs: number, signal?: AbortSignal): Promise<boolean> {
633
525
  if (waitMs === 0) {
634
526
  await this.detach()
635
527
  return true
636
528
  }
637
- if (signal?.aborted) await this.stop()
529
+ if (signal?.aborted) await this.stopTree()
638
530
  let timer: ReturnType<typeof setTimeout> | undefined
639
531
  let onAbort: (() => void) | undefined
640
532
  const timeout = new Promise<"timeout">(resolve => {
@@ -655,7 +547,7 @@ class AgentRuntime implements ResidentAgent {
655
547
  if (timer) clearTimeout(timer)
656
548
  if (signal && onAbort) signal.removeEventListener("abort", onAbort)
657
549
  if (result === "aborted") {
658
- await this.stop()
550
+ await this.stopTree()
659
551
  return false
660
552
  }
661
553
  if (result === "timeout") {
@@ -665,11 +557,16 @@ class AgentRuntime implements ResidentAgent {
665
557
  return false
666
558
  }
667
559
 
560
+ /** Cancellation owns the accepted run and any descendants it already detached. */
561
+ private async stopTree(): Promise<void> {
562
+ await this.stop()
563
+ await stopOwnedTaskTree(this.#paths.workspace, this.#child.session.sessionId)
564
+ }
565
+
668
566
  async stop(): Promise<void> {
669
567
  if (this.#stopRequested) return this.#runtimeCompletion.promise
670
568
  this.#stopRequested = true
671
569
  this.#accepting = false
672
- this.wakeProviderRecovery("stop")
673
570
  this.#scheduleAbort.abort(new Error("Agent run stopped"))
674
571
  await this.#child.session.abort()
675
572
  const loaded = await readTaskMetadata(this.#paths)
@@ -697,8 +594,6 @@ class AgentRuntime implements ResidentAgent {
697
594
  this.#unsubscribe = undefined
698
595
  this.#unbindResident?.()
699
596
  this.#unbindResident = undefined
700
- this.#unbindSuspended?.()
701
- this.#unbindSuspended = undefined
702
597
  this.#child.dispose()
703
598
  }
704
599
 
@@ -706,24 +601,15 @@ class AgentRuntime implements ResidentAgent {
706
601
  if (delivery === "stdin" || options.eof !== undefined) throw new Error("Agent tasks do not accept stdin or eof")
707
602
  if (!this.#accepting || this.#disposed) throw new RuntimeClosingError()
708
603
  let result: ResidentInputResult | undefined
709
- let acceptedRun: NonNullable<TaskMetadata["activeRun"]> | undefined
710
604
  try {
711
605
  await mutateTaskMetadata(this.#paths, async metadata => {
712
606
  if (options.signal?.aborted) throw abortError(options.signal)
713
607
  if (!this.#accepting || this.#disposed) throw new RuntimeClosingError()
714
608
  if (metadata.discardedAt !== null) throw new Error(`Task ${metadata.taskRef} has been discarded`)
715
609
  if (delivery === "steer" && metadata.state === "running" && metadata.activeRun && this.#child.session.isStreaming) {
716
- const pending = { content }
717
- this.#pendingSteers.push(pending)
718
- try {
719
- // prompt() can yield in input hooks and start a new run after streaming ends.
720
- if (this.#expandPromptTemplates) await this.#child.session.steer(content)
721
- else this.#child.session.agent.steer({ role: "user", content, timestamp: Date.now() })
722
- } catch (error) {
723
- const index = this.#pendingSteers.indexOf(pending)
724
- if (index >= 0) this.#pendingSteers.splice(index, 1)
725
- throw error
726
- }
610
+ // prompt() can yield in input hooks and start a new run after streaming ends.
611
+ if (this.#expandPromptTemplates) await this.#child.session.steer(content)
612
+ else this.#child.session.agent.steer({ role: "user", content, timestamp: Date.now() })
727
613
  result = {
728
614
  run: metadata.activeRun.sequence,
729
615
  delivery: "steer",
@@ -732,12 +618,6 @@ class AgentRuntime implements ResidentAgent {
732
618
  }
733
619
  return metadata
734
620
  }
735
- if (
736
- (!options.background && (metadata.activeRun || metadata.queuedFollowUps.length)) ||
737
- (metadata.activeRun && !metadata.activeRun.background)
738
- ) {
739
- throw new Error(`Task ${metadata.taskRef} is busy; foreground Follow-ups require an idle task`)
740
- }
741
621
  if (metadata.queuedFollowUps.length >= MAX_QUEUED_FOLLOWUPS) {
742
622
  throw new Error(`Task ${metadata.taskRef} already has ${MAX_QUEUED_FOLLOWUPS} queued Follow-ups`)
743
623
  }
@@ -753,11 +633,10 @@ class AgentRuntime implements ResidentAgent {
753
633
  queuedFollowUps: metadata.queuedFollowUps.length + 1,
754
634
  ...(delivery === "steer" ? { conversionReason: `task ${metadata.state}, no live stream to steer` } : {})
755
635
  }
756
- acceptedRun = {
636
+ const acceptedRun: NonNullable<TaskMetadata["activeRun"]> = {
757
637
  id: runId,
758
638
  sequence,
759
639
  acceptanceOrder,
760
- background: options.background ?? false,
761
640
  kind: "followup",
762
641
  state: "queued",
763
642
  input: content,
@@ -776,10 +655,7 @@ class AgentRuntime implements ResidentAgent {
776
655
  return {
777
656
  ...metadata,
778
657
  lastRunSequence: sequence,
779
- queuedFollowUps: [
780
- ...metadata.queuedFollowUps,
781
- { id: runId, sequence, acceptanceOrder, background: options.background ?? false, content, acceptedAt }
782
- ],
658
+ queuedFollowUps: [...metadata.queuedFollowUps, { id: runId, sequence, acceptanceOrder, content, acceptedAt }],
783
659
  updatedAt: acceptedAt
784
660
  }
785
661
  })
@@ -793,47 +669,19 @@ class AgentRuntime implements ResidentAgent {
793
669
  throw error
794
670
  }
795
671
  if (!result) throw new Error("Task input was not accepted")
796
- if (acceptedRun) {
797
- if (!acceptedRun.background) this.#completions.set(acceptedRun.id, deferred())
798
- // Inactive background reservations preserve acceptance order across promotion.
799
- if (acceptedRun.background) this.reserveRun(acceptedRun)
800
- }
801
672
  this.start()
802
- if (acceptedRun && !acceptedRun.background) result.completed = await this.waitForRun(acceptedRun.id, options.signal)
803
673
  return result
804
674
  }
805
675
 
806
- recover(): boolean {
807
- return this.wakeProviderRecovery("manual")
808
- }
809
-
810
- wakeProviderRecovery(mode: RecoveryMode): boolean {
811
- const wait = this.#recoveryWait
812
- if (!wait || wait.mode || this.#disposed || (this.#stopRequested && mode !== "stop")) return false
813
- wait.mode = mode
814
- this.#unbindSuspended?.()
815
- this.#unbindSuspended = undefined
816
- wait.resolve(mode)
817
- return true
818
- }
819
-
820
676
  private async run(): Promise<void> {
821
677
  try {
822
678
  while (!this.#stopRequested) {
823
679
  const activeRun = await this.nextRun()
824
680
  if (!activeRun) break
825
- if (await this.executeRun(activeRun)) {
826
- const wait = this.#recoveryWait
827
- if (!wait) throw new Error("Suspended run has no recovery wait")
828
- const mode = await wait.promise
829
- if (this.#recoveryWait === wait) this.#recoveryWait = undefined
830
- if (mode === "stop") break
831
- this.#recoveryMode = mode
832
- }
681
+ await this.executeRun(activeRun)
833
682
  }
834
683
  } finally {
835
684
  this.dispose()
836
- for (const completion of this.#completions.values()) completion.resolve(undefined)
837
685
  this.#initialCompletion.resolve(undefined)
838
686
  this.#runtimeCompletion.resolve(undefined)
839
687
  }
@@ -855,7 +703,6 @@ class AgentRuntime implements ResidentAgent {
855
703
  id: next.id,
856
704
  sequence: next.sequence,
857
705
  ...(next.acceptanceOrder ? { acceptanceOrder: next.acceptanceOrder } : {}),
858
- background: next.background ?? false,
859
706
  kind: "followup",
860
707
  state: "queued",
861
708
  input: next.content,
@@ -866,19 +713,17 @@ class AgentRuntime implements ResidentAgent {
866
713
  return selected.run
867
714
  }
868
715
 
869
- private async executeRun(run: NonNullable<TaskMetadata["activeRun"]>): Promise<boolean> {
716
+ private async executeRun(run: NonNullable<TaskMetadata["activeRun"]>): Promise<void> {
870
717
  this.#recordingRunId = run.id
871
718
  this.#lastHeartbeatAt = 0
872
719
  let outcome: NonNullable<TaskMetadata["latestOutcome"]> = "failed"
873
- const recovering = run.state === "suspended"
874
720
  this.#lastAssistantOutcome = undefined
875
- this.#providerLimitReached = false
876
721
  let settled = false
877
- let suspended = false
878
722
  try {
879
- await this.reserveRun(run).run(async () => {
723
+ // The stored acceptance order puts a promoted Follow-up ahead of later-accepted work.
724
+ const schedule = { signal: this.#scheduleAbort.signal, ...(run.acceptanceOrder ? { acceptanceOrder: run.acceptanceOrder } : {}) }
725
+ await getAgentCoordinator().run(schedule, async () => {
880
726
  if (this.#stopRequested) return
881
- let promptCompleted = false
882
727
  try {
883
728
  let won = false
884
729
  const startedAt = Date.now()
@@ -895,116 +740,33 @@ class AgentRuntime implements ResidentAgent {
895
740
  }
896
741
  })
897
742
  if (!won) return
898
- this.#recoveryMode = undefined
899
- if (run.kind === "followup" && !recovering) {
900
- await appendHistoryLog(this.#paths, {
901
- type: "run-start",
902
- sequence: run.sequence,
903
- kind: "followup",
904
- timestamp: startedAt
905
- })
906
- await appendHistoryLog(this.#paths, {
907
- type: "input",
908
- delivery: "followup",
909
- timestamp: startedAt,
910
- content: run.input
911
- })
912
- }
913
- this.#awaitingPrimaryInput = true
914
- await this.#child.prompt(
915
- run.id,
916
- recovering ? "Continue." : run.input,
917
- childPromptOptions(recovering ? false : this.#expandPromptTemplates)
918
- )
919
- promptCompleted = true
743
+ // Inputs are logged as the child session observes them: the first is the run's
744
+ // prompt, later ones are delivered Steers.
745
+ this.#sawInput = false
746
+ this.queueEventWrite(() => appendHistoryLog(this.#paths, { type: "run-start", sequence: run.sequence, kind: run.kind }))
747
+ await this.#child.prompt(run.id, run.input, childPromptOptions(this.#expandPromptTemplates))
920
748
  await this.#eventWrites
921
- if (this.#eventWriteFailed) throw new Error("Could not retain one or more child session events")
922
749
  outcome = this.#stopRequested ? "stopped" : (this.#lastAssistantOutcome ?? "failed")
923
750
  } catch {
924
- if (!promptCompleted) this.#providerLimitReached = false
925
751
  await this.#eventWrites
926
752
  outcome = this.#stopRequested ? "stopped" : "failed"
927
753
  }
928
- if (!this.#stopRequested && this.#providerLimitReached) {
929
- suspended = await this.suspend(run)
930
- if (suspended) return
931
- }
932
- const promoted = await this.settle(run, outcome, this.#stopRequested)
754
+ await this.settle(run, outcome, this.#stopRequested)
933
755
  settled = true
934
- if (promoted) this.reserveRun(promoted).activate()
935
756
  })
936
- } catch (error) {
757
+ } catch {
937
758
  await this.#eventWrites
938
759
  outcome = this.#stopRequested ? "stopped" : "failed"
939
- if (!run.background && !this.#stopRequested) {
940
- await mutateTaskMetadata(this.#paths, metadata =>
941
- metadata.activeRun?.id === run.id
942
- ? {
943
- ...metadata,
944
- latestReply: { text: error instanceof Error ? error.message : String(error), streaming: false },
945
- updatedAt: Date.now()
946
- }
947
- : metadata
948
- )
949
- }
950
760
  }
951
761
  this.#child.session.clearQueue()
952
- this.#pendingSteers.length = 0
953
- this.#reservations.delete(run.id)
954
- if (!settled && !suspended) {
955
- const promoted = await this.settle(run, outcome, this.#stopRequested)
956
- if (promoted) this.reserveRun(promoted).activate()
957
- }
958
- return suspended
959
- }
960
-
961
- private async suspend(run: NonNullable<TaskMetadata["activeRun"]>): Promise<boolean> {
962
- // Provider evidence is tuple-global even if another task mutation wins.
963
- getAgentCoordinator().closeTuple(this.#tuple)
964
- if (!run.background) {
965
- await writeTaskProgress(this.#paths, run.id, {
966
- latestReply: { text: "Provider limit reached; foreground run failed and will not restart automatically.", streaming: false }
967
- })
968
- return false
969
- }
970
- const wait = Object.assign(deferred<RecoveryMode>(), { mode: undefined as RecoveryMode | undefined })
971
- const unbind = bindSuspendedRuntime(this.#tuple, this)
972
- this.#recoveryWait = wait
973
- this.#unbindSuspended = unbind
974
- let won = false
975
- let notification: TaskMetadata["notifications"][number] | undefined
976
- const timestamp = Date.now()
977
- await mutateTaskMetadata(this.#paths, async metadata => {
978
- const activeRun = metadata.activeRun
979
- if (!activeRun || activeRun.id !== run.id) return metadata
980
- won = true
981
- if (run.kind === "followup" || activeRun.detachedAt !== undefined) {
982
- notification = await prepareTaskNotification(this.#paths, metadata, activeRun, "suspension")
983
- }
984
- return {
985
- ...metadata,
986
- state: "suspended",
987
- activeRun: { ...activeRun, state: "suspended" },
988
- ...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
989
- updatedAt: timestamp
990
- }
991
- })
992
- if (won) {
993
- if (run.id === this.#initialRunId) this.#initialCompletion.resolve(undefined)
994
- if (notification) await deliverTaskNotifications(this.#paths).catch(() => {})
995
- } else {
996
- unbind()
997
- if (this.#recoveryWait === wait) this.#recoveryWait = undefined
998
- if (this.#unbindSuspended === unbind) this.#unbindSuspended = undefined
999
- }
1000
- return won
762
+ if (!settled) await this.settle(run, outcome, this.#stopRequested)
1001
763
  }
1002
764
 
1003
765
  private async detach(): Promise<void> {
1004
766
  const detachedAt = Date.now()
1005
767
  await mutateTaskMetadata(this.#paths, metadata => {
1006
768
  const activeRun = metadata.activeRun
1007
- if (!activeRun?.background || activeRun.id !== this.#initialRunId || activeRun.detachedAt !== undefined) return metadata
769
+ if (!activeRun || activeRun.id !== this.#initialRunId || activeRun.detachedAt !== undefined) return metadata
1008
770
  return { ...metadata, activeRun: { ...activeRun, detachedAt }, updatedAt: detachedAt }
1009
771
  })
1010
772
  }
@@ -1013,39 +775,24 @@ class AgentRuntime implements ResidentAgent {
1013
775
  run: NonNullable<TaskMetadata["activeRun"]>,
1014
776
  outcome: NonNullable<TaskMetadata["latestOutcome"]>,
1015
777
  clearFollowUps = false
1016
- ): Promise<NonNullable<TaskMetadata["activeRun"]> | null> {
778
+ ): Promise<void> {
1017
779
  let won = false
1018
- const promoted: { run: NonNullable<TaskMetadata["activeRun"]> | null } = { run: null }
1019
780
  let notification: TaskMetadata["notifications"][number] | undefined
1020
- let completed: TaskMetadata | undefined
1021
781
  const timestamp = Date.now()
1022
782
  await mutateTaskMetadata(this.#paths, async metadata => {
1023
783
  const activeRun = metadata.activeRun
1024
784
  if (activeRun?.id !== run.id) return clearFollowUps ? { ...metadata, queuedFollowUps: [], updatedAt: timestamp } : metadata
1025
785
  won = true
1026
- completed = {
1027
- ...metadata,
1028
- state: "idle",
1029
- latestOutcome: outcome,
1030
- lastSettledRun: run.sequence,
1031
- activeRun: null,
1032
- queuedFollowUps: [],
1033
- updatedAt: timestamp
1034
- }
1035
- if (
1036
- activeRun.background &&
1037
- (outcome === "succeeded" || outcome === "failed") &&
1038
- (run.kind === "followup" || activeRun.detachedAt !== undefined)
1039
- ) {
786
+ if ((outcome === "succeeded" || outcome === "failed") && (run.kind === "followup" || activeRun.detachedAt !== undefined)) {
1040
787
  notification = await prepareTaskNotification(this.#paths, metadata, activeRun, "completion", outcome)
1041
788
  }
1042
789
  const [next, ...remaining] = clearFollowUps ? [] : metadata.queuedFollowUps
1043
790
  if (next) {
1044
- promoted.run = {
791
+ // Promote atomically: an idle gap would let new input jump the queue.
792
+ const promoted: NonNullable<TaskMetadata["activeRun"]> = {
1045
793
  id: next.id,
1046
794
  sequence: next.sequence,
1047
795
  ...(next.acceptanceOrder ? { acceptanceOrder: next.acceptanceOrder } : {}),
1048
- background: next.background ?? false,
1049
796
  kind: "followup",
1050
797
  state: "queued",
1051
798
  input: next.content,
@@ -1055,7 +802,7 @@ class AgentRuntime implements ResidentAgent {
1055
802
  ...metadata,
1056
803
  state: "queued",
1057
804
  latestOutcome: outcome,
1058
- activeRun: promoted.run,
805
+ activeRun: promoted,
1059
806
  queuedFollowUps: remaining,
1060
807
  ...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
1061
808
  updatedAt: timestamp
@@ -1071,28 +818,10 @@ class AgentRuntime implements ResidentAgent {
1071
818
  updatedAt: timestamp
1072
819
  }
1073
820
  })
1074
- if (won) await appendHistoryLog(this.#paths, { type: "run-end", sequence: run.sequence, outcome, timestamp })
821
+ // Observability only: a failed append must not skip notification delivery below.
822
+ if (won) await appendHistoryLog(this.#paths, { type: "run-end", sequence: run.sequence, outcome }).catch(() => {})
1075
823
  if (won && notification) await deliverTaskNotifications(this.#paths).catch(() => {})
1076
- if (won) this.#completions.get(run.id)?.resolve(completed)
1077
824
  if (run.id === this.#initialRunId) this.#initialCompletion.resolve(undefined)
1078
- return promoted.run
1079
- }
1080
-
1081
- private reserveRun(run: NonNullable<TaskMetadata["activeRun"]>): AgentReservation {
1082
- const existing = this.#reservations.get(run.id)
1083
- if (existing) return existing
1084
- const tuple =
1085
- run.state === "suspended" && this.#recoveryMode === "manual"
1086
- ? { provider: `${this.#tuple.provider}#manual#${this.#paths.taskRef}`, model: this.#tuple.model }
1087
- : this.#tuple
1088
- const reservation = getAgentCoordinator().reserve({
1089
- tuple,
1090
- signal: this.#scheduleAbort.signal,
1091
- rejectOnClosedTuple: !run.background,
1092
- ...(run.acceptanceOrder ? { acceptanceOrder: run.acceptanceOrder } : {})
1093
- })
1094
- this.#reservations.set(run.id, reservation)
1095
- return reservation
1096
825
  }
1097
826
 
1098
827
  private recordEvent(event: AgentSessionEvent): void {
@@ -1123,11 +852,7 @@ class AgentRuntime implements ResidentAgent {
1123
852
  event.message.role === "assistant" &&
1124
853
  (event.type !== "message_update" || event.assistantMessageEvent.type === "text_delta")
1125
854
  ) {
1126
- const text = event.message.content
1127
- .filter(part => part.type === "text")
1128
- .map(part => part.text)
1129
- .join("")
1130
- latestReply = { text, streaming: event.type !== "message_end" }
855
+ latestReply = { text: messageText(event.message.content), streaming: event.type !== "message_end" }
1131
856
  action = latestReply.streaming ? "responding" : "reply complete"
1132
857
  }
1133
858
  if (runId && action) {
@@ -1137,21 +862,7 @@ class AgentRuntime implements ResidentAgent {
1137
862
  ...(latestReply ? { latestReply } : {}),
1138
863
  lastActivity: { at, action }
1139
864
  }
1140
- if (!this.#progressWriteQueued) {
1141
- this.#progressWriteQueued = true
1142
- this.queueEventWrite(async () => {
1143
- try {
1144
- // Coalesce replies and activity together so bursts cannot reorder their last action.
1145
- while (this.#pendingProgress) {
1146
- const { runId, ...progress } = this.#pendingProgress
1147
- this.#pendingProgress = undefined
1148
- await writeTaskProgress(this.#paths, runId, progress)
1149
- }
1150
- } finally {
1151
- this.#progressWriteQueued = false
1152
- }
1153
- })
1154
- }
865
+ this.scheduleProgressWrite(latestReply?.streaming === true)
1155
866
  }
1156
867
  if (event.type === "tool_execution_start") {
1157
868
  this.#toolArguments.set(event.toolCallId, { name: event.toolName, arguments: renderUnknown(event.args) })
@@ -1159,37 +870,14 @@ class AgentRuntime implements ResidentAgent {
1159
870
  }
1160
871
  if (event.type === "message_end" && event.message.role === "assistant") {
1161
872
  this.#lastAssistantOutcome = event.message.stopReason === "error" || event.message.stopReason === "aborted" ? "failed" : "succeeded"
1162
- this.#providerLimitReached = event.message.stopReason === "error" && isProviderLimitError(event.message.errorMessage)
1163
- const content = event.message.content
1164
- .filter(part => part.type === "text")
1165
- .map(part => part.text)
1166
- .join("")
873
+ const content = messageText(event.message.content)
1167
874
  if (content) this.queueEventWrite(() => appendHistoryLog(this.#paths, { type: "assistant", content }))
1168
875
  return
1169
876
  }
1170
- if (event.type === "message_start" && event.message.role === "user" && this.#awaitingPrimaryInput) {
1171
- this.#awaitingPrimaryInput = false
1172
- return
1173
- }
1174
- if (event.type === "message_start" && event.message.role === "user" && this.#pendingSteers.length > 0) {
1175
- const delivered = this.#pendingSteers.shift()
1176
- if (delivered) {
1177
- const content =
1178
- typeof event.message.content === "string"
1179
- ? event.message.content
1180
- : event.message.content
1181
- .filter(part => part.type === "text")
1182
- .map(part => part.text)
1183
- .join("")
1184
- this.queueEventWrite(() =>
1185
- appendHistoryLog(this.#paths, {
1186
- type: "input",
1187
- delivery: "steer",
1188
- timestamp: Date.now(),
1189
- content: content || delivered.content
1190
- })
1191
- )
1192
- }
877
+ if (event.type === "message_start" && event.message.role === "user") {
878
+ const entry = { type: this.#sawInput ? ("steer" as const) : ("user" as const), content: messageText(event.message.content) }
879
+ this.#sawInput = true
880
+ this.queueEventWrite(() => appendHistoryLog(this.#paths, entry))
1193
881
  return
1194
882
  }
1195
883
  if (event.type === "tool_execution_end") {
@@ -1207,11 +895,45 @@ class AgentRuntime implements ResidentAgent {
1207
895
  }
1208
896
  }
1209
897
 
1210
- private queueEventWrite(write: () => Promise<void>): void {
1211
- this.#eventWrites = this.#eventWrites.then(write).catch(() => {
1212
- this.#eventWriteFailed = true
898
+ /**
899
+ * Each write rewrites and fsyncs the whole snapshot, so streaming deltas are
900
+ * capped at two per second; completed replies and tool activity flush at once.
901
+ */
902
+ private scheduleProgressWrite(deferrable: boolean): void {
903
+ const wait = deferrable ? this.#lastProgressWriteAt + 500 - Date.now() : 0
904
+ if (wait > 0) {
905
+ this.#progressTimer ??= setTimeout(() => this.scheduleProgressWrite(false), wait)
906
+ this.#progressTimer.unref()
907
+ return
908
+ }
909
+ clearTimeout(this.#progressTimer)
910
+ this.#progressTimer = undefined
911
+ if (this.#progressWriteQueued) return
912
+ this.#progressWriteQueued = true
913
+ this.queueEventWrite(async () => {
914
+ try {
915
+ // Coalesce replies and activity together so bursts cannot reorder their last action.
916
+ // Drain inside this one chain link: settlement awaits the chain it saw, so a
917
+ // terminal reply arriving mid-write must not be deferred to a later link.
918
+ while (this.#pendingProgress) {
919
+ if (this.#pendingProgress.latestReply?.streaming === true && this.#lastProgressWriteAt + 500 > Date.now()) break
920
+ const { runId, ...progress } = this.#pendingProgress
921
+ this.#pendingProgress = undefined
922
+ this.#lastProgressWriteAt = Date.now()
923
+ await writeTaskProgress(this.#paths, runId, progress)
924
+ }
925
+ } finally {
926
+ this.#progressWriteQueued = false
927
+ // Only a throttled streaming snapshot can remain.
928
+ if (this.#pendingProgress) this.scheduleProgressWrite(true)
929
+ }
1213
930
  })
1214
931
  }
932
+
933
+ private queueEventWrite(write: () => Promise<void>): void {
934
+ // history.md and progress snapshots are observability; their I/O errors never decide a run's outcome.
935
+ this.#eventWrites = this.#eventWrites.then(write).catch(() => {})
936
+ }
1215
937
  }
1216
938
 
1217
939
  class RuntimeClosingError extends Error {
@@ -1307,8 +1029,7 @@ function coldRuntimeLoads(): Map<string, Promise<AgentRuntime>> {
1307
1029
  function buildAgentCreationToolResult(
1308
1030
  metadata: TaskMetadata,
1309
1031
  detached: boolean,
1310
- output: Awaited<ReturnType<typeof readRetainedOutput>>,
1311
- tasks: Awaited<ReturnType<typeof loadTaskList>>["details"]
1032
+ output: Awaited<ReturnType<typeof readRetainedOutput>>
1312
1033
  ): { content: [{ type: "text"; text: string }]; details: AgentCreationResult } {
1313
1034
  if (metadata.kind !== "agent") throw new Error("Expected an Agent task")
1314
1035
  const result: AgentCreationResult = {
@@ -1324,10 +1045,9 @@ function buildAgentCreationToolResult(
1324
1045
  allowAgents: metadata.allowAgents,
1325
1046
  detached,
1326
1047
  queuedFollowUps: metadata.queuedFollowUps.length,
1327
- output,
1328
- tasks
1048
+ output
1329
1049
  }
1330
- const pending = detached && (result.state === "queued" || result.state === "running" || result.state === "suspended")
1050
+ const pending = detached && (result.state === "queued" || result.state === "running")
1331
1051
  return {
1332
1052
  content: [
1333
1053
  {
@@ -1360,6 +1080,10 @@ function createRunId(): string {
1360
1080
  return `r_${randomBytes(8).toString("hex")}`
1361
1081
  }
1362
1082
 
1083
+ function messageText(content: string | ReadonlyArray<{ type: string; text?: string }>): string {
1084
+ return typeof content === "string" ? content : content.map(part => (part.type === "text" ? (part.text ?? "") : "")).join("")
1085
+ }
1086
+
1363
1087
  function renderUnknown(value: unknown): string {
1364
1088
  if (typeof value === "string") return value
1365
1089
  try {
@@ -1369,36 +1093,6 @@ function renderUnknown(value: unknown): string {
1369
1093
  }
1370
1094
  }
1371
1095
 
1372
- function suspendedRuntimes(): Map<string, Set<SuspendedRuntime>> {
1373
- const global = globalThis as typeof globalThis & { [SUSPENDED_RUNTIMES_SYMBOL]?: Map<string, Set<SuspendedRuntime>> }
1374
- global[SUSPENDED_RUNTIMES_SYMBOL] ??= new Map()
1375
- return global[SUSPENDED_RUNTIMES_SYMBOL]
1376
- }
1377
-
1378
- function bindSuspendedRuntime(tuple: ModelTuple, runtime: SuspendedRuntime): () => void {
1379
- const registry = suspendedRuntimes()
1380
- const key = modelTupleKey(tuple)
1381
- const runtimes = registry.get(key) ?? new Set()
1382
- runtimes.add(runtime)
1383
- registry.set(key, runtimes)
1384
- return () => {
1385
- runtimes.delete(runtime)
1386
- if (runtimes.size === 0 && registry.get(key) === runtimes) registry.delete(key)
1387
- }
1388
- }
1389
-
1390
- function modelTupleKey(tuple: ModelTuple): string {
1391
- return `${tuple.provider}\0${tuple.model}`
1392
- }
1393
-
1394
1096
  function abortError(signal: AbortSignal): Error {
1395
1097
  return signal.reason instanceof Error ? signal.reason : new Error("Agent creation aborted")
1396
1098
  }
1397
-
1398
- function deferred<T>(): { promise: Promise<T>; resolve(value: T | PromiseLike<T>): void } {
1399
- let resolve!: (value: T | PromiseLike<T>) => void
1400
- const promise = new Promise<T>(resolvePromise => {
1401
- resolve = resolvePromise
1402
- })
1403
- return { promise, resolve }
1404
- }