@bybrawe/opencode-loop 0.5.14 → 0.5.16

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.16
4
+
5
+ - Treat active OpenCode tools, shell calls, and foreground or background subtasks as busy even if the parent session receives an idle/status event while they are still running.
6
+ - Prevent stale active-run recovery from finalizing a loop turn while any tracked tool call remains active.
7
+ - Added regression coverage proving that a due loop waits for long-running tools and background child sessions, then resumes only after that work finishes and the parent session becomes idle.
8
+
9
+ ## 0.5.15
10
+
11
+ - Hardened experimental Goal Mode completion: agent tool completion now requires concrete evidence instead of accepting empty or generic claims.
12
+ - Required configured goal `--check` commands to pass before agent-tool completion, unless the user explicitly opts out or manually marks the goal done.
13
+ - Added a no-progress guard for goals. Goals pause after 3 turns without recorded meaningful progress by default, configurable with `--max-no-progress`.
14
+ - Paused active goals when a real user message arrives, so new user intent wins before the next automatic continuation.
15
+ - Preferred the OpenCode plugin SDK's `{ body }`, `{ path: { id }, body }`, and `{ query }` argument shapes while keeping flat and older path-key fallbacks.
16
+ - Added the resolved working directory and workspace-relative path rules to Goal Mode prompts so agents do not accidentally target filesystem-root paths.
17
+ - Increased Goal Mode's default active-turn recovery window to 3 minutes so slow model/tool turns are not finalized as stale after 20 seconds.
18
+ - Added plugin disposal cleanup for heartbeat, idle, due, watchdog, and active-run timers.
19
+ - Added an automated smoke test for SDK call order, Goal Mode prompts, evidence rejection, and successful completion state.
20
+ - Updated local installers to create or update config `package.json` with `@opencode-ai/plugin`, needed by local `.ts` plugins that import `tool()`.
21
+ - Updated Goal Mode status/report output and README guidance for the new guards.
22
+
3
23
  ## 0.5.14
4
24
 
5
25
  - Guarded active-run finalization so heartbeat and retry checks do not treat a still-running assistant turn as finished.
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.11 is a reliability hotfix for recurring timers that only continued after manual activity.** It keeps the real due timer from v0.5.8 and the stale-busy recovery from v0.5.9, then adds a watchdog checker and stronger stale-active-run recovery. This targets the case where `/loop 1m ...` reaches its due time but does not fire until you run `/loop-status` or type another command. Scheduled `/compact` still routes through OpenCode's current TUI alias first, with fallback paths for older builds.
19
+ **v0.5.16 fixes idle detection during long-running tools and background subtasks.** The scheduler now treats OpenCode tool lifecycle activity and running child sessions as stronger busy signals than a premature parent idle/status event, so due prompts wait instead of filling the queue. It keeps the Goal Mode and SDK compatibility hardening from v0.5.15.
20
20
 
21
21
  The known update-related symptoms from older builds are fixed:
22
22
 
@@ -26,20 +26,20 @@ The known update-related symptoms from older builds are fixed:
26
26
  - prompt, shell, or toast calls silently fail after OpenCode SDK changes
27
27
  - `/compact` accidentally being treated as a normal agent prompt
28
28
  - intermittent `Tool execution aborted` behavior caused by triggering a new turn too early
29
- - experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, or cleared
29
+ - experimental `/loop-goal` support for goal-driven work that continues until complete, blocked, paused, cleared, or paused by no-progress/user-interrupt guards
30
30
  - `--no-now` interval jobs not waking until another OpenCode event happens
31
31
  - `/loop-ask`, `/loop-command`, and `/loop-shell` being configured but not reliably firing on their own
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.11 quick behavior guide
35
+ ## v0.5.16 quick behavior guide
36
36
 
37
37
  OpenCode Loop has two triggers now:
38
38
 
39
39
  1. **Idle/status events** from OpenCode. These are used when OpenCode finishes a turn.
40
40
  2. **A real due timer** inside the plugin. This wakes delayed jobs even when no new OpenCode event happens.
41
41
 
42
- All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, the job does not interrupt the active turn. It waits and retries until the session becomes idle.
42
+ All TUI jobs are still idle-safe. If the timer expires while OpenCode is busy, or while a tool/background child session is still executing, the job does not interrupt the active turn. It waits and retries until that work finishes and the parent session becomes idle.
43
43
 
44
44
  Simple examples:
45
45
 
@@ -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 plugin and slash command files into your OpenCode config directory.
160
+ The installer copies the plugin and slash command files into your OpenCode config directory. It also ensures that the config directory has a `package.json` dependency on `@opencode-ai/plugin`, which local `.ts` plugins need for the `tool()` helper.
161
161
  It also removes the old local `opencode-loop.js` copy from earlier releases so only the current local `opencode-loop.ts` plugin remains.
162
162
 
163
163
  Windows target paths:
@@ -352,7 +352,7 @@ Experimental goal mode: keep working until the objective is actually done, block
352
352
 
353
353
  ### What “when idle” means
354
354
 
355
- OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy.
355
+ OpenCode Loop is idle-safe. It does **not** intentionally start a new agent turn while OpenCode is already busy. Active tool calls and running child sessions also count as busy, even if OpenCode emits a premature idle/status signal for the parent during long-running background work.
356
356
 
357
357
  Example:
358
358
 
@@ -390,7 +390,7 @@ That can look like a chat prompt in older versions or unclear setups. Prefer `/l
390
390
 
391
391
  ### Experimental Goal Mode
392
392
 
393
- Goal Mode is experimental. Use it when you do **not** want a timer such as “every 5 minutes”. Use it when you want OpenCode to keep pursuing a result until it is complete or blocked.
393
+ Goal Mode is experimental. Use it when you do **not** want a timer such as "every 5 minutes". Use it when you want OpenCode to keep pursuing a result until it is complete or blocked.
394
394
 
395
395
  Basic goal:
396
396
 
@@ -408,6 +408,16 @@ What happens:
408
408
 
409
409
  Goal Mode is idle-safe. If OpenCode is busy, it waits. It does not intentionally start another turn on top of a running turn.
410
410
 
411
+ Completion guards:
412
+
413
+ - The agent tool cannot mark a goal complete with empty or generic evidence. Evidence should mention commands, files, checks, results, or code inspection details.
414
+ - If a goal has `--check` commands, agent-tool completion requires the latest configured checks to pass by default.
415
+ - `/loop-goal-done` is still a manual user override when you want to mark the goal complete yourself.
416
+ - Goal Mode pauses when a real user message arrives, so a new instruction can take control before the next automatic continuation.
417
+ - Goal Mode pauses after 3 turns without recorded meaningful progress by default. Change this with `--max-no-progress <n>`, or disable it with `--max-no-progress 0`.
418
+ - Each Goal Mode prompt includes the resolved project directory and requires workspace-relative file paths.
419
+ - A running goal turn gets 3 minutes by default before stale-run recovery can finalize it. This avoids overlapping or prematurely closing slower model/tool turns.
420
+
411
421
  Simple status and control commands:
412
422
 
413
423
  ```text
@@ -451,6 +461,12 @@ Goal that automatically stops when all configured checks pass:
451
461
  /loop-goal --check "npm run typecheck" --check "npm run build" --complete-when-checks-pass make the project build cleanly
