@bybrawe/opencode-loop 0.5.35 → 0.5.36

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.36
4
+
5
+ Empty-turn fail-safe release.
6
+
7
+ - Distinguish a completed assistant message from a meaningful completed Loop turn instead of treating a timestamp-only blank reply as success.
8
+ - Count non-empty assistant text and tool/file/patch/artifact activity anywhere in the logical run as meaningful work, including tool-only runs with a blank final assistant tail.
9
+ - Refund a completed-but-empty logical run so it does not consume `runCount`, `--max-runs`, or normal success finalization/checkpoint behavior.
10
+ - Persist consecutive empty-turn state across reloads, retry one empty completion with bounded delay, then pause safely after the second consecutive empty turn with a visible warning instead of injecting prompts forever.
11
+ - Recover stale host `busy` state for already-completed empty turns so the fail-safe can finalize them rather than leaving the Loop stuck between busy and due states.
12
+ - Keep release metadata automation compatible with the README's `Current stable release` marker so future release prep does not fail on the documented format.
13
+ - Add focused regressions for blank/whitespace replies, tool activity, max-run refund/re-enable, bounded retry, fail-safe pause, successful streak reset, and generated-bundle parity; full Ubuntu/Windows CI, real-host, Goal coexistence, lifecycle, and single-file bundle gates are green on the feature head.
14
+
3
15
  ## 0.5.35
4
16
 
5
17
  - Treat OpenCode `session.status=retry` as authoritative provider ownership: retry and unreadable status can no longer age into idle and cause an overlapping Loop turn; a genuinely completed current assistant message can still release stale retry safely.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
6
6
 
7
- > **Current stable release: `0.5.35`.**
7
+ > **Current stable release: `0.5.36`.**
8
8
 
9
9
  ## Install or update
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.35",
3
+ "version": "0.5.36",
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",
@@ -21,8 +21,8 @@
21
21
  "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
22
  "build:plugin:npm": "npm run build:plugin",
23
23
  "prepack": "node --check src/index.js",
24
- "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
25
- "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
24
+ "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
25
+ "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/install-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
26
26
  "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
27
27
  "install:global": "node scripts/install-with-goals.mjs",
