@bybrawe/opencode-loop 0.6.5 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +32 -24
- package/examples/OPENCODE2-0.6.6.md +48 -0
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/package.json +4 -4
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/goal-companion-test.mjs +23 -15
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +28 -118
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +7 -3
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +217 -11
- package/src/native.js +236 -19
- package/src/server.js +236 -19
- package/src/source/core/state.js +203 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
|
@@ -94,8 +94,8 @@ try {
|
|
|
94
94
|
const absentLog = path.join(temporaryRoot, "absent.log")
|
|
95
95
|
const absent = await runInstaller(absentConfig, [], { FAKE_NPM_LOG: absentLog })
|
|
96
96
|
assert.equal(absent.code, 0, absent.stderr)
|
|
97
|
-
assert.deepEqual(await calls(absentLog), [], "Loop
|
|
98
|
-
assert.
|
|
97
|
+
assert.deepEqual(await calls(absentLog), [], "default Loop installs must not silently add or update Goal")
|
|
98
|
+
assert.deepEqual(await loopGoalCommands(absentConfig), [], "legacy Loop Goal commands must be omitted by default")
|
|
99
99
|
|
|
100
100
|
const withoutLoopGoalsConfig = path.join(temporaryRoot, "without-loop-goals")
|
|
101
101
|
const withoutLoopGoalsLog = path.join(temporaryRoot, "without-loop-goals.log")
|
|
@@ -111,15 +111,15 @@ try {
|
|
|
111
111
|
await writeConfig(existingConfig, ["other-plugin", "@bybrawe/opencode-goal@1.3.16"])
|
|
112
112
|
const existing = await runInstaller(existingConfig, [], { FAKE_NPM_LOG: existingLog })
|
|
113
113
|
assert.equal(existing.code, 0, existing.stderr)
|
|
114
|
-
assert.deepEqual(await calls(existingLog), [
|
|
115
|
-
assert.match(existing.stdout, /
|
|
114
|
+
assert.deepEqual(await calls(existingLog), [], "default Loop updates must not refresh an installed Goal package")
|
|
115
|
+
assert.match(existing.stdout, /Recommended companion install \(separate command\)/)
|
|
116
116
|
|
|
117
117
|
const localConfig = path.join(temporaryRoot, "local-goal")
|
|
118
118
|
const localLog = path.join(temporaryRoot, "local-goal.log")
|
|
119
119
|
await writeConfig(localConfig, ["./plugins/opencode-goal.ts"])
|
|
120
120
|
const local = await runInstaller(localConfig, [], { FAKE_NPM_LOG: localLog })
|
|
121
121
|
assert.equal(local.code, 0, local.stderr)
|
|
122
|
-
assert.deepEqual(await calls(localLog), [
|
|
122
|
+
assert.deepEqual(await calls(localLog), [], "default Loop updates must not migrate or refresh legacy Goal installs")
|
|
123
123
|
|
|
124
124
|
const managedCommandConfig = path.join(temporaryRoot, "managed-command")
|
|
125
125
|
const managedCommandLog = path.join(temporaryRoot, "managed-command.log")
|
|
@@ -127,7 +127,7 @@ try {
|
|
|
127
127
|
await fs.writeFile(path.join(managedCommandConfig, "commands", "goal.md"), "<!-- managed-by:@bybrawe/opencode-goal -->\n", "utf8")
|
|
128
128
|
const managedCommand = await runInstaller(managedCommandConfig, [], { FAKE_NPM_LOG: managedCommandLog })
|
|
129
129
|
assert.equal(managedCommand.code, 0, managedCommand.stderr)
|
|
130
|
-
assert.deepEqual(await calls(managedCommandLog), [
|
|
130
|
+
assert.deepEqual(await calls(managedCommandLog), [], "a managed /goal command must not trigger Goal network work during a default Loop update")
|
|
131
131
|
|
|
132
132
|
const commentedConfig = path.join(temporaryRoot, "comment-only")
|
|
133
133
|
const commentedLog = path.join(temporaryRoot, "comment-only.log")
|
|
@@ -149,21 +149,27 @@ try {
|
|
|
149
149
|
assert.deepEqual(await calls(explicitCleanLog), [expectedGoalExec], "the clean command surface option must compose with --with-goals")
|
|
150
150
|
assert.deepEqual(await loopGoalCommands(explicitCleanConfig), [], "combined install must keep the stronger Goals plugin without legacy Loop Goal command clutter")
|
|
151
151
|
|
|
152
|
+
const legacyGoalConfig = path.join(temporaryRoot, "legacy-loop-goals")
|
|
153
|
+
const legacyGoalLog = path.join(temporaryRoot, "legacy-loop-goals.log")
|
|
154
|
+
const legacyGoal = await runInstaller(legacyGoalConfig, ["--with-loop-goals"], { FAKE_NPM_LOG: legacyGoalLog })
|
|
155
|
+
assert.equal(legacyGoal.code, 0, legacyGoal.stderr)
|
|
156
|
+
assert.deepEqual(await calls(legacyGoalLog), [], "--with-loop-goals must not install the dedicated Goal package")
|
|
157
|
+
assert.ok((await loopGoalCommands(legacyGoalConfig)).length > 0, "--with-loop-goals must explicitly restore the legacy experimental command surface")
|
|
158
|
+
|
|
152
159
|
const optOutConfig = path.join(temporaryRoot, "opt-out")
|
|
153
160
|
const optOutLog = path.join(temporaryRoot, "opt-out.log")
|
|
154
161
|
await writeConfig(optOutConfig, ["@bybrawe/opencode-goal@1.3.16"])
|
|
155
162
|
const optOut = await runInstaller(optOutConfig, ["--loop-only"], { FAKE_NPM_LOG: optOutLog })
|
|
156
163
|
assert.equal(optOut.code, 0, optOut.stderr)
|
|
157
164
|
assert.deepEqual(await calls(optOutLog), [], "--loop-only must suppress companion network work")
|
|
158
|
-
assert.match(optOut.stdout, /
|
|
165
|
+
assert.match(optOut.stdout, /Installed\/updated OpenCode Loop only \(--loop-only\)/)
|
|
159
166
|
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
await writeConfig(
|
|
163
|
-
const
|
|
164
|
-
assert.equal(
|
|
165
|
-
assert.
|
|
166
|
-
assert.match(autoFailure.stderr, /npx -y @bybrawe\/opencode-goal@latest/)
|
|
167
|
+
const untouchedConfig = path.join(temporaryRoot, "untouched-existing-goal")
|
|
168
|
+
const untouchedLog = path.join(temporaryRoot, "untouched-existing-goal.log")
|
|
169
|
+
await writeConfig(untouchedConfig, ["@bybrawe/opencode-goal@1.3.16"])
|
|
170
|
+
const untouched = await runInstaller(untouchedConfig, [], { FAKE_NPM_LOG: untouchedLog, FAKE_NPM_EXIT: "7" })
|
|
171
|
+
assert.equal(untouched.code, 0, untouched.stderr)
|
|
172
|
+
assert.deepEqual(await calls(untouchedLog), [], "default Loop update must perform zero Goal network work even when Goal is already installed")
|
|
167
173
|
|
|
168
174
|
const explicitFailureConfig = path.join(temporaryRoot, "explicit-failure")
|
|
169
175
|
const explicitFailureLog = path.join(temporaryRoot, "explicit-failure.log")
|
|
@@ -181,8 +187,10 @@ try {
|
|
|
181
187
|
assert.match(help.stdout, /OpenCode Loop installer\/updater/)
|
|
182
188
|
assert.match(help.stdout, /--with-goals/)
|
|
183
189
|
assert.match(help.stdout, /--loop-only/)
|
|
190
|
+
assert.match(help.stdout, /--with-loop-goals/)
|
|
184
191
|
assert.match(help.stdout, /--without-loop-goals/)
|
|
185
|
-
assert.match(help.stdout, /
|
|
192
|
+
assert.match(help.stdout, /Default install\/update touches only OpenCode Loop/)
|
|
193
|
+
assert.match(help.stdout, /Loop uninstall never removes Goal/)
|
|
186
194
|
assert.deepEqual(await calls(helpLog), [], "help/version flows must never install companion packages")
|
|
187
195
|
|
|
188
196
|
console.log("OpenCode Loop Goals companion installer test passed")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import { waitForSessionBootstrap } from "./host-goal-bootstrap.mjs"
|
|
4
|
+
|
|
5
|
+
const baseURL = "http://127.0.0.1:4011"
|
|
6
|
+
const workspace = "C:\\projects\\space in path"
|
|
7
|
+
|
|
8
|
+
test("bounded GET /session bootstrap retries transient host readiness without sending work", async () => {
|
|
9
|
+
const calls = []
|
|
10
|
+
let attempts = 0
|
|
11
|
+
const fetchImpl = async (input, init) => {
|
|
12
|
+
calls.push({ url: new URL(input.toString()), method: init.method, directory: init.headers["x-opencode-directory"] })
|
|
13
|
+
if (new URL(input.toString()).pathname === "/command") return { status: 200 }
|
|
14
|
+
attempts++
|
|
15
|
+
if (attempts < 4) throw Object.assign(new Error("timed out"), { name: "TimeoutError" })
|
|
16
|
+
return { ok: true, json: async () => ({ data: [{ id: "ses_test" }] }) }
|
|
17
|
+
}
|
|
18
|
+
const result = await waitForSessionBootstrap({
|
|
19
|
+
baseURL, workspace, fetchImpl, timeoutMs: 250, perRequestMs: 10, intervalMs: 1,
|
|
20
|
+
})
|
|
21
|
+
assert.deepEqual(result.data, [{ id: "ses_test" }])
|
|
22
|
+
assert.equal(attempts, 4)
|
|
23
|
+
assert.ok(calls.some((c) => c.url.pathname === "/command"))
|
|
24
|
+
assert.ok(calls.every((c) => c.method === "GET" && c.directory === workspace))
|
|
25
|
+
assert.ok(calls.filter((c) => c.url.pathname === "/session").every((c) => c.url.searchParams.get("directory") === workspace))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("bootstrap fails immediately on authorization, malformed response or unsafe service", async () => {
|
|
29
|
+
const unauthorized = async () => ({ ok: false, status: 401 })
|
|
30
|
+
await assert.rejects(waitForSessionBootstrap({ baseURL, workspace, fetchImpl: unauthorized }), /non-retryable HTTP 401/)
|
|
31
|
+
const malformed = async () => ({ ok: true, json: async () => ({ status: "unknown" }) })
|
|
32
|
+
await assert.rejects(waitForSessionBootstrap({ baseURL, workspace, fetchImpl: malformed }), /unexpected payload/)
|
|
33
|
+
await assert.rejects(
|
|
34
|
+
waitForSessionBootstrap({ baseURL: "https://example.com", workspace, fetchImpl: () => { throw new Error("must not connect") } }),
|
|
35
|
+
/restricted to a local service/,
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("persistent startup stall is bounded, preserves diagnostics, and never claims ownership collision", async () => {
|
|
40
|
+
let calls = 0
|
|
41
|
+
const stalled = async () => { calls++; throw Object.assign(new Error("timeout"), { name: "TimeoutError" }) }
|
|
42
|
+
await assert.rejects(waitForSessionBootstrap({
|
|
43
|
+
baseURL, workspace, fetchImpl: stalled, timeoutMs: 45, perRequestMs: 5, intervalMs: 1,
|
|
44
|
+
}), /did not become ready after \d+ bounded attempts.*plugin initialization must be investigated/i)
|
|
45
|
+
assert.ok(calls >= 1 && calls < 80, "startup must have a bounded number of probes")
|
|
46
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// A TCP listener is not proof that OpenCode's session router and project
|
|
2
|
+
// plugin graph have finished initialization. This is intentionally read-only:
|
|
3
|
+
// never send a prompt/command or retry a mutating session request.
|
|
4
|
+
export async function waitForSessionBootstrap({
|
|
5
|
+
baseURL, workspace, fetchImpl = fetch,
|
|
6
|
+
timeoutMs = 90_000, perRequestMs = 6_000, intervalMs = 300,
|
|
7
|
+
} = {}) {
|
|
8
|
+
if (!URL.canParse(baseURL) || !workspace) throw new Error("Missing OpenCode bootstrap baseURL/workspace")
|
|
9
|
+
const base = new URL(baseURL)
|
|
10
|
+
if (!["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
|
|
11
|
+
throw new Error("OpenCode bootstrap is restricted to a local service")
|
|
12
|
+
if (base.username || base.password) throw new Error("Credentials must not be embedded in the bootstrap URL")
|
|
13
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1) throw new Error("Invalid bootstrap timeout")
|
|
14
|
+
const url = new URL("/session", base)
|
|
15
|
+
url.searchParams.set("directory", workspace)
|
|
16
|
+
const deadline = Date.now() + timeoutMs
|
|
17
|
+
let attempts = 0
|
|
18
|
+
let last = "no response"
|
|
19
|
+
let commandProbe = "not checked"
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
attempts++
|
|
22
|
+
const remaining = deadline - Date.now()
|
|
23
|
+
const signal = AbortSignal.timeout(Math.max(1, Math.min(perRequestMs, remaining)))
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetchImpl(url, {
|
|
26
|
+
method: "GET",
|
|
27
|
+
headers: { "x-opencode-directory": workspace, accept: "application/json" },
|
|
28
|
+
signal,
|
|
29
|
+
redirect: "error",
|
|
30
|
+
})
|
|
31
|
+
if (res.ok) {
|
|
32
|
+
const body = await res.json()
|
|
33
|
+
if (!Array.isArray(body?.data ?? body)) {
|
|
34
|
+
throw new Error("OpenCode GET /session returned an unexpected payload (not a session array)")
|
|
35
|
+
}
|
|
36
|
+
return body
|
|
37
|
+
}
|
|
38
|
+
if (![502, 503, 504].includes(res.status)) {
|
|
39
|
+
throw new Error("OpenCode GET /session returned non-retryable HTTP " + res.status)
|
|
40
|
+
}
|
|
41
|
+
last = "HTTP " + res.status
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!["TimeoutError", "AbortError", "TypeError"].includes(error?.name)) throw error
|
|
44
|
+
last = error?.name === "TimeoutError" ? "request timed out" : error?.name === "AbortError" ? "request aborted" : "connection not ready"
|
|
45
|
+
}
|
|
46
|
+
// A side probe distinguishes a live command catalog from a stalled global
|
|
47
|
+
// bootstrap. It is diagnostic only: we never waive the session readiness
|
|
48
|
+
// requirement, and do not emit any endpoint response or credentials.
|
|
49
|
+
if (attempts % 3 === 0 && Date.now() < deadline) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetchImpl(new URL("/command", base), {
|
|
52
|
+
method: "GET",
|
|
53
|
+
headers: { "x-opencode-directory": workspace, accept: "application/json" },
|
|
54
|
+
signal: AbortSignal.timeout(Math.min(2_000, deadline - Date.now())),
|
|
55
|
+
redirect: "error",
|
|
56
|
+
})
|
|
57
|
+
commandProbe = "HTTP " + res.status
|
|
58
|
+
} catch (error) {
|
|
59
|
+
commandProbe = error?.name === "TimeoutError" ? "timed out" : "unavailable"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (Date.now() >= deadline) break
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, deadline - Date.now())))
|
|
64
|
+
}
|
|
65
|
+
throw new Error(
|
|
66
|
+
"OpenCode GET /session did not become ready after " + attempts
|
|
67
|
+
+ " bounded attempts in " + timeoutMs + "ms"
|
|
68
|
+
+ " (last=" + last + ", GET /command=" + commandProbe + ")."
|
|
69
|
+
+ " Server routing/plugin initialization must be investigated before claiming a Goal/Loop collision.",
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -7,11 +7,12 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis
|
|
|
7
7
|
import os from "node:os"
|
|
8
8
|
import path from "node:path"
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
10
|
+
import { waitForSessionBootstrap } from "./host-goal-bootstrap.mjs"
|
|
10
11
|
|
|
11
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
13
|
const isWindows = process.platform === "win32"
|
|
13
14
|
const SERVER_READY_TIMEOUT_MS = isWindows ? 60_000 : 30_000
|
|
14
|
-
const API_BOOTSTRAP_TIMEOUT_MS = isWindows ?
|
|
15
|
+
const API_BOOTSTRAP_TIMEOUT_MS = isWindows ? 180_000 : 60_000
|
|
15
16
|
const PREWARM_TIMEOUT_MS = isWindows ? 120_000 : 60_000
|
|
16
17
|
const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
|
|
17
18
|
const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
|
|
@@ -341,7 +342,15 @@ async function main() {
|
|
|
341
342
|
}
|
|
342
343
|
}
|
|
343
344
|
|
|
344
|
-
|
|
345
|
+
// A Windows cold host may accept TCP before its session/project/plugin
|
|
346
|
+
// services are ready. Probe readiness in bounded requests rather than
|
|
347
|
+
// hanging for 90 seconds on one socket and attributing it to Goal/Loop.
|
|
348
|
+
const sessionsPayload = await waitForSessionBootstrap({
|
|
349
|
+
baseURL,
|
|
350
|
+
workspace,
|
|
351
|
+
timeoutMs: API_BOOTSTRAP_TIMEOUT_MS,
|
|
352
|
+
fetchImpl: fetch,
|
|
353
|
+
})
|
|
345
354
|
assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
|
|
346
355
|
const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "Loop + Goal ownership canary" }) })
|
|
347
356
|
const session = createdPayload?.data ?? createdPayload
|
|
@@ -9,118 +9,19 @@ import { fileURLToPath } from "node:url"
|
|
|
9
9
|
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
10
10
|
const loopInstaller = join(root, "scripts", "install-node.mjs")
|
|
11
11
|
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
12
|
-
const
|
|
13
|
-
const goalPackageName = "@bybrawe/opencode-goal"
|
|
14
|
-
const goalPackageSpec = `${goalPackageName}@latest`
|
|
15
|
-
const managedGoalCommandMarker = "<!-- managed-by:@bybrawe/opencode-goal -->"
|
|
12
|
+
const goalPackageSpec = "@bybrawe/opencode-goal@latest"
|
|
16
13
|
const rawArgs = process.argv.slice(2)
|
|
17
14
|
const withGoals = rawArgs.includes("--with-goals")
|
|
18
15
|
const loopOnly = rawArgs.includes("--loop-only")
|
|
16
|
+
const withLoopGoals = rawArgs.includes("--with-loop-goals")
|
|
19
17
|
const withoutLoopGoals = rawArgs.includes("--without-loop-goals")
|
|
20
|
-
const loopArgs = rawArgs.filter((arg) => !["--with-goals", "--loop-only", "--without-loop-goals"].includes(arg))
|
|
18
|
+
const loopArgs = rawArgs.filter((arg) => !["--with-goals", "--loop-only", "--with-loop-goals", "--without-loop-goals"].includes(arg))
|
|
21
19
|
const commandArgs = loopArgs.filter((arg) => arg !== "--legacy-v1")
|
|
22
20
|
const uninstallRequested = commandArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(commandArgs[0] || "")
|
|
23
21
|
const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg))
|
|
24
22
|
const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg))
|
|
25
23
|
const informational = helpRequested || versionRequested
|
|
26
24
|
|
|
27
|
-
function stripJsonComments(input) {
|
|
28
|
-
let output = ""
|
|
29
|
-
let quote = ""
|
|
30
|
-
let escaped = false
|
|
31
|
-
let lineComment = false
|
|
32
|
-
let blockComment = false
|
|
33
|
-
for (let index = 0; index < input.length; index++) {
|
|
34
|
-
const char = input[index]
|
|
35
|
-
const next = input[index + 1]
|
|
36
|
-
if (lineComment) {
|
|
37
|
-
if (char === "\n" || char === "\r") { lineComment = false; output += char }
|
|
38
|
-
continue
|
|
39
|
-
}
|
|
40
|
-
if (blockComment) {
|
|
41
|
-
if (char === "*" && next === "/") { blockComment = false; index++ }
|
|
42
|
-
else if (char === "\n" || char === "\r") output += char
|
|
43
|
-
continue
|
|
44
|
-
}
|
|
45
|
-
if (quote) {
|
|
46
|
-
output += char
|
|
47
|
-
if (escaped) escaped = false
|
|
48
|
-
else if (char === "\\") escaped = true
|
|
49
|
-
else if (char === quote) quote = ""
|
|
50
|
-
continue
|
|
51
|
-
}
|
|
52
|
-
if (char === '"') { quote = char; output += char; continue }
|
|
53
|
-
if (char === "/" && next === "/") { lineComment = true; index++; continue }
|
|
54
|
-
if (char === "/" && next === "*") { blockComment = true; index++; continue }
|
|
55
|
-
output += char
|
|
56
|
-
}
|
|
57
|
-
return output
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function stripTrailingCommas(input) {
|
|
61
|
-
let output = ""
|
|
62
|
-
let quote = ""
|
|
63
|
-
let escaped = false
|
|
64
|
-
for (let index = 0; index < input.length; index++) {
|
|
65
|
-
const char = input[index]
|
|
66
|
-
if (quote) {
|
|
67
|
-
output += char
|
|
68
|
-
if (escaped) escaped = false
|
|
69
|
-
else if (char === "\\") escaped = true
|
|
70
|
-
else if (char === quote) quote = ""
|
|
71
|
-
continue
|
|
72
|
-
}
|
|
73
|
-
if (char === '"') { quote = char; output += char; continue }
|
|
74
|
-
if (char === ",") {
|
|
75
|
-
let lookahead = index + 1
|
|
76
|
-
while (/\s/.test(input[lookahead] || "")) lookahead++
|
|
77
|
-
if (input[lookahead] === "]" || input[lookahead] === "}") continue
|
|
78
|
-
}
|
|
79
|
-
output += char
|
|
80
|
-
}
|
|
81
|
-
return output
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function parseJsonc(input) {
|
|
85
|
-
const parsed = JSON.parse(stripTrailingCommas(stripJsonComments(input)))
|
|
86
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("OpenCode config root must be a JSON object")
|
|
87
|
-
return parsed
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function isGoalPluginSpec(value) {
|
|
91
|
-
if (Array.isArray(value)) value = value[0]
|
|
92
|
-
if (value && typeof value === "object") value = value.package
|
|
93
|
-
if (typeof value !== "string") return false
|
|
94
|
-
const spec = value.trim()
|
|
95
|
-
if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true
|
|
96
|
-
const normalized = spec.replaceAll("\\", "/")
|
|
97
|
-
return normalized === "./plugins/opencode-goal.ts"
|
|
98
|
-
|| normalized === "./plugins/opencode-goal.js"
|
|
99
|
-
|| normalized.endsWith("/plugins/opencode-goal.ts")
|
|
100
|
-
|| normalized.endsWith("/plugins/opencode-goal.js")
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function goalsAlreadyInstalled() {
|
|
104
|
-
for (const name of configCandidates) {
|
|
105
|
-
try {
|
|
106
|
-
const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
|
|
107
|
-
if ([parsed.plugin, parsed.plugins].some((entries) => Array.isArray(entries) && entries.some(isGoalPluginSpec))) return true
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if (error?.code !== "ENOENT") {
|
|
110
|
-
console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
const command = await readFile(join(config, "commands", "goal.md"), "utf8")
|
|
117
|
-
return command.includes(managedGoalCommandMarker)
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (error?.code !== "ENOENT") console.warn(`Could not inspect managed /goal command: ${error.message}`)
|
|
120
|
-
}
|
|
121
|
-
return false
|
|
122
|
-
}
|
|
123
|
-
|
|
124
25
|
async function removeLoopGoalCommands() {
|
|
125
26
|
const commandDir = join(config, "commands")
|
|
126
27
|
const names = (await readdir(join(root, "commands")))
|
|
@@ -178,15 +79,31 @@ function statusCode(result) {
|
|
|
178
79
|
}
|
|
179
80
|
|
|
180
81
|
function printCompanionHelp() {
|
|
181
|
-
console.log(`\nOpenCode
|
|
82
|
+
console.log(`\nOpenCode Goal options:\n --with-goals Explicitly install/update @bybrawe/opencode-goal@latest after Loop.\n --loop-only Explicit alias for the default: install/update Loop only and never touch Goal.\n --with-loop-goals Also install Loop's legacy experimental /loop-goal* command files (not recommended).\n --without-loop-goals Compatibility alias for the default: omit/remove legacy /loop-goal* commands.\n\nDefault install/update touches only OpenCode Loop. Recommended setup: install OpenCode Goal separately with: npx -y @bybrawe/opencode-goal@latest. Loop uninstall never removes Goal.`)
|
|
182
83
|
}
|
|
183
84
|
|
|
184
85
|
async function main() {
|
|
86
|
+
if (rawArgs[0] === "--desktop-diagnostics") {
|
|
87
|
+
// Diagnostic mode must not run either installer or modify any configuration.
|
|
88
|
+
const { runDesktopDiagnosticCli } = await import("./desktop-command-diagnostics.mjs")
|
|
89
|
+
process.exitCode = await runDesktopDiagnosticCli(rawArgs.slice(1))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (rawArgs.includes("--desktop-diagnostics")) {
|
|
93
|
+
console.error("--desktop-diagnostics must be the first argument.")
|
|
94
|
+
process.exitCode = 2
|
|
95
|
+
return
|
|
96
|
+
}
|
|
185
97
|
if (withGoals && loopOnly) {
|
|
186
98
|
console.error("Use either --with-goals or --loop-only, not both.")
|
|
187
99
|
process.exitCode = 2
|
|
188
100
|
return
|
|
189
101
|
}
|
|
102
|
+
if (withLoopGoals && withoutLoopGoals) {
|
|
103
|
+
console.error("Use either --with-loop-goals or --without-loop-goals, not both.")
|
|
104
|
+
process.exitCode = 2
|
|
105
|
+
return
|
|
106
|
+
}
|
|
190
107
|
|
|
191
108
|
const loopResult = runLoopInstaller()
|
|
192
109
|
if (loopResult.error) {
|
|
@@ -206,19 +123,18 @@ async function main() {
|
|
|
206
123
|
}
|
|
207
124
|
if (uninstallRequested) return
|
|
208
125
|
|
|
209
|
-
if (
|
|
126
|
+
if (!withLoopGoals) {
|
|
210
127
|
const removed = await removeLoopGoalCommands()
|
|
211
|
-
|
|
128
|
+
const reason = withoutLoopGoals ? "--without-loop-goals" : "default Loop-only command surface"
|
|
129
|
+
console.log(`Omitted ${removed} packaged experimental /loop-goal* command file(s) (${reason}).`)
|
|
212
130
|
}
|
|
213
131
|
|
|
214
|
-
if (
|
|
215
|
-
console.log("
|
|
132
|
+
if (!withGoals) {
|
|
133
|
+
if (loopOnly) console.log("Installed/updated OpenCode Loop only (--loop-only).")
|
|
134
|
+
else console.log("Installed/updated OpenCode Loop only. Recommended companion install (separate command): npx -y @bybrawe/opencode-goal@latest")
|
|
216
135
|
return
|
|
217
136
|
}
|
|
218
137
|
|
|
219
|
-
const alreadyInstalled = await goalsAlreadyInstalled()
|
|
220
|
-
if (!alreadyInstalled && !withGoals) return
|
|
221
|
-
|
|
222
138
|
const goalResult = runGoalInstaller()
|
|
223
139
|
const goalCode = statusCode(goalResult)
|
|
224
140
|
if (goalCode === 0) {
|
|
@@ -227,14 +143,8 @@ async function main() {
|
|
|
227
143
|
}
|
|
228
144
|
|
|
229
145
|
const detail = goalResult.error?.message || `exit code ${goalCode}`
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
process.exitCode = goalCode || 1
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
console.warn(`OpenCode Loop updated, but the existing OpenCode Goals companion could not be refreshed (${detail}).`)
|
|
237
|
-
console.warn(`Retry later with: npx -y ${goalPackageSpec}`)
|
|
146
|
+
console.error(`OpenCode Goals companion install/update failed (${detail}).`)
|
|
147
|
+
process.exitCode = goalCode || 1
|
|
238
148
|
}
|
|
239
149
|
|
|
240
150
|
main().catch((error) => {
|
|
@@ -5,8 +5,10 @@ import os from "node:os"
|
|
|
5
5
|
import path from "node:path"
|
|
6
6
|
import plugin from "../src/source/opencode2/native-plugin.js"
|
|
7
7
|
|
|
8
|
-
function fixture(directory, { failCleanup = false } = {}) {
|
|
8
|
+
function fixture(directory, { failCleanup = false, captureCommands = false } = {}) {
|
|
9
9
|
const released = []
|
|
10
|
+
const commands = new Map()
|
|
11
|
+
const prompts = []
|
|
10
12
|
let releaseRead, signal
|
|
11
13
|
let streamClosed = false
|
|
12
14
|
const end = () => releaseRead?.()
|
|
@@ -18,10 +20,17 @@ function fixture(directory, { failCleanup = false } = {}) {
|
|
|
18
20
|
location: { directory }, options: {}, app: { version: "2.0.18" },
|
|
19
21
|
session: {
|
|
20
22
|
get: async ({ sessionID }) => ({ id: sessionID, location: { directory } }),
|
|
21
|
-
prompt: async () => {
|
|
23
|
+
prompt: async (request) => {
|
|
24
|
+
if (!captureCommands) throw new Error("cleanup must not dispatch a prompt")
|
|
25
|
+
prompts.push(request)
|
|
26
|
+
return { id: "test-prompt" }
|
|
27
|
+
},
|
|
22
28
|
hook: async (name) => registration(name),
|
|
23
29
|
},
|
|
24
|
-
command: { transform: async () =>
|
|
30
|
+
command: { transform: async (register) => {
|
|
31
|
+
if (captureCommands) await register({ add: ({ name, execute }) => commands.set(name, execute) })
|
|
32
|
+
return registration("commands")
|
|
33
|
+
} },
|
|
25
34
|
event: { subscribe(options = {}) {
|
|
26
35
|
signal = options.signal
|
|
27
36
|
return (async function* () {
|
|
@@ -35,7 +44,7 @@ function fixture(directory, { failCleanup = false } = {}) {
|
|
|
35
44
|
})()
|
|
36
45
|
} },
|
|
37
46
|
}
|
|
38
|
-
return { ctx, end, released, signal: () => signal, streamClosed: () => streamClosed }
|
|
47
|
+
return { ctx, end, released, commands, prompts, signal: () => signal, streamClosed: () => streamClosed }
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
async function bounded(promise) {
|
|
@@ -100,19 +109,22 @@ test("native Loop cleanup is shared and drains all registrations after an error"
|
|
|
100
109
|
}
|
|
101
110
|
})
|
|
102
111
|
|
|
103
|
-
test("native Loop never probes undocumented SessionDomain shell
|
|
112
|
+
test("native Loop never probes undocumented SessionDomain shell or inbox fields", async () => {
|
|
104
113
|
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-public-session-"))
|
|
105
114
|
const host = fixture(directory)
|
|
106
115
|
let dispose
|
|
107
116
|
try {
|
|
108
|
-
for (const field of ["shell", "
|
|
117
|
+
for (const field of ["shell", "inbox"]) {
|
|
109
118
|
Object.defineProperty(host.ctx.session, field, {
|
|
110
119
|
configurable: true,
|
|
111
120
|
get() { throw new Error(`undocumented session.${field} was accessed`) },
|
|
112
121
|
})
|
|
113
122
|
}
|
|
123
|
+
const calls = []
|
|
124
|
+
host.ctx.session.compact = async (input) => { calls.push(input); return { id: input.id } }
|
|
114
125
|
dispose = await plugin.setup(host.ctx)
|
|
115
126
|
assert.equal(typeof dispose, "function")
|
|
127
|
+
assert.deepEqual(calls, [], "public compact is invoked only for scheduled work")
|
|
116
128
|
} finally {
|
|
117
129
|
host.end()
|
|
118
130
|
await dispose?.().catch(() => {})
|
|
@@ -123,3 +135,24 @@ test("native Loop never probes undocumented SessionDomain shell compact or inbox
|
|
|
123
135
|
test("native Loop uses the same public plugin identity as the package entry", () => {
|
|
124
136
|
assert.equal(plugin.id, "@bybrawe/opencode-loop", "native entry must share canonical plugin identity")
|
|
125
137
|
})
|
|
138
|
+
|
|
139
|
+
test("native /loop-help reflects public V2 compaction rather than obsolete unsupported guidance", async () => {
|
|
140
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-v2-help-compaction-"))
|
|
141
|
+
const host = fixture(directory, { captureCommands: true })
|
|
142
|
+
host.ctx.session.compact = async () => ({ id: "unused-compaction" })
|
|
143
|
+
let dispose
|
|
144
|
+
try {
|
|
145
|
+
dispose = await plugin.setup(host.ctx)
|
|
146
|
+
const help = host.commands.get("loop-help")
|
|
147
|
+
assert.equal(typeof help, "function")
|
|
148
|
+
await help({ sessionID: "ses-help" })
|
|
149
|
+
assert.equal(host.prompts.length, 1)
|
|
150
|
+
assert.match(host.prompts[0].text, /public session\.compact/)
|
|
151
|
+
assert.match(host.prompts[0].text, /inputID/)
|
|
152
|
+
assert.doesNotMatch(host.prompts[0].text, /does not expose manual session compaction/)
|
|
153
|
+
} finally {
|
|
154
|
+
host.end()
|
|
155
|
+
await dispose?.().catch(() => {})
|
|
156
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
157
|
+
}
|
|
158
|
+
})
|
|
@@ -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.7", "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.6")
|
|
15
|
+
assert.equal(request.goal?.version, "1.3.48")
|
|
16
16
|
assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
|
|
17
17
|
return request
|
|
18
18
|
}
|
|
@@ -215,13 +215,75 @@ await test("compaction cadence does not charge a logical run", async (f) => {
|
|
|
215
215
|
await f.complete()
|
|
216
216
|
assert.equal(f.compacts.length, 1)
|
|
217
217
|
assert.equal((await f.state()).jobs[0].runCount, 1)
|
|
218
|
-
await f.event("compaction", "started")
|
|
219
|
-
await f.event("compaction", "ended")
|
|
218
|
+
await f.event("compaction", "started", { reason: "auto" })
|
|
219
|
+
await f.event("compaction", "ended", { reason: "auto" })
|
|
220
|
+
await f.event("session", "idle")
|
|
221
|
+
assert.equal(f.prompts.length, 1, "an unrelated automatic compaction cannot settle a Loop request")
|
|
222
|
+
await f.event("compaction", "started", { reason: "manual", inputID: f.compacts[0].id })
|
|
223
|
+
await f.event("compaction", "ended", { reason: "manual" })
|
|
220
224
|
await f.event("session", "idle")
|
|
221
225
|
assert.equal(f.prompts.length, 2)
|
|
222
226
|
assert.equal((await f.state()).jobs[0].runCount, 2)
|
|
223
227
|
})
|
|
224
228
|
|
|
229
|
+
await test("manual compaction needs delivered inbox and completed host boundary", async (f) => {
|
|
230
|
+
await f.control("loop-compact", "0s --now --max-runs 1")
|
|
231
|
+
await f.wake()
|
|
232
|
+
assert.equal(f.compacts.length, 1)
|
|
233
|
+
assert.equal(f.compacts[0].delivery, "queue")
|
|
234
|
+
assert.ok(f.compacts[0].id)
|
|
235
|
+
await f.event("compaction", "started", { reason: "manual", inputID: "a-different-compaction" })
|
|
236
|
+
await f.event("compaction", "ended", { reason: "manual" })
|
|
237
|
+
await f.event("session", "idle")
|
|
238
|
+
assert.ok((await f.state()).jobs[0].v2Run, "a different compaction must not finish the scheduled request")
|
|
239
|
+
await f.event("compaction", "started", { reason: "manual", inputID: f.compacts[0].id })
|
|
240
|
+
await f.event("compaction", "ended", { reason: "manual" })
|
|
241
|
+
await f.event("session", "idle")
|
|
242
|
+
assert.equal((await f.state()).jobs[0].v2Run, undefined)
|
|
243
|
+
assert.equal((await f.state()).jobs[0].runCount, 1)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
await test("foreign host compaction failure cannot pause or consume a pending Loop compact", async (f) => {
|
|
247
|
+
await f.control("loop-compact", "0s --now --max-runs 1")
|
|
248
|
+
await f.wake()
|
|
249
|
+
const jobID = f.compacts[0].id
|
|
250
|
+
await f.event("compaction", "started", { reason: "manual", inputID: "another-user-input" })
|
|
251
|
+
await f.event("compaction", "failed", {
|
|
252
|
+
reason: "manual", inputID: "another-user-input",
|
|
253
|
+
error: "Not our compaction",
|
|
254
|
+
})
|
|
255
|
+
await f.event("session", "idle")
|
|
256
|
+
const waiting = (await f.state()).jobs[0]
|
|
257
|
+
assert.equal(waiting.paused, false, "foreign compaction failure must not pause our pending operation")
|
|
258
|
+
assert.ok(waiting.v2Run, "foreign event must not erase the pending compact request")
|
|
259
|
+
assert.equal(waiting.runCount, 1, "a confirmed host admission is charged once; foreign failure cannot change its accounting")
|
|
260
|
+
|
|
261
|
+
await f.event("compaction", "started", { reason: "manual", inputID: jobID })
|
|
262
|
+
await f.event("compaction", "failed", {
|
|
263
|
+
reason: "manual", inputID: jobID,
|
|
264
|
+
error: "Summary did not match the template\nwith details",
|
|
265
|
+
})
|
|
266
|
+
const failed = (await f.state()).jobs[0]
|
|
267
|
+
assert.equal(failed.paused, true)
|
|
268
|
+
assert.match(failed.pauseReason, /^compaction-failed: Summary did not match the template/)
|
|
269
|
+
assert.equal(failed.pauseReason.includes("\n"), false, "provider messages cannot inject log newlines")
|
|
270
|
+
assert.equal(failed.v2Run, undefined, "confirmed owned host failure must release pending work")
|
|
271
|
+
assert.equal(failed.runCount, 1, "a failed host compact preserves the one already-admitted logical run")
|
|
272
|
+
await f.event("session", "idle")
|
|
273
|
+
assert.equal((await f.state()).jobs[0].paused, true)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await test("unowned automatic host compaction failure leaves unrelated Loop jobs enabled", async (f) => {
|
|
277
|
+
await f.add("every 1h delayed task --max-runs 1")
|
|
278
|
+
await f.event("compaction", "started", { reason: "auto" })
|
|
279
|
+
await f.event("compaction", "failed", { reason: "auto", error: "host model unavailable" })
|
|
280
|
+
await f.event("session", "idle")
|
|
281
|
+
const job = (await f.state()).jobs[0]
|
|
282
|
+
assert.equal(job.paused, false)
|
|
283
|
+
assert.equal(job.runCount, 0)
|
|
284
|
+
assert.equal(job.v2Run, undefined)
|
|
285
|
+
})
|
|
286
|
+
|
|
225
287
|
await test("a failed delivered execution is paused and can be explicitly resumed", async (f) => {
|
|
226
288
|
await f.add("0s task --max-runs 3")
|
|
227
289
|
await f.wake()
|