opencode-agent-skill 14.4.0 → 15.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 (53) hide show
  1. package/README.md +34 -16
  2. package/docs/PI-COMPAT.md +151 -2
  3. package/docs/V15-MANAGED-RUNTIME.md +128 -0
  4. package/global-config/agents/architect.md +63 -1
  5. package/global-config/agents/executor.md +7 -0
  6. package/global-config/agents/plan-checker.md +8 -1
  7. package/global-config/agents/verifier.md +3 -3
  8. package/global-config/skills/database-engineering/references/workflow.md +15 -0
  9. package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
  10. package/lib/activity-deadline.mjs +70 -0
  11. package/lib/adaptive-context-budget.mjs +25 -5
  12. package/lib/affected-tests.mjs +38 -5
  13. package/lib/capability-fabric.mjs +17 -0
  14. package/lib/code-intelligence/index.mjs +3 -2
  15. package/lib/code-intelligence/lsp-provider.mjs +326 -52
  16. package/lib/compaction-resume-guard.mjs +255 -0
  17. package/lib/completion-auditor.mjs +25 -5
  18. package/lib/context-engine-v11.mjs +20 -7
  19. package/lib/evidence-store.mjs +92 -8
  20. package/lib/execution-contract.mjs +592 -0
  21. package/lib/leaf-runtime-optimizer.mjs +76 -0
  22. package/lib/memory-engine.mjs +189 -16
  23. package/lib/performance-fabric.mjs +262 -107
  24. package/lib/pi-child-invocation.mjs +65 -0
  25. package/lib/pi-rpc-pool.mjs +61 -9
  26. package/lib/plan-salvage.mjs +124 -0
  27. package/lib/planning-speed-policy.mjs +131 -0
  28. package/lib/repo-graph.mjs +1 -1
  29. package/lib/repo-inspect.mjs +1 -0
  30. package/lib/runtime-artifacts.mjs +42 -0
  31. package/lib/runtime-events.mjs +33 -1
  32. package/lib/safety.mjs +1 -0
  33. package/lib/semantic-index.mjs +1 -1
  34. package/lib/service-manager.mjs +526 -0
  35. package/lib/session-display.mjs +49 -0
  36. package/lib/subagent-artifacts.mjs +182 -0
  37. package/lib/task-graph.mjs +87 -0
  38. package/lib/task-policy.mjs +150 -4
  39. package/lib/trajectory.mjs +111 -1
  40. package/lib/turbo-fast-path.mjs +44 -0
  41. package/lib/verification-broker.mjs +32 -27
  42. package/lib/workspace-fingerprint.mjs +45 -19
  43. package/lib/workspace-hygiene.mjs +481 -0
  44. package/lib/workspace-root.mjs +49 -0
  45. package/lib/worktree-sandbox.mjs +234 -11
  46. package/package.json +8 -5
  47. package/pi/extensions/ues-child-runtime.ts +170 -4
  48. package/pi/extensions/ues.ts +2063 -188
  49. package/scripts/check-release-consistency.mjs +25 -2
  50. package/scripts/check-source-integrity.mjs +471 -4
  51. package/scripts/eval-pi.mjs +76 -36
  52. package/scripts/smoke-pi-extension.mjs +1 -1
  53. package/pi/prompts/ues-run.md +0 -17
@@ -9,23 +9,41 @@ function clampInt(value, fallback, min, max) {
9
9
  return Math.max(min, Math.min(max, Math.trunc(parsed)))
10
10
  }
11
11
 
