opencode-agent-skill 13.0.0-beta.2 → 14.2.0-beta.1

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 (93) hide show
  1. package/README.md +1556 -607
  2. package/bin/ocskill.mjs +172 -24
  3. package/docs/OPENCODE-COMPAT.md +34 -97
  4. package/docs/PI-COMPAT.md +188 -0
  5. package/docs/V14-CONTEXT-MEMORY-FABRIC.md +70 -0
  6. package/docs/V14.1-QUALITY-PERFORMANCE-FABRIC.md +114 -0
  7. package/docs/V14.2-TURBO-WEAK-MODEL-RUNTIME.md +448 -0
  8. package/evals/v14/tasks.json +46 -0
  9. package/global-config/agents/executor.md +7 -0
  10. package/global-config/agents/visual-verifier.md +22 -3
  11. package/global-config/plugins/ues-router/index.js +13 -8
  12. package/global-config/plugins/ues-router/policy-runtime.js +7 -0
  13. package/global-config/plugins/ues-router/router.js +12 -2
  14. package/global-config/skills/ecommerce-engineering/SKILL.md +1 -1
  15. package/global-config/skills/file-upload-engineering/SKILL.md +1 -1
  16. package/global-config/skills/git-safety/SKILL.md +1 -1
  17. package/global-config/skills/nestjs-engineering/SKILL.md +1 -1
  18. package/global-config/skills/performance-engineering/SKILL.md +1 -1
  19. package/global-config/skills/react-native-engineering/SKILL.md +1 -1
  20. package/global-config/skills/rest-api-design/SKILL.md +1 -1
  21. package/global-config/skills/ui-ux-engineering/SKILL.md +1 -1
  22. package/lib/adaptive-context-budget.mjs +97 -0
  23. package/lib/affected-tests.mjs +260 -0
  24. package/lib/benchmark-confidence.mjs +41 -2
  25. package/lib/browser-mcp-routing.mjs +166 -0
  26. package/lib/capability-fabric.mjs +336 -0
  27. package/lib/capability-registry.mjs +9 -0
  28. package/lib/context-engine-v11.mjs +65 -1
  29. package/lib/context-graph-rank.mjs +118 -0
  30. package/lib/context-manifest.mjs +97 -18
  31. package/lib/control-center.mjs +19 -1
  32. package/lib/dynamic-workflow.mjs +3 -1
  33. package/lib/evidence-store.mjs +82 -1
  34. package/lib/hierarchical-context.mjs +215 -0
  35. package/lib/memory-engine.mjs +465 -0
  36. package/lib/model-performance.mjs +33 -8
  37. package/lib/model-policy.mjs +3 -3
  38. package/lib/orchestrator-policy.mjs +5 -209
  39. package/lib/performance-fabric.mjs +229 -0
  40. package/lib/pi-rpc-pool.mjs +433 -0
  41. package/lib/process-hang-detector.mjs +83 -0
  42. package/lib/process-supervisor.mjs +193 -0
  43. package/lib/prompt-cache.mjs +2 -0
  44. package/lib/repo-graph.mjs +53 -2
  45. package/lib/runtime-config.mjs +31 -0
  46. package/lib/safety.mjs +132 -0
  47. package/lib/semantic-index.mjs +52 -3
  48. package/lib/skill-compiler.mjs +128 -0
  49. package/lib/skill-quality.mjs +48 -2
  50. package/lib/task-engine.mjs +66 -5
  51. package/lib/task-policy.mjs +235 -0
  52. package/lib/verification-broker.mjs +284 -0
  53. package/lib/verification-command.mjs +111 -0
  54. package/lib/windows-shim.mjs +35 -0
  55. package/lib/workspace-fingerprint.mjs +198 -0
  56. package/package.json +52 -42
  57. package/pi/extensions/ues-child-runtime.ts +238 -0
  58. package/pi/extensions/ues.ts +3200 -0
  59. package/pi/prompts/ues-audit.md +9 -0
  60. package/pi/prompts/ues-critique.md +9 -0
  61. package/pi/prompts/ues-debug.md +9 -0
  62. package/pi/prompts/ues-feature.md +9 -0
  63. package/pi/prompts/ues-fix.md +9 -0
  64. package/pi/prompts/ues-plan.md +9 -0
  65. package/pi/prompts/ues-research.md +9 -0
  66. package/pi/prompts/ues-resume.md +9 -0
  67. package/pi/prompts/ues-review.md +7 -0
  68. package/pi/prompts/ues-run.md +17 -0
  69. package/pi/prompts/ues-verify.md +9 -0
  70. package/scripts/check-release-consistency.mjs +119 -185
  71. package/scripts/check-runtime-exports.mjs +66 -0
  72. package/scripts/check-source-integrity.mjs +184 -0
  73. package/scripts/eval-pi.mjs +492 -0
  74. package/scripts/install.mjs +16 -0
  75. package/scripts/smoke-package-closure.mjs +110 -0
  76. package/scripts/smoke-packed-install.mjs +24 -11
  77. package/scripts/smoke-pi-extension.mjs +144 -0
  78. package/scripts/uninstall.mjs +44 -0
  79. package/CHANGELOG.md +0 -415
  80. package/docs/DETERMINISTIC-TOOLS.md +0 -105
  81. package/docs/ENGINEERING-DESIGN.md +0 -194
  82. package/docs/EVALS.md +0 -158
  83. package/docs/GITHUB-RULESET.md +0 -50
  84. package/docs/NPM-PUBLISH.md +0 -116
  85. package/docs/RESEARCH-SOURCES.md +0 -37
  86. package/docs/TRACE-SCHEMA.md +0 -122
  87. package/docs/V11-PERCEPTION-ADAPTIVE-EXECUTION.md +0 -75
  88. package/docs/V11-PERCEPTION-ADAPTIVE.md +0 -220
  89. package/docs/V12-WEAK-MODEL-INTELLIGENCE.md +0 -27
  90. package/docs/V13-PARALLEL-WEAK-MODEL-RUNTIME.md +0 -86
  91. package/docs/V7-INTELLIGENCE-RUNTIME.md +0 -166
  92. package/docs/V8-INTELLIGENCE-RELIABILITY.md +0 -206
  93. package/docs/V9-SPEED-INTELLIGENCE.md +0 -102
