@bybrawe/opencode-loop 0.5.29 → 0.5.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.31
4
+
5
+ - 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.
6
+ - 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.
7
+ - 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.
8
+ - Keep release metadata internally consistent by synchronizing the package-lock root `bin` map with `package.json` while preparing releases.
9
+
10
+ ## 0.5.30
11
+
12
+ - 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.
13
+ - Extend the experimental OpenCode 2 command registry with read-only `/loop-export`, `/loop-help`, and `/loop-doctor` diagnostics.
14
+ - Keep OpenCode 2 diagnostics conservative: help advertises only implemented V2 behavior, doctor reports local runtime/state information, and full stable-plugin parity remains explicitly unclaimed.
15
+ - Expand V2 lifecycle, real `@next` command-registry, general CI, and generated-bundle gates around the new diagnostics and release-safety contracts.
16
+
3
17
  ## 0.5.29
4
18
 
5
19
  - Fix `--no-now` first-run semantics end to end: delayed jobs now wait until `createdAt + interval`, and stable `/loop-status` reports that first due time correctly instead of showing the job as immediately due.
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.29`.** 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.
7
+ > **Current release: `0.5.31`.** 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,21 @@ 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
+ Update/install only Loop and skip all Goals companion network work with:
28
+
29
+ ```bash
30
+ npx -y @bybrawe/opencode-loop@latest --loop-only
31
+ ```
32
+
33
+ `--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
34
 
21
35
  ### Option 2 — install with npm
22
36
 
@@ -34,6 +48,12 @@ npm install -g @bybrawe/opencode-loop@latest
34
48
  opencode-loop
35
49
  ```
36
50
 
51
+ If you want the global Loop installer to install Goals too, run:
52
+
53
+ ```bash
54
+ opencode-loop --with-goals
55
+ ```
56
+
37
57
  `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
58
 
39
59
  The installer:
@@ -42,7 +62,9 @@ The installer:
42
62
  - installs the `/loop-*` slash-command definitions;
43
63
  - installs the tool-denied `opencode-loop-local` command agent;
44
64
  - 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.
65
+ - removes duplicate old local Loop plugin copies when the package entry is authoritative;
66
+ - refreshes OpenCode Goals through **Goals' own official installer** when an existing managed Goals installation is detected;
67
+ - can explicitly install both packages with `--with-goals`, or skip companion network work with `--loop-only`.
46
68
 
47
69
  Then **fully restart OpenCode** and verify:
48
70
 
@@ -51,6 +73,12 @@ Then **fully restart OpenCode** and verify:
51
73
  /loop-doctor
52
74
  ```
53
75
 
76
+ If Goals is installed too, also verify:
77
+
78
+ ```text
79
+ /goal status
80
+ ```
81
+
54
82
  ### Uninstall
55
83
 
56
84
  If you use the `npx` installer:
@@ -68,7 +96,7 @@ npm uninstall -g @bybrawe/opencode-loop
68
96
 
69
97
  Run `opencode-loop --uninstall` before removing the global npm package so it can clean its OpenCode registrations and managed command files.
70
98
 
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.
99
+ 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
100
 
73
101
  Project Loop state is intentionally preserved under:
74
102
 
@@ -142,7 +170,13 @@ OpenCode Loop still includes the older **experimental** `/loop-goal` workflow. I
142
170
 
143
171
  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
172
 
145
- Recommended one-command installer:
173
+ Convenient combined installer from Loop:
174
+
175
+ ```bash
176
+ npx -y @bybrawe/opencode-loop@latest --with-goals
177
+ ```
178
+
179
+ Or install Goals directly with its standalone installer:
146
180
 
147
181
  ```bash
148
182
  npx -y @bybrawe/opencode-goal@latest
@@ -177,21 +211,21 @@ Do **not** run Loop’s `/loop-goal` and OpenCode Goals `/goal` against the same
177
211
 
178
212
  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
213
 
180
- Install both when needed with `npx`:
214
+ Install both with one `npx` command:
181
215
 
182
216
  ```bash
183
- npx -y @bybrawe/opencode-loop@latest
184
- npx -y @bybrawe/opencode-goal@latest
217
+ npx -y @bybrawe/opencode-loop@latest --with-goals
185
218
  ```
