@bybrawe/opencode-loop 0.6.8 → 0.6.9
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.
- package/CHANGELOG.md +9 -0
- package/README.md +21 -9
- package/examples/OPENCODE2-0.6.9.md +34 -0
- package/package.json +3 -3
- package/scripts/checklist-completion-test.mjs +104 -0
- package/scripts/comprehensive-test.mjs +9 -1
- package/scripts/desktop-command-diagnostics-test.mjs +97 -2
- package/scripts/desktop-command-diagnostics.mjs +70 -9
- package/scripts/loopd-test.mjs +1 -1
- package/scripts/loopd.mjs +4 -2
- package/scripts/native-cli-io-test.mjs +6 -3
- package/scripts/native-entry-test.mjs +16 -6
- package/scripts/native-release.mjs +2 -2
- package/scripts/native-v2-test.mjs +61 -0
- package/scripts/opencode2-loop-canary.mjs +2 -1
- package/scripts/package-native-smoke.mjs +41 -7
- package/scripts/publish-workflow-test.mjs +3 -2
- package/scripts/run-admission-test.mjs +47 -0
- package/src/index.js +155 -2
- package/src/native.js +176 -15
- package/src/server.js +180 -19
- package/src/source/core/args.js +3 -0
- package/src/source/core/jobs.js +1 -0
- package/src/source/opencode2/native-plugin.js +3 -0
- package/src/source/opencode2/native-policy.js +21 -0
- package/src/source/opencode2/native-runtime.js +1 -0
- package/src/source/opencode2/status.js +5 -1
- package/src/source/runtime/checklist-completion.js +113 -0
- package/src/source/runtime/run-admission.js +25 -0
- package/src/source/runtime/run-finalization.js +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes yet.
|
|
6
6
|
|
|
7
|
+
## 0.6.9 - 2026-10-27
|
|
8
|
+
|
|
9
|
+
- Fix #174: add opt-in `--until-checklist-complete` paired with `--progress-file TASK.md` so a host-verified Markdown checklist can stop Loop without an extra model iteration. The default plain idle Loop remains intentionally unlimited.
|
|
10
|
+
- Validate task files strictly (project-scoped regular file, bounded size, symlink and control-plane protection); ignore Markdown code fences, block quotes and commented checkbox examples. Missing/unsafe/unreadable/no-task files pause visibly rather than report completion.
|
|
11
|
+
- If `--verify` is configured, require successful host verification of the **exact SHA-256 checklist file contents**, never accepting a stale test success after the file changes.
|
|
12
|
+
- Preserve V1 and V2 semantics, update native help/status to clarify host-owned `/loop-stop` and `/loop-clear`, add regression tests and synchronize all shipped bundles.
|
|
13
|
+
- Clarify OpenCode 2 native package support and Windows Desktop GUI limitations; enhance two-server Desktop diagnostics already on main. Do not claim Windows GUI slash discovery is fixed.
|
|
14
|
+
- Keep dedicated Goal 1.3.49 separately installed and validated on real OpenCode 2 stable and pinned hosts. Track expanded macOS/platform canaries separately under #177.
|
|
15
|
+
|
|
7
16
|
## 0.6.8 - 2026-10-13
|
|
8
17
|
|
|
9
18
|
- Fix #172: fall back to exclusive `wx` lock claims where exFAT/FAT or other volumes reject `fs.link()`, including the reported `EISDIR` error.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenCode Loop
|
|
2
2
|
|
|
3
|
-
## Native OpenCode 2 (0.6.
|
|
3
|
+
## Native OpenCode 2 (0.6.9)
|
|
4
4
|
|
|
5
5
|
The npm and V2 entrypoints use the official `@opencode/plugin` `Plugin.define()` contract. The installer now registers the exact `@bybrawe/opencode-loop` package in OpenCode's plural `plugins` configuration instead of copying a loose global plugin directory. OpenCode therefore resolves `@opencode/plugin` from Loop's own production dependency graph; explicit `--legacy-v1` remains a separate compatibility path.
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ Prompt/command timers, watch and stop conditions, verification, preflight/postru
|
|
|
17
17
|
|
|
18
18
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
|
|
19
19
|
|
|
20
|
-
> **Current
|
|
20
|
+
> **Current native OpenCode 2 release: `0.6.9`.** The release workflow publishes this package only after all verified release gates pass. For a not-yet-published package, use the previously published `0.6.8` until the npm registry confirms `0.6.9`.
|
|
21
21
|
|
|
22
22
|
## Install or update
|
|
23
23
|
|
|
@@ -127,18 +127,34 @@ This is an **unlimited idle loop** by default. When the assistant finishes and t
|
|
|
127
127
|
|
|
128
128
|
For very short continuation prompts such as `continue`, `keep going`, or `devam et`, Loop adds project-continuation guidance: treat the turn as continuation of the current repository/conversation, inspect relevant files/TODO/progress/git state as needed, find the next unfinished step, avoid redoing completed work, and verify meaningful changes when practical.
|
|
129
129
|
|
|
130
|
-
For project work, a
|
|
130
|
+
For project work, a continuation loop with an explicit progress file is:
|
|
131
131
|
|
|
132
132
|
```text
|
|
133
133
|
/loop --safe --ask-never --progress-file progress.md devam et
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
**Important:** `--progress-file` supplies project context only; it is **not** a completion or stop condition. Checking every `- [ ]` task as `- [x]` in `progress.md` or `TASK.md` will **not automatically stop** an ordinary idle loop. This distinction is tracked in [#174](https://github.com/ByBrawe/opencode-loop/issues/174) following a [user report](https://www.reddit.com/r/opencode/comments/1vzchjo/i_need_help_with_opencode/). For unattended tasks that must stop, combine the progress file with a **host-observed** stop criterion such as `--max-runs`, `--max-runtime`, `--stop-file` or an explicit `--until` marker. For checklist-completion stop in **0.6.9 or newer**, add **`--until-checklist-complete`** explicitly alongside `--progress-file`; this option is absent from 0.6.8. Do not assume a progress file by itself implements a stop condition.
|
|
137
|
+
|
|
138
|
+
The **user**, not an AI-generated chat response, must invoke `/loop-stop` or `/loop-clear` as a native control command. Asking the model to type `/loop-clear` in conversation does not reliably execute that command. Use `/loop-status` to inspect current jobs and `/loop-stop` or `/loop-clear` to stop them. See [#175](https://github.com/ByBrawe/opencode-loop/issues/175) for improvements to host-owned stop UX.
|
|
139
|
+
|
|
136
140
|
Create a starter progress file first if needed:
|
|
137
141
|
|
|
138
142
|
```text
|
|
139
143
|
/loop-init
|
|
140
144
|
```
|
|
141
145
|
|
|
146
|
+
### Stop when a Markdown checklist is complete (0.6.9)
|
|
147
|
+
|
|
148
|
+
OpenCode Loop 0.6.9 adds an **opt-in, host-checked** completion guard. It is **not part of 0.6.8**. Install 0.6.9 only after its npm release passes the verification gates:
|
|
149
|
+
|
|
150
|
+
```text
|
|
151
|
+
/loop --progress-file TASK.md --until-checklist-complete --verify "npm test" finish the tasks in TASK.md
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
On a safe idle/scheduling boundary, Loop reads `TASK.md` as a regular project file. If it has at least one Markdown checkbox and all actionable boxes are checked, Loop stops scheduling further turns. If `--verify` is configured, Loop also requires a previously observed successful host verification. Empty/missing/oversized/unsafe/symlink/control-plane files pause for review rather than being called finished; code-fenced and quoted checkbox examples do not count.
|
|
155
|
+
|
|
156
|
+
**Without `--until-checklist-complete`, `--progress-file` only supplies context** and normal unlimited loops stay unlimited. The host/user controls `/loop-stop` and `/loop-clear` directly; having a model print those names is not an actual command invocation. Follow [#174](https://github.com/ByBrawe/opencode-loop/issues/174) and [#175](https://github.com/ByBrawe/opencode-loop/issues/175).
|
|
157
|
+
|
|
142
158
|
### Repeat every 5 minutes
|
|
143
159
|
|
|
144
160
|
```text
|
|
@@ -473,13 +489,9 @@ For unattended work, avoid permanently allowing every shell command. Grant routi
|
|
|
473
489
|
@bybrawe/opencode-loop
|
|
474
490
|
```
|
|
475
491
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
```text
|
|
479
|
-
@opencode-ai/plugin >=1.4.0 <2
|
|
480
|
-
```
|
|
492
|
+
Native OpenCode 2 is the default supported package path for `0.6.9`. The production dependency is pinned to `@opencode/plugin@2.0.22`; the explicit legacy V1 compatibility path retains `@opencode-ai/plugin >=1.4.0 <2`. Do **not** mistake the legacy V1 peer range for the V2 runtime API version.
|
|
481
493
|
|
|
482
|
-
|
|
494
|
+
Automated release gates cover native V2 packages, Windows/Linux regressions and real OpenCode 2 host command registration/coexistence with the separately installed Goal plugin. **Windows Desktop GUI slash-command discovery is not yet proven equivalent to CLI TUI**; see [#169](https://github.com/ByBrawe/opencode-loop/issues/169) and the [diagnostic guide](docs/OPENCODE2-DESKTOP-WINDOWS.md). Platform gaps are tracked in [#177](https://github.com/ByBrawe/opencode-loop/issues/177).
|
|
483
495
|
|
|
484
496
|
See [docs/SCHEDULING.md](./docs/SCHEDULING.md) for the scheduling contract and [CHANGELOG.md](./CHANGELOG.md) for release history.
|
|
485
497
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# OpenCode Loop 0.6.9
|
|
2
|
+
|
|
3
|
+
## Native OpenCode 2 checklist completion guard (#174)
|
|
4
|
+
|
|
5
|
+
Loop now supports **opt-in host-verified checklist completion** for user-owned project Markdown files. This addresses the real user report in which all tasks in TASK.md were checked but an intentionally unlimited idle Loop kept running.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
|
|
9
|
+
~~~text
|
|
10
|
+
/loop --progress-file TASK.md --until-checklist-complete --verify "npm test" finish remaining project work
|
|
11
|
+
~~~
|
|
12
|
+
|
|
13
|
+
The **--until-checklist-complete** flag is explicit: an ordinary **--progress-file** is context only and does **not** change the existing unlimited Loop continuation contract. At a safe scheduling boundary, the host reads the Markdown file and requires at least one task checkbox with **zero unchecked tasks**. It ignores fenced code/quoted/commented examples. Missing, unsafe, symlinked, outside-project, control-plane, empty/unreadable or oversized paths never authorize completion; Loop pauses visibly instead.
|
|
14
|
+
|
|
15
|
+
When **--verify** is present, completion requires the host's successful verification recorded for the **exact SHA-256 content** of the checklist file. If TASK.md changes after a successful verification, the old result cannot stop the updated task. This is a file-content consistency check, **not** independent semantic evidence that untested work was correct.
|
|
16
|
+
|
|
17
|
+
Both native V2 and explicit V1 compatibility runtimes use the guard. Deterministic parser/filesystem, safe idle prompt suppression, stale-verification, bundle parity and Loop+Goal coexistence tests are required release gates.
|
|
18
|
+
|
|
19
|
+
## Native stop controls and OpenCode 2 diagnostics
|
|
20
|
+
|
|
21
|
+
Invoke /loop-stop or /loop-clear as a **real user slash command**. An AI assistant merely printing /loop-clear does not invoke the host command. /loop-status shows the opt-in checklist state and actionable stop information for active jobs.
|
|
22
|
+
|
|
23
|
+
Read-only Windows Desktop diagnostics can compare the CLI backend and an explicit localhost Desktop backend across two workspace directories, but the original Desktop GUI command discovery issue [#169](https://github.com/ByBrawe/opencode-loop/issues/169) is **not** claimed fixed until field replay.
|
|
24
|
+
|
|
25
|
+
## Package installation and verification
|
|
26
|
+
|
|
27
|
+
~~~text
|
|
28
|
+
npx -y @bybrawe/opencode-loop@0.6.9
|
|
29
|
+
npx -y @bybrawe/opencode-goal@1.3.49
|
|
30
|
+
~~~
|
|
31
|
+
|
|
32
|
+
Install packages separately; Loop never silently installs or updates dedicated Goal. Fully quit and restart OpenCode, verify /loop-doctor, /loop-status, /goal status, and a short bounded Loop run in the intended project directory. Only rely on the new flag after 0.6.9 is present in the npm registry and the full published-package consumer workflow has succeeded. If publication fails, do not treat the GitHub source commit as an npm release.
|
|
33
|
+
|
|
34
|
+
This release does **not** claim all Windows Desktop/WSL/ARM64 GUI scenarios are solved.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"description": "Idle-safe /loop scheduling and background automation for OpenCode, with optional legacy Loop Goal compatibility and a separately installable OpenCode Goal companion.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/plugin.js",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
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",
|
|
25
25
|
"build:plugin:npm": "npm run build:plugin",
|
|
26
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/state-process-test.mjs && node --check scripts/state-exfat-lock-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-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-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-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/state-process-test.mjs && node scripts/state-exfat-lock-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
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/state-process-test.mjs && node --check scripts/state-exfat-lock-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-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-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-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/source/runtime/checklist-completion.js && node --check scripts/checklist-completion-test.mjs && 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/checklist-completion-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/state-process-test.mjs && node scripts/state-exfat-lock-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
29
29
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
30
30
|
"install:global": "node scripts/install-with-goals.mjs",
|
|
31
31
|
"pack:zip": "node scripts/make-zip.mjs",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { parseLoopArgs } from "../src/source/core/args.js"
|
|
6
|
+
import { summarizeTaskChecklist, inspectTaskChecklist } from "../src/source/runtime/checklist-completion.js"
|
|
7
|
+
import { formatOpenCode2LoopStatus } from "../src/source/opencode2/status.js"
|
|
8
|
+
import { createNativeJobPolicy } from "../src/source/opencode2/native-policy.js"
|
|
9
|
+
|
|
10
|
+
let checks = 0
|
|
11
|
+
function expectSummary(input, status, total, pending) {
|
|
12
|
+
const got = summarizeTaskChecklist(input)
|
|
13
|
+
assert.deepEqual([got.status, got.total, got.pending], [status, total, pending])
|
|
14
|
+
checks++
|
|
15
|
+
}
|
|
16
|
+
const ticks = String.fromCharCode(96).repeat(3)
|
|
17
|
+
expectSummary("- [ ] first\n- [x] second", "pending", 2, 1)
|
|
18
|
+
expectSummary("- [X] first\r\n * [x] second\r\n + [x] third", "complete", 3, 0)
|
|
19
|
+
expectSummary("\uFEFF- [x] already finished\n", "complete", 1, 0)
|
|
20
|
+
expectSummary("- [ ]", "pending", 1, 1)
|
|
21
|
+
expectSummary("- [x]\n- [ ] TODO", "pending", 2, 1)
|
|
22
|
+
expectSummary(ticks + "md\n- [ ] fake\n" + ticks + "\n- [x] real", "complete", 1, 0)
|
|
23
|
+
expectSummary("~~~~\n- [ ] fake\n~~~~\n- [x] real", "complete", 1, 0)
|
|
24
|
+
expectSummary("> - [ ] fake\n- [x] real", "complete", 1, 0)
|
|
25
|
+
expectSummary("<!--\n- [ ] fake\n-->\n- [x] real", "complete", 1, 0)
|
|
26
|
+
expectSummary("<!-- - [ ] fake -->\n- [x] real", "complete", 1, 0)
|
|
27
|
+
expectSummary("No Markdown task boxes here.", "unavailable", 0, 0)
|
|
28
|
+
expectSummary(ticks + "\n- [x] fake\n" + ticks, "unavailable", 0, 0)
|
|
29
|
+
expectSummary("## [x] Not a task\n- [other] Nope", "unavailable", 0, 0)
|
|
30
|
+
|
|
31
|
+
const args = parseLoopArgs("0s --progress-file TASK.md --until-checklist-complete task")
|
|
32
|
+
assert.equal(args.ok, true)
|
|
33
|
+
assert.equal(args.job.untilChecklistComplete, true)
|
|
34
|
+
assert.equal(args.job.progressFile, "TASK.md")
|
|
35
|
+
assert.equal(parseLoopArgs("0s --until-checklist-complete task").ok, false)
|
|
36
|
+
assert.equal(parseLoopArgs("0s --progress-file TASK.md task").job.untilChecklistComplete, false)
|
|
37
|
+
checks += 5
|
|
38
|
+
assert.equal(formatOpenCode2LoopStatus({ jobs: [] }).text, "OpenCode loop status:\nNo active loop jobs.")
|
|
39
|
+
checks++
|
|
40
|
+
|
|
41
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "loop-checklist-"))
|
|
42
|
+
try {
|
|
43
|
+
await writeFile(path.join(root, "TASK.md"), "- [ ] one\n- [x] two\n")
|
|
44
|
+
let state = await inspectTaskChecklist(root, "TASK.md")
|
|
45
|
+
assert.deepEqual([state.status, state.total, state.pending], ["pending", 2, 1])
|
|
46
|
+
checks++
|
|
47
|
+
await writeFile(path.join(root, "TASK.md"), "- [X] one\n- [x] two\n")
|
|
48
|
+
state = await inspectTaskChecklist(root, "TASK.md")
|
|
49
|
+
assert.equal(state.status, "complete")
|
|
50
|
+
assert.match(state.digest, /^[0-9a-f]{64}$/)
|
|
51
|
+
checks++
|
|
52
|
+
const verifiedHash = state.digest
|
|
53
|
+
const policy = createNativeJobPolicy()
|
|
54
|
+
const job = { id: "verify", untilChecklistComplete: true, progressFile: "TASK.md",
|
|
55
|
+
verifyCommand: "npm test", lastVerifyCode: 0, lastVerifyAt: 10, lastVerifyChecklistDigest: verifiedHash }
|
|
56
|
+
await writeFile(path.join(root, "TASK.md"), "- [X] one\n- [x] two\n- [x] three\n")
|
|
57
|
+
assert.equal(await policy.checkStop({ directory: root }, job), true,
|
|
58
|
+
"stale digest must not complete a modified checklist")
|
|
59
|
+
assert.equal(job.enabled, undefined)
|
|
60
|
+
assert.equal(job.paused, undefined)
|
|
61
|
+
checks++
|
|
62
|
+
job.lastVerifyChecklistDigest = (await inspectTaskChecklist(root, "TASK.md")).digest
|
|
63
|
+
assert.equal(await policy.checkStop({ directory: root }, job), false)
|
|
64
|
+
assert.equal(job.paused, true)
|
|
65
|
+
assert.equal(job.pauseReason, "checklist-complete")
|
|
66
|
+
checks++
|
|
67
|
+
await mkdir(path.join(root, "plans"))
|
|
68
|
+
await writeFile(path.join(root, "plans", "tasks.md"), " - [x] done\n")
|
|
69
|
+
assert.equal((await inspectTaskChecklist(root, "plans/tasks.md")).status, "complete")
|
|
70
|
+
checks++
|
|
71
|
+
|
|
72
|
+
const bad = [
|
|
73
|
+
["missing.md", "file-missing"],
|
|
74
|
+
["../escape.md", "outside-project"],
|
|
75
|
+
["/tmp/not-a-project-task.md", "absolute-path"],
|
|
76
|
+
[".opencode/goals/goal.md", "control-plane-path"],
|
|
77
|
+
[".git/TASK.md", "control-plane-path"],
|
|
78
|
+
["node_modules/TASK.md", "control-plane-path"],
|
|
79
|
+
["plans", "not-regular-file"],
|
|
80
|
+
]
|
|
81
|
+
for (const [file, reason] of bad) {
|
|
82
|
+
const result = await inspectTaskChecklist(root, file)
|
|
83
|
+
assert.equal(result.status, "unavailable", file)
|
|
84
|
+
assert.equal(result.reason, reason, file)
|
|
85
|
+
checks++
|
|
86
|
+
}
|
|
87
|
+
await writeFile(path.join(root, "empty.md"), "Just prose\n")
|
|
88
|
+
assert.equal((await inspectTaskChecklist(root, "empty.md")).reason, "no-task-checkboxes")
|
|
89
|
+
checks++
|
|
90
|
+
await writeFile(path.join(root, "huge.md"), "x".repeat(256_001))
|
|
91
|
+
assert.equal((await inspectTaskChecklist(root, "huge.md")).reason, "file-too-large")
|
|
92
|
+
checks++
|
|
93
|
+
try {
|
|
94
|
+
await symlink(path.join(root, "TASK.md"), path.join(root, "linked.md"))
|
|
95
|
+
assert.equal((await inspectTaskChecklist(root, "linked.md")).reason, "symlink-path")
|
|
96
|
+
checks++
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (!["EPERM", "EACCES", "ENOTSUP", "EOPNOTSUPP"].includes(error?.code)) throw error
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(root, { force: true, recursive: true })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log("checklist completion regression tests passed: " + checks)
|
|
@@ -559,7 +559,15 @@ async function testStopsPreflightAndGoalLifecycle() {
|
|
|
559
559
|
await h.command("loop", "5m --no-now --stop-file STOP continue")
|
|
560
560
|
await fs.writeFile(path.join(h.directory, "STOP"), "stop", "utf8")
|
|
561
561
|
await h.command("loop-now")
|
|
562
|
-
|
|
562
|
+
// /loop-now dispatches through a 250ms idle-safe timer, not the
|
|
563
|
+
// synchronous command response. Busy Windows runners can take longer
|
|
564
|
+
// than the harness's 400ms delay to process the persisted stop-file.
|
|
565
|
+
const removed = await waitForValue(async () => {
|
|
566
|
+
const state = await h.readState()
|
|
567
|
+
return state.jobs.length === 0 ? state : undefined
|
|
568
|
+
}, 4_000, 20)
|
|
569
|
+
assert.ok(removed, "--stop-file must remove the job at the next safe scheduler boundary")
|
|
570
|
+
assert.equal(h.actionTexts().length, 0, "--stop-file must suppress prompts, not just hide a persisted job")
|
|
563
571
|
} finally {
|
|
564
572
|
await h.cleanup()
|
|
565
573
|
}
|
|
@@ -19,7 +19,7 @@ test("Windows Desktop native catalog probe checks both commands but never return
|
|
|
19
19
|
const server = createServer((req, res) => {
|
|
20
20
|
calls.push({ url: req.url, directory: req.headers["x-opencode-directory"], auth: req.headers.authorization })
|
|
21
21
|
res.setHeader("Content-Type", "application/json")
|
|
22
|
-
if (req.url === "/api/info") return res.end(JSON.stringify({ pid: process.pid, version: "2.0.
|
|
22
|
+
if (req.url === "/api/info") return res.end(JSON.stringify({ pid: process.pid, version: "2.0.29" }))
|
|
23
23
|
if (req.url === "/api/command") {
|
|
24
24
|
const native = req.headers["x-opencode-directory"] === desktopWorkspace
|
|
25
25
|
? [{ name: "init" }] : [{ name: "loop" }, { name: "goal" }, { name: "loop-help" }]
|
|
@@ -39,10 +39,13 @@ test("Windows Desktop native catalog probe checks both commands but never return
|
|
|
39
39
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
|
|
40
40
|
const url = "http://127.0.0.1:" + server.address().port
|
|
41
41
|
await writeFile(path.join(state, "service.json"), JSON.stringify({
|
|
42
|
-
url, pid: process.pid, version: "2.0.
|
|
42
|
+
url, pid: process.pid, version: "2.0.29", password,
|
|
43
43
|
}))
|
|
44
44
|
const result = await diagnose({ home, workspace, env: {} })
|
|
45
45
|
assert.equal(result.service.status, "ready")
|
|
46
|
+
assert.equal(result.service.actualVersion, "2.0.29")
|
|
47
|
+
assert.equal(result.service.registeredVersion, "2.0.29")
|
|
48
|
+
assert.equal(result.service.versionMismatch, undefined)
|
|
46
49
|
assert.deepEqual(result.service.commands, { loop: true, goal: true, count: 3 })
|
|
47
50
|
assert.deepEqual(result.config.pins.loop, ["@bybrawe/opencode-loop@0.6.6"])
|
|
48
51
|
assert.deepEqual(result.config.pins.goal, ["@bybrawe/opencode-goal@1.3.47"])
|
|
@@ -62,6 +65,16 @@ test("Windows Desktop native catalog probe checks both commands but never return
|
|
|
62
65
|
assert.ok(!JSON.stringify(compared).includes(password), "comparison never exposes credentials")
|
|
63
66
|
const same = await diagnose({ home, workspace, compareWorkspace: workspace, env: {} })
|
|
64
67
|
assert.equal(same.comparison, undefined, "identical workspace needs no redundant catalog probe")
|
|
68
|
+
await writeFile(path.join(state, "service.json"), JSON.stringify({
|
|
69
|
+
url, pid: process.pid, version: "2.0.22", password,
|
|
70
|
+
}))
|
|
71
|
+
const versionMismatch = await diagnose({ home, workspace, env: {} })
|
|
72
|
+
assert.equal(versionMismatch.service.status, "ready", "version mismatch is diagnostic, not proof of bad commands")
|
|
73
|
+
assert.equal(versionMismatch.service.actualVersion, "2.0.29")
|
|
74
|
+
assert.equal(versionMismatch.service.registeredVersion, "2.0.22")
|
|
75
|
+
assert.equal(versionMismatch.service.versionMismatch, true)
|
|
76
|
+
assert.deepEqual(versionMismatch.service.commands, { loop: true, goal: true, count: 3 })
|
|
77
|
+
assert.ok(!JSON.stringify(versionMismatch).includes(password), "do not leak authentication on version mismatch")
|
|
65
78
|
const bad = await diagnose({
|
|
66
79
|
home, workspace, env: {}, server: "https://example.com", fetchImpl: () => { throw new Error("must not contact remote") },
|
|
67
80
|
})
|
|
@@ -90,3 +103,85 @@ test("installer desktop diagnosis is read-only and does not invoke Loop or Goal
|
|
|
90
103
|
assert.ok(!result.stdout.includes("Installed/updated"), "installer must not run in diagnosis mode")
|
|
91
104
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
92
105
|
})
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
test("secondary localhost backend comparison detects missing native commands without leaking registration authentication", async () => {
|
|
109
|
+
const home = await mkdtemp(path.join(tmpdir(), "loop-desktop-second-backend-"))
|
|
110
|
+
const workspace = path.join(home, "my project")
|
|
111
|
+
const desktopWorkspace = path.join(home, "parent shown in Desktop")
|
|
112
|
+
const state = path.join(home, ".local", "state", "opencode")
|
|
113
|
+
const primaryCalls = [], desktopCalls = []
|
|
114
|
+
const localSecret = "primary-service-password-not-for-desktop"
|
|
115
|
+
const externalSecret = "explicit-desktop-probe-password"
|
|
116
|
+
const reply = (res, payload) => {
|
|
117
|
+
res.setHeader("Content-Type", "application/json")
|
|
118
|
+
res.end(JSON.stringify(payload))
|
|
119
|
+
}
|
|
120
|
+
const primary = createServer((req, res) => {
|
|
121
|
+
primaryCalls.push({ directory: req.headers["x-opencode-directory"], auth: req.headers.authorization })
|
|
122
|
+
if (req.url === "/api/info") return reply(res, { pid: process.pid, version: "2.0.29" })
|
|
123
|
+
if (req.url === "/api/command") return reply(res, req.headers["x-opencode-directory"] === desktopWorkspace ? [{ name: "init" }] : [{ name: "loop" }, { name: "goal" }])
|
|
124
|
+
res.writeHead(404).end("{}")
|
|
125
|
+
})
|
|
126
|
+
const desktop = createServer((req, res) => {
|
|
127
|
+
desktopCalls.push({ directory: req.headers["x-opencode-directory"], auth: req.headers.authorization })
|
|
128
|
+
if (req.url === "/api/info") return reply(res, { pid: process.pid + 99, version: "2.0.30" })
|
|
129
|
+
if (req.url === "/api/command") return reply(res, req.headers["x-opencode-directory"] === desktopWorkspace ? [{ name: "goal" }] : [{ name: "init" }])
|
|
130
|
+
res.writeHead(404).end("{}")
|
|
131
|
+
})
|
|
132
|
+
try {
|
|
133
|
+
await mkdir(state, { recursive: true })
|
|
134
|
+
await mkdir(workspace, { recursive: true })
|
|
135
|
+
await mkdir(desktopWorkspace, { recursive: true })
|
|
136
|
+
await Promise.all([primary, desktop].map(s => new Promise(resolve => s.listen(0, "127.0.0.1", resolve))))
|
|
137
|
+
const primaryURL = "http://127.0.0.1:" + primary.address().port
|
|
138
|
+
const desktopURL = "http://127.0.0.1:" + desktop.address().port
|
|
139
|
+
await writeFile(path.join(state, "service.json"), JSON.stringify({
|
|
140
|
+
url: primaryURL, pid: process.pid, version: "2.0.29", password: localSecret,
|
|
141
|
+
}))
|
|
142
|
+
|
|
143
|
+
const result = await diagnose({ home, workspace, compareWorkspace: desktopWorkspace, env: {}, compareServer: desktopURL })
|
|
144
|
+
assert.equal(result.service.status, "ready")
|
|
145
|
+
assert.deepEqual(result.service.commands, { loop: true, goal: true, count: 2 })
|
|
146
|
+
assert.equal(result.serverComparison.status, "ready")
|
|
147
|
+
assert.equal(result.serverComparison.actualVersion, "2.0.30")
|
|
148
|
+
assert.deepEqual(result.serverComparison.commands, { loop: false, goal: false, count: 1 })
|
|
149
|
+
assert.equal(result.serverComparison.matchesPrimary, false)
|
|
150
|
+
assert.deepEqual(result.comparison.commands, { loop: false, goal: false, count: 1 })
|
|
151
|
+
assert.deepEqual(result.serverComparison.comparison.commands, { loop: false, goal: true, count: 1 })
|
|
152
|
+
assert.equal(result.serverComparison.comparison.matchesPrimary, false)
|
|
153
|
+
assert.ok(primaryCalls.some(call => call.directory === desktopWorkspace))
|
|
154
|
+
assert.ok(desktopCalls.some(call => call.directory === desktopWorkspace))
|
|
155
|
+
assert.ok(primaryCalls.every(call => call.auth === "Basic " + Buffer.from("opencode:" + localSecret).toString("base64")))
|
|
156
|
+
assert.ok(desktopCalls.every(call => call.auth === undefined), "never forward service.json password to another localhost server")
|
|
157
|
+
assert.ok(desktopCalls.every(call => [workspace, desktopWorkspace].includes(call.directory)), "both servers must receive only the two selected project directories")
|
|
158
|
+
assert.ok(!JSON.stringify(result).includes(localSecret))
|
|
159
|
+
assert.ok(!JSON.stringify(result).includes(externalSecret))
|
|
160
|
+
|
|
161
|
+
const before = desktopCalls.length
|
|
162
|
+
const authorized = await diagnose({
|
|
163
|
+
home, workspace, env: { OPENCODE_PASSWORD: externalSecret }, compareServer: desktopURL,
|
|
164
|
+
})
|
|
165
|
+
assert.equal(authorized.serverComparison.status, "ready")
|
|
166
|
+
assert.ok(desktopCalls.slice(before).every(call =>
|
|
167
|
+
call.auth === "Basic " + Buffer.from("opencode:" + externalSecret).toString("base64")))
|
|
168
|
+
assert.ok(!JSON.stringify(authorized).includes(externalSecret))
|
|
169
|
+
|
|
170
|
+
const remoteCalls = desktopCalls.length
|
|
171
|
+
const rejected = await diagnose({
|
|
172
|
+
home, workspace, env: {}, compareServer: "https://not-local.example.com",
|
|
173
|
+
})
|
|
174
|
+
assert.equal(rejected.serverComparison.status, "refused-nonlocal-or-invalid-url")
|
|
175
|
+
assert.equal(desktopCalls.length, remoteCalls, "refused remote target must not be fetched")
|
|
176
|
+
const withoutRegistration = await diagnose({
|
|
177
|
+
home, workspace, env: {}, serviceFile: path.join(home, "missing-service.json"),
|
|
178
|
+
compareServer: desktopURL,
|
|
179
|
+
})
|
|
180
|
+
assert.equal(withoutRegistration.service.status, "registration-not-found")
|
|
181
|
+
assert.equal(withoutRegistration.serverComparison.status, "ready", "compare even if primary registration is missing")
|
|
182
|
+
assert.equal(withoutRegistration.serverComparison.matchesPrimary, undefined)
|
|
183
|
+
} finally {
|
|
184
|
+
await Promise.all([primary, desktop].map(s => new Promise(resolve => s.close(resolve))))
|
|
185
|
+
await rm(home, { recursive: true, force: true })
|
|
186
|
+
}
|
|
187
|
+
})
|
|
@@ -33,6 +33,11 @@ function localUrl(raw) {
|
|
|
33
33
|
return url.origin
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Service metadata is untrusted; reject multiline/control-character version labels.
|
|
37
|
+
function safeVersion(value) {
|
|
38
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9.+_-]{0,39}$/.test(value) ? value : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
function presentPins(raw) {
|
|
37
42
|
const result = {}
|
|
38
43
|
for (const name of packageNames) {
|
|
@@ -71,6 +76,8 @@ async function request(fetchImpl, base, pathname, headers) {
|
|
|
71
76
|
const response = await fetchImpl(base + pathname, {
|
|
72
77
|
method: "GET",
|
|
73
78
|
headers,
|
|
79
|
+
// Credentials and location headers must never follow a server redirect.
|
|
80
|
+
redirect: "error",
|
|
74
81
|
signal: AbortSignal.timeout(5_000),
|
|
75
82
|
})
|
|
76
83
|
if (![200, 201].includes(response.status)) return { status: response.status }
|
|
@@ -87,7 +94,7 @@ async function catalog(fetchImpl, origin, headers) {
|
|
|
87
94
|
return response
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
async function diagnoseOne({
|
|
91
98
|
env = process.env, home = homedir(), workspace = process.cwd(),
|
|
92
99
|
compareWorkspace, server, serviceFile, fetchImpl = fetch,
|
|
93
100
|
} = {}) {
|
|
@@ -117,7 +124,7 @@ export async function diagnose({
|
|
|
117
124
|
}
|
|
118
125
|
url = info?.url
|
|
119
126
|
password = info?.password
|
|
120
|
-
version =
|
|
127
|
+
version = safeVersion(info?.version)
|
|
121
128
|
pid = Number.isInteger(info?.pid) ? info.pid : undefined
|
|
122
129
|
}
|
|
123
130
|
const origin = localUrl(url)
|
|
@@ -146,7 +153,9 @@ export async function diagnose({
|
|
|
146
153
|
output.service.status = "stale-service-registration"
|
|
147
154
|
return output
|
|
148
155
|
}
|
|
149
|
-
if (
|
|
156
|
+
if (safeVersion(info.json.version)) output.service.actualVersion = safeVersion(info.json.version)
|
|
157
|
+
if (version && output.service.actualVersion && version !== output.service.actualVersion)
|
|
158
|
+
output.service.versionMismatch = true
|
|
150
159
|
}
|
|
151
160
|
const list = await catalog(fetchImpl, origin, headers)
|
|
152
161
|
if (list.error) {
|
|
@@ -183,27 +192,61 @@ export async function diagnose({
|
|
|
183
192
|
return output
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
// Compare a caller-supplied *local* Desktop backend with the regular service.
|
|
196
|
+
// Do not forward the service.json password to a different server: diagnoseOne
|
|
197
|
+
// authenticates explicit endpoints using only the caller's environment.
|
|
198
|
+
export async function diagnose({ compareServer, ...options } = {}) {
|
|
199
|
+
const output = await diagnoseOne(options)
|
|
200
|
+
if (compareServer === undefined) return output
|
|
201
|
+
|
|
202
|
+
if (typeof compareServer !== "string" || !localUrl(compareServer)) {
|
|
203
|
+
output.serverComparison = { status: "refused-nonlocal-or-invalid-url" }
|
|
204
|
+
return output
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const compared = await diagnoseOne({
|
|
208
|
+
...options,
|
|
209
|
+
server: compareServer,
|
|
210
|
+
// Compare both project locations on BOTH backend connections.
|
|
211
|
+
// The second backend never receives the primary registration secret.
|
|
212
|
+
})
|
|
213
|
+
const service = compared.service
|
|
214
|
+
output.serverComparison = {
|
|
215
|
+
status: service.status,
|
|
216
|
+
...(service.host ? { host: service.host } : {}),
|
|
217
|
+
...(service.actualVersion ? { actualVersion: service.actualVersion } : {}),
|
|
218
|
+
...(service.commands ? { commands: service.commands } : {}),
|
|
219
|
+
...(compared.comparison ? { comparison: compared.comparison } : {}),
|
|
220
|
+
...(service.commands && output.service.commands ? {
|
|
221
|
+
matchesPrimary: service.commands.loop === output.service.commands.loop
|
|
222
|
+
&& service.commands.goal === output.service.commands.goal,
|
|
223
|
+
} : {}),
|
|
224
|
+
}
|
|
225
|
+
return output
|
|
226
|
+
}
|
|
227
|
+
|
|
186
228
|
export async function runDesktopDiagnosticCli(args = process.argv.slice(2), options = {}) {
|
|
187
|
-
let workspace = process.cwd(), compareWorkspace, server, json = false
|
|
229
|
+
let workspace = process.cwd(), compareWorkspace, server, compareServer, json = false
|
|
188
230
|
for (let i = 0; i < args.length; i++) {
|
|
189
231
|
const arg = args[i]
|
|
190
232
|
if (arg === "--help" || arg === "-h") {
|
|
191
|
-
console.log("Usage: opencode-loop --desktop-diagnostics [--workspace PATH] [--compare-workspace PATH] [--server LOCAL_URL] [--json]")
|
|
233
|
+
console.log("Usage: opencode-loop --desktop-diagnostics [--workspace PATH] [--compare-workspace PATH] [--server LOCAL_URL] [--compare-server LOCAL_URL] [--json]")
|
|
192
234
|
console.log("Read-only check of both native /loop and /goal registrations; never prints credentials.")
|
|
193
235
|
return 0
|
|
194
236
|
}
|
|
195
237
|
if (arg === "--json") { json = true; continue }
|
|
196
|
-
if (arg === "--workspace" || arg === "--compare-workspace" || arg === "--server") {
|
|
238
|
+
if (arg === "--workspace" || arg === "--compare-workspace" || arg === "--server" || arg === "--compare-server") {
|
|
197
239
|
if (!args[i + 1]) { console.error("Missing value for " + arg); return 2 }
|
|
198
240
|
if (arg === "--workspace") workspace = args[++i]
|
|
199
241
|
else if (arg === "--compare-workspace") compareWorkspace = args[++i]
|
|
242
|
+
else if (arg === "--compare-server") compareServer = args[++i]
|
|
200
243
|
else server = args[++i]
|
|
201
244
|
continue
|
|
202
245
|
}
|
|
203
246
|
console.error("Unknown Desktop diagnostic option: " + arg)
|
|
204
247
|
return 2
|
|
205
248
|
}
|
|
206
|
-
const result = await diagnose({ ...options, workspace, compareWorkspace, server })
|
|
249
|
+
const result = await diagnose({ ...options, workspace, compareWorkspace, server, compareServer })
|
|
207
250
|
if (json) console.log(JSON.stringify(result, null, 2))
|
|
208
251
|
else {
|
|
209
252
|
console.log("OpenCode Desktop native command diagnosis (read-only)")
|
|
@@ -211,7 +254,10 @@ export async function runDesktopDiagnosticCli(args = process.argv.slice(2), opti
|
|
|
211
254
|
console.log("Global config: " + result.config.directory)
|
|
212
255
|
for (const kind of packageNames)
|
|
213
256
|
console.log("Configured " + kind + " spec: " + (result.config.pins[kind].join(", ") || "not found (text scan)"))
|
|
214
|
-
console.log("Local service: " + result.service.status
|
|
257
|
+
console.log("Local service: " + result.service.status
|
|
258
|
+
+ (result.service.actualVersion ? " (live " + result.service.actualVersion + ")" : result.service.registeredVersion ? " (registered " + result.service.registeredVersion + ")" : ""))
|
|
259
|
+
if (result.service.versionMismatch)
|
|
260
|
+
console.log("Version mismatch: service.json registration and live /api/info disagree; verify the service identity before diagnosing Desktop.")
|
|
215
261
|
if (result.service.commands)
|
|
216
262
|
console.log("Server /loop: " + result.service.commands.loop + " | /goal: " + result.service.commands.goal)
|
|
217
263
|
if (result.comparison) {
|
|
@@ -222,9 +268,24 @@ export async function runDesktopDiagnosticCli(args = process.argv.slice(2), opti
|
|
|
222
268
|
console.log("Same native command visibility across directories: " + result.comparison.matchesPrimary)
|
|
223
269
|
}
|
|
224
270
|
}
|
|
271
|
+
if (result.serverComparison) {
|
|
272
|
+
const compared = result.serverComparison
|
|
273
|
+
console.log("Compared local backend: " + compared.status
|
|
274
|
+
+ (compared.actualVersion ? " (live " + compared.actualVersion + ")" : "")
|
|
275
|
+
+ (compared.host ? " at " + compared.host : ""))
|
|
276
|
+
if (compared.commands)
|
|
277
|
+
console.log("Compared backend /loop: " + compared.commands.loop + " | /goal: " + compared.commands.goal)
|
|
278
|
+
if (compared.matchesPrimary !== undefined)
|
|
279
|
+
console.log("Same native command visibility across backends: " + compared.matchesPrimary)
|
|
280
|
+
if (compared.comparison) {
|
|
281
|
+
console.log("Compared backend second workspace catalog: " + compared.comparison.status)
|
|
282
|
+
if (compared.comparison.commands)
|
|
283
|
+
console.log("Compared backend second workspace /loop: " + compared.comparison.commands.loop + " | /goal: " + compared.comparison.commands.goal)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
225
286
|
if (!result.service.commands)
|
|
226
287
|
console.log("No confirmed local server catalog. Desktop may use a different connection or service.")
|
|
227
|
-
console.log("
|
|
288
|
+
console.log("These are local backend probes, not proof of the connection selected by the Desktop window.")
|
|
228
289
|
}
|
|
229
290
|
return 0
|
|
230
291
|
}
|
package/scripts/loopd-test.mjs
CHANGED
|
@@ -76,7 +76,7 @@ try {
|
|
|
76
76
|
let calls = await readLog(fakeOpenCode.log)
|
|
77
77
|
let runCalls = calls.filter((item) => item.args[0] === "run")
|
|
78
78
|
assert.equal(runCalls.length, 1)
|
|
79
|
-
assert.equal(
|
|
79
|
+
assert.equal(await fs.realpath(runCalls[0].cwd), await fs.realpath(project), "daemon and selected project refer to the same physical directory")
|
|
80
80
|
assert.deepEqual(runCalls[0].args, [
|
|
81
81
|
"run", "--title", runCalls[0].args[2],
|
|
82
82
|
"--model", "opencode/nemotron-3-ultra-free",
|
package/scripts/loopd.mjs
CHANGED
|
@@ -181,9 +181,11 @@ async function selectHost(project, options = {}) {
|
|
|
181
181
|
async function listSessions(host, project, limit) {
|
|
182
182
|
const argv = ["session", "list", "--format", "json", "-n", String(limit)]
|
|
183
183
|
if (host.server) argv.push("--server", host.server)
|
|
184
|
+
// macOS aliases /var -> /private/var. OpenCode 2 needs the canonical
|
|
185
|
+
// project PWD to agree with process.cwd() and the native session location.
|
|
184
186
|
const result = await run(host.binary, argv, project, {
|
|
185
187
|
capture: true, timeoutMs: 15_000,
|
|
186
|
-
...(host.major === 2 ? { env: { ...process.env, PWD: project } } : {}),
|
|
188
|
+
...(host.major === 2 ? { env: { ...process.env, PWD: process.platform === "win32" ? project : fs.realpathSync.native(project) } } : {}),
|
|
187
189
|
})
|
|
188
190
|
if (result.code !== 0 || result.timedOut) throw new Error("OpenCode session list failed; refusing to guess a session")
|
|
189
191
|
return projectSessions(result.stdout, project, host.major === 2)
|
|
@@ -286,7 +288,7 @@ async function daemon(options = {}) {
|
|
|
286
288
|
// Keep native runs project-bound and non-interactive without changing V1.
|
|
287
289
|
const result = await run(host.binary, runArgs, project, {
|
|
288
290
|
timeoutMs, closeStdin: host.major === 2,
|
|
289
|
-
...(host.major === 2 ? { env: { ...process.env, PWD: project } } : {}),
|
|
291
|
+
...(host.major === 2 ? { env: { ...process.env, PWD: process.platform === "win32" ? project : fs.realpathSync.native(project) } } : {}),
|
|
290
292
|
})
|
|
291
293
|
const code = result.timedOut ? 124 : result.code
|
|
292
294
|
const moreRunsRemain = maxRuns === 0 || count < maxRuns
|