@xl0/pi-lovely-agents 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.2] - 2026-09-10
6
+
7
+ ### Breaking Changes
8
+
9
+ - Keep discarded task files at their original paths and return `taskDirectory` with `state: "discarded"` from `task_discard`.
10
+
11
+ ### Added
12
+
13
+ - Retrieve retained results by run index with `task_output(run: N)`, including after discard, and identify runs in results and completion notices.
14
+ - Browse non-discarded tasks through an `active/` index and safely prune discarded tasks with the dry-run-first `scripts/prune-tasks.ts` command.
15
+ - Report child progress in task inspection and the panel with `task_update` without notifying the parent.
16
+ - Scroll live output with keyboard navigation, Bash tail-following, and visible exit codes or termination signals.
17
+ - Read shorter Bash output tails with `task_output(lines: N)`.
18
+
19
+ ### Changed
20
+
21
+ - Group Agents and Bash separately and list active tasks first.
22
+ - Show the latest Bash output in UTF-8-safe completion previews.
23
+ - Clarify Steer acceptance and Follow-up conversion, and expose process-wide capacity and Bash queue reasons.
24
+ - Guide agents to delegate independent implementation work and keep coupled changes in the parent.
25
+
26
+ ### Fixed
27
+
28
+ - Keep output waits attached to the selected run across Follow-ups.
29
+ - Deliver pending completion notifications for discarded tasks.
30
+ - Prevent another Pi process from claiming tasks whose cleanup failed.
31
+
32
+ ## [0.1.1] - 2026-09-06
33
+
34
+ - Publish through GitHub Actions with npm provenance.
35
+
5
36
  ## [0.1.0] - 2026-09-06
6
37
 
7
38
  - Reusable Markdown agent definitions with model, thinking, tool, and context settings.
package/README.md CHANGED
@@ -83,14 +83,19 @@ agents get the normal tools and extensions; a role description is not a sandbox.
83
83
 
84
84
  Open **`/lovely-agents` → Tasks**, or press **Down with an empty editor** to focus
85
85
  the task list below it.
86
+ Agents and Bash appear in separate groups, active tasks first within each group.
86
87
 
87
88
  - **Arrow keys** select a task; **Enter** opens its actions.
88
- - **Live output** shows what it has produced so far.
89
+ - **Live output** scrolls with arrows, PgUp/PgDn, Home/End. Bash opens at the
90
+ bottom and follows new output; scrolling up pauses following, End resumes it.
91
+ Its fixed header includes the exit code and termination signal.
89
92
  - **Inputs / history** shows earlier requests and results.
90
93
  - **Follow-up** adds another request after the agent's current work.
91
- - **Steer** redirects work already in progress.
94
+ - **Steer** queues input for a live streaming run. Without a live target it becomes
95
+ a Follow-up; foreground busy tasks reject it. Queued input can be lost on stop.
92
96
  - **Stop** cancels the work but keeps its files. An agent can take a new request later.
93
- - **Discard** stops and archives it, removing it from the list. It does not delete the files.
97
+ - **Discard** stops it and removes it from active work. Files stay in place and
98
+ results remain readable, but it cannot receive new input.
94
99
  - **Esc** returns to the editor.
95
100
 
96
101
  You can also ask Pi directly:
@@ -102,6 +107,14 @@ You can also ask Pi directly:
102
107
  Tool calls and notifications are compact by default. **Ctrl+O** expands their
103
108
  full contents.
104
109
 
110
+ Children can call `task_update({ progress: "Root cause found; testing the fix" })`
111
+ to report meaningful milestones or blockers in up to 240 characters. Progress
112
+ replaces the initial-input preview in the panel and appears in task inspection.
113
+ It does not rename the task, change its lifecycle state, notify or wake the parent.
114
+ Each new run clears the report; earlier reports remain with their run's result.
115
+ Default-tool children get this tool automatically. If a Definition has an explicit
116
+ `tools` list, include `task_update` to allow progress reporting.
117
+
105
118
  ## Background Bash
106
119
 
107
120
  Ask Pi to run a long command in the background:
