@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { presetDefaults } from "../core/jobs.js"
|
|
2
|
+
import { captureSessionExecutionContext as defaultCaptureSessionExecutionContext } from "./session-context.js"
|
|
3
|
+
import { guardLoopOwnedUserMessage as defaultGuardLoopOwnedUserMessage } from "./messages.js"
|
|
4
|
+
import {
|
|
5
|
+
markHandled,
|
|
6
|
+
consumeHandled,
|
|
7
|
+
hasHandledCommandEvent,
|
|
8
|
+
markHandledCommandEvent,
|
|
9
|
+
forgetHandledCommandEvent,
|
|
10
|
+
commandName,
|
|
11
|
+
isPreset,
|
|
12
|
+
isLoopCommandName,
|
|
13
|
+
commandArgsText,
|
|
14
|
+
} from "./commands.js"
|
|
15
|
+
|
|
16
|
+
const HANDLER_NAMES = [
|
|
17
|
+
"addGoal",
|
|
18
|
+
"statusGoal",
|
|
19
|
+
"pauseGoal",
|
|
20
|
+
"resumeGoal",
|
|
21
|
+
"clearGoal",
|
|
22
|
+
"completeGoalCommand",
|
|
23
|
+
"blockGoalCommand",
|
|
24
|
+
"addLoop",
|
|
25
|
+
"stopLoop",
|
|
26
|
+
"statusLoop",
|
|
27
|
+
"logsLoop",
|
|
28
|
+
"helpLoop",
|
|
29
|
+
"runNow",
|
|
30
|
+
"updateJobState",
|
|
31
|
+
"doctorLoop",
|
|
32
|
+
"initLoop",
|
|
33
|
+
"exportLoop",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
function requireFunction(value, name) {
|
|
37
|
+
if (typeof value !== "function") throw new TypeError(`createCommandRouter requires ${name}`)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createCommandRouter(options = {}) {
|
|
42
|
+
const rememberSession = requireFunction(options.rememberSession, "rememberSession")
|
|
43
|
+
const captureSessionExecutionContext = typeof options.captureSessionExecutionContext === "function"
|
|
44
|
+
? options.captureSessionExecutionContext
|
|
45
|
+
: defaultCaptureSessionExecutionContext
|
|
46
|
+
const guardLoopOwnedUserMessage = typeof options.guardLoopOwnedUserMessage === "function"
|
|
47
|
+
? options.guardLoopOwnedUserMessage
|
|
48
|
+
: defaultGuardLoopOwnedUserMessage
|
|
49
|
+
const handlers = {}
|
|
50
|
+
for (const name of HANDLER_NAMES) handlers[name] = requireFunction(options.handlers?.[name], `handlers.${name}`)
|
|
51
|
+
|
|
52
|
+
return async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
|
|
53
|
+
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
54
|
+
const sessionID = input?.sessionID
|
|
55
|
+
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
56
|
+
if (!sessionID || !name) return false
|
|
57
|
+
rememberSession(directory, client, sessionID)
|
|
58
|
+
if (isLoopCommandName(name)) await captureSessionExecutionContext(client, sessionID)
|
|
59
|
+
if (source === "event") {
|
|
60
|
+
if (consumeHandled(sessionID, name, args)) return true
|
|
61
|
+
if (hasHandledCommandEvent(sessionID, input?.messageID)) return true
|
|
62
|
+
markHandledCommandEvent(sessionID, input?.messageID)
|
|
63
|
+
} else {
|
|
64
|
+
// Every before-hook invocation is an intentional command. Keep a pending
|
|
65
|
+
// count only so the matching command.executed compatibility event can be
|
|
66
|
+
// consumed without suppressing a genuine repeated command.
|
|
67
|
+
markHandled(sessionID, name, args)
|
|
68
|
+
}
|
|
69
|
+
if (isLoopCommandName(name)) guardLoopOwnedUserMessage(sessionID)
|
|
70
|
+
|
|
71
|
+
const handled = () => {
|
|
72
|
+
// OpenCode 1.18.x ignores unknown hook output fields, while proposed/newer
|
|
73
|
+
// hosts can honor noReply to skip the otherwise unavoidable model turn.
|
|
74
|
+
// Keep acknowledgement parts intact as the compatibility fallback.
|
|
75
|
+
if (output && typeof output === "object") output.noReply = true
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (name === "loop-goal") return await handlers.addGoal(directory, client, sessionID, args), handled()
|
|
80
|
+
if (name === "loop-goal-status") return await handlers.statusGoal(directory, client, sessionID), handled()
|
|
81
|
+
if (name === "loop-goal-pause") return await handlers.pauseGoal(directory, client, sessionID, args), handled()
|
|
82
|
+
if (name === "loop-goal-resume") return await handlers.resumeGoal(directory, client, sessionID, args), handled()
|
|
83
|
+
if (name === "loop-goal-clear") return await handlers.clearGoal(directory, client, sessionID, args), handled()
|
|
84
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete") return await handlers.completeGoalCommand(directory, client, sessionID, args), handled()
|
|
85
|
+
if (name === "loop-goal-blocked") return await handlers.blockGoalCommand(directory, client, sessionID, args), handled()
|
|
86
|
+
if (name === "loop") return await handlers.addLoop(directory, client, sessionID, args), handled()
|
|
87
|
+
if (isPreset(name)) return await handlers.addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled()
|
|
88
|
+
if (name === "loop-stop" || name === "loop-remove") return await handlers.stopLoop(directory, client, sessionID, args), handled()
|
|
89
|
+
if (name === "loop-clear") return await handlers.stopLoop(directory, client, sessionID, "all"), handled()
|
|
90
|
+
if (name === "loop-status") return await handlers.statusLoop(directory, client, sessionID), handled()
|
|
91
|
+
if (name === "loop-logs") return await handlers.logsLoop(directory, client, sessionID), handled()
|
|
92
|
+
if (name === "loop-help") return await handlers.helpLoop(client, sessionID), handled()
|
|
93
|
+
if (name === "loop-now") return await handlers.runNow(directory, client, sessionID, args), handled()
|
|
94
|
+
if (name === "loop-pause") return await handlers.updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled()
|
|
95
|
+
if (name === "loop-resume") return await handlers.updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled()
|
|
96
|
+
if (name === "loop-doctor") return await handlers.doctorLoop(directory, client, sessionID), handled()
|
|
97
|
+
if (name === "loop-init") return await handlers.initLoop(directory, client, sessionID, args), handled()
|
|
98
|
+
if (name === "loop-export") return await handlers.exportLoop(directory, client, sessionID), handled()
|
|
99
|
+
if (source === "event") forgetHandledCommandEvent(sessionID, input?.messageID)
|
|
100
|
+
else consumeHandled(sessionID, name, args)
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { now } from "../core/args.js"
|
|
2
|
+
|
|
3
|
+
const COMMAND_DEDUPE_MS = 30_000
|
|
4
|
+
const handledCommands = new Map()
|
|
5
|
+
const handledCommandEvents = new Map()
|
|
6
|
+
|
|
7
|
+
function normalizeArgsForKey(args) {
|
|
8
|
+
if (args === undefined || args === null) return ""
|
|
9
|
+
if (typeof args === "string") return args.trim().replace(/\s+/g, " ")
|
|
10
|
+
if (Array.isArray(args)) return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ")
|
|
11
|
+
try { return JSON.stringify(args) } catch { return String(args) }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function commandKey(sessionID, name, args) {
|
|
15
|
+
return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function commandEventKey(sessionID, messageID) {
|
|
19
|
+
return `${sessionID || "no-session"}:event:${messageID || "no-message"}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function markHandled(sessionID, name, args) {
|
|
23
|
+
const key = commandKey(sessionID, name, args)
|
|
24
|
+
const previous = handledCommands.get(key)
|
|
25
|
+
const pending = previous && now() - previous.time < COMMAND_DEDUPE_MS ? previous.pending + 1 : 1
|
|
26
|
+
handledCommands.set(key, { time: now(), pending })
|
|
27
|
+
for (const [entryKey, entry] of handledCommands.entries()) if (now() - entry.time > COMMAND_DEDUPE_MS) handledCommands.delete(entryKey)
|
|
28
|
+
for (const [entryKey, time] of handledCommandEvents.entries()) if (now() - time > COMMAND_DEDUPE_MS) handledCommandEvents.delete(entryKey)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function consumeHandled(sessionID, name, args) {
|
|
32
|
+
const key = commandKey(sessionID, name, args)
|
|
33
|
+
const entry = handledCommands.get(key)
|
|
34
|
+
if (!entry || now() - entry.time >= COMMAND_DEDUPE_MS) {
|
|
35
|
+
handledCommands.delete(key)
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
if (entry.pending <= 1) handledCommands.delete(key)
|
|
39
|
+
else handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 })
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hasHandledCommandEvent(sessionID, messageID) {
|
|
44
|
+
return handledCommandEvents.has(commandEventKey(sessionID, messageID))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function markHandledCommandEvent(sessionID, messageID) {
|
|
48
|
+
handledCommandEvents.set(commandEventKey(sessionID, messageID), now())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function forgetHandledCommandEvent(sessionID, messageID) {
|
|
52
|
+
handledCommandEvents.delete(commandEventKey(sessionID, messageID))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function clearCommandLifecycle(sessionID) {
|
|
56
|
+
const prefix = `${sessionID}:`
|
|
57
|
+
for (const key of handledCommands.keys()) if (key.startsWith(prefix)) handledCommands.delete(key)
|
|
58
|
+
for (const key of handledCommandEvents.keys()) if (key.startsWith(prefix)) handledCommandEvents.delete(key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function commandName(name) {
|
|
62
|
+
return String(name || "")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isPreset(name) {
|
|
66
|
+
return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isLoopCommandName(name) {
|
|
70
|
+
return name === "loop" || name === "loop-stop" || name === "loop-remove" || name === "loop-clear" || name === "loop-status" || name === "loop-logs" || name === "loop-help" || name === "loop-now" || name === "loop-pause" || name === "loop-resume" || name === "loop-doctor" || name === "loop-init" || name === "loop-export" || name === "loop-goal" || name === "loop-goal-status" || name === "loop-goal-pause" || name === "loop-goal-resume" || name === "loop-goal-clear" || name === "loop-goal-done" || name === "loop-goal-complete" || name === "loop-goal-blocked" || isPreset(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function commandArgsText(args) {
|
|
74
|
+
if (args === undefined || args === null) return ""
|
|
75
|
+
if (typeof args === "string") return args
|
|
76
|
+
if (Array.isArray(args)) return args.map(commandArgsText).join(" ")
|
|
77
|
+
if (typeof args === "object") {
|
|
78
|
+
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
79
|
+
if (args[key] !== undefined) return commandArgsText(args[key])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return String(args)
|
|
83
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { DEFAULT_GOAL_MAX_NO_PROGRESS, splitFirst } from "../core/args.js"
|
|
2
|
+
import { isGoalJob, matchJob, goalStatusText } from "../core/jobs.js"
|
|
3
|
+
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
4
|
+
import { setGoalComplete as defaultSetGoalComplete, setGoalBlocked as defaultSetGoalBlocked } from "../runtime/goal-runtime.js"
|
|
5
|
+
|
|
6
|
+
function requireFunction(value, name) {
|
|
7
|
+
if (typeof value !== "function") throw new TypeError(`createGoalCommandHandlers requires ${name}`)
|
|
8
|
+
return value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createGoalCommandHandlers(options = {}) {
|
|
12
|
+
const addLoop = requireFunction(options.addLoop, "addLoop")
|
|
13
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
14
|
+
const scheduleIdleWork = requireFunction(options.scheduleIdleWork, "scheduleIdleWork")
|
|
15
|
+
const toast = requireFunction(options.toast, "toast")
|
|
16
|
+
const say = requireFunction(options.say, "say")
|
|
17
|
+
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
18
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
19
|
+
const setGoalComplete = typeof options.setGoalComplete === "function" ? options.setGoalComplete : defaultSetGoalComplete
|
|
20
|
+
const setGoalBlocked = typeof options.setGoalBlocked === "function" ? options.setGoalBlocked : defaultSetGoalBlocked
|
|
21
|
+
|
|
22
|
+
async function statusGoal(directory, client, sessionID) {
|
|
23
|
+
const state = await readState(directory, sessionID)
|
|
24
|
+
const goals = (state.jobs || []).filter(isGoalJob)
|
|
25
|
+
const lines = goals.length ? goals.map((job, index) => {
|
|
26
|
+
const status = goalStatusText(job)
|
|
27
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
28
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
29
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
30
|
+
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
31
|
+
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
|
|
32
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${status} | turns=${job.runCount || 0} | objective=${String(job.action || job.goalFile || "").slice(0, 220)}${checks}${acceptance}${progress}${noProgress}${rejected}`
|
|
33
|
+
}) : ["No experimental goal jobs."]
|
|
34
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
35
|
+
await say(client, sessionID, "OpenCode Loop experimental goal status:\n" + lines.join("\n"))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function pauseGoal(directory, client, sessionID, args) {
|
|
39
|
+
const target = String(args || "").trim() || "goal"
|
|
40
|
+
const state = await readState(directory, sessionID)
|
|
41
|
+
let count = 0
|
|
42
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
43
|
+
await writeState(directory, sessionID, state)
|
|
44
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
45
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function resumeGoal(directory, client, sessionID, args) {
|
|
49
|
+
const target = String(args || "").trim() || "goal"
|
|
50
|
+
const state = await readState(directory, sessionID)
|
|
51
|
+
let count = 0
|
|
52
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
53
|
+
if (!isGoalJob(job) || !matchJob(job, target, index)) return job
|
|
54
|
+
count++
|
|
55
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : (job.goalStatus || "active"), lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" }
|
|
56
|
+
})
|
|
57
|
+
await writeState(directory, sessionID, state)
|
|
58
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
59
|
+
if (count) {
|
|
60
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
61
|
+
scheduleIdleWork(directory, client, sessionID)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function clearGoal(directory, client, sessionID, args) {
|
|
66
|
+
const target = String(args || "").trim()
|
|
67
|
+
const state = await readState(directory, sessionID)
|
|
68
|
+
const before = state.jobs.length
|
|
69
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || (target && !matchJob(job, target, index)))
|
|
70
|
+
await writeState(directory, sessionID, state)
|
|
71
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
72
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
76
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
|
|
77
|
+
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
81
|
+
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
82
|
+
await toast(client, result.message, "warning")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function addGoal(directory, client, sessionID, args) {
|
|
86
|
+
const text = String(args || "").trim()
|
|
87
|
+
const [maybeCommand, rest] = splitFirst(text)
|
|
88
|
+
const sub = maybeCommand.toLowerCase()
|
|
89
|
+
if (!text || sub === "status") return await statusGoal(directory, client, sessionID)
|
|
90
|
+
if (sub === "pause") return await pauseGoal(directory, client, sessionID, rest)
|
|
91
|
+
if (sub === "resume") return await resumeGoal(directory, client, sessionID, rest)
|
|
92
|
+
if (["clear", "remove", "stop"].includes(sub)) return await clearGoal(directory, client, sessionID, rest)
|
|
93
|
+
if (["done", "complete", "completed"].includes(sub)) return await completeGoalCommand(directory, client, sessionID, rest)
|
|
94
|
+
if (["blocked", "block"].includes(sub)) return await blockGoalCommand(directory, client, sessionID, rest)
|
|
95
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
addGoal,
|
|
100
|
+
statusGoal,
|
|
101
|
+
pauseGoal,
|
|
102
|
+
resumeGoal,
|
|
103
|
+
clearGoal,
|
|
104
|
+
completeGoalCommand,
|
|
105
|
+
blockGoalCommand,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { now as defaultNow, durationToText } from "../core/args.js"
|
|
4
|
+
import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
|
+
import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js"
|
|
6
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
7
|
+
|
|
8
|
+
const SERVICE = "opencode-loop"
|
|
9
|
+
const DEFAULT_PROGRESS_MD = `# Progress
|
|
10
|
+
|
|
11
|
+
## Current Goal
|
|
12
|
+
Describe the current project goal here.
|
|
13
|
+
|
|
14
|
+
## Agent Rules
|
|
15
|
+
- Do not ask questions unless truly blocked.
|
|
16
|
+
- Make reasonable assumptions and continue.
|
|
17
|
+
- Work on unfinished TODOs in order.
|
|
18
|
+
- Mark completed TODOs with [x].
|
|
19
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
20
|
+
- Run tests, lint, or build when available.
|
|
21
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
22
|
+
|
|
23
|
+
## Active TODO
|
|
24
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
25
|
+
|
|
26
|
+
## Completed
|
|
27
|
+
- [x] Created progress.md.
|
|
28
|
+
|
|
29
|
+
## Backlog Ideas
|
|
30
|
+
- [ ] Add more project-specific tasks here.
|
|
31
|
+
|
|
32
|
+
## Blocked
|
|
33
|
+
- None.
|
|
34
|
+
`
|
|
35
|
+
|
|
36
|
+
function requireFunction(value, name) {
|
|
37
|
+
if (typeof value !== "function") throw new TypeError(`createLoopCommandHandlers requires ${name}`)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createLoopCommandHandlers(options = {}) {
|
|
42
|
+
const clearActiveRun = requireFunction(options.clearActiveRun, "clearActiveRun")
|
|
43
|
+
const cancelDueWork = requireFunction(options.cancelDueWork, "cancelDueWork")
|
|
44
|
+
const stopWatchdog = requireFunction(options.stopWatchdog, "stopWatchdog")
|
|
45
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
46
|
+
const maybeRunDueJobs = requireFunction(options.maybeRunDueJobs, "maybeRunDueJobs")
|
|
47
|
+
const toast = requireFunction(options.toast, "toast")
|
|
48
|
+
const say = requireFunction(options.say, "say")
|
|
49
|
+
const now = typeof options.now === "function" ? options.now : defaultNow
|
|
50
|
+
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
51
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
52
|
+
const removeState = typeof options.removeState === "function" ? options.removeState : defaultRemoveState
|
|
53
|
+
const pathExists = typeof options.pathExists === "function" ? options.pathExists : defaultPathExists
|
|
54
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
55
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args)
|
|
56
|
+
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs.writeFile(...args)
|
|
57
|
+
const runtimeVersion = options.runtimeVersion || process.version
|
|
58
|
+
const runtimePlatform = options.runtimePlatform || process.platform
|
|
59
|
+
|
|
60
|
+
async function stopLoop(directory, client, sessionID, args) {
|
|
61
|
+
const target = String(args || "").trim()
|
|
62
|
+
if (!target || target.toLowerCase() === "all") {
|
|
63
|
+
await removeState(directory, sessionID)
|
|
64
|
+
clearActiveRun(sessionID)
|
|
65
|
+
cancelDueWork(sessionID)
|
|
66
|
+
stopWatchdog(sessionID)
|
|
67
|
+
await toast(client, "All loops stopped for this session.", "success")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const state = await readState(directory, sessionID)
|
|
71
|
+
const before = state.jobs.length
|
|
72
|
+
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index))
|
|
73
|
+
await writeState(directory, sessionID, state)
|
|
74
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
75
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
79
|
+
const target = String(args || "").trim() || "all"
|
|
80
|
+
const state = await readState(directory, sessionID)
|
|
81
|
+
let count = 0
|
|
82
|
+
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
83
|
+
await writeState(directory, sessionID, state)
|
|
84
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
85
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function statusLoop(directory, client, sessionID) {
|
|
89
|
+
const state = await readState(directory, sessionID)
|
|
90
|
+
const jobs = state.jobs || []
|
|
91
|
+
const lines = jobs.length ? jobs.map((job, index) => {
|
|
92
|
+
const current = now()
|
|
93
|
+
const intervalMs = Number(job.intervalMs || 0)
|
|
94
|
+
const lastRunAt = Number(job.lastRunAt || 0)
|
|
95
|
+
const createdAt = Date.parse(job.createdAt || "")
|
|
96
|
+
const dueAt = Number(job.runNowRequestedAt || 0) > 0
|
|
97
|
+
? current
|
|
98
|
+
: lastRunAt > 0
|
|
99
|
+
? lastRunAt + intervalMs
|
|
100
|
+
: job.immediate === false && Number.isFinite(createdAt)
|
|
101
|
+
? createdAt + intervalMs
|
|
102
|
+
: current
|
|
103
|
+
const dueIn = Math.max(0, dueAt - current)
|
|
104
|
+
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",")
|
|
105
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`
|
|
106
|
+
}) : ["No active loop jobs."]
|
|
107
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
108
|
+
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function logsLoop(directory, client, sessionID) {
|
|
112
|
+
let text = "No loop log found."
|
|
113
|
+
try { text = (await readFile(path.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join("\n") || text } catch {}
|
|
114
|
+
await say(client, sessionID, "OpenCode loop logs:\n" + text)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function helpLoop(client, sessionID) {
|
|
118
|
+
await say(client, sessionID, [
|
|
119
|
+
"OpenCode Loop help:",
|
|
120
|
+
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
121
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
122
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
123
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
124
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
125
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
126
|
+
"/loop-goal --check \"npm run build\" --check \"npm test\" --complete-when-checks-pass ship it",
|
|
127
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
128
|
+
"/loop 200m --command /compact same as command loop",
|
|
129
|
+
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
130
|
+
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
131
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
132
|
+
"/loop-doctor | /loop-init | /loop-export",
|
|
133
|
+
].join("\n"))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runNow(directory, client, sessionID, args) {
|
|
137
|
+
const target = String(args || "").trim() || "all"
|
|
138
|
+
const state = await readState(directory, sessionID)
|
|
139
|
+
const requestedAt = Math.max(1, Number(now()) || Date.now())
|
|
140
|
+
let count = 0
|
|
141
|
+
for (const [index, job] of (state.jobs || []).entries()) {
|
|
142
|
+
if (!matchJob(job, target, index)) continue
|
|
143
|
+
job.lastRunAt = 0
|
|
144
|
+
job.paused = false
|
|
145
|
+
job.runNowRequestedAt = requestedAt
|
|
146
|
+
count += 1
|
|
147
|
+
}
|
|
148
|
+
await writeState(directory, sessionID, state)
|
|
149
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning")
|
|
150
|
+
// /loop-now is handled in command.execute.before. Do not start a model turn re-entrantly
|
|
151
|
+
// inside that hook; schedule the persisted request and let the idle-safe timer dispatch it.
|
|
152
|
+
if (count) await scheduleDueWork(directory, client, sessionID)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function doctorLoop(directory, client, sessionID) {
|
|
156
|
+
const state = await readState(directory, sessionID)
|
|
157
|
+
await say(client, sessionID, [
|
|
158
|
+
"OpenCode Loop doctor:",
|
|
159
|
+
`- plugin: ${SERVICE}`,
|
|
160
|
+
`- project directory: ${directory}`,
|
|
161
|
+
`- state directory: ${stateDir(directory)}`,
|
|
162
|
+
`- active jobs: ${(state.jobs || []).length}`,
|
|
163
|
+
`- node: ${runtimeVersion}`,
|
|
164
|
+
`- platform: ${runtimePlatform}`,
|
|
165
|
+
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
166
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
167
|
+
].join("\n"))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function initLoop(directory, client, sessionID, args) {
|
|
171
|
+
const target = String(args || "").trim() || "progress.md"
|
|
172
|
+
const full = path.resolve(directory, target)
|
|
173
|
+
if (await pathExists(full)) { await toast(client, `${target} already exists.`, "warning"); return }
|
|
174
|
+
await writeFile(full, DEFAULT_PROGRESS_MD, "utf8")
|
|
175
|
+
await toast(client, `Created ${target}.`, "success")
|
|
176
|
+
await appendLoopLog(directory, "init", { sessionID, file: target })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function exportLoop(directory, client, sessionID) {
|
|
180
|
+
const state = await readState(directory, sessionID)
|
|
181
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
stopLoop,
|
|
186
|
+
updateJobState,
|
|
187
|
+
statusLoop,
|
|
188
|
+
logsLoop,
|
|
189
|
+
helpLoop,
|
|
190
|
+
runNow,
|
|
191
|
+
doctorLoop,
|
|
192
|
+
initLoop,
|
|
193
|
+
exportLoop,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js"
|
|
2
|
+
import { jobLabel, isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
4
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
5
|
+
import { normalizedModelRef as defaultNormalizedModelRef, getSessionExecutionContext as defaultGetSessionExecutionContext } from "./session-context.js"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
8
|
+
const FALLBACK_ACTIVE_GUARD_MS = 45_000
|
|
9
|
+
|
|
10
|
+
function requireFunction(value, name) {
|
|
11
|
+
if (typeof value !== "function") throw new TypeError(`createLoopRegistration requires ${name}`)
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeActionForCompare(value) {
|
|
16
|
+
return String(value || "").replace(/\s+/g, " ").trim()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sameLoopDefinition(a, b) {
|
|
20
|
+
if (!a || !b) return false
|
|
21
|
+
return (a.name || "") === (b.name || "") &&
|
|
22
|
+
Number(a.intervalMs || 0) === Number(b.intervalMs || 0) &&
|
|
23
|
+
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
24
|
+
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
25
|
+
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createLoopRegistration(options = {}) {
|
|
29
|
+
const snapshotPaths = requireFunction(options.snapshotPaths, "snapshotPaths")
|
|
30
|
+
const scheduleDueWork = requireFunction(options.scheduleDueWork, "scheduleDueWork")
|
|
31
|
+
const scheduleIdleWork = requireFunction(options.scheduleIdleWork, "scheduleIdleWork")
|
|
32
|
+
const toast = requireFunction(options.toast, "toast")
|
|
33
|
+
const say = requireFunction(options.say, "say")
|
|
34
|
+
const parseLoopArgs = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : defaultParseLoopArgs
|
|
35
|
+
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
36
|
+
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
37
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
38
|
+
const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
|
|
39
|
+
const getSessionExecutionContext = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : defaultGetSessionExecutionContext
|
|
40
|
+
const configuredGuard = Number(options.defaultActiveGuardMs)
|
|
41
|
+
const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS
|
|
42
|
+
|
|
43
|
+
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
44
|
+
const parsed = parseLoopArgs(args, defaults)
|
|
45
|
+
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
46
|
+
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
|
|
47
|
+
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
48
|
+
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
49
|
+
if (defaults.testfixPreset) {
|
|
50
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
51
|
+
const parsedAction = String(parsed.job.action || "").trim()
|
|
52
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
53
|
+
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
54
|
+
parsed.job.verifyCommand = parsedAction
|
|
55
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`
|
|
56
|
+
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
57
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (parsed.job.watchPaths.length) parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths)
|
|
61
|
+
if (!parsed.job.activeRecoveryMs) {
|
|
62
|
+
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
63
|
+
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
64
|
+
: Math.max(defaultActiveGuardMs, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
65
|
+
}
|
|
66
|
+
if (parsed.job.dryRun) {
|
|
67
|
+
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info")
|
|
68
|
+
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```")
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const state = await readState(directory, sessionID)
|
|
72
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : []
|
|
73
|
+
|
|
74
|
+
// Default behavior is replace/upsert, not append forever. This prevents duplicate
|
|
75
|
+
// loops when OpenCode emits both command.execute.before and command.executed,
|
|
76
|
+
// and it matches the common expectation that /loop configures the current loop.
|
|
77
|
+
let replaced = false
|
|
78
|
+
if (!parsed.job.multi) {
|
|
79
|
+
const targetName = parsed.job.name || "default"
|
|
80
|
+
parsed.job.name = targetName
|
|
81
|
+
state.jobs = jobs.filter((existing) => {
|
|
82
|
+
const existingName = existing.name || "default"
|
|
83
|
+
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
|
|
84
|
+
if (shouldReplace) replaced = true
|
|
85
|
+
return !shouldReplace
|
|
86
|
+
})
|
|
87
|
+
} else {
|
|
88
|
+
state.jobs = jobs
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
state.jobs.push(parsed.job)
|
|
92
|
+
await writeState(directory, sessionID, state)
|
|
93
|
+
await scheduleDueWork(directory, client, sessionID)
|
|
94
|
+
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
95
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
96
|
+
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { addLoop }
|
|
100
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { now } from "../core/args.js"
|
|
2
|
+
import { sdkCall } from "./sdk.js"
|
|
3
|
+
|
|
4
|
+
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
5
|
+
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
6
|
+
const loopOwnedUserMessageGuards = new Map()
|
|
7
|
+
|
|
8
|
+
export function guardLoopOwnedUserMessage(sessionID) {
|
|
9
|
+
if (!sessionID) return
|
|
10
|
+
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map() }
|
|
11
|
+
current.pending += 1
|
|
12
|
+
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS)
|
|
13
|
+
for (const [messageID, expiresAt] of current.messageIDs.entries()) if (expiresAt < now()) current.messageIDs.delete(messageID)
|
|
14
|
+
loopOwnedUserMessageGuards.set(sessionID, current)
|
|
15
|
+
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
16
|
+
for (const [messageID, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(messageID)
|
|
17
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now()) loopOwnedUserMessageGuards.delete(key)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
22
|
+
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
23
|
+
if (!entry || typeof entry !== "object") return false
|
|
24
|
+
for (const [id, expiresAt] of entry.messageIDs.entries()) if (expiresAt < now()) entry.messageIDs.delete(id)
|
|
25
|
+
const id = typeof messageID === "string" ? messageID : ""
|
|
26
|
+
if (id && entry.messageIDs.has(id)) return true
|
|
27
|
+
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
28
|
+
// chat.message can fire before OpenCode assigns the synthetic user message
|
|
29
|
+
// an ID. Treat that pre-dispatch hook as Loop-owned without consuming the
|
|
30
|
+
// pending guard; message.updated will later bind and retain the real ID.
|
|
31
|
+
if (!id) return true
|
|
32
|
+
entry.pending -= 1
|
|
33
|
+
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS)
|
|
34
|
+
loopOwnedUserMessageGuards.set(sessionID, entry)
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0) loopOwnedUserMessageGuards.delete(sessionID)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function say(client, sessionID, text) {
|
|
42
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
43
|
+
try {
|
|
44
|
+
await sdkCall(
|
|
45
|
+
client.session.prompt.bind(client.session),
|
|
46
|
+
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
47
|
+
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
48
|
+
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
49
|
+
)
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function clearLoopOwnedUserMessageGuard(sessionID) {
|
|
54
|
+
loopOwnedUserMessageGuards.delete(sessionID)
|
|
55
|
+
}
|