@bybrawe/opencode-loop 0.5.17 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.19
4
+
5
+ - Made the asynchronous goal prompt smoke test wait for the observable SDK call instead of assuming a single event-loop tick is always sufficient under load.
6
+
7
+ ## 0.5.18
8
+
9
+ - Fixed package-based installs loading stale OpenCode cache entries after npm had installed a newer release. The installer now pins an existing package config entry to its exact installed version while preserving JSONC comments.
10
+ - Fixed Goal Mode mistaking delayed updates for its own scheduler-created user message as a real user interruption. Loop-owned message IDs remain recognized without hiding a distinct user message.
11
+ - Added a dedicated `opencode-loopd` regression suite for literal argument passing, BOM prompt files, model/agent forwarding, finite-run exit codes, invalid options, and Windows scheduled-task lifecycle behavior.
12
+ - Made finite daemon runs propagate OpenCode failures instead of reporting exit code 0, and reject invalid `--max-runs` values and missing project directories.
13
+ - Added `--model` and `--agent` forwarding to daemon and scheduled-task runs.
14
+ - Fixed Windows Task Scheduler installation failing when `/TR` exceeded its 261-character limit. Scheduled tasks now use a short launcher plus a task-specific JSON config under the local app-data directory, and uninstall removes both artifacts.
15
+ - Made Task Scheduler command launch failures return a nonzero exit code instead of silently succeeding.
16
+ - Added non-mutating `--help` and `--version` handling to the `opencode-loop` installer CLI.
17
+
3
18
  ## 0.5.17
4
19
 
5
20
  - Prevented OpenCode project bootstrap deadlocks by deferring local SDK logging and session discovery until after plugin hooks are returned.
package/README.md CHANGED
@@ -16,7 +16,7 @@ v0.5.11 includes a referenced heartbeat scheduler. This is important in OpenCode
16
16
 
17
17
  ## Current status
18
18
 
19
- **v0.5.17 hardens command, installer, and scheduler behavior around the v0.5.16 busy-detection fix.** Long-running tools and child sessions remain authoritative busy signals. Local `/loop-*` commands now use a tool-denied acknowledgement agent, scheduled work restores the normal agent/model context, package/local duplicate loading is prevented, watched jobs fire correctly, and preset/safe-shell edge cases have regression coverage.
19
+ **v0.5.19 hardens Goal Mode, package updates, and the background daemon.** Package installs are pinned to the installed version so OpenCode cannot keep loading an older cached release, scheduler-created goal messages no longer self-interrupt on delayed updates, finite daemon failures return nonzero, model/agent selection is supported, Windows scheduled tasks use a short launcher that stays below the `/TR` limit, and asynchronous release verification is reliable under load.
20
20
 
21
21
  The known update-related symptoms from older builds are fixed:
22
22
 
@@ -32,7 +32,7 @@ The known update-related symptoms from older builds are fixed:
32
32
 
33
33
  The TUI loop is still intentionally session-bound: it runs while OpenCode is open and the current session emits status/idle events. For long-running background work after closing the terminal or OpenCode, use `opencode-loopd`.
34
34
 
35
- ## v0.5.17 quick behavior guide
35
+ ## v0.5.19 quick behavior guide
36
36
 
37
37
  OpenCode Loop has two triggers now:
38
38
 
@@ -157,7 +157,7 @@ Use the npm installer from any shell: Windows PowerShell, Windows CMD, macOS, or
157
157
  npx -y @bybrawe/opencode-loop@latest
158
158
  ```
159
159
 
160
- The installer copies the slash command files and the tool-denied local command agent into your OpenCode config directory. If `@bybrawe/opencode-loop` is already present in the OpenCode `plugin` array, that package entry remains authoritative and the installer removes duplicate local plugin copies. Otherwise it installs `opencode-loop.ts` locally and ensures the config directory has the `@opencode-ai/plugin` dependency required by `tool()`.
160
+ The installer copies the slash command files and the tool-denied local command agent into your OpenCode config directory. If `@bybrawe/opencode-loop` is already present in the OpenCode `plugin` array, that package entry remains authoritative, is pinned to the installer's exact version to bypass stale OpenCode package caches, and duplicate local plugin copies are removed. Otherwise it installs `opencode-loop.ts` locally and ensures the config directory has the `@opencode-ai/plugin` dependency required by `tool()`.
161
161
 
162
162
  Windows target paths:
163
163
 
@@ -531,6 +531,14 @@ Limit runs:
531
531
  opencode-loopd --project . --every 5m --max-runs 20 --prompt-file loop-prompt.md
532
532
  ```
533
533
 
534
+ Select the OpenCode model and agent explicitly:
535
+
536
+ ```bash
537
+ opencode-loopd --project . --every 0s --max-runs 1 --model opencode/big-pickle --agent build --prompt-file loop-prompt.md
538
+ ```
539
+
540
+ When `--max-runs` is finite, the daemon exits with the final OpenCode run's nonzero status so scripts and CI can detect failures.
541
+
534
542
  Wait before the first run:
535
543
 
536
544
  ```bash
@@ -568,6 +576,8 @@ Install a scheduled task:
568
576
  opencode-loopd install-task --project "C:\path\to\project" --every 10m --prompt-file loop-prompt.md --name OpenCodeLoop
569
577
  ```
570
578
 
579
+ You can also pass `--model <provider/model>` and `--agent <name>`. The installer stores long task options in a task-specific JSON file and registers only a short launcher with Task Scheduler, avoiding Windows' 261-character `/TR` limit. `uninstall-task` removes that launcher and JSON config after the task is deleted.
580
+
571
581
  Remove it:
572
582
 