28
28
  "pack:zip": "node scripts/make-zip.mjs"
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict"
2
+ import { activeRunCompletionFromMessages, assistantMessageHasMeaningfulActivity } from "../src/source/opencode/host.js"
3
+ import { createSessionStatusRuntime } from "../src/source/runtime/session-status.js"
4
+ import { createLoopExecutor } from "../src/source/runtime/executor.js"
5
+ import { refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "../src/source/runtime/empty-turn.js"
6
+ import { clearSessionActivity } from "../src/source/runtime/session-activity.js"
7
+
8
+ const blank = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [] }
9
+ const whitespace = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: " \n" }] }
10
+ const text = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "text", text: "done" }] }
11
+ const tool = { info: { role: "assistant", time: { created: 120, completed: 130 } }, parts: [{ type: "tool", callID: "call-1", state: { status: "completed" } }] }
12
+ assert.equal(assistantMessageHasMeaningfulActivity(blank), false)
13
+ assert.equal(assistantMessageHasMeaningfulActivity(whitespace), false)
14
+ assert.equal(assistantMessageHasMeaningfulActivity(text), true)
15
+ assert.equal(assistantMessageHasMeaningfulActivity(tool), true)
16
+
17
+ let hostMessages = [blank]
18
+ const hostClient = { session: { messages: async () => ({ data: hostMessages }) } }
19
+ assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-empty", { startedAt: 100 }), "empty")
20
+ hostMessages = [tool, { info: { role: "assistant", time: { created: 140, completed: 150 } }, parts: [] }]
21
+ assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-tool-then-blank", { startedAt: 100 }), "completed", "tool activity earlier in the same logical run must prevent a false empty classification")
22
+ hostMessages = [text]
23
+ assert.equal(await activeRunCompletionFromMessages("/repo", hostClient, "host-text", { startedAt: 100 }), "completed")
24
+
25
+ const pureJob = { id: "pure", enabled: false, paused: false, runCount: 1, maxRuns: 1, lastRunAt: 50 }
26
+ let pure = refundEmptyAssistantTurn(pureJob, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 200)
27
+ assert.equal(pure.job.runCount, 0)
28
+ assert.equal(pure.job.enabled, true)
29
+ assert.equal(pure.job.lastRunAt, 10)
30
+ assert.equal(pure.job.runNowRequestedAt, 200)
31
+ assert.equal(pure.job.emptyTurnCount, 1)
32
+ assert.equal(pure.paused, false)
33
+ pure = refundEmptyAssistantTurn(pure.job, { job: { runCount: 1 }, previousLastRunAt: 10, disabledByMaxRuns: true }, 300)
34
+ assert.equal(pure.paused, true)
35
+ assert.equal(pure.job.paused, true)
36
+ assert.equal(pure.job.runNowRequestedAt, undefined)
37
+ clearEmptyAssistantTurnStreak(pure.job)
38
+ assert.equal(pure.job.emptyTurnCount, 0)
39
+ assert.equal(pure.job.lastFailureReason, undefined)
40
+
41
+ const statusLogs = []
42
+ const statusActive = new Map([["status-empty", { jobId: "j", job: { id: "j" }, startedAt: 100 }]])
43
+ const statusRuntime = createSessionStatusRuntime({
44
+ activeRuns: statusActive,
45
+ now: () => 10_000,
46
+ sessionStatusCacheMs: 0,
47
+ activeRunCompletionFromMessages: async () => "empty",
48
+ appendLoopLog: async (...args) => statusLogs.push(args),
49
+ })
50
+ const statusClient = { session: { status: async () => ({ data: { "status-empty": { type: "busy" } } }) } }
51
+ assert.equal(await statusRuntime.sessionStatusType(statusClient, "status-empty", "/repo"), "idle", "a host-busy tail that is already an empty completed assistant turn must settle so the empty-turn guard can run")
52
+ assert.ok(statusLogs.some((entry) => entry[1] === "status-message-empty-recovery"))
53
+ clearSessionActivity("status-empty")
54
+
55
+ let clock = 1_000
56
+ let outcome = "empty"
57
+ const states = new Map()
58
+ const schedules = []
59
+ const toasts = []
60
+ const notifications = []
61
+ const checkpoints = []
62
+ const key = (directory, sessionID) => String(directory) + ":" + String(sessionID)
63
+ const clone = (value) => JSON.parse(JSON.stringify(value))
64
+ const workspace = {
65
+ buildPrompt: async () => "continue",
66
+ ensureBranch: async (_directory, job) => job,
67
+ watchChanged: async () => false,
68
+ untilReached: async () => false,
69
+ createCheckpoint: async (...args) => checkpoints.push(args),
70
+ }
71
+ const goalPolicy = {
72
+ runGoalChecks: async (_directory, _sessionID, job) => job,
73
+ applyGoalNoProgressGuard: async (_directory, _client, _sessionID, job) => job,
74
+ }
75
+ const scheduler = {
76
+ rememberSession: () => {},
77
+ scheduleDueWork: async (...args) => schedules.push(args),
78
+ }
79
+ const executor = createLoopExecutor({
80
+ workspace,
81
+ goalPolicy,
82
+ scheduler,
83
+ now: () => clock,
84
+ readState: async (directory, sessionID) => clone(states.get(key(directory, sessionID)) || { jobs: [] }),
85
+ writeState: async (directory, sessionID, state) => states.set(key(directory, sessionID), clone(state)),
86
+ appendLoopLog: async () => {},
87
+ runShellCommand: async () => ({ code: 0, stdout: "", stderr: "" }),
88
+ notifyJob: async (...args) => notifications.push(args),
89
+ toast: async (...args) => toasts.push(args),
90
+ fireSdk: () => Promise.resolve({}),
91
+ compactSession: async () => true,
92
+ activeRunCompletionFromMessages: async () => outcome,
93
+ busyRetryMs: 5_000,
94
+ })
95
+ const client = { session: { status: async () => ({ data: {} }), prompt: async () => ({ data: {} }), abort: async () => ({ data: {} }) } }
96
+ const directory = "/repo"
97
+ const sessionID = "empty-run"
98
+ states.set(key(directory, sessionID), { jobs: [{
99
+ id: "job", name: "job", action: "devam et", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 1, maxRuntimeMs: 0, timeoutMs: 0,
100
+ }] })
101
+
102
+ await executor.maybeRunDueJobs(directory, client, sessionID)
103
+ assert.equal(states.get(key(directory, sessionID)).jobs[0].runCount, 1)
104
+ assert.equal(states.get(key(directory, sessionID)).jobs[0].enabled, false, "max-runs is provisionally consumed at dispatch")
105
+ clock += 100
106
+ assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)
107
+ let persisted = states.get(key(directory, sessionID)).jobs[0]
108
+ assert.equal(persisted.runCount, 0, "empty completed turn must refund runCount")
109
+ assert.equal(persisted.enabled, true, "empty max-runs attempt must be re-enabled")
110
+ assert.equal(persisted.emptyTurnCount, 1)
111
+ assert.equal(persisted.paused, false)
112
+ assert.equal(checkpoints.length, 0, "empty turn must not run successful finalization/checkpoint work")
113
+ assert.ok(schedules.some((entry) => entry[2] === sessionID && entry[3] === 5_000), "first empty turn retries with bounded delay")
114
+
115
+ executor.markSessionStatus(sessionID, "idle", clock)
116
+ await executor.maybeRunDueJobs(directory, client, sessionID)
117
+ clock += 100
118
+ assert.equal(await executor.finalizeActiveRun(directory, client, sessionID), true)
119
+ persisted = states.get(key(directory, sessionID)).jobs[0]
120
+ assert.equal(persisted.runCount, 0)
121
+ assert.equal(persisted.emptyTurnCount, 2)
122
+ assert.equal(persisted.paused, true, "second consecutive empty completed turn must fail safe instead of looping forever")
123
+ assert.ok(notifications.some((entry) => entry[1]?.id === "job" && entry[2] === "empty_turn"))
124
+ assert.ok(toasts.some((entry) => /paused after 2 consecutive completed assistant turns/i.test(String(entry[1]))))
125
+
126
+ const recoverySession = "empty-then-success"
127
+ outcome = "empty"
128
+ states.set(key(directory, recoverySession), { jobs: [{
129
+ id: "recover", name: "recover", action: "continue", enabled: true, paused: false, intervalMs: 0, runCount: 0, maxRuns: 0, maxRuntimeMs: 0, timeoutMs: 0,
130
+ }] })
131
+ await executor.maybeRunDueJobs(directory, client, recoverySession)
132
+ clock += 100
133
+ await executor.finalizeActiveRun(directory, client, recoverySession)
134
+ assert.equal(states.get(key(directory, recoverySession)).jobs[0].emptyTurnCount, 1)
135
+ executor.markSessionStatus(recoverySession, "idle", clock)
136
+ outcome = "completed"
137
+ await executor.maybeRunDueJobs(directory, client, recoverySession)
138
+ clock += 100
139
+ await executor.finalizeActiveRun(directory, client, recoverySession)
140
+ const recovered = states.get(key(directory, recoverySession)).jobs[0]
141
+ assert.equal(recovered.emptyTurnCount, 0, "a meaningful completed turn resets the consecutive empty streak")
142
+ assert.equal(recovered.lastFailureReason, undefined)
143
+ assert.equal(recovered.runCount, 1)
144
+ assert.ok(checkpoints.some((entry) => entry[2]?.id === "recover"), "meaningful completion still follows normal finalization")
145
+
146
+ executor.disposeSession(sessionID)
147
+ executor.disposeSession(recoverySession)
148
+ clearSessionActivity(sessionID)
149
+ clearSessionActivity(recoverySession)
150
+ console.log("empty-turn recovery tests passed")
@@ -61,7 +61,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
61
61
  "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 50, completed: 90 } } }]), "old", { startedAt: 100 },
62
62
  ), "incomplete")
