@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- 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
|
+
}
|