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.
Files changed (37) hide show
  1. package/README.md +3 -3
  2. package/docs/V15-MANAGED-RUNTIME.md +18 -0
  3. package/global-config/agents/verifier.md +6 -2
  4. package/lib/affected-tests.mjs +21 -4
  5. package/lib/capability-fabric.mjs +107 -48
  6. package/lib/code-intelligence/index.mjs +3 -15
  7. package/lib/code-intelligence/lsp-pool.mjs +694 -0
  8. package/lib/code-intelligence/lsp-provider.mjs +160 -20
  9. package/lib/compaction-resume-guard.mjs +76 -4
  10. package/lib/completion-auditor.mjs +20 -0
  11. package/lib/context-manifest.mjs +38 -21
  12. package/lib/decision-policy.mjs +40 -2
  13. package/lib/evidence-store.mjs +121 -31
  14. package/lib/executable-probe.mjs +69 -0
  15. package/lib/hierarchical-context.mjs +31 -3
  16. package/lib/learning-engine.mjs +164 -65
  17. package/lib/memory-engine.mjs +163 -86
  18. package/lib/model-config.mjs +86 -7
  19. package/lib/performance-fabric.mjs +92 -0
  20. package/lib/permission-policy.mjs +232 -0
  21. package/lib/pi-rpc-pool.mjs +170 -5
  22. package/lib/provider-recovery.mjs +51 -0
  23. package/lib/repo-graph.mjs +38 -5
  24. package/lib/runtime-events.mjs +31 -10
  25. package/lib/semantic-index.mjs +67 -20
  26. package/lib/skill-compiler.mjs +84 -18
  27. package/lib/task-policy.mjs +80 -7
  28. package/lib/trajectory.mjs +25 -1
  29. package/lib/untrusted-output.mjs +106 -0
  30. package/package.json +8 -6
  31. package/pi/extensions/ues-child-runtime.ts +78 -3
  32. package/pi/extensions/ues.ts +695 -81
  33. package/scripts/benchmark-lsp-pool.mjs +128 -0
  34. package/scripts/check-release-consistency.mjs +16 -4
  35. package/scripts/check-source-integrity.mjs +60 -1
  36. package/scripts/run-test-suite.mjs +143 -0
  37. 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
+ }
@@ -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
- async steerActive(message) {
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
- accepted: false,
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
- const result = await worker.steer(message)
458
- return { ...result, key, active: 1 }
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
+ }
@@ -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
- for (const file of files) {
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) continue
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
- edges.push({ from: relative, to: resolved, kind: "local-import" })
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
- external.set(key, (external.get(key) || 0) + 1)
124
+ if (key) externalKeys.push(key)
106
125
  }
107
126
  }
108
127
 
109
- nodes.push({ path: relative, imports: imports.length, localImports: [...new Set(local)].sort() })
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()
@@ -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
- await compactRuntimeEventFile(file).catch(() => false)
37
- const event = {
38
- schemaVersion: 1,
39
- id: randomUUID(),
40
- type: String(type || "unknown"),
41
- at: new Date().toISOString(),
42
- ...data,
43
- }
44
- await appendFile(file, JSON.stringify(event) + "\n", "utf8")
45
- return event
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 = {}) {