@bybrawe/opencode-loop 0.5.33 → 0.5.35

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.
@@ -0,0 +1,71 @@
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
+ const COMPLETION_BOUNDED_PATTERNS = [
14
+ /\bbitene kadar\b/i,
15
+ /\b(?:tamamen|komple) projeyi bitir\b/i,
16
+ /\bişi bitir\b/i,
17
+ /\buntil (?:it(?:'s| is) )?(?:done|complete|completed|finished)\b/i,
18
+ /\bfinish (?:the )?(?:project|task|work)\b/i,
19
+ /\bkeep going until\b/i,
20
+ ]
21
+
22
+ const TERMINAL_COMPLETION_PATTERNS = [
23
+ // JS \b is ASCII-oriented and does not see Turkish dotless ı as a word
24
+ // character. Use an explicit following delimiter for Turkish terminal forms.
25
+ /proje tamamland[ıi](?=\s|[.,;:!—-]|$)/i,
26
+ /\bproject (?:is )?(?:complete|completed|finished|done)\b/i,
27
+ /\b(?:task|work) (?:is )?(?:complete|completed|finished|done)\b/i,
28
+ ]
29
+
30
+ const TERMINAL_NO_WORK_PATTERNS = [
31
+ /yap[ıi]lacak (?:başka )?iş yok(?=\s|[.,;:!—-]|$)/i,
32
+ /başka (?:bir )?iş (?:kalmad[ıi]|yok)(?=\s|[.,;:!—-]|$)/i,
33
+ /\bnothing (?:else )?left to do\b/i,
34
+ /\bno (?:more|remaining) work\b/i,
35
+ /\bno known (?:bugs|issues)\b/i,
36
+ /\bzero known (?:bugs|issues)\b/i,
37
+ ]
38
+
39
+ const NEXT_WORK_PATTERNS = [
40
+ /\bnext(?: step| task)?\b/i,
41
+ /s[ıi]radaki(?=\s|[.,;:!—-]|$)/i,
42
+ /sonraki(?=\s|[.,;:!—-]|$)/i,
43
+ /kalan (?:iş|işler|todo|adım)(?=\s|[.,;:!—-]|$)/i,
44
+ /\bremaining (?:work|task|todo|step)/i,
45
+ /devam (?:edeceğim|ediyorum|etmek gerek)(?=\s|[.,;:!—-]|$)/i,
46
+ ]
47
+
48
+ export function isContinuationShorthand(value) {
49
+ return CONTINUATION_SHORTHANDS.has(String(value || "").trim().toLowerCase().replace(/\s+/g, " "))
50
+ }
51
+
52
+ export function isCompletionBoundedContinuation(value) {
53
+ const text = String(value || "").trim()
54
+ return COMPLETION_BOUNDED_PATTERNS.some((pattern) => pattern.test(text))
55
+ }
56
+
57
+ export function isTerminalNoWorkReply(value) {
58
+ const text = String(value || "").trim()
59
+ if (!text || NEXT_WORK_PATTERNS.some((pattern) => pattern.test(text))) return false
60
+ const completed = TERMINAL_COMPLETION_PATTERNS.some((pattern) => pattern.test(text))
61
+ const noWork = TERMINAL_NO_WORK_PATTERNS.some((pattern) => pattern.test(text))
62
+ return completed && noWork
63
+ }
64
+
65
+ export function continuationProjectInstruction(value) {
66
+ if (!isContinuationShorthand(value) && !isCompletionBoundedContinuation(value)) return ""
67
+ const finish = isCompletionBoundedContinuation(value)
68
+ ? " If you believe the project is finished, perform a fresh verification pass before declaring completion; report both that the project is complete and that no work remains only when you have concrete current evidence."
69
+ : ""
70
+ 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.${finish}`
71
+ }
@@ -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, durationToText } from "../core/args.js"
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 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)
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} | due in ${durationToText(dueIn)} | ${flags}`
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 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",
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 say(client, sessionID, [
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
- `- active jobs: ${(state.jobs || []).length}`,
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 0s --max-runs 1 --dry-run continue from progress.md",
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
- ].join("\n"))
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 parsed = parseLoopArgs(args, defaults)
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", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
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,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
+ }