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
@@ -0,0 +1,49 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import path from "node:path"
3
+
4
+ function runGit(cwd, args) {
5
+ return spawnSync("git", ["-C", cwd, ...args], {
6
+ encoding: "utf8",
7
+ windowsHide: true,
8
+ shell: false,
9
+ })
10
+ }
11
+
12
+ export function resolveGitWorkspaceRoot(start = process.cwd()) {
13
+ const requested = path.resolve(String(start || process.cwd()))
14
+ const inside = runGit(requested, ["rev-parse", "--is-inside-work-tree"])
15
+ if (inside.status !== 0 || String(inside.stdout || "").trim() !== "true") {
16
+ return {
17
+ ok: false,
18
+ requested,
19
+ root: null,
20
+ error: String(inside.stderr || inside.stdout || "not a git worktree").trim(),
21
+ }
22
+ }
23
+
24
+ const top = runGit(requested, ["rev-parse", "--show-toplevel"])
25
+ if (top.status !== 0) {
26
+ return {
27
+ ok: false,
28
+ requested,
29
+ root: null,
30
+ error: String(top.stderr || top.stdout || "could not resolve git root").trim(),
31
+ }
32
+ }
33
+
34
+ const root = path.resolve(String(top.stdout || "").trim())
35
+ return { ok: true, requested, root, error: null }
36
+ }
37
+
38
+ export function requireGitWorkspaceRoot(start = process.cwd(), purpose = "UES") {
39
+ const resolved = resolveGitWorkspaceRoot(start)
40
+ if (resolved.ok) return resolved.root
41
+
42
+ const detail = resolved.error ? ": " + resolved.error : ""
43
+ throw new Error(
44
+ purpose +
45
+ " requires Pi to be opened inside a Git repository. Refusing to create UES runtime artifacts in " +
46
+ resolved.requested +
47
+ detail,
48
+ )
49
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs"
2
2
  import { createHash } from "node:crypto"
3
- import { copyFile, mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises"
3
+ import { copyFile, mkdir, readFile, readdir, rm, unlink, writeFile } from "node:fs/promises"
4
4
  import { spawnSync } from "node:child_process"
5
5
  import path from "node:path"
6
+ import { isUesRuntimeArtifactPath, sourceFacingPaths, sourceGitPathspecs } from "./runtime-artifacts.mjs"
6
7
 
7
8
  function git(root, args, options = {}) {
8
9
  return spawnSync("git", args, {
@@ -22,6 +23,51 @@ function statusFiles(root) {
22
23
  .map((line) => line.slice(3).trim())
23
24
  .map((value) => value.includes(" -> ") ? value.split(" -> ").at(-1) : value)
24
25
  .map((value) => value.replaceAll("\\", "/"))
26
+ .filter((value) => !isUesRuntimeArtifactPath(value))
27
+ }
28
+
29
+ function sourceIntentBatches(values, options = {}) {
30
+ const maxItems = Math.max(1, Math.min(256, Number(options.maxItems || 64)))
31
+ const maxChars = Math.max(1024, Math.min(24_000, Number(options.maxChars || 12_000)))
32
+ const batches = []
33
+ let batch = []
34
+ let chars = 0
35
+
36
+ for (const value of sourceFacingPaths(values)) {
37
+ const cost = value.length + 3
38
+ if (batch.length && (batch.length >= maxItems || chars + cost > maxChars)) {
39
+ batches.push(batch)
40
+ batch = []
41
+ chars = 0
42
+ }
43
+ batch.push(value)
44
+ chars += cost
45
+ }
46
+ if (batch.length) batches.push(batch)
47
+ return batches
48
+ }
49
+
50
+ function intentToAddSourceFiles(root) {
51
+ const untracked = git(root, [
52
+ "ls-files",
53
+ "--others",
54
+ "--exclude-standard",
55
+ "-z",
56
+ "--",
57
+ ...sourceGitPathspecs(),
58
+ ])
59
+ if (untracked.status !== 0) {
60
+ throw new Error((untracked.stderr || untracked.stdout || "git ls-files for intent-to-add failed").trim())
61
+ }
62
+
63
+ const candidates = sourceFacingPaths(untracked.stdout.split("\0").filter(Boolean))
64
+ for (const batch of sourceIntentBatches(candidates)) {
65
+ const addIntent = git(root, ["add", "-N", "--", ...batch])
66
+ if (addIntent.status !== 0) {
67
+ throw new Error((addIntent.stderr || addIntent.stdout || "git add -N failed").trim())
68
+ }
69
+ }
70
+ return candidates
25
71
  }
26
72
 
27
73
  function safeName(value) {
@@ -40,13 +86,19 @@ async function readSandboxMetadata(dir) {
40
86
  }
41
87
  }
42
88
 
89
+ export async function taskSandboxOwnerRoot(dir) {
90
+ const metadata = await readSandboxMetadata(path.resolve(dir)).catch(() => null)
91
+ const root = metadata?.root ? path.resolve(String(metadata.root)) : null
92
+ return root || null
93
+ }
94
+
43
95
  async function writeSandboxMetadata(dir, value) {
44
96
  await writeFile(metadataFile(dir), JSON.stringify(value, null, 2) + "\n", "utf8")
45
97
  }
46
98
 
47
99
  async function inheritDirtySnapshot(root, dir) {
48
100
  const inheritedUntrackedHashes = {}
49
- const diff = git(root, ["diff", "--binary", "--no-ext-diff", "HEAD", "--"])
101
+ const diff = git(root, ["diff", "--binary", "--no-ext-diff", "HEAD", "--", ...sourceGitPathspecs()])
50
102
  if (diff.status !== 0) throw new Error((diff.stderr || diff.stdout || "git diff HEAD failed").trim())
51
103
  if (diff.stdout) {
52
104
  const applied = git(dir, ["apply", "--whitespace=nowarn", "-"], { input: diff.stdout })
@@ -56,10 +108,12 @@ async function inheritDirtySnapshot(root, dir) {
56
108
  const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z"])
57
109
  if (untracked.status !== 0) throw new Error((untracked.stderr || untracked.stdout || "git ls-files failed").trim())
58
110
  for (const relative of untracked.stdout.split("\0").filter(Boolean)) {
111
+ const normalizedRelative = relative.replaceAll("\\", "/")
112
+ if (isUesRuntimeArtifactPath(normalizedRelative)) continue
59
113
  const source = path.join(root, relative)
60
114
  const target = path.join(dir, relative)
61
115
  const bytes = await readFile(source)
62
- inheritedUntrackedHashes[relative.replaceAll("\\", "/")] = createHash("sha256").update(bytes).digest("hex")
116
+ inheritedUntrackedHashes[normalizedRelative] = createHash("sha256").update(bytes).digest("hex")
63
117
  await mkdir(path.dirname(target), { recursive: true })
64
118
  await copyFile(source, target)
65
119
  }
@@ -87,9 +141,7 @@ async function inheritDirtySnapshot(root, dir) {
87
141
 
88
142
  export async function createTaskSandbox(root, slug, taskID, options = {}) {
89
143
  root = path.resolve(root)
90
- const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all"])
91
- if (status.status !== 0) throw new Error((status.stderr || status.stdout || "git status failed").trim())
92
- const rootDirty = Boolean(status.stdout.trim())
144
+ const rootDirty = statusFiles(root).length > 0
93
145
  if (rootDirty && options.allowDirtyRoot !== true && options.inheritDirtyRoot !== true) {
94
146
  throw new Error("sandbox creation requires a clean root working tree so the isolated executor cannot miss existing uncommitted changes")
95
147
  }
@@ -129,6 +181,7 @@ export async function createTaskSandbox(root, slug, taskID, options = {}) {
129
181
  inheritedDirtyRoot,
130
182
  inheritedUntrackedHashes,
131
183
  createdAt: new Date().toISOString(),
184
+ ownerPid: process.pid,
132
185
  }
133
186
  await writeSandboxMetadata(dir, metadata)
134
187
  return metadata
@@ -155,11 +208,12 @@ export async function integrateTaskSandbox(root, dir, options = {}) {
155
208
  baseSha = base.stdout.trim()
156
209
  }
157
210
 
158
- // Intent-to-add exposes untracked files in a binary diff without committing them.
159
- const addIntent = git(dir, ["add", "-N", "."])
160
- if (addIntent.status !== 0) throw new Error((addIntent.stderr || addIntent.stdout || "git add -N failed").trim())
211
+ // Intent-to-add only the non-ignored untracked source files. A root-wide
212
+ // `git add -N -- .` fails when an ignored UES runtime directory such as
213
+ // .ues-cache exists, even when an exclude pathspec is also present.
214
+ intentToAddSourceFiles(dir)
161
215
 
162
- const changedRun = git(dir, ["diff", "--name-only", baseSha, "--"])
216
+ const changedRun = git(dir, ["diff", "--name-only", baseSha, "--", ...sourceGitPathspecs()])
163
217
  if (changedRun.status !== 0) throw new Error((changedRun.stderr || changedRun.stdout || "git diff --name-only failed").trim())
164
218
  const changed = changedRun.stdout.split(/\r?\n/).filter(Boolean).map((value) => value.replaceAll("\\", "/"))
165
219
  if (changed.length === 0) {
@@ -195,7 +249,7 @@ export async function integrateTaskSandbox(root, dir, options = {}) {
195
249
  throw new Error("sandbox integration conflicts with existing root changes: " + unsafeOverlap.join(", "))
196
250
  }
197
251
 
198
- const patch = git(dir, ["diff", "--binary", "--no-ext-diff", baseSha, "--"])
252
+ const patch = git(dir, ["diff", "--binary", "--no-ext-diff", baseSha, "--", ...sourceGitPathspecs()])
199
253
  if (patch.status !== 0) throw new Error((patch.stderr || patch.stdout || "git diff failed").trim())
200
254
  const applied = git(root, ["apply", "--whitespace=nowarn", "-"], { input: patch.stdout })
201
255
  if (applied.status !== 0) {
@@ -206,6 +260,175 @@ export async function integrateTaskSandbox(root, dir, options = {}) {
206
260
  return { integrated: true, changed, base: baseSha, empty: false }
207
261
  }
208
262
 
263
+ function processAlive(pid) {
264
+ const value = Number(pid)
265
+ if (!Number.isInteger(value) || value <= 0) return false
266
+ try {
267
+ process.kill(value, 0)
268
+ return true
269
+ } catch {
270
+ return false
271
+ }
272
+ }
273
+
274
+ export async function pruneOrphanTaskSandboxes(root, options = {}) {
275
+ root = path.resolve(root)
276
+ const now = Date.now()
277
+ const minAgeMs = Math.max(60_000, Number(options.minAgeMs || 5 * 60_000))
278
+ const legacyMinAgeMs = Math.max(minAgeMs, Number(options.legacyMinAgeMs || 30 * 60_000))
279
+ const ownedMinAgeMs = Math.max(0, Number(options.ownedMinAgeMs ?? 60_000))
280
+ const reclaimOwnerPid = Number(options.reclaimOwnerPid || 0)
281
+ const protectedDirs = new Set(
282
+ (options.protectedDirs || []).map((value) => path.resolve(String(value))),
283
+ )
284
+ const expectedBase = path.resolve(
285
+ options.baseDir || path.join(path.dirname(root), "." + path.basename(root) + ".ues-sandboxes"),
286
+ )
287
+ const removed = []
288
+ const skipped = []
289
+ const sidecarsRemoved = []
290
+
291
+ for (const item of listTaskSandboxes(root)) {
292
+ const branch = String(item.branch || "").replace(/^refs\/heads\//, "")
293
+ const dir = path.resolve(String(item.path || ""))
294
+ if (!branch.startsWith("ues/")) continue
295
+ if (path.dirname(dir) !== expectedBase) continue
296
+ if (protectedDirs.has(dir)) {
297
+ skipped.push({ dir, reason: "protected-active" })
298
+ continue
299
+ }
300
+
301
+ const metadata = await readSandboxMetadata(dir)
302
+ if (!metadata || path.resolve(String(metadata.root || "")) !== root) {
303
+ skipped.push({ dir, reason: "metadata-missing-or-root-mismatch" })
304
+ continue
305
+ }
306
+
307
+ const createdAt = Date.parse(String(metadata.createdAt || ""))
308
+ const ageMs = Number.isFinite(createdAt) ? Math.max(0, now - createdAt) : Infinity
309
+ const ownerPid = Number(metadata.ownerPid || 0)
310
+ const reclaimOwned = reclaimOwnerPid > 0 && ownerPid === reclaimOwnerPid
311
+
312
+ if (ownerPid > 0 && processAlive(ownerPid) && !reclaimOwned) {
313
+ skipped.push({ dir, reason: "owner-alive", ownerPid })
314
+ continue
315
+ }
316
+
317
+ const requiredAge = reclaimOwned
318
+ ? ownedMinAgeMs
319
+ : ownerPid > 0
320
+ ? minAgeMs
321
+ : legacyMinAgeMs
322
+ if (ageMs < requiredAge) {
323
+ skipped.push({ dir, reason: "too-recent", ageMs, ownerPid: ownerPid || null })
324
+ continue
325
+ }
326
+
327
+ try {
328
+ const result = await removeTaskSandbox(root, dir, {
329
+ force: true,
330
+ deleteBranch: true,
331
+ branch,
332
+ })
333
+ removed.push({ ...result, ageMs, ownerPid: ownerPid || null, reclaimOwned })
334
+ } catch (error) {
335
+ skipped.push({
336
+ dir,
337
+ reason: "remove-failed",
338
+ error: error instanceof Error ? error.message : String(error),
339
+ })
340
+ }
341
+ }
342
+
343
+ // Clean detached physical sandbox directories left after Git forgot the
344
+ // worktree registration (for example after a crash/manual prune). Only remove
345
+ // directories with UES metadata bound to this exact root repository.
346
+ try {
347
+ const registered = new Set(
348
+ listTaskSandboxes(root).map((item) => path.resolve(String(item.path || ""))),
349
+ )
350
+ const entries = await readdir(expectedBase, { withFileTypes: true })
351
+ for (const entry of entries) {
352
+ if (!entry.isDirectory()) continue
353
+ const dir = path.resolve(expectedBase, entry.name)
354
+ if (registered.has(dir) || protectedDirs.has(dir)) continue
355
+
356
+ const metadata = await readSandboxMetadata(dir)
357
+ if (!metadata || path.resolve(String(metadata.root || "")) !== root) continue
358
+
359
+ const createdAt = Date.parse(String(metadata.createdAt || ""))
360
+ const ageMs = Number.isFinite(createdAt) ? Math.max(0, now - createdAt) : Infinity
361
+ const ownerPid = Number(metadata.ownerPid || 0)
362
+ const reclaimOwned = reclaimOwnerPid > 0 && ownerPid === reclaimOwnerPid
363
+
364
+ if (ownerPid > 0 && processAlive(ownerPid) && !reclaimOwned) {
365
+ skipped.push({ dir, reason: "detached-owner-alive", ownerPid })
366
+ continue
367
+ }
368
+
369
+ const requiredAge = reclaimOwned
370
+ ? ownedMinAgeMs
371
+ : ownerPid > 0
372
+ ? minAgeMs
373
+ : legacyMinAgeMs
374
+ if (ageMs < requiredAge) {
375
+ skipped.push({ dir, reason: "detached-too-recent", ageMs, ownerPid: ownerPid || null })
376
+ continue
377
+ }
378
+
379
+ try {
380
+ await rm(dir, { recursive: true, force: true })
381
+ await unlink(metadataFile(dir)).catch(() => {})
382
+ const branch = String(metadata.branch || "")
383
+ if (branch.startsWith("ues/")) git(root, ["branch", "-D", branch])
384
+ removed.push({
385
+ removed: !existsSync(dir),
386
+ dir,
387
+ branch: branch || null,
388
+ ageMs,
389
+ ownerPid: ownerPid || null,
390
+ reclaimOwned,
391
+ detached: true,
392
+ })
393
+ } catch (error) {
394
+ skipped.push({
395
+ dir,
396
+ reason: "detached-remove-failed",
397
+ error: error instanceof Error ? error.message : String(error),
398
+ })
399
+ }
400
+ }
401
+ } catch {}
402
+
403
+ // Clean metadata sidecars left after a crash or manual worktree removal.
404
+ try {
405
+ const entries = await readdir(expectedBase, { withFileTypes: true })
406
+ for (const entry of entries) {
407
+ if (!entry.isFile() || !entry.name.endsWith(".ues-meta.json")) continue
408
+ const sidecar = path.join(expectedBase, entry.name)
409
+ const dir = sidecar.slice(0, -".ues-meta.json".length)
410
+ if (existsSync(dir)) continue
411
+ try {
412
+ const metadata = JSON.parse(await readFile(sidecar, "utf8"))
413
+ if (path.resolve(String(metadata.root || "")) !== root) continue
414
+ const createdAt = Date.parse(String(metadata.createdAt || ""))
415
+ const ageMs = Number.isFinite(createdAt) ? Math.max(0, now - createdAt) : Infinity
416
+ if (ageMs < minAgeMs) continue
417
+ await unlink(sidecar)
418
+ sidecarsRemoved.push(sidecar)
419
+ } catch {}
420
+ }
421
+
422
+ const remaining = await readdir(expectedBase)
423
+ if (remaining.length === 0) {
424
+ await rm(expectedBase, { recursive: false, force: true })
425
+ }
426
+ } catch {}
427
+
428
+ git(root, ["worktree", "prune"])
429
+ return { removed, skipped, sidecarsRemoved, baseRemoved: !existsSync(expectedBase) }
430
+ }
431
+
209
432
  export async function removeTaskSandbox(root, dir, options = {}) {
210
433
  root = path.resolve(root)
211
434
  dir = path.resolve(dir)
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "opencode-agent-skill",
3
- "version": "14.4.0",
4
- "description": "Pi Agent engineering runtime with code intelligence, hash-anchored editing, verified persistent memory, health-aware capability routing, reversible context, and evidence-gated completion",
3
+ "version": "15.1.0",
4
+ "description": "Pi-native UES coding-agent runtime with adaptive stability, Turbo Fast Path, durable verification, managed services, and weak-model orchestration",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ues": "bin/ocskill.mjs",
8
- "ocskill": "bin/ocskill.mjs"
7
+ "ocskill": "bin/ocskill.mjs",
8
+ "ues": "bin/ocskill.mjs"
9
9
  },
10
10
  "files": [
11
11
  "bin/ocskill.mjs",
@@ -21,6 +21,7 @@
21
21
  "docs/V14-CONTEXT-MEMORY-FABRIC.md",
22
22
  "docs/V14.1-QUALITY-PERFORMANCE-FABRIC.md",
23
23
  "docs/V14.2-TURBO-WEAK-MODEL-RUNTIME.md",
24
+ "docs/V15-MANAGED-RUNTIME.md",
24
25
  "docs/PI-COMPAT.md",
25
26
  "docs/OPENCODE-COMPAT.md",
26
27
  "README.md",
@@ -40,7 +41,9 @@
40
41
  "smoke:packed": "node scripts/smoke-packed-install.mjs",
41
42
  "eval:v14": "node --test test/capability-fabric-v14.test.mjs test/hierarchical-context-v14.test.mjs test/memory-engine-v14.test.mjs test/context-engine-v14.test.mjs test/task-engine-v14-context.test.mjs test/cli-v14.test.mjs test/v14-contract.test.mjs test/performance-fabric-v14.test.mjs test/browser-mcp-routing-v14.test.mjs test/process-hang-detector-v14.test.mjs test/v14.2-runtime.test.mjs test/benchmark-confidence.test.mjs test/v14.3-intelligence.test.mjs",
42
43
  "integrity": "node scripts/check-source-integrity.mjs",
43
- "runtime:exports": "node scripts/check-runtime-exports.mjs"
44
+ "runtime:exports": "node scripts/check-runtime-exports.mjs",
45
+ "eval:v15": "node --test test/v15-runtime.test.mjs test/trajectory.test.mjs test/evidence-store-v11.test.mjs test/runtime-events.test.mjs test/v14.3-intelligence.test.mjs test/v14.2-runtime.test.mjs test/pi-package.test.mjs test/execution-contract.test.mjs",
46
+ "release:verify": "npm run ci && npm run eval:v15"
44
47
  },
45
48
  "keywords": [
46
49
  "pi-package",
@@ -6,6 +6,7 @@ import { getEvidenceSelected } from "../../lib/evidence-store.mjs";
6
6
  import { recordVerification } from "../../lib/verification-broker.mjs";
7
7
  import { runtimeWorkspaceFingerprint } from "../../lib/workspace-fingerprint.mjs";
8
8
  import { destructiveShellRisk } from "../../lib/safety.mjs";
9
+ import { crossToolTempPathRisk, isLocalEnvPath, localEnvWriteRisk } from "../../lib/execution-contract.mjs";
9
10
  import {
10
11
  canonicalVerificationCommand,
11
12
  looksLikeVerificationCommand,
@@ -13,12 +14,23 @@ import {
13
14
  import {
14
15
  applyAnchoredFileEdits,
15
16
  diagnoseCode,
17
+ lspOperation,
16
18
  probeCodeIntelligence,
17
19
  readAnchoredCode,
18
20
  searchCodeIntelligence,
19
21
  } from "../../lib/code-intelligence/index.mjs";
20
22
  import { ingestDocument } from "../../lib/document-ingestion.mjs";
21
23
  import { compactContext, expandContext, searchContext } from "../../lib/reversible-context.mjs";
24
+ import {
25
+ looksLikeLongRunningServiceCommand,
26
+ restartService,
27
+ serviceLogs,
28
+ serviceStatus,
29
+ startService,
30
+ stopAllServices,
31
+ stopService,
32
+ waitForService,
33
+ } from "../../lib/service-manager.mjs";
22
34
 
23
35
  const toolExecutionState = new Map<string, {
24
36
  startedAt: number;
@@ -81,15 +93,67 @@ async function capturedText(event: any, fallback: string) {
81
93
  }
82
94
 
83
95
  export default function (pi: ExtensionAPI) {
96
+ // Internal-only extension: the package host may discover this file alongside
97
+ // ues.ts, but only specialist child Pi processes should register its tools.
98
+ // Parent launch paths set UES_CHILD_PROCESS=1 for both CLI and RPC workers.
99
+ if (process.env.UES_CHILD_PROCESS !== "1") return;
100
+
84
101
  const clearExecutionState = () => toolExecutionState.clear();
85
102
  pi.on("session_start", clearExecutionState);
86
- pi.on("session_shutdown", clearExecutionState);
103
+ pi.on("session_shutdown", async (_event, ctx) => {
104
+ clearExecutionState();
105
+ await stopAllServices(ctx.cwd).catch(() => []);
106
+ });
87
107
 
88
108
  pi.on("tool_call", async (event, ctx) => {
89
109
  const toolName = String(event.toolName || "");
110
+ const input: any = event.input || {};
111
+ const localEnvAllowed = String(process.env.UES_CHILD_ALLOW_LOCAL_ENV_WRITE || "") === "1";
112
+ const writeTool = ["edit", "write", "write_file", "apply_patch", "ues_code_edit"].includes(toolName);
113
+ const fileTool = ["read", "edit", "write", "write_file", "apply_patch", "ues_code", "ues_code_edit"].includes(toolName);
114
+ const fileCandidate = String(input.file || input.path || input.filePath || input.target || "");
115
+ const tempPathRisk = crossToolTempPathRisk(fileCandidate);
116
+ if (fileTool && tempPathRisk.risky) {
117
+ toolExecutionState.delete(String(event.toolCallId || ""));
118
+ return {
119
+ block: true,
120
+ reason:
121
+ "UES portable temp-path guard blocked " + fileCandidate +
122
+ ". On Windows, /tmp and /var/tmp may resolve differently between Pi file tools and bash/MSYS. " +
123
+ "For transient transforms, keep creation/read in one shell pipeline; for cross-tool scratch use a repository-local ignored UES path such as .ues-cache/tmp after creating it.",
124
+ };
125
+ }
126
+ if (writeTool && isLocalEnvPath(fileCandidate) && !localEnvAllowed) {
127
+ toolExecutionState.delete(String(event.toolCallId || ""));
128
+ return {
129
+ block: true,
130
+ reason:
131
+ "UES local-env guard blocked a write to " + fileCandidate +
132
+ ". .env/.env.* are local runtime inputs; update an example/template or report NEEDS_USER_ENV unless the user explicitly authorized this local env mutation.",
133
+ };
134
+ }
135
+
90
136
  if (!["bash", "powershell"].includes(toolName)) return undefined;
91
137
 
92
- const command = String((event.input as any)?.command || "");
138
+ const command = String(input.command || "");
139
+ const envRisk = localEnvWriteRisk(command);
140
+ if (envRisk.risky && !localEnvAllowed) {
141
+ toolExecutionState.delete(String(event.toolCallId || ""));
142
+ return {
143
+ block: true,
144
+ reason:
145
+ "UES local-env guard blocked a shell write to .env/.env.*. Use .env.example/sample/template or report NEEDS_USER_ENV unless explicitly authorized.",
146
+ };
147
+ }
148
+ if (looksLikeLongRunningServiceCommand(command)) {
149
+ toolExecutionState.delete(String(event.toolCallId || ""));
150
+ return {
151
+ block: true,
152
+ reason:
153
+ "UES detected a likely long-running foreground service command. " +
154
+ "Use the ues_service tool (start -> wait-ready/logs -> stop) instead of bash/powershell so the agent does not stall.",
155
+ };
156
+ }
93
157
  const risk = destructiveShellRisk(command);
94
158
  if (risk.risky) {
95
159
  toolExecutionState.delete(String(event.toolCallId || ""));
@@ -209,13 +273,20 @@ export default function (pi: ExtensionAPI) {
209
273
  name: "ues_code",
210
274
  label: "UES Code Intelligence",
211
275
  description:
212
- "Bounded code/document/context intelligence for weak models: semantic/AST search, hash-anchored reads, optional LSP diagnostics, optional MarkItDown ingestion, and reversible context recovery.",
276
+ "Bounded code/document/context intelligence for weak models: semantic/AST search, hash-anchored reads, deterministic LSP definition/references/symbols/hover/rename-preview/call hierarchy, diagnostics, optional MarkItDown ingestion, and reversible context recovery.",
213
277
  parameters: Type.Object({
214
278
  action: Type.Union([
215
279
  Type.Literal("status"),
216
280
  Type.Literal("search"),
217
281
  Type.Literal("read"),
218
282
  Type.Literal("diagnostics"),
283
+ Type.Literal("definition"),
284
+ Type.Literal("references"),
285
+ Type.Literal("symbols"),
286
+ Type.Literal("hover"),
287
+ Type.Literal("rename-preview"),
288
+ Type.Literal("incoming-calls"),
289
+ Type.Literal("outgoing-calls"),
219
290
  Type.Literal("document"),
220
291
  Type.Literal("context-expand"),
221
292
  Type.Literal("context-search"),
@@ -226,6 +297,10 @@ export default function (pi: ExtensionAPI) {
226
297
  language: Type.Optional(Type.String()),
227
298
  startLine: Type.Optional(Type.Number({ minimum: 1 })),
228
299
  endLine: Type.Optional(Type.Number({ minimum: 1 })),
300
+ line: Type.Optional(Type.Number({ minimum: 1, description: "1-based source line for LSP operations" })),
301
+ character: Type.Optional(Type.Number({ minimum: 1, description: "1-based source character for LSP operations" })),
302
+ newName: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
303
+ includeDeclaration: Type.Optional(Type.Boolean()),
229
304
  ref: Type.Optional(Type.String()),
230
305
  maxBytes: Type.Optional(Type.Number({ minimum: 1, maximum: 128000 })),
231
306
  }),
@@ -257,7 +332,18 @@ export default function (pi: ExtensionAPI) {
257
332
  };
258
333
  } else if (params.action === "diagnostics") {
259
334
  if (!params.file) throw new Error("ues_code diagnostics requires file");
260
- result = await diagnoseCode(ctx.cwd, params.file, { timeoutMs: 3000, maxDiagnostics: 40 });
335
+ result = await diagnoseCode(ctx.cwd, params.file, { timeoutMs: 5000, maxResults: 80 });
336
+ } else if (["definition", "references", "symbols", "hover", "rename-preview", "incoming-calls", "outgoing-calls"].includes(params.action)) {
337
+ if (!params.file) throw new Error(`ues_code ${params.action} requires file`);
338
+ if (params.action === "rename-preview" && !params.newName) throw new Error("ues_code rename-preview requires newName");
339
+ result = await lspOperation(ctx.cwd, params.file, params.action, {
340
+ line: params.line || 1,
341
+ character: params.character || 1,
342
+ newName: params.newName,
343
+ includeDeclaration: params.includeDeclaration,
344
+ timeoutMs: 7000,
345
+ maxResults: 120,
346
+ });
261
347
  } else if (params.action === "document") {
262
348
  if (!params.file) throw new Error("ues_code document requires file");
263
349
  const document = await ingestDocument(ctx.cwd, params.file, { maxBytes: Math.min(Number(params.maxBytes || 4 * 1024 * 1024), 4 * 1024 * 1024) });
@@ -345,6 +431,86 @@ export default function (pi: ExtensionAPI) {
345
431
  },
346
432
  });
347
433
 
434
+ pi.registerTool({
435
+ name: "ues_service",
436
+ label: "UES Managed Service",
437
+ description:
438
+ "Manage long-running development servers/watchers without blocking the agent. Use start, wait-ready, status, logs, stop, or restart. For start/restart, command is the executable only (for example node or npm); put every argument in args. Services are bounded to the current workspace/runtime and are cleaned up on session shutdown.",
439
+ parameters: Type.Object({
440
+ action: Type.Union([
441
+ Type.Literal("start"),
442
+ Type.Literal("wait-ready"),
443
+ Type.Literal("status"),
444
+ Type.Literal("logs"),
445
+ Type.Literal("stop"),
446
+ Type.Literal("restart"),
447
+ ]),
448
+ name: Type.String({ minLength: 1, maxLength: 64 }),
449
+ command: Type.Optional(Type.String({ minLength: 1 })),
450
+ args: Type.Optional(Type.Array(Type.String(), { maxItems: 128 })),
451
+ cwd: Type.Optional(Type.String()),
452
+ readyPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })),
453
+ readyHost: Type.Optional(Type.String({ maxLength: 255 })),
454
+ readyLog: Type.Optional(Type.String({ maxLength: 512 })),
455
+ timeoutMs: Type.Optional(Type.Number({ minimum: 100, maximum: 600000 })),
456
+ lifetimeMs: Type.Optional(Type.Number({ minimum: 10000, maximum: 7200000 })),
457
+ idleTimeoutMs: Type.Optional(Type.Number({ minimum: 5000, maximum: 3600000 })),
458
+ maxChars: Type.Optional(Type.Number({ minimum: 256, maximum: 128000 })),
459
+ }),
460
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
461
+ try {
462
+ let result: any;
463
+ if (params.action === "start") {
464
+ if (!params.command) throw new Error("ues_service start requires command");
465
+ const riskText = [params.command, ...(params.args || [])].join(" ");
466
+ const risk = destructiveShellRisk(riskText);
467
+ if (risk.risky) throw new Error(`UES service safety blocked ${risk.id || "destructive"} command`);
468
+ result = await startService(ctx.cwd, {
469
+ name: params.name,
470
+ command: params.command,
471
+ args: params.args || [],
472
+ cwd: params.cwd,
473
+ readyPort: params.readyPort,
474
+ readyHost: params.readyHost,
475
+ readyLog: params.readyLog,
476
+ timeoutMs: params.timeoutMs,
477
+ lifetimeMs: params.lifetimeMs,
478
+ idleTimeoutMs: params.idleTimeoutMs,
479
+ });
480
+ } else if (params.action === "wait-ready") {
481
+ result = await waitForService(ctx.cwd, params.name, { timeoutMs: params.timeoutMs });
482
+ } else if (params.action === "status") {
483
+ result = await serviceStatus(ctx.cwd, params.name);
484
+ } else if (params.action === "logs") {
485
+ result = await serviceLogs(ctx.cwd, params.name, { maxChars: params.maxChars, evidence: true });
486
+ } else if (params.action === "stop") {
487
+ result = await stopService(ctx.cwd, params.name, { timeoutMs: params.timeoutMs });
488
+ } else if (params.action === "restart") {
489
+ result = await restartService(ctx.cwd, params.name, {
490
+ timeoutMs: params.timeoutMs,
491
+ lifetimeMs: params.lifetimeMs,
492
+ idleTimeoutMs: params.idleTimeoutMs,
493
+ });
494
+ } else {
495
+ throw new Error("Unknown ues_service action");
496
+ }
497
+ const failed =
498
+ (params.action === "start" || params.action === "wait-ready") && result?.ready !== true;
499
+ return {
500
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
501
+ details: result,
502
+ isError: failed,
503
+ };
504
+ } catch (error) {
505
+ return {
506
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
507
+ details: { action: params.action, name: params.name },
508
+ isError: true,
509
+ };
510
+ }
511
+ },
512
+ });
513
+
348
514
  pi.registerTool({
349
515
  name: "ues_evidence_get",
350
516
  label: "UES Evidence Get",