@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.
@@ -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-only installations must not silently add Goals")
98
- assert.ok((await loopGoalCommands(absentConfig)).length > 0, "legacy Loop Goal commands stay installed by default for compatibility")
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), [expectedGoalExec], "an installed Goals package must auto-refresh through its own latest installer")
115
- assert.match(existing.stdout, /Installed\/updated OpenCode Goals via @bybrawe\/opencode-goal@latest/)
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), [expectedGoalExec], "legacy local Goals installs must be refreshed/migrated by the companion installer")
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), [expectedGoalExec], "a managed /goal command is sufficient evidence that Goals should be refreshed")
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, /Skipped OpenCode Goals companion update/)
165
+ assert.match(optOut.stdout, /Installed\/updated OpenCode Loop only \(--loop-only\)/)
159
166
 
160
- const autoFailureConfig = path.join(temporaryRoot, "auto-failure")
161
- const autoFailureLog = path.join(temporaryRoot, "auto-failure.log")
162
- await writeConfig(autoFailureConfig, ["@bybrawe/opencode-goal@1.3.16"])
163
- const autoFailure = await runInstaller(autoFailureConfig, [], { FAKE_NPM_LOG: autoFailureLog, FAKE_NPM_EXIT: "7" })
164
- assert.equal(autoFailure.code, 0, "a best-effort refresh failure must not break an otherwise successful Loop update")
165
- assert.match(autoFailure.stderr, /could not be refreshed/)
166
- assert.match(autoFailure.stderr, /npx -y @bybrawe\/opencode-goal@latest/)
167
+ 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, /Loop uninstall never removes Goals/)
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 ? 90_000 : 30_000
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
- const sessionsPayload = await api("/session", { method: "GET" })
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 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 -->"
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 Goals companion options:\n --with-goals Install/update @bybrawe/opencode-goal@latest even when it is not already installed.\n --loop-only Skip all OpenCode Goals companion detection and network update work.\n --without-loop-goals Omit/remove Loop's packaged experimental /loop-goal* command files for this install/update.\n\nNormal Loop install/update refreshes an already-managed OpenCode Goals installation on a best-effort basis. Loop uninstall never removes Goals.`)
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 (withoutLoopGoals) {
126
+ if (!withLoopGoals) {
210
127
  const removed = await removeLoopGoalCommands()
211
- console.log(`Omitted ${removed} packaged experimental /loop-goal* command file(s) (--without-loop-goals).`)
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 (loopOnly) {
215
- console.log("Skipped OpenCode Goals companion update (--loop-only).")
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
- if (withGoals) {
231
- console.error(`OpenCode Goals companion install/update failed (${detail}).`)
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 () => { throw new Error("cleanup must not dispatch a prompt") },
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 () => registration("commands") },
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 compact or inbox fields", async () => {
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", "compact", "inbox"]) {
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.5", "release version is not authorized")
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.4")
15
- assert.equal(request.goal?.version, "1.3.44")
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()