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