@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,599 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
2
+ import { randomBytes } from "node:crypto"
3
+ import { constants } from "node:fs"
4
+ import { type FileHandle, open, rm, stat } from "node:fs/promises"
5
+ import { resolve } from "node:path"
6
+ import type { Readable } from "node:stream"
7
+ import { StringDecoder } from "node:string_decoder"
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
9
+ import { Container, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
10
+ import { type Static, Type } from "typebox"
11
+ import type { AgentsConfig } from "./config.js"
12
+ import {
13
+ getAgentCoordinator,
14
+ getBashCoordinator,
15
+ type ResidentAgent,
16
+ type ResidentInputOptions,
17
+ type ResidentInputResult
18
+ } from "./coordinator.js"
19
+ import { appendTaskNotification, deliverTaskNotifications, prepareTaskNotification } from "./notifications.js"
20
+ import { renderExpandableResult } from "./rendering.js"
21
+ import {
22
+ acquireParentLease,
23
+ appendHistoryLog,
24
+ type BashTaskMetadata,
25
+ ensureParentStorage,
26
+ initializeRetainedLogs,
27
+ MAX_AGENT_INPUT_BYTES,
28
+ MAX_AGENT_LABEL_BYTES,
29
+ mutateTaskMetadata,
30
+ RETAINED_OUTPUT_MAX_BYTES,
31
+ RETAINED_OUTPUT_MAX_LINES,
32
+ readRetainedOutput,
33
+ readTaskMetadata,
34
+ reserveTaskStorage,
35
+ TASK_METADATA_VERSION,
36
+ type TaskStoragePaths,
37
+ writeTaskMetadata,
38
+ writeTaskProgress
39
+ } from "./state.js"
40
+
41
+ const BashParameters = Type.Object(
42
+ {
43
+ command: Type.String({ minLength: 1, description: "Literal bash -c command" }),
44
+ label: Type.String({ minLength: 1, description: "Short task label" }),
45
+ cwd: Type.Optional(Type.String({ minLength: 1, description: "Working directory, relative to the workspace or absolute" })),
46
+ waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 600_000, description: "Wait before detaching; default 0" }))
47
+ },
48
+ { additionalProperties: false }
49
+ )
50
+
51
+ export type BashToolInput = Static<typeof BashParameters>
52
+ export type BashCreationResult = {
53
+ id: string
54
+ label: string
55
+ state: BashTaskMetadata["state"]
56
+ latestOutcome: BashTaskMetadata["latestOutcome"]
57
+ exitCode: number | null
58
+ signal: string | null
59
+ detached: boolean
60
+ output: Awaited<ReturnType<typeof readRetainedOutput>>
61
+ }
62
+
63
+ export function registerBashTool(pi: ExtensionAPI, options: { getConfig: () => AgentsConfig }): void {
64
+ pi.registerTool({
65
+ name: "bash_bg",
66
+ label: "Background Bash",
67
+ description:
68
+ "Run a literal Bash command as a durable b_ task. Defaults to immediate background execution; waitMs optionally waits without restarting. Output tail is capped at 2000 lines/50 KiB; full stdout/stderr stays in output.log.",
69
+ promptSnippet: "Run a background Bash command with durable output and task controls",
70
+ promptGuidelines: [
71
+ "Use bash_bg for background shell work, not as a replacement for normal bash. Use task_list/task_output/task_stop/task_discard with its b_ ID.",
72
+ "Use task_input on a running bash_bg task for literal stdin, optionally eof:true to close stdin; agent Follow-up/Steer modes are not supported. Commands never restart automatically."
73
+ ],
74
+ parameters: BashParameters,
75
+ renderCall(args, theme, context) {
76
+ return {
77
+ render(width) {
78
+ const header = `${theme.fg("toolTitle", theme.bold("bash_bg"))}${args.label ? ` ${theme.fg("dim", `label=${JSON.stringify(args.label)}`)}` : ""}`
79
+ const suffix = context.state.taskRef ? `${theme.fg("muted", " -> ")}${theme.fg("accent", context.state.taskRef)}` : ""
80
+ if (context.expanded) {
81
+ return new Text(`${header}${suffix}\n${args.command ?? ""}`, 0, 0).render(width)
82
+ }
83
+ const available = Math.max(0, width - visibleWidth(suffix))
84
+ const preview = `${header}${args.command ? ` ${theme.fg("muted", JSON.stringify(args.command.replace(/\s+/g, " ").trim()))}` : ""}`
85
+ // Full resets from Pi's truncation must not erase the surrounding tool background.
86
+ return [truncateToWidth(truncateToWidth(preview, available) + suffix, width).replaceAll("\x1b[0m", "\x1b[22;39m")]
87
+ },
88
+ invalidate() {}
89
+ }
90
+ },
91
+ renderResult(result, { expanded }, theme, context) {
92
+ const details = result.details as Partial<BashCreationResult> | undefined
93
+ if (typeof details?.id === "string") context.state.taskRef = details.id
94
+ const output = new Container()
95
+ if (expanded || context.isError) output.addChild(renderExpandableResult(result, true, theme))
96
+ return output
97
+ },
98
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
99
+ const config = options.getConfig()
100
+ if (!config.backgroundBash) throw new Error("bash_bg requires backgroundBash to be enabled")
101
+ signal?.throwIfAborted()
102
+ if (process.platform === "win32") throw new Error("bash_bg requires POSIX process-group termination; Windows is unsupported")
103
+ if (typeof params.command !== "string" || !params.command.trim() || params.command.includes("\0")) {
104
+ throw new Error("command must be nonblank and contain no NUL bytes")
105
+ }
106
+ if (Buffer.byteLength(params.command) > MAX_AGENT_INPUT_BYTES) throw new Error("command must be at most 64 KiB")
107
+ if (typeof params.label !== "string" || !params.label.trim() || Buffer.byteLength(params.label.trim()) > MAX_AGENT_LABEL_BYTES) {
108
+ throw new Error("label must be nonblank and at most 80 UTF-8 bytes")
109
+ }
110
+ const waitMs = params.waitMs ?? 0
111
+ if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 600_000) throw new Error("waitMs must be an integer from 0 to 600000")
112
+ if (params.cwd !== undefined && (typeof params.cwd !== "string" || !params.cwd || params.cwd.includes("\0"))) {
113
+ throw new Error("cwd must be a nonempty directory path")
114
+ }
115
+ const cwd = resolve(ctx.cwd, params.cwd ?? ".")
116
+ if (!(await stat(cwd)).isDirectory()) throw new Error(`cwd is not a directory: ${cwd}`)
117
+ signal?.throwIfAborted()
118
+ const parentSessionId = ctx.sessionManager.getSessionId()
119
+ const pool = getBashCoordinator(config.maxBashConcurrency)
120
+ pool.setMaxConcurrency(config.maxBashConcurrency)
121
+ await acquireParentLease(ctx.cwd, parentSessionId)
122
+ const paths = await reserveTaskStorage(
123
+ await ensureParentStorage(ctx.cwd, parentSessionId),
124
+ () => `b_${randomBytes(4).toString("hex")}`
125
+ )
126
+ let accepted = false
127
+ let log: FileHandle | undefined
128
+ try {
129
+ await initializeRetainedLogs(paths)
130
+ // Never reopen the pathname while running: replacement symlinks cannot redirect output.
131
+ log = await open(paths.output, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK)
132
+ if (!(await log.stat()).isFile()) throw new Error("Bash output.log must be a regular file")
133
+ signal?.throwIfAborted()
134
+ const now = Date.now()
135
+ const metadata: BashTaskMetadata = {
136
+ version: TASK_METADATA_VERSION,
137
+ kind: "bash",
138
+ taskRef: paths.taskRef,
139
+ parentSessionId,
140
+ label: params.label.trim(),
141
+ command: params.command,
142
+ cwd,
143
+ exitCode: null,
144
+ signal: null,
145
+ state: "queued",
146
+ latestOutcome: null,
147
+ latestReply: null,
148
+ lastActivity: { at: now, action: "queued" },
149
+ lastRunSequence: 1,
150
+ activeRun: {
151
+ id: `r_${randomBytes(8).toString("hex")}`,
152
+ sequence: 1,
153
+ acceptanceOrder: pool.nextAcceptanceOrder(),
154
+ background: true,
155
+ kind: "initial",
156
+ state: "queued",
157
+ input: params.command,
158
+ acceptedAt: now
159
+ },
160
+ queuedFollowUps: [],
161
+ notifications: [],
162
+ discardedAt: null,
163
+ createdAt: now,
164
+ updatedAt: now
165
+ }
166
+ await appendHistoryLog(paths, { type: "run-start", sequence: 1, kind: "initial", timestamp: now })
167
+ await appendHistoryLog(paths, { type: "input", delivery: "initial", content: params.command, timestamp: now })
168
+ await writeTaskMetadata(paths, metadata)
169
+ accepted = true
170
+ const runtime = new BashRuntime(paths, metadata, log)
171
+ log = undefined
172
+ runtime.start()
173
+ const wait = () => runtime.wait(waitMs, signal)
174
+ const detached = waitMs === 0 ? await wait() : await getAgentCoordinator().withLentPermit(wait, signal)
175
+ const loaded = await readTaskMetadata(paths)
176
+ if (loaded.status !== "ok" || loaded.metadata.kind !== "bash") throw new Error(`Could not read accepted task ${paths.taskRef}`)
177
+ const result: BashCreationResult = {
178
+ id: paths.taskRef,
179
+ label: loaded.metadata.label,
180
+ state: loaded.metadata.state,
181
+ latestOutcome: loaded.metadata.latestOutcome,
182
+ exitCode: loaded.metadata.exitCode,
183
+ signal: loaded.metadata.signal,
184
+ detached,
185
+ output: await readRetainedOutput(paths)
186
+ }
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text" as const,
191
+ text: `${result.id} ${JSON.stringify(result.label)}: ${result.state}${result.latestOutcome ? ` / ${result.latestOutcome}` : ""}${detached ? " (detached)" : ""}\n${result.output.text}`
192
+ }
193
+ ],
194
+ details: result
195
+ }
196
+ } finally {
197
+ await log?.close()
198
+ if (!accepted) await rm(paths.taskDirectory, { recursive: true, force: true })
199
+ }
200
+ }
201
+ })
202
+ }
203
+
204
+ /** One retained command, one process group, one terminal transition. Never cold-loaded. */
205
+ class BashRuntime implements ResidentAgent {
206
+ readonly #abort = new AbortController()
207
+ readonly #unbind: () => void
208
+ readonly #run: NonNullable<BashTaskMetadata["activeRun"]>
209
+ #done: Promise<void> | undefined
210
+ #child: ChildProcessWithoutNullStreams | undefined
211
+ #stopRequested = false
212
+ #detached = false
213
+ #stdinClosed = false
214
+ #inputLane: Promise<unknown> = Promise.resolve()
215
+ #outputLane: Promise<void> = Promise.resolve()
216
+ #progressTimer: ReturnType<typeof setTimeout> | undefined
217
+ #tail = ""
218
+ #truncated = false
219
+ #failure: string | undefined
220
+ #lastProgress = 0
221
+ #exitCode: number | null = null
222
+ #signal: string | null = null
223
+
224
+ constructor(
225
+ readonly paths: TaskStoragePaths,
226
+ readonly metadata: BashTaskMetadata,
227
+ readonly log: FileHandle
228
+ ) {
229
+ if (!metadata.activeRun) throw new Error("Bash runtime requires an accepted run")
230
+ this.#run = metadata.activeRun
231
+ this.#unbind = getAgentCoordinator().bindResident(paths.taskDirectory, this)
232
+ }
233
+
234
+ start(): void {
235
+ this.#done ??= this.run().finally(() => {
236
+ // Keep failed cleanup reachable through task_stop/discard until the FD closes.
237
+ if (this.log.fd === -1) this.#unbind()
238
+ })
239
+ // Detached failures stay observable through stop/wait and retained metadata, not unhandled rejections.
240
+ void this.#done.catch(() => {})
241
+ }
242
+
243
+ async wait(waitMs: number, signal?: AbortSignal): Promise<boolean> {
244
+ let timer: ReturnType<typeof setTimeout> | undefined
245
+ let onAbort = () => {}
246
+ const aborted = new Promise<"aborted">(resolve => {
247
+ onAbort = () => resolve("aborted")
248
+ signal?.addEventListener("abort", onAbort, { once: true })
249
+ if (signal?.aborted) onAbort()
250
+ })
251
+ try {
252
+ const result =
253
+ waitMs === 0
254
+ ? "timeout"
255
+ : await Promise.race([
256
+ this.#done?.then(() => "completed" as const),
257
+ aborted,
258
+ new Promise<"timeout">(resolve => {
259
+ timer = setTimeout(() => resolve("timeout"), waitMs)
260
+ timer.unref()
261
+ })
262
+ ])
263
+ if (signal?.aborted || result === "aborted") {
264
+ await this.stop()
265
+ signal?.throwIfAborted()
266
+ }
267
+ if (result === "timeout") {
268
+ await mutateTaskMetadata(this.paths, metadata => {
269
+ if (signal?.aborted || metadata.discardedAt !== null || metadata.activeRun?.id !== this.#run.id) return metadata
270
+ this.#detached = true
271
+ return { ...metadata, activeRun: { ...metadata.activeRun, detachedAt: Date.now() }, updatedAt: Date.now() }
272
+ })
273
+ if (!this.#detached && signal?.aborted) {
274
+ await this.stop()
275
+ signal.throwIfAborted()
276
+ }
277
+ // Settlement won the lane: return its synchronous result, not a phantom detachment.
278
+ if (!this.#detached) await this.#done
279
+ }
280
+ return this.#detached
281
+ } finally {
282
+ if (timer) clearTimeout(timer)
283
+ signal?.removeEventListener("abort", onAbort)
284
+ }
285
+ }
286
+
287
+ async stop(): Promise<void> {
288
+ this.#stopRequested = true
289
+ this.#stdinClosed = true
290
+ this.#abort.abort(new Error("Bash task stopped"))
291
+ this.kill()
292
+ try {
293
+ await this.#done
294
+ } finally {
295
+ if (!this.#child && this.log.fd !== -1) await this.log.close()
296
+ if (this.log.fd === -1) this.#unbind()
297
+ }
298
+ }
299
+
300
+ async dispose(): Promise<void> {
301
+ await this.stop()
302
+ }
303
+
304
+ async input(content: string, delivery: "followup" | "steer" | "stdin", options: ResidentInputOptions = {}): Promise<ResidentInputResult> {
305
+ if (delivery !== "stdin") throw new Error("Bash tasks accept stdin only, not Follow-up or Steer")
306
+ if (typeof content !== "string" || Buffer.byteLength(content) > MAX_AGENT_INPUT_BYTES) throw new Error("stdin must be at most 64 KiB")
307
+ const operation = this.#inputLane.then(async () => {
308
+ options.signal?.throwIfAborted()
309
+ const child = this.#child
310
+ if (
311
+ this.#stopRequested ||
312
+ this.#stdinClosed ||
313
+ !child ||
314
+ child.exitCode !== null ||
315
+ child.signalCode !== null ||
316
+ child.stdin.destroyed
317
+ ) {
318
+ throw new Error("Bash stdin is unavailable: task is queued, stopped, completed, or stdin is closed")
319
+ }
320
+ const loaded = await readTaskMetadata(this.paths)
321
+ if (loaded.status !== "ok" || loaded.metadata.discardedAt !== null || loaded.metadata.state !== "running") {
322
+ throw new Error("Bash stdin requires a running, non-discarded task")
323
+ }
324
+ options.signal?.throwIfAborted()
325
+ if (this.#stopRequested || this.#stdinClosed || child.stdin.destroyed) throw new Error("Bash stdin is closed")
326
+ if (options.eof) this.#stdinClosed = true
327
+ await new Promise<void>((resolve, reject) => {
328
+ let settled = false
329
+ const finish = (error?: Error | null) => {
330
+ if (settled) return
331
+ settled = true
332
+ child.stdin.removeListener("close", onClose)
333
+ child.stdin.removeListener("error", finish)
334
+ error ? reject(error) : resolve()
335
+ }
336
+ const onClose = () => finish(new Error("Bash stdin closed during delivery"))
337
+ child.stdin.once("close", onClose)
338
+ child.stdin.once("error", finish)
339
+ // Write callbacks honor backpressure; EOF is sent only after these literal bytes.
340
+ if (options.eof) child.stdin.end(content, finish)
341
+ else child.stdin.write(content, finish)
342
+ })
343
+ try {
344
+ await appendHistoryLog(this.paths, { type: "stdin", content, timestamp: Date.now(), eof: options.eof === true })
345
+ } catch (error) {
346
+ this.fail(error)
347
+ throw error
348
+ }
349
+ return { delivery: "stdin" as const, queuePosition: null, queuedFollowUps: 0 }
350
+ })
351
+ this.#inputLane = operation.catch(() => {})
352
+ if (!options.signal) return operation
353
+ const signal = options.signal
354
+ let onAbort = () => {}
355
+ const aborted = new Promise<never>((_resolve, reject) => {
356
+ onAbort = () => reject(new Error("Bash stdin delivery cancelled; bytes already written cannot be undone"))
357
+ signal.addEventListener("abort", onAbort, { once: true })
358
+ if (signal.aborted) onAbort()
359
+ })
360
+ try {
361
+ // Cancellation ends the caller's wait, not an already-submitted write. Its lane
362
+ // still records successful delivery and must drain before settlement/archival.
363
+ return await Promise.race([operation, aborted])
364
+ } finally {
365
+ signal.removeEventListener("abort", onAbort)
366
+ }
367
+ }
368
+
369
+ private kill(): void {
370
+ if (this.#child?.pid) killGroup(this.#child.pid)
371
+ }
372
+
373
+ private fail(error: unknown): void {
374
+ this.#failure ??= error instanceof Error ? error.message : String(error)
375
+ try {
376
+ this.kill()
377
+ } catch (killError) {
378
+ this.#failure += `; process-group cleanup failed: ${killError instanceof Error ? killError.message : String(killError)}`
379
+ }
380
+ }
381
+
382
+ private async run(): Promise<void> {
383
+ let permit: Awaited<ReturnType<ReturnType<typeof getBashCoordinator>["acquire"]>> | undefined
384
+ try {
385
+ try {
386
+ permit = await getBashCoordinator().acquire({
387
+ tuple: { provider: "bash", model: "process" },
388
+ ...(this.#run.acceptanceOrder ? { acceptanceOrder: this.#run.acceptanceOrder } : {}),
389
+ signal: this.#abort.signal
390
+ })
391
+ let running = false
392
+ await mutateTaskMetadata(this.paths, metadata => {
393
+ if (this.#stopRequested || metadata.discardedAt !== null || metadata.activeRun?.id !== this.#run.id) return metadata
394
+ running = true
395
+ return {
396
+ ...metadata,
397
+ state: "running",
398
+ activeRun: { ...metadata.activeRun, state: "running", startedAt: Date.now() },
399
+ lastActivity: { at: Date.now(), action: "started" },
400
+ updatedAt: Date.now()
401
+ }
402
+ })
403
+ if (running && !this.#stopRequested) await this.process()
404
+ } catch (error) {
405
+ if (error !== this.#abort.signal.reason) this.fail(error)
406
+ }
407
+ if (this.#progressTimer) clearTimeout(this.#progressTimer)
408
+ try {
409
+ await this.#outputLane
410
+ } catch (error) {
411
+ this.fail(error)
412
+ }
413
+ await this.#inputLane
414
+ try {
415
+ await this.log.sync()
416
+ } catch (error) {
417
+ this.fail(error)
418
+ }
419
+ try {
420
+ await this.log.close()
421
+ } catch (error) {
422
+ this.fail(error)
423
+ }
424
+ await mutateTaskMetadata(this.paths, async metadata => {
425
+ if (metadata.kind !== "bash" || metadata.discardedAt !== null || metadata.activeRun?.id !== this.#run.id) return metadata
426
+ let outcome: NonNullable<BashTaskMetadata["latestOutcome"]> = this.#stopRequested
427
+ ? "stopped"
428
+ : this.#failure || this.#exitCode !== 0
429
+ ? "failed"
430
+ : "succeeded"
431
+ let reason = this.#stopRequested
432
+ ? "Bash task stopped"
433
+ : (this.#failure ?? (this.#signal ? `Bash terminated by ${this.#signal}` : `Bash exited with code ${this.#exitCode}`))
434
+ // A broken retained log must not suppress a still-writable terminal snapshot.
435
+ try {
436
+ await appendHistoryLog(this.paths, { type: "run-end", sequence: 1, outcome, timestamp: Date.now(), summary: reason })
437
+ } catch (error) {
438
+ this.fail(error)
439
+ outcome = this.#stopRequested ? "stopped" : "failed"
440
+ reason = this.#failure ?? reason
441
+ }
442
+ if (this.#failure) this.appendOutput(`\n[Bash error: ${this.#failure}]`)
443
+ const completed = {
444
+ ...metadata,
445
+ exitCode: this.#exitCode,
446
+ signal: this.#signal,
447
+ latestReply: { text: this.#tail || (outcome === "failed" ? reason : ""), streaming: false, truncated: this.#truncated }
448
+ }
449
+ const notification =
450
+ outcome !== "stopped" && metadata.activeRun.detachedAt !== undefined
451
+ ? await prepareTaskNotification(this.paths, completed, metadata.activeRun, "completion", outcome)
452
+ : undefined
453
+ return {
454
+ ...completed,
455
+ state: "idle",
456
+ latestOutcome: outcome,
457
+ activeRun: null,
458
+ notifications: notification ? appendTaskNotification(metadata.notifications, notification) : metadata.notifications,
459
+ updatedAt: Date.now()
460
+ }
461
+ })
462
+ // Routing failures leave the durable outbox pending for exact-parent reconciliation.
463
+ await deliverTaskNotifications(this.paths).catch(() => {})
464
+ } finally {
465
+ this.#stdinClosed = true
466
+ try {
467
+ // Retry cleanup only after a real close failure left this handle open.
468
+ if (this.log.fd !== -1) await this.log.close()
469
+ } finally {
470
+ permit?.release()
471
+ }
472
+ }
473
+ }
474
+
475
+ private async process(): Promise<void> {
476
+ const child = spawn("bash", ["-c", this.metadata.command], { cwd: this.metadata.cwd, detached: true, stdio: "pipe" })
477
+ this.#child = child
478
+ const groups = liveGroups()
479
+ if (child.pid) groups.add(child.pid)
480
+ let spawnError: Error | undefined
481
+ child.on("error", error => {
482
+ spawnError = error
483
+ })
484
+ // EPIPE is an input error, not an uncaught process-wide exception.
485
+ child.stdin.on("error", () => {
486
+ this.#stdinClosed = true
487
+ })
488
+ const closed = new Promise<void>(resolve => {
489
+ child.once("close", (code, signal) => {
490
+ this.#exitCode = spawnError ? null : (code ?? null)
491
+ this.#signal = signal ?? null
492
+ this.#stdinClosed = true
493
+ resolve()
494
+ })
495
+ })
496
+ try {
497
+ const readers = await Promise.allSettled(
498
+ [child.stdout, child.stderr].map(stream =>
499
+ this.consume(stream).catch(error => {
500
+ this.fail(error)
501
+ throw error
502
+ })
503
+ )
504
+ )
505
+ await closed
506
+ if (spawnError) {
507
+ // Failed spawn can close pipes prematurely; retain its cause, not that symptom.
508
+ this.#failure = spawnError.message
509
+ throw spawnError
510
+ }
511
+ for (const result of readers) if (result.status === "rejected") throw result.reason
512
+ } finally {
513
+ // Also reap shell-launched jobs that redirected their pipes before the shell exited.
514
+ this.kill()
515
+ await closed
516
+ if (child.pid) groups.delete(child.pid)
517
+ this.#child = undefined
518
+ }
519
+ }
520
+
521
+ private async consume(stream: Readable): Promise<void> {
522
+ const decoder = new StringDecoder("utf8")
523
+ for await (const chunk of stream) {
524
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
525
+ const text = decoder.write(bytes)
526
+ // At most one pending chunk per pipe. Disk latency backpressures both producers.
527
+ this.#outputLane = this.#outputLane.then(async () => {
528
+ let offset = 0
529
+ while (offset < bytes.length) {
530
+ const { bytesWritten } = await this.log.write(bytes, offset, bytes.length - offset)
531
+ if (!bytesWritten) throw new Error("Bash output.log write made no progress")
532
+ offset += bytesWritten
533
+ }
534
+ this.appendOutput(text)
535
+ if (Date.now() - this.#lastProgress >= 100) await this.flushProgress(true)
536
+ else if (!this.#progressTimer) {
537
+ this.#progressTimer = setTimeout(() => {
538
+ this.#progressTimer = undefined
539
+ this.#outputLane = this.#outputLane.then(() => this.flushProgress(true))
540
+ void this.#outputLane.catch(error => this.fail(error))
541
+ }, 100)
542
+ this.#progressTimer.unref()
543
+ }
544
+ })
545
+ await this.#outputLane
546
+ }
547
+ const remainder = decoder.end()
548
+ if (remainder) {
549
+ this.#outputLane = this.#outputLane.then(() => {
550
+ this.appendOutput(remainder)
551
+ })
552
+ await this.#outputLane
553
+ }
554
+ }
555
+
556
+ private async flushProgress(streaming: boolean): Promise<void> {
557
+ this.#lastProgress = Date.now()
558
+ await writeTaskProgress(this.paths, this.#run.id, {
559
+ latestReply: { text: this.#tail, streaming, truncated: this.#truncated },
560
+ lastActivity: { at: this.#lastProgress, action: "output" }
561
+ })
562
+ }
563
+
564
+ private appendOutput(text: string): void {
565
+ const combined = this.#tail + text
566
+ this.#tail = outputTail(combined)
567
+ this.#truncated ||= this.#tail.length !== combined.length
568
+ }
569
+ }
570
+
571
+ function outputTail(text: string): string {
572
+ const bytes = Buffer.from(text)
573
+ let start = Math.max(0, bytes.length - RETAINED_OUTPUT_MAX_BYTES)
574
+ while (start < bytes.length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start++
575
+ return bytes.subarray(start).toString("utf8").split("\n").slice(-RETAINED_OUTPUT_MAX_LINES).join("\n")
576
+ }
577
+
578
+ const LIVE_GROUPS = Symbol.for("@xl0/pi-lovely-agents/bash-process-groups/v1")
579
+ function liveGroups(): Set<number> {
580
+ const global = globalThis as typeof globalThis & { [LIVE_GROUPS]?: Set<number> }
581
+ if (!global[LIVE_GROUPS]) {
582
+ const groups = new Set<number>()
583
+ global[LIVE_GROUPS] = groups
584
+ // Synchronous exit cleanup only. SIGKILL/power loss and deliberate setsid escapes
585
+ // cannot be contained without OS supervision; no retained PID is ever reused.
586
+ process.once("exit", () => {
587
+ for (const pid of groups) killGroup(pid)
588
+ })
589
+ }
590
+ return global[LIVE_GROUPS]
591
+ }
592
+
593
+ function killGroup(pid: number): void {
594
+ try {
595
+ process.kill(-pid, "SIGKILL")
596
+ } catch (error) {
597
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) throw error
598
+ }
599
+ }