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.
- package/README.md +34 -16
- package/docs/PI-COMPAT.md +151 -2
- package/docs/V15-MANAGED-RUNTIME.md +128 -0
- package/global-config/agents/architect.md +63 -1
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/plan-checker.md +8 -1
- package/global-config/agents/verifier.md +3 -3
- package/global-config/skills/database-engineering/references/workflow.md +15 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
- package/lib/activity-deadline.mjs +70 -0
- package/lib/adaptive-context-budget.mjs +25 -5
- package/lib/affected-tests.mjs +38 -5
- package/lib/capability-fabric.mjs +17 -0
- package/lib/code-intelligence/index.mjs +3 -2
- package/lib/code-intelligence/lsp-provider.mjs +326 -52
- package/lib/compaction-resume-guard.mjs +255 -0
- package/lib/completion-auditor.mjs +25 -5
- package/lib/context-engine-v11.mjs +20 -7
- package/lib/evidence-store.mjs +92 -8
- package/lib/execution-contract.mjs +592 -0
- package/lib/leaf-runtime-optimizer.mjs +76 -0
- package/lib/memory-engine.mjs +189 -16
- package/lib/performance-fabric.mjs +262 -107
- package/lib/pi-child-invocation.mjs +65 -0
- package/lib/pi-rpc-pool.mjs +61 -9
- package/lib/plan-salvage.mjs +124 -0
- package/lib/planning-speed-policy.mjs +131 -0
- package/lib/repo-graph.mjs +1 -1
- package/lib/repo-inspect.mjs +1 -0
- package/lib/runtime-artifacts.mjs +42 -0
- package/lib/runtime-events.mjs +33 -1
- package/lib/safety.mjs +1 -0
- package/lib/semantic-index.mjs +1 -1
- package/lib/service-manager.mjs +526 -0
- package/lib/session-display.mjs +49 -0
- package/lib/subagent-artifacts.mjs +182 -0
- package/lib/task-graph.mjs +87 -0
- package/lib/task-policy.mjs +150 -4
- package/lib/trajectory.mjs +111 -1
- package/lib/turbo-fast-path.mjs +44 -0
- package/lib/verification-broker.mjs +32 -27
- package/lib/workspace-fingerprint.mjs +45 -19
- package/lib/workspace-hygiene.mjs +481 -0
- package/lib/workspace-root.mjs +49 -0
- package/lib/worktree-sandbox.mjs +234 -11
- package/package.json +8 -5
- package/pi/extensions/ues-child-runtime.ts +170 -4
- package/pi/extensions/ues.ts +2063 -188
- package/scripts/check-release-consistency.mjs +25 -2
- package/scripts/check-source-integrity.mjs +471 -4
- package/scripts/eval-pi.mjs +76 -36
- package/scripts/smoke-pi-extension.mjs +1 -1
- package/pi/prompts/ues-run.md +0 -17
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
3
|
+
import net from "node:net"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { putEvidence } from "./evidence-store.mjs"
|
|
6
|
+
import { terminateProcessTree } from "./process-supervisor.mjs"
|
|
7
|
+
import { resolveWindowsCommand } from "./windows-shim.mjs"
|
|
8
|
+
|
|
9
|
+
const SERVICES = new Map()
|
|
10
|
+
const MAX_SERVICES = 8
|
|
11
|
+
const DEFAULT_READY_TIMEOUT_MS = 30_000
|
|
12
|
+
const DEFAULT_LOG_LIMIT_BYTES = 4 * 1024 * 1024
|
|
13
|
+
const DEFAULT_TAIL_CHARS = 64 * 1024
|
|
14
|
+
const DEFAULT_LIFETIME_MS = 30 * 60_000
|
|
15
|
+
|
|
16
|
+
function clamp(value, fallback, min, max) {
|
|
17
|
+
const parsed = Number(value)
|
|
18
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
19
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sleep(ms) {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeName(name) {
|
|
27
|
+
const value = String(name || "").trim()
|
|
28
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value)) {
|
|
29
|
+
throw new Error("UES service name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
|
30
|
+
}
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function within(root, candidate) {
|
|
35
|
+
const base = path.resolve(root)
|
|
36
|
+
const full = path.resolve(candidate)
|
|
37
|
+
return full === base || full.startsWith(base + path.sep)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function safeRootAndCwd(root, requestedCwd) {
|
|
41
|
+
const base = realpathSync(path.resolve(root))
|
|
42
|
+
const cwdCandidate = path.resolve(base, requestedCwd || ".")
|
|
43
|
+
if (!within(base, cwdCandidate)) throw new Error("UES service cwd escapes workspace root")
|
|
44
|
+
const cwd = realpathSync(cwdCandidate)
|
|
45
|
+
if (!within(base, cwd)) throw new Error("UES service cwd resolves outside workspace root")
|
|
46
|
+
return { root: base, cwd }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serviceDir(root) {
|
|
50
|
+
return path.join(root, ".ues-services")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function servicePaths(root, name) {
|
|
54
|
+
const dir = serviceDir(root)
|
|
55
|
+
return {
|
|
56
|
+
dir,
|
|
57
|
+
meta: path.join(dir, name + ".json"),
|
|
58
|
+
log: path.join(dir, name + ".log"),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function keyFor(root, name) {
|
|
63
|
+
return path.resolve(root) + "\u0000" + normalizeName(name)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function boundedArgs(args) {
|
|
67
|
+
if (!Array.isArray(args)) return []
|
|
68
|
+
if (args.length > 128) throw new Error("UES service args exceed 128 items")
|
|
69
|
+
return args.map((item) => {
|
|
70
|
+
const value = String(item)
|
|
71
|
+
if (value.length > 16_384) throw new Error("UES service argument exceeds 16 KiB")
|
|
72
|
+
if (value.includes("\u0000")) throw new Error("UES service argument contains NUL")
|
|
73
|
+
return value
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function executionFor(command) {
|
|
78
|
+
const value = String(command || "").trim()
|
|
79
|
+
if (!value || value.includes("\u0000")) throw new Error("UES service command is required")
|
|
80
|
+
if (process.platform !== "win32") return { executable: value, argsPrefix: [], source: value }
|
|
81
|
+
const resolved = resolveWindowsCommand(value)
|
|
82
|
+
if (!resolved) throw new Error("UES service command could not be resolved safely on Windows: " + value)
|
|
83
|
+
return resolved
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function publicState(state) {
|
|
87
|
+
return {
|
|
88
|
+
schemaVersion: 1,
|
|
89
|
+
name: state.name,
|
|
90
|
+
pid: state.proc?.pid || state.pid || null,
|
|
91
|
+
status: state.status,
|
|
92
|
+
alive: processIsAlive(state),
|
|
93
|
+
command: state.command,
|
|
94
|
+
args: [...state.args],
|
|
95
|
+
cwd: state.cwd,
|
|
96
|
+
startedAt: state.startedAt,
|
|
97
|
+
finishedAt: state.finishedAt || null,
|
|
98
|
+
exitCode: state.exitCode ?? null,
|
|
99
|
+
signal: state.signal || null,
|
|
100
|
+
ready: state.ready === true,
|
|
101
|
+
readiness: {
|
|
102
|
+
host: state.readyHost || null,
|
|
103
|
+
port: state.readyPort || null,
|
|
104
|
+
log: state.readyLog || null,
|
|
105
|
+
},
|
|
106
|
+
logFile: path.relative(state.root, state.logFile).replaceAll("\\", "/"),
|
|
107
|
+
logBytes: state.logBytes,
|
|
108
|
+
logTruncated: state.logTruncated,
|
|
109
|
+
lifetimeMs: state.lifetimeMs,
|
|
110
|
+
idleTimeoutMs: state.idleTimeoutMs || 0,
|
|
111
|
+
lastOutputAt: state.lastOutputAt ? new Date(state.lastOutputAt).toISOString() : null,
|
|
112
|
+
stopReason: state.stopReason || null,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function persist(state) {
|
|
117
|
+
try {
|
|
118
|
+
mkdirSync(path.dirname(state.metaFile), { recursive: true })
|
|
119
|
+
writeFileSync(state.metaFile, JSON.stringify(publicState(state), null, 2) + "\n", "utf8")
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function appendLog(state, stream, chunk) {
|
|
124
|
+
const text = String(chunk || "")
|
|
125
|
+
if (!text) return
|
|
126
|
+
state.lastOutputAt = Date.now()
|
|
127
|
+
const tagged = `[${stream}] ${text}`
|
|
128
|
+
state.tail = (state.tail + tagged).slice(-DEFAULT_TAIL_CHARS)
|
|
129
|
+
const bytes = Buffer.byteLength(tagged)
|
|
130
|
+
if (state.logBytes >= state.logLimitBytes) {
|
|
131
|
+
if (!state.logTruncated) {
|
|
132
|
+
state.logTruncated = true
|
|
133
|
+
try { appendFileSync(state.logFile, "\n[ues] service log capture limit reached; further output omitted\n", "utf8") } catch {}
|
|
134
|
+
}
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const remaining = state.logLimitBytes - state.logBytes
|
|
138
|
+
const buffer = Buffer.from(tagged, "utf8")
|
|
139
|
+
const slice = buffer.subarray(0, Math.min(buffer.length, remaining))
|
|
140
|
+
if (slice.length) {
|
|
141
|
+
try { appendFileSync(state.logFile, slice) } catch {}
|
|
142
|
+
state.logBytes += slice.length
|
|
143
|
+
}
|
|
144
|
+
if (slice.length < buffer.length) state.logTruncated = true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function processIsAlive(state) {
|
|
148
|
+
return Boolean(
|
|
149
|
+
state?.proc &&
|
|
150
|
+
state.proc.exitCode == null &&
|
|
151
|
+
!["exited", "failed", "stopped"].includes(String(state.status || "")),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function portReady(host, port) {
|
|
156
|
+
return await new Promise((resolve) => {
|
|
157
|
+
const socket = new net.Socket()
|
|
158
|
+
let settled = false
|
|
159
|
+
const finish = (value) => {
|
|
160
|
+
if (settled) return
|
|
161
|
+
settled = true
|
|
162
|
+
socket.destroy()
|
|
163
|
+
resolve(value)
|
|
164
|
+
}
|
|
165
|
+
socket.setTimeout(600)
|
|
166
|
+
socket.once("connect", () => finish(true))
|
|
167
|
+
socket.once("timeout", () => finish(false))
|
|
168
|
+
socket.once("error", () => finish(false))
|
|
169
|
+
socket.connect(port, host)
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function logContains(state, needle) {
|
|
174
|
+
if (!needle) return true
|
|
175
|
+
if (state.tail.includes(needle)) return true
|
|
176
|
+
try {
|
|
177
|
+
const text = readFileSync(state.logFile, "utf8")
|
|
178
|
+
return text.slice(-DEFAULT_TAIL_CHARS).includes(needle)
|
|
179
|
+
} catch {
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readiness(state) {
|
|
185
|
+
if (!processIsAlive(state)) {
|
|
186
|
+
return { ready: false, reason: "process-exited" }
|
|
187
|
+
}
|
|
188
|
+
const checks = []
|
|
189
|
+
if (state.readyPort) {
|
|
190
|
+
checks.push({
|
|
191
|
+
kind: "port",
|
|
192
|
+
ready: await portReady(state.readyHost || "127.0.0.1", state.readyPort),
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
if (state.readyLog) {
|
|
196
|
+
checks.push({ kind: "log", ready: logContains(state, state.readyLog) })
|
|
197
|
+
}
|
|
198
|
+
if (!checks.length) return { ready: true, reason: "process-running", checks: [] }
|
|
199
|
+
return {
|
|
200
|
+
ready: checks.every((item) => item.ready),
|
|
201
|
+
reason: checks.every((item) => item.ready) ? "readiness-satisfied" : "readiness-pending",
|
|
202
|
+
checks,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function looksLikeLongRunningServiceCommand(command) {
|
|
207
|
+
const text = String(command || "").trim()
|
|
208
|
+
if (!text) return false
|
|
209
|
+
const patterns = [
|
|
210
|
+
/^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start(?::[\w.-]+)?|serve|preview)(?:\s|$)/i,
|
|
211
|
+
/^(?:npx\s+)?(?:next\s+(?:dev|start)|vite(?:\s|$)|nest\s+start|react-scripts\s+start|expo\s+start)(?:\s|$)/i,
|
|
212
|
+
/^(?:node|bun)\s+["']?[^;&|\r\n]*(?:^|[\\/])(?:main|server|app)\.(?:mjs|cjs|js|ts)(?:["']?\s|$)/i,
|
|
213
|
+
/^(?:python|python3|py)\s+(?:-m\s+http\.server|[^\r\n]*(?:uvicorn|gunicorn|manage\.py\s+runserver))(?:\s|$)/i,
|
|
214
|
+
/^(?:uvicorn|gunicorn|flask\s+run|dotnet\s+run|mvn\s+spring-boot:run|gradle\s+bootRun)(?:\s|$)/i,
|
|
215
|
+
/^docker\s+compose\s+up(?![^\r\n]*\s-d(?:\s|$))/i,
|
|
216
|
+
]
|
|
217
|
+
return patterns.some((pattern) => pattern.test(text))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function waitForService(root, name, options = {}) {
|
|
221
|
+
name = normalizeName(name)
|
|
222
|
+
root = realpathSync(path.resolve(root))
|
|
223
|
+
const state = SERVICES.get(keyFor(root, name))
|
|
224
|
+
if (!state) {
|
|
225
|
+
return { schemaVersion: 1, name, ready: false, status: "not-owned", reason: "service-not-active-in-this-runtime" }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const timeoutMs = clamp(options.timeoutMs, DEFAULT_READY_TIMEOUT_MS, 100, 10 * 60_000)
|
|
229
|
+
const pollMs = clamp(options.pollMs, 150, 50, 2_000)
|
|
230
|
+
const deadline = Date.now() + timeoutMs
|
|
231
|
+
do {
|
|
232
|
+
const probe = await readiness(state)
|
|
233
|
+
if (probe.ready) {
|
|
234
|
+
state.ready = true
|
|
235
|
+
persist(state)
|
|
236
|
+
return { ...publicState(state), ready: true, reason: probe.reason, checks: probe.checks || [] }
|
|
237
|
+
}
|
|
238
|
+
if (!processIsAlive(state)) {
|
|
239
|
+
persist(state)
|
|
240
|
+
return { ...publicState(state), ready: false, reason: "process-exited", checks: probe.checks || [] }
|
|
241
|
+
}
|
|
242
|
+
await sleep(pollMs)
|
|
243
|
+
} while (Date.now() < deadline)
|
|
244
|
+
|
|
245
|
+
const probe = await readiness(state)
|
|
246
|
+
state.ready = probe.ready
|
|
247
|
+
persist(state)
|
|
248
|
+
return {
|
|
249
|
+
...publicState(state),
|
|
250
|
+
ready: probe.ready,
|
|
251
|
+
reason: probe.ready ? probe.reason : "readiness-timeout",
|
|
252
|
+
checks: probe.checks || [],
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function startService(root = process.cwd(), options = {}) {
|
|
257
|
+
const name = normalizeName(options.name)
|
|
258
|
+
const scoped = safeRootAndCwd(root, options.cwd)
|
|
259
|
+
root = scoped.root
|
|
260
|
+
const key = keyFor(root, name)
|
|
261
|
+
const existing = SERVICES.get(key)
|
|
262
|
+
if (existing && processIsAlive(existing)) {
|
|
263
|
+
throw new Error("UES service already running: " + name)
|
|
264
|
+
}
|
|
265
|
+
if ([...SERVICES.values()].filter(processIsAlive).length >= MAX_SERVICES) {
|
|
266
|
+
throw new Error("UES service limit reached (" + MAX_SERVICES + ")")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const command = String(options.command || "").trim()
|
|
270
|
+
const args = boundedArgs(options.args)
|
|
271
|
+
const execution = executionFor(command)
|
|
272
|
+
const readyPort = options.readyPort == null
|
|
273
|
+
? null
|
|
274
|
+
: clamp(options.readyPort, 0, 1, 65535)
|
|
275
|
+
if (options.readyPort != null && !readyPort) throw new Error("UES service readyPort must be 1..65535")
|
|
276
|
+
const readyLog = options.readyLog == null ? null : String(options.readyLog)
|
|
277
|
+
if (readyLog && readyLog.length > 512) throw new Error("UES service readyLog exceeds 512 characters")
|
|
278
|
+
const readyHost = String(options.readyHost || "127.0.0.1").trim() || "127.0.0.1"
|
|
279
|
+
if (readyHost.length > 255) throw new Error("UES service readyHost is too long")
|
|
280
|
+
|
|
281
|
+
const paths = servicePaths(root, name)
|
|
282
|
+
mkdirSync(paths.dir, { recursive: true })
|
|
283
|
+
writeFileSync(paths.log, "", "utf8")
|
|
284
|
+
|
|
285
|
+
const state = {
|
|
286
|
+
name,
|
|
287
|
+
root,
|
|
288
|
+
cwd: scoped.cwd,
|
|
289
|
+
command,
|
|
290
|
+
args,
|
|
291
|
+
executable: execution.executable,
|
|
292
|
+
executionArgs: [...(execution.argsPrefix || []), ...args],
|
|
293
|
+
readyPort,
|
|
294
|
+
readyHost,
|
|
295
|
+
readyLog,
|
|
296
|
+
ready: false,
|
|
297
|
+
status: "starting",
|
|
298
|
+
startedAt: new Date().toISOString(),
|
|
299
|
+
finishedAt: null,
|
|
300
|
+
exitCode: null,
|
|
301
|
+
signal: null,
|
|
302
|
+
proc: null,
|
|
303
|
+
metaFile: paths.meta,
|
|
304
|
+
logFile: paths.log,
|
|
305
|
+
logBytes: 0,
|
|
306
|
+
logLimitBytes: clamp(options.logLimitBytes, DEFAULT_LOG_LIMIT_BYTES, 64 * 1024, 32 * 1024 * 1024),
|
|
307
|
+
logTruncated: false,
|
|
308
|
+
tail: "",
|
|
309
|
+
lastOutputAt: Date.now(),
|
|
310
|
+
lifetimeMs: clamp(options.lifetimeMs, DEFAULT_LIFETIME_MS, 10_000, 2 * 60 * 60_000),
|
|
311
|
+
idleTimeoutMs: options.idleTimeoutMs == null
|
|
312
|
+
? 0
|
|
313
|
+
: clamp(options.idleTimeoutMs, 0, 5_000, 60 * 60_000),
|
|
314
|
+
lifecycleTimer: null,
|
|
315
|
+
stopReason: null,
|
|
316
|
+
startOptions: {
|
|
317
|
+
name,
|
|
318
|
+
command,
|
|
319
|
+
args,
|
|
320
|
+
cwd: path.relative(root, scoped.cwd) || ".",
|
|
321
|
+
readyPort,
|
|
322
|
+
readyHost,
|
|
323
|
+
readyLog,
|
|
324
|
+
timeoutMs: options.timeoutMs,
|
|
325
|
+
logLimitBytes: options.logLimitBytes,
|
|
326
|
+
lifetimeMs: options.lifetimeMs,
|
|
327
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const proc = spawn(execution.executable, state.executionArgs, {
|
|
332
|
+
cwd: scoped.cwd,
|
|
333
|
+
env: options.env && typeof options.env === "object"
|
|
334
|
+
? { ...process.env, ...Object.fromEntries(Object.entries(options.env).map(([key, value]) => [String(key), String(value)])) }
|
|
335
|
+
: process.env,
|
|
336
|
+
shell: false,
|
|
337
|
+
detached: process.platform !== "win32",
|
|
338
|
+
windowsHide: true,
|
|
339
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
340
|
+
})
|
|
341
|
+
state.proc = proc
|
|
342
|
+
state.status = "running"
|
|
343
|
+
SERVICES.set(key, state)
|
|
344
|
+
persist(state)
|
|
345
|
+
|
|
346
|
+
proc.stdout?.on("data", (chunk) => appendLog(state, "stdout", chunk))
|
|
347
|
+
proc.stderr?.on("data", (chunk) => appendLog(state, "stderr", chunk))
|
|
348
|
+
|
|
349
|
+
state.lifecycleTimer = setInterval(() => {
|
|
350
|
+
if (!processIsAlive(state)) return
|
|
351
|
+
const now = Date.now()
|
|
352
|
+
const started = Date.parse(state.startedAt)
|
|
353
|
+
if (Number.isFinite(started) && now - started >= state.lifetimeMs) {
|
|
354
|
+
state.stopReason = "lifetime-expired"
|
|
355
|
+
void stopService(root, name).catch(() => {})
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (state.idleTimeoutMs > 0 && now - state.lastOutputAt >= state.idleTimeoutMs) {
|
|
359
|
+
state.stopReason = "idle-output-timeout"
|
|
360
|
+
void stopService(root, name).catch(() => {})
|
|
361
|
+
}
|
|
362
|
+
}, 1000)
|
|
363
|
+
state.lifecycleTimer.unref?.()
|
|
364
|
+
|
|
365
|
+
proc.on("error", (error) => {
|
|
366
|
+
appendLog(state, "error", String(error?.message || error) + "\n")
|
|
367
|
+
state.status = "failed"
|
|
368
|
+
state.finishedAt = new Date().toISOString()
|
|
369
|
+
state.exitCode = 1
|
|
370
|
+
persist(state)
|
|
371
|
+
})
|
|
372
|
+
proc.on("exit", (code, signal) => {
|
|
373
|
+
if (state.lifecycleTimer) clearInterval(state.lifecycleTimer)
|
|
374
|
+
state.lifecycleTimer = null
|
|
375
|
+
state.exitCode = Number.isInteger(code) ? code : null
|
|
376
|
+
state.signal = signal || null
|
|
377
|
+
state.status = state.status === "stopping" ? "stopped" : "exited"
|
|
378
|
+
state.finishedAt = new Date().toISOString()
|
|
379
|
+
persist(state)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
const shouldWait = options.waitReady !== false
|
|
383
|
+
if (!shouldWait) return { ...publicState(state), ready: false, reason: "readiness-not-requested" }
|
|
384
|
+
|
|
385
|
+
// Give spawn errors and immediate exits a chance to surface even when no
|
|
386
|
+
// explicit readiness probe was supplied.
|
|
387
|
+
if (!readyPort && !readyLog) await sleep(200)
|
|
388
|
+
const result = await waitForService(root, name, {
|
|
389
|
+
timeoutMs: readyPort || readyLog ? options.timeoutMs : Math.min(1000, Number(options.timeoutMs || 500)),
|
|
390
|
+
})
|
|
391
|
+
if (!readyPort && !readyLog && result.ready) state.ready = true
|
|
392
|
+
persist(state)
|
|
393
|
+
|
|
394
|
+
if (result.ready) {
|
|
395
|
+
const evidence = await serviceLogs(root, name, { maxChars: 32_000, evidence: true }).catch(() => null)
|
|
396
|
+
return { ...result, evidenceRef: evidence?.evidenceRef || null }
|
|
397
|
+
}
|
|
398
|
+
return result
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function serviceStatus(root = process.cwd(), name) {
|
|
402
|
+
name = normalizeName(name)
|
|
403
|
+
root = realpathSync(path.resolve(root))
|
|
404
|
+
const state = SERVICES.get(keyFor(root, name))
|
|
405
|
+
if (state) {
|
|
406
|
+
const probe = await readiness(state)
|
|
407
|
+
state.ready = probe.ready
|
|
408
|
+
persist(state)
|
|
409
|
+
return { ...publicState(state), ready: probe.ready, reason: probe.reason, checks: probe.checks || [] }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const paths = servicePaths(root, name)
|
|
413
|
+
if (!existsSync(paths.meta)) {
|
|
414
|
+
return { schemaVersion: 1, name, status: "not-found", alive: false, ready: false }
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const prior = JSON.parse(readFileSync(paths.meta, "utf8"))
|
|
418
|
+
return {
|
|
419
|
+
...prior,
|
|
420
|
+
status: "not-owned",
|
|
421
|
+
alive: false,
|
|
422
|
+
ready: false,
|
|
423
|
+
reason: "historical-service-metadata-only",
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
return { schemaVersion: 1, name, status: "unknown", alive: false, ready: false }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function serviceLogs(root = process.cwd(), name, options = {}) {
|
|
431
|
+
name = normalizeName(name)
|
|
432
|
+
root = realpathSync(path.resolve(root))
|
|
433
|
+
const state = SERVICES.get(keyFor(root, name))
|
|
434
|
+
const paths = servicePaths(root, name)
|
|
435
|
+
const maxChars = clamp(options.maxChars, 24_000, 256, 128_000)
|
|
436
|
+
let content = state?.tail || ""
|
|
437
|
+
if (!content && existsSync(paths.log)) {
|
|
438
|
+
try { content = readFileSync(paths.log, "utf8").slice(-maxChars) } catch {}
|
|
439
|
+
} else {
|
|
440
|
+
content = content.slice(-maxChars)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let evidenceRef = null
|
|
444
|
+
if (options.evidence !== false && content) {
|
|
445
|
+
const evidence = await putEvidence(root, content, {
|
|
446
|
+
kind: "service-log",
|
|
447
|
+
source: "ues-service:" + name,
|
|
448
|
+
summary: "Bounded managed-service log snapshot for " + name,
|
|
449
|
+
})
|
|
450
|
+
evidenceRef = evidence.ref
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
schemaVersion: 1,
|
|
454
|
+
name,
|
|
455
|
+
content,
|
|
456
|
+
returnedChars: content.length,
|
|
457
|
+
truncated: Boolean(state?.logTruncated),
|
|
458
|
+
evidenceRef,
|
|
459
|
+
logFile: path.relative(root, paths.log).replaceAll("\\", "/"),
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function stopService(root = process.cwd(), name, options = {}) {
|
|
464
|
+
name = normalizeName(name)
|
|
465
|
+
root = realpathSync(path.resolve(root))
|
|
466
|
+
const key = keyFor(root, name)
|
|
467
|
+
const state = SERVICES.get(key)
|
|
468
|
+
if (!state) {
|
|
469
|
+
return { schemaVersion: 1, name, stopped: false, reason: "service-not-active-in-this-runtime" }
|
|
470
|
+
}
|
|
471
|
+
if (!processIsAlive(state)) {
|
|
472
|
+
SERVICES.delete(key)
|
|
473
|
+
persist(state)
|
|
474
|
+
return { ...publicState(state), stopped: true, reason: "already-exited" }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
state.status = "stopping"
|
|
478
|
+
if (state.lifecycleTimer) clearInterval(state.lifecycleTimer)
|
|
479
|
+
state.lifecycleTimer = null
|
|
480
|
+
persist(state)
|
|
481
|
+
terminateProcessTree(state.proc, { graceMs: clamp(options.graceMs, 1500, 0, 30_000) })
|
|
482
|
+
|
|
483
|
+
const deadline = Date.now() + clamp(options.timeoutMs, 5_000, 100, 30_000)
|
|
484
|
+
while (processIsAlive(state) && Date.now() < deadline) await sleep(50)
|
|
485
|
+
if (processIsAlive(state)) {
|
|
486
|
+
try { state.proc.kill("SIGKILL") } catch {}
|
|
487
|
+
await sleep(50)
|
|
488
|
+
}
|
|
489
|
+
const stillAlive = processIsAlive(state)
|
|
490
|
+
state.status = stillAlive ? "stop-timeout" : "stopped"
|
|
491
|
+
state.finishedAt ||= new Date().toISOString()
|
|
492
|
+
persist(state)
|
|
493
|
+
// Never forget ownership of a process that is still alive. Keeping the
|
|
494
|
+
// handle lets session shutdown or an explicit retry attempt cleanup again.
|
|
495
|
+
if (!stillAlive) SERVICES.delete(key)
|
|
496
|
+
return { ...publicState(state), stopped: !stillAlive, reason: stillAlive ? "stop-timeout" : "stopped" }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function restartService(root = process.cwd(), name, options = {}) {
|
|
500
|
+
name = normalizeName(name)
|
|
501
|
+
root = realpathSync(path.resolve(root))
|
|
502
|
+
const state = SERVICES.get(keyFor(root, name))
|
|
503
|
+
if (!state?.startOptions) throw new Error("UES service cannot restart an unowned service: " + name)
|
|
504
|
+
const startOptions = { ...state.startOptions, ...options, name }
|
|
505
|
+
await stopService(root, name, options)
|
|
506
|
+
return startService(root, startOptions)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export async function stopAllServices(root = null) {
|
|
510
|
+
const base = root ? realpathSync(path.resolve(root)) : null
|
|
511
|
+
const targets = [...SERVICES.values()].filter((state) => !base || state.root === base)
|
|
512
|
+
const results = []
|
|
513
|
+
for (const state of targets) {
|
|
514
|
+
results.push(await stopService(state.root, state.name).catch((error) => ({
|
|
515
|
+
schemaVersion: 1,
|
|
516
|
+
name: state.name,
|
|
517
|
+
stopped: false,
|
|
518
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
519
|
+
})))
|
|
520
|
+
}
|
|
521
|
+
return results
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function managedServiceCount() {
|
|
525
|
+
return [...SERVICES.values()].filter(processIsAlive).length
|
|
526
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const COMMAND_LABELS = Object.freeze({
|
|
2
|
+
run: "UES",
|
|
3
|
+
resume: "UES Resume",
|
|
4
|
+
fix: "UES Fix",
|
|
5
|
+
feature: "UES Feature",
|
|
6
|
+
debug: "UES Debug",
|
|
7
|
+
review: "UES Review",
|
|
8
|
+
audit: "UES Audit",
|
|
9
|
+
plan: "UES Plan",
|
|
10
|
+
research: "UES Research",
|
|
11
|
+
critique: "UES Critique",
|
|
12
|
+
verify: "UES Verify",
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
function clipUnicode(value, maxLength) {
|
|
16
|
+
const chars = Array.from(String(value || ""))
|
|
17
|
+
if (chars.length <= maxLength) return chars.join("")
|
|
18
|
+
if (maxLength <= 1) return "…"
|
|
19
|
+
return chars.slice(0, maxLength - 1).join("") + "…"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compactSessionSubject(value, maxLength = 56) {
|
|
23
|
+
const compact = String(value || "")
|
|
24
|
+
.replace(/\r?\n+/g, " ")
|
|
25
|
+
.replace(/\s+/g, " ")
|
|
26
|
+
.replace(/^[-*#>\s]+/, "")
|
|
27
|
+
.trim()
|
|
28
|
+
return clipUnicode(compact, Math.max(8, Number(maxLength || 56)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function workspaceLabel(cwd) {
|
|
32
|
+
const normalized = String(cwd || "").replaceAll("\\", "/").replace(/\/+$/, "")
|
|
33
|
+
return normalized.split("/").filter(Boolean).at(-1) || "workspace"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function uesSessionName(kind, subject = "", cwd = "", maxLength = 72) {
|
|
37
|
+
const normalizedKind = String(kind || "run").toLowerCase()
|
|
38
|
+
const label = COMMAND_LABELS[normalizedKind] || "UES"
|
|
39
|
+
const subjectBudget = Math.max(8, Number(maxLength || 72) - label.length - 2)
|
|
40
|
+
const detail = compactSessionSubject(subject, subjectBudget) || compactSessionSubject(workspaceLabel(cwd), subjectBudget)
|
|
41
|
+
return clipUnicode(label + ": " + detail, Math.max(16, Number(maxLength || 72)))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sessionNameFromUesInput(input, cwd = "", maxLength = 72) {
|
|
45
|
+
const text = String(input || "").trim()
|
|
46
|
+
const match = text.match(/^\/ues-(run|resume|fix|feature|debug|review|audit|plan|research|critique|verify)(?:\s+([\s\S]*))?$/i)
|
|
47
|
+
if (!match) return null
|
|
48
|
+
return uesSessionName(match[1], match[2] || "", cwd, maxLength)
|
|
49
|
+
}
|