186
219
 
187
- Or install both globally with npm, then run both installers:
220
+ Or install both globally with npm, then run the Loop installer with the companion flag:
188
221
 
189
222
  ```bash
190
223
  npm install -g @bybrawe/opencode-loop@latest @bybrawe/opencode-goal@latest
191
- opencode-loop
192
- opencode-goal
224
+ opencode-loop --with-goals
193
225
  ```
194
226
 
227
+ 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.
228
+
195
229
  ## Core commands
196
230
 
197
231
  | Command | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.29",
3
+ "version": "0.5.31",
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/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/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs",
25
- "test": "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/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-node.mjs",
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-node.mjs",
67
+ "opencode-loop": "scripts/install-with-goals.mjs",
68
68
  "opencode-loopd": "scripts/loopd.mjs"
69
69
  },
70
70
  "publishConfig": {
@@ -0,0 +1,155 @@
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 writeConfig(config, plugins, comment = "") {
66
+ await fs.mkdir(config, { recursive: true })
67
+ await fs.writeFile(
68
+ path.join(config, "opencode.jsonc"),
69
+ `{\n ${comment ? `// ${comment}\n ` : ""}\"plugin\": ${JSON.stringify(plugins, null, 2).replaceAll("\n", "\n ")},\n}\n`,
70
+ "utf8",
71
+ )
72
+ }
73
+
74
+ try {
75
+ const absentConfig = path.join(temporaryRoot, "absent")
76
+ const absentLog = path.join(temporaryRoot, "absent.log")
77
+ const absent = await runInstaller(absentConfig, [], { FAKE_NPM_LOG: absentLog })
78
+ assert.equal(absent.code, 0, absent.stderr)
79
+ assert.deepEqual(await calls(absentLog), [], "Loop-only installations must not silently add Goals")
80
+
81
+ const existingConfig = path.join(temporaryRoot, "existing")
82
+ const existingLog = path.join(temporaryRoot, "existing.log")
83
+ await writeConfig(existingConfig, ["other-plugin", "@bybrawe/opencode-goal@1.3.16"])
84
+ const existing = await runInstaller(existingConfig, [], { FAKE_NPM_LOG: existingLog })
85
+ assert.equal(existing.code, 0, existing.stderr)
86
+ assert.deepEqual(await calls(existingLog), [expectedGoalExec], "an installed Goals package must auto-refresh through its own latest installer")
87
+ assert.match(existing.stdout, /Installed\/updated OpenCode Goals via @bybrawe\/opencode-goal@latest/)
88
+
89
+ const localConfig = path.join(temporaryRoot, "local-goal")
90
+ const localLog = path.join(temporaryRoot, "local-goal.log")
91
+ await writeConfig(localConfig, ["./plugins/opencode-goal.ts"])
92
+ const local = await runInstaller(localConfig, [], { FAKE_NPM_LOG: localLog })
93
+ assert.equal(local.code, 0, local.stderr)
94
+ assert.deepEqual(await calls(localLog), [expectedGoalExec], "legacy local Goals installs must be refreshed/migrated by the companion installer")
95
+
96
+ const managedCommandConfig = path.join(temporaryRoot, "managed-command")
97
+ const managedCommandLog = path.join(temporaryRoot, "managed-command.log")
98
+ await fs.mkdir(path.join(managedCommandConfig, "commands"), { recursive: true })
99
+ await fs.writeFile(path.join(managedCommandConfig, "commands", "goal.md"), "<!-- managed-by:@bybrawe/opencode-goal -->\n", "utf8")
100
+ const managedCommand = await runInstaller(managedCommandConfig, [], { FAKE_NPM_LOG: managedCommandLog })
101
+ assert.equal(managedCommand.code, 0, managedCommand.stderr)
102
+ assert.deepEqual(await calls(managedCommandLog), [expectedGoalExec], "a managed /goal command is sufficient evidence that Goals should be refreshed")
103
+
104
+ const commentedConfig = path.join(temporaryRoot, "comment-only")
105
+ const commentedLog = path.join(temporaryRoot, "comment-only.log")
106
+ await writeConfig(commentedConfig, ["other-plugin"], 'old note: "@bybrawe/opencode-goal@1.3.16"')
107
+ const commented = await runInstaller(commentedConfig, [], { FAKE_NPM_LOG: commentedLog })
108
+ assert.equal(commented.code, 0, commented.stderr)
109
+ assert.deepEqual(await calls(commentedLog), [], "a package name mentioned only in JSONC comments must not trigger a network update")
110
+
111
+ const explicitConfig = path.join(temporaryRoot, "explicit")
112
+ const explicitLog = path.join(temporaryRoot, "explicit.log")
113
+ const explicit = await runInstaller(explicitConfig, ["--with-goals"], { FAKE_NPM_LOG: explicitLog })
114
+ assert.equal(explicit.code, 0, explicit.stderr)
115
+ assert.deepEqual(await calls(explicitLog), [expectedGoalExec], "--with-goals must install the companion even when it is absent")
116
+
117
+ const optOutConfig = path.join(temporaryRoot, "opt-out")
118
+ const optOutLog = path.join(temporaryRoot, "opt-out.log")
119
+ await writeConfig(optOutConfig, ["@bybrawe/opencode-goal@1.3.16"])
120
+ const optOut = await runInstaller(optOutConfig, ["--loop-only"], { FAKE_NPM_LOG: optOutLog })
121
+ assert.equal(optOut.code, 0, optOut.stderr)
122
+ assert.deepEqual(await calls(optOutLog), [], "--loop-only must suppress companion network work")
123
+ assert.match(optOut.stdout, /Skipped OpenCode Goals companion update/)
124
+
125
+ const autoFailureConfig = path.join(temporaryRoot, "auto-failure")
126
+ const autoFailureLog = path.join(temporaryRoot, "auto-failure.log")
127
+ await writeConfig(autoFailureConfig, ["@bybrawe/opencode-goal@1.3.16"])
128
+ const autoFailure = await runInstaller(autoFailureConfig, [], { FAKE_NPM_LOG: autoFailureLog, FAKE_NPM_EXIT: "7" })
129
+ assert.equal(autoFailure.code, 0, "a best-effort refresh failure must not break an otherwise successful Loop update")
130
+ assert.match(autoFailure.stderr, /could not be refreshed/)
131
+ assert.match(autoFailure.stderr, /npx -y @bybrawe\/opencode-goal@latest/)
132
+
133
+ const explicitFailureConfig = path.join(temporaryRoot, "explicit-failure")
134
+ const explicitFailureLog = path.join(temporaryRoot, "explicit-failure.log")
135
+ const explicitFailure = await runInstaller(explicitFailureConfig, ["--with-goals"], { FAKE_NPM_LOG: explicitFailureLog, FAKE_NPM_EXIT: "9" })
136
+ assert.equal(explicitFailure.code, 9, "an explicitly requested companion install must fail closed")
137
+ assert.match(explicitFailure.stderr, /companion install\/update failed/)
138
+
139
+ const conflicting = await runInstaller(path.join(temporaryRoot, "conflicting"), ["--with-goals", "--loop-only"])
140
+ assert.equal(conflicting.code, 2)
141
+ assert.match(conflicting.stderr, /either --with-goals or --loop-only/)
142
+
143
+ const helpLog = path.join(temporaryRoot, "help.log")
144
+ const help = await runInstaller(path.join(temporaryRoot, "help"), ["--with-goals", "--help"], { FAKE_NPM_LOG: helpLog })
145
+ assert.equal(help.code, 0, help.stderr)
146
+ assert.match(help.stdout, /OpenCode Loop installer\/updater/)
147
+ assert.match(help.stdout, /--with-goals/)
148
+ assert.match(help.stdout, /--loop-only/)
149
+ assert.match(help.stdout, /Loop uninstall never removes Goals/)
150
+ assert.deepEqual(await calls(helpLog), [], "help/version flows must never install companion packages")
151
+
152
+ console.log("OpenCode Loop Goals companion installer test passed")
153
+ } finally {
154
+ await fs.rm(temporaryRoot, { recursive: true, force: true })
155
+ }
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process"
3
+ import { readFile } 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 loopArgs = rawArgs.filter((arg) => arg !== "--with-goals" && arg !== "--loop-only")
20
+ const uninstallRequested = loopArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(loopArgs[0] || "")
21
+ const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg))
22
+ const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg))
23
+ const informational = helpRequested || versionRequested
24
+
25
+ function stripJsonComments(input) {
26
+ let output = ""
27
+ let quote = ""
28
+ let escaped = false
29
+ let lineComment = false
30
+ let blockComment = false
31
+ for (let index = 0; index < input.length; index++) {
32
+ const char = input[index]
33
+ const next = input[index + 1]
34
+ if (lineComment) {
35
+ if (char === "\n" || char === "\r") { lineComment = false; output += char }
36
+ continue
37
+ }
38
+ if (blockComment) {
39
+ if (char === "*" && next === "/") { blockComment = false; index++ }
40
+ else if (char === "\n" || char === "\r") output += char
41
+ continue
42
+ }
43
+ if (quote) {
44
+ output += char
45
+ if (escaped) escaped = false
46
+ else if (char === "\\") escaped = true
47
+ else if (char === quote) quote = ""
48
+ continue
49
+ }
50
+ if (char === '"') { quote = char; output += char; continue }
51
+ if (char === "/" && next === "/") { lineComment = true; index++; continue }
52
+ if (char === "/" && next === "*") { blockComment = true; index++; continue }
53
+ output += char
54
+ }
55
+ return output
56
+ }
57
+
58
+ function stripTrailingCommas(input) {
59
+ let output = ""
60
+ let quote = ""
61
+ let escaped = false
62
+ for (let index = 0; index < input.length; index++) {
63
+ const char = input[index]
64
+ if (quote) {
65
+ output += char
66
+ if (escaped) escaped = false
67
+ else if (char === "\\") escaped = true
68
+ else if (char === quote) quote = ""
69
+ continue
70
+ }
71
+ if (char === '"') { quote = char; output += char; continue }
72
+ if (char === ",") {
73
+ let lookahead = index + 1
74
+ while (/\s/.test(input[lookahead] || "")) lookahead++
75
+ if (input[lookahead] === "]" || input[lookahead] === "}") continue
76
+ }
77
+ output += char
78
+ }
79
+ return output
80
+ }
81
+
82
+ function parseJsonc(input) {
83
+ const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(input)))
84
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("OpenCode config root must be a JSON object")
85
+ return parsed
86
+ }
87
+
88
+ function isGoalPluginSpec(value) {
89
+ if (typeof value !== "string") return false
90
+ const spec = value.trim()
91
+ if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true
92
+ const normalized = spec.replaceAll("\\", "/")
93
+ return normalized === "./plugins/opencode-goal.ts"
94
+ || normalized === "./plugins/opencode-goal.js"
95
+ || normalized.endsWith("/plugins/opencode-goal.ts")
96
+ || normalized.endsWith("/plugins/opencode-goal.js")
97
+ }
98
+
99
+ async function goalsAlreadyInstalled() {
100
+ for (const name of configCandidates) {
101
+ try {
102
+ const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
103
+ if (Array.isArray(parsed.plugin) && parsed.plugin.some(isGoalPluginSpec)) return true
104
+ } catch (error) {
105
+ if (error?.code !== "ENOENT") {
106
+ console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`)
107
+ }
108
+ }
109
+ }
110
+
111
+ try {
112
+ const command = await readFile(join(config, "commands", "goal.md"), "utf8")
113
+ return command.includes(managedGoalCommandMarker)
114
+ } catch (error) {
115
+ if (error?.code !== "ENOENT") console.warn(`Could not inspect managed /goal command: ${error.message}`)
116
+ }
117
+ return false
118
+ }
119
+
120
+ function runLoopInstaller() {
121
+ return spawnSync(process.execPath, [loopInstaller, ...loopArgs], {
122
+ cwd: root,
123
+ env: process.env,
124
+ stdio: "inherit",
125
+ windowsHide: true,
126
+ })
127
+ }
128
+
129
+ function runGoalInstaller() {
130
+ const npmExecPath = process.env.npm_execpath
131
+ const command = npmExecPath ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm")
132
+ const args = npmExecPath
133
+ ? [npmExecPath, "exec", "--yes", `--package=${goalPackageSpec}`, "--", "opencode-goal"]
134
+ : ["exec", "--yes", `--package=${goalPackageSpec}`, "--", "opencode-goal"]
135
+
136
+ return spawnSync(command, args, {
137
+ cwd: root,
138
+ env: { ...process.env, OPENCODE_CONFIG_DIR: config },
139
+ stdio: "inherit",
140
+ windowsHide: true,
141
+ timeout: 180_000,
142
+ })
143
+ }
144
+
145
+ function statusCode(result) {
146
+ if (Number.isInteger(result?.status)) return result.status
147
+ return result?.error ? 1 : 0
148
+ }
149
+
150
+ function printCompanionHelp() {
151
+ 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\nNormal Loop install/update refreshes an already-managed OpenCode Goals installation on a best-effort basis. Loop uninstall never removes Goals.`)
152
+ }
153
+
154
+ async function main() {
155
+ if (withGoals && loopOnly) {
156
+ console.error("Use either --with-goals or --loop-only, not both.")
157
+ process.exitCode = 2
158
+ return
159
+ }
160
+
161
+ const loopResult = runLoopInstaller()
162
+ if (loopResult.error) {
163
+ console.error(`OpenCode Loop installer failed to start: ${loopResult.error.message}`)
164
+ process.exitCode = 1
165
+ return
166
+ }
167
+ const loopCode = statusCode(loopResult)
168
+ if (loopCode !== 0) {
169
+ process.exitCode = loopCode
170
+ return
171
+ }
172
+
173
+ if (informational) {
174
+ if (helpRequested) printCompanionHelp()
175
+ return
176
+ }
177
+ if (uninstallRequested) return
178
+
179
+ if (loopOnly) {
180
+ console.log("Skipped OpenCode Goals companion update (--loop-only).")
181
+ return
182
+ }
183
+
184
+ const alreadyInstalled = await goalsAlreadyInstalled()
185
+ if (!alreadyInstalled && !withGoals) return
186
+
187
+ const goalResult = runGoalInstaller()
188
+ const goalCode = statusCode(goalResult)
189
+ if (goalCode === 0) {
190
+ console.log(`Installed/updated OpenCode Goals via ${goalPackageSpec}.`)
191
+ return
192
+ }
193
+
194
+ const detail = goalResult.error?.message || `exit code ${goalCode}`
195
+ if (withGoals) {
196
+ console.error(`OpenCode Goals companion install/update failed (${detail}).`)
197
+ process.exitCode = goalCode || 1
198
+ return
199
+ }
200
+
201
+ console.warn(`OpenCode Loop updated, but the existing OpenCode Goals companion could not be refreshed (${detail}).`)
202
+ console.warn(`Retry later with: npx -y ${goalPackageSpec}`)
203
+ }
204
+
205
+ main().catch((error) => {
206
+ console.error(error?.stack || error)
207
+ process.exitCode = 1
208
+ })
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict"
2
+ import { readFile } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
7
+ const workflowPath = path.join(repoRoot, ".github", "workflows", "publish-npm.yml")
8
+ const workflow = await readFile(workflowPath, "utf8")
9
+
10
+ assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "publish workflow must checkout the release trigger SHA")
11
+ assert.doesNotMatch(workflow, /ref:\s*main\b/, "publish workflow must not checkout a moving main branch")
12
+ assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/, "release source must be verified against main history")
13
+ assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/, "release tag must target the immutable trigger SHA")
14
+ assert.match(workflow, /group:\s*npm-release/, "npm publication must be serialized")
15
+ assert.match(workflow, /cancel-in-progress:\s*false/, "a newer release must not cancel an in-flight publication")
16
+
17
+ const publishIndex = workflow.indexOf("npm publish --access public")
18
+ const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
19
+ const tagIndex = workflow.indexOf("Create tag and GitHub release")
20
+ const cleanupIndex = workflow.indexOf("Remove one-shot release branch")
21
+ assert.ok(publishIndex >= 0 && verifyIndex > publishIndex, "npm publication must be verified after publish")
22
+ assert.ok(tagIndex > verifyIndex, "tag/release creation must happen only after npm verification")
23
+ assert.ok(cleanupIndex > tagIndex, "release branch cleanup must be the final release step")
24
+
25
+ console.log("publish workflow tests passed")
@@ -0,0 +1,101 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2DiagnosticsRuntime, OPENCODE_LOOP_V2_HELP_TEXT } from "../src/source/opencode2/diagnostics.js"
3
+
4
+ const prompts = []
5
+ const reads = []
6
+ const state = {
7
+ version: 1,
8
+ jobs: [{ id: "job_export", name: "exported", runCount: 2, paused: false }],
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")
14
+ const runtime = createOpenCode2DiagnosticsRuntime({
15
+ prompt: async (request) => { prompts.push(request) },
16
+ readState: async (directory, sessionID) => {
17
+ reads.push([directory, sessionID])
18
+ return structuredClone(state)
19
+ },
20
+ readFile: async () => logLines,
21
+ runtimeVersion: "v-test",
22
+ runtimePlatform: "test-platform",
23
+ })
24
+
25
+ const unrelated = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-status", sessionID: "ses", directory: "/work" })
26
+ assert.equal(unrelated.handled, false)
27
+ assert.equal(prompts.length, 0)
28
+
29
+ const missing = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-export", sessionID: "ses" })
30
+ assert.equal(missing.handled, false)
31
+ assert.equal(missing.reason, "missing-scope")
32
+ assert.equal(prompts.length, 0)
33
+
34
+ const exported = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-export", sessionID: "ses", directory: "/work" })
35
+ assert.equal(exported.handled, true)
36
+ assert.equal(exported.accepted, true)
37
+ assert.deepEqual(reads, [["/work", "ses"]])
38
+ assert.equal(prompts.length, 1)
39
+ assert.equal(prompts[0].sessionID, "ses")
40
+ assert.equal(prompts[0].noReply, true)
41
+ assert.match(prompts[0].text, /^OpenCode loop state export:\n```json\n/)
42
+ assert.match(prompts[0].text, /"name": "exported"/)
43
+ assert.match(prompts[0].text, /"runCount": 2/)
44
+ assert.match(prompts[0].text, /\n```$/)
45
+
46
+ const help = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-help", sessionID: "ses", directory: "/work" })
47
+ assert.equal(help.handled, true)
48
+ assert.equal(help.accepted, true)
49
+ assert.equal(reads.length, 1, "loop-help must not read or mutate Loop state")
50
+ assert.equal(prompts.length, 2)
51
+ assert.equal(prompts[1].noReply, true)
52
+ assert.equal(prompts[1].text, OPENCODE_LOOP_V2_HELP_TEXT)
53
+ assert.match(prompts[1].text, /^OpenCode Loop V2 experimental help:/)
54
+ assert.match(prompts[1].text, /\/loop-status/)
55
+ assert.match(prompts[1].text, /\/loop-export/)
56
+ assert.match(prompts[1].text, /\/loop-logs/)
57
+ assert.match(prompts[1].text, /\/loop-doctor/)
58
+ assert.match(prompts[1].text, /does not yet claim full stable-plugin parity/)
59
+ assert.doesNotMatch(prompts[1].text, /\/loop-goal/)
60
+ assert.doesNotMatch(prompts[1].text, /--verify/)
61
+
62
+ const doctor = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-doctor", sessionID: "ses", directory: "/work" })
63
+ assert.equal(doctor.handled, true)
64
+ assert.equal(doctor.accepted, true)
65
+ assert.equal(reads.length, 2, "loop-doctor may read state but must not mutate it")
66
+ assert.equal(prompts.length, 3)
67
+ assert.equal(prompts[2].noReply, true)
68
+ assert.match(prompts[2].text, /^OpenCode Loop V2 doctor:/)
69
+ assert.match(prompts[2].text, /- plugin: bybrawe\.opencode-loop\.v2\.experimental/)
70
+ assert.match(prompts[2].text, /- project directory: \/work/)
71
+ assert.match(prompts[2].text, /- state directory:/)
72
+ assert.match(prompts[2].text, /- active jobs: 1/)
73
+ assert.match(prompts[2].text, /- node: v-test/)
74
+ assert.match(prompts[2].text, /- platform: test-platform/)
75
+ assert.match(prompts[2].text, /- full stable parity: not claimed/)
76
+ assert.match(prompts[2].text, /- smoke test: \/loop 0s --max-runs 1/)
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
+
99
+ assert.throws(() => createOpenCode2DiagnosticsRuntime({}), /requires prompt\(\)/)
100
+
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")
@@ -30,6 +30,10 @@ assert.deepEqual(Object.keys(OPENCODE_LOOP_V2_COMMANDS), [
30
30
  "loop-clear",
31
31
  "loop-status",
32
32
  "loop-now",
33
+ "loop-export",
34
+ "loop-help",
35
+ "loop-doctor",
36
+ "loop-logs",
33
37
  ])
34
38
  assert.deepEqual(
35
39
  parseOpenCode2LoopCommandText(`${OPENCODE_LOOP_V2_COMMANDS["loop-now"].template}\n\nsecond`),
@@ -31,6 +31,22 @@ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
31
31
  description: "Run matching OpenCode Loop jobs on the next idle boundary.",
32
32
  template: "OpenCode Loop run-now command handled locally. Reply exactly: OK.",
33
33
  }),
