@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.38
4
+
5
+ Long-session provider-error, waiting-user, and OpenCode 2.0.11 compatibility release.
6
+
7
+ - Route Loop-owned OpenCode `session.error` events into stable V1 run ownership instead of letting a terminal provider failure disappear into later idle/stale recovery.
8
+ - Pause fail-closed on non-retryable provider/auth/policy/unknown session errors, persisting the error name/message/timestamp so an unlimited idle loop cannot keep dispatching after a terminal host failure.
9
+ - Refund and exponentially back off retryable terminal `APIError` attempts without consuming logical `runCount`, `--max-runs`, or ordinary failure budget; keep `MessageAbortedError` on the existing user/session-abort path.
10
+ - Deduplicate identical stale/completed-status recovery diagnostics per active run, preventing concurrent status probes from amplifying one recovery event into hundreds of `loop.log` records.
11
+ - Keep ordinary `/loop devam et` intentionally unlimited, but pause after two consecutive current assistant replies explicitly say autonomous work is waiting for user approval, input, access, credentials, or another user action. A reply naming concrete autonomous next work clears the waiting-user streak.
12
+ - Add exact regressions for the reported Loto Diyarı free-tier provider error, the Turkish `Onay bekleyenler: commit + push, canlı migration, cihaz testi` steady-state, retryable provider recovery, abort classification, recovery-log deduplication, waiting-user UX, and generated-bundle parity.
13
+ - Harden Windows regression reliability with bounded temporary-directory cleanup retries and bounded waits for persisted stale-busy recovery, without changing production scheduler semantics.
14
+ - Add a dual local-plugin server entry that preserves the OpenCode 1.x `server` contract while exposing the OpenCode 2.x `{ id, setup }` contract required by OpenCode 2.0.11.
15
+ - Support the current OpenCode 2.0.11 command editor and session-command field shapes while retaining the older beta adapter path; normalize current native session execution/status events and select the official Promise event stream without relying on function arity or command-reload timing.
16
+ - Prove OpenCode 2.0.11 compatibility with an exact real-host gate: the local plugin is active, Loop commands register, a two-turn autonomous Loop reaches the deterministic provider exactly twice, control lifecycle commands mutate persisted state correctly, and `--max-runs` stops further turns.
17
+ - Verify the exact feature heads across Ubuntu/Windows CI, minimum/latest OpenCode plugin compatibility, real OpenCode Loop canaries, Dedicated Goal Coexistence, V2 Lifecycle Contract, OpenCode 2 Real Adapter, and the Single-file Bundle Gate.
18
+
19
+ ## 0.5.37
20
+
21
+ Dedicated Goal tool-isolation release.
22
+
23
+ - Detect a persisted dedicated @bybrawe/opencode-goal Goal before any experimental `opencode_loop_goal_*` lifecycle tool can mutate Loop Goal state.
24
+ - Fail closed when a dedicated Goal owns the session and explicitly direct the model to `opencode_goal_progress`, `opencode_goal_wait_for_user`, `opencode_goal_blocked`, or `opencode_goal_complete` instead of returning a misleading experimental-Goal result.
25
+ - Mark the Loop lifecycle tools as experimental `/loop-goal`-only in their model-visible descriptions, preventing the exact blocker-tool collision reproduced by the long Loto Diyarı session.
26
+ - Keep modular source and the committed single-file stable bundle byte-for-byte synchronized with the Bun build output.
27
+ - Add coexistence smoke coverage for blocked, progress, and completion collisions; exact feature-head CI, Dedicated Goal Coexistence, and Single-file Bundle Gate pass across the required host/platform lanes.
28
+
3
29
  ## 0.5.36
4
30
 
5
31
  Empty-turn fail-safe release.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
6
6
 
7
- > **Current stable release: `0.5.36`.**
7
+ > **Current stable release: `0.5.38`.**
8
8
 
9
9
  ## Install or update
10
10
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.36",
3
+ "version": "0.5.38",
4
4
  "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/index.js",
