@bybrawe/opencode-loop 0.5.38 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +33 -1
  3. package/examples/OPENCODE2-0.6.0.md +54 -0
  4. package/examples/OPENCODE2-0.6.1.md +60 -0
  5. package/package.json +15 -9
  6. package/scripts/branch-hygiene.mjs +326 -0
  7. package/scripts/comprehensive-test.mjs +12 -6
  8. package/scripts/control-plane-until-test.mjs +39 -0
  9. package/scripts/goal-compaction-safety-test.mjs +68 -0
  10. package/scripts/goal-companion-test.mjs +1 -1
  11. package/scripts/host-adapter-contract-test.mjs +1 -0
  12. package/scripts/install-node.mjs +118 -60
  13. package/scripts/install-test.mjs +3 -3
  14. package/scripts/install-with-goals.mjs +17 -4
  15. package/scripts/job-workspace-test.mjs +11 -1
  16. package/scripts/loopd-test.mjs +74 -4
  17. package/scripts/loopd.mjs +153 -25
  18. package/scripts/native-cli-host-canary.mjs +118 -0
  19. package/scripts/native-cli-io-test.mjs +66 -0
  20. package/scripts/native-cli-test.mjs +130 -0
  21. package/scripts/native-coexistence-test.mjs +38 -0
  22. package/scripts/native-entry-test.mjs +77 -0
  23. package/scripts/native-guide-test.mjs +45 -0
  24. package/scripts/native-install-pair-test.mjs +73 -0
  25. package/scripts/native-install-test.mjs +114 -0
  26. package/scripts/native-lifecycle-test.mjs +100 -0
  27. package/scripts/native-plugin-lifetime-test.mjs +92 -0
  28. package/scripts/native-presets-test.mjs +227 -0
  29. package/scripts/native-process-tree-fixture.mjs +72 -0
  30. package/scripts/native-process-tree-test.mjs +40 -0
  31. package/scripts/native-release.mjs +121 -0
  32. package/scripts/native-session-location-test.mjs +86 -0
  33. package/scripts/native-shell-test.mjs +38 -0
  34. package/scripts/native-v2-test.mjs +262 -0
  35. package/scripts/opencode-host.mjs +47 -0
  36. package/scripts/opencode2-loop-canary.mjs +72 -4
  37. package/scripts/package-native-smoke.mjs +31 -0
  38. package/scripts/publish-workflow-test.mjs +60 -19
  39. package/scripts/pulse-check.mjs +246 -0
  40. package/scripts/session-status-test.mjs +12 -0
  41. package/src/index.js +87 -48
  42. package/src/native.js +3135 -0
  43. package/src/plugin.js +19 -0
  44. package/src/server.js +2164 -4560
  45. package/src/source/core/jobs.js +17 -0
  46. package/src/source/core/process.js +6 -4
  47. package/src/source/core/progress.js +58 -0
  48. package/src/source/legacy-v1.js +9 -3
  49. package/src/source/native.js +1 -0
  50. package/src/source/opencode/host.js +1 -0
  51. package/src/source/opencode/loop-commands.js +1 -27
  52. package/src/source/opencode/loop-registration.js +2 -12
  53. package/src/source/opencode/messages.js +1 -1
  54. package/src/source/opencode2/commands.js +9 -4
  55. package/src/source/opencode2/event-bridge.js +15 -3
  56. package/src/source/opencode2/events.js +20 -0
  57. package/src/source/opencode2/experimental.js +2 -0
  58. package/src/source/opencode2/native-companion.js +24 -0
  59. package/src/source/opencode2/native-plugin.js +153 -0
  60. package/src/source/opencode2/native-policy.js +106 -0
  61. package/src/source/opencode2/native-runtime.js +464 -0
  62. package/src/source/opencode2/native-shell.js +81 -0
  63. package/src/source/opencode2/prompt-runtime.js +2 -0
  64. package/src/source/opencode2/session-location.js +16 -0
  65. package/src/source/runtime/goal-steering.js +17 -3
  66. package/src/source/runtime/job-workspace.js +11 -2
  67. package/src/source/runtime/session-status.js +11 -1
  68. package/src/source/server.js +9 -1
  69. package/src/v1.js +1 -0
  70. package/src/v2.js +10 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 - 2026-09-29
