@bybrawe/opencode-loop 0.5.36 → 0.5.38

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 (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -6
  4. package/scripts/comprehensive-test.mjs +7 -4
  5. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  6. package/scripts/install-node.mjs +4 -2
  7. package/scripts/install-test.mjs +5 -0
  8. package/scripts/network-recovery-test.mjs +38 -1
  9. package/scripts/opencode2-loop-canary.mjs +48 -7
  10. package/scripts/publish-workflow-test.mjs +5 -0
  11. package/scripts/run-finalization-test.mjs +33 -0
  12. package/scripts/server-entry-test.mjs +15 -0
  13. package/scripts/session-error-recovery-test.mjs +186 -0
  14. package/scripts/session-status-test.mjs +30 -0
  15. package/scripts/smoke-test.mjs +21 -0
  16. package/scripts/v2-command-adapter-test.mjs +14 -2
  17. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  18. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  19. package/src/index.js +257 -96
  20. package/src/server.js +6451 -0
  21. package/src/source/core/continuation.js +23 -0
  22. package/src/source/legacy-v1.js +34 -6
  23. package/src/source/opencode2/capabilities.js +1 -0
  24. package/src/source/opencode2/commands.js +44 -8
  25. package/src/source/opencode2/events.js +11 -0
  26. package/src/source/opencode2/experimental.js +51 -7
  27. package/src/source/opencode2/prompt-runtime.js +36 -6
  28. package/src/source/opencode2/runtime-adapter.js +15 -2
  29. package/src/source/runtime/executor.js +83 -0
  30. package/src/source/runtime/run-finalization.js +10 -0
  31. package/src/source/runtime/session-status.js +5 -1
  32. package/src/source/runtime/terminal-guard.js +32 -7
  33. package/src/source/server.js +18 -0
@@ -157,6 +157,36 @@ try {
157
157
  completion = "unknown"
158
158
  }
159
159
 
160
+ {
161
+ const sessionID = "concurrent-recovery-log-dedupe"
162
+ track(sessionID)
163
+ runtime.clearSessionStatus(sessionID)
164
+ activeRuns.set(sessionID, { jobId: "job-dedupe", job: { id: "job-dedupe", name: "dedupe" }, startedAt: 1_000 })
165
+ completion = "completed"
166
+ let releaseStatus
167
+ const gate = new Promise((resolve) => { releaseStatus = resolve })
168
+ let calls = 0
169
+ const client = {
170
+ session: {
171
+ status: async () => {
172
+ calls += 1
173
+ await gate
174
+ return { data: { [sessionID]: { type: "busy" } } }
175
+ },
176
+ },
177
+ }
178
+ const before = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
179
+ const pending = Array.from({ length: 20 }, () => runtime.sessionStatusType(client, sessionID, "/repo"))
180
+ await Promise.resolve()
181
+ assert.equal(calls, 20, "all concurrent status reads should be in flight before release")
182
+ releaseStatus()
183
+ assert.deepEqual(await Promise.all(pending), Array(20).fill("idle"))
184
+ const after = logs.filter((entry) => entry[1] === "status-message-complete-recovery" && entry[2]?.sessionID === sessionID).length
185
+ assert.equal(after - before, 1, "one active run must emit at most one copy of the same stale-recovery diagnostic")
186
+ activeRuns.delete(sessionID)
187
+ completion = "unknown"
188
+ }
189
+
160
190
  {
161
191
  const sessionID = "retry-remains-owned"
162
192
  track(sessionID)
@@ -86,6 +86,27 @@ try {
86
86
  "opencode_loop_goal_progress",
87
87
  ])
88
88
 
89
+ const dedicatedGoalRoot = path.join(directory, ".opencode", "goals")
90
+ await fs.mkdir(dedicatedGoalRoot, { recursive: true })
91
+ await fs.writeFile(path.join(dedicatedGoalRoot, "dedicated.json"), JSON.stringify({
92
+ schemaVersion: 1,
93
+ id: "dedicated-goal",
94
+ sessionID,
95
+ status: "active",
96
+ }), "utf8")
97
+ for (const [name, args] of [
98
+ ["opencode_loop_goal_blocked", { reason: "Need deploy", needed: "User deploy" }],
99
+ ["opencode_loop_goal_progress", { summary: "Checked status", next: "Continue" }],
100
+ ["opencode_loop_goal_complete", { summary: "Done", evidence: "tests passed" }],
101
+ ]) {
102
+ const guarded = await hooks.tool[name].execute(args, { directory, sessionID })
103
+ assert.equal(guarded.title, "Dedicated Goal owns lifecycle")
104
+ assert.match(guarded.output, /opencode_loop_goal_\*/)
105
+ assert.match(guarded.output, /opencode_goal_wait_for_user/)
106
+ assert.match(guarded.output, /opencode_goal_blocked/)
107
+ }
108
+ await fs.rm(dedicatedGoalRoot, { recursive: true, force: true })
109
+
89
110
  const output = { parts: [{ type: "text", text: "original command body" }] }
90
111
  await hooks["command.execute.before"]({
91
112
  command: "loop-goal",
@@ -56,7 +56,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
56
56
  await adapter.start()
57
57
  const result = await adapter.command({ sessionID: "ses_adapter", command: "/review", arguments: "--quick" })
58
58
  assert.deepEqual(result, { accepted: true })
59
- assert.deepEqual(commands, [{ sessionID: "ses_adapter", command: "review", arguments: "--quick" }])
59
+ assert.deepEqual(commands, [{
60
+ sessionID: "ses_adapter",
61
+ name: "review",
62
+ command: "review",
63
+ arguments: "--quick",
64
+ text: "--quick",
65
+ }])
60
66
  assert.equal(Object.prototype.hasOwnProperty.call(commands[0], "id"), false)
61
67
  await adapter.dispose()
62
68
  }
@@ -103,7 +109,13 @@ async function waitFor(predicate, description, timeoutMs = 2_000) {
103
109
  })
104
110
  events.push({ directory, payload: { type: "session.idle", properties: { sessionID } } })
105
111
  await waitFor(() => commands.length === 1, "V2 command dispatch")
106
- assert.deepEqual(commands[0], { sessionID, command: "review", arguments: "--quick" })
112
+ assert.deepEqual(commands[0], {
113
+ sessionID,
114
+ name: "review",
115
+ command: "review",
116
+ arguments: "--quick",
117
+ text: "--quick",
118
+ })
107
119
  } finally {
108
120
  await cleanup?.()
109
121
  await rm(directory, { recursive: true, force: true })
@@ -3,7 +3,7 @@ import plugin, {
3
3
  OPENCODE_LOOP_V2_COMMANDS,
4
4
  OPENCODE_LOOP_V2_PLUGIN_ID,
5
5
  } from "../src/source/opencode2/experimental.js"
6
- import { parseOpenCode2LoopCommandText } from "../src/source/opencode2/commands.js"
6
+ import { parseOpenCode2LoopCommandText, registerOpenCode2LoopCommands } from "../src/source/opencode2/commands.js"
7
7
  import {
8
8
  OPENCODE_LOOP_V2_COMMAND_SOURCE,
9
9
  OPENCODE_LOOP_V2_HOST_REQUIREMENTS,
@@ -77,13 +77,43 @@ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
77
77
  assert.equal(command?.description, definition.description)
78
78
  }
79
79
 
80
- let missingDraftUpdateFailed = false
80
+ let missingDraftMutationFailed = false
81
81
  try {
82
82
  await registered({})
83
83
  } catch (error) {
84
- missingDraftUpdateFailed = /command draft\.update capability is unavailable/.test(String(error))
84
+ missingDraftMutationFailed = /neither add\(\) nor update\(\)/.test(String(error))
85
85
  }
86
- assert.equal(missingDraftUpdateFailed, true)
86
+ assert.equal(missingDraftMutationFailed, true)
87
+
88
+ const addedCommands = new Map()
89
+ const executedCommands = []
90
+ registerOpenCode2LoopCommands({
91
+ add(definition) {
92
+ addedCommands.set(definition.name, definition)
93
+ },
94
+ }, {
95
+ execute: async (event) => {
96
+ executedCommands.push(structuredClone(event))
97
+ },
98
+ })
99
+ assert.deepEqual([...addedCommands.keys()], Object.keys(OPENCODE_LOOP_V2_COMMANDS))
100
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
101
+ const command = addedCommands.get(name)
102
+ assert.equal(command?.name, name)
103
+ assert.equal(command?.description, definition.description)
104
+ assert.equal(typeof command?.execute, "function")
105
+ }
106
+ await addedCommands.get("loop").execute({
107
+ sessionID: "ses_2_0_11",
108
+ prompt: { text: "devam et" },
109
+ delivery: "steer",
110
+ })
111
+ assert.deepEqual(executedCommands, [{
112
+ name: "loop",
113
+ sessionID: "ses_2_0_11",
114
+ arguments: "devam et",
115
+ delivery: "steer",
116
+ }])
87
117
 
