@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,192 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+ import { Key, matchesKey, type SelectItem, SelectList, truncateToWidth } from "@earendil-works/pi-tui"
3
+ import type { TaskListResult, TaskListRow } from "./tools.js"
4
+ import { bindTaskUpdateRoute } from "./updates.js"
5
+
6
+ const PANEL_ID = "lovely-agents"
7
+
8
+ /** One below-editor surface: passive active rows, or a focused list of all direct tasks. */
9
+ export function createTaskPanel(
10
+ ctx: ExtensionContext,
11
+ options: { loadTasks: () => Promise<TaskListResult>; openSelection: (value: string) => Promise<void> }
12
+ ) {
13
+ let tasks: TaskListResult["tasks"] = []
14
+ let items: SelectItem[] = []
15
+ let capacity = { active: 0, limit: 0 }
16
+ let bashCapacity = { active: 0, limit: 0 }
17
+ let selected: string | undefined
18
+ let focused = false
19
+ let opening = false
20
+ let disposed = false
21
+ let pending: Promise<void> | undefined
22
+ let refreshAgain = false
23
+
24
+ function draw() {
25
+ if (disposed) return
26
+ const active = tasks.filter(task => task.state === "queued" || task.state === "running" || task.state === "suspended")
27
+ const bashCount = active.filter(task => task.kind === "bash").length
28
+ const agentCount = active.length - bashCount
29
+ ctx.ui.setStatus(
30
+ PANEL_ID,
31
+ active.length > 0
32
+ ? [
33
+ ...(agentCount ? [`agents:${agentCount} slots:${capacity.active}/${capacity.limit}`] : []),
34
+ ...(bashCount ? [`bash:${bashCount} slots:${bashCapacity.active}/${bashCapacity.limit}`] : [])
35
+ ].join(" ")
36
+ : undefined
37
+ )
38
+ if (opening || (!focused && active.length === 0)) {
39
+ ctx.ui.setWidget(PANEL_ID, undefined)
40
+ return
41
+ }
42
+ ctx.ui.setWidget(
43
+ PANEL_ID,
44
+ (_tui, theme) => ({
45
+ render(width) {
46
+ if (!focused) return renderActiveTaskRows(active).map(line => truncateToWidth(line, width))
47
+ const list = new SelectList(
48
+ items,
49
+ 5,
50
+ {
51
+ selectedPrefix: text => theme.fg("accent", text),
52
+ selectedText: text => theme.fg("accent", text),
53
+ description: text => theme.fg("muted", text),
54
+ scrollInfo: text => theme.fg("dim", text),
55
+ noMatch: text => theme.fg("warning", text)
56
+ },
57
+ { truncatePrimary: ({ text, maxWidth }) => truncateToWidth(text, maxWidth) }
58
+ )
59
+ list.setSelectedIndex(items.findIndex(item => item.value === selected))
60
+ return [
61
+ ...(items.length > 0 ? list.render(width) : ["No durable tasks for this session."]),
62
+ theme.fg("dim", "↑↓ navigate · Enter actions · Esc/↑ at top editor · type to edit")
63
+ ].map(line => truncateToWidth(line, width))
64
+ },
65
+ invalidate() {}
66
+ }),
67
+ { placement: "belowEditor" }
68
+ )
69
+ }
70
+
71
+ function refresh(): Promise<void> {
72
+ if (disposed) return Promise.resolve()
73
+ refreshAgain = true
74
+ pending ??= (async () => {
75
+ do {
76
+ refreshAgain = false
77
+ const result = await options.loadTasks()
78
+ if (disposed) return
79
+ const oldIndex = items.findIndex(item => item.value === selected)
80
+ tasks = [...result.tasks].sort((left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id))
81
+ capacity = result.capacity
82
+ bashCapacity = result.bashCapacity
83
+ items = [
84
+ ...tasks.map(task => ({
85
+ value: `task:${task.id}`,
86
+ // One full-width row, not SelectList's fixed-width label/description columns.
87
+ label: (
88
+ `${task.id} ${task.state}${task.latestOutcome ? `/${task.latestOutcome}` : ""} ${task.label} · ` +
89
+ `${task.queueReason ? `waiting: ${task.queueReason} · ` : ""}${task.kind === "bash" ? "bash" : task.model}${task.queuedFollowUps ? ` (+${task.queuedFollowUps})` : ""}` +
90
+ (task.inputPreview ? ` · ${JSON.stringify(task.inputPreview)}` : "")
91
+ ).replace(/[\r\n]+/g, " ")
92
+ })),
93
+ ...result.diagnostics.map(diagnostic => ({
94
+ value: `diagnostic:${diagnostic.path}`,
95
+ label: `[error] ${diagnostic.id ?? diagnostic.code}`,
96
+ description: diagnostic.message
97
+ }))
98
+ ]
99
+ // Keep identity across live reordering; on removal select the nearest remaining row.
100
+ if (!items.some(item => item.value === selected)) {
101
+ selected = items[Math.max(0, Math.min(oldIndex, items.length - 1))]?.value
102
+ }
103
+ draw()
104
+ } while (refreshAgain)
105
+ })().finally(() => {
106
+ pending = undefined
107
+ })
108
+ return pending
109
+ }
110
+
111
+ function focus() {
112
+ if (disposed || opening) return
113
+ focused = true
114
+ selected = items[0]?.value
115
+ draw()
116
+ }
117
+
118
+ async function openSelected(value: string) {
119
+ opening = true
120
+ draw()
121
+ try {
122
+ await options.openSelection(value)
123
+ } finally {
124
+ opening = false
125
+ if (!disposed) {
126
+ draw()
127
+ await refresh()
128
+ }
129
+ }
130
+ }
131
+
132
+ const reportError = (error: unknown) => {
133
+ if (!disposed) ctx.ui.notify(`Lovely Agents task panel: ${error instanceof Error ? error.message : String(error)}`, "error")
134
+ }
135
+ const unbind = bindTaskUpdateRoute(ctx.cwd, ctx.sessionManager.getSessionId(), () => refresh().catch(reportError))
136
+ return {
137
+ refresh,
138
+ focus,
139
+ /** True consumes input; false delegates the original bytes to the existing editor. */
140
+ handleInput(data: string, editorEmpty: boolean): boolean {
141
+ if (disposed) return false
142
+ if (opening) return true
143
+ if (!focused) {
144
+ if (!editorEmpty || !matchesKey(data, Key.down)) return false
145
+ focus()
146
+ return true
147
+ }
148
+ const index = items.findIndex(item => item.value === selected)
149
+ if (matchesKey(data, Key.escape) || (matchesKey(data, Key.up) && index <= 0)) {
150
+ focused = false
151
+ } else if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
152
+ const delta = matchesKey(data, Key.up) ? -1 : 1
153
+ selected = items[Math.max(0, Math.min(index + delta, items.length - 1))]?.value
154
+ } else if (matchesKey(data, Key.enter)) {
155
+ if (selected) void openSelected(selected).catch(reportError)
156
+ } else {
157
+ focused = false
158
+ draw()
159
+ return false
160
+ }
161
+ draw()
162
+ return true
163
+ },
164
+ dispose() {
165
+ if (disposed) return
166
+ disposed = true
167
+ unbind()
168
+ ctx.ui.setStatus(PANEL_ID, undefined)
169
+ ctx.ui.setWidget(PANEL_ID, undefined)
170
+ }
171
+ }
172
+ }
173
+
174
+ export function renderActiveTaskRows(
175
+ tasks: readonly ({
176
+ id: string
177
+ label: string
178
+ state: string
179
+ queuedFollowUps: number
180
+ } & Partial<Pick<TaskListRow, "queueReason" | "inputPreview">>)[]
181
+ ): string[] {
182
+ const rows = tasks
183
+ .slice(0, 5)
184
+ .map(
185
+ task =>
186
+ `↳ ${task.id} ${task.state} ${task.label.replace(/[\r\n]+/g, " ")}${task.queuedFollowUps ? ` (+${task.queuedFollowUps})` : ""}` +
187
+ (task.queueReason ? ` · waiting: ${task.queueReason}` : "") +
188
+ (task.inputPreview ? ` · ${JSON.stringify(task.inputPreview)}` : "")
189
+ )
190
+ if (tasks.length > rows.length) rows.push(` … ${tasks.length - rows.length} more active`)
191
+ return rows
192
+ }