@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.
- package/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/package.json +7 -6
- package/scripts/comprehensive-test.mjs +7 -4
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/install-node.mjs +4 -2
- package/scripts/install-test.mjs +5 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode2-loop-canary.mjs +48 -7
- package/scripts/publish-workflow-test.mjs +5 -0
- package/scripts/run-finalization-test.mjs +33 -0
- package/scripts/server-entry-test.mjs +15 -0
- package/scripts/session-error-recovery-test.mjs +186 -0
- package/scripts/session-status-test.mjs +30 -0
- package/scripts/smoke-test.mjs +21 -0
- package/scripts/v2-command-adapter-test.mjs +14 -2
- package/scripts/v2-plugin-sentinel-test.mjs +35 -4
- package/scripts/v2-runtime-adapter-test.mjs +75 -0
- package/src/index.js +257 -96
- package/src/server.js +6451 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/legacy-v1.js +34 -6
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +44 -8
- package/src/source/opencode2/events.js +11 -0
- package/src/source/opencode2/experimental.js +51 -7
- package/src/source/opencode2/prompt-runtime.js +36 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +5 -1
- package/src/source/runtime/terminal-guard.js +32 -7
- 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
|
package/src/source/legacy-v1.js
CHANGED
|
@@ -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: "
|
|
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(
|
|
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: "
|
|
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(
|
|
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: "
|
|
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(
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
return
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
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
|