@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,635 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs"
|
|
2
|
+
import { readdir } from "node:fs/promises"
|
|
3
|
+
import { dirname } from "node:path"
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
5
|
+
import { Text } from "@earendil-works/pi-tui"
|
|
6
|
+
import { Type } from "typebox"
|
|
7
|
+
import type { AgentsConfig, AgentsConfigWarning, ModelAliasChoice, ModelChoice } from "./config.js"
|
|
8
|
+
import { MODEL_ALIASES, resolveConfiguredModels } from "./config.js"
|
|
9
|
+
import { getAgentCoordinator, getBashCoordinator } from "./coordinator.js"
|
|
10
|
+
import { type AgentDefinition, discoverAgentDefinitions } from "./definitions.js"
|
|
11
|
+
import { renderExpandableResult } from "./rendering.js"
|
|
12
|
+
import {
|
|
13
|
+
type AgentTaskMetadata,
|
|
14
|
+
acquireParentLease,
|
|
15
|
+
countRetainedOutputLines,
|
|
16
|
+
displayWorkspacePath,
|
|
17
|
+
type MetadataDiagnostic,
|
|
18
|
+
parentStoragePaths,
|
|
19
|
+
type RetainedPaths,
|
|
20
|
+
readRetainedOutput,
|
|
21
|
+
readTaskMetadata,
|
|
22
|
+
releaseParentLeaseFor,
|
|
23
|
+
retainedPaths,
|
|
24
|
+
TASK_REFERENCE_PATTERN,
|
|
25
|
+
type TaskMetadata,
|
|
26
|
+
type TaskStoragePaths,
|
|
27
|
+
taskSchedulingStatus,
|
|
28
|
+
taskStoragePaths
|
|
29
|
+
} from "./state.js"
|
|
30
|
+
|
|
31
|
+
const TASK_STATE_ORDER: Record<TaskMetadata["state"], number> = {
|
|
32
|
+
running: 0,
|
|
33
|
+
suspended: 1,
|
|
34
|
+
queued: 2,
|
|
35
|
+
interrupted: 3,
|
|
36
|
+
idle: 4
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RosterDefinition = {
|
|
40
|
+
name: string
|
|
41
|
+
description: string
|
|
42
|
+
source: "user" | "project"
|
|
43
|
+
path: string
|
|
44
|
+
model?: string
|
|
45
|
+
thinking?: string
|
|
46
|
+
tools?: string[]
|
|
47
|
+
exclude_agents_md?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type RosterDiagnostic = {
|
|
51
|
+
type: "error" | "warning"
|
|
52
|
+
code: string
|
|
53
|
+
message: string
|
|
54
|
+
path?: string
|
|
55
|
+
name?: string
|
|
56
|
+
pattern?: string
|
|
57
|
+
source?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type RosterModel = {
|
|
61
|
+
id: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AgentRosterResult = {
|
|
65
|
+
definitions: RosterDefinition[]
|
|
66
|
+
diagnostics: RosterDiagnostic[]
|
|
67
|
+
models: RosterModel[]
|
|
68
|
+
aliases: Array<{ name: ModelAliasChoice["name"]; model: string; thinking: ModelAliasChoice["thinkingLevel"]; description: string }>
|
|
69
|
+
depth: { current: number; maximum: number }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type DescendantSummary = {
|
|
73
|
+
total: number
|
|
74
|
+
states: Record<TaskMetadata["state"], number>
|
|
75
|
+
outcomes: Record<NonNullable<TaskMetadata["latestOutcome"]>, number>
|
|
76
|
+
activeLabels: string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type TaskListRow = {
|
|
80
|
+
id: string
|
|
81
|
+
kind: TaskMetadata["kind"]
|
|
82
|
+
label: string
|
|
83
|
+
/** Human-only bounded run input; omitted from ordinary tool loads. */
|
|
84
|
+
inputPreview?: string
|
|
85
|
+
definition?: string
|
|
86
|
+
state: TaskMetadata["state"]
|
|
87
|
+
latestOutcome: TaskMetadata["latestOutcome"]
|
|
88
|
+
model?: string
|
|
89
|
+
thinking?: AgentTaskMetadata["thinking"]
|
|
90
|
+
command?: string
|
|
91
|
+
cwd?: string
|
|
92
|
+
exitCode?: number | null
|
|
93
|
+
signal?: string | null
|
|
94
|
+
createdAt: number
|
|
95
|
+
updatedAt: number
|
|
96
|
+
acceptedAt: number | null
|
|
97
|
+
startedAt: number | null
|
|
98
|
+
detachedAt: number | null
|
|
99
|
+
queuedFollowUps: number
|
|
100
|
+
outputLines: number | null
|
|
101
|
+
lastActivity: NonNullable<TaskMetadata["lastActivity"]> | null
|
|
102
|
+
queueReason: ReturnType<typeof taskSchedulingStatus>["queueReason"]
|
|
103
|
+
capacity?: ReturnType<typeof taskSchedulingStatus>["capacity"]
|
|
104
|
+
paths: RetainedPaths
|
|
105
|
+
descendants: DescendantSummary
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type TaskDiagnostic = {
|
|
109
|
+
code: string
|
|
110
|
+
message: string
|
|
111
|
+
path: string
|
|
112
|
+
id?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type TaskListResult = {
|
|
116
|
+
tasks: TaskListRow[]
|
|
117
|
+
diagnostics: TaskDiagnostic[]
|
|
118
|
+
total: number
|
|
119
|
+
capacity: ReturnType<typeof taskSchedulingStatus>["capacity"]
|
|
120
|
+
bashCapacity: ReturnType<typeof taskSchedulingStatus>["capacity"]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type TaskOutputResult = Awaited<ReturnType<typeof readRetainedOutput>> & {
|
|
124
|
+
id: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function registerRosterTool(
|
|
128
|
+
pi: ExtensionAPI,
|
|
129
|
+
options: {
|
|
130
|
+
getConfig: () => AgentsConfig
|
|
131
|
+
getConfigWarnings: () => readonly AgentsConfigWarning[]
|
|
132
|
+
getDepth?: () => number
|
|
133
|
+
getAgentDir?: () => string
|
|
134
|
+
}
|
|
135
|
+
): void {
|
|
136
|
+
pi.registerTool({
|
|
137
|
+
name: "agent_roster",
|
|
138
|
+
label: "Agent Roster",
|
|
139
|
+
description:
|
|
140
|
+
"List available Lovely Agent definitions, configured models and model/thinking aliases, diagnostics, and delegation depth.",
|
|
141
|
+
promptSnippet: "List available Lovely Agent definitions, model choices, and aliases",
|
|
142
|
+
promptGuidelines: [
|
|
143
|
+
"Call agent_roster before delegating work and after editing Agent Definition files.",
|
|
144
|
+
"Choose any model ID or alias listed by agent_roster. Alias descriptions are guidance, not routing rules; explicit thinking overrides the preset."
|
|
145
|
+
],
|
|
146
|
+
parameters: Type.Object({}),
|
|
147
|
+
renderCall(_args, theme) {
|
|
148
|
+
return new Text(theme.fg("toolTitle", theme.bold("agent_roster")), 0, 0)
|
|
149
|
+
},
|
|
150
|
+
renderResult(result, { expanded }, theme) {
|
|
151
|
+
return renderExpandableResult(result, expanded, theme)
|
|
152
|
+
},
|
|
153
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
154
|
+
const config = options.getConfig()
|
|
155
|
+
const resolvedModels = await resolveConfiguredModels(config, ctx)
|
|
156
|
+
const discovered = discoverAgentDefinitions({
|
|
157
|
+
cwd: ctx.cwd,
|
|
158
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
159
|
+
toolNames: pi.getAllTools().map(tool => tool.name),
|
|
160
|
+
models: ctx.modelRegistry.getAll(),
|
|
161
|
+
...(options.getAgentDir ? { agentDir: options.getAgentDir() } : {})
|
|
162
|
+
})
|
|
163
|
+
return buildRosterToolResult({
|
|
164
|
+
definitions: discovered.definitions,
|
|
165
|
+
diagnostics: [
|
|
166
|
+
...discovered.diagnostics,
|
|
167
|
+
...options.getConfigWarnings().map(warning => ({
|
|
168
|
+
type: "warning" as const,
|
|
169
|
+
code: warning.key ? "invalid-config-value" : "invalid-config-file",
|
|
170
|
+
message: warning.message,
|
|
171
|
+
path: warning.path,
|
|
172
|
+
source: warning.scope,
|
|
173
|
+
...(warning.key ? { name: warning.key } : {})
|
|
174
|
+
})),
|
|
175
|
+
...resolvedModels.diagnostics.map(diagnostic => ({
|
|
176
|
+
...diagnostic,
|
|
177
|
+
source: "models"
|
|
178
|
+
}))
|
|
179
|
+
],
|
|
180
|
+
models: resolvedModels.models,
|
|
181
|
+
aliases: resolvedModels.aliases,
|
|
182
|
+
currentDepth: options.getDepth?.() ?? 0,
|
|
183
|
+
maximumDepth: config.maxDepth
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function buildRosterToolResult(options: {
|
|
190
|
+
definitions: readonly AgentDefinition[]
|
|
191
|
+
diagnostics: readonly RosterDiagnostic[]
|
|
192
|
+
models: readonly ModelChoice[]
|
|
193
|
+
aliases?: readonly ModelAliasChoice[]
|
|
194
|
+
currentDepth: number
|
|
195
|
+
maximumDepth: number
|
|
196
|
+
}): {
|
|
197
|
+
content: [{ type: "text"; text: string }]
|
|
198
|
+
details: AgentRosterResult
|
|
199
|
+
} {
|
|
200
|
+
const result: AgentRosterResult = {
|
|
201
|
+
definitions: options.definitions.map(definition => ({
|
|
202
|
+
name: definition.name,
|
|
203
|
+
description: definition.description,
|
|
204
|
+
source: definition.source,
|
|
205
|
+
path: definition.displayPath,
|
|
206
|
+
...(definition.model ? { model: definition.model } : {}),
|
|
207
|
+
...(definition.thinking ? { thinking: definition.thinking } : {}),
|
|
208
|
+
...(definition.tools ? { tools: definition.tools } : {}),
|
|
209
|
+
...(definition.excludeAgentsMd !== undefined ? { exclude_agents_md: definition.excludeAgentsMd } : {})
|
|
210
|
+
})),
|
|
211
|
+
diagnostics: [...options.diagnostics],
|
|
212
|
+
models: options.models.map(choice => ({
|
|
213
|
+
id: `${choice.model.provider}/${choice.model.id}`
|
|
214
|
+
})),
|
|
215
|
+
aliases: (options.aliases ?? []).map(alias => ({
|
|
216
|
+
name: alias.name,
|
|
217
|
+
model: `${alias.model.provider}/${alias.model.id}`,
|
|
218
|
+
thinking: alias.thinkingLevel,
|
|
219
|
+
description: MODEL_ALIASES[alias.name]
|
|
220
|
+
})),
|
|
221
|
+
depth: { current: options.currentDepth, maximum: options.maximumDepth }
|
|
222
|
+
}
|
|
223
|
+
const lines: string[] = []
|
|
224
|
+
if (result.definitions.length === 0) {
|
|
225
|
+
lines.push("definitions: []")
|
|
226
|
+
} else {
|
|
227
|
+
lines.push("definitions:")
|
|
228
|
+
for (const definition of result.definitions) {
|
|
229
|
+
lines.push(` - name: ${yamlScalar(definition.name)}`)
|
|
230
|
+
lines.push(` description: ${yamlScalar(definition.description)}`)
|
|
231
|
+
lines.push(` path: ${yamlScalar(definition.path)}`)
|
|
232
|
+
if (definition.model) lines.push(` model: ${yamlScalar(definition.model)}`)
|
|
233
|
+
if (definition.thinking) lines.push(` thinking: ${definition.thinking}`)
|
|
234
|
+
if (definition.tools) lines.push(` tools: [${definition.tools.map(yamlScalar).join(", ")}]`)
|
|
235
|
+
if (definition.exclude_agents_md) lines.push(" exclude_agents_md: true")
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (result.diagnostics.length > 0) {
|
|
240
|
+
lines.push("diagnostics:")
|
|
241
|
+
for (const diagnostic of result.diagnostics) {
|
|
242
|
+
lines.push(` - type: ${diagnostic.type}`)
|
|
243
|
+
lines.push(` code: ${yamlScalar(diagnostic.code)}`)
|
|
244
|
+
lines.push(` message: ${yamlScalar(diagnostic.message)}`)
|
|
245
|
+
if (diagnostic.source) lines.push(` source: ${yamlScalar(diagnostic.source)}`)
|
|
246
|
+
if (diagnostic.path) lines.push(` path: ${yamlScalar(diagnostic.path)}`)
|
|
247
|
+
if (diagnostic.name) lines.push(` name: ${yamlScalar(diagnostic.name)}`)
|
|
248
|
+
if (diagnostic.pattern) lines.push(` pattern: ${yamlScalar(diagnostic.pattern)}`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (result.aliases.length > 0) {
|
|
253
|
+
lines.push("aliases:")
|
|
254
|
+
for (const alias of result.aliases) {
|
|
255
|
+
lines.push(` - name: ${alias.name}`)
|
|
256
|
+
lines.push(` model: ${yamlScalar(`${alias.model}:${alias.thinking}`)}`)
|
|
257
|
+
lines.push(` description: ${yamlScalar(alias.description)}`)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
lines.push(result.models.length === 0 ? "models: []" : "models:")
|
|
261
|
+
for (const model of result.models) {
|
|
262
|
+
lines.push(` - ${yamlScalar(model.id)}`)
|
|
263
|
+
}
|
|
264
|
+
lines.push(`depth: ${result.depth.current}/${result.depth.maximum}`)
|
|
265
|
+
|
|
266
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: result }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function registerTaskTools(
|
|
270
|
+
pi: ExtensionAPI,
|
|
271
|
+
options: { beforeParentLeaseRelease?: (cwd: string, parentSessionId: string) => void | Promise<void> } = {}
|
|
272
|
+
): void {
|
|
273
|
+
pi.registerTool({
|
|
274
|
+
name: "task_list",
|
|
275
|
+
label: "Task List",
|
|
276
|
+
description:
|
|
277
|
+
"List durable Agent and Background Bash tasks owned by this exact Pi session, with queue reasons, capacity, and last activity.",
|
|
278
|
+
promptSnippet: "List durable tasks owned by this session",
|
|
279
|
+
promptGuidelines: ["Use task_list to inspect existing work before starting duplicate agents."],
|
|
280
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
281
|
+
renderCall(_args, theme) {
|
|
282
|
+
return new Text(theme.fg("toolTitle", theme.bold("task_list")), 0, 0)
|
|
283
|
+
},
|
|
284
|
+
renderResult(result, { expanded }, theme) {
|
|
285
|
+
return renderExpandableResult(result, expanded, theme)
|
|
286
|
+
},
|
|
287
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
288
|
+
return loadTaskList(ctx.cwd, ctx.sessionManager.getSessionId())
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
pi.registerTool({
|
|
293
|
+
name: "task_output",
|
|
294
|
+
label: "Task Output",
|
|
295
|
+
description:
|
|
296
|
+
"Read a task's latest reply or Bash output tail, run status, last activity, queue reason, and its execution capacity. Snapshots are capped at 2,000 lines/50 KiB; full agent replies are in history.md and full Bash output in output.log.",
|
|
297
|
+
promptSnippet: "Read a task's latest reply, progress, and current run status",
|
|
298
|
+
promptGuidelines: [
|
|
299
|
+
"task_output returns a snapshot, not history. With waitMs, wait for the current run to end or suspend, or for the timeout; activity and partial output do not end the wait. Omit waitMs for an immediate snapshot."
|
|
300
|
+
],
|
|
301
|
+
parameters: Type.Object(
|
|
302
|
+
{
|
|
303
|
+
id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }),
|
|
304
|
+
waitMs: Type.Optional(
|
|
305
|
+
Type.Integer({ minimum: 0, maximum: 600_000, description: "Maximum wait for the current run to end or suspend" })
|
|
306
|
+
)
|
|
307
|
+
},
|
|
308
|
+
{ additionalProperties: false }
|
|
309
|
+
),
|
|
310
|
+
renderCall(args, theme) {
|
|
311
|
+
const range = args.waitMs ? `wait=${args.waitMs}ms` : ""
|
|
312
|
+
return new Text(
|
|
313
|
+
`${theme.fg("toolTitle", theme.bold("task_output"))}${args.id ? ` ${theme.fg("muted", args.id)}` : ""}${range ? ` ${theme.fg("dim", range)}` : ""}`,
|
|
314
|
+
0,
|
|
315
|
+
0
|
|
316
|
+
)
|
|
317
|
+
},
|
|
318
|
+
renderResult(result, { expanded }, theme) {
|
|
319
|
+
return renderExpandableResult(result, expanded, theme)
|
|
320
|
+
},
|
|
321
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
322
|
+
const parentSessionId = ctx.sessionManager.getSessionId()
|
|
323
|
+
const lease = await acquireParentLease(ctx.cwd, parentSessionId)
|
|
324
|
+
const paths = taskStoragePaths(lease.paths, params.id)
|
|
325
|
+
const loaded = await readTaskMetadata(paths)
|
|
326
|
+
if (loaded.status === "missing") throw new Error(`Unknown Task Reference: ${params.id}`)
|
|
327
|
+
if (loaded.status === "invalid") {
|
|
328
|
+
throw new Error(`${loaded.diagnostic.message}: ${displayWorkspacePath(ctx.cwd, loaded.diagnostic.path)}`)
|
|
329
|
+
}
|
|
330
|
+
if (loaded.metadata.discardedAt !== null) throw new Error(`Task ${params.id} has been discarded`)
|
|
331
|
+
|
|
332
|
+
const readOutput = () =>
|
|
333
|
+
readRetainedOutput(paths, {
|
|
334
|
+
...(params.waitMs !== undefined ? { waitMs: params.waitMs } : {}),
|
|
335
|
+
...(signal ? { signal } : {})
|
|
336
|
+
})
|
|
337
|
+
const shouldLend = (params.waitMs ?? 0) > 0 && (loaded.metadata.state === "queued" || loaded.metadata.state === "running")
|
|
338
|
+
const output = shouldLend ? await getAgentCoordinator().withLentPermit(readOutput, signal) : await readOutput()
|
|
339
|
+
return buildTaskOutputToolResult(params.id, output)
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
344
|
+
if (event.reason !== "reload") {
|
|
345
|
+
const parentSessionId = ctx.sessionManager.getSessionId()
|
|
346
|
+
await options.beforeParentLeaseRelease?.(ctx.cwd, parentSessionId)
|
|
347
|
+
await releaseParentLeaseFor(ctx.cwd, parentSessionId)
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function loadTaskList(
|
|
353
|
+
cwd: string,
|
|
354
|
+
parentSessionId: string,
|
|
355
|
+
options: { includeInputPreviews?: boolean } = {}
|
|
356
|
+
): Promise<ReturnType<typeof buildTaskListToolResult>> {
|
|
357
|
+
await acquireParentLease(cwd, parentSessionId)
|
|
358
|
+
return buildTaskListToolResult(await scanDirectTasks(cwd, parentSessionId, options.includeInputPreviews ?? false))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function buildTaskListToolResult(options: { rows: readonly TaskListRow[]; diagnostics: readonly TaskDiagnostic[] }): {
|
|
362
|
+
content: [{ type: "text"; text: string }]
|
|
363
|
+
details: TaskListResult
|
|
364
|
+
} {
|
|
365
|
+
const tasks = [...options.rows].sort(compareTaskRows)
|
|
366
|
+
const coordinator = getAgentCoordinator()
|
|
367
|
+
const bashCoordinator = getBashCoordinator()
|
|
368
|
+
const result: TaskListResult = {
|
|
369
|
+
tasks,
|
|
370
|
+
diagnostics: [...options.diagnostics],
|
|
371
|
+
total: tasks.length,
|
|
372
|
+
capacity: { active: coordinator.activeCount, limit: coordinator.maxConcurrency },
|
|
373
|
+
bashCapacity: { active: bashCoordinator.activeCount, limit: bashCoordinator.maxConcurrency }
|
|
374
|
+
}
|
|
375
|
+
return { content: [{ type: "text", text: renderTaskListResult(result) }], details: result }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function buildTaskOutputToolResult(
|
|
379
|
+
id: string,
|
|
380
|
+
output: Awaited<ReturnType<typeof readRetainedOutput>>
|
|
381
|
+
): {
|
|
382
|
+
content: [{ type: "text"; text: string }]
|
|
383
|
+
details: TaskOutputResult
|
|
384
|
+
} {
|
|
385
|
+
const result: TaskOutputResult = { id, ...output }
|
|
386
|
+
const lines = [
|
|
387
|
+
`task_output state=${result.state} outcome=${result.latestOutcome ?? "none"} streaming=${result.streaming} queued=${result.queuedFollowUps}`,
|
|
388
|
+
`capacity=${result.capacity.active}/${result.capacity.limit} execution permits${result.queueReason ? ` waiting=${result.queueReason}` : ""}`,
|
|
389
|
+
...(result.exitCode !== undefined ? [`exit_code=${result.exitCode ?? "unknown"} signal=${result.signal ?? "none"}`] : []),
|
|
390
|
+
...(result.lastActivity ? [`last_activity: ${result.lastActivity.action} (${relativeTime(result.lastActivity.at, Date.now())})`] : []),
|
|
391
|
+
...(result.timedOut ? ["timed_out=true"] : []),
|
|
392
|
+
"",
|
|
393
|
+
result.text || "(no reply yet)"
|
|
394
|
+
]
|
|
395
|
+
return { content: [{ type: "text", text: lines.join("\n") }], details: result }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function scanDirectTasks(
|
|
399
|
+
cwd: string,
|
|
400
|
+
parentSessionId: string,
|
|
401
|
+
includeInputPreviews: boolean
|
|
402
|
+
): Promise<{ rows: TaskListRow[]; diagnostics: TaskDiagnostic[] }> {
|
|
403
|
+
const parent = parentStoragePaths(cwd, parentSessionId)
|
|
404
|
+
const entries = await readTaskDirectory(parent.parentDirectory)
|
|
405
|
+
const rows: TaskListRow[] = []
|
|
406
|
+
const diagnostics: TaskDiagnostic[] = []
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
if (!TASK_REFERENCE_PATTERN.test(entry.name)) continue
|
|
409
|
+
const paths = taskStoragePaths(parent, entry.name)
|
|
410
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
411
|
+
diagnostics.push({
|
|
412
|
+
code: "invalid-task-directory",
|
|
413
|
+
message: "Task path is not a regular directory",
|
|
414
|
+
path: displayWorkspacePath(cwd, paths.taskDirectory),
|
|
415
|
+
id: entry.name
|
|
416
|
+
})
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
const loaded = await readTaskMetadata(paths)
|
|
420
|
+
if (loaded.status === "missing") {
|
|
421
|
+
diagnostics.push({
|
|
422
|
+
code: "missing-metadata",
|
|
423
|
+
message: "Task metadata is missing",
|
|
424
|
+
path: displayWorkspacePath(cwd, paths.metadata),
|
|
425
|
+
id: entry.name
|
|
426
|
+
})
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
if (loaded.status === "invalid") {
|
|
430
|
+
diagnostics.push(metadataTaskDiagnostic(cwd, loaded.diagnostic, entry.name))
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
if (loaded.metadata.discardedAt !== null) continue
|
|
434
|
+
|
|
435
|
+
let outputLines: number | null = null
|
|
436
|
+
try {
|
|
437
|
+
outputLines = await countRetainedOutputLines(paths)
|
|
438
|
+
} catch (error) {
|
|
439
|
+
diagnostics.push({
|
|
440
|
+
code: "unreadable-output",
|
|
441
|
+
message: errorMessage(error),
|
|
442
|
+
path: displayWorkspacePath(cwd, paths.metadata),
|
|
443
|
+
id: entry.name
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
rows.push(await taskListRow(cwd, paths, loaded.metadata, outputLines, includeInputPreviews))
|
|
447
|
+
}
|
|
448
|
+
return { rows, diagnostics }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function taskListRow(
|
|
452
|
+
cwd: string,
|
|
453
|
+
paths: TaskStoragePaths,
|
|
454
|
+
metadata: TaskMetadata,
|
|
455
|
+
outputLines: number | null,
|
|
456
|
+
includeInputPreviews: boolean
|
|
457
|
+
): Promise<TaskListRow> {
|
|
458
|
+
const descendants = emptyDescendantAccumulator()
|
|
459
|
+
if (metadata.kind === "agent") await collectDescendants(cwd, metadata.childSessionId, new Set([metadata.parentSessionId]), descendants)
|
|
460
|
+
const activeRun = metadata.activeRun
|
|
461
|
+
return {
|
|
462
|
+
id: metadata.taskRef,
|
|
463
|
+
kind: metadata.kind,
|
|
464
|
+
label: metadata.label,
|
|
465
|
+
...(includeInputPreviews && metadata.inputPreview ? { inputPreview: metadata.inputPreview } : {}),
|
|
466
|
+
...(metadata.kind === "agent"
|
|
467
|
+
? { definition: metadata.definitionName, model: `${metadata.model.provider}/${metadata.model.id}`, thinking: metadata.thinking }
|
|
468
|
+
: { command: metadata.command, cwd: metadata.cwd, exitCode: metadata.exitCode, signal: metadata.signal }),
|
|
469
|
+
state: metadata.state,
|
|
470
|
+
latestOutcome: metadata.latestOutcome,
|
|
471
|
+
createdAt: metadata.createdAt,
|
|
472
|
+
updatedAt: metadata.updatedAt,
|
|
473
|
+
acceptedAt: activeRun?.acceptedAt ?? null,
|
|
474
|
+
startedAt: activeRun?.startedAt ?? null,
|
|
475
|
+
detachedAt: activeRun?.detachedAt ?? null,
|
|
476
|
+
queuedFollowUps: metadata.queuedFollowUps.length,
|
|
477
|
+
outputLines,
|
|
478
|
+
lastActivity: metadata.lastActivity ?? null,
|
|
479
|
+
...taskSchedulingStatus(metadata),
|
|
480
|
+
paths: retainedPaths(paths),
|
|
481
|
+
descendants: {
|
|
482
|
+
total: descendants.total,
|
|
483
|
+
states: descendants.states,
|
|
484
|
+
outcomes: descendants.outcomes,
|
|
485
|
+
activeLabels: descendants.active
|
|
486
|
+
.sort(
|
|
487
|
+
(left, right) =>
|
|
488
|
+
TASK_STATE_ORDER[left.state] - TASK_STATE_ORDER[right.state] ||
|
|
489
|
+
right.updatedAt - left.updatedAt ||
|
|
490
|
+
left.label.localeCompare(right.label)
|
|
491
|
+
)
|
|
492
|
+
.slice(0, 3)
|
|
493
|
+
.map(item => item.label)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
type DescendantAccumulator = Omit<DescendantSummary, "activeLabels"> & {
|
|
499
|
+
active: Array<{ label: string; state: TaskMetadata["state"]; updatedAt: number }>
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function collectDescendants(
|
|
503
|
+
cwd: string,
|
|
504
|
+
parentSessionId: string,
|
|
505
|
+
visited: Set<string>,
|
|
506
|
+
summary: DescendantAccumulator
|
|
507
|
+
): Promise<void> {
|
|
508
|
+
if (visited.has(parentSessionId)) return
|
|
509
|
+
visited.add(parentSessionId)
|
|
510
|
+
const parent = parentStoragePaths(cwd, parentSessionId)
|
|
511
|
+
for (const entry of await readTaskDirectory(parent.parentDirectory)) {
|
|
512
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !TASK_REFERENCE_PATTERN.test(entry.name)) continue
|
|
513
|
+
const loaded = await readTaskMetadata(taskStoragePaths(parent, entry.name))
|
|
514
|
+
if (loaded.status !== "ok" || loaded.metadata.discardedAt !== null) continue
|
|
515
|
+
summary.total++
|
|
516
|
+
summary.states[loaded.metadata.state]++
|
|
517
|
+
if (loaded.metadata.latestOutcome) summary.outcomes[loaded.metadata.latestOutcome]++
|
|
518
|
+
if (isActiveState(loaded.metadata.state)) {
|
|
519
|
+
summary.active.push({ label: loaded.metadata.label, state: loaded.metadata.state, updatedAt: loaded.metadata.updatedAt })
|
|
520
|
+
}
|
|
521
|
+
if (loaded.metadata.kind === "agent") await collectDescendants(cwd, loaded.metadata.childSessionId, visited, summary)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function emptyDescendantAccumulator(): DescendantAccumulator {
|
|
526
|
+
return {
|
|
527
|
+
total: 0,
|
|
528
|
+
states: { idle: 0, queued: 0, running: 0, suspended: 0, interrupted: 0 },
|
|
529
|
+
outcomes: { succeeded: 0, failed: 0, stopped: 0, interrupted: 0 },
|
|
530
|
+
active: []
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function readTaskDirectory(path: string): Promise<Dirent[]> {
|
|
535
|
+
try {
|
|
536
|
+
return (await readdir(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (hasCode(error, "ENOENT")) return []
|
|
539
|
+
throw error
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function metadataTaskDiagnostic(cwd: string, diagnostic: MetadataDiagnostic, id: string): TaskDiagnostic {
|
|
544
|
+
return { code: diagnostic.code, message: diagnostic.message, path: displayWorkspacePath(cwd, diagnostic.path), id }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function compareTaskRows(left: TaskListRow, right: TaskListRow): number {
|
|
548
|
+
return TASK_STATE_ORDER[left.state] - TASK_STATE_ORDER[right.state] || right.updatedAt - left.updatedAt || left.id.localeCompare(right.id)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderTaskListResult(result: TaskListResult): string {
|
|
552
|
+
const lines = [
|
|
553
|
+
`capacity: ${result.capacity.active}/${result.capacity.limit} execution permits`,
|
|
554
|
+
`bash_capacity: ${result.bashCapacity.active}/${result.bashCapacity.limit} execution permits`,
|
|
555
|
+
result.tasks.length === 0 ? "tasks: []" : "tasks:"
|
|
556
|
+
]
|
|
557
|
+
const now = Date.now()
|
|
558
|
+
for (const state of Object.keys(TASK_STATE_ORDER) as TaskMetadata["state"][]) {
|
|
559
|
+
const tasks = result.tasks.filter(task => task.state === state)
|
|
560
|
+
if (tasks.length === 0) continue
|
|
561
|
+
lines.push(` ${state}:`)
|
|
562
|
+
for (const task of tasks) {
|
|
563
|
+
lines.push(` - ${task.kind}${task.definition ? ` ${task.definition}` : ""} ${task.id}: ${yamlScalar(task.label)}`)
|
|
564
|
+
if (state === "idle" || state === "interrupted") lines.push(` outcome: ${task.latestOutcome ?? "none"}`)
|
|
565
|
+
if (task.kind === "agent") lines.push(` model: ${yamlScalar(`${task.model}:${task.thinking}`)}`)
|
|
566
|
+
else {
|
|
567
|
+
lines.push(` command: ${yamlScalar(task.command ?? "")}`)
|
|
568
|
+
lines.push(` exit_code: ${task.exitCode ?? "unknown"} signal: ${task.signal ?? "none"}`)
|
|
569
|
+
lines.push(` output: ${yamlScalar(task.paths.output ?? "")}`)
|
|
570
|
+
}
|
|
571
|
+
lines.push(` queued_followups: ${task.queuedFollowUps}`)
|
|
572
|
+
lines.push(` output_lines: ${task.outputLines ?? "unknown"}`)
|
|
573
|
+
if (task.queueReason) lines.push(` waiting: ${task.queueReason}`)
|
|
574
|
+
if (task.lastActivity) lines.push(` last_activity: ${task.lastActivity.action} (${relativeTime(task.lastActivity.at, now)})`)
|
|
575
|
+
if (task.descendants.total > 0) lines.push(` descendants: ${renderDescendantSummary(task.descendants)}`)
|
|
576
|
+
lines.push(` created: ${relativeTime(task.createdAt, now)}`)
|
|
577
|
+
lines.push(` updated: ${relativeTime(task.updatedAt, now)}`)
|
|
578
|
+
lines.push(` dir: ${yamlScalar(dirname(task.paths.history))}`)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (result.diagnostics.length > 0) {
|
|
582
|
+
lines.push("diagnostics:")
|
|
583
|
+
for (const diagnostic of result.diagnostics) {
|
|
584
|
+
lines.push(` - ${diagnostic.code}${diagnostic.id ? ` ${diagnostic.id}` : ""}: ${yamlScalar(diagnostic.message)}`)
|
|
585
|
+
lines.push(` path: ${yamlScalar(diagnostic.path)}`)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return lines.join("\n")
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function relativeTime(timestamp: number, now: number): string {
|
|
592
|
+
let seconds = Math.floor(Math.max(0, now - timestamp) / 1_000)
|
|
593
|
+
if (seconds === 0) return "now"
|
|
594
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
595
|
+
const minutes = Math.floor(seconds / 60)
|
|
596
|
+
seconds %= 60
|
|
597
|
+
if (minutes < 60) return `${minutes}m${seconds ? `${seconds}s` : ""} ago`
|
|
598
|
+
const hours = Math.floor(minutes / 60)
|
|
599
|
+
const remainingMinutes = minutes % 60
|
|
600
|
+
if (hours < 24) return `${hours}h${remainingMinutes ? `${remainingMinutes}m` : ""} ago`
|
|
601
|
+
const days = Math.floor(hours / 24)
|
|
602
|
+
const remainingHours = hours % 24
|
|
603
|
+
return `${days}d${remainingHours ? `${remainingHours}h` : ""} ago`
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function renderDescendantSummary(summary: DescendantSummary): string {
|
|
607
|
+
const counts = [
|
|
608
|
+
...Object.entries(summary.states)
|
|
609
|
+
.filter(([, count]) => count > 0)
|
|
610
|
+
.map(([name, count]) => `state.${name}=${count}`),
|
|
611
|
+
...Object.entries(summary.outcomes)
|
|
612
|
+
.filter(([, count]) => count > 0)
|
|
613
|
+
.map(([name, count]) => `outcome.${name}=${count}`)
|
|
614
|
+
]
|
|
615
|
+
const active = summary.activeLabels.length > 0 ? `, active=[${summary.activeLabels.map(yamlScalar).join(", ")}]` : ""
|
|
616
|
+
return `{total=${summary.total}${counts.length > 0 ? `, ${counts.join(", ")}` : ""}${active}}`
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isActiveState(state: TaskMetadata["state"]): boolean {
|
|
620
|
+
return state === "running" || state === "suspended" || state === "queued"
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
624
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function errorMessage(error: unknown): string {
|
|
628
|
+
return error instanceof Error ? error.message : String(error)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function yamlScalar(value: string): string {
|
|
632
|
+
return /^[A-Za-z0-9_@+./:-]+$/.test(value) && !/^(?:null|true|false|yes|no|on|off|[-+]?(?:\d+\.?\d*|\.\d+))$/i.test(value)
|
|
633
|
+
? value
|
|
634
|
+
: JSON.stringify(value)
|
|
635
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
|
|
3
|
+
const TASK_UPDATE_ROUTES_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/task-update-routes/v1")
|
|
4
|
+
type TaskUpdateRoute = () => void | Promise<void>
|
|
5
|
+
|
|
6
|
+
/** Subscribes to process-local durable state/output changes for one exact parent. */
|
|
7
|
+
export function bindTaskUpdateRoute(cwd: string, parentSessionId: string, route: TaskUpdateRoute): () => void {
|
|
8
|
+
const registry = taskUpdateRoutes()
|
|
9
|
+
const key = taskUpdateKey(cwd, parentSessionId)
|
|
10
|
+
const routes = registry.get(key) ?? new Set<TaskUpdateRoute>()
|
|
11
|
+
routes.add(route)
|
|
12
|
+
registry.set(key, routes)
|
|
13
|
+
return () => {
|
|
14
|
+
routes.delete(route)
|
|
15
|
+
if (routes.size === 0 && registry.get(key) === routes) registry.delete(key)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Requests an event-driven refresh without waiting for UI work. */
|
|
20
|
+
export function publishTaskUpdate(cwd: string, parentSessionId: string): void {
|
|
21
|
+
for (const route of [...(taskUpdateRoutes().get(taskUpdateKey(cwd, parentSessionId)) ?? [])])
|
|
22
|
+
void Promise.resolve()
|
|
23
|
+
.then(route)
|
|
24
|
+
.catch(() => {})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Capacity and tuple gates are process-wide, so every open task panel may change. */
|
|
28
|
+
export function publishSchedulerUpdate(): void {
|
|
29
|
+
for (const routes of taskUpdateRoutes().values()) {
|
|
30
|
+
for (const route of [...routes])
|
|
31
|
+
void Promise.resolve()
|
|
32
|
+
.then(route)
|
|
33
|
+
.catch(() => {})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function taskUpdateRoutes(): Map<string, Set<TaskUpdateRoute>> {
|
|
38
|
+
const global = globalThis as typeof globalThis & { [TASK_UPDATE_ROUTES_SYMBOL]?: Map<string, Set<TaskUpdateRoute>> }
|
|
39
|
+
global[TASK_UPDATE_ROUTES_SYMBOL] ??= new Map()
|
|
40
|
+
return global[TASK_UPDATE_ROUTES_SYMBOL]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function taskUpdateKey(cwd: string, parentSessionId: string): string {
|
|
44
|
+
return `${resolve(cwd)}\0${parentSessionId}`
|
|
45
|
+
}
|