opencode-agent-skill 15.1.0 → 15.1.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.
- package/README.md +3 -3
- package/docs/V15-MANAGED-RUNTIME.md +18 -0
- package/global-config/agents/verifier.md +6 -2
- package/lib/affected-tests.mjs +21 -4
- package/lib/capability-fabric.mjs +107 -48
- package/lib/code-intelligence/index.mjs +3 -15
- package/lib/code-intelligence/lsp-pool.mjs +694 -0
- package/lib/code-intelligence/lsp-provider.mjs +160 -20
- package/lib/compaction-resume-guard.mjs +76 -4
- package/lib/completion-auditor.mjs +20 -0
- package/lib/context-manifest.mjs +38 -21
- package/lib/decision-policy.mjs +40 -2
- package/lib/evidence-store.mjs +121 -31
- package/lib/executable-probe.mjs +69 -0
- package/lib/hierarchical-context.mjs +31 -3
- package/lib/learning-engine.mjs +164 -65
- package/lib/memory-engine.mjs +163 -86
- package/lib/model-config.mjs +86 -7
- package/lib/performance-fabric.mjs +92 -0
- package/lib/permission-policy.mjs +232 -0
- package/lib/pi-rpc-pool.mjs +170 -5
- package/lib/provider-recovery.mjs +51 -0
- package/lib/repo-graph.mjs +38 -5
- package/lib/runtime-events.mjs +31 -10
- package/lib/semantic-index.mjs +67 -20
- package/lib/skill-compiler.mjs +84 -18
- package/lib/task-policy.mjs +80 -7
- package/lib/trajectory.mjs +25 -1
- package/lib/untrusted-output.mjs +106 -0
- package/package.json +8 -6
- package/pi/extensions/ues-child-runtime.ts +78 -3
- package/pi/extensions/ues.ts +695 -81
- package/scripts/benchmark-lsp-pool.mjs +128 -0
- package/scripts/check-release-consistency.mjs +16 -4
- package/scripts/check-source-integrity.mjs +60 -1
- package/scripts/run-test-suite.mjs +143 -0
- package/scripts/smoke-package-closure.mjs +4 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises"
|
|
2
|
+
|
|
3
|
+
const EFFECTS = new Set(["allow", "deny", "ask"])
|
|
4
|
+
|
|
5
|
+
function normalizeSlashes(value) {
|
|
6
|
+
return String(value || "").replaceAll("\\", "/")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function escapeRegex(value) {
|
|
10
|
+
return value.replace(/[|\\{}()[\]^$+.\-]/g, "\\$&")
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function wildcardRegex(pattern, { caseInsensitive = false, shell = false } = {}) {
|
|
14
|
+
let value = normalizeSlashes(pattern)
|
|
15
|
+
const shellBare = shell && value.endsWith(" *")
|
|
16
|
+
? value.slice(0, -2)
|
|
17
|
+
: null
|
|
18
|
+
const compile = (input) => {
|
|
19
|
+
let source = ""
|
|
20
|
+
for (const char of input) {
|
|
21
|
+
if (char === "*") source += ".*"
|
|
22
|
+
else if (char === "?") source += "."
|
|
23
|
+
else source += escapeRegex(char)
|
|
24
|
+
}
|
|
25
|
+
return source
|
|
26
|
+
}
|
|
27
|
+
const alternatives = shellBare === null
|
|
28
|
+
? [compile(value)]
|
|
29
|
+
: [compile(value), compile(shellBare)]
|
|
30
|
+
return new RegExp(`^(?:${alternatives.join("|")})$`, caseInsensitive ? "i" : "")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function permissionPatternMatches(pattern, value, options = {}) {
|
|
34
|
+
return wildcardRegex(String(pattern || ""), options).test(normalizeSlashes(value))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizePermissionRules(value) {
|
|
38
|
+
const rules = Array.isArray(value) ? value : []
|
|
39
|
+
return rules.map((rule, index) => {
|
|
40
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
41
|
+
throw new Error(`permission rule ${index} must be an object`)
|
|
42
|
+
}
|
|
43
|
+
const action = String(rule.action || "").trim()
|
|
44
|
+
const resource = String(rule.resource || "").trim()
|
|
45
|
+
const effect = String(rule.effect || "").trim().toLowerCase()
|
|
46
|
+
if (!action || !resource || !EFFECTS.has(effect)) {
|
|
47
|
+
throw new Error(`permission rule ${index} requires action, resource and effect=allow|deny|ask`)
|
|
48
|
+
}
|
|
49
|
+
return { action, resource, effect }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resourceValue(action, value) {
|
|
54
|
+
if (["read", "edit", "external_directory"].includes(String(action || ""))) {
|
|
55
|
+
return normalizeSlashes(value)
|
|
56
|
+
}
|
|
57
|
+
return String(value || "")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function matchRule(rule, request, platform) {
|
|
61
|
+
const caseInsensitive = platform === "win32"
|
|
62
|
+
const actionMatches = permissionPatternMatches(rule.action, request.action, { caseInsensitive })
|
|
63
|
+
if (!actionMatches) return false
|
|
64
|
+
return permissionPatternMatches(
|
|
65
|
+
rule.resource,
|
|
66
|
+
resourceValue(request.action, request.resource),
|
|
67
|
+
{ caseInsensitive, shell: request.action === "shell" },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function evaluatePermissionRules(rules, request = {}, options = {}) {
|
|
72
|
+
const normalized = normalizePermissionRules(rules)
|
|
73
|
+
const action = String(request.action || "").trim()
|
|
74
|
+
const resources = (Array.isArray(request.resources) ? request.resources : [request.resource])
|
|
75
|
+
.filter((value) => value !== undefined && value !== null)
|
|
76
|
+
.map((value) => String(value))
|
|
77
|
+
const platform = String(options.platform || process.platform)
|
|
78
|
+
const defaultEffect = EFFECTS.has(options.defaultEffect) ? options.defaultEffect : "allow"
|
|
79
|
+
if (!action) throw new Error("permission request requires action")
|
|
80
|
+
if (!resources.length) resources.push("*")
|
|
81
|
+
|
|
82
|
+
const decisions = resources.map((resource) => {
|
|
83
|
+
let matchedRule = null
|
|
84
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
85
|
+
const rule = normalized[index]
|
|
86
|
+
if (matchRule(rule, { action, resource }, platform)) {
|
|
87
|
+
matchedRule = { ...rule, index }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
resource,
|
|
92
|
+
effect: matchedRule?.effect || defaultEffect,
|
|
93
|
+
matchedRule,
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const effect = decisions.some((item) => item.effect === "deny")
|
|
98
|
+
? "deny"
|
|
99
|
+
: decisions.some((item) => item.effect === "ask")
|
|
100
|
+
? "ask"
|
|
101
|
+
: "allow"
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
action,
|
|
106
|
+
effect,
|
|
107
|
+
defaultEffect,
|
|
108
|
+
decisions,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function toolPermissionRequest(toolName, input = {}) {
|
|
113
|
+
const name = String(toolName || "").trim()
|
|
114
|
+
const lower = name.toLowerCase()
|
|
115
|
+
if (lower === "bash" || lower === "powershell") {
|
|
116
|
+
return { action: "shell", resources: [String(input.command || "")] }
|
|
117
|
+
}
|
|
118
|
+
if (lower === "read") {
|
|
119
|
+
return { action: "read", resources: [String(input.path || input.file || input.filePath || "*")] }
|
|
120
|
+
}
|
|
121
|
+
if (["edit", "write", "write_file", "apply_patch", "ues_code_edit"].includes(lower)) {
|
|
122
|
+
return { action: "edit", resources: [String(input.path || input.file || input.filePath || input.target || "*")] }
|
|
123
|
+
}
|
|
124
|
+
if (lower === "grep") {
|
|
125
|
+
return { action: "grep", resources: [String(input.pattern || input.query || "*")] }
|
|
126
|
+
}
|
|
127
|
+
if (lower === "find" || lower === "glob") {
|
|
128
|
+
return { action: "glob", resources: [String(input.pattern || input.glob || input.query || "*")] }
|
|
129
|
+
}
|
|
130
|
+
if (lower === "ues_dispatch") {
|
|
131
|
+
return { action: "subagent", resources: [String(input.agent || input.target || "*")] }
|
|
132
|
+
}
|
|
133
|
+
if (lower === "ues_session") {
|
|
134
|
+
return { action: "session", resources: [String(input.action || "*")] }
|
|
135
|
+
}
|
|
136
|
+
const action = lower.replace(/[^a-z0-9_]+/g, "_") || "tool"
|
|
137
|
+
return { action, resources: ["*"] }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function permissionRecoveryHint(request = {}, decision = null, options = {}) {
|
|
141
|
+
const action = String(request.action || decision?.action || "tool").trim() || "tool"
|
|
142
|
+
const resources = (
|
|
143
|
+
Array.isArray(request.resources)
|
|
144
|
+
? request.resources
|
|
145
|
+
: request.resource !== undefined
|
|
146
|
+
? [request.resource]
|
|
147
|
+
: []
|
|
148
|
+
).map((value) => String(value)).filter(Boolean)
|
|
149
|
+
const denied = Array.isArray(decision?.decisions)
|
|
150
|
+
? decision.decisions.filter((item) => item?.effect === "deny" || item?.effect === "ask")
|
|
151
|
+
: []
|
|
152
|
+
const resource = String(denied[0]?.resource || resources[0] || "*")
|
|
153
|
+
const effect = String(options.effect || decision?.effect || "deny")
|
|
154
|
+
const prefix = effect === "ask"
|
|
155
|
+
? "UES approval was not granted for this action."
|
|
156
|
+
: "UES policy denied this action."
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
prefix,
|
|
160
|
+
`Do not repeat the same blocked ${action} request for ${resource}.`,
|
|
161
|
+
"Continue by choosing the narrowest allowed, reversible alternative that still advances the task.",
|
|
162
|
+
"Prefer read-only inspection or a less-privileged resource when possible.",
|
|
163
|
+
"If no allowed path can satisfy the task, report the blocker with evidence instead of bypassing the guard.",
|
|
164
|
+
].join(" ")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizedConfig(parsed) {
|
|
168
|
+
if (Array.isArray(parsed)) {
|
|
169
|
+
return { permissions: normalizePermissionRules(parsed), agents: {}, defaultEffect: "allow" }
|
|
170
|
+
}
|
|
171
|
+
if (!parsed || typeof parsed !== "object") {
|
|
172
|
+
throw new Error("permission config must be an array or object")
|
|
173
|
+
}
|
|
174
|
+
const permissions = normalizePermissionRules(parsed.permissions || [])
|
|
175
|
+
const agents = {}
|
|
176
|
+
for (const [agent, value] of Object.entries(parsed.agents || {})) {
|
|
177
|
+
const rules = Array.isArray(value) ? value : value?.permissions
|
|
178
|
+
agents[String(agent)] = normalizePermissionRules(rules || [])
|
|
179
|
+
}
|
|
180
|
+
const defaultEffect = EFFECTS.has(parsed.defaultEffect) ? parsed.defaultEffect : "allow"
|
|
181
|
+
return { permissions, agents, defaultEffect }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class PermissionPolicyStore {
|
|
185
|
+
constructor(file) {
|
|
186
|
+
this.file = String(file || "")
|
|
187
|
+
this.cacheKey = null
|
|
188
|
+
this.cached = null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async load() {
|
|
192
|
+
if (!this.file) return { configured: false, config: null }
|
|
193
|
+
const info = await stat(this.file).catch((error) => {
|
|
194
|
+
if (error?.code === "ENOENT") return null
|
|
195
|
+
throw error
|
|
196
|
+
})
|
|
197
|
+
if (!info?.isFile()) {
|
|
198
|
+
this.cacheKey = null
|
|
199
|
+
this.cached = null
|
|
200
|
+
return { configured: false, config: null }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const cacheKey = `${info.mtimeMs}:${info.size}`
|
|
204
|
+
if (this.cached && this.cacheKey === cacheKey) {
|
|
205
|
+
return { configured: true, config: this.cached, cached: true }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parsed = JSON.parse(await readFile(this.file, "utf8"))
|
|
209
|
+
const config = normalizedConfig(parsed)
|
|
210
|
+
this.cacheKey = cacheKey
|
|
211
|
+
this.cached = config
|
|
212
|
+
return { configured: true, config, cached: false }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async evaluate(request, options = {}) {
|
|
216
|
+
const loaded = await this.load()
|
|
217
|
+
if (!loaded.configured) return { configured: false, decision: null }
|
|
218
|
+
const agent = String(options.agent || "")
|
|
219
|
+
const agentRules = agent ? (loaded.config.agents[agent] || []) : []
|
|
220
|
+
const rules = [...loaded.config.permissions, ...agentRules]
|
|
221
|
+
const decision = evaluatePermissionRules(rules, request, {
|
|
222
|
+
platform: options.platform,
|
|
223
|
+
defaultEffect: loaded.config.defaultEffect,
|
|
224
|
+
})
|
|
225
|
+
return {
|
|
226
|
+
configured: true,
|
|
227
|
+
decision,
|
|
228
|
+
agent: agent || null,
|
|
229
|
+
cached: loaded.cached === true,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
package/lib/pi-rpc-pool.mjs
CHANGED
|
@@ -180,6 +180,95 @@ class RpcWorker {
|
|
|
180
180
|
return { accepted: true }
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
async followUp(message) {
|
|
184
|
+
if (!this.proc || this.dead || !this.active) {
|
|
185
|
+
return { accepted: false, reason: "worker-not-active" }
|
|
186
|
+
}
|
|
187
|
+
await this.send(
|
|
188
|
+
{ type: "follow_up", message: String(message || "") },
|
|
189
|
+
{ timeoutMs: RPC_CONTROL_TIMEOUT_MS },
|
|
190
|
+
)
|
|
191
|
+
return { accepted: true }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async getState() {
|
|
195
|
+
if (!this.proc || this.dead) return null
|
|
196
|
+
const response = await this.send(
|
|
197
|
+
{ type: "get_state" },
|
|
198
|
+
{ timeoutMs: RPC_CONTROL_TIMEOUT_MS },
|
|
199
|
+
)
|
|
200
|
+
return response?.data || null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async clearQueue() {
|
|
204
|
+
if (!this.proc || this.dead) return { steering: [], followUp: [] }
|
|
205
|
+
const response = await this.send(
|
|
206
|
+
{ type: "clear_queue" },
|
|
207
|
+
{ timeoutMs: RPC_CONTROL_TIMEOUT_MS },
|
|
208
|
+
)
|
|
209
|
+
return response?.data || { steering: [], followUp: [] }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async setModel(provider, modelId) {
|
|
213
|
+
if (!this.proc || this.dead) throw new Error("Pi RPC worker is not available")
|
|
214
|
+
const response = await this.send(
|
|
215
|
+
{ type: "set_model", provider: String(provider || ""), modelId: String(modelId || "") },
|
|
216
|
+
{ timeoutMs: RPC_CONTROL_TIMEOUT_MS },
|
|
217
|
+
)
|
|
218
|
+
return response?.data || null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async setThinkingLevel(level) {
|
|
222
|
+
if (!this.proc || this.dead) throw new Error("Pi RPC worker is not available")
|
|
223
|
+
await this.send(
|
|
224
|
+
{ type: "set_thinking_level", level: String(level || "") },
|
|
225
|
+
{ timeoutMs: RPC_CONTROL_TIMEOUT_MS },
|
|
226
|
+
)
|
|
227
|
+
return { level: String(level || "") }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async compact(customInstructions = "") {
|
|
231
|
+
if (!this.proc || this.dead) throw new Error("Pi RPC worker is not available")
|
|
232
|
+
const response = await this.send(
|
|
233
|
+
{
|
|
234
|
+
type: "compact",
|
|
235
|
+
...(String(customInstructions || "").trim()
|
|
236
|
+
? { customInstructions: String(customInstructions).trim() }
|
|
237
|
+
: {}),
|
|
238
|
+
},
|
|
239
|
+
{ timeoutMs: Math.max(RPC_CONTROL_TIMEOUT_MS, 5 * 60_000) },
|
|
240
|
+
)
|
|
241
|
+
return response?.data || null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async waitForIdle(timeoutMs = 30_000) {
|
|
245
|
+
if (!this.proc || this.dead) {
|
|
246
|
+
return { idle: true, state: null, reason: "worker-unavailable" }
|
|
247
|
+
}
|
|
248
|
+
const timeout = clamp(timeoutMs, 30_000, 500, 30 * 60_000)
|
|
249
|
+
const deadline = Date.now() + timeout
|
|
250
|
+
let state = await this.getState()
|
|
251
|
+
let pollMs = 100
|
|
252
|
+
|
|
253
|
+
while (
|
|
254
|
+
state &&
|
|
255
|
+
(
|
|
256
|
+
state.isStreaming === true ||
|
|
257
|
+
state.isCompacting === true ||
|
|
258
|
+
Number(state.pendingMessageCount || 0) > 0
|
|
259
|
+
)
|
|
260
|
+
) {
|
|
261
|
+
if (Date.now() >= deadline) {
|
|
262
|
+
return { idle: false, state, reason: "timeout" }
|
|
263
|
+
}
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
265
|
+
pollMs = Math.min(500, Math.max(100, Math.trunc(pollMs * 1.6)))
|
|
266
|
+
state = await this.getState()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { idle: true, state, reason: null }
|
|
270
|
+
}
|
|
271
|
+
|
|
183
272
|
async abortTransport() {
|
|
184
273
|
if (!this.proc || this.dead) return false
|
|
185
274
|
try {
|
|
@@ -216,7 +305,7 @@ class RpcWorker {
|
|
|
216
305
|
const execute = async () => {
|
|
217
306
|
try {
|
|
218
307
|
await this.start()
|
|
219
|
-
if (this.runs > 0) {
|
|
308
|
+
if (this.runs > 0 && options.reuseSession !== true) {
|
|
220
309
|
await this.send({ type: "new_session" }, { timeoutMs: 30_000 })
|
|
221
310
|
}
|
|
222
311
|
} catch (error) {
|
|
@@ -407,6 +496,11 @@ export class PiRpcWorkerPool {
|
|
|
407
496
|
async run(key, spec, message, options = {}) {
|
|
408
497
|
let worker = this.workers.get(key)
|
|
409
498
|
let workerReused = Boolean(worker && !worker.dead && worker.runs > 0)
|
|
499
|
+
if (options.reuseSession === true && !workerReused) {
|
|
500
|
+
const error = new Error("Pi RPC same-session resume unavailable")
|
|
501
|
+
error.code = "UES_RPC_SESSION_UNAVAILABLE"
|
|
502
|
+
throw error
|
|
503
|
+
}
|
|
410
504
|
if (!worker || worker.dead) {
|
|
411
505
|
if (worker) {
|
|
412
506
|
await worker.stop().catch(() => {})
|
|
@@ -443,19 +537,90 @@ export class PiRpcWorkerPool {
|
|
|
443
537
|
}))
|
|
444
538
|
}
|
|
445
539
|
|
|
446
|
-
|
|
540
|
+
selectSingleActive() {
|
|
447
541
|
const active = [...this.workers.entries()]
|
|
448
542
|
.filter(([, worker]) => worker && !worker.dead && worker.active)
|
|
449
543
|
if (active.length !== 1) {
|
|
450
544
|
return {
|
|
451
|
-
|
|
545
|
+
ok: false,
|
|
452
546
|
reason: active.length === 0 ? "no-active-worker" : "multiple-active-workers",
|
|
453
547
|
active: active.length,
|
|
454
548
|
}
|
|
455
549
|
}
|
|
456
550
|
const [key, worker] = active[0]
|
|
457
|
-
|
|
458
|
-
|
|
551
|
+
return { ok: true, key, worker, active: 1 }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async controlActive(action, input = {}) {
|
|
555
|
+
const name = String(action || "").trim().toLowerCase()
|
|
556
|
+
if (name === "status") return { ok: true, action: name, ...this.status() }
|
|
557
|
+
|
|
558
|
+
const selected = this.selectSingleActive()
|
|
559
|
+
if (!selected.ok) return { ...selected, action: name }
|
|
560
|
+
const { key, worker } = selected
|
|
561
|
+
let data
|
|
562
|
+
|
|
563
|
+
if (name === "get-state") {
|
|
564
|
+
data = await worker.getState()
|
|
565
|
+
} else if (name === "steer") {
|
|
566
|
+
data = await worker.steer(input.message)
|
|
567
|
+
} else if (name === "follow-up") {
|
|
568
|
+
data = await worker.followUp(input.message)
|
|
569
|
+
} else if (name === "abort") {
|
|
570
|
+
data = { aborted: await worker.abort() }
|
|
571
|
+
} else if (name === "clear-queue") {
|
|
572
|
+
data = await worker.clearQueue()
|
|
573
|
+
} else if (name === "set-model") {
|
|
574
|
+
if (!String(input.provider || "").trim() || !String(input.modelId || "").trim()) {
|
|
575
|
+
return { ok: false, action: name, reason: "provider-and-model-required", active: 1, key }
|
|
576
|
+
}
|
|
577
|
+
data = await worker.setModel(input.provider, input.modelId)
|
|
578
|
+
} else if (name === "set-thinking") {
|
|
579
|
+
if (!String(input.level || "").trim()) {
|
|
580
|
+
return { ok: false, action: name, reason: "thinking-level-required", active: 1, key }
|
|
581
|
+
}
|
|
582
|
+
data = await worker.setThinkingLevel(input.level)
|
|
583
|
+
} else if (name === "compact") {
|
|
584
|
+
data = await worker.compact(input.customInstructions)
|
|
585
|
+
} else if (name === "wait") {
|
|
586
|
+
data = await worker.waitForIdle(input.timeoutMs)
|
|
587
|
+
} else {
|
|
588
|
+
return { ok: false, action: name, reason: "unknown-control-action", active: 1, key }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return { ok: true, action: name, key, active: 1, data }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async steerActive(message) {
|
|
595
|
+
const result = await this.controlActive("steer", { message })
|
|
596
|
+
if (!result.ok) {
|
|
597
|
+
return {
|
|
598
|
+
accepted: false,
|
|
599
|
+
reason: result.reason,
|
|
600
|
+
active: result.active || 0,
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
accepted: result.data?.accepted !== false,
|
|
605
|
+
key: result.key,
|
|
606
|
+
active: 1,
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async followUpActive(message) {
|
|
611
|
+
const result = await this.controlActive("follow-up", { message })
|
|
612
|
+
if (!result.ok) {
|
|
613
|
+
return {
|
|
614
|
+
accepted: false,
|
|
615
|
+
reason: result.reason,
|
|
616
|
+
active: result.active || 0,
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
accepted: result.data?.accepted !== false,
|
|
621
|
+
key: result.key,
|
|
622
|
+
active: 1,
|
|
623
|
+
}
|
|
459
624
|
}
|
|
460
625
|
|
|
461
626
|
async abortActive() {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const EMPTY_PROVIDER_SIGNAL = /(?:provider\s+returned\s+an?\s+empty\s+response|empty\s+provider\s+response|empty\s+response\s*\(no\s+content\s+emitted\)|no\s+content\s+emitted)/i
|
|
2
|
+
|
|
3
|
+
function finiteInt(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 classifyProviderFailure(result = {}) {
|
|
10
|
+
const output = String(result.output || "").trim()
|
|
11
|
+
const diagnostics = [result.errorMessage, result.stderr]
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map(String)
|
|
14
|
+
.join("\n")
|
|
15
|
+
const toolCalls = Math.max(0, Number(result.toolCalls || 0))
|
|
16
|
+
const explicitEmpty = EMPTY_PROVIDER_SIGNAL.test(diagnostics)
|
|
17
|
+
const missingAssistant =
|
|
18
|
+
result.noAssistantMessage === true ||
|
|
19
|
+
/^\(no assistant output\)$/i.test(output) ||
|
|
20
|
+
/^\(no output\)$/i.test(output)
|
|
21
|
+
const transient =
|
|
22
|
+
explicitEmpty ||
|
|
23
|
+
missingAssistant ||
|
|
24
|
+
result.providerFailure === "empty-provider-response"
|
|
25
|
+
const reason = transient
|
|
26
|
+
? explicitEmpty || result.providerFailure === "empty-provider-response"
|
|
27
|
+
? "empty-provider-response"
|
|
28
|
+
: "missing-assistant-output"
|
|
29
|
+
: null
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
transient,
|
|
33
|
+
reason,
|
|
34
|
+
safeReplay: transient && toolCalls === 0,
|
|
35
|
+
safeSessionResume: transient && toolCalls > 0,
|
|
36
|
+
toolCalls,
|
|
37
|
+
stopReason: String(result.stopReason || ""),
|
|
38
|
+
message: transient
|
|
39
|
+
? toolCalls === 0
|
|
40
|
+
? "Provider produced no usable assistant content before any tool side effect; a bounded fresh-session retry is safe."
|
|
41
|
+
: "Provider produced no usable assistant content after tool execution; blind replay is blocked, but the same live RPC session may continue without repeating completed side effects."
|
|
42
|
+
: null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function providerRecoveryBackoffMs(retryNumber, options = {}) {
|
|
47
|
+
const attempt = finiteInt(retryNumber, 1, 1, 8)
|
|
48
|
+
const baseMs = finiteInt(options.baseMs, 250, 0, 10_000)
|
|
49
|
+
const maxMs = finiteInt(options.maxMs, 2_000, baseMs, 30_000)
|
|
50
|
+
return Math.min(maxMs, baseMs * (2 ** (attempt - 1)))
|
|
51
|
+
}
|
package/lib/repo-graph.mjs
CHANGED
|
@@ -31,6 +31,22 @@ async function walk(root, maxFiles = 2500, maxDepth = 8) {
|
|
|
31
31
|
return files
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
async function mapLimit(items, limit, worker) {
|
|
35
|
+
if (!items.length) return []
|
|
36
|
+
const output = new Array(items.length)
|
|
37
|
+
let cursor = 0
|
|
38
|
+
const width = Math.min(items.length, Math.max(1, Number(limit || 1)))
|
|
39
|
+
const runners = Array.from({ length: width }, async () => {
|
|
40
|
+
while (true) {
|
|
41
|
+
const index = cursor++
|
|
42
|
+
if (index >= items.length) return
|
|
43
|
+
output[index] = await worker(items[index], index)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
await Promise.all(runners)
|
|
47
|
+
return output
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
function rel(root, file) {
|
|
35
51
|
return path.relative(root, file).replaceAll("\\", "/")
|
|
36
52
|
}
|
|
@@ -84,29 +100,46 @@ export async function buildRepoGraph(root = process.cwd(), options = {}) {
|
|
|
84
100
|
const nodes = []
|
|
85
101
|
const edges = []
|
|
86
102
|
const external = new Map()
|
|
103
|
+
const ioConcurrency = Math.max(1, Math.min(32, Number(options.ioConcurrency ?? 12)))
|
|
87
104
|
|
|
88
|
-
|
|
105
|
+
const scanned = await mapLimit(files, ioConcurrency, async (file) => {
|
|
89
106
|
const info = await stat(file).catch(() => null)
|
|
90
|
-
if (!info || info.size > 768 * 1024)
|
|
107
|
+
if (!info || info.size > 768 * 1024) return null
|
|
91
108
|
const source = await readFile(file, "utf8").catch(() => "")
|
|
92
109
|
const relative = rel(root, file)
|
|
93
110
|
const ext = path.extname(file).toLowerCase()
|
|
94
111
|
const imports = importsFor(source, ext)
|
|
95
112
|
const local = []
|
|
113
|
+
const fileEdges = []
|
|
114
|
+
const externalKeys = []
|
|
96
115
|
|
|
97
116
|
for (const specifier of imports) {
|
|
98
117
|
const resolved = resolveRelativeImport(root, file, specifier, fileSet)
|
|
99
118
|
if (resolved) {
|
|
100
119
|
local.push(resolved)
|
|
101
|
-
|
|
120
|
+
fileEdges.push({ from: relative, to: resolved, kind: "local-import" })
|
|
102
121
|
} else {
|
|
103
122
|
const parts = specifier.split("/")
|
|
104
123
|
const key = parts[0].startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
|
|
105
|
-
|
|
124
|
+
if (key) externalKeys.push(key)
|
|
106
125
|
}
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
|
|
128
|
+
return {
|
|
129
|
+
node: { path: relative, imports: imports.length, localImports: [...new Set(local)].sort() },
|
|
130
|
+
edges: fileEdges,
|
|
131
|
+
externalKeys,
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Preserve discovery-order aggregation so concurrency changes latency, not graph output.
|
|
136
|
+
for (const result of scanned) {
|
|
137
|
+
if (!result) continue
|
|
138
|
+
nodes.push(result.node)
|
|
139
|
+
edges.push(...result.edges)
|
|
140
|
+
for (const key of result.externalKeys) {
|
|
141
|
+
external.set(key, (external.get(key) || 0) + 1)
|
|
142
|
+
}
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
const incoming = new Map()
|
package/lib/runtime-events.mjs
CHANGED
|
@@ -4,6 +4,25 @@ import { appendFile, readFile, stat, writeFile } from "node:fs/promises"
|
|
|
4
4
|
const DEFAULT_MAX_EVENT_FILE_BYTES = 4 * 1024 * 1024
|
|
5
5
|
const DEFAULT_RETAIN_EVENT_BYTES = 2 * 1024 * 1024
|
|
6
6
|
|
|
7
|
+
const EVENT_WRITE_TAILS = new Map()
|
|
8
|
+
|
|
9
|
+
async function withEventWriteLock(file, fn) {
|
|
10
|
+
const key = String(file)
|
|
11
|
+
const previous = EVENT_WRITE_TAILS.get(key) || Promise.resolve()
|
|
12
|
+
let release
|
|
13
|
+
const barrier = new Promise((resolve) => { release = resolve })
|
|
14
|
+
const tail = previous.catch(() => {}).then(() => barrier)
|
|
15
|
+
EVENT_WRITE_TAILS.set(key, tail)
|
|
16
|
+
|
|
17
|
+
await previous.catch(() => {})
|
|
18
|
+
try {
|
|
19
|
+
return await fn()
|
|
20
|
+
} finally {
|
|
21
|
+
release()
|
|
22
|
+
if (EVENT_WRITE_TAILS.get(key) === tail) EVENT_WRITE_TAILS.delete(key)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
async function compactRuntimeEventFile(file, options = {}) {
|
|
8
27
|
const maxBytes = Math.max(
|
|
9
28
|
256 * 1024,
|
|
@@ -33,16 +52,18 @@ async function compactRuntimeEventFile(file, options = {}) {
|
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
export async function appendRuntimeEvent(file, type, data = {}) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
return withEventWriteLock(file, async () => {
|
|
56
|
+
await compactRuntimeEventFile(file).catch(() => false)
|
|
57
|
+
const event = {
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
id: randomUUID(),
|
|
60
|
+
type: String(type || "unknown"),
|
|
61
|
+
at: new Date().toISOString(),
|
|
62
|
+
...data,
|
|
63
|
+
}
|
|
64
|
+
await appendFile(file, JSON.stringify(event) + "\n", "utf8")
|
|
65
|
+
return event
|
|
66
|
+
})
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
export async function readRuntimeEvents(file, options = {}) {
|