@bybrawe/opencode-loop 0.5.33 → 0.5.35

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.
@@ -78,7 +78,9 @@ export function createSessionStatusRuntime(options = {}) {
78
78
  const argsList = []
79
79
  if (directory) argsList.push({ query: { directory } }, { directory }, { workspace: directory })
80
80
  argsList.push({})
81
+ let attempted = false
81
82
  for (const args of argsList) {
83
+ attempted = true
82
84
  try {
83
85
  const result = await statusMethod.call(client.session, args)
84
86
  const error = sdkError(result)
@@ -91,8 +93,6 @@ export function createSessionStatusRuntime(options = {}) {
91
93
  if (typeof observedType !== "string") continue
92
94
  markSessionStatus(observedSessionID, observedType, observedAt)
93
95
  }
94
- // OpenCode's status list contains active sessions; idle sessions are
95
- // normally omitted. Clear a completed descendant that was previously busy.
96
96
  for (const childID of sessionParents.keys()) {
97
97
  if (!isDescendantSession(childID, sessionID) || data[childID]) continue
98
98
  markSessionStatus(childID, "idle", observedAt)
@@ -104,7 +104,7 @@ export function createSessionStatusRuntime(options = {}) {
104
104
  return { type: "idle", source: "sdk" }
105
105
  } catch {}
106
106
  }
107
- return undefined
107
+ return attempted ? { type: "unknown", source: "sdk-error" } : undefined
108
108
  }
109
109
 
110
110
  async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
@@ -117,23 +117,43 @@ export function createSessionStatusRuntime(options = {}) {
117
117
  if (completion === "completed") return true
118
118
  if (!options.requireIdle) return completion === "unknown" && staleActiveRun(sessionID)
119
119
 
120
+ const cached = sessionStatuses.get(sessionID)
121
+ const seenAt = sessionStatusSeenAt.get(sessionID) || 0
122
+ const cachedIdleAfterRun = cached === "idle" && seenAt > (active.startedAt || 0)
120
123
  const live = await readLiveSessionStatus(client, sessionID, directory)
121
124
  if (live?.type === "idle") return true
125
+ if (live?.type === "unknown" && cachedIdleAfterRun) {
126
+ // A concrete idle observed after this active run is sufficient to finalize
127
+ // that run even if a later status read fails. This does not authorize a
128
+ // new prompt by itself; admission applies its own current status policy.
129
+ return true
130
+ }
122
131
  if (live?.type) {
123
- if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
132
+ if (live.type === "busy" && options.forceStale && completion === "unknown" && staleActiveRun(sessionID)) return true
124
133
  return false
125
134
  }
126
135
 
127
136
  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)
137
+ return cachedIdleAfterRun
138
+ }
139
+
140
+ async function recoverCompletedTailWithoutActiveRun(directory, client, sessionID, liveType, seenAt) {
141
+ if (activeRuns.has(sessionID)) return false
142
+ if (liveType !== "busy") return false
143
+ if (!seenAt || now() - seenAt < sessionStatusCacheMs) return false
144
+ const completion = await activeRunCompletionFromMessages(directory, client, sessionID, { startedAt: 0 })
145
+ if (completion !== "completed") return false
146
+ markSessionStatus(sessionID, "idle")
147
+ await appendLoopLog(directory, "status-message-idle-recovery", {
148
+ sessionID,
149
+ staleStatus: liveType,
150
+ statusSeenAt: seenAt,
151
+ staleForMs: Math.max(0, now() - seenAt),
152
+ })
153
+ return true
131
154
  }
132
155
 
133
156
  async function sessionStatusType(client, sessionID, directory, options = {}) {
134
- // OpenCode can briefly report an idle session while a long-running tool or
135
- // subtask is still executing. Tool lifecycle hooks are the more specific
136
- // signal here, so never enqueue another turn until every active call ends.
137
157
  if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
138
158
  markSessionStatus(sessionID, "busy")
139
159
  return "busy"
@@ -141,32 +161,35 @@ export function createSessionStatusRuntime(options = {}) {
141
161
 
142
162
  const cached = sessionStatuses.get(sessionID)
143
163
  const seenAt = sessionStatusSeenAt.get(sessionID) || 0
144
-
145
- // Idle is safe to trust until OpenCode tells us otherwise. Busy/retry is only
146
- // trusted briefly: OpenCode custom commands such as /loop-status create their
147
- // own short assistant turn, and some TUI builds do not always emit the final
148
- // idle event after that turn. If we cache busy forever, due loop work can get
149
- // stuck at "due in every idle" until the user types another command.
150
164
  if (cached === "idle") return cached
151
165
  if (cached && now() - seenAt < sessionStatusCacheMs) return cached
152
166
 
153
167
  const live = await readLiveSessionStatus(client, sessionID, directory)
168
+ if (live?.type === "unknown") {
169
+ const conservative = cached === "retry" ? "retry" : "busy"
170
+ markSessionStatus(sessionID, conservative)
171
+ return conservative
172
+ }
154
173
  if (live?.type) {
155
- // Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
156
- // plugin-injected turn until the next user command touches the session.
157
- // When the only reason we still think the session is busy is our own stale
158
- // active-run guard, recover instead of waiting for another manual command.
174
+ if (await recoverCompletedTailWithoutActiveRun(directory, client, sessionID, live.type, seenAt)) return "idle"
175
+
159
176
  if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
160
177
  const active = activeRuns.get(sessionID)
161
178
  if (active) {
162
179
  const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
163
- if (completion === "completed" || (completion === "unknown" && staleActiveRun(sessionID))) {
180
+ if (completion === "completed" || (live.type === "busy" && completion === "unknown" && staleActiveRun(sessionID))) {
164
181
  markSessionStatus(sessionID, "idle")
165
- await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
182
+ const logDetails = {
166
183
  sessionID,
167
184
  job: active.job?.name || active.jobId,
168
185
  startedAt: active.startedAt,
169
- })
186
+ ...(completion === "completed" ? {} : { staleStatus: live.type }),
187
+ }
188
+ await appendLoopLog(
189
+ directory,
190
+ completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery",
191
+ logDetails,
192
+ )
170
193
  return "idle"
171
194
  }
172
195
  }
@@ -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
+ }