@xl0/pi-lovely-agents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/extensions/lovely-agents/agent.ts +1374 -0
  5. package/extensions/lovely-agents/bash.ts +599 -0
  6. package/extensions/lovely-agents/child-session.ts +296 -0
  7. package/extensions/lovely-agents/config.ts +221 -0
  8. package/extensions/lovely-agents/coordinator.ts +506 -0
  9. package/extensions/lovely-agents/definitions.ts +380 -0
  10. package/extensions/lovely-agents/index.ts +400 -0
  11. package/extensions/lovely-agents/lifecycle.ts +251 -0
  12. package/extensions/lovely-agents/management.ts +638 -0
  13. package/extensions/lovely-agents/notifications.ts +220 -0
  14. package/extensions/lovely-agents/provider-limits.ts +13 -0
  15. package/extensions/lovely-agents/rendering.ts +90 -0
  16. package/extensions/lovely-agents/state.ts +1179 -0
  17. package/extensions/lovely-agents/task-panel.ts +192 -0
  18. package/extensions/lovely-agents/tools.ts +635 -0
  19. package/extensions/lovely-agents/updates.ts +45 -0
  20. package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
  21. package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
  22. package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
  23. package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
  24. package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
  25. package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
  26. package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
  27. package/package.json +68 -0
  28. package/skills/agent/SKILL.md +21 -0
  29. package/skills/agent-creator/SKILL.md +35 -0
@@ -0,0 +1,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
+ }