@bybrawe/opencode-loop 0.6.1 → 0.6.3
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 +17 -0
- package/README.md +27 -11
- package/examples/OPENCODE2-0.6.2.md +54 -0
- package/examples/OPENCODE2-0.6.3.md +71 -0
- package/package.json +1 -1
- package/scripts/host-goal-coexistence-canary.mjs +27 -11
- package/scripts/install-node.mjs +38 -14
- package/scripts/native-install-test.mjs +16 -5
- package/scripts/native-lifecycle-test.mjs +13 -0
- package/scripts/native-plugin-lifetime-test.mjs +33 -0
- package/scripts/native-presets-test.mjs +18 -12
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +10 -0
- package/scripts/publish-workflow-test.mjs +6 -3
- package/src/native.js +8 -11
- package/src/server.js +8 -11
- package/src/source/opencode2/native-plugin.js +11 -9
- package/src/source/opencode2/native-runtime.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
- Fix native OpenCode 2 fresh installs on Windows/Linux by registering the exact published Loop package in plural `plugins` instead of copying a loose global `plugins/opencode-loop` directory.
|
|
6
|
+
- Let OpenCode resolve `@opencode/plugin` from Loop's published production dependency graph, eliminating the reproduced `Cannot find package '@opencode/plugin'` startup failure from the 0.6.1/0.6.2 loose-install layout.
|
|
7
|
+
- Automatically migrate known loose V2 Loop copies on update while preserving project Loop state, unrelated plugin registrations, JSONC comments and configured package options.
|
|
8
|
+
- Add installer regressions for fresh package registration, idempotence and migration of a broken loose V2 copy.
|
|
9
|
+
- Keep the dedicated OpenCode Goal 1.3.42 companion unchanged and retain explicit `--legacy-v1` as the isolated compatibility path.
|
|
10
|
+
|
|
11
|
+
## 0.6.2 - 2026-09-30
|
|
12
|
+
|
|
13
|
+
- Conform the native OpenCode 2 runtime to the migration-guide location/options boundary: project scope comes only from `ctx.location.directory`; plugin `ctx.options` can no longer masquerade as project location.
|
|
14
|
+
- Keep session/worktree isolation on public `session.get(...).location.directory` and fail setup/dispatch closed when project or session location cannot be established.
|
|
15
|
+
- Retain the official root/server/`./v2` `Plugin.define({ id, setup })` entrypoints, plural `plugins` installer migration, abortable event subscription cleanup, public command/session capabilities and separately isolated V1 compatibility.
|
|
16
|
+
- Keep undocumented `session.shell`, `session.compact` and `session.inbox` properties out of the V2 runtime; scheduled shell remains Loop-managed and automatic compaction remains host-owned.
|
|
17
|
+
- Add lifecycle regression coverage proving `options.directory` is not V2 location authority.
|
|
18
|
+
- Pair trusted publication with immutable OpenCode Goal 1.3.42 and re-run production tarballs plus the joint OpenCode 2.0.18 host matrix.
|
|
19
|
+
|
|
3
20
|
## 0.6.1 - 2026-09-29
|
|
4
21
|
|
|
5
22
|
- Expose the root/server and `./v2` npm entrypoints through the official `@opencode/plugin` `Plugin.define({ id, setup })` contract while keeping V1 `server()` as a separate compatibility implementation.
|
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# OpenCode Loop
|
|
2
2
|
|
|
3
|
-
## Native OpenCode 2 (0.6.
|
|
3
|
+
## Native OpenCode 2 (0.6.3)
|
|
4
4
|
|
|
5
|
-
The npm and
|
|
5
|
+
The npm and V2 entrypoints use the official `@opencode/plugin` `Plugin.define()` contract. The installer now registers the exact `@bybrawe/opencode-loop` package in OpenCode's plural `plugins` configuration instead of copying a loose global plugin directory. OpenCode therefore resolves `@opencode/plugin` from Loop's own production dependency graph; explicit `--legacy-v1` remains a separate compatibility path.
|
|
6
6
|
|
|
7
|
-
The installer
|
|
7
|
+
The installer defaults to the native V2 package plugin. It needs no loose local V2 copy, V1 SDK, or legacy command files. Existing npm registrations migrate from `plugin` to `plugins` without dropping object options, and 0.6.1/0.6.2 loose `plugins/opencode-loop` installs are removed on update. Use `--legacy-v1` explicitly for the compatibility installation.
|
|
8
8
|
|
|
9
9
|
V2 prompt admission and durable inbox/execution/compaction events own scheduling. Core-generated user-role messages never authorize a session abort. An unfinished dedicated Goal reserves its session, including paused and handed-off states; Loop does not wake or replace that Goal. Soft iteration timeouts do not abort a native model/tool/compaction turn.
|
|
10
10
|
|
|
11
|
-
Scheduled shell commands use bounded local child processes
|
|
11
|
+
Scheduled shell commands use Loop-managed bounded local child processes; the OpenCode 2 `ctx.shell` surface is a hook domain, not a command-execution API. Automatic compaction stays host-owned. **The public OpenCode 2 SessionDomain has no manual compaction action, so `/loop-compact`, `--compact`, and `--compact-every` are rejected before job creation instead of probing undocumented `session.compact` fields.** Use OpenCode's built-in manual/automatic compaction on V2.
|
|
12
12
|
|
|
13
13
|
Prompt/command timers, watch and stop conditions, verification, preflight/postrun, notifications, checkpoints and diagnostic commands use the V2 runtime. Uncertain restart admissions pause for review rather than replaying potentially admitted work. The dedicated `@bybrawe/opencode-goal` plugin owns `/goal`; legacy Loop Goal records are preserved and are not silently reinterpreted as new Goal contracts.
|
|
14
14
|
|
|
@@ -17,32 +17,48 @@ Prompt/command timers, watch and stop conditions, verification, preflight/postru
|
|
|
17
17
|
|
|
18
18
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
|
|
19
19
|
|
|
20
|
-
> **Current stable release: `0.6.
|
|
20
|
+
> **Current stable release: `0.6.3`.**
|
|
21
21
|
|
|
22
22
|
## Install or update
|
|
23
23
|
|
|
24
|
-
Recommended
|
|
24
|
+
### Recommended OpenCode 2 setup
|
|
25
|
+
|
|
26
|
+
Install the dedicated Goal plugin first:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx -y @bybrawe/opencode-goal@latest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then install Loop by itself and disable Loop's older experimental Goal command files:
|
|
25
33
|
|
|
26
34
|
```bash
|
|
27
|
-
npx -y @bybrawe/opencode-loop@latest
|
|
35
|
+
npx -y @bybrawe/opencode-loop@latest --loop-only --without-loop-goals
|
|
28
36
|
```
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
This is the recommended split for OpenCode 2:
|
|
39
|
+
|
|
40
|
+
- **OpenCode Goal** owns `/goal`, durable outcome-driven work, evidence, verification, recovery, and semantic completion.
|
|
41
|
+
- **OpenCode Loop** owns `/loop`, scheduling, repeated prompts/commands/shell work, and `opencode-loopd`.
|
|
42
|
+
- `--loop-only` prevents the Loop installer from installing/updating the Goal companion.
|
|
43
|
+
- `--without-loop-goals` removes only Loop's older experimental `/loop-goal*` command files; it does not remove the dedicated OpenCode Goal plugin.
|
|
44
|
+
|
|
45
|
+
Run the same two commands again to update both packages. Loop 0.6.3 also migrates the earlier 0.6.1/0.6.2 loose global V2 install that could fail with `Cannot find package '@opencode/plugin'`; the update removes that loose copy and pins the published package in `plugins` instead. Then **fully restart OpenCode** and verify:
|
|
31
46
|
|
|
32
47
|
```text
|
|
48
|
+
/goal status
|
|
33
49
|
/loop-help
|
|
34
50
|
/loop-doctor
|
|
35
51
|
```
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
Convenience alternative: let the Loop installer also install/update the dedicated Goal companion:
|
|
38
54
|
|
|
39
55
|
```bash
|
|
40
56
|
npx -y @bybrawe/opencode-loop@latest --with-goals --without-loop-goals
|
|
41
57
|
```
|
|
42
58
|
|
|
43
|
-
|
|
59
|
+
For new OpenCode 2 installations, the explicit two-command setup above is preferred because ownership is clear and each package can be updated independently.
|
|
44
60
|
|
|
45
|
-
Install only Loop
|
|
61
|
+
Install only Loop without touching Goal:
|
|
46
62
|
|
|
47
63
|
```bash
|
|
48
64
|
npx -y @bybrawe/opencode-loop@latest --loop-only
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# OpenCode Loop 0.6.2
|
|
2
|
+
|
|
3
|
+
OpenCode 2 migration-guide conformance release, paired with OpenCode Goal 1.3.42.
|
|
4
|
+
|
|
5
|
+
## Public V2 plugin context
|
|
6
|
+
|
|
7
|
+
The native runtime treats `ctx.location.directory` as the only plugin-instance
|
|
8
|
+
project location. `ctx.options` remains plugin configuration and is not accepted as
|
|
9
|
+
a project-directory fallback.
|
|
10
|
+
|
|
11
|
+
Per-session scope is verified through public `session.get` location data, so a
|
|
12
|
+
session from another worktree/project cannot borrow this plugin instance's state.
|
|
13
|
+
|
|
14
|
+
## V2 plugin and installation contract
|
|
15
|
+
|
|
16
|
+
The npm root/server and explicit `./v2` entries use
|
|
17
|
+
`@opencode/plugin` `Plugin.define({ id, setup })`. Native installs use plural
|
|
18
|
+
`plugins` configuration and the V2 plugins layout. Explicit `--legacy-v1` remains
|
|
19
|
+
a separate compatibility path; V1 hooks and SDK request shapes are not the default
|
|
20
|
+
V2 runtime.
|
|
21
|
+
|
|
22
|
+
Native prompt/compaction hooks, command transforms and the event subscription are
|
|
23
|
+
registered through V2 domains and disposed on unload. The event stream is cancelled
|
|
24
|
+
with an abort signal before local resources and registrations are drained.
|
|
25
|
+
|
|
26
|
+
## Capability boundary
|
|
27
|
+
|
|
28
|
+
Loop does not probe undocumented `session.shell`, `session.compact` or
|
|
29
|
+
`session.inbox` properties. Scheduled shell execution is a bounded Loop-managed
|
|
30
|
+
local process. Manual scheduled compaction remains unsupported on the OpenCode
|
|
31
|
+
2.0.18 public plugin contract and is rejected before durable job creation; automatic
|
|
32
|
+
compaction remains host-owned.
|
|
33
|
+
|
|
34
|
+
## Paired Goal release
|
|
35
|
+
|
|
36
|
+
Publication is pinned to OpenCode Goal 1.3.42 from immutable source
|
|
37
|
+
`1dd58abf1e7c08594c2085883e28acea3dbe0418` and refuses to continue until that
|
|
38
|
+
exact package is visible on npm.
|
|
39
|
+
|
|
40
|
+
## Verification
|
|
41
|
+
|
|
42
|
+
The publisher requires Windows/Linux native regressions, exact-main CI and Native V2
|
|
43
|
+
CI, production tarball installation, joint Goal+Loop installation/options/uninstall,
|
|
44
|
+
real native commands and the two-turn single-session daemon on OpenCode 2.0.18.
|
|
45
|
+
|
|
46
|
+
Install the verified pair after registry publication:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npx -y @bybrawe/opencode-goal@1.3.42
|
|
50
|
+
npx -y @bybrawe/opencode-loop@0.6.2 --loop-only --without-loop-goals
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Fully restart OpenCode after installation. Existing project Goal/Loop state and
|
|
54
|
+
user-owned files are preserved.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# OpenCode Loop 0.6.3
|
|
2
|
+
|
|
3
|
+
OpenCode 2 installer dependency-resolution fix, paired with OpenCode Goal 1.3.42.
|
|
4
|
+
|
|
5
|
+
## What changed
|
|
6
|
+
|
|
7
|
+
OpenCode Loop now installs natively as an exact published package registration:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"plugins": [
|
|
12
|
+
"@bybrawe/opencode-loop@0.6.3"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The default V2 installer no longer copies a loose global
|
|
18
|
+
`~/.config/opencode/plugins/opencode-loop` package directory.
|
|
19
|
+
|
|
20
|
+
This fixes the reproduced OpenCode 2.0.21 startup failure:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
Cannot find package '@opencode/plugin' imported from
|
|
24
|
+
.../.config/opencode/plugins/opencode-loop/index.js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The V2 entrypoint still uses the official `@opencode/plugin`
|
|
28
|
+
`Plugin.define({ id, setup })` contract. The difference is distribution:
|
|
29
|
+
OpenCode now resolves the entrypoint and `@opencode/plugin` from the same
|
|
30
|
+
published package dependency graph.
|
|
31
|
+
|
|
32
|
+
## Upgrade behavior
|
|
33
|
+
|
|
34
|
+
Running the 0.6.3 installer over 0.6.1/0.6.2 automatically:
|
|
35
|
+
|
|
36
|
+
- adds or pins the exact Loop package in plural `plugins`;
|
|
37
|
+
- removes known loose `plugins/opencode-loop`, `opencode-loop.ts` and
|
|
38
|
+
`opencode-loop.js` copies;
|
|
39
|
+
- preserves unrelated plugin registrations and object options;
|
|
40
|
+
- preserves project state under `.opencode/opencode-loop/`;
|
|
41
|
+
- keeps explicit `--legacy-v1` behavior isolated.
|
|
42
|
+
|
|
43
|
+
## Paired Goal release
|
|
44
|
+
|
|
45
|
+
Publication remains paired with OpenCode Goal 1.3.42 from immutable source
|
|
46
|
+
`1dd58abf1e7c08594c2085883e28acea3dbe0418`.
|
|
47
|
+
|
|
48
|
+
## Verification
|
|
49
|
+
|
|
50
|
+
The release gate runs Windows/Linux installer regressions, package smoke tests,
|
|
51
|
+
exact-main CI, native V2 CI and the production Goal+Loop host pair before npm
|
|
52
|
+
publication.
|
|
53
|
+
|
|
54
|
+
Install or update:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npx -y @bybrawe/opencode-goal@1.3.42
|
|
58
|
+
npx -y @bybrawe/opencode-loop@0.6.3 --loop-only --without-loop-goals
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then fully restart OpenCode and verify:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
/goal status
|
|
65
|
+
/loop-help
|
|
66
|
+
/loop-doctor
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If OpenCode itself reports that managed service port 49374 is already in use,
|
|
70
|
+
that is a host service collision rather than this plugin dependency bug. Stop or
|
|
71
|
+
reconfigure the OpenCode service before testing the commands.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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/plugin.js",
|
|
@@ -10,6 +10,9 @@ import { fileURLToPath, pathToFileURL } from "node:url"
|
|
|
10
10
|
|
|
11
11
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
12
|
const isWindows = process.platform === "win32"
|
|
13
|
+
const SERVER_READY_TIMEOUT_MS = isWindows ? 60_000 : 30_000
|
|
14
|
+
const API_BOOTSTRAP_TIMEOUT_MS = isWindows ? 90_000 : 30_000
|
|
15
|
+
const PREWARM_TIMEOUT_MS = isWindows ? 120_000 : 60_000
|
|
13
16
|
const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
|
|
14
17
|
const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
|
|
15
18
|
const LOOP_PROMPT_MARKER = "AUTONOMOUS OPENCODE LOOP ITERATION"
|
|
@@ -301,28 +304,41 @@ async function main() {
|
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
try {
|
|
304
|
-
const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs:
|
|
307
|
+
const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: PREWARM_TIMEOUT_MS })
|
|
305
308
|
assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`)
|
|
306
309
|
|
|
307
310
|
const port = await reservePort()
|
|
308
311
|
server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
|
|
309
312
|
server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
310
313
|
server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
|
|
311
|
-
await waitForTcp(port, server, () => serverLog)
|
|
314
|
+
await waitForTcp(port, server, () => serverLog, SERVER_READY_TIMEOUT_MS)
|
|
312
315
|
|
|
313
316
|
const baseURL = `http://127.0.0.1:${port}`
|
|
314
317
|
const directoryQuery = `directory=${encodeURIComponent(workspace)}`
|
|
315
318
|
const api = async (pathname, init = {}) => {
|
|
316
319
|
const separator = pathname.includes("?") ? "&" : "?"
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
320
|
+
const url = `${baseURL}${pathname}${separator}${directoryQuery}`
|
|
321
|
+
const ownTimeout = !init.signal
|
|
322
|
+
const timeoutMs = API_BOOTSTRAP_TIMEOUT_MS
|
|
323
|
+
try {
|
|
324
|
+
const response = await fetch(url, {
|
|
325
|
+
...init,
|
|
326
|
+
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
|
|
327
|
+
signal: init.signal ?? AbortSignal.timeout(timeoutMs),
|
|
328
|
+
})
|
|
329
|
+
const text = await response.text()
|
|
330
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
|
|
331
|
+
if (!text) return null
|
|
332
|
+
try { return JSON.parse(text) } catch { return text }
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const method = init.method ?? "GET"
|
|
335
|
+
const boundary = ownTimeout ? ` after ${timeoutMs}ms bootstrap timeout` : " with caller-owned signal"
|
|
336
|
+
throw new Error(
|
|
337
|
+
`OpenCode API ${method} ${pathname} failed${boundary}: ${error?.name ?? "Error"}: ${error?.message ?? error}\n`
|
|
338
|
+
+ `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`,
|
|
339
|
+
{ cause: error },
|
|
340
|
+
)
|
|
341
|
+
}
|
|
326
342
|
}
|
|
327
343
|
|
|
328
344
|
const sessionsPayload = await api("/session", { method: "GET" })
|
package/scripts/install-node.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const installerArgs = rawInstallerArgs.filter((arg) => arg !== "--legacy-v1")
|
|
|
22
22
|
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
23
23
|
|
|
24
24
|
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
25
|
-
console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update defaults to native OpenCode 2:
|
|
25
|
+
console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update defaults to native OpenCode 2: the exact package version is registered in the plural plugins config so OpenCode resolves the package and its V2 runtime dependencies together. No loose local V2 plugin copy, V1 SDK, or legacy command files are needed. Existing npm registrations are pinned to the exact version and migrated to plugins while retaining object options. Use --legacy-v1 to install the compatibility loader and legacy command files explicitly.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
|
|
26
26
|
process.exit(0)
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -277,9 +277,13 @@ async function configurePackagePlugin() {
|
|
|
277
277
|
}
|
|
278
278
|
} else {
|
|
279
279
|
const pinned = pinRegistration(own)
|
|
280
|
-
if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec))
|
|
280
|
+
if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) {
|
|
281
|
+
updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
|
|
282
|
+
}
|
|
281
283
|
const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
|
|
282
|
-
updated = parsed.plugins === undefined
|
|
284
|
+
updated = parsed.plugins === undefined
|
|
285
|
+
? appendPluginProperty(updated, next, "plugins")
|
|
286
|
+
: rewriteExistingPluginArray(updated, next, "plugins")
|
|
283
287
|
}
|
|
284
288
|
}
|
|
285
289
|
parseJsonc(updated)
|
|
@@ -288,8 +292,35 @@ async function configurePackagePlugin() {
|
|
|
288
292
|
if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
|
|
289
293
|
}
|
|
290
294
|
}
|
|
295
|
+
|
|
296
|
+
// Native V2 installs are package registrations, not loose discovered copies.
|
|
297
|
+
// The published package owns @opencode/plugin as a production dependency, so
|
|
298
|
+
// OpenCode resolves the entrypoint and SDK from the same package install.
|
|
299
|
+
// This also migrates broken 0.6.1/0.6.2 loose installs on the next update.
|
|
300
|
+
if (!configured && !legacyV1) {
|
|
301
|
+
if (plans.length) {
|
|
302
|
+
const plan = plans[0]
|
|
303
|
+
const parsed = parseJsonc(plan.updated)
|
|
304
|
+
const entries = parsed.plugins || []
|
|
305
|
+
if (!Array.isArray(entries)) throw new Error("OpenCode config 'plugins' must be an array")
|
|
306
|
+
plan.updated = parsed.plugins === undefined
|
|
307
|
+
? appendPluginProperty(plan.updated, [...entries, packageSpec], "plugins")
|
|
308
|
+
: rewriteExistingPluginArray(plan.updated, [...entries, packageSpec], "plugins")
|
|
309
|
+
parseJsonc(plan.updated)
|
|
310
|
+
} else {
|
|
311
|
+
const target = join(config, "opencode.json")
|
|
312
|
+
const updated = JSON.stringify({
|
|
313
|
+
$schema: "https://opencode.ai/config.json",
|
|
314
|
+
plugins: [packageSpec],
|
|
315
|
+
}, null, 2) + "\n"
|
|
316
|
+
plans.push({ target, source: "", updated })
|
|
317
|
+
}
|
|
318
|
+
configured = true
|
|
319
|
+
}
|
|
320
|
+
|
|
291
321
|
// Validate every candidate before writing any config. Malformed later files
|
|
292
|
-
// must not leave
|
|
322
|
+
// must not leave an earlier file migrated and the plugin half-installed.
|
|
323
|
+
for (const plan of plans) parseJsonc(plan.updated)
|
|
293
324
|
for (const plan of plans) if (plan.source !== plan.updated) await writeFile(plan.target, plan.updated, "utf8")
|
|
294
325
|
return { configured, updatedFiles: plans.filter((plan) => plan.source !== plan.updated).map((plan) => plan.target) }
|
|
295
326
|
}
|
|
@@ -367,14 +398,7 @@ async function installOrUpdate() {
|
|
|
367
398
|
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
368
399
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
369
400
|
} else {
|
|
370
|
-
|
|
371
|
-
// Keep the generated native runtime beside it as a supporting module instead
|
|
372
|
-
// of auto-discovering that implementation object as a second plugin.
|
|
373
|
-
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
374
|
-
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
375
|
-
await mkdir(nativePluginDir, { recursive: true })
|
|
376
|
-
await copyFile(join(root, "src", "v2.js"), join(nativePluginDir, "index.js"))
|
|
377
|
-
await copyFile(join(root, "src", "native.js"), join(nativePluginDir, "native.js"))
|
|
401
|
+
throw new Error("Native OpenCode 2 package registration was not created")
|
|
378
402
|
}
|
|
379
403
|
if (legacyV1) {
|
|
380
404
|
await mkdir(commandDir, { recursive: true })
|
|
@@ -388,11 +412,11 @@ async function installOrUpdate() {
|
|
|
388
412
|
if (packageConfig.configured) {
|
|
389
413
|
const pinResult = packageConfig.updatedFiles.length ? `pinned the config entry to ${packageSpec}` : `the config entry is already pinned to ${packageSpec}`
|
|
390
414
|
console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
|
|
391
|
-
} else console.log(`Installed OpenCode Loop plugin to ${
|
|
415
|
+
} else console.log(`Installed OpenCode Loop compatibility plugin to ${join(pluginDir, "opencode-loop.ts")}`)
|
|
392
416
|
if (legacyV1) {
|
|
393
417
|
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
394
418
|
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
|
395
|
-
} else console.log("Native OpenCode 2
|
|
419
|
+
} else console.log("Native OpenCode 2 is registered as an exact package plugin; OpenCode resolves @opencode/plugin from the package dependency graph. No loose local plugin copy, legacy command files, or V1 SDK are needed.")
|
|
396
420
|
console.log("Restart OpenCode, then run: /loop-help")
|
|
397
421
|
}
|
|
398
422
|
|
|
@@ -28,22 +28,33 @@ try {
|
|
|
28
28
|
run(fresh)
|
|
29
29
|
const native = await readFile(path.join(root, "src/native.js"), "utf8")
|
|
30
30
|
const v2 = await readFile(path.join(root, "src/v2.js"), "utf8")
|
|
31
|
-
|
|
32
|
-
assert.
|
|
31
|
+
const readConfig = async (directory) => JSON.parse(await readFile(path.join(directory, "opencode.json"), "utf8"))
|
|
32
|
+
assert.deepEqual((await readConfig(fresh)).plugins, [spec])
|
|
33
33
|
assert.match(v2, /from\s+["']@opencode\/plugin["']/)
|
|
34
34
|
assert.match(v2, /Plugin\.define\s*\(/)
|
|
35
35
|
assert.doesNotMatch(native, /(?:from\s*|import\s*\()["']@opencode-ai\/plugin/)
|
|
36
|
+
assert.equal(await exists(path.join(fresh, "plugins/opencode-loop")), false)
|
|
36
37
|
assert.equal(await exists(path.join(fresh, "plugins/opencode-loop.ts")), false)
|
|
37
38
|
assert.equal(await exists(path.join(fresh, "package.json")), false)
|
|
38
39
|
assert.equal(await exists(path.join(fresh, "commands/loop.md")), false)
|
|
39
40
|
assert.equal(await exists(path.join(fresh, "agents/opencode-loop-local.md")), false)
|
|
41
|
+
const freshBefore = await readFile(path.join(fresh, "opencode.json"), "utf8")
|
|
40
42
|
run(fresh)
|
|
43
|
+
assert.equal(await readFile(path.join(fresh, "opencode.json"), "utf8"), freshBefore)
|
|
44
|
+
cases++
|
|
45
|
+
|
|
46
|
+
const brokenLoose = await config("broken-loose")
|
|
47
|
+
await mkdir(path.join(brokenLoose, "plugins/opencode-loop"), { recursive: true })
|
|
48
|
+
await writeFile(path.join(brokenLoose, "plugins/opencode-loop/index.js"), 'import { Plugin } from "@opencode/plugin"\nexport default Plugin.define({ id: "broken", setup() {} })\n')
|
|
49
|
+
await writeFile(path.join(brokenLoose, "plugins/opencode-loop/native.js"), "export default {}\n")
|
|
50
|
+
run(brokenLoose)
|
|
51
|
+
assert.deepEqual((await readConfig(brokenLoose)).plugins, [spec])
|
|
52
|
+
assert.equal(await exists(path.join(brokenLoose, "plugins/opencode-loop")), false, "native update must remove the old loose V2 copy that cannot resolve package dependencies")
|
|
41
53
|
cases++
|
|
42
54
|
|
|
43
55
|
const options = { package: "@bybrawe/opencode-loop@0.5.38", options: { quiet: true, nested: { value: "preserved" } }, enabled: true }
|
|
44
56
|
const obj = await config("object", { plugins: ["other-plugin", options] })
|
|
45
57
|
run(obj)
|
|
46
|
-
const readConfig = async (directory) => JSON.parse(await readFile(path.join(directory, "opencode.json"), "utf8"))
|
|
47
58
|
assert.deepEqual((await readConfig(obj)).plugins, ["other-plugin", { ...options, package: spec }])
|
|
48
59
|
const before = await readFile(path.join(obj, "opencode.json"), "utf8")
|
|
49
60
|
run(obj)
|
|
@@ -88,8 +99,8 @@ try {
|
|
|
88
99
|
run(upgrade)
|
|
89
100
|
assert.equal(await exists(path.join(upgrade, "commands/loop.md")), false)
|
|
90
101
|
assert.equal(await readFile(path.join(upgrade, "commands/loop-help.md"), "utf8"), "This is a custom user command; keep it.")
|
|
91
|
-
assert.
|
|
92
|
-
assert.equal(await
|
|
102
|
+
assert.deepEqual((await readConfig(upgrade)).plugins, [spec])
|
|
103
|
+
assert.equal(await exists(path.join(upgrade, "plugins/opencode-loop")), false)
|
|
93
104
|
assert.equal(await exists(path.join(upgrade, "plugins/opencode-loop.ts")), false)
|
|
94
105
|
cases++
|
|
95
106
|
|
|
@@ -19,6 +19,7 @@ async function fixture({ failDispose = false, failSubscribe = false } = {}) {
|
|
|
19
19
|
const ctx = {
|
|
20
20
|
location: { directory }, options: {},
|
|
21
21
|
session: {
|
|
22
|
+
get: async ({ sessionID }) => ({ id: sessionID, location: { directory } }),
|
|
22
23
|
prompt: async () => { throw new Error("cleanup must not prompt or abort a native session") },
|
|
23
24
|
hook: async name => registration(name),
|
|
24
25
|
},
|
|
@@ -46,6 +47,18 @@ async function bounded(promise) {
|
|
|
46
47
|
finally { clearTimeout(timer) }
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
test("native setup never treats plugin options.directory as location authority", async () => {
|
|
51
|
+
const host = await fixture()
|
|
52
|
+
try {
|
|
53
|
+
host.ctx.location = {}
|
|
54
|
+
host.ctx.options.directory = "/not-a-v2-location"
|
|
55
|
+
await assert.rejects(
|
|
56
|
+
plugin.setup(host.ctx),
|
|
57
|
+
/requires a project directory/i,
|
|
58
|
+
)
|
|
59
|
+
} finally { host.release(); await host.remove() }
|
|
60
|
+
})
|
|
61
|
+
|
|
49
62
|
test("native unload aborts a pending public event stream and shares cleanup", async () => {
|
|
50
63
|
const host = await fixture()
|
|
51
64
|
let dispose
|
|
@@ -17,6 +17,7 @@ function fixture(directory, { failCleanup = false } = {}) {
|
|
|
17
17
|
const ctx = {
|
|
18
18
|
location: { directory }, options: {}, app: { version: "2.0.18" },
|
|
19
19
|
session: {
|
|
20
|
+
get: async ({ sessionID }) => ({ id: sessionID, location: { directory } }),
|
|
20
21
|
prompt: async () => { throw new Error("cleanup must not dispatch a prompt") },
|
|
21
22
|
hook: async (name) => registration(name),
|
|
22
23
|
},
|
|
@@ -47,6 +48,18 @@ async function bounded(promise) {
|
|
|
47
48
|
} finally { clearTimeout(timer) }
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
test("native Loop fails setup when the public session.get location capability is unavailable", async () => {
|
|
52
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-no-session-get-"))
|
|
53
|
+
const host = fixture(directory)
|
|
54
|
+
delete host.ctx.session.get
|
|
55
|
+
try {
|
|
56
|
+
await assert.rejects(plugin.setup(host.ctx), /requires session\.get/)
|
|
57
|
+
} finally {
|
|
58
|
+
host.end()
|
|
59
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
50
63
|
test("native Loop aborts an idle subscription before awaiting iterator return", async () => {
|
|
51
64
|
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-lifetime-"))
|
|
52
65
|
const host = fixture(directory)
|
|
@@ -87,6 +100,26 @@ test("native Loop cleanup is shared and drains all registrations after an error"
|
|
|
87
100
|
}
|
|
88
101
|
})
|
|
89
102
|
|
|
103
|
+
test("native Loop never probes undocumented SessionDomain shell compact or inbox fields", async () => {
|
|
104
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-public-session-"))
|
|
105
|
+
const host = fixture(directory)
|
|
106
|
+
let dispose
|
|
107
|
+
try {
|
|
108
|
+
for (const field of ["shell", "compact", "inbox"]) {
|
|
109
|
+
Object.defineProperty(host.ctx.session, field, {
|
|
110
|
+
configurable: true,
|
|
111
|
+
get() { throw new Error(`undocumented session.${field} was accessed`) },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
dispose = await plugin.setup(host.ctx)
|
|
115
|
+
assert.equal(typeof dispose, "function")
|
|
116
|
+
} finally {
|
|
117
|
+
host.end()
|
|
118
|
+
await dispose?.().catch(() => {})
|
|
119
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
90
123
|
test("native Loop uses the same public plugin identity as the package entry", () => {
|
|
91
124
|
assert.equal(plugin.id, "@bybrawe/opencode-loop", "native entry must share canonical plugin identity")
|
|
92
125
|
})
|
|
@@ -9,9 +9,9 @@ import { presetDefaults } from "../src/source/core/jobs.js"
|
|
|
9
9
|
import { normalizeLoopScheduleArgs } from "../src/source/core/schedule-syntax.js"
|
|
10
10
|
import { readState } from "../src/source/core/state.js"
|
|
11
11
|
|
|
12
|
-
const PRESETS = ["loop-dev", "loop-testfix", "loop-
|
|
12
|
+
const PRESETS = ["loop-dev", "loop-testfix", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"]
|
|
13
13
|
const SESSION = "ses_native_presets"
|
|
14
|
-
async function fixture(
|
|
14
|
+
async function fixture() {
|
|
15
15
|
const temp = await mkdtemp(path.join(os.tmpdir(), "loop-native-presets-"))
|
|
16
16
|
const directory = path.join(temp, "project with spaces")
|
|
17
17
|
await mkdir(directory)
|
|
@@ -27,7 +27,6 @@ async function fixture({ compact = true } = {}) {
|
|
|
27
27
|
command: async () => { throw new Error("a deferred/dry-run preset must not execute a slash command") },
|
|
28
28
|
wait: async () => {},
|
|
29
29
|
shell: async request => { shellCalls.push(request); return { id: request.id, status: "exited", exit: 0 } },
|
|
30
|
-
...(compact ? { compact: async () => { throw new Error("a deferred/dry-run preset must not compact") } } : {}),
|
|
31
30
|
},
|
|
32
31
|
command: { transform: async edit => edit({ add(definition) {
|
|
33
32
|
assert.equal(commands.has(definition.name), false, `duplicate command ${definition.name}`)
|
|
@@ -54,7 +53,7 @@ for (const name of PRESETS) {
|
|
|
54
53
|
const action = ["loop-command", "loop-cmd"].includes(name) ? "/review project"
|
|
55
54
|
: name === "loop-shell" ? "npm test"
|
|
56
55
|
: ["loop-prompt", "loop-ask"].includes(name) ? "did you run tests and tsc --noEmit?" : ""
|
|
57
|
-
const text =
|
|
56
|
+
const text = `every 1h --dry-run --max-runs 2 ${action}`.trim()
|
|
58
57
|
const normalized = normalizeLoopScheduleArgs(text, presetDefaults(name))
|
|
59
58
|
assert.equal(normalized.ok, true, normalized.error)
|
|
60
59
|
const expected = parseLoopArgs(normalized.args, normalized.defaults)
|
|
@@ -64,7 +63,7 @@ for (const name of PRESETS) {
|
|
|
64
63
|
const state = await host.state()
|
|
65
64
|
assert.equal(state.jobs.length, 1)
|
|
66
65
|
for (const field of fields) assert.deepEqual(state.jobs[0][field], expected.job[field], `${name}: ${field}`)
|
|
67
|
-
assert.equal(state.jobs[0].intervalMs,
|
|
66
|
+
assert.equal(state.jobs[0].intervalMs, 3600000, "explicit duration cannot remain part of the task text")
|
|
68
67
|
assert.equal(state.jobs[0].immediate, false)
|
|
69
68
|
assert.equal(state.jobs[0].scheduleMode, normalized.scheduleMode)
|
|
70
69
|
assert.equal(state.jobs[0].scheduleSyntax, normalized.scheduleSyntax)
|
|
@@ -98,21 +97,28 @@ test("an immediate native development preset enters the existing dry-run safety
|
|
|
98
97
|
} finally { await host.dispose() }
|
|
99
98
|
})
|
|
100
99
|
|
|
101
|
-
test("zero-delay deferred shell preset
|
|
100
|
+
test("zero-delay deferred shell preset uses Loop-managed local execution without an unrelated model turn", async () => {
|
|
102
101
|
const host = await fixture()
|
|
103
102
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
const marker = path.join(host.directory, "shell-ran.txt")
|
|
104
|
+
await writeFile(path.join(host.directory, "shell-test.cjs"), 'require("fs").writeFileSync("shell-ran.txt", "ok")')
|
|
105
|
+
const command = `"${process.execPath}" shell-test.cjs`
|
|
106
|
+
await host.execute("loop-shell", `0s ${command} --max-runs 1`)
|
|
107
|
+
const deadline = Date.now() + 5000
|
|
107
108
|
while (Date.now() < deadline && (await host.state()).jobs[0]?.runCount !== 1) await new Promise(resolve => setTimeout(resolve, 10))
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
try { if ((await readFile(marker, "utf8")) === "ok") break } catch {}
|
|
111
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
112
|
+
}
|
|
108
113
|
assert.equal((await host.state()).jobs[0].runCount, 1)
|
|
109
|
-
assert.equal(
|
|
114
|
+
assert.equal(await readFile(marker, "utf8"), "ok")
|
|
115
|
+
assert.equal(host.shellCalls.length, 0, "V2 must not call undocumented session.shell")
|
|
110
116
|
assert.deepEqual(host.prompts, [])
|
|
111
117
|
} finally { await host.dispose() }
|
|
112
118
|
})
|
|
113
119
|
|
|
114
|
-
test("
|
|
115
|
-
const host = await fixture(
|
|
120
|
+
test("native compaction rejects its preset before job persistence because V2 exposes no manual compaction action", async () => {
|
|
121
|
+
const host = await fixture()
|
|
116
122
|
try {
|
|
117
123
|
await assert.rejects(host.execute("loop-compact", "--dry-run"), /native compaction capability/)
|
|
118
124
|
assert.deepEqual((await host.state()).jobs, [])
|
|
@@ -7,12 +7,12 @@ export function validateRelease(request, pkg, env) {
|
|
|
7
7
|
assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
|
|
8
8
|
assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
|
|
9
9
|
assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
|
|
10
|
-
assert.equal(request?.version, "0.6.
|
|
10
|
+
assert.equal(request?.version, "0.6.3", "release version is not authorized")
|
|
11
11
|
assert.equal(request?.name, "@bybrawe/opencode-loop")
|
|
12
12
|
assert.equal(pkg.name, request.name)
|
|
13
13
|
assert.equal(pkg.version, request.version)
|
|
14
|
-
assert.equal(request.predecessor, "0.6.
|
|
15
|
-
assert.equal(request.goal?.version, "1.3.
|
|
14
|
+
assert.equal(request.predecessor, "0.6.2")
|
|
15
|
+
assert.equal(request.goal?.version, "1.3.42")
|
|
16
16
|
assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
|
|
17
17
|
return request
|
|
18
18
|
}
|
|
@@ -183,6 +183,16 @@ await test("preflight failure does not consume a run", async (f) => {
|
|
|
183
183
|
assert.equal(f.prompts.length, 0)
|
|
184
184
|
}, { runShellCommand: async () => ({ code: 3, stdout: "", stderr: "not ready" }) })
|
|
185
185
|
|
|
186
|
+
await test("native slash command dispatch uses OpenCode 2 command and arguments fields", async (f) => {
|
|
187
|
+
await f.add("0s --command /review --staged --max-runs 1")
|
|
188
|
+
await f.wake()
|
|
189
|
+
assert.equal(f.commands.length, 1)
|
|
190
|
+
assert.equal(f.commands[0].command, "review")
|
|
191
|
+
assert.equal(f.commands[0].arguments, "--staged")
|
|
192
|
+
assert.equal("name" in f.commands[0], false)
|
|
193
|
+
assert.equal("text" in f.commands[0], false)
|
|
194
|
+
})
|
|
195
|
+
|
|
186
196
|
await test("safe guard covers verification commands too", async (f) => {
|
|
187
197
|
await f.add('0s task --safe --verify "git reset --hard" --max-runs 1')
|
|
188
198
|
await f.wake()
|
|
@@ -6,6 +6,7 @@ import { validateRelease, releaseDecision, assertPublishedSource, waitForPublish
|
|
|
6
6
|
|
|
7
7
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
8
8
|
const workflow = await readFile(path.join(repoRoot, ".github/workflows/publish-npm.yml"), "utf8")
|
|
9
|
+
const nativeV2Workflow = await readFile(path.join(repoRoot, ".github/workflows/native-v2-ci.yml"), "utf8")
|
|
9
10
|
assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "checkout must use the immutable trigger SHA")
|
|
10
11
|
assert.doesNotMatch(workflow, /ref:\s*main\b/, "publication cannot checkout moving main")
|
|
11
12
|
assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/)
|
|
@@ -28,6 +29,8 @@ assert.match(workflow, /native-release\.mjs verify/)
|
|
|
28
29
|
assert.match(workflow, /node_modules\/@bybrawe\/opencode-loop\/scripts\/opencode2-loop-canary.mjs/)
|
|
29
30
|
assert.match(workflow, /node_modules\/@bybrawe\/opencode-goal\/dist\/server.js/)
|
|
30
31
|
assert.match(workflow, /ref: \$\{\{ steps.release.outputs.goal_sha \}\}/)
|
|
32
|
+
assert.match(workflow, /npm install --no-save --legacy-peer-deps @opencode\/plugin@2\.0\.18/, "immutable Goal companion must build against its pinned OpenCode 2.0.18 plugin contract")
|
|
33
|
+
assert.match(nativeV2Workflow, /npm install --no-save --legacy-peer-deps @opencode\/plugin@2\.0\.18/, "Native V2 CI must pin the immutable Goal companion to the same OpenCode 2.0.18 plugin contract")
|
|
31
34
|
assert.doesNotMatch(workflow, /secrets\.NPM_TOKEN/)
|
|
32
35
|
|
|
33
36
|
const publishIndex = workflow.indexOf("npm publish --access public")
|
|
@@ -41,14 +44,14 @@ const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.inde
|
|
|
41
44
|
assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
|
|
42
45
|
|
|
43
46
|
const sha = "a".repeat(40)
|
|
44
|
-
const request = { name: "@bybrawe/opencode-loop", version: "0.6.
|
|
47
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.3", predecessor: "0.6.2", goal: { version: "1.3.42", sha: "b".repeat(40) } }
|
|
45
48
|
const pkg = { name: request.name, version: request.version }
|
|
46
49
|
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
47
50
|
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
48
51
|
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
49
52
|
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
50
|
-
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.
|
|
51
|
-
assert.equal(releaseDecision(404, null, request, sha, "0.6.
|
|
53
|
+
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.2" }, env))
|
|
54
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.6.2"), true)
|
|
52
55
|
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
|
|
53
56
|
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
54
57
|
const manifest = { ...pkg, gitHead: sha }
|
package/src/native.js
CHANGED
|
@@ -1862,7 +1862,7 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
1862
1862
|
request = { sessionID: scope.sessionID, id: id(), command: String(job.action).replace(/^[!$]\s*/, ""), timeoutMs: job.timeoutMs };
|
|
1863
1863
|
else {
|
|
1864
1864
|
const [name, text] = splitFirst(String(job.action).replace(/^\/+/, ""));
|
|
1865
|
-
request = { sessionID: scope.sessionID, name,
|
|
1865
|
+
request = { sessionID: scope.sessionID, command: name, arguments: text || "", delivery: "queue" };
|
|
1866
1866
|
}
|
|
1867
1867
|
if (!isCurrent())
|
|
1868
1868
|
return result({ reason: "foreground-or-compaction" });
|
|
@@ -2963,8 +2963,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
2963
2963
|
/loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
|
|
2964
2964
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
2965
2965
|
Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
|
|
2966
|
-
Scheduled shell commands run as bounded local child processes
|
|
2967
|
-
|
|
2966
|
+
Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
|
|
2967
|
+
The public OpenCode 2 plugin API does not expose manual session compaction. /loop-compact, --compact and --compact-every are rejected before job creation on V2; native automatic compaction remains host-owned and is observed through the compaction hook.
|
|
2968
2968
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
2969
2969
|
--timeout pauses future iterations without aborting the current native model/tool/compaction operation.
|
|
2970
2970
|
--safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
|
|
@@ -2982,11 +2982,11 @@ async function cleanup(value) {
|
|
|
2982
2982
|
var OpenCodeLoopNativePlugin = Object.freeze({
|
|
2983
2983
|
id: "@bybrawe/opencode-loop",
|
|
2984
2984
|
async setup(ctx) {
|
|
2985
|
-
for (const [label, fn] of [["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
|
|
2985
|
+
for (const [label, fn] of [["session.get", ctx?.session?.get], ["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
|
|
2986
2986
|
if (typeof fn !== "function")
|
|
2987
2987
|
throw new Error(`Native OpenCode Loop requires ${label}; use OpenCode 2.0.18 or newer.`);
|
|
2988
2988
|
}
|
|
2989
|
-
const directory = String(ctx.location?.directory ||
|
|
2989
|
+
const directory = String(ctx.location?.directory || "").trim();
|
|
2990
2990
|
if (!directory)
|
|
2991
2991
|
throw new Error("Native OpenCode Loop requires a project directory");
|
|
2992
2992
|
const ownsSession = createNativeSessionLocationGuard({
|
|
@@ -3010,12 +3010,9 @@ var OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
3010
3010
|
prompt,
|
|
3011
3011
|
command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
|
|
3012
3012
|
wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
|
|
3013
|
-
compact:
|
|
3014
|
-
shell:
|
|
3015
|
-
cancel:
|
|
3016
|
-
await ctx.session.inbox.cancel(request);
|
|
3017
|
-
return false;
|
|
3018
|
-
} : undefined,
|
|
3013
|
+
compact: undefined,
|
|
3014
|
+
shell: (request) => shellHost.dispatch(request),
|
|
3015
|
+
cancel: undefined,
|
|
3019
3016
|
onError: (error) => {
|
|
3020
3017
|
appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {});
|
|
3021
3018
|
}
|
package/src/server.js
CHANGED
|
@@ -1862,7 +1862,7 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
1862
1862
|
request = { sessionID: scope.sessionID, id: id(), command: String(job.action).replace(/^[!$]\s*/, ""), timeoutMs: job.timeoutMs };
|
|
1863
1863
|
else {
|
|
1864
1864
|
const [name, text] = splitFirst(String(job.action).replace(/^\/+/, ""));
|
|
1865
|
-
request = { sessionID: scope.sessionID, name,
|
|
1865
|
+
request = { sessionID: scope.sessionID, command: name, arguments: text || "", delivery: "queue" };
|
|
1866
1866
|
}
|
|
1867
1867
|
if (!isCurrent())
|
|
1868
1868
|
return result({ reason: "foreground-or-compaction" });
|
|
@@ -2963,8 +2963,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
2963
2963
|
/loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
|
|
2964
2964
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
2965
2965
|
Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
|
|
2966
|
-
Scheduled shell commands run as bounded local child processes
|
|
2967
|
-
|
|
2966
|
+
Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
|
|
2967
|
+
The public OpenCode 2 plugin API does not expose manual session compaction. /loop-compact, --compact and --compact-every are rejected before job creation on V2; native automatic compaction remains host-owned and is observed through the compaction hook.
|
|
2968
2968
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
2969
2969
|
--timeout pauses future iterations without aborting the current native model/tool/compaction operation.
|
|
2970
2970
|
--safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
|
|
@@ -2982,11 +2982,11 @@ async function cleanup(value) {
|
|
|
2982
2982
|
var OpenCodeLoopNativePlugin = Object.freeze({
|
|
2983
2983
|
id: "@bybrawe/opencode-loop",
|
|
2984
2984
|
async setup(ctx) {
|
|
2985
|
-
for (const [label, fn] of [["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
|
|
2985
|
+
for (const [label, fn] of [["session.get", ctx?.session?.get], ["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
|
|
2986
2986
|
if (typeof fn !== "function")
|
|
2987
2987
|
throw new Error(`Native OpenCode Loop requires ${label}; use OpenCode 2.0.18 or newer.`);
|
|
2988
2988
|
}
|
|
2989
|
-
const directory = String(ctx.location?.directory ||
|
|
2989
|
+
const directory = String(ctx.location?.directory || "").trim();
|
|
2990
2990
|
if (!directory)
|
|
2991
2991
|
throw new Error("Native OpenCode Loop requires a project directory");
|
|
2992
2992
|
const ownsSession = createNativeSessionLocationGuard({
|
|
@@ -3010,12 +3010,9 @@ var OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
3010
3010
|
prompt,
|
|
3011
3011
|
command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
|
|
3012
3012
|
wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
|
|
3013
|
-
compact:
|
|
3014
|
-
shell:
|
|
3015
|
-
cancel:
|
|
3016
|
-
await ctx.session.inbox.cancel(request);
|
|
3017
|
-
return false;
|
|
3018
|
-
} : undefined,
|
|
3013
|
+
compact: undefined,
|
|
3014
|
+
shell: (request) => shellHost.dispatch(request),
|
|
3015
|
+
cancel: undefined,
|
|
3019
3016
|
onError: (error) => {
|
|
3020
3017
|
appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {});
|
|
3021
3018
|
}
|
|
@@ -20,8 +20,8 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
20
20
|
/loop-init [progress.md] creates a missing project-local progress file without overwriting existing data.
|
|
21
21
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
22
22
|
Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
|
|
23
|
-
Scheduled shell commands run as bounded local child processes
|
|
24
|
-
|
|
23
|
+
Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
|
|
24
|
+
The public OpenCode 2 plugin API does not expose manual session compaction. /loop-compact, --compact and --compact-every are rejected before job creation on V2; native automatic compaction remains host-owned and is observed through the compaction hook.
|
|
25
25
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
26
26
|
--timeout pauses future iterations without aborting the current native model/tool/compaction operation.
|
|
27
27
|
--safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
|
|
@@ -38,10 +38,10 @@ async function cleanup(value) {
|
|
|
38
38
|
export const OpenCodeLoopNativePlugin = Object.freeze({
|
|
39
39
|
id: "@bybrawe/opencode-loop",
|
|
40
40
|
async setup(ctx) {
|
|
41
|
-
for (const [label, fn] of [["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
|
|
41
|
+
for (const [label, fn] of [["session.get", ctx?.session?.get], ["session.prompt", ctx?.session?.prompt], ["session.hook", ctx?.session?.hook], ["event.subscribe", ctx?.event?.subscribe], ["command.transform", ctx?.command?.transform]]) {
|
|
42
42
|
if (typeof fn !== "function") throw new Error(`Native OpenCode Loop requires ${label}; use OpenCode 2.0.18 or newer.`)
|
|
43
43
|
}
|
|
44
|
-
const directory = String(ctx.location?.directory ||
|
|
44
|
+
const directory = String(ctx.location?.directory || "").trim()
|
|
45
45
|
if (!directory) throw new Error("Native OpenCode Loop requires a project directory")
|
|
46
46
|
const ownsSession = createNativeSessionLocationGuard({
|
|
47
47
|
directory, workspaceID: ctx.location?.workspaceID,
|
|
@@ -61,11 +61,13 @@ export const OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
61
61
|
prompt,
|
|
62
62
|
command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
|
|
63
63
|
wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
64
|
+
// OpenCode 2 exposes ctx.shell as a hook domain, not an execution method,
|
|
65
|
+
// and SessionDomain has no public compact/inbox-cancel action. Keep those
|
|
66
|
+
// undocumented capabilities out of the V2 runtime instead of probing
|
|
67
|
+
// private host fields that may disappear between releases.
|
|
68
|
+
compact: undefined,
|
|
69
|
+
shell: (request) => shellHost.dispatch(request),
|
|
70
|
+
cancel: undefined,
|
|
69
71
|
onError: (error) => { void appendLoopLog(directory, "v2-native-error", { message: String(error?.message || error) }).catch(() => {}) },
|
|
70
72
|
})
|
|
71
73
|
const bridge = createOpenCode2EventBridge({ directory, allowInboxCommands: false, onEvent: async (event) => {
|
|
@@ -166,7 +166,7 @@ export function createNativeLoopRuntime(options = {}) {
|
|
|
166
166
|
else if (kind === "shell") request = { sessionID: scope.sessionID, id: id(), command: String(job.action).replace(/^[!$]\s*/, ""), timeoutMs: job.timeoutMs }
|
|
167
167
|
else {
|
|
168
168
|
const [name, text] = splitFirst(String(job.action).replace(/^\/+/, ""))
|
|
169
|
-
request = { sessionID: scope.sessionID, name,
|
|
169
|
+
request = { sessionID: scope.sessionID, command: name, arguments: text || "", delivery: "queue" }
|
|
170
170
|
}
|
|
171
171
|
if (!isCurrent()) return result({ reason: "foreground-or-compaction" })
|
|
172
172
|
if (job.dryRun) {
|