opencode-agent-skill 14.2.0-beta.1 → 15.0.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 (55) hide show
  1. package/README.md +51 -18
  2. package/docs/PI-COMPAT.md +167 -3
  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 +1 -1
  13. package/lib/capability-fabric.mjs +40 -0
  14. package/lib/code-intelligence/edit-anchor.mjs +102 -0
  15. package/lib/code-intelligence/index.mjs +95 -0
  16. package/lib/code-intelligence/lsp-provider.mjs +189 -0
  17. package/lib/completion-auditor.mjs +102 -0
  18. package/lib/document-ingestion.mjs +60 -0
  19. package/lib/evidence-store.mjs +92 -8
  20. package/lib/execution-contract.mjs +579 -0
  21. package/lib/fast-verification-gate.mjs +69 -0
  22. package/lib/leaf-runtime-optimizer.mjs +76 -0
  23. package/lib/mcp-health.mjs +144 -0
  24. package/lib/mcp-tool-policy.mjs +19 -0
  25. package/lib/memory-engine.mjs +28 -0
  26. package/lib/pi-child-invocation.mjs +65 -0
  27. package/lib/pi-rpc-pool.mjs +61 -9
  28. package/lib/plan-salvage.mjs +124 -0
  29. package/lib/planning-speed-policy.mjs +131 -0
  30. package/lib/prompt-cache.mjs +26 -12
  31. package/lib/repo-graph.mjs +1 -1
  32. package/lib/repo-inspect.mjs +1 -0
  33. package/lib/reversible-context.mjs +49 -0
  34. package/lib/runtime-artifacts.mjs +42 -0
  35. package/lib/runtime-events.mjs +33 -1
  36. package/lib/safety.mjs +1 -0
  37. package/lib/semantic-index.mjs +1 -1
  38. package/lib/service-manager.mjs +526 -0
  39. package/lib/session-display.mjs +49 -0
  40. package/lib/task-engine.mjs +6 -10
  41. package/lib/task-graph.mjs +87 -0
  42. package/lib/task-policy.mjs +210 -8
  43. package/lib/trajectory.mjs +111 -1
  44. package/lib/turbo-fast-path.mjs +44 -0
  45. package/lib/workspace-fingerprint.mjs +2 -11
  46. package/lib/workspace-root.mjs +49 -0
  47. package/lib/worktree-sandbox.mjs +234 -11
  48. package/package.json +9 -6
  49. package/pi/extensions/ues-child-runtime.ts +300 -2
  50. package/pi/extensions/ues.ts +1887 -160
  51. package/scripts/check-release-consistency.mjs +25 -2
  52. package/scripts/check-source-integrity.mjs +532 -3
  53. package/scripts/eval-pi.mjs +76 -36
  54. package/scripts/smoke-pi-extension.mjs +1 -1
  55. package/pi/prompts/ues-run.md +0 -17