@@ -110,6 +123,8 @@ Ask Pi to run a long command in the background:
110
123
 
111
124
  The command appears alongside agent tasks. You can inspect its output or stop
112
125
  it from the same menu. Normal, short Bash commands still work as before.
126
+ Detached completion automatically notifies Pi and wakes an idle parent.
127
+ Synchronous completion and explicit stops do not send a completion notice.
113
128
 
114
129
  For a command that needs input, **Write stdin** sends exactly what you type;
115
130
  include a newline if the command expects one. **Close stdin** sends EOF: “there
@@ -131,6 +146,8 @@ work running. Turning either switch off does not stop tasks already accepted.
131
146
  Agent work and Bash jobs have separate concurrency limits, both initially 4.
132
147
  Extra work queues until a slot is free. Pi initially waits up to 30 seconds for
133
148
  an agent result before leaving it in the background.
149
+ These limits are shared process-wide, not per conversation. The roster reports
150
+ held/max execution permits; task lists show only the current parent's tasks.
134
151
 
135
152
  Under **Models**, choose additional models Pi may use for agents. You can also
136
153
  configure these optional shortcuts:
@@ -152,10 +169,16 @@ Long results aren't lost. Pi's output-reading tool returns at most **2,000 lines
152
169
  or 50 KiB** at a time, with a truncation notice and a file path when capped.
153
170
  These are snapshots, not pages to assemble by repeatedly reading. Full agent
154
171
  replies are in `history.md`; full Bash output is in `output.log`.
172
+ Each agent assignment has a 1-based run index, shown in tool results and notices.
173
+ `task_output(id, run: 2)` retrieves that run even after later Follow-ups start.
174
+ Omit `run` for the current snapshot; `lines: 20` requests a shorter Bash tail.
175
+ Older runs completed before this feature may require reading `history.md`.
155
176
 
156
177
  If Pi asks to wait for a result, the wait ends when the run finishes, pauses on
157
178
  a provider limit, or reaches the requested timeout. A timeout returns the
158
179
  latest output—it does not stop the task.
180
+ The wait stays attached to the selected run; a later Follow-up cannot replace
181
+ its answer. Prefer completion notices or a meaningful wait over short polling.
159
182
 
160
183
  Tasks belong to the Pi conversation that started them. **`/reload` keeps work
161
184
  running; quitting or switching conversations stops it.** After a crash, lost
@@ -168,8 +191,24 @@ an error or was aborted, **`/continue`** resumes it and eligible paused agents.
168
191
 
169
192
  Task files live in `.pi/lovely-agents/` under your working directory and are
170
193
  ignored by Git. They include conversation history and command output, so treat
171
- them as potentially sensitive. Discarded tasks move to its `archive/` directory;
172
- there is no automatic deletion.
194
+ them as potentially sensitive. Each task keeps its original
195
+ `<parent-session>/<task-id>/` path. The parent's `active/` directory links to all
196
+ non-discarded tasks, including idle specialists. It is a browsing index, rebuilt
197
+ when that parent is reopened—not the authority for execution or deletion.
198
+ Keep tasks until dependent work is integrated, then discard what is no longer
199
+ needed. Existing old `archive/` contents are left untouched.
200
+
201
+ There is no automatic deletion. From the package checkout/install directory:
202
+
203
+ ```bash
204
+ bun scripts/prune-tasks.ts /path/to/workspace # dry-run
205
+ bun scripts/prune-tasks.ts /path/to/workspace --apply # permanently delete eligible tasks
206
+ ```
207
+
208
+ Pruning requires closed parent sessions and retains non-discarded tasks, pending
209
+ notifications, unknown/corrupt records, and unsafe or still-needed descendants.
210
+ Missing `active/` links never authorize deletion. Crash leftovers stay until
211
+ their parent is reopened; abandoned sessions are not automatically collected.
173
212
 
174
213
  ## Development
175
214
 
