@bybrawe/opencode-loop 0.5.37 → 0.5.38

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.
@@ -45,6 +45,21 @@ const NEXT_WORK_PATTERNS = [
45
45
  /devam (?:edeceğim|ediyorum|etmek gerek)(?=\s|[.,;:!—-]|$)/i,
46
46
  ]
47
47
 
48
+ const WAITING_USER_PATTERNS = [
49
+ /(?:^|\n)\s*onay bekleyenler\s*:/i,
50
+ /(?:^|\n)\s*(?:kullanıcı|user) (?:onayı|onayi|aksiyonu|işlemi|islemi|erişimi|erisimi) (?:bekleniyor|gerekiyor|gerekli)\b/i,
51
+ /\b(?:senin|sizin) (?:onayın|onayınız|onayiniz|aksiyonun|aksiyonunuz|erişimin|erisimin) (?:bekleniyor|gerekiyor|gerekli)\b/i,
52
+ /\b(?:onay|erişim|erisim|yetki) olmadan (?:devam edemem|ilerleyemem|işlem yapamam|islem yapamam)\b/i,
53
+ /(?:^|\n)\s*(?:waiting (?:for|on) (?:your|user) (?:approval|input|action|access|credentials?)|(?:approval|user action|access) (?:required|pending))\s*:?/i,
54
+ /\b(?:requires?|needs?) (?:your|user) (?:approval|input|action|access|credentials?)\b/i,
55
+ /\bblocked (?:pending|until|on) (?:your|user|approval|access|credentials?)\b/i,
56
+ ]
57
+
58
+ const WAITING_USER_NEGATIONS = [
59
+ /\b(?:onay|erişim|erisim|yetki) (?:gerekmiyor|gerekli değil|gerekli degil|beklenmiyor)\b/i,
60
+ /\bno (?:user )?(?:approval|input|action|access|credentials?) (?:is )?(?:needed|required|pending)\b/i,
61
+ ]
62
+
48
63
  export function isContinuationShorthand(value) {
49
64
  return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "))
50
65
  }
@@ -54,6 +69,14 @@ export function isCompletionBoundedContinuation(value) {
54
69
  return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text))
55
70
  }
56
71
 
72
+ export function isWaitingUserReply(value) {
73
+ const text = String(value || "").trim()
74
+ if (!text) return false
75
+ if (WAITING_USER_NEGATIONS.some((pattern) => pattern.test(text))) return false
76
+ if (NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text))) return false
77
+ return WAITING_USER_PATTERNS.some((pattern) => pattern.test(text))
78
+ }
79
+
57
80
  export function isTerminalNoWorkReply(value) {
58
81
  const text = String(value || "").trim()
59
82
  if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text))) return false
@@ -43,6 +43,7 @@ const executorRuntime = createLoopExecutor({
43
43
  })
44
44
  const {
45
45
  clearActiveRun,
46
+ handleSessionError,
46
47
  finalizeActiveRun,
47
48
  maybeRunDueJobs: runDueJobs,
48
49
  sessionIsIdle,
@@ -263,6 +264,10 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
263
264
  }
264
265
  const steering = await goalSteeringRuntime.handleEvent(directory, client, event)
265
266
  if (steering?.handled && steering.sessionID) rememberSession(directory, client, steering.sessionID)
267
+ if (event.type === "session.error") {
268
+ const handledError = await handleSessionError(directory, client, event)
269
+ if (handledError && event?.properties?.sessionID) rememberSession(directory, client, event.properties.sessionID)
270
+ }
266
271
  const statusUpdate = updateSessionStatusFromEvent(event)
267
272
  if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
268
273
  if (statusUpdate?.idle && !goalSteeringRuntime.shouldSuppressIdle(statusUpdate.sessionID)) {
@@ -42,6 +42,7 @@ export function inspectOpenCode2CommandDraft(draft) {
42
42
  return frozenRecord({
43
43
  list: hasFunction(draft, "list"),
44
44
  get: hasFunction(draft, "get"),
45
+ add: hasFunction(draft, "add"),
45
46
  update: hasFunction(draft, "update"),
46
47
  remove: hasFunction(draft, "remove"),
47
48
  })
@@ -49,16 +49,52 @@ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
49
49
  }),
50
50
  })
51
51
 
