@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-09-29
4
+
5
+ - Fix #164: persisted core user-message events cannot abort compaction; trusted admission hooks alone may steer legacy turns.
6
+ - Activate native V2 queued scheduling and default local installation, preserving explicit V1 compatibility.
7
+ - Correct unsupported plugin shell assumptions with a bounded local process lifecycle and correlated terminals.
8
+ - Preserve dedicated Goal ownership, native compaction barriers, uncertain-admission safety, and configuration options.
9
+ - Fix branch success reporting, timeout exit codes and self-matching completion markers.
10
+ - Explicitly report the OpenCode 2.0.18 scheduled-compaction capability gap.
11
+
12
+
13
+ ## 0.5.38
14
+
15
+ Long-session provider-error, waiting-user, and OpenCode 2.0.11 compatibility release.
16
+
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - Harden Windows regression reliability with bounded temporary-directory cleanup retries and bounded waits for persisted stale-busy recovery, without changing production scheduler semantics.
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - 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.
28
+
3
29
  ## 0.5.37
4
30
 
5
31
  Dedicated Goal tool-isolation release.
package/README.md CHANGED
@@ -1,10 +1,21 @@
1
1
  # OpenCode Loop
2
2
 
3
+ ## Native OpenCode 2 (0.6.0)
4
+
5
+ The installer now defaults to the standalone native V2 plugin. It needs no V1 SDK or legacy command files for a local installation. Existing npm registrations migrate from `plugin` to `plugins` without dropping object options. Use `--legacy-v1` explicitly for the compatibility installation.
6
+
7
+ V2 prompt admission and durable inbox/execution/compaction events own scheduling. Core-generated user-role messages never authorize a session abort. An unfinished dedicated Goal reserves its session, including paused and handed-off states; Loop does not wake or replace that Goal. Soft iteration timeouts do not abort a native model/tool/compaction turn.
8
+
9
+ Scheduled shell commands use bounded local child processes when OpenCode does not expose `session.shell` to plugins. Automatic compaction stays host-owned. **Scheduled compaction requires a host exposing `session.compact`; OpenCode 2.0.18's published plugin Context does not expose it, and those commands are rejected before job creation rather than silently ignored.** Use OpenCode's built-in manual/automatic compaction on that host.
10
+
11
+ Prompt/command timers, watch and stop conditions, verification, preflight/postrun, notifications, checkpoints and diagnostic commands use the V2 runtime. Uncertain restart admissions pause for review rather than replaying potentially admitted work. The dedicated `@bybrawe/opencode-goal` plugin owns `/goal`; legacy Loop Goal records are preserved and are not silently reinterpreted as new Goal contracts.
12
+
13
+
3
14
  **Idle-safe auto-continue, scheduled OpenCode work, and background loops.**
4
15
 
5
16
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
6
17
 
7
- > **Current stable release: `0.5.37`.**
18
+ > **Current stable release: `0.5.38`.**
8
19
 
9
20
  ## Install or update
10
21
 
@@ -383,6 +394,25 @@ Pin a session and limit runs:
383
394
  opencode-loopd --project . --session ses_xxx --every 5m --max-runs 20 --timeout 30m --prompt-file loop-prompt.md
