@bybrawe/opencode-loop 0.5.34 → 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.
@@ -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")
@@ -78,7 +82,9 @@ export function createSessionStatusRuntime(options = {}) {
78
82
  const argsList = []
79
83
  if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
80
84
  argsList.push({})
85
+ let attempted = false
81
86
  for (const args of argsList) {
87
+ attempted = true
82
88
  try {
83
89
  const result = await statusMethod.call(client.session, args)
84
90
  const error = sdkError(result)
@@ -91,8 +97,6 @@ export function createSessionStatusRuntime(options = {}) {
91
97
  if (typeof observedType !== "string") continue
92
98
  markSessionStatus(observedSessionID, observedType, observedAt)
93
99
  }
94
- // OpenCode's status list contains active sessions; idle sessions are
95
- // normally omitted. Clear a completed descendant that was previously busy.
96
100
  for (const childID of sessionParents.keys()) {
97
101
  if (!isDescendantSession(childID, sessionID) || data[childID]) continue
98
102
  markSessionStatus(childID, "idle", observedAt)
@@ -104,7 +108,7 @@ export function createSessionStatusRuntime(options = {}) {
104
108
  return { type: "idle", source: "sdk" }
105
109
  } catch {}
106
110
  }
107
- return undefined
111
+ return attempted ? { type: "unknown", source: "sdk-error" } : undefined
108
112
  }
109
113
 
110
114
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
@@ -114,30 +118,35 @@ 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
 
124
+ const cached = sessionStatuses.get(sessionID)
125
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
126
+ const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0)
120
127
  const live = await readLiveSessionStatus(client, sessionID, directory)
121
128
  if (live?.type === "idle") return true
129
+ if (live?.type === "unknown" && cachedIdleAfterRun) {
130
+ // A concrete idle observed after this active run is sufficient to finalize
131
+ // that run even if a later status read fails. This does not authorize a
132
+ // new prompt by itself; admission applies its own current status policy.
133
+ return true
134
+ }
122
135
  if (live?.type) {
123
- if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
136
+ if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
124
137
  return false
125
138
  }
126
139
 
127
140
  if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
128
- const cached = sessionStatuses.get(sessionID)
129
- const seenAt = sessionStatusSeenAt.get(sessionID) || 0
130
- return cached === "idle" && seenAt > (active.startedAt || 0)
141
+ return cachedIdleAfterRun
131
142
  }
132
143
 
133
144
  async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
134
145
  if (activeRuns.has(sessionID)) return false
135
- if (liveType !== "busy" && liveType !== "retry") return false
136
- // Never override the first fresh busy observation. Waiting at least one cache
137
- // window gives the host time to expose a new user/assistant turn if it is real.
146
+ if (liveType !== "busy") return false
138
147
  if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
139
148
  const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
140
- if (completion !== "completed") return false
149
+ if (!settledAssistantCompletion(completion)) return false
141
150
  markSessionStatus(sessionID, "idle")
