@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +31 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/package.json +12 -8
  5. package/scripts/branch-hygiene.mjs +326 -0
  6. package/scripts/comprehensive-test.mjs +19 -10
  7. package/scripts/control-plane-until-test.mjs +39 -0
  8. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  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 +105 -58
  13. package/scripts/install-test.mjs +6 -1
  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 +107 -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/network-recovery-test.mjs +38 -1
  36. package/scripts/opencode-host.mjs +47 -0
  37. package/scripts/opencode2-loop-canary.mjs +119 -10
  38. package/scripts/package-native-smoke.mjs +31 -0
  39. package/scripts/publish-workflow-test.mjs +60 -14
  40. package/scripts/pulse-check.mjs +246 -0
  41. package/scripts/run-finalization-test.mjs +33 -0
  42. package/scripts/server-entry-test.mjs +15 -0
  43. package/scripts/session-error-recovery-test.mjs +186 -0
  44. package/scripts/session-status-test.mjs +42 -0
  45. package/scripts/v2-command-adapter-test.mjs +14 -2
  46. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  47. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  48. package/src/index.js +227 -53
  49. package/src/native.js +3135 -0
  50. package/src/server.js +4055 -0
  51. package/src/source/core/continuation.js +23 -0
  52. package/src/source/core/jobs.js +17 -0
  53. package/src/source/core/process.js +6 -4
  54. package/src/source/core/progress.js +58 -0
  55. package/src/source/legacy-v1.js +14 -3
  56. package/src/source/native.js +1 -0
  57. package/src/source/opencode/host.js +1 -0
  58. package/src/source/opencode/loop-commands.js +1 -27
  59. package/src/source/opencode/loop-registration.js +2 -12
  60. package/src/source/opencode/messages.js +1 -1
  61. package/src/source/opencode2/capabilities.js +1 -0
  62. package/src/source/opencode2/commands.js +52 -11
  63. package/src/source/opencode2/event-bridge.js +15 -3
  64. package/src/source/opencode2/events.js +31 -0
  65. package/src/source/opencode2/experimental.js +53 -7
  66. package/src/source/opencode2/native-companion.js +24 -0
  67. package/src/source/opencode2/native-plugin.js +153 -0
  68. package/src/source/opencode2/native-policy.js +106 -0
  69. package/src/source/opencode2/native-runtime.js +464 -0
  70. package/src/source/opencode2/native-shell.js +81 -0
  71. package/src/source/opencode2/prompt-runtime.js +38 -6
  72. package/src/source/opencode2/runtime-adapter.js +15 -2
  73. package/src/source/opencode2/session-location.js +16 -0
  74. package/src/source/runtime/executor.js +83 -0
  75. package/src/source/runtime/goal-steering.js +17 -3
  76. package/src/source/runtime/job-workspace.js +11 -2
  77. package/src/source/runtime/run-finalization.js +10 -0
  78. package/src/source/runtime/session-status.js +16 -2
  79. package/src/source/runtime/terminal-guard.js +32 -7
  80. package/src/source/server.js +26 -0
  81. 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
@@ -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
+ }
@@ -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) => { await noteLoopCompactionStarted(directory, input?.sessionID) },
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
- 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)
@@ -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 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
- export function registerOpenCode2LoopCommands(draft) {
53
- if (!draft || typeof draft.update !== "function") {
54
- throw new Error("OpenCode 2 command draft.update capability is unavailable")
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
- for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
57
- draft.update(name, (command) => {
58
- command.template = definition.template
59
- command.description = definition.description
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
- 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
 
@@ -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
- const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
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
+ }