@@ -0,0 +1,433 @@
1
+ import { spawn } from "node:child_process"
2
+ import { terminateProcessTree } from "./process-supervisor.mjs"
3
+
4
+ function clamp(value, fallback, min, max) {
5
+ const parsed = Number(value)
6
+ if (!Number.isFinite(parsed)) return fallback
7
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
8
+ }
9
+
10
+ const RPC_CONTROL_TIMEOUT_MS = clamp(
11
+ process.env.UES_RPC_CONTROL_TIMEOUT_MS,
12
+ 3_000,
13
+ 500,
14
+ 30_000,
15
+ )
16
+
17
+ async function waitForProcessClose(proc, timeoutMs = 2_000) {
18
+ if (!proc) return
19
+ const delayAfterExitMs = process.platform === "win32" ? 75 : 0
20
+
21
+ if (proc.exitCode !== null || proc.signalCode !== null) {
22
+ if (delayAfterExitMs > 0) {
23
+ await new Promise((resolve) => setTimeout(resolve, delayAfterExitMs))
24
+ }
25
+ return
26
+ }
27
+
28
+ await new Promise((resolve) => {
29
+ let settled = false
30
+ let timer = null
31
+ const finish = () => {
32
+ if (settled) return
33
+ settled = true
34
+ if (timer) clearTimeout(timer)
35
+ proc.removeListener("close", finish)
36
+ resolve()
37
+ }
38
+ // "close" fires after the process has exited and its stdio handles have
39
+ // closed. That distinction matters on Windows, where removing the worker's
40
+ // temporary cwd immediately after only "exit" can still fail with EBUSY.
41
+ proc.once("close", finish)
42
+ timer = setTimeout(finish, timeoutMs)
43
+ timer.unref?.()
44
+ })
45
+
46
+ if (delayAfterExitMs > 0) {
47
+ await new Promise((resolve) => setTimeout(resolve, delayAfterExitMs))
48
+ }
49
+ }
50
+
51
+ class RpcWorker {
52
+ constructor(spec) {
53
+ this.spec = spec
54
+ this.proc = null
55
+ this.buffer = ""
56
+ this.stderr = ""
57
+ this.pending = new Map()
58
+ this.listeners = new Set()
59
+ this.requestId = 0
60
+ this.runs = 0
61
+ this.dead = false
62
+ this.active = false
63
+ this.activeAbort = null
64
+ this.lastActivityAt = 0
65
+ this.tail = Promise.resolve()
66
+ }
67
+
68
+ async start() {
69
+ if (this.proc && !this.dead) return
70
+ const proc = spawn(this.spec.command, this.spec.args, {
71
+ cwd: this.spec.cwd,
72
+ env: this.spec.env || process.env,
73
+ shell: false,
74
+ detached: process.platform !== "win32",
75
+ windowsHide: true,
76
+ stdio: ["pipe", "pipe", "pipe"],
77
+ })
78
+ this.proc = proc
79
+ this.dead = false
80
+ this.stderr = ""
81
+
82
+ proc.stdout.on("data", (data) => {
83
+ this.buffer += data.toString()
84
+ const lines = this.buffer.split("\n")
85
+ this.buffer = lines.pop() || ""
86
+ for (const line of lines) this.handleLine(line)
87
+ })
88
+ proc.stderr.on("data", (data) => {
89
+ if (this.stderr.length < 128 * 1024) this.stderr += data.toString()
90
+ })
91
+ proc.stdin.on("error", (error) => this.fail(new Error("Pi RPC stdin error: " + error.message)))
92
+ proc.on("error", (error) => this.fail(error))
93
+ proc.on("exit", (code, signal) => {
94
+ this.fail(new Error(`Pi RPC worker exited code=${code} signal=${signal || "none"}\n${this.stderr}`))
95
+ })
96
+
97
+ // Probe readiness through the protocol rather than relying on an arbitrary sleep.
98
+ await this.send({ type: "get_state" }, { timeoutMs: 30_000 })
99
+ }
100
+
101
+ handleLine(line) {
102
+ if (!line.trim()) return
103
+ let event
104
+ try {
105
+ event = JSON.parse(line)
106
+ } catch {
107
+ return
108
+ }
109
+ if (event?.type === "response" && event?.id && this.pending.has(event.id)) {
110
+ const request = this.pending.get(event.id)
111
+ this.pending.delete(event.id)
112
+ clearTimeout(request.timer)
113
+ if (event.success === false) request.reject(new Error(event.error || event.message || "Pi RPC command failed"))
114
+ else request.resolve(event)
115
+ return
116
+ }
117
+ for (const listener of [...this.listeners]) {
118
+ try { listener(event) } catch {}
119
+ }
120
+ }
121
+
122
+ fail(error) {
123
+ if (this.dead) return
124
+ this.dead = true
125
+ for (const request of this.pending.values()) {
126
+ clearTimeout(request.timer)
127
+ request.reject(error)
128
+ }
129
+ this.pending.clear()
130
+ for (const listener of [...this.listeners]) {
131
+ try { listener({ type: "ues_rpc_worker_error", error: String(error?.message || error) }) } catch {}
132
+ }
133
+ }
134
+
135
+ async send(body, options = {}) {
136
+ await this.startIfNeededForSend(body)
137
+ if (!this.proc?.stdin || this.dead) throw new Error("Pi RPC worker is not available")
138
+ const id = "ues-" + (++this.requestId)
139
+ const timeoutMs = clamp(options.timeoutMs, 30_000, 1000, 30 * 60_000)
140
+ const payload = JSON.stringify({ id, ...body }) + "\n"
141
+ return await new Promise((resolve, reject) => {
142
+ const timer = setTimeout(() => {
143
+ this.pending.delete(id)
144
+ reject(new Error(`Pi RPC command timeout: ${body.type}`))
145
+ }, timeoutMs)
146
+ timer.unref?.()
147
+ this.pending.set(id, { resolve, reject, timer })
148
+ this.proc.stdin.write(payload, (error) => {
149
+ if (!error) return
150
+ const request = this.pending.get(id)
151
+ if (!request) return
152
+ clearTimeout(request.timer)
153
+ this.pending.delete(id)
154
+ reject(error)
155
+ })
156
+ })
157
+ }
158
+
159
+ async startIfNeededForSend(body) {
160
+ // start() itself probes with get_state, so allow that first write through.
161
+ if (this.proc && !this.dead) return
162
+ if (body?.type === "get_state" && this.proc && !this.dead) return
163
+ if (!this.proc || this.dead) await this.start()
164
+ }
165
+
166
+ onEvent(listener) {
167
+ this.listeners.add(listener)
168
+ return () => this.listeners.delete(listener)
169
+ }
170
+
171
+ async steer(message) {
172
+ if (!this.proc || this.dead || !this.active) {
173
+ return { accepted: false, reason: "worker-not-active" }
174
+ }
175
+ await this.send(
176
+ { type: "steer", message: String(message || "") },
177
+ { timeoutMs: RPC_CONTROL_TIMEOUT_MS },
178
+ )
179
+ return { accepted: true }
180
+ }
181
+
182
+ async abortTransport() {
183
+ if (!this.proc || this.dead) return false
184
+ try {
185
+ await this.send({ type: "abort" }, { timeoutMs: RPC_CONTROL_TIMEOUT_MS })
186
+ return true
187
+ } catch {
188
+ const proc = this.proc
189
+ terminateProcessTree(proc, { graceMs: 1000 })
190
+ this.dead = true
191
+ await waitForProcessClose(proc)
192
+ return true
193
+ }
194
+ }
195
+
196
+ async abort() {
197
+ if (!this.proc || this.dead) return false
198
+ if (typeof this.activeAbort === "function") {
199
+ await this.activeAbort("aborted")
200
+ return true
201
+ }
202
+ return this.abortTransport()
203
+ }
204
+
205
+ async stop() {
206
+ const proc = this.proc
207
+ if (!proc) return
208
+ terminateProcessTree(proc, { graceMs: 500 })
209
+ this.dead = true
210
+ await waitForProcessClose(proc)
211
+ if (this.proc === proc) this.proc = null
212
+ }
213
+
214
+ run(message, options = {}) {
215
+ const execute = async () => {
216
+ try {
217
+ await this.start()
218
+ if (this.runs > 0) {
219
+ await this.send({ type: "new_session" }, { timeoutMs: 30_000 })
220
+ }
221
+ } catch (error) {
222
+ if (error && typeof error === "object") error.uesRpcPhase = "startup"
223
+ throw error
224
+ }
225
+ this.runs += 1
226
+ this.stderr = ""
227
+
228
+ const hardTimeoutMs = clamp(options.hardTimeoutMs, 30 * 60_000, 30_000, 2 * 60 * 60_000)
229
+ const idleTimeoutMs = clamp(options.idleTimeoutMs, 5 * 60_000, 10_000, 30 * 60_000)
230
+ let lastActivityAt = Date.now()
231
+ this.lastActivityAt = lastActivityAt
232
+ this.active = true
233
+ let finalMessage = null
234
+ let toolCalls = 0
235
+ let lastToolErrorAt = 0
236
+ const toolNames = new Set()
237
+ let settledResolve
238
+ let settledReject
239
+ const settled = new Promise((resolve, reject) => {
240
+ settledResolve = resolve
241
+ settledReject = reject
242
+ })
243
+
244
+ let aborting = false
245
+ const abortFor = async (reason) => {
246
+ if (aborting) return
247
+ aborting = true
248
+ try { await this.abortTransport() } catch {}
249
+ settledReject(new Error("UES RPC " + reason))
250
+ }
251
+ this.activeAbort = abortFor
252
+
253
+ const unsubscribe = this.onEvent((event) => {
254
+ lastActivityAt = Date.now()
255
+ this.lastActivityAt = lastActivityAt
256
+ if (event.type === "tool_execution_start") {
257
+ toolCalls += 1
258
+ lastToolErrorAt = 0
259
+ if (event.toolName) toolNames.add(String(event.toolName))
260
+ }
261
+ if (event.type === "tool_execution_end") {
262
+ lastToolErrorAt = event.isError === true ? Date.now() : 0
263
+ }
264
+ if (event.type === "message_end" && event.message?.role === "assistant") {
265
+ finalMessage = event.message
266
+ }
267
+ try {
268
+ const decision = options.onEvent?.(event)
269
+ if (decision?.abort === true) void abortFor(decision.reason || "event-abort")
270
+ } catch {}
271
+ if (event.type === "agent_settled" && !aborting) settledResolve()
272
+ if (event.type === "ues_rpc_worker_error") settledReject(new Error(event.error || "RPC worker failed"))
273
+ })
274
+
275
+ const hardTimer = setTimeout(() => void abortFor("hard-timeout"), hardTimeoutMs)
276
+ hardTimer.unref?.()
277
+ const postToolErrorIdleTimeoutMs = clamp(
278
+ options.postToolErrorIdleTimeoutMs,
279
+ 60_000,
280
+ 5_000,
281
+ idleTimeoutMs,
282
+ )
283
+ const idleTimer = setInterval(() => {
284
+ const now = Date.now()
285
+ const threshold = lastToolErrorAt > 0 ? Math.min(idleTimeoutMs, postToolErrorIdleTimeoutMs) : idleTimeoutMs
286
+ if (now - lastActivityAt >= threshold) {
287
+ void abortFor(lastToolErrorAt > 0 ? "post-tool-error-stall" : "idle-timeout")
288
+ }
289
+ }, 1000)
290
+ idleTimer.unref?.()
291
+
292
+ const signal = options.signal
293
+ const onAbort = () => void abortFor("aborted")
294
+ if (signal) {
295
+ if (signal.aborted) onAbort()
296
+ else signal.addEventListener("abort", onAbort, { once: true })
297
+ }
298
+
299
+ try {
300
+ await this.send({ type: "prompt", message }, { timeoutMs: 30_000 })
301
+ await settled
302
+ return {
303
+ message: finalMessage,
304
+ stderr: this.stderr,
305
+ toolCalls,
306
+ toolNames: [...toolNames],
307
+ }
308
+ } catch (error) {
309
+ if (error && typeof error === "object") error.uesRpcPhase = "runtime"
310
+ throw error
311
+ } finally {
312
+ this.active = false
313
+ this.activeAbort = null
314
+ clearTimeout(hardTimer)
315
+ clearInterval(idleTimer)
316
+ unsubscribe()
317
+ if (signal) signal.removeEventListener("abort", onAbort)
318
+ }
319
+ }
320
+
321
+ const promise = this.tail.then(execute, execute)
322
+ this.tail = promise.catch(() => {})
323
+ return promise
324
+ }
325
+ }
326
+
327
+ export class PiRpcWorkerPool {
328
+ constructor(options = {}) {
329
+ this.maxWorkers = Math.max(1, Math.min(16, Number(options.maxWorkers || 8)))
330
+ this.workers = new Map()
331
+ }
332
+
333
+ async pruneIdleWorkers(protectedKey = null) {
334
+ while (this.workers.size > this.maxWorkers) {
335
+ let candidateKey = null
336
+ let candidate = null
337
+ for (const [key, worker] of this.workers.entries()) {
338
+ if (key === protectedKey) continue
339
+ if (!worker || worker.dead) {
340
+ candidateKey = key
341
+ candidate = worker
342
+ break
343
+ }
344
+ if (worker.active) continue
345
+ candidateKey = key
346
+ candidate = worker
347
+ break
348
+ }
349
+ if (!candidateKey) break
350
+ this.workers.delete(candidateKey)
351
+ await candidate?.stop().catch(() => {})
352
+ }
353
+ }
354
+
355
+ async run(key, spec, message, options = {}) {
356
+ let worker = this.workers.get(key)
357
+ let workerReused = Boolean(worker && !worker.dead && worker.runs > 0)
358
+ if (!worker || worker.dead) {
359
+ if (worker) {
360
+ await worker.stop().catch(() => {})
361
+ this.workers.delete(key)
362
+ }
363
+ worker = new RpcWorker(spec)
364
+ workerReused = false
365
+ this.workers.set(key, worker)
366
+ await this.pruneIdleWorkers(key)
367
+ } else {
368
+ // Refresh insertion order so the bounded worker map behaves as an LRU.
369
+ this.workers.delete(key)
370
+ this.workers.set(key, worker)
371
+ }
372
+
373
+ try {
374
+ const result = await worker.run(message, options)
375
+ await this.pruneIdleWorkers(key)
376
+ return { ...result, workerReused }
377
+ } catch (error) {
378
+ this.workers.delete(key)
379
+ await worker.stop().catch(() => {})
380
+ await this.pruneIdleWorkers().catch(() => {})
381
+ throw error
382
+ }
383
+ }
384
+
385
+ activeEntries() {
386
+ return [...this.workers.entries()]
387
+ .filter(([, worker]) => worker && !worker.dead && worker.active)
388
+ .map(([key, worker]) => ({
389
+ key,
390
+ lastActivityAt: worker.lastActivityAt || 0,
391
+ }))
392
+ }
393
+
394
+ async steerActive(message) {
395
+ const active = [...this.workers.entries()]
396
+ .filter(([, worker]) => worker && !worker.dead && worker.active)
397
+ if (active.length !== 1) {
398
+ return {
399
+ accepted: false,
400
+ reason: active.length === 0 ? "no-active-worker" : "multiple-active-workers",
401
+ active: active.length,
402
+ }
403
+ }
404
+ const [key, worker] = active[0]
405
+ const result = await worker.steer(message)
406
+ return { ...result, key, active: 1 }
407
+ }
408
+
409
+ async abortActive() {
410
+ const active = [...this.workers.values()]
411
+ .filter((worker) => worker && !worker.dead && worker.active)
412
+ const outcomes = await Promise.all(
413
+ active.map((worker) => worker.abort().catch(() => false)),
414
+ )
415
+ return { aborted: outcomes.filter(Boolean).length }
416
+ }
417
+
418
+ async stopAll() {
419
+ const workers = [...this.workers.values()]
420
+ this.workers.clear()
421
+ await Promise.all(workers.map((worker) => worker.stop().catch(() => {})))
422
+ }
423
+
424
+ status() {
425
+ const active = this.activeEntries()
426
+ return {
427
+ workers: this.workers.size,
428
+ activeWorkers: active.length,
429
+ keys: [...this.workers.keys()],
430
+ active,
431
+ }
432
+ }
433
+ }
@@ -0,0 +1,83 @@
1
+ const SHELL_TOOLS = new Set(["bash", "powershell"])
2
+
3
+ const JEST_OPEN_HANDLE =
4
+ /Jest did not exit one second after the test run has completed\.?/i
5
+
6
+ const JEST_OPEN_HANDLE_HINT =
7
+ /asynchronous operations that weren't stopped in your tests|--detectOpenHandles/i
8
+
9
+ function bounded(text, limit = 6000) {
10
+ const value = String(text || "")
11
+ return value.length <= limit ? value : value.slice(value.length - limit)
12
+ }
13
+
14
+ export function toolResultText(value) {
15
+ if (value == null) return ""
16
+ if (typeof value === "string") return value
17
+ if (Array.isArray(value)) return value.map((item) => toolResultText(item)).filter(Boolean).join("\n")
18
+ if (typeof value !== "object") return String(value)
19
+
20
+ if (typeof value.text === "string") return value.text
21
+ if (Array.isArray(value.content)) {
22
+ return value.content
23
+ .map((item) => toolResultText(item))
24
+ .filter(Boolean)
25
+ .join("\n")
26
+ }
27
+ if (value.partialResult) return toolResultText(value.partialResult)
28
+ if (value.result) return toolResultText(value.result)
29
+ return ""
30
+ }
31
+
32
+ export function detectHungToolEvidence(input = {}) {
33
+ const toolName = String(input.toolName || "").toLowerCase()
34
+ if (!SHELL_TOOLS.has(toolName)) return null
35
+
36
+ const text = String(input.text || "")
37
+ if (!text) return null
38
+
39
+ if (JEST_OPEN_HANDLE.test(text)) {
40
+ return {
41
+ schemaVersion: 1,
42
+ kind: "jest-open-handle",
43
+ confidence: "high",
44
+ toolName,
45
+ message:
46
+ "Jest reported that the test run completed but the process did not exit, indicating an open async handle/resource leak.",
47
+ evidence: bounded(text),
48
+ diagnosticHint: JEST_OPEN_HANDLE_HINT.test(text)
49
+ ? "Run the narrow failing suite with --detectOpenHandles and close leaked timers, servers, Prisma/Redis/BullMQ connections or workers."
50
+ : "Run the narrow failing suite with --detectOpenHandles and inspect leaked async resources.",
51
+ }
52
+ }
53
+
54
+ return null
55
+ }
56
+
57
+ export function createToolOutputAccumulator(options = {}) {
58
+ const maxChars = Math.max(1024, Math.min(64 * 1024, Number(options.maxChars || 12_000)))
59
+ const values = new Map()
60
+
61
+ return {
62
+ append(toolCallId, text) {
63
+ const key = String(toolCallId || "")
64
+ if (!key) return String(text || "")
65
+ const next = (String(values.get(key) || "") + String(text || "")).slice(-maxChars)
66
+ values.set(key, next)
67
+ return next
68
+ },
69
+ get(toolCallId) {
70
+ return String(values.get(String(toolCallId || "")) || "")
71
+ },
72
+ delete(toolCallId) {
73
+ values.delete(String(toolCallId || ""))
74
+ },
75
+ clear() {
76
+ values.clear()
77
+ },
78
+ }
79
+ }
80
+
81
+ export function isToolExecutionError(event = {}) {
82
+ return event?.type === "tool_execution_end" && event?.isError === true
83
+ }
@@ -0,0 +1,193 @@
1
+ import { spawn, spawnSync } from "node:child_process"
2
+
3
+ function clamp(value, fallback, min, max) {
4
+ const parsed = Number(value)
5
+ if (!Number.isFinite(parsed)) return fallback
6
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
7
+ }
8
+
9
+ export function terminateProcessTree(proc, options = {}) {
10
+ if (!proc?.pid) return false
11
+ const graceMs = clamp(options.graceMs, 1500, 0, 30_000)
12
+
13
+ if (process.platform === "win32") {
14
+ const result = spawnSync("taskkill", ["/PID", String(proc.pid), "/T", "/F"], {
15
+ stdio: "ignore",
16
+ windowsHide: true,
17
+ })
18
+ return result.status === 0
19
+ }
20
+
21
+ // Processes created with detached=true become their own process group.
22
+ let signaled = false
23
+ try {
24
+ process.kill(-proc.pid, "SIGTERM")
25
+ signaled = true
26
+ } catch {
27
+ try {
28
+ signaled = proc.kill("SIGTERM") !== false
29
+ } catch {}
30
+ }
31
+
32
+ if (signaled && graceMs > 0) {
33
+ const processGroupId = proc.pid
34
+ const timer = setTimeout(() => {
35
+ // The direct child can exit while a descendant survives and keeps an
36
+ // inherited pipe open. Probe the process group itself before escalation.
37
+ let groupAlive = false
38
+ try {
39
+ process.kill(-processGroupId, 0)
40
+ groupAlive = true
41
+ } catch {}
42
+ if (!groupAlive) return
43
+ try {
44
+ process.kill(-processGroupId, "SIGKILL")
45
+ } catch {
46
+ try { proc.kill("SIGKILL") } catch {}
47
+ }
48
+ }, graceMs)
49
+ timer.unref?.()
50
+ }
51
+ return signaled
52
+ }
53
+
54
+ export async function runSupervisedProcess(command, args = [], options = {}) {
55
+ const cwd = options.cwd || process.cwd()
56
+ const stdoutLimit = clamp(options.stdoutLimit, 512 * 1024, 1024, 64 * 1024 * 1024)
57
+ const stderrLimit = clamp(options.stderrLimit, 128 * 1024, 1024, 64 * 1024 * 1024)
58
+ const hardTimeoutMs = clamp(options.hardTimeoutMs, 0, 0, 24 * 60 * 60_000)
59
+ const idleTimeoutMs = clamp(options.idleTimeoutMs, 0, 0, 24 * 60 * 60_000)
60
+ const drainTimeoutMs = clamp(options.drainTimeoutMs, 1500, 50, 30_000)
61
+ const signal = options.signal
62
+
63
+ return await new Promise((resolve) => {
64
+ const startedAt = Date.now()
65
+ let lastActivityAt = startedAt
66
+ let stdout = ""
67
+ let stderr = ""
68
+ let stdoutTruncated = false
69
+ let stderrTruncated = false
70
+ let settled = false
71
+ let exitSeen = false
72
+ let exitCode = null
73
+ let exitSignal = null
74
+ let stopReason = null
75
+ let hardTimer = null
76
+ let idleTimer = null
77
+ let drainTimer = null
78
+ let abortListener = null
79
+
80
+ const proc = spawn(command, args, {
81
+ cwd,
82
+ env: options.env || process.env,
83
+ shell: false,
84
+ detached: process.platform !== "win32",
85
+ windowsHide: true,
86
+ stdio: options.stdin === "pipe" ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"],
87
+ })
88
+
89
+ const clear = () => {
90
+ if (hardTimer) clearTimeout(hardTimer)
91
+ if (idleTimer) clearInterval(idleTimer)
92
+ if (drainTimer) clearTimeout(drainTimer)
93
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener)
94
+ }
95
+
96
+ const finish = () => {
97
+ if (settled) return
98
+ settled = true
99
+ clear()
100
+ resolve({
101
+ pid: proc.pid || null,
102
+ exitCode: stopReason === "aborted"
103
+ ? 130
104
+ : stopReason
105
+ ? 124
106
+ : Number.isInteger(exitCode)
107
+ ? exitCode
108
+ : 1,
109
+ signal: exitSignal,
110
+ stopReason,
111
+ stdout,
112
+ stderr,
113
+ stdoutTruncated,
114
+ stderrTruncated,
115
+ startedAt: new Date(startedAt).toISOString(),
116
+ finishedAt: new Date().toISOString(),
117
+ durationMs: Date.now() - startedAt,
118
+ })
119
+ }
120
+
121
+ const stop = (reason) => {
122
+ if (settled || stopReason) return
123
+ stopReason = reason
124
+ terminateProcessTree(proc, { graceMs: options.killGraceMs })
125
+ // Do not wait forever for inherited stdout/stderr descriptors held by grandchildren.
126
+ drainTimer = setTimeout(finish, drainTimeoutMs)
127
+ drainTimer.unref?.()
128
+ }
129
+
130
+ const append = (kind, chunk) => {
131
+ lastActivityAt = Date.now()
132
+ const text = String(chunk)
133
+ if (kind === "stdout") {
134
+ const room = stdoutLimit - stdout.length
135
+ if (room > 0) stdout += text.slice(0, room)
136
+ if (text.length > Math.max(0, room)) stdoutTruncated = true
137
+ } else {
138
+ const room = stderrLimit - stderr.length
139
+ if (room > 0) stderr += text.slice(0, room)
140
+ if (text.length > Math.max(0, room)) stderrTruncated = true
141
+ }
142
+ try { options.onOutput?.({ kind, text, at: Date.now() }) } catch {}
143
+ }
144
+
145
+ proc.stdout?.on("data", (data) => append("stdout", data))
146
+ proc.stderr?.on("data", (data) => append("stderr", data))
147
+
148
+ proc.on("error", (error) => {
149
+ stderr += (stderr ? "\n" : "") + String(error?.message || error)
150
+ exitCode = 1
151
+ finish()
152
+ })
153
+
154
+ proc.on("exit", (code, sig) => {
155
+ exitSeen = true
156
+ exitCode = code
157
+ exitSignal = sig
158
+ // close can be delayed forever if a grandchild inherited the pipe.
159
+ drainTimer = setTimeout(finish, drainTimeoutMs)
160
+ drainTimer.unref?.()
161
+ })
162
+ proc.on("close", (code, sig) => {
163
+ if (code !== null) exitCode = code
164
+ if (sig) exitSignal = sig
165
+ finish()
166
+ })
167
+
168
+ if (options.stdin === "pipe" && typeof options.input === "string") {
169
+ proc.stdin?.on("error", () => {})
170
+ proc.stdin?.end(options.input)
171
+ }
172
+
173
+ if (hardTimeoutMs > 0) {
174
+ hardTimer = setTimeout(() => stop("hard-timeout"), hardTimeoutMs)
175
+ hardTimer.unref?.()
176
+ }
177
+
178
+ if (idleTimeoutMs > 0) {
179
+ idleTimer = setInterval(() => {
180
+ if (!settled && !exitSeen && Date.now() - lastActivityAt >= idleTimeoutMs) {
181
+ stop("idle-timeout")
182
+ }
183
+ }, Math.min(1000, Math.max(100, Math.floor(idleTimeoutMs / 10))))
184
+ idleTimer.unref?.()
185
+ }
186
+
187
+ abortListener = () => stop("aborted")
188
+ if (signal) {
189
+ if (signal.aborted) abortListener()
190
+ else signal.addEventListener("abort", abortListener, { once: true })
191
+ }
192
+ })
193
+ }