384
395
  ```
385
396
 
397
+ ### Dedicated Goal pulse-check
398
+
399
+ Use the daemon as a read-only stall detector for dedicated `/goal` state:
400
+
401
+ ```bash
402
+ # one-shot (good for systemd timers / cron-style runners)
403
+ opencode-loopd pulse-check --project . --stale-after 30m
404
+
405
+ # repeating opt-in watcher
406
+ opencode-loopd pulse-check --project . --stale-after 30m --every 1m
407
+
408
+ # watch one Goal by Goal ID or session ID
409
+ opencode-loopd pulse-check --project . --goal <goal-or-session-id> --stale-after 30m --every 1m
410
+ ```
411
+
412
+ The pulse-check reads `.opencode/goals/*.json` and alerts only while the dedicated Goal is `active`. `paused`, `waiting_user`, `blocked`, budget/usage-limited, and completed Goals do not alert. A stall episode is based on durable Goal activity (`updatedAt`, progress/revision changes); OpenCode session `updated` time is included only as corroborating telemetry because it is not reliable enough to be the stall authority on every host version.
413
+
414
+ Episode state is stored separately in `.opencode/opencode-loop/pulse-check.json`. The checker never writes Goal state, never dispatches a prompt, never runs `/goal resume`, and therefore does not participate in the prompt-producing Goal-overlap guard. It emits at most one alert per stall episode and re-arms after observed Goal activity or a status change. The alert explicitly names the human recovery action: run `/goal resume` in the Goal's session.
415
+
386
416
  Windows Task Scheduler:
387
417
 
388
418
  ```powershell
@@ -0,0 +1,54 @@
1
+ # OpenCode Loop 0.6.0
2
+
3
+ Native OpenCode 2 release, paired with OpenCode Goal 1.3.39.
4
+
5
+ ## Included behavior
6
+
7
+ The default installer uses the standalone native V2 entry and preserves plugin
8
+ object options. Explicit V1 compatibility remains available. Native prompt
9
+ admission, durable execution/compaction boundaries and exclusive Goal ownership
10
+ prevent a second continuation owner or aborts caused by synthetic user-role text.
11
+
12
+ All ten non-Goal presets share schedule parsing, including every/after/in and
13
+ delayed one-shot behavior. /loop-init creates only missing project-local progress
14
+ files, preserves existing bytes and rejects control-plane and symlink escapes.
15
+ Commands resolve their session's actual location rather than a different worktree.
16
+
17
+ Local scheduled shell work has bounded process-tree cleanup. Plugin teardown and
18
+ partial setup attempt all cleanup without hanging on the event subscription.
19
+ Goal live/archive/lease/queue state cannot satisfy --until; explicit until.txt and
20
+ user-owned project progress files remain valid markers.
21
+
22
+ Goal 1.3.39 adds the native sidebar, location-bound read-only status RPC, late-result
23
+ handoff guards and full-contract three-session checks. Goal IDs, evidence and
24
+ cumulative budgets remain durable across session rotation.
25
+
26
+ ## Publication and verification
27
+
28
+ The release no longer creates a release branch. An explicit main release request
29
+ runs Windows/Linux bundle, test and installed-package gates; actual production
30
+ Loop and Goal tarballs are exercised together on OpenCode 2.0.18. Normal CI for the
31
+ same immutable commit must also pass. Only then does the existing OIDC publisher
32
+ publish 0.6.0. Existing npm versions must match their source SHA, not merely version.
33
+
34
+ Registry latest, gitHead, exports, bins and integrity are checked after publication.
35
+ A clean consumer installs both published packages from npm and re-runs native
36
+ installation, actual-host commands and the two-turn single-session daemon.
37
+ The GitHub tag/release follows those checks in a separate least-privilege job.
38
+
39
+ ## Host capability boundaries
40
+
41
+ OpenCode 2.0.18 is the exact native integration baseline. Its published plugin
42
+ context does not expose session.compact, so scheduled /loop-compact is rejected
43
+ before durable job creation rather than silently ignored. Automatic compaction is
44
+ host-owned. Legacy Loop Goal records are preserved, not silently converted into
45
+ dedicated Goal contracts. Real-model cost and long-duration reliability are not
46
+ claimed by deterministic local-provider canaries.
47
+
48
+ Install the published pair after successful registry verification:
49
+
50
+ ```sh
51
+ npx -y @bybrawe/opencode-loop@0.6.0 --with-goals --without-loop-goals
52
+ ```
53
+
54
+ Fully restart OpenCode after installation. Existing project state is preserved.
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.37",
3
+ "version": "0.6.0",
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
- "main": "src/index.js",
6
+ "main": "src/server.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/server.js",
9
+ "./server": "./src/server.js",
10
+ "./v2": "./src/native.js",
11
+ "./v1": "./src/index.js"
9
12
  },
10
13
  "files": [
11
14
  "src",
@@ -18,14 +21,15 @@
18
21
  "CHANGELOG.md"
19
22
  ],
20
23
  "scripts": {
21
- "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
24
+ "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 && bun build src/source/native.js --outfile=src/native.js --target=bun --format=esm",
22
25
  "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",
26
+ "prepack": "node --check src/index.js && node --check src/server.js && node --check src/native.js",
27
+ "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/pulse-check.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 && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs",
28
+ "test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && 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
29
  "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
27
30
  "install:global": "node scripts/install-with-goals.mjs",
28
- "pack:zip": "node scripts/make-zip.mjs"
31
+ "pack:zip": "node scripts/make-zip.mjs",
32
+ "package:smoke": "node scripts/package-native-smoke.mjs"
29
33
  },
30
34
  "keywords": [
31
35
  "opencode",
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env node
2
+ import { appendFile } from "node:fs/promises"
3
+ import process from "node:process"
4
+
5
+ function sameRepositoryHead(pull, repository) {
6
+ return pull?.head?.repo?.full_name === repository
7
+ }
8
+
9
+ function encodeRef(name) {
10
+ return String(name || "").split("/").map(encodeURIComponent).join("/")
11
+ }
12
+
13
+ function validSha(value) {
14
+ return /^[0-9a-f]{40}$/i.test(String(value || ""))
15
+ }
16
+
17
+ export function planBranchCleanup({ repository, defaultBranch, branches, openPulls, closedPulls }) {
18
+ const openHeads = new Set(
19
+ openPulls
20
+ .filter((pull) => sameRepositoryHead(pull, repository))
21
+ .map((pull) => String(pull?.head?.ref || "").trim())
22
+ .filter(Boolean),
23
+ )
24
+
25
+ const mergedHeads = new Map()
26
+ for (const pull of closedPulls) {
27
+ if (!pull?.merged_at || !sameRepositoryHead(pull, repository)) continue
28
+ const name = String(pull?.head?.ref || "").trim()
29
+ const sha = String(pull?.head?.sha || "").trim()
30
+ if (!name || !sha) continue
31
+ const values = mergedHeads.get(name) || []
32
+ values.push({ sha, number: pull.number })
33
+ mergedHeads.set(name, values)
34
+ }
35
+
36
+ const candidates = []
37
+ const retained = []
38
+ for (const branch of branches) {
39
+ const name = String(branch?.name || "").trim()
40
+ const sha = String(branch?.commit?.sha || "").trim()
41
+ if (!name || !sha) continue
42
+ if (name === defaultBranch) {
43
+ retained.push({ name, reason: "default" })
44
+ continue
45
+ }
46
+ if (branch?.protected === true) {
47
+ retained.push({ name, reason: "protected" })
48
+ continue
49
+ }
50
+ if (openHeads.has(name)) {
51
+ retained.push({ name, reason: "open-pr" })
52
+ continue
53
+ }
54
+
55
+ const merged = (mergedHeads.get(name) || []).find((entry) => entry.sha === sha)
56
+ if (!merged) {
57
+ retained.push({ name, reason: "no-exact-merged-head" })
58
+ continue
59
+ }
60
+ candidates.push({ name, sha, pull: merged.number, source: "merged-pr" })
61
+ }
62
+
63
+ candidates.sort((a, b) => a.name.localeCompare(b.name))
64
+ retained.sort((a, b) => a.name.localeCompare(b.name))
65
+ return { candidates, retained }
66
+ }
67
+
68
+ export function planArchivedCleanup({
69
+ repository,
70
+ defaultBranch,
71
+ archiveRef,
72
+ manifest,
73
+ branches,
74
+ openPulls,
75
+ }) {
76
+ if (!manifest || typeof manifest !== "object") throw new Error("archive manifest must be an object")
77
+ if (manifest.repository !== repository) throw new Error(`archive manifest repository mismatch: ${manifest.repository}`)
78
+ if (manifest.archiveBranch !== archiveRef) throw new Error(`archive manifest branch mismatch: ${manifest.archiveBranch}`)
79
+ if (!Array.isArray(manifest.archivedBranches)) throw new Error("archive manifest archivedBranches must be an array")
80
+
81
+ const branchByName = new Map(
82
+ branches
83
+ .map((branch) => [String(branch?.name || "").trim(), branch])
84
+ .filter(([name]) => Boolean(name)),
85
+ )
86
+ const openHeads = new Set(
87
+ openPulls
88
+ .filter((pull) => sameRepositoryHead(pull, repository))
89
+ .map((pull) => String(pull?.head?.ref || "").trim())
90
+ .filter(Boolean),
91
+ )
92
+
93
+ const seenNames = new Set()
94
+ const candidates = []
95
+ const retained = []
96
+
97
+ for (const entry of manifest.archivedBranches) {
98
+ const name = String(entry?.name || "").trim()
99
+ const sha = String(entry?.sha || "").trim()
100
+ if (!name || !validSha(sha)) throw new Error(`invalid archived branch entry: ${JSON.stringify(entry)}`)
101
+ if (seenNames.has(name)) throw new Error(`duplicate archived branch entry: ${name}`)
102
+ seenNames.add(name)
103
+
104
+ if (name === defaultBranch || name === archiveRef) {
105
+ retained.push({ name, reason: "reserved" })
106
+ continue
107
+ }
108
+
109
+ const branch = branchByName.get(name)
110
+ if (!branch) {
111
+ retained.push({ name, reason: "already-absent" })
112
+ continue
113
+ }
114
+ if (branch?.protected === true) {
115
+ retained.push({ name, reason: "protected" })
116
+ continue
117
+ }
118
+ if (openHeads.has(name)) {
119
+ retained.push({ name, reason: "open-pr" })
120
+ continue
121
+ }
122
+
123
+ const currentSha = String(branch?.commit?.sha || "").trim()
124
+ if (currentSha !== sha) {
125
+ retained.push({ name, reason: "head-changed", expectedSha: sha, currentSha })
126
+ continue
127
+ }
128
+
129
+ candidates.push({ name, sha, source: "archive-manifest" })
130
+ }
131
+
132
+ candidates.sort((a, b) => a.name.localeCompare(b.name))
133
+ retained.sort((a, b) => a.name.localeCompare(b.name))
134
+ return { candidates, retained }
135
+ }
136
+
137
+ async function request({ api, token }, method, pathname) {
138
+ const response = await fetch(`${api}${pathname}`, {
139
+ method,
140
+ headers: {
141
+ Accept: "application/vnd.github+json",
142
+ Authorization: `Bearer ${token}`,
143
+ "X-GitHub-Api-Version": "2022-11-28",
144
+ "User-Agent": "opencode-branch-hygiene",
145
+ },
146
+ })
147
+ if (response.status === 204) return undefined
148
+ const raw = await response.text()
149
+ if (!response.ok) {
150
+ throw new Error(`${method} ${pathname} failed (${response.status}): ${raw.slice(0, 1000)}`)
151
+ }
152
+ return raw ? JSON.parse(raw) : undefined
153
+ }
154
+
155
+ async function paginate(client, pathname, query = {}) {
156
+ const values = []
157
+ for (let page = 1; page <= 100; page += 1) {
158
+ const params = new URLSearchParams({
159
+ ...Object.fromEntries(Object.entries(query).map(([key, value]) => [key, String(value)])),
160
+ per_page: "100",
161
+ page: String(page),
162
+ })
163
+ const batch = await request(client, "GET", `${pathname}?${params}`)
164
+ if (!Array.isArray(batch)) throw new Error(`Expected an array from ${pathname}`)
165
+ values.push(...batch)
166
+ if (batch.length < 100) return values
167
+ }
168
+ throw new Error(`Pagination safety limit exceeded for ${pathname}`)
169
+ }
170
+
171
+ async function loadArchive(client, root, repository, archiveRef) {
172
+ if (!archiveRef) return null
173
+
174
+ const archivePath = "archive/legacy-branches-2026-09-21.json"
175
+ const encodedRef = encodeURIComponent(archiveRef)
176
+ const payload = await request(client, "GET", `${root}/contents/${archivePath}?ref=${encodedRef}`)
177
+ if (payload?.encoding !== "base64" || typeof payload?.content !== "string") {
178
+ throw new Error("archive manifest content is not base64")
179
+ }
180
+
181
+ const raw = Buffer.from(payload.content.replace(/\s+/g, ""), "base64").toString("utf8")
182
+ const manifest = JSON.parse(raw)
183
+ if (manifest.repository !== repository) throw new Error("archive manifest repository does not match current repository")
184
+ if (manifest.archiveBranch !== archiveRef) throw new Error("archive manifest archiveBranch does not match configured ref")
185
+
186
+ const ref = await request(client, "GET", `${root}/git/ref/heads/${encodeRef(archiveRef)}`)
187
+ const archiveSha = String(ref?.object?.sha || "").trim()
188
+ if (!validSha(archiveSha)) throw new Error("archive branch did not resolve to a commit SHA")
189
+
190
+ return { manifest, archiveSha, archivePath }
191
+ }
192
+
193
+ async function verifyArchivedReachability(client, root, archiveSha, items) {
194
+ const verified = []
195
+ for (const item of items) {
196
+ const compare = await request(client, "GET", `${root}/compare/${item.sha}...${archiveSha}`)
197
+ if (!["ahead", "identical"].includes(compare?.status)) {
198
+ throw new Error(`archive ref does not retain ${item.name} @ ${item.sha}; compare status=${compare?.status || "unknown"}`)
199
+ }
200
+ verified.push(item)
201
+ }
202
+ return verified
203
+ }
204
+
205
+ async function deleteCandidates(client, root, items, dryRun, label) {
206
+ const deleted = []
207
+ const skipped = []
208
+ const failures = []
209
+
210
+ for (const item of items) {
211
+ console.log(`${dryRun ? "DRY-RUN" : "DELETE"} [${label}] ${item.name} @ ${item.sha}`)
212
+ if (dryRun) continue
213
+
214
+ try {
215
+ const encodedRef = encodeRef(item.name)
216
+ const refPath = `${root}/git/ref/heads/${encodedRef}`
217
+ const current = await request(client, "GET", refPath)
218
+ const currentSha = String(current?.object?.sha || "").trim()
219
+ if (currentSha !== item.sha) {
220
+ console.log(`SKIP [${label}] ${item.name}: head changed from ${item.sha} to ${currentSha || "unknown"}`)
221
+ skipped.push({ ...item, reason: "head-changed-at-delete", currentSha })
222
+ continue
223
+ }
224
+ await request(client, "DELETE", `${root}/git/refs/heads/${encodedRef}`)
225
+ deleted.push(item)
226
+ } catch (error) {
227
+ failures.push({ item, error: error instanceof Error ? error.message : String(error) })
228
+ }
229
+ }
230
+
231
+ return { deleted, skipped, failures }
232
+ }
233
+
234
+ function countReasons(items) {
235
+ return Object.fromEntries(
236
+ [...new Set(items.map((item) => item.reason))]
237
+ .sort()
238
+ .map((reason) => [reason, items.filter((item) => item.reason === reason).length]),
239
+ )
240
+ }
241
+
242
+ async function main() {
243
+ const repository = String(process.env.GITHUB_REPOSITORY || "").trim()
244
+ const token = String(process.env.GITHUB_TOKEN || "").trim()
245
+ const api = String(process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "")
246
+ const defaultBranch = String(process.env.BRANCH_HYGIENE_DEFAULT_BRANCH || "main").trim()
247
+ const archiveRef = String(process.env.BRANCH_HYGIENE_ARCHIVE_REF || "").trim()
248
+ const dryRun = process.env.BRANCH_HYGIENE_DRY_RUN === "1"
249
+
250
+ if (!/^[^/]+\/[^/]+$/.test(repository)) throw new Error("GITHUB_REPOSITORY must be owner/name")
251
+ if (!token) throw new Error("GITHUB_TOKEN is required")
252
+ if (!defaultBranch) throw new Error("default branch is required")
253
+
254
+ const client = { api, token }
255
+ const root = `/repos/${repository}`
256
+ const [branches, openPulls, closedPulls] = await Promise.all([
257
+ paginate(client, `${root}/branches`),
258
+ paginate(client, `${root}/pulls`, { state: "open" }),
259
+ paginate(client, `${root}/pulls`, { state: "closed" }),
260
+ ])
261
+
262
+ const mergedPlan = planBranchCleanup({
263
+ repository,
264
+ defaultBranch,
265
+ branches,
266
+ openPulls,
267
+ closedPulls,
268
+ })
269
+
270
+ let archivePlan = { candidates: [], retained: [] }
271
+ let archiveSha = ""
272
+ if (archiveRef) {
273
+ const archive = await loadArchive(client, root, repository, archiveRef)
274
+ archiveSha = archive.archiveSha
275
+ archivePlan = planArchivedCleanup({
276
+ repository,
277
+ defaultBranch,
278
+ archiveRef,
279
+ manifest: archive.manifest,
280
+ branches,
281
+ openPulls,
282
+ })
283
+ await verifyArchivedReachability(client, root, archiveSha, archivePlan.candidates)
284
+ }
285
+
286
+ const mergedResult = await deleteCandidates(client, root, mergedPlan.candidates, dryRun, "merged-pr")
287
+
288
+ const mergedNames = new Set(mergedPlan.candidates.map((item) => item.name))
289
+ const archiveCandidates = archivePlan.candidates.filter((item) => !mergedNames.has(item.name))
290
+ const archiveResult = await deleteCandidates(client, root, archiveCandidates, dryRun, "archive")
291
+
292
+ const failures = [...mergedResult.failures, ...archiveResult.failures]
293
+ const summary = [
294
+ "## Branch hygiene",
295
+ "",
296
+ `- Repository: \`${repository}\``,
297
+ `- Default branch: \`${defaultBranch}\``,
298
+ `- Branches scanned: ${branches.length}`,
299
+ `- Exact merged-head candidates: ${mergedPlan.candidates.length}`,
300
+ `- Archived inactive candidates: ${archiveCandidates.length}`,
301
+ `- Archive ref: \`${archiveRef || "disabled"}\``,
302
+ `- Archive SHA: \`${archiveSha || "n/a"}\``,
303
+ `- Deleted: ${dryRun ? 0 : mergedResult.deleted.length + archiveResult.deleted.length}`,
304
+ `- Dry run: ${dryRun}`,
305
+ `- Retained merged-plan reasons: \`${JSON.stringify(countReasons(mergedPlan.retained))}\``,
306
+ `- Retained archive-plan reasons: \`${JSON.stringify(countReasons(archivePlan.retained))}\``,
307
+ "",
308
+ "Safety rules: merged branches require an exact merged-PR head SHA. Archived branches additionally require an unchanged current SHA, no open PR, and proof that the archived tip is an ancestor of the configured archive ref before deletion.",
309
+ ].join("\n")
310
+
311
+ if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`, "utf8")
312
+ console.log(summary)
313
+
314
+ if (failures.length) {
315
+ for (const failure of failures) console.error(`FAILED ${failure.item.name}: ${failure.error}`)
316
+ throw new Error(`Failed to delete ${failures.length} eligible branch(es)`)
317
+ }
318
+ }
319
+
320
+ const invokedDirectly = process.argv[1] && new URL(import.meta.url).pathname.endsWith(process.argv[1].replaceAll("\\", "/"))
321
+ if (invokedDirectly) {
322
+ main().catch((error) => {
323
+ console.error(error?.stack || error)
324
+ process.exitCode = 1
325
+ })
326
+ }
@@ -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
  }
@@ -373,9 +373,12 @@ async function testActionRoutingAndSafety() {
373
373
  await fs.writeFile(path.join(h.directory, "empty.md"), "fallback context", "utf8")
374
374
  await h.command("loop-command", "0s --prompt-file empty.md")
375
375
  await h.command("loop-now", "command")
376
- const state = await h.readState()
377
- assert.equal(state.jobs[0].paused, true)
378
- assert.equal(state.jobs[0].lastFailureReason, "empty_command")
376
+ const state = await waitForValue(async () => {
377
+ const candidate = await h.readState()
378
+ return candidate.jobs[0]?.paused === true && candidate.jobs[0]?.lastFailureReason === "empty_command" ? candidate : undefined
379
+ }, 2_000)
380
+ assert.equal(state?.jobs[0]?.paused, true)
381
+ assert.equal(state?.jobs[0]?.lastFailureReason, "empty_command")
379
382
  } finally {
380
383
  await h.cleanup()
381
384
  }
@@ -384,9 +387,12 @@ async function testActionRoutingAndSafety() {
384
387
  try {
385
388
  await h.command("loop-compact", "0s")
386
389
  await h.command("loop-now", "compact")
387
- const state = await h.readState()
388
- assert.equal(state.jobs[0].paused, true)
389
- assert.equal(state.jobs[0].lastFailureReason, "compact_failed")
390
+ const state = await waitForValue(async () => {
391
+ const candidate = await h.readState()
392
+ return candidate.jobs[0]?.paused === true && candidate.jobs[0]?.lastFailureReason === "compact_failed" ? candidate : undefined
393
+ }, 2_000)
394
+ assert.equal(state?.jobs[0]?.paused, true)
395
+ assert.equal(state?.jobs[0]?.lastFailureReason, "compact_failed")
390
396
  } finally {
391
397
  await h.cleanup()
392
398
  }
@@ -486,8 +492,11 @@ async function testStaleBusyUsesCompletedAssistantTail() {
486
492
  // and can reconcile the completed assistant tail instead of trusting cached busy.
487
493
  await delay(1_700)
488
494
  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")
495
+ const state = await waitForValue(async () => {
496
+ const candidate = await h.readState()
497
+ return candidate.jobs[0]?.lastFinishedAt > 0 ? candidate : undefined
498
+ }, 3_000)
499
+ assert.ok(state?.jobs[0]?.lastFinishedAt > 0, "a completed assistant tail must override a stale busy status")
491
500
  assert.ok(h.records.messageReads.length > 0, "busy recovery must cross-check message history")
492
501
  } finally {
493
502
  await h.cleanup()
@@ -643,7 +652,7 @@ async function testInitializationDoesNotWaitForLocalApi() {
643
652
  assert.equal(typeof hooks.event, "function")
644
653
  await hooks.dispose?.()
645
654
  } finally {
646
- await fs.rm(directory, { recursive: true, force: true })
655
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
647
656
  }
648
657
  }
649
658
 
@@ -0,0 +1,39 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { createJobWorkspaceRuntime } from "../src/source/runtime/job-workspace.js"
7
+
8
+ const marker = "NATIVE_COMPLETION_FROM_WORK_ONLY_8fd8"
9
+ const runtime = createJobWorkspaceRuntime({ toast: async () => {} })
10
+ async function fixture(file, expected) {
11
+ const directory = await mkdtemp(path.join(os.tmpdir(), "loop-control-until-"))
12
+ try {
13
+ const target = path.join(directory, file)
14
+ await mkdir(path.dirname(target), { recursive: true })
15
+ await writeFile(target, JSON.stringify({ objective: marker }))
16
+ assert.equal(await runtime.untilReached(directory, { until: marker }), expected)
17
+ } finally {
18
+ await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
19
+ }
20
+ }
21
+
22
+ for (const file of [
23
+ ".opencode/goals/live.json",
24
+ ".opencode/goals/archive/history.json",
25
+ ".opencode/goal-sequences/session.json",
26
+ ".opencode/goal-locks/lease.json",
27
+ ".opencode/goal-handoff-locks/lease.json",
28
+ ".opencode/opencode-loop/session.json",
29
+ ]) {
30
+ test("Goal/Loop control state cannot satisfy --until: " + file, () => fixture(file, false))
31
+ }
32
+ for (const file of [
33
+ "progress.md",
34
+ ".opencode/opencode-loop/until.txt",
35
+ ".opencode/commands/project-task.md",
36
+ ".opencode/goals-project-notes/finished.md",
37
+ ]) {
38
+ test("explicit or project-owned completion marker stays supported: " + file, () => fixture(file, true))
39
+ }
@@ -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,