@@ -0,0 +1,76 @@
1
+ import { classifyEngineeringTask } from "./task-policy.mjs"
2
+
3
+ function strings(value) {
4
+ if (!value) return []
5
+ return (Array.isArray(value) ? value : [value]).map(String).map((item) => item.trim()).filter(Boolean)
6
+ }
7
+
8
+ function declaredWriteFiles(task = {}) {
9
+ const files = task?.files || {}
10
+ return [...new Set([
11
+ ...strings(files.create),
12
+ ...strings(files.modify),
13
+ ...strings(files.test),
14
+ ...strings(files.delete),
15
+ ].map((item) => item.replaceAll("\\", "/")))]
16
+ }
17
+
18
+ export function leafTaskPolicy(task = {}, rootPolicy = {}) {
19
+ const writeFiles = declaredWriteFiles(task)
20
+ const readFiles = strings(task?.files?.read)
21
+ const text = [
22
+ task?.title,
23
+ task?.summary,
24
+ ...strings(task?.acceptance),
25
+ ...strings(task?.verification),
26
+ task?.riskNotes,
27
+ writeFiles.length ? "Declared write files: " + writeFiles.join(", ") : "",
28
+ readFiles.length ? "Declared read files: " + readFiles.join(", ") : "",
29
+ ].filter(Boolean).join("\n")
30
+
31
+ const policy = classifyEngineeringTask(text, {
32
+ declaredFiles: writeFiles,
33
+ changedFiles: writeFiles.length,
34
+ risk: task?.risk,
35
+ longHorizon: false,
36
+ failureEvidence: false,
37
+ })
38
+
39
+ // The root may be DEEP because the overall project is large; do not force an
40
+ // independent low-risk leaf to pay that cost. Root safety is preserved by the
41
+ // final integration/completion gates and by leaf-declared risk.
42
+ return {
43
+ ...policy,
44
+ requireFreshEvidence: true,
45
+ rootExecutionProfile: rootPolicy?.executionProfile || null,
46
+ rootRisk: rootPolicy?.risk || null,
47
+ localEnvWriteExplicitlyAllowed: rootPolicy?.localEnvWriteExplicitlyAllowed === true,
48
+ leafOptimized: true,
49
+ }
50
+ }
51
+
52
+ const FAILURE_SIGNAL = /(?:\bFAIL(?:ED|URE)?\b|\bERROR\b|AssertionError|TypeError|ReferenceError|SQLSTATE|ECONN\w*|EADDRINUSE|expected|actual|exit(?:Code| code)?\s*[:=]?\s*[1-9]|timed?\s*out|timeout|not found|cannot|unable|mismatch|regression|✖|×)/i
53
+ const FILE_SIGNAL = /(?:^|\s)(?:[A-Za-z]:)?[^\s:]+\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|cs|sql|json|yaml|yml)(?::\d+(?::\d+)?)?/i
54
+
55
+ export function failureDelta(output, options = {}) {
56
+ const maxChars = Math.max(1200, Math.min(8000, Number(options.maxChars || 4200)))
57
+ const raw = String(output || "").trim()
58
+ if (!raw) return ""
59
+
60
+ const lines = raw.split(/\r?\n/)
61
+ const selected = []
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i]
64
+ if (!FAILURE_SIGNAL.test(line) && !FILE_SIGNAL.test(line)) continue
65
+ if (i > 0 && lines[i - 1].trim()) selected.push(lines[i - 1])
66
+ selected.push(line)
67
+ if (i + 1 < lines.length && lines[i + 1].trim()) selected.push(lines[i + 1])
68
+ }
69
+
70
+ const compact = [...new Set(selected.map((line) => line.trim()).filter(Boolean))].join("\n")
71
+ if (compact) return compact.slice(0, maxChars)
72
+
73
+ // Fail closed on unknown output shape: keep a bounded tail, which usually
74
+ // contains the final verdict/error without replaying the entire previous turn.
75
+ return raw.slice(Math.max(0, raw.length - maxChars))
76
+ }
@@ -0,0 +1,144 @@
1
+ const TRANSIENT_ERROR =
2
+ /(timeout|timed out|econnreset|econnrefused|socket|connection (?:closed|reset|lost)|transport|network|temporar(?:y|ily)|service unavailable|bad gateway|gateway timeout|\b502\b|\b503\b|\b504\b|rate limit|\b429\b)/i
3
+
4
+ function nowMs(value) {
5
+ const number = Number(value)
6
+ return Number.isFinite(number) ? number : Date.now()
7
+ }
8
+
9
+ export function isTransientMcpFailure(value = "") {
10
+ return TRANSIENT_ERROR.test(String(value || ""))
11
+ }
12
+
13
+ export function mcpReconnectAdvice(input = {}) {
14
+ const policy = input.policy || {}
15
+ const attempt = Math.max(1, Math.trunc(Number(input.attempt || 1)))
16
+ const transient = isTransientMcpFailure(input.error || input.message || "")
17
+ const destructive = policy.confirmationRequired === true || policy.annotations?.destructiveHint === true
18
+ const retryable = policy.retryAllowed === true && transient && !destructive && attempt < Math.max(1, Number(input.maxAttempts || 3))
19
+ return {
20
+ schemaVersion: 1,
21
+ retryable,
22
+ reconnectRecommended: retryable,
23
+ reason: destructive
24
+ ? "destructive-tool-never-auto-reconnect"
25
+ : !transient
26
+ ? "non-transient-failure"
27
+ : policy.retryAllowed !== true
28
+ ? "tool-not-explicitly-idempotent"
29
+ : retryable
30
+ ? "transient-idempotent-failure"
31
+ : "retry-budget-exhausted",
32
+ backoffMs: retryable ? Math.min(5_000, 250 * (2 ** Math.max(0, attempt - 1))) : 0,
33
+ }
34
+ }
35
+
36
+ export class McpHealthTracker {
37
+ constructor(options = {}) {
38
+ this.failureThreshold = Math.max(1, Math.min(10, Number(options.failureThreshold || 2)))
39
+ this.cooldownMs = Math.max(1_000, Math.min(5 * 60_000, Number(options.cooldownMs || 15_000)))
40
+ this.maxEntries = Math.max(16, Math.min(2_000, Number(options.maxEntries || 256)))
41
+ this.state = new Map()
42
+ this.active = new Map()
43
+ }
44
+
45
+ clear() {
46
+ this.state.clear()
47
+ this.active.clear()
48
+ }
49
+
50
+ begin(callId, toolName, policy = {}, at = Date.now()) {
51
+ const id = String(callId || "")
52
+ const name = String(toolName || "")
53
+ if (id && name) this.active.set(id, { name, policy, startedAt: nowMs(at) })
54
+ this.#trim()
55
+ return this.status(name, at)
56
+ }
57
+
58
+ finish(callId, result = {}, at = Date.now()) {
59
+ const id = String(callId || "")
60
+ const active = this.active.get(id)
61
+ if (!active) return null
62
+ this.active.delete(id)
63
+ const timestamp = nowMs(at)
64
+ const previous = this.state.get(active.name) || {
65
+ successes: 0,
66
+ failures: 0,
67
+ consecutiveFailures: 0,
68
+ cooldownUntil: 0,
69
+ lastError: null,
70
+ lastFinishedAt: 0,
71
+ }
72
+ const errorText = [
73
+ result.error,
74
+ result.message,
75
+ result.stderr,
76
+ result.text,
77
+ ].filter(Boolean).join("\n").slice(0, 4000)
78
+ const failed = result.isError === true || Number(result.exitCode) > 0
79
+ const transient = failed && isTransientMcpFailure(errorText)
80
+ const consecutiveFailures = failed ? Number(previous.consecutiveFailures || 0) + 1 : 0
81
+ const cooldownUntil = transient && consecutiveFailures >= this.failureThreshold
82
+ ? timestamp + this.cooldownMs
83
+ : failed
84
+ ? Number(previous.cooldownUntil || 0)
85
+ : 0
86
+ const reconnectAdvice = failed
87
+ ? mcpReconnectAdvice({ policy: active.policy, error: errorText, attempt: consecutiveFailures })
88
+ : null
89
+ const next = {
90
+ successes: Number(previous.successes || 0) + (failed ? 0 : 1),
91
+ failures: Number(previous.failures || 0) + (failed ? 1 : 0),
92
+ consecutiveFailures,
93
+ cooldownUntil,
94
+ lastError: failed ? errorText || "tool-result-error" : null,
95
+ lastTransientFailure: transient,
96
+ reconnectAdvice,
97
+ lastFinishedAt: timestamp,
98
+ policy: active.policy,
99
+ }
100
+ this.state.set(active.name, next)
101
+ this.#trim()
102
+ return this.status(active.name, timestamp)
103
+ }
104
+
105
+ status(toolName, at = Date.now()) {
106
+ const name = String(toolName || "")
107
+ const row = this.state.get(name)
108
+ const timestamp = nowMs(at)
109
+ if (!row) {
110
+ return { schemaVersion: 1, tool: name, status: "unknown", available: true, consecutiveFailures: 0, cooldownUntil: null }
111
+ }
112
+ const coolingDown = Number(row.cooldownUntil || 0) > timestamp
113
+ return {
114
+ schemaVersion: 1,
115
+ tool: name,
116
+ status: coolingDown ? "degraded" : row.consecutiveFailures > 0 ? "recovering" : "healthy",
117
+ available: !coolingDown,
118
+ successes: row.successes,
119
+ failures: row.failures,
120
+ consecutiveFailures: row.consecutiveFailures,
121
+ cooldownUntil: coolingDown ? new Date(row.cooldownUntil).toISOString() : null,
122
+ lastError: row.lastError,
123
+ lastTransientFailure: row.lastTransientFailure === true,
124
+ reconnectAdvice: row.reconnectAdvice || null,
125
+ lastFinishedAt: row.lastFinishedAt ? new Date(row.lastFinishedAt).toISOString() : null,
126
+ }
127
+ }
128
+
129
+ available(toolName, at = Date.now()) {
130
+ return this.status(toolName, at).available !== false
131
+ }
132
+
133
+ snapshot(at = Date.now()) {
134
+ return [...this.state.keys()]
135
+ .sort()
136
+ .map((name) => this.status(name, at))
137
+ }
138
+
139
+ #trim() {
140
+ if (this.state.size <= this.maxEntries) return
141
+ const ordered = [...this.state.entries()].sort((a, b) => Number(a[1]?.lastFinishedAt || 0) - Number(b[1]?.lastFinishedAt || 0))
142
+ for (const [name] of ordered.slice(0, Math.max(0, this.state.size - this.maxEntries))) this.state.delete(name)
143
+ }
144
+ }
@@ -0,0 +1,19 @@
1
+ function firstObject(...values) { return values.find((value) => value && typeof value === "object" && !Array.isArray(value)) || {} }
2
+ function explicitBoolean(value) { return typeof value === "boolean" ? value : null }
3
+ export function normalizeMcpAnnotations(tool = {}) {
4
+ const raw = firstObject(tool.annotations, tool.metadata?.annotations, tool._meta?.annotations, tool._meta?.mcp?.annotations)
5
+ const annotations = { readOnlyHint: explicitBoolean(raw.readOnlyHint), destructiveHint: explicitBoolean(raw.destructiveHint), idempotentHint: explicitBoolean(raw.idempotentHint), openWorldHint: explicitBoolean(raw.openWorldHint) }
6
+ return { schemaVersion: 1, explicit: Object.values(annotations).some((value) => value !== null), ...annotations }
7
+ }
8
+ export function mcpExecutionPolicy(tool = {}) {
9
+ const annotations = normalizeMcpAnnotations(tool)
10
+ const destructive = annotations.destructiveHint === true
11
+ const readOnly = annotations.readOnlyHint === true && !destructive
12
+ const idempotent = annotations.idempotentHint === true && !destructive
13
+ return { schemaVersion: 1, tool: String(tool?.name || ""), annotations, fastAllowed: readOnly, confirmationRequired: destructive, retryAllowed: idempotent, externalEvidenceBoundary: annotations.openWorldHint === true, trust: "hint-only" }
14
+ }
15
+ export function mcpPolicyMap(tools = []) {
16
+ const map = new Map()
17
+ for (const tool of Array.isArray(tools) ? tools : []) { const name = String(tool?.name || "").trim(); if (name) map.set(name, mcpExecutionPolicy(tool)) }
18
+ return map
19
+ }
@@ -303,6 +303,33 @@ function rankMap(rows, key) {
303
303
  )