12
- function uniqueSignalLines(text, limit = 48) {
12
+ function hintFor(options = {}) {
13
+ return String(options.command || options.source || options.kind || "")
14
+ .trim()
15
+ .replace(/\s+/g, " ")
16
+ .toLowerCase()
17
+ }
18
+
19
+ function uniqRows(rows, limit = 100) {
13
20
  const seen = new Set()
21
+ const result = []
22
+ for (const value of rows || []) {
23
+ const row = String(value || "").trimEnd()
24
+ if (!row || seen.has(row)) continue
25
+ seen.add(row)
26
+ result.push(row)
27
+ if (result.length >= limit) break
28
+ }
29
+ return result
30
+ }
31
+
32
+ function matchingLines(source, patterns, limit = 100) {
14
33
  const rows = []
15
- for (const raw of String(text || "").split(/\r?\n/)) {
16
- const line = raw.trimEnd()
17
- if (!line || !SIGNAL_LINE.test(line) || seen.has(line)) continue
18
- seen.add(line)
19
- rows.push(line)
34
+ for (const raw of String(source || "").split(/\r?\n/)) {
35
+ if (patterns.some((pattern) => pattern.test(raw))) rows.push(raw)
20
36
  if (rows.length >= limit) break
21
37
  }
22
- return rows
38
+ return uniqRows(rows, limit)
23
39
  }
24
40
 
25
- function testSummaryLines(source, limit = 80) {
26
- const rows = []
27
- const seen = new Set()
28
- const patterns = [
41
+ function uniqueSignalLines(text, limit = 48) {
42
+ return matchingLines(text, [SIGNAL_LINE], limit)
43
+ }
44
+
45
+ function npmTestRows(source, limit = 100) {
46
+ return matchingLines(source, [
29
47
  /^FAIL\b/i,
30
48
  /^PASS\b/i,
31
49
  /^Test Suites:/i,
@@ -38,80 +56,124 @@ function testSummaryLines(source, limit = 80) {
38
56
  /^\s*●\s+/,
39
57
  /^\s*at\s+.+:\d+:\d+/,
40
58
  /(?:AssertionError|Expected|Received|Traceback|panic:|FAILED\s)/i,
41
- ]
42
- for (const raw of String(source || "").split(/\r?\n/)) {
43
- const line = raw.trimEnd()
44
- if (!line || seen.has(line) || !patterns.some((re) => re.test(line))) continue
45
- seen.add(line)
46
- rows.push(line)
47
- if (rows.length >= limit) break
48
- }
49
- return rows
59
+ /^not ok\b/i,
60
+ /^ok\s+\d+\b/i,
61
+ /^#\s+(?:tests|suites|pass|fail|cancelled|skipped|todo|duration_ms)\b/i,
62
+ ], limit)
50
63
  }
51
64
 
52
- function gitSummaryLines(source, limit = 120) {
53
- const rows = []
54
- for (const raw of String(source || "").split(/\r?\n/)) {
65
+ function pytestRows(source, limit = 100) {
66
+ return matchingLines(source, [
67
+ /^(?:FAILED|ERROR)\b/,
68
+ /^=+\s+.*(?:passed|failed|error|skipped|xfailed|xpassed).*\s+=+$/i,
69
+ /^E\s{2,}/,
70
+ /^_+\s+.+\s+_+$/,
71
+ /^Traceback \(most recent call last\):/,
72
+ /^\s*File ".+", line \d+/,
73
+ /(?:AssertionError|ImportError|ModuleNotFoundError|pytest\.)/,
74
+ /collected\s+\d+\s+items?/i,
75
+ ], limit)
76
+ }
77
+
78
+ function gitDiffRows(source, limit = 140) {
79
+ return matchingLines(source, [
80
+ /^diff --git /,
81
+ /^index [0-9a-f.]+/i,
82
+ /^@@ /,
83
+ /^(?:---|\+\+\+) /,
84
+ /^fatal:/i,
85
+ /^error:/i,
86
+ /^CONFLICT\b/i,
87
+ /^rename (?:from|to) /,
88
+ /^new file mode /,
89
+ /^deleted file mode /,
90
+ /^\s*\d+ files? changed/i,
91
+ ], limit)
92
+ }
93
+
94
+ function rgRows(source, limit = 100) {
95
+ const lines = String(source || "").split(/\r?\n/).filter(Boolean)
96
+ const matched = []
97
+ for (const line of lines) {
55
98
  if (
56
- /^diff --git /.test(raw) ||
57
- /^@@ /.test(raw) ||
58
- /^(?:---|\+\+\+) /.test(raw) ||
59
- /^fatal:/i.test(raw) ||
60
- /^error:/i.test(raw) ||
61
- /^CONFLICT\b/i.test(raw)
62
- ) {
63
- rows.push(raw)
64
- if (rows.length >= limit) break
65
- }
99
+ /^(?:[^:\n]+:)?\d+(?::\d+)?:/.test(line) ||
100
+ /^\.?[^:\n]+:\s/.test(line) ||
101
+ /(?:error|permission denied|binary file|no such file)/i.test(line)
102
+ ) matched.push(line)
103
+ if (matched.length >= limit) break
66
104
  }
67
- return rows
105
+ const rows = matched.length ? matched : lines.slice(0, limit)
106
+ return uniqRows(rows, limit)
68
107
  }
69
108
 
109
+ function treeRows(source, limit = 120) {
110
+ const lines = String(source || "").split(/\r?\n/).filter(Boolean)
111
+ if (lines.length <= limit) return uniqRows(lines, limit)
112
+ const head = lines.slice(0, Math.max(1, limit - 12))
113
+ const tail = lines.slice(-12)
114
+ return uniqRows([...head, `...[tree omitted ${Math.max(0, lines.length - head.length - tail.length)} rows]...`, ...tail], limit)
115
+ }
70
116
 
71
- function lintSummaryLines(source, limit = 100) {
72
- const rows = []
73
- const seen = new Set()
74
- const patterns = [
75
- /\b(?:error|warning)\b/i,
117
+ function tscRows(source, limit = 120) {
118
+ return matchingLines(source, [
119
+ /\berror\s+TS\d+\b/i,
120
+ /^.+\(\d+,\d+\):\s+error\s+TS\d+/i,
121
+ /^.+:\d+:\d+\s+-\s+error\s+TS\d+/i,
122
+ /^Found\s+\d+\s+errors?/i,
123
+ /\b(?:ELIFECYCLE|ERR_|exit code)\b/i,
124
+ ], limit)
125
+ }
126
+
127
+ function eslintRows(source, limit = 120) {
128
+ return matchingLines(source, [
129
+ /^\/?[^\n]+\.[cm]?[jt]sx?$/i,
130
+ /^\s*\d+:\d+\s+(?:error|warning)\s+/i,
76
131
  /\b\d+\s+problems?\b/i,
77
132
  /\b\d+\s+errors?\b/i,
78
133
  /\b\d+\s+warnings?\b/i,
79
- /\b(?:eslint|ruff|flake8|pylint|clippy)\b/i,
80
- /:\d+:\d+\b/,
81
134
  /^✖\s+/,
82
- ]
83
- for (const raw of String(source || "").split(/\r?\n/)) {
84
- const line = raw.trimEnd()
85
- if (!line || seen.has(line) || !patterns.some((pattern) => pattern.test(line))) continue
86
- seen.add(line)
87
- rows.push(line)
88
- if (rows.length >= limit) break
89
- }
90
- return rows
135
+ /\beslint\b.*\b(?:error|warning|failed)\b/i,
136
+ ], limit)
91
137
  }
92
138
 
93
- function buildSummaryLines(source, limit = 100) {
94
- const rows = []
95
- const seen = new Set()
96
- const patterns = [
97
- /\berror\s+TS\d+\b/i,
139
+ function dockerRows(source, limit = 120) {
140
+ return matchingLines(source, [
141
+ /^#\d+\s+/,
142
+ /^\s*=>\s+/,
143
+ /\b(?:error|failed|failure|fatal|warning|unhealthy|exited)\b/i,
144
+ /^NAME\s+IMAGE\s+COMMAND/i,
145
+ /^NAME\s+STATUS/i,
146
+ /^\[?\+\]?\s+(?:Running|Started|Stopped|Created|Built|Recreated)/i,
147
+ /(?:container|service|network|volume).*(?:created|started|stopped|failed|error)/i,
148
+ ], limit)
149
+ }
150
+
151
+ function prismaRows(source, limit = 120) {
152
+ return matchingLines(source, [
153
+ /Prisma schema loaded from/i,
154
+ /Datasource\s+".+"/i,
155
+ /Applying migration/i,
156
+ /migrations? found/i,
157
+ /Generated Prisma Client/i,
158
+ /Your database is now in sync/i,
159
+ /The following migration/i,
160
+ /\bP\d{4}\b/,
161
+ /\b(?:error|failed|warning)\b/i,
162
+ /schema\.prisma:\d+/i,
163
+ ], limit)
164
+ }
165
+
166
+ function genericBuildRows(source, limit = 100) {
167
+ return matchingLines(source, [
98
168
  /\b(?:compile|compiled|compilation|build)\b.*\b(?:failed|success|succeeded|complete|completed)\b/i,
99
169
  /\b(?:failed|fatal|exception|panic)\b/i,
100
170
  /\bwarning\b/i,
101
171
  /:\d+:\d+\b/,
102
172
  /\b(?:ELIFECYCLE|ERR_|exit code)\b/i,
103
- ]
104
- for (const raw of String(source || "").split(/\r?\n/)) {
105
- const line = raw.trimEnd()
106
- if (!line || seen.has(line) || !patterns.some((pattern) => pattern.test(line))) continue
107
- seen.add(line)
108
- rows.push(line)
109
- if (rows.length >= limit) break
110
- }
111
- return rows
173
+ ], limit)
112
174
  }
113
175
 
114
- function jsonSummaryLines(source, limit = 80) {
176
+ function jsonRows(source, limit = 80) {
115
177
  const text = String(source || "").trim()
116
178
  if (!text || text.length > 4 * 1024 * 1024 || !/^[\[{]/.test(text)) return []
117
179
  let value
@@ -134,50 +196,132 @@ function jsonSummaryLines(source, limit = 80) {
134
196
  }
135
197
  return
136
198
  }
137
- const rendered = JSON.stringify(current)
138
- rows.push(`${prefix || "$"}: ${String(rendered).slice(0, 160)}`)
199
+ rows.push(`${prefix || "$"}: ${String(JSON.stringify(current)).slice(0, 160)}`)
139
200
  }
140
201
  visit(value, "", 0)
141
202
  return rows
142
203
  }
143
204
 
144
- function structuredSignalLines(source, options = {}) {
145
- const hint = String(options.kind || options.source || options.command || "").toLowerCase()
146
- if (/(jest|vitest|pytest|test|spec)/.test(hint)) {
147
- const rows = testSummaryLines(source)
148
- if (rows.length) return { kind: "test", rows }
149
- }
150
- if (/(eslint|lint|ruff|flake8|pylint|clippy)/.test(hint)) {
151
- const rows = lintSummaryLines(source)
152
- if (rows.length) return { kind: "lint", rows }
153
- }
154
- if (/(typecheck|tsc|compile|build|gradle|maven|cargo check|dotnet build)/.test(hint)) {
155
- const rows = buildSummaryLines(source)
156
- if (rows.length) return { kind: "build", rows }
205
+ const REDUCERS = Object.freeze([
206
+ {
207
+ family: "npm-test",
208
+ match: (hint) => /(?:^|\s)(?:npm|pnpm|yarn)\s+(?:run\s+)?test\b|node\s+--test\b|\b(?:jest|vitest)\b/.test(hint),
209
+ rows: npmTestRows,
210
+ },
211
+ {
212
+ family: "pytest",
213
+ match: (hint) => /\b(?:pytest|py\.test)\b/.test(hint),
214
+ rows: pytestRows,
215
+ },
216
+ {
217
+ family: "git-diff",
218
+ match: (hint) => /\bgit\s+(?:diff|show)\b/.test(hint),
219
+ rows: gitDiffRows,
220
+ },
221
+ {
222
+ family: "ripgrep",
223
+ match: (hint) => /(?:^|\s)(?:rg|ripgrep)(?:\s|$)|\bgrep\b/.test(hint),
224
+ rows: rgRows,
225
+ },
226
+ {
227
+ family: "tree",
228
+ match: (hint) => /(?:^|[;&|]\s*|\s)(?:tree)(?:\s|$)/.test(hint) || hint === "tree",
229
+ rows: treeRows,
230
+ },
231
+ {
232
+ family: "tsc",
233
+ match: (hint) => /\b(?:tsc|typescript|typecheck)\b/.test(hint),
234
+ rows: tscRows,
235
+ },
236
+ {
237
+ family: "eslint",
238
+ match: (hint) => /\b(?:eslint|lint)\b/.test(hint),
239
+ rows: eslintRows,
240
+ },
241
+ {
242
+ family: "docker",
243
+ match: (hint) => /\bdocker(?:\s+compose)?\b/.test(hint),
244
+ rows: dockerRows,
245
+ },
246
+ {
247
+ family: "prisma",
248
+ match: (hint) => /\bprisma\b|schema\.prisma/.test(hint),
249
+ rows: prismaRows,
250
+ },
251
+ {
252
+ family: "build",
253
+ match: (hint) => /\b(?:compile|build|gradle|maven|mvn|cargo\s+check|dotnet\s+build)\b/.test(hint),
254
+ rows: genericBuildRows,
255
+ },
256
+ ])
257
+
258
+ export const COMMAND_REDUCER_FAMILIES = Object.freeze(REDUCERS.map((item) => item.family))
259
+
260
+ export function reduceCommandOutput(source, options = {}) {
261
+ const text = String(source || "")
262
+ const hint = hintFor(options)
263
+ const reducer = REDUCERS.find((item) => item.match(hint))
264
+ if (reducer) {
265
+ const rows = reducer.rows(text)
266
+ if (rows.length) {
267
+ return {
268
+ schemaVersion: 2,
269
+ family: reducer.family,
270
+ hint,
271
+ rows,
272
+ summary: `command-aware reducer=${reducer.family}; selected=${rows.length}; totalLines=${text.split(/\r?\n/).length}`,
273
+ }
274
+ }
157
275
  }
158
- if (/(git|diff|status)/.test(hint)) {
159
- const rows = gitSummaryLines(source)
160
- if (rows.length) return { kind: "git", rows }
276
+
277
+ if (/(?:--json|\bjson\b)/.test(hint) || /^[\s]*[\[{]/.test(text)) {
278
+ const rows = jsonRows(text)
279
+ if (rows.length) {
280
+ return {
281
+ schemaVersion: 2,
282
+ family: "json",
283
+ hint,
284
+ rows,
285
+ summary: `command-aware reducer=json; selected=${rows.length}`,
286
+ }
287
+ }
161
288
  }
162
- if (/(json|--json)/.test(hint) || /^[\s]*[\[{]/.test(String(source || ""))) {
163
- const rows = jsonSummaryLines(source)
164
- if (rows.length) return { kind: "json", rows }
289
+
290
+ const rows = uniqueSignalLines(text)
291
+ return {
292
+ schemaVersion: 2,
293
+ family: rows.length ? "signal" : "generic",
294
+ hint,
295
+ rows,
296
+ summary: rows.length
297
+ ? `generic signal reducer; selected=${rows.length}`
298
+ : "generic head/tail fallback; no structured signal reducer matched",
165
299
  }
166
- const rows = uniqueSignalLines(source)
167
- return { kind: rows.length ? "signal" : "generic", rows }
300
+ }
301
+
302
+ function legacyStrategyKind(source, options = {}) {
303
+ const hint = hintFor(options)
304
+ if (/(jest|vitest|pytest|test|spec)/.test(hint) && npmTestRows(source).length) return "test"
305
+ if (/(eslint|lint|ruff|flake8|pylint|clippy)/.test(hint) && eslintRows(source).length) return "lint"
306
+ if (/(typecheck|tsc|compile|build|gradle|maven|cargo check|dotnet build)/.test(hint) && genericBuildRows(source).length) return "build"
307
+ if (/(git|diff|status)/.test(hint) && gitDiffRows(source).length) return "git"
308
+ if ((/(json|--json)/.test(hint) || /^[\s]*[\[{]/.test(String(source || ""))) && jsonRows(source).length) return "json"
309
+ return uniqueSignalLines(source).length ? "signal" : "generic"
168
310
  }
169
311
 
170
312
  function boundedPreview(text, maxChars, options = {}) {
171
313
  const source = String(text || "")
172
- const headBudget = Math.max(2048, Math.floor(maxChars * 0.24))
173
- const tailBudget = Math.max(4096, Math.floor(maxChars * 0.30))
174
- const signalBudget = Math.max(2048, maxChars - headBudget - tailBudget - 1800)
314
+ const headBudget = Math.max(1536, Math.floor(maxChars * 0.18))
315
+ const tailBudget = Math.max(2048, Math.floor(maxChars * 0.24))
316
+ const signalBudget = Math.max(2048, maxChars - headBudget - tailBudget - 1900)
175
317
  const head = source.slice(0, headBudget)
176
318
  const tail = source.slice(Math.max(head.length, source.length - tailBudget))
177
- const structured = structuredSignalLines(source, options)
178
- let signalText = structured.rows.join("\n")
179
- if (signalText.length > signalBudget) signalText = signalText.slice(0, signalBudget) + "\n...[signal lines truncated]"
180
- return { head, tail, signalText, signalKind: structured.kind }
319
+ const reduced = reduceCommandOutput(source, options)
320
+ let signalText = [reduced.summary, ...reduced.rows].join("\n")
321
+ if (signalText.length > signalBudget) {
322
+ signalText = signalText.slice(0, signalBudget) + "\n...[reducer output truncated]"
323
+ }
324
+ return { head, tail, signalText, reducer: reduced }
181
325
  }
182
326
 
183
327
  export async function compactReversibleOutput(root, text, options = {}) {
@@ -185,9 +329,10 @@ export async function compactReversibleOutput(root, text, options = {}) {
185
329
  const maxChars = clampInt(options.maxChars, DEFAULT_LIMIT, 8 * 1024, 256 * 1024)
186
330
  if (source.length <= maxChars) {
187
331
  return {
188
- schemaVersion: 1,
332
+ schemaVersion: 2,
189
333
  compacted: false,
190
334
  strategy: "raw",
335
+ commandFamily: null,
191
336
  originalChars: source.length,
192
337
  returnedChars: source.length,
193
338
  evidenceRef: null,
@@ -195,35 +340,45 @@ export async function compactReversibleOutput(root, text, options = {}) {
195
340
  }
196
341
  }
197
342
 
343
+ // Raw bytes are persisted before any model-visible reduction. Reducers never
344
+ // become the verification source of truth; verifiers can recover exact data.
198
345
  const evidence = await putEvidence(root, source, {
199
346
  kind: options.kind || "raw-tool-output",
200
- source: options.source || "ues-performance-fabric",
201
- summary: options.summary || `Raw output preserved before model-visible compaction (${source.length} chars)`,
347
+ source: options.source || options.command || "ues-performance-fabric-v2",
348
+ summary: options.summary || `Raw output preserved before command-aware model-visible compaction (${source.length} chars)`,
202
349
  })
203
- const { head, tail, signalText, signalKind } = boundedPreview(source, maxChars, options)
350
+ const { head, tail, signalText, reducer } = boundedPreview(source, maxChars, options)
204
351
  const recovery = `Raw captured output: ${evidence.ref}. Recover exact captured bytes in slices with: ues store get ${evidence.ref} --start N --max 24000`
205
352
  let preview = [
206
- `[UES reversible output compaction: ${source.length} -> <=${maxChars} chars; reducer=${signalKind}]`,
353
+ `[UES command-aware reversible compaction v2: ${source.length} -> <=${maxChars} chars; reducer=${reducer.family}]`,
207
354
  recovery,
208
355
  "",
209
- "--- head ---",
356
+ "--- reducer summary ---",
357
+ signalText,
358
+ "",
359
+ "--- bounded head ---",
210
360
  head,
211
- signalText ? "\n--- high-signal lines ---\n" + signalText : "",
212
- "\n--- tail ---",
361
+ "",
362
+ "--- bounded tail ---",
213
363
  tail,
214
364
  ].filter(Boolean).join("\n")
215
365
  if (preview.length > maxChars) {
216
- const reserve = Math.min(1400, recovery.length + 160)
366
+ const reserve = Math.min(1500, recovery.length + 200)
217
367
  preview = preview.slice(0, Math.max(0, maxChars - reserve)) + `\n...[model-visible preview truncated]\n${recovery}`
218
368
  }
219
369
 
370
+ const compatibilityKind = legacyStrategyKind(source, options)
220
371
  return {
221
- schemaVersion: 1,
372
+ schemaVersion: 2,
222
373
  compacted: true,
223
- strategy: `reversible-head-${signalKind}-tail`,
374
+ // Keep the V14 public strategy contract stable for callers/tests while
375
+ // exposing the richer V2 reducer identity separately.
376
+ strategy: `reversible-head-${compatibilityKind}-tail`,
377
+ strategyV2: `reversible-command-aware-${reducer.family}`,
378
+ commandFamily: reducer.family,
224
379
  originalChars: source.length,
225
380
  returnedChars: preview.length,
226
381
  evidenceRef: evidence.ref,
227
382
  text: preview,
228
383
  }
229
- }
384
+ }
@@ -0,0 +1,65 @@
1
+ import { existsSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { resolveManagedPiCommand, resolveWindowsCommand } from "./windows-shim.mjs"
4
+
5
+ export function looksLikePiCliEntrypoint(file) {
6
+ const value = String(file || "").replaceAll("\\", "/").toLowerCase()
7
+ if (!value) return false
8
+
9
+ // Managed/npm Pi installs execute the package CLI script. ACP adapters may
10
+ // also be Node scripts, so do not trust an arbitrary existing argv[1].
11
+ if (value.includes("/@earendil-works/pi-coding-agent/")) return true
12
+
13
+ const base = path.posix.basename(value)
14
+ return /^(?:pi)(?:\.[cm]?js)?$/.test(base)
15
+ }
16
+
17
+ function fromResolvedPi(resolved, args, source) {
18
+ if (!resolved?.executable) return null
19
+ return {
20
+ command: resolved.executable,
21
+ args: [...(resolved.argsPrefix || []), ...args],
22
+ source,
23
+ }
24
+ }
25
+
26
+ export function resolvePiChildInvocation(args = [], options = {}) {
27
+ const currentScript = options.currentScript ?? process.argv[1]
28
+ const execPath = options.execPath ?? process.execPath
29
+ const platform = options.platform ?? process.platform
30
+ const exists = options.existsSync ?? existsSync
31
+
32
+ if (
33
+ currentScript &&
34
+ !String(currentScript).startsWith("/$bunfs/root/") &&
35
+ exists(currentScript) &&
36
+ looksLikePiCliEntrypoint(currentScript)
37
+ ) {
38
+ return {
39
+ command: execPath,
40
+ args: [currentScript, ...args],
41
+ source: "host-pi-cli",
42
+ }
43
+ }
44
+
45
+ const execBase = path.basename(String(execPath || "")).toLowerCase()
46
+ if (/^pi(?:\.exe)?$/.test(execBase)) {
47
+ return { command: execPath, args, source: "host-pi-executable" }
48
+ }
49
+
50
+ if (platform === "win32") {
51
+ const managed = options.managedPi === undefined
52
+ ? resolveManagedPiCommand()
53
+ : options.managedPi
54
+ const managedInvocation = fromResolvedPi(managed, args, "managed-pi")
55
+ if (managedInvocation) return managedInvocation
56
+
57
+ const pathPi = options.pathPi === undefined
58
+ ? resolveWindowsCommand("pi")
59
+ : options.pathPi
60
+ const pathInvocation = fromResolvedPi(pathPi, args, "path-pi")
61
+ if (pathInvocation) return pathInvocation
62
+ }
63
+
64
+ return { command: "pi", args, source: "path-pi-fallback" }
65
+ }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process"
2
2
  import { terminateProcessTree } from "./process-supervisor.mjs"
3
+ import { createAdaptiveDeadline } from "./activity-deadline.mjs"
3
4
 
4
5
  function clamp(value, fallback, min, max) {
5
6
  const parsed = Number(value)
@@ -226,11 +227,37 @@ class RpcWorker {
226
227
  this.stderr = ""
227
228
 
228
229
  const hardTimeoutMs = clamp(options.hardTimeoutMs, 30 * 60_000, 30_000, 2 * 60 * 60_000)
230
+ const absoluteHardTimeoutMs = clamp(
231
+ options.absoluteHardTimeoutMs,
232
+ hardTimeoutMs,
233
+ hardTimeoutMs,
234
+ 2 * 60 * 60_000,
235
+ )
236
+ const activityExtensionMs = clamp(
237
+ options.activityExtensionMs,
238
+ 0,
239
+ 0,
240
+ Math.max(hardTimeoutMs, absoluteHardTimeoutMs),
241
+ )
242
+ const activityWindowMs = clamp(
243
+ options.activityWindowMs,
244
+ Math.max(5_000, Math.min(activityExtensionMs || hardTimeoutMs, 30_000)),
245
+ 1_000,
246
+ Math.max(hardTimeoutMs, absoluteHardTimeoutMs),
247
+ )
229
248
  const idleTimeoutMs = clamp(options.idleTimeoutMs, 5 * 60_000, 10_000, 30 * 60_000)
230
- let lastActivityAt = Date.now()
249
+ const runStartedAt = Date.now()
250
+ let lastActivityAt = runStartedAt
251
+ const adaptiveDeadline = createAdaptiveDeadline({
252
+ hardTimeoutMs,
253
+ absoluteHardTimeoutMs,
254
+ activityExtensionMs,
255
+ activityWindowMs,
256
+ }, runStartedAt)
231
257
  this.lastActivityAt = lastActivityAt
232
258
  this.active = true
233
259
  let finalMessage = null
260
+ let partialAssistantMessage = null
234
261
  let toolCalls = 0
235
262
  let lastToolErrorAt = 0
236
263
  const toolNames = new Set()
@@ -240,19 +267,37 @@ class RpcWorker {
240
267
  settledResolve = resolve
241
268
  settledReject = reject
242
269
  })
270
+ // The active run can be aborted while the prompt transport is still
271
+ // completing, before execution reaches "await settled". Attach a handler
272
+ // immediately so Node never reports a transient unhandled rejection;
273
+ // awaiting the original promise below still preserves the rejection.
274
+ settled.catch(() => {})
243
275
 
244
276
  let aborting = false
245
277
  const abortFor = async (reason) => {
246
278
  if (aborting) return
247
279
  aborting = true
280
+ const error = new Error("UES RPC " + reason)
281
+ error.partialMessage = partialAssistantMessage || finalMessage
282
+ error.stderr = this.stderr
283
+ error.toolCalls = toolCalls
284
+ error.toolNames = [...toolNames]
285
+ error.deadlineExtensions = adaptiveDeadline.extensions
286
+ error.deadlineAt = adaptiveDeadline.deadlineAt
287
+ error.absoluteDeadlineAt = adaptiveDeadline.absoluteAt
288
+ // Settle with the rich timeout error before transport teardown can emit a
289
+ // generic worker-exit error and win the race.
290
+ settledReject(error)
248
291
  try { await this.abortTransport() } catch {}
249
- settledReject(new Error("UES RPC " + reason))
250
292
  }
251
293
  this.activeAbort = abortFor
252
294
 
253
295
  const unsubscribe = this.onEvent((event) => {
254
296
  lastActivityAt = Date.now()
255
297
  this.lastActivityAt = lastActivityAt
298
+ if (event?.message?.role === "assistant") {
299
+ partialAssistantMessage = event.message
300
+ }
256
301
  if (event.type === "tool_execution_start") {
257
302
  toolCalls += 1
258
303
  lastToolErrorAt = 0
@@ -272,22 +317,27 @@ class RpcWorker {
272
317
  if (event.type === "ues_rpc_worker_error") settledReject(new Error(event.error || "RPC worker failed"))
273
318
  })
274
319
 
275
- const hardTimer = setTimeout(() => void abortFor("hard-timeout"), hardTimeoutMs)
276
- hardTimer.unref?.()
277
320
  const postToolErrorIdleTimeoutMs = clamp(
278
321
  options.postToolErrorIdleTimeoutMs,
279
322
  60_000,
280
323
  5_000,
281
324
  idleTimeoutMs,
282
325
  )
283
- const idleTimer = setInterval(() => {
326
+ const watchdogTimer = setInterval(() => {
284
327
  const now = Date.now()
285
- const threshold = lastToolErrorAt > 0 ? Math.min(idleTimeoutMs, postToolErrorIdleTimeoutMs) : idleTimeoutMs
328
+ const threshold = lastToolErrorAt > 0
329
+ ? Math.min(idleTimeoutMs, postToolErrorIdleTimeoutMs)
330
+ : idleTimeoutMs
286
331
  if (now - lastActivityAt >= threshold) {
287
332
  void abortFor(lastToolErrorAt > 0 ? "post-tool-error-stall" : "idle-timeout")
333
+ return
334
+ }
335
+ const deadline = adaptiveDeadline.shouldAbort(now, lastActivityAt)
336
+ if (deadline.abort) {
337
+ void abortFor(deadline.reason || "hard-timeout")
288
338
  }
289
339
  }, 1000)
290
- idleTimer.unref?.()
340
+ watchdogTimer.unref?.()
291
341
 
292
342
  const signal = options.signal
293
343
  const onAbort = () => void abortFor("aborted")
@@ -304,6 +354,9 @@ class RpcWorker {
304
354
  stderr: this.stderr,
305
355
  toolCalls,
306
356
  toolNames: [...toolNames],
357
+ deadlineExtensions: adaptiveDeadline.extensions,
358
+ deadlineAt: adaptiveDeadline.deadlineAt,
359
+ absoluteDeadlineAt: adaptiveDeadline.absoluteAt,
307
360
  }
308
361
  } catch (error) {
309
362
  if (error && typeof error === "object") error.uesRpcPhase = "runtime"
@@ -311,8 +364,7 @@ class RpcWorker {
311
364
  } finally {
312
365
  this.active = false
313
366
  this.activeAbort = null
314
- clearTimeout(hardTimer)
315
- clearInterval(idleTimer)
367
+ clearInterval(watchdogTimer)
316
368
  unsubscribe()
317
369
  if (signal) signal.removeEventListener("abort", onAbort)
318
370
  }