@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 +12 -0
- package/README.md +7 -3
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +11 -0
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +6 -2
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +217 -11
- package/src/native.js +236 -19
- package/src/server.js +236 -19
- package/src/source/core/state.js +203 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
407
|
-
assert.deepEqual(
|
|
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 ?
|
|
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
|
-
|
|
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
|