88
118
  assert.deepEqual(inspectOpenCode2Context(currentContext), {
89
119
  commandTransform: true,
@@ -98,6 +128,7 @@ assert.deepEqual(inspectOpenCode2Context(currentContext), {
98
128
  assert.deepEqual(inspectOpenCode2CommandDraft(currentDraft), {
99
129
  list: true,
100
130
  get: true,
131
+ add: false,
101
132
  update: true,
102
133
  remove: true,
103
134
  })
@@ -164,6 +164,8 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
164
164
  sessionID: "ses_v2",
165
165
  noReply: true,
166
166
  parts: [{ type: "text", text: "OpenCode loop status:\nNo active loop jobs." }],
167
+ text: "OpenCode loop status:\nNo active loop jobs.",
168
+ resume: false,
167
169
  agent: "build",
168
170
  model: { providerID: "canary", modelID: "canary" },
169
171
  })
@@ -301,6 +303,79 @@ async function verifyTwoTurnLoop({ directory, sessionID, events, prompts, native
301
303
  }
302
304
  }
303
305
 
306
+ {
307
+ const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-2-0-11-command-add-"))
308
+ const sessionID = "ses_v2_2_0_11_add"
309
+ const events = controllableStream()
310
+ const prompts = []
311
+ const commands = new Map()
312
+ const ctx = {
313
+ app: { name: "opencode", version: "2.0.11", channel: "latest" },
314
+ location: { directory },
315
+ command: {
316
+ transform: async (callback) => {
317
+ await callback({
318
+ add(definition) {
319
+ commands.set(definition.name, definition)
320
+ },
321
+ })
322
+ return { dispose: async () => {} }
323
+ },
324
+ },
325
+ event: {
326
+ // OpenCode 2.0.11 exposes subscribe(options), so Function.length === 1
327
+ // even though it returns an AsyncIterable rather than accepting a callback.
328
+ subscribe(options) {
329
+ assert.equal(options, undefined)
330
+ return events.stream
331
+ },
332
+ },
333
+ session: {
334
+ prompt: async (input) => {
335
+ prompts.push(structuredClone(input))
336
+ return { accepted: true }
337
+ },
338
+ },
339
+ }
340
+
341
+ const cleanup = await OpenCodeLoopV2ExperimentalPlugin.setup(ctx)
342
+ try {
343
+ assert.ok(commands.has("loop"), "OpenCode 2.0.11 add() command editor must receive /loop")
344
+ await commands.get("loop").execute({
345
+ sessionID,
346
+ prompt: { text: "0s --max-runs 2 continue exact 2.0.11 command path" },
347
+ delivery: "steer",
348
+ })
349
+
350
+ const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
351
+ await waitFor(async () => {
352
+ try { return JSON.parse(await readFile(stateFile, "utf8")).jobs?.length === 1 } catch { return false }
353
+ }, "OpenCode 2.0.11 direct command state creation")
354
+
355
+ await waitFor(() => prompts.length === 1, "OpenCode 2.0.11 direct command first local-kick dispatch")
356
+ assert.equal(prompts[0].sessionID, sessionID)
357
+ assert.match(prompts[0].text || "", /continue exact 2\.0\.11 command path/)
358
+
359
+ events.push({
360
+ type: "session.status",
361
+ location: { directory },
362
+ data: { sessionID, status: { type: "busy" } },
363
+ })
364
+ events.push({
365
+ type: "session.status",
366
+ location: { directory },
367
+ data: { sessionID, status: { type: "idle" } },
368
+ })
369
+ await waitFor(() => prompts.length === 2, "OpenCode 2.0.11 second dispatch from native idle status")
370
+ const state = JSON.parse(await readFile(stateFile, "utf8"))
371
+ assert.equal(state.jobs[0].runCount, 2)
372
+ assert.equal(state.jobs[0].enabled, false)
373
+ } finally {
374
+ await cleanup?.()
375
+ await rm(directory, { recursive: true, force: true })
376
+ }
377
+ }
378
+
304
379
  {
305
380
  let commandDisposals = 0
306
381
  const result = await OpenCodeLoopV2ExperimentalPlugin.setup({