@bybrawe/opencode-loop 0.5.37 → 0.6.0
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 +31 -1
- package/examples/OPENCODE2-0.6.0.md +54 -0
- package/package.json +12 -8
- package/scripts/branch-hygiene.mjs +326 -0
- package/scripts/comprehensive-test.mjs +19 -10
- package/scripts/control-plane-until-test.mjs +39 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +6 -1
- package/scripts/goal-compaction-safety-test.mjs +68 -0
- package/scripts/goal-companion-test.mjs +1 -1
- package/scripts/host-adapter-contract-test.mjs +1 -0
- package/scripts/install-node.mjs +105 -58
- package/scripts/install-test.mjs +6 -1
- package/scripts/install-with-goals.mjs +17 -4
- package/scripts/job-workspace-test.mjs +11 -1
- package/scripts/loopd-test.mjs +74 -4
- package/scripts/loopd.mjs +153 -25
- package/scripts/native-cli-host-canary.mjs +118 -0
- package/scripts/native-cli-io-test.mjs +66 -0
- package/scripts/native-cli-test.mjs +130 -0
- package/scripts/native-coexistence-test.mjs +38 -0
- package/scripts/native-entry-test.mjs +77 -0
- package/scripts/native-guide-test.mjs +45 -0
- package/scripts/native-install-pair-test.mjs +73 -0
- package/scripts/native-install-test.mjs +107 -0
- package/scripts/native-lifecycle-test.mjs +100 -0
- package/scripts/native-plugin-lifetime-test.mjs +92 -0
- package/scripts/native-presets-test.mjs +227 -0
- package/scripts/native-process-tree-fixture.mjs +72 -0
- package/scripts/native-process-tree-test.mjs +40 -0
- package/scripts/native-release.mjs +121 -0
- package/scripts/native-session-location-test.mjs +86 -0
- package/scripts/native-shell-test.mjs +38 -0
- package/scripts/native-v2-test.mjs +262 -0
- package/scripts/network-recovery-test.mjs +38 -1
- package/scripts/opencode-host.mjs +47 -0
- package/scripts/opencode2-loop-canary.mjs +119 -10
- package/scripts/package-native-smoke.mjs +31 -0
- package/scripts/publish-workflow-test.mjs +60 -14
- package/scripts/pulse-check.mjs +246 -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 +42 -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 +227 -53
- package/src/native.js +3135 -0
- package/src/server.js +4055 -0
- package/src/source/core/continuation.js +23 -0
- package/src/source/core/jobs.js +17 -0
- package/src/source/core/process.js +6 -4
- package/src/source/core/progress.js +58 -0
- package/src/source/legacy-v1.js +14 -3
- package/src/source/native.js +1 -0
- package/src/source/opencode/host.js +1 -0
- package/src/source/opencode/loop-commands.js +1 -27
- package/src/source/opencode/loop-registration.js +2 -12
- package/src/source/opencode/messages.js +1 -1
- package/src/source/opencode2/capabilities.js +1 -0
- package/src/source/opencode2/commands.js +52 -11
- package/src/source/opencode2/event-bridge.js +15 -3
- package/src/source/opencode2/events.js +31 -0
- package/src/source/opencode2/experimental.js +53 -7
- package/src/source/opencode2/native-companion.js +24 -0
- package/src/source/opencode2/native-plugin.js +153 -0
- package/src/source/opencode2/native-policy.js +106 -0
- package/src/source/opencode2/native-runtime.js +464 -0
- package/src/source/opencode2/native-shell.js +81 -0
- package/src/source/opencode2/prompt-runtime.js +38 -6
- package/src/source/opencode2/runtime-adapter.js +15 -2
- package/src/source/opencode2/session-location.js +16 -0
- package/src/source/runtime/executor.js +83 -0
- package/src/source/runtime/goal-steering.js +17 -3
- package/src/source/runtime/job-workspace.js +11 -2
- package/src/source/runtime/run-finalization.js +10 -0
- package/src/source/runtime/session-status.js +16 -2
- package/src/source/runtime/terminal-guard.js +32 -7
- package/src/source/server.js +26 -0
- package/src/v1.js +1 -0
|
@@ -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,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
// The plugin location is not evidence about an arbitrary session.
|
|
4
|
+
// Resolve through the host before using the instance's local files.
|
|
5
|
+
export function createNativeSessionLocationGuard({ directory, workspaceID, getSession }) {
|
|
6
|
+
const expected = path.resolve(directory)
|
|
7
|
+
return async (sessionID) => {
|
|
8
|
+
if (typeof sessionID !== 'string' || !sessionID || typeof getSession !== 'function') return false
|
|
9
|
+
try {
|
|
10
|
+
const session = await getSession({ sessionID })
|
|
11
|
+
if (session?.id !== sessionID || typeof session.location?.directory !== 'string' || !path.isAbsolute(session.location.directory)) return false
|
|
12
|
+
if (path.relative(expected, path.resolve(session.location.directory)) !== '') return false
|
|
13
|
+
return (session.location.workspaceID ?? '') === (workspaceID ?? '')
|
|
14
|
+
} catch { return false }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -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,
|
|
@@ -62,6 +62,7 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
62
62
|
? Number(options.seenUserMessageMs)
|
|
63
63
|
: DEFAULT_SEEN_USER_MESSAGE_MS
|
|
64
64
|
|
|
65
|
+
const compactingSessions = new Set()
|
|
65
66
|
const pendingSteering = new Map()
|
|
66
67
|
const seenUserMessages = new Map()
|
|
67
68
|
|
|
@@ -134,7 +135,7 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
134
135
|
|
|
135
136
|
const active = getActiveRun(sessionID)
|
|
136
137
|
const activeGoalIDs = new Set(goals.map((goal) => goal.id))
|
|
137
|
-
const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
|
|
138
|
+
const canPreempt = !compactingSessions.has(sessionID) && active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function"
|
|
138
139
|
let preempted = false
|
|
139
140
|
let abortError = ""
|
|
140
141
|
|
|
@@ -154,7 +155,8 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
154
155
|
{ path: { sessionID }, body: {} },
|
|
155
156
|
{ sessionID },
|
|
156
157
|
)
|
|
157
|
-
|
|
158
|
+
// A foreground turn can replace the active run while abort settles.
|
|
159
|
+
if (getActiveRun(sessionID) === active) clearActiveRun(sessionID)
|
|
158
160
|
preempted = true
|
|
159
161
|
} catch (error) {
|
|
160
162
|
pendingSteering.delete(sessionID)
|
|
@@ -177,10 +179,21 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
177
179
|
observeAssistantMessage(event)
|
|
178
180
|
const user = userMessageFromEvent(event)
|
|
179
181
|
if (!user) return undefined
|
|
180
|
-
|
|
182
|
+
// Persisted user-role events include core compaction markers, synthetic
|
|
183
|
+
// continuations and replayed history. Parts may not even be persisted yet.
|
|
184
|
+
// Only the pre-dispatch chat.message admission hook is steering authority.
|
|
185
|
+
if (alreadyHandled(user.sessionID, user.messageID)) return { handled: false, duplicate: true, ...user }
|
|
186
|
+
return { handled: false, reason: "untrusted-message-event", ...user }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function setCompacting(sessionID, value = true) {
|
|
190
|
+
if (typeof sessionID !== "string" || !sessionID) return
|
|
191
|
+
if (value) compactingSessions.add(sessionID)
|
|
192
|
+
else compactingSessions.delete(sessionID)
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
function clearSession(sessionID) {
|
|
196
|
+
compactingSessions.delete(sessionID)
|
|
184
197
|
pendingSteering.delete(sessionID)
|
|
185
198
|
const prefix = `${sessionID}\u0000`
|
|
186
199
|
for (const key of seenUserMessages.keys()) if (key.startsWith(prefix)) seenUserMessages.delete(key)
|
|
@@ -193,6 +206,7 @@ export function createGoalSteeringRuntime(options = {}) {
|
|
|
193
206
|
shouldSuppressIdle,
|
|
194
207
|
hasPendingSteering: shouldSuppressIdle,
|
|
195
208
|
pendingForSession,
|
|
209
|
+
setCompacting,
|
|
196
210
|
clearSession,
|
|
197
211
|
}
|
|
198
212
|
}
|
|
@@ -58,10 +58,10 @@ export function createJobWorkspaceRuntime(options = {}) {
|
|
|
58
58
|
if (!job.branch || job.branchDone) return job
|
|
59
59
|
const branch = safeID(job.branch)
|
|
60
60
|
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 10_000)
|
|
61
|
-
if (inRepo.code !== 0) { job.branchDone = true; return job }
|
|
61
|
+
if (inRepo.code !== 0) { job.branchDone = false; job.branchUnavailable = true; return job }
|
|
62
62
|
let result = await runProcess("git", ["switch", branch], directory, 30_000)
|
|
63
63
|
if (result.code !== 0) result = await runProcess("git", ["switch", "-c", branch], directory, 30_000)
|
|
64
|
-
job.branchDone =
|
|
64
|
+
job.branchDone = result.code === 0
|
|
65
65
|
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning")
|
|
66
66
|
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code })
|
|
67
67
|
return job
|
|
@@ -97,6 +97,13 @@ export function createJobWorkspaceRuntime(options = {}) {
|
|
|
97
97
|
|
|
98
98
|
async function untilReached(directory, job) {
|
|
99
99
|
if (!job.until) return false
|
|
100
|
+
// Goal contracts/archives/leases can contain the configured marker too.
|
|
101
|
+
// None of this control state proves completion of user-project work.
|
|
102
|
+
const controlRoots = new Set([
|
|
103
|
+
stateDir(directory),
|
|
104
|
+
...["goals", "goal-locks", "goal-handoff-locks", "goal-sequences"]
|
|
105
|
+
.map((name) => path.join(directory, ".opencode", name)),
|
|
106
|
+
].map((root) => path.resolve(root).toLowerCase()))
|
|
100
107
|
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path.join(".opencode", "opencode-loop", "until.txt")]
|
|
101
108
|
for (const file of files) if (await fileContains(path.resolve(directory, file), job.until)) return true
|
|
102
109
|
let scanned = 0
|
|
@@ -108,6 +115,8 @@ export function createJobWorkspaceRuntime(options = {}) {
|
|
|
108
115
|
if (scanned >= MAX_SCAN_FILES) return false
|
|
109
116
|
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name)) continue
|
|
110
117
|
const full = path.join(current, entry.name)
|
|
118
|
+
// Keep the explicit until.txt marker above, but never scan plugin state.
|
|
119
|
+
if (controlRoots.has(path.resolve(full).toLowerCase())) continue
|
|
111
120
|
if (entry.isDirectory()) { if (await walk(full)) return true }
|
|
112
121
|
else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) { scanned++; if (await fileContains(full, job.until)) return true }
|
|
113
122
|
}
|
|
@@ -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,
|
|
@@ -113,7 +113,17 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
113
113
|
|
|
114
114
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
115
115
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) return false
|
|
116
|
-
|
|
116
|
+
|
|
117
|
+
// OpenCode 1.x can emit transient session.idle boundaries between tool
|
|
118
|
+
// steps while the same assistant turn is still in progress. When the
|
|
119
|
+
// message API can prove that the current assistant message is incomplete,
|
|
120
|
+
// keep the Loop-owned run active instead of treating that transient idle
|
|
121
|
+
// as a completed turn. Preserve the historical fallback when message
|
|
122
|
+
// completion evidence is unavailable.
|
|
123
|
+
if (!options.requireIdle && !options.forceStale) {
|
|
124
|
+
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
125
|
+
return completion !== "incomplete"
|
|
126
|
+
}
|
|
117
127
|
|
|
118
128
|
const completion = options.forceStale
|
|
119
129
|
? await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
@@ -194,7 +204,11 @@ export function createSessionStatusRuntime(options = {}) {
|
|
|
194
204
|
: completion === "completed"
|
|
195
205
|
? "status-message-complete-recovery"
|
|
196
206
|
: "status-stale-recovery"
|
|
197
|
-
|
|
207
|
+
active.statusRecoveryLogEvents ??= new Set()
|
|
208
|
+
if (!active.statusRecoveryLogEvents.has(recoveryEvent)) {
|
|
209
|
+
active.statusRecoveryLogEvents.add(recoveryEvent)
|
|
210
|
+
await appendLoopLog(directory, recoveryEvent, logDetails)
|
|
211
|
+
}
|
|
198
212
|
return "idle"
|
|
199
213
|
}
|
|
200
214
|
}
|
|
@@ -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,26 @@
|
|
|
1
|
+
import OpenCodeLoopV2ExperimentalPlugin from "./opencode2/experimental.js"
|
|
2
|
+
|
|
3
|
+
// Load the legacy runtime only when a V1 host invokes its server contract.
|
|
4
|
+
// A runtime URL prevents bundlers from inlining the legacy implementation.
|
|
5
|
+
// Both source and package entries have a sibling v1.js; the package shim
|
|
6
|
+
// delegates to the standalone V1 bundle, without initializing it on V2.
|
|
7
|
+
async function OpenCodeLoopPlugin(...args) {
|
|
8
|
+
const { default: legacy } = await import(new URL("./v1.js", import.meta.url).href)
|
|
9
|
+
return legacy(...args)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const OPENCODE_LOOP_PLUGIN_ID = "@bybrawe/opencode-loop"
|
|
13
|
+
|
|
14
|
+
export const OpenCodeLoopPluginModule = Object.freeze({
|
|
15
|
+
id: OPENCODE_LOOP_PLUGIN_ID,
|
|
16
|
+
|
|
17
|
+
// OpenCode 1.x PluginModule contract.
|
|
18
|
+
server: OpenCodeLoopPlugin,
|
|
19
|
+
|
|
20
|
+
// OpenCode 2.x promise-plugin contract.
|
|
21
|
+
setup: (context) => OpenCodeLoopV2ExperimentalPlugin.setup(context),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export { OpenCodeLoopPlugin, OpenCodeLoopV2ExperimentalPlugin }
|
|
25
|
+
|
|
26
|
+
export default OpenCodeLoopPluginModule
|
package/src/v1.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./index.js"
|