@bybrawe/opencode-loop 0.5.28 → 0.5.29
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 +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
|
@@ -18,7 +18,7 @@ export const OPENCODE_LOOP_V2_RUNTIME_REQUIREMENTS = Object.freeze([
|
|
|
18
18
|
"runtime.adapter",
|
|
19
19
|
])
|
|
20
20
|
|
|
21
|
-
export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "
|
|
21
|
+
export const OPENCODE_LOOP_V2_COMMAND_SOURCE = "plugin-transform"
|
|
22
22
|
|
|
23
23
|
export function inspectOpenCode2Context(ctx) {
|
|
24
24
|
const command = ctx?.command
|
|
@@ -31,6 +31,8 @@ export function inspectOpenCode2Context(ctx) {
|
|
|
31
31
|
eventSubscribe: hasFunction(event, "subscribe"),
|
|
32
32
|
sessionHook: hasFunction(session, "hook"),
|
|
33
33
|
sessionPrompt: hasFunction(session, "prompt"),
|
|
34
|
+
sessionCommand: hasFunction(session, "command"),
|
|
35
|
+
sessionShell: hasFunction(session, "shell"),
|
|
34
36
|
toolTransform: hasFunction(tool, "transform"),
|
|
35
37
|
toolHook: hasFunction(tool, "hook"),
|
|
36
38
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
|
|
2
|
+
loop: Object.freeze({
|
|
3
|
+
description: "Start an OpenCode auto-continue loop. Usage: /loop 5m <task>",
|
|
4
|
+
template: "OpenCode Loop local command handled. Reply exactly: OK.",
|
|
5
|
+
}),
|
|
6
|
+
"loop-pause": Object.freeze({
|
|
7
|
+
description: "Pause OpenCode Loop jobs.",
|
|
8
|
+
template: "OpenCode Loop pause command handled locally. Reply exactly: OK.",
|
|
9
|
+
}),
|
|
10
|
+
"loop-resume": Object.freeze({
|
|
11
|
+
description: "Resume OpenCode Loop jobs.",
|
|
12
|
+
template: "OpenCode Loop resume command handled locally. Reply exactly: OK.",
|
|
13
|
+
}),
|
|
14
|
+
"loop-stop": Object.freeze({
|
|
15
|
+
description: "Stop OpenCode Loop jobs.",
|
|
16
|
+
template: "OpenCode Loop stop command handled locally. Reply exactly: OK.",
|
|
17
|
+
}),
|
|
18
|
+
"loop-remove": Object.freeze({
|
|
19
|
+
description: "Remove OpenCode Loop jobs.",
|
|
20
|
+
template: "OpenCode Loop remove command handled locally. Reply exactly: OK.",
|
|
21
|
+
}),
|
|
22
|
+
"loop-clear": Object.freeze({
|
|
23
|
+
description: "Clear all OpenCode Loop jobs.",
|
|
24
|
+
template: "OpenCode Loop clear command handled locally. Reply exactly: OK.",
|
|
25
|
+
}),
|
|
26
|
+
"loop-status": Object.freeze({
|
|
27
|
+
description: "Show OpenCode Loop status for the current session.",
|
|
28
|
+
template: "OpenCode Loop status command handled locally. Reply exactly: OK.",
|
|
29
|
+
}),
|
|
30
|
+
"loop-now": Object.freeze({
|
|
31
|
+
description: "Run matching OpenCode Loop jobs on the next idle boundary.",
|
|
32
|
+
template: "OpenCode Loop run-now command handled locally. Reply exactly: OK.",
|
|
33
|
+
}),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
export function registerOpenCode2LoopCommands(draft) {
|
|
37
|
+
if (!draft || typeof draft.update !== "function") {
|
|
38
|
+
throw new Error("OpenCode 2 command draft.update capability is unavailable")
|
|
39
|
+
}
|
|
40
|
+
for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
41
|
+
draft.update(name, (command) => {
|
|
42
|
+
command.template = definition.template
|
|
43
|
+
command.description = definition.description
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseOpenCode2LoopCommandText(value) {
|
|
49
|
+
if (typeof value !== "string") return undefined
|
|
50
|
+
for (const [name, definition] of Object.entries(OPENCODE_LOOP_V2_COMMANDS)) {
|
|
51
|
+
const template = definition.template
|
|
52
|
+
if (value === template) return Object.freeze({ name, arguments: "" })
|
|
53
|
+
if (!value.startsWith(`${template}\n\n`)) continue
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
name,
|
|
56
|
+
arguments: value.slice(template.length + 2).trim(),
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeOpenCodeEvent } from "../runtime/events.js"
|
|
2
2
|
import { createSessionRuntimeManager } from "../runtime/session-manager.js"
|
|
3
|
+
import { normalizeOpenCode2NativeEvent } from "./events.js"
|
|
3
4
|
|
|
4
5
|
function cleanupRegistration(registration) {
|
|
5
6
|
if (typeof registration === "function") return registration
|
|
@@ -41,6 +42,7 @@ export function createOpenCode2EventBridge({
|
|
|
41
42
|
let disposed = false
|
|
42
43
|
let managerDisposed = false
|
|
43
44
|
let queue = Promise.resolve()
|
|
45
|
+
const sessionDirectories = new Map()
|
|
44
46
|
|
|
45
47
|
function report(error) {
|
|
46
48
|
try { onError(error) } catch {}
|
|
@@ -61,20 +63,35 @@ export function createOpenCode2EventBridge({
|
|
|
61
63
|
return Boolean(current)
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
function normalize(raw) {
|
|
67
|
+
const current = normalizeOpenCode2NativeEvent(raw) || normalizeOpenCodeEvent(raw)
|
|
68
|
+
if (!current) return undefined
|
|
69
|
+
const sessionID = current.sessionID
|
|
70
|
+
const rememberedDirectory = sessionID ? sessionDirectories.get(sessionID) : undefined
|
|
71
|
+
const eventDirectory = current.directory || rememberedDirectory || directory
|
|
72
|
+
const event = eventDirectory === current.directory
|
|
73
|
+
? current
|
|
74
|
+
: Object.freeze({ ...current, directory: eventDirectory })
|
|
75
|
+
if (sessionID && event.directory) sessionDirectories.set(sessionID, event.directory)
|
|
76
|
+
return event
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
async function process(raw) {
|
|
65
80
|
if (stopped) return undefined
|
|
66
|
-
const event =
|
|
81
|
+
const event = normalize(raw)
|
|
67
82
|
if (!event || !sameDirectory(directory, event.directory)) return undefined
|
|
68
83
|
|
|
69
84
|
const runtime = event.sessionID ? runtimeManager.observeExternal(event.sessionID) : undefined
|
|
70
85
|
await onEvent(event, runtime)
|
|
71
86
|
|
|
72
87
|
if (event.kind === "session" && event.action === "deleted") {
|
|
88
|
+
sessionDirectories.delete(event.sessionID)
|
|
73
89
|
runtimeManager.remove(event.sessionID, { expectedRuntime: runtime, reason: "session-deleted" })
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
if (event.kind === "server" && event.action === "disposed") {
|
|
77
93
|
stopped = true
|
|
94
|
+
sessionDirectories.clear()
|
|
78
95
|
disposeManager("server-disposed")
|
|
79
96
|
}
|
|
80
97
|
|
|
@@ -141,6 +158,7 @@ export function createOpenCode2EventBridge({
|
|
|
141
158
|
const cleanup = cleanupRegistration(registration)
|
|
142
159
|
registration = undefined
|
|
143
160
|
if (cleanup) await cleanup()
|
|
161
|
+
sessionDirectories.clear()
|
|
144
162
|
disposeManager(reason)
|
|
145
163
|
return true
|
|
146
164
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { parseOpenCode2LoopCommandText } from "./commands.js"
|
|
2
|
+
|
|
3
|
+
function record(value) {
|
|
4
|
+
return value && typeof value === "object" ? value : undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function text(value) {
|
|
8
|
+
return typeof value === "string" && value.trim() ? value : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function directoryFrom(raw) {
|
|
12
|
+
return text(record(raw?.location)?.directory)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sessionIDFrom(raw) {
|
|
16
|
+
const data = record(raw?.data)
|
|
17
|
+
return text(data?.sessionID)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeOpenCode2NativeEvent(raw) {
|
|
21
|
+
const type = text(raw?.type)
|
|
22
|
+
if (!type) return undefined
|
|
23
|
+
|
|
24
|
+
const data = record(raw?.data) || {}
|
|
25
|
+
const directory = directoryFrom(raw)
|
|
26
|
+
const sessionID = sessionIDFrom(raw)
|
|
27
|
+
|
|
28
|
+
if (type === "session.inbox.enqueued") {
|
|
29
|
+
const item = record(data.item)
|
|
30
|
+
const payload = record(item?.payload)
|
|
31
|
+
const parsed = item?.type === "user" ? parseOpenCode2LoopCommandText(payload?.text) : undefined
|
|
32
|
+
if (!sessionID || !parsed) return undefined
|
|
33
|
+
return Object.freeze({
|
|
34
|
+
kind: "command",
|
|
35
|
+
action: "executed",
|
|
36
|
+
sessionID,
|
|
37
|
+
directory,
|
|
38
|
+
name: parsed.name,
|
|
39
|
+
arguments: parsed.arguments,
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (type === "session.execution.started") {
|
|
44
|
+
if (!sessionID) return undefined
|
|
45
|
+
return Object.freeze({ kind: "session", action: "status", sessionID, directory, status: "busy" })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (type === "session.execution.succeeded") {
|
|
49
|
+
if (!sessionID) return undefined
|
|
50
|
+
return Object.freeze({ kind: "session", action: "idle", sessionID, directory })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (type === "session.created") {
|
|
54
|
+
if (!sessionID) return undefined
|
|
55
|
+
return Object.freeze({ kind: "session", action: "created", sessionID, directory: directory || text(record(data.location)?.directory) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (type === "session.deleted") {
|
|
59
|
+
if (!sessionID) return undefined
|
|
60
|
+
return Object.freeze({ kind: "session", action: "deleted", sessionID, directory })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { inspectOpenCode2Context } from "./capabilities.js"
|
|
2
|
+
import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
|
|
3
|
+
import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
|
|
4
|
+
import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
|
|
2
5
|
|
|
3
6
|
export const OPENCODE_LOOP_V2_PLUGIN_ID = "bybrawe.opencode-loop.v2.experimental"
|
|
7
|
+
export { OPENCODE_LOOP_V2_COMMANDS }
|
|
4
8
|
|
|
5
9
|
export const OpenCodeLoopV2ExperimentalPlugin = {
|
|
6
10
|
id: OPENCODE_LOOP_V2_PLUGIN_ID,
|
|
@@ -9,7 +13,34 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
|
|
|
9
13
|
if (!capabilities.commandTransform) {
|
|
10
14
|
throw new Error("OpenCode 2 command.transform capability is unavailable")
|
|
11
15
|
}
|
|
12
|
-
|
|
16
|
+
|
|
17
|
+
const commandRegistration = await ctx.command.transform(registerOpenCode2LoopCommands)
|
|
18
|
+
if (!capabilities.eventSubscribe || !capabilities.sessionPrompt) {
|
|
19
|
+
await commandRegistration?.dispose?.()
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let promptRuntime
|
|
24
|
+
const onRuntimeEvent = async (event) => promptRuntime?.onEvent(event)
|
|
25
|
+
const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
|
|
26
|
+
promptRuntime = createOpenCode2PromptRuntime({
|
|
27
|
+
prompt: (request) => runtime.prompt(request),
|
|
28
|
+
command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await runtime.start()
|
|
33
|
+
} catch (error) {
|
|
34
|
+
await promptRuntime?.dispose?.().catch(() => undefined)
|
|
35
|
+
await commandRegistration?.dispose?.().catch(() => undefined)
|
|
36
|
+
throw error
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return async () => {
|
|
40
|
+
await promptRuntime?.dispose?.()
|
|
41
|
+
await runtime.dispose("plugin-cleanup")
|
|
42
|
+
await commandRegistration?.dispose?.()
|
|
43
|
+
}
|
|
13
44
|
},
|
|
14
45
|
}
|
|
15
46
|
|
|
@@ -16,6 +16,22 @@ function normalizePrompt(input) {
|
|
|
16
16
|
})
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function normalizeCommand(input) {
|
|
20
|
+
const sessionID = String(input?.sessionID || "").trim()
|
|
21
|
+
const command = String(input?.command || "").trim().replace(/^\/+/, "")
|
|
22
|
+
if (!sessionID) throw new TypeError("command requires a session ID")
|
|
23
|
+
if (!command) throw new TypeError("command requires a command name")
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
sessionID,
|
|
26
|
+
command,
|
|
27
|
+
arguments: input?.arguments === undefined ? undefined : String(input.arguments),
|
|
28
|
+
agent: input?.agent,
|
|
29
|
+
model: input?.model,
|
|
30
|
+
delivery: input?.delivery,
|
|
31
|
+
resume: input?.resume,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export function createOpenCode2HostContract(options = {}) {
|
|
20
36
|
const onEvent = typeof options.onEvent === "function" ? options.onEvent : async () => {}
|
|
21
37
|
let started = false
|
|
@@ -49,6 +65,15 @@ export function createOpenCode2HostContract(options = {}) {
|
|
|
49
65
|
return await options.sendPrompt(Object.freeze({ ...request, runtime }))
|
|
50
66
|
}
|
|
51
67
|
|
|
68
|
+
async function command(input) {
|
|
69
|
+
if (disposed || hostDisposed) throw new Error("OpenCode 2 host contract is unavailable")
|
|
70
|
+
if (!started) throw new Error("OpenCode 2 host contract is not started")
|
|
71
|
+
if (typeof options.sendCommand !== "function") throw new Error("OpenCode 2 session.command capability is unavailable")
|
|
72
|
+
const request = normalizeCommand(input)
|
|
73
|
+
const runtime = bridge.runtimeManager.observeExternal(request.sessionID)
|
|
74
|
+
return await options.sendCommand(Object.freeze({ ...request, runtime }))
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
async function dispose(reason = "host-contract-disposed") {
|
|
53
78
|
if (disposed) return false
|
|
54
79
|
disposed = true
|
|
@@ -59,6 +84,7 @@ export function createOpenCode2HostContract(options = {}) {
|
|
|
59
84
|
return Object.freeze({
|
|
60
85
|
start,
|
|
61
86
|
prompt,
|
|
87
|
+
command,
|
|
62
88
|
dispose,
|
|
63
89
|
runtimeManager: bridge.runtimeManager,
|
|
64
90
|
isStarted: () => started,
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { parseLoopArgs, splitFirst } from "../core/args.js"
|
|
2
|
+
import { actionKind, decoratePrompt, matchJob } from "../core/jobs.js"
|
|
3
|
+
import { readState, removeState, writeState } from "../core/state.js"
|
|
4
|
+
import { formatOpenCode2LoopStatus } from "./status.js"
|
|
5
|
+
|
|
6
|
+
export const OPENCODE_LOOP_V2_PROMPT_RUNTIME = "prompt-zero-interval"
|
|
7
|
+
export const OPENCODE_LOOP_V2_INTERVAL_RUNTIME = "idle-safe-timer"
|
|
8
|
+
export const OPENCODE_LOOP_V2_PROMPT_PREFIX = "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly."
|
|
9
|
+
|
|
10
|
+
function directoryFrom(event) {
|
|
11
|
+
return typeof event?.directory === "string" && event.directory.trim() ? event.directory : undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function commandParts(action) {
|
|
15
|
+
const normalized = String(action || "").trim().replace(/^\/+/, "")
|
|
16
|
+
return splitFirst(normalized)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unsupportedRuntimeJob(job, supportsCommand) {
|
|
20
|
+
const blockers = []
|
|
21
|
+
const kind = actionKind(job?.action, job)
|
|
22
|
+
if (!String(job?.action || "").trim()) blockers.push("action")
|
|
23
|
+
if (kind === "command") {
|
|
24
|
+
if (!supportsCommand) blockers.push("command-capability")
|
|
25
|
+
if (!commandParts(job?.action)[0]) blockers.push("action")
|
|
26
|
+
} else if (kind !== "prompt") {
|
|
27
|
+
blockers.push("kind")
|
|
28
|
+
}
|
|
29
|
+
if (job?.watchPaths?.length) blockers.push("watch")
|
|
30
|
+
if (job?.promptFile) blockers.push("prompt-file")
|
|
31
|
+
if (job?.includeFiles?.length) blockers.push("include-file")
|
|
32
|
+
if (job?.verifyCommand) blockers.push("verify")
|
|
33
|
+
if (job?.preflightCommand) blockers.push("preflight")
|
|
34
|
+
if (job?.postrunCommand) blockers.push("postrun")
|
|
35
|
+
if (job?.notifyCommand) blockers.push("notify")
|
|
36
|
+
if (job?.branch) blockers.push("branch")
|
|
37
|
+
if (job?.compactEveryRuns > 0 || job?.compactEveryMs > 0) blockers.push("compact-every")
|
|
38
|
+
if (job?.gitCheckpoint || job?.checkpointOnly) blockers.push("checkpoint")
|
|
39
|
+
if (job?.timeoutMs > 0) blockers.push("timeout")
|
|
40
|
+
if (job?.maxRuntimeMs > 0) blockers.push("max-runtime")
|
|
41
|
+
if (job?.maxFailures > 0) blockers.push("max-failures")
|
|
42
|
+
if (job?.until) blockers.push("until")
|
|
43
|
+
if (job?.stopFile) blockers.push("stop-file")
|
|
44
|
+
return [...new Set(blockers)]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function jobName(job) {
|
|
48
|
+
return String(job?.name || "default")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function promptText(job) {
|
|
52
|
+
return `${OPENCODE_LOOP_V2_PROMPT_PREFIX}\n\n${decoratePrompt(job)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function commandTarget(event, fallback = "all") {
|
|
56
|
+
return String(event?.arguments || "").trim() || fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scopeFrom(event) {
|
|
60
|
+
const directory = directoryFrom(event)
|
|
61
|
+
const sessionID = String(event?.sessionID || "").trim()
|
|
62
|
+
if (!directory || !sessionID) return undefined
|
|
63
|
+
return { directory, sessionID, key: `${directory}\u0000${sessionID}` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function runNowRequested(job) {
|
|
67
|
+
return Number(job?.runNowRequestedAt || 0) > 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function dueAt(job, current) {
|
|
71
|
+
if (runNowRequested(job)) return current
|
|
72
|
+
const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
|
|
73
|
+
const lastRunAt = Number(job?.lastRunAt || 0)
|
|
74
|
+
if (intervalMs === 0) return current
|
|
75
|
+
if (lastRunAt <= 0) {
|
|
76
|
+
if (job?.immediate === false) {
|
|
77
|
+
const createdAt = Date.parse(job?.createdAt || "")
|
|
78
|
+
return (Number.isFinite(createdAt) ? createdAt : current) + intervalMs
|
|
79
|
+
}
|
|
80
|
+
return current
|
|
81
|
+
}
|
|
82
|
+
return lastRunAt + intervalMs
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createOpenCode2PromptRuntime(options = {}) {
|
|
86
|
+
if (typeof options.prompt !== "function") throw new TypeError("V2 prompt runtime requires prompt()")
|
|
87
|
+
|
|
88
|
+
const supportsCommand = typeof options.command === "function"
|
|
89
|
+
const now = typeof options.now === "function" ? options.now : Date.now
|
|
90
|
+
const setTimer = typeof options.setTimer === "function" ? options.setTimer : setTimeout
|
|
91
|
+
const clearTimer = typeof options.clearTimer === "function" ? options.clearTimer : clearTimeout
|
|
92
|
+
const onError = typeof options.onError === "function" ? options.onError : () => {}
|
|
93
|
+
const timers = new Map()
|
|
94
|
+
const idle = new Map()
|
|
95
|
+
const queues = new Map()
|
|
96
|
+
let disposed = false
|
|
97
|
+
|
|
98
|
+
function report(error) {
|
|
99
|
+
try { onError(error) } catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function eligibleRuntimeJob(job) {
|
|
103
|
+
if (!job?.enabled || job?.paused) return false
|
|
104
|
+
if (unsupportedRuntimeJob(job, supportsCommand).length) return false
|
|
105
|
+
return !(job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function clearScopeTimer(key) {
|
|
109
|
+
const current = timers.get(key)
|
|
110
|
+
if (!current) return false
|
|
111
|
+
timers.delete(key)
|
|
112
|
+
try { clearTimer(current.handle) } catch {}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearScope(scope) {
|
|
117
|
+
clearScopeTimer(scope.key)
|
|
118
|
+
idle.delete(scope.key)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function enqueueScope(scope, task) {
|
|
122
|
+
if (disposed) return Promise.resolve({ handled: false, reason: "disposed" })
|
|
123
|
+
const previous = queues.get(scope.key) || Promise.resolve()
|
|
124
|
+
const result = previous.catch(() => undefined).then(async () => {
|
|
125
|
+
if (disposed) return { handled: false, reason: "disposed" }
|
|
126
|
+
return await task()
|
|
127
|
+
})
|
|
128
|
+
const tail = result.catch(() => undefined)
|
|
129
|
+
queues.set(scope.key, tail)
|
|
130
|
+
tail.then(() => {
|
|
131
|
+
if (queues.get(scope.key) === tail) queues.delete(scope.key)
|
|
132
|
+
})
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function scheduleScope(scope) {
|
|
137
|
+
clearScopeTimer(scope.key)
|
|
138
|
+
if (disposed) return undefined
|
|
139
|
+
|
|
140
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
141
|
+
const current = now()
|
|
142
|
+
let earliest
|
|
143
|
+
for (const job of state.jobs || []) {
|
|
144
|
+
if (!eligibleRuntimeJob(job)) continue
|
|
145
|
+
const intervalMs = Math.max(0, Number(job?.intervalMs || 0))
|
|
146
|
+
if (intervalMs === 0) continue
|
|
147
|
+
const candidate = dueAt(job, current)
|
|
148
|
+
if (candidate <= current) continue
|
|
149
|
+
if (!earliest || candidate < earliest) earliest = candidate
|
|
150
|
+
}
|
|
151
|
+
if (!earliest) return undefined
|
|
152
|
+
|
|
153
|
+
const delay = Math.max(0, earliest - current)
|
|
154
|
+
const token = Symbol(scope.key)
|
|
155
|
+
const handle = setTimer(() => {
|
|
156
|
+
const pending = enqueueScope(scope, async () => {
|
|
157
|
+
const currentTimer = timers.get(scope.key)
|
|
158
|
+
if (!currentTimer || currentTimer.token !== token) return { handled: false, reason: "stale-timer" }
|
|
159
|
+
timers.delete(scope.key)
|
|
160
|
+
if (idle.get(scope.key) !== true) return { handled: true, dispatched: false, reason: "not-idle" }
|
|
161
|
+
return await runDueAction(scope)
|
|
162
|
+
})
|
|
163
|
+
pending.catch(report)
|
|
164
|
+
return pending
|
|
165
|
+
}, delay)
|
|
166
|
+
handle?.unref?.()
|
|
167
|
+
timers.set(scope.key, { handle, token, dueAt: earliest })
|
|
168
|
+
return earliest
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function dispatchJob(scope, job) {
|
|
172
|
+
const kind = actionKind(job?.action, job)
|
|
173
|
+
if (kind === "command") {
|
|
174
|
+
const [command, argumentsText] = commandParts(job.action)
|
|
175
|
+
const request = {
|
|
176
|
+
sessionID: scope.sessionID,
|
|
177
|
+
command,
|
|
178
|
+
arguments: argumentsText || undefined,
|
|
179
|
+
}
|
|
180
|
+
await options.command(request)
|
|
181
|
+
return { kind, request }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const text = promptText(job)
|
|
185
|
+
const request = { sessionID: scope.sessionID, text }
|
|
186
|
+
await options.prompt(request)
|
|
187
|
+
return { kind: "prompt", request, text }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function runDueAction(scope) {
|
|
191
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
192
|
+
const current = now()
|
|
193
|
+
const due = (state.jobs || []).filter((candidate) => eligibleRuntimeJob(candidate) && dueAt(candidate, current) <= current)
|
|
194
|
+
const job = due.find(runNowRequested) || due[0]
|
|
195
|
+
if (!job) {
|
|
196
|
+
await scheduleScope(scope)
|
|
197
|
+
return { handled: true, dispatched: false }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
delete job.runNowRequestedAt
|
|
201
|
+
job.lastRunAt = current
|
|
202
|
+
job.runCount = (job.runCount || 0) + 1
|
|
203
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) job.enabled = false
|
|
204
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
205
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
206
|
+
|
|
207
|
+
idle.set(scope.key, false)
|
|
208
|
+
await scheduleScope(scope)
|
|
209
|
+
|
|
210
|
+
const dispatched = await dispatchJob(scope, job)
|
|
211
|
+
return { handled: true, dispatched: true, job, ...dispatched }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function addPromptLoop(event) {
|
|
215
|
+
const scope = scopeFrom(event)
|
|
216
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
217
|
+
return await enqueueScope(scope, async () => {
|
|
218
|
+
const parsed = parseLoopArgs(event.arguments || "")
|
|
219
|
+
if (!parsed.ok) return { handled: true, accepted: false, reason: "parse", error: parsed.error }
|
|
220
|
+
|
|
221
|
+
const blockers = unsupportedRuntimeJob(parsed.job, supportsCommand)
|
|
222
|
+
if (blockers.length) return { handled: true, accepted: false, reason: "unsupported", blockers }
|
|
223
|
+
|
|
224
|
+
parsed.job.name = jobName(parsed.job)
|
|
225
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
226
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
227
|
+
if (!parsed.job.multi) {
|
|
228
|
+
state.jobs = jobs.filter((job) => jobName(job) !== parsed.job.name)
|
|
229
|
+
} else {
|
|
230
|
+
state.jobs = jobs
|
|
231
|
+
}
|
|
232
|
+
state.jobs.push(parsed.job)
|
|
233
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
234
|
+
await scheduleScope(scope)
|
|
235
|
+
return { handled: true, accepted: true, job: parsed.job }
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function statusPromptLoops(event) {
|
|
240
|
+
const scope = scopeFrom(event)
|
|
241
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
242
|
+
return await enqueueScope(scope, async () => {
|
|
243
|
+
const status = formatOpenCode2LoopStatus(await readState(scope.directory, scope.sessionID), now())
|
|
244
|
+
const request = { sessionID: scope.sessionID, text: status.text, noReply: true }
|
|
245
|
+
await options.prompt(request)
|
|
246
|
+
return { handled: true, accepted: true, status, request }
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runPromptLoopsNow(event) {
|
|
251
|
+
const scope = scopeFrom(event)
|
|
252
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
253
|
+
return await enqueueScope(scope, async () => {
|
|
254
|
+
const target = commandTarget(event)
|
|
255
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
256
|
+
const requestedAt = Math.max(1, now())
|
|
257
|
+
let count = 0
|
|
258
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
259
|
+
if (!matchJob(job, target, index)) return job
|
|
260
|
+
const resumed = { ...job, paused: false }
|
|
261
|
+
if (!eligibleRuntimeJob(resumed)) return job
|
|
262
|
+
count += 1
|
|
263
|
+
return { ...resumed, runNowRequestedAt: requestedAt }
|
|
264
|
+
})
|
|
265
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
266
|
+
await scheduleScope(scope)
|
|
267
|
+
return { handled: true, accepted: true, count, target, requestedAt: count ? requestedAt : undefined }
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function updatePromptLoops(event, updater) {
|
|
272
|
+
const scope = scopeFrom(event)
|
|
273
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
274
|
+
return await enqueueScope(scope, async () => {
|
|
275
|
+
const target = commandTarget(event)
|
|
276
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
277
|
+
let count = 0
|
|
278
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
279
|
+
if (!matchJob(job, target, index)) return job
|
|
280
|
+
count += 1
|
|
281
|
+
return updater(job)
|
|
282
|
+
})
|
|
283
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
284
|
+
await scheduleScope(scope)
|
|
285
|
+
return { handled: true, accepted: true, count, target }
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function stopPromptLoops(event, forcedTarget) {
|
|
290
|
+
const scope = scopeFrom(event)
|
|
291
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
292
|
+
return await enqueueScope(scope, async () => {
|
|
293
|
+
const target = forcedTarget || commandTarget(event)
|
|
294
|
+
if (target.toLowerCase() === "all") {
|
|
295
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
296
|
+
const count = (state.jobs || []).length
|
|
297
|
+
await removeState(scope.directory, scope.sessionID)
|
|
298
|
+
await scheduleScope(scope)
|
|
299
|
+
return { handled: true, accepted: true, count, target }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const state = await readState(scope.directory, scope.sessionID)
|
|
303
|
+
const before = (state.jobs || []).length
|
|
304
|
+
state.jobs = (state.jobs || []).filter((job, index) => !matchJob(job, target, index))
|
|
305
|
+
await writeState(scope.directory, scope.sessionID, state)
|
|
306
|
+
await scheduleScope(scope)
|
|
307
|
+
return { handled: true, accepted: true, count: before - state.jobs.length, target }
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function runIdlePrompt(event) {
|
|
312
|
+
const scope = scopeFrom(event)
|
|
313
|
+
if (!scope) return { handled: false, reason: "missing-scope" }
|
|
314
|
+
idle.set(scope.key, true)
|
|
315
|
+
return await enqueueScope(scope, () => runDueAction(scope))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function onEvent(event) {
|
|
319
|
+
const scope = scopeFrom(event)
|
|
320
|
+
if (event?.kind === "command" && event?.action === "executed") {
|
|
321
|
+
if (scope) idle.set(scope.key, false)
|
|
322
|
+
if (event?.name === "loop") return addPromptLoop(event)
|
|
323
|
+
if (event?.name === "loop-status") return statusPromptLoops(event)
|
|
324
|
+
if (event?.name === "loop-now") return runPromptLoopsNow(event)
|
|
325
|
+
if (event?.name === "loop-pause") return updatePromptLoops(event, (job) => ({ ...job, paused: true }))
|
|
326
|
+
if (event?.name === "loop-resume") return updatePromptLoops(event, (job) => ({ ...job, paused: false, lastRunAt: 0 }))
|
|
327
|
+
if (["loop-stop", "loop-remove"].includes(event?.name)) return stopPromptLoops(event)
|
|
328
|
+
if (event?.name === "loop-clear") return stopPromptLoops(event, "all")
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (event?.kind === "session" && event?.action === "idle") {
|
|
332
|
+
return runIdlePrompt(event)
|
|
333
|
+
}
|
|
334
|
+
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
|
+
})
|
|
340
|
+
}
|
|
341
|
+
if (event?.kind === "session" && event?.action === "deleted" && scope) {
|
|
342
|
+
clearScope(scope)
|
|
343
|
+
return { handled: true, disposedScope: true }
|
|
344
|
+
}
|
|
345
|
+
if (event?.kind === "message" && event?.action === "updated" && scope) {
|
|
346
|
+
if (event.role === "user" || (event.role === "assistant" && !event.completedAt)) idle.set(scope.key, false)
|
|
347
|
+
return { handled: false }
|
|
348
|
+
}
|
|
349
|
+
if (event?.kind === "server" && event?.action === "disposed") {
|
|
350
|
+
await dispose()
|
|
351
|
+
return { handled: true, disposed: true }
|
|
352
|
+
}
|
|
353
|
+
return { handled: false }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function dispose() {
|
|
357
|
+
if (disposed) return false
|
|
358
|
+
disposed = true
|
|
359
|
+
for (const { handle } of timers.values()) {
|
|
360
|
+
try { clearTimer(handle) } catch {}
|
|
361
|
+
}
|
|
362
|
+
timers.clear()
|
|
363
|
+
idle.clear()
|
|
364
|
+
const pending = [...queues.values()]
|
|
365
|
+
if (pending.length) await Promise.allSettled(pending)
|
|
366
|
+
queues.clear()
|
|
367
|
+
return true
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return Object.freeze({
|
|
371
|
+
onEvent,
|
|
372
|
+
addPromptLoop,
|
|
373
|
+
statusPromptLoops,
|
|
374
|
+
runPromptLoopsNow,
|
|
375
|
+
updatePromptLoops,
|
|
376
|
+
stopPromptLoops,
|
|
377
|
+
runIdlePrompt,
|
|
378
|
+
dispose,
|
|
379
|
+
scheduledCount: () => timers.size,
|
|
380
|
+
})
|
|
381
|
+
}
|