@bybrawe/opencode-loop 0.6.6 → 0.6.7

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
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased (main only)
4
+
5
+ No unreleased changes yet.
6
+
7
+ ## 0.6.7 - 2026-10-09
8
+
9
+ - 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.
10
+ - Enforce fail-closed corrupt Loop state reads and protect per-session writes across multiple processes on Windows and Linux, including dead-owner recovery.
11
+ - Support manual native V2 compaction through the public `session.compact` capability, verified by matching manual `inputID`, completion and terminal boundaries.
12
+ - Add read-only Windows Desktop command-registry diagnostics for Loop and Goal and parent-versus-child workspace comparisons; the separate GUI `command.updated` refresh is proposed upstream but not published.
13
+ - Align `/loop-help` with the current compaction behavior, rather than advertising the removed V2 limitation.
14
+
3
15
  ## 0.6.6 - 2026-10-04
4
16
 
5
17
  - Make a normal Loop install/update strictly Loop-only: it no longer installs, detects, refreshes, migrates, or modifies the dedicated `@bybrawe/opencode-goal` package.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenCode Loop
2
2
 
3
- ## Native OpenCode 2 (0.6.6)
3
+ ## Native OpenCode 2 (0.6.7)
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
 
@@ -8,7 +8,7 @@ The installer defaults to the native V2 package plugin. It needs no loose local
8
8
 
9
9
  V2 prompt admission and durable inbox/execution/compaction events own scheduling. Core-generated user-role messages never authorize a session abort. An unfinished dedicated Goal reserves its session, including paused and handed-off states; Loop does not wake or replace that Goal. Soft iteration timeouts do not abort a native model/tool/compaction turn.
10
10
 
11
- Scheduled shell commands use Loop-managed bounded local child processes; the OpenCode 2 `ctx.shell` surface is a hook domain, not a command-execution API. Automatic compaction stays host-owned. **The public OpenCode 2 SessionDomain has no manual compaction action, so `/loop-compact`, `--compact`, and `--compact-every` are rejected before job creation instead of probing undocumented `session.compact` fields.** Use OpenCode's built-in manual/automatic compaction on V2.
11
+ Automatic compaction remains host-owned. On OpenCode 2 versions exposing public `session.compact`, Loop's explicit native compaction is capability-gated and waits for its own manual `session.compaction.started` input ID, compaction completion, and a session terminal event. Older hosts reject unsupported compaction before scheduling.
12
12
 
13
13
  Prompt/command timers, watch and stop conditions, verification, preflight/postrun, notifications, checkpoints and diagnostic commands use the V2 runtime. Uncertain restart admissions pause for review rather than replaying potentially admitted work. The dedicated `@bybrawe/opencode-goal` plugin owns `/goal`; legacy Loop Goal records are preserved and are not silently reinterpreted as new Goal contracts.
14
14
 
@@ -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.6`.**
20
+ > **Current stable release: `0.6.7`.**
21
21
 
22
22
  ## Install or update
23
23
 
@@ -68,6 +68,10 @@ If you installed Goal too:
68
68
  /goal status
69
69
  ```
70
70
 
71
+ If both native commands work in terminal TUI but are missing from Windows Desktop, follow the [Windows Desktop command-registry diagnostic guide](docs/OPENCODE2-DESKTOP-WINDOWS.md). Do not replace native commands with legacy Markdown bridges.
72
+
73
+ On a version containing the diagnostic, run `npx -y @bybrawe/opencode-loop@latest --desktop-diagnostics --workspace "C:\\path\\to\\your\\project"` to check both commands against the currently registered *local* V2 service without printing its credentials. This does not install or modify Goal or Loop.
74
+
71
75
  Global npm alternative:
72
76
 