9
+ "./server": "./src/server.js"
9
10
  },
10
11
  "files": [
11
12
  "src",
@@ -18,11 +19,11 @@
18
19
  "CHANGELOG.md"
19
20
  ],
20
21
  "scripts": {
21
- "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
+ "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
23
  "build:plugin:npm": "npm run build:plugin",
23
- "prepack": "node --check src/index.js",
24
- "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
25
- "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
24
+ "prepack": "node --check src/index.js && node --check src/server.js",
25
+ "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
26
+ "test": "node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
26
27
  "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
27
28
  "install:global": "node scripts/install-with-goals.mjs",
28
29
  "pack:zip": "node scripts/make-zip.mjs"
@@ -165,7 +165,7 @@ async function createHarness(options = {}) {
165
165
  },
166
166
  async cleanup() {
167
167
  await hooks.dispose?.()
168
- await fs.rm(directory, { recursive: true, force: true })
168
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
169
169
  },
170
170
  }
171
171
  }
@@ -486,8 +486,11 @@ async function testStaleBusyUsesCompletedAssistantTail() {
486
486
  // and can reconcile the completed assistant tail instead of trusting cached busy.
487
487
  await delay(1_700)
488
488
  await h.command("loop-now", "stale-complete")
489
- const state = await h.readState()
490
- assert.ok(state.jobs[0].lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
489
+ const state = await waitForValue(async () => {
490
+ const candidate = await h.readState()
491
+ return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
492
+ }, 3_000)
493
+ assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
491
494
  assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
492
495
  } finally {
493
496
  await h.cleanup()
@@ -643,7 +646,7 @@ async function testInitializationDoesNotWaitForLocalApi() {
643
646
  assert.equal(typeof hooks.event, "function")
644
647
  await hooks.dispose?.()
645
648
  } finally {
646
- await fs.rm(directory, { recursive: true, force: true })
649
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
647
650
  }
648
651
  }
649
652
 
@@ -28,7 +28,12 @@ export default {
28
28
  const sessionID = String(created?.id || created?.data?.id || created?.sessionID || "")
29
29
  if (!sessionID) throw new Error("session.create returned no session id")
30
30
  try {
31
- await ctx.session.command({ sessionID, command: COMMAND_SENTINEL })
31
+ await ctx.session.command({
32
+ sessionID,
33
+ name: COMMAND_SENTINEL,
34
+ command: COMMAND_SENTINEL,
35
+ text: "",
36
+ })
32
37
  commandFieldProbe = {
33
38
  matched: false,
34
39
  sessionID,
@@ -18,7 +18,7 @@ const installerArgs = process.argv.slice(2)
18
18
  const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
19
19
 
20
20
  if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
21
- console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update copies the Loop command files and local command agent, and keeps an existing npm package entry pinned to the exact version.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
21
+ console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update copies the dual OpenCode 1/2 Loop plugin plus command files and local command agent, and keeps an existing npm package entry pinned to the exact version.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
22
22
  process.exit(0)
23
23
  }
24
24
 
@@ -311,7 +311,9 @@ async function installOrUpdate() {
311
311
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
312
312
  } else {
313
313
  await ensureDependency()
314
- await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
314
+ // Copy the dual contract bundle: OpenCode 1.x consumes default.server,
315
+ // while OpenCode 2.x consumes default.setup from the same local file.
316
+ await copyFile(join(root, "src", "server.js"), join(pluginDir, "opencode-loop.ts"))
315
317
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
316
318
  }
317
319
 
@@ -62,6 +62,11 @@ try {
62
62
  const localResult = await runInstaller(local)
63
63
  assert.equal(localResult.code, 0, localResult.stderr)
64
64
  assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
65
+ assert.equal(
66
+ await fs.readFile(path.join(local, "plugins", "opencode-loop.ts"), "utf8"),
67
+ await fs.readFile(path.join(root, "src", "server.js"), "utf8"),
68
+ "local install must copy the dual OpenCode 1/2 server bundle",
69
+ )
65
70
  assert.equal(await commandCount(local), packagedCommandCount)
66
71
  assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
67
72
  const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict"
2
- import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../src/source/core/continuation.js"
2
+ import { isCompletionBoundedContinuation, isTerminalNoWorkReply, isWaitingUserReply } from "../src/source/core/continuation.js"
3
3
  import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "../src/source/runtime/network-recovery.js"
4
4
  import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
5
5
  import { applyTerminalContinuationGuard } from "../src/source/runtime/terminal-guard.js"
@@ -90,6 +90,10 @@ assert.equal(isCompletionBoundedContinuation("devam et"), false)
90
90
  assert.equal(isCompletionBoundedContinuation("devam et bitene kadar devam tamamen projeyi bitir"), true)
91
91
  assert.equal(isTerminalNoWorkReply("Proje tamamlandı — 28/28 test yeşil. Yapılacak iş yok. Sıfır bilinen hata."), true)
92
92
  assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build doğrulaması."), false)
93
+ assert.equal(isWaitingUserReply("Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi."), true)
94
+ assert.equal(isWaitingUserReply("Waiting for your approval before I can push the release."), true)
95
+ assert.equal(isWaitingUserReply("Onay gerekmiyor; sıradaki iş build doğrulaması."), false)
96
+ assert.equal(isWaitingUserReply("Deployment needs your approval, but next step is local verification."), false)
93
97
 
94
98
  {
95
99
  const terminalMessages = [{
@@ -122,6 +126,39 @@ assert.equal(isTerminalNoWorkReply("Proje tamamlandı ama sıradaki iş build do
122
126
  const untouched = await applyTerminalContinuationGuard("/repo", client, "s", infinite)
123
127
  assert.equal(untouched.terminal, false)
124
128
  assert.equal(untouched.job.paused, false, "plain /loop devam et must remain intentionally infinite")
129
+
130
+ const waitingMessages = [{
131
+ info: { role: "assistant", time: { created: 2_200, completed: 2_300 } },
132
+ parts: [{ type: "text", text: "Durum sabit.\n\nOnay bekleyenler: commit + push, canlı migration, cihaz testi." }],
133
+ }]
134
+ const waitingClient = { session: { messages: async () => ({ data: waitingMessages }) } }
135
+ const waiting = {
136
+ id: "waiting",
137
+ action: "devam et",
138
+ scheduleMode: "idle",
139
+ lastRunAt: 1_500,
140
+ paused: false,
141
+ }
142
+ let waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waiting)
143
+ assert.equal(waitingResult.waitingUser, true)
144
+ assert.equal(waitingResult.waitingUserPausedNow, false)
145
+ assert.equal(waitingResult.job.waitingUserCount, 1)
146
+ assert.equal(waitingResult.job.paused, false)
147
+ waitingResult = await applyTerminalContinuationGuard("/repo", waitingClient, "s", waitingResult.job)
148
+ assert.equal(waitingResult.waitingUserPausedNow, true, "two explicit user-dependency replies should pause plain continuation")
149
+ assert.equal(waitingResult.job.paused, true)
150
+ assert.equal(waitingResult.job.lastFailureReason, "waiting_user")
151
+
152
+ const productiveMessages = [{
153
+ info: { role: "assistant", time: { created: 2_400, completed: 2_500 } },
154
+ parts: [{ type: "text", text: "Deployment needs your approval, but next step is local verification." }],
155
+ }]
156
+ const productiveClient = { session: { messages: async () => ({ data: productiveMessages }) } }
157
+ const productive = { ...waitingResult.job, paused: false, waitingUserCount: 1 }
158
+ const productiveResult = await applyTerminalContinuationGuard("/repo", productiveClient, "s", productive)
159
+ assert.equal(productiveResult.waitingUser, false)
160
+ assert.equal(productiveResult.job.waitingUserCount, 0, "a reply with autonomous next work must clear waiting-user streak")
161
+ assert.equal(productiveResult.job.paused, false)
125
162
  }
126
163
 
127
164
  {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
2
2
  import { createServer } from "node:http"
3
3
  import net from "node:net"
4
4
  import { execFileSync, spawn } from "node:child_process"
5
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
5
+ import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
6
6
  import os from "node:os"
7
7
  import path from "node:path"
8
8
  import process from "node:process"
@@ -14,6 +14,8 @@ const EXPECTED_TURNS = 2
14
14
  const EXPECTED_COMMANDS = ["loop", "loop-pause", "loop-resume", "loop-stop", "loop-remove", "loop-clear"]
15
15
  const SERVER_USERNAME = "opencode"
16
16
  const SERVER_PASSWORD = "opencode-loop-v2-canary"
17
+ const OPENCODE_BINARY = process.env.OPENCODE2_BINARY || "opencode2"
18
+ const CURRENT_COMMAND_FIELDS = process.env.OPENCODE2_CURRENT_COMMAND_FIELDS === "1"
17
19
 
18
20
  function appendLog(current, chunk, limit = 100_000) {
19
21
  return (current + String(chunk)).slice(-limit)
@@ -161,13 +163,36 @@ function startProvider() {
161
163
  }
162
164
 
163
165
  function bridgePluginSource() {
164
- return `import { writeFile } from "node:fs/promises"
166
+ return `import { appendFile, writeFile } from "node:fs/promises"
165
167
 
166
168
  export default {
167
169
  id: "bybrawe.opencode-loop.v2.loop-canary-bridge",
168
170
  async setup(ctx) {
169
171
  const module = await import(process.env.OPENCODE_LOOP_V2_PLUGIN_URL)
170
- const cleanup = await module.default.setup(ctx)
172
+ const traceFile = process.env.OPENCODE_LOOP_V2_EVENT_TRACE
173
+ const pluginTraceFile = process.env.OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE
174
+ const pluginContext = typeof ctx?.event?.subscribe === "function"
175
+ ? {
176
+ ...ctx,
177
+ event: {
178
+ ...ctx.event,
179
+ subscribe(options) {
180
+ const source = ctx.event.subscribe(options)
181
+ return {
182
+ async *[Symbol.asyncIterator]() {
183
+ for await (const event of source) {
184
+ const line = JSON.stringify(event) + "\\n"
185
+ if (traceFile) await appendFile(traceFile, line, "utf8")
186
+ if (pluginTraceFile) await appendFile(pluginTraceFile, line, "utf8")
187
+ yield event
188
+ }
189
+ },
190
+ }
191
+ },
192
+ },
193
+ }
194
+ : ctx
195
+ const cleanup = await module.default.setup(pluginContext)
171
196
  await writeFile(process.env.OPENCODE_LOOP_V2_MARKER, JSON.stringify({ activated: true }, null, 2), "utf8")
172
197
  return async () => {
173
198
  if (typeof cleanup === "function") await cleanup()
@@ -187,8 +212,10 @@ async function main() {
187
212
 
188
213
  const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-v2-e2e-"))
189
214
  const home = path.join(workspace, ".home")
190
- const pluginDir = path.join(workspace, ".opencode", "plugins")
215
+ const pluginDir = path.join(home, ".config", "opencode", "plugins")
191
216
  const marker = path.join(workspace, "v2-real-adapter-marker.json")
217
+ const eventTrace = path.join(workspace, "v2-native-events.jsonl")
218
+ const pluginEventTrace = path.join(workspace, "v2-plugin-events.jsonl")
192
219
  const provider = startProvider()
193
220
  const providerPort = await provider.listen()
194
221
  let server
@@ -241,6 +268,8 @@ async function main() {
241
268
  XDG_CACHE_HOME: path.join(home, ".cache"),
242
269
  OPENCODE_LOOP_V2_PLUGIN_URL: pathToFileURL(path.join(repoRoot, "src", "source", "opencode2", "experimental.js")).href,
243
270
  OPENCODE_LOOP_V2_MARKER: marker,
271
+ OPENCODE_LOOP_V2_EVENT_TRACE: eventTrace,
272
+ OPENCODE_LOOP_V2_PLUGIN_EVENT_TRACE: pluginEventTrace,
244
273
  OPENCODE_SERVER_USERNAME: SERVER_USERNAME,
245
274
  OPENCODE_SERVER_PASSWORD: SERVER_PASSWORD,
246
275
  OPENCODE_DISABLE_AUTOUPDATE: "true",
@@ -253,6 +282,10 @@ async function main() {
253
282
  if (sessionID) {
254
283
  try { state = await readFile(path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`), "utf8") } catch {}
255
284
  }
285
+ let nativeEvents = "missing"
286
+ let pluginEvents = "missing"
287
+ try { nativeEvents = await readFile(eventTrace, "utf8") } catch {}
288
+ try { pluginEvents = await readFile(pluginEventTrace, "utf8") } catch {}
256
289
  return [
257
290
  `apiPrefix=${String(apiPrefix)}`,
258
291
  `commands=${JSON.stringify([...latestCommands])}`,
@@ -260,14 +293,18 @@ async function main() {
260
293
  `sessionID=${sessionID || "none"}`,
261
294
  `commandError=${String(commandError ?? "none")}`,
262
295
  `state=${state}`,
296
+ `binary=${OPENCODE_BINARY}`,
297
+ `currentCommandFields=${CURRENT_COMMAND_FIELDS}`,
263
298
  `serverExit=${server?.exitCode}`,
299
+ `nativeEvents=${nativeEvents.slice(-40_000)}`,
300
+ `pluginEvents=${pluginEvents.slice(-40_000)}`,
264
301
  `serverLog=${serverLog}`,
265
302
  ].join("\n")
266
303
  }
267
304
 
268
305
  try {
269
306
  const port = await reservePort()
270
- server = spawn("opencode2", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
307
+ server = spawn(OPENCODE_BINARY, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
271
308
  cwd: workspace,
272
309
  env,
273
310
  windowsHide: true,
@@ -336,9 +373,13 @@ async function main() {
336
373
  sessionID = String(session?.id ?? "")
337
374
  assert.ok(sessionID, `OpenCode 2 did not create a session: ${createdResponse.text}\n${await diagnostics()}`)
338
375
 
376
+ const commandBody = (command, argumentsText = "") => CURRENT_COMMAND_FIELDS
377
+ ? { name: command, text: argumentsText }
378
+ : { command, arguments: argumentsText }
379
+
339
380
  const commandPromise = request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
340
381
  method: "POST",
341
- body: JSON.stringify({ command: "loop", arguments: `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}` }),
382
+ body: JSON.stringify(commandBody("loop", `0s --max-runs ${EXPECTED_TURNS} ${LOOP_OBJECTIVE}`)),
342
383
  }, 120_000).catch((error) => {
343
384
  commandError = error
344
385
  return null
@@ -371,7 +412,7 @@ async function main() {
371
412
  const sendControl = async (command, argumentsText = "") => {
372
413
  const response = await request(`${apiPrefix}/session/${encodeURIComponent(sessionID)}/command`, {
373
414
  method: "POST",
374
- body: JSON.stringify({ command, arguments: argumentsText }),
415
+ body: JSON.stringify(commandBody(command, argumentsText)),
375
416
  }, 120_000)
376
417
  if (!response.ok) throw new Error(`${command} failed: HTTP ${response.status} ${response.text}\n${await diagnostics()}`)
377
418
  return response
@@ -14,6 +14,11 @@ assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/, "release tag must target the
14
14
  assert.match(workflow, /group:\s*npm-release/, "npm publication must be serialized")
15
15
  assert.match(workflow, /cancel-in-progress:\s*false/, "a newer release must not cancel an in-flight publication")
16
16
 
17
+ assert.match(workflow, /Decide whether npm publish is needed/, "release reruns must detect an already-published immutable version")
18
+ assert.match(workflow, /steps\.npm-state\.outputs\.publish == 'true'/, "npm publish must be idempotently gated")
19
+ assert.match(workflow, /for attempt in \{1\.\.60\}/, "registry verification must allow bounded multi-minute propagation")
20
+ assert.match(workflow, /--registry=https:\/\/registry\.npmjs\.org/, "registry checks must target the authoritative npm registry")
21
+
17
22
  const publishIndex = workflow.indexOf("npm publish --access public")
18
23
  const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
19
24
  const tagIndex = workflow.indexOf("Create tag and GitHub release")
@@ -98,6 +98,39 @@ assert.equal(failedPostrun.failureCount, 1)
98
98
  assert.equal(failedPostrun.paused, true)
99
99
  assert.equal(notifications.at(-1)[2], "postrun_failed")
100
100
 
101
+ const waitingNotifications = []
102
+ const waitingLogs = []
103
+ const waitingToasts = []
104
+ const waitingRuntime = createRunFinalizationRuntime({
105
+ runGoalChecks: async (_directory, _sessionID, job) => job,
106
+ applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
107
+ createCheckpoint: async () => {},
108
+ scheduleDueWork: async () => {},
109
+ writeState: async () => {},
110
+ appendLoopLog: async (...args) => { waitingLogs.push(args) },
111
+ notifyJob: async (...args) => { waitingNotifications.push(args) },
112
+ toast: async (...args) => { waitingToasts.push(args) },
113
+ applyTerminalContinuationGuard: async (_directory, _client, _sessionID, job) => ({
114
+ job: {
115
+ ...job,
116
+ paused: true,
117
+ waitingUserCount: 2,
118
+ lastFailureReason: "waiting_user",
119
+ },
120
+ terminal: false,
121
+ pausedNow: false,
122
+ waitingUser: true,
123
+ waitingUserPausedNow: true,
124
+ text: "Onay bekleyenler: commit + push, canlı migration, cihaz testi.",
125
+ }),
126
+ })
127
+ const waitingJob = { id: "waiting-user", name: "default", enabled: true, paused: false }
128
+ await waitingRuntime.finalizeJob("/repo", {}, "session-waiting", { jobs: [waitingJob] }, waitingJob, { id: "before-waiting" })
129
+ assert.equal(waitingNotifications.at(-1)[2], "waiting_user")
130
+ assert.equal(waitingLogs.at(-1)[1], "waiting-user")
131
+ assert.match(waitingToasts.at(-1)[1], /waiting for explicit user approval/)
132
+ assert.equal(waitingToasts.at(-1)[2], "warning")
133
+
101
134
  const goal = {
102
135
  id: "goal-job",
103
136
  kind: "goal",
@@ -0,0 +1,15 @@
1
+ import assert from "node:assert/strict"
2
+ import plugin, {
3
+ OPENCODE_LOOP_PLUGIN_ID,
4
+ OpenCodeLoopPlugin,
5
+ OpenCodeLoopV2ExperimentalPlugin,
6
+ } from "../src/source/server.js"
7
+
8
+ assert.equal(plugin.id, OPENCODE_LOOP_PLUGIN_ID)
9
+ assert.equal(plugin.id, "@bybrawe/opencode-loop")
10
+ assert.equal(typeof plugin.server, "function", "OpenCode 1.x requires default.server()")
11
+ assert.equal(typeof plugin.setup, "function", "OpenCode 2.x requires default.setup()")
12
+ assert.strictEqual(plugin.server, OpenCodeLoopPlugin)
13
+ assert.equal(typeof OpenCodeLoopV2ExperimentalPlugin.setup, "function")
14
+
15
+ console.log("dual OpenCode 1/2 server entry contract passed")
@@ -0,0 +1,186 @@
1
+ import assert from "node:assert/strict"
2
+ import { createLoopExecutor } from "../src/source/runtime/executor.js"
3
+ import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
4
+
5
+ function clone(value) {
6
+ return JSON.parse(JSON.stringify(value))
7
+ }
8
+
9
+ let clock = 1_000_000
10
+ const directory = "/repo"
11
+ const states = new Map()
12
+ const logs = []
13
+ const toasts = []
14
+ const schedules = []
15
+ const notifications = []
16
+
17
+ function stateKey(sessionID) {
18
+ return `${directory}:${sessionID}`
19
+ }
20
+
21
+ function setJob(sessionID, overrides = {}) {
22
+ states.set(stateKey(sessionID), {
23
+ jobs: [{
24
+ id: `${sessionID}-job`,
25
+ name: `${sessionID}-job`,
26
+ action: "devam et",
27
+ enabled: true,
28
+ paused: false,
29
+ intervalMs: 0,
30
+ runCount: 0,
31
+ maxRuns: 0,
32
+ maxRuntimeMs: 0,
33
+ timeoutMs: 0,
34
+ noOverlap: true,
35
+ ...overrides,
36
+ }],
37
+ })
38
+ }
39
+
40
+ const executor = createLoopExecutor({
41
+ workspace: {
42
+ buildPrompt: async (_directory, job) => job.action,
43
+ ensureBranch: async (_directory, job) => job,
44
+ watchChanged: async () => false,
45
+ untilReached: async () => false,
46
+ createCheckpoint: async () => {},
47
+ },
48
+ goalPolicy: {
49
+ runGoalChecks: async (_directory, _sessionID, job) => job,
50
+ applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
51
+ },
52
+ scheduler: {
53
+ rememberSession: () => {},
54
+ scheduleDueWork: async (...args) => schedules.push(args),
55
+ },
56
+ now: () => clock,
57
+ readState: async (_directory, sessionID) => clone(states.get(stateKey(sessionID)) || { jobs: [] }),
58
+ writeState: async (_directory, sessionID, state) => states.set(stateKey(sessionID), clone(state)),
59
+ appendLoopLog: async (...args) => logs.push(args),
60
+ notifyJob: async (...args) => notifications.push(args),
61
+ toast: async (...args) => toasts.push(args),
62
+ runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
63
+ fireSdk: async () => ({ data: {} }),
64
+ sdkCall: async () => ({}),
65
+ normalizedModelRef: () => undefined,
66
+ compactTuiCommandName: () => undefined,
67
+ guardLoopOwnedUserMessage: () => {},
68
+ dangerousShell: () => false,
69
+ activeRunCompletionFromMessages: async () => "unknown",
70
+ busyRetryMs: 5_000,
71
+ networkRetryMaxMs: 60_000,
72
+ })
73
+
74
+ const client = {
75
+ session: {
76
+ status: async () => ({ data: {} }),
77
+ prompt: async () => ({ data: {} }),
78
+ abort: async () => ({ data: {} }),
79
+ },
80
+ }
81
+
82
+ {
83
+ const sessionID = "policy-error"
84
+ setJob(sessionID)
85
+ await executor.maybeRunDueJobs(directory, client, sessionID)
86
+ assert.ok(executor.getActiveRun(sessionID))
87
+ assert.equal(states.get(stateKey(sessionID)).jobs[0].runCount, 1)
88
+
89
+ const handled = await executor.handleSessionError(directory, client, {
90
+ type: "session.error",
91
+ properties: {
92
+ sessionID,
93
+ error: {
94
+ name: "APIError",
95
+ data: {
96
+ message: "OpenCode's free tier can only be used from within OpenCode",
97
+ statusCode: 403,
98
+ isRetryable: false,
99
+ },
100
+ },
101
+ },
102
+ })
103
+
104
+ assert.equal(handled, true)
105
+ assert.equal(executor.getActiveRun(sessionID), undefined)
106
+ const job = states.get(stateKey(sessionID)).jobs[0]
107
+ assert.equal(job.paused, true, "terminal provider policy error must pause an infinite idle loop")
108
+ assert.equal(job.failureCount, 1)
109
+ assert.equal(job.lastFailureReason, "session_error")
110
+ assert.equal(job.lastSessionErrorName, "APIError")
111
+ assert.equal(job.lastSessionError, "OpenCode's free tier can only be used from within OpenCode")
112
+ assert.equal(job.lastSessionErrorAt, clock)
113
+ assert.ok(notifications.some((call) => call[1]?.id === job.id && call[2] === "session_error"))
114
+ assert.ok(logs.some((call) => call[1] === "session-error" && call[2]?.sessionID === sessionID && call[2]?.paused === true))
115
+ assert.ok(toasts.some((call) => String(call[1]).includes("Loop paused after a terminal OpenCode session error")))
116
+
117
+ await executor.maybeRunDueJobs(directory, client, sessionID)
118
+ assert.equal(states.get(stateKey(sessionID)).jobs[0].runCount, 1, "paused terminal-error job must not immediately dispatch another run")
119
+ }
120
+
121
+ {
122
+ const sessionID = "retryable-provider-error"
123
+ setJob(sessionID, { maxRuns: 1 })
124
+ await executor.maybeRunDueJobs(directory, client, sessionID)
125
+ let job = states.get(stateKey(sessionID)).jobs[0]
126
+ assert.equal(job.runCount, 1)
127
+ assert.equal(job.enabled, false, "max-runs may disable while the provider turn is in flight")
128
+
129
+ clock += 10
130
+ const handled = await executor.handleSessionError(directory, client, {
131
+ type: "session.error",
132
+ properties: {
133
+ sessionID,
134
+ error: {
135
+ name: "APIError",
136
+ data: {
137
+ message: "provider temporarily unavailable",
138
+ statusCode: 503,
139
+ isRetryable: true,
140
+ },
141
+ },
142
+ },
143
+ })
144
+
145
+ assert.equal(handled, true)
146
+ assert.equal(executor.getActiveRun(sessionID), undefined)
147
+ job = states.get(stateKey(sessionID)).jobs[0]
148
+ assert.equal(job.runCount, 0, "retryable terminal provider error must refund the logical run")
149
+ assert.equal(job.enabled, true, "refunded max-runs disable must be rolled back")
150
+ assert.equal(job.paused, false)
151
+ assert.equal(job.failureCount || 0, 0, "retryable provider outage must not consume ordinary failure budget")
152
+ assert.equal(job.infrastructureFailureCount, 1)
153
+ assert.equal(job.lastInfrastructureFailure, "session_error_retryable")
154
+ assert.match(job.lastInfrastructureError, /provider temporarily unavailable/)
155
+ assert.ok(logs.some((call) => call[1] === "provider-session-error-retry" && call[2]?.sessionID === sessionID))
156
+ assert.ok(schedules.some((call) => call[2] === sessionID && call[3] >= 5_000))
157
+ }
158
+
159
+ {
160
+ const sessionID = "user-abort"
161
+ setJob(sessionID)
162
+ await executor.maybeRunDueJobs(directory, client, sessionID)
163
+ const active = executor.getActiveRun(sessionID)
164
+ assert.ok(active)
165
+
166
+ const handled = await executor.handleSessionError(directory, client, {
167
+ type: "session.error",
168
+ properties: {
169
+ sessionID,
170
+ error: {
171
+ name: "MessageAbortedError",
172
+ data: { message: "The operation was aborted." },
173
+ },
174
+ },
175
+ })
176
+
177
+ assert.equal(handled, false, "user/session abort must keep existing abort/finalization semantics")
178
+ assert.strictEqual(executor.getActiveRun(sessionID), active)
179
+ }
180
+
181
+ for (const sessionID of ["policy-error", "retryable-provider-error", "user-abort"]) {
182
+ executor.disposeSession(sessionID)
183
+ clearSessionActivity(sessionID)
184
+ }
185
+
186
+ console.log("session error recovery tests passed")