63
63
  assert.equal(await activeRunCompletionFromMessages(
64
- "/tmp", messagesClient([{ info: { role: "assistant", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
64
+ "/tmp", messagesClient([{ info: { role: "assistant", text: "done", time: { created: 110, completed: 120 } } }]), "done", { startedAt: 100 },
65
65
  ), "completed")
66
66
  }
67
67
 
@@ -118,7 +118,7 @@ assert.equal(compactTuiCommandName("other"), undefined)
118
118
  session: {
119
119
  messages: async (args) => {
120
120
  calls.push(["messages", args])
121
- return { data: [{ info: { role: "assistant", time: { created: 10, completed: 20 } } }] }
121
+ return { data: [{ info: { role: "assistant", text: "adapter completed", time: { created: 10, completed: 20 } } }] }
122
122
  },
123
123
  },
124
124
  }
@@ -0,0 +1,419 @@
1
+ import assert from "node:assert/strict"
2
+ import { existsSync } from "node:fs"
3
+ import { createServer } from "node:http"
4
+ import net from "node:net"
5
+ import { spawn } from "node:child_process"
6
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+ import { fileURLToPath, pathToFileURL } from "node:url"
10
+
11
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
+ const isWindows = process.platform === "win32"
13
+ const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
14
+ const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
15
+ const LOOP_PROMPT_MARKER = "AUTONOMOUS OPENCODE LOOP ITERATION"
16
+
17
+ function resolveOpenCodeBinary() {
18
+ if (!isWindows) return path.join(repoRoot, "node_modules", ".bin", "opencode")
19
+ const candidates = [
20
+ path.join(repoRoot, "node_modules", "opencode-windows-x64", "bin", "opencode.exe"),
21
+ path.join(repoRoot, "node_modules", "opencode-windows-x64-baseline", "bin", "opencode.exe"),
22
+ path.join(repoRoot, "node_modules", "opencode-windows-arm64", "bin", "opencode.exe"),
23
+ ]
24
+ const found = candidates.find((candidate) => existsSync(candidate))
25
+ if (!found) throw new Error(`OpenCode native Windows binary was not installed. Checked: ${candidates.join(", ")}`)
26
+ return found
27
+ }
28
+
29
+ const opencodeBin = resolveOpenCodeBinary()
30
+
31
+ function appendLog(current, chunk, limit = 80_000) {
32
+ return (current + String(chunk)).slice(-limit)
33
+ }
34
+
35
+ async function seedConfigDependencies(dir) {
36
+ await mkdir(path.join(dir, "node_modules"), { recursive: true })
37
+ const dependencies = { "@opencode-ai/plugin": "*" }
38
+ await writeFile(path.join(dir, "package.json"), `${JSON.stringify({ private: true, dependencies }, null, 2)}\n`)
39
+ await writeFile(
40
+ path.join(dir, "package-lock.json"),
41
+ `${JSON.stringify({
42
+ name: "opencode-loop-goal-coexistence-config",
43
+ lockfileVersion: 3,
44
+ requires: true,
45
+ packages: { "": { dependencies } },
46
+ }, null, 2)}\n`,
47
+ )
48
+ await writeFile(path.join(dir, ".gitignore"), "node_modules\npackage.json\npackage-lock.json\nbun.lock\n.gitignore\n")
49
+ }
50
+
51
+ async function reservePort() {
52
+ return await new Promise((resolve, reject) => {
53
+ const server = net.createServer()
54
+ server.once("error", reject)
55
+ server.listen(0, "127.0.0.1", () => {
56
+ const address = server.address()
57
+ if (!address || typeof address === "string") return reject(new Error("failed to reserve TCP port"))
58
+ server.close((error) => error ? reject(error) : resolve(address.port))
59
+ })
60
+ })
61
+ }
62
+
63
+ async function waitForTcp(port, child, logs, timeoutMs = 30_000) {
64
+ const deadline = Date.now() + timeoutMs
65
+ while (Date.now() < deadline) {
66
+ if (child.exitCode !== null) throw new Error(`OpenCode server exited before ready.\n${logs()}`)
67
+ const connected = await new Promise((resolve) => {
68
+ const socket = net.createConnection({ host: "127.0.0.1", port })
69
+ socket.once("connect", () => { socket.destroy(); resolve(true) })
70
+ socket.once("error", () => resolve(false))
71
+ socket.setTimeout(500, () => { socket.destroy(); resolve(false) })
72
+ })
73
+ if (connected) return
74
+ await new Promise((resolve) => setTimeout(resolve, 100))
75
+ }
76
+ throw new Error(`timed out waiting for OpenCode server on ${port}\n${logs()}`)
77
+ }
78
+
79
+ async function stopProcess(child, timeoutMs = 2_000) {
80
+ if (!child || child.exitCode !== null) return
81
+ child.kill()
82
+ await new Promise((resolve) => {
83
+ if (child.exitCode !== null) return resolve()
84
+ const timer = setTimeout(resolve, timeoutMs)
85
+ child.once("close", () => { clearTimeout(timer); resolve() })
86
+ })
87
+ }
88
+
89
+ function spawnOpenCode(args, options = {}) {
90
+ return spawn(opencodeBin, args, { ...options, windowsHide: true })
91
+ }
92
+
93
+ async function runOpenCode(args, { cwd, env, timeoutMs = 60_000 }) {
94
+ return await new Promise((resolve, reject) => {
95
+ const child = spawnOpenCode(args, { cwd, env })
96
+ let stdout = ""
97
+ let stderr = ""
98
+ let settled = false
99
+ child.stdout?.on("data", (chunk) => { stdout = appendLog(stdout, chunk) })
100
+ child.stderr?.on("data", (chunk) => { stderr = appendLog(stderr, chunk) })
101
+ const finish = (fn, value) => {
102
+ if (settled) return
103
+ settled = true
104
+ clearTimeout(timer)
105
+ fn(value)
106
+ }
107
+ const timer = setTimeout(() => {
108
+ void stopProcess(child)
109
+ finish(reject, new Error(`OpenCode command timed out: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
110
+ }, timeoutMs)
111
+ child.once("error", (error) => finish(reject, error))
112
+ child.once("close", (code) => {
113
+ if (code !== 0) return finish(reject, new Error(`OpenCode command exited ${code}: ${args.join(" ")}\nstdout:\n${stdout}\nstderr:\n${stderr}`))
114
+ finish(resolve, { stdout, stderr })
115
+ })
116
+ })
117
+ }
118
+
119
+ function contentText(content) {
120
+ if (typeof content === "string") return content
121
+ if (!Array.isArray(content)) return ""
122
+ return content.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").join("\n")
123
+ }
124
+
125
+ function lastUserMessageText(body) {
126
+ const messages = Array.isArray(body?.messages) ? body.messages : []
127
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
128
+ if (messages[index]?.role === "user") return contentText(messages[index]?.content)
129
+ }
130
+ return contentText(messages.at(-1)?.content)
131
+ }
132
+
133
+ function streamText(res, content, sequence) {
134
+ const id = `chatcmpl-loop-goal-coexistence-${sequence}`
135
+ const created = Math.floor(Date.now() / 1000)
136
+ res.writeHead(200, {
137
+ "content-type": "text/event-stream; charset=utf-8",
138
+ "cache-control": "no-cache",
139
+ connection: "keep-alive",
140
+ })
141
+ const send = (value) => res.write(`data: ${JSON.stringify(value)}\n\n`)
142
+ send({
143
+ id,
144
+ object: "chat.completion.chunk",
145
+ created,
146
+ model: "canary",
147
+ choices: [{ index: 0, delta: { role: "assistant", content }, finish_reason: null }],
148
+ })
149
+ send({
150
+ id,
151
+ object: "chat.completion.chunk",
152
+ created,
153
+ model: "canary",
154
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
155
+ usage: { prompt_tokens: 40, completion_tokens: 4, total_tokens: 44 },
156
+ })
157
+ res.end("data: [DONE]\n\n")
158
+ }
159
+
160
+ function startProvider() {
161
+ let releaseGoal
162
+ const goalRelease = new Promise((resolve) => { releaseGoal = resolve })
163
+ const stats = { chatRequests: 0, goalRequests: 0, loopRequests: 0, otherRequests: 0, paths: [] }
164
+ const server = createServer(async (req, res) => {
165
+ const url = new URL(req.url ?? "/", "http://127.0.0.1")
166
+ stats.paths.push(`${req.method} ${url.pathname}`)
167
+ if (req.method === "GET" && url.pathname.endsWith("/models")) {
168
+ res.writeHead(200, { "content-type": "application/json" })
169
+ res.end(JSON.stringify({ object: "list", data: [{ id: "canary", object: "model", owned_by: "canary" }] }))
170
+ return
171
+ }
172
+ if (req.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
173
+ res.writeHead(404, { "content-type": "application/json" })
174
+ res.end(JSON.stringify({ error: { message: `unexpected endpoint: ${req.method} ${url.pathname}` } }))
175
+ return
176
+ }
177
+
178
+ let raw = ""
179
+ for await (const chunk of req) raw += String(chunk)
180
+ const body = raw ? JSON.parse(raw) : {}
181
+ const text = lastUserMessageText(body)
182
+ stats.chatRequests += 1
183
+ const sequence = stats.chatRequests
184
+
185
+ if (text.includes(GOAL_PROMPT_MARKER) && text.includes(GOAL_OBJECTIVE)) {
186
+ stats.goalRequests += 1
187
+ if (stats.goalRequests === 1) await goalRelease
188
+ streamText(res, `GOAL_TURN_${stats.goalRequests}`, sequence)
189
+ return
190
+ }
191
+ if (text.includes(LOOP_PROMPT_MARKER)) {
192
+ stats.loopRequests += 1
193
+ streamText(res, `UNEXPECTED_LOOP_TURN_${stats.loopRequests}`, sequence)
194
+ return
195
+ }
196
+
197
+ stats.otherRequests += 1
198
+ streamText(res, `OTHER_TURN_${stats.otherRequests}`, sequence)
199
+ })
200
+
201
+ return {
202
+ stats,
203
+ releaseGoal() { releaseGoal() },
204
+ async listen() {
205
+ await new Promise((resolve, reject) => {
206
+ server.once("error", reject)
207
+ server.listen(0, "127.0.0.1", resolve)
208
+ })
209
+ const address = server.address()
210
+ if (!address || typeof address === "string") throw new Error("failed to start deterministic coexistence provider")
211
+ return address.port
212
+ },
213
+ async close() {
214
+ releaseGoal()
215
+ await new Promise((resolve) => server.close(() => resolve()))
216
+ },
217
+ }
218
+ }
219
+
220
+ async function waitFor(predicate, description, diagnostics, timeoutMs = 45_000) {
221
+ const deadline = Date.now() + timeoutMs
222
+ while (Date.now() < deadline) {
223
+ if (await predicate()) return
224
+ await new Promise((resolve) => setTimeout(resolve, 50))
225
+ }
226
+ const detail = typeof diagnostics === "function" ? await diagnostics() : diagnostics
227
+ throw new Error(`timed out waiting for ${description}\n${detail}`)
228
+ }
229
+
230
+ async function readActiveGoal(workspace, sessionID) {
231
+ const dir = path.join(workspace, ".opencode", "goals")
232
+ try {
233
+ for (const name of await readdir(dir)) {
234
+ if (!name.endsWith(".json")) continue
235
+ try {
236
+ const value = JSON.parse(await readFile(path.join(dir, name), "utf8"))
237
+ if (value?.sessionID === sessionID) return value
238
+ } catch {}
239
+ }
240
+ } catch {}
241
+ return undefined
242
+ }
243
+
244
+ async function main() {
245
+ const goalPackageRoot = path.join(repoRoot, "node_modules", "@bybrawe", "opencode-goal")
246
+ const goalPackageJson = path.join(goalPackageRoot, "package.json")
247
+ assert.ok(existsSync(goalPackageJson), "@bybrawe/opencode-goal must be installed before the coexistence canary")
248
+ const goalPackage = JSON.parse(await readFile(goalPackageJson, "utf8"))
249
+
250
+ const workspace = await mkdtemp(path.join(os.tmpdir(), "opencode-loop-goal-coexistence-"))
251
+ const home = path.join(workspace, ".home")
252
+ const projectConfig = path.join(workspace, ".opencode")
253
+ const globalConfig = path.join(home, ".config", "opencode")
254
+ const pluginDir = path.join(projectConfig, "plugins")
255
+ const commandDir = path.join(projectConfig, "commands")
256
+ const agentDir = path.join(projectConfig, "agents")
257
+ const provider = startProvider()
258
+ const providerPort = await provider.listen()
259
+ let server
260
+ let serverLog = ""
261
+ let goalCommandError = null
262
+ let loopCommandError = null
263
+
264
+ await mkdir(pluginDir, { recursive: true })
265
+ await mkdir(commandDir, { recursive: true })
266
+ await mkdir(agentDir, { recursive: true })
267
+ await seedConfigDependencies(projectConfig)
268
+ await seedConfigDependencies(globalConfig)
269
+
270
+ const loopPluginEntry = pathToFileURL(path.join(repoRoot, "src", "index.js")).href
271
+ const goalPackageSpec = pathToFileURL(goalPackageRoot).href
272
+ await writeFile(path.join(pluginDir, "opencode-loop.js"), `export { default as OpenCodeLoopPlugin } from ${JSON.stringify(loopPluginEntry)}\n`)
273
+ await writeFile(path.join(commandDir, "loop.md"), `---\ndescription: Start a coexistence Loop\nagent: opencode-loop-local\n---\n\nOpenCode Loop local command handled. Reply exactly: OK.\n`)
274
+ await writeFile(path.join(agentDir, "opencode-loop-local.md"), `---\ndescription: Local Loop coexistence acknowledgement\nmode: primary\npermission:\n "*": deny\n---\n\nReply exactly: OK\n`)
275
+ await writeFile(path.join(workspace, "opencode.json"), `${JSON.stringify({
276
+ $schema: "https://opencode.ai/config.json",
277
+ plugin: [goalPackageSpec],
278
+ model: "canary/canary",
279
+ small_model: "canary/canary",
280
+ provider: {
281
+ canary: {
282
+ npm: "@ai-sdk/openai-compatible",
283
+ name: "Deterministic Loop Goal Coexistence Canary",
284
+ options: { baseURL: `http://127.0.0.1:${providerPort}/v1`, apiKey: "canary-key" },
285
+ models: { canary: { name: "Deterministic Loop Goal Coexistence Canary", limit: { context: 100000, output: 4096 } } },
286
+ },
287
+ },
288
+ }, null, 2)}\n`)
289
+
290
+ const env = {
291
+ ...process.env,
292
+ HOME: home,
293
+ USERPROFILE: home,
294
+ XDG_CONFIG_HOME: path.join(home, ".config"),
295
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
296
+ XDG_CACHE_HOME: path.join(home, ".cache"),
297
+ OPENCODE_DISABLE_AUTOUPDATE: "true",
298
+ OPENCODE_DB: ":memory:",
299
+ OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
300
+ CI: "true",
301
+ }
302
+
303
+ try {
304
+ const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
305
+ assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`)
306
+
307
+ const port = await reservePort()
308
+ server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
309
+ server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
310
+ server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
311
+ await waitForTcp(port, server, () => serverLog)
312
+
313
+ const baseURL = `http://127.0.0.1:${port}`
314
+ const directoryQuery = `directory=${encodeURIComponent(workspace)}`
315
+ const api = async (pathname, init = {}) => {
316
+ const separator = pathname.includes("?") ? "&" : "?"
317
+ const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
318
+ ...init,
319
+ headers: { "content-type": "application/json", ...(init.headers ?? {}) },
320
+ signal: init.signal ?? AbortSignal.timeout(30_000),
321
+ })
322
+ const text = await response.text()
323
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
324
+ if (!text) return null
325
+ try { return JSON.parse(text) } catch { return text }
326
+ }
327
+
328
+ const sessionsPayload = await api("/session", { method: "GET" })
329
+ assert.ok(Array.isArray(sessionsPayload?.data ?? sessionsPayload), "GET /session bootstrap did not return an array")
330
+ const createdPayload = await api("/session", { method: "POST", body: JSON.stringify({ title: "Loop + Goal ownership canary" }) })
331
+ const session = createdPayload?.data ?? createdPayload
332
+ const sessionID = String(session?.id ?? "")
333
+ assert.ok(sessionID, `OpenCode did not create a session: ${JSON.stringify(createdPayload)}`)
334
+
335
+ const commandPath = `/session/${encodeURIComponent(sessionID)}/command`
336
+ const sendCommand = async (name, argumentsText, signal) => await api(commandPath, {
337
+ method: "POST",
338
+ body: JSON.stringify({ agent: "build", model: "canary/canary", command: name, arguments: argumentsText }),
339
+ signal: signal ?? AbortSignal.timeout(90_000),
340
+ })
341
+
342
+ const goalController = new AbortController()
343
+ const goalCommand = sendCommand("goal", `${GOAL_OBJECTIVE} --max-turns 5`, goalController.signal).catch((error) => {
344
+ goalCommandError = error
345
+ return null
346
+ })
347
+
348
+ const loopStateFile = path.join(workspace, ".opencode", "opencode-loop", `${sessionID}.json`)
349
+ const loopLogFile = path.join(workspace, ".opencode", "opencode-loop", "loop.log")
350
+ const diagnostics = async () => {
351
+ let goal = "missing"
352
+ let loopState = "missing"
353
+ let loopLog = "missing"
354
+ try { goal = JSON.stringify(await readActiveGoal(workspace, sessionID), null, 2) } catch {}
355
+ try { loopState = await readFile(loopStateFile, "utf8") } catch {}
356
+ try { loopLog = await readFile(loopLogFile, "utf8") } catch {}
357
+ return `goal package=${goalPackage.version}\nprovider=${JSON.stringify(provider.stats)}\ngoalCommandError=${String(goalCommandError ?? "none")}\nloopCommandError=${String(loopCommandError ?? "none")}\ngoal=${goal}\nloopState=${loopState}\nloopLog=${loopLog}\nserver log:\n${serverLog}`
358
+ }
359
+
360
+ await waitFor(() => provider.stats.goalRequests === 1, "the first dedicated Goal provider turn to be held open", diagnostics)
361
+ await waitFor(async () => {
362
+ const goal = await readActiveGoal(workspace, sessionID)
363
+ return goal?.status === "active" && goal?.objective === GOAL_OBJECTIVE
364
+ }, "released Goal package to persist an active same-session Goal", diagnostics)
365
+
366
+ const loopController = new AbortController()
367
+ const loopCommand = sendCommand("loop", "devam et", loopController.signal).catch((error) => {
368
+ loopCommandError = error
369
+ return null
370
+ })
371
+
372
+ await waitFor(async () => {
373
+ try {
374
+ return (await readFile(loopLogFile, "utf8")).includes("goal-overlap-blocked")
375
+ } catch {
376
+ return false
377
+ }
378
+ }, "Loop to reject prompt ownership while the dedicated Goal is active", diagnostics, 20_000)
379
+
380
+ await new Promise((resolve) => setTimeout(resolve, 500))
381
+ assert.equal(provider.stats.goalRequests, 1, `Goal provider turn should remain the only autonomous request while overlap is rejected\n${await diagnostics()}`)
382
+ assert.equal(provider.stats.loopRequests, 0, `Loop must not dispatch while dedicated Goal owns continuation\n${await diagnostics()}`)
383
+ assert.equal(provider.stats.otherRequests, 0, `command bridge must not leak to the model during ownership rejection\n${await diagnostics()}`)
384
+
385
+ try {
386
+ const loopState = JSON.parse(await readFile(loopStateFile, "utf8"))
387
+ assert.equal(Array.isArray(loopState.jobs) ? loopState.jobs.length : 0, 0, `blocked prompt Loop must not persist a job: ${JSON.stringify(loopState)}`)
388
+ } catch (error) {
389
+ if (error?.code !== "ENOENT") throw error
390
+ }
391
+
392
+ const activeGoal = await readActiveGoal(workspace, sessionID)
393
+ assert.equal(activeGoal?.status, "active", `Goal must remain active after Loop overlap rejection\n${await diagnostics()}`)
394
+
395
+ loopController.abort()
396
+ goalController.abort()
397
+ await Promise.allSettled([loopCommand, goalCommand])
398
+
399
+ console.log(JSON.stringify({
400
+ ok: true,
401
+ platform: process.platform,
402
+ sessionID,
403
+ goalPackageVersion: goalPackage.version,
404
+ provider: provider.stats,
405
+ dedicatedGoal: { id: activeGoal?.id, status: activeGoal?.status },
406
+ loop: { blocked: true, persistedJobs: 0 },
407
+ }, null, 2))
408
+ } finally {
409
+ provider.releaseGoal()
410
+ await stopProcess(server)
411
+ await provider.close().catch(() => undefined)
412
+ await rm(workspace, { recursive: true, force: true }).catch(() => undefined)
413
+ }
414
+ }
415
+
416
+ main().catch((error) => {
417
+ console.error(error?.stack || error)
418
+ process.exitCode = 1
419
+ })
package/src/index.js CHANGED
@@ -801,11 +801,25 @@ function orderedSessionMessages(messages) {
801
801
  return { message, index, created: Number.isFinite(created) ? created : 0 };
802
802
  }).sort((a, b) => a.created - b.created || a.index - b.index).map((entry) => entry.message);
803
803
  }
804
+ function assistantMessageHasMeaningfulActivity(message) {
805
+ const parts = Array.isArray(message?.parts) ? message.parts : [];
806
+ for (const part of parts) {
807
+ if (!part || typeof part !== "object")
808
+ continue;
809
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim())
810
+ return true;
811
+ if (["tool", "file", "patch", "artifact"].includes(String(part.type || "")))
812
+ return true;
813
+ }
814
+ const info = message?.info || message || {};
815
+ return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim());
816
+ }
804
817
  async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