452
462
  ```
453
463
 
464
+ Goal that gives the agent more attempts before the no-progress guard pauses it:
465
+
466
+ ```text
467
+ /loop-goal --max-no-progress 6 --check "npm test" fix the failing tests and keep evidence in the goal report
468
+ ```
469
+
454
470
  Goal with a safety/runtime limit:
455
471
 
456
472
  ```text
@@ -1096,6 +1112,19 @@ Improve the application in small safe steps.
1096
1112
 
1097
1113
  ## Changelog highlights
1098
1114
 
1115
+ ### v0.5.16
1116
+
1117
+ - Treat active tools, shell calls, and running foreground/background child sessions as busy even when the parent session appears idle.
1118
+ - Prevent stale-run recovery from enqueueing another continuation while tracked background work is still active.
1119
+ - Added regression tests for long-running tool calls and background child sessions.
1120
+
1121
+ ### v0.5.15
1122
+
1123
+ - Hardened experimental Goal Mode completion with concrete evidence, passing-check requirements, no-progress pausing, and user-message interruption.
1124
+ - Preferred OpenCode plugin SDK `{ body }`, `{ path: { id }, body }`, and `{ query }` shapes before compatibility fallbacks.
1125
+ - Added explicit workspace path guidance, safer 3-minute active-turn recovery, timer disposal cleanup, and an automated Goal Mode smoke test.
1126
+ - Made local installers add the `@opencode-ai/plugin` dependency needed by local `.ts` plugins.
1127
+
1099
1128
  ### v0.5.13
1100
1129
 
1101
1130
  - Wrapped experimental goal tools with OpenCode's `tool()` helper and Zod schemas for current OpenCode tool validation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
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",
@@ -17,7 +17,8 @@
17
17
  "CHANGELOG.md"
18
18
  ],
19
19
  "scripts": {
20
- "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs",
20
+ "check": "node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/smoke-test.mjs",
21
+ "test": "node scripts/smoke-test.mjs",
21
22
  "install:global": "node scripts/install-node.mjs",
22
23
  "pack:zip": "node scripts/make-zip.mjs"
23
24
  },
@@ -58,8 +59,8 @@
58
59
  "@opencode-ai/plugin": "^1.4.0"
59
60
  },
60
61
  "bin": {
61
- "opencode-loop": "scripts/install-node.mjs",
62
- "opencode-loopd": "scripts/loopd.mjs"
62
+ "opencode-loop": "./scripts/install-node.mjs",
63
+ "opencode-loopd": "./scripts/loopd.mjs"
63
64
  },
