@bybrawe/opencode-loop 0.6.7 → 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 CHANGED
@@ -4,6 +4,23 @@
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
+
16
+ ## 0.6.8 - 2026-10-13
17
+
18
+ - Fix #172: fall back to exclusive `wx` lock claims where exFAT/FAT or other volumes reject `fs.link()`, including the reported `EISDIR` error.
19
+ - Preserve live-owner exclusion, dead-owner cleanup elections, crash-safe state updates and fail-closed corrupted locks.
20
+ - Exercise simulated unsupported hard-link errors across independent workers, merged writes, recovered dead owners and unexpected errors.
21
+ - Pair the new release with independently installed Goal 1.3.49 and require Windows/Linux and native OpenCode 2 release gates.
22
+ - The reported physical Windows exFAT case still needs re-testing on its actual disk.
23
+
7
24
  ## 0.6.7 - 2026-10-09
8
25
 
9
26
  - Recover the native Loop+Goal coexistence canary's cold Windows server bootstrap with bounded read-only session polling and distinguish server startup hangs from actual autonomous-ownership conflicts.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenCode Loop
2
2
 
3
- ## Native OpenCode 2 (0.6.7)
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 stable release: `0.6.7`.**
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 strong default is:
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
- Stable package peer range:
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
- The repository contains experimental OpenCode 2 work, but the stable package does not claim full OpenCode 2 parity.
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,18 @@
1
+ # OpenCode Loop 0.6.8
2
+
3
+ ## exFAT/FAT project volume lock compatibility (#172)
4
+
5
+ When `fs.link()` cannot claim a Loop session lock because hard links are unsupported (`EISDIR` on the reported Windows exFAT case, or `ENOTSUP`/`EOPNOTSUPP`/`ENOSYS`/`EPERM`/`EACCES`), Loop attempts an atomic `wx` exclusive-file claim instead.
6
+
7
+ The fallback also applies to dead-owner cleanup elections. A different live owner always wins; malformed lock owner metadata remains fail-closed, and unexpected I/O errors are not ignored. Short empty-owner publication windows get bounded retries instead of false corruption reports.
8
+
9
+ Tests exercise synthetic exFAT-style errors in independent Node processes, concurrent updates, abandoned locks and broken metadata. Windows/Linux, real OpenCode 2 and production artifact validation remain release gates. **Physical Windows exFAT and Desktop GUI behavior require separate field verification.**
10
+
11
+ ## Independent installation
12
+
13
+ ```powershell
14
+ npx -y @bybrawe/opencode-loop@0.6.8
15
+ npx -y @bybrawe/opencode-goal@1.3.49
16
+ ```
17
+
18
+ Fully exit and restart OpenCode. From the affected exFAT project, verify `/loop-status`, `/loop-doctor`, a short `/loop` job, and `/goal status`. Dedicated Goal remains a separately installed plugin; `/loop-goal*` compatibility commands are opt-in.
@@ -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.7",
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-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-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
- assert.equal((await h.readState()).jobs.length, 0)
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" }))
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.22", password,
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
- export async function diagnose({
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 = typeof info?.version === "string" ? info.version : undefined
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 (typeof info.json.version === "string") output.service.actualVersion = info.json.version
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 + (result.service.registeredVersion ? " (" + result.service.registeredVersion + ")" : ""))
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("This checks one local service and directory; it cannot prove which connection Desktop currently uses.")
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
  }
@@ -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(path.resolve(runCalls[0].cwd), path.resolve(project))
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",