52
- export function registerOpenCode2LoopCommands(draft) {
53
- if (!draft || typeof draft.update !== "function") {
54
- throw new Error("OpenCode 2 command draft.update capability is unavailable")
52
+ function commandArguments(input) {
53
+ const prompt = input?.prompt
54
+ if (typeof prompt?.text === "string") return prompt.text.trim()
55
+ if (typeof input?.arguments === "string") return input.arguments.trim()
56
+ return ""
57
+ }
58
+
59
+ export function registerOpenCode2LoopCommands(draft, options = {}) {
60
+ if (!draft || typeof draft !== "object") {
61
+ throw new Error("OpenCode 2 command transform draft is unavailable")
55
62
  }
56
- for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
57
- draft.update(name, (command) => {
58
- command.template = definition.template
59
- command.description = definition.description
60
- })
63
+
64
+ // OpenCode 2.0.11 exposes CommandEditor.add({ name, execute }). Earlier
65
+ // beta hosts exposed update(name, mutate) over command-file definitions.
66
+ // Support both without guessing the host version.
67
+ if (typeof draft.add === "function") {
68
+ const execute = options.execute
69
+ if (typeof execute !== "function") {
70
+ throw new Error("OpenCode 2 command draft.add requires a local Loop command executor")
71
+ }
72
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
73
+ draft.add({
74
+ name,
75
+ description: definition.description,
76
+ execute: async (input) => await execute({
77
+ name,
78
+ sessionID: input?.sessionID,
79
+ arguments: commandArguments(input),
80
+ delivery: input?.delivery,
81
+ }),
82
+ })
83
+ }
84
+ return
85
+ }
86
+
87
+ if (typeof draft.update === "function") {
88
+ for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
89
+ draft.update(name, (command) => {
90
+ command.template = definition.template
91
+ command.description = definition.description
92
+ })
93
+ }
94
+ return
61
95
  }
96
+
97
+ throw new Error("OpenCode 2 command transform exposes neither add() nor update()")
62
98
  }
63
99
 
64
100
  export function parseOpenCode2LoopCommandText(value) {
@@ -40,6 +40,17 @@ export function normalizeOpenCode2NativeEvent(raw) {
40
40
  })
41
41
  }
42
42
 
43
+ if (type === "session.status") {
44
+ const status = text(record(data.status)?.type)
45
+ if (!sessionID || !status) return undefined
46
+ return Object.freeze({ kind: "session", action: "status", sessionID, directory, status })
47
+ }
48
+
49
+ if (type === "session.idle") {
50
+ if (!sessionID) return undefined
51
+ return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
52
+ }
53
+
43
54
  if (type === "session.execution.started") {
44
55
  if (!sessionID) return undefined
45
56
  return Object.freeze({ kind: "session", action: "status", sessionID, directory, status: "busy" })
@@ -16,25 +16,69 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
16
16
  throw new Error("OpenCode 2 command.transform capability is unavailable")
17
17
  }
18
18
 
19
- const commandRegistration = await ctx.command.transform(registerOpenCode2LoopCommands)
20
- if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
21
- await commandRegistration?.dispose?.()
22
- return undefined
23
- }
24
-
25
19
  let promptRuntime
26
20
  let diagnosticsRuntime
21
+ const hostVersion = String(ctx?.app?.version || "").trim()
22
+ const promiseStreamHost = /^2\./.test(hostVersion)
23
+ const requireBusyBeforeIdle = promiseStreamHost
27
24
  const logRuntime = createOpenCode2LogRuntime()
25
+ const runtimeDirectory = String(ctx?.location?.directory || ctx?.options?.directory || "").trim() || undefined
26
+
28
27
  const onRuntimeEvent = async (event) => {
29
28
  const promptResult = await promptRuntime?.onEvent(event)
30
29
  await logRuntime.record(event, promptResult)
31
30
  if (promptResult?.handled) return promptResult
32
31
  return await diagnosticsRuntime?.onEvent(event) ?? promptResult
33
32
  }
