@bybrawe/opencode-loop 0.5.38 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +33 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/examples/OPENCODE2-0.6.1.md +60 -0
  5. package/package.json +15 -9
  6. package/scripts/branch-hygiene.mjs +326 -0
  7. package/scripts/comprehensive-test.mjs +12 -6
  8. package/scripts/control-plane-until-test.mjs +39 -0
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +118 -60
  13. package/scripts/install-test.mjs +3 -3
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +114 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/opencode-host.mjs +47 -0
  36. package/scripts/opencode2-loop-canary.mjs +72 -4
  37. package/scripts/package-native-smoke.mjs +31 -0
  38. package/scripts/publish-workflow-test.mjs +60 -19
  39. package/scripts/pulse-check.mjs +246 -0
  40. package/scripts/session-status-test.mjs +12 -0
  41. package/src/index.js +87 -48
  42. package/src/native.js +3135 -0
  43. package/src/plugin.js +19 -0
  44. package/src/server.js +2164 -4560
  45. package/src/source/core/jobs.js +17 -0
  46. package/src/source/core/process.js +6 -4
  47. package/src/source/core/progress.js +58 -0
  48. package/src/source/legacy-v1.js +9 -3
  49. package/src/source/native.js +1 -0
  50. package/src/source/opencode/host.js +1 -0
  51. package/src/source/opencode/loop-commands.js +1 -27
  52. package/src/source/opencode/loop-registration.js +2 -12
  53. package/src/source/opencode/messages.js +1 -1
  54. package/src/source/opencode2/commands.js +9 -4
  55. package/src/source/opencode2/event-bridge.js +15 -3
  56. package/src/source/opencode2/events.js +20 -0
  57. package/src/source/opencode2/experimental.js +2 -0
  58. package/src/source/opencode2/native-companion.js +24 -0
  59. package/src/source/opencode2/native-plugin.js +153 -0
  60. package/src/source/opencode2/native-policy.js +106 -0
  61. package/src/source/opencode2/native-runtime.js +464 -0
  62. package/src/source/opencode2/native-shell.js +81 -0
  63. package/src/source/opencode2/prompt-runtime.js +2 -0
  64. package/src/source/opencode2/session-location.js +16 -0
  65. package/src/source/runtime/goal-steering.js +17 -3
  66. package/src/source/runtime/job-workspace.js +11 -2
  67. package/src/source/runtime/session-status.js +11 -1
  68. package/src/source/server.js +9 -1
  69. package/src/v1.js +1 -0
  70. package/src/v2.js +10 -0
@@ -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
- const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
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 ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
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
- const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
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 ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
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
+ }
@@ -243,17 +243,23 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
243
243
  dispose: async () => { disposeRuntime(directory, client) },
244
244
  tool: goalTools(directory),
245
245
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
246
- "chat.message": async (input) => {
246
+ "chat.message": async (input, output) => {
247
247
  const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
248
248
  sessionID: input?.sessionID,
249
- messageID: input?.messageID,
249
+ messageID: input?.messageID || output?.message?.id,
250
250
  })
251
251
  if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
252
252
  },
253
253
  "tool.execute.before": async (input) => { markToolCallActive(input) },
254
254
  "tool.execute.after": async (input) => { markToolCallFinished(input) },
255
- "experimental.session.compacting": async (input) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
255
+ "experimental.session.compacting": async (input) => {
256
+ goalSteeringRuntime.setCompacting(input?.sessionID)
257
+ await noteLoopCompactionStarted(directory, input?.sessionID)
258
+ },
256
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
+ }
257
263
  if (event.type === "session.compacted") await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID)
258
264
  updateSessionRelationshipFromEvent(event)
259
265
  if (event.type === "message.updated") updateSessionExecutionContext(event?.properties?.info)
