@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,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
+ }