73
77
  ```bash
@@ -0,0 +1,26 @@
1
+ # OpenCode Loop 0.6.7
2
+
3
+ OpenCode 2 native reliability and package parity patch, tested alongside **separately installed** OpenCode Goal 1.3.48.
4
+
5
+ ## Changes
6
+
7
+ - Protect per-session Loop state across multiple OpenCode processes with file locks, fail-closed corrupt-file reads, crash/dead-owner lock recovery and atomic writes on Windows/Linux.
8
+ - Restore manual native compaction with the public OpenCode 2 `session.compact` capability, requiring its exact manual compaction input ID, completion and terminal. Reject unsupported hosts without corrupting scheduled work.
9
+ - Provide read-only Windows Desktop native `/loop` and `/goal` command-catalog diagnostics and explicit project-directory comparisons. This is **not** a claim that Windows Desktop GUI autocomplete is fixed upstream.
10
+ - Bound Loop+Goal Windows cold-server startup probes and separate unresponsive host bootstrap from real Loop/Goal ownership conflicts.
11
+ - Align native `/loop-help` with the current V2 compaction contract.
12
+
13
+ ## Install independently
14
+
15
+ ```powershell
16
+ npx -y @bybrawe/opencode-loop@0.6.7
17
+ npx -y @bybrawe/opencode-goal@1.3.48
18
+ ```
19
+
20
+ A normal Loop install touches Loop only; it never installs or updates the dedicated Goal package. Legacy `/loop-goal*` commands stay opt-in, and existing state is preserved.
21
+
22
+ ## Verification
23
+
24
+ When npm confirms publication, fully restart OpenCode and run `/loop-help`, `/loop-doctor`, `/goal status` in terminal TUI. For Windows Desktop use the [read-only diagnostic](../docs/OPENCODE2-DESKTOP-WINDOWS.md) without adding Markdown slash-command bridges.
25
+
26
+ Required release gates: immutable Goal source, Windows/Ubuntu package regressions, exact-main CI, installed-pair real host and clean npm consumer checks. GitHub tag and release creation require separate repository authorization, which may fail independently of verified npm publication.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
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/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js",
28
- "test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
27
+ "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",
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",
@@ -337,7 +337,7 @@ async function testActionRoutingAndSafety() {
337
337
  try {
338
338
  await h.command("loop-shell", "0s --safe npm run format")
339
339
  await h.command("loop-now", "shell")
340
- await delay(25)
340
+ await waitForValue(() => h.records.shells[0], 2_000)
341
341
  assert.equal(h.records.shells.length, 1)
342
342
  assert.equal(h.records.shells[0].command, "npm run format", "safe mode must allow a harmless format script")
343
343
  } finally {
@@ -403,8 +403,8 @@ async function testActionRoutingAndSafety() {
403
403
  try {
404
404
  await h.command("loop-command", "0s /custom-command alpha beta")
405
405
  await h.command("loop-now", "command")
406
- await delay(25)
407
- assert.deepEqual(h.records.commands[0], {
406
+ const dispatched = await waitForValue(() => h.records.commands[0], 2_000)
407
+ assert.deepEqual(dispatched, {
408
408
  command: "custom-command",
409
409
  arguments: "alpha beta",
410
410
  agent: "build",
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict"
2
+ import { createServer } from "node:http"
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import path from "node:path"
6
+ import { spawnSync } from "node:child_process"
7
+ import { fileURLToPath } from "node:url"
8
+ import test from "node:test"
9
+ import { diagnose } from "./desktop-command-diagnostics.mjs"
10
+
11
+ test("Windows Desktop native catalog probe checks both commands but never returns credentials", async () => {
12
+ const home = await mkdtemp(path.join(tmpdir(), "loop-desktop-diagnostic-"))
13
+ const workspace = path.join(home, "project with spaces")
14
+ const desktopWorkspace = path.join(home, "desktop parent project")
15
+ const cfg = path.join(home, ".config", "opencode")
16
+ const state = path.join(home, ".local", "state", "opencode")
17
+ const calls = []
18
+ const password = "private-diagnostic-password"
19
+ const server = createServer((req, res) => {
20
+ calls.push({ url: req.url, directory: req.headers["x-opencode-directory"], auth: req.headers.authorization })
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" }))
23
+ if (req.url === "/api/command") {
24
+ const native = req.headers["x-opencode-directory"] === desktopWorkspace
25
+ ? [{ name: "init" }] : [{ name: "loop" }, { name: "goal" }, { name: "loop-help" }]
26
+ return res.end(JSON.stringify(native))
27
+ }
28
+ res.writeHead(404).end("{}")
29
+ })
30
+ try {
31
+ await mkdir(cfg, { recursive: true })
32
+ await mkdir(state, { recursive: true })
33
+ await mkdir(workspace, { recursive: true })
34
+ await mkdir(desktopWorkspace, { recursive: true })
35
+ await writeFile(path.join(cfg, "opencode.json"), JSON.stringify({
36
+ plugins: ["@bybrawe/opencode-loop@0.6.6", "@bybrawe/opencode-goal@1.3.47"],
37
+ secrets: password,
38
+ }))
39
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
40
+ const url = "http://127.0.0.1:" + server.address().port
41
+ await writeFile(path.join(state, "service.json"), JSON.stringify({
42
+ url, pid: process.pid, version: "2.0.22", password,
43
+ }))
44
+ const result = await diagnose({ home, workspace, env: {} })
45
+ assert.equal(result.service.status, "ready")
46
+ assert.deepEqual(result.service.commands, { loop: true, goal: true, count: 3 })
47
+ assert.deepEqual(result.config.pins.loop, ["@bybrawe/opencode-loop@0.6.6"])
48
+ assert.deepEqual(result.config.pins.goal, ["@bybrawe/opencode-goal@1.3.47"])
49
+ assert.ok(calls.every((c) => c.directory === workspace))
50
+ assert.ok(calls.every((c) => c.auth === "Basic " + Buffer.from("opencode:" + password).toString("base64")))
51
+ assert.ok(!JSON.stringify(result).includes(password), "no secret may appear in diagnostic output")
52
+ const beforeCompare = calls.length
53
+ const compared = await diagnose({ home, workspace, compareWorkspace: desktopWorkspace, env: {} })
54
+ assert.equal(compared.comparison.workspace, desktopWorkspace)
55
+ assert.equal(compared.comparison.status, "ready")
56
+ assert.deepEqual(compared.comparison.commands, { loop: false, goal: false, count: 1 })
57
+ assert.equal(compared.comparison.matchesPrimary, false, "Desktop parent and terminal child must be comparable")
58
+ assert.ok(calls.slice(beforeCompare).some((entry) =>
59
+ entry.url === "/api/command" && entry.directory === desktopWorkspace &&
60
+ entry.auth === "Basic " + Buffer.from("opencode:" + password).toString("base64")
61
+ ), "comparison must use the same authorized local server and the requested Desktop project location")
62
+ assert.ok(!JSON.stringify(compared).includes(password), "comparison never exposes credentials")
63
+ const same = await diagnose({ home, workspace, compareWorkspace: workspace, env: {} })
64
+ assert.equal(same.comparison, undefined, "identical workspace needs no redundant catalog probe")
65
+ const bad = await diagnose({
66
+ home, workspace, env: {}, server: "https://example.com", fetchImpl: () => { throw new Error("must not contact remote") },
67
+ })
68
+ assert.equal(bad.service.status, "refused-nonlocal-or-invalid-url")
69
+ await writeFile(path.join(state, "service.json"), JSON.stringify({ url, pid: process.pid + 5, password }))
70
+ const stale = await diagnose({ home, workspace, env: {} })
71
+ assert.equal(stale.service.status, "stale-service-registration")
72
+ } finally {
73
+ await new Promise((resolve) => server.close(resolve))
74
+ await rm(home, { recursive: true, force: true })
75
+ }
76
+ })
77
+
78
+ test("installer desktop diagnosis is read-only and does not invoke Loop or Goal installers", async () => {
79
+ const home = await mkdtemp(path.join(tmpdir(), "loop-desktop-installer-"))
80
+ try {
81
+ const installer = fileURLToPath(new URL("./install-with-goals.mjs", import.meta.url))
82
+ const result = spawnSync(process.execPath, [installer, "--desktop-diagnostics", "--workspace", home, "--json"], {
83
+ encoding: "utf8", timeout: 15_000, env: { ...process.env, OPENCODE_CONFIG_DIR: path.join(home, "no-config"), XDG_STATE_HOME: path.join(home, "no-state") },
84
+ })
85
+ assert.equal(result.status, 0, result.stderr)
86
+ const report = JSON.parse(result.stdout)
87
+ assert.equal(report.service.status, "registration-not-found")
88
+ assert.equal(report.workspace, home)
89
+ assert.equal(await readFile(path.join(home, "no-config", "opencode.json"), "utf8").then(() => true, () => false), false)
90
+ assert.ok(!result.stdout.includes("Installed/updated"), "installer must not run in diagnosis mode")
91
+ } finally { await rm(home, { recursive: true, force: true }) }
92
+ })
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ // Read-only diagnosis for a Windows OpenCode Desktop/TUI command mismatch.
3
+ // Never publish service.json, config content, authorization or response bodies.
4
+ import { readFile, stat } from "node:fs/promises"
5
+ import { homedir } from "node:os"
6
+ import path from "node:path"
7
+ import process from "node:process"
8
+ import { fileURLToPath } from "node:url"
9
+
10
+ const packageNames = Object.freeze(["loop", "goal"])
11
+ const allowHosts = new Set(["localhost", "127.0.0.1", "[::1]"])
12
+ const knownConfigs = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
13
+
14
+ async function readSmall(file) {
15
+ try {
16
+ const info = await stat(file)
17
+ if (!info.isFile() || info.size > 1024 * 1024) return undefined
18
+ return await readFile(file, "utf8")
19
+ } catch (error) {
20
+ if (error?.code === "ENOENT") return undefined
21
+ throw error
22
+ }
23
+ }
24
+
25
+ function localUrl(raw) {
26
+ if (typeof raw !== "string") return
27
+ let url
28
+ try { url = new URL(raw) } catch { return }
29
+ if (!["http:", "https:"].includes(url.protocol)) return
30
+ if (!allowHosts.has(url.hostname.toLowerCase())) return
31
+ if (url.username || url.password) return
32
+ if ((url.pathname && url.pathname !== "/") || url.search || url.hash) return
33
+ return url.origin
34
+ }
35
+
36
+ function presentPins(raw) {
37
+ const result = {}
38
+ for (const name of packageNames) {
39
+ const prefix = "@bybrawe/opencode-" + name + "@"
40
+ const hits = raw.match(new RegExp(prefix.replace(/[.*+?^$\\{\\}()|[\\]\\\\]/g, "\\$&") + "[0-9]+\\.[0-9]+\\.[0-9]+", "g")) ?? []
41
+ result[name] = [...new Set(hits)].sort()
42
+ }
43
+ return result
44
+ }
45
+
46
+ async function configPins(env, home) {
47
+ const configHome = env.OPENCODE_CONFIG_DIR
48
+ ?? path.join(env.XDG_CONFIG_HOME ?? path.join(home, ".config"), "opencode")
49
+ const files = []
50
+ const pins = { loop: [], goal: [] }
51
+ for (const name of knownConfigs) {
52
+ const file = path.join(configHome, name)
53
+ const value = await readSmall(file)
54
+ if (value === undefined) continue
55
+ files.push(file)
56
+ const found = presentPins(value)
57
+ for (const kind of packageNames) pins[kind].push(...found[kind])
58
+ }
59
+ for (const kind of packageNames) pins[kind] = [...new Set(pins[kind])].sort()
60
+ return { directory: configHome, files, pins }
61
+ }
62
+
63
+ function commandsFrom(json) {
64
+ const body = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
65
+ if (!body) return undefined
66
+ return new Set(body.flatMap((item) => typeof item?.name === "string" ? [item.name] : []))
67
+ }
68
+
69
+ async function request(fetchImpl, base, pathname, headers) {
70
+ try {
71
+ const response = await fetchImpl(base + pathname, {
72
+ method: "GET",
73
+ headers,
74
+ signal: AbortSignal.timeout(5_000),
75
+ })
76
+ if (![200, 201].includes(response.status)) return { status: response.status }
77
+ const json = await response.json()
78
+ return { status: response.status, json }
79
+ } catch (error) {
80
+ return { error: error?.name === "TimeoutError" ? "timeout" : "connection-failed" }
81
+ }
82
+ }
83
+
84
+ async function catalog(fetchImpl, origin, headers) {
85
+ let response = await request(fetchImpl, origin, "/api/command", headers)
86
+ if (response.status === 404) response = await request(fetchImpl, origin, "/command", headers)
87
+ return response
88
+ }
89
+
90
+ export async function diagnose({
91
+ env = process.env, home = homedir(), workspace = process.cwd(),
92
+ compareWorkspace, server, serviceFile, fetchImpl = fetch,
93
+ } = {}) {
94
+ const cwd = path.resolve(workspace)
95
+ const comparisonDirectory = typeof compareWorkspace === "string" && compareWorkspace.trim()
96
+ ? path.resolve(compareWorkspace) : undefined
97
+ const config = await configPins(env, home)
98
+ const registeredFile = serviceFile ?? path.join(
99
+ env.XDG_STATE_HOME ?? path.join(home, ".local", "state"), "opencode", "service.json",
100
+ )
101
+ const output = { workspace: cwd, config, service: { source: server ? "explicit" : "registration", status: "unavailable" } }
102
+ let url = server
103
+ let password = env.OPENCODE_PASSWORD ?? env.OPENCODE_SERVER_PASSWORD
104
+ let version
105
+ let pid
106
+ if (!server) {
107
+ const raw = await readSmall(registeredFile)
108
+ if (raw === undefined) {
109
+ output.service.status = "registration-not-found"
110
+ return output
111
+ }
112
+ let info
113
+ try { info = JSON.parse(raw) }
114
+ catch {
115
+ output.service.status = "registration-invalid"
116
+ return output
117
+ }
118
+ url = info?.url
119
+ password = info?.password
120
+ version = typeof info?.version === "string" ? info.version : undefined
121
+ pid = Number.isInteger(info?.pid) ? info.pid : undefined
122
+ }
123
+ const origin = localUrl(url)
124
+ if (!origin) {
125
+ output.service.status = "refused-nonlocal-or-invalid-url"
126
+ return output
127
+ }
128
+ output.service.host = new URL(origin).host
129
+ if (version) output.service.registeredVersion = version
130
+ const headers = { "x-opencode-directory": cwd, accept: "application/json" }
131
+ if (typeof password === "string" && password.length) {
132
+ headers.authorization = "Basic " + Buffer.from("opencode:" + password).toString("base64")
133
+ }
134
+ const info = await request(fetchImpl, origin, "/api/info", headers)
135
+ if (info.error) {
136
+ output.service.status = info.error
137
+ return output
138
+ }
139
+ if (info.status === 401 || info.status === 403) {
140
+ output.service.status = "authorization-failed"
141
+ return output
142
+ }
143
+ if (info.status === 200 && info.json) {
144
+ const hostPID = info.json?.pid
145
+ if (pid !== undefined && hostPID !== undefined && pid !== hostPID) {
146
+ output.service.status = "stale-service-registration"
147
+ return output
148
+ }
149
+ if (typeof info.json.version === "string") output.service.actualVersion = info.json.version
150
+ }
151
+ const list = await catalog(fetchImpl, origin, headers)
152
+ if (list.error) {
153
+ output.service.status = list.error
154
+ return output
155
+ }
156
+ if (list.status === 401 || list.status === 403) {
157
+ output.service.status = "authorization-failed"
158
+ return output
159
+ }
160
+ const names = commandsFrom(list.json)
161
+ if (!names) {
162
+ output.service.status = "command-catalog-unavailable"
163
+ output.service.httpStatus = list.status
164
+ return output
165
+ }
166
+ output.service.status = "ready"
167
+ output.service.commands = { loop: names.has("loop"), goal: names.has("goal"), count: names.size }
168
+ if (comparisonDirectory && comparisonDirectory !== cwd) {
169
+ const comparisonHeaders = { ...headers, "x-opencode-directory": comparisonDirectory }
170
+ const compared = await catalog(fetchImpl, origin, comparisonHeaders)
171
+ const comparedNames = commandsFrom(compared.json)
172
+ output.comparison = {
173
+ workspace: comparisonDirectory,
174
+ status: comparedNames ? "ready" : compared.error || (
175
+ compared.status === 401 || compared.status === 403 ? "authorization-failed" : "command-catalog-unavailable"
176
+ ),
177
+ ...(comparedNames ? {
178
+ commands: { loop: comparedNames.has("loop"), goal: comparedNames.has("goal"), count: comparedNames.size },
179
+ matchesPrimary: comparedNames.has("loop") === names.has("loop") && comparedNames.has("goal") === names.has("goal"),
180
+ } : {}),
181
+ }
182
+ }
183
+ return output
184
+ }
185
+
186
+ export async function runDesktopDiagnosticCli(args = process.argv.slice(2), options = {}) {
187
+ let workspace = process.cwd(), compareWorkspace, server, json = false
188
+ for (let i = 0; i < args.length; i++) {
189
+ const arg = args[i]
190
+ if (arg === "--help" || arg === "-h") {
191
+ console.log("Usage: opencode-loop --desktop-diagnostics [--workspace PATH] [--compare-workspace PATH] [--server LOCAL_URL] [--json]")
192
+ console.log("Read-only check of both native /loop and /goal registrations; never prints credentials.")
193
+ return 0
194
+ }
195
+ if (arg === "--json") { json = true; continue }
196
+ if (arg === "--workspace" || arg === "--compare-workspace" || arg === "--server") {
197
+ if (!args[i + 1]) { console.error("Missing value for " + arg); return 2 }
198
+ if (arg === "--workspace") workspace = args[++i]
199
+ else if (arg === "--compare-workspace") compareWorkspace = args[++i]
200
+ else server = args[++i]
201
+ continue
202
+ }
203
+ console.error("Unknown Desktop diagnostic option: " + arg)
204
+ return 2
205
+ }
206
+ const result = await diagnose({ ...options, workspace, compareWorkspace, server })
207
+ if (json) console.log(JSON.stringify(result, null, 2))
208
+ else {
209
+ console.log("OpenCode Desktop native command diagnosis (read-only)")
210
+ console.log("Workspace: " + result.workspace)
211
+ console.log("Global config: " + result.config.directory)
212
+ for (const kind of packageNames)
213
+ 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 + ")" : ""))
215
+ if (result.service.commands)
216
+ console.log("Server /loop: " + result.service.commands.loop + " | /goal: " + result.service.commands.goal)
217
+ if (result.comparison) {
218
+ console.log("Compared workspace: " + result.comparison.workspace)
219
+ console.log("Compared catalog: " + result.comparison.status)
220
+ if (result.comparison.commands) {
221
+ console.log("Compared /loop: " + result.comparison.commands.loop + " | /goal: " + result.comparison.commands.goal)
222
+ console.log("Same native command visibility across directories: " + result.comparison.matchesPrimary)
223
+ }
224
+ }
225
+ if (!result.service.commands)
226
+ 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.")
228
+ }
229
+ return 0
230
+ }
231
+
232
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
233
+ runDesktopDiagnosticCli().then((code) => { process.exitCode = code }).catch((error) => {
234
+ console.error("Desktop diagnostic failed: " + (error?.code || error?.name || "unknown error"))
235
+ process.exitCode = 1
236
+ })
237
+ }
@@ -0,0 +1,46 @@
1
+ import test from "node:test"
2
+ import assert from "node:assert/strict"
3
+ import { waitForSessionBootstrap } from "./host-goal-bootstrap.mjs"
4
+
5
+ const baseURL = "http://127.0.0.1:4011"
6
+ const workspace = "C:\\projects\\space in path"
7
+
8
+ test("bounded GET /session bootstrap retries transient host readiness without sending work", async () => {
9
+ const calls = []
10
+ let attempts = 0
11
+ const fetchImpl = async (input, init) => {
12
+ calls.push({ url: new URL(input.toString()), method: init.method, directory: init.headers["x-opencode-directory"] })
13
+ if (new URL(input.toString()).pathname === "/command") return { status: 200 }
14
+ attempts++
15
+ if (attempts < 4) throw Object.assign(new Error("timed out"), { name: "TimeoutError" })
16
+ return { ok: true, json: async () => ({ data: [{ id: "ses_test" }] }) }
17
+ }
18
+ const result = await waitForSessionBootstrap({
19
+ baseURL, workspace, fetchImpl, timeoutMs: 250, perRequestMs: 10, intervalMs: 1,
20
+ })
21
+ assert.deepEqual(result.data, [{ id: "ses_test" }])
22
+ assert.equal(attempts, 4)
23
+ assert.ok(calls.some((c) => c.url.pathname === "/command"))
24
+ assert.ok(calls.every((c) => c.method === "GET" && c.directory === workspace))
25
+ assert.ok(calls.filter((c) => c.url.pathname === "/session").every((c) => c.url.searchParams.get("directory") === workspace))
26
+ })
27
+
28
+ test("bootstrap fails immediately on authorization, malformed response or unsafe service", async () => {
29
+ const unauthorized = async () => ({ ok: false, status: 401 })
30
+ await assert.rejects(waitForSessionBootstrap({ baseURL, workspace, fetchImpl: unauthorized }), /non-retryable HTTP 401/)
31
+ const malformed = async () => ({ ok: true, json: async () => ({ status: "unknown" }) })
32
+ await assert.rejects(waitForSessionBootstrap({ baseURL, workspace, fetchImpl: malformed }), /unexpected payload/)
33
+ await assert.rejects(
34
+ waitForSessionBootstrap({ baseURL: "https://example.com", workspace, fetchImpl: () => { throw new Error("must not connect") } }),
35
+ /restricted to a local service/,
36
+ )
37
+ })
38
+
39
+ test("persistent startup stall is bounded, preserves diagnostics, and never claims ownership collision", async () => {
40
+ let calls = 0
41
+ const stalled = async () => { calls++; throw Object.assign(new Error("timeout"), { name: "TimeoutError" }) }
42
+ await assert.rejects(waitForSessionBootstrap({
43
+ baseURL, workspace, fetchImpl: stalled, timeoutMs: 45, perRequestMs: 5, intervalMs: 1,
44
+ }), /did not become ready after \d+ bounded attempts.*plugin initialization must be investigated/i)
45
+ assert.ok(calls >= 1 && calls < 80, "startup must have a bounded number of probes")
46
+ })
@@ -0,0 +1,71 @@
1
+ // A TCP listener is not proof that OpenCode's session router and project
2
+ // plugin graph have finished initialization. This is intentionally read-only:
3
+ // never send a prompt/command or retry a mutating session request.
4
+ export async function waitForSessionBootstrap({
5
+ baseURL, workspace, fetchImpl = fetch,
6
+ timeoutMs = 90_000, perRequestMs = 6_000, intervalMs = 300,
7
+ } = {}) {
8
+ if (!URL.canParse(baseURL) || !workspace) throw new Error("Missing OpenCode bootstrap baseURL/workspace")
9
+ const base = new URL(baseURL)
10
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
11
+ throw new Error("OpenCode bootstrap is restricted to a local service")
12
+ if (base.username || base.password) throw new Error("Credentials must not be embedded in the bootstrap URL")
13
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) throw new Error("Invalid bootstrap timeout")
14
+ const url = new URL("/session", base)
15
+ url.searchParams.set("directory", workspace)
16
+ const deadline = Date.now() + timeoutMs
17
+ let attempts = 0
18
+ let last = "no response"
19
+ let commandProbe = "not checked"
20
+ while (Date.now() < deadline) {
21
+ attempts++
22
+ const remaining = deadline - Date.now()
23
+ const signal = AbortSignal.timeout(Math.max(1, Math.min(perRequestMs, remaining)))
24
+ try {
25
+ const res = await fetchImpl(url, {
26
+ method: "GET",
27
+ headers: { "x-opencode-directory": workspace, accept: "application/json" },
28
+ signal,
29
+ redirect: "error",
30
+ })
31
+ if (res.ok) {
32
+ const body = await res.json()
33
+ if (!Array.isArray(body?.data ?? body)) {
34
+ throw new Error("OpenCode GET /session returned an unexpected payload (not a session array)")
35
+ }
36
+ return body
37
+ }
38
+ if (![502, 503, 504].includes(res.status)) {
39
+ throw new Error("OpenCode GET /session returned non-retryable HTTP " + res.status)
40
+ }
41
+ last = "HTTP " + res.status
42
+ } catch (error) {
43
+ if (!["TimeoutError", "AbortError", "TypeError"].includes(error?.name)) throw error
44
+ last = error?.name === "TimeoutError" ? "request timed out" : error?.name === "AbortError" ? "request aborted" : "connection not ready"
45
+ }
46
+ // A side probe distinguishes a live command catalog from a stalled global
47
+ // bootstrap. It is diagnostic only: we never waive the session readiness
48
+ // requirement, and do not emit any endpoint response or credentials.
49
+ if (attempts % 3 === 0 && Date.now() < deadline) {
50
+ try {
51
+ const res = await fetchImpl(new URL("/command", base), {
52
+ method: "GET",
53
+ headers: { "x-opencode-directory": workspace, accept: "application/json" },
54
+ signal: AbortSignal.timeout(Math.min(2_000, deadline - Date.now())),
55
+ redirect: "error",
56
+ })
57
+ commandProbe = "HTTP " + res.status
58
+ } catch (error) {
59
+ commandProbe = error?.name === "TimeoutError" ? "timed out" : "unavailable"
60
+ }
61
+ }
62
+ if (Date.now() >= deadline) break
63
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, deadline - Date.now())))
64
+ }
65
+ throw new Error(
66
+ "OpenCode GET /session did not become ready after " + attempts
67
+ + " bounded attempts in " + timeoutMs + "ms"
68
+ + " (last=" + last + ", GET /command=" + commandProbe + ")."
69
+ + " Server routing/plugin initialization must be investigated before claiming a Goal/Loop collision.",
70
+ )
71
+ }
@@ -7,11 +7,12 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis
7
7
  import os from "node:os"
8
8
  import path from "node:path"
9
9
  import { fileURLToPath, pathToFileURL } from "node:url"
10
+ import { waitForSessionBootstrap } from "./host-goal-bootstrap.mjs"
10
11
 
11
12
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
13
  const isWindows = process.platform === "win32"
13
14
  const SERVER_READY_TIMEOUT_MS = isWindows ? 60_000 : 30_000
14
- const API_BOOTSTRAP_TIMEOUT_MS = isWindows ? 90_000 : 30_000
15
+ const API_BOOTSTRAP_TIMEOUT_MS = isWindows ? 180_000 : 60_000
15
16
  const PREWARM_TIMEOUT_MS = isWindows ? 120_000 : 60_000
16
17
  const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
17
18
  const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
@@ -341,7 +342,15 @@ async function main() {
341
342
  }
342
343
  }
343
344
 
344
- const sessionsPayload = await api("/session", { method: "GET" })
345
+ // A Windows cold host may accept TCP before its session/project/plugin
346
+ // services are ready. Probe readiness in bounded requests rather than
347
+ // hanging for 90 seconds on one socket and attributing it to Goal/Loop.
348
+ const sessionsPayload = await waitForSessionBootstrap({
349
+ baseURL,
350
+ workspace,
351
+ timeoutMs: API_BOOTSTRAP_TIMEOUT_MS,
352
+ fetchImpl: fetch,
353
+ })
345
354
  assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
346
355
  const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "Loop + Goal ownership canary" }) })
347
356
  const session = createdPayload?.data ?? createdPayload