805
818
  const messages = await readRecentSessionMessages(client, sessionID, directory);
806
819
  if (!messages)
807
820
  return "unknown";
808
- const tail = orderedSessionMessages(messages).at(-1);
821
+ const ordered = orderedSessionMessages(messages);
822
+ const tail = ordered.at(-1);
809
823
  const info = tail?.info || tail;
810
824
  if (!info || info.role !== "assistant")
811
825
  return "incomplete";
@@ -816,7 +830,17 @@ async function activeRunCompletionFromMessages(directory, client, sessionID, act
816
830
  const startedAt = Number(active?.startedAt || 0);
817
831
  if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt))
818
832
  return "incomplete";
819
- return "completed";
833
+ const relevant = ordered.filter((message) => {
834
+ const candidate = message?.info || message || {};
835
+ if (candidate.role !== "assistant")
836
+ return false;
837
+ if (startedAt <= 0)
838
+ return true;
839
+ const candidateCreated = Number(candidate?.time?.created || 0);
840
+ const candidateCompleted = Number(candidate?.time?.completed || 0);
841
+ return candidateCreated >= startedAt || candidateCompleted >= startedAt;
842
+ });
843
+ return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty";
820
844
  }
821
845
  async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
822
846
  const preferred = normalizedModelRef(preferredModel);