@@ -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
- if (defaults.testfixPreset) {
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; message.updated will later bind and retain the real ID.
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)
@@ -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,20 +41,20 @@ 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 experimental OpenCode 2 Loop command help.",
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 experimental OpenCode 2 Loop diagnostics.",
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 experimental OpenCode 2 Loop runtime events.",
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
- function commandArguments(input) {
57
+ export function commandArguments(input) {
53
58
  const prompt = input?.prompt
54
59
  if (typeof prompt?.text === "string") return prompt.text.trim()
55
60
  if (typeof input?.arguments === "string") return input.arguments.trim()
@@ -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
- if (cleanup) await cleanup()
161
- sessionDirectories.clear()
162
- disposeManager(reason)
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
 
@@ -61,6 +61,26 @@ export function normalizeOpenCode2NativeEvent(raw) {
61
61
  return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
62
62
  }
63
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
+
64
84
  if (type === "session.created") {
65
85
  if (!sessionID) return undefined
66
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,6 +12,7 @@ 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")
@@ -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
+ }
@@ -0,0 +1,153 @@
1
+ import path from "node:path"
2
+ import { createNativeSessionLocationGuard } from "./session-location.js"
3
+ import { createNativeShellHost } from "./native-shell.js"
4
+ import { createNativeLoopRuntime } from "./native-runtime.js"
5
+ import { createOpenCode2EventBridge } from "./event-bridge.js"
6
+ import { registerOpenCode2LoopCommands, OPENCODE_LOOP_V2_PRESET_NAMES, commandArguments } from "./commands.js"
7
+ import { readSmallTextFile, appendLoopLog } from "../core/process.js"
8
+ import { stateDir } from "../core/state.js"
9
+ import { initializeProgressFile } from "../core/progress.js"
10
+
11
+ const HELP = `OpenCode Loop: native OpenCode 2 runtime
12
+ /loop 0s <task> --max-runs 3
13
+ /loop 5m <task> --verify "npm test" --pause-on-verify-fail
14
+ /loop --watch progress.md <task>
15
+ /loop 0s <task> --prompt-file instructions.md --include-file progress.md
16
+ /loop 0s <task> --compact-every 3
17
+ /loop 5m --shell npm test
18
+ /loop 15m --compact /compact
19
+ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /loop-prompt, /loop-command, /loop-cmd, /loop-shell, /loop-compact.
20
+ /loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
21
+ Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
22
+ Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
23
+ Scheduled shell commands run as bounded local child processes when the plugin context has no session.shell.
24
+ Scheduled/manual compaction requires session.compact on the host; unavailable capability is rejected before creating a job. Native automatic compaction is always left to OpenCode.
25
+ An unfinished dedicated Goal reserves its session; Loop will not override it.
26
+ --timeout pauses future iterations without aborting the current native model/tool/compaction operation.
27
+ --safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
28
+ Native sessions stay serialized; --allow-overlap is rejected.
29
+ Use @bybrawe/opencode-goal and /goal for native Goal contracts. Legacy Loop Goal state is not silently migrated.
30
+ An uncertain admission after a restart is paused for review instead of being replayed automatically.`
31
+
32
+ async function cleanup(value) {
33
+ if (typeof value === "function") await value()
34
+ else if (typeof value?.dispose === "function") await value.dispose()
35
+ else if (typeof value?.unsubscribe === "function") await value.unsubscribe()
36
+ }
37
+
38
+ export const OpenCodeLoopNativePlugin = Object.freeze({
39
+ id: "@bybrawe/opencode-loop",
40
+ async setup(ctx) {
41
+ for (const [label, fn] of [["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
42
+ if (typeof fn !== "function") throw new Error(`Native OpenCode Loop requires ${label}; use OpenCode 2.0.18 or newer.`)
43
+ }
44
+ const directory = String(ctx.location?.directory || ctx.options?.directory || "").trim()
45
+ if (!directory) throw new Error("Native OpenCode Loop requires a project directory")
46
+ const ownsSession = createNativeSessionLocationGuard({
47
+ directory, workspaceID: ctx.location?.workspaceID,
48
+ getSession: (input) => ctx.session.get(input),
49
+ })
50
+ const registrations = []
51
+ const lifecycleAbort = new AbortController()
52
+ const prompt = (request) => ctx.session.prompt({ ...request, delivery: request.delivery || "queue", metadata: { ...request.metadata, opencode_loop_v2: true } })
53
+ const shellHost = createNativeShellHost({
54
+ directory,
55
+ onTerminal: (event) => runtime.onEvent(event),
56
+ onError: (error) => { void appendLoopLog(directory, "v2-shell-error", { message: String(error?.message || error) }).catch(() => {}) },
57
+ })
58
+ const runtime = createNativeLoopRuntime({
59
+ directory,
60
+ scopeAllowed: (scope) => ownsSession(scope.sessionID),
61
+ prompt,
62
+ command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
63
+ wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
64
+ compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact({ ...request, delivery: "queue" }) : undefined,
65
+ shell: typeof ctx.session.shell === "function" ? (request) => ctx.session.shell(request) : (request) => shellHost.dispatch(request),
66
+ // cancel() returns void. The durable inbox.cancelled event, not this
67
+ // response alone, proves that an undelivered input can be refunded.
68
+ cancel: typeof ctx.session.inbox?.cancel === "function" ? async (request) => { await ctx.session.inbox.cancel(request); return false } : undefined,
69
+ onError: (error) => { void appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {}) },
70
+ })
71
+ const bridge = createOpenCode2EventBridge({ directory, allowInboxCommands: false, onEvent: async (event) => {
72
+ if (event.sessionID && !(event.kind === "session" && event.action === "deleted") && !await ownsSession(event.sessionID)) return
73
+ return runtime.onEvent(event)
74
+ }, onError: (error) => { void appendLoopLog(directory, "v2-native-event-error", { message: String(error?.message || error) }).catch(() => {}) } })
75
+ let closed = false
76
+ let disposeTask
77
+ function dispose() {
78
+ if (disposeTask) return disposeTask
79
+ closed = true
80
+ disposeTask = Promise.resolve().then(async () => {
81
+ const errors = []
82
+ // Stop accepting events before aborting next(); iterator.return() alone
83
+ // cannot wake an async generator blocked on a public server stream.
84
+ const stopped = bridge.dispose("native-plugin-disposed").catch((error) => { errors.push(error) })
85
+ lifecycleAbort.abort()
86
+ for (const action of [() => runtime.dispose(), () => shellHost.dispose()]) {
87
+ try { await action() } catch (error) { errors.push(error) }
88
+ }
89
+ await stopped
90
+ for (const registration of [...registrations].reverse()) {
91
+ try { await cleanup(registration) } catch (error) { errors.push(error) }
92
+ }
93
+ if (errors.length) throw new AggregateError(errors, "Native Loop cleanup failed")
94
+ })
95
+ return disposeTask
96
+ }
97
+ async function execute({ name, sessionID, arguments: argumentsText }) {
98
+ if (closed) throw new Error("Native Loop is disposed")
99
+ if (!await ownsSession(sessionID)) throw new Error("Native Loop cannot resolve this session in the plugin location; use its own project/worktree.")
100
+ if (closed) throw new Error("Native Loop is disposed")
101
+ if (name === "loop-init") {
102
+ const output = await initializeProgressFile(directory, argumentsText)
103
+ await prompt({ sessionID, text: output.created ? `Created ${output.file}.` : `${output.file} already exists; preserved unchanged.`, resume: false })
104
+ return { handled: true, accepted: true, ...output }
105
+ }
106
+ if (["loop-help", "loop-doctor", "loop-logs"].includes(name)) {
107
+ const text = name === "loop-logs"
108
+ ? (await readSmallTextFile(path.join(stateDir(directory), "loop.log"), 2_000_000)).split("\n").slice(-60).join("\n") || "No Loop log entries."
109
+ : name === "loop-doctor"
110
+ ? `Native OpenCode Loop\nHost: ${ctx.app?.version || "unknown"}\nDirectory: ${directory}\nPrompt hooks: enabled\nCompaction ownership: native host\nDelivery: durable queue\nScheduled timers: ${runtime.scheduledCount()}\nUse /loop-status for paused/admitted job state.`
111
+ : HELP
112
+ await prompt({ sessionID, text, resume: false })
113
+ return { handled: true, accepted: true }
114
+ }
115
+ const event = { kind: "command", action: "executed", directory, sessionID, name, arguments: argumentsText }
116
+ const result = await runtime.onEvent(event)
117
+ if (result?.accepted === false) throw new Error(result.error || result.blockers?.join(" ") || "Loop command rejected")
118
+ const shouldWake = result?.job ? result.job.immediate !== false : ["loop-now", "loop-resume"].includes(name)
119
+ if (result?.accepted && shouldWake) await runtime.wake(event)
120
+ return result
121
+ }
122
+ try {
123
+ registrations.push(await ctx.session.hook("prompt", async (event) => {
124
+ if (closed || event.metadata?.opencode_loop_v2 === true || !await ownsSession(event.sessionID)) return
125
+ if (closed) return
126
+ return runtime.onEvent({ kind: "foreground", directory, sessionID: event.sessionID })
127
+ }))
128
+ registrations.push(await ctx.session.hook("compaction", async (event) => {
129
+ if (closed || !await ownsSession(event.sessionID)) return
130
+ if (!closed) return runtime.onEvent({ kind: "compaction", action: "started", directory, sessionID: event.sessionID })
131
+ }))
132
+ registrations.push(await ctx.command.transform((draft) => {
133
+ registerOpenCode2LoopCommands(draft, { execute })
134
+ if (typeof draft.add !== "function") return
135
+ for (const name of [...OPENCODE_LOOP_V2_PRESET_NAMES, "loop-init"]) {
136
+ draft.add({
137
+ name,
138
+ description: name === "loop-init" ? "Create a missing project-local progress file." : `Native Loop ${name.slice(5)} preset`,
139
+ execute: (input) => execute({ name, sessionID: input.sessionID, arguments: commandArguments(input) }),
140
+ })
141
+ }
142
+ }))
143
+ await bridge.attach(() => ctx.event.subscribe({ signal: lifecycleAbort.signal }))
144
+ await appendLoopLog(directory, "v2-native-ready", { host: ctx.app?.version || "unknown" })
145
+ return dispose
146
+ } catch (error) {
147
+ await dispose().catch(() => {})
148
+ throw error
149
+ }
150
+ },
151
+ })
152
+
153
+ export default OpenCodeLoopNativePlugin
@@ -0,0 +1,106 @@
1
+ import { stat } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { runShellCommand, appendLoopLog } from "../core/process.js"
4
+ import { createJobWorkspaceRuntime, dangerousShell } from "../runtime/job-workspace.js"
5
+
6
+ export function createNativeJobPolicy(options = {}) {
7
+ const now = options.now || Date.now
8
+ const run = options.runShellCommand || runShellCommand
9
+ const workspace = options.workspace || createJobWorkspaceRuntime({ toast: async () => {} })
10
+
11
+ function pause(job, reason, failed = false) {
12
+ job.paused = true
13
+ job.pauseReason = reason
14
+ if (failed) job.failureCount = (job.failureCount || 0) + 1
15
+ return false
16
+ }
17
+
18
+ async function shell(scope, job, command, phase) {
19
+ if (job.safe && dangerousShell(command)) {
20
+ return { code: -1, stdout: "", stderr: `Blocked ${phase} by --safe command guard`, blocked: true }
21
+ }
22
+ const result = await run(command, scope.directory, Math.max(1, Number(job.timeoutMs) || 120_000))
23
+ await appendLoopLog(scope.directory, `v2-${phase}`, {
24
+ sessionID: scope.sessionID, job: job.name || job.id, code: result.code,
25
+ output: String(result.stdout || "").slice(-8000), error: String(result.stderr || "").slice(-8000),
26
+ })
27
+ return result
28
+ }
29
+
30
+ async function checkStop(scope, job) {
31
+ const created = Date.parse(job.createdAt)
32
+ if (job.maxRuntimeMs > 0 && Number.isFinite(created) && now() - created >= job.maxRuntimeMs) {
33
+ job.enabled = false
34
+ return pause(job, "max-runtime")
35
+ }
36
+ if (job.stopFile) {
37
+ try {
38
+ await stat(path.resolve(scope.directory, job.stopFile))
39
+ job.enabled = false
40
+ return pause(job, "stop-file")
41
+ } catch (error) { if (error?.code !== "ENOENT") throw error }
42
+ }
43
+ if (await workspace.untilReached(scope.directory, job)) {
44
+ job.enabled = false
45
+ return pause(job, "until-reached")
46
+ }
47
+ return true
48
+ }
49
+
50
+ async function prepare(scope, job, isCurrent = () => true) {
51
+ if (!await checkStop(scope, job) || !isCurrent()) return false
52
+ if (job.dryRun) return true
53
+ if (job.preflightCommand) {
54
+ const result = await shell(scope, job, job.preflightCommand, "preflight")
55
+ job.lastPreflightCode = result.code
56
+ if (result.code !== 0) return pause(job, "preflight-failed", true)
57
+ }
58
+ if (!isCurrent()) return false
59
+ if (job.branch) {
60
+ await workspace.ensureBranch(scope.directory, job, undefined, scope.sessionID)
61
+ if (!job.branchDone) return pause(job, "branch-unavailable", true)
62
+ }
63
+ return isCurrent()
64
+ }
65
+
66
+ async function finish(scope, job, isCurrent = () => true) {
67
+ if (!isCurrent() || job.dryRun) return
68
+ if (job.verifyCommand) {
69
+ const result = await shell(scope, job, job.verifyCommand, "verify")
70
+ job.lastVerifyCode = result.code
71
+ job.lastVerifyAt = now()
72
+ job.lastVerifyOutput = `${result.stdout || ""}\n${result.stderr || ""}`.trim().slice(-8000)
73
+ if (result.code !== 0) {
74
+ job.failureCount = (job.failureCount || 0) + 1
75
+ if (result.blocked || job.pauseOnVerifyFail || (job.maxFailures > 0 && job.failureCount >= job.maxFailures)) pause(job, "verification-failed")
76
+ return
77
+ }
78
+ job.failureCount = 0
79
+ }
80
+ if (!isCurrent()) return
81
+ if (job.postrunCommand) {
82
+ const result = await shell(scope, job, job.postrunCommand, "postrun")
83
+ job.lastPostrunCode = result.code
84
+ if (result.code !== 0) {
85
+ pause(job, "postrun-failed", true)
86
+ return
87
+ }
88
+ }
89
+ if (isCurrent()) await workspace.createCheckpoint(scope.directory, scope.sessionID, job, undefined)
90
+ }
91
+
92
+ async function notify(scope, job, reason) {
93
+ if (!job.notifyCommand || job.dryRun) return
94
+ // Placeholders are data, not a route for event/error strings to become shell syntax.
95
+ const literal = (value) => String(value || "").replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 160)
96
+ const command = String(job.notifyCommand).replace(/\{reason\}/g, literal(reason)).replace(/\{job\}/g, literal(job.name || job.id))
97
+ await shell(scope, job, command, "notify")
98
+ }
99
+
100
+ return {
101
+ pause, checkStop, prepare, finish, notify, shell,
102
+ buildPrompt: workspace.buildPrompt,
103
+ snapshotPaths: workspace.snapshotPaths,
104
+ watchChanged: workspace.watchChanged,
105
+ }
106
+ }