64
65
  "publishConfig": {
65
66
  "access": "public"
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { copyFile, mkdir, readdir, rm } from "node:fs/promises"
2
+ import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
3
3
  import { homedir } from "node:os"
4
4
  import { dirname, join } from "node:path"
5
5
  import { fileURLToPath } from "node:url"
@@ -8,9 +8,30 @@ const root = dirname(dirname(fileURLToPath(import.meta.url)))
8
8
  const config = process.env.OPENCODE_CONFIG_DIR || join(homedir(), ".config", "opencode")
9
9
  const pluginDir = join(config, "plugins")
10
10
  const commandDir = join(config, "commands")
11
+ const packagePath = join(config, "package.json")
12
+
13
+ async function ensureDependency() {
14
+ let pkg = {}
15
+ try {
16
+ pkg = JSON.parse(await readFile(packagePath, "utf8"))
17
+ } catch (error) {
18
+ if (error?.code !== "ENOENT") {
19
+ console.warn(`Could not update ${packagePath}: ${error.message}`)
20
+ console.warn('Add "@opencode-ai/plugin": ">=1.4.0" to that package.json if OpenCode cannot load the local plugin.')
21
+ return
22
+ }
23
+ }
24
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
25
+ pkg.dependencies = pkg.dependencies && typeof pkg.dependencies === "object" && !Array.isArray(pkg.dependencies) ? pkg.dependencies : {}
26
+ if (!pkg.dependencies["@opencode-ai/plugin"]) {
27
+ pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
28
+ await writeFile(packagePath, JSON.stringify(pkg, null, 2) + "\n", "utf8")
29
+ }
30
+ }
11
31
 
12
32
  await mkdir(pluginDir, { recursive: true })
13
33
  await mkdir(commandDir, { recursive: true })
34
+ await ensureDependency()
14
35
  await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
15
36
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
16
37
 
@@ -8,6 +8,24 @@ $Root = Split-Path -Parent $PSScriptRoot
8
8
  New-Item -ItemType Directory -Force -Path $PluginDir | Out-Null
9
9
  New-Item -ItemType Directory -Force -Path $CommandDir | Out-Null
10
10
 
11
+ $PackageFile = Join-Path $ConfigDir "package.json"
12
+ if (Test-Path $PackageFile) {
13
+ try {
14
+ $PackageJson = Get-Content -Raw -Path $PackageFile | ConvertFrom-Json
15
+ if (-not $PackageJson.dependencies) {
16
+ $PackageJson | Add-Member -MemberType NoteProperty -Name dependencies -Value ([pscustomobject]@{})
17
+ }
18
+ if (-not $PackageJson.dependencies.PSObject.Properties["@opencode-ai/plugin"]) {
19
+ $PackageJson.dependencies | Add-Member -MemberType NoteProperty -Name "@opencode-ai/plugin" -Value ">=1.4.0"
20
+ $PackageJson | ConvertTo-Json -Depth 20 | Set-Content -Path $PackageFile -Encoding UTF8
21
+ }
22
+ } catch {
23
+ Write-Warning "Could not update $PackageFile. Add '@opencode-ai/plugin': '>=1.4.0' if OpenCode cannot load the local plugin."
24
+ }
25
+ } else {
26
+ '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' | Set-Content -Path $PackageFile -Encoding UTF8
27
+ }
28
+
11
29
  Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
12
30
  Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
13
31
  Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
@@ -7,6 +7,31 @@ COMMAND_DIR="$CONFIG_DIR/commands"
7
7
  ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
8
8
 
9
9
  mkdir -p "$PLUGIN_DIR" "$COMMAND_DIR"
10
+ PACKAGE_FILE="$CONFIG_DIR/package.json"
11
+ if command -v node >/dev/null 2>&1; then
12
+ PACKAGE_FILE="$PACKAGE_FILE" node --input-type=module - <<'NODE'
13
+ import fs from "node:fs"
14
+
15
+ const file = process.env.PACKAGE_FILE
16
+ let pkg = {}
17
+ try {
18
+ pkg = JSON.parse(fs.readFileSync(file, "utf8"))
19
+ } catch (error) {
20
+ if (error.code !== "ENOENT") {
21
+ console.warn(`Could not update ${file}: ${error.message}`)
22
+ process.exit(0)
23
+ }
24
+ }
25
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
26
+ if (!pkg.dependencies || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) pkg.dependencies = {}
27
+ if (!pkg.dependencies["@opencode-ai/plugin"]) {
28
+ pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
29
+ fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n")
30
+ }
31
+ NODE
32
+ elif [ ! -f "$PACKAGE_FILE" ]; then
33
+ printf '%s\n' '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' > "$PACKAGE_FILE"
34
+ fi
10
35
  cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
11
36
  rm -f "$PLUGIN_DIR/opencode-loop.js"
12
37
  cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
@@ -0,0 +1,158 @@
1
+ import assert from "node:assert/strict"
2
+ import { promises as fs } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ import OpenCodeLoopPlugin from "../src/index.js"
7
+
8
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-smoke-"))
9
+ const sessionID = "ses_smoke_goal"
10
+ const attempts = { log: 0, prompt: 0, status: 0, toast: 0 }
11
+ const prompts = []
12
+ const liveStatuses = new Map([[sessionID, "idle"]])
13
+ let hooks
14
+
15
+ function legacyBody(name) {
16
+ return async (args) => {
17
+ attempts[name]++
18
+ assert.ok(args?.body, `${name} must use the plugin SDK body shape first`)
19
+ return { data: true }
20
+ }
21
+ }
22
+
23
+ const client = {
24
+ app: {
25
+ log: legacyBody("log"),
26
+ },
27
+ tui: {
28
+ executeCommand: async (args) => {
29
+ assert.ok(args?.body?.command)
30
+ return { data: true }
31
+ },
32
+ showToast: legacyBody("toast"),
33
+ },
34
+ session: {
35
+ list: async (args) => {
36
+ assert.equal(args?.query?.directory, directory)
37
+ return { data: [{ id: sessionID, directory, projectID: "project", title: "smoke", version: "test", time: { created: Date.now(), updated: Date.now() } }] }
38
+ },
39
+ abort: async (args) => {
40
+ assert.equal(args?.path?.id, sessionID)
41
+ return { data: true }
42
+ },
43
+ command: async (args) => {
44
+ assert.equal(args?.path?.id, sessionID)
45
+ assert.ok(args?.body?.command)
46
+ return { data: true }
47
+ },
48
+ prompt: async (args) => {
49
+ attempts.prompt++
50
+ assert.equal(args?.path?.id, sessionID, "session.prompt must use path.id first")
51
+ assert.ok(Array.isArray(args?.body?.parts))
52
+ prompts.push(args.body.parts.map((part) => part.text || "").join("\n"))
53
+ return { data: true }
54
+ },
55
+ shell: async (args) => {
56
+ assert.equal(args?.path?.id, sessionID)
57
+ assert.ok(args?.body?.command)
58
+ return { data: true }
59
+ },
60
+ status: async (args) => {
61
+ attempts.status++
62
+ assert.equal(args?.query?.directory, directory, "session.status must use query.directory first")
63
+ return { data: Object.fromEntries([...liveStatuses].filter(([, type]) => type !== "idle").map(([id, type]) => [id, { type }])) }
64
+ },
65
+ summarize: async (args) => {
66
+ assert.equal(args?.path?.id, sessionID)
67
+ return { data: true }
68
+ },
69
+ },
70
+ }
71
+
72
+ try {
73
+ hooks = await OpenCodeLoopPlugin({ client, directory })
74
+ assert.deepEqual(Object.keys(hooks.tool).sort(), [
75
+ "opencode_loop_goal_blocked",
76
+ "opencode_loop_goal_complete",
77
+ "opencode_loop_goal_progress",
78
+ ])
79
+
80
+ const output = { parts: [{ type: "text", text: "original command body" }] }
81
+ await hooks["command.execute.before"]({
82
+ command: "loop-goal",
83
+ sessionID,
84
+ arguments: "Create proof.txt and verify it --max-turns 3",
85
+ }, output)
86
+ assert.match(output.parts[0].text, /handled by the local plugin/i)
87
+
88
+ 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")
91
+ assert.ok(prompts.some((prompt) => prompt.includes("never turn a relative path into a root path")))
92
+
93
+ const stateFile = path.join(directory, ".opencode", "opencode-loop", `${sessionID}.json`)
94
+ const activeState = JSON.parse(await fs.readFile(stateFile, "utf8"))
95
+ assert.equal(activeState.jobs[0].activeRecoveryMs, 180_000)
96
+
97
+ const rejected = await hooks.tool.opencode_loop_goal_complete.execute({ summary: "Done", evidence: "done" }, { directory, sessionID })
98
+ assert.equal(rejected.title, "Goal completion rejected")
99
+
100
+ const completed = await hooks.tool.opencode_loop_goal_complete.execute({
101
+ summary: "Created and verified proof.txt",
102
+ evidence: "Created proof.txt and read the file back; its exact content matched the requested value.",
103
+ }, { directory, sessionID })
104
+ assert.equal(completed.title, "Goal completed")
105
+
106
+ const completedState = JSON.parse(await fs.readFile(stateFile, "utf8"))
107
+ assert.equal(completedState.jobs[0].goalStatus, "completed")
108
+ assert.equal(completedState.jobs[0].paused, true)
109
+ assert.equal(completedState.jobs[0].enabled, false)
110
+
111
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
112
+
113
+ const promptCountBeforeBackgroundTool = prompts.length
114
+ await hooks["tool.execute.before"]({ tool: "bash", sessionID, callID: "call_background" }, { args: {} })
115
+ await hooks["command.execute.before"]({
116
+ command: "loop",
117
+ sessionID,
118
+ arguments: "0s --max-runs 1 continue after the background task",
119
+ }, { parts: [] })
120
+ await new Promise((resolve) => setTimeout(resolve, 1_400))
121
+ assert.equal(prompts.length, promptCountBeforeBackgroundTool, "an active tool call must keep the loop busy even when session.status says idle")
122
+
123
+ await hooks["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_background", args: {} }, { title: "done", output: "", metadata: {} })
124
+ 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")
127
+
128
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
129
+
130
+ const childSessionID = "ses_background_child"
131
+ liveStatuses.set(childSessionID, "busy")
132
+ await hooks.event({ event: { type: "session.created", properties: { info: { id: childSessionID, parentID: sessionID } } } })
133
+ await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "busy" } } } })
134
+ const promptCountBeforeBackgroundChild = prompts.length
135
+ await hooks["command.execute.before"]({
136
+ command: "loop",
137
+ sessionID,
138
+ arguments: "0s --max-runs 1 continue after the background subtask",
139
+ }, { parts: [] })
140
+ await new Promise((resolve) => setTimeout(resolve, 2_700))
141
+ assert.equal(prompts.length, promptCountBeforeBackgroundChild, "a running background child session must keep its parent loop busy")
142
+
143
+ liveStatuses.set(childSessionID, "idle")
144
+ await hooks.event({ event: { type: "session.status", properties: { sessionID: childSessionID, status: { type: "idle" } } } })
145
+ 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")
148
+
149
+ await hooks["command.execute.before"]({ command: "loop-clear", sessionID, arguments: "" }, { parts: [] })
150
+ assert.equal(attempts.log, 1)
151
+ assert.ok(attempts.prompt >= 1)
152
+ assert.ok(attempts.status >= 1)
153
+ assert.ok(attempts.toast >= 1)
154
+ console.log("OpenCode Loop smoke test passed")
155
+ } finally {
156
+ await hooks?.dispose?.()
157
+ await fs.rm(directory, { recursive: true, force: true })
158
+ }
package/src/index.js CHANGED
@@ -17,15 +17,21 @@ const MAX_SCAN_FILES = 200
17
17
  const MAX_SCAN_BYTES = 2_000_000
18
18
  const GOAL_REPORT_DIR = "goals"
19
19
  const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
20
+ const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
21
+ const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
22
+ const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
20
23
 
21
24
  const activeRuns = new Map()
22
25
  const handledCommands = new Map()
26
+ const loopOwnedUserMessageGuards = new Map()
23
27
  const idleTimers = new Map()
24
28
  const dueTimers = new Map()
25
29
  const watchdogTimers = new Map()
