@bybrawe/opencode-loop 0.5.38 → 0.6.1
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 +33 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/examples/OPENCODE2-0.6.1.md +60 -0
- package/package.json +15 -9
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +12 -6
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +118 -60
- package/scripts/install-test.mjs +3 -3
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +114 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +72 -4
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -19
- package/scripts/pulse-check.mjs +246 -0
- package/scripts/session-status-test.mjs +12 -0
- package/src/index.js +87 -48
- package/src/native.js +3135 -0
- package/src/plugin.js +19 -0
- package/src/server.js +2164 -4560
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +9 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/commands.js +9 -4
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +20 -0
- package/src/source/opencode2/experimental.js +2 -0
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +2 -0
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/session-status.js +11 -1
- package/src/source/server.js +9 -1
- package/src/v1.js +1 -0
- package/src/v2.js +10 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { createJobWorkspaceRuntime } from "../src/source/runtime/job-workspace.js"
|
|
7
|
+
|
|
8
|
+
const marker = "NATIVE_COMPLETION_FROM_WORK_ONLY_8fd8"
|
|
9
|
+
const runtime = createJobWorkspaceRuntime({ toast: async () => {} })
|
|
10
|
+
async function fixture(file, expected) {
|
|
11
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "loop-control-until-"))
|
|
12
|
+
try {
|
|
13
|
+
const target = path.join(directory, file)
|
|
14
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
15
|
+
await writeFile(target, JSON.stringify({ objective: marker }))
|
|
16
|
+
assert.equal(await runtime.untilReached(directory, { until: marker }), expected)
|
|
17
|
+
} finally {
|
|
18
|
+
await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const file of [
|
|
23
|
+
".opencode/goals/live.json",
|
|
24
|
+
".opencode/goals/archive/history.json",
|
|
25
|
+
".opencode/goal-sequences/session.json",
|
|
26
|
+
".opencode/goal-locks/lease.json",
|
|
27
|
+
".opencode/goal-handoff-locks/lease.json",
|
|
28
|
+
".opencode/opencode-loop/session.json",
|
|
29
|
+
]) {
|
|
30
|
+
test("Goal/Loop control state cannot satisfy --until: " + file, () => fixture(file, false))
|
|
31
|
+
}
|
|
32
|
+
for (const file of [
|
|
33
|
+
"progress.md",
|
|
34
|
+
".opencode/opencode-loop/until.txt",
|
|
35
|
+
".opencode/commands/project-task.md",
|
|
36
|
+
".opencode/goals-project-notes/finished.md",
|
|
37
|
+
]) {
|
|
38
|
+
test("explicit or project-owned completion marker stays supported: " + file, () => fixture(file, true))
|
|
39
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { createGoalSteeringRuntime } from "../src/source/runtime/goal-steering.js"
|
|
3
|
+
|
|
4
|
+
const sessionID = "ses-compaction-safety"
|
|
5
|
+
const goal = { id: "goal-1", name: "goal", kind: "goal", enabled: true, paused: false, goalStatus: "active" }
|
|
6
|
+
function fixture(abortHook) {
|
|
7
|
+
let active = { jobId: goal.id, job: { ...goal } }
|
|
8
|
+
let aborts = 0
|
|
9
|
+
let clears = 0
|
|
10
|
+
let reads = 0
|
|
11
|
+
const client = { session: { abort: async () => { aborts++; await abortHook?.(() => { active = { jobId: "replacement", job: { ...goal } } }) } } }
|
|
12
|
+
const runtime = createGoalSteeringRuntime({
|
|
13
|
+
getActiveRun: () => active,
|
|
14
|
+
clearActiveRun: () => { clears++; active = undefined },
|
|
15
|
+
readState: async () => { reads++; return { jobs: [{ ...goal }] } },
|
|
16
|
+
appendLoopLog: async () => {},
|
|
17
|
+
fireSdk: async (_client, _label, method) => method(),
|
|
18
|
+
now: () => 1000,
|
|
19
|
+
})
|
|
20
|
+
return { runtime, client, counts: () => ({ aborts, clears, reads }), active: () => active }
|
|
21
|
+
}
|
|
22
|
+
let cases = 0
|
|
23
|
+
for (const type of ["message.created", "message.updated"]) {
|
|
24
|
+
for (const parts of [undefined, [], [{ type: "compaction", auto: true }], [{ type: "text", synthetic: true, text: "Continue" }], [{ type: "text", text: "Persisted user message" }]]) {
|
|
25
|
+
const f = fixture()
|
|
26
|
+
const event = { type, properties: { info: { id: "msg-core", role: "user", sessionID }, ...(parts ? { parts } : {}) } }
|
|
27
|
+
const result = await f.runtime.handleEvent("/workspace", f.client, event)
|
|
28
|
+
assert.equal(result.handled, false)
|
|
29
|
+
assert.deepEqual(f.counts(), { aborts: 0, clears: 0, reads: 0 })
|
|
30
|
+
assert.equal(f.runtime.shouldSuppressIdle(sessionID), false)
|
|
31
|
+
cases++
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
{
|
|
35
|
+
const f = fixture()
|
|
36
|
+
await f.runtime.handleEvent("/workspace", f.client, { type: "message.updated", properties: { info: { role: "user", sessionID, id: "msg-real" } } })
|
|
37
|
+
const result = await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-real" })
|
|
38
|
+
assert.equal(result.preempted, true, "untrusted event must not consume the later trusted admission")
|
|
39
|
+
assert.equal(f.counts().aborts, 1)
|
|
40
|
+
cases++
|
|
41
|
+
}
|
|
42
|
+
{
|
|
43
|
+
const f = fixture()
|
|
44
|
+
f.runtime.setCompacting(sessionID)
|
|
45
|
+
const result = await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-during-summary" })
|
|
46
|
+
assert.equal(result.preempted, false)
|
|
47
|
+
assert.equal(f.counts().aborts, 0)
|
|
48
|
+
assert.equal(f.runtime.shouldSuppressIdle(sessionID), false)
|
|
49
|
+
f.runtime.setCompacting(sessionID, false)
|
|
50
|
+
assert.equal((await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-after-summary" })).preempted, true)
|
|
51
|
+
cases++
|
|
52
|
+
}
|
|
53
|
+
{
|
|
54
|
+
const f = fixture((replace) => replace())
|
|
55
|
+
await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-race" })
|
|
56
|
+
assert.equal(f.counts().aborts, 1)
|
|
57
|
+
assert.equal(f.counts().clears, 0, "the old abort must not clear a replacement run")
|
|
58
|
+
assert.equal(f.active().jobId, "replacement")
|
|
59
|
+
cases++
|
|
60
|
+
}
|
|
61
|
+
{
|
|
62
|
+
const f = fixture()
|
|
63
|
+
f.runtime.setCompacting(sessionID)
|
|
64
|
+
f.runtime.clearSession(sessionID)
|
|
65
|
+
assert.equal((await f.runtime.handleUserMessage("/workspace", f.client, { sessionID, messageID: "msg-after-delete" })).preempted, true)
|
|
66
|
+
cases++
|
|
67
|
+
}
|
|
68
|
+
console.log(`Goal compaction safety: ${cases} scenarios passed`)
|
|
@@ -26,7 +26,7 @@ process.exit(Number(process.env.FAKE_NPM_EXIT || 0))
|
|
|
26
26
|
|
|
27
27
|
async function runInstaller(config, cliArgs = [], env = {}) {
|
|
28
28
|
return await new Promise((resolve, reject) => {
|
|
29
|
-
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
29
|
+
const child = spawn(process.execPath, [installer, "--legacy-v1", ...cliArgs], {
|
|
30
30
|
cwd: root,
|
|
31
31
|
env: {
|
|
32
32
|
...process.env,
|
|
@@ -57,6 +57,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
|
|
|
57
57
|
assert.deepEqual(ordered.map((item) => item.info.id), ["a", "b", "c"])
|
|
58
58
|
|
|
59
59
|
assert.equal(await activeRunCompletionFromMessages("/tmp", {}, "missing", { startedAt: 100 }), "unknown")
|
|
60
|
+
assert.equal(await activeRunCompletionFromMessages("/tmp", messagesClient([]), "empty-history", { startedAt: 100 }), "unknown")
|
|
60
61
|
assert.equal(await activeRunCompletionFromMessages(
|
|
61
62
|
"/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
|
|
62
63
|
), "incomplete")
|
package/scripts/install-node.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { isDeepStrictEqual } from "node:util"
|
|
2
3
|
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
4
|
import { homedir } from "node:os"
|
|
4
5
|
import { dirname, join } from "node:path"
|
|
@@ -7,6 +8,7 @@ import { fileURLToPath } from "node:url"
|
|
|
7
8
|
const root = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
8
9
|
const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
|
|
9
10
|
const pluginDir = join(config, "plugins")
|
|
11
|
+
const nativePluginDir = join(pluginDir, "opencode-loop")
|
|
10
12
|
const commandDir = join(config, "commands")
|
|
11
13
|
const agentDir = join(config, "agents")
|
|
12
14
|
const packagePath = join(config, "package.json")
|
|
@@ -14,11 +16,13 @@ const packageName = "@bybrawe/opencode-loop"
|
|
|
14
16
|
const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
|
|
15
17
|
const packageSpec = `${packageName}@${packageVersion}`
|
|
16
18
|
const configCandidates = ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]
|
|
17
|
-
const
|
|
19
|
+
const rawInstallerArgs = process.argv.slice(2)
|
|
20
|
+
const legacyV1 = rawInstallerArgs.includes("--legacy-v1")
|
|
21
|
+
const installerArgs = rawInstallerArgs.filter((arg) => arg !== "--legacy-v1")
|
|
18
22
|
const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
|
|
19
23
|
|
|
20
24
|
if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
|
|
21
|
-
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
|
|
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: no V1 SDK or legacy command files are needed for a local install. 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.`)
|
|
22
26
|
process.exit(0)
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -96,7 +100,8 @@ function parseJsonc(input) {
|
|
|
96
100
|
}
|
|
97
101
|
|
|
98
102
|
function isPackageSpec(value) {
|
|
99
|
-
|
|
103
|
+
if (Array.isArray(value)) value = value[0]
|
|
104
|
+
const spec = String(typeof value === "object" && value !== null ? value.package || "" : value || "").trim()
|
|
100
105
|
return spec === packageName || spec.startsWith(`${packageName}@`)
|
|
101
106
|
}
|
|
102
107
|
|
|
@@ -207,38 +212,86 @@ function formatPluginArray(values, indent, eol) {
|
|
|
207
212
|
return `[${eol}${values.map((value) => `${childIndent}${JSON.stringify(value)}`).join(`,${eol}`)}${eol}${indent}]`
|
|
208
213
|
}
|
|
209
214
|
|
|
210
|
-
function rewriteExistingPluginArray(source, nextPlugins) {
|
|
211
|
-
const property = findRootProperty(source,
|
|
215
|
+
function rewriteExistingPluginArray(source, nextPlugins, key = "plugin") {
|
|
216
|
+
const property = findRootProperty(source, key)
|
|
212
217
|
if (!property) return source
|
|
213
218
|
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
214
219
|
const replacement = formatPluginArray(nextPlugins, property.indent, eol)
|
|
215
220
|
return `${source.slice(0, property.valueStart)}${replacement}${source.slice(property.valueEnd)}`
|
|
216
221
|
}
|
|
217
222
|
|
|
223
|
+
function appendPluginProperty(source, values, key) {
|
|
224
|
+
const parsed = parseJsonc(source)
|
|
225
|
+
const keys = Object.keys(parsed)
|
|
226
|
+
if (keys.length) {
|
|
227
|
+
const last = findRootProperty(source, keys.at(-1))
|
|
228
|
+
const after = skipTrivia(source, last.valueEnd)
|
|
229
|
+
if (source[after] !== ",") source = source.slice(0, last.valueEnd) + "," + source.slice(last.valueEnd)
|
|
230
|
+
}
|
|
231
|
+
const end = skipJsonValue(source, 0) - 1
|
|
232
|
+
const eol = source.includes("\r\n") ? "\r\n" : "\n"
|
|
233
|
+
return source.slice(0, end) + `${eol} ${JSON.stringify(key)}: ${formatPluginArray(values, " ", eol)}${eol}` + source.slice(end)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function pinRegistration(entries) {
|
|
237
|
+
const objects = entries.map((entry) => {
|
|
238
|
+
if (!Array.isArray(entry)) return entry
|
|
239
|
+
if (entry.length !== 2 || !entry[1] || typeof entry[1] !== "object" || Array.isArray(entry[1])) {
|
|
240
|
+
throw new Error("Invalid Loop package/options tuple; no configuration was changed")
|
|
241
|
+
}
|
|
242
|
+
return { package: entry[0], options: entry[1] }
|
|
243
|
+
}).filter((entry) => typeof entry === "object" && entry !== null)
|
|
244
|
+
const optionsOf = (entry) => { const { package: _package, ...options } = entry; return options }
|
|
245
|
+
const configured = objects.filter((entry) => Object.keys(optionsOf(entry)).length > 0)
|
|
246
|
+
const preferred = configured[0]
|
|
247
|
+
if (!preferred) return packageSpec
|
|
248
|
+
if (configured.some((entry) => !isDeepStrictEqual(optionsOf(entry), optionsOf(preferred)))) {
|
|
249
|
+
throw new Error("Conflicting Loop object registrations; reconcile their options before updating")
|
|
250
|
+
}
|
|
251
|
+
const legacyTuple = entries.some((entry) => Array.isArray(entry) && isDeepStrictEqual(entry[1], preferred.options))
|
|
252
|
+
return legacyV1 && legacyTuple ? [packageSpec, preferred.options] : { ...preferred, package: packageSpec }
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
async function configurePackagePlugin() {
|
|
256
|
+
const plans = []
|
|
219
257
|
let configured = false
|
|
220
|
-
const updatedFiles = []
|
|
221
258
|
for (const name of configCandidates) {
|
|
222
259
|
const target = join(config, name)
|
|
223
260
|
try {
|
|
224
261
|
const source = await readFile(target, "utf8")
|
|
225
262
|
const parsed = parseJsonc(source)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
for (const key of ["plugin", "plugins"]) {
|
|
264
|
+
if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
|
|
265
|
+
}
|
|
266
|
+
let updated = source
|
|
267
|
+
const own = [...(parsed.plugins || []), ...(parsed.plugin || [])].filter(isPackageSpec)
|
|
268
|
+
if (own.length) {
|
|
269
|
+
configured = true
|
|
270
|
+
if (legacyV1) {
|
|
271
|
+
for (const key of ["plugin", "plugins"]) {
|
|
272
|
+
const entries = parsed[key] || []
|
|
273
|
+
const matching = entries.filter(isPackageSpec)
|
|
274
|
+
if (!matching.length) continue
|
|
275
|
+
const pinned = pinRegistration(matching)
|
|
276
|
+
updated = rewriteExistingPluginArray(updated, [...entries.filter((entry) => !isPackageSpec(entry)), pinned], key)
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
const pinned = pinRegistration(own)
|
|
280
|
+
if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
|
|
281
|
+
const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
|
|
282
|
+
updated = parsed.plugins === undefined ? appendPluginProperty(updated, next, "plugins") : rewriteExistingPluginArray(updated, next, "plugins")
|
|
283
|
+
}
|
|
236
284
|
}
|
|
285
|
+
parseJsonc(updated)
|
|
286
|
+
plans.push({ target, source, updated })
|
|
237
287
|
} catch (error) {
|
|
238
288
|
if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
|
|
239
289
|
}
|
|
240
290
|
}
|
|
241
|
-
|
|
291
|
+
// Validate every candidate before writing any config. Malformed later files
|
|
292
|
+
// must not leave the earlier file migrated and the plugin half-installed.
|
|
293
|
+
for (const plan of plans) if (plan.source !== plan.updated) await writeFile(plan.target, plan.updated, "utf8")
|
|
294
|
+
return { configured, updatedFiles: plans.filter((plan) => plan.source !== plan.updated).map((plan) => plan.target) }
|
|
242
295
|
}
|
|
243
296
|
|
|
244
297
|
async function ensureDependency() {
|
|
@@ -262,7 +315,13 @@ async function ensureDependency() {
|
|
|
262
315
|
|
|
263
316
|
async function removePackagedFiles(sourceDir, targetDir) {
|
|
264
317
|
for (const name of await readdir(sourceDir)) {
|
|
265
|
-
if (name.endsWith(".md"))
|
|
318
|
+
if (!name.endsWith(".md")) continue
|
|
319
|
+
try {
|
|
320
|
+
const installed = await readFile(join(targetDir, name), "utf8")
|
|
321
|
+
const packaged = await readFile(join(sourceDir, name), "utf8")
|
|
322
|
+
const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
|
|
323
|
+
if (installed === packaged || recognized) await rm(join(targetDir, name), { force: true })
|
|
324
|
+
} catch (error) { if (error?.code !== "ENOENT") throw error }
|
|
266
325
|
}
|
|
267
326
|
}
|
|
268
327
|
|
|
@@ -273,68 +332,67 @@ async function uninstall() {
|
|
|
273
332
|
try {
|
|
274
333
|
const source = await readFile(target, "utf8")
|
|
275
334
|
const parsed = parseJsonc(source)
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
335
|
+
let updated = source
|
|
336
|
+
for (const key of ["plugin", "plugins"]) {
|
|
337
|
+
if (parsed[key] !== undefined && !Array.isArray(parsed[key])) throw new Error(`OpenCode config '${key}' must be an array`)
|
|
338
|
+
const entries = parsed[key] || []
|
|
339
|
+
if (entries.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, entries.filter((entry) => !isPackageSpec(entry)), key)
|
|
340
|
+
}
|
|
280
341
|
plans.push({ target, source, updated })
|
|
281
|
-
} catch (error) {
|
|
282
|
-
if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
for (const plan of plans) {
|
|
287
|
-
if (plan.updated !== plan.source) await writeFile(plan.target, plan.updated, "utf8")
|
|
342
|
+
} catch (error) { if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`) }
|
|
288
343
|
}
|
|
344
|
+
for (const plan of plans) if (plan.updated !== plan.source) await writeFile(plan.target, plan.updated, "utf8")
|
|
289
345
|
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
290
346
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
347
|
+
await rm(nativePluginDir, { recursive: true, force: true })
|
|
291
348
|
await removePackagedFiles(join(root, "commands"), commandDir)
|
|
292
349
|
await removePackagedFiles(join(root, "agents"), agentDir)
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
console.log(
|
|
296
|
-
? `Removed ${packageName} package registrations from ${changedConfigs} OpenCode config file(s).`
|
|
297
|
-
: `${packageName} was not registered as an npm package in the inspected OpenCode config files.`)
|
|
298
|
-
console.log("Removed known local OpenCode Loop plugin, slash-command, and local-agent files when present.")
|
|
350
|
+
const changed = plans.filter((plan) => plan.source !== plan.updated).length
|
|
351
|
+
console.log(`Removed ${packageName} registrations from ${changed} OpenCode config file(s).`)
|
|
352
|
+
console.log("Removed known local Loop plugin and managed legacy command/agent files; custom files are preserved.")
|
|
299
353
|
console.log("Project state under .opencode/opencode-loop is preserved.")
|
|
300
354
|
console.log("Restart OpenCode to finish unloading OpenCode Loop.")
|
|
301
355
|
}
|
|
302
356
|
|
|
303
357
|
async function installOrUpdate() {
|
|
304
358
|
await mkdir(pluginDir, { recursive: true })
|
|
305
|
-
await mkdir(commandDir, { recursive: true })
|
|
306
|
-
await mkdir(agentDir, { recursive: true })
|
|
307
359
|
const packageConfig = await configurePackagePlugin()
|
|
308
|
-
|
|
309
|
-
if (useConfiguredPackage) {
|
|
360
|
+
if (packageConfig.configured) {
|
|
310
361
|
await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
|
|
311
362
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
312
|
-
|
|
363
|
+
await rm(nativePluginDir, { recursive: true, force: true })
|
|
364
|
+
} else if (legacyV1) {
|
|
313
365
|
await ensureDependency()
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
await copyFile(join(root, "src", "server.js"), join(pluginDir, "opencode-loop.ts"))
|
|
366
|
+
await rm(nativePluginDir, { recursive: true, force: true })
|
|
367
|
+
await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
|
|
317
368
|
await rm(join(pluginDir, "opencode-loop.js"), { force: true })
|
|
369
|
+
} else {
|
|
370
|
+
// V2 local plugins use the same Plugin.define entrypoint as the npm package.
|
|
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"))
|
|
318
378
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
for (const name of await readdir(join(root, "agents"))) {
|
|
325
|
-
if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (useConfiguredPackage) {
|
|
329
|
-
const pinResult = packageConfig.updatedFiles.length
|
|
330
|
-
? `pinned the config entry to ${packageSpec}`
|
|
331
|
-
: `the config entry is already pinned to ${packageSpec}`
|
|
332
|
-
console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
|
|
379
|
+
if (legacyV1) {
|
|
380
|
+
await mkdir(commandDir, { recursive: true })
|
|
381
|
+
await mkdir(agentDir, { recursive: true })
|
|
382
|
+
for (const name of await readdir(join(root, "commands"))) if (name.endsWith(".md")) await copyFile(join(root, "commands", name), join(commandDir, name))
|
|
383
|
+
for (const name of await readdir(join(root, "agents"))) if (name.endsWith(".md")) await copyFile(join(root, "agents", name), join(agentDir, name))
|
|
333
384
|
} else {
|
|
334
|
-
|
|
385
|
+
await removePackagedFiles(join(root, "commands"), commandDir)
|
|
386
|
+
await removePackagedFiles(join(root, "agents"), agentDir)
|
|
335
387
|
}
|
|
336
|
-
|
|
337
|
-
|
|
388
|
+
if (packageConfig.configured) {
|
|
389
|
+
const pinResult = packageConfig.updatedFiles.length ? `pinned the config entry to ${packageSpec}` : `the config entry is already pinned to ${packageSpec}`
|
|
390
|
+
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 ${legacyV1 ? join(pluginDir, "opencode-loop.ts") : nativePluginDir}`)
|
|
392
|
+
if (legacyV1) {
|
|
393
|
+
console.log(`Installed ${packageName} commands to ${commandDir}`)
|
|
394
|
+
console.log(`Installed ${packageName} local command agent to ${agentDir}`)
|
|
395
|
+
} else console.log("Native OpenCode 2 commands are registered by the plugin; no legacy command files or V1 SDK are needed for a local install.")
|
|
338
396
|
console.log("Restart OpenCode, then run: /loop-help")
|
|
339
397
|
}
|
|
340
398
|
|
package/scripts/install-test.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const packagedCommandCount = (await fs.readdir(path.join(root, "commands"))).fil
|
|
|
14
14
|
|
|
15
15
|
async function runInstaller(config, cliArgs = []) {
|
|
16
16
|
return await new Promise((resolve, reject) => {
|
|
17
|
-
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
17
|
+
const child = spawn(process.execPath, [installer, "--legacy-v1", ...cliArgs], {
|
|
18
18
|
cwd: root,
|
|
19
19
|
env: { ...process.env, OPENCODE_CONFIG_DIR: config },
|
|
20
20
|
windowsHide: true,
|
|
@@ -64,8 +64,8 @@ try {
|
|
|
64
64
|
assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
|
|
65
65
|
assert.equal(
|
|
66
66
|
await fs.readFile(path.join(local, "plugins", "opencode-loop.ts"), "utf8"),
|
|
67
|
-
await fs.readFile(path.join(root, "src", "
|
|
68
|
-
"
|
|
67
|
+
await fs.readFile(path.join(root, "src", "index.js"), "utf8"),
|
|
68
|
+
"explicit legacy install must copy the standalone V1 bundle, not the lazy package facade",
|
|
69
69
|
)
|
|
70
70
|
assert.equal(await commandCount(local), packagedCommandCount)
|
|
71
71
|
assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
|
|
@@ -18,7 +18,8 @@ const withGoals = rawArgs.includes("--with-goals")
|
|
|
18
18
|
const loopOnly = rawArgs.includes("--loop-only")
|
|
19
19
|
const withoutLoopGoals = rawArgs.includes("--without-loop-goals")
|
|
20
20
|
const loopArgs = rawArgs.filter((arg) => !["--with-goals", "--loop-only", "--without-loop-goals"].includes(arg))
|
|
21
|
-
const
|
|
21
|
+
const commandArgs = loopArgs.filter((arg) => arg !== "--legacy-v1")
|
|
22
|
+
const uninstallRequested = commandArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(commandArgs[0] || "")
|
|
22
23
|
const helpRequested = loopArgs.some((arg) => ["--help", "-h"].includes(arg))
|
|
23
24
|
const versionRequested = loopArgs.some((arg) => ["--version", "-v"].includes(arg))
|
|
24
25
|
const informational = helpRequested || versionRequested
|
|
@@ -87,6 +88,8 @@ function parseJsonc(input) {
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function isGoalPluginSpec(value) {
|
|
91
|
+
if (Array.isArray(value)) value = value[0]
|
|
92
|
+
if (value && typeof value === "object") value = value.package
|
|
90
93
|
if (typeof value !== "string") return false
|
|
91
94
|
const spec = value.trim()
|
|
92
95
|
if (spec === goalPackageName || spec.startsWith(`${goalPackageName}@`)) return true
|
|
@@ -101,7 +104,7 @@ async function goalsAlreadyInstalled() {
|
|
|
101
104
|
for (const name of configCandidates) {
|
|
102
105
|
try {
|
|
103
106
|
const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
|
|
104
|
-
if (Array.isArray(
|
|
107
|
+
if ([parsed.plugin, parsed.plugins].some((entries) => Array.isArray(entries) && entries.some(isGoalPluginSpec))) return true
|
|
105
108
|
} catch (error) {
|
|
106
109
|
if (error?.code !== "ENOENT") {
|
|
107
110
|
console.warn(`Could not inspect ${join(config, name)} for OpenCode Goals: ${error.message}`)
|
|
@@ -125,6 +128,10 @@ async function removeLoopGoalCommands() {
|
|
|
125
128
|
let removed = 0
|
|
126
129
|
for (const name of names) {
|
|
127
130
|
try {
|
|
131
|
+
const installed = await readFile(join(commandDir, name), "utf8")
|
|
132
|
+
const packaged = await readFile(join(root, "commands", name), "utf8")
|
|
133
|
+
const recognized = /OpenCode Loop[^\n]{0,120}handled (?:locally|exactly)/.test(installed) || /OpenCode Loop local command handled/.test(installed)
|
|
134
|
+
if (installed !== packaged && !recognized) continue
|
|
128
135
|
await rm(join(commandDir, name))
|
|
129
136
|
removed++
|
|
130
137
|
} catch (error) {
|
|
@@ -152,7 +159,13 @@ function runGoalInstaller() {
|
|
|
152
159
|
|
|
153
160
|
return spawnSync(command, args, {
|
|
154
161
|
cwd: root,
|
|
155
|
-
|
|
162
|
+
// This is a target-dialect hint, not a claim about an installed binary.
|
|
163
|
+
// Older Goal installers already understand this variable, unlike new CLI flags.
|
|
164
|
+
env: {
|
|
165
|
+
...process.env,
|
|
166
|
+
OPENCODE_CONFIG_DIR: config,
|
|
167
|
+
OPENCODE_GOAL_HOST_VERSION: rawArgs.includes("--legacy-v1") ? "1.0.0" : "2.0.0",
|
|
168
|
+
},
|
|
156
169
|
stdio: "inherit",
|
|
157
170
|
windowsHide: true,
|
|
158
171
|
timeout: 180_000,
|
|
@@ -227,4 +240,4 @@ async function main() {
|
|
|
227
240
|
main().catch((error) => {
|
|
228
241
|
console.error(error?.stack || error)
|
|
229
242
|
process.exitCode = 1
|
|
230
|
-
})
|
|
243
|
+
})
|
|
@@ -72,7 +72,8 @@ try {
|
|
|
72
72
|
processResults.push({ code: 1, stdout: "", stderr: "not a repo" })
|
|
73
73
|
const nonRepo = { id: "nonrepo", branch: "feature/test" }
|
|
74
74
|
await runtime.ensureBranch(directory, nonRepo, {}, "session")
|
|
75
|
-
assert.equal(nonRepo.branchDone,
|
|
75
|
+
assert.equal(nonRepo.branchDone, false, "a non-repository cannot claim a successful branch switch")
|
|
76
|
+
assert.equal(nonRepo.branchUnavailable, true)
|
|
76
77
|
assert.equal(processCalls.at(-1)[0], "git")
|
|
77
78
|
assert.deepEqual(processCalls.at(-1)[1], ["rev-parse", "--is-inside-work-tree"])
|
|
78
79
|
assert.equal(toasts.length, 0)
|
|
@@ -95,6 +96,15 @@ try {
|
|
|
95
96
|
assert.deepEqual(toasts.at(-1), [client, "Loop branch active: feature-test", "success"])
|
|
96
97
|
assert.deepEqual(logs.at(-1), [directory, "branch", { sessionID: "session", branch: "feature-test", code: 0 }])
|
|
97
98
|
|
|
99
|
+
processResults.push(
|
|
100
|
+
{ code: 0, stdout: "true", stderr: "" },
|
|
101
|
+
{ code: 1, stdout: "", stderr: "switch failed" },
|
|
102
|
+
{ code: 1, stdout: "", stderr: "create failed" },
|
|
103
|
+
)
|
|
104
|
+
const failedBranch = { branch: "cannot-switch" }
|
|
105
|
+
await runtime.ensureBranch(directory, failedBranch, client, "session")
|
|
106
|
+
assert.equal(failedBranch.branchDone, false, "failed switch/create must not admit work on the wrong branch")
|
|
107
|
+
|
|
98
108
|
const watched = path.join(directory, "watched.txt")
|
|
99
109
|
await fs.writeFile(watched, "one", "utf8")
|
|
100
110
|
const initialSnapshot = await runtime.snapshotPaths(directory, ["watched.txt", "missing.txt"])
|
package/scripts/loopd-test.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loopd-te
|
|
|
11
11
|
|
|
12
12
|
function runCli(cliArgs, env = {}) {
|
|
13
13
|
return new Promise((resolve, reject) => {
|
|
14
|
-
const child = spawn(process.execPath, [loopd, ...cliArgs], {
|
|
14
|
+
const child = spawn(process.execPath, [loopd, ...cliArgs, "--legacy-v1"], {
|
|
15
15
|
cwd: root,
|
|
16
16
|
env: { ...process.env, ...env },
|
|
17
17
|
windowsHide: true,
|
|
@@ -32,7 +32,7 @@ function runCli(cliArgs, env = {}) {
|
|
|
32
32
|
async function makeFakeCommand(name) {
|
|
33
33
|
const script = path.join(temporaryRoot, `${name}.mjs`)
|
|
34
34
|
const log = path.join(temporaryRoot, `${name}.jsonl`)
|
|
35
|
-
await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
|
|
35
|
+
await fs.writeFile(script, "\nimport fs from \"node:fs\"\nconst log = process.env.FAKE_COMMAND_LOG\nconst argv = process.argv.slice(2)\nif (argv[0] === \"--version\") { console.log(\"1.18.15\"); process.exit(0) }\nlet previous = []\ntry { previous = fs.readFileSync(log, \"utf8\").trim().split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)) } catch {}\nfs.appendFileSync(log, JSON.stringify({ args: argv, cwd: process.cwd() }) + \"\\n\")\nif (argv[0] === \"session\" && argv[1] === \"list\") {\n console.log(process.env.FAKE_SESSION_LIST_JSON || \"[]\")\n process.exit(0)\n}\nconst sleepMs = Number(process.env.FAKE_SLEEP_MS || 0)\nif (sleepMs > 0) await new Promise((resolve) => setTimeout(resolve, sleepMs))\nconst previousRuns = previous.filter((entry) => entry?.args?.[0] === \"run\").length\nconst codes = String(process.env.FAKE_EXIT_CODES || \"0\").split(\",\").map((value) => Number(value.trim()))\nconst code = codes[Math.min(previousRuns, codes.length - 1)]\nprocess.exit(Number.isInteger(code) ? code : 0)\n", "utf8")
|
|
36
36
|
|
|
37
37
|
if (process.platform === "win32") {
|
|
38
38
|
const command = path.join(temporaryRoot, `${name}.cmd`)
|
|
@@ -81,7 +81,7 @@ try {
|
|
|
81
81
|
"run", "--title", runCalls[0].args[2],
|
|
82
82
|
"--model", "opencode/nemotron-3-ultra-free",
|
|
83
83
|
"--agent", "build",
|
|
84
|
-
inlinePrompt,
|
|
84
|
+
"--", inlinePrompt,
|
|
85
85
|
], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
|
|
86
86
|
assert.match(runCalls[0].args[2], /^OpenCode Loop daemon /)
|
|
87
87
|
|
|
@@ -146,6 +146,73 @@ try {
|
|
|
146
146
|
})
|
|
147
147
|
assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code")
|
|
148
148
|
|
|
149
|
+
const goalsDir = path.join(project, ".opencode", "goals")
|
|
150
|
+
await fs.mkdir(goalsDir, { recursive: true })
|
|
151
|
+
const goalFile = path.join(goalsDir, "goal-stale.json")
|
|
152
|
+
const staleGoal = {
|
|
153
|
+
schemaVersion: 1,
|
|
154
|
+
id: "goal-stale",
|
|
155
|
+
sessionID: "ses_goal",
|
|
156
|
+
status: "active",
|
|
157
|
+
progressRevision: 4,
|
|
158
|
+
revision: 1,
|
|
159
|
+
storageGeneration: 1,
|
|
160
|
+
updatedAt: Date.now() - 60 * 60_000,
|
|
161
|
+
}
|
|
162
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
163
|
+
const originalGoal = await fs.readFile(goalFile, "utf8")
|
|
164
|
+
const pulseEnv = {
|
|
165
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
166
|
+
FAKE_COMMAND_LOG: path.join(temporaryRoot, "pulse-opencode.jsonl"),
|
|
167
|
+
FAKE_SESSION_LIST_JSON: JSON.stringify([{ id: "ses_goal", title: "Goal", updated: Date.now() - 1_000 }]),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
171
|
+
assert.equal(result.code, 0, result.stderr)
|
|
172
|
+
assert.match(result.stdout, /GOAL PULSE STALLED/)
|
|
173
|
+
assert.match(result.stdout, /goal=goal-stale/)
|
|
174
|
+
assert.match(result.stdout, /session=ses_goal/)
|
|
175
|
+
assert.match(result.stdout, /sessionUpdated=/)
|
|
176
|
+
assert.match(result.stdout, /action="\/goal resume"/)
|
|
177
|
+
assert.equal(await fs.readFile(goalFile, "utf8"), originalGoal, "pulse-check must never write dedicated Goal state")
|
|
178
|
+
|
|
179
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
180
|
+
assert.equal(result.code, 0, result.stderr)
|
|
181
|
+
assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "one stall episode must alert only once")
|
|
182
|
+
|
|
183
|
+
staleGoal.status = "waiting_user"
|
|
184
|
+
staleGoal.storageGeneration += 1
|
|
185
|
+
staleGoal.updatedAt = Date.now() - 30 * 60_000
|
|
186
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
187
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
188
|
+
assert.equal(result.code, 0, result.stderr)
|
|
189
|
+
assert.doesNotMatch(result.stdout, /GOAL PULSE STALLED/, "waiting_user must suppress pulse alerts")
|
|
190
|
+
|
|
191
|
+
staleGoal.status = "active"
|
|
192
|
+
staleGoal.progressRevision += 1
|
|
193
|
+
staleGoal.storageGeneration += 1
|
|
194
|
+
staleGoal.updatedAt = Date.now() - 11 * 60_000
|
|
195
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
196
|
+
result = await runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv)
|
|
197
|
+
assert.equal(result.code, 0, result.stderr)
|
|
198
|
+
assert.match(result.stdout, /GOAL PULSE STALLED/, "new Goal activity/status must re-arm a later stall episode")
|
|
199
|
+
|
|
200
|
+
const pulseState = path.join(project, ".opencode", "opencode-loop", "pulse-check.json")
|
|
201
|
+
await fs.rm(pulseState, { force: true })
|
|
202
|
+
staleGoal.updatedAt = Date.now() - 60 * 60_000
|
|
203
|
+
staleGoal.storageGeneration += 1
|
|
204
|
+
await fs.writeFile(goalFile, JSON.stringify(staleGoal, null, 2) + "\n", "utf8")
|
|
205
|
+
const concurrent = await Promise.all([
|
|
206
|
+
runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv),
|
|
207
|
+
runCli(["pulse-check", "--project", project, "--stale-after", "10m"], pulseEnv),
|
|
208
|
+
])
|
|
209
|
+
assert.ok(concurrent.every((item) => item.code === 0), concurrent.map((item) => item.stderr).join("\n"))
|
|
210
|
+
const concurrentAlerts = concurrent.reduce((count, item) => count + (item.stdout.match(/GOAL PULSE STALLED/g) || []).length, 0)
|
|
211
|
+
assert.equal(concurrentAlerts, 1, "concurrent one-shot checks must share episode state and emit at most one alert")
|
|
212
|
+
|
|
213
|
+
const pulseCalls = await readLog(pulseEnv.FAKE_COMMAND_LOG)
|
|
214
|
+
assert.ok(pulseCalls.every((item) => item.args[0] === "session"), "pulse-check may inspect session telemetry but must never dispatch an OpenCode run")
|
|
215
|
+
|
|
149
216
|
result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], {
|
|
150
217
|
OPENCODE_BIN: fakeOpenCode.command,
|
|
151
218
|
FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"),
|
|
@@ -208,7 +275,7 @@ try {
|
|
|
208
275
|
"run", "--title", taskRunCalls[0].args[2],
|
|
209
276
|
"--model", "opencode/nemotron-3-ultra-free",
|
|
210
277
|
"--agent", "build",
|
|
211
|
-
"scheduled task prompt",
|
|
278
|
+
"--", "scheduled task prompt",
|
|
212
279
|
])
|
|
213
280
|
|
|
214
281
|
result = await runCli(["uninstall-task", "--name", taskName], {
|
|
@@ -223,6 +290,7 @@ try {
|
|
|
223
290
|
|
|
224
291
|
result = await runCli(["install-task", "--project", project, "--name", taskName, "--prompt", "test"], {
|
|
225
292
|
SCHTASKS_BIN: path.join(temporaryRoot, "missing-schtasks.exe"),
|
|
293
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
226
294
|
OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "missing-task-files"),
|
|
227
295
|
})
|
|
228
296
|
assert.equal(result.code, 1, "a missing Task Scheduler executable must not report success")
|
|
@@ -233,3 +301,5 @@ try {
|
|
|
233
301
|
} finally {
|
|
234
302
|
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
|
235
303
|
}
|
|
304
|
+
|
|
305
|
+
await import("./native-cli-test.mjs")
|