@bybrawe/opencode-loop 0.5.32 → 0.5.34
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 +17 -0
- package/README.md +197 -283
- package/commands/loop.md +1 -1
- package/package.json +3 -3
- package/scripts/action-dispatch-test.mjs +117 -0
- package/scripts/comprehensive-test.mjs +1 -1
- package/scripts/host-loop-canary.mjs +8 -3
- package/scripts/loop-command-handlers-test.mjs +33 -4
- package/scripts/loop-registration-test.mjs +77 -3
- package/scripts/run-admission-test.mjs +133 -0
- package/scripts/run-finalization-test.mjs +119 -0
- package/scripts/schedule-policy-test.mjs +114 -0
- package/scripts/schedule-syntax-test.mjs +75 -0
- package/scripts/session-status-idle-recovery-test.mjs +44 -0
- package/src/index.js +916 -473
- package/src/source/core/continuation.js +20 -0
- package/src/source/core/jobs.js +3 -0
- package/src/source/core/schedule-syntax.js +93 -0
- package/src/source/opencode/loop-commands.js +39 -30
- package/src/source/opencode/loop-registration.js +40 -3
- package/src/source/runtime/action-dispatch.js +108 -0
- package/src/source/runtime/companion-goal.js +37 -0
- package/src/source/runtime/executor.js +55 -228
- package/src/source/runtime/goal-evidence.js +39 -0
- package/src/source/runtime/goal-policy.js +1 -1
- package/src/source/runtime/goal-prompt.js +53 -0
- package/src/source/runtime/goal-report.js +52 -0
- package/src/source/runtime/goal-runtime.js +9 -141
- package/src/source/runtime/loop-diagnostics.js +45 -0
- package/src/source/runtime/run-admission.js +121 -0
- package/src/source/runtime/run-finalization.js +100 -0
- package/src/source/runtime/schedule-policy.js +80 -0
- package/src/source/runtime/scheduler-diagnostics.js +30 -0
- package/src/source/runtime/scheduler.js +10 -23
- package/src/source/runtime/session-status.js +28 -4
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const CONTINUATION_SHORTHANDS = new Set([
|
|
2
|
+
"continue",
|
|
3
|
+
"continue.",
|
|
4
|
+
"continue working",
|
|
5
|
+
"keep going",
|
|
6
|
+
"go on",
|
|
7
|
+
"devam",
|
|
8
|
+
"devam et",
|
|
9
|
+
"devam et.",
|
|
10
|
+
"devam et bakalım",
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
export function isContinuationShorthand(value) {
|
|
14
|
+
return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function continuationProjectInstruction(value) {
|
|
18
|
+
if (!isContinuationShorthand(value)) return ""
|
|
19
|
+
return "Treat this as continuation of the current project and conversation, not a fresh task. Inspect the repository state, relevant files, TODO/progress notes, recent changes, and git status as needed to identify the next unfinished step. Continue from existing work, do not redo completed work, and verify meaningful changes when practical."
|
|
20
|
+
}
|
package/src/source/core/jobs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_GOAL_MAX_NO_PROGRESS, parseDuration, durationToText } from "./args.js"
|
|
2
|
+
import { continuationProjectInstruction } from "./continuation.js"
|
|
2
3
|
|
|
3
4
|
export function presetDefaults(name) {
|
|
4
5
|
// parseLoopArgs owns duration/flag/action parsing. Presets only provide real
|
|
@@ -54,6 +55,8 @@ export function actionKind(action, job = {}) {
|
|
|
54
55
|
|
|
55
56
|
export function decoratePrompt(job) {
|
|
56
57
|
const additions = []
|
|
58
|
+
const continuation = continuationProjectInstruction(job.action)
|
|
59
|
+
if (continuation) additions.push(continuation)
|
|
57
60
|
if (job.progressFile) additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`)
|
|
58
61
|
if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
|
|
59
62
|
if (job.askNever) additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { parseDuration } from "./args.js"
|
|
2
|
+
|
|
3
|
+
function removeBooleanFlag(input, flag) {
|
|
4
|
+
const pattern = new RegExp(`(^|\\s)${flag.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}(?=\\s|$)`, "i")
|
|
5
|
+
const found = pattern.test(input)
|
|
6
|
+
return {
|
|
7
|
+
found,
|
|
8
|
+
value: String(input || "").replace(pattern, " ").replace(/\s+/g, " ").trim(),
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function firstToken(input) {
|
|
13
|
+
const match = String(input || "").trim().match(/^(\S+)(?:\s+([\s\S]*))?$/)
|
|
14
|
+
return match ? { token: match[1], rest: String(match[2] || "").trim() } : { token: "", rest: "" }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function inferredMode(intervalMs, maxRuns) {
|
|
18
|
+
if (Number(maxRuns || 0) === 1 && Number(intervalMs || 0) > 0) return "once"
|
|
19
|
+
return Number(intervalMs || 0) === 0 ? "idle" : "interval"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize the human-facing /loop schedule grammar before the legacy flag parser.
|
|
24
|
+
*
|
|
25
|
+
* Supported forms:
|
|
26
|
+
* /loop <prompt> -> every idle, unlimited
|
|
27
|
+
* /loop idle <prompt> -> every idle, unlimited
|
|
28
|
+
* /loop every 5m <prompt> -> recurring, first run after 5m
|
|
29
|
+
* /loop after 5m <prompt> -> one shot after 5m
|
|
30
|
+
* /loop in 5m <prompt> -> alias for `after`
|
|
31
|
+
* /loop 5m <prompt> -> legacy compact form (starts now unless --no-now)
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeLoopScheduleArgs(raw, defaults = {}) {
|
|
34
|
+
const overlap = removeBooleanFlag(String(raw || "").trim(), "--allow-goal-overlap")
|
|
35
|
+
let input = overlap.value
|
|
36
|
+
const nextDefaults = { ...defaults }
|
|
37
|
+
let scheduleMode = defaults.scheduleMode
|
|
38
|
+
let scheduleSyntax = "legacy"
|
|
39
|
+
|
|
40
|
+
const first = firstToken(input)
|
|
41
|
+
const keyword = first.token.toLowerCase()
|
|
42
|
+
|
|
43
|
+
if (keyword === "idle") {
|
|
44
|
+
nextDefaults.intervalMs = 0
|
|
45
|
+
nextDefaults.immediate = true
|
|
46
|
+
scheduleMode = "idle"
|
|
47
|
+
scheduleSyntax = "idle"
|
|
48
|
+
input = first.rest
|
|
49
|
+
} else if (keyword === "every" || keyword === "after" || keyword === "in") {
|
|
50
|
+
const duration = firstToken(first.rest)
|
|
51
|
+
const intervalMs = parseDuration(duration.token)
|
|
52
|
+
if (intervalMs === null) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: `Invalid ${keyword} schedule. Example: /loop ${keyword === "every" ? "every" : "after"} 5m continue the project`,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
nextDefaults.intervalMs = intervalMs
|
|
59
|
+
nextDefaults.immediate = false
|
|
60
|
+
input = duration.rest
|
|
61
|
+
if (keyword === "every") {
|
|
62
|
+
scheduleMode = intervalMs === 0 ? "idle" : "interval"
|
|
63
|
+
scheduleSyntax = "every"
|
|
64
|
+
} else {
|
|
65
|
+
nextDefaults.maxRuns = 1
|
|
66
|
+
scheduleMode = "once"
|
|
67
|
+
scheduleSyntax = "after"
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const duration = parseDuration(first.token)
|
|
71
|
+
if (duration !== null) {
|
|
72
|
+
scheduleMode = duration === 0 ? "idle" : "interval"
|
|
73
|
+
} else if (nextDefaults.intervalMs === undefined || nextDefaults.intervalMs === null) {
|
|
74
|
+
// Plain /loop text is the ergonomic auto-continue form. Flags at the front
|
|
75
|
+
// are also accepted because the lower-level parser now receives an idle default.
|
|
76
|
+
nextDefaults.intervalMs = 0
|
|
77
|
+
nextDefaults.immediate = nextDefaults.immediate ?? true
|
|
78
|
+
scheduleMode = "idle"
|
|
79
|
+
scheduleSyntax = "idle-shorthand"
|
|
80
|
+
} else {
|
|
81
|
+
scheduleMode = scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
args: input,
|
|
88
|
+
defaults: nextDefaults,
|
|
89
|
+
scheduleMode: scheduleMode || inferredMode(nextDefaults.intervalMs, nextDefaults.maxRuns),
|
|
90
|
+
scheduleSyntax,
|
|
91
|
+
allowGoalOverlap: overlap.found || defaults.allowGoalOverlap === true,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
|
-
import { now as defaultNow
|
|
3
|
+
import { now as defaultNow } from "../core/args.js"
|
|
4
4
|
import { jobLabel, matchJob, isGoalJob, goalStatusText } from "../core/jobs.js"
|
|
5
5
|
import { stateDir, pathExists as defaultPathExists, readState as defaultReadState, writeState as defaultWriteState, removeState as defaultRemoveState } from "../core/state.js"
|
|
6
6
|
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
7
|
+
import { dedicatedGoalSummary, findDedicatedGoalForSession as defaultFindDedicatedGoalForSession } from "../runtime/companion-goal.js"
|
|
8
|
+
import { describeJobScheduling, listPersistedLoopSessions as defaultListPersistedLoopSessions } from "../runtime/loop-diagnostics.js"
|
|
7
9
|
|
|
8
10
|
const SERVICE = "opencode-loop"
|
|
9
11
|
const DEFAULT_PROGRESS_MD = `# Progress
|
|
@@ -54,6 +56,8 @@ export function createLoopCommandHandlers(options = {}) {
|
|
|
54
56
|
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
55
57
|
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs.readFile(...args)
|
|
56
58
|
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs.writeFile(...args)
|
|
59
|
+
const listPersistedLoopSessions = typeof options.listPersistedLoopSessions === "function" ? options.listPersistedLoopSessions : defaultListPersistedLoopSessions
|
|
60
|
+
const findDedicatedGoalForSession = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : defaultFindDedicatedGoalForSession
|
|
57
61
|
const runtimeVersion = options.runtimeVersion || process.version
|
|
58
62
|
const runtimePlatform = options.runtimePlatform || process.platform
|
|
59
63
|
|
|
@@ -88,21 +92,11 @@ export function createLoopCommandHandlers(options = {}) {
|
|
|
88
92
|
async function statusLoop(directory, client, sessionID) {
|
|
89
93
|
const state = await readState(directory, sessionID)
|
|
90
94
|
const jobs = state.jobs || []
|
|
95
|
+
const current = now()
|
|
91
96
|
const lines = jobs.length ? jobs.map((job, index) => {
|
|
92
|
-
const
|
|
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)
|
|
97
|
+
const scheduling = describeJobScheduling(job, current)
|
|
104
98
|
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} |
|
|
99
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | schedule=${scheduling.schedule} | state=${scheduling.state} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | ${flags}`
|
|
106
100
|
}) : ["No active loop jobs."]
|
|
107
101
|
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
108
102
|
await say(client, sessionID, "OpenCode loop status:\n" + lines.join("\n"))
|
|
@@ -117,18 +111,20 @@ export function createLoopCommandHandlers(options = {}) {
|
|
|
117
111
|
async function helpLoop(client, sessionID) {
|
|
118
112
|
await say(client, sessionID, [
|
|
119
113
|
"OpenCode Loop help:",
|
|
120
|
-
"/loop
|
|
121
|
-
"/loop
|
|
122
|
-
"/loop
|
|
123
|
-
"/loop-
|
|
124
|
-
"/loop
|
|
125
|
-
"/loop-
|
|
126
|
-
"/loop-
|
|
127
|
-
"/loop-
|
|
128
|
-
"/loop
|
|
129
|
-
"/loop
|
|
130
|
-
"/loop 0s --
|
|
131
|
-
"/loop
|
|
114
|
+
"/loop continue the project auto-continue forever whenever the session becomes idle",
|
|
115
|
+
"/loop idle continue the project explicit form of the same idle loop",
|
|
116
|
+
"/loop every 5m continue the project recurring timer; first run after 5m, always waits for idle",
|
|
117
|
+
"/loop after 5m continue the project one-shot delayed prompt; runs once when 5m has passed and session is idle",
|
|
118
|
+
"/loop 5m continue the project legacy compact form: starts on next idle, then every 5m",
|
|
119
|
+
"/loop 5m --no-now continue the project legacy recurring form with first run delayed 5m",
|
|
120
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
121
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
122
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
123
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
124
|
+
"/loop 0s --verify \"npm test\" <prompt> verify after each assistant turn",
|
|
125
|
+
"/loop --prompt-file loop-prompt.md idle loop loading its prompt from a file",
|
|
126
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
127
|
+
"Prompt-producing /loop jobs are blocked while dedicated /goal owns the same session; use another session or --allow-goal-overlap only intentionally.",
|
|
132
128
|
"/loop-doctor | /loop-init | /loop-export",
|
|
133
129
|
].join("\n"))
|
|
134
130
|
}
|
|
@@ -154,17 +150,30 @@ export function createLoopCommandHandlers(options = {}) {
|
|
|
154
150
|
|
|
155
151
|
async function doctorLoop(directory, client, sessionID) {
|
|
156
152
|
const state = await readState(directory, sessionID)
|
|
157
|
-
await
|
|
153
|
+
const persisted = await listPersistedLoopSessions(directory, sessionID)
|
|
154
|
+
const otherSessions = persisted.filter((entry) => !entry.current && entry.enabled > 0)
|
|
155
|
+
const dedicatedGoal = await findDedicatedGoalForSession(directory, sessionID)
|
|
156
|
+
const lines = [
|
|
158
157
|
"OpenCode Loop doctor:",
|
|
159
158
|
`- plugin: ${SERVICE}`,
|
|
160
159
|
`- project directory: ${directory}`,
|
|
161
160
|
`- state directory: ${stateDir(directory)}`,
|
|
162
|
-
`-
|
|
161
|
+
`- current session: ${sessionID}`,
|
|
162
|
+
`- current-session jobs: ${(state.jobs || []).length}`,
|
|
163
|
+
`- dedicated /goal: ${dedicatedGoalSummary(dedicatedGoal)}`,
|
|
164
|
+
`- other persisted sessions with enabled jobs: ${otherSessions.length}`,
|
|
163
165
|
`- node: ${runtimeVersion}`,
|
|
164
166
|
`- platform: ${runtimePlatform}`,
|
|
165
|
-
"- smoke test: /loop
|
|
167
|
+
"- smoke test: /loop --max-runs 1 --dry-run continue from progress.md",
|
|
168
|
+
"- delayed smoke test: /loop after 5m --dry-run continue from progress.md",
|
|
169
|
+
"- recurring smoke test: /loop every 5m --dry-run continue from progress.md",
|
|
166
170
|
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it",
|
|
167
|
-
]
|
|
171
|
+
]
|
|
172
|
+
for (const entry of otherSessions.slice(0, 8)) {
|
|
173
|
+
lines.push(`- other session ${entry.sessionID}: jobs=${entry.jobs}, enabled=${entry.enabled}, never-ran=${entry.neverRan}`)
|
|
174
|
+
}
|
|
175
|
+
if (otherSessions.length > 8) lines.push(`- ... ${otherSessions.length - 8} more persisted session(s)`)
|
|
176
|
+
await say(client, sessionID, lines.join("\n"))
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
async function initLoop(directory, client, sessionID, args) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { parseLoopArgs as defaultParseLoopArgs } from "../core/args.js"
|
|
2
|
-
import { jobLabel, isGoalJob } from "../core/jobs.js"
|
|
2
|
+
import { actionKind, jobLabel, isGoalJob } from "../core/jobs.js"
|
|
3
|
+
import { normalizeLoopScheduleArgs as defaultNormalizeLoopScheduleArgs } from "../core/schedule-syntax.js"
|
|
3
4
|
import { readState as defaultReadState, writeState as defaultWriteState } from "../core/state.js"
|
|
4
5
|
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
6
|
+
import { dedicatedGoalOwnsContinuation, findDedicatedGoalForSession as defaultFindDedicatedGoalForSession } from "../runtime/companion-goal.js"
|
|
5
7
|
import { normalizedModelRef as defaultNormalizedModelRef, getSessionExecutionContext as defaultGetSessionExecutionContext } from "./session-context.js"
|
|
6
8
|
|
|
7
9
|
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
@@ -32,17 +34,31 @@ export function createLoopRegistration(options = {}) {
|
|
|
32
34
|
const toast = requireFunction(options.toast, "toast")
|
|
33
35
|
const say = requireFunction(options.say, "say")
|
|
34
36
|
const parseLoopArgs = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : defaultParseLoopArgs
|
|
37
|
+
const normalizeLoopScheduleArgs = typeof options.normalizeLoopScheduleArgs === "function" ? options.normalizeLoopScheduleArgs : defaultNormalizeLoopScheduleArgs
|
|
35
38
|
const readState = typeof options.readState === "function" ? options.readState : defaultReadState
|
|
36
39
|
const writeState = typeof options.writeState === "function" ? options.writeState : defaultWriteState
|
|
37
40
|
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
38
41
|
const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
|
|
39
42
|
const getSessionExecutionContext = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : defaultGetSessionExecutionContext
|
|
43
|
+
const findDedicatedGoalForSession = typeof options.findDedicatedGoalForSession === "function" ? options.findDedicatedGoalForSession : defaultFindDedicatedGoalForSession
|
|
40
44
|
const configuredGuard = Number(options.defaultActiveGuardMs)
|
|
41
45
|
const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS
|
|
42
46
|
|
|
43
47
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
44
|
-
const
|
|
48
|
+
const normalized = normalizeLoopScheduleArgs(args, defaults)
|
|
49
|
+
if (!normalized.ok) { await toast(client, normalized.error, "warning"); return }
|
|
50
|
+
|
|
51
|
+
const parsed = parseLoopArgs(normalized.args, normalized.defaults)
|
|
45
52
|
if (!parsed.ok) { await toast(client, parsed.error, "warning"); return }
|
|
53
|
+
parsed.job.scheduleMode = normalized.scheduleMode
|
|
54
|
+
parsed.job.scheduleSyntax = normalized.scheduleSyntax
|
|
55
|
+
parsed.job.allowGoalOverlap = normalized.allowGoalOverlap === true
|
|
56
|
+
if (normalized.scheduleSyntax === "after") {
|
|
57
|
+
parsed.job.immediate = false
|
|
58
|
+
parsed.job.maxRuns = 1
|
|
59
|
+
parsed.job.lastRunAt = Date.parse(parsed.job.createdAt || "") || Date.now()
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" }
|
|
47
63
|
parsed.job.agent = defaults.agent || executionContext.agent || "build"
|
|
48
64
|
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model
|
|
@@ -63,6 +79,21 @@ export function createLoopRegistration(options = {}) {
|
|
|
63
79
|
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
64
80
|
: Math.max(defaultActiveGuardMs, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
65
81
|
}
|
|
82
|
+
|
|
83
|
+
const promptProducing = actionKind(parsed.job.action, parsed.job) === "prompt"
|
|
84
|
+
if (promptProducing && !parsed.job.allowGoalOverlap) {
|
|
85
|
+
const dedicatedGoal = await findDedicatedGoalForSession(directory, sessionID)
|
|
86
|
+
if (dedicatedGoalOwnsContinuation(dedicatedGoal)) {
|
|
87
|
+
await appendLoopLog(directory, "goal-overlap-blocked", {
|
|
88
|
+
sessionID,
|
|
89
|
+
job: parsed.job.name || parsed.job.id,
|
|
90
|
+
goal: dedicatedGoal.id,
|
|
91
|
+
})
|
|
92
|
+
await toast(client, "Prompt loop not added: dedicated /goal already owns continuation in this session. Pause/finish the Goal, use another session, or pass --allow-goal-overlap intentionally.", "warning")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
66
97
|
if (parsed.job.dryRun) {
|
|
67
98
|
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info")
|
|
68
99
|
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```")
|
|
@@ -93,7 +124,13 @@ export function createLoopRegistration(options = {}) {
|
|
|
93
124
|
await scheduleDueWork(directory, client, sessionID)
|
|
94
125
|
if (parsed.job.immediate) scheduleIdleWork(directory, client, sessionID)
|
|
95
126
|
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
96
|
-
await appendLoopLog(directory, replaced ? "replace" : "add", {
|
|
127
|
+
await appendLoopLog(directory, replaced ? "replace" : "add", {
|
|
128
|
+
sessionID,
|
|
129
|
+
job: parsed.job.name || parsed.job.id,
|
|
130
|
+
label: jobLabel(parsed.job),
|
|
131
|
+
scheduleMode: parsed.job.scheduleMode,
|
|
132
|
+
scheduleSyntax: parsed.job.scheduleSyntax,
|
|
133
|
+
})
|
|
97
134
|
}
|
|
98
135
|
|
|
99
136
|
return { addLoop }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { splitFirst } from "../core/args.js"
|
|
2
|
+
import { actionKind } from "../core/jobs.js"
|
|
3
|
+
import { appendLoopLog as defaultAppendLoopLog } from "../core/process.js"
|
|
4
|
+
import { sdkCall as defaultSdkCall } from "../opencode/sdk.js"
|
|
5
|
+
import { normalizedModelRef as defaultNormalizedModelRef } from "../opencode/session-context.js"
|
|
6
|
+
import { fireSdk as defaultFireSdk, compactTuiCommandName as defaultCompactTuiCommandName, toast as defaultToast } from "../opencode/host.js"
|
|
7
|
+
import { guardLoopOwnedUserMessage as defaultGuardLoopOwnedUserMessage } from "../opencode/messages.js"
|
|
8
|
+
import { dangerousShell as defaultDangerousShell } from "./job-workspace.js"
|
|
9
|
+
|
|
10
|
+
function requireFunction(value, label) {
|
|
11
|
+
if (typeof value !== "function") throw new TypeError(`createActionDispatcher requires ${label}`)
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createActionDispatcher(options = {}) {
|
|
16
|
+
const buildPrompt = requireFunction(options.buildPrompt, "buildPrompt")
|
|
17
|
+
const startCompaction = requireFunction(options.compactionRuntime?.start, "compactionRuntime.start")
|
|
18
|
+
const appendLoopLog = typeof options.appendLoopLog === "function" ? options.appendLoopLog : defaultAppendLoopLog
|
|
19
|
+
const sdkCall = typeof options.sdkCall === "function" ? options.sdkCall : defaultSdkCall
|
|
20
|
+
const normalizedModelRef = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : defaultNormalizedModelRef
|
|
21
|
+
const fireSdk = typeof options.fireSdk === "function" ? options.fireSdk : defaultFireSdk
|
|
22
|
+
const compactTuiCommandName = typeof options.compactTuiCommandName === "function"
|
|
23
|
+
? options.compactTuiCommandName
|
|
24
|
+
: defaultCompactTuiCommandName
|
|
25
|
+
const toast = typeof options.toast === "function" ? options.toast : defaultToast
|
|
26
|
+
const guardLoopOwnedUserMessage = typeof options.guardLoopOwnedUserMessage === "function"
|
|
27
|
+
? options.guardLoopOwnedUserMessage
|
|
28
|
+
: defaultGuardLoopOwnedUserMessage
|
|
29
|
+
const dangerousShell = typeof options.dangerousShell === "function" ? options.dangerousShell : defaultDangerousShell
|
|
30
|
+
|
|
31
|
+
async function fireAction(directory, client, sessionID, job) {
|
|
32
|
+
const action = String(job.action || "").trim()
|
|
33
|
+
const kind = actionKind(action, job)
|
|
34
|
+
const agent = job.agent || "build"
|
|
35
|
+
const model = normalizedModelRef(job.model)
|
|
36
|
+
|
|
37
|
+
if (kind === "compact") {
|
|
38
|
+
const ok = await startCompaction(directory, client, sessionID, job.id, model, false)
|
|
39
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (kind === "command") {
|
|
43
|
+
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
44
|
+
const [command, argumentsText] = splitFirst(normalized)
|
|
45
|
+
if (!command) {
|
|
46
|
+
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
47
|
+
return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
|
|
48
|
+
}
|
|
49
|
+
const tuiCommand = compactTuiCommandName(command)
|
|
50
|
+
if (tuiCommand) {
|
|
51
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
52
|
+
const ok = await startCompaction(directory, client, sessionID, job.id, model, false)
|
|
53
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok }
|
|
54
|
+
}
|
|
55
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
56
|
+
const commandBody = { command, arguments: argumentsText, agent }
|
|
57
|
+
if (model) commandBody.model = `${model.providerID}/${model.modelID}`
|
|
58
|
+
await sdkCall(
|
|
59
|
+
client.session.command.bind(client.session),
|
|
60
|
+
{ path: { id: sessionID }, body: commandBody },
|
|
61
|
+
{ path: { sessionID }, body: commandBody },
|
|
62
|
+
{ sessionID, ...commandBody },
|
|
63
|
+
)
|
|
64
|
+
return { startsAssistantTurn: true }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (kind === "shell") {
|
|
68
|
+
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
69
|
+
if (job.safe && dangerousShell(command)) {
|
|
70
|
+
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
71
|
+
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
72
|
+
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
|
|
73
|
+
}
|
|
74
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
75
|
+
const shellBody = { command, agent }
|
|
76
|
+
if (model) shellBody.model = model
|
|
77
|
+
const dispatch = fireSdk(
|
|
78
|
+
client,
|
|
79
|
+
"session.shell",
|
|
80
|
+
client.session.shell.bind(client.session),
|
|
81
|
+
{ path: { id: sessionID }, body: shellBody },
|
|
82
|
+
{ path: { sessionID }, body: shellBody },
|
|
83
|
+
{ sessionID, ...shellBody },
|
|
84
|
+
)
|
|
85
|
+
return { startsAssistantTurn: true, dispatch }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const prompt = await buildPrompt(directory, job)
|
|
89
|
+
const prefix = kind === "goal"
|
|
90
|
+
? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real."
|
|
91
|
+
: "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."
|
|
92
|
+
const promptText = `${prefix}\n\n${prompt}`
|
|
93
|
+
guardLoopOwnedUserMessage(sessionID)
|
|
94
|
+
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
95
|
+
if (model) promptBody.model = model
|
|
96
|
+
const dispatch = fireSdk(
|
|
97
|
+
client,
|
|
98
|
+
"session.prompt",
|
|
99
|
+
client.session.prompt.bind(client.session),
|
|
100
|
+
{ path: { id: sessionID }, body: promptBody },
|
|
101
|
+
{ path: { sessionID }, body: promptBody },
|
|
102
|
+
{ sessionID, ...promptBody },
|
|
103
|
+
)
|
|
104
|
+
return { startsAssistantTurn: true, dispatch }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { fireAction }
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
function goalRoot(directory) {
|
|
5
|
+
return path.join(directory, ".opencode", "goals")
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function findDedicatedGoalForSession(directory, sessionID) {
|
|
9
|
+
if (!directory || !sessionID) return undefined
|
|
10
|
+
let names
|
|
11
|
+
try {
|
|
12
|
+
names = await fs.readdir(goalRoot(directory))
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error?.code === "ENOENT") return undefined
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
if (!name.endsWith(".json")) continue
|
|
20
|
+
try {
|
|
21
|
+
const value = JSON.parse(await fs.readFile(path.join(goalRoot(directory), name), "utf8"))
|
|
22
|
+
if (value?.sessionID === sessionID) return value
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function dedicatedGoalOwnsContinuation(goal) {
|
|
29
|
+
return goal?.status === "active"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function dedicatedGoalSummary(goal) {
|
|
33
|
+
if (!goal) return "not detected"
|
|
34
|
+
const id = String(goal.id || "unknown").slice(0, 12)
|
|
35
|
+
const status = String(goal.status || "unknown")
|
|
36
|
+
return `${status} (${id})`
|
|
37
|
+
}
|