@@ -29,7 +29,6 @@ import {
29
29
  type AgentTaskMetadata,
30
30
  acquireParentLease,
31
31
  appendHistoryLog,
32
- archivedTaskStoragePaths,
33
32
  displayWorkspacePath,
34
33
  ensureParentStorage,
35
34
  initializeRetainedLogs,
@@ -82,6 +81,7 @@ export function recoverProviderTuple(tuple: ModelTuple): number {
82
81
 
83
82
  export type AgentCreationResult = {
84
83
  id: string
84
+ run: number
85
85
  label: string
86
86
  definition: string
87
87
  state: TaskMetadata["state"]
@@ -98,8 +98,10 @@ export type AgentCreationResult = {
98
98
 
99
99
  export type TaskInputResult = {
100
100
  id: string
101
+ run: number
101
102
  requestedDelivery: "followup" | "steer" | undefined
102
103
  effectiveDelivery: "followup" | "steer" | "stdin"
104
+ conversionReason?: string
103
105
  queuePosition: number | null
104
106
  state: TaskMetadata["state"]
105
107
  latestOutcome: TaskMetadata["latestOutcome"]
@@ -304,7 +306,7 @@ export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions):
304
306
  const detached = waitMs === 0 ? await wait() : await coordinator.withLentPermit(wait, signal)
305
307
  const loaded = await readTaskMetadata(paths)
306
308
  if (loaded.status !== "ok") throw new Error(`Could not read accepted task ${paths.taskRef}`)
307
- const output = await readRetainedOutput(paths)
309
+ const output = await readRetainedOutput(paths, { run: 1 })
308
310
  const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
309
311
  return buildAgentCreationToolResult(loaded.metadata, detached, output, tasks)
310
312
  } finally {
@@ -340,7 +342,11 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
340
342
  ? "Write literal stdin to a running Bash task"
341
343
  : "Send input to an owned task",
342
344
  promptGuidelines: [
343
- ...(agentInput ? ["Use Follow-up for later work; use Steer only to redirect a currently running agent."] : []),
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
+ : []),
344
350
  ...(bashInput ? ["For Bash, omit delivery and write literal stdin; eof closes stdin without restarting the command."] : [])
345
351
  ],
346
352
  parameters: Type.Object(
@@ -383,12 +389,12 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
383
389
  type: "text",
384
390
  text:
385
391
  result.effectiveDelivery === "stdin"
386
- ? `${result.id}: stdin delivered${params.eof ? " (EOF)" : ""}`
392
+ ? `${result.id} run=${result.run}: stdin delivered${params.eof ? " (EOF)" : ""}`
387
393
  : result.effectiveDelivery === "steer"
388
- ? `${result.id}: steer delivered (${result.state}; ${result.queuedFollowUps} Follow-ups queued)`
394
+ ? `${result.id} run=${result.run}: steer queued for current run`
389
395
  : 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)`
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)`
392
398
  }
393
399
  ],
394
400
  details: result
@@ -403,12 +409,12 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
403
409
  description:
404
410
  action === "stop"
405
411
  ? "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.",
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.",
407
413
  promptSnippet: action === "stop" ? "Stop work for a durable task" : "Discard a durable task",
408
414
  promptGuidelines:
409
415
  action === "discard"
410
416
  ? [
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."
417
+ "Use task_discard after dependent work is integrated and no Follow-up is expected. Keep reusable specialists and tasks whose results are still needed; idle agents already unload."
412
418
  ]
413
419
  : [],
414
420
  parameters: Type.Object(
@@ -426,7 +432,7 @@ export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOption
426
432
  type: "text",
427
433
  text:
428
434
  action === "discard"
429
- ? `${details.id}: discarded (files archived at ${details.archiveDirectory})`
435
+ ? `${details.id}: discarded (files retained at ${details.taskDirectory})`
430
436
  : `${details.id}: stop complete (${details.state}; outcome ${details.latestOutcome ?? "none"}; ${details.queuedFollowUps} queued)`
431
437
  }
432
438
  ],
