@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,506 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import type { TaskMetadata } from "./state.js"
3
+ import { publishSchedulerUpdate } from "./updates.js"
4
+
5
+ export const AGENT_COORDINATOR_VERSION = 3
6
+ const AGENT_COORDINATOR_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/coordinator")
7
+ const BASH_COORDINATOR_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/bash-coordinator")
8
+
9
+ export type ModelTuple = Readonly<{ provider: string; model: string }>
10
+
11
+ export type AgentScheduleRequest = {
12
+ tuple: ModelTuple
13
+ acceptanceOrder?: number
14
+ signal?: AbortSignal
15
+ /** Foreground callers cannot leave work parked behind a provider gate. */
16
+ rejectOnClosedTuple?: boolean
17
+ }
18
+
19
+ export type AgentPermit = {
20
+ readonly held: boolean
21
+ lend<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T>
22
+ release(): void
23
+ }
24
+
25
+ export type AgentReservation = {
26
+ readonly acceptanceOrder: number
27
+ activate(): void
28
+ run<T>(work: () => Promise<T>): Promise<T>
29
+ cancel(reason?: unknown): void
30
+ }
31
+
32
+ export type ResidentAgent = {
33
+ stop(): void | Promise<void>
34
+ dispose(): void | Promise<void>
35
+ input?(content: string, delivery: "followup" | "steer" | "stdin", options?: ResidentInputOptions): Promise<ResidentInputResult>
36
+ recover?(): boolean | Promise<boolean>
37
+ }
38
+
39
+ export type ResidentInputOptions = { background?: boolean; signal?: AbortSignal; eof?: boolean }
40
+
41
+ export type ResidentInputResult = {
42
+ delivery: "followup" | "steer" | "stdin"
43
+ queuePosition: number | null
44
+ queuedFollowUps: number
45
+ completed?: TaskMetadata
46
+ }
47
+
48
+ export type ParentNotification = Readonly<{ id: string; taskRef: string; content: string }>
49
+ export type ParentNotificationRoute = (notification: ParentNotification) => void | Promise<void>
50
+ export type ManagedSessionContext = Readonly<{
51
+ depth: number
52
+ allowAgents: boolean
53
+ /** Aborted before SDK disposal, which does not emit session_shutdown. */
54
+ disposeSignal?: AbortSignal
55
+ }>
56
+
57
+ export type AgentCoordinator = {
58
+ readonly version: typeof AGENT_COORDINATOR_VERSION
59
+ readonly maxConcurrency: number
60
+ readonly activeCount: number
61
+ readonly queuedCount: number
62
+ readonly residentCount: number
63
+ nextAcceptanceOrder(): number
64
+ setMaxConcurrency(limit: number): void
65
+ closeTuple(tuple: ModelTuple): void
66
+ openTuple(tuple: ModelTuple): void
67
+ isTupleOpen(tuple: ModelTuple): boolean
68
+ acquire(request: AgentScheduleRequest): Promise<AgentPermit>
69
+ run<T>(request: AgentScheduleRequest, work: () => Promise<T>): Promise<T>
70
+ reserve(request: AgentScheduleRequest): AgentReservation
71
+ withLentPermit<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T>
72
+ bindResident(taskKey: string, resident: ResidentAgent): () => void
73
+ getResident(taskKey: string): ResidentAgent | undefined
74
+ bindNotificationRoute(parentKey: string, route: ParentNotificationRoute): () => void
75
+ getNotificationRoute(parentKey: string): ParentNotificationRoute | undefined
76
+ bindSessionContext(sessionId: string, context: ManagedSessionContext): () => void
77
+ getSessionContext(sessionId: string): ManagedSessionContext | undefined
78
+ }
79
+
80
+ type Waiter = {
81
+ tuple: string
82
+ acceptanceOrder: number
83
+ queueOrder: number
84
+ bypassTupleGate: boolean
85
+ eligible: boolean
86
+ resolve: () => void
87
+ reject: (error: unknown) => void
88
+ signal?: AbortSignal
89
+ onAbort?: () => void
90
+ rejectOnClosedTuple: boolean
91
+ }
92
+
93
+ class ProcessAgentCoordinator implements AgentCoordinator {
94
+ readonly version = AGENT_COORDINATOR_VERSION
95
+ readonly #permits = new AsyncLocalStorage<Permit>()
96
+ readonly #closedTuples = new Set<string>()
97
+ readonly #waiters: Waiter[] = []
98
+ readonly #residents = new Map<string, ResidentAgent>()
99
+ readonly #notificationRoutes = new Map<string, ParentNotificationRoute>()
100
+ readonly #sessionContexts = new Map<string, ManagedSessionContext>()
101
+ #limit: number
102
+ #active = 0
103
+ #acceptanceOrder = 0
104
+ #queueOrder = 0
105
+
106
+ constructor(limit: number) {
107
+ assertConcurrency(limit)
108
+ this.#limit = limit
109
+ }
110
+
111
+ get maxConcurrency(): number {
112
+ return this.#limit
113
+ }
114
+
115
+ get activeCount(): number {
116
+ return this.#active
117
+ }
118
+
119
+ get queuedCount(): number {
120
+ return this.#waiters.length
121
+ }
122
+
123
+ get residentCount(): number {
124
+ return this.#residents.size
125
+ }
126
+
127
+ nextAcceptanceOrder(): number {
128
+ return ++this.#acceptanceOrder
129
+ }
130
+
131
+ setMaxConcurrency(limit: number): void {
132
+ assertConcurrency(limit)
133
+ this.#limit = limit
134
+ this.#drain()
135
+ }
136
+
137
+ closeTuple(tuple: ModelTuple): void {
138
+ assertTuple(tuple)
139
+ this.#closedTuples.add(tupleKey(tuple))
140
+ for (const waiter of [...this.#waiters]) {
141
+ if (waiter.tuple === tupleKey(tuple) && waiter.rejectOnClosedTuple) {
142
+ this.cancel(waiter, new Error("Provider/model is suspended by a provider limit; foreground work cannot wait for recovery"))
143
+ }
144
+ }
145
+ publishSchedulerUpdate()
146
+ }
147
+
148
+ openTuple(tuple: ModelTuple): void {
149
+ assertTuple(tuple)
150
+ this.#closedTuples.delete(tupleKey(tuple))
151
+ this.#drain()
152
+ }
153
+
154
+ isTupleOpen(tuple: ModelTuple): boolean {
155
+ assertTuple(tuple)
156
+ return !this.#closedTuples.has(tupleKey(tuple))
157
+ }
158
+
159
+ async acquire(request: AgentScheduleRequest): Promise<AgentPermit> {
160
+ await this.#waitForSlot(request, false)
161
+ return new Permit(this, { provider: request.tuple.provider, model: request.tuple.model })
162
+ }
163
+
164
+ async run<T>(request: AgentScheduleRequest, work: () => Promise<T>): Promise<T> {
165
+ const permit = await this.acquire(request)
166
+ return this.#permits.run(permit as Permit, async () => {
167
+ try {
168
+ return await work()
169
+ } finally {
170
+ permit.release()
171
+ }
172
+ })
173
+ }
174
+
175
+ reserve(request: AgentScheduleRequest): AgentReservation {
176
+ assertTuple(request.tuple)
177
+ if (request.signal?.aborted) throw abortError(request.signal)
178
+ const acceptanceOrder = request.acceptanceOrder ?? this.nextAcceptanceOrder()
179
+ if (!Number.isSafeInteger(acceptanceOrder) || acceptanceOrder < 1) {
180
+ throw new Error("acceptanceOrder must be a positive safe integer")
181
+ }
182
+ this.#acceptanceOrder = Math.max(this.#acceptanceOrder, acceptanceOrder)
183
+ const queued = this.#enqueue(request, acceptanceOrder, false, false)
184
+ return new Reservation(this, request.tuple, acceptanceOrder, queued.waiter, queued.promise)
185
+ }
186
+
187
+ async withLentPermit<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T> {
188
+ const permit = this.#permits.getStore()
189
+ return permit?.held ? permit.lend(wait, signal) : wait()
190
+ }
191
+
192
+ bindResident(taskKey: string, resident: ResidentAgent): () => void {
193
+ assertRegistryKey(taskKey)
194
+ this.#residents.set(taskKey, resident)
195
+ return () => {
196
+ if (this.#residents.get(taskKey) === resident) this.#residents.delete(taskKey)
197
+ }
198
+ }
199
+
200
+ getResident(taskKey: string): ResidentAgent | undefined {
201
+ assertRegistryKey(taskKey)
202
+ return this.#residents.get(taskKey)
203
+ }
204
+
205
+ bindNotificationRoute(parentKey: string, route: ParentNotificationRoute): () => void {
206
+ assertRegistryKey(parentKey)
207
+ this.#notificationRoutes.set(parentKey, route)
208
+ return () => {
209
+ if (this.#notificationRoutes.get(parentKey) === route) this.#notificationRoutes.delete(parentKey)
210
+ }
211
+ }
212
+
213
+ getNotificationRoute(parentKey: string): ParentNotificationRoute | undefined {
214
+ assertRegistryKey(parentKey)
215
+ return this.#notificationRoutes.get(parentKey)
216
+ }
217
+
218
+ bindSessionContext(sessionId: string, context: ManagedSessionContext): () => void {
219
+ assertRegistryKey(sessionId)
220
+ if (!Number.isSafeInteger(context.depth) || context.depth < 0)
221
+ throw new Error("Managed session depth must be a nonnegative safe integer")
222
+ const stored = { ...context }
223
+ this.#sessionContexts.set(sessionId, stored)
224
+ return () => {
225
+ if (this.#sessionContexts.get(sessionId) === stored) this.#sessionContexts.delete(sessionId)
226
+ }
227
+ }
228
+
229
+ getSessionContext(sessionId: string): ManagedSessionContext | undefined {
230
+ assertRegistryKey(sessionId)
231
+ return this.#sessionContexts.get(sessionId)
232
+ }
233
+
234
+ async reacquire(tuple: ModelTuple, signal?: AbortSignal): Promise<void> {
235
+ await this.#waitForSlot({ tuple, ...(signal ? { signal } : {}) }, true)
236
+ }
237
+
238
+ releaseSlot(): void {
239
+ if (this.#active <= 0) throw new Error("Agent coordinator released an unowned permit")
240
+ this.#active--
241
+ this.#drain()
242
+ }
243
+
244
+ async #waitForSlot(request: AgentScheduleRequest, bypassTupleGate: boolean): Promise<void> {
245
+ assertTuple(request.tuple)
246
+ if (request.signal?.aborted) throw abortError(request.signal)
247
+ const acceptanceOrder = request.acceptanceOrder ?? this.nextAcceptanceOrder()
248
+ if (!Number.isSafeInteger(acceptanceOrder) || acceptanceOrder < 1) {
249
+ throw new Error("acceptanceOrder must be a positive safe integer")
250
+ }
251
+ this.#acceptanceOrder = Math.max(this.#acceptanceOrder, acceptanceOrder)
252
+
253
+ await this.#enqueue(request, acceptanceOrder, bypassTupleGate, true).promise
254
+ }
255
+
256
+ #enqueue(
257
+ request: AgentScheduleRequest,
258
+ acceptanceOrder: number,
259
+ bypassTupleGate: boolean,
260
+ eligible: boolean
261
+ ): { waiter: Waiter; promise: Promise<void> } {
262
+ if (request.rejectOnClosedTuple && !this.isTupleOpen(request.tuple)) {
263
+ throw new Error("Provider/model is suspended by a provider limit; foreground work cannot wait for recovery")
264
+ }
265
+ let waiter!: Waiter
266
+ const promise = new Promise<void>((resolve, reject) => {
267
+ waiter = {
268
+ tuple: tupleKey(request.tuple),
269
+ acceptanceOrder,
270
+ queueOrder: ++this.#queueOrder,
271
+ bypassTupleGate,
272
+ eligible,
273
+ rejectOnClosedTuple: request.rejectOnClosedTuple ?? false,
274
+ resolve,
275
+ reject,
276
+ ...(request.signal ? { signal: request.signal } : {})
277
+ }
278
+ if (request.signal) {
279
+ waiter.onAbort = () => {
280
+ const index = this.#waiters.indexOf(waiter)
281
+ if (index < 0) return
282
+ this.#waiters.splice(index, 1)
283
+ reject(abortError(request.signal as AbortSignal))
284
+ }
285
+ request.signal.addEventListener("abort", waiter.onAbort, { once: true })
286
+ }
287
+ const insertion = this.#waiters.findIndex(
288
+ queued =>
289
+ queued.acceptanceOrder > waiter.acceptanceOrder ||
290
+ (queued.acceptanceOrder === waiter.acceptanceOrder && queued.queueOrder > waiter.queueOrder)
291
+ )
292
+ if (insertion < 0) this.#waiters.push(waiter)
293
+ else this.#waiters.splice(insertion, 0, waiter)
294
+ this.#drain()
295
+ })
296
+ return { waiter, promise }
297
+ }
298
+
299
+ activate(waiter: Waiter): void {
300
+ if (!this.#waiters.includes(waiter)) return
301
+ waiter.eligible = true
302
+ this.#drain()
303
+ }
304
+
305
+ cancel(waiter: Waiter, reason?: unknown): void {
306
+ const index = this.#waiters.indexOf(waiter)
307
+ if (index < 0) return
308
+ this.#waiters.splice(index, 1)
309
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort)
310
+ waiter.reject(reason instanceof Error ? reason : new Error("Agent reservation cancelled"))
311
+ }
312
+
313
+ async runReservation<T>(tuple: ModelTuple, waiter: Waiter, ready: Promise<void>, work: () => Promise<T>): Promise<T> {
314
+ this.activate(waiter)
315
+ await ready
316
+ const permit = new Permit(this, tuple)
317
+ return this.#permits.run(permit, async () => {
318
+ try {
319
+ return await work()
320
+ } finally {
321
+ permit.release()
322
+ }
323
+ })
324
+ }
325
+
326
+ #drain(): void {
327
+ while (this.#active < this.#limit) {
328
+ const index = this.#waiters.findIndex(waiter => waiter.eligible && (waiter.bypassTupleGate || !this.#closedTuples.has(waiter.tuple)))
329
+ if (index < 0) break
330
+ const [waiter] = this.#waiters.splice(index, 1)
331
+ if (!waiter) break
332
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort)
333
+ this.#active++
334
+ waiter.resolve()
335
+ }
336
+ publishSchedulerUpdate()
337
+ }
338
+ }
339
+
340
+ class Reservation implements AgentReservation {
341
+ readonly acceptanceOrder: number
342
+ readonly #coordinator: ProcessAgentCoordinator
343
+ readonly #tuple: ModelTuple
344
+ readonly #waiter: Waiter
345
+ readonly #ready: Promise<void>
346
+ #used = false
347
+
348
+ constructor(coordinator: ProcessAgentCoordinator, tuple: ModelTuple, acceptanceOrder: number, waiter: Waiter, ready: Promise<void>) {
349
+ this.#coordinator = coordinator
350
+ this.#tuple = { ...tuple }
351
+ this.acceptanceOrder = acceptanceOrder
352
+ this.#waiter = waiter
353
+ this.#ready = ready
354
+ void this.#ready.catch(() => {})
355
+ }
356
+
357
+ activate(): void {
358
+ this.#coordinator.activate(this.#waiter)
359
+ }
360
+
361
+ async run<T>(work: () => Promise<T>): Promise<T> {
362
+ if (this.#used) throw new Error("Agent reservation has already been used")
363
+ this.#used = true
364
+ return this.#coordinator.runReservation(this.#tuple, this.#waiter, this.#ready, work)
365
+ }
366
+
367
+ cancel(reason?: unknown): void {
368
+ this.#coordinator.cancel(this.#waiter, reason)
369
+ }
370
+ }
371
+
372
+ class Permit implements AgentPermit {
373
+ readonly #coordinator: ProcessAgentCoordinator
374
+ readonly #tuple: ModelTuple
375
+ #state: "held" | "lent" | "released" = "held"
376
+ #reacquireAbort: AbortController | undefined
377
+
378
+ constructor(coordinator: ProcessAgentCoordinator, tuple: ModelTuple) {
379
+ this.#coordinator = coordinator
380
+ this.#tuple = tuple
381
+ }
382
+
383
+ get held(): boolean {
384
+ return this.#state === "held"
385
+ }
386
+
387
+ async lend<T>(wait: () => Promise<T>, signal?: AbortSignal): Promise<T> {
388
+ if (this.#state !== "held") throw new Error(`Cannot lend an Agent permit while it is ${this.#state}`)
389
+ this.#state = "lent"
390
+ this.#coordinator.releaseSlot()
391
+
392
+ let succeeded = false
393
+ let result: T | undefined
394
+ let failure: unknown
395
+ try {
396
+ result = await wait()
397
+ succeeded = true
398
+ } catch (error) {
399
+ failure = error
400
+ }
401
+
402
+ if (this.#state === "lent") {
403
+ const reacquireAbort = new AbortController()
404
+ const forwardAbort = () => reacquireAbort.abort(signal?.reason)
405
+ if (signal?.aborted) forwardAbort()
406
+ else signal?.addEventListener("abort", forwardAbort, { once: true })
407
+ this.#reacquireAbort = reacquireAbort
408
+ try {
409
+ await this.#coordinator.reacquire(this.#tuple, reacquireAbort.signal)
410
+ if (this.currentState() === "released") {
411
+ this.#coordinator.releaseSlot()
412
+ throw abortError(reacquireAbort.signal)
413
+ }
414
+ this.#state = "held"
415
+ } catch (error) {
416
+ this.#state = "released"
417
+ throw error
418
+ } finally {
419
+ signal?.removeEventListener("abort", forwardAbort)
420
+ this.#reacquireAbort = undefined
421
+ }
422
+ }
423
+ if (!succeeded) throw failure
424
+ return result as T
425
+ }
426
+
427
+ release(): void {
428
+ if (this.#state === "released") return
429
+ if (this.#state === "held") this.#coordinator.releaseSlot()
430
+ this.#state = "released"
431
+ this.#reacquireAbort?.abort(new Error("Agent permit released while waiting to reacquire"))
432
+ }
433
+
434
+ private currentState(): "held" | "lent" | "released" {
435
+ return this.#state
436
+ }
437
+ }
438
+
439
+ export function createAgentCoordinator(maxConcurrency: number): AgentCoordinator {
440
+ return new ProcessAgentCoordinator(maxConcurrency)
441
+ }
442
+
443
+ export function getAgentCoordinator(maxConcurrency = 4): AgentCoordinator {
444
+ const globals = globalThis as unknown as Record<symbol, unknown>
445
+ const existing = globals[AGENT_COORDINATOR_SYMBOL]
446
+ if (existing !== undefined) {
447
+ if (!isAgentCoordinator(existing)) throw new Error("Incompatible process-global Lovely Agents coordinator")
448
+ return existing
449
+ }
450
+ const coordinator = createAgentCoordinator(maxConcurrency)
451
+ globals[AGENT_COORDINATOR_SYMBOL] = coordinator
452
+ return coordinator
453
+ }
454
+
455
+ /** Independent process permits; Bash residents still bind in the main coordinator. */
456
+ export function getBashCoordinator(maxConcurrency = 4): AgentCoordinator {
457
+ const globals = globalThis as unknown as Record<symbol, unknown>
458
+ const existing = globals[BASH_COORDINATOR_SYMBOL]
459
+ if (existing !== undefined) {
460
+ if (!isAgentCoordinator(existing)) throw new Error("Incompatible process-global Lovely Bash coordinator")
461
+ return existing
462
+ }
463
+ const coordinator = createAgentCoordinator(maxConcurrency)
464
+ globals[BASH_COORDINATOR_SYMBOL] = coordinator
465
+ return coordinator
466
+ }
467
+
468
+ function isAgentCoordinator(value: unknown): value is AgentCoordinator {
469
+ if (typeof value !== "object" || value === null) return false
470
+ const candidate = value as Partial<AgentCoordinator>
471
+ return (
472
+ candidate.version === AGENT_COORDINATOR_VERSION &&
473
+ typeof candidate.acquire === "function" &&
474
+ typeof candidate.reserve === "function" &&
475
+ typeof candidate.withLentPermit === "function" &&
476
+ typeof candidate.setMaxConcurrency === "function" &&
477
+ typeof candidate.bindResident === "function" &&
478
+ typeof candidate.bindNotificationRoute === "function" &&
479
+ typeof candidate.bindSessionContext === "function"
480
+ )
481
+ }
482
+
483
+ function tupleKey(tuple: ModelTuple): string {
484
+ return `${tuple.provider}\0${tuple.model}`
485
+ }
486
+
487
+ function assertTuple(tuple: ModelTuple): void {
488
+ if (!tuple.provider || !tuple.model || tuple.provider.includes("\0") || tuple.model.includes("\0")) {
489
+ throw new Error("Model tuple provider and model must be nonempty and contain no NUL bytes")
490
+ }
491
+ }
492
+
493
+ function assertConcurrency(limit: number): void {
494
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("maxConcurrency must be a positive safe integer")
495
+ }
496
+
497
+ function assertRegistryKey(key: string): void {
498
+ if (!key) throw new Error("Coordinator registry keys must be nonempty")
499
+ }
500
+
501
+ function abortError(signal: AbortSignal): Error {
502
+ if (signal.reason instanceof Error) return signal.reason
503
+ const error = new Error(signal.reason === undefined ? "Agent scheduling aborted" : String(signal.reason))
504
+ error.name = "AbortError"
505
+ return error
506
+ }