573
583
  ```powershell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.17",
3
+ "version": "0.5.19",
4
4
  "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -18,8 +18,8 @@
18
18
  "CHANGELOG.md"
19
19
  ],
20
20
  "scripts": {
21
- "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs",
22
- "test": "node scripts/install-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
21
+ "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/comprehensive-test.mjs",
22
+ "test": "node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/comprehensive-test.mjs",
23
23
  "install:global": "node scripts/install-node.mjs",
24
24
  "pack:zip": "node scripts/make-zip.mjs"
25
25
  },
@@ -425,6 +425,36 @@ async function testStopsPreflightAndGoalLifecycle() {
425
425
  }
426
426
  }
427
427
 
428
+ async function testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt() {
429
+ const h = await createHarness()
430
+ const originalDateNow = Date.now
431
+ let fakeNow = originalDateNow()
432
+ Date.now = () => fakeNow
433
+ try {
434
+ await h.command("loop-goal", "--no-now Keep working until the objective is complete")
435
+ const synthetic = {
436
+ type: "message.updated",
437
+ properties: { info: { id: "msg_loop_owned", sessionID: h.sessionID, role: "user" } },
438
+ }
439
+ await h.hooks.event({ event: synthetic })
440
+
441
+ fakeNow += 60_000
442
+ await h.hooks.event({ event: synthetic })
443
+ let state = await h.readState()
444
+ assert.equal(state.jobs[0].paused, false, "a delayed update for the same loop-owned user message must remain ignored")
445
+
446
+ await h.hooks.event({ event: {
447
+ type: "message.updated",
448
+ properties: { info: { id: "msg_real_user", sessionID: h.sessionID, role: "user" } },
449
+ } })
450
+ state = await h.readState()
451
+ assert.equal(state.jobs[0].paused, true, "a distinct real user message must still pause an active goal")
452
+ } finally {
453
+ Date.now = originalDateNow
454
+ await h.cleanup()
455
+ }
456
+ }
457
+
428
458
  async function testInitializationDoesNotWaitForLocalApi() {
429
459
  const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-init-deadlock-"))
430
460
  const never = new Promise(() => {})
@@ -450,6 +480,7 @@ await testLifecycleAndCommandDedupe()
450
480
  await testWatchScheduling()
451
481
  await testActionRoutingAndSafety()
452
482
  await testStopsPreflightAndGoalLifecycle()
483
+ await testLoopOwnedGoalMessageUpdatesDoNotSelfInterrupt()
453
484
  await testInitializationDoesNotWaitForLocalApi()
454
485
 
455
486
  console.log("OpenCode Loop comprehensive test passed")
@@ -11,6 +11,26 @@ const commandDir = join(config, "commands")
11
11
  const agentDir = join(config, "agents")
12
12
  const packagePath = join(config, "package.json")
13
13
  const packageName = "@bybrawe/opencode-loop"
14
+ const packageVersion = JSON.parse(await readFile(join(root, "package.json"), "utf8")).version
15
+ const packageSpec = `${packageName}@${packageVersion}`
16
+ const installerArgs = process.argv.slice(2)
17
+
18
+ if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
19
+ console.log(`OpenCode Loop installer
20
+
21
+ Usage:
22
+ opencode-loop
23
+ npx -y @bybrawe/opencode-loop@latest
24
+
25
+ Installs the plugin commands and local command agent into OPENCODE_CONFIG_DIR
26
+ or the default ~/.config/opencode directory.`)
27
+ process.exit(0)
28
+ }
29
+
30
+ if (installerArgs.includes("--version") || installerArgs.includes("-v")) {
31
+ console.log(packageVersion)
32
+ process.exit(0)
33
+ }
14
34
 
15
35
  function stripJsonComments(input) {
16
36
  let output = ""
@@ -78,16 +98,36 @@ function isPackageSpec(value) {
78
98
  return spec === packageName || spec.startsWith(`${packageName}@`)
79
99
  }
80
100
 
81
- async function packagePluginConfigured() {
101
+ async function configurePackagePlugin() {
102
+ let configured = false
103
+ const updatedFiles = []
82
104
  for (const name of ["opencode.json", "opencode.jsonc", "config.json", "config.jsonc"]) {
83
105
  try {
84
- const parsed = parseJsonc(await readFile(join(config, name), "utf8"))
85
- if (Array.isArray(parsed?.plugin) && parsed.plugin.some(isPackageSpec)) return true
106
+ const target = join(config, name)
107
+ const source = await readFile(target, "utf8")
108
+ const parsed = parseJsonc(source)
109
+ const specs = Array.isArray(parsed?.plugin) ? parsed.plugin.filter(isPackageSpec) : []
110
+ if (!specs.length) continue
111
+ configured = true
112
+
113
+ // OpenCode caches package plugins by the literal config spec. A bare
114
+ // package name or @latest can therefore keep loading an older cached
115
+ // release after npm installs a newer one. Pin the config entry to the
116
+ // installer package's exact version while preserving JSONC comments.
117
+ let updated = source
118
+ for (const spec of new Set(specs)) {
119
+ if (spec === packageSpec) continue
120
+ updated = updated.replaceAll(JSON.stringify(spec), JSON.stringify(packageSpec))
121
+ }
122
+ if (updated !== source) {
123
+ await writeFile(target, updated, "utf8")
124
+ updatedFiles.push(target)
125
+ }
86
126
  } catch (error) {
87
127
  if (error?.code !== "ENOENT") console.warn(`Could not inspect ${join(config, name)} for duplicate plugin entries: ${error.message}`)
88
128
  }
89
129
  }
90
- return false
130
+ return { configured, updatedFiles }
91
131
  }
92
132
 
93
133
  async function ensureDependency() {
@@ -112,7 +152,8 @@ async function ensureDependency() {
112
152
  await mkdir(pluginDir, { recursive: true })
113
153
  await mkdir(commandDir, { recursive: true })
114
154
  await mkdir(agentDir, { recursive: true })
115
- const useConfiguredPackage = await packagePluginConfigured()
155
+ const packageConfig = await configurePackagePlugin()
156
+ const useConfiguredPackage = packageConfig.configured
116
157
  if (useConfiguredPackage) {
117
158
  await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
118
159
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
@@ -134,7 +175,12 @@ for (const name of await readdir(join(root, "agents"))) {
134
175
  }
135
176
  }
136
177
 
137
- if (useConfiguredPackage) console.log(`OpenCode Loop is already configured as a package in ${config}; removed the duplicate local plugin copy.`)
178
+ if (useConfiguredPackage) {
179
+ const pinResult = packageConfig.updatedFiles.length
180
+ ? `pinned the config entry to ${packageSpec}`
181
+ : `the config entry is already pinned to ${packageSpec}`
182
+ console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
183
+ }
138
184
  else console.log(`Installed OpenCode Loop plugin to ${config}`)
139
185
  console.log(`Installed ${packageName} commands to ${commandDir}`)
140
186
  console.log(`Installed ${packageName} local command agent to ${agentDir}`)
@@ -7,11 +7,13 @@ import { fileURLToPath } from "node:url"
7
7
 
8
8
  const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
9
9
  const installer = path.join(root, "scripts", "install-node.mjs")
10
+ const packageVersion = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version
11
+ const expectedPackageSpec = `@bybrawe/opencode-loop@${packageVersion}`
10
12
  const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
11
13
 
12
- async function runInstaller(config) {
14
+ async function runInstaller(config, cliArgs = []) {
13
15
  return await new Promise((resolve, reject) => {
14
- const child = spawn(process.execPath, [installer], {
16
+ const child = spawn(process.execPath, [installer, ...cliArgs], {
15
17
  cwd: root,
16
18
  env: { ...process.env, OPENCODE_CONFIG_DIR: config },
17
19
  windowsHide: true,
@@ -38,6 +40,17 @@ async function exists(target) {
38
40
  }
39
41
 
40
42
  try {
43
+ const helpConfig = path.join(temporaryRoot, "help-must-not-install")
44
+ const helpResult = await runInstaller(helpConfig, ["--help"])
45
+ assert.equal(helpResult.code, 0, helpResult.stderr)
46
+ assert.match(helpResult.stdout, /OpenCode Loop installer/)
47
+ assert.equal(await exists(helpConfig), false, "--help must not mutate the OpenCode config directory")
48
+
49
+ const versionResult = await runInstaller(helpConfig, ["--version"])
50
+ assert.equal(versionResult.code, 0, versionResult.stderr)
51
+ assert.equal(versionResult.stdout.trim(), packageVersion)
52
+ assert.equal(await exists(helpConfig), false, "--version must not mutate the OpenCode config directory")
53
+
41
54
  const local = path.join(temporaryRoot, "local")
42
55
  const localResult = await runInstaller(local)
43
56
  assert.equal(localResult.code, 0, localResult.stderr)
@@ -59,6 +72,8 @@ try {
59
72
  assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
60
73
  assert.equal(await commandCount(configured), 30)
61
74
  assert.equal(await exists(path.join(configured, "agents", "opencode-loop-local.md")), true)
75
+ const configuredJson = JSON.parse(await fs.readFile(path.join(configured, "opencode.json"), "utf8"))
76
+ assert.deepEqual(configuredJson.plugin, [expectedPackageSpec], "the installer must bust OpenCode's stale package cache with an exact version spec")
62
77
 
63
78
  const jsonc = path.join(temporaryRoot, "jsonc")
64
79
  await fs.mkdir(jsonc, { recursive: true })
@@ -72,6 +87,9 @@ try {
72
87
  const jsoncResult = await runInstaller(jsonc)
73
88
  assert.equal(jsoncResult.code, 0, jsoncResult.stderr)
74
89
  assert.equal(await exists(path.join(jsonc, "plugins", "opencode-loop.ts")), false)
90
+ const updatedJsonc = await fs.readFile(path.join(jsonc, "opencode.jsonc"), "utf8")
91
+ assert.match(updatedJsonc, /A configured package is authoritative/, "pinning must preserve JSONC comments")
92
+ assert.match(updatedJsonc, new RegExp(expectedPackageSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
75
93
 
76
94
  const lookalike = path.join(temporaryRoot, "lookalike")
77
95
  await fs.mkdir(lookalike, { recursive: true })
@@ -0,0 +1,208 @@
1
+ import assert from "node:assert/strict"
2
+ import { spawn } from "node:child_process"
3
+ import { promises as fs } from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
9
+ const loopd = path.join(root, "scripts", "loopd.mjs")
10
+ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loopd-test-"))
11
+
12
+ function runCli(cliArgs, env = {}) {
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(process.execPath, [loopd, ...cliArgs], {
15
+ cwd: root,
16
+ env: { ...process.env, ...env },
17
+ windowsHide: true,
18
+ })
19
+ const stdout = []
20
+ const stderr = []
21
+ child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
22
+ child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
23
+ child.on("error", reject)
24
+ child.on("close", (code, signal) => resolve({
25
+ code: code ?? (signal ? 1 : 0),
26
+ stdout: Buffer.concat(stdout).toString("utf8"),
27
+ stderr: Buffer.concat(stderr).toString("utf8"),
28
+ }))
29
+ })
30
+ }
31
+
32
+ async function makeFakeCommand(name) {
33
+ const script = path.join(temporaryRoot, `${name}.mjs`)
34
+ const log = path.join(temporaryRoot, `${name}.jsonl`)
35
+ await fs.writeFile(script, `
36
+ import fs from "node:fs"
37
+ const log = process.env.FAKE_COMMAND_LOG
38
+ let previous = []
39
+ try { previous = fs.readFileSync(log, "utf8").trim().split(/\\r?\\n/).filter(Boolean) } catch {}
40
+ fs.appendFileSync(log, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + "\\n")
41
+ const codes = String(process.env.FAKE_EXIT_CODES || "0").split(",").map((value) => Number(value.trim()))
42
+ const code = codes[Math.min(previous.length, codes.length - 1)]
43
+ process.exit(Number.isInteger(code) ? code : 0)
44
+ `, "utf8")
45
+
46
+ if (process.platform === "win32") {
47
+ const command = path.join(temporaryRoot, `${name}.cmd`)
48
+ const escapeCmd = (value) => String(value).replace(/%/g, "%%")
49
+ await fs.writeFile(command, `@echo off\r\n"${escapeCmd(process.execPath)}" "${escapeCmd(script)}" %*\r\nexit /b %errorlevel%\r\n`, "utf8")
50
+ return { command, log }
51
+ }
52
+
53
+ const command = path.join(temporaryRoot, name)
54
+ await fs.writeFile(command, `#!/bin/sh\nexec "${process.execPath.replace(/"/g, '\\"')}" "${script.replace(/"/g, '\\"')}" "$@"\n`, { mode: 0o755 })
55
+ return { command, log }
56
+ }
57
+
58
+ async function readLog(log) {
59
+ try {
60
+ return (await fs.readFile(log, "utf8")).trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
61
+ } catch (error) {
62
+ if (error?.code === "ENOENT") return []
63
+ throw error
64
+ }
65
+ }
66
+
67
+ try {
68
+ const project = path.join(temporaryRoot, "project with spaces")
69
+ await fs.mkdir(project, { recursive: true })
70
+ const fakeOpenCode = await makeFakeCommand("fake-opencode")
71
+
72
+ const inlinePrompt = 'continue "quoted text" & keep | characters literal'
73
+ let result = await runCli([
74
+ "--project", project,
75
+ "--every", "0s",
76
+ "--max-runs", "1",
77
+ "--model", "opencode/nemotron-3-ultra-free",
78
+ "--agent", "build",
79
+ "--prompt", inlinePrompt,
80
+ ], {
81
+ OPENCODE_BIN: fakeOpenCode.command,
82
+ FAKE_COMMAND_LOG: fakeOpenCode.log,
83
+ })
84
+ assert.equal(result.code, 0, result.stderr)
85
+ let calls = await readLog(fakeOpenCode.log)
86
+ assert.equal(calls.length, 1)
87
+ assert.equal(path.resolve(calls[0].cwd), path.resolve(project))
88
+ assert.deepEqual(calls[0].args, [
89
+ "run", "--continue",
90
+ "--model", "opencode/nemotron-3-ultra-free",
91
+ "--agent", "build",
92
+ inlinePrompt,
93
+ ], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
94
+
95
+ const promptFile = path.join(project, "goal prompt.md")
96
+ await fs.writeFile(promptFile, "\uFEFFPrompt loaded from a BOM file.", "utf8")
97
+ result = await runCli([
98
+ "--project", project,
99
+ "--every", "0s",
100
+ "--max-runs", "1",
101
+ "--prompt-file", path.basename(promptFile),
102
+ ], {
103
+ OPENCODE_BIN: fakeOpenCode.command,
104
+ FAKE_COMMAND_LOG: fakeOpenCode.log,
105
+ })
106
+ assert.equal(result.code, 0, result.stderr)
107
+ calls = await readLog(fakeOpenCode.log)
108
+ assert.equal(calls.at(-1).args.at(-1), "Prompt loaded from a BOM file.")
109
+
110
+ const failureLog = path.join(temporaryRoot, "failure.jsonl")
111
+ result = await runCli([
112
+ "--project", project,
113
+ "--every", "0s",
114
+ "--max-runs", "1",
115
+ "--prompt", "fail once",
116
+ ], {
117
+ OPENCODE_BIN: fakeOpenCode.command,
118
+ FAKE_COMMAND_LOG: failureLog,
119
+ FAKE_EXIT_CODES: "7",
120
+ OPENCODE_LOOPD_FAILED_RUN_RETRY_MS: "0",
121
+ })
122
+ assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code")
123
+
124
+ result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], {
125
+ OPENCODE_BIN: fakeOpenCode.command,
126
+ FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"),
127
+ })
128
+ assert.equal(result.code, 1)
129
+ assert.match(result.stderr, /Invalid --max-runs value/)
130
+
131
+ result = await runCli(["--project", path.join(temporaryRoot, "missing-project"), "--max-runs", "1", "--prompt", "test"], {
132
+ OPENCODE_BIN: fakeOpenCode.command,
133
+ FAKE_COMMAND_LOG: path.join(temporaryRoot, "missing-project.jsonl"),
134
+ })
135
+ assert.equal(result.code, 1)
136
+ assert.match(result.stderr, /Project directory does not exist/)
137
+
138
+ if (process.platform === "win32") {
139
+ const fakeTasks = await makeFakeCommand("fake-schtasks")
140
+ const taskName = "OpenCodeLoop-Test"
141
+ result = await runCli([
142
+ "install-task",
143
+ "--project", project,
144
+ "--every", "90s",
145
+ "--name", taskName,
146
+ "--prompt", "scheduled task prompt",
147
+ "--model", "opencode/nemotron-3-ultra-free",
148
+ "--agent", "build",
149
+ ], {
150
+ SCHTASKS_BIN: fakeTasks.command,
151
+ FAKE_COMMAND_LOG: fakeTasks.log,
152
+ OPENCODE_BIN: fakeOpenCode.command,
153
+ OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "task-files"),
154
+ })
155
+ assert.equal(result.code, 0, result.stderr)
156
+ const taskCalls = await readLog(fakeTasks.log)
157
+ assert.equal(taskCalls.length, 1)
158
+ const taskArgs = taskCalls[0].args
159
+ assert.equal(taskArgs[0], "/Create")
160
+ assert.equal(taskArgs[taskArgs.indexOf("/MO") + 1], "2")
161
+ assert.equal(taskArgs[taskArgs.indexOf("/TN") + 1], taskName)
162
+ const taskCommand = taskArgs[taskArgs.indexOf("/TR") + 1]
163
+ assert.ok(taskCommand.length <= 261, "the Task Scheduler /TR command must stay within its documented limit")
164
+ assert.match(taskCommand, /^cmd\.exe \/d \/s \/c /)
165
+ const taskFiles = await fs.readdir(path.join(temporaryRoot, "task-files"))
166
+ const taskConfigFile = path.join(temporaryRoot, "task-files", taskFiles.find((name) => name.endsWith(".json")))
167
+ const taskConfig = JSON.parse(await fs.readFile(taskConfigFile, "utf8"))
168
+ assert.equal(taskConfig.project, project)
169
+ assert.equal(taskConfig.prompt, "scheduled task prompt")
170
+ assert.equal(taskConfig.model, "opencode/nemotron-3-ultra-free")
171
+ assert.equal(taskConfig.agent, "build")
172
+ assert.equal(taskConfig.opencodeBin, fakeOpenCode.command)
173
+
174
+ const taskRunLog = path.join(temporaryRoot, "task-run.jsonl")
175
+ result = await runCli(["task-run", "--config", taskConfigFile], {
176
+ FAKE_COMMAND_LOG: taskRunLog,
177
+ })
178
+ assert.equal(result.code, 0, result.stderr)
179
+ const taskRunCall = (await readLog(taskRunLog))[0]
180
+ assert.deepEqual(taskRunCall.args, [
181
+ "run", "--continue",
182
+ "--model", "opencode/nemotron-3-ultra-free",
183
+ "--agent", "build",
184
+ "scheduled task prompt",
185
+ ])
186
+
187
+ result = await runCli(["uninstall-task", "--name", taskName], {
188
+ SCHTASKS_BIN: fakeTasks.command,
189
+ FAKE_COMMAND_LOG: fakeTasks.log,
190
+ OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "task-files"),
191
+ })
192
+ assert.equal(result.code, 0, result.stderr)
193
+ const uninstallArgs = (await readLog(fakeTasks.log)).at(-1).args
194
+ assert.deepEqual(uninstallArgs, ["/Delete", "/F", "/TN", taskName])
195
+ assert.deepEqual(await fs.readdir(path.join(temporaryRoot, "task-files")), [], "uninstall-task must remove its launcher and config")
196
+
197
+ result = await runCli(["install-task", "--project", project, "--name", taskName, "--prompt", "test"], {
198
+ SCHTASKS_BIN: path.join(temporaryRoot, "missing-schtasks.exe"),
199
+ OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "missing-task-files"),
200
+ })
201
+ assert.equal(result.code, 1, "a missing Task Scheduler executable must not report success")
202
+ assert.match(result.stderr, /failed to start/i)
203
+ }
204
+
205
+ console.log("OpenCode Loop daemon test passed")
206
+ } finally {
207
+ await fs.rm(temporaryRoot, { recursive: true, force: true })
208
+ }
package/scripts/loopd.mjs CHANGED
@@ -2,15 +2,22 @@
2
2
  import fs from "node:fs"