@@ -2853,6 +2877,9 @@ function nonNegativeNumber(value, fallback) {
2853
2877
  const number = Number(value);
2854
2878
  return Number.isFinite(number) && number >= 0 ? number : fallback;
2855
2879
  }
2880
+ function settledAssistantCompletion(value) {
2881
+ return value === "completed" || value === "empty";
2882
+ }
2856
2883
  function createSessionStatusRuntime(options = {}) {
2857
2884
  const activeRuns = options.activeRuns;
2858
2885
  if (!(activeRuns instanceof Map))
@@ -2947,7 +2974,7 @@ function createSessionStatusRuntime(options = {}) {
2947
2974
  if (!options2.requireIdle && !options2.forceStale)
2948
2975
  return true;
2949
2976
  const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
2950
- if (completion === "completed")
2977
+ if (settledAssistantCompletion(completion))
2951
2978
  return true;
2952
2979
  if (!options2.requireIdle)
2953
2980
  return completion === "unknown" && staleActiveRun(sessionID);
@@ -2977,7 +3004,7 @@ function createSessionStatusRuntime(options = {}) {
2977
3004
  if (!seenAt || now2() - seenAt < sessionStatusCacheMs)
2978
3005
  return false;
2979
3006
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, { startedAt: 0 });
2980
- if (completion !== "completed")
3007
+ if (!settledAssistantCompletion(completion))
2981
3008
  return false;
2982
3009
  markSessionStatus(sessionID, "idle");
2983
3010
  await appendLoopLog2(directory, "status-message-idle-recovery", {
@@ -3012,7 +3039,7 @@ function createSessionStatusRuntime(options = {}) {
3012
3039
  const active = activeRuns.get(sessionID);
3013
3040
  if (active) {
3014
3041
  const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
3015
- if (completion === "completed" || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
3042
+ if (settledAssistantCompletion(completion) || live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID)) {
3016
3043
  markSessionStatus(sessionID, "idle");
3017
3044
  const logDetails = {
3018
3045
  sessionID,
@@ -3020,7 +3047,8 @@ function createSessionStatusRuntime(options = {}) {
3020
3047
  startedAt: active.startedAt,
3021
3048
  ...completion === "completed" ? {} : { staleStatus: live.type }
3022
3049
  };
3023
- await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", logDetails);
3050
+ const recoveryEvent = completion === "empty" ? "status-message-empty-recovery" : completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery";
3051
+ await appendLoopLog2(directory, recoveryEvent, logDetails);
3024
3052
  return "idle";
3025
3053
  }
3026
3054
  }
@@ -3042,6 +3070,7 @@ function createSessionStatusRuntime(options = {}) {
3042
3070
  staleActiveRun,
3043
3071
  canFinalizeActiveRun,
3044
3072
  readLiveSessionStatus,
3073
+ activeRunCompletion: activeRunCompletionFromMessages2,
3045
3074
  sessionStatusType,
3046
3075
  sessionIsIdle
3047
3076
  };
@@ -3532,6 +3561,50 @@ function refundInfrastructureRun(job, snapshot = {}, input = {}) {
3532
3561
  return job;
3533
3562
  }
3534
3563
 
3564
+ // src/source/runtime/empty-turn.js
3565
+ var DEFAULT_MAX_EMPTY_TURNS = 2;
3566
+ function guardsEmptyAssistantTurn(job) {
3567
+ const kind = actionKind(job?.action, job || {});
3568
+ return kind === "prompt" || kind === "goal";
3569
+ }
3570
+ function emptyTurnLimit(job) {
3571
+ const configured = Number(job?.maxEmptyTurns || 0);
3572
+ if (Number.isFinite(configured) && configured > 0)
3573
+ return Math.max(1, Math.floor(configured));
3574
+ return DEFAULT_MAX_EMPTY_TURNS;
3575
+ }
3576
+ function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
3577
+ const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0);
3578
+ const currentCount = Number(job?.runCount || 0);
3579
+ if (chargedCount > 0 && currentCount >= chargedCount)
3580
+ job.runCount = Math.max(0, currentCount - 1);
3581
+ if (Number.isFinite(Number(active?.previousLastRunAt)))
3582
+ job.lastRunAt = Number(active.previousLastRunAt);
3583
+ if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
3584
+ job.enabled = true;
3585
+ }
3586
+ job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1;
3587
+ job.lastEmptyTurnAt = Number(timestamp) || Date.now();
3588
+ job.lastFailureReason = "empty_turn";
3589
+ const limit = emptyTurnLimit(job);
3590
+ const paused = job.emptyTurnCount >= limit;
3591
+ if (paused) {
3592
+ job.paused = true;
3593
+ delete job.runNowRequestedAt;
3594
+ } else {
3595
+ job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now());
3596
+ }
3597
+ return { job, paused, count: job.emptyTurnCount, limit };
3598
+ }
3599
+ function clearEmptyAssistantTurnStreak(job) {
3600
+ if (!job)
3601
+ return job;
3602
+ job.emptyTurnCount = 0;
3603
+ if (job.lastFailureReason === "empty_turn")
3604
+ delete job.lastFailureReason;
3605
+ return job;
3606
+ }
3607
+
3535
3608
  // src/source/runtime/executor.js
3536
3609
  var DEFAULT_ACTIVE_GUARD_MS = 45000;
3537
3610
  var DEFAULT_BUSY_RETRY_MS2 = 5000;
@@ -3585,6 +3658,7 @@ function createLoopExecutor(options = {}) {
3585
3658
  updateSessionStatusFromEvent,
3586
3659
  staleActiveRun,
3587
3660
  canFinalizeActiveRun,
3661
+ activeRunCompletion,
3588
3662
  sessionStatusType,
3589
3663
  sessionIsIdle,
3590
3664
  markSessionStatus,
@@ -3760,6 +3834,7 @@ function createLoopExecutor(options = {}) {
3760
3834
  return;
3761
3835
  if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
3762
3836
  return false;
3837
+ const completion = await activeRunCompletion(directory, client, sessionID, active);
3763
3838
  const recoveredStale = staleActiveRun(sessionID);
3764
3839
  if (active.compactionOnly) {
3765
3840
  const pending = compactionRuntime.getPending(sessionID);
@@ -3780,6 +3855,30 @@ function createLoopExecutor(options = {}) {
3780
3855
  if (!job)
3781
3856
  return;
3782
3857
  job.lastFinishedAt = now2();
3858
+ if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
3859
+ const empty = refundEmptyAssistantTurn(job, active, now2());
3860
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
3861
+ await writeState2(directory, sessionID, state);
3862
+ await appendLoopLog2(directory, "empty-assistant-turn", {
3863
+ sessionID,
3864
+ job: job.name || job.id,
3865
+ count: empty.count,
3866
+ limit: empty.limit,
3867
+ paused: empty.paused,
3868
+ refunded: true
3869
+ });
3870
+ if (empty.paused) {
3871
+ await notifyJob2(directory, job, "empty_turn");
3872
+ await toast2(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning");
3873
+ await scheduleDueWork(directory, client, sessionID);
3874
+ } else {
3875
+ await toast2(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning");
3876
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs);
3877
+ }
3878
+ return true;
3879
+ }
3880
+ if (completion === "completed")
3881
+ clearEmptyAssistantTurnStreak(job);
3783
3882
  if (recoveredStale) {
3784
3883
  await appendLoopLog2(directory, "active-stale-recovery", {
3785
3884
  sessionID,
@@ -54,10 +54,22 @@ export function orderedSessionMessages(messages) {
54
54
  .map((entry) => entry.message)
55
55
  }
56
56
 
57
+ export function assistantMessageHasMeaningfulActivity(message) {
58
+ const parts = Array.isArray(message?.parts) ? message.parts : []
59
+ for (const part of parts) {
60
+ if (!part || typeof part !== "object") continue
61
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) return true
62
+ if (["tool", "file", "patch", "artifact"].includes(String(part.type || ""))) return true
63
+ }
64
+ const info = message?.info || message || {}
65
+ return [info.text, info.content, info.summary].some((value) => typeof value === "string" && value.trim())
66
+ }
67
+
57
68
  export async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
58
69
  const messages = await readRecentSessionMessages(client, sessionID, directory)
59
70
  if (!messages) return "unknown"
60
- const tail = orderedSessionMessages(messages).at(-1)
71
+ const ordered = orderedSessionMessages(messages)
72
+ const tail = ordered.at(-1)
61
73
  const info = tail?.info || tail
62
74
  if (!info || info.role !== "assistant") return "incomplete"
63
75
  const completed = Number(info?.time?.completed || 0)
@@ -65,7 +77,16 @@ export async function activeRunCompletionFromMessages(directory, client, session
65
77
  if (!Number.isFinite(completed) || completed <= 0) return "incomplete"
66
78
  const startedAt = Number(active?.startedAt || 0)
67
79
  if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt)) return "incomplete"
68
- return "completed"
80
+
81
+ const relevant = ordered.filter((message) => {
82
+ const candidate = message?.info || message || {}
83
+ if (candidate.role !== "assistant") return false
84
+ if (startedAt <= 0) return true
85
+ const candidateCreated = Number(candidate?.time?.created || 0)
86
+ const candidateCompleted = Number(candidate?.time?.completed || 0)
87
+ return candidateCreated >= startedAt || candidateCompleted >= startedAt
88
+ })
89
+ return relevant.some(assistantMessageHasMeaningfulActivity) ? "completed" : "empty"
69
90
  }
70
91
 
71
92
  export async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
@@ -0,0 +1,46 @@
1
+ import { actionKind } from "../core/jobs.js"
2
+
3
+ export const DEFAULT_MAX_EMPTY_TURNS = 2
4
+
5
+ export function guardsEmptyAssistantTurn(job) {
6
+ const kind = actionKind(job?.action, job || {})
7
+ return kind === "prompt" || kind === "goal"
8
+ }
9
+
10
+ export function emptyTurnLimit(job) {
11
+ const configured = Number(job?.maxEmptyTurns || 0)
12
+ if (Number.isFinite(configured) && configured > 0) return Math.max(1, Math.floor(configured))
13
+ return DEFAULT_MAX_EMPTY_TURNS
14
+ }
15
+
16
+ export function refundEmptyAssistantTurn(job, active = {}, timestamp = Date.now()) {
17
+ const chargedCount = Number(active?.job?.runCount ?? job?.runCount ?? 0)
18
+ const currentCount = Number(job?.runCount || 0)
19
+ if (chargedCount > 0 && currentCount >= chargedCount) job.runCount = Math.max(0, currentCount - 1)
20
+
21
+ if (Number.isFinite(Number(active?.previousLastRunAt))) job.lastRunAt = Number(active.previousLastRunAt)
22
+ if (active?.disabledByMaxRuns && Number(job?.maxRuns || 0) > 0 && Number(job?.runCount || 0) < Number(job.maxRuns)) {
23
+ job.enabled = true
24
+ }
25
+
26
+ job.emptyTurnCount = Number(job.emptyTurnCount || 0) + 1
27
+ job.lastEmptyTurnAt = Number(timestamp) || Date.now()
28
+ job.lastFailureReason = "empty_turn"
29
+
30
+ const limit = emptyTurnLimit(job)
31
+ const paused = job.emptyTurnCount >= limit
32
+ if (paused) {
33
+ job.paused = true
34
+ delete job.runNowRequestedAt
35
+ } else {
36
+ job.runNowRequestedAt = Math.max(1, Number(timestamp) || Date.now())
37
+ }
38
+ return { job, paused, count: job.emptyTurnCount, limit }
39
+ }
40
+
41
+ export function clearEmptyAssistantTurnStreak(job) {
42
+ if (!job) return job
43
+ job.emptyTurnCount = 0
44
+ if (job.lastFailureReason === "empty_turn") delete job.lastFailureReason
45
+ return job
46
+ }
@@ -11,6 +11,7 @@ import { createActionDispatcher } from "./action-dispatch.js"
11
11
  import { createRunFinalizationRuntime } from "./run-finalization.js"
12
12
  import { createRunAdmissionRuntime } from "./run-admission.js"
13
13
  import { isTransientNetworkError, networkRetryDelayMs, refundInfrastructureRun } from "./network-recovery.js"
14
+ import { guardsEmptyAssistantTurn, refundEmptyAssistantTurn, clearEmptyAssistantTurnStreak } from "./empty-turn.js"
14
15
 
15
16
  const DEFAULT_ACTIVE_GUARD_MS = 45_000
16
17
  const DEFAULT_BUSY_RETRY_MS = 5_000
@@ -77,6 +78,7 @@ export function createLoopExecutor(options = {}) {
77
78
  updateSessionStatusFromEvent,
78
79
  staleActiveRun,
79
80
  canFinalizeActiveRun,
81
+ activeRunCompletion,
80
82
  sessionStatusType,
81
83
  sessionIsIdle,
82
84
  markSessionStatus,
@@ -267,6 +269,7 @@ export function createLoopExecutor(options = {}) {
267
269
  const active = activeRuns.get(sessionID)
268
270
  if (!active) return
269
271
  if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions)) return false
272
+ const completion = await activeRunCompletion(directory, client, sessionID, active)
270
273
  const recoveredStale = staleActiveRun(sessionID)
271
274
  if (active.compactionOnly) {
272
275
  const pending = compactionRuntime.getPending(sessionID)
@@ -287,6 +290,31 @@ export function createLoopExecutor(options = {}) {
287
290
  let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
288
291
  if (!job) return
289
292
  job.lastFinishedAt = now()
293
+
294
+ if (completion === "empty" && guardsEmptyAssistantTurn(job)) {
295
+ const empty = refundEmptyAssistantTurn(job, active, now())
296
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
297
+ await writeState(directory, sessionID, state)
298
+ await appendLoopLog(directory, "empty-assistant-turn", {
299
+ sessionID,
300
+ job: job.name || job.id,
301
+ count: empty.count,
302
+ limit: empty.limit,
303
+ paused: empty.paused,
304
+ refunded: true,
305
+ })
306
+ if (empty.paused) {
307
+ await notifyJob(directory, job, "empty_turn")
308
+ await toast(client, "Loop paused after " + empty.count + " consecutive completed assistant turns with no visible output or tool activity. Resume after changing the model/prompt or use /loop-resume.", "warning")
309
+ await scheduleDueWork(directory, client, sessionID)
310
+ } else {
311
+ await toast(client, "Loop received an empty completed assistant turn; the logical run was refunded and will retry once.", "warning")
312
+ await scheduleDueWork(directory, client, sessionID, busyRetryMs)
313
+ }
314
+ return true
315
+ }
316
+
317
+ if (completion === "completed") clearEmptyAssistantTurnStreak(job)
290
318
  if (recoveredStale) {
291
319
  await appendLoopLog(directory, "active-stale-recovery", {
292
320
  sessionID,
@@ -24,6 +24,10 @@ function nonNegativeNumber(value, fallback) {
24
24
  return Number.isFinite(number) && number >= 0 ? number : fallback
25
25
  }
26
26
 
27
+ function settledAssistantCompletion(value) {
28
+ return value === "completed" || value === "empty"
29
+ }
30
+
27
31
  export function createSessionStatusRuntime(options = {}) {
28
32
  const activeRuns = options.activeRuns
29
33
  if (!(activeRuns instanceof Map)) throw new TypeError("createSessionStatusRuntime requires activeRuns Map")
@@ -114,7 +118,7 @@ export function createSessionStatusRuntime(options = {}) {
114
118
  const completion = options.forceStale
115
119
  ? await activeRunCompletionFromMessages(directory, client, sessionID, active)
116
120
  : undefined
117
- if (completion === "completed") return true
121
+ if (settledAssistantCompletion(completion)) return true
118
122
  if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
119
123
 
120
124
  const cached = sessionStatuses.get(sessionID)
@@ -142,7 +146,7 @@ export function createSessionStatusRuntime(options = {}) {
142
146
  if (liveType !== "busy") return false
143
147
  if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
144
148
  const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
145
- if (completion !== "completed") return false
149
+ if (!settledAssistantCompletion(completion)) return false
146
150
  markSessionStatus(sessionID, "idle")
147
151
  await appendLoopLog(directory, "status-message-idle-recovery", {
148
152
  sessionID,
@@ -177,7 +181,7 @@ export function createSessionStatusRuntime(options = {}) {
177
181
  const active = activeRuns.get(sessionID)
178
182
  if (active) {
179
183
  const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
180
- if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
184
+ if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
181
185
  markSessionStatus(sessionID, "idle")
182
186
  const logDetails = {
183
187
  sessionID,
@@ -185,11 +189,12 @@ export function createSessionStatusRuntime(options = {}) {
185
189
  startedAt: active.startedAt,
186
190
  ...(completion === "completed" ? {} : { staleStatus: live.type }),
187
191
  }
188
- await appendLoopLog(
189
- directory,
190
- completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery",
191
- logDetails,
192
- )
192
+ const recoveryEvent = completion === "empty"
193
+ ? "status-message-empty-recovery"
194
+ : completion === "completed"
195
+ ? "status-message-complete-recovery"
196
+ : "status-stale-recovery"
197
+ await appendLoopLog(directory, recoveryEvent, logDetails)
193
198
  return "idle"
194
199
  }
195
200
  }
@@ -214,6 +219,7 @@ export function createSessionStatusRuntime(options = {}) {
214
219
  staleActiveRun,
215
220
  canFinalizeActiveRun,
216
221
  readLiveSessionStatus,
222
+ activeRunCompletion: activeRunCompletionFromMessages,
217
223
  sessionStatusType,
218
224
  sessionIsIdle,
219
225
  }