34
+ "loop-export": Object.freeze({
35
+ description: "Export OpenCode Loop state for the current session.",
36
+ template: "OpenCode Loop export command handled locally. Reply exactly: OK.",
37
+ }),
38
+ "loop-help": Object.freeze({
39
+ description: "Show the experimental OpenCode 2 Loop command help.",
40
+ template: "OpenCode Loop help command handled locally. Reply exactly: OK.",
41
+ }),
42
+ "loop-doctor": Object.freeze({
43
+ description: "Show experimental OpenCode 2 Loop diagnostics.",
44
+ template: "OpenCode Loop doctor command handled locally. Reply exactly: OK.",
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
+ }),
34
50
  })
35
51
 
36
52
  export function registerOpenCode2LoopCommands(draft) {
@@ -0,0 +1,103 @@
1
+ import { promises as fs } from "node:fs"
2
+ import path from "node:path"
3
+ import { readState as defaultReadState, stateDir } from "../core/state.js"
4
+
5
+ export const OPENCODE_LOOP_V2_HELP_TEXT = [
6
+ "OpenCode Loop V2 experimental help:",
7
+ "/loop 0s --max-runs 2 <prompt> autonomous prompt loop",
8
+ "/loop 5m --no-now --name later --multi <prompt> delayed named prompt loop",
9
+ "/loop 0s --command /review slash-command loop when the host exposes session.command",
10
+ "/loop-status show current Loop jobs",
11
+ "/loop-now [target] run matching jobs on the next idle boundary",
12
+ "/loop-pause [target] | /loop-resume [target] pause or resume jobs",
13
+ "/loop-stop [target] | /loop-remove [target] remove matching jobs",
14
+ "/loop-clear clear all jobs",
15
+ "/loop-export export current session Loop state as JSON",
16
+ "/loop-logs show the latest V2 runtime events",
17
+ "/loop-help show this experimental V2 help",
18
+ "/loop-doctor show local V2 diagnostics",
19
+ "Experimental V2 does not yet claim full stable-plugin parity; unsupported options fail closed.",
20
+ ].join("\n")
21
+
22
+ function scopeFrom(event) {
23
+ const directory = typeof event?.directory === "string" ? event.directory.trim() : ""
24
+ const sessionID = String(event?.sessionID || "").trim()
25
+ if (!directory || !sessionID) return undefined
26
+ return { directory, sessionID }
27
+ }
28
+
29
+ export function createOpenCode2DiagnosticsRuntime(options = {}) {
30
+ if (typeof options.prompt !== "function") throw new TypeError("V2 diagnostics runtime requires prompt()")
31
+ const readState = typeof options.readState === "function" ? options.readState : defaultReadState
32
+ const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args)
33
+ const runtimeVersion = options.runtimeVersion || process.version
34
+ const runtimePlatform = options.runtimePlatform || process.platform
35
+
36
+ async function exportState(event) {
37
+ const scope = scopeFrom(event)
38
+ if (!scope) return { handled: false, reason: "missing-scope" }
39
+ const state = await readState(scope.directory, scope.sessionID)
40
+ const text = `OpenCode loop state export:\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\``
41
+ const request = { sessionID: scope.sessionID, text, noReply: true }
42
+ await options.prompt(request)
43
+ return { handled: true, accepted: true, state, request }
44
+ }
45
+
46
+ async function help(event) {
47
+ const scope = scopeFrom(event)
48
+ if (!scope) return { handled: false, reason: "missing-scope" }
49
+ const request = { sessionID: scope.sessionID, text: OPENCODE_LOOP_V2_HELP_TEXT, noReply: true }
50
+ await options.prompt(request)
51
+ return { handled: true, accepted: true, request }
52
+ }
53
+
54
+ async function doctor(event) {
55
+ const scope = scopeFrom(event)
56
+ if (!scope) return { handled: false, reason: "missing-scope" }
57
+ const state = await readState(scope.directory, scope.sessionID)
58
+ const text = [
59
+ "OpenCode Loop V2 doctor:",
60
+ "- plugin: bybrawe.opencode-loop.v2.experimental",
61
+ `- project directory: ${scope.directory}`,
62
+ `- state directory: ${stateDir(scope.directory)}`,
63
+ `- active jobs: ${(state.jobs || []).length}`,
64
+ `- node: ${runtimeVersion}`,
65
+ `- platform: ${runtimePlatform}`,
66
+ "- full stable parity: not claimed",
67
+ "- smoke test: /loop 0s --max-runs 1 continue the current task",
68
+ ].join("\n")
69
+ const request = { sessionID: scope.sessionID, text, noReply: true }
70
+ await options.prompt(request)
71
+ return { handled: true, accepted: true, state, request }
72
+ }
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
+
92
+ async function onEvent(event) {
93
+ if (event?.kind === "command" && event?.action === "executed") {
94
+ if (event?.name === "loop-export") return await exportState(event)
95
+ if (event?.name === "loop-help") return await help(event)
96
+ if (event?.name === "loop-doctor") return await doctor(event)
97
+ if (event?.name === "loop-logs") return await logs(event)
98
+ }
99
+ return { handled: false }
100
+ }
101
+
102
+ return Object.freeze({ onEvent, exportState, help, doctor, logs })
103
+ }
@@ -1,5 +1,7 @@
1
1
  import { inspectOpenCode2Context } from "./capabilities.js"