34
- const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
33
+
34
+ const commandRegistration = await ctx.command.transform((draft) => {
35
+ return registerOpenCode2LoopCommands(draft, {
36
+ execute: async ({ name, sessionID, arguments: argumentsText, delivery }) => {
37
+ if (!promptRuntime || !diagnosticsRuntime) {
38
+ throw new Error("OpenCode Loop V2 runtime is not ready")
39
+ }
40
+ const commandEvent = Object.freeze({
41
+ kind: "command",
42
+ action: "executed",
43
+ sessionID: String(sessionID || ""),
44
+ directory: runtimeDirectory,
45
+ name,
46
+ arguments: argumentsText,
47
+ delivery,
48
+ })
49
+ const result = await onRuntimeEvent(commandEvent)
50
+
51
+ // OpenCode 2.0.11 command callbacks are truly local: creating a Loop
52
+ // job does not itself start a model turn, so no execution-succeeded
53
+ // idle event follows the command. Kick one local idle boundary for
54
+ // commands that can make prompt work immediately due. Once the prompt
55
+ // is admitted, the real execution event stream owns subsequent turns.
56
+ if (result?.handled && result?.accepted && ["loop", "loop-now", "loop-resume"].includes(name)) {
57
+ await onRuntimeEvent(Object.freeze({
58
+ kind: "session",
59
+ action: "idle",
60
+ sessionID: String(sessionID || ""),
61
+ directory: runtimeDirectory,
62
+ }))
63
+ }
64
+ return result
65
+ },
66
+ })
67
+ })
68
+
69
+ if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
70
+ await commandRegistration?.dispose?.()
71
+ return undefined
72
+ }
73
+ const runtime = createOpenCode2RuntimeAdapter(ctx, {
74
+ directory: runtimeDirectory,
75
+ onEvent: onRuntimeEvent,
76
+ eventSubscribeStyle: promiseStreamHost ? "stream" : "auto",
77
+ })
35
78
  promptRuntime = createOpenCode2PromptRuntime({
36
79
  prompt: (request) => runtime.prompt(request),
37
80
  command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
81
+ requireBusyBeforeIdle,
38
82
  })