142
151
  await appendLoopLog(directory, "status-message-idle-recovery", {
143
152
  sessionID,
@@ -149,9 +158,6 @@ export function createSessionStatusRuntime(options = {}) {
149
158
  }
150
159
 
151
160
  async function sessionStatusType(client, sessionID, directory, options = {}) {
152
- // OpenCode can briefly report an idle session while a long-running tool or
153
- // subtask is still executing. Tool lifecycle hooks are the more specific
154
- // signal here, so never enqueue another turn until every active call ends.
155
161
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
156
162
  markSessionStatus(sessionID, "busy")
157
163
  return "busy"
@@ -159,38 +165,36 @@ export function createSessionStatusRuntime(options = {}) {
159
165
 
160
166
  const cached = sessionStatuses.get(sessionID)
161
167
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
162
-
163
- // Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
164
- // trusted briefly: OpenCode custom commands such as /loop-status create their
165
- // own short assistant turn, and some TUI builds do not always emit the final
166
- // idle event after that turn. If we cache busy forever, due loop work can get
167
- // stuck at "due in every idle" until the user types another command.
168
168
  if (cached === "idle") return cached
169
169
  if (cached && now() - seenAt < sessionStatusCacheMs) return cached
170
170
 
171
171
  const live = await readLiveSessionStatus(client, sessionID, directory)
172
+ if (live?.type === "unknown") {
173
+ const conservative = cached === "retry" ? "retry" : "busy"
174
+ markSessionStatus(sessionID, conservative)
175
+ return conservative
176
+ }
172
177
  if (live?.type) {
173
- // Some OpenCode 1.15.x/1.18.x TUI builds can leave session.status at busy
174
- // after a plugin command acknowledgement. If no Loop run exists yet, a
175
- // completed assistant tail plus an already-stale busy observation is a
176
- // stronger signal than the unchanged live status. A genuinely running turn
177
- // has an unfinished assistant tail (or latest user message) and is never
178
- // force-recovered by this path.
179
178
  if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) return "idle"
180
179
 
181
- // When a Loop-owned turn exists, use its exact start boundary so an older
182
- // completed assistant message can never finalize a newer active run.
183
180
  if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
184
181
  const active = activeRuns.get(sessionID)
185
182
  if (active) {
186
183
  const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
187
- if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
184
+ if (settledAssistantCompletion(completion) || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
188
185
  markSessionStatus(sessionID, "idle")
189
- await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
186
+ const logDetails = {
190
187
  sessionID,
191
188
  job: active.job?.name || active.jobId,
192
189
  startedAt: active.startedAt,
193
- })
190
+ ...(completion === "completed" ? {} : { staleStatus: live.type }),
191
+ }
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)
194
198
  return "idle"
195
199
  }
196
200
  }
@@ -215,6 +219,7 @@ export function createSessionStatusRuntime(options = {}) {
215
219
  staleActiveRun,
216
220
  canFinalizeActiveRun,
217
221
  readLiveSessionStatus,
222
+ activeRunCompletion: activeRunCompletionFromMessages,
218
223
  sessionStatusType,
219
224
  sessionIsIdle,
220
225
  }
@@ -0,0 +1,56 @@
1
+ import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../core/continuation.js"
2
+ import { orderedSessionMessages, readRecentSessionMessages } from "../opencode/host.js"
3
+
4
+ function messageText(message) {
5
+ const parts = Array.isArray(message?.parts) ? message.parts : []
6
+ const fromParts = parts
7
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
8
+ .map((part) => part.text)
9
+ .join("\n")
10
+ .trim()
11
+ if (fromParts) return fromParts
12
+ const info = message?.info || message || {}
13
+ for (const value of [info.text, info.content, info.summary]) {
14
+ if (typeof value === "string" && value.trim()) return value.trim()
15
+ }
16
+ return ""
17
+ }
18
+
19
+ export async function applyTerminalContinuationGuard(directory, client, sessionID, job, options = {}) {
20
+ // `/loop devam et` is intentionally infinite. The guard is only for an
21
+ // explicit completion-bounded request such as "bitene kadar" / "until done".
22
+ if (job?.scheduleMode !== "idle" || !isCompletionBoundedContinuation(job?.action)) {
23
+ return { job, terminal: false, pausedNow: false }
24
+ }
25
+
26
+ const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8)
27
+ if (!messages) return { job, terminal: false, pausedNow: false }
28
+ const tail = orderedSessionMessages(messages).at(-1)
29
+ const info = tail?.info || tail || {}
30
+ if (info.role !== "assistant") return { job, terminal: false, pausedNow: false }
31
+ const completed = Number(info?.time?.completed || 0)
32
+ const created = Number(info?.time?.created || 0)
33
+ const runStarted = Number(job?.lastRunAt || 0)
34
+ if (!Number.isFinite(completed) || completed <= 0) return { job, terminal: false, pausedNow: false }
35
+ if (runStarted > 0 && completed < runStarted && (!Number.isFinite(created) || created < runStarted)) {
36
+ return { job, terminal: false, pausedNow: false }
37
+ }
38
+
39
+ const text = messageText(tail)
40
+ const terminal = isTerminalNoWorkReply(text)
41
+ if (!terminal) {
42
+ if (job.terminalNoWorkCount) job.terminalNoWorkCount = 0
43
+ return { job, terminal: false, pausedNow: false, text }
44
+ }
45
+
46
+ job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1
47
+ job.lastTerminalNoWorkAt = Date.now()
48
+ job.lastTerminalNoWorkSummary = text.slice(0, 1000)
49
+ const threshold = Math.max(2, Number(options.threshold) || 2)
50
+ const pausedNow = job.terminalNoWorkCount >= threshold && !job.paused
51
+ if (pausedNow) {
52
+ job.paused = true
53
+ job.lastFailureReason = "terminal_no_work"
54
+ }
55
+ return { job, terminal: true, pausedNow, text }
56
+ }