26
30
  const runLocks = new Map()
27
31
  const knownSessions = new Map()
28
32
  const stateWriteLocks = new Map()
33
+ const activeToolCalls = new Map()
34
+ const sessionParents = new Map()
29
35
  let heartbeatTimer
30
36
  const sessionStatuses = new Map()
31
37
  const sessionStatusSeenAt = new Map()
@@ -142,6 +148,11 @@ function parsePositiveInt(value, fallback = 0) {
142
148
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
143
149
  }
144
150
 
151
+ function parseNonNegativeInt(value, fallback = 0) {
152
+ const parsed = Number.parseInt(String(value || ""), 10)
153
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
154
+ }
155
+
145
156
  function parseCompactEvery(value) {
146
157
  const duration = parseDuration(value)
147
158
  if (duration !== null) return { compactEveryMs: duration }
@@ -203,11 +214,16 @@ function parseLoopArgs(raw, defaults = {}) {
203
214
  goalAcceptance: Array.isArray(defaults.goalAcceptance) ? [...defaults.goalAcceptance] : [],
204
215
  goalChecks: Array.isArray(defaults.goalChecks) ? [...defaults.goalChecks] : [],
205
216
  goalCompleteWhenChecksPass: defaults.goalCompleteWhenChecksPass ?? false,
217
+ goalRequireEvidence: defaults.goalRequireEvidence,
218
+ goalRequireChecksPass: defaults.goalRequireChecksPass,
206
219
  goalEvidenceFile: defaults.goalEvidenceFile,
207
220
  goalSummary: defaults.goalSummary || "",
208
221
  goalEvidence: defaults.goalEvidence || "",
209
222
  goalBlockedReason: defaults.goalBlockedReason || "",
210
223
  goalProgress: Array.isArray(defaults.goalProgress) ? [...defaults.goalProgress] : [],
224
+ maxNoProgress: defaults.maxNoProgress,
225
+ noProgressCount: defaults.noProgressCount ?? 0,
226
+ lastProgressAt: defaults.lastProgressAt ?? 0,
211
227
  noOverlap: defaults.noOverlap ?? true,
212
228
  safe: defaults.safe ?? false,
213
229
  quiet: defaults.quiet ?? false,
@@ -255,10 +271,16 @@ function parseLoopArgs(raw, defaults = {}) {
255
271
  ;[found, rest] = takeFlag(rest, "--goal"); if (found) job.kind = "goal"
256
272
  ;[found, rest] = takeFlag(rest, "--complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = true
257
273
  ;[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass"); if (found) job.goalCompleteWhenChecksPass = false
274
+ ;[found, rest] = takeFlag(rest, "--require-evidence"); if (found) job.goalRequireEvidence = true
275
+ ;[found, rest] = takeFlag(rest, "--allow-weak-evidence"); if (found) job.goalRequireEvidence = false
276
+ ;[found, rest] = takeFlag(rest, "--require-checks-pass"); if (found) job.goalRequireChecksPass = true
277
+ ;[found, rest] = takeFlag(rest, "--allow-complete-without-checks"); if (found) job.goalRequireChecksPass = false
278
+ ;[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks"); if (found) job.goalRequireChecksPass = false
258
279
 
259
280
  ;[value, rest] = takeFlagValue(rest, "--name"); if (value !== undefined) job.name = value.trim()
260
281
  ;[value, rest] = takeFlagValue(rest, "--max-runs"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
261
282
  ;[value, rest] = takeFlagValue(rest, "--max-turns"); if (value !== undefined) job.maxRuns = parsePositiveInt(value, 0)
283
+ ;[value, rest] = takeFlagValue(rest, "--max-no-progress"); if (value !== undefined) job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS)
262
284
  ;[value, rest] = takeFlagValue(rest, "--timeout"); if (value !== undefined) job.timeoutMs = parseDuration(value) ?? 0
263
285
  ;[value, rest] = takeFlagValue(rest, "--max-runtime"); if (value !== undefined) job.maxRuntimeMs = parseDuration(value) ?? 0
264
286
  ;[value, rest] = takeFlagValue(rest, "--max-failures"); if (value !== undefined) job.maxFailures = parsePositiveInt(value, 0)
@@ -309,6 +331,9 @@ function parseLoopArgs(raw, defaults = {}) {
309
331
  job.safe = job.safe !== false
310
332
  job.askNever = job.askNever !== false
311
333
  job.noOverlap = job.noOverlap !== false
334
+ job.goalRequireEvidence = job.goalRequireEvidence !== false
335
+ job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
336
+ job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
312
337
  }
313
338
  job.lastRunAt = job.immediate ? 0 : now()
314
339
 
@@ -399,32 +424,25 @@ function sdkErrorMessage(error) {
399
424
  return String(error)
400
425
  }
401
426
 
402
- async function sdkCall(method, modernArgs, legacyArgs) {
427
+ async function sdkCall(method, ...argsList) {
403
428
  let firstError
404
- try {
405
- const result = await method(modernArgs)
406
- const error = sdkError(result)
407
- if (!error) return sdkData(result)
408
- firstError = new Error(sdkErrorMessage(error))
409
- } catch (error) {
410
- firstError = error
411
- }
412
- if (legacyArgs !== undefined) {
429
+ for (const args of argsList) {
430
+ if (args === undefined) continue
413
431
  try {
414
- const result = await method(legacyArgs)
432
+ const result = await method(args)
415
433
  const error = sdkError(result)
416
434
  if (!error) return sdkData(result)
417
- throw new Error(sdkErrorMessage(error))
435
+ firstError = firstError || new Error(sdkErrorMessage(error))
418
436
  } catch (error) {
419
- throw error
437
+ firstError = firstError || error
420
438
  }
421
439
  }
422
- throw firstError
440
+ throw firstError || new Error("SDK call failed without arguments")
423
441
  }
424
442
 
425
- function fireSdk(client, label, method, modernArgs, legacyArgs) {
443
+ function fireSdk(client, label, method, ...argsList) {
426
444
  Promise.resolve()
427
- .then(() => sdkCall(method, modernArgs, legacyArgs))
445
+ .then(() => sdkCall(method, ...argsList))
428
446
  .catch((error) => log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }))
429
447
  }
430
448
 
@@ -457,7 +475,12 @@ async function compactSession(client, sessionID) {
457
475
  }
458
476
  }
459
477
  try {
460
- await sdkCall(client.session.summarize.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID })
478
+ await sdkCall(
479
+ client.session.summarize.bind(client.session),
480
+ { path: { id: sessionID }, body: {} },
481
+ { path: { sessionID }, body: {} },
482
+ { sessionID },
483
+ )
461
484
  return true
462
485
  } catch (error) {
463
486
  await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
@@ -480,11 +503,29 @@ async function toast(client, message, variant = "info") {
480
503
  try { await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant }) } catch {}
481
504
  }
482
505
 
506
+ function guardLoopOwnedUserMessage(sessionID) {
507
+ if (!sessionID) return
508
+ loopOwnedUserMessageGuards.set(sessionID, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
509
+ for (const [key, time] of loopOwnedUserMessageGuards.entries()) if (time < now()) loopOwnedUserMessageGuards.delete(key)
510
+ }
511
+
512
+ function loopOwnedUserMessageGuardActive(sessionID) {
513
+ const until = loopOwnedUserMessageGuards.get(sessionID)
514
+ if (typeof until !== "number") return false
515
+ if (until < now()) {
516
+ loopOwnedUserMessageGuards.delete(sessionID)
517
+ return false
518
+ }
519
+ return true
520
+ }
521
+
483
522
  async function say(client, sessionID, text) {
523
+ guardLoopOwnedUserMessage(sessionID)
484
524
  try {
485
525
  await sdkCall(
486
526
  client.session.prompt.bind(client.session),
487
527
  { path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
528
+ { path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
488
529
  { sessionID, noReply: true, parts: [{ type: "text", text }] },
489
530
  )
490
531
  } catch {}
@@ -502,6 +543,9 @@ function wasHandled(sessionID, name, args) {
502
543
 
503
544
  function commandName(name) { return String(name || "") }
504
545
  function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
546
+ function isLoopCommandName(name) {
547
+ return name === "loop" || name === "loop-stop" || name === "loop-remove" || name === "loop-clear" || name === "loop-status" || name === "loop-logs" || name === "loop-help" || name === "loop-now" || name === "loop-pause" || name === "loop-resume" || name === "loop-doctor" || name === "loop-init" || name === "loop-export" || name === "loop-goal" || name === "loop-goal-status" || name === "loop-goal-pause" || name === "loop-goal-resume" || name === "loop-goal-clear" || name === "loop-goal-done" || name === "loop-goal-complete" || name === "loop-goal-blocked" || isPreset(name)
548
+ }
505
549
 
506
550
  function normalizeArgsForKey(args) {
507
551
  if (args === undefined || args === null) return ""
@@ -528,6 +572,100 @@ function rememberSession(directory, client, sessionID) {
528
572
  startHeartbeat()
529
573
  }
530
574
 
575
+ function hasActiveToolCalls(sessionID) {
576
+ return (activeToolCalls.get(sessionID)?.size || 0) > 0
577
+ }
578
+
579
+ function markToolCallActive(input) {
580
+ const sessionID = input?.sessionID
581
+ const callID = input?.callID
582
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
583
+ const calls = activeToolCalls.get(sessionID) || new Set()
584
+ calls.add(callID)
585
+ activeToolCalls.set(sessionID, calls)
586
+ sessionStatuses.set(sessionID, "busy")
587
+ sessionStatusSeenAt.set(sessionID, now())
588
+ }
589
+
590
+ function markToolCallFinished(input) {
591
+ const sessionID = input?.sessionID
592
+ const callID = input?.callID
593
+ if (typeof sessionID !== "string" || typeof callID !== "string") return
594
+ const calls = activeToolCalls.get(sessionID)
595
+ if (!calls) return
596
+ calls.delete(callID)
597
+ if (!calls.size) activeToolCalls.delete(sessionID)
598
+ }
599
+
600
+ function updateSessionRelationship(info, removed = false) {
601
+ const sessionID = info?.id
602
+ if (typeof sessionID !== "string") return
603
+ if (removed || typeof info?.parentID !== "string") sessionParents.delete(sessionID)
604
+ else sessionParents.set(sessionID, info.parentID)
605
+ }
606
+
607
+ function updateSessionRelationshipFromEvent(event) {
608
+ if (!["session.created", "session.updated", "session.deleted"].includes(event?.type)) return
609
+ updateSessionRelationship(event?.properties?.info, event.type === "session.deleted")
610
+ }
611
+
612
+ function isDescendantSession(sessionID, ancestorID) {
613
+ const visited = new Set()
614
+ let current = sessionID
615
+ while (sessionParents.has(current) && !visited.has(current)) {
616
+ visited.add(current)
617
+ current = sessionParents.get(current)
618
+ if (current === ancestorID) return true
619
+ }
620
+ return false
621
+ }
622
+
623
+ function hasBusyDescendant(sessionID) {
624
+ for (const childID of sessionParents.keys()) {
625
+ if (!isDescendantSession(childID, sessionID)) continue
626
+ const status = sessionStatuses.get(childID)
627
+ if (status === "busy" || status === "retry" || hasActiveToolCalls(childID)) return true
628
+ }
629
+ return false
630
+ }
631
+
632
+ async function refreshSessionRelationships(client, directory) {
633
+ if (!client?.session?.list) return
634
+ try {
635
+ const sessions = await sdkCall(
636
+ client.session.list.bind(client.session),
637
+ { query: { directory } },
638
+ { directory },
639
+ {},
640
+ )
641
+ if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
642
+ } catch {}
643
+ }
644
+
645
+ function updateToolActivityFromEvent(event) {
646
+ const props = event?.properties || {}
647
+ if (event?.type === "message.part.updated") {
648
+ const part = props.part
649
+ if (part?.type !== "tool") return
650
+ const sessionID = part.sessionID || props.sessionID
651
+ if (["pending", "running"].includes(part.state?.status)) {
652
+ markToolCallActive({ sessionID, callID: part.callID })
653
+ }
654
+ if (["completed", "error"].includes(part.state?.status)) {
655
+ // Task/subagent hooks have used the part id as their hook call id in some
656
+ // OpenCode versions, while normal tools use part.callID. Clear either.
657
+ const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))]
658
+ for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
659
+ }
660
+ return
661
+ }
662
+
663
+ const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type)
664
+ const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type)
665
+ if (started) markToolCallActive(props)
666
+ if (finished) markToolCallFinished(props)
667
+ }
668
+
531
669
  function startHeartbeat() {
532
670
  if (heartbeatTimer) return
533
671
  heartbeatTimer = setInterval(() => {
@@ -549,6 +687,34 @@ function startHeartbeat() {
549
687
  }
550
688
  }, HEARTBEAT_MS)
551
689
  }
690
+
691
+ function disposeRuntime(directory, client) {
692
+ const sessions = [...knownSessions.entries()]
693
+ .filter(([, info]) => info?.directory === directory && info?.client === client)
694
+ .map(([sessionID]) => sessionID)
695
+ for (const sessionID of sessions) {
696
+ clearActiveRun(sessionID)
697
+ const idle = idleTimers.get(sessionID); if (idle) clearTimeout(idle)
698
+ const due = dueTimers.get(sessionID); if (due) clearTimeout(due)
699
+ const watchdog = watchdogTimers.get(sessionID); if (watchdog) clearInterval(watchdog)
700
+ idleTimers.delete(sessionID)
701
+ dueTimers.delete(sessionID)
702
+ watchdogTimers.delete(sessionID)
703
+ runLocks.delete(sessionID)
704
+ knownSessions.delete(sessionID)
705
+ loopOwnedUserMessageGuards.delete(sessionID)
706
+ activeToolCalls.delete(sessionID)
707
+ sessionParents.delete(sessionID)
708
+ sessionStatuses.delete(sessionID)
709
+ sessionStatusSeenAt.delete(sessionID)
710
+ for (const key of handledCommands.keys()) if (key.startsWith(`${sessionID}:`)) handledCommands.delete(key)
711
+ }
712
+ if (!knownSessions.size && heartbeatTimer) {
713
+ clearInterval(heartbeatTimer)
714
+ heartbeatTimer = undefined
715
+ }
716
+ }
717
+
552
718
  function presetDefaults(name, args) {
553
719
  const [maybeDuration, rest] = splitFirst(args)
554
720
  const parsed = parseDuration(maybeDuration)
@@ -575,10 +741,11 @@ function jobLabel(job) {
575
741
  const verify = job.verifyCommand ? ", verify" : ""
576
742
  const preflight = job.preflightCommand ? ", preflight" : ""
577
743
  const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
744
+ const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
578
745
  const stopFile = job.stopFile ? ", stop-file" : ""
579
746
  const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
580
747
  const paused = job.paused ? ", paused" : ""
581
- return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${stopFile}${watch}${paused}`
748
+ return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
582
749
  }
583
750
 
584
751
  function matchJob(job, target, index) {
@@ -682,6 +849,7 @@ function goalStatusText(job) {
682
849
 
683
850
  async function buildGoalPrompt(directory, job) {
684
851
  const sections = []
852
+ sections.push(`Working directory:\n${path.resolve(directory)}\nKeep every file operation inside this directory. Prefer workspace-relative paths such as \"src/index.js\"; never turn a relative path into a root path such as \"/src/index.js\".`)
685
853
  const objective = String(job.action || "").trim()
686
854
  if (objective) sections.push(`Goal objective:\n${objective}`)
687
855
  if (job.goalFile) {
@@ -698,6 +866,8 @@ async function buildGoalPrompt(directory, job) {
698
866
  if (job.verifyCommand) sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`)
699
867
  if (job.lastGoalChecks?.length) sections.push("Latest goal check results:\n" + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join("\n"))
700
868
  if (job.lastVerifyFailure) sections.push("Previous verify/check failure summary:\n" + String(job.lastVerifyFailure).slice(0, 1600))
869
+ if (job.goalCompletionRejectedReason) sections.push(`Previous completion attempt was rejected:\n${job.goalCompletionRejectedReason}`)
870
+ if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) sections.push(`No-progress guard:\n${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`)
701
871
  if (job.goalProgress?.length) sections.push("Recent goal progress:\n" + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join("\n"))
702
872
  for (const file of job.includeFiles || []) {
703
873
  const text = await readSmallTextFile(path.resolve(directory, file), 80_000)
@@ -712,9 +882,12 @@ Rules:
712
882
  - Work on the next smallest useful step toward the goal.
713
883
  - Prefer direct code changes, tests, typechecks, builds, and evidence over discussion.
714
884
  - Do not claim the goal is complete unless the acceptance criteria are satisfied and verification evidence supports it.
885
+ - If verification commands are configured, do not call opencode_loop_goal_complete until the latest relevant checks have passed unless the user explicitly overrides the goal.
886
+ - Completion evidence must be concrete: mention commands, files, checks, results, or code inspection details.
715
887
  - When the goal is complete, call the tool opencode_loop_goal_complete with a summary and evidence.
716
888
  - If you are truly blocked and need user input, call the tool opencode_loop_goal_blocked with the reason and what is needed.
717
889
  - If you made meaningful progress but the goal is not complete, call the tool opencode_loop_goal_progress with the summary and next step.
890
+ - If you cannot make meaningful progress for this turn, call opencode_loop_goal_blocked instead of repeating the same attempt.
718
891
  - Do not call completion tools just to be polite; only call them when the state is real.
719
892
  - Do not ask the user questions unless blocked; make reasonable assumptions and continue.
720
893
  - Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
@@ -852,6 +1025,39 @@ function updateSessionStatusFromEvent(event) {
852
1025
  return undefined
853
1026
  }
854
1027
 
1028
+ function userInterruptSessionFromEvent(event) {
1029
+ if (!["message.updated", "message.created"].includes(String(event?.type || ""))) return undefined
1030
+ const props = event?.properties || {}
1031
+ const info = props.info || props.message || props
1032
+ const role = info?.role
1033
+ const sessionID = info?.sessionID || props.sessionID
1034
+ if (role !== "user" || typeof sessionID !== "string") return undefined
1035
+ if (loopOwnedUserMessageGuardActive(sessionID)) return undefined
1036
+ return sessionID
1037
+ }
1038
+
1039
+ async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
1040
+ const state = await readState(directory, sessionID)
1041
+ const interruptedAt = now()
1042
+ let count = 0
1043
+ state.jobs = (state.jobs || []).map((job) => {
1044
+ if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus)) return job
1045
+ count++
1046
+ return {
1047
+ ...job,
1048
+ paused: true,
1049
+ lastUserInterruptAt: interruptedAt,
1050
+ goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active.",
1051
+ }
1052
+ })
1053
+ if (!count) return false
1054
+ await writeState(directory, sessionID, state)
1055
+ await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning")
1056
+ await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count })
1057
+ await scheduleDueWork(directory, client, sessionID)
1058
+ return true
1059
+ }
1060
+
855
1061
  function staleActiveRun(sessionID) {
856
1062
  const active = activeRuns.get(sessionID)
857
1063
  if (!active) return false
@@ -862,6 +1068,7 @@ function staleActiveRun(sessionID) {
862
1068
  }
863
1069
 
864
1070
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
1071
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
865
1072
  if (!options.requireIdle && !options.forceStale) return true
866
1073
  if (options.forceStale && staleActiveRun(sessionID)) return true
867
1074
  if (!options.requireIdle) return false
@@ -876,7 +1083,7 @@ async function canFinalizeActiveRun(directory, client, sessionID, active, option
876
1083
 
877
1084
  async function readLiveSessionStatus(client, sessionID, directory) {
878
1085
  const argsList = []
879
- if (directory) argsList.push({ query: { directory } }, { workspace: directory }, { directory })
1086
+ if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
880
1087
  argsList.push({})
881
1088
  for (const args of argsList) {
882
1089
  try {
@@ -884,15 +1091,41 @@ async function readLiveSessionStatus(client, sessionID, directory) {
884
1091
  const error = sdkError(result)
885
1092
  if (error) continue
886
1093
  const data = sdkData(result)
1094
+ if (!data || typeof data !== "object" || Array.isArray(data)) continue
1095
+ const observedAt = now()
1096
+ for (const [observedSessionID, observedStatus] of Object.entries(data)) {
1097
+ const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
1098
+ if (typeof observedType !== "string") continue
1099
+ sessionStatuses.set(observedSessionID, observedType)
1100
+ sessionStatusSeenAt.set(observedSessionID, observedAt)
1101
+ }
1102
+ // OpenCode's status list contains active sessions; idle sessions are
1103
+ // normally omitted. Clear a completed descendant that was previously busy.
1104
+ for (const childID of sessionParents.keys()) {
1105
+ if (!isDescendantSession(childID, sessionID) || data[childID]) continue
1106
+ sessionStatuses.set(childID, "idle")
1107
+ sessionStatusSeenAt.set(childID, observedAt)
1108
+ }
1109
+ if (hasBusyDescendant(sessionID)) return { type: "busy", source: "descendant" }
887
1110
  const status = data?.[sessionID]
888
1111
  const type = status && typeof status === "object" ? status.type : undefined
889
1112
  if (typeof type === "string") return { type, source: "sdk" }
1113
+ return { type: "idle", source: "sdk" }
890
1114
  } catch {}
891
1115
  }
892
1116
  return undefined
893
1117
  }
894
1118
 
895
1119
  async function sessionStatusType(client, sessionID, directory, options = {}) {
1120
+ // OpenCode can briefly report an idle session while a long-running tool or
1121
+ // subtask is still executing. Tool lifecycle hooks are the more specific
1122
+ // signal here, so never enqueue another turn until every active call ends.
1123
+ if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
1124
+ sessionStatuses.set(sessionID, "busy")
1125
+ sessionStatusSeenAt.set(sessionID, now())
1126
+ return "busy"
1127
+ }
1128
+
896
1129
  const cached = sessionStatuses.get(sessionID)
897
1130
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
898
1131
 
@@ -1075,11 +1308,17 @@ function goalReportText(job) {
1075
1308
  lines.push(`Created: ${job.createdAt || ""}`)
1076
1309
  if (job.goalCompletedAt) lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`)
1077
1310
  if (job.goalBlockedAt) lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`)
1311
+ if (job.lastUserInterruptAt) lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`)
1312
+ if (job.goalNoProgressPausedAt) lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`)
1078
1313
  if (job.runCount) lines.push(`Turns: ${job.runCount}`)
1314
+ if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0) lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`)
1079
1315
  lines.push("")
1080
1316
  if (job.goalSummary) lines.push("## Summary", "", String(job.goalSummary), "")
1081
1317
  if (job.goalEvidence) lines.push("## Evidence", "", String(job.goalEvidence), "")
1082
1318
  if (job.goalBlockedReason) lines.push("## Blocked reason", "", String(job.goalBlockedReason), "")
1319
+ if (job.goalCompletionRejectedReason) lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "")
1320
+ if (job.goalInterruptedReason) lines.push("## Interrupt", "", String(job.goalInterruptedReason), "")
1321
+ if (job.goalNoProgressReason) lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "")
1083
1322
  if (job.goalAcceptance?.length) lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "")
1084
1323
  if (job.lastGoalChecks?.length) {
1085
1324
  lines.push("## Latest checks", "")
@@ -1115,17 +1354,101 @@ function parseGoalToolText(args, fields) {
1115
1354
  return result
1116
1355
  }
1117
1356
 
1357
+ function hasConcreteGoalEvidence(value) {
1358
+ const text = String(value || "").trim()
1359
+ if (text.length < 24) return false
1360
+ const normalized = text.toLowerCase().replace(/\s+/g, " ")
1361
+ const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"])
1362
+ if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done")) return false
1363
+ return /(\b(npm|pnpm|yarn|bun|node|pytest|cargo|dotnet|go test|tsc|typecheck|test|tests|lint|build|check|checks|exit\s*\d|passed|verified|changed|updated|created|fixed|file|files|diff|commit)\b|[`\\/][\w./:-]+)/i.test(text) || text.length >= 80
1364
+ }
1365
+
1366
+ function goalChecksPassed(job) {
1367
+ return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
1368
+ }
1369
+
1370
+ function goalRequiresPassingChecks(job) {
1371
+ return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
1372
+ }
1373
+
1374
+ async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
1375
+ job.goalCompletionRejectedAt = now()
1376
+ job.goalCompletionRejectedReason = reason
1377
+ job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
1378
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1379
+ await writeState(directory, sessionID, state)
1380
+ await writeGoalReport(directory, sessionID, job)
1381
+ await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
1382
+ return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
1383
+ }
1384
+
1385
+ function goalProgressSnapshot(job) {
1386
+ return {
1387
+ status: job?.goalStatus || "",
1388
+ progressCount: Array.isArray(job?.goalProgress) ? job.goalProgress.length : 0,
1389
+ evidence: String(job?.goalEvidence || ""),
1390
+ checksPassedAt: Number(job?.goalChecksPassedAt || 0),
1391
+ lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
1392
+ lastVerifyAt: Number(job?.lastVerifyAt || 0),
1393
+ lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined,
1394
+ }
1395
+ }
1396
+
1397
+ function goalMadeMeaningfulProgress(beforeJob, afterJob) {
1398
+ const before = goalProgressSnapshot(beforeJob || {})
1399
+ const after = goalProgressSnapshot(afterJob || {})
1400
+ if (["completed", "blocked"].includes(after.status) && after.status !== before.status) return true
1401
+ if (after.progressCount > before.progressCount) return true
1402
+ if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence)) return true
1403
+ if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt) return true
1404
+ if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0) return true
1405
+ return false
1406
+ }
1407
+
1408
+ async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
1409
+ if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false) return job
1410
+ const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS)
1411
+ if (!Number.isFinite(limit) || limit <= 0) return job
1412
+ if (goalMadeMeaningfulProgress(beforeJob, job)) {
1413
+ job.noProgressCount = 0
1414
+ job.lastProgressAt = now()
1415
+ return job
1416
+ }
1417
+ job.noProgressCount = (job.noProgressCount || 0) + 1
1418
+ job.lastNoProgressAt = now()
1419
+ await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
1420
+ if (job.noProgressCount >= limit) {
1421
+ job.paused = true
1422
+ job.goalNoProgressPausedAt = now()
1423
+ job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`
1424
+ await toast(client, job.goalNoProgressReason, "warning")
1425
+ await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
1426
+ }
1427
+ return job
1428
+ }
1429
+
1118
1430
  async function setGoalComplete(directory, sessionID, args = {}) {
1119
1431
  const state = await readState(directory, sessionID)
1120
1432
  const job = pickGoalJob(state, args.target)
1121
1433
  if (!job) return { ok: false, message: "No active experimental goal was found." }
1122
1434
  const parsed = parseGoalToolText(args, ["summary", "evidence"])
1435
+ const manualOverride = args.manual === true || args.manualOverride === true
1436
+ const completionEvidence = parsed.evidence || job.goalEvidence || ""
1437
+ const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false
1438
+ const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false
1439
+ if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
1440
+ return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
1441
+ }
1442
+ if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
1443
+ return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
1444
+ }
1123
1445
  job.goalStatus = "completed"
1124
1446
  job.enabled = false
1125
1447
  job.paused = true
1126
1448
  job.goalCompletedAt = now()
1127
1449
  job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
1128
- job.goalEvidence = parsed.evidence || job.goalEvidence || "No evidence provided."
1450
+ job.goalEvidence = completionEvidence || "No evidence provided."
1451
+ job.noProgressCount = 0
1129
1452
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1130
1453
  await writeState(directory, sessionID, state)
1131
1454
  await writeGoalReport(directory, sessionID, job)
@@ -1159,6 +1482,8 @@ async function setGoalProgress(directory, sessionID, args = {}) {
1159
1482
  const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" }
1160
1483
  job.goalProgress = [...(job.goalProgress || []), item].slice(-30)
1161
1484
  if (parsed.evidence) job.goalEvidence = parsed.evidence
1485
+ job.noProgressCount = 0
1486
+ job.lastProgressAt = now()
1162
1487
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
1163
1488
  await writeState(directory, sessionID, state)
1164
1489
  await writeGoalReport(directory, sessionID, job)
@@ -1252,7 +1577,10 @@ async function finalizeActiveRun(directory, client, sessionID, options = {}) {
1252
1577
  }
1253
1578
  }
1254
1579
 
1255
- if (isGoalJob(job)) job = await runGoalChecks(directory, sessionID, job, client)
1580
+ if (isGoalJob(job)) {
1581
+ job = await runGoalChecks(directory, sessionID, job, client)
1582
+ job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
1583
+ }
1256
1584
  state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
1257
1585
  await writeState(directory, sessionID, state)
1258
1586
  if (isGoalJob(job)) await writeGoalReport(directory, sessionID, job)
@@ -1277,10 +1605,17 @@ async function fireAction(directory, client, sessionID, job) {
1277
1605
  }
1278
1606
  const tuiCommand = compactTuiCommandName(command)
1279
1607
  if (tuiCommand) {
1608
+ guardLoopOwnedUserMessage(sessionID)
1280
1609
  await compactSession(client, sessionID)
1281
1610
  return { startsAssistantTurn: true }
1282
1611
  }
1283
- await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: { command, arguments: argumentsText } }, { sessionID, command, arguments: argumentsText })
1612
+ guardLoopOwnedUserMessage(sessionID)
1613
+ await sdkCall(
1614
+ client.session.command.bind(client.session),
1615
+ { path: { id: sessionID }, body: { command, arguments: argumentsText } },
1616
+ { path: { sessionID }, body: { command, arguments: argumentsText } },
1617
+ { sessionID, command, arguments: argumentsText },
1618
+ )
1284
1619
  return { startsAssistantTurn: true }
1285
1620
  }
1286
1621
  if (kind === "shell") {
@@ -1290,18 +1625,33 @@ async function fireAction(directory, client, sessionID, job) {
1290
1625
  await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
1291
1626
  return { startsAssistantTurn: false }
1292
1627
  }
1293
- fireSdk(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: { command } }, { sessionID, command })
1628
+ guardLoopOwnedUserMessage(sessionID)
1629
+ fireSdk(
1630
+ client,
1631
+ "session.shell",
1632
+ client.session.shell.bind(client.session),
1633
+ { path: { id: sessionID }, body: { command } },
1634
+ { path: { sessionID }, body: { command } },
1635
+ { sessionID, command },
1636
+ )
1294
1637
  return { startsAssistantTurn: true }
1295
1638
  }
1296
1639
  const prompt = await buildPrompt(directory, job)
1297
1640
  const prefix = kind === "goal"
1298
1641
  ? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real."
1299
1642
  : "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
1300
- fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { parts: [{ type: "text", text: `${prefix}
1301
-
1302
- ${prompt}` }] } }, { sessionID, parts: [{ type: "text", text: `${prefix}
1303
-
1304
- ${prompt}` }] })
1643
+ const promptText = `${prefix}
1644
+
1645
+ ${prompt}`
1646
+ guardLoopOwnedUserMessage(sessionID)
1647
+ fireSdk(
1648
+ client,
1649
+ "session.prompt",
1650
+ client.session.prompt.bind(client.session),
1651
+ { path: { id: sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1652
+ { path: { sessionID }, body: { parts: [{ type: "text", text: promptText }] } },
1653
+ { sessionID, parts: [{ type: "text", text: promptText }] },
1654
+ )
1305
1655
  return { startsAssistantTurn: true }
1306
1656
  }
1307
1657
 
@@ -1417,7 +1767,7 @@ async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
1417
1767
  return
1418
1768
  }
