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