304
304
  }
305
305
 
306
+ export function buildMemorySnapshot(memories = []) {
307
+ const entries = (Array.isArray(memories) ? memories : [])
308
+ .map((item) => ({
309
+ id: item.id || null,
310
+ type: item.type || null,
311
+ scope: item.scope || null,
312
+ content: normalizedContent(item.content),
313
+ confidence: clamp(item.confidence),
314
+ files: uniq((item.files || []).map(normalizeFile)).sort(),
315
+ taskClass: item.taskClass || null,
316
+ verifiedAt: item.verifiedAt || null,
317
+ evidenceRefs: uniq(item.evidenceRefs || []).sort(),
318
+ }))
319
+ .filter((item) => item.id && item.content)
320
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)))
321
+ const generation = createHash("sha256")
322
+ .update(JSON.stringify(entries))
323
+ .digest("hex")
324
+ .slice(0, 20)
325
+ return {
326
+ schemaVersion: 1,
327
+ generation,
328
+ entryCount: entries.length,
329
+ entries,
330
+ }
331
+ }
332
+
306
333
  export async function retrieveMemories(root, query, options = {}) {
307
334
  const state = await readMemoryState(root)
308
335
  const memories = state.memories.filter((item) => item.status === "verified" && !item.supersededBy && !isExpired(item))
@@ -365,6 +392,7 @@ export async function retrieveMemories(root, query, options = {}) {
365
392
  query: String(query || ""),
366
393
  eligible: memories.length,
367
394
  results,
395
+ snapshot: buildMemorySnapshot(results),
368
396
  }
369
397
  }