3
3
  import path from "node:path"
4
4
  import { spawn, spawnSync } from "node:child_process"
5
+ import { createHash } from "node:crypto"
6
+ import { homedir } from "node:os"
5
7
  import { fileURLToPath } from "node:url"
6
8
 
7
9
  const args = process.argv.slice(2)
8
- const FAILED_RUN_RETRY_MS = 5_000
10
+ const configuredRetryMs = Number(process.env.OPENCODE_LOOPD_FAILED_RUN_RETRY_MS)
11
+ const FAILED_RUN_RETRY_MS = Number.isFinite(configuredRetryMs) && configuredRetryMs >= 0 ? configuredRetryMs : 5_000
9
12
  const OPENCODE_BIN = process.env.OPENCODE_BIN || "opencode"
13
+ const SCHTASKS_BIN = process.env.SCHTASKS_BIN || "schtasks"
14
+ const TASK_ROOT = process.env.OPENCODE_LOOPD_TASK_DIR || path.join(process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local"), "opencode-loop", "tasks")
10
15
 
11
16
  function arg(name, fallback = null) {
12
17
  const i = args.indexOf(name)
13
- return i >= 0 ? args[i + 1] ?? fallback : fallback
18
+ if (i < 0) return fallback
19
+ if (i + 1 >= args.length) throw new Error(`Missing value for ${name}`)
20
+ return args[i + 1]
14
21
  }
15
22
 
16
23
  function has(name) {
@@ -18,7 +25,7 @@ function has(name) {
18
25
  }
19
26
 
20
27
  function parseMs(value) {
21
- const v = String(value || "0s").trim().toLowerCase()
28
+ const v = String(value ?? "0s").trim().toLowerCase()
22
29
  if (v === "0" || v === "0s" || v === "now") return 0
23
30
 
24
31
  const m = v.match(/^(\d+(?:\.\d+)?)\s*(ms|s|sec|secs|m|min|mins|h|hr|hrs|d|day|days)$/)
@@ -36,6 +43,20 @@ function parseMs(value) {
36
43
  return n
37
44
  }
38
45
 
46
+ function parseMaxRuns(value) {
47
+ const text = String(value ?? "0").trim()
48
+ if (!/^\d+$/.test(text)) throw new Error(`Invalid --max-runs value: ${value}`)
49
+ const parsed = Number(text)
50
+ if (!Number.isSafeInteger(parsed)) throw new Error(`Invalid --max-runs value: ${value}`)
51
+ return parsed
52
+ }
53
+
54
+ function validateProject(project) {
55
+ let stat
56
+ try { stat = fs.statSync(project) } catch { throw new Error(`Project directory does not exist: ${project}`) }
57
+ if (!stat.isDirectory()) throw new Error(`Project path is not a directory: ${project}`)
58
+ }
59
+
39
60
  function sleep(ms) {
40
61
  return new Promise((resolve) => setTimeout(resolve, ms))
41
62
  }
@@ -64,7 +85,7 @@ function spawnOnce(command, commandArgs, cwd) {
64
85
  }
65
86
 
66
87
  child.on("error", (error) => done({ code: -1, error }))
67
- child.on("exit", (code) => done({ code: code ?? 0 }))
88
+ child.on("exit", (code, signal) => done({ code: code ?? (signal ? 1 : 0), signal }))
68
89
  })
69
90
  }
