@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,220 @@
1
+ import type { Dirent } from "node:fs"
2
+ import { readdir } from "node:fs/promises"
3
+ import { resolve } from "node:path"
4
+ import { getAgentCoordinator } from "./coordinator.js"
5
+ import {
6
+ MAX_NOTIFICATION_CONTENT_BYTES,
7
+ MAX_TASK_NOTIFICATIONS,
8
+ mutateTaskMetadata,
9
+ parentStoragePaths,
10
+ readTaskMetadata,
11
+ retainedPaths,
12
+ TASK_REFERENCE_PATTERN,
13
+ type TaskMetadata,
14
+ type TaskStoragePaths,
15
+ taskStoragePaths
16
+ } from "./state.js"
17
+ import { loadTaskList } from "./tools.js"
18
+
19
+ export const NOTIFICATION_CUSTOM_TYPE = "lovely-agents:notification"
20
+ export const NOTIFICATION_OUTPUT_PREVIEW_BYTES = 2 * 1024
21
+ const NOTIFICATION_IN_FLIGHT_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/notification-in-flight/v1")
22
+
23
+ type TaskNotification = TaskMetadata["notifications"][number]
24
+ type NotificationType = TaskNotification["type"]
25
+ type ActiveRun = NonNullable<TaskMetadata["activeRun"]>
26
+
27
+ /** Stable process-global route identity for one workspace/session partition. */
28
+ export function notificationRouteKey(cwd: string, parentSessionId: string): string {
29
+ return `${resolve(cwd)}\0${parentSessionId}`
30
+ }
31
+
32
+ /** Drops process-local send suppression when an exact parent runtime closes. */
33
+ export function clearNotificationInFlight(cwd: string, parentSessionId: string): void {
34
+ const prefix = `${notificationRouteKey(cwd, parentSessionId)}\0`
35
+ const inFlight = notificationInFlight()
36
+ for (const key of [...inFlight]) {
37
+ if (key.startsWith(prefix)) inFlight.delete(key)
38
+ }
39
+ }
40
+
41
+ /** Builds one bounded durable notification before its state transition commits. */
42
+ export async function prepareTaskNotification(
43
+ paths: TaskStoragePaths,
44
+ metadata: TaskMetadata,
45
+ run: ActiveRun,
46
+ type: NotificationType,
47
+ outcome?: TaskMetadata["latestOutcome"]
48
+ ): Promise<TaskNotification> {
49
+ const output = truncateUtf8(metadata.latestReply?.text ?? "", NOTIFICATION_OUTPUT_PREVIEW_BYTES)
50
+ const taskList = await loadTaskList(paths.workspace, metadata.parentSessionId).catch(() => undefined)
51
+ const descendants = taskList?.details.tasks.find(task => task.id === metadata.taskRef)?.descendants
52
+ const descendantText = descendants && descendants.total > 0 ? `\nDescendants: ${JSON.stringify(descendants)}` : ""
53
+ const pathsForDisplay = retainedPaths(paths)
54
+ const status = type === "suspension" ? "suspended by a provider limit" : type === "interruption" ? "interrupted" : `completed: ${outcome}`
55
+ const content = truncateUtf8(
56
+ [
57
+ `[Lovely ${metadata.kind === "bash" ? "Bash" : "Agent"} ${metadata.taskRef}:${run.id}:${type}]`,
58
+ `Task ${metadata.taskRef} ${JSON.stringify(metadata.label)} ${status}`,
59
+ metadata.kind === "bash"
60
+ ? `Command: ${truncateUtf8(metadata.command, 1024)}\nExit: ${metadata.exitCode ?? "unknown"}${metadata.signal ? ` signal=${metadata.signal}` : ""}`
61
+ : `Model: ${metadata.model.provider}/${metadata.model.id}:${metadata.thinking}`,
62
+ output ? `Output:\n${output}` : "Output: (empty)",
63
+ `Files: history=${pathsForDisplay.history} ${metadata.kind === "bash" ? `output=${pathsForDisplay.output}` : `session=${pathsForDisplay.session}`}${descendantText}`
64
+ ].join("\n"),
65
+ MAX_NOTIFICATION_CONTENT_BYTES
66
+ )
67
+ return {
68
+ id: `${metadata.taskRef}:${run.id}:${type}`,
69
+ type,
70
+ runId: run.id,
71
+ content,
72
+ createdAt: Date.now()
73
+ }
74
+ }
75
+
76
+ /** Appends idempotently while keeping the durable queue bounded. */
77
+ export function appendTaskNotification(notifications: TaskNotification[], notification: TaskNotification): TaskNotification[] {
78
+ if (notifications.some(existing => existing.id === notification.id)) return notifications
79
+ if (notifications.length < MAX_TASK_NOTIFICATIONS) return [...notifications, notification]
80
+ const delivered = notifications.findIndex(existing => existing.deliveredAt !== undefined)
81
+ const retained = notifications.filter((_, index) => index !== (delivered >= 0 ? delivered : 0))
82
+ return [...retained, notification]
83
+ }
84
+
85
+ /** Sends every pending notification for one task, without marking delivery. */
86
+ export async function deliverTaskNotifications(paths: TaskStoragePaths): Promise<number> {
87
+ const loaded = await readTaskMetadata(paths)
88
+ if (loaded.status !== "ok" || loaded.metadata.discardedAt !== null) return 0
89
+ const routeKey = notificationRouteKey(paths.workspace, loaded.metadata.parentSessionId)
90
+ const route = getAgentCoordinator().getNotificationRoute(routeKey)
91
+ if (!route) return 0
92
+ const inFlight = notificationInFlight()
93
+ let sent = 0
94
+ for (const notification of loaded.metadata.notifications) {
95
+ if (notification.deliveredAt !== undefined) continue
96
+ const key = `${routeKey}\0${notification.id}`
97
+ if (inFlight.has(key)) continue
98
+ inFlight.add(key)
99
+ try {
100
+ await route({ id: notification.id, taskRef: loaded.metadata.taskRef, content: notification.content })
101
+ sent++
102
+ } catch (error) {
103
+ inFlight.delete(key)
104
+ throw error
105
+ }
106
+ }
107
+ return sent
108
+ }
109
+
110
+ /** Reconciles transcript evidence, then resends only still-absent direct notices. */
111
+ export async function reconcileParentNotifications(
112
+ cwd: string,
113
+ parentSessionId: string,
114
+ entries: readonly unknown[]
115
+ ): Promise<{ delivered: number; sent: number; diagnostics: string[] }> {
116
+ const observed = observedNotificationIds(entries)
117
+ const result = { delivered: 0, sent: 0, diagnostics: [] as string[] }
118
+ for (const paths of await directTaskPaths(cwd, parentSessionId)) {
119
+ const loaded = await readTaskMetadata(paths)
120
+ if (loaded.status !== "ok") {
121
+ if (loaded.status === "invalid") result.diagnostics.push(`${paths.taskDirectory}: ${loaded.diagnostic.message}`)
122
+ continue
123
+ }
124
+ const pendingObserved = loaded.metadata.notifications.filter(
125
+ notification => notification.deliveredAt === undefined && observed.has(notification.id)
126
+ )
127
+ if (pendingObserved.length > 0) {
128
+ const ids = new Set(pendingObserved.map(notification => notification.id))
129
+ await mutateTaskMetadata(paths, metadata => ({
130
+ ...metadata,
131
+ notifications: metadata.notifications.map(notification =>
132
+ ids.has(notification.id) && notification.deliveredAt === undefined ? { ...notification, deliveredAt: Date.now() } : notification
133
+ ),
134
+ updatedAt: Date.now()
135
+ }))
136
+ result.delivered += ids.size
137
+ }
138
+ try {
139
+ result.sent += await deliverTaskNotifications(paths)
140
+ } catch (error) {
141
+ result.diagnostics.push(`${paths.taskDirectory}: notification delivery failed: ${errorMessage(error)}`)
142
+ }
143
+ }
144
+ return result
145
+ }
146
+
147
+ /** Marks one observed custom message delivered in its owning direct task. */
148
+ export async function observeNotification(cwd: string, parentSessionId: string, taskRef: string, notificationId: string): Promise<boolean> {
149
+ const paths = taskStoragePaths(parentStoragePaths(cwd, parentSessionId), taskRef)
150
+ let changed = false
151
+ await mutateTaskMetadata(paths, metadata => {
152
+ if (metadata.parentSessionId !== parentSessionId || metadata.taskRef !== taskRef) return metadata
153
+ const notifications = metadata.notifications.map(notification => {
154
+ if (notification.id !== notificationId || notification.deliveredAt !== undefined) return notification
155
+ changed = true
156
+ return { ...notification, deliveredAt: Date.now() }
157
+ })
158
+ return changed ? { ...metadata, notifications, updatedAt: Date.now() } : metadata
159
+ })
160
+ notificationInFlight().delete(`${notificationRouteKey(cwd, parentSessionId)}\0${notificationId}`)
161
+ return changed
162
+ }
163
+
164
+ export function observedNotificationIds(entries: readonly unknown[]): Set<string> {
165
+ const ids = new Set<string>()
166
+ for (const entry of entries) {
167
+ if (!entry || typeof entry !== "object") continue
168
+ const candidate = entry as { type?: unknown; customType?: unknown; details?: unknown }
169
+ if (candidate.type !== "custom_message" || candidate.customType !== NOTIFICATION_CUSTOM_TYPE) continue
170
+ if (!candidate.details || typeof candidate.details !== "object") continue
171
+ const id = (candidate.details as { notificationId?: unknown }).notificationId
172
+ if (typeof id === "string" && id) ids.add(id)
173
+ }
174
+ return ids
175
+ }
176
+
177
+ export function notificationDetails(value: unknown): { notificationId: string; taskRef: string } | undefined {
178
+ if (!value || typeof value !== "object") return undefined
179
+ const details = value as { notificationId?: unknown; taskRef?: unknown }
180
+ return typeof details.notificationId === "string" && typeof details.taskRef === "string"
181
+ ? { notificationId: details.notificationId, taskRef: details.taskRef }
182
+ : undefined
183
+ }
184
+
185
+ function notificationInFlight(): Set<string> {
186
+ const global = globalThis as typeof globalThis & { [NOTIFICATION_IN_FLIGHT_SYMBOL]?: Set<string> }
187
+ global[NOTIFICATION_IN_FLIGHT_SYMBOL] ??= new Set()
188
+ return global[NOTIFICATION_IN_FLIGHT_SYMBOL]
189
+ }
190
+
191
+ async function directTaskPaths(cwd: string, parentSessionId: string): Promise<TaskStoragePaths[]> {
192
+ const parent = parentStoragePaths(cwd, parentSessionId)
193
+ let entries: Dirent[]
194
+ try {
195
+ entries = await readdir(parent.parentDirectory, { withFileTypes: true })
196
+ } catch (error) {
197
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return []
198
+ throw error
199
+ }
200
+ return entries
201
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && TASK_REFERENCE_PATTERN.test(entry.name))
202
+ .map(entry => taskStoragePaths(parent, entry.name))
203
+ .sort((left, right) => left.taskDirectory.localeCompare(right.taskDirectory))
204
+ }
205
+
206
+ function truncateUtf8(content: string, maximumBytes: number): string {
207
+ const bytes = Buffer.from(content)
208
+ if (bytes.length <= maximumBytes) return content
209
+ let end = maximumBytes - 3
210
+ while (end > 0 && isUtf8Continuation(bytes[end])) end--
211
+ return `${bytes.subarray(0, end).toString("utf8")}...`
212
+ }
213
+
214
+ function isUtf8Continuation(byte: number | undefined): boolean {
215
+ return byte !== undefined && (byte & 0xc0) === 0x80
216
+ }
217
+
218
+ function errorMessage(error: unknown): string {
219
+ return error instanceof Error ? error.message : String(error)
220
+ }
@@ -0,0 +1,13 @@
1
+ const ACCOUNT_LIMIT =
2
+ /(?:GoUsageLimitError|FreeUsageLimitError|insufficient[_ -]?quota|\bquota\b|\bbilling\b|\bbudget\b|usage[_ -]?limit|usage[_ -]?not[_ -]?included|available balance)/i
3
+ const EXPLICIT_LIMIT = /(?:\b429\b|ResourceExhausted|RESOURCE_EXHAUSTED)/i
4
+ const TRANSIENT_LIMIT = /(?:rate[_ -]?limit|too many requests)/i
5
+ const NON_LIMIT_TRANSIENT =
6
+ /(?:overload(?:ed)?|\b5\d\d\b|service[_ -]?unavailable|server[_ -]?error|internal[_ -]?error|network[_ -]?error|connection[_ -]?(?:error|refused|lost)|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|upstream[_ -]?connect|reset before headers|socket hang up|timed? out|timeout|websocket[_ -]?(?:closed|error))/i
7
+
8
+ /** True only for terminal provider errors that should suspend an exact model tuple. */
9
+ export function isProviderLimitError(errorMessage: string | undefined): boolean {
10
+ if (!errorMessage) return false
11
+ if (ACCOUNT_LIMIT.test(errorMessage) || EXPLICIT_LIMIT.test(errorMessage)) return true
12
+ return TRANSIENT_LIMIT.test(errorMessage) && !NON_LIMIT_TRANSIENT.test(errorMessage)
13
+ }
@@ -0,0 +1,90 @@
1
+ import { keyHint, type MessageRenderer, type Theme } from "@earendil-works/pi-coding-agent"
2
+ import { Box, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui"
3
+
4
+ const COLLAPSED_LINES = 10
5
+ const COLLAPSED_HEAD_LINES = 6
6
+ const COLLAPSED_TAIL_LINES = 3
7
+ const COLLAPSED_CHARACTERS = 1_200
8
+ const PREVIEW_LINE_CHARACTERS = 240
9
+
10
+ type TextToolResult = {
11
+ content: Array<{ type: string; text?: string }>
12
+ }
13
+
14
+ /** Notifications have a distinct message shell; only the header is visible when collapsed. */
15
+ export const renderAgentNotification: MessageRenderer = (message, { expanded, outputPad }, theme) => {
16
+ const content =
17
+ typeof message.content === "string"
18
+ ? message.content
19
+ : message.content
20
+ .filter(part => part.type === "text")
21
+ .map(part => part.text)
22
+ .join("\n")
23
+ const lines = content.split("\n")
24
+ const summary = (/^\[Lovely (Agent|Bash) /.test(lines[0] ?? "") ? lines[1] : lines[0]) || "Notification"
25
+ return {
26
+ render(width) {
27
+ const box = new Box(outputPad, 0, text => theme.bg("customMessageBg", text))
28
+ box.addChild({
29
+ render: available => [
30
+ truncateToWidth(
31
+ theme.fg("customMessageLabel", theme.bold(`${expanded ? "▾" : "▸"} Lovely Agents · ${summary}`)),
32
+ available
33
+ ).replaceAll("\x1b[0m", "\x1b[22;39m")
34
+ ],
35
+ invalidate() {}
36
+ })
37
+ if (expanded && content) {
38
+ box.addChild(new Spacer(1))
39
+ box.addChild(new Text(theme.fg("customMessageText", content), 0, 0))
40
+ }
41
+ return box.render(width).map(line => truncateToWidth(line, width))
42
+ },
43
+ invalidate() {}
44
+ }
45
+ }
46
+
47
+ /** Renders short results whole and long results as a head/tail preview toggled by Ctrl+O. */
48
+ export function renderExpandableResult(result: TextToolResult, expanded: boolean, theme: Theme, outputPad = 0): Text {
49
+ const output = result.content
50
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
51
+ .map(part => part.text)
52
+ .join("\n")
53
+ if (!output) return new Text("", outputPad, 0)
54
+
55
+ const lines = output.split("\n")
56
+ const characterCount = Array.from(output).length
57
+ if (expanded || (lines.length <= COLLAPSED_LINES && characterCount <= COLLAPSED_CHARACTERS)) {
58
+ return new Text(lines.map(line => theme.fg("toolOutput", line)).join("\n"), outputPad, 0)
59
+ }
60
+
61
+ const preview =
62
+ lines.length > COLLAPSED_LINES
63
+ ? [
64
+ ...lines.slice(0, COLLAPSED_HEAD_LINES).map(line => theme.fg("toolOutput", previewLine(line))),
65
+ expansionHint(`${lines.length - COLLAPSED_HEAD_LINES - COLLAPSED_TAIL_LINES} more lines`, theme),
66
+ ...lines.slice(-COLLAPSED_TAIL_LINES).map(line => theme.fg("toolOutput", previewLine(line)))
67
+ ]
68
+ : characterPreview(output, theme)
69
+ return new Text(preview.join("\n"), outputPad, 0)
70
+ }
71
+
72
+ function characterPreview(output: string, theme: Theme): string[] {
73
+ const characters = Array.from(output)
74
+ const head = characters.slice(0, 800).join("")
75
+ const tail = characters.slice(-300).join("")
76
+ return [
77
+ ...head.split("\n").map(line => theme.fg("toolOutput", line)),
78
+ expansionHint(`${characters.length - 1_100} more characters`, theme),
79
+ ...tail.split("\n").map(line => theme.fg("toolOutput", line))
80
+ ]
81
+ }
82
+
83
+ function previewLine(line: string): string {
84
+ const characters = Array.from(line)
85
+ return characters.length <= PREVIEW_LINE_CHARACTERS ? line : `${characters.slice(0, PREVIEW_LINE_CHARACTERS).join("")}...`
86
+ }
87
+
88
+ function expansionHint(omitted: string, theme: Theme): string {
89
+ return `${theme.fg("muted", `... (${omitted}, `)}${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`
90
+ }