@bybrawe/opencode-loop 0.5.36 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +7 -6
  4. package/scripts/comprehensive-test.mjs +7 -4
  5. package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
  6. package/scripts/install-node.mjs +4 -2
  7. package/scripts/install-test.mjs +5 -0
  8. package/scripts/network-recovery-test.mjs +38 -1
  9. package/scripts/opencode2-loop-canary.mjs +48 -7
  10. package/scripts/publish-workflow-test.mjs +5 -0
  11. package/scripts/run-finalization-test.mjs +33 -0
  12. package/scripts/server-entry-test.mjs +15 -0
  13. package/scripts/session-error-recovery-test.mjs +186 -0
  14. package/scripts/session-status-test.mjs +30 -0
  15. package/scripts/smoke-test.mjs +21 -0
  16. package/scripts/v2-command-adapter-test.mjs +14 -2
  17. package/scripts/v2-plugin-sentinel-test.mjs +35 -4
  18. package/scripts/v2-runtime-adapter-test.mjs +75 -0
  19. package/src/index.js +257 -96
  20. package/src/server.js +6451 -0
  21. package/src/source/core/continuation.js +23 -0
  22. package/src/source/legacy-v1.js +34 -6
  23. package/src/source/opencode2/capabilities.js +1 -0
  24. package/src/source/opencode2/commands.js +44 -8
  25. package/src/source/opencode2/events.js +11 -0
  26. package/src/source/opencode2/experimental.js +51 -7
  27. package/src/source/opencode2/prompt-runtime.js +36 -6
  28. package/src/source/opencode2/runtime-adapter.js +15 -2
  29. package/src/source/runtime/executor.js +83 -0
  30. package/src/source/runtime/run-finalization.js +10 -0
  31. package/src/source/runtime/session-status.js +5 -1
  32. package/src/source/runtime/terminal-guard.js +32 -7
  33. package/src/source/server.js +18 -0
@@ -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
@@ -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
 
@@ -42,6 +43,7 @@ const executorRuntime = createLoopExecutor({
42
43
  })
43
44
  const {
44
45
  clearActiveRun,
46
+ handleSessionError,
45
47
  finalizeActiveRun,
46
48
  maybeRunDueJobs: runDueJobs,
47
49
  sessionIsIdle,
@@ -162,44 +164,66 @@ function steeringToolRejection(sessionID) {
162
164
  }
163
165
  }
164
166
 
167
+ async function dedicatedGoalToolRejection(directory, sessionID) {
168
+ const goal = await findDedicatedGoalForSession(directory, sessionID)
169
+ if (!goal || goal.status === "completed") return undefined
170
+ return {
171
+ title: "Dedicated Goal owns lifecycle",
172
+ output: [
173
+ `A dedicated OpenCode Goal is persisted for this session (status=${String(goal.status || "unknown")}).`,
174
+ "The opencode_loop_goal_* tools control only experimental /loop-goal jobs and will not mutate the dedicated Goal.",
175
+ "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.",
176
+ ].join("\n"),
177
+ }
178
+ }
179
+
165
180
  function goalTools(defaultDirectory) {
166
181
  return {
167
182
  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.",
183
+ 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
184
  args: {
170
185
  summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
171
186
  evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results."),
172
187
  },
173
188
  execute: async (args, context) => {
189
+ const directory = context.directory || defaultDirectory
190
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
191
+ if (dedicated) return dedicated
174
192
  const steering = steeringToolRejection(context.sessionID)
175
193
  if (steering) return steering
176
- const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
194
+ const result = await setGoalComplete(directory, context.sessionID, args)
177
195
  return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
178
196
  },
179
197
  }),
180
198
  opencode_loop_goal_blocked: tool({
181
- description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
199
+ 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
200
  args: {
183
201
  reason: tool.schema.string().describe("Why the goal is blocked."),
184
202
  needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue."),
185
203
  },
186
204
  execute: async (args, context) => {
205
+ const directory = context.directory || defaultDirectory
206
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
207
+ if (dedicated) return dedicated
187
208
  const steering = steeringToolRejection(context.sessionID)
188
209
  if (steering) return steering
189
- const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
210
+ const result = await setGoalBlocked(directory, context.sessionID, args)
190
211
  return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
191
212
  },
192
213
  }),
193
214
  opencode_loop_goal_progress: tool({
194
- description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
215
+ 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
216
  args: {
196
217
  summary: tool.schema.string().describe("What useful progress was made."),
197
218
  next: tool.schema.string().describe("The next step toward completing the goal."),
198
219
  },
199
220
  execute: async (args, context) => {
221
+ const directory = context.directory || defaultDirectory
222
+ const dedicated = await dedicatedGoalToolRejection(directory, context.sessionID)
223
+ if (dedicated) return dedicated
200
224
  const steering = steeringToolRejection(context.sessionID)
201
225
  if (steering) return steering
202
- const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
226
+ const result = await setGoalProgress(directory, context.sessionID, args)
203
227
  return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
204
228
  },
205
229
  }),
@@ -240,6 +264,10 @@ export const OpenCodeLoopPlugin = async ({ client, directory }) => {
240
264
  }
241
265
  const steering = await goalSteeringRuntime.handleEvent(directory, client, event)
242
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
+ }
243
271
  const statusUpdate = updateSessionStatusFromEvent(event)
244
272
  if (statusUpdate?.sessionID) rememberSession(directory, client, statusUpdate.sessionID)
245
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