1419
1769
  let timer
1420
- if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
1770
+ if (job.timeoutMs > 0) timer = setTimeout(() => { fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID }); toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {}) }, job.timeoutMs)
1421
1771
  activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer })
1422
1772
  sessionStatuses.set(sessionID, "busy")
1423
1773
  sessionStatusSeenAt.set(sessionID, now())
@@ -1450,7 +1800,11 @@ async function addLoop(directory, client, sessionID, args, defaults = {}) {
1450
1800
  const parsed = parseLoopArgs(args, defaults)
1451
1801
  if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
1452
1802
  if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
1453
- if (!parsed.job.activeRecoveryMs) parsed.job.activeRecoveryMs = Math.max(20_000, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
1803
+ if (!parsed.job.activeRecoveryMs) {
1804
+ parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
1805
+ ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
1806
+ : Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
1807
+ }
1454
1808
  if (parsed.job.dryRun) { await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info"); await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```"); return }
1455
1809
  const state = await readState(directory, sessionID)
1456
1810
  const jobs = Array.isArray(state.jobs) ? state.jobs : []
@@ -1516,7 +1870,9 @@ async function statusGoal(directory, client, sessionID) {
1516
1870
  const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
1517
1871
  const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
1518
1872
  const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
1519
- return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}`
1873
+ const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
1874
+ const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
1875
+ return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}${noProgress}${rejected}`
1520
1876
  }) : ["No experimental goal jobs."]