39
83
  diagnosticsRuntime = createOpenCode2DiagnosticsRuntime({
40
84
  prompt: (request) => runtime.prompt(request),
@@ -90,9 +90,11 @@ export function createOpenCode2PromptRuntime(options = {}) {
90
90
  const setTimer = typeof options.setTimer === "function" ? options.setTimer : setTimeout
91
91
  const clearTimer = typeof options.clearTimer === "function" ? options.clearTimer : clearTimeout
92
92
  const onError = typeof options.onError === "function" ? options.onError : () => {}
93
+ const requireBusyBeforeIdle = options.requireBusyBeforeIdle === true
93
94
  const timers = new Map()
94
95
  const idle = new Map()
95
96
  const queues = new Map()
97
+ const awaitingBusy = new Set()
96
98
  let disposed = false
97
99
 
98
100
  function report(error) {
@@ -116,6 +118,7 @@ export function createOpenCode2PromptRuntime(options = {}) {
116
118
  function clearScope(scope) {
117
119
  clearScopeTimer(scope.key)
118
120
  idle.delete(scope.key)
121
+ awaitingBusy.delete(scope.key)
119
122
  }
120
123
 
121
124
  function enqueueScope(scope, task) {
@@ -183,7 +186,13 @@ export function createOpenCode2PromptRuntime(options = {}) {
183
186
 
184
187
  const text = promptText(job)
185
188
  const request = { sessionID: scope.sessionID, text }
186
- await options.prompt(request)
189
+ if (requireBusyBeforeIdle) awaitingBusy.add(scope.key)
190
+ try {
191
+ await options.prompt(request)
192
+ } catch (error) {
193
+ awaitingBusy.delete(scope.key)
194
+ throw error
195
+ }
187
196
  return { kind: "prompt", request, text }
188
197
  }
189
198
 
@@ -329,14 +338,34 @@ export function createOpenCode2PromptRuntime(options = {}) {
329
338
  }
330
339
 
331
340
  if (event?.kind === "session" && event?.action === "idle") {
341
+ if (requireBusyBeforeIdle && scope && awaitingBusy.has(scope.key)) {
342
+ return { handled: true, dispatched: false, reason: "awaiting-busy" }
343
+ }
332
344
  return runIdlePrompt(event)
333
345
  }
334
346
  if (event?.kind === "session" && event?.action === "status" && scope) {
335
- idle.set(scope.key, event.status === "idle")
336
- return await enqueueScope(scope, async () => {
337
- await scheduleScope(scope)
338
- return { handled: true, dispatched: false }
339
- })
347
+ if (event.status === "busy" || event.status === "retry") {
348
+ awaitingBusy.delete(scope.key)
349
+ idle.set(scope.key, false)
350
+ return await enqueueScope(scope, async () => {
351
+ await scheduleScope(scope)
352
+ return { handled: true, dispatched: false }
353
+ })
354
+ }
355
+ if (event.status === "idle") {
356
+ if (requireBusyBeforeIdle) {
357
+ if (awaitingBusy.has(scope.key)) {
358
+ return { handled: true, dispatched: false, reason: "awaiting-busy" }
359
+ }
360
+ return runIdlePrompt(event)
361
+ }
362
+ idle.set(scope.key, true)
363
+ return await enqueueScope(scope, async () => {
364
+ await scheduleScope(scope)
365
+ return { handled: true, dispatched: false }
366
+ })
367
+ }
368
+ return { handled: false }
340
369
  }
341
370
  if (event?.kind === "session" && event?.action === "deleted" && scope) {
342
371
  clearScope(scope)
@@ -361,6 +390,7 @@ export function createOpenCode2PromptRuntime(options = {}) {
361
390
  }
362
391
  timers.clear()
363
392
  idle.clear()
393
+ awaitingBusy.clear()
364
394
  const pending = [...queues.values()]
365
395
  if (pending.length) await Promise.allSettled(pending)
366
396
  queues.clear()
@@ -6,8 +6,12 @@ function promptRequest(request) {
6
6
  sessionID: request.sessionID,
7
7
  }
8
8
  if (request.noReply === true) {
9
+ // beta hosts understood noReply+parts; OpenCode 2.0.11 uses the ordinary
10
+ // prompt shape and resume=false for a durable non-running message.
9
11
  value.noReply = true
10
12
  value.parts = [{ type: "text", text: request.text }]
13
+ value.text = request.text
14
+ value.resume = false
11
15
  } else {
12
16
  value.text = request.text
13
17
  }
@@ -19,9 +23,14 @@ function promptRequest(request) {
19
23
  function commandRequest(request) {
20
24
  const value = {
21
25
  sessionID: request.sessionID,
26
+ // OpenCode 2.0.11 calls this field name; earlier beta clients called it command.
27
+ name: request.command,
22
28
  command: request.command,
23
29
  }
24
- if (request.arguments !== undefined) value.arguments = request.arguments
30
+ if (request.arguments !== undefined) {
31
+ value.arguments = request.arguments
32
+ value.text = request.arguments
33
+ }
25
34
  if (request.agent !== undefined) value.agent = request.agent
26
35
  if (request.model !== undefined) value.model = request.model
27
36
  if (request.delivery !== undefined) value.delivery = request.delivery
@@ -34,9 +43,13 @@ export function createOpenCode2RuntimeAdapter(ctx, options = {}) {
34
43
  if (!capabilities.eventSubscribe) throw new Error("OpenCode 2 event.subscribe capability is unavailable")
35
44
  if (!capabilities.sessionPrompt) throw new Error("OpenCode 2 session.prompt capability is unavailable")
36
45
 
46
+ const subscribe = options.eventSubscribeStyle === "stream"
47
+ ? () => ctx.event.subscribe()
48
+ : ctx.event.subscribe.bind(ctx.event)
49
+
37
50
  const host = createOpenCode2HostContract({
38
51
  directory: options.directory,
39
- subscribe: ctx.event.subscribe.bind(ctx.event),
52
+ subscribe,
40
53
  sendPrompt: (request) => ctx.session.prompt(promptRequest(request)),
41
54
  sendCommand: capabilities.sessionCommand ? (request) => ctx.session.command(commandRequest(request)) : undefined,
42
55
  onEvent: options.onEvent,
@@ -23,6 +23,30 @@ function requireFunction(value, label) {
23
23
  return value
24
24
  }
25
25
 
26
+ function sessionErrorDetails(event, fallbackMessage) {
27
+ if (event?.type !== "session.error") return undefined
28
+ const sessionID = event?.properties?.sessionID
29
+ if (typeof sessionID !== "string" || !sessionID) return undefined
30
+ const error = event?.properties?.error
31
+ const name = error && typeof error === "object" && typeof error.name === "string"
32
+ ? error.name
33
+ : "SessionError"
34
+ const data = error && typeof error === "object" && error.data && typeof error.data === "object"
35
+ ? error.data
36
+ : undefined
37
+ const message = typeof data?.message === "string" && data.message.trim()
38
+ ? data.message.trim()
39
+ : fallbackMessage(error)
40
+ return {
41
+ sessionID,
42
+ error,
43
+ name,
44
+ message,
45
+ aborted: name === "MessageAbortedError",
46
+ retryable: name === "APIError" && data?.isRetryable === true,
47
+ }
48
+ }
49
+
26
50
  export function createLoopExecutor(options = {}) {
27
51
  const workspace = options.workspace || {}
28
52
  const goalPolicy = options.goalPolicy || {}
@@ -222,6 +246,64 @@ export function createLoopExecutor(options = {}) {
222
246
  return true
223
247
  }
224
248
 
249
+ async function handleSessionError(directory, client, event) {
250
+ const details = sessionErrorDetails(event, errorMessage)
251
+ if (!details || details.aborted) return false
252
+
253
+ const active = activeRuns.get(details.sessionID)
254
+ if (!active || active.compactionOnly) return false
255
+
256
+ const snapshot = active
257
+ clearActiveRun(details.sessionID)
258
+ clearSessionStatus(details.sessionID)
259
+
260
+ if (details.retryable) {
261
+ const refunded = await persistInfrastructureRefund(directory, details.sessionID, snapshot, {
262
+ reason: "session_error_retryable",
263
+ error: details.message,
264
+ })
265
+ await appendLoopLog(directory, "provider-session-error-retry", {
266
+ sessionID: details.sessionID,
267
+ job: refunded.job?.name || snapshot.jobId,
268
+ errorName: details.name,
269
+ error: details.message,
270
+ retryInMs: refunded.delayMs,
271
+ })
272
+ await toast(client, `Loop provider turn failed after OpenCode retries; the logical run was refunded and will retry with backoff: ${details.message}`, "warning")
273
+ await scheduleDueWork(directory, client, details.sessionID, refunded.delayMs)
274
+ return true
275
+ }
276
+
277
+ const state = await readState(directory, details.sessionID)
278
+ const job = (state.jobs || []).find((candidate) => candidate.id === snapshot.jobId)
279
+ if (job) {
280
+ job.failureCount = (job.failureCount || 0) + 1
281
+ job.paused = true
282
+ job.lastFailureReason = "session_error"
283
+ job.lastSessionErrorName = details.name
284
+ job.lastSessionError = details.message.slice(0, 4000)
285
+ job.lastSessionErrorAt = now()
286
+ state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
287
+ await writeState(directory, details.sessionID, state)
288
+ await notifyJob(directory, job, "session_error")
289
+ }
290
+
291
+ await appendLoopLog(directory, "session-error", {
292
+ sessionID: details.sessionID,
293
+ job: job?.name || snapshot.jobId,
294
+ errorName: details.name,
295
+ error: details.message,
296
+ paused: Boolean(job?.paused),
297
+ })
298
+ await toast(
299
+ client,
300
+ `Loop paused after a terminal OpenCode session error: ${details.message}`,
301
+ "error",
302
+ )
303
+ await scheduleDueWork(directory, client, details.sessionID)
304
+ return true
305
+ }
306
+
225
307
  async function recoverStuckProviderRetry(directory, client, sessionID) {
226
308
  const active = activeRuns.get(sessionID)
227
309
  if (!active || active.compactionOnly) {
@@ -525,6 +607,7 @@ export function createLoopExecutor(options = {}) {
525
607
  clearActiveRun,
526
608
  disposeSession,
527
609
  recoverActiveDispatchFailure,
610
+ handleSessionError,
528
611
  recoverStuckProviderRetry,
529
612
  finalizeActiveRun,
530
613
  fireAction,
@@ -103,6 +103,16 @@ export function createRunFinalizationRuntime(options = {}) {
103
103
 
104
104
  const terminal = await applyTerminalContinuationGuard(directory, client, sessionID, job)
105
105
  job = terminal.job
106
+ if (terminal.waitingUserPausedNow) {
107
+ await appendLoopLog(directory, "waiting-user", {
108
+ sessionID,
109
+ job: job.name || job.id,
110
+ count: job.waitingUserCount,
111
+ summary: String(terminal.text || "").slice(0, 1000),
112
+ })
113
+ await notifyJob(directory, job, "waiting_user")
114
+ await toast(client, "Loop paused: autonomous continuation is waiting for explicit user approval, access, or action.", "warning")
115
+ }
106
116
  if (terminal.pausedNow) {
107
117
  await appendLoopLog(directory, "terminal-no-work", {
108
118
  sessionID,
@@ -194,7 +194,11 @@ export function createSessionStatusRuntime(options = {}) {
194
194
  : completion === "completed"
195
195
  ? "status-message-complete-recovery"
196
196
  : "status-stale-recovery"
197
- await appendLoopLog(directory, recoveryEvent, logDetails)
197
+ active.statusRecoveryLogEvents ??= new Set()
198
+ if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
199
+ active.statusRecoveryLogEvents.add(recoveryEvent)
200
+ await appendLoopLog(directory, recoveryEvent, logDetails)
201
+ }
198
202
  return "idle"
199
203
  }
200
204
  }
@@ -1,4 +1,4 @@
1
- import { isCompletionBoundedContinuation, isTerminalNoWorkReply } from "../core/continuation.js"
1
+ import { isCompletionBoundedContinuation, isContinuationShorthand, isTerminalNoWorkReply, isWaitingUserReply } from "../core/continuation.js"
2
2
  import { orderedSessionMessages, readRecentSessionMessages } from "../opencode/host.js"
3
3
 
4
4
  function messageText(message) {
@@ -17,10 +17,13 @@ function messageText(message) {
17
17
  }
18
18
 
19
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 }
20
+ // Plain continuation remains intentionally infinite for ordinary work and
21
+ // even ordinary "done" replies. We only inspect it for an explicit
22
+ // waiting-user dependency so a blocked autonomous loop does not poll forever.
23
+ const completionBounded = isCompletionBoundedContinuation(job?.action)
24
+ const continuation = completionBounded || isContinuationShorthand(job?.action)
25
+ if (job?.scheduleMode !== "idle" || !continuation) {
26
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false }
24
27
  }
25
28
 
26
29
  const messages = await readRecentSessionMessages(client, sessionID, directory, options.messageLimit || 8)
@@ -37,10 +40,32 @@ export async function applyTerminalContinuationGuard(directory, client, sessionI
37
40
  }
38
41
 
39
42
  const text = messageText(tail)
43
+ const waitingUser = isWaitingUserReply(text)
44
+ if (waitingUser) {
45
+ if (job.terminalNoWorkCount) job.terminalNoWorkCount = 0
46
+ job.waitingUserCount = (job.waitingUserCount || 0) + 1
47
+ job.lastWaitingUserAt = Date.now()
48
+ job.lastWaitingUserSummary = text.slice(0, 1000)
49
+ const threshold = Math.max(2, Number(options.waitingUserThreshold) || 2)
50
+ const waitingUserPausedNow = job.waitingUserCount >= threshold && !job.paused
51
+ if (waitingUserPausedNow) {
52
+ job.paused = true
53
+ job.lastFailureReason = "waiting_user"
54
+ }
55
+ return { job, terminal: false, pausedNow: false, waitingUser: true, waitingUserPausedNow, text }
56
+ }
57
+
58
+ if (job.waitingUserCount) job.waitingUserCount = 0
59
+
60
+ // A normal completion/no-work reply still cannot stop plain /loop devam et.
61
+ if (!completionBounded) {
62
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text }
63
+ }
64
+
40
65
  const terminal = isTerminalNoWorkReply(text)
41
66
  if (!terminal) {
42
67
  if (job.terminalNoWorkCount) job.terminalNoWorkCount = 0
43
- return { job, terminal: false, pausedNow: false, text }
68
+ return { job, terminal: false, pausedNow: false, waitingUser: false, waitingUserPausedNow: false, text }
44
69
  }
45
70
 
46
71
  job.terminalNoWorkCount = (job.terminalNoWorkCount || 0) + 1
@@ -52,5 +77,5 @@ export async function applyTerminalContinuationGuard(directory, client, sessionI
52
77
  job.paused = true
53
78
  job.lastFailureReason = "terminal_no_work"
54
79
  }
55
- return { job, terminal: true, pausedNow, text }
80
+ return { job, terminal: true, pausedNow, waitingUser: false, waitingUserPausedNow: false, text }
56
81
  }
@@ -0,0 +1,18 @@
1
+ import OpenCodeLoopPlugin from "./v1.js"
2
+ import OpenCodeLoopV2ExperimentalPlugin from "./opencode2/experimental.js"
3
+
4
+ export const OPENCODE_LOOP_PLUGIN_ID = "@bybrawe/opencode-loop"
5
+
6
+ export const OpenCodeLoopPluginModule = Object.freeze({
7
+ id: OPENCODE_LOOP_PLUGIN_ID,
8
+
9
+ // OpenCode 1.x PluginModule contract.
10
+ server: OpenCodeLoopPlugin,
11
+
12
+ // OpenCode 2.x promise-plugin contract.
13
+ setup: (context) => OpenCodeLoopV2ExperimentalPlugin.setup(context),
14
+ })
15
+
16
+ export { OpenCodeLoopPlugin, OpenCodeLoopV2ExperimentalPlugin }
17
+
18
+ export default OpenCodeLoopPluginModule