@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.
- package/CHANGELOG.md +16 -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/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 +140 -5
- package/src/server.js +6451 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/legacy-v1.js +5 -0
- 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
|
@@ -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
|
-
|
|
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
|