@bybrawe/opencode-loop 0.5.37 → 0.6.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/CHANGELOG.md +26 -0
- package/README.md +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +42 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- package/src/v1.js +1 -0
|
@@ -45,6 +45,21 @@ const NEXT_WORK_PATTERNS = [
|
|
|
45
45
|
/devam (?:edeceğim|ediyorum|etmek gerek)(?=\s|[.,;:!—-]|$)/i,
|
|
46
46
|
]
|
|
47
47
|
|
|
48
|
+
const WAITING_USER_PATTERNS = [
|
|
49
|
+
/(?:^|\n)\s*onay bekleyenler\s*:/i,
|
|
50
|
+
/(?:^|\n)\s*(?:kullanıcı|user) (?:onayı|onayi|aksiyonu|işlemi|islemi|erişimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
|
|
51
|
+
/\b(?:senin|sizin) (?:onayın|onayınız|onayiniz|aksiyonun|aksiyonunuz|erişimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
|
|
52
|
+
/\b(?:onay|erişim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|işlem yapamam|islem yapamam)\b/i,
|
|
53
|
+
/(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
|
|
54
|
+
/\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
|
|
55
|
+
/\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
const WAITING_USER_NEGATIONS = [
|
|
59
|
+
/\b(?:onay|erişim|erisim|yetki) (?:gerekmiyor|gerekli değil|gerekli degil|beklenmiyor)\b/i,
|
|
60
|
+
/\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i,
|
|
61
|
+
]
|
|
62
|
+
|
|
48
63
|
export function isContinuationShorthand(value) {
|
|
49
64
|
return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "))
|
|
50
65
|
}
|
|
@@ -54,6 +69,14 @@ export function isCompletionBoundedContinuation(value) {
|
|
|
54
69
|
return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text))
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
export function isWaitingUserReply(value) {
|
|
73
|
+
const text = String(value || "").trim()
|
|
74
|
+
if (!text) return false
|
|
75
|
+
if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text))) return false
|
|
76
|
+
if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text))) return false
|
|
77
|
+
return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text))
|
|
78
|
+
}
|
|
79
|
+
|
|
57
80
|
export function isTerminalNoWorkReply(value) {
|
|
58
81
|
const text = String(value || "").trim()
|
|
59
82
|
if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text))) return false
|
package/src/source/core/jobs.js
CHANGED
|
@@ -81,3 +81,20 @@ export function goalStatusText(job) {
|
|
|
81
81
|
if (job?.paused) return "paused"
|
|
82
82
|
return status
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
// Preserve the same custom test-command semantics in both host adapters.
|
|
87
|
+
export function applyTestfixPreset(job, defaults = {}) {
|
|
88
|
+
if (defaults.testfixPreset) {
|
|
89
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
90
|
+
const parsedAction = String(job.action || "").trim()
|
|
91
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
92
|
+
if (!usedDefaultAction && job.verifyCommand === defaults.verifyCommand) {
|
|
93
|
+
job.verifyCommand = parsedAction
|
|
94
|
+
job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
95
|
+
} else if (usedDefaultAction && job.verifyCommand !== defaults.verifyCommand) {
|
|
96
|
+
job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${job.verifyCommand || defaultCommand}`
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return job
|
|
100
|
+
}
|
|
@@ -23,11 +23,12 @@ export async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
|
23
23
|
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
24
24
|
const stdout = []
|
|
25
25
|
const stderr = []
|
|
26
|
-
|
|
26
|
+
let timedOut = false
|
|
27
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
27
28
|
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
28
29
|
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
29
30
|
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
30
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ??
|
|
31
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: timedOut ? 124 : (code ?? -1), stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
31
32
|
})
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -36,11 +37,12 @@ export async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
|
36
37
|
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
37
38
|
const stdout = []
|
|
38
39
|
const stderr = []
|
|
39
|
-
|
|
40
|
+
let timedOut = false
|
|
41
|
+
const timer = setTimeout(() => { timedOut = true; try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
40
42
|
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
41
43
|
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
42
44
|
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
43
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ??
|
|
45
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ code: timedOut ? 124 : (code ?? -1), stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
44
46
|
})
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PROGRESS_MD = `# Progress
|
|
5
|
+
|
|
6
|
+
## Current Goal
|
|
7
|
+
Describe the current project goal here.
|
|
8
|
+
|
|
9
|
+
## Agent Rules
|
|
10
|
+
- Do not ask questions unless truly blocked.
|
|
11
|
+
- Make reasonable assumptions and continue.
|
|
12
|
+
- Work on unfinished TODOs in order.
|
|
13
|
+
- Mark completed TODOs with [x].
|
|
14
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
15
|
+
- Run tests, lint, or build when available.
|
|
16
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
17
|
+
|
|
18
|
+
## Active TODO
|
|
19
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
20
|
+
|
|
21
|
+
## Completed
|
|
22
|
+
- [x] Created progress.md.
|
|
23
|
+
|
|
24
|
+
## Backlog Ideas
|
|
25
|
+
- [ ] Add more project-specific tasks here.
|
|
26
|
+
|
|
27
|
+
## Blocked
|
|
28
|
+
- None.
|
|
29
|
+
`
|
|
30
|
+
|
|
31
|
+
function inside(root, candidate) {
|
|
32
|
+
const relative = path.relative(root, candidate)
|
|
33
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// This direct command creates only a missing progress file. It does not own
|
|
37
|
+
// native sessions, mutate Goal/Loop records, or start a model execution.
|
|
38
|
+
export async function initializeProgressFile(directory, argumentsText = "") {
|
|
39
|
+
const root = await fs.realpath(directory)
|
|
40
|
+
const target = String(argumentsText || "").trim() || "progress.md"
|
|
41
|
+
const file = path.resolve(root, target)
|
|
42
|
+
const relative = path.relative(root, file)
|
|
43
|
+
if (!relative || !inside(root, file)) throw new Error("Progress file must be inside the project")
|
|
44
|
+
if ([".git", ".opencode"].includes(relative.split(path.sep)[0].toLowerCase())) {
|
|
45
|
+
throw new Error("Progress file cannot replace a control-plane path")
|
|
46
|
+
}
|
|
47
|
+
const parent = await fs.realpath(path.dirname(file))
|
|
48
|
+
if (!inside(root, parent)) throw new Error("Progress file must remain inside the project; symlink escape refused")
|
|
49
|
+
try {
|
|
50
|
+
// Exclusive creation closes the exists-then-write race and never follows
|
|
51
|
+
// an existing target symlink. Existing parent directories are required.
|
|
52
|
+
await fs.writeFile(file, DEFAULT_PROGRESS_MD, { encoding: "utf8", flag: "wx" })
|
|
53
|
+
return { created: true, file: relative }
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error?.code === "EEXIST") return { created: false, file: relative }
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/source/legacy-v1.js
CHANGED
|
@@ -43,6 +43,7 @@ const executorRuntime = createLoopExecutor({
|
|
|
43
43
|
})
|
|
44
44
|
const {
|
|
45
45
|
clearActiveRun,
|
|
46
|
+
handleSessionError,
|
|
46
47
|
finalizeActiveRun,
|
|
47
48
|
maybeRunDueJobs: runDueJobs,
|
|
48
49
|
sessionIsIdle,
|
|
@@ -242,17 +243,23 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
242
243
|
dispose: async () => { disposeRuntime(directory, client) },
|
|
243
244
|
tool: goalTools(directory),
|
|
244
245
|
"command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
|
|
245
|
-
"chat.message": async (input) => {
|
|
246
|
+
"chat.message": async (input, output) => {
|
|
246
247
|
const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
|
|
247
248
|
sessionID: input?.sessionID,
|
|
248
|
-
messageID: input?.messageID,
|
|
249
|
+
messageID: input?.messageID || output?.message?.id,
|
|
249
250
|
})
|
|
250
251
|
if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
|
|
251
252
|
},
|
|
252
253
|
"tool.execute.before": async (input) => { markToolCallActive(input) },
|
|
253
254
|
"tool.execute.after": async (input) => { markToolCallFinished(input) },
|
|
254
|
-
"experimental.session.compacting": async (input) => {
|
|
255
|
+
"experimental.session.compacting": async (input) => {
|
|
256
|
+
goalSteeringRuntime.setCompacting(input?.sessionID)
|
|
257
|
+
await noteLoopCompactionStarted(directory, input?.sessionID)
|
|
258
|
+
},
|
|
255
259
|
event: async ({ event }) => {
|
|
260
|
+
if (["session.compacted", "session.error", "session.deleted"].includes(event?.type)) {
|
|
261
|
+
goalSteeringRuntime.setCompacting(event?.properties?.sessionID || event?.properties?.info?.id, false)
|
|
262
|
+
}
|
|
256
263
|
if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
|
|
257
264
|
updateSessionRelationshipFromEvent(event)
|
|
258
265
|
if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
|
|
@@ -263,6 +270,10 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
263
270
|
}
|
|
264
271
|
const steering = await goalSteeringRuntime.handleEvent(directory, client, event)
|
|
265
272
|
if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
|
|
273
|
+
if (event.type === "session.error") {
|
|
274
|
+
const handledError = await handleSessionError(directory, client, event)
|
|
275
|
+
if (handledError && event?.properties?.sessionID) rememberSession(directory, client, event.properties.sessionID)
|
|
276
|
+
}
|
|
266
277
|
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
267
278
|
if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
|
|
268
279
|
if (statusUpdate?.idle && !goalSteeringRuntime.shouldSuppressIdle(statusUpdate.sessionID)) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OpenCodeLoopNativePlugin as default, OpenCodeLoopNativePlugin } from "./opencode2/native-plugin.js"
|
|
@@ -69,6 +69,7 @@ export async function activeRunCompletionFromMessages(directory, client, session
|
|
|
69
69
|
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
70
70
|
if (!messages) return "unknown"
|
|
71
71
|
const ordered = orderedSessionMessages(messages)
|
|
72
|
+
if (ordered.length === 0) return "unknown"
|
|
72
73
|
const tail = ordered.at(-1)
|
|
73
74
|
const info = tail?.info || tail
|
|
74
75
|
if (!info || info.role !== "assistant") return "incomplete"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
+
import { DEFAULT_PROGRESS_MD } from "../core/progress.js"
|
|
3
4
|
import { now as defaultNow } from "../core/args.js"
|
|
4
5
|
import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
6
|
import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js"
|
|
@@ -8,33 +9,6 @@ import { dedicatedGoalSummary, findDedicatedGoalForSession as defaultFindDedicat
|
|
|
8
9
|
import { describeJobScheduling, listPersistedLoopSessions as defaultListPersistedLoopSessions } from "../runtime/loop-diagnostics.js"
|
|
9
10
|
|
|
10
11
|
const SERVICE = "opencode-loop"
|
|
11
|
-
const DEFAULT_PROGRESS_MD = `# Progress
|
|
12
|
-
|
|
13
|
-
## Current Goal
|
|
14
|
-
Describe the current project goal here.
|
|
15
|
-
|
|
16
|
-
## Agent Rules
|
|
17
|
-
- Do not ask questions unless truly blocked.
|
|
18
|
-
- Make reasonable assumptions and continue.
|
|
19
|
-
- Work on unfinished TODOs in order.
|
|
20
|
-
- Mark completed TODOs with [x].
|
|
21
|
-
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
22
|
-
- Run tests, lint, or build when available.
|
|
23
|
-
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
24
|
-
|
|
25
|
-
## Active TODO
|
|
26
|
-
- [ ] Review the project structure and pick the next safe improvement.
|
|
27
|
-
|
|
28
|
-
## Completed
|
|
29
|
-
- [x] Created progress.md.
|
|
30
|
-
|
|
31
|
-
## Backlog Ideas
|
|
32
|
-
- [ ] Add more project-specific tasks here.
|
|
33
|
-
|
|
34
|
-
## Blocked
|
|
35
|
-
- None.
|
|
36
|
-
`
|
|
37
|
-
|
|
38
12
|
function requireFunction(value, name) {
|
|
39
13
|
if (typeof value !== "function") throw new TypeError(`createLoopCommandHandlers requires ${name}`)
|
|
40
14
|
return value
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js"
|
|
2
|
-
import { actionKind, jobLabel, isGoalJob } from "../core/jobs.js"
|
|
2
|
+
import { actionKind, jobLabel, isGoalJob, applyTestfixPreset } from "../core/jobs.js"
|
|
3
3
|
import { normalizeLoopScheduleArgs as defaultNormalizeLoopScheduleArgs } from "../core/schedule-syntax.js"
|
|
4
4
|
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
5
5
|
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
@@ -62,17 +62,7 @@ export function createLoopRegistration(options = {}) {
|
|
|
62
62
|
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
|
|
63
63
|
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
64
64
|
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
65
|
-
|
|
66
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
67
|
-
const parsedAction = String(parsed.job.action || "").trim()
|
|
68
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
69
|
-
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
70
|
-
parsed.job.verifyCommand = parsedAction
|
|
71
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
72
|
-
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
73
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
74
|
-
}
|
|
75
|
-
}
|
|
65
|
+
applyTestfixPreset(parsed.job, defaults)
|
|
76
66
|
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
77
67
|
if (!parsed.job.activeRecoveryMs) {
|
|
78
68
|
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
@@ -27,7 +27,7 @@ export function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
|
27
27
|
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
28
28
|
// chat.message can fire before OpenCode assigns the synthetic user message
|
|
29
29
|
// an ID. Treat that pre-dispatch hook as Loop-owned without consuming the
|
|
30
|
-
// pending guard
|
|
30
|
+
// pending guard. Prefer the resolved ID from the trusted hook output.
|
|
31
31
|
if (!id) return true
|
|
32
32
|
entry.pending -= 1
|
|
33
33
|
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
@@ -42,6 +42,7 @@ export function inspectOpenCode2CommandDraft(draft) {
|
|
|
42
42
|
return frozenRecord({
|
|
43
43
|
list: hasFunction(draft, "list"),
|
|
44
44
|
get: hasFunction(draft, "get"),
|
|
45
|
+
add: hasFunction(draft, "add"),
|
|
45
46
|
update: hasFunction(draft, "update"),
|
|
46
47
|
remove: hasFunction(draft, "remove"),
|
|
47
48
|
})
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
export const OPENCODE_LOOP_V2_PRESET_NAMES = Object.freeze([
|
|
2
|
+
"loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev",
|
|
3
|
+
"loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell",
|
|
4
|
+
])
|
|
5
|
+
|
|
1
6
|
export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
|
|
2
7
|
loop: Object.freeze({
|
|
3
8
|
description: "Start an OpenCode auto-continue loop. Usage: /loop 5m <task>",
|
|
@@ -36,29 +41,65 @@ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
|
|
|
36
41
|
template: "OpenCode Loop export command handled locally. Reply exactly: OK.",
|
|
37
42
|
}),
|
|
38
43
|
"loop-help": Object.freeze({
|
|
39
|
-
description: "Show the
|
|
44
|
+
description: "Show the native OpenCode 2 Loop command help.",
|
|
40
45
|
template: "OpenCode Loop help command handled locally. Reply exactly: OK.",
|
|
41
46
|
}),
|
|
42
47
|
"loop-doctor": Object.freeze({
|
|
43
|
-
description: "Show
|
|
48
|
+
description: "Show native OpenCode 2 Loop diagnostics.",
|
|
44
49
|
template: "OpenCode Loop doctor command handled locally. Reply exactly: OK.",
|
|
45
50
|
}),
|
|
46
51
|
"loop-logs": Object.freeze({
|
|
47
|
-
description: "Show recent
|
|
52
|
+
description: "Show recent native OpenCode 2 Loop runtime events.",
|
|
48
53
|
template: "OpenCode Loop logs command handled locally. Reply exactly: OK.",
|
|
49
54
|
}),
|
|
50
55
|
})
|
|
51
56
|
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
export function commandArguments(input) {
|
|
58
|
+
const prompt = input?.prompt
|
|
59
|
+
if (typeof prompt?.text === "string") return prompt.text.trim()
|
|
60
|
+
if (typeof input?.arguments === "string") return input.arguments.trim()
|
|
61
|
+
return ""
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerOpenCode2LoopCommands(draft, options = {}) {
|
|
65
|
+
if (!draft || typeof draft !== "object") {
|
|
66
|
+
throw new Error("OpenCode 2 command transform draft is unavailable")
|
|
55
67
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
|
|
69
|
+
// OpenCode 2.0.11 exposes CommandEditor.add({ name, execute }). Earlier
|
|
70
|
+
// beta hosts exposed update(name, mutate) over command-file definitions.
|
|
71
|
+
// Support both without guessing the host version.
|
|
72
|
+
if (typeof draft.add === "function") {
|
|
73
|
+
const execute = options.execute
|
|
74
|
+
if (typeof execute !== "function") {
|
|
75
|
+
throw new Error("OpenCode 2 command draft.add requires a local Loop command executor")
|
|
76
|
+
}
|
|
77
|
+
for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
78
|
+
draft.add({
|
|
79
|
+
name,
|
|
80
|
+
description: definition.description,
|
|
81
|
+
execute: async (input) => await execute({
|
|
82
|
+
name,
|
|
83
|
+
sessionID: input?.sessionID,
|
|
84
|
+
arguments: commandArguments(input),
|
|
85
|
+
delivery: input?.delivery,
|
|
86
|
+
}),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof draft.update === "function") {
|
|
93
|
+
for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
94
|
+
draft.update(name, (command) => {
|
|
95
|
+
command.template = definition.template
|
|
96
|
+
command.description = definition.description
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
return
|
|
61
100
|
}
|
|
101
|
+
|
|
102
|
+
throw new Error("OpenCode 2 command transform exposes neither add() nor update()")
|
|
62
103
|
}
|
|
63
104
|
|
|
64
105
|
export function parseOpenCode2LoopCommandText(value) {
|
|
@@ -26,6 +26,7 @@ function sameDirectory(expected, actual) {
|
|
|
26
26
|
|
|
27
27
|
export function createOpenCode2EventBridge({
|
|
28
28
|
directory,
|
|
29
|
+
allowInboxCommands = true,
|
|
29
30
|
onEvent = async () => {},
|
|
30
31
|
onError = () => {},
|
|
31
32
|
runtimeManager = createSessionRuntimeManager(),
|
|
@@ -43,6 +44,7 @@ export function createOpenCode2EventBridge({
|
|
|
43
44
|
let managerDisposed = false
|
|
44
45
|
let queue = Promise.resolve()
|
|
45
46
|
const sessionDirectories = new Map()
|
|
47
|
+
const seenEventIDs = new Set()
|
|
46
48
|
|
|
47
49
|
function report(error) {
|
|
48
50
|
try { onError(error) } catch {}
|
|
@@ -78,8 +80,14 @@ export function createOpenCode2EventBridge({
|
|
|
78
80
|
|
|
79
81
|
async function process(raw) {
|
|
80
82
|
if (stopped) return undefined
|
|
83
|
+
if (!allowInboxCommands && raw?.type === "session.inbox.enqueued") return undefined
|
|
81
84
|
const event = normalize(raw)
|
|
82
85
|
if (!event || !sameDirectory(directory, event.directory)) return undefined
|
|
86
|
+
if (typeof raw?.id === "string") {
|
|
87
|
+
if (seenEventIDs.has(raw.id)) return undefined
|
|
88
|
+
seenEventIDs.add(raw.id)
|
|
89
|
+
if (seenEventIDs.size > 2048) seenEventIDs.delete(seenEventIDs.values().next().value)
|
|
90
|
+
}
|
|
83
91
|
|
|
84
92
|
const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
|
|
85
93
|
await onEvent(event, runtime)
|
|
@@ -157,9 +165,13 @@ export function createOpenCode2EventBridge({
|
|
|
157
165
|
|
|
158
166
|
const cleanup = cleanupRegistration(registration)
|
|
159
167
|
registration = undefined
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
try {
|
|
169
|
+
if (cleanup) await cleanup()
|
|
170
|
+
} finally {
|
|
171
|
+
// A host unsubscription failure must not keep our per-session scopes alive.
|
|
172
|
+
sessionDirectories.clear()
|
|
173
|
+
disposeManager(reason)
|
|
174
|
+
}
|
|
163
175
|
return true
|
|
164
176
|
}
|
|
165
177
|
|
|
@@ -40,6 +40,17 @@ export function normalizeOpenCode2NativeEvent(raw) {
|
|
|
40
40
|
})
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
if (type === "session.status") {
|
|
44
|
+
const status = text(record(data.status)?.type)
|
|
45
|
+
if (!sessionID || !status) return undefined
|
|
46
|
+
return Object.freeze({ kind: "session", action: "status", sessionID, directory, status })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (type === "session.idle") {
|
|
50
|
+
if (!sessionID) return undefined
|
|
51
|
+
return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
if (type === "session.execution.started") {
|
|
44
55
|
if (!sessionID) return undefined
|
|
45
56
|
return Object.freeze({ kind: "session", action: "status", sessionID, directory, status: "busy" })
|
|
@@ -50,6 +61,26 @@ export function normalizeOpenCode2NativeEvent(raw) {
|
|
|
50
61
|
return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
if (type === "session.inbox.delivered" || type === "session.inbox.cancelled") {
|
|
65
|
+
if (!sessionID || !text(data.inboxID)) return undefined
|
|
66
|
+
return Object.freeze({ kind: "inbox", action: type.endsWith("delivered") ? "delivered" : "cancelled", sessionID, directory, inboxID: data.inboxID })
|
|
67
|
+
}
|
|
68
|
+
if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
|
|
69
|
+
if (!sessionID) return undefined
|
|
70
|
+
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory })
|
|
71
|
+
}
|
|
72
|
+
if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
|
|
73
|
+
if (!sessionID) return undefined
|
|
74
|
+
return Object.freeze({ kind: "session", action: "error", sessionID, directory, reason: type })
|
|
75
|
+
}
|
|
76
|
+
if (["session.shell.started", "session.shell.ended"].includes(type)) {
|
|
77
|
+
if (!sessionID) return undefined
|
|
78
|
+
const shell = record(data.shell) || {}
|
|
79
|
+
return Object.freeze({ kind: "shell", action: type.endsWith("started") ? "started" : "ended", sessionID, directory, shellID: shell.id, command: shell.command, code: typeof shell.exit === "number" ? shell.exit : -1, status: shell.status, metadata: shell.metadata })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (type === "location.shutdown") return Object.freeze({ kind: "server", action: "disposed", directory })
|
|
83
|
+
|
|
53
84
|
if (type === "session.created") {
|
|
54
85
|
if (!sessionID) return undefined
|
|
55
86
|
return Object.freeze({ kind: "session", action: "created", sessionID, directory: directory || text(record(data.location)?.directory) })
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OpenCodeLoopNativePlugin } from "./native-plugin.js"
|
|
1
2
|
import { inspectOpenCode2Context } from "./capabilities.js"
|
|
2
3
|
import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
|
|
3
4
|
import { createOpenCode2DiagnosticsRuntime } from "./diagnostics.js"
|
|
@@ -11,30 +12,75 @@ export { OPENCODE_LOOP_V2_COMMANDS }
|
|
|
11
12
|
export const OpenCodeLoopV2ExperimentalPlugin = {
|
|
12
13
|
id: OPENCODE_LOOP_V2_PLUGIN_ID,
|
|
13
14
|
async setup(ctx) {
|
|
15
|
+
if (typeof ctx?.session?.hook === "function") return OpenCodeLoopNativePlugin.setup(ctx)
|
|
14
16
|
const capabilities = inspectOpenCode2Context(ctx)
|
|
15
17
|
if (!capabilities.commandTransform) {
|
|
16
18
|
throw new Error("OpenCode 2 command.transform capability is unavailable")
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
const commandRegistration = await ctx.command.transform(registerOpenCode2LoopCommands)
|
|
20
|
-
if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
|
|
21
|
-
await commandRegistration?.dispose?.()
|
|
22
|
-
return undefined
|
|
23
|
-
}
|
|
24
|
-
|
|
25
21
|
let promptRuntime
|
|
26
22
|
let diagnosticsRuntime
|
|
23
|
+
const hostVersion = String(ctx?.app?.version || "").trim()
|
|
24
|
+
const promiseStreamHost = /^2\./.test(hostVersion)
|
|
25
|
+
const requireBusyBeforeIdle = promiseStreamHost
|
|
27
26
|
const logRuntime = createOpenCode2LogRuntime()
|
|
27
|
+
const runtimeDirectory = String(ctx?.location?.directory || ctx?.options?.directory || "").trim() || undefined
|
|
28
|
+
|
|
28
29
|
const onRuntimeEvent = async (event) => {
|
|
29
30
|
const promptResult = await promptRuntime?.onEvent(event)
|
|
30
31
|
await logRuntime.record(event, promptResult)
|
|
31
32
|
if (promptResult?.handled) return promptResult
|
|
32
33
|
return await diagnosticsRuntime?.onEvent(event) ?? promptResult
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
|
|
36
|
+
const commandRegistration = await ctx.command.transform((draft) => {
|
|
37
|
+
return registerOpenCode2LoopCommands(draft, {
|
|
38
|
+
execute: async ({ name, sessionID, arguments: argumentsText, delivery }) => {
|
|
39
|
+
if (!promptRuntime || !diagnosticsRuntime) {
|
|
40
|
+
throw new Error("OpenCode Loop V2 runtime is not ready")
|
|
41
|
+
}
|
|
42
|
+
const commandEvent = Object.freeze({
|
|
43
|
+
kind: "command",
|
|
44
|
+
action: "executed",
|
|
45
|
+
sessionID: String(sessionID || ""),
|
|
46
|
+
directory: runtimeDirectory,
|
|
47
|
+
name,
|
|
48
|
+
arguments: argumentsText,
|
|
49
|
+
delivery,
|
|
50
|
+
})
|
|
51
|
+
const result = await onRuntimeEvent(commandEvent)
|
|
52
|
+
|
|
53
|
+
// OpenCode 2.0.11 command callbacks are truly local: creating a Loop
|
|
54
|
+
// job does not itself start a model turn, so no execution-succeeded
|
|
55
|
+
// idle event follows the command. Kick one local idle boundary for
|
|
56
|
+
// commands that can make prompt work immediately due. Once the prompt
|
|
57
|
+
// is admitted, the real execution event stream owns subsequent turns.
|
|
58
|
+
if (result?.handled && result?.accepted && ["loop", "loop-now", "loop-resume"].includes(name)) {
|
|
59
|
+
await onRuntimeEvent(Object.freeze({
|
|
60
|
+
kind: "session",
|
|
61
|
+
action: "idle",
|
|
62
|
+
sessionID: String(sessionID || ""),
|
|
63
|
+
directory: runtimeDirectory,
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
return result
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
|
|
72
|
+
await commandRegistration?.dispose?.()
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
const runtime = createOpenCode2RuntimeAdapter(ctx, {
|
|
76
|
+
directory: runtimeDirectory,
|
|
77
|
+
onEvent: onRuntimeEvent,
|
|
78
|
+
eventSubscribeStyle: promiseStreamHost ? "stream" : "auto",
|
|
79
|
+
})
|
|
35
80
|
promptRuntime = createOpenCode2PromptRuntime({
|
|
36
81
|
prompt: (request) => runtime.prompt(request),
|
|
37
82
|
command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
|
|
83
|
+
requireBusyBeforeIdle,
|
|
38
84
|
})
|
|
39
85
|
diagnosticsRuntime = createOpenCode2DiagnosticsRuntime({
|
|
40
86
|
prompt: (request) => runtime.prompt(request),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { lstat, readFile } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
// Read-only interoperation with Goal's session-sharded store. An unreadable or
|
|
6
|
+
// unsafe live shard is not permission to take over autonomous continuation.
|
|
7
|
+
export async function nativeGoalReservesSession(directory, sessionID) {
|
|
8
|
+
const shard = createHash("sha256").update(sessionID).digest("hex").slice(0, 32)
|
|
9
|
+
const root = path.resolve(directory)
|
|
10
|
+
const parts = [".opencode", "goals", `${shard}.json`]
|
|
11
|
+
let file = root
|
|
12
|
+
try {
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
file = path.join(file, part)
|
|
15
|
+
const info = await lstat(file)
|
|
16
|
+
if (info.isSymbolicLink()) return true
|
|
17
|
+
}
|
|
18
|
+
const goal = JSON.parse(await readFile(file, "utf8"))
|
|
19
|
+
if (goal?.schemaVersion !== 1 || goal?.sessionID !== sessionID || typeof goal?.id !== "string") return true
|
|
20
|
+
return !["completed", "cleared"].includes(goal.status)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return error?.code !== "ENOENT"
|
|
23
|
+
}
|
|
24
|
+
}
|