@@ -472,6 +478,7 @@ export async function sendTaskInput(
472
478
  if (current.status !== "ok") throw new Error(`Could not read accepted task ${id}`)
473
479
  return {
474
480
  id,
481
+ run: accepted.run,
475
482
  requestedDelivery,
476
483
  effectiveDelivery: accepted.delivery,
477
484
  queuePosition: accepted.queuePosition,
@@ -500,6 +507,8 @@ export async function sendTaskInput(
500
507
  if (current.status !== "ok") throw new Error(`Could not read accepted task ${id}`)
501
508
  return {
502
509
  id,
510
+ run: accepted.run,
511
+ ...(accepted.conversionReason ? { conversionReason: accepted.conversionReason } : {}),
503
512
  requestedDelivery: delivery,
504
513
  effectiveDelivery: accepted.delivery,
505
514
  queuePosition: accepted.queuePosition,
@@ -515,15 +524,14 @@ export async function controlTaskLifecycle(ctx: ExtensionContext, id: string, ac
515
524
  const paths = taskStoragePaths(lease.paths, id)
516
525
  if (action === "discard") {
517
526
  await discardTask(paths)
518
- const archived = archivedTaskStoragePaths(paths)
519
527
  return {
520
528
  id,
521
529
  action,
522
- state: "archived",
530
+ state: "discarded",
523
531
  latestOutcome: null,
524
532
  discarded: true,
525
533
  queuedFollowUps: 0,
526
- archiveDirectory: displayWorkspacePath(ctx.cwd, archived.taskDirectory)
534
+ taskDirectory: displayWorkspacePath(ctx.cwd, paths.taskDirectory)
527
535
  }
528
536
  }
529
537
  let loaded = await readTaskMetadata(paths)
@@ -542,7 +550,7 @@ export async function controlTaskLifecycle(ctx: ExtensionContext, id: string, ac
542
550
  latestOutcome: metadata.latestOutcome,
543
551
  discarded: metadata.discardedAt !== null,
544
552
  queuedFollowUps: metadata.queuedFollowUps.length,
545
- archiveDirectory: undefined,
553
+ taskDirectory: displayWorkspacePath(ctx.cwd, paths.taskDirectory),
546
554
  paths: retainedPaths(paths)
547
555
  }
548
556
  }
@@ -716,7 +724,12 @@ class AgentRuntime implements ResidentAgent {
716
724
  if (index >= 0) this.#pendingSteers.splice(index, 1)
717
725
  throw error
718
726
  }
719
- result = { delivery: "steer", queuePosition: null, queuedFollowUps: metadata.queuedFollowUps.length }
727
+ result = {
728
+ run: metadata.activeRun.sequence,
729
+ delivery: "steer",
730
+ queuePosition: null,
731
+ queuedFollowUps: metadata.queuedFollowUps.length
732
+ }
720
733
  return metadata
721
734
  }
722
735
  if (
@@ -733,7 +746,13 @@ class AgentRuntime implements ResidentAgent {
733
746
  const runId = createRunId()
734
747
  const acceptanceOrder = getAgentCoordinator().nextAcceptanceOrder()
735
748
  const queuePosition = metadata.activeRun ? metadata.queuedFollowUps.length + 1 : 1
736
- result = { delivery: "followup", queuePosition, queuedFollowUps: metadata.queuedFollowUps.length + 1 }
749
+ result = {
750
+ run: sequence,
751
+ delivery: "followup",
752
+ queuePosition,
753
+ queuedFollowUps: metadata.queuedFollowUps.length + 1,
754
+ ...(delivery === "steer" ? { conversionReason: `task ${metadata.state}, no live stream to steer` } : {})
755
+ }
737
756
  acceptedRun = {
738
757
  id: runId,
739
758
  sequence,
@@ -892,7 +911,8 @@ class AgentRuntime implements ResidentAgent {
892
911
  })
893
912
  }
894
913
  this.#awaitingPrimaryInput = true
895
- await this.#child.session.prompt(
914
+ await this.#child.prompt(
915
+ run.id,
896
916
  recovering ? "Continue." : run.input,
897
917
  childPromptOptions(recovering ? false : this.#expandPromptTemplates)
898
918
  )
@@ -1003,7 +1023,15 @@ class AgentRuntime implements ResidentAgent {
1003
1023
  const activeRun = metadata.activeRun
1004
1024
  if (activeRun?.id !== run.id) return clearFollowUps ? { ...metadata, queuedFollowUps: [], updatedAt: timestamp } : metadata
1005
1025
  won = true
1006
- completed = { ...metadata, state: "idle", latestOutcome: outcome, activeRun: null, queuedFollowUps: [], updatedAt: timestamp }
1026
+ completed = {
1027
+ ...metadata,
1028
+ state: "idle",
1029
+ latestOutcome: outcome,
1030
+ lastSettledRun: run.sequence,
1031
+ activeRun: null,
1032
+ queuedFollowUps: [],
1033
+ updatedAt: timestamp
1034
+ }
1007
1035
  if (
1008
1036
  activeRun.background &&
1009
1037
  (outcome === "succeeded" || outcome === "failed") &&
@@ -1285,10 +1313,11 @@ function buildAgentCreationToolResult(
1285
1313
  if (metadata.kind !== "agent") throw new Error("Expected an Agent task")
1286
1314
  const result: AgentCreationResult = {
1287
1315
  id: metadata.taskRef,
1316
+ run: 1,
1288
1317
  label: metadata.label,
1289
1318
  definition: metadata.definitionName,
1290
- state: metadata.state,
1291
- latestOutcome: metadata.latestOutcome,
1319
+ state: output.state,
1320
+ latestOutcome: output.latestOutcome,
1292
1321
  model: `${metadata.model.provider}/${metadata.model.id}`,
1293
1322
  thinking: metadata.thinking,
1294
1323
  depth: metadata.depth,
@@ -1305,6 +1334,7 @@ function buildAgentCreationToolResult(
1305
1334
  type: "text",
1306
1335
  text: [
1307
1336
  `task: ${result.id}`,
1337
+ `run: ${result.run}`,
1308
1338
  `state: ${result.state}`,
1309
1339
  ...(output.queueReason
1310
1340
  ? [`waiting: ${output.queueReason} (${output.capacity.active}/${output.capacity.limit} execution permits)`]
@@ -51,6 +51,7 @@ const BashParameters = Type.Object(
51
51
  export type BashToolInput = Static<typeof BashParameters>
52
52
  export type BashCreationResult = {
53
53
  id: string
54
+ run: number
54
55
  label: string
55
56
  state: BashTaskMetadata["state"]
56
57
  latestOutcome: BashTaskMetadata["latestOutcome"]
@@ -69,6 +70,7 @@ export function registerBashTool(pi: ExtensionAPI, options: { getConfig: () => A
69
70
  promptSnippet: "Run a background Bash command with durable output and task controls",
70
71
  promptGuidelines: [
71
72
  "Use bash_bg for background shell work, not as a replacement for normal bash. Use task_list/task_output/task_stop/task_discard with its b_ ID.",
73
+ "Detached bash_bg tasks automatically notify you on success or failure and wake an idle parent; no completion polling is needed. Synchronous completion and explicit stops do not notify.",
72
74
  "Use task_input on a running bash_bg task for literal stdin, optionally eof:true to close stdin; agent Follow-up/Steer modes are not supported. Commands never restart automatically."
73
75
  ],
74
76
  parameters: BashParameters,
@@ -176,6 +178,7 @@ export function registerBashTool(pi: ExtensionAPI, options: { getConfig: () => A
176
178
  if (loaded.status !== "ok" || loaded.metadata.kind !== "bash") throw new Error(`Could not read accepted task ${paths.taskRef}`)
177
179
  const result: BashCreationResult = {
178
180
  id: paths.taskRef,
181
+ run: 1,
179
182
  label: loaded.metadata.label,
180
183
  state: loaded.metadata.state,
181
184
  latestOutcome: loaded.metadata.latestOutcome,
@@ -188,7 +191,7 @@ export function registerBashTool(pi: ExtensionAPI, options: { getConfig: () => A
188
191
  content: [
189
192
  {
190
193
  type: "text" as const,
191
- text: `${result.id} ${JSON.stringify(result.label)}: ${result.state}${result.latestOutcome ? ` / ${result.latestOutcome}` : ""}${detached ? " (detached)" : ""}\n${result.output.text}`
194
+ text: `${result.id} run=1 ${JSON.stringify(result.label)}: ${result.state}${result.latestOutcome ? ` / ${result.latestOutcome}` : ""}${detached ? " (detached)" : ""}${result.output.queueReason ? ` waiting=${result.output.queueReason}` : ""}\n${result.output.text}`
192
195
  }
193
196
  ],
194
197
  details: result
@@ -346,7 +349,7 @@ class BashRuntime implements ResidentAgent {
346
349
  this.fail(error)
347
350
  throw error
348
351
  }
349
- return { delivery: "stdin" as const, queuePosition: null, queuedFollowUps: 0 }
352
+ return { run: 1, delivery: "stdin" as const, queuePosition: null, queuedFollowUps: 0 }
350
353
  })
351
354
  this.#inputLane = operation.catch(() => {})
352
355
  if (!options.signal) return operation
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
1
2
  import { lstat, open } from "node:fs/promises"
2
3
  import {
3
4
  type AgentSession,
@@ -12,10 +13,12 @@ import {
12
13
  SettingsManager,
13
14
  type Skill
14
15
  } from "@earendil-works/pi-coding-agent"
16
+ import { Type } from "typebox"
17
+ import { Value } from "typebox/value"
15
18
  import { MODEL_ALIASES, type ModelAliasChoice } from "./config.js"
16
19
  import { getAgentCoordinator } from "./coordinator.js"
17
20
  import type { AgentDefinition, AgentThinkingLevel } from "./definitions.js"
18
- import type { TaskStoragePaths } from "./state.js"
21
+ import { mutateTaskMetadata, TaskProgressSchema, type TaskStoragePaths } from "./state.js"
19
22
 
20
23
  const PROMPT_EXTENSION_PATH = "<inline:lovely-agent-prompt>"
21
24
  const CREATION_TOOL_NAMES = new Set(["agent"])
@@ -51,6 +54,8 @@ export type ChildSessionHandle = {
51
54
  extensionsResult: LoadExtensionsResult
52
55
  depth: number
53
56
  allowAgents: boolean
57
+ /** Binds tool execution to this immutable run, including delayed async callbacks. */
58
+ prompt(runId: string, text: string, options?: PromptOptions): Promise<void>
54
59
  dispose(): void
55
60
  }
56
61
 
@@ -126,6 +131,8 @@ export async function createChildSession(options: CreateChildSessionOptions): Pr
126
131
  const agentDir = options.agentDir ?? getAgentDir()
127
132
  const settingsManager = SettingsManager.create(options.cwd, agentDir)
128
133
  settingsManager.setProjectTrusted(options.projectTrusted)
134
+ const runContext = new AsyncLocalStorage<string>()
135
+ const lifetime = new AbortController()
129
136
  const resourceLoader = new DefaultResourceLoader({
130
137
  cwd: options.cwd,
131
138
  agentDir,
@@ -137,6 +144,40 @@ export async function createChildSession(options: CreateChildSessionOptions): Pr
137
144
  name: "lovely-agent-prompt",
138
145
  hidden: true,
139
146
  factory(pi) {
147
+ pi.registerTool({
148
+ name: "task_update",
149
+ label: "Task Update",
150
+ description:
151
+ "Report a short progress line for your own current task run (up to 240 characters). Does not change its label or lifecycle state, or notify the parent.",
152
+ promptSnippet: "Update your task's progress in the panel and task inspection",
153
+ promptGuidelines: [
154
+ "Use task_update for meaningful phase changes or blockers: what is achieved and what remains. Do not report every tool call or invent percentages. Updates do not interrupt or wake the parent."
155
+ ],
156
+ parameters: Type.Object({ progress: TaskProgressSchema }, { additionalProperties: false }),
157
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
158
+ const runId = runContext.getStore()
159
+ if (!runId) throw new Error("task_update requires a managed task run")
160
+ if (!Value.Check(TaskProgressSchema, params.progress)) throw new Error("progress must be 1–240 characters")
161
+ const progress = params.progress.replace(/\s+/g, " ").trim()
162
+ if (!progress || /\p{Cc}/u.test(progress)) throw new Error("progress must be nonempty text without control characters")
163
+ const childSessionId = ctx.sessionManager.getSessionId()
164
+ await mutateTaskMetadata(options.paths, metadata => {
165
+ signal?.throwIfAborted()
166
+ lifetime.signal.throwIfAborted()
167
+ if (
168
+ metadata.kind !== "agent" ||
169
+ metadata.childSessionId !== childSessionId ||
170
+ metadata.activeRun?.id !== runId ||
171
+ metadata.state !== "running" ||
172
+ metadata.discardedAt !== null
173
+ ) {
174
+ throw new Error("task_update can only update its own active run")
175
+ }
176
+ return { ...metadata, progress, updatedAt: Date.now() }
177
+ })
178
+ return { content: [{ type: "text", text: "Progress updated." }], details: { progress } }
179
+ }
180
+ })
140
181
  pi.on("before_agent_start", event => ({
141
182
  systemPrompt: buildDefinitionSystemPrompt(event.systemPromptOptions)
142
183
  }))
@@ -173,7 +214,6 @@ export async function createChildSession(options: CreateChildSessionOptions): Pr
173
214
  throw new Error(`Child session identity mismatch: expected ${options.expectedSessionId}, found ${result.session.sessionId}`)
174
215
  }
175
216
 
176
- const lifetime = new AbortController()
177
217
  const unbindContext = getAgentCoordinator().bindSessionContext(result.session.sessionId, {
178
218
  depth: policy.depth,
179
219
  allowAgents: policy.allowAgents,
@@ -193,6 +233,7 @@ export async function createChildSession(options: CreateChildSessionOptions): Pr
193
233
  extensionsResult: result.extensionsResult,
194
234
  depth: policy.depth,
195
235
  allowAgents: policy.allowAgents,
236
+ prompt: (runId, text, promptOptions) => runContext.run(runId, () => result.session.prompt(text, promptOptions)),
196
237
  dispose() {
197
238
  if (disposed) return
198
239
  disposed = true
@@ -39,7 +39,9 @@ export type ResidentAgent = {
39
39
  export type ResidentInputOptions = { background?: boolean; signal?: AbortSignal; eof?: boolean }
40
40
 
41
41
  export type ResidentInputResult = {
42
+ run: number
42
43
  delivery: "followup" | "steer" | "stdin"
44
+ conversionReason?: string
43
45
  queuePosition: number | null
44
46
  queuedFollowUps: number
45
47
  completed?: TaskMetadata
@@ -290,7 +290,8 @@ function parseTools(value: unknown, knownTools: readonly string[]): { value?: st
290
290
  const tools = raw.map(tool => (tool as string).trim())
291
291
  const duplicate = tools.find((tool, index) => tools.indexOf(tool) !== index)
292
292
  if (duplicate) return { error: `duplicate tool "${duplicate}"` }
293
- const unknown = tools.filter(tool => !knownTools.includes(tool))
293
+ // This tool is registered only inside managed children, not in the parent's roster.
294
+ const unknown = tools.filter(tool => tool !== "task_update" && !knownTools.includes(tool))
294
295
  if (unknown.length > 0) return { error: `unknown tools: ${unknown.sort(compareText).join(", ")}` }
295
296
  return { value: tools }
296
297
  }
@@ -164,7 +164,7 @@ export default function lovelyAgentsExtension(pi: ExtensionAPI) {
164
164
  pi.sendMessage(
165
165
  {
166
166
  customType: NOTIFICATION_CUSTOM_TYPE,
167
- content: `User manually discarded task ${id} and its descendants.\nFiles are archived; these tasks cannot receive further input.`,
167
+ content: `User manually discarded task ${id} and its descendants.\nFiles stay at their original paths for read-only inspection; these tasks cannot receive further input.`,
168
168
  display: true
169
169
  },
170
170
  { deliverAs: "steer", triggerTurn: false }