2
2
  import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
3
+ import { createOpenCode2DiagnosticsRuntime } from "./diagnostics.js"
4
+ import { createOpenCode2LogRuntime } from "./logging.js"
3
5
  import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
4
6
  import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
5
7
 
@@ -21,12 +23,22 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
21
23
  }
22
24
 
23
25
  let promptRuntime
24
- const onRuntimeEvent = async (event) => promptRuntime?.onEvent(event)
26
+ let diagnosticsRuntime
27
+ const logRuntime = createOpenCode2LogRuntime()
28
+ const onRuntimeEvent = async (event) => {
29
+ const promptResult = await promptRuntime?.onEvent(event)
30
+ await logRuntime.record(event, promptResult)
31
+ if (promptResult?.handled) return promptResult
32
+ return await diagnosticsRuntime?.onEvent(event) ?? promptResult
33
+ }
25
34
  const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
26
35
  promptRuntime = createOpenCode2PromptRuntime({
27
36
  prompt: (request) => runtime.prompt(request),
28
37
  command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
29
38
  })
39
+ diagnosticsRuntime = createOpenCode2DiagnosticsRuntime({
40
+ prompt: (request) => runtime.prompt(request),
41
+ })
30
42
 
31
43
  try {
32
44
  await runtime.start()
@@ -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
+ }