70
91
 
@@ -88,9 +109,17 @@ async function run(command, commandArgs, cwd) {
88
109
  return fallback.code
89
110
  }
90
111
 
91
- function readPrompt(project) {
92
- const promptFile = arg("--prompt-file")
93
- const promptArg = arg("--prompt")
112
+ function runSync(command, commandArgs) {
113
+ const options = { shell: false, stdio: "inherit", windowsHide: true }
114
+ const direct = spawnSync(command, commandArgs, options)
115
+ const extension = path.extname(String(command || "")).toLowerCase()
116
+ if (!direct.error || process.platform !== "win32" || direct.error.code !== "EINVAL" || ![".cmd", ".bat"].includes(extension)) return direct
117
+ return spawnSync("cmd.exe", ["/d", "/s", "/c", command, ...commandArgs], options)
118
+ }
119
+
120
+ function readPrompt(project, options = {}) {
121
+ const promptFile = options.promptFile ?? arg("--prompt-file")
122
+ const promptArg = options.prompt ?? arg("--prompt")
94
123
 
95
124
  if (promptFile) {
96
125
  return stripBom(fs.readFileSync(path.resolve(project, promptFile), "utf8"))
@@ -110,13 +139,17 @@ function readPrompt(project) {
110
139
  ].join(" ")
111
140
  }
112
141
 
113
- async function daemon() {
114
- const project = path.resolve(arg("--project", process.cwd()))
115
- const every = arg("--every", "0s")
142
+ async function daemon(options = {}) {
143
+ const project = path.resolve(options.project ?? arg("--project", process.cwd()))
144
+ validateProject(project)
145
+ const every = options.every ?? arg("--every", "0s")
116
146
  const delay = parseMs(every)
117
- const maxRuns = Number(arg("--max-runs", "0")) || 0
118
- const sleepFirst = has("--sleep-first")
119
- const prompt = readPrompt(project)
147
+ const maxRuns = parseMaxRuns(options.maxRuns ?? arg("--max-runs", "0"))
148
+ const sleepFirst = options.sleepFirst ?? has("--sleep-first")
149
+ const prompt = readPrompt(project, options)
150
+ const model = options.model ?? arg("--model")
151
+ const agent = options.agent ?? arg("--agent")
152
+ const opencodeBin = options.opencodeBin || OPENCODE_BIN
120
153
 
121
154
  console.log("OpenCode Loop daemon")
122
155
  console.log(`project: ${project}`)
@@ -135,7 +168,11 @@ async function daemon() {
135
168
  console.log("")
136
169
  console.log(`[opencode-loopd] run #${count} ${new Date().toISOString()}`)
137
170
 
138
- const code = await run(OPENCODE_BIN, ["run", "--continue", prompt], project)
171
+ const runArgs = ["run", "--continue"]
172
+ if (model) runArgs.push("--model", model)
173
+ if (agent) runArgs.push("--agent", agent)
174
+ runArgs.push(prompt)
175
+ const code = await run(opencodeBin, runArgs, project)
139
176
 
140
177
  if (code !== 0) {
141
178
  console.log(`[opencode-loopd] opencode exited with code ${code}`)
@@ -146,7 +183,7 @@ async function daemon() {
146
183
 
147
184
  if (maxRuns > 0 && count >= maxRuns) {
148
185
  console.log("[opencode-loopd] max runs reached")
149
- break
186
+ return Number.isInteger(code) && code > 0 ? code : code < 0 ? 1 : 0
150
187
  }
151
188
 
152
189
  if (delay > 0) {
@@ -155,41 +192,75 @@ async function daemon() {
155
192
  }
156
193
  }
157
194
 
195
+ function taskArtifacts(name) {
196
+ const id = createHash("sha256").update(String(name || "OpenCodeLoop")).digest("hex").slice(0, 16)
197
+ return {
198
+ config: path.join(TASK_ROOT, `${id}.json`),
199
+ launcher: path.join(TASK_ROOT, `${id}.cmd`),
200
+ }
201
+ }
202
+
203
+ function removeTaskArtifacts(artifacts) {
204
+ for (const target of [artifacts.launcher, artifacts.config]) {
205
+ try { fs.rmSync(target, { force: true }) } catch {}
206
+ }
207
+ }
208
+
209
+ async function taskRun() {
210
+ const configFile = path.resolve(arg("--config"))
211
+ let config
212
+ try { config = JSON.parse(fs.readFileSync(configFile, "utf8")) } catch (error) { throw new Error(`Could not read task config ${configFile}: ${error.message}`) }
213
+ return await daemon(config)
214
+ }
215
+
158
216
  function installTask() {
159
217
  if (process.platform !== "win32") {
160
218
  throw new Error("install-task is currently implemented for Windows Task Scheduler only. Use daemon mode on macOS/Linux.")
161
219
  }
162
220
 
163
221
  const project = path.resolve(arg("--project", process.cwd()))
222
+ validateProject(project)
164
223
  const every = arg("--every", "10m")
165
224
  const minutes = Math.max(1, Math.round(parseMs(every) / 60_000))
166
225
  const name = arg("--name", "OpenCodeLoop")
167
226
  const promptFile = arg("--prompt-file")
168
227
  const promptArg = arg("--prompt")
228
+ const model = arg("--model")
229
+ const agent = arg("--agent")
169
230
  const node = process.execPath
170
231
  const script = fileURLToPath(import.meta.url)
171
-
172
- const commandParts = [
173
- quoteWindowsArg(node),
174
- quoteWindowsArg(script),
175
- "daemon",
176
- "--project",
177
- quoteWindowsArg(project),
178
- "--every",
179
- "0s",
180
- "--max-runs",
181
- "1",
182
- ]
183
-
184
- if (promptFile) commandParts.push("--prompt-file", quoteWindowsArg(promptFile))
185
- if (promptArg) commandParts.push("--prompt", quoteWindowsArg(promptArg))
186
-
187
- const taskCommand = commandParts.join(" ")
232
+ const artifacts = taskArtifacts(name)
233
+ fs.mkdirSync(TASK_ROOT, { recursive: true })
234
+ const taskConfig = {
235
+ project,
236
+ every: "0s",
237
+ maxRuns: 1,
238
+ promptFile: promptFile ? path.resolve(project, promptFile) : undefined,
239
+ prompt: promptArg || undefined,
240
+ model: model || undefined,
241
+ agent: agent || undefined,
242
+ opencodeBin: OPENCODE_BIN,
243
+ }
244
+ fs.writeFileSync(artifacts.config, JSON.stringify(taskConfig, null, 2) + "\n", "utf8")
245
+ const launcherCommand = [quoteWindowsArg(node), quoteWindowsArg(script), "task-run", "--config", quoteWindowsArg(artifacts.config)].join(" ")
246
+ fs.writeFileSync(artifacts.launcher, `@echo off\r\n${launcherCommand}\r\nexit /b %errorlevel%\r\n`, "utf8")
247
+ const taskCommand = `cmd.exe /d /s /c "${quoteWindowsArg(artifacts.launcher)}"`
248
+ if (taskCommand.length > 261) {
249
+ removeTaskArtifacts(artifacts)
250
+ throw new Error(`Task Scheduler command is still too long (${taskCommand.length} characters). Set OPENCODE_LOOPD_TASK_DIR to a shorter directory.`)
251
+ }
188
252
  const taskArgs = ["/Create", "/F", "/SC", "MINUTE", "/MO", String(minutes), "/TN", name, "/TR", taskCommand]
189
253
 
190
- console.log(["schtasks", ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
191
- const result = spawnSync("schtasks", taskArgs, { shell: false, stdio: "inherit" })
192
- process.exit(result.status ?? 0)
254
+ console.log([SCHTASKS_BIN, ...taskArgs.map((part) => JSON.stringify(part))].join(" "))
255
+ const result = runSync(SCHTASKS_BIN, taskArgs)
256
+ if (result.error) {
257
+ removeTaskArtifacts(artifacts)
258
+ console.error(`[opencode-loopd] failed to start ${SCHTASKS_BIN}: ${result.error.message}`)
259
+ return 1
260
+ }
261
+ const status = Number.isInteger(result.status) ? result.status : 1
262
+ if (status !== 0) removeTaskArtifacts(artifacts)
263
+ return status
193
264
  }
194
265
 
195
266
  function uninstallTask() {
@@ -198,8 +269,14 @@ function uninstallTask() {
198
269
  }
199
270
 
200
271
  const name = arg("--name", "OpenCodeLoop")
201
- const result = spawnSync("schtasks", ["/Delete", "/F", "/TN", name], { shell: false, stdio: "inherit" })
202
- process.exit(result.status ?? 0)
272
+ const result = runSync(SCHTASKS_BIN, ["/Delete", "/F", "/TN", name])
273
+ if (result.error) {
274
+ console.error(`[opencode-loopd] failed to start ${SCHTASKS_BIN}: ${result.error.message}`)
275
+ return 1
276
+ }
277
+ const status = Number.isInteger(result.status) ? result.status : 1
278
+ if (status === 0) removeTaskArtifacts(taskArtifacts(name))
279
+ return status
203
280
  }
204
281
 
205
282
  function help() {
@@ -217,6 +294,8 @@ Options:
217
294
  --every <duration> 0s, 5m, 1h, etc.
218
295
  --prompt <text> Prompt text
219
296
  --prompt-file <file> Read prompt from file relative to the project
297
+ --model <provider/id> OpenCode model used for each run
298
+ --agent <name> OpenCode agent used for each run
220
299
  --max-runs <n> Stop after n runs
221
300
  --sleep-first Wait before first run
222
301
  `)
@@ -225,20 +304,25 @@ Options:
225
304
  const command = args[0]
226
305
 
227
306
  try {
307
+ let exitCode = 0
228
308
  if (command === "daemon" || command === "loopd") {
229
309
  args.shift()
230
- await daemon()
310
+ exitCode = await daemon()
231
311
  } else if (command === "install-task") {
232
312
  args.shift()
233
- installTask()
313
+ exitCode = installTask()
234
314
  } else if (command === "uninstall-task") {
235
315
  args.shift()
236
- uninstallTask()
316
+ exitCode = uninstallTask()
317
+ } else if (command === "task-run") {
318
+ args.shift()
319
+ exitCode = await taskRun()
237
320
  } else if (has("--help") || has("-h") || command === "help") {
238
321
  help()
239
322
  } else {
240
- await daemon()
323
+ exitCode = await daemon()
241
324
  }
325
+ if (exitCode) process.exitCode = exitCode
242
326
  } catch (error) {
243
327
  console.error(error?.message || error)
244
328
  process.exit(1)
@@ -12,6 +12,15 @@ const prompts = []
12
12
  const liveStatuses = new Map([[sessionID, "idle"]])
13
13
  let hooks
14
14
 
15
+ async function waitFor(predicate, timeoutMs = 2_000) {
16
+ const deadline = Date.now() + timeoutMs
17
+ while (!predicate()) {
18
+ if (Date.now() >= deadline) return false
19
+ await new Promise((resolve) => setTimeout(resolve, 10))
20
+ }
21
+ return true
22
+ }
23
+
15
24
  function legacyBody(name) {
16
25
  return async (args) => {
17
26
  attempts[name]++
@@ -86,8 +95,11 @@ try {
86
95
  assert.equal(output.parts.length, 0, "a locally handled slash command must not start a placeholder model turn")
87
96
 
88
97
  await hooks["command.execute.before"]({ command: "loop-now", sessionID, arguments: "goal" }, { parts: [] })
89
- await new Promise((resolve) => setTimeout(resolve, 0))
90
- assert.ok(prompts.some((prompt) => prompt.includes(path.resolve(directory))), "goal prompt must include the working directory")
98
+ assert.equal(
99
+ await waitFor(() => prompts.some((prompt) => prompt.includes(path.resolve(directory)))),
100
+ true,
101
+ "goal prompt must include the working directory",
102
+ )
91
103
  assert.ok(prompts.some((prompt) => prompt.includes("never turn a relative path into a root path")))
92
104
 
93
105
  const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
@@ -122,8 +134,11 @@ try {
122
134
 
123
135
  await hooks["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_background", args: {} }, { title: "done", output: "", metadata: {} })
124
136
  await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
125
- await new Promise((resolve) => setTimeout(resolve, 1_400))
126
- assert.equal(prompts.length, promptCountBeforeBackgroundTool + 1, "the due loop must resume after the active tool finishes and the session becomes idle")
137
+ assert.equal(
138
+ await waitFor(() => prompts.length === promptCountBeforeBackgroundTool + 1, 5_000),
139
+ true,
140
+ "the due loop must resume after the active tool finishes and the session becomes idle",
141
+ )
127
142
 
128
143
  await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
129
144
 
@@ -143,8 +158,11 @@ try {
143
158
  liveStatuses.set(childSessionID, "idle")
144
159
  await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "idle" } } } })
145
160
  await hooks.event({ event: { type: "session.idle", properties: { sessionID } } })
146
- await new Promise((resolve) => setTimeout(resolve, 1_400))
147
- assert.equal(prompts.length, promptCountBeforeBackgroundChild + 1, "the parent loop must resume after its background child becomes idle")
161
+ assert.equal(
162
+ await waitFor(() => prompts.length === promptCountBeforeBackgroundChild + 1, 5_000),
163
+ true,
164
+ "the parent loop must resume after its background child becomes idle",
165
+ )
148
166
 
149
167
  await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
150
168
  assert.equal(attempts.log, 1)
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
20
20
  const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
21
21
  const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
22
22
  const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
23
+ const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
23
24
  const LOCAL_COMMAND_AGENT = "opencode-loop-local"
24
25
 
25
26
  const activeRuns = new Map()
@@ -509,18 +510,31 @@ async function toast(client, message, variant = "info") {
509
510
 
510
511
  function guardLoopOwnedUserMessage(sessionID) {
511
512
  if (!sessionID) return
512
- loopOwnedUserMessageGuards.set(sessionID, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
513
- for (const [key, time] of loopOwnedUserMessageGuards.entries()) if (time < now()) loopOwnedUserMessageGuards.delete(key)
513
+ const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
514
+ current.pending += 1
515
+ current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
516
+ for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
517
+ loopOwnedUserMessageGuards.set(sessionID, current)
518
+ for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
519
+ for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
520
+ if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
521
+ }
514
522
  }
515
523
 
516
- function loopOwnedUserMessageGuardActive(sessionID) {
517
- const until = loopOwnedUserMessageGuards.get(sessionID)
518
- if (typeof until !== "number") return false
519
- if (until < now()) {
520
- loopOwnedUserMessageGuards.delete(sessionID)
521
- return false
524
+ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
525
+ const entry = loopOwnedUserMessageGuards.get(sessionID)
526
+ if (!entry || typeof entry !== "object") return false
527
+ for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
528
+ const id = typeof messageID === "string" ? messageID : ""
529
+ if (id && entry.messageIDs.has(id)) return true
530
+ if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
531
+ entry.pending -= 1
532
+ if (id) entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
533
+ loopOwnedUserMessageGuards.set(sessionID, entry)
534
+ return true
522
535
  }
523
- return true
536
+ if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
537
+ return false
524
538
  }
525
539
 
526
540
  async function say(client, sessionID, text) {
@@ -1106,8 +1120,9 @@ function userInterruptSessionFromEvent(event) {
1106
1120
  const info = props.info || props.message || props
1107
1121
  const role = info?.role
1108
1122
  const sessionID = info?.sessionID || props.sessionID
1123
+ const messageID = info?.id || props.messageID
1109
1124
  if (role !== "user" || typeof sessionID !== "string") return undefined
1110
- if (loopOwnedUserMessageGuardActive(sessionID)) return undefined
1125
+ if (loopOwnedUserMessageGuardActive(sessionID, messageID)) return undefined
1111
1126
  return sessionID
1112
1127
  }
1113
1128