@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,400 @@
1
+ import { BorderedLoader, CustomEditor, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent"
2
+ import { ScopedConfigEditor } from "@xl0/pi-lovely-config"
3
+ import { controlTaskLifecycle, recoverProviderTuple, registerAgentTool, registerTaskInputTool, sendTaskInput } from "./agent.js"
4
+ import { registerBashTool } from "./bash.js"
5
+ import { type AgentsConfig, type AgentsConfigWarning, createAgentsConfigSpec, defaultAgentsConfig, resolveAgentsConfig } from "./config.js"
6
+ import { getAgentCoordinator, getBashCoordinator } from "./coordinator.js"
7
+ import { discoverAgentDefinitions } from "./definitions.js"
8
+ import { reconcileParentTasks, recoverOwnedTaskTree, stopOwnedTaskTree } from "./lifecycle.js"
9
+ import { type ManagementUiOptions, openManagementUi, openTaskManagementUi, stopFixtureTimersFor } from "./management.js"
10
+ import {
11
+ clearNotificationInFlight,
12
+ NOTIFICATION_CUSTOM_TYPE,
13
+ notificationDetails,
14
+ notificationRouteKey,
15
+ observeNotification,
16
+ reconcileParentNotifications
17
+ } from "./notifications.js"
18
+ import { renderAgentNotification } from "./rendering.js"
19
+ import { createTaskPanel } from "./task-panel.js"
20
+ import { loadTaskList, registerRosterTool, registerTaskTools } from "./tools.js"
21
+ import { bindTaskUpdateRoute } from "./updates.js"
22
+
23
+ type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>
24
+ const CREATION_TOOLS = new Set(["agent", "agent_roster"])
25
+ const TASK_TOOLS = new Set(["task_list", "task_output", "task_input", "task_stop", "task_discard"])
26
+
27
+ export default function lovelyAgentsExtension(pi: ExtensionAPI) {
28
+ let configValue = defaultAgentsConfig
29
+ let configWarnings: AgentsConfigWarning[] = []
30
+ let currentDepth = 0
31
+ let unbindNotificationRoute: (() => void) | undefined
32
+ let taskPanel: ReturnType<typeof createTaskPanel> | undefined
33
+ let previousEditorFactory: EditorFactory | undefined
34
+ let taskEditorFactory: EditorFactory | undefined
35
+ let unbindToolUpdates: (() => void) | undefined
36
+ let unbindDisposal: (() => void) | undefined
37
+ let toolRevision = 0
38
+ let toolSession = 0
39
+ let agentInputEnabled = true
40
+ let bashInputEnabled = false
41
+ const hiddenTools = new Set<string>()
42
+
43
+ pi.registerMessageRenderer(NOTIFICATION_CUSTOM_TYPE, renderAgentNotification)
44
+
45
+ const disposeBindings = () => {
46
+ toolRevision++
47
+ toolSession++
48
+ unbindToolUpdates?.()
49
+ unbindToolUpdates = undefined
50
+ unbindNotificationRoute?.()
51
+ unbindNotificationRoute = undefined
52
+ unbindDisposal?.()
53
+ unbindDisposal = undefined
54
+ }
55
+
56
+ const refreshToolVisibility = async (ctx: ExtensionContext) => {
57
+ const revision = ++toolRevision
58
+ const session = getAgentCoordinator().getSessionContext(ctx.sessionManager.getSessionId())
59
+ const active = pi.getActiveTools()
60
+ const canCreateAgent =
61
+ session?.allowAgents !== false &&
62
+ (session?.depth ?? 0) < configValue.maxDepth &&
63
+ (active.includes("agent") || hiddenTools.has("agent"))
64
+ const canCreateBash = configValue.backgroundBash && (active.includes("bash_bg") || hiddenTools.has("bash_bg"))
65
+ const owned = (await loadTaskList(ctx.cwd, ctx.sessionManager.getSessionId())).details
66
+ if (revision !== toolRevision) return
67
+ const agentInput = canCreateAgent || owned.tasks.some(task => task.kind === "agent")
68
+ const bashInput = canCreateBash || owned.tasks.some(task => task.kind === "bash")
69
+ if (agentInput !== agentInputEnabled || bashInput !== bashInputEnabled) {
70
+ agentInputEnabled = agentInput
71
+ bashInputEnabled = bashInput
72
+ registerInputs()
73
+ }
74
+ const canControl = canCreateAgent || canCreateBash || owned.total > 0 || owned.diagnostics.length > 0
75
+ const current = pi.getActiveTools()
76
+ const allowed = (name: string) =>
77
+ CREATION_TOOLS.has(name)
78
+ ? canCreateAgent
79
+ : name === "bash_bg"
80
+ ? canCreateBash
81
+ : name === "task_input"
82
+ ? agentInput || bashInput
83
+ : TASK_TOOLS.has(name)
84
+ ? canControl
85
+ : true
86
+ const next = current.filter(name => {
87
+ if (allowed(name)) return true
88
+ hiddenTools.add(name)
89
+ return false
90
+ })
91
+ // Restore only tools this extension hid, never bypass an SDK/Definition allowlist.
92
+ for (const name of hiddenTools) {
93
+ if (!allowed(name)) continue
94
+ if (!next.includes(name)) next.push(name)
95
+ hiddenTools.delete(name)
96
+ }
97
+ if (next.join("\0") !== current.join("\0")) pi.setActiveTools(next)
98
+ }
99
+ const updateTools = (ctx: ExtensionContext) => {
100
+ const revision = toolRevision + 1
101
+ void refreshToolVisibility(ctx).catch(error => {
102
+ if (revision === toolRevision) ctx.ui.notify(`Lovely Agents tool visibility: ${errorMessage(error)}`, "error")
103
+ })
104
+ }
105
+ const registerInputs = () =>
106
+ registerTaskInputTool(pi, {
107
+ getConfig: () => configValue,
108
+ agentInputEnabled: () => agentInputEnabled,
109
+ bashInputEnabled: () => bashInputEnabled
110
+ })
111
+
112
+ const applyConfig = (value: AgentsConfig, warnings: AgentsConfigWarning[], ctx: ExtensionContext) => {
113
+ configValue = value
114
+ configWarnings = warnings
115
+ getAgentCoordinator(value.maxConcurrency).setMaxConcurrency(value.maxConcurrency)
116
+ getBashCoordinator(value.maxBashConcurrency).setMaxConcurrency(value.maxBashConcurrency)
117
+ notifyConfigWarnings(ctx, warnings)
118
+ registerAgentTool(pi, { getConfig: () => configValue, canDelegate: () => currentDepth + 1 < configValue.maxDepth })
119
+ registerInputs()
120
+ updateTools(ctx)
121
+ }
122
+ const loadConfig = (ctx: ExtensionContext) => {
123
+ const config = createAgentsConfigSpec(ctx).load(ctx.cwd)
124
+ const loaded = resolveAgentsConfig(config)
125
+ applyConfig(loaded.value, loaded.warnings, ctx)
126
+ return config
127
+ }
128
+ const managementOptions = (ctx: ExtensionContext): ManagementUiOptions => ({
129
+ discoverDefinitions: () =>
130
+ discoverAgentDefinitions({
131
+ cwd: ctx.cwd,
132
+ projectTrusted: ctx.isProjectTrusted(),
133
+ toolNames: pi.getAllTools().map(tool => tool.name),
134
+ models: ctx.modelRegistry.getAll()
135
+ }),
136
+ loadTasks: async () => (await loadTaskList(ctx.cwd, ctx.sessionManager.getSessionId(), { includeInputPreviews: true })).details,
137
+ focusTasks: async () => {
138
+ await taskPanel?.refresh()
139
+ taskPanel?.focus()
140
+ },
141
+ inputTask: async (id, content, delivery, eof) => {
142
+ const options = { getConfig: () => configValue }
143
+ const send = (signal?: AbortSignal) =>
144
+ sendTaskInput(ctx, options, id, content, delivery === "stdin" ? undefined : delivery, signal, eof === undefined ? {} : { eof })
145
+ if (configValue.backgroundAgents && delivery !== "stdin") {
146
+ await send()
147
+ return
148
+ }
149
+ const failed = await ctx.ui.custom<{ error: unknown; cancelled: boolean } | undefined>((tui, theme, _keys, done) => {
150
+ const loader = new BorderedLoader(tui, theme, `${delivery === "stdin" ? "Sending" : "Running"} ${delivery} for ${id}…`)
151
+ // Cancel the pending operation; already-written stdin cannot be undone.
152
+ void send(loader.signal).then(
153
+ () => done(undefined),
154
+ error => done({ error, cancelled: loader.signal.aborted })
155
+ )
156
+ return loader
157
+ })
158
+ if (failed?.cancelled) return false
159
+ if (failed) throw failed.error
160
+ },
161
+ controlTask: async (id, action) => {
162
+ await controlTaskLifecycle(ctx, id, action)
163
+ if (action === "discard") {
164
+ pi.sendMessage(
165
+ {
166
+ customType: NOTIFICATION_CUSTOM_TYPE,
167
+ content: `User manually discarded task ${id} and its descendants.\nFiles are archived; these tasks cannot receive further input.`,
168
+ display: true
169
+ },
170
+ { deliverAs: "steer", triggerTurn: false }
171
+ )
172
+ }
173
+ },
174
+ openConfig: async () => {
175
+ const config = loadConfig(ctx)
176
+ await ctx.ui.custom<void>(
177
+ (tui, theme, _keybindings, done) =>
178
+ new ScopedConfigEditor({
179
+ tui,
180
+ theme: theme as unknown as ConstructorParameters<typeof ScopedConfigEditor>[0]["theme"],
181
+ config,
182
+ onChange(config) {
183
+ const loaded = resolveAgentsConfig(config)
184
+ applyConfig(loaded.value, loaded.warnings, ctx)
185
+ },
186
+ done
187
+ })
188
+ )
189
+ }
190
+ })
191
+
192
+ pi.on("session_start", async (_event, ctx) => {
193
+ const parentSessionId = ctx.sessionManager.getSessionId()
194
+ disposeBindings()
195
+ const toolSessionId = ++toolSession
196
+ const disposeSignal = getAgentCoordinator().getSessionContext(parentSessionId)?.disposeSignal
197
+ if (disposeSignal) {
198
+ disposeSignal.addEventListener("abort", disposeBindings, { once: true })
199
+ unbindDisposal = () => disposeSignal.removeEventListener("abort", disposeBindings)
200
+ }
201
+ unbindToolUpdates = bindTaskUpdateRoute(ctx.cwd, parentSessionId, () => {
202
+ if (toolSessionId === toolSession) updateTools(ctx)
203
+ })
204
+ unbindNotificationRoute = getAgentCoordinator().bindNotificationRoute(notificationRouteKey(ctx.cwd, parentSessionId), notification => {
205
+ pi.sendMessage(
206
+ {
207
+ customType: NOTIFICATION_CUSTOM_TYPE,
208
+ content: notification.content,
209
+ display: true,
210
+ details: { notificationId: notification.id, taskRef: notification.taskRef }
211
+ },
212
+ { triggerTurn: true, deliverAs: "steer" }
213
+ )
214
+ })
215
+ taskPanel?.dispose()
216
+ taskPanel = undefined
217
+ if (ctx.mode === "tui") {
218
+ const options = managementOptions(ctx)
219
+ taskPanel = createTaskPanel(ctx, {
220
+ loadTasks: options.loadTasks,
221
+ openSelection: selection => openTaskManagementUi(ctx, options, selection)
222
+ })
223
+ }
224
+ try {
225
+ currentDepth = getAgentCoordinator().getSessionContext(parentSessionId)?.depth ?? 0
226
+ loadConfig(ctx)
227
+ } catch (error) {
228
+ configValue = defaultAgentsConfig
229
+ configWarnings = []
230
+ getAgentCoordinator(defaultAgentsConfig.maxConcurrency).setMaxConcurrency(defaultAgentsConfig.maxConcurrency)
231
+ ctx.ui.notify(`Lovely Agents config error: ${errorMessage(error)}`, "error")
232
+ }
233
+ if (_event.reason !== "reload") {
234
+ try {
235
+ const reconciled = await reconcileParentTasks(ctx.cwd, parentSessionId)
236
+ if (reconciled.interrupted > 0) {
237
+ ctx.ui.notify(`Lovely Agents marked ${reconciled.interrupted} stale task(s) interrupted.`, "warning")
238
+ }
239
+ if (reconciled.diagnostics.length > 0) {
240
+ ctx.ui.notify(`Lovely Agents skipped ${reconciled.diagnostics.length} invalid task(s) during recovery.`, "warning")
241
+ }
242
+ } catch (error) {
243
+ ctx.ui.notify(`Lovely Agents recovery failed: ${errorMessage(error)}`, "warning")
244
+ }
245
+ }
246
+ try {
247
+ const notifications = await reconcileParentNotifications(ctx.cwd, parentSessionId, ctx.sessionManager.getBranch())
248
+ if (notifications.diagnostics.length > 0) {
249
+ ctx.ui.notify(`Lovely Agents skipped ${notifications.diagnostics.length} notification task(s).`, "warning")
250
+ }
251
+ } catch (error) {
252
+ ctx.ui.notify(`Lovely Agents notification recovery failed: ${errorMessage(error)}`, "warning")
253
+ }
254
+ if (ctx.mode === "tui") {
255
+ previousEditorFactory = ctx.ui.getEditorComponent()
256
+ const baseFactory: EditorFactory = previousEditorFactory ?? ((tui, theme, keybindings) => new CustomEditor(tui, theme, keybindings))
257
+ taskEditorFactory = (tui, theme, keybindings) => {
258
+ const editor = baseFactory(tui, theme, keybindings)
259
+ return new Proxy(editor, {
260
+ get(target, property) {
261
+ if (property === "handleInput") {
262
+ return (data: string) => {
263
+ if (taskPanel?.handleInput(data, target.getText() === "")) return
264
+ target.handleInput(data)
265
+ }
266
+ }
267
+ const value = Reflect.get(target, property, target)
268
+ return typeof value === "function" ? value.bind(target) : value
269
+ },
270
+ set(target, property, value) {
271
+ return Reflect.set(target, property, value, target)
272
+ }
273
+ })
274
+ }
275
+ ctx.ui.setEditorComponent(taskEditorFactory)
276
+ }
277
+ await taskPanel?.refresh().catch(error => ctx.ui.notify(`Lovely Agents status refresh failed: ${errorMessage(error)}`, "warning"))
278
+ await refreshToolVisibility(ctx)
279
+ })
280
+
281
+ pi.on("message_end", async (event, ctx) => {
282
+ if (event.message.role !== "custom" || event.message.customType !== NOTIFICATION_CUSTOM_TYPE) return
283
+ const details = notificationDetails(event.message.details)
284
+ if (!details) return
285
+ try {
286
+ await observeNotification(ctx.cwd, ctx.sessionManager.getSessionId(), details.taskRef, details.notificationId)
287
+ } catch (error) {
288
+ ctx.ui.notify(`Lovely Agents could not mark notification delivered: ${errorMessage(error)}`, "warning")
289
+ }
290
+ })
291
+
292
+ pi.on("session_shutdown", (event, ctx) => {
293
+ disposeBindings()
294
+ if (event.reason === "reload" && hiddenTools.size > 0) {
295
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...hiddenTools])])
296
+ }
297
+ hiddenTools.clear()
298
+ taskPanel?.dispose()
299
+ taskPanel = undefined
300
+ if (ctx.mode === "tui") {
301
+ if (ctx.ui.getEditorComponent() === taskEditorFactory) ctx.ui.setEditorComponent(previousEditorFactory)
302
+ taskEditorFactory = undefined
303
+ previousEditorFactory = undefined
304
+ }
305
+ if (event.reason !== "reload") clearNotificationInFlight(ctx.cwd, ctx.sessionManager.getSessionId())
306
+ })
307
+
308
+ pi.on("turn_end", event => {
309
+ const tuple = successfulTurnTuple(event.message)
310
+ if (tuple) recoverProviderTuple(tuple)
311
+ })
312
+
313
+ pi.registerCommand("lovely-agents", {
314
+ description: "Manage Lovely Agent definitions, tasks, and settings",
315
+ async handler(_args, ctx) {
316
+ if (ctx.mode !== "tui") return
317
+ try {
318
+ await openManagementUi(ctx, managementOptions(ctx))
319
+ } catch (error) {
320
+ ctx.ui.notify(`Lovely Agents management error: ${errorMessage(error)}`, "error")
321
+ }
322
+ }
323
+ })
324
+
325
+ pi.registerCommand("continue", {
326
+ description: "Retry the latest errored or aborted turn",
327
+ handler: async (_args, ctx) => {
328
+ if (!ctx.isIdle()) {
329
+ ctx.ui.notify("Agent is still running", "warning")
330
+ return
331
+ }
332
+ if (!latestReplyWasInterrupted(ctx.sessionManager.getBranch())) return
333
+ try {
334
+ const recovered = await recoverOwnedTaskTree(ctx.cwd, ctx.sessionManager.getSessionId())
335
+ if (recovered.diagnostics.length > 0) {
336
+ ctx.ui.notify(`Lovely Agents skipped ${recovered.diagnostics.length} task(s) during recovery.`, "warning")
337
+ }
338
+ } catch (error) {
339
+ ctx.ui.notify(`Lovely Agents recovery failed: ${errorMessage(error)}`, "warning")
340
+ return
341
+ }
342
+ pi.sendMessage(
343
+ {
344
+ customType: "lovely-agents:continue",
345
+ content: [],
346
+ display: false
347
+ },
348
+ { triggerTurn: true, deliverAs: "followUp" }
349
+ )
350
+ }
351
+ })
352
+
353
+ registerRosterTool(pi, {
354
+ getConfig: () => configValue,
355
+ getConfigWarnings: () => configWarnings,
356
+ getDepth: () => currentDepth
357
+ })
358
+ registerAgentTool(pi, { getConfig: () => configValue, canDelegate: () => currentDepth + 1 < configValue.maxDepth })
359
+ registerBashTool(pi, { getConfig: () => configValue })
360
+ registerInputs()
361
+ registerTaskTools(pi, {
362
+ beforeParentLeaseRelease: async (cwd, parentSessionId) => {
363
+ try {
364
+ await stopFixtureTimersFor(cwd, parentSessionId)
365
+ } finally {
366
+ await stopOwnedTaskTree(cwd, parentSessionId)
367
+ }
368
+ }
369
+ })
370
+ }
371
+
372
+ export function successfulTurnTuple(message: {
373
+ role: string
374
+ stopReason?: string
375
+ provider?: string
376
+ model?: string
377
+ }): { provider: string; model: string } | undefined {
378
+ if (message.role !== "assistant" || message.stopReason !== "stop" || !message.provider || !message.model) {
379
+ return undefined
380
+ }
381
+ return { provider: message.provider, model: message.model }
382
+ }
383
+
384
+ export function latestReplyWasInterrupted(entries: readonly SessionEntry[]): boolean {
385
+ for (let index = entries.length - 1; index >= 0; index--) {
386
+ const entry = entries[index]
387
+ if (entry?.type !== "message" || entry.message.role !== "assistant") continue
388
+ return entry.message.stopReason === "error" || entry.message.stopReason === "aborted"
389
+ }
390
+ return false
391
+ }
392
+
393
+ function notifyConfigWarnings(ctx: ExtensionContext, warnings: readonly AgentsConfigWarning[]): void {
394
+ if (warnings.length === 0) return
395
+ ctx.ui.notify(warnings.map(warning => `${warning.path}: ${warning.message}`).join("\n"), "warning")
396
+ }
397
+
398
+ function errorMessage(error: unknown): string {
399
+ return error instanceof Error ? error.message : String(error)
400
+ }
@@ -0,0 +1,251 @@
1
+ import { lstat, readdir } from "node:fs/promises"
2
+ import { getAgentCoordinator } from "./coordinator.js"
3
+ import { appendTaskNotification, prepareTaskNotification } from "./notifications.js"
4
+ import {
5
+ acquireParentLease,
6
+ appendHistoryLog,
7
+ archivedTaskStoragePaths,
8
+ archiveTaskStorage,
9
+ mutateTaskMetadata,
10
+ parentStoragePaths,
11
+ readTaskIdentity,
12
+ readTaskMetadata,
13
+ releaseParentLease,
14
+ TASK_REFERENCE_PATTERN,
15
+ type TaskMetadata,
16
+ type TaskStoragePaths,
17
+ taskStoragePaths
18
+ } from "./state.js"
19
+
20
+ export type ReconciliationResult = {
21
+ interrupted: number
22
+ diagnostics: string[]
23
+ }
24
+
25
+ export type RecoveryResult = {
26
+ resumed: number
27
+ diagnostics: string[]
28
+ }
29
+
30
+ const DISCARD_OPERATIONS = Symbol.for("@xl0/pi-lovely-agents/discards/v1")
31
+
32
+ /** Stops and archives an owned subtree, decoding only identity for unsupported versions. */
33
+ export function discardTask(paths: TaskStoragePaths, visited = new Set([paths.parentSessionId])): Promise<void> {
34
+ const global = globalThis as typeof globalThis & { [DISCARD_OPERATIONS]?: Map<string, Promise<void>> }
35
+ global[DISCARD_OPERATIONS] ??= new Map()
36
+ const operations = global[DISCARD_OPERATIONS]
37
+ const existing = operations.get(paths.taskDirectory)
38
+ if (existing) return existing
39
+ const pending = (async () => {
40
+ const identity = await readTaskIdentity(paths)
41
+ if (!identity) {
42
+ if (await readTaskIdentity(archivedTaskStoragePaths(paths))) return
43
+ throw new Error(`Unknown Task Reference: ${paths.taskRef}`)
44
+ }
45
+ if (identity.kind === "agent" && visited.has(identity.childSessionId)) throw new Error("Cyclic task ownership")
46
+ const loaded = await readTaskMetadata(paths)
47
+ if (loaded.status === "ok") {
48
+ await stopTask(paths)
49
+ await mutateTaskMetadata(paths, metadata => ({
50
+ ...metadata,
51
+ discardedAt: metadata.discardedAt ?? Date.now(),
52
+ queuedFollowUps: [],
53
+ updatedAt: Date.now()
54
+ }))
55
+ // Fence input that raced the first stop before the tombstone committed.
56
+ await stopTask(paths)
57
+ } else if (loaded.status === "invalid" && loaded.diagnostic.code === "unsupported-version") {
58
+ await getAgentCoordinator().getResident(paths.taskDirectory)?.stop()
59
+ } else {
60
+ throw new Error(loaded.status === "invalid" ? loaded.diagnostic.message : `Missing metadata: ${paths.metadata}`)
61
+ }
62
+ if (identity.kind === "agent") {
63
+ const descendants = new Set([...visited, identity.childSessionId])
64
+ const lease = await acquireParentLease(paths.workspace, identity.childSessionId)
65
+ try {
66
+ for (const child of await directTaskPaths(paths.workspace, identity.childSessionId)) {
67
+ await discardTask(child, descendants)
68
+ }
69
+ } finally {
70
+ await releaseParentLease(lease)
71
+ }
72
+ }
73
+ await archiveTaskStorage(paths)
74
+ })().finally(() => {
75
+ if (operations.get(paths.taskDirectory) === pending) operations.delete(paths.taskDirectory)
76
+ })
77
+ operations.set(paths.taskDirectory, pending)
78
+ return pending
79
+ }
80
+
81
+ /** Marks stale direct work interrupted after a non-reload parent session start. */
82
+ export async function reconcileParentTasks(cwd: string, parentSessionId: string): Promise<ReconciliationResult> {
83
+ const parent = parentStoragePaths(cwd, parentSessionId)
84
+ if (!(await isDirectory(parent.parentDirectory))) return { interrupted: 0, diagnostics: [] }
85
+ await acquireParentLease(cwd, parentSessionId)
86
+
87
+ const result: ReconciliationResult = { interrupted: 0, diagnostics: [] }
88
+ for (const paths of await directTaskPaths(cwd, parentSessionId)) {
89
+ const loaded = await readTaskMetadata(paths)
90
+ if (loaded.status !== "ok") {
91
+ if (loaded.status === "invalid") result.diagnostics.push(`${paths.taskDirectory}: ${loaded.diagnostic.message}`)
92
+ continue
93
+ }
94
+ if (getAgentCoordinator().getResident(paths.taskDirectory)) continue
95
+ if (loaded.metadata.state === "idle" || loaded.metadata.state === "interrupted") {
96
+ if (loaded.metadata.queuedFollowUps.length > 0) {
97
+ await mutateTaskMetadata(paths, metadata => ({ ...metadata, queuedFollowUps: [], updatedAt: Date.now() }))
98
+ }
99
+ continue
100
+ }
101
+ let notification: TaskMetadata["notifications"][number] | undefined
102
+ if (loaded.metadata.activeRun?.background) {
103
+ try {
104
+ notification = await prepareTaskNotification(paths, loaded.metadata, loaded.metadata.activeRun, "interruption", "interrupted")
105
+ } catch (error) {
106
+ result.diagnostics.push(`${paths.taskDirectory}: could not prepare interruption notification: ${errorMessage(error)}`)
107
+ }
108
+ }
109
+ const settled: { run: TaskMetadata["activeRun"] } = { run: null }
110
+ const updatedAt = Date.now()
111
+ const changed = await mutateTaskMetadata(paths, metadata => {
112
+ if (metadata.state === "idle") return metadata
113
+ settled.run = metadata.activeRun
114
+ return {
115
+ ...metadata,
116
+ state: "interrupted",
117
+ latestOutcome: "interrupted",
118
+ activeRun: null,
119
+ queuedFollowUps: [],
120
+ updatedAt,
121
+ notifications: notification ? appendTaskNotification(metadata.notifications, notification) : metadata.notifications
122
+ }
123
+ })
124
+ if (!settled.run || changed.latestOutcome !== "interrupted") continue
125
+ await appendHistoryLog(paths, {
126
+ type: "run-end",
127
+ sequence: settled.run.sequence,
128
+ outcome: "interrupted",
129
+ timestamp: updatedAt
130
+ })
131
+ result.interrupted++
132
+ }
133
+ return result
134
+ }
135
+
136
+ /** Stops active work and clears queued input throughout an owned task tree. */
137
+ export async function stopOwnedTaskTree(cwd: string, parentSessionId: string): Promise<void> {
138
+ await stopPartition(cwd, parentSessionId, new Set())
139
+ }
140
+
141
+ /** Requeues resident suspended work in the exact owned descendant tree. */
142
+ export async function recoverOwnedTaskTree(cwd: string, parentSessionId: string): Promise<RecoveryResult> {
143
+ const result: RecoveryResult = { resumed: 0, diagnostics: [] }
144
+ await recoverPartition(cwd, parentSessionId, new Set(), result)
145
+ return result
146
+ }
147
+
148
+ /** Stops one retained task through its resident runtime when available. */
149
+ export async function stopTask(paths: TaskStoragePaths): Promise<void> {
150
+ const resident = getAgentCoordinator().getResident(paths.taskDirectory)
151
+ if (resident) await resident.stop()
152
+ else await settleStopped(paths)
153
+ }
154
+
155
+ async function stopPartition(cwd: string, parentSessionId: string, visited: Set<string>): Promise<void> {
156
+ if (visited.has(parentSessionId)) return
157
+ visited.add(parentSessionId)
158
+ const parent = parentStoragePaths(cwd, parentSessionId)
159
+ if (!(await isDirectory(parent.parentDirectory))) return
160
+ const lease = await acquireParentLease(cwd, parentSessionId)
161
+ const errors: unknown[] = []
162
+
163
+ try {
164
+ for (const paths of await directTaskPaths(cwd, parentSessionId)) {
165
+ const loaded = await readTaskMetadata(paths)
166
+ if (loaded.status !== "ok") continue
167
+ try {
168
+ await stopTask(paths)
169
+ } catch (error) {
170
+ errors.push(error)
171
+ }
172
+ try {
173
+ if (loaded.metadata.kind === "agent") await stopPartition(cwd, loaded.metadata.childSessionId, visited)
174
+ } catch (error) {
175
+ errors.push(error)
176
+ }
177
+ }
178
+ } finally {
179
+ await releaseParentLease(lease)
180
+ }
181
+ if (errors.length > 0) throw new AggregateError(errors, `Failed to stop ${errors.length} owned task operation(s)`)
182
+ }
183
+
184
+ async function recoverPartition(cwd: string, parentSessionId: string, visited: Set<string>, result: RecoveryResult): Promise<void> {
185
+ if (visited.has(parentSessionId)) return
186
+ visited.add(parentSessionId)
187
+ const parent = parentStoragePaths(cwd, parentSessionId)
188
+ if (!(await isDirectory(parent.parentDirectory))) return
189
+
190
+ for (const paths of await directTaskPaths(cwd, parentSessionId)) {
191
+ const loaded = await readTaskMetadata(paths)
192
+ if (loaded.status !== "ok") {
193
+ if (loaded.status === "invalid") result.diagnostics.push(`${paths.taskDirectory}: ${loaded.diagnostic.message}`)
194
+ continue
195
+ }
196
+ if (loaded.metadata.kind !== "agent") continue
197
+ if (loaded.metadata.discardedAt === null && loaded.metadata.state === "suspended" && loaded.metadata.activeRun?.background) {
198
+ const resident = getAgentCoordinator().getResident(paths.taskDirectory)
199
+ if (!resident?.recover) result.diagnostics.push(`${paths.taskDirectory}: suspended task has no recoverable resident`)
200
+ else if (await resident.recover()) result.resumed++
201
+ }
202
+ await recoverPartition(cwd, loaded.metadata.childSessionId, visited, result)
203
+ }
204
+ }
205
+
206
+ async function settleStopped(paths: TaskStoragePaths): Promise<void> {
207
+ const settled: { run: TaskMetadata["activeRun"] } = { run: null }
208
+ const updatedAt = Date.now()
209
+ await mutateTaskMetadata(paths, metadata => {
210
+ if (metadata.state === "idle" || metadata.state === "interrupted" || !metadata.activeRun) {
211
+ return metadata.queuedFollowUps.length === 0 ? metadata : { ...metadata, queuedFollowUps: [], updatedAt }
212
+ }
213
+ settled.run = metadata.activeRun
214
+ return { ...metadata, state: "idle", latestOutcome: "stopped", activeRun: null, queuedFollowUps: [], updatedAt }
215
+ })
216
+ if (settled.run) {
217
+ await appendHistoryLog(paths, {
218
+ type: "run-end",
219
+ sequence: settled.run.sequence,
220
+ outcome: "stopped",
221
+ timestamp: updatedAt
222
+ })
223
+ }
224
+ }
225
+
226
+ async function directTaskPaths(cwd: string, parentSessionId: string): Promise<TaskStoragePaths[]> {
227
+ const parent = parentStoragePaths(cwd, parentSessionId)
228
+ const entries = await readdir(parent.parentDirectory, { withFileTypes: true })
229
+ return entries
230
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && TASK_REFERENCE_PATTERN.test(entry.name))
231
+ .map(entry => taskStoragePaths(parent, entry.name))
232
+ .sort((a, b) => a.taskDirectory.localeCompare(b.taskDirectory))
233
+ }
234
+
235
+ async function isDirectory(path: string): Promise<boolean> {
236
+ try {
237
+ const stats = await lstat(path)
238
+ return stats.isDirectory() && !stats.isSymbolicLink()
239
+ } catch (error) {
240
+ if (hasCode(error, "ENOENT")) return false
241
+ throw error
242
+ }
243
+ }
244
+
245
+ function hasCode(error: unknown, code: string): boolean {
246
+ return typeof error === "object" && error !== null && "code" in error && error.code === code
247
+ }
248
+
249
+ function errorMessage(error: unknown): string {
250
+ return error instanceof Error ? error.message : String(error)
251
+ }