@bybrawe/opencode-loop 0.5.30 → 0.5.32
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 +14 -0
- package/README.md +70 -16
- package/agents/opencode-loop-local.md +2 -1
- package/package.json +5 -5
- package/scripts/goal-companion-test.mjs +191 -0
- package/scripts/install-with-goals.mjs +230 -0
- package/scripts/v2-diagnostics-test.mjs +27 -0
- package/scripts/v2-logging-test.mjs +59 -0
- package/scripts/v2-plugin-sentinel-test.mjs +1 -0
- package/src/source/opencode2/commands.js +4 -0
- package/src/source/opencode2/diagnostics.js +24 -1
- package/src/source/opencode2/experimental.js +3 -0
- package/src/source/opencode2/logging.js +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.32
|
|
4
|
+
|
|
5
|
+
- Hide the tool-denied `opencode-loop-local` acknowledgement agent from the normal primary-agent cycle by installing it as a hidden subagent while keeping Loop slash commands explicitly bound to it.
|
|
6
|
+
- Add `--without-loop-goals` so Loop installs/updates can omit the packaged experimental `/loop-goal*` command files without removing normal Loop commands, Loop runtime state, or the separate OpenCode Goals plugin.
|
|
7
|
+
- Keep backward compatibility by installing the older Loop Goal command surface by default; the new opt-out composes with `--with-goals` for a cleaner `/loop` + dedicated `/goal` setup.
|
|
8
|
+
- Add deterministic installer coverage for hidden-agent metadata, default legacy-command compatibility, clean command-surface opt-out, and combined Loop + Goals installation.
|
|
9
|
+
|
|
10
|
+
## 0.5.31
|
|
11
|
+
|
|
12
|
+
- Add best-effort experimental OpenCode 2 runtime logging in the existing JSON-lines `loop.log` format, tagged with `v2: true`, plus experimental `/loop-logs` diagnostics for recent V2 records. Logging remains non-critical and does not change the stable OpenCode 1.x support claim.
|
|
13
|
+
- Keep an already-installed `@bybrawe/opencode-goal` companion current when Loop is installed/updated by delegating to Goals' own `@latest` installer. Add `--with-goals` for an explicit combined install and `--loop-only` to suppress companion network work; Loop uninstall never removes Goals.
|
|
14
|
+
- Add deterministic cross-platform companion-installer coverage for package/local/managed-command detection, JSONC comment false positives, opt-out, explicit failure semantics, help output, and best-effort automatic refresh behavior.
|
|
15
|
+
- Keep release metadata internally consistent by synchronizing the package-lock root `bin` map with `package.json` while preparing releases.
|
|
16
|
+
|
|
3
17
|
## 0.5.30
|
|
4
18
|
|
|
5
19
|
- Harden npm publishing so a one-shot release publishes the immutable trigger SHA, verifies that commit belongs to `main`, serializes releases, confirms the package is readable from npm, and tags exactly the published snapshot.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon.
|
|
6
6
|
|
|
7
|
-
> **Current release: `0.5.
|
|
7
|
+
> **Current release: `0.5.32`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
|
|
8
8
|
|
|
9
9
|
## Install or update
|
|
10
10
|
|
|
@@ -16,7 +16,29 @@ Choose either installation method below.
|
|
|
16
16
|
npx -y @bybrawe/opencode-loop@latest
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Run the same command again whenever you want to update.
|
|
19
|
+
Run the same command again whenever you want to update. If **OpenCode Goals is already installed**, a normal Loop install/update also makes a best-effort call to the official `@bybrawe/opencode-goal@latest` installer so the companion Goal plugin does not silently remain on an older release.
|
|
20
|
+
|
|
21
|
+
Install Loop and Goals together from scratch with:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx -y @bybrawe/opencode-loop@latest --with-goals
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If you use the dedicated OpenCode Goals plugin and do not want Loop's older experimental `/loop-goal*` commands in the slash-command list, use:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx -y @bybrawe/opencode-loop@latest --with-goals --without-loop-goals
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`--without-loop-goals` removes only Loop's packaged experimental `/loop-goal*` command files for that install/update. It keeps `/loop`, `/loop-command`, `/loop-shell`, the Loop runtime, and the separate OpenCode Goals `/goal` workflow. The choice is not persisted: run the flag again on a later Loop update if you want to keep those legacy command files omitted.
|
|
34
|
+
|
|
35
|
+
Update/install only Loop and skip all Goals companion network work with:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx -y @bybrawe/opencode-loop@latest --loop-only
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`--with-goals` is explicit and fails if the requested Goals install/update fails. The automatic refresh of an already-installed Goals companion is best-effort: Loop still finishes its own update and prints a retry command if the companion registry/install step is temporarily unavailable.
|
|
20
42
|
|
|
21
43
|
### Option 2 — install with npm
|
|
22
44
|
|
|
@@ -34,15 +56,29 @@ npm install -g @bybrawe/opencode-loop@latest
|
|
|
34
56
|
opencode-loop
|
|
35
57
|
```
|
|
36
58
|
|
|
59
|
+
If you want the global Loop installer to install Goals too, run:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
opencode-loop --with-goals
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For the cleaner command surface with the dedicated Goals plugin:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
opencode-loop --with-goals --without-loop-goals
|
|
69
|
+
```
|
|
70
|
+
|
|
37
71
|
`npm install @bybrawe/opencode-loop` by itself only adds the Node package to the current project. For a normal OpenCode installation, use the global npm method above or the recommended `npx` installer.
|
|
38
72
|
|
|
39
73
|
The installer:
|
|
40
74
|
|
|
41
75
|
- installs/updates the OpenCode Loop plugin;
|
|
42
76
|
- installs the `/loop-*` slash-command definitions;
|
|
43
|
-
- installs the tool-denied `opencode-loop-local` command
|
|
77
|
+
- installs the tool-denied, hidden `opencode-loop-local` acknowledgement subagent used only by Loop's bound local command definitions;
|
|
44
78
|
- keeps an existing npm plugin entry pinned to the installer’s exact version to avoid stale OpenCode package-cache resolution;
|
|
45
|
-
- removes duplicate old local Loop plugin copies when the package entry is authoritative
|
|
79
|
+
- removes duplicate old local Loop plugin copies when the package entry is authoritative;
|
|
80
|
+
- refreshes OpenCode Goals through **Goals' own official installer** when an existing managed Goals installation is detected;
|
|
81
|
+
- can explicitly install both packages with `--with-goals`, skip companion network work with `--loop-only`, or omit the packaged experimental `/loop-goal*` command files with `--without-loop-goals`.
|
|
46
82
|
|
|
47
83
|
Then **fully restart OpenCode** and verify:
|
|
48
84
|
|
|
@@ -51,6 +87,12 @@ Then **fully restart OpenCode** and verify:
|
|
|
51
87
|
/loop-doctor
|
|
52
88
|
```
|
|
53
89
|
|
|
90
|
+
If Goals is installed too, also verify:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
/goal status
|
|
94
|
+
```
|
|
95
|
+
|
|
54
96
|
### Uninstall
|
|
55
97
|
|
|
56
98
|
If you use the `npx` installer:
|
|
@@ -68,7 +110,7 @@ npm uninstall -g @bybrawe/opencode-loop
|
|
|
68
110
|
|
|
69
111
|
Run `opencode-loop --uninstall` before removing the global npm package so it can clean its OpenCode registrations and managed command files.
|
|
70
112
|
|
|
71
|
-
Uninstall removes known OpenCode Loop package registrations, local plugin files, `/loop-*` command markdown files, and the Loop local command agent while preserving unrelated OpenCode configuration.
|
|
113
|
+
Uninstall removes known OpenCode Loop package registrations, local plugin files, `/loop-*` command markdown files, and the Loop local command agent while preserving unrelated OpenCode configuration. It **does not uninstall OpenCode Goals**; Goals remains separately managed by its own installer.
|
|
72
114
|
|
|
73
115
|
Project Loop state is intentionally preserved under:
|
|
74
116
|
|
|
@@ -138,11 +180,23 @@ Loop is idle-safe: if a job becomes due while the session is busy, active tools
|
|
|
138
180
|
|
|
139
181
|
## For stronger persistent Goals, use OpenCode Goals
|
|
140
182
|
|
|
141
|
-
OpenCode Loop still includes the older **experimental** `/loop-goal` workflow
|
|
183
|
+
OpenCode Loop still includes the older **experimental** `/loop-goal` workflow for backward compatibility unless you install/update with `--without-loop-goals`. It is useful for compatibility and lightweight outcome-driven automation, but it is not the strongest Goal implementation in this project family.
|
|
142
184
|
|
|
143
185
|
For durable Goal Contracts, host-owned evidence, semantic verification, native Todo coordination, revision isolation, false-completion protection, restart recovery, Goal audit, budgets, and ordered Goals, install **OpenCode Goals**.
|
|
144
186
|
|
|
145
|
-
|
|
187
|
+
Convenient combined installer from Loop:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
npx -y @bybrawe/opencode-loop@latest --with-goals
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
For new installations where the dedicated `/goal` workflow replaces Loop's older experimental Goal commands, prefer:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npx -y @bybrawe/opencode-loop@latest --with-goals --without-loop-goals
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Or install Goals directly with its standalone installer:
|
|
146
200
|
|
|
147
201
|
```bash
|
|
148
202
|
npx -y @bybrawe/opencode-goal@latest
|
|
@@ -173,25 +227,25 @@ Recommended split:
|
|
|
173
227
|
- use **`/goal`** for persistent, strongly verified outcome completion;
|
|
174
228
|
- use **`/loop`**, `/loop-command`, `/loop-shell`, and `opencode-loopd` for scheduling/repetition/background infrastructure.
|
|
175
229
|
|
|
176
|
-
Do **not** run Loop’s `/loop-goal` and OpenCode Goals `/goal` against the same work in the same session. Both can autonomously continue on idle boundaries and can compete to start turns.
|
|
230
|
+
Do **not** run Loop’s `/loop-goal` and OpenCode Goals `/goal` against the same work in the same session. Both can autonomously continue on idle boundaries and can compete to start turns. Use `--without-loop-goals` when you want to remove that legacy command surface entirely.
|
|
177
231
|
|
|
178
232
|
Likewise, avoid leaving a prompt-producing `/loop ...` job continuously injecting agent turns into a session while an OpenCode Goal is actively continuing. Use separate sessions or pause/remove that prompt loop until the Goal is done. Scheduled shell/command jobs should also be chosen carefully so they do not race files or verification.
|
|
179
233
|
|
|
180
|
-
Install both
|
|
234
|
+
Install both with one `npx` command:
|
|
181
235
|
|
|
182
236
|
```bash
|
|
183
|
-
npx -y @bybrawe/opencode-loop@latest
|
|
184
|
-
npx -y @bybrawe/opencode-goal@latest
|
|
237
|
+
npx -y @bybrawe/opencode-loop@latest --with-goals
|
|
185
238
|
```
|
|
186
239
|
|
|
187
|
-
Or install both globally with npm, then run
|
|
240
|
+
Or install both globally with npm, then run the Loop installer with the companion flag:
|
|
188
241
|
|
|
189
242
|
```bash
|
|
190
243
|
npm install -g @bybrawe/opencode-loop@latest @bybrawe/opencode-goal@latest
|
|
191
|
-
opencode-loop
|
|
192
|
-
opencode-goal
|
|
244
|
+
opencode-loop --with-goals
|
|
193
245
|
```
|
|
194
246
|
|
|
247
|
+
Once Goals is managed in the OpenCode config, future normal `opencode-loop` / `npx ...opencode-loop@latest` updates also attempt to refresh Goals to its latest stable release. Use `--loop-only` when you intentionally do not want that companion refresh. Use `--without-loop-goals` on any install/update where you want the older Loop Goal command files omitted.
|
|
248
|
+
|
|
195
249
|
## Core commands
|
|
196
250
|
|
|
197
251
|
| Command | Purpose |
|
|
@@ -210,7 +264,7 @@ opencode-goal
|
|
|
210
264
|
| `/loop-doctor` | Diagnose Loop/OpenCode state |
|
|
211
265
|
| `/loop-init` | Create a starter `progress.md` |
|
|
212
266
|
| `/loop-export` | Export Loop state as JSON |
|
|
213
|
-
| `/loop-goal <objective>` | Start the older experimental Loop Goal Mode |
|
|
267
|
+
| `/loop-goal <objective>` | Start the older experimental Loop Goal Mode when those optional legacy command files are installed |
|
|
214
268
|
|
|
215
269
|
## Intervals and job types
|
|
216
270
|
|
|
@@ -352,7 +406,7 @@ opencode-loopd uninstall-task --name OpenCodeLoop
|
|
|
352
406
|
|
|
353
407
|
## Experimental Loop Goal Mode
|
|
354
408
|
|
|
355
|
-
Loop’s original Goal Mode remains available for existing users
|
|
409
|
+
Loop’s original Goal Mode remains available for existing users by default. If you install/update Loop with `--without-loop-goals`, its packaged `/loop-goal*` command definitions are removed while the rest of Loop keeps working.
|
|
356
410
|
|
|
357
411
|
```text
|
|
358
412
|
/loop-goal --check "npm test" --complete-when-checks-pass fix the failing tests
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.32",
|
|
4
4
|
"description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
|
|
22
22
|
"build:plugin:npm": "npm run build:plugin",
|
|
23
23
|
"prepack": "node --check src/index.js",
|
|
24
|
-
"check": "node --check src/source/v1.js && node --check src/source/core/args.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/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.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/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-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/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/session-activity-test.mjs && node --check scripts/session-status-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/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",
|
|
25
|
-
"test": "node scripts/publish-workflow-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/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-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/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
24
|
+
"check": "node --check src/source/v1.js && node --check src/source/core/args.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/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.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/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/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/session-activity-test.mjs && node --check scripts/session-status-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",
|
|
25
|
+
"test": "node scripts/publish-workflow-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/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
26
26
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
27
|
-
"install:global": "node scripts/install-
|
|
27
|
+
"install:global": "node scripts/install-with-goals.mjs",
|
|
28
28
|
"pack:zip": "node scripts/make-zip.mjs"
|
|
29
29
|
},
|
|
30
30
|
"keywords": [
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@opencode-ai/plugin": "^1.18.15"
|
|
65
65
|
},
|
|
66
66
|
"bin": {
|
|
67
|
-
"opencode-loop": "scripts/install-
|
|
67
|
+
"opencode-loop": "scripts/install-with-goals.mjs",
|
|
68
68
|
"opencode-loopd": "scripts/loopd.mjs"
|
|
69
69
|
},
|
|
70
70
|
"publishConfig": {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { promises as fs } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
9
|
+
const installer = path.join(root, "scripts", "install-with-goals.mjs")
|
|
10
|
+
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-goal-companion-"))
|
|
11
|
+
const fakeNpm = path.join(temporaryRoot, "fake-npm.mjs")
|
|
12
|
+
const expectedGoalExec = [
|
|
13
|
+
"exec",
|
|
14
|
+
"--yes",
|
|
15
|
+
"--package=@bybrawe/opencode-goal@latest",
|
|
16
|
+
"--",
|
|
17
|
+
"opencode-goal",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
await fs.writeFile(fakeNpm, `
|
|
21
|
+
import { appendFileSync } from "node:fs"
|
|
22
|
+
const log = process.env.FAKE_NPM_LOG
|
|
23
|
+
if (log) appendFileSync(log, JSON.stringify(process.argv.slice(2)) + "\\n", "utf8")
|
|
24
|
+
process.exit(Number(process.env.FAKE_NPM_EXIT || 0))
|
|
25
|
+
`, "utf8")
|
|
26
|
+
|
|
27
|
+
async function runInstaller(config, cliArgs = [], env = {}) {
|
|
28
|
+
return await new Promise((resolve, reject) => {
|
|
29
|
+
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
env: {
|
|
32
|
+
...process.env,
|
|
33
|
+
OPENCODE_CONFIG_DIR: config,
|
|
34
|
+
npm_execpath: fakeNpm,
|
|
35
|
+
...env,
|
|
36
|
+
},
|
|
37
|
+
windowsHide: true,
|
|
38
|
+
})
|
|
39
|
+
const stdout = []
|
|
40
|
+
const stderr = []
|
|
41
|
+
child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
42
|
+
child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
43
|
+
child.on("error", reject)
|
|
44
|
+
child.on("close", (code) => resolve({
|
|
45
|
+
code,
|
|
46
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
47
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
48
|
+
}))
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function calls(log) {
|
|
53
|
+
try {
|
|
54
|
+
return (await fs.readFile(log, "utf8"))
|
|
55
|
+
.trim()
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((line) => JSON.parse(line))
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code === "ENOENT") return []
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loopGoalCommands(config) {
|
|
66
|
+
try {
|
|
67
|
+
return (await fs.readdir(path.join(config, "commands")))
|
|
68
|
+
.filter((name) => name === "loop-goal.md" || (name.startsWith("loop-goal-") && name.endsWith(".md")))
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error?.code === "ENOENT") return []
|
|
71
|
+
throw error
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function exists(target) {
|
|
76
|
+
try { await fs.access(target); return true } catch { return false }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeConfig(config, plugins, comment = "") {
|
|
80
|
+
await fs.mkdir(config, { recursive: true })
|
|
81
|
+
await fs.writeFile(
|
|
82
|
+
path.join(config, "opencode.jsonc"),
|
|
83
|
+
`{\n ${comment ? `// ${comment}\n ` : ""}\"plugin\": ${JSON.stringify(plugins, null, 2).replaceAll("\n", "\n ")},\n}\n`,
|
|
84
|
+
"utf8",
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const localAgent = await fs.readFile(path.join(root, "agents", "opencode-loop-local.md"), "utf8")
|
|
89
|
+
assert.match(localAgent, /^mode:\s*subagent$/m, "the local acknowledgement agent must not participate in the primary-agent cycle")
|
|
90
|
+
assert.match(localAgent, /^hidden:\s*true$/m, "the local acknowledgement agent must be hidden from normal subagent selection")
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const absentConfig = path.join(temporaryRoot, "absent")
|
|
94
|
+
const absentLog = path.join(temporaryRoot, "absent.log")
|
|
95
|
+
const absent = await runInstaller(absentConfig, [], { FAKE_NPM_LOG: absentLog })
|
|
96
|
+
assert.equal(absent.code, 0, absent.stderr)
|
|
97
|
+
assert.deepEqual(await calls(absentLog), [], "Loop-only installations must not silently add Goals")
|
|
98
|
+
assert.ok((await loopGoalCommands(absentConfig)).length > 0, "legacy Loop Goal commands stay installed by default for compatibility")
|
|
99
|
+
|
|
100
|
+
const withoutLoopGoalsConfig = path.join(temporaryRoot, "without-loop-goals")
|
|
101
|
+
const withoutLoopGoalsLog = path.join(temporaryRoot, "without-loop-goals.log")
|
|
102
|
+
const withoutLoopGoals = await runInstaller(withoutLoopGoalsConfig, ["--without-loop-goals"], { FAKE_NPM_LOG: withoutLoopGoalsLog })
|
|
103
|
+
assert.equal(withoutLoopGoals.code, 0, withoutLoopGoals.stderr)
|
|
104
|
+
assert.deepEqual(await calls(withoutLoopGoalsLog), [], "omitting legacy Loop Goal commands must not silently install Goals")
|
|
105
|
+
assert.deepEqual(await loopGoalCommands(withoutLoopGoalsConfig), [], "--without-loop-goals must remove every packaged experimental /loop-goal* command")
|
|
106
|
+
assert.equal(await exists(path.join(withoutLoopGoalsConfig, "commands", "loop.md")), true, "normal Loop commands must remain installed")
|
|
107
|
+
assert.match(withoutLoopGoals.stdout, /Omitted \d+ packaged experimental \/loop-goal\*/)
|
|
108
|
+
|
|
109
|
+
const existingConfig = path.join(temporaryRoot, "existing")
|
|
110
|
+
const existingLog = path.join(temporaryRoot, "existing.log")
|
|
111
|
+
await writeConfig(existingConfig, ["other-plugin", "@bybrawe/opencode-goal@1.3.16"])
|
|
112
|
+
const existing = await runInstaller(existingConfig, [], { FAKE_NPM_LOG: existingLog })
|
|
113
|
+
assert.equal(existing.code, 0, existing.stderr)
|
|
114
|
+
assert.deepEqual(await calls(existingLog), [expectedGoalExec], "an installed Goals package must auto-refresh through its own latest installer")
|
|
115
|
+
assert.match(existing.stdout, /Installed\/updated OpenCode Goals via @bybrawe\/opencode-goal@latest/)
|
|
116
|
+
|
|
117
|
+
const localConfig = path.join(temporaryRoot, "local-goal")
|
|
118
|
+
const localLog = path.join(temporaryRoot, "local-goal.log")
|
|
119
|
+
await writeConfig(localConfig, ["./plugins/opencode-goal.ts"])
|
|
120
|
+
const local = await runInstaller(localConfig, [], { FAKE_NPM_LOG: localLog })
|
|
121
|
+
assert.equal(local.code, 0, local.stderr)
|
|
122
|
+
assert.deepEqual(await calls(localLog), [expectedGoalExec], "legacy local Goals installs must be refreshed/migrated by the companion installer")
|
|
123
|
+
|
|
124
|
+
const managedCommandConfig = path.join(temporaryRoot, "managed-command")
|
|
125
|
+
const managedCommandLog = path.join(temporaryRoot, "managed-command.log")
|
|
126
|
+
await fs.mkdir(path.join(managedCommandConfig, "commands"), { recursive: true })
|
|
127
|
+
await fs.writeFile(path.join(managedCommandConfig, "commands", "goal.md"), "<!-- managed-by:@bybrawe/opencode-goal -->\n", "utf8")
|
|
128
|
+
const managedCommand = await runInstaller(managedCommandConfig, [], { FAKE_NPM_LOG: managedCommandLog })
|
|
129
|
+
assert.equal(managedCommand.code, 0, managedCommand.stderr)
|
|
130
|
+
assert.deepEqual(await calls(managedCommandLog), [expectedGoalExec], "a managed /goal command is sufficient evidence that Goals should be refreshed")
|
|
131
|
+
|
|
132
|
+
const commentedConfig = path.join(temporaryRoot, "comment-only")
|
|
133
|
+
const commentedLog = path.join(temporaryRoot, "comment-only.log")
|
|
134
|
+
await writeConfig(commentedConfig, ["other-plugin"], 'old note: "@bybrawe/opencode-goal@1.3.16"')
|
|
135
|
+
const commented = await runInstaller(commentedConfig, [], { FAKE_NPM_LOG: commentedLog })
|
|
136
|
+
assert.equal(commented.code, 0, commented.stderr)
|
|
137
|
+
assert.deepEqual(await calls(commentedLog), [], "a package name mentioned only in JSONC comments must not trigger a network update")
|
|
138
|
+
|
|
139
|
+
const explicitConfig = path.join(temporaryRoot, "explicit")
|
|
140
|
+
const explicitLog = path.join(temporaryRoot, "explicit.log")
|
|
141
|
+
const explicit = await runInstaller(explicitConfig, ["--with-goals"], { FAKE_NPM_LOG: explicitLog })
|
|
142
|
+
assert.equal(explicit.code, 0, explicit.stderr)
|
|
143
|
+
assert.deepEqual(await calls(explicitLog), [expectedGoalExec], "--with-goals must install the companion even when it is absent")
|
|
144
|
+
|
|
145
|
+
const explicitCleanConfig = path.join(temporaryRoot, "explicit-clean")
|
|
146
|
+
const explicitCleanLog = path.join(temporaryRoot, "explicit-clean.log")
|
|
147
|
+
const explicitClean = await runInstaller(explicitCleanConfig, ["--with-goals", "--without-loop-goals"], { FAKE_NPM_LOG: explicitCleanLog })
|
|
148
|
+
assert.equal(explicitClean.code, 0, explicitClean.stderr)
|
|
149
|
+
assert.deepEqual(await calls(explicitCleanLog), [expectedGoalExec], "the clean command surface option must compose with --with-goals")
|
|
150
|
+
assert.deepEqual(await loopGoalCommands(explicitCleanConfig), [], "combined install must keep the stronger Goals plugin without legacy Loop Goal command clutter")
|
|
151
|
+
|
|
152
|
+
const optOutConfig = path.join(temporaryRoot, "opt-out")
|
|
153
|
+
const optOutLog = path.join(temporaryRoot, "opt-out.log")
|
|
154
|
+
await writeConfig(optOutConfig, ["@bybrawe/opencode-goal@1.3.16"])
|
|
155
|
+
const optOut = await runInstaller(optOutConfig, ["--loop-only"], { FAKE_NPM_LOG: optOutLog })
|
|
156
|
+
assert.equal(optOut.code, 0, optOut.stderr)
|
|
157
|
+
assert.deepEqual(await calls(optOutLog), [], "--loop-only must suppress companion network work")
|
|
158
|
+
assert.match(optOut.stdout, /Skipped OpenCode Goals companion update/)
|
|
159
|
+
|
|
160
|
+
const autoFailureConfig = path.join(temporaryRoot, "auto-failure")
|
|
161
|
+
const autoFailureLog = path.join(temporaryRoot, "auto-failure.log")
|
|
162
|
+
await writeConfig(autoFailureConfig, ["@bybrawe/opencode-goal@1.3.16"])
|
|
163
|
+
const autoFailure = await runInstaller(autoFailureConfig, [], { FAKE_NPM_LOG: autoFailureLog, FAKE_NPM_EXIT: "7" })
|
|
164
|
+
assert.equal(autoFailure.code, 0, "a best-effort refresh failure must not break an otherwise successful Loop update")
|
|
165
|
+
assert.match(autoFailure.stderr, /could not be refreshed/)
|
|
166
|
+
assert.match(autoFailure.stderr, /npx -y @bybrawe\/opencode-goal@latest/)
|
|
167
|
+
|
|
168
|
+
const explicitFailureConfig = path.join(temporaryRoot, "explicit-failure")
|
|
169
|
+
const explicitFailureLog = path.join(temporaryRoot, "explicit-failure.log")
|
|
170
|
+
const explicitFailure = await runInstaller(explicitFailureConfig, ["--with-goals"], { FAKE_NPM_LOG: explicitFailureLog, FAKE_NPM_EXIT: "9" })
|
|
171
|
+
assert.equal(explicitFailure.code, 9, "an explicitly requested companion install must fail closed")
|
|
172
|
+
assert.match(explicitFailure.stderr, /companion install\/update failed/)
|
|
173
|
+
|
|
174
|
+
const conflicting = await runInstaller(path.join(temporaryRoot, "conflicting"), ["--with-goals", "--loop-only"])
|
|
175
|
+
assert.equal(conflicting.code, 2)
|
|
176
|
+
assert.match(conflicting.stderr, /either --with-goals or --loop-only/)
|
|
177
|
+
|
|
178
|
+
const helpLog = path.join(temporaryRoot, "help.log")
|
|
179
|
+
const help = await runInstaller(path.join(temporaryRoot, "help"), ["--with-goals", "--help"], { FAKE_NPM_LOG: helpLog })
|
|
180
|
+
assert.equal(help.code, 0, help.stderr)
|
|
181
|
+
assert.match(help.stdout, /OpenCode Loop installer\/updater/)
|
|
182
|
+
assert.match(help.stdout, /--with-goals/)
|
|
183
|
+
assert.match(help.stdout, /--loop-only/)
|
|
184
|
+
assert.match(help.stdout, /--without-loop-goals/)
|
|
185
|
+
assert.match(help.stdout, /Loop uninstall never removes Goals/)
|
|
186
|
+
assert.deepEqual(await calls(helpLog), [], "help/version flows must never install companion packages")
|
|
187
|
+
|
|
188
|
+
console.log("OpenCode Loop Goals companion installer test passed")
|
|
189
|
+
} finally {
|
|
190
|
+
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
|
191
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { readFile, readdir, rm } from "node:fs/promises"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import { dirname, join } from "node:path"
|
|
6
|
+
import process from "node:process"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
|
|
9
|
+
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
10
|
+
const loopInstaller = join(root, "scripts", "install-node.mjs")
|
|
11
|
+
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
12
|
+
const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
|
|
13
|
+
const goalPackageName = "@bybrawe/opencode-goal"
|
|
14
|
+
const goalPackageSpec = `${goalPackageName}@latest`
|
|
15
|
+
const managedGoalCommandMarker = "<!-- managed-by:@bybrawe/opencode-goal -->"
|
|
16
|
+
const rawArgs = process.argv.slice(2)
|
|
17
|
+
const withGoals = rawArgs.includes("--with-goals")
|
|
18
|
+
const loopOnly = rawArgs.includes("--loop-only")
|
|
19
|
+
const withoutLoopGoals = rawArgs.includes("--without-loop-goals")
|
|
20
|
+
const loopArgs = rawArgs.filter((arg) => !["--with-goals", "--loop-only", "--without-loop-goals"].includes(arg))
|
|
21
|
+
const uninstallRequested = loopArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(loopArgs[0] || "")
|
|
22
|
+
const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg))
|
|
23
|
+
const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg))
|
|
24
|
+
const informational = helpRequested || versionRequested
|
|
25
|
+
|
|
26
|
+
function stripJsonComments(input) {
|
|
27
|
+
let output = ""
|
|
28
|
+
let quote = ""
|
|
29
|
+
let escaped = false
|
|
30
|
+
let lineComment = false
|
|
31
|
+
let blockComment = false
|
|
32
|
+
for (let index = 0; index < input.length; index++) {
|
|
33
|
+
const char = input[index]
|
|
34
|
+
const next = input[index + 1]
|
|
35
|
+
if (lineComment) {
|
|
36
|
+
if (char === "\n" || char === "\r") { lineComment = false; output += char }
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (blockComment) {
|
|
40
|
+
if (char === "*" && next === "/") { blockComment = false; index++ }
|
|
41
|
+
else if (char === "\n" || char === "\r") output += char
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (quote) {
|
|
45
|
+
output += char
|
|
46
|
+
if (escaped) escaped = false
|
|
47
|
+
else if (char === "\\") escaped = true
|
|
48
|
+
else if (char === quote) quote = ""
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
if (char === '"') { quote = char; output += char; continue }
|
|
52
|
+
if (char === "/" && next === "/") { lineComment = true; index++; continue }
|
|
53
|
+
if (char === "/" && next === "*") { blockComment = true; index++; continue }
|
|
54
|
+
output += char
|
|
55
|
+
}
|
|
56
|
+
return output
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stripTrailingCommas(input) {
|
|
60
|
+
let output = ""
|
|
61
|
+
let quote = ""
|
|
62
|
+
let escaped = false
|
|
63
|
+
for (let index = 0; index < input.length; index++) {
|
|
64
|
+
const char = input[index]
|
|
65
|
+
if (quote) {
|
|
66
|
+
output += char
|
|
67
|
+
if (escaped) escaped = false
|
|
68
|
+
else if (char === "\\") escaped = true
|
|
69
|
+
else if (char === quote) quote = ""
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (char === '"') { quote = char; output += char; continue }
|
|
73
|
+
if (char === ",") {
|
|
74
|
+
let lookahead = index + 1
|
|
75
|
+
while (/\s/.test(input[lookahead] || "")) lookahead++
|
|
76
|
+
if (input[lookahead] === "]" || input[lookahead] === "}") continue
|
|
77
|
+
}
|
|
78
|
+
output += char
|
|
79
|
+
}
|
|
80
|
+
return output
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseJsonc(input) {
|
|
84
|
+
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(input)))
|
|
85
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("OpenCode config root must be a JSON object")
|
|
86
|
+
return parsed
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isGoalPluginSpec(value) {
|
|
90
|
+
if (typeof value !== "string") return false
|
|
91
|
+
const spec = value.trim()
|
|
92
|
+
if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true
|
|
93
|
+
const normalized = spec.replaceAll("\\", "/")
|
|
94
|
+
return normalized === "./plugins/opencode-goal.ts"
|
|
95
|
+
|| normalized === "./plugins/opencode-goal.js"
|
|
96
|
+
|| normalized.endsWith("/plugins/opencode-goal.ts")
|
|
97
|
+
|| normalized.endsWith("/plugins/opencode-goal.js")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function goalsAlreadyInstalled() {
|
|
101
|
+
for (const name of configCandidates) {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
|
|
104
|
+
if (Array.isArray(parsed.plugin) && parsed.plugin.some(isGoalPluginSpec)) return true
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code !== "ENOENT") {
|
|
107
|
+
console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const command = await readFile(join(config, "commands", "goal.md"), "utf8")
|
|
114
|
+
return command.includes(managedGoalCommandMarker)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error?.code !== "ENOENT") console.warn(`Could not inspect managed /goal command: ${error.message}`)
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function removeLoopGoalCommands() {
|
|
122
|
+
const commandDir = join(config, "commands")
|
|
123
|
+
const names = (await readdir(join(root, "commands")))
|
|
124
|
+
.filter((name) => name === "loop-goal.md" || (name.startsWith("loop-goal-") && name.endsWith(".md")))
|
|
125
|
+
let removed = 0
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
try {
|
|
128
|
+
await rm(join(commandDir, name))
|
|
129
|
+
removed++
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error?.code !== "ENOENT") throw error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return removed
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function runLoopInstaller() {
|
|
138
|
+
return spawnSync(process.execPath, [loopInstaller, ...loopArgs], {
|
|
139
|
+
cwd: root,
|
|
140
|
+
env: process.env,
|
|
141
|
+
stdio: "inherit",
|
|
142
|
+
windowsHide: true,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function runGoalInstaller() {
|
|
147
|
+
const npmExecPath = process.env.npm_execpath
|
|
148
|
+
const command = npmExecPath ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm")
|
|
149
|
+
const args = npmExecPath
|
|
150
|
+
? [npmExecPath, "exec", "--yes", `--package=${goalPackageSpec}`, "--", "opencode-goal"]
|
|
151
|
+
: ["exec", "--yes", `--package=${goalPackageSpec}`, "--", "opencode-goal"]
|
|
152
|
+
|
|
153
|
+
return spawnSync(command, args, {
|
|
154
|
+
cwd: root,
|
|
155
|
+
env: { ...process.env, OPENCODE_CONFIG_DIR: config },
|
|
156
|
+
stdio: "inherit",
|
|
157
|
+
windowsHide: true,
|
|
158
|
+
timeout: 180_000,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function statusCode(result) {
|
|
163
|
+
if (Number.isInteger(result?.status)) return result.status
|
|
164
|
+
return result?.error ? 1 : 0
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function printCompanionHelp() {
|
|
168
|
+
console.log(`\nOpenCode Goals companion options:\n --with-goals Install/update @bybrawe/opencode-goal@latest even when it is not already installed.\n --loop-only Skip all OpenCode Goals companion detection and network update work.\n --without-loop-goals Omit/remove Loop's packaged experimental /loop-goal* command files for this install/update.\n\nNormal Loop install/update refreshes an already-managed OpenCode Goals installation on a best-effort basis. Loop uninstall never removes Goals.`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function main() {
|
|
172
|
+
if (withGoals && loopOnly) {
|
|
173
|
+
console.error("Use either --with-goals or --loop-only, not both.")
|
|
174
|
+
process.exitCode = 2
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const loopResult = runLoopInstaller()
|
|
179
|
+
if (loopResult.error) {
|
|
180
|
+
console.error(`OpenCode Loop installer failed to start: ${loopResult.error.message}`)
|
|
181
|
+
process.exitCode = 1
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const loopCode = statusCode(loopResult)
|
|
185
|
+
if (loopCode !== 0) {
|
|
186
|
+
process.exitCode = loopCode
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (informational) {
|
|
191
|
+
if (helpRequested) printCompanionHelp()
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (uninstallRequested) return
|
|
195
|
+
|
|
196
|
+
if (withoutLoopGoals) {
|
|
197
|
+
const removed = await removeLoopGoalCommands()
|
|
198
|
+
console.log(`Omitted ${removed} packaged experimental /loop-goal* command file(s) (--without-loop-goals).`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (loopOnly) {
|
|
202
|
+
console.log("Skipped OpenCode Goals companion update (--loop-only).")
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const alreadyInstalled = await goalsAlreadyInstalled()
|
|
207
|
+
if (!alreadyInstalled && !withGoals) return
|
|
208
|
+
|
|
209
|
+
const goalResult = runGoalInstaller()
|
|
210
|
+
const goalCode = statusCode(goalResult)
|
|
211
|
+
if (goalCode === 0) {
|
|
212
|
+
console.log(`Installed/updated OpenCode Goals via ${goalPackageSpec}.`)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const detail = goalResult.error?.message || `exit code ${goalCode}`
|
|
217
|
+
if (withGoals) {
|
|
218
|
+
console.error(`OpenCode Goals companion install/update failed (${detail}).`)
|
|
219
|
+
process.exitCode = goalCode || 1
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
console.warn(`OpenCode Loop updated, but the existing OpenCode Goals companion could not be refreshed (${detail}).`)
|
|
224
|
+
console.warn(`Retry later with: npx -y ${goalPackageSpec}`)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
main().catch((error) => {
|
|
228
|
+
console.error(error?.stack || error)
|
|
229
|
+
process.exitCode = 1
|
|
230
|
+
})
|
|
@@ -7,12 +7,17 @@ const state = {
|
|
|
7
7
|
version: 1,
|
|
8
8
|
jobs: [{ id: "job_export", name: "exported", runCount: 2, paused: false }],
|
|
9
9
|
}
|
|
10
|
+
const logLines = [
|
|
11
|
+
JSON.stringify({ time: "stable", line: "stable-only" }),
|
|
12
|
+
...Array.from({ length: 82 }, (_, index) => JSON.stringify({ time: `t${index + 1}`, line: "run", v2: true, seq: index + 1 })),
|
|
13
|
+
].join("\n")
|
|
10
14
|
const runtime = createOpenCode2DiagnosticsRuntime({
|
|
11
15
|
prompt: async (request) => { prompts.push(request) },
|
|
12
16
|
readState: async (directory, sessionID) => {
|
|
13
17
|
reads.push([directory, sessionID])
|
|
14
18
|
return structuredClone(state)
|
|
15
19
|
},
|
|
20
|
+
readFile: async () => logLines,
|
|
16
21
|
runtimeVersion: "v-test",
|
|
17
22
|
runtimePlatform: "test-platform",
|
|
18
23
|
})
|
|
@@ -48,6 +53,7 @@ assert.equal(prompts[1].text, OPENCODE_LOOP_V2_HELP_TEXT)
|
|
|
48
53
|
assert.match(prompts[1].text, /^OpenCode Loop V2 experimental help:/)
|
|
49
54
|
assert.match(prompts[1].text, /\/loop-status/)
|
|
50
55
|
assert.match(prompts[1].text, /\/loop-export/)
|
|
56
|
+
assert.match(prompts[1].text, /\/loop-logs/)
|
|
51
57
|
assert.match(prompts[1].text, /\/loop-doctor/)
|
|
52
58
|
assert.match(prompts[1].text, /does not yet claim full stable-plugin parity/)
|
|
53
59
|
assert.doesNotMatch(prompts[1].text, /\/loop-goal/)
|
|
@@ -69,6 +75,27 @@ assert.match(prompts[2].text, /- platform: test-platform/)
|
|
|
69
75
|
assert.match(prompts[2].text, /- full stable parity: not claimed/)
|
|
70
76
|
assert.match(prompts[2].text, /- smoke test: \/loop 0s --max-runs 1/)
|
|
71
77
|
|
|
78
|
+
const logs = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-logs", sessionID: "ses", directory: "/work" })
|
|
79
|
+
assert.equal(logs.handled, true)
|
|
80
|
+
assert.equal(logs.accepted, true)
|
|
81
|
+
assert.equal(reads.length, 2, "loop-logs must not read or mutate Loop state")
|
|
82
|
+
assert.equal(prompts.length, 4)
|
|
83
|
+
assert.equal(prompts[3].noReply, true)
|
|
84
|
+
assert.match(prompts[3].text, /^OpenCode Loop V2 logs:\n/)
|
|
85
|
+
assert.doesNotMatch(prompts[3].text, /stable-only/)
|
|
86
|
+
assert.doesNotMatch(prompts[3].text, /"seq":1}/)
|
|
87
|
+
assert.doesNotMatch(prompts[3].text, /"seq":2}/)
|
|
88
|
+
assert.match(prompts[3].text, /"seq":3}/)
|
|
89
|
+
assert.match(prompts[3].text, /"seq":82}/)
|
|
90
|
+
|
|
91
|
+
const missingLogs = []
|
|
92
|
+
const missingLogRuntime = createOpenCode2DiagnosticsRuntime({
|
|
93
|
+
prompt: async (request) => { missingLogs.push(request) },
|
|
94
|
+
readFile: async () => { throw new Error("missing") },
|
|
95
|
+
})
|
|
96
|
+
await missingLogRuntime.onEvent({ kind: "command", action: "executed", name: "loop-logs", sessionID: "ses", directory: "/work" })
|
|
97
|
+
assert.equal(missingLogs[0].text, "OpenCode Loop V2 logs:\nNo OpenCode 2 Loop log found.")
|
|
98
|
+
|
|
72
99
|
assert.throws(() => createOpenCode2DiagnosticsRuntime({}), /requires prompt\(\)/)
|
|
73
100
|
|
|
74
101
|
console.log("OpenCode 2 diagnostics contract passed")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createOpenCode2LogRuntime } from "../src/source/opencode2/logging.js"
|
|
3
|
+
|
|
4
|
+
const records = []
|
|
5
|
+
const runtime = createOpenCode2LogRuntime({
|
|
6
|
+
appendLoopLog: async (...args) => { records.push(args) },
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
assert.equal(await runtime.record({ kind: "message", action: "updated", sessionID: "ses", directory: "/work" }, { handled: false }), false)
|
|
10
|
+
assert.equal(records.length, 0)
|
|
11
|
+
|
|
12
|
+
assert.equal(await runtime.record(
|
|
13
|
+
{ kind: "command", action: "executed", name: "loop", sessionID: "ses", directory: "/work" },
|
|
14
|
+
{ handled: true, accepted: true, job: { id: "job_a", name: "alpha" } },
|
|
15
|
+
), true)
|
|
16
|
+
assert.deepEqual(records[0], ["/work", "add", { sessionID: "ses", v2: true, job: "alpha" }])
|
|
17
|
+
|
|
18
|
+
await runtime.record(
|
|
19
|
+
{ kind: "command", action: "executed", name: "loop-now", sessionID: "ses", directory: "/work" },
|
|
20
|
+
{ handled: true, accepted: true, target: "alpha", count: 1 },
|
|
21
|
+
)
|
|
22
|
+
await runtime.record(
|
|
23
|
+
{ kind: "command", action: "executed", name: "loop-pause", sessionID: "ses", directory: "/work" },
|
|
24
|
+
{ handled: true, accepted: true, target: "alpha", count: 1 },
|
|
25
|
+
)
|
|
26
|
+
await runtime.record(
|
|
27
|
+
{ kind: "command", action: "executed", name: "loop-resume", sessionID: "ses", directory: "/work" },
|
|
28
|
+
{ handled: true, accepted: true, target: "alpha", count: 1 },
|
|
29
|
+
)
|
|
30
|
+
await runtime.record(
|
|
31
|
+
{ kind: "command", action: "executed", name: "loop-remove", sessionID: "ses", directory: "/work" },
|
|
32
|
+
{ handled: true, accepted: true, target: "alpha", count: 1 },
|
|
33
|
+
)
|
|
34
|
+
await runtime.record(
|
|
35
|
+
{ kind: "command", action: "executed", name: "loop-clear", sessionID: "ses", directory: "/work" },
|
|
36
|
+
{ handled: true, accepted: true, target: "all", count: 1 },
|
|
37
|
+
)
|
|
38
|
+
await runtime.record(
|
|
39
|
+
{ kind: "session", action: "idle", sessionID: "ses", directory: "/work" },
|
|
40
|
+
{ handled: true, dispatched: true, kind: "prompt", job: { id: "job_a", name: "alpha", runCount: 2 } },
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(records.map((entry) => entry[1]), ["add", "run-now", "pause", "resume", "remove", "clear", "run"])
|
|
44
|
+
assert.deepEqual(records.at(-1), ["/work", "run", { sessionID: "ses", v2: true, job: "alpha", kind: "prompt", runs: 2 }])
|
|
45
|
+
|
|
46
|
+
const beforeRejected = records.length
|
|
47
|
+
await runtime.record(
|
|
48
|
+
{ kind: "command", action: "executed", name: "loop", sessionID: "ses", directory: "/work" },
|
|
49
|
+
{ handled: true, accepted: false, reason: "unsupported" },
|
|
50
|
+
)
|
|
51
|
+
assert.equal(records.length, beforeRejected, "rejected commands must not create success logs")
|
|
52
|
+
|
|
53
|
+
const failing = createOpenCode2LogRuntime({ appendLoopLog: async () => { throw new Error("disk unavailable") } })
|
|
54
|
+
assert.equal(await failing.record(
|
|
55
|
+
{ kind: "command", action: "executed", name: "loop-now", sessionID: "ses", directory: "/work" },
|
|
56
|
+
{ handled: true, accepted: true, target: "all", count: 1 },
|
|
57
|
+
), false, "logging failures must never fail the Loop runtime")
|
|
58
|
+
|
|
59
|
+
console.log("OpenCode 2 logging contract passed")
|
|
@@ -43,6 +43,10 @@ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
|
|
|
43
43
|
description: "Show experimental OpenCode 2 Loop diagnostics.",
|
|
44
44
|
template: "OpenCode Loop doctor command handled locally. Reply exactly: OK.",
|
|
45
45
|
}),
|
|
46
|
+
"loop-logs": Object.freeze({
|
|
47
|
+
description: "Show recent experimental OpenCode 2 Loop runtime events.",
|
|
48
|
+
template: "OpenCode Loop logs command handled locally. Reply exactly: OK.",
|
|
49
|
+
}),
|
|
46
50
|
})
|
|
47
51
|
|
|
48
52
|
export function registerOpenCode2LoopCommands(draft) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
1
3
|
import { readState as defaultReadState, stateDir } from "../core/state.js"
|
|
2
4
|
|
|
3
5
|
export const OPENCODE_LOOP_V2_HELP_TEXT = [
|
|
@@ -11,6 +13,7 @@ export const OPENCODE_LOOP_V2_HELP_TEXT = [
|
|
|
11
13
|
"/loop-stop [target] | /loop-remove [target] remove matching jobs",
|
|
12
14
|
"/loop-clear clear all jobs",
|
|
13
15
|
"/loop-export export current session Loop state as JSON",
|
|
16
|
+
"/loop-logs show the latest V2 runtime events",
|
|
14
17
|
"/loop-help show this experimental V2 help",
|
|
15
18
|
"/loop-doctor show local V2 diagnostics",
|
|
16
19
|
"Experimental V2 does not yet claim full stable-plugin parity; unsupported options fail closed.",
|
|
@@ -26,6 +29,7 @@ function scopeFrom(event) {
|
|
|
26
29
|
export function createOpenCode2DiagnosticsRuntime(options = {}) {
|
|
27
30
|
if (typeof options.prompt !== "function") throw new TypeError("V2 diagnostics runtime requires prompt()")
|
|
28
31
|
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
32
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args)
|
|
29
33
|
const runtimeVersion = options.runtimeVersion || process.version
|
|
30
34
|
const runtimePlatform = options.runtimePlatform || process.platform
|
|
31
35
|
|
|
@@ -67,14 +71,33 @@ export function createOpenCode2DiagnosticsRuntime(options = {}) {
|
|
|
67
71
|
return { handled: true, accepted: true, state, request }
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
async function logs(event) {
|
|
75
|
+
const scope = scopeFrom(event)
|
|
76
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
77
|
+
let text = "No OpenCode 2 Loop log found."
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(path.join(stateDir(scope.directory), "loop.log"), "utf8")
|
|
80
|
+
const lines = String(raw || "")
|
|
81
|
+
.trim()
|
|
82
|
+
.split(/\r?\n/)
|
|
83
|
+
.filter((line) => line.includes('"v2":true'))
|
|
84
|
+
.slice(-80)
|
|
85
|
+
if (lines.length) text = lines.join("\n")
|
|
86
|
+
} catch {}
|
|
87
|
+
const request = { sessionID: scope.sessionID, text: `OpenCode Loop V2 logs:\n${text}`, noReply: true }
|
|
88
|
+
await options.prompt(request)
|
|
89
|
+
return { handled: true, accepted: true, request }
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
async function onEvent(event) {
|
|
71
93
|
if (event?.kind === "command" && event?.action === "executed") {
|
|
72
94
|
if (event?.name === "loop-export") return await exportState(event)
|
|
73
95
|
if (event?.name === "loop-help") return await help(event)
|
|
74
96
|
if (event?.name === "loop-doctor") return await doctor(event)
|
|
97
|
+
if (event?.name === "loop-logs") return await logs(event)
|
|
75
98
|
}
|
|
76
99
|
return { handled: false }
|
|
77
100
|
}
|
|
78
101
|
|
|
79
|
-
return Object.freeze({ onEvent, exportState, help, doctor })
|
|
102
|
+
return Object.freeze({ onEvent, exportState, help, doctor, logs })
|
|
80
103
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { inspectOpenCode2Context } from "./capabilities.js"
|
|
2
2
|
import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
|
|
3
3
|
import { createOpenCode2DiagnosticsRuntime } from "./diagnostics.js"
|
|
4
|
+
import { createOpenCode2LogRuntime } from "./logging.js"
|
|
4
5
|
import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
|
|
5
6
|
import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
|
|
6
7
|
|
|
@@ -23,8 +24,10 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
|
|
|
23
24
|
|
|
24
25
|
let promptRuntime
|
|
25
26
|
let diagnosticsRuntime
|
|
27
|
+
const logRuntime = createOpenCode2LogRuntime()
|
|
26
28
|
const onRuntimeEvent = async (event) => {
|
|
27
29
|
const promptResult = await promptRuntime?.onEvent(event)
|
|
30
|
+
await logRuntime.record(event, promptResult)
|
|
28
31
|
if (promptResult?.handled) return promptResult
|
|
29
32
|
return await diagnosticsRuntime?.onEvent(event) ?? promptResult
|
|
30
33
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
2
|
+
|
|
3
|
+
function scopeFrom(event) {
|
|
4
|
+
const directory = typeof event?.directory === "string" ? event.directory.trim() : ""
|
|
5
|
+
const sessionID = String(event?.sessionID || "").trim()
|
|
6
|
+
if (!directory || !sessionID) return undefined
|
|
7
|
+
return { directory, sessionID }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function jobName(job) {
|
|
11
|
+
return String(job?.name || job?.id || "default")
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createOpenCode2LogRuntime(options = {}) {
|
|
15
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
16
|
+
|
|
17
|
+
async function append(scope, line, extra = {}) {
|
|
18
|
+
try {
|
|
19
|
+
await appendLoopLog(scope.directory, line, { sessionID: scope.sessionID, v2: true, ...extra })
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function record(event, result) {
|
|
27
|
+
if (!result?.handled) return false
|
|
28
|
+
const scope = scopeFrom(event)
|
|
29
|
+
if (!scope) return false
|
|
30
|
+
|
|
31
|
+
if (event?.kind === "session" && event?.action === "idle" && result.dispatched && result.job) {
|
|
32
|
+
return await append(scope, "run", {
|
|
33
|
+
job: jobName(result.job),
|
|
34
|
+
kind: result.kind || "prompt",
|
|
35
|
+
runs: Number(result.job.runCount || 0),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (event?.kind !== "command" || event?.action !== "executed" || result.accepted !== true) return false
|
|
40
|
+
|
|
41
|
+
if (event.name === "loop" && result.job) {
|
|
42
|
+
return await append(scope, "add", { job: jobName(result.job) })
|
|
43
|
+
}
|
|
44
|
+
if (event.name === "loop-now") {
|
|
45
|
+
return await append(scope, "run-now", { target: result.target, count: Number(result.count || 0) })
|
|
46
|
+
}
|
|
47
|
+
if (event.name === "loop-pause") {
|
|
48
|
+
return await append(scope, "pause", { target: result.target, count: Number(result.count || 0) })
|
|
49
|
+
}
|
|
50
|
+
if (event.name === "loop-resume") {
|
|
51
|
+
return await append(scope, "resume", { target: result.target, count: Number(result.count || 0) })
|
|
52
|
+
}
|
|
53
|
+
if (event.name === "loop-stop") {
|
|
54
|
+
return await append(scope, "stop", { target: result.target, count: Number(result.count || 0) })
|
|
55
|
+
}
|
|
56
|
+
if (event.name === "loop-remove") {
|
|
57
|
+
return await append(scope, "remove", { target: result.target, count: Number(result.count || 0) })
|
|
58
|
+
}
|
|
59
|
+
if (event.name === "loop-clear") {
|
|
60
|
+
return await append(scope, "clear", { target: result.target, count: Number(result.count || 0) })
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return Object.freeze({ record })
|
|
66
|
+
}
|