1521
1877
  await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
1522
1878
  await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
@@ -1539,7 +1895,7 @@ async function resumeGoal(directory, client, sessionID, args) {
1539
1895
  state.jobs = (state.jobs || []).map((job, index) => {
1540
1896
  if (!isGoalJob(job) || !matchJob(job, target, index)) return job
1541
1897
  count++
1542
- return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0 }
1898
+ return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
1543
1899
  })
1544
1900
  await writeState(directory, sessionID, state)
1545
1901
  await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
@@ -1560,7 +1916,7 @@ async function clearGoal(directory, client, sessionID, args) {
1560
1916
  }
1561
1917
 
1562
1918
  async function completeGoalCommand(directory, client, sessionID, args) {
1563
- const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done." })
1919
+ const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
1564
1920
  await toast(client, result.message, result.ok ? "success" : "warning")
1565
1921
  }
1566
1922
 
@@ -1666,6 +2022,7 @@ async function handleCommand(directory, client, input, fallbackName, fallbackArg
1666
2022
  rememberSession(directory, client, sessionID)
1667
2023
  if (wasHandled(sessionID, name, args)) return true
1668
2024
  markHandled(sessionID, name, args)
2025
+ if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
1669
2026
 
1670
2027
  const handled = () => {
1671
2028
  if (output && Array.isArray(output.parts)) {
@@ -1709,7 +2066,7 @@ function goalTools(defaultDirectory) {
1709
2066
  },
1710
2067
  execute: async (args, context) => {
1711
2068
  const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
1712
- return { title: result.ok ? "Goal completed" : "Goal not found", output: result.message }
2069
+ return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
1713
2070
  },
1714
2071
  }),
1715
2072
  opencode_loop_goal_blocked: tool({
@@ -1739,14 +2096,25 @@ function goalTools(defaultDirectory) {
1739
2096
 
1740
2097
  export const OpenCodeLoopPlugin = async ({ client, directory }) => {
1741
2098
  await log(client, "info", "Plugin initialized", { directory })
2099
+ await refreshSessionRelationships(client, directory)
1742
2100
  return {
2101
+ dispose: async () => { disposeRuntime(directory, client) },
1743
2102
  tool: goalTools(directory),
1744
2103
  "command.execute.before": async (input, output) => { await handleCommand(directory, client, input, undefined, undefined, output) },
2104
+ "tool.execute.before": async (input) => { markToolCallActive(input) },
2105
+ "tool.execute.after": async (input) => { markToolCallFinished(input) },
1745
2106
  event: async ({ event }) => {
2107
+ updateSessionRelationshipFromEvent(event)
2108
+ updateToolActivityFromEvent(event)
1746
2109
  if (event.type === "command.executed") {
1747
2110
  const props = event.properties || {}
1748
2111
  await handleCommand(directory, client, props, props.name, props.arguments)
1749
2112
  }
2113
+ const interruptedSessionID = userInterruptSessionFromEvent(event)
2114
+ if (interruptedSessionID) {
2115
+ rememberSession(directory, client, interruptedSessionID)
2116
+ await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
2117
+ }
1750
2118
  const statusUpdate = updateSessionStatusFromEvent(event)
1751
2119
  if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
1752
2120
  if (statusUpdate?.idle) scheduleIdleWork(directory, client, statusUpdate.sessionID)