@bybrawe/opencode-loop 0.5.35 → 0.5.37

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.
@@ -17,6 +17,7 @@ import { createGoalExecutionPolicy } from "./runtime/goal-policy.js"
17
17
  import { createJobWorkspaceRuntime, dangerousShell } from "./runtime/job-workspace.js"
18
18
  import { createLoopExecutor } from "./runtime/executor.js"
19
19
  import { createGoalSteeringRuntime } from "./runtime/goal-steering.js"
20
+ import { findDedicatedGoalForSession } from "./runtime/companion-goal.js"
20
21
 
21
22
  const DEFAULT_ACTIVE_GUARD_MS = 45_000
22
23
 
@@ -162,44 +163,66 @@ function steeringToolRejection(sessionID) {
162
163
  }
163
164
  }
164
165
 
166
+ async function dedicatedGoalToolRejection(directory, sessionID) {
167
+ const goal = await findDedicatedGoalForSession(directory, sessionID)
168
+ if (!goal || goal.status === "completed") return undefined
169
+ return {
170
+ title: "Dedicated Goal owns lifecycle",
171
+ output: [
172
+ `A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
173
+ "The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
174
+ "Use opencode_goal_progress for checkpoints, opencode_goal_wait_for_user when user/manual input is required, opencode_goal_blocked for a genuine blocker, or opencode_goal_complete for verified completion.",
175
+ ].join("\n"),
176
+ }
177
+ }
178
+
165
179
  function goalTools(defaultDirectory) {
166
180
  return {
167
181
  opencode_loop_goal_complete: tool({
168
- description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
182
+ description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as completed. Never use this tool to control a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_complete there. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
169
183
  args: {
170
184
  summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
171
185
  evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
172
186
  },
173
187
  execute: async (args, context) => {
188
+ const directory = context.directory || defaultDirectory
189
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
190
+ if (dedicated) return dedicated
174
191
  const steering = steeringToolRejection(context.sessionID)
175
192
  if (steering) return steering
176
- const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
193
+ const result = await setGoalComplete(directory, context.sessionID, args)
177
194
  return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
178
195
  },
179
196
  }),
180
197
  opencode_loop_goal_blocked: tool({
181
- description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
198
+ description: "Experimental /loop-goal ONLY: mark the current OpenCode Loop experimental goal as blocked. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_wait_for_user for user/manual input or opencode_goal_blocked for a genuine dedicated-Goal blocker.",
182
199
  args: {
183
200
  reason: tool.schema.string().describe("Why the goal is blocked."),
184
201
  needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
185
202
  },
186
203
  execute: async (args, context) => {
204
+ const directory = context.directory || defaultDirectory
205
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
206
+ if (dedicated) return dedicated
187
207
  const steering = steeringToolRejection(context.sessionID)
188
208
  if (steering) return steering
189
- const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
209
+ const result = await setGoalBlocked(directory, context.sessionID, args)
190
210
  return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
191
211
  },
192
212
  }),
193
213
  opencode_loop_goal_progress: tool({
194
- description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
214
+ description: "Experimental /loop-goal ONLY: record meaningful progress on the current OpenCode Loop experimental goal. Never use this tool for a dedicated @bybrawe/opencode-goal Goal; use opencode_goal_progress there.",
195
215
  args: {
196
216
  summary: tool.schema.string().describe("What useful progress was made."),
197
217
  next: tool.schema.string().describe("The next step toward completing the goal."),
198
218
  },
199
219
  execute: async (args, context) => {
220
+ const directory = context.directory || defaultDirectory
221
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
222
+ if (dedicated) return dedicated
200
223
  const steering = steeringToolRejection(context.sessionID)
201
224
  if (steering) return steering
202
- const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
225
+ const result = await setGoalProgress(directory, context.sessionID, args)
203
226
  return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
204
227
  },
205
228
  }),
@@ -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
  }