4
+
5
+ - Expose the root/server and `./v2` npm entrypoints through the official `@opencode/plugin` `Plugin.define({ id, setup })` contract while keeping V1 `server()` as a separate compatibility implementation.
6
+ - Ship `@opencode/plugin@^2.0.18` as a production dependency and lock its non-optional runtime peer closure for reproducible clean installs.
7
+ - Make native local-file installs use the same real V2 entrypoint: `plugins/opencode-loop/index.js` is the `Plugin.define()` wrapper and `native.js` is only its supporting generated runtime.
8
+ - Preserve configured package options while migrating `plugin` to `plugins`; remove old single-file local copies during native upgrades and uninstall.
9
+ - Extend Windows production-tarball smoke time only at the harness boundary so the larger V2 dependency graph can install without weakening runtime assertions.
10
+ - Pair trusted publication with OpenCode Goal 1.3.40 from its exact immutable source and re-run the installed npm pair on OpenCode 2.0.18.
11
+
12
+ ## 0.6.0 - 2026-09-29
13
+
14
+ - Fix #164: persisted core user-message events cannot abort compaction; trusted admission hooks alone may steer legacy turns.
15
+ - Activate native V2 queued scheduling and default local installation, preserving explicit V1 compatibility.
16
+ - Correct unsupported plugin shell assumptions with a bounded local process lifecycle and correlated terminals.
17
+ - Preserve dedicated Goal ownership, native compaction barriers, uncertain-admission safety, and configuration options.
18
+ - Fix branch success reporting, timeout exit codes and self-matching completion markers.
19
+ - Explicitly report the OpenCode 2.0.18 scheduled-compaction capability gap.
20
+
21
+
3
22
  ## 0.5.38
4
23
 
5
24
  Long-session provider-error, waiting-user, and OpenCode 2.0.11 compatibility release.
package/README.md CHANGED
@@ -1,10 +1,23 @@
1
1
  # OpenCode Loop
2
2
 
3
+ ## Native OpenCode 2 (0.6.1)
4
+
5
+ The npm and local-file V2 entrypoints now use the official `@opencode/plugin` `Plugin.define()` contract. The local installer writes a discovered plugin package directory with the generated runtime as a supporting module, while explicit `--legacy-v1` remains a separate compatibility path.
6
+
7
+ 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.
8
+
9
+ 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.
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+
3
16
  **Idle-safe auto-continue, scheduled OpenCode work, and background loops.**
4
17
 
5
18
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
6
19
 
7
- > **Current stable release: `0.5.38`.**
20
+ > **Current stable release: `0.6.1`.**
8
21
 
9
22
  ## Install or update
10
23
 
@@ -383,6 +396,25 @@ Pin a session and limit runs:
383
396
  opencode-loopd --project . --session ses_xxx --every 5m --max-runs 20 --timeout 30m --prompt-file loop-prompt.md
384
397
  ```
385
398
 
399
+ ### Dedicated Goal pulse-check
400
+
401
+ Use the daemon as a read-only stall detector for dedicated `/goal` state:
402
+
403
+ ```bash
404
+ # one-shot (good for systemd timers / cron-style runners)
405
+ opencode-loopd pulse-check --project . --stale-after 30m
406
+
407
+ # repeating opt-in watcher
408
+ opencode-loopd pulse-check --project . --stale-after 30m --every 1m
409
+
410
+ # watch one Goal by Goal ID or session ID
411
+ opencode-loopd pulse-check --project . --goal <goal-or-session-id> --stale-after 30m --every 1m
412
+ ```
413
+
414
+ 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.
415
+
416
+ 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.
417
+
386
418
  Windows Task Scheduler:
387
419
 
388
420
  ```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.
