@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,638 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import { lstat, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { join, resolve } from "node:path"
|
|
4
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
5
|
+
import { Container, Key, matchesKey, type SelectItem, SelectList, Text, truncateToWidth } from "@earendil-works/pi-tui"
|
|
6
|
+
import type { AgentDefinition, DefinitionDiagnostic, DefinitionDiscoveryResult } from "./definitions.js"
|
|
7
|
+
import {
|
|
8
|
+
acquireParentLease,
|
|
9
|
+
appendHistoryLog,
|
|
10
|
+
ensureParentStorage,
|
|
11
|
+
initializeRetainedLogs,
|
|
12
|
+
mutateTaskMetadata,
|
|
13
|
+
parentStoragePaths,
|
|
14
|
+
readRetainedOutput,
|
|
15
|
+
readTaskMetadata,
|
|
16
|
+
reserveTaskStorage,
|
|
17
|
+
TASK_METADATA_VERSION,
|
|
18
|
+
TASK_REFERENCE_PATTERN,
|
|
19
|
+
type TaskMetadata,
|
|
20
|
+
type TaskStoragePaths,
|
|
21
|
+
taskStoragePaths,
|
|
22
|
+
writeTaskMetadata
|
|
23
|
+
} from "./state.js"
|
|
24
|
+
import { relativeTime, type TaskListResult, type TaskListRow } from "./tools.js"
|
|
25
|
+
import { bindTaskUpdateRoute } from "./updates.js"
|
|
26
|
+
|
|
27
|
+
const FIXTURE_MARKER = ".fixture"
|
|
28
|
+
const FIXTURE_DEFINITION = "lovely-fixture"
|
|
29
|
+
const FIXTURE_LIVE_INTERVAL_MS = 500
|
|
30
|
+
const FIXTURE_TIMERS = Symbol.for("@xl0/pi-lovely-agents/fixture-timers")
|
|
31
|
+
type FixtureTimer = ReturnType<typeof setInterval>
|
|
32
|
+
type FixtureLiveTask = { timer?: FixtureTimer; pending?: Promise<void> }
|
|
33
|
+
type FixtureTimerRegistry = Map<string, Set<FixtureLiveTask>>
|
|
34
|
+
|
|
35
|
+
export type ManagementUiOptions = {
|
|
36
|
+
discoverDefinitions: () => DefinitionDiscoveryResult
|
|
37
|
+
loadTasks: () => Promise<TaskListResult>
|
|
38
|
+
focusTasks: () => Promise<void>
|
|
39
|
+
openConfig: () => Promise<void>
|
|
40
|
+
/** False means the foreground input was cancelled and must not be reported accepted. */
|
|
41
|
+
inputTask: (id: string, content: string, delivery: "followup" | "steer" | "stdin", eof?: boolean) => Promise<undefined | false>
|
|
42
|
+
controlTask: (id: string, action: "stop" | "discard") => Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function openManagementUi(ctx: ExtensionContext, options: ManagementUiOptions): Promise<void> {
|
|
46
|
+
while (true) {
|
|
47
|
+
const definitions = options.discoverDefinitions()
|
|
48
|
+
const tasks = await options.loadTasks()
|
|
49
|
+
const choice = await select(ctx, "Lovely Agents", [
|
|
50
|
+
{
|
|
51
|
+
value: "definitions",
|
|
52
|
+
label: `Agent definitions (${definitions.definitions.length})`,
|
|
53
|
+
description: `${definitions.diagnostics.length} diagnostics`
|
|
54
|
+
},
|
|
55
|
+
{ value: "tasks", label: `Tasks (${tasks.total})`, description: "Inspect durable direct children" },
|
|
56
|
+
{ value: "config", label: "Configuration", description: "Edit user and workspace settings" }
|
|
57
|
+
])
|
|
58
|
+
if (!choice) return
|
|
59
|
+
|
|
60
|
+
switch (choice) {
|
|
61
|
+
case "definitions":
|
|
62
|
+
await showDefinitions(ctx, definitions)
|
|
63
|
+
break
|
|
64
|
+
case "tasks":
|
|
65
|
+
await options.focusTasks()
|
|
66
|
+
return
|
|
67
|
+
case "config":
|
|
68
|
+
await options.openConfig()
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function openTaskManagementUi(ctx: ExtensionContext, options: ManagementUiOptions, selection: string): Promise<void> {
|
|
75
|
+
if (selection.startsWith("task:")) {
|
|
76
|
+
await manageTask(ctx, selection.slice("task:".length), options)
|
|
77
|
+
} else {
|
|
78
|
+
const tasks = await options.loadTasks()
|
|
79
|
+
const diagnostic = tasks.diagnostics.find(item => item.path === selection.slice("diagnostic:".length))
|
|
80
|
+
if (diagnostic) await showText(ctx, diagnostic.code, `${diagnostic.message}\n\n${diagnostic.path}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function seedFixtureTasks(cwd: string, parentSessionId: string): Promise<string[]> {
|
|
85
|
+
await acquireParentLease(cwd, parentSessionId)
|
|
86
|
+
const fixtures: Array<{ label: string; state: TaskMetadata["state"]; outcome: TaskMetadata["latestOutcome"] }> = [
|
|
87
|
+
{ label: "Running", state: "running", outcome: null },
|
|
88
|
+
{ label: "Suspended", state: "suspended", outcome: null },
|
|
89
|
+
{ label: "Queued", state: "queued", outcome: null },
|
|
90
|
+
{ label: "Interrupted", state: "interrupted", outcome: "interrupted" },
|
|
91
|
+
{ label: "Succeeded", state: "idle", outcome: "succeeded" },
|
|
92
|
+
{ label: "Failed", state: "idle", outcome: "failed" },
|
|
93
|
+
{ label: "Stopped", state: "idle", outcome: "stopped" }
|
|
94
|
+
]
|
|
95
|
+
const ids: string[] = []
|
|
96
|
+
for (const fixture of fixtures) {
|
|
97
|
+
const paths = await createFixtureTask(
|
|
98
|
+
cwd,
|
|
99
|
+
parentSessionId,
|
|
100
|
+
parentSessionId,
|
|
101
|
+
`[fixture] ${fixture.label}`,
|
|
102
|
+
fixture.state,
|
|
103
|
+
fixture.outcome
|
|
104
|
+
)
|
|
105
|
+
ids.push(paths.taskRef)
|
|
106
|
+
}
|
|
107
|
+
return ids
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function seedFixtureEdgeCases(cwd: string, parentSessionId: string): Promise<string[]> {
|
|
111
|
+
await acquireParentLease(cwd, parentSessionId)
|
|
112
|
+
const showcase = await createFixtureTask(
|
|
113
|
+
cwd,
|
|
114
|
+
parentSessionId,
|
|
115
|
+
parentSessionId,
|
|
116
|
+
"[fixture] Follow-up, descendants, and large UTF-8",
|
|
117
|
+
"idle",
|
|
118
|
+
"succeeded"
|
|
119
|
+
)
|
|
120
|
+
await mutateTaskMetadata(showcase, metadata => ({
|
|
121
|
+
...metadata,
|
|
122
|
+
lastRunSequence: 2,
|
|
123
|
+
latestReply: { text: `Large UTF-8 line: ${"🦋".repeat(20_000)}`, streaming: false },
|
|
124
|
+
queuedFollowUps: [
|
|
125
|
+
{
|
|
126
|
+
id: `r_${randomBytes(8).toString("hex")}`,
|
|
127
|
+
sequence: 2,
|
|
128
|
+
content: "Queued fixture Follow-up",
|
|
129
|
+
acceptedAt: Date.now()
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
updatedAt: Date.now()
|
|
133
|
+
}))
|
|
134
|
+
await appendHistoryLog(showcase, { type: "assistant", content: `Large UTF-8 line: ${"🦋".repeat(20_000)}` })
|
|
135
|
+
const loaded = await readTaskMetadata(showcase)
|
|
136
|
+
if (loaded.status !== "ok" || loaded.metadata.kind !== "agent") throw new Error(`Could not read fixture ${showcase.taskRef}`)
|
|
137
|
+
await createFixtureTask(cwd, loaded.metadata.childSessionId, parentSessionId, "[fixture] Nested running child", "running", null)
|
|
138
|
+
|
|
139
|
+
const discarded = await createFixtureTask(cwd, parentSessionId, parentSessionId, "[fixture] Discarded", "idle", "stopped")
|
|
140
|
+
await mutateTaskMetadata(discarded, metadata => ({ ...metadata, discardedAt: Date.now(), updatedAt: Date.now() }))
|
|
141
|
+
|
|
142
|
+
const corrupt = await reserveTaskStorage(await ensureParentStorage(cwd, parentSessionId))
|
|
143
|
+
await writeFile(join(corrupt.taskDirectory, FIXTURE_MARKER), parentSessionId, { flag: "wx", mode: 0o600 })
|
|
144
|
+
await writeFile(corrupt.metadata, "{ malformed fixture metadata", { flag: "wx", mode: 0o600 })
|
|
145
|
+
return [showcase.taskRef, discarded.taskRef, corrupt.taskRef]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function seedLiveFixtureTask(cwd: string, parentSessionId: string): Promise<string> {
|
|
149
|
+
await acquireParentLease(cwd, parentSessionId)
|
|
150
|
+
const paths = await createFixtureTask(cwd, parentSessionId, parentSessionId, "[fixture] Live output", "running", null)
|
|
151
|
+
let tick = 0
|
|
152
|
+
const live: FixtureLiveTask = {}
|
|
153
|
+
const timer = setInterval(() => {
|
|
154
|
+
if (live.pending) return
|
|
155
|
+
live.pending = (async () => {
|
|
156
|
+
tick++
|
|
157
|
+
await appendHistoryLog(paths, { type: "assistant", content: `Fixture update ${tick}` })
|
|
158
|
+
await mutateTaskMetadata(paths, metadata => ({
|
|
159
|
+
...metadata,
|
|
160
|
+
latestReply: { text: `Fixture update ${tick}`, streaming: false },
|
|
161
|
+
updatedAt: Date.now()
|
|
162
|
+
}))
|
|
163
|
+
if (tick < 5) return
|
|
164
|
+
clearInterval(timer)
|
|
165
|
+
await appendHistoryLog(paths, { type: "run-end", sequence: 1, outcome: "succeeded", timestamp: Date.now() })
|
|
166
|
+
await mutateTaskMetadata(paths, metadata => ({
|
|
167
|
+
...metadata,
|
|
168
|
+
state: "idle",
|
|
169
|
+
latestOutcome: "succeeded",
|
|
170
|
+
activeRun: null,
|
|
171
|
+
updatedAt: Date.now()
|
|
172
|
+
}))
|
|
173
|
+
unregisterFixtureTimer(cwd, parentSessionId, live)
|
|
174
|
+
})()
|
|
175
|
+
void live.pending
|
|
176
|
+
.catch(() => {
|
|
177
|
+
clearInterval(timer)
|
|
178
|
+
unregisterFixtureTimer(cwd, parentSessionId, live)
|
|
179
|
+
})
|
|
180
|
+
.finally(() => {
|
|
181
|
+
delete live.pending
|
|
182
|
+
})
|
|
183
|
+
}, FIXTURE_LIVE_INTERVAL_MS)
|
|
184
|
+
live.timer = timer
|
|
185
|
+
timer.unref()
|
|
186
|
+
registerFixtureTimer(cwd, parentSessionId, live)
|
|
187
|
+
return paths.taskRef
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function clearFixtureTasks(cwd: string, parentSessionId: string): Promise<number> {
|
|
191
|
+
await acquireParentLease(cwd, parentSessionId)
|
|
192
|
+
await stopFixtureTimersFor(cwd, parentSessionId)
|
|
193
|
+
const parent = await ensureParentStorage(cwd, parentSessionId)
|
|
194
|
+
let removed = 0
|
|
195
|
+
for (const partition of await readdir(parent.root, { withFileTypes: true })) {
|
|
196
|
+
if (!partition.isDirectory() || partition.isSymbolicLink()) continue
|
|
197
|
+
const partitionDirectory = join(parent.root, partition.name)
|
|
198
|
+
for (const entry of await readdir(partitionDirectory, { withFileTypes: true })) {
|
|
199
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !TASK_REFERENCE_PATTERN.test(entry.name)) continue
|
|
200
|
+
const taskDirectory = join(partitionDirectory, entry.name)
|
|
201
|
+
if (!(await isOwnedFixture(join(taskDirectory, FIXTURE_MARKER), parentSessionId))) continue
|
|
202
|
+
await rm(taskDirectory, { recursive: true })
|
|
203
|
+
removed++
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return removed
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function stopFixtureTimersFor(cwd: string, parentSessionId: string): Promise<void> {
|
|
210
|
+
const registry = fixtureTimerRegistry()
|
|
211
|
+
const key = fixtureTimerKey(cwd, parentSessionId)
|
|
212
|
+
const liveTasks = [...(registry.get(key) ?? [])]
|
|
213
|
+
for (const live of liveTasks) {
|
|
214
|
+
if (live.timer) clearInterval(live.timer)
|
|
215
|
+
}
|
|
216
|
+
await Promise.allSettled(liveTasks.flatMap(live => live.pending ?? []))
|
|
217
|
+
registry.delete(key)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function showDefinitions(ctx: ExtensionContext, discovered: DefinitionDiscoveryResult): Promise<void> {
|
|
221
|
+
while (true) {
|
|
222
|
+
const items: SelectItem[] = [
|
|
223
|
+
...discovered.definitions.map(definition => ({
|
|
224
|
+
value: `definition:${definition.name}`,
|
|
225
|
+
label: definition.name,
|
|
226
|
+
description: `${definition.description} · ${definition.source}`
|
|
227
|
+
})),
|
|
228
|
+
...discovered.diagnostics.map((diagnostic, index) => ({
|
|
229
|
+
value: `diagnostic:${index}`,
|
|
230
|
+
label: `[${diagnostic.type}] ${diagnostic.code}`,
|
|
231
|
+
description: diagnostic.message
|
|
232
|
+
}))
|
|
233
|
+
]
|
|
234
|
+
if (items.length === 0) {
|
|
235
|
+
await showText(ctx, "Agent definitions", "No Agent Definitions or diagnostics.")
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const choice = await select(ctx, "Agent definitions", items)
|
|
239
|
+
if (!choice) return
|
|
240
|
+
if (choice.startsWith("definition:")) {
|
|
241
|
+
const definition = discovered.definitions.find(item => item.name === choice.slice("definition:".length))
|
|
242
|
+
if (definition) await showText(ctx, definition.name, renderDefinition(definition))
|
|
243
|
+
} else {
|
|
244
|
+
const diagnostic = discovered.diagnostics[Number(choice.slice("diagnostic:".length))]
|
|
245
|
+
if (diagnostic) await showText(ctx, diagnostic.code, renderDiagnostic(diagnostic))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function manageTask(ctx: ExtensionContext, id: string, options: ManagementUiOptions): Promise<void> {
|
|
251
|
+
while (true) {
|
|
252
|
+
const task = (await options.loadTasks()).tasks.find(candidate => candidate.id === id)
|
|
253
|
+
if (!task) return
|
|
254
|
+
const choice = await select(ctx, task.label, [
|
|
255
|
+
{ value: "details", label: "Details", description: `${task.state}${task.latestOutcome ? `/${task.latestOutcome}` : ""}` },
|
|
256
|
+
{
|
|
257
|
+
value: "output",
|
|
258
|
+
label: "Live output",
|
|
259
|
+
description: task.kind === "bash" ? "Command output tail and exit status" : "Latest assistant reply and run status"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
value: "history",
|
|
263
|
+
label: "Inputs / history",
|
|
264
|
+
description: task.kind === "bash" ? "Command, stdin, and outcome" : "Current and queued inputs, past runs, and delivered Steers"
|
|
265
|
+
},
|
|
266
|
+
...(task.kind === "bash"
|
|
267
|
+
? task.state === "running"
|
|
268
|
+
? [
|
|
269
|
+
{ value: "stdin", label: "Write stdin", description: "Send literal input; include a newline if the command expects one" },
|
|
270
|
+
{ value: "eof", label: "Close stdin", description: "Send EOF; stdin cannot be reopened" }
|
|
271
|
+
]
|
|
272
|
+
: []
|
|
273
|
+
: [
|
|
274
|
+
{ value: "prompt", label: "System prompt", description: "Captured Pi system prompt" },
|
|
275
|
+
{ value: "followup", label: "Follow-up", description: "Run more work in this retained session" },
|
|
276
|
+
{ value: "steer", label: "Steer", description: "Redirect running work; otherwise becomes a Follow-up" }
|
|
277
|
+
]),
|
|
278
|
+
{ value: "stop", label: "Stop", description: "Stop work and preserve retained files" },
|
|
279
|
+
{ value: "discard", label: "Discard", description: "Stop and archive this task and its descendants" }
|
|
280
|
+
])
|
|
281
|
+
if (!choice) return
|
|
282
|
+
if (choice === "details") await showText(ctx, task.label, renderTask(task))
|
|
283
|
+
else if (choice === "output") await showLiveTaskOutput(ctx, task)
|
|
284
|
+
else if (choice === "history" || choice === "prompt") await showTaskContext(ctx, task, choice)
|
|
285
|
+
else if (choice === "followup" || choice === "steer" || choice === "stdin") {
|
|
286
|
+
const content = await ctx.ui.editor(`${title(choice)} ${task.id}`)
|
|
287
|
+
if (content !== undefined && (choice === "stdin" ? content.length > 0 : !!content.trim())) {
|
|
288
|
+
if ((await options.inputTask(task.id, content, choice)) !== false) {
|
|
289
|
+
ctx.ui.notify(`${title(choice)} accepted for ${task.id}`, "info")
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
} else if (choice === "eof") {
|
|
293
|
+
if (await ctx.ui.confirm(`Close stdin for ${task.id}?`, "This cannot be undone.")) {
|
|
294
|
+
if ((await options.inputTask(task.id, "", "stdin", true)) !== false) ctx.ui.notify(`Stdin closed for ${task.id}`, "info")
|
|
295
|
+
}
|
|
296
|
+
} else if (choice === "stop") {
|
|
297
|
+
if (
|
|
298
|
+
await ctx.ui.confirm(
|
|
299
|
+
`Stop ${task.id}?`,
|
|
300
|
+
task.kind === "bash" ? "The command will not restart. Output remains available." : "The retained session remains reusable."
|
|
301
|
+
)
|
|
302
|
+
) {
|
|
303
|
+
await options.controlTask(task.id, "stop")
|
|
304
|
+
}
|
|
305
|
+
} else if (await ctx.ui.confirm(`Discard ${task.id}?`, "Files move to the archive. Later model I/O is rejected.")) {
|
|
306
|
+
await options.controlTask(task.id, "discard")
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function showTaskContext(ctx: ExtensionContext, task: TaskListRow, view: "history" | "prompt"): Promise<void> {
|
|
313
|
+
const paths = taskStoragePaths(parentStoragePaths(ctx.cwd, ctx.sessionManager.getSessionId()), task.id)
|
|
314
|
+
const loaded = await readTaskMetadata(paths)
|
|
315
|
+
if (loaded.status !== "ok") throw new Error(`Cannot inspect ${task.id}: invalid or missing metadata`)
|
|
316
|
+
const metadata = loaded.metadata
|
|
317
|
+
if (metadata.discardedAt !== null) throw new Error(`Task ${task.id} has been discarded`)
|
|
318
|
+
if (view === "prompt") {
|
|
319
|
+
if (metadata.kind !== "agent") throw new Error("Bash tasks do not have a system prompt")
|
|
320
|
+
const captured = metadata.effectiveSystemPrompt
|
|
321
|
+
if (captured === undefined) {
|
|
322
|
+
ctx.ui.notify("No system prompt captured for this run.", "info")
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
await showText(ctx, `${task.id} · System prompt`, captured)
|
|
326
|
+
} else {
|
|
327
|
+
if (!(await isRegularFile(paths.history))) throw new Error(`Cannot inspect ${task.id}: history.md is missing or not a regular file`)
|
|
328
|
+
await showText(
|
|
329
|
+
ctx,
|
|
330
|
+
`${task.id} · Inputs / history`,
|
|
331
|
+
[
|
|
332
|
+
...(metadata.activeRun ? [`Current run ${metadata.activeRun.sequence} (${metadata.state}):`, metadata.activeRun.input, ""] : []),
|
|
333
|
+
...metadata.queuedFollowUps.flatMap(input => [`Queued Follow-up ${input.sequence} (not started):`, input.content, ""]),
|
|
334
|
+
await readFile(paths.history, "utf8")
|
|
335
|
+
].join("\n")
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function showLiveTaskOutput(ctx: ExtensionContext, task: TaskListRow): Promise<void> {
|
|
341
|
+
const paths = taskStoragePaths(parentStoragePaths(ctx.cwd, ctx.sessionManager.getSessionId()), task.id)
|
|
342
|
+
let output = await readRetainedOutput(paths)
|
|
343
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
344
|
+
let closed = false
|
|
345
|
+
let loading = false
|
|
346
|
+
let refreshAgain = false
|
|
347
|
+
const refresh = async () => {
|
|
348
|
+
if (closed) return
|
|
349
|
+
refreshAgain = true
|
|
350
|
+
if (loading) return
|
|
351
|
+
loading = true
|
|
352
|
+
try {
|
|
353
|
+
do {
|
|
354
|
+
refreshAgain = false
|
|
355
|
+
const latest = await readRetainedOutput(paths)
|
|
356
|
+
if (closed) return
|
|
357
|
+
output = latest
|
|
358
|
+
tui.requestRender()
|
|
359
|
+
} while (refreshAgain)
|
|
360
|
+
} finally {
|
|
361
|
+
loading = false
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const unbind = bindTaskUpdateRoute(ctx.cwd, ctx.sessionManager.getSessionId(), refresh)
|
|
365
|
+
return {
|
|
366
|
+
render(width: number) {
|
|
367
|
+
const heading = theme.fg(
|
|
368
|
+
"accent",
|
|
369
|
+
theme.bold(
|
|
370
|
+
`${task.id} · ${output.state}${output.latestOutcome ? `/${output.latestOutcome}` : ""}${output.streaming ? " · streaming" : ""}`
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
const progress = [
|
|
374
|
+
`Capacity: ${output.capacity.active}/${output.capacity.limit} execution permits`,
|
|
375
|
+
...(output.queueReason ? [`Waiting: ${output.queueReason}`] : []),
|
|
376
|
+
...(output.lastActivity ? [`${output.lastActivity.action} · ${relativeTime(output.lastActivity.at, Date.now())}`] : [])
|
|
377
|
+
].join("\n")
|
|
378
|
+
return new Text(
|
|
379
|
+
`${heading}\n${progress}\n\n${output.text || "(no output)"}\n\n${theme.fg("dim", "Esc or Enter to go back")}`,
|
|
380
|
+
1,
|
|
381
|
+
0
|
|
382
|
+
).render(width)
|
|
383
|
+
},
|
|
384
|
+
invalidate() {},
|
|
385
|
+
handleInput(data: string) {
|
|
386
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) done(undefined)
|
|
387
|
+
},
|
|
388
|
+
dispose() {
|
|
389
|
+
closed = true
|
|
390
|
+
unbind()
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function select(ctx: ExtensionContext, title: string, items: SelectItem[]): Promise<string | null> {
|
|
397
|
+
return ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
|
|
398
|
+
const container = new Container()
|
|
399
|
+
container.addChild(border(text => theme.fg("accent", text)))
|
|
400
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0))
|
|
401
|
+
const list = new SelectList(items, Math.min(Math.max(items.length, 1), 15), {
|
|
402
|
+
selectedPrefix: text => theme.fg("accent", text),
|
|
403
|
+
selectedText: text => theme.fg("accent", text),
|
|
404
|
+
description: text => theme.fg("muted", text),
|
|
405
|
+
scrollInfo: text => theme.fg("dim", text),
|
|
406
|
+
noMatch: text => theme.fg("warning", text)
|
|
407
|
+
})
|
|
408
|
+
list.onSelect = item => done(item.value)
|
|
409
|
+
list.onCancel = () => done(null)
|
|
410
|
+
container.addChild(list)
|
|
411
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc back"), 1, 0))
|
|
412
|
+
container.addChild(border(text => theme.fg("accent", text)))
|
|
413
|
+
return {
|
|
414
|
+
render: (width: number) => container.render(width),
|
|
415
|
+
invalidate: () => container.invalidate(),
|
|
416
|
+
handleInput(data: string) {
|
|
417
|
+
list.handleInput(data)
|
|
418
|
+
tui.requestRender()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function showText(ctx: ExtensionContext, title: string, content: string): Promise<void> {
|
|
425
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
426
|
+
const body = new Text(content, 0, 0)
|
|
427
|
+
let offset = 0
|
|
428
|
+
let pageSize = 1
|
|
429
|
+
let total = 0
|
|
430
|
+
return {
|
|
431
|
+
render(width: number) {
|
|
432
|
+
const lines = body.render(width)
|
|
433
|
+
total = lines.length
|
|
434
|
+
pageSize = Math.max(1, Math.floor(tui.terminal.rows * 0.6) - 2)
|
|
435
|
+
offset = Math.max(0, Math.min(offset, total - pageSize))
|
|
436
|
+
return [
|
|
437
|
+
truncateToWidth(theme.fg("accent", theme.bold(title.replace(/[\r\n]+/g, " "))), width),
|
|
438
|
+
...lines.slice(offset, offset + pageSize).map(line => truncateToWidth(line, width)),
|
|
439
|
+
truncateToWidth(
|
|
440
|
+
theme.fg(
|
|
441
|
+
"dim",
|
|
442
|
+
`Enter/Esc back · ↑↓ PgUp/PgDn Home/End · ${Math.min(offset + 1, total)}-${Math.min(offset + pageSize, total)}/${total}`
|
|
443
|
+
),
|
|
444
|
+
width
|
|
445
|
+
)
|
|
446
|
+
]
|
|
447
|
+
},
|
|
448
|
+
invalidate: () => body.invalidate(),
|
|
449
|
+
handleInput(data: string) {
|
|
450
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) return done(undefined)
|
|
451
|
+
if (matchesKey(data, Key.up)) offset--
|
|
452
|
+
else if (matchesKey(data, Key.down)) offset++
|
|
453
|
+
else if (matchesKey(data, Key.pageUp)) offset -= pageSize
|
|
454
|
+
else if (matchesKey(data, Key.pageDown)) offset += pageSize
|
|
455
|
+
else if (matchesKey(data, Key.home)) offset = 0
|
|
456
|
+
else if (matchesKey(data, Key.end)) offset = total - pageSize
|
|
457
|
+
else return
|
|
458
|
+
offset = Math.max(0, Math.min(offset, total - pageSize))
|
|
459
|
+
tui.requestRender()
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function border(color: (text: string) => string): { render(width: number): string[]; invalidate(): void } {
|
|
466
|
+
return {
|
|
467
|
+
render: width => [color("─".repeat(Math.max(1, width)))],
|
|
468
|
+
invalidate() {}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function registerFixtureTimer(cwd: string, parentSessionId: string, live: FixtureLiveTask): void {
|
|
473
|
+
const registry = fixtureTimerRegistry()
|
|
474
|
+
const key = fixtureTimerKey(cwd, parentSessionId)
|
|
475
|
+
const liveTasks = registry.get(key) ?? new Set<FixtureLiveTask>()
|
|
476
|
+
liveTasks.add(live)
|
|
477
|
+
registry.set(key, liveTasks)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function unregisterFixtureTimer(cwd: string, parentSessionId: string, live: FixtureLiveTask): void {
|
|
481
|
+
const registry = fixtureTimerRegistry()
|
|
482
|
+
const key = fixtureTimerKey(cwd, parentSessionId)
|
|
483
|
+
const liveTasks = registry.get(key)
|
|
484
|
+
if (!liveTasks) return
|
|
485
|
+
liveTasks.delete(live)
|
|
486
|
+
if (liveTasks.size === 0) registry.delete(key)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function fixtureTimerRegistry(): FixtureTimerRegistry {
|
|
490
|
+
const globals = globalThis as unknown as Record<symbol, unknown>
|
|
491
|
+
const existing = globals[FIXTURE_TIMERS]
|
|
492
|
+
if (existing instanceof Map) return existing as FixtureTimerRegistry
|
|
493
|
+
const registry: FixtureTimerRegistry = new Map()
|
|
494
|
+
globals[FIXTURE_TIMERS] = registry
|
|
495
|
+
return registry
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function fixtureTimerKey(cwd: string, parentSessionId: string): string {
|
|
499
|
+
return `${resolve(cwd)}\0${parentSessionId}`
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function createFixtureTask(
|
|
503
|
+
cwd: string,
|
|
504
|
+
parentSessionId: string,
|
|
505
|
+
fixtureOwnerSessionId: string,
|
|
506
|
+
label: string,
|
|
507
|
+
state: TaskMetadata["state"],
|
|
508
|
+
latestOutcome: TaskMetadata["latestOutcome"]
|
|
509
|
+
): Promise<TaskStoragePaths> {
|
|
510
|
+
const paths = await reserveTaskStorage(await ensureParentStorage(cwd, parentSessionId))
|
|
511
|
+
await writeFile(join(paths.taskDirectory, FIXTURE_MARKER), fixtureOwnerSessionId, { flag: "wx", mode: 0o600 })
|
|
512
|
+
await initializeRetainedLogs(paths)
|
|
513
|
+
const now = Date.now()
|
|
514
|
+
const active = state === "queued" || state === "running" || state === "suspended"
|
|
515
|
+
const metadata: TaskMetadata = {
|
|
516
|
+
version: TASK_METADATA_VERSION,
|
|
517
|
+
kind: "agent",
|
|
518
|
+
taskRef: paths.taskRef,
|
|
519
|
+
parentSessionId,
|
|
520
|
+
childSessionId: `fixture-${randomBytes(8).toString("hex")}`,
|
|
521
|
+
definitionName: FIXTURE_DEFINITION,
|
|
522
|
+
label,
|
|
523
|
+
model: { provider: "fixture", id: "dummy" },
|
|
524
|
+
thinking: "off",
|
|
525
|
+
depth: 1,
|
|
526
|
+
allowAgents: false,
|
|
527
|
+
sessionConfig: {
|
|
528
|
+
systemPrompt: "Development fixture agent.",
|
|
529
|
+
tools: null,
|
|
530
|
+
excludeAgentsMd: false,
|
|
531
|
+
scopedModels: [{ provider: "fixture", id: "dummy" }]
|
|
532
|
+
},
|
|
533
|
+
state,
|
|
534
|
+
latestOutcome,
|
|
535
|
+
latestReply: { text: `${title(state)} fixture output.`, streaming: false },
|
|
536
|
+
lastRunSequence: 1,
|
|
537
|
+
activeRun: active
|
|
538
|
+
? {
|
|
539
|
+
id: `r_${randomBytes(8).toString("hex")}`,
|
|
540
|
+
sequence: 1,
|
|
541
|
+
kind: "initial",
|
|
542
|
+
state,
|
|
543
|
+
input: "Exercise the Lovely Agents development UI.",
|
|
544
|
+
acceptedAt: now,
|
|
545
|
+
...(state === "queued" ? {} : { startedAt: now }),
|
|
546
|
+
detachedAt: now
|
|
547
|
+
}
|
|
548
|
+
: null,
|
|
549
|
+
queuedFollowUps: [],
|
|
550
|
+
notifications: [],
|
|
551
|
+
discardedAt: null,
|
|
552
|
+
createdAt: now,
|
|
553
|
+
updatedAt: now
|
|
554
|
+
}
|
|
555
|
+
await writeTaskMetadata(paths, metadata)
|
|
556
|
+
await appendHistoryLog(paths, { type: "run-start", sequence: 1, kind: "initial", timestamp: now })
|
|
557
|
+
await appendHistoryLog(paths, {
|
|
558
|
+
type: "input",
|
|
559
|
+
delivery: "initial",
|
|
560
|
+
timestamp: now,
|
|
561
|
+
content: "Exercise the Lovely Agents development UI."
|
|
562
|
+
})
|
|
563
|
+
await appendHistoryLog(paths, { type: "assistant", content: `${title(state)} fixture output.` })
|
|
564
|
+
if (!active) await appendHistoryLog(paths, { type: "run-end", sequence: 1, outcome: latestOutcome ?? "succeeded", timestamp: now })
|
|
565
|
+
await appendHistoryLog(paths, {
|
|
566
|
+
type: "tool",
|
|
567
|
+
tool: "fixture",
|
|
568
|
+
arguments: JSON.stringify({ state }),
|
|
569
|
+
result: "Fixture task created",
|
|
570
|
+
isError: false
|
|
571
|
+
})
|
|
572
|
+
return paths
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function isRegularFile(path: string): Promise<boolean> {
|
|
576
|
+
try {
|
|
577
|
+
const stats = await lstat(path)
|
|
578
|
+
return stats.isFile() && !stats.isSymbolicLink()
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false
|
|
581
|
+
throw error
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function isOwnedFixture(path: string, parentSessionId: string): Promise<boolean> {
|
|
586
|
+
return (await isRegularFile(path)) && (await readFile(path, "utf8")) === parentSessionId
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function renderDefinition(definition: AgentDefinition): string {
|
|
590
|
+
return [
|
|
591
|
+
definition.description,
|
|
592
|
+
`Source: ${definition.source}`,
|
|
593
|
+
`Path: ${definition.displayPath}`,
|
|
594
|
+
`Model: ${definition.model ?? "inherit parent"}`,
|
|
595
|
+
`Thinking: ${definition.thinking ?? "inherit"}`,
|
|
596
|
+
`Tools: ${definition.tools?.join(", ") ?? "default"}`,
|
|
597
|
+
`AGENTS.md: ${definition.excludeAgentsMd ? "excluded" : "included"}`,
|
|
598
|
+
"",
|
|
599
|
+
"Body:",
|
|
600
|
+
definition.systemPrompt
|
|
601
|
+
].join("\n")
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function renderDiagnostic(diagnostic: DefinitionDiagnostic): string {
|
|
605
|
+
return `${diagnostic.message}\n\nSource: ${diagnostic.source}\nPath: ${diagnostic.path}`
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function renderTask(task: TaskListRow): string {
|
|
609
|
+
return [
|
|
610
|
+
`Task: ${task.id}`,
|
|
611
|
+
`State: ${task.state}`,
|
|
612
|
+
`Outcome: ${task.latestOutcome ?? "none"}`,
|
|
613
|
+
...(task.kind === "bash"
|
|
614
|
+
? [
|
|
615
|
+
`Command: ${task.command}`,
|
|
616
|
+
`Working directory: ${task.cwd}`,
|
|
617
|
+
`Exit code: ${task.exitCode ?? "none"}`,
|
|
618
|
+
`Signal: ${task.signal ?? "none"}`
|
|
619
|
+
]
|
|
620
|
+
: [
|
|
621
|
+
`Definition: ${task.definition}`,
|
|
622
|
+
`Model: ${task.model}`,
|
|
623
|
+
`Thinking: ${task.thinking}`,
|
|
624
|
+
`Queued Follow-ups: ${task.queuedFollowUps}`
|
|
625
|
+
]),
|
|
626
|
+
`Output lines: ${task.outputLines ?? "unknown"}`,
|
|
627
|
+
...(task.queueReason ? [`Waiting: ${task.queueReason}`] : []),
|
|
628
|
+
...(task.lastActivity ? [`Last activity: ${task.lastActivity.action} (${relativeTime(task.lastActivity.at, Date.now())})`] : []),
|
|
629
|
+
...(task.kind === "agent" ? [`Descendants: ${task.descendants.total}`] : []),
|
|
630
|
+
"",
|
|
631
|
+
`History: ${task.paths.history}`,
|
|
632
|
+
task.kind === "bash" ? `Output: ${task.paths.output}` : `Session: ${task.paths.session}`
|
|
633
|
+
].join("\n")
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function title(value: string): string {
|
|
637
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`
|
|
638
|
+
}
|