370
398
 
@@ -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
  }
@@ -0,0 +1,124 @@
1
+ import {
2
+ normalizePlanForValidation,
3
+ validatePlan,
4
+ } from "./task-graph.mjs"
5
+
6
+ function balancedObject(source, start) {
7
+ let depth = 0
8
+ let inString = false
9
+ let escaped = false
10
+ for (let index = start; index < source.length; index++) {
11
+ const char = source[index]
12
+ if (inString) {
13
+ if (escaped) escaped = false
14
+ else if (char === "\\") escaped = true
15
+ else if (char === "\"") inString = false
16
+ continue
17
+ }
18
+ if (char === "\"") {
19
+ inString = true
20
+ continue
21
+ }
22
+ if (char === "{") depth += 1
23
+ else if (char === "}") {
24
+ depth -= 1
25
+ if (depth === 0) {
26
+ const raw = source.slice(start, index + 1)
27
+ try {
28
+ return { value: JSON.parse(raw), raw, end: index + 1 }
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+ }
34
+ }
35
+ return null
36
+ }
37
+
38
+ function markedCandidate(source, marker) {
39
+ const markerIndex = source.lastIndexOf(marker)
40
+ if (markerIndex < 0) return null
41
+ const start = source.indexOf("{", markerIndex + marker.length)
42
+ if (start < 0) return null
43
+ const parsed = balancedObject(source, start)
44
+ return parsed ? { ...parsed, source: "marked" } : null
45
+ }
46
+
47
+ function fencedCandidates(source) {
48
+ const results = []
49
+ const fence = String.fromCharCode(96, 96, 96)
50
+ let offset = 0
51
+ while (offset < source.length) {
52
+ const open = source.indexOf(fence, offset)
53
+ if (open < 0) break
54
+ const bodyStart = source.indexOf("\n", open + fence.length)
55
+ if (bodyStart < 0) break
56
+ const close = source.indexOf(fence, bodyStart + 1)
57
+ if (close < 0) break
58
+ const body = source.slice(bodyStart + 1, close).trim()
59
+ if (body.startsWith("{")) {
60
+ try { results.push({ value: JSON.parse(body), raw: body, source: "fenced" }) } catch {}
61
+ }
62
+ offset = close + fence.length
63
+ }
64
+ return results
65
+ }
66
+
67
+ function balancedCandidates(source, limit = 24) {
68
+ const starts = []
69
+ for (let i = source.length - 1; i >= 0 && starts.length < limit; i--) {
70
+ if (source[i] === "{") starts.push(i)
71
+ }
72
+ const results = []
73
+ for (const start of starts) {
74
+ const parsed = balancedObject(source, start)
75
+ if (parsed) results.push({ ...parsed, source: "unmarked-balanced" })
76
+ }
77
+ return results
78
+ }
79
+
80
+ function planLike(value) {
81
+ return Boolean(
82
+ value &&
83
+ typeof value === "object" &&
84
+ Array.isArray(value.tasks) &&
85
+ (typeof value.goal === "string" || value.schemaVersion === 1)
86
+ )
87
+ }
88
+
89
+ export function extractValidatedPlan(output, options = {}) {
90
+ const source = String(output || "")
91
+ const marker = String(options.marker || "UES_PLAN_JSON:")
92
+ const candidates = []
93
+ const marked = markedCandidate(source, marker)
94
+ if (marked) candidates.push(marked)
95
+ candidates.push(...fencedCandidates(source))
96
+ candidates.push(...balancedCandidates(source))
97
+
98
+ const seen = new Set()
99
+ let bestInvalid = null
100
+ for (const candidate of candidates) {
101
+ if (!planLike(candidate.value)) continue
102
+ let normalized
103
+ try { normalized = normalizePlanForValidation(candidate.value) } catch { continue }
104
+ const key = JSON.stringify(normalized)
105
+ if (seen.has(key)) continue
106
+ seen.add(key)
107
+ const validation = validatePlan(normalized)
108
+ const result = {
109
+ plan: normalized,
110
+ validation,
111
+ source: candidate.source,
112
+ salvaged: candidate.source !== "marked",
113
+ }
114
+ if (validation && validation.valid === true) return result
115
+ if (!bestInvalid) bestInvalid = result
116
+ }
117
+
118
+ return bestInvalid || {
119
+ plan: null,
120
+ validation: null,
121
+ source: null,
122
+ salvaged: false,
123
+ }
124
+ }