@@ -0,0 +1,60 @@
1
+ # OpenCode Loop 0.6.1
2
+
3
+ Official OpenCode 2 plugin-definition release, paired with OpenCode Goal 1.3.40.
4
+
5
+ ## V2 package and local entrypoints
6
+
7
+ The npm root/server entry and the explicit `./v2` entry now use
8
+ `@opencode/plugin` `Plugin.define({ id, setup })`. V1 `server()` compatibility
9
+ remains a separate implementation rather than an adapter that translates V1 hooks.
10
+
11
+ A native local install uses the same contract. It creates
12
+ `plugins/opencode-loop/index.js` as the real V2 entrypoint and keeps the generated
13
+ single-file runtime beside it as `native.js`, where it is a support module rather
14
+ than a second auto-discovered plugin. Upgrades remove the old root
15
+ `opencode-loop.ts`/JS copies; explicit `--legacy-v1` still installs the V1 path.
16
+
17
+ ## Runtime and packaging
18
+
19
+ `@opencode/plugin@^2.0.18` is a production dependency and its required runtime
20
+ peer closure is locked for reproducible npm installs. Clean Windows package smoke
21
+ keeps all previous assertions but permits six minutes for the larger V2 dependency
22
+ graph to install.
23
+
24
+ The native prompt, command, scheduling, session-location, process-tree, Goal
25
+ ownership, compaction-safety and `--until` control-state protections from 0.6.0
26
+ remain unchanged.
27
+
28
+ ## Paired Goal release
29
+
30
+ Goal 1.3.40 defines its server, native-only and TUI entries with the official V2
31
+ plugin APIs while preserving separate V1 compatibility. Loop publication is pinned
32
+ to Goal's immutable 1.3.40 source and refuses publication until that exact package
33
+ is visible on npm.
34
+
35
+ ## Publication and verification
36
+
37
+ The main-only OIDC publisher requires Windows/Linux installed-package gates plus
38
+ normal exact-main CI and Native V2 CI. It builds the declared Goal source, installs
39
+ the actual Loop and Goal tarballs together, and exercises them on OpenCode 2.0.18.
40
+
41
+ After publication, registry latest, gitHead, exports, bins and integrity are checked.
42
+ A fresh npm consumer then installs Loop 0.6.1 and Goal 1.3.40 and re-runs joint
43
+ installation/options/uninstall, real native commands and the two-turn
44
+ single-session daemon before the GitHub v0.6.1 tag/release is created.
45
+
46
+ ## Capability boundary
47
+
48
+ OpenCode 2.0.18's published plugin context still does not expose
49
+ `session.compact`; scheduled `/loop-compact` therefore rejects before durable job
50
+ creation while automatic compaction stays host-owned. Controlled-provider canaries
51
+ are functional verification, not live-model cost or endurance measurements.
52
+
53
+ Install the verified pair after registry publication:
54
+
55
+ ```sh
56
+ npx -y @bybrawe/opencode-goal@1.3.40
57
+ npx -y @bybrawe/opencode-loop@0.6.1 --loop-only --without-loop-goals
58
+ ```
59
+
60
+ Fully restart OpenCode after installation. Existing project state is preserved.
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.38",
3
+ "version": "0.6.1",
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/plugin.js",
7
7
  "exports": {
8
- ".": "./src/index.js",
9
- "./server": "./src/server.js"
8
+ ".": "./src/plugin.js",
9
+ "./server": "./src/plugin.js",
10
+ "./v2": "./src/v2.js",
11
+ "./v1": "./src/index.js"
10
12
  },
11
13
  "files": [
12
14
  "src",
@@ -19,14 +21,15 @@
19
21
  "CHANGELOG.md"
20
22
  ],
21
23
  "scripts": {
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",
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",
23
25
  "build:plugin:npm": "npm run build:plugin",
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
+ "prepack": "node --check src/plugin.js && node --check src/v2.js && 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 && node --check src/plugin.js && node --check src/v2.js",
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",
27
29
  "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
28
30
  "install:global": "node scripts/install-with-goals.mjs",
29
- "pack:zip": "node scripts/make-zip.mjs"
31
+ "pack:zip": "node scripts/make-zip.mjs",
32
+ "package:smoke": "node scripts/package-native-smoke.mjs"
30
33
  },
31
34
  "keywords": [
32
35
  "opencode",
@@ -70,5 +73,8 @@
70
73
  },
71
74
  "publishConfig": {
72
75
  "access": "public"
76
+ },
77
+ "dependencies": {
78
+ "@opencode/plugin": "^2.0.18"
73
79
  }
74
80
  }
@@ -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
+ }
@@ -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
  }