@bybrawe/opencode-loop 0.5.27 → 0.5.28
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/package.json +6 -3
- package/scripts/comprehensive-test.mjs +18 -4
- package/scripts/comprehensive-watchdog.mjs +32 -0
- package/scripts/host-adapter-contract-test.mjs +147 -0
- package/scripts/host-bridge-parity-test.mjs +22 -0
- package/scripts/host-loop-canary.mjs +16 -2
- package/scripts/opencode2-host-canary.mjs +194 -0
- package/scripts/runtime-contract-test.mjs +59 -0
- package/scripts/runtime-event-test.mjs +78 -0
- package/scripts/runtime-scope-test.mjs +46 -0
- package/scripts/session-manager-test.mjs +48 -0
- package/scripts/v2-event-bridge-test.mjs +72 -0
- package/scripts/v2-event-stream-contract-test.mjs +38 -0
- package/scripts/v2-host-contract-test.mjs +55 -0
- package/scripts/v2-plugin-sentinel-test.mjs +87 -0
- package/src/index.js +2278 -1918
- package/src/opencode/v1.js +1 -0
- package/src/source/core/args.js +281 -0
- package/src/source/core/jobs.js +80 -0
- package/src/source/core/process.js +51 -0
- package/src/source/core/state.js +211 -0
- package/src/source/legacy-v1.js +1944 -0
- package/src/source/opencode/adapter.js +21 -0
- package/src/source/opencode/host.js +134 -0
- package/src/source/opencode/sdk.js +38 -0
- package/src/source/opencode/session-context.js +50 -0
- package/src/source/opencode2/capabilities.js +69 -0
- package/src/source/opencode2/event-bridge.js +156 -0
- package/src/source/opencode2/experimental.js +16 -0
- package/src/source/opencode2/host-contract.js +68 -0
- package/src/source/runtime/events.js +113 -0
- package/src/source/runtime/observer.js +23 -0
- package/src/source/runtime/scope.js +56 -0
- package/src/source/runtime/session-manager.js +100 -0
- package/src/source/runtime/session-registry.js +58 -0
- package/src/source/runtime/timers.js +45 -0
- package/src/source/v1.js +69 -0
package/src/index.js
CHANGED
|
@@ -1,195 +1,134 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { tool } from "@opencode-ai/plugin/tool"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const DEFAULT_ACTIVE_GUARD_MS = 45_000
|
|
10
|
-
const STALE_ACTIVE_RECOVERY_MS = 45_000
|
|
11
|
-
const IDLE_DEBOUNCE_MS = 1_200
|
|
12
|
-
const BUSY_RETRY_MS = 5_000
|
|
13
|
-
const SESSION_STATUS_CACHE_MS = 1_500
|
|
14
|
-
const MIN_DUE_TIMER_MS = 250
|
|
15
|
-
const MAX_DUE_TIMER_MS = 2_147_000_000
|
|
16
|
-
const HEARTBEAT_MS = 2_500
|
|
17
|
-
const MAX_SCAN_FILES = 200
|
|
18
|
-
const MAX_SCAN_BYTES = 2_000_000
|
|
19
|
-
const GOAL_REPORT_DIR = "goals"
|
|
20
|
-
const GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION"
|
|
21
|
-
const DEFAULT_GOAL_MAX_NO_PROGRESS = 3
|
|
22
|
-
const DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180_000
|
|
23
|
-
const LOOP_OWNED_USER_MESSAGE_GUARD_MS = 10_000
|
|
24
|
-
const LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60_000
|
|
25
|
-
const LOCAL_COMMAND_AGENT = "opencode-loop-local"
|
|
26
|
-
const STATE_BASELINE = Symbol("opencode-loop-state-baseline")
|
|
27
|
-
|
|
28
|
-
const activeRuns = new Map()
|
|
29
|
-
const handledCommands = new Map()
|
|
30
|
-
const handledCommandEvents = new Map()
|
|
31
|
-
const sessionExecutionContexts = new Map()
|
|
32
|
-
const loopOwnedUserMessageGuards = new Map()
|
|
33
|
-
const idleTimers = new Map()
|
|
34
|
-
const dueTimers = new Map()
|
|
35
|
-
const watchdogTimers = new Map()
|
|
36
|
-
const runLocks = new Map()
|
|
37
|
-
const knownSessions = new Map()
|
|
38
|
-
const stateWriteLocks = new Map()
|
|
39
|
-
const activeToolCalls = new Map()
|
|
40
|
-
const sessionParents = new Map()
|
|
41
|
-
let heartbeatTimer
|
|
42
|
-
const sessionStatuses = new Map()
|
|
43
|
-
const sessionStatusSeenAt = new Map()
|
|
44
|
-
const loopCompactionRequests = new Map()
|
|
45
|
-
|
|
46
|
-
const DEFAULT_PROGRESS_MD = `# Progress
|
|
47
|
-
|
|
48
|
-
## Current Goal
|
|
49
|
-
Describe the current project goal here.
|
|
50
|
-
|
|
51
|
-
## Agent Rules
|
|
52
|
-
- Do not ask questions unless truly blocked.
|
|
53
|
-
- Make reasonable assumptions and continue.
|
|
54
|
-
- Work on unfinished TODOs in order.
|
|
55
|
-
- Mark completed TODOs with [x].
|
|
56
|
-
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
57
|
-
- Run tests, lint, or build when available.
|
|
58
|
-
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
59
|
-
|
|
60
|
-
## Active TODO
|
|
61
|
-
- [ ] Review the project structure and pick the next safe improvement.
|
|
62
|
-
|
|
63
|
-
## Completed
|
|
64
|
-
- [x] Created progress.md.
|
|
65
|
-
|
|
66
|
-
## Backlog Ideas
|
|
67
|
-
- [ ] Add more project-specific tasks here.
|
|
68
|
-
|
|
69
|
-
## Blocked
|
|
70
|
-
- None.
|
|
71
|
-
`
|
|
72
|
-
|
|
1
|
+
// @bun
|
|
2
|
+
// src/source/legacy-v1.js
|
|
3
|
+
import { promises as fs3 } from "fs";
|
|
4
|
+
import path3 from "path";
|
|
5
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
6
|
+
|
|
7
|
+
// src/source/core/args.js
|
|
8
|
+
var DEFAULT_GOAL_MAX_NO_PROGRESS = 3;
|
|
73
9
|
function now() {
|
|
74
|
-
return Date.now()
|
|
10
|
+
return Date.now();
|
|
75
11
|
}
|
|
76
|
-
|
|
77
12
|
function safeID(value) {
|
|
78
|
-
return String(value || "job")
|
|
79
|
-
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
|
|
80
|
-
.replace(/^-+|-+$/g, "")
|
|
81
|
-
.slice(0, 80) || "job"
|
|
13
|
+
return String(value || "job").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "job";
|
|
82
14
|
}
|
|
83
|
-
|
|
84
15
|
function parseDuration(value) {
|
|
85
|
-
const input = String(value || "").trim()
|
|
86
|
-
if (input === "0")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
if (unit
|
|
96
|
-
|
|
97
|
-
|
|
16
|
+
const input = String(value || "").trim();
|
|
17
|
+
if (input === "0")
|
|
18
|
+
return 0;
|
|
19
|
+
const match = input.match(/^(\d+)\s*(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/i);
|
|
20
|
+
if (!match)
|
|
21
|
+
return null;
|
|
22
|
+
const amount = Number.parseInt(match[1], 10);
|
|
23
|
+
const unit = match[2].toLowerCase();
|
|
24
|
+
if (!Number.isFinite(amount) || amount < 0)
|
|
25
|
+
return null;
|
|
26
|
+
if (unit === "ms")
|
|
27
|
+
return amount;
|
|
28
|
+
if (unit.startsWith("s"))
|
|
29
|
+
return amount * 1000;
|
|
30
|
+
if (unit.startsWith("m"))
|
|
31
|
+
return amount * 60000;
|
|
32
|
+
if (unit.startsWith("h"))
|
|
33
|
+
return amount * 3600000;
|
|
34
|
+
if (unit.startsWith("d"))
|
|
35
|
+
return amount * 86400000;
|
|
36
|
+
return null;
|
|
98
37
|
}
|
|
99
|
-
|
|
100
38
|
function durationToText(ms) {
|
|
101
|
-
if (ms === 0)
|
|
102
|
-
|
|
103
|
-
if (ms
|
|
104
|
-
|
|
105
|
-
if (ms %
|
|
106
|
-
|
|
107
|
-
|
|
39
|
+
if (ms === 0)
|
|
40
|
+
return "every idle";
|
|
41
|
+
if (!Number.isFinite(ms))
|
|
42
|
+
return "unknown";
|
|
43
|
+
if (ms % 86400000 === 0)
|
|
44
|
+
return `${ms / 86400000}d`;
|
|
45
|
+
if (ms % 3600000 === 0)
|
|
46
|
+
return `${ms / 3600000}h`;
|
|
47
|
+
if (ms % 60000 === 0)
|
|
48
|
+
return `${ms / 60000}m`;
|
|
49
|
+
if (ms % 1000 === 0)
|
|
50
|
+
return `${ms / 1000}s`;
|
|
51
|
+
return `${ms}ms`;
|
|
108
52
|
}
|
|
109
|
-
|
|
110
53
|
function splitFirst(input) {
|
|
111
|
-
const match = String(input || "").trim().match(/^(\S+)\s*([\s\S]*)$/)
|
|
112
|
-
if (!match)
|
|
113
|
-
|
|
54
|
+
const match = String(input || "").trim().match(/^(\S+)\s*([\s\S]*)$/);
|
|
55
|
+
if (!match)
|
|
56
|
+
return ["", ""];
|
|
57
|
+
return [match[1], (match[2] || "").trim()];
|
|
114
58
|
}
|
|
115
|
-
|
|
116
59
|
function stripOuterQuotes(value) {
|
|
117
|
-
const input = String(value || "").trim()
|
|
118
|
-
if (
|
|
119
|
-
return input.slice(1, -1)
|
|
60
|
+
const input = String(value || "").trim();
|
|
61
|
+
if (input.startsWith('"') && input.endsWith('"') || input.startsWith("'") && input.endsWith("'")) {
|
|
62
|
+
return input.slice(1, -1);
|
|
120
63
|
}
|
|
121
|
-
return input
|
|
64
|
+
return input;
|
|
122
65
|
}
|
|
123
|
-
|
|
124
66
|
function escapeRegExp(value) {
|
|
125
|
-
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
|
|
67
|
+
return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
126
68
|
}
|
|
127
|
-
|
|
128
69
|
function takeFlag(rest, flag) {
|
|
129
|
-
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}(?=\\s|$)`, "i")
|
|
130
|
-
const found = pattern.test(rest)
|
|
131
|
-
return [found, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()]
|
|
70
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}(?=\\s|$)`, "i");
|
|
71
|
+
const found = pattern.test(rest);
|
|
72
|
+
return [found, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()];
|
|
132
73
|
}
|
|
133
|
-
|
|
134
74
|
function takeFlagValue(rest, flag) {
|
|
135
|
-
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}\\s+(
|
|
136
|
-
const match = rest.match(pattern)
|
|
137
|
-
if (!match)
|
|
138
|
-
|
|
139
|
-
|
|
75
|
+
const pattern = new RegExp(`(^|\\s)${escapeRegExp(flag)}\\s+(?:"([^"]*)"|'([^']*)'|(\\S+))`, "i");
|
|
76
|
+
const match = rest.match(pattern);
|
|
77
|
+
if (!match)
|
|
78
|
+
return [undefined, rest];
|
|
79
|
+
const value = match[2] ?? match[3] ?? match[4];
|
|
80
|
+
return [value, rest.replace(pattern, " ").replace(/\s+/g, " ").trim()];
|
|
140
81
|
}
|
|
141
|
-
|
|
142
82
|
function takeAllFlagValues(rest, flag) {
|
|
143
|
-
const values = []
|
|
144
|
-
let current = rest
|
|
83
|
+
const values = [];
|
|
84
|
+
let current = rest;
|
|
145
85
|
while (true) {
|
|
146
|
-
const [value, next] = takeFlagValue(current, flag)
|
|
147
|
-
if (value === undefined)
|
|
148
|
-
|
|
149
|
-
|
|
86
|
+
const [value, next] = takeFlagValue(current, flag);
|
|
87
|
+
if (value === undefined)
|
|
88
|
+
return [values, current];
|
|
89
|
+
values.push(value);
|
|
90
|
+
current = next;
|
|
150
91
|
}
|
|
151
92
|
}
|
|
152
|
-
|
|
153
93
|
function parsePositiveInt(value, fallback = 0) {
|
|
154
|
-
const parsed = Number.parseInt(String(value || ""), 10)
|
|
155
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
94
|
+
const parsed = Number.parseInt(String(value || ""), 10);
|
|
95
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
156
96
|
}
|
|
157
|
-
|
|
158
97
|
function parseNonNegativeInt(value, fallback = 0) {
|
|
159
|
-
const parsed = Number.parseInt(String(value || ""), 10)
|
|
160
|
-
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
98
|
+
const parsed = Number.parseInt(String(value || ""), 10);
|
|
99
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
161
100
|
}
|
|
162
|
-
|
|
163
101
|
function parseCompactEvery(value) {
|
|
164
|
-
const duration = parseDuration(value)
|
|
165
|
-
if (duration !== null)
|
|
166
|
-
|
|
167
|
-
|
|
102
|
+
const duration = parseDuration(value);
|
|
103
|
+
if (duration !== null)
|
|
104
|
+
return { compactEveryMs: duration };
|
|
105
|
+
const runs = parsePositiveInt(value, 0);
|
|
106
|
+
return runs > 0 ? { compactEveryRuns: runs } : {};
|
|
168
107
|
}
|
|
169
|
-
|
|
170
108
|
function parseLoopArgs(raw, defaults = {}) {
|
|
171
|
-
let input = stripOuterQuotes(String(raw || "").trim())
|
|
172
|
-
let first = ""
|
|
173
|
-
let rest = input
|
|
174
|
-
let intervalMs = defaults.intervalMs ?? null
|
|
175
|
-
|
|
109
|
+
let input = stripOuterQuotes(String(raw || "").trim());
|
|
110
|
+
let first = "";
|
|
111
|
+
let rest = input;
|
|
112
|
+
let intervalMs = defaults.intervalMs ?? null;
|
|
176
113
|
if (!input && defaults.action) {
|
|
177
|
-
rest = defaults.action
|
|
114
|
+
rest = defaults.action;
|
|
178
115
|
} else {
|
|
179
|
-
|
|
116
|
+
[first, rest] = splitFirst(input);
|
|
180
117
|
if (first === "--watch") {
|
|
181
|
-
intervalMs = defaults.intervalMs ?? 0
|
|
182
|
-
rest = input
|
|
118
|
+
intervalMs = defaults.intervalMs ?? 0;
|
|
119
|
+
rest = input;
|
|
183
120
|
} else if (first) {
|
|
184
|
-
const parsedDuration = parseDuration(first)
|
|
185
|
-
if (parsedDuration !== null)
|
|
186
|
-
|
|
187
|
-
else
|
|
121
|
+
const parsedDuration = parseDuration(first);
|
|
122
|
+
if (parsedDuration !== null)
|
|
123
|
+
intervalMs = parsedDuration;
|
|
124
|
+
else if (intervalMs === null)
|
|
125
|
+
return { ok: false, error: "Usage: /loop 0s <prompt> | /loop 5m <prompt> | /loop-goal <objective> | /loop-command 200m /compact | /loop-shell 10m npm test | /loop --watch progress.md <prompt>" };
|
|
126
|
+
else
|
|
127
|
+
rest = input;
|
|
188
128
|
}
|
|
189
129
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
130
|
+
if (intervalMs === null)
|
|
131
|
+
intervalMs = 0;
|
|
193
132
|
const job = {
|
|
194
133
|
id: `${now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`,
|
|
195
134
|
name: defaults.name,
|
|
@@ -250,849 +189,1130 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
250
189
|
watchTriggered: false,
|
|
251
190
|
createdAt: new Date().toISOString(),
|
|
252
191
|
enabled: true,
|
|
253
|
-
paused: false
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
let
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
rest =
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
rest =
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
192
|
+
paused: false
|
|
193
|
+
};
|
|
194
|
+
let found;
|
|
195
|
+
let value;
|
|
196
|
+
[found, rest] = takeFlag(rest, "--no-now");
|
|
197
|
+
if (found)
|
|
198
|
+
job.immediate = false;
|
|
199
|
+
[found, rest] = takeFlag(rest, "--now");
|
|
200
|
+
if (found)
|
|
201
|
+
job.immediate = true;
|
|
202
|
+
[found, rest] = takeFlag(rest, "--no-overlap");
|
|
203
|
+
if (found)
|
|
204
|
+
job.noOverlap = true;
|
|
205
|
+
[found, rest] = takeFlag(rest, "--allow-overlap");
|
|
206
|
+
if (found)
|
|
207
|
+
job.noOverlap = false;
|
|
208
|
+
[found, rest] = takeFlag(rest, "--safe");
|
|
209
|
+
if (found)
|
|
210
|
+
job.safe = true;
|
|
211
|
+
[found, rest] = takeFlag(rest, "--quiet");
|
|
212
|
+
if (found)
|
|
213
|
+
job.quiet = true;
|
|
214
|
+
[found, rest] = takeFlag(rest, "--ask-never");
|
|
215
|
+
if (found)
|
|
216
|
+
job.askNever = true;
|
|
217
|
+
[found, rest] = takeFlag(rest, "--git-checkpoint");
|
|
218
|
+
if (found)
|
|
219
|
+
job.gitCheckpoint = true;
|
|
220
|
+
[found, rest] = takeFlag(rest, "--checkpoint-only");
|
|
221
|
+
if (found)
|
|
222
|
+
job.checkpointOnly = true;
|
|
223
|
+
[found, rest] = takeFlag(rest, "--pause-on-verify-fail");
|
|
224
|
+
if (found)
|
|
225
|
+
job.pauseOnVerifyFail = true;
|
|
226
|
+
[found, rest] = takeFlag(rest, "--dry-run");
|
|
227
|
+
if (found)
|
|
228
|
+
job.dryRun = true;
|
|
229
|
+
[found, rest] = takeFlag(rest, "--multi");
|
|
230
|
+
if (found)
|
|
231
|
+
job.multi = true;
|
|
232
|
+
[found, rest] = takeFlag(rest, "--replace");
|
|
233
|
+
if (found)
|
|
234
|
+
job.multi = false;
|
|
235
|
+
[found, rest] = takeFlag(rest, "--prompt");
|
|
236
|
+
if (found)
|
|
237
|
+
job.kind = "prompt";
|
|
238
|
+
[found, rest] = takeFlag(rest, "--ask");
|
|
239
|
+
if (found)
|
|
240
|
+
job.kind = "prompt";
|
|
241
|
+
[found, rest] = takeFlag(rest, "--command");
|
|
242
|
+
if (found)
|
|
243
|
+
job.kind = "command";
|
|
244
|
+
[found, rest] = takeFlag(rest, "--cmd");
|
|
245
|
+
if (found)
|
|
246
|
+
job.kind = "command";
|
|
247
|
+
[found, rest] = takeFlag(rest, "--slash");
|
|
248
|
+
if (found)
|
|
249
|
+
job.kind = "command";
|
|
250
|
+
[found, rest] = takeFlag(rest, "--shell");
|
|
251
|
+
if (found)
|
|
252
|
+
job.kind = "shell";
|
|
253
|
+
[found, rest] = takeFlag(rest, "--compact");
|
|
254
|
+
if (found)
|
|
255
|
+
job.kind = "compact";
|
|
256
|
+
[found, rest] = takeFlag(rest, "--goal");
|
|
257
|
+
if (found)
|
|
258
|
+
job.kind = "goal";
|
|
259
|
+
[found, rest] = takeFlag(rest, "--complete-when-checks-pass");
|
|
260
|
+
if (found)
|
|
261
|
+
job.goalCompleteWhenChecksPass = true;
|
|
262
|
+
[found, rest] = takeFlag(rest, "--no-complete-when-checks-pass");
|
|
263
|
+
if (found)
|
|
264
|
+
job.goalCompleteWhenChecksPass = false;
|
|
265
|
+
[found, rest] = takeFlag(rest, "--require-evidence");
|
|
266
|
+
if (found)
|
|
267
|
+
job.goalRequireEvidence = true;
|
|
268
|
+
[found, rest] = takeFlag(rest, "--allow-weak-evidence");
|
|
269
|
+
if (found)
|
|
270
|
+
job.goalRequireEvidence = false;
|
|
271
|
+
[found, rest] = takeFlag(rest, "--require-checks-pass");
|
|
272
|
+
if (found)
|
|
273
|
+
job.goalRequireChecksPass = true;
|
|
274
|
+
[found, rest] = takeFlag(rest, "--allow-complete-without-checks");
|
|
275
|
+
if (found)
|
|
276
|
+
job.goalRequireChecksPass = false;
|
|
277
|
+
[found, rest] = takeFlag(rest, "--allow-complete-with-failing-checks");
|
|
278
|
+
if (found)
|
|
279
|
+
job.goalRequireChecksPass = false;
|
|
280
|
+
[value, rest] = takeFlagValue(rest, "--name");
|
|
281
|
+
if (value !== undefined)
|
|
282
|
+
job.name = value.trim();
|
|
283
|
+
[value, rest] = takeFlagValue(rest, "--max-runs");
|
|
284
|
+
if (value !== undefined)
|
|
285
|
+
job.maxRuns = parsePositiveInt(value, 0);
|
|
286
|
+
[value, rest] = takeFlagValue(rest, "--max-turns");
|
|
287
|
+
if (value !== undefined)
|
|
288
|
+
job.maxRuns = parsePositiveInt(value, 0);
|
|
289
|
+
[value, rest] = takeFlagValue(rest, "--max-no-progress");
|
|
290
|
+
if (value !== undefined)
|
|
291
|
+
job.maxNoProgress = parseNonNegativeInt(value, DEFAULT_GOAL_MAX_NO_PROGRESS);
|
|
292
|
+
[value, rest] = takeFlagValue(rest, "--timeout");
|
|
293
|
+
if (value !== undefined)
|
|
294
|
+
job.timeoutMs = parseDuration(value) ?? 0;
|
|
295
|
+
[value, rest] = takeFlagValue(rest, "--max-runtime");
|
|
296
|
+
if (value !== undefined)
|
|
297
|
+
job.maxRuntimeMs = parseDuration(value) ?? 0;
|
|
298
|
+
[value, rest] = takeFlagValue(rest, "--max-failures");
|
|
299
|
+
if (value !== undefined)
|
|
300
|
+
job.maxFailures = parsePositiveInt(value, 0);
|
|
301
|
+
[value, rest] = takeFlagValue(rest, "--until");
|
|
302
|
+
if (value !== undefined)
|
|
303
|
+
job.until = stripOuterQuotes(value);
|
|
304
|
+
[value, rest] = takeFlagValue(rest, "--stop-file");
|
|
305
|
+
if (value !== undefined)
|
|
306
|
+
job.stopFile = stripOuterQuotes(value);
|
|
307
|
+
[value, rest] = takeFlagValue(rest, "--progress-file");
|
|
308
|
+
if (value !== undefined)
|
|
309
|
+
job.progressFile = stripOuterQuotes(value);
|
|
310
|
+
[value, rest] = takeFlagValue(rest, "--prompt-file");
|
|
311
|
+
if (value !== undefined)
|
|
312
|
+
job.promptFile = stripOuterQuotes(value);
|
|
313
|
+
[value, rest] = takeFlagValue(rest, "--goal-file");
|
|
314
|
+
if (value !== undefined)
|
|
315
|
+
job.goalFile = stripOuterQuotes(value);
|
|
316
|
+
[value, rest] = takeFlagValue(rest, "--evidence-file");
|
|
317
|
+
if (value !== undefined)
|
|
318
|
+
job.goalEvidenceFile = stripOuterQuotes(value);
|
|
319
|
+
[value, rest] = takeFlagValue(rest, "--test");
|
|
320
|
+
if (value !== undefined)
|
|
321
|
+
job.testCommand = stripOuterQuotes(value);
|
|
322
|
+
[value, rest] = takeFlagValue(rest, "--verify");
|
|
323
|
+
if (value !== undefined)
|
|
324
|
+
job.verifyCommand = stripOuterQuotes(value);
|
|
325
|
+
[value, rest] = takeFlagValue(rest, "--preflight");
|
|
326
|
+
if (value !== undefined)
|
|
327
|
+
job.preflightCommand = stripOuterQuotes(value);
|
|
328
|
+
[value, rest] = takeFlagValue(rest, "--postrun");
|
|
329
|
+
if (value !== undefined)
|
|
330
|
+
job.postrunCommand = stripOuterQuotes(value);
|
|
331
|
+
[value, rest] = takeFlagValue(rest, "--notify");
|
|
332
|
+
if (value !== undefined)
|
|
333
|
+
job.notifyCommand = stripOuterQuotes(value);
|
|
334
|
+
[value, rest] = takeFlagValue(rest, "--branch");
|
|
335
|
+
if (value !== undefined)
|
|
336
|
+
job.branch = stripOuterQuotes(value);
|
|
337
|
+
[value, rest] = takeFlagValue(rest, "--batch");
|
|
338
|
+
if (value !== undefined)
|
|
339
|
+
job.batch = parsePositiveInt(value, 0);
|
|
340
|
+
[value, rest] = takeFlagValue(rest, "--compact-every");
|
|
341
|
+
if (value !== undefined)
|
|
342
|
+
Object.assign(job, parseCompactEvery(value));
|
|
343
|
+
const watch = takeAllFlagValues(rest, "--watch");
|
|
344
|
+
job.watchPaths.push(...watch[0].map(stripOuterQuotes).filter(Boolean));
|
|
345
|
+
rest = watch[1];
|
|
346
|
+
const includes = takeAllFlagValues(rest, "--include-file");
|
|
347
|
+
job.includeFiles.push(...includes[0].map(stripOuterQuotes).filter(Boolean));
|
|
348
|
+
rest = includes[1];
|
|
349
|
+
const acceptances = takeAllFlagValues(rest, "--acceptance");
|
|
350
|
+
job.goalAcceptance.push(...acceptances[0].map(stripOuterQuotes).filter(Boolean));
|
|
351
|
+
rest = acceptances[1];
|
|
352
|
+
const success = takeAllFlagValues(rest, "--success");
|
|
353
|
+
job.goalAcceptance.push(...success[0].map(stripOuterQuotes).filter(Boolean));
|
|
354
|
+
rest = success[1];
|
|
355
|
+
const checks = takeAllFlagValues(rest, "--check");
|
|
356
|
+
job.goalChecks.push(...checks[0].map(stripOuterQuotes).filter(Boolean));
|
|
357
|
+
rest = checks[1];
|
|
358
|
+
job.action = stripOuterQuotes(rest || job.action || "");
|
|
359
|
+
job.watchPaths = [...new Set(job.watchPaths)];
|
|
360
|
+
job.includeFiles = [...new Set(job.includeFiles)];
|
|
361
|
+
job.goalAcceptance = [...new Set(job.goalAcceptance || [])];
|
|
362
|
+
job.goalChecks = [...new Set(job.goalChecks || [])];
|
|
336
363
|
if (String(job.kind || "").toLowerCase() === "goal") {
|
|
337
|
-
job.name = job.name || "goal"
|
|
338
|
-
job.goalStatus = job.goalStatus || "active"
|
|
339
|
-
job.safe = job.safe !== false
|
|
340
|
-
job.askNever = job.askNever !== false
|
|
341
|
-
job.noOverlap = job.noOverlap !== false
|
|
342
|
-
job.goalRequireEvidence = job.goalRequireEvidence !== false
|
|
343
|
-
job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0
|
|
344
|
-
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS
|
|
345
|
-
}
|
|
346
|
-
job.lastRunAt = job.immediate ? 0 : now()
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return { ok: true, job }
|
|
364
|
+
job.name = job.name || "goal";
|
|
365
|
+
job.goalStatus = job.goalStatus || "active";
|
|
366
|
+
job.safe = job.safe !== false;
|
|
367
|
+
job.askNever = job.askNever !== false;
|
|
368
|
+
job.noOverlap = job.noOverlap !== false;
|
|
369
|
+
job.goalRequireEvidence = job.goalRequireEvidence !== false;
|
|
370
|
+
job.goalRequireChecksPass = job.goalRequireChecksPass ?? job.goalChecks.length > 0;
|
|
371
|
+
job.maxNoProgress = job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS;
|
|
372
|
+
}
|
|
373
|
+
job.lastRunAt = job.immediate ? 0 : now();
|
|
374
|
+
if (!job.action && !job.promptFile && !job.goalFile)
|
|
375
|
+
return { ok: false, error: "Missing action. Example: /loop 0s continue from progress.md, /loop-goal ship the feature, or /loop 0s --prompt-file loop-prompt.md" };
|
|
376
|
+
return { ok: true, job };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/source/core/jobs.js
|
|
380
|
+
function presetDefaults(name) {
|
|
381
|
+
if (name === "loop-compact")
|
|
382
|
+
return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false };
|
|
383
|
+
if (name === "loop-command" || name === "loop-cmd")
|
|
384
|
+
return { intervalMs: 0, kind: "command", name: "command", immediate: false };
|
|
385
|
+
if (name === "loop-prompt")
|
|
386
|
+
return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true };
|
|
387
|
+
if (name === "loop-ask")
|
|
388
|
+
return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false };
|
|
389
|
+
if (name === "loop-shell")
|
|
390
|
+
return { intervalMs: 0, kind: "shell", name: "shell", immediate: false };
|
|
391
|
+
if (name === "loop-testfix")
|
|
392
|
+
return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" };
|
|
393
|
+
if (name === "loop-progress")
|
|
394
|
+
return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." };
|
|
395
|
+
if (name === "loop-safe-dev")
|
|
396
|
+
return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." };
|
|
397
|
+
return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." };
|
|
398
|
+
}
|
|
399
|
+
function jobLabel(job) {
|
|
400
|
+
const title = job.name ? `${job.name}: ` : "";
|
|
401
|
+
const kind = job.kind ? ` [${job.kind}]` : "";
|
|
402
|
+
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : "";
|
|
403
|
+
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : "";
|
|
404
|
+
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : "";
|
|
405
|
+
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : "";
|
|
406
|
+
const verify = job.verifyCommand ? ", verify" : "";
|
|
407
|
+
const preflight = job.preflightCommand ? ", preflight" : "";
|
|
408
|
+
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : "";
|
|
409
|
+
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : "";
|
|
410
|
+
const stopFile = job.stopFile ? ", stop-file" : "";
|
|
411
|
+
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : "";
|
|
412
|
+
const paused = job.paused ? ", paused" : "";
|
|
413
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`;
|
|
414
|
+
}
|
|
415
|
+
function matchJob(job, target, index) {
|
|
416
|
+
const text = String(target || "").trim();
|
|
417
|
+
if (!text || text.toLowerCase() === "all")
|
|
418
|
+
return true;
|
|
419
|
+
return job.id === text || job.name === text || String(index + 1) === text;
|
|
420
|
+
}
|
|
421
|
+
function actionKind(action, job = {}) {
|
|
422
|
+
const text = String(action || "").trim();
|
|
423
|
+
const forced = String(job.kind || "").trim().toLowerCase();
|
|
424
|
+
if (forced === "compact")
|
|
425
|
+
return "compact";
|
|
426
|
+
if (forced === "goal")
|
|
427
|
+
return "goal";
|
|
428
|
+
if (text === "/compact" || text === "/summarize")
|
|
429
|
+
return "compact";
|
|
430
|
+
if (forced === "prompt" || forced === "ask")
|
|
431
|
+
return "prompt";
|
|
432
|
+
if (forced === "command" || forced === "cmd" || forced === "slash")
|
|
433
|
+
return "command";
|
|
434
|
+
if (forced === "shell")
|
|
435
|
+
return "shell";
|
|
436
|
+
if (text.startsWith("/"))
|
|
437
|
+
return "command";
|
|
438
|
+
if (text.startsWith("!") || text.startsWith("$"))
|
|
439
|
+
return "shell";
|
|
440
|
+
return "prompt";
|
|
441
|
+
}
|
|
442
|
+
function decoratePrompt(job) {
|
|
443
|
+
const additions = [];
|
|
444
|
+
if (job.progressFile)
|
|
445
|
+
additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
|
|
446
|
+
if (job.lastVerifyFailure)
|
|
447
|
+
additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
|
|
448
|
+
if (job.askNever)
|
|
449
|
+
additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.");
|
|
450
|
+
if (job.safe)
|
|
451
|
+
additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.");
|
|
452
|
+
if (job.batch > 0)
|
|
453
|
+
additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`);
|
|
454
|
+
if (job.quiet)
|
|
455
|
+
additions.push("Keep replies short. Summarize only what changed, tests run, and next step.");
|
|
456
|
+
if (job.testCommand)
|
|
457
|
+
additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`);
|
|
458
|
+
if (job.checkpointOnly || job.gitCheckpoint)
|
|
459
|
+
additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.");
|
|
460
|
+
if (!additions.length)
|
|
461
|
+
return job.action;
|
|
462
|
+
return `${job.action}
|
|
463
|
+
|
|
464
|
+
OpenCode loop instructions:
|
|
465
|
+
- ${additions.join(`
|
|
466
|
+
- `)}`;
|
|
467
|
+
}
|
|
468
|
+
function isGoalJob(job) {
|
|
469
|
+
return String(job?.kind || "").toLowerCase() === "goal";
|
|
470
|
+
}
|
|
471
|
+
function goalStatusText(job) {
|
|
472
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "");
|
|
473
|
+
if (!status)
|
|
474
|
+
return "";
|
|
475
|
+
if (status === "completed")
|
|
476
|
+
return "completed";
|
|
477
|
+
if (status === "blocked")
|
|
478
|
+
return "blocked";
|
|
479
|
+
if (job?.paused)
|
|
480
|
+
return "paused";
|
|
481
|
+
return status;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/source/core/state.js
|
|
485
|
+
import { promises as fs } from "fs";
|
|
486
|
+
import os from "os";
|
|
487
|
+
import path from "path";
|
|
488
|
+
var STATE_DIR = ".opencode/opencode-loop";
|
|
489
|
+
var STATE_BASELINE = Symbol("opencode-loop-state-baseline");
|
|
490
|
+
var stateWriteLocks = new Map;
|
|
491
|
+
function stateDir(directory) {
|
|
492
|
+
return path.join(directory, STATE_DIR);
|
|
493
|
+
}
|
|
494
|
+
function statePath(directory, sessionID) {
|
|
495
|
+
return path.join(stateDir(directory), `${safeID(sessionID)}.json`);
|
|
496
|
+
}
|
|
497
|
+
async function ensureDir(directory) {
|
|
498
|
+
await fs.mkdir(directory, { recursive: true });
|
|
499
|
+
}
|
|
500
|
+
async function pathExists(filePath) {
|
|
501
|
+
try {
|
|
502
|
+
await fs.access(filePath);
|
|
503
|
+
return true;
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
350
507
|
}
|
|
351
|
-
|
|
352
|
-
function stateDir(directory) { return path.join(directory, STATE_DIR) }
|
|
353
|
-
function statePath(directory, sessionID) { return path.join(stateDir(directory), `${safeID(sessionID)}.json`) }
|
|
354
|
-
async function ensureDir(directory) { await fs.mkdir(directory, { recursive: true }) }
|
|
355
|
-
async function pathExists(filePath) { try { await fs.access(filePath); return true } catch { return false } }
|
|
356
|
-
|
|
357
508
|
function stateLockKey(directory, sessionID) {
|
|
358
|
-
return `${path.resolve(directory)}:${safeID(sessionID)}
|
|
509
|
+
return `${path.resolve(directory)}:${safeID(sessionID)}`;
|
|
359
510
|
}
|
|
360
|
-
|
|
361
511
|
async function withStateWriteLock(directory, sessionID, fn) {
|
|
362
|
-
const key = stateLockKey(directory, sessionID)
|
|
363
|
-
const previous = stateWriteLocks.get(key) || Promise.resolve()
|
|
364
|
-
let release
|
|
365
|
-
const current = new Promise((resolve) => {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
512
|
+
const key = stateLockKey(directory, sessionID);
|
|
513
|
+
const previous = stateWriteLocks.get(key) || Promise.resolve();
|
|
514
|
+
let release;
|
|
515
|
+
const current = new Promise((resolve) => {
|
|
516
|
+
release = resolve;
|
|
517
|
+
});
|
|
518
|
+
const next = previous.catch(() => {}).then(() => current);
|
|
519
|
+
stateWriteLocks.set(key, next);
|
|
520
|
+
await previous.catch(() => {});
|
|
369
521
|
try {
|
|
370
|
-
return await fn()
|
|
522
|
+
return await fn();
|
|
371
523
|
} finally {
|
|
372
|
-
release()
|
|
373
|
-
if (stateWriteLocks.get(key) === next)
|
|
524
|
+
release();
|
|
525
|
+
if (stateWriteLocks.get(key) === next)
|
|
526
|
+
stateWriteLocks.delete(key);
|
|
374
527
|
}
|
|
375
528
|
}
|
|
376
|
-
|
|
377
529
|
async function readStateFile(directory, sessionID) {
|
|
378
|
-
const target = statePath(directory, sessionID)
|
|
379
|
-
const attempts = 5
|
|
380
|
-
for (let attempt = 0;
|
|
530
|
+
const target = statePath(directory, sessionID);
|
|
531
|
+
const attempts = 5;
|
|
532
|
+
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
381
533
|
try {
|
|
382
|
-
const parsed = JSON.parse(await fs.readFile(target, "utf8"))
|
|
383
|
-
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] }
|
|
534
|
+
const parsed = JSON.parse(await fs.readFile(target, "utf8"));
|
|
535
|
+
return { version: 4, jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [] };
|
|
384
536
|
} catch (error) {
|
|
385
|
-
if (error?.code === "ENOENT")
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
537
|
+
if (error?.code === "ENOENT")
|
|
538
|
+
return { version: 4, jobs: [] };
|
|
539
|
+
const transient = error instanceof SyntaxError || isRetriableStateWriteError(error);
|
|
540
|
+
if (!transient || attempt === attempts - 1)
|
|
541
|
+
break;
|
|
542
|
+
await delay(25 * (attempt + 1));
|
|
389
543
|
}
|
|
390
544
|
}
|
|
391
545
|
try {
|
|
392
|
-
await ensureDir(stateDir(directory))
|
|
393
|
-
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`)
|
|
546
|
+
await ensureDir(stateDir(directory));
|
|
547
|
+
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
|
|
394
548
|
} catch {}
|
|
395
|
-
return { version: 4, jobs: [] }
|
|
549
|
+
return { version: 4, jobs: [] };
|
|
396
550
|
}
|
|
397
|
-
|
|
398
551
|
async function readState(directory, sessionID) {
|
|
399
|
-
const state = await readStateFile(directory, sessionID)
|
|
552
|
+
const state = await readStateFile(directory, sessionID);
|
|
400
553
|
Object.defineProperty(state, STATE_BASELINE, {
|
|
401
554
|
value: structuredClone(state.jobs || []),
|
|
402
555
|
enumerable: false,
|
|
403
556
|
configurable: false,
|
|
404
|
-
writable: true
|
|
405
|
-
})
|
|
406
|
-
return state
|
|
557
|
+
writable: true
|
|
558
|
+
});
|
|
559
|
+
return state;
|
|
407
560
|
}
|
|
408
|
-
|
|
409
561
|
function isRetriableStateWriteError(error) {
|
|
410
|
-
const code = error?.code
|
|
411
|
-
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN"
|
|
562
|
+
const code = error?.code;
|
|
563
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY" || code === "EEXIST" || code === "EAGAIN";
|
|
412
564
|
}
|
|
413
|
-
|
|
414
565
|
async function delay(ms) {
|
|
415
|
-
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
566
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
416
567
|
}
|
|
417
|
-
|
|
418
|
-
// Windows often fails POSIX-style "write temp next to target, then rename over it":
|
|
419
|
-
// existing destinations can return EPERM/EEXIST while antivirus, IDE indexers, or
|
|
420
|
-
// OpenCode's own snapshotter briefly hold the state file. Temp files next to the
|
|
421
|
-
// target also show up in project git snapshots as *.tmp pathspec noise.
|
|
422
|
-
//
|
|
423
|
-
// Write the payload outside the project first, then replace the target with
|
|
424
|
-
// rename when possible and a copy/unlink fallback with short retries.
|
|
425
568
|
async function writeFileAtomically(target, contents, options = {}) {
|
|
426
|
-
const encoding = options.encoding || "utf8"
|
|
427
|
-
const attempts = Math.max(1, Number(options.attempts) || 5)
|
|
428
|
-
const temp = path.join(
|
|
429
|
-
|
|
430
|
-
`opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`,
|
|
431
|
-
)
|
|
432
|
-
await fs.writeFile(temp, contents, encoding)
|
|
569
|
+
const encoding = options.encoding || "utf8";
|
|
570
|
+
const attempts = Math.max(1, Number(options.attempts) || 5);
|
|
571
|
+
const temp = path.join(os.tmpdir(), `opencode-loop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
572
|
+
await fs.writeFile(temp, contents, encoding);
|
|
433
573
|
try {
|
|
434
|
-
let lastError
|
|
435
|
-
|
|
436
|
-
// before falling back to a non-atomic copy/overwrite.
|
|
437
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
574
|
+
let lastError;
|
|
575
|
+
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
438
576
|
try {
|
|
439
|
-
await fs.rename(temp, target)
|
|
440
|
-
return
|
|
577
|
+
await fs.rename(temp, target);
|
|
578
|
+
return;
|
|
441
579
|
} catch (error) {
|
|
442
|
-
lastError = error
|
|
443
|
-
if (error?.code === "EXDEV")
|
|
444
|
-
|
|
445
|
-
if (
|
|
580
|
+
lastError = error;
|
|
581
|
+
if (error?.code === "EXDEV")
|
|
582
|
+
break;
|
|
583
|
+
if (!isRetriableStateWriteError(error))
|
|
584
|
+
throw error;
|
|
585
|
+
if (attempt < attempts - 1)
|
|
586
|
+
await delay(25 * (attempt + 1));
|
|
446
587
|
}
|
|
447
588
|
}
|
|
448
|
-
|
|
449
|
-
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
589
|
+
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
450
590
|
try {
|
|
451
|
-
await fs.copyFile(temp, target)
|
|
452
|
-
return
|
|
591
|
+
await fs.copyFile(temp, target);
|
|
592
|
+
return;
|
|
453
593
|
} catch (error) {
|
|
454
|
-
lastError = error
|
|
455
|
-
if (!isRetriableStateWriteError(error))
|
|
456
|
-
|
|
594
|
+
lastError = error;
|
|
595
|
+
if (!isRetriableStateWriteError(error))
|
|
596
|
+
throw error;
|
|
597
|
+
if (attempt < attempts - 1)
|
|
598
|
+
await delay(25 * (attempt + 1));
|
|
457
599
|
}
|
|
458
600
|
}
|
|
459
|
-
|
|
460
601
|
try {
|
|
461
|
-
await fs.writeFile(target, contents, encoding)
|
|
462
|
-
return
|
|
602
|
+
await fs.writeFile(target, contents, encoding);
|
|
603
|
+
return;
|
|
463
604
|
} catch (error) {
|
|
464
|
-
if (lastError && !error.cause)
|
|
465
|
-
|
|
605
|
+
if (lastError && !error.cause)
|
|
606
|
+
error.cause = lastError;
|
|
607
|
+
throw error;
|
|
466
608
|
}
|
|
467
609
|
} finally {
|
|
468
|
-
try {
|
|
610
|
+
try {
|
|
611
|
+
await fs.rm(temp, { force: true });
|
|
612
|
+
} catch {}
|
|
469
613
|
}
|
|
470
614
|
}
|
|
471
|
-
|
|
472
615
|
function stateValuesEqual(left, right) {
|
|
473
|
-
if (Object.is(left, right))
|
|
474
|
-
|
|
616
|
+
if (Object.is(left, right))
|
|
617
|
+
return true;
|
|
618
|
+
try {
|
|
619
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
475
623
|
}
|
|
476
|
-
|
|
477
624
|
function mergeStateJob(baseJob, intendedJob, currentJob) {
|
|
478
|
-
const merged = structuredClone(currentJob || {})
|
|
625
|
+
const merged = structuredClone(currentJob || {});
|
|
479
626
|
const keys = new Set([
|
|
480
627
|
...Object.keys(baseJob || {}),
|
|
481
|
-
...Object.keys(intendedJob || {})
|
|
482
|
-
])
|
|
628
|
+
...Object.keys(intendedJob || {})
|
|
629
|
+
]);
|
|
483
630
|
for (const key of keys) {
|
|
484
|
-
const baseHas = Object.prototype.hasOwnProperty.call(baseJob || {}, key)
|
|
485
|
-
const intendedHas = Object.prototype.hasOwnProperty.call(intendedJob || {}, key)
|
|
486
|
-
const currentHas = Object.prototype.hasOwnProperty.call(currentJob || {}, key)
|
|
487
|
-
const intendedChanged = baseHas !== intendedHas || !stateValuesEqual(baseJob?.[key], intendedJob?.[key])
|
|
488
|
-
if (!intendedChanged)
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (intendedHas)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
631
|
+
const baseHas = Object.prototype.hasOwnProperty.call(baseJob || {}, key);
|
|
632
|
+
const intendedHas = Object.prototype.hasOwnProperty.call(intendedJob || {}, key);
|
|
633
|
+
const currentHas = Object.prototype.hasOwnProperty.call(currentJob || {}, key);
|
|
634
|
+
const intendedChanged = baseHas !== intendedHas || !stateValuesEqual(baseJob?.[key], intendedJob?.[key]);
|
|
635
|
+
if (!intendedChanged)
|
|
636
|
+
continue;
|
|
637
|
+
const currentChanged = baseHas !== currentHas || !stateValuesEqual(baseJob?.[key], currentJob?.[key]);
|
|
638
|
+
const sameResult = intendedHas === currentHas && stateValuesEqual(intendedJob?.[key], currentJob?.[key]);
|
|
639
|
+
if (currentChanged && !sameResult)
|
|
640
|
+
continue;
|
|
641
|
+
if (intendedHas)
|
|
642
|
+
merged[key] = structuredClone(intendedJob[key]);
|
|
643
|
+
else
|
|
644
|
+
delete merged[key];
|
|
645
|
+
}
|
|
646
|
+
return merged;
|
|
498
647
|
}
|
|
499
|
-
|
|
500
648
|
function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
501
|
-
const byID = (jobs) => new Map((jobs || []).filter((job) => job?.id).map((job) => [job.id, job]))
|
|
502
|
-
const base = byID(baseJobs)
|
|
503
|
-
const intended = byID(intendedJobs)
|
|
504
|
-
const current = byID(currentJobs)
|
|
505
|
-
const merged = []
|
|
506
|
-
|
|
649
|
+
const byID = (jobs) => new Map((jobs || []).filter((job) => job?.id).map((job) => [job.id, job]));
|
|
650
|
+
const base = byID(baseJobs);
|
|
651
|
+
const intended = byID(intendedJobs);
|
|
652
|
+
const current = byID(currentJobs);
|
|
653
|
+
const merged = [];
|
|
507
654
|
for (const currentJob of currentJobs || []) {
|
|
508
|
-
const id = currentJob?.id
|
|
655
|
+
const id = currentJob?.id;
|
|
509
656
|
if (!id || !base.has(id)) {
|
|
510
|
-
merged.push(structuredClone(currentJob))
|
|
511
|
-
continue
|
|
657
|
+
merged.push(structuredClone(currentJob));
|
|
658
|
+
continue;
|
|
512
659
|
}
|
|
513
|
-
const baseJob = base.get(id)
|
|
514
|
-
const intendedJob = intended.get(id)
|
|
660
|
+
const baseJob = base.get(id);
|
|
661
|
+
const intendedJob = intended.get(id);
|
|
515
662
|
if (!intendedJob) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
continue
|
|
663
|
+
if (!stateValuesEqual(baseJob, currentJob))
|
|
664
|
+
merged.push(structuredClone(currentJob));
|
|
665
|
+
continue;
|
|
520
666
|
}
|
|
521
|
-
merged.push(mergeStateJob(baseJob, intendedJob, currentJob))
|
|
667
|
+
merged.push(mergeStateJob(baseJob, intendedJob, currentJob));
|
|
522
668
|
}
|
|
523
|
-
|
|
524
669
|
for (const intendedJob of intendedJobs || []) {
|
|
525
|
-
const id = intendedJob?.id
|
|
526
|
-
if (!id || base.has(id) || current.has(id))
|
|
527
|
-
|
|
670
|
+
const id = intendedJob?.id;
|
|
671
|
+
if (!id || base.has(id) || current.has(id))
|
|
672
|
+
continue;
|
|
673
|
+
merged.push(structuredClone(intendedJob));
|
|
528
674
|
}
|
|
529
|
-
return merged
|
|
675
|
+
return merged;
|
|
530
676
|
}
|
|
531
|
-
|
|
532
677
|
async function writeState(directory, sessionID, state) {
|
|
533
678
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
534
|
-
await ensureDir(stateDir(directory))
|
|
535
|
-
const target = statePath(directory, sessionID)
|
|
536
|
-
const baseline = state?.[STATE_BASELINE]
|
|
537
|
-
let jobs = structuredClone(state.jobs || [])
|
|
679
|
+
await ensureDir(stateDir(directory));
|
|
680
|
+
const target = statePath(directory, sessionID);
|
|
681
|
+
const baseline = state?.[STATE_BASELINE];
|
|
682
|
+
let jobs = structuredClone(state.jobs || []);
|
|
538
683
|
if (Array.isArray(baseline)) {
|
|
539
|
-
const current = await readStateFile(directory, sessionID)
|
|
540
|
-
jobs = mergeStateJobs(baseline, jobs, current.jobs || [])
|
|
541
|
-
state.jobs = structuredClone(jobs)
|
|
542
|
-
state[STATE_BASELINE] = structuredClone(jobs)
|
|
684
|
+
const current = await readStateFile(directory, sessionID);
|
|
685
|
+
jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
|
|
686
|
+
state.jobs = structuredClone(jobs);
|
|
687
|
+
state[STATE_BASELINE] = structuredClone(jobs);
|
|
543
688
|
}
|
|
544
|
-
const payload = JSON.stringify({ version: 4, jobs }, null, 2)
|
|
545
|
-
await writeFileAtomically(target, payload)
|
|
546
|
-
})
|
|
689
|
+
const payload = JSON.stringify({ version: 4, jobs }, null, 2);
|
|
690
|
+
await writeFileAtomically(target, payload);
|
|
691
|
+
});
|
|
547
692
|
}
|
|
548
|
-
|
|
549
693
|
async function removeState(directory, sessionID) {
|
|
550
694
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
551
|
-
try {
|
|
552
|
-
|
|
695
|
+
try {
|
|
696
|
+
await fs.unlink(statePath(directory, sessionID));
|
|
697
|
+
} catch {}
|
|
698
|
+
});
|
|
553
699
|
}
|
|
554
700
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
701
|
+
// src/source/core/process.js
|
|
702
|
+
import { promises as fs2 } from "fs";
|
|
703
|
+
import path2 from "path";
|
|
704
|
+
import { spawn } from "child_process";
|
|
705
|
+
async function appendLoopLog(directory, line, extra = {}) {
|
|
706
|
+
try {
|
|
707
|
+
await ensureDir(stateDir(directory));
|
|
708
|
+
await fs2.appendFile(path2.join(stateDir(directory), "loop.log"), JSON.stringify({ time: new Date().toISOString(), line, ...extra }) + `
|
|
709
|
+
`);
|
|
710
|
+
} catch {}
|
|
558
711
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
712
|
+
async function readSmallTextFile(filePath, maxBytes = 120000) {
|
|
713
|
+
try {
|
|
714
|
+
const stat = await fs2.stat(filePath);
|
|
715
|
+
if (!stat.isFile() || stat.size > maxBytes)
|
|
716
|
+
return "";
|
|
717
|
+
return await fs2.readFile(filePath, "utf8");
|
|
718
|
+
} catch {
|
|
719
|
+
return "";
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function runProcess(command, args, cwd, timeoutMs = 60000) {
|
|
723
|
+
return await new Promise((resolve) => {
|
|
724
|
+
const child = spawn(command, args, { cwd, shell: false, windowsHide: true });
|
|
725
|
+
const stdout = [];
|
|
726
|
+
const stderr = [];
|
|
727
|
+
const timer = setTimeout(() => {
|
|
728
|
+
try {
|
|
729
|
+
child.kill("SIGTERM");
|
|
730
|
+
} catch {}
|
|
731
|
+
}, timeoutMs);
|
|
732
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)));
|
|
733
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)));
|
|
734
|
+
child.on("error", (error) => {
|
|
735
|
+
clearTimeout(timer);
|
|
736
|
+
resolve({ code: -1, stdout: "", stderr: String(error) });
|
|
737
|
+
});
|
|
738
|
+
child.on("close", (code) => {
|
|
739
|
+
clearTimeout(timer);
|
|
740
|
+
resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
async function runShellCommand(command, cwd, timeoutMs = 120000) {
|
|
745
|
+
return await new Promise((resolve) => {
|
|
746
|
+
const child = spawn(command, [], { cwd, shell: true, windowsHide: true });
|
|
747
|
+
const stdout = [];
|
|
748
|
+
const stderr = [];
|
|
749
|
+
const timer = setTimeout(() => {
|
|
750
|
+
try {
|
|
751
|
+
child.kill("SIGTERM");
|
|
752
|
+
} catch {}
|
|
753
|
+
}, timeoutMs);
|
|
754
|
+
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)));
|
|
755
|
+
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)));
|
|
756
|
+
child.on("error", (error) => {
|
|
757
|
+
clearTimeout(timer);
|
|
758
|
+
resolve({ code: -1, stdout: "", stderr: String(error) });
|
|
759
|
+
});
|
|
760
|
+
child.on("close", (code) => {
|
|
761
|
+
clearTimeout(timer);
|
|
762
|
+
resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
async function notifyJob(directory, job, reason) {
|
|
767
|
+
if (!job.notifyCommand)
|
|
768
|
+
return;
|
|
769
|
+
const command = String(job.notifyCommand).replace(/\{reason\}/g, String(reason || "")).replace(/\{job\}/g, String(job.name || job.id || ""));
|
|
770
|
+
await runShellCommand(command, directory, 60000);
|
|
563
771
|
}
|
|
564
772
|
|
|
773
|
+
// src/source/opencode/sdk.js
|
|
774
|
+
function sdkError2(result) {
|
|
775
|
+
if (!result || typeof result !== "object")
|
|
776
|
+
return;
|
|
777
|
+
return result.error || result.error === null ? result.error : undefined;
|
|
778
|
+
}
|
|
779
|
+
function sdkData2(result) {
|
|
780
|
+
if (!result || typeof result !== "object")
|
|
781
|
+
return result;
|
|
782
|
+
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result;
|
|
783
|
+
}
|
|
565
784
|
function sdkErrorMessage(error) {
|
|
566
|
-
if (!error)
|
|
567
|
-
|
|
568
|
-
if (
|
|
785
|
+
if (!error)
|
|
786
|
+
return "unknown SDK error";
|
|
787
|
+
if (error instanceof Error)
|
|
788
|
+
return error.message;
|
|
789
|
+
if (typeof error === "string")
|
|
790
|
+
return error;
|
|
569
791
|
if (typeof error === "object") {
|
|
570
|
-
if (typeof error.message === "string")
|
|
571
|
-
|
|
572
|
-
|
|
792
|
+
if (typeof error.message === "string")
|
|
793
|
+
return error.message;
|
|
794
|
+
if (typeof error.name === "string")
|
|
795
|
+
return error.name;
|
|
796
|
+
try {
|
|
797
|
+
return JSON.stringify(error).slice(0, 400);
|
|
798
|
+
} catch {}
|
|
573
799
|
}
|
|
574
|
-
return String(error)
|
|
800
|
+
return String(error);
|
|
575
801
|
}
|
|
576
|
-
|
|
577
802
|
async function sdkCall(method, ...argsList) {
|
|
578
|
-
let firstError
|
|
803
|
+
let firstError;
|
|
579
804
|
for (const args of argsList) {
|
|
580
|
-
if (args === undefined)
|
|
805
|
+
if (args === undefined)
|
|
806
|
+
continue;
|
|
581
807
|
try {
|
|
582
|
-
const result = await method(args)
|
|
583
|
-
const error =
|
|
584
|
-
if (!error)
|
|
585
|
-
|
|
808
|
+
const result = await method(args);
|
|
809
|
+
const error = sdkError2(result);
|
|
810
|
+
if (!error)
|
|
811
|
+
return sdkData2(result);
|
|
812
|
+
firstError = firstError || new Error(sdkErrorMessage(error));
|
|
586
813
|
} catch (error) {
|
|
587
|
-
firstError = firstError || error
|
|
814
|
+
firstError = firstError || error;
|
|
588
815
|
}
|
|
589
816
|
}
|
|
590
|
-
throw firstError || new Error("SDK call failed without arguments")
|
|
817
|
+
throw firstError || new Error("SDK call failed without arguments");
|
|
591
818
|
}
|
|
592
819
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
820
|
+
// src/source/opencode/session-context.js
|
|
821
|
+
var LOCAL_COMMAND_AGENT = "opencode-loop-local";
|
|
822
|
+
var sessionExecutionContexts = new Map;
|
|
823
|
+
function normalizedModelRef(model) {
|
|
824
|
+
if (typeof model === "string") {
|
|
825
|
+
const separator = model.indexOf("/");
|
|
826
|
+
if (separator > 0)
|
|
827
|
+
return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) };
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const providerID = model?.providerID;
|
|
831
|
+
const modelID = model?.modelID || model?.id;
|
|
832
|
+
if (typeof providerID !== "string" || typeof modelID !== "string")
|
|
833
|
+
return;
|
|
834
|
+
return { providerID, modelID };
|
|
599
835
|
}
|
|
836
|
+
function updateSessionExecutionContext(info) {
|
|
837
|
+
const sessionID = info?.sessionID || info?.id;
|
|
838
|
+
if (typeof sessionID !== "string")
|
|
839
|
+
return;
|
|
840
|
+
const previous = sessionExecutionContexts.get(sessionID) || {};
|
|
841
|
+
const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined);
|
|
842
|
+
const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT ? candidateAgent : previous.agent;
|
|
843
|
+
const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model;
|
|
844
|
+
sessionExecutionContexts.set(sessionID, { agent, model });
|
|
845
|
+
}
|
|
846
|
+
async function captureSessionExecutionContext(client, sessionID) {
|
|
847
|
+
if (client?.session?.get) {
|
|
848
|
+
try {
|
|
849
|
+
const info = await sdkCall(client.session.get.bind(client.session), { path: { id: sessionID } }, { path: { sessionID } }, { sessionID });
|
|
850
|
+
updateSessionExecutionContext(info);
|
|
851
|
+
} catch {}
|
|
852
|
+
}
|
|
853
|
+
const context = sessionExecutionContexts.get(sessionID) || {};
|
|
854
|
+
const normalized = { agent: context.agent || "build", model: context.model };
|
|
855
|
+
sessionExecutionContexts.set(sessionID, normalized);
|
|
856
|
+
return normalized;
|
|
857
|
+
}
|
|
858
|
+
function getSessionExecutionContext(sessionID) {
|
|
859
|
+
return sessionExecutionContexts.get(sessionID);
|
|
860
|
+
}
|
|
861
|
+
function setSessionExecutionContext(sessionID, context) {
|
|
862
|
+
sessionExecutionContexts.set(sessionID, context);
|
|
863
|
+
return context;
|
|
864
|
+
}
|
|
865
|
+
function deleteSessionExecutionContext(sessionID) {
|
|
866
|
+
sessionExecutionContexts.delete(sessionID);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/source/legacy-v1.js
|
|
870
|
+
var SERVICE = "opencode-loop";
|
|
871
|
+
var DEFAULT_ACTIVE_GUARD_MS = 45000;
|
|
872
|
+
var STALE_ACTIVE_RECOVERY_MS = 45000;
|
|
873
|
+
var IDLE_DEBOUNCE_MS = 1200;
|
|
874
|
+
var BUSY_RETRY_MS = 5000;
|
|
875
|
+
var SESSION_STATUS_CACHE_MS = 1500;
|
|
876
|
+
var MIN_DUE_TIMER_MS = 250;
|
|
877
|
+
var MAX_DUE_TIMER_MS = 2147000000;
|
|
878
|
+
var HEARTBEAT_MS = 2500;
|
|
879
|
+
var MAX_SCAN_FILES = 200;
|
|
880
|
+
var MAX_SCAN_BYTES = 2000000;
|
|
881
|
+
var GOAL_REPORT_DIR = "goals";
|
|
882
|
+
var GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION";
|
|
883
|
+
var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
|
|
884
|
+
var LOOP_OWNED_USER_MESSAGE_GUARD_MS = 1e4;
|
|
885
|
+
var LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60000;
|
|
886
|
+
var activeRuns = new Map;
|
|
887
|
+
var handledCommands = new Map;
|
|
888
|
+
var handledCommandEvents = new Map;
|
|
889
|
+
var loopOwnedUserMessageGuards = new Map;
|
|
890
|
+
var idleTimers = new Map;
|
|
891
|
+
var dueTimers = new Map;
|
|
892
|
+
var watchdogTimers = new Map;
|
|
893
|
+
var runLocks = new Map;
|
|
894
|
+
var knownSessions = new Map;
|
|
895
|
+
var activeToolCalls = new Map;
|
|
896
|
+
var sessionParents = new Map;
|
|
897
|
+
var heartbeatTimer;
|
|
898
|
+
var sessionStatuses = new Map;
|
|
899
|
+
var sessionStatusSeenAt = new Map;
|
|
900
|
+
var loopCompactionRequests = new Map;
|
|
901
|
+
var DEFAULT_PROGRESS_MD = `# Progress
|
|
902
|
+
|
|
903
|
+
## Current Goal
|
|
904
|
+
Describe the current project goal here.
|
|
905
|
+
|
|
906
|
+
## Agent Rules
|
|
907
|
+
- Do not ask questions unless truly blocked.
|
|
908
|
+
- Make reasonable assumptions and continue.
|
|
909
|
+
- Work on unfinished TODOs in order.
|
|
910
|
+
- Mark completed TODOs with [x].
|
|
911
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
912
|
+
- Run tests, lint, or build when available.
|
|
913
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
600
914
|
|
|
915
|
+
## Active TODO
|
|
916
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
917
|
+
|
|
918
|
+
## Completed
|
|
919
|
+
- [x] Created progress.md.
|
|
920
|
+
|
|
921
|
+
## Backlog Ideas
|
|
922
|
+
- [ ] Add more project-specific tasks here.
|
|
923
|
+
|
|
924
|
+
## Blocked
|
|
925
|
+
- None.
|
|
926
|
+
`;
|
|
927
|
+
function fireSdk(client, label, method, ...argsList) {
|
|
928
|
+
const pending = Promise.resolve().then(() => sdkCall(method, ...argsList));
|
|
929
|
+
pending.catch((error) => {
|
|
930
|
+
log(client, "warn", `${label} failed`, { error: sdkErrorMessage(error) }).catch(() => {});
|
|
931
|
+
});
|
|
932
|
+
return pending;
|
|
933
|
+
}
|
|
601
934
|
async function executeTuiCommand(client, command) {
|
|
602
|
-
if (!client?.tui?.executeCommand)
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
{ body: { command } },
|
|
606
|
-
{ command },
|
|
607
|
-
)
|
|
935
|
+
if (!client?.tui?.executeCommand)
|
|
936
|
+
throw new Error("client.tui.executeCommand is not available");
|
|
937
|
+
return await sdkCall(client.tui.executeCommand.bind(client.tui), { body: { command } }, { command });
|
|
608
938
|
}
|
|
609
|
-
|
|
610
939
|
function compactTuiCommandName(command = "compact") {
|
|
611
|
-
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase()
|
|
612
|
-
if (normalized === "compact" || normalized === "summarize")
|
|
613
|
-
|
|
940
|
+
const normalized = String(command || "compact").replace(/^\/+/, "").trim().toLowerCase();
|
|
941
|
+
if (normalized === "compact" || normalized === "summarize")
|
|
942
|
+
return "session_compact";
|
|
943
|
+
return;
|
|
614
944
|
}
|
|
615
|
-
|
|
616
945
|
async function readRecentSessionMessages(client, sessionID, directory, limit = 20) {
|
|
617
|
-
if (!client?.session?.messages)
|
|
618
|
-
|
|
619
|
-
|
|
946
|
+
if (!client?.session?.messages)
|
|
947
|
+
return;
|
|
948
|
+
const query = { limit };
|
|
949
|
+
if (directory)
|
|
950
|
+
query.directory = directory;
|
|
620
951
|
try {
|
|
621
|
-
const messages = await sdkCall(
|
|
622
|
-
|
|
623
|
-
{ path: { id: sessionID }, query },
|
|
624
|
-
{ path: { sessionID }, query },
|
|
625
|
-
{ sessionID, ...query },
|
|
626
|
-
)
|
|
627
|
-
return Array.isArray(messages) ? messages : undefined
|
|
952
|
+
const messages = await sdkCall(client.session.messages.bind(client.session), { path: { id: sessionID }, query }, { path: { sessionID }, query }, { sessionID, ...query });
|
|
953
|
+
return Array.isArray(messages) ? messages : undefined;
|
|
628
954
|
} catch {
|
|
629
|
-
return
|
|
955
|
+
return;
|
|
630
956
|
}
|
|
631
957
|
}
|
|
632
|
-
|
|
633
958
|
function orderedSessionMessages(messages) {
|
|
634
|
-
return (messages || [])
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
})
|
|
640
|
-
.sort((a, b) => a.created - b.created || a.index - b.index)
|
|
641
|
-
.map((entry) => entry.message)
|
|
959
|
+
return (messages || []).map((message, index) => {
|
|
960
|
+
const info = message?.info || message || {};
|
|
961
|
+
const created = Number(info?.time?.created || 0);
|
|
962
|
+
return { message, index, created: Number.isFinite(created) ? created : 0 };
|
|
963
|
+
}).sort((a, b) => a.created - b.created || a.index - b.index).map((entry) => entry.message);
|
|
642
964
|
}
|
|
643
|
-
|
|
644
965
|
async function activeRunCompletionFromMessages(directory, client, sessionID, active) {
|
|
645
|
-
const messages = await readRecentSessionMessages(client, sessionID, directory)
|
|
646
|
-
if (!messages)
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
if (
|
|
655
|
-
|
|
966
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory);
|
|
967
|
+
if (!messages)
|
|
968
|
+
return "unknown";
|
|
969
|
+
const tail = orderedSessionMessages(messages).at(-1);
|
|
970
|
+
const info = tail?.info || tail;
|
|
971
|
+
if (!info || info.role !== "assistant")
|
|
972
|
+
return "incomplete";
|
|
973
|
+
const completed = Number(info?.time?.completed || 0);
|
|
974
|
+
const created = Number(info?.time?.created || 0);
|
|
975
|
+
if (!Number.isFinite(completed) || completed <= 0)
|
|
976
|
+
return "incomplete";
|
|
977
|
+
const startedAt = Number(active?.startedAt || 0);
|
|
978
|
+
if (startedAt > 0 && completed < startedAt && (!Number.isFinite(created) || created < startedAt))
|
|
979
|
+
return "incomplete";
|
|
980
|
+
return "completed";
|
|
656
981
|
}
|
|
657
|
-
|
|
658
982
|
async function resolveCompactionModel(directory, client, sessionID, preferredModel) {
|
|
659
|
-
const preferred = normalizedModelRef(preferredModel)
|
|
660
|
-
if (preferred)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
983
|
+
const preferred = normalizedModelRef(preferredModel);
|
|
984
|
+
if (preferred)
|
|
985
|
+
return preferred;
|
|
986
|
+
const cached = normalizedModelRef(getSessionExecutionContext(sessionID)?.model);
|
|
987
|
+
if (cached)
|
|
988
|
+
return cached;
|
|
989
|
+
const captured = await captureSessionExecutionContext(client, sessionID);
|
|
990
|
+
const capturedModel = normalizedModelRef(captured?.model);
|
|
991
|
+
if (capturedModel)
|
|
992
|
+
return capturedModel;
|
|
993
|
+
const messages = await readRecentSessionMessages(client, sessionID, directory);
|
|
667
994
|
for (const message of orderedSessionMessages(messages).reverse()) {
|
|
668
|
-
const info = message?.info || message
|
|
669
|
-
const model = normalizedModelRef(info?.model) || normalizedModelRef(info)
|
|
670
|
-
if (!model)
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
995
|
+
const info = message?.info || message;
|
|
996
|
+
const model = normalizedModelRef(info?.model) || normalizedModelRef(info);
|
|
997
|
+
if (!model)
|
|
998
|
+
continue;
|
|
999
|
+
const previous = getSessionExecutionContext(sessionID) || {};
|
|
1000
|
+
setSessionExecutionContext(sessionID, { ...previous, model });
|
|
1001
|
+
return model;
|
|
674
1002
|
}
|
|
675
|
-
return
|
|
1003
|
+
return;
|
|
676
1004
|
}
|
|
677
|
-
|
|
678
1005
|
async function compactSession(directory, client, sessionID, preferredModel) {
|
|
679
|
-
// Prefer the native TUI command when a TUI is present. Headless/server hosts
|
|
680
|
-
// fall back to session.summarize, whose current API requires an explicit
|
|
681
|
-
// provider/model pair.
|
|
682
1006
|
for (const command of ["session.compact", "session_compact"]) {
|
|
683
1007
|
try {
|
|
684
|
-
await executeTuiCommand(client, command)
|
|
685
|
-
return true
|
|
1008
|
+
await executeTuiCommand(client, command);
|
|
1009
|
+
return true;
|
|
686
1010
|
} catch (error) {
|
|
687
|
-
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) })
|
|
1011
|
+
await log(client, "warn", `tui ${command} failed`, { error: sdkErrorMessage(error) });
|
|
688
1012
|
}
|
|
689
1013
|
}
|
|
690
1014
|
try {
|
|
691
|
-
if (!client?.session?.summarize)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
{ sessionID, ...body },
|
|
700
|
-
)
|
|
701
|
-
return true
|
|
1015
|
+
if (!client?.session?.summarize)
|
|
1016
|
+
throw new Error("client.session.summarize is not available");
|
|
1017
|
+
const model = await resolveCompactionModel(directory, client, sessionID, preferredModel);
|
|
1018
|
+
if (!model)
|
|
1019
|
+
throw new Error("could not resolve a provider/model for session.summarize");
|
|
1020
|
+
const body = { providerID: model.providerID, modelID: model.modelID, auto: false };
|
|
1021
|
+
await sdkCall(client.session.summarize.bind(client.session), { path: { id: sessionID }, body }, { path: { sessionID }, body }, { sessionID, ...body });
|
|
1022
|
+
return true;
|
|
702
1023
|
} catch (error) {
|
|
703
|
-
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) })
|
|
1024
|
+
await log(client, "warn", "session.summarize fallback failed", { error: sdkErrorMessage(error) });
|
|
704
1025
|
}
|
|
705
|
-
await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error")
|
|
706
|
-
return false
|
|
1026
|
+
await toast(client, "Could not run /compact from loop. Check OpenCode version and active session model.", "error");
|
|
1027
|
+
return false;
|
|
707
1028
|
}
|
|
708
|
-
|
|
709
1029
|
async function log(client, level, message, extra) {
|
|
710
1030
|
try {
|
|
711
|
-
await sdkCall(
|
|
712
|
-
client.app.log.bind(client.app),
|
|
713
|
-
{ body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } },
|
|
714
|
-
extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra },
|
|
715
|
-
)
|
|
1031
|
+
await sdkCall(client.app.log.bind(client.app), { body: extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra } }, extra === undefined ? { service: SERVICE, level, message } : { service: SERVICE, level, message, extra });
|
|
716
1032
|
} catch {}
|
|
717
1033
|
}
|
|
718
|
-
|
|
719
1034
|
async function toast(client, message, variant = "info") {
|
|
720
|
-
try {
|
|
1035
|
+
try {
|
|
1036
|
+
await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant });
|
|
1037
|
+
} catch {}
|
|
721
1038
|
}
|
|
722
|
-
|
|
723
1039
|
function guardLoopOwnedUserMessage(sessionID) {
|
|
724
|
-
if (!sessionID)
|
|
725
|
-
|
|
726
|
-
current.pending
|
|
727
|
-
current.
|
|
728
|
-
|
|
729
|
-
|
|
1040
|
+
if (!sessionID)
|
|
1041
|
+
return;
|
|
1042
|
+
const current = loopOwnedUserMessageGuards.get(sessionID) || { pending: 0, until: 0, messageIDs: new Map };
|
|
1043
|
+
current.pending += 1;
|
|
1044
|
+
current.until = Math.max(current.until || 0, now() + LOOP_OWNED_USER_MESSAGE_GUARD_MS);
|
|
1045
|
+
for (const [messageID, expiresAt] of current.messageIDs.entries())
|
|
1046
|
+
if (expiresAt < now())
|
|
1047
|
+
current.messageIDs.delete(messageID);
|
|
1048
|
+
loopOwnedUserMessageGuards.set(sessionID, current);
|
|
730
1049
|
for (const [key, entry] of loopOwnedUserMessageGuards.entries()) {
|
|
731
|
-
for (const [messageID, expiresAt] of entry.messageIDs.entries())
|
|
732
|
-
|
|
1050
|
+
for (const [messageID, expiresAt] of entry.messageIDs.entries())
|
|
1051
|
+
if (expiresAt < now())
|
|
1052
|
+
entry.messageIDs.delete(messageID);
|
|
1053
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0 && (entry.until || 0) < now())
|
|
1054
|
+
loopOwnedUserMessageGuards.delete(key);
|
|
733
1055
|
}
|
|
734
1056
|
}
|
|
735
|
-
|
|
736
1057
|
function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
737
|
-
const entry = loopOwnedUserMessageGuards.get(sessionID)
|
|
738
|
-
if (!entry || typeof entry !== "object")
|
|
739
|
-
|
|
740
|
-
const
|
|
741
|
-
|
|
1058
|
+
const entry = loopOwnedUserMessageGuards.get(sessionID);
|
|
1059
|
+
if (!entry || typeof entry !== "object")
|
|
1060
|
+
return false;
|
|
1061
|
+
for (const [id2, expiresAt] of entry.messageIDs.entries())
|
|
1062
|
+
if (expiresAt < now())
|
|
1063
|
+
entry.messageIDs.delete(id2);
|
|
1064
|
+
const id = typeof messageID === "string" ? messageID : "";
|
|
1065
|
+
if (id && entry.messageIDs.has(id))
|
|
1066
|
+
return true;
|
|
742
1067
|
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
743
|
-
entry.pending -= 1
|
|
744
|
-
if (id)
|
|
745
|
-
|
|
746
|
-
|
|
1068
|
+
entry.pending -= 1;
|
|
1069
|
+
if (id)
|
|
1070
|
+
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS);
|
|
1071
|
+
loopOwnedUserMessageGuards.set(sessionID, entry);
|
|
1072
|
+
return true;
|
|
747
1073
|
}
|
|
748
|
-
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0)
|
|
749
|
-
|
|
1074
|
+
if ((entry.pending || 0) <= 0 && entry.messageIDs.size === 0)
|
|
1075
|
+
loopOwnedUserMessageGuards.delete(sessionID);
|
|
1076
|
+
return false;
|
|
750
1077
|
}
|
|
751
|
-
|
|
752
1078
|
async function say(client, sessionID, text) {
|
|
753
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
1079
|
+
guardLoopOwnedUserMessage(sessionID);
|
|
754
1080
|
try {
|
|
755
|
-
await sdkCall(
|
|
756
|
-
client.session.prompt.bind(client.session),
|
|
757
|
-
{ path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
758
|
-
{ path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } },
|
|
759
|
-
{ sessionID, noReply: true, parts: [{ type: "text", text }] },
|
|
760
|
-
)
|
|
1081
|
+
await sdkCall(client.session.prompt.bind(client.session), { path: { id: sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } }, { path: { sessionID }, body: { noReply: true, parts: [{ type: "text", text }] } }, { sessionID, noReply: true, parts: [{ type: "text", text }] });
|
|
761
1082
|
} catch {}
|
|
762
1083
|
}
|
|
763
|
-
|
|
764
|
-
|
|
1084
|
+
function commandKey(sessionID, name, args) {
|
|
1085
|
+
return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}`;
|
|
1086
|
+
}
|
|
765
1087
|
function markHandled(sessionID, name, args) {
|
|
766
|
-
const key = commandKey(sessionID, name, args)
|
|
767
|
-
const previous = handledCommands.get(key)
|
|
768
|
-
const pending = previous && now() - previous.time <
|
|
769
|
-
handledCommands.set(key, { time: now(), pending })
|
|
770
|
-
for (const [entryKey, entry] of handledCommands.entries())
|
|
771
|
-
|
|
1088
|
+
const key = commandKey(sessionID, name, args);
|
|
1089
|
+
const previous = handledCommands.get(key);
|
|
1090
|
+
const pending = previous && now() - previous.time < 30000 ? previous.pending + 1 : 1;
|
|
1091
|
+
handledCommands.set(key, { time: now(), pending });
|
|
1092
|
+
for (const [entryKey, entry] of handledCommands.entries())
|
|
1093
|
+
if (now() - entry.time > 30000)
|
|
1094
|
+
handledCommands.delete(entryKey);
|
|
1095
|
+
for (const [entryKey, time] of handledCommandEvents.entries())
|
|
1096
|
+
if (now() - time > 30000)
|
|
1097
|
+
handledCommandEvents.delete(entryKey);
|
|
772
1098
|
}
|
|
773
1099
|
function consumeHandled(sessionID, name, args) {
|
|
774
|
-
const key = commandKey(sessionID, name, args)
|
|
775
|
-
const entry = handledCommands.get(key)
|
|
776
|
-
if (!entry || now() - entry.time >=
|
|
777
|
-
handledCommands.delete(key)
|
|
778
|
-
return false
|
|
779
|
-
}
|
|
780
|
-
if (entry.pending <= 1)
|
|
781
|
-
|
|
782
|
-
|
|
1100
|
+
const key = commandKey(sessionID, name, args);
|
|
1101
|
+
const entry = handledCommands.get(key);
|
|
1102
|
+
if (!entry || now() - entry.time >= 30000) {
|
|
1103
|
+
handledCommands.delete(key);
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
if (entry.pending <= 1)
|
|
1107
|
+
handledCommands.delete(key);
|
|
1108
|
+
else
|
|
1109
|
+
handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 });
|
|
1110
|
+
return true;
|
|
783
1111
|
}
|
|
784
1112
|
function commandEventKey(sessionID, messageID) {
|
|
785
|
-
return `${sessionID || "no-session"}:event:${messageID || "no-message"}
|
|
1113
|
+
return `${sessionID || "no-session"}:event:${messageID || "no-message"}`;
|
|
1114
|
+
}
|
|
1115
|
+
function commandName(name) {
|
|
1116
|
+
return String(name || "");
|
|
1117
|
+
}
|
|
1118
|
+
function isPreset(name) {
|
|
1119
|
+
return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name);
|
|
786
1120
|
}
|
|
787
|
-
|
|
788
|
-
function commandName(name) { return String(name || "") }
|
|
789
|
-
function isPreset(name) { return ["loop-dev", "loop-testfix", "loop-compact", "loop-progress", "loop-safe-dev", "loop-command", "loop-cmd", "loop-prompt", "loop-ask", "loop-shell"].includes(name) }
|
|
790
1121
|
function isLoopCommandName(name) {
|
|
791
|
-
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)
|
|
1122
|
+
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);
|
|
792
1123
|
}
|
|
793
|
-
|
|
794
1124
|
function normalizeArgsForKey(args) {
|
|
795
|
-
if (args === undefined || args === null)
|
|
796
|
-
|
|
797
|
-
if (
|
|
798
|
-
|
|
1125
|
+
if (args === undefined || args === null)
|
|
1126
|
+
return "";
|
|
1127
|
+
if (typeof args === "string")
|
|
1128
|
+
return args.trim().replace(/\s+/g, " ");
|
|
1129
|
+
if (Array.isArray(args))
|
|
1130
|
+
return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ");
|
|
1131
|
+
try {
|
|
1132
|
+
return JSON.stringify(args);
|
|
1133
|
+
} catch {
|
|
1134
|
+
return String(args);
|
|
1135
|
+
}
|
|
799
1136
|
}
|
|
800
|
-
|
|
801
1137
|
function commandArgsText(args) {
|
|
802
|
-
if (args === undefined || args === null)
|
|
803
|
-
|
|
804
|
-
if (
|
|
1138
|
+
if (args === undefined || args === null)
|
|
1139
|
+
return "";
|
|
1140
|
+
if (typeof args === "string")
|
|
1141
|
+
return args;
|
|
1142
|
+
if (Array.isArray(args))
|
|
1143
|
+
return args.map(commandArgsText).join(" ");
|
|
805
1144
|
if (typeof args === "object") {
|
|
806
1145
|
for (const key of ["arguments", "args", "message", "text", "value"]) {
|
|
807
|
-
if (args[key] !== undefined)
|
|
1146
|
+
if (args[key] !== undefined)
|
|
1147
|
+
return commandArgsText(args[key]);
|
|
808
1148
|
}
|
|
809
1149
|
}
|
|
810
|
-
return String(args)
|
|
1150
|
+
return String(args);
|
|
811
1151
|
}
|
|
812
|
-
|
|
813
1152
|
function rememberSession(directory, client, sessionID) {
|
|
814
|
-
if (!sessionID)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
function normalizedModelRef(model) {
|
|
820
|
-
if (typeof model === "string") {
|
|
821
|
-
const separator = model.indexOf("/")
|
|
822
|
-
if (separator > 0) return { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }
|
|
823
|
-
return undefined
|
|
824
|
-
}
|
|
825
|
-
const providerID = model?.providerID
|
|
826
|
-
const modelID = model?.modelID || model?.id
|
|
827
|
-
if (typeof providerID !== "string" || typeof modelID !== "string") return undefined
|
|
828
|
-
return { providerID, modelID }
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
function updateSessionExecutionContext(info) {
|
|
832
|
-
const sessionID = info?.sessionID || info?.id
|
|
833
|
-
if (typeof sessionID !== "string") return
|
|
834
|
-
const previous = sessionExecutionContexts.get(sessionID) || {}
|
|
835
|
-
const candidateAgent = info?.agent || (info?.role === "assistant" ? info?.mode : undefined)
|
|
836
|
-
const agent = typeof candidateAgent === "string" && candidateAgent !== LOCAL_COMMAND_AGENT
|
|
837
|
-
? candidateAgent
|
|
838
|
-
: previous.agent
|
|
839
|
-
const model = normalizedModelRef(info?.model) || normalizedModelRef(info) || previous.model
|
|
840
|
-
sessionExecutionContexts.set(sessionID, { agent, model })
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
async function captureSessionExecutionContext(client, sessionID) {
|
|
844
|
-
if (client?.session?.get) {
|
|
845
|
-
try {
|
|
846
|
-
const info = await sdkCall(
|
|
847
|
-
client.session.get.bind(client.session),
|
|
848
|
-
{ path: { id: sessionID } },
|
|
849
|
-
{ path: { sessionID } },
|
|
850
|
-
{ sessionID },
|
|
851
|
-
)
|
|
852
|
-
updateSessionExecutionContext(info)
|
|
853
|
-
} catch {}
|
|
854
|
-
}
|
|
855
|
-
const context = sessionExecutionContexts.get(sessionID) || {}
|
|
856
|
-
const normalized = { agent: context.agent || "build", model: context.model }
|
|
857
|
-
sessionExecutionContexts.set(sessionID, normalized)
|
|
858
|
-
return normalized
|
|
1153
|
+
if (!sessionID)
|
|
1154
|
+
return;
|
|
1155
|
+
knownSessions.set(sessionID, { directory, client, seenAt: now() });
|
|
1156
|
+
startHeartbeat();
|
|
859
1157
|
}
|
|
860
|
-
|
|
861
1158
|
function hasActiveToolCalls(sessionID) {
|
|
862
|
-
return (activeToolCalls.get(sessionID)?.size || 0) > 0
|
|
1159
|
+
return (activeToolCalls.get(sessionID)?.size || 0) > 0;
|
|
863
1160
|
}
|
|
864
|
-
|
|
865
1161
|
function markToolCallActive(input) {
|
|
866
|
-
const sessionID = input?.sessionID
|
|
867
|
-
const callID = input?.callID
|
|
868
|
-
if (typeof sessionID !== "string" || typeof callID !== "string")
|
|
869
|
-
|
|
870
|
-
calls.
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1162
|
+
const sessionID = input?.sessionID;
|
|
1163
|
+
const callID = input?.callID;
|
|
1164
|
+
if (typeof sessionID !== "string" || typeof callID !== "string")
|
|
1165
|
+
return;
|
|
1166
|
+
const calls = activeToolCalls.get(sessionID) || new Set;
|
|
1167
|
+
calls.add(callID);
|
|
1168
|
+
activeToolCalls.set(sessionID, calls);
|
|
1169
|
+
sessionStatuses.set(sessionID, "busy");
|
|
1170
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
874
1171
|
}
|
|
875
|
-
|
|
876
1172
|
function markToolCallFinished(input) {
|
|
877
|
-
const sessionID = input?.sessionID
|
|
878
|
-
const callID = input?.callID
|
|
879
|
-
if (typeof sessionID !== "string" || typeof callID !== "string")
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
calls
|
|
883
|
-
|
|
1173
|
+
const sessionID = input?.sessionID;
|
|
1174
|
+
const callID = input?.callID;
|
|
1175
|
+
if (typeof sessionID !== "string" || typeof callID !== "string")
|
|
1176
|
+
return;
|
|
1177
|
+
const calls = activeToolCalls.get(sessionID);
|
|
1178
|
+
if (!calls)
|
|
1179
|
+
return;
|
|
1180
|
+
calls.delete(callID);
|
|
1181
|
+
if (!calls.size)
|
|
1182
|
+
activeToolCalls.delete(sessionID);
|
|
884
1183
|
}
|
|
885
|
-
|
|
886
1184
|
function updateSessionRelationship(info, removed = false) {
|
|
887
|
-
const sessionID = info?.id
|
|
888
|
-
if (typeof sessionID !== "string")
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
else
|
|
1185
|
+
const sessionID = info?.id;
|
|
1186
|
+
if (typeof sessionID !== "string")
|
|
1187
|
+
return;
|
|
1188
|
+
if (removed || typeof info?.parentID !== "string")
|
|
1189
|
+
sessionParents.delete(sessionID);
|
|
1190
|
+
else
|
|
1191
|
+
sessionParents.set(sessionID, info.parentID);
|
|
1192
|
+
if (!removed)
|
|
1193
|
+
updateSessionExecutionContext(info);
|
|
1194
|
+
else
|
|
1195
|
+
deleteSessionExecutionContext(sessionID);
|
|
893
1196
|
}
|
|
894
|
-
|
|
895
1197
|
function updateSessionRelationshipFromEvent(event) {
|
|
896
|
-
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type))
|
|
897
|
-
|
|
1198
|
+
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type))
|
|
1199
|
+
return;
|
|
1200
|
+
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted");
|
|
898
1201
|
}
|
|
899
|
-
|
|
900
1202
|
function isDescendantSession(sessionID, ancestorID) {
|
|
901
|
-
const visited = new Set
|
|
902
|
-
let current = sessionID
|
|
1203
|
+
const visited = new Set;
|
|
1204
|
+
let current = sessionID;
|
|
903
1205
|
while (sessionParents.has(current) && !visited.has(current)) {
|
|
904
|
-
visited.add(current)
|
|
905
|
-
current = sessionParents.get(current)
|
|
906
|
-
if (current === ancestorID)
|
|
1206
|
+
visited.add(current);
|
|
1207
|
+
current = sessionParents.get(current);
|
|
1208
|
+
if (current === ancestorID)
|
|
1209
|
+
return true;
|
|
907
1210
|
}
|
|
908
|
-
return false
|
|
1211
|
+
return false;
|
|
909
1212
|
}
|
|
910
|
-
|
|
911
1213
|
function hasBusyDescendant(sessionID) {
|
|
912
1214
|
for (const childID of sessionParents.keys()) {
|
|
913
|
-
if (!isDescendantSession(childID, sessionID))
|
|
914
|
-
|
|
915
|
-
|
|
1215
|
+
if (!isDescendantSession(childID, sessionID))
|
|
1216
|
+
continue;
|
|
1217
|
+
const status = sessionStatuses.get(childID);
|
|
1218
|
+
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID))
|
|
1219
|
+
return true;
|
|
916
1220
|
}
|
|
917
|
-
return false
|
|
1221
|
+
return false;
|
|
918
1222
|
}
|
|
919
|
-
|
|
920
1223
|
async function refreshSessionRelationships(client, directory) {
|
|
921
|
-
if (!client?.session?.list)
|
|
1224
|
+
if (!client?.session?.list)
|
|
1225
|
+
return;
|
|
922
1226
|
try {
|
|
923
|
-
const sessions = await sdkCall(
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
{},
|
|
928
|
-
)
|
|
929
|
-
if (Array.isArray(sessions)) for (const info of sessions) updateSessionRelationship(info)
|
|
1227
|
+
const sessions = await sdkCall(client.session.list.bind(client.session), { query: { directory } }, { directory }, {});
|
|
1228
|
+
if (Array.isArray(sessions))
|
|
1229
|
+
for (const info of sessions)
|
|
1230
|
+
updateSessionRelationship(info);
|
|
930
1231
|
} catch {}
|
|
931
1232
|
}
|
|
932
|
-
|
|
933
1233
|
function updateToolActivityFromEvent(event) {
|
|
934
|
-
const props = event?.properties || {}
|
|
1234
|
+
const props = event?.properties || {};
|
|
935
1235
|
if (event?.type === "message.part.updated") {
|
|
936
|
-
const part = props.part
|
|
937
|
-
if (part?.type !== "tool")
|
|
938
|
-
|
|
1236
|
+
const part = props.part;
|
|
1237
|
+
if (part?.type !== "tool")
|
|
1238
|
+
return;
|
|
1239
|
+
const sessionID = part.sessionID || props.sessionID;
|
|
939
1240
|
if (["pending", "running"].includes(part.state?.status)) {
|
|
940
|
-
markToolCallActive({ sessionID, callID: part.callID })
|
|
1241
|
+
markToolCallActive({ sessionID, callID: part.callID });
|
|
941
1242
|
}
|
|
942
1243
|
if (["completed", "error"].includes(part.state?.status)) {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
for (const callID of identifiers) markToolCallFinished({ sessionID, callID })
|
|
1244
|
+
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))];
|
|
1245
|
+
for (const callID of identifiers)
|
|
1246
|
+
markToolCallFinished({ sessionID, callID });
|
|
947
1247
|
}
|
|
948
|
-
return
|
|
1248
|
+
return;
|
|
949
1249
|
}
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
if (finished)
|
|
1250
|
+
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type);
|
|
1251
|
+
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type);
|
|
1252
|
+
if (started)
|
|
1253
|
+
markToolCallActive(props);
|
|
1254
|
+
if (finished)
|
|
1255
|
+
markToolCallFinished(props);
|
|
955
1256
|
}
|
|
956
|
-
|
|
957
1257
|
function startHeartbeat() {
|
|
958
|
-
if (heartbeatTimer)
|
|
1258
|
+
if (heartbeatTimer)
|
|
1259
|
+
return;
|
|
959
1260
|
heartbeatTimer = setInterval(() => {
|
|
960
1261
|
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
961
1262
|
if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
|
|
962
|
-
knownSessions.delete(sessionID)
|
|
963
|
-
continue
|
|
1263
|
+
knownSessions.delete(sessionID);
|
|
1264
|
+
continue;
|
|
964
1265
|
}
|
|
965
|
-
Promise.resolve()
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
})
|
|
970
|
-
.catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}))
|
|
1266
|
+
Promise.resolve().then(async () => {
|
|
1267
|
+
await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true });
|
|
1268
|
+
await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true });
|
|
1269
|
+
}).catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}));
|
|
971
1270
|
}
|
|
972
1271
|
if (!knownSessions.size && heartbeatTimer) {
|
|
973
|
-
clearInterval(heartbeatTimer)
|
|
974
|
-
heartbeatTimer = undefined
|
|
1272
|
+
clearInterval(heartbeatTimer);
|
|
1273
|
+
heartbeatTimer = undefined;
|
|
975
1274
|
}
|
|
976
|
-
}, HEARTBEAT_MS)
|
|
1275
|
+
}, HEARTBEAT_MS);
|
|
977
1276
|
}
|
|
978
|
-
|
|
979
1277
|
function disposeRuntime(directory, client) {
|
|
980
|
-
const sessions = [...knownSessions.entries()]
|
|
981
|
-
.filter(([, info]) => info?.directory === directory && info?.client === client)
|
|
982
|
-
.map(([sessionID]) => sessionID)
|
|
1278
|
+
const sessions = [...knownSessions.entries()].filter(([, info]) => info?.directory === directory && info?.client === client).map(([sessionID]) => sessionID);
|
|
983
1279
|
for (const sessionID of sessions) {
|
|
984
|
-
clearActiveRun(sessionID)
|
|
985
|
-
const idle = idleTimers.get(sessionID);
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1280
|
+
clearActiveRun(sessionID);
|
|
1281
|
+
const idle = idleTimers.get(sessionID);
|
|
1282
|
+
if (idle)
|
|
1283
|
+
clearTimeout(idle);
|
|
1284
|
+
const due = dueTimers.get(sessionID);
|
|
1285
|
+
if (due)
|
|
1286
|
+
clearTimeout(due);
|
|
1287
|
+
const watchdog = watchdogTimers.get(sessionID);
|
|
1288
|
+
if (watchdog)
|
|
1289
|
+
clearInterval(watchdog);
|
|
1290
|
+
idleTimers.delete(sessionID);
|
|
1291
|
+
dueTimers.delete(sessionID);
|
|
1292
|
+
watchdogTimers.delete(sessionID);
|
|
1293
|
+
runLocks.delete(sessionID);
|
|
1294
|
+
knownSessions.delete(sessionID);
|
|
1295
|
+
loopOwnedUserMessageGuards.delete(sessionID);
|
|
1296
|
+
activeToolCalls.delete(sessionID);
|
|
1297
|
+
sessionParents.delete(sessionID);
|
|
1298
|
+
sessionStatuses.delete(sessionID);
|
|
1299
|
+
sessionStatusSeenAt.delete(sessionID);
|
|
1300
|
+
deleteSessionExecutionContext(sessionID);
|
|
1301
|
+
loopCompactionRequests.delete(sessionID);
|
|
1302
|
+
for (const key of handledCommands.keys())
|
|
1303
|
+
if (key.startsWith(`${sessionID}:`))
|
|
1304
|
+
handledCommands.delete(key);
|
|
1305
|
+
for (const key of handledCommandEvents.keys())
|
|
1306
|
+
if (key.startsWith(`${sessionID}:`))
|
|
1307
|
+
handledCommandEvents.delete(key);
|
|
1002
1308
|
}
|
|
1003
1309
|
if (!knownSessions.size && heartbeatTimer) {
|
|
1004
|
-
clearInterval(heartbeatTimer)
|
|
1005
|
-
heartbeatTimer = undefined
|
|
1310
|
+
clearInterval(heartbeatTimer);
|
|
1311
|
+
heartbeatTimer = undefined;
|
|
1006
1312
|
}
|
|
1007
1313
|
}
|
|
1008
|
-
|
|
1009
|
-
function presetDefaults(name) {
|
|
1010
|
-
// parseLoopArgs owns duration/flag/action parsing. Presets only provide real
|
|
1011
|
-
// defaults; deriving an action from the raw string made flag-only invocations
|
|
1012
|
-
// such as `/loop-compact --dry-run` use "--dry-run" as the action.
|
|
1013
|
-
if (name === "loop-compact") return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false }
|
|
1014
|
-
if (name === "loop-command" || name === "loop-cmd") return { intervalMs: 0, kind: "command", name: "command", immediate: false }
|
|
1015
|
-
if (name === "loop-prompt") return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true }
|
|
1016
|
-
if (name === "loop-ask") return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false }
|
|
1017
|
-
if (name === "loop-shell") return { intervalMs: 0, kind: "shell", name: "shell", immediate: false }
|
|
1018
|
-
if (name === "loop-testfix") return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" }
|
|
1019
|
-
if (name === "loop-progress") return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." }
|
|
1020
|
-
if (name === "loop-safe-dev") return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
1021
|
-
return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." }
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
function jobLabel(job) {
|
|
1025
|
-
const title = job.name ? `${job.name}: ` : ""
|
|
1026
|
-
const kind = job.kind ? ` [${job.kind}]` : ""
|
|
1027
|
-
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : ""
|
|
1028
|
-
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : ""
|
|
1029
|
-
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : ""
|
|
1030
|
-
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : ""
|
|
1031
|
-
const verify = job.verifyCommand ? ", verify" : ""
|
|
1032
|
-
const preflight = job.preflightCommand ? ", preflight" : ""
|
|
1033
|
-
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : ""
|
|
1034
|
-
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
1035
|
-
const stopFile = job.stopFile ? ", stop-file" : ""
|
|
1036
|
-
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : ""
|
|
1037
|
-
const paused = job.paused ? ", paused" : ""
|
|
1038
|
-
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
function matchJob(job, target, index) {
|
|
1042
|
-
const text = String(target || "").trim()
|
|
1043
|
-
if (!text || text.toLowerCase() === "all") return true
|
|
1044
|
-
return job.id === text || job.name === text || String(index + 1) === text
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
async function appendLoopLog(directory, line, extra = {}) {
|
|
1048
|
-
try {
|
|
1049
|
-
await ensureDir(stateDir(directory))
|
|
1050
|
-
await fs.appendFile(path.join(stateDir(directory), "loop.log"), JSON.stringify({ time: new Date().toISOString(), line, ...extra }) + "\n")
|
|
1051
|
-
} catch {}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
async function readSmallTextFile(filePath, maxBytes = 120_000) {
|
|
1055
|
-
try {
|
|
1056
|
-
const stat = await fs.stat(filePath)
|
|
1057
|
-
if (!stat.isFile() || stat.size > maxBytes) return ""
|
|
1058
|
-
return await fs.readFile(filePath, "utf8")
|
|
1059
|
-
} catch { return "" }
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
async function runProcess(command, args, cwd, timeoutMs = 60_000) {
|
|
1063
|
-
return await new Promise((resolve) => {
|
|
1064
|
-
const child = spawn(command, args, { cwd, shell: false, windowsHide: true })
|
|
1065
|
-
const stdout = []
|
|
1066
|
-
const stderr = []
|
|
1067
|
-
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
1068
|
-
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
1069
|
-
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
1070
|
-
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
1071
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
1072
|
-
})
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
async function runShellCommand(command, cwd, timeoutMs = 120_000) {
|
|
1076
|
-
return await new Promise((resolve) => {
|
|
1077
|
-
const child = spawn(command, [], { cwd, shell: true, windowsHide: true })
|
|
1078
|
-
const stdout = []
|
|
1079
|
-
const stderr = []
|
|
1080
|
-
const timer = setTimeout(() => { try { child.kill("SIGTERM") } catch {} }, timeoutMs)
|
|
1081
|
-
child.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
1082
|
-
child.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
1083
|
-
child.on("error", (error) => { clearTimeout(timer); resolve({ code: -1, stdout: "", stderr: String(error) }) })
|
|
1084
|
-
child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 0, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") }) })
|
|
1085
|
-
})
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
async function notifyJob(directory, job, reason) {
|
|
1089
|
-
if (!job.notifyCommand) return
|
|
1090
|
-
const command = String(job.notifyCommand).replace(/\{reason\}/g, String(reason || "")).replace(/\{job\}/g, String(job.name || job.id || ""))
|
|
1091
|
-
await runShellCommand(command, directory, 60_000)
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
1314
|
function dangerousShell(command) {
|
|
1095
|
-
const text = String(command || "").toLowerCase()
|
|
1315
|
+
const text = String(command || "").toLowerCase();
|
|
1096
1316
|
return [
|
|
1097
1317
|
/\brm\b(?=[^\r\n]*\s-{1,2}(?:[a-z]*r[a-z]*|recursive)\b)(?=[^\r\n]*\s-{1,2}(?:[a-z]*f[a-z]*|force)\b)/,
|
|
1098
1318
|
/\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
|
|
@@ -1104,78 +1324,65 @@ function dangerousShell(command) {
|
|
|
1104
1324
|
/(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
|
|
1105
1325
|
/\bterraform\s+destroy\b/,
|
|
1106
1326
|
/\bkubectl\s+delete\b/,
|
|
1107
|
-
/\bdeploy\b.*\bproduction\b
|
|
1108
|
-
].some((pattern) => pattern.test(text))
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
function actionKind(action, job = {}) {
|
|
1112
|
-
const text = String(action || "").trim()
|
|
1113
|
-
const forced = String(job.kind || "").trim().toLowerCase()
|
|
1114
|
-
if (forced === "compact") return "compact"
|
|
1115
|
-
if (forced === "goal") return "goal"
|
|
1116
|
-
if (text === "/compact" || text === "/summarize") return "compact"
|
|
1117
|
-
if (forced === "prompt" || forced === "ask") return "prompt"
|
|
1118
|
-
if (forced === "command" || forced === "cmd" || forced === "slash") return "command"
|
|
1119
|
-
if (forced === "shell") return "shell"
|
|
1120
|
-
if (text.startsWith("/")) return "command"
|
|
1121
|
-
if (text.startsWith("!") || text.startsWith("$")) return "shell"
|
|
1122
|
-
return "prompt"
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function decoratePrompt(job) {
|
|
1126
|
-
const additions = []
|
|
1127
|
-
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.`)
|
|
1128
|
-
if (job.lastVerifyFailure) additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200))
|
|
1129
|
-
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.")
|
|
1130
|
-
if (job.safe) additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.")
|
|
1131
|
-
if (job.batch > 0) additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`)
|
|
1132
|
-
if (job.quiet) additions.push("Keep replies short. Summarize only what changed, tests run, and next step.")
|
|
1133
|
-
if (job.testCommand) additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`)
|
|
1134
|
-
if (job.checkpointOnly || job.gitCheckpoint) additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.")
|
|
1135
|
-
if (!additions.length) return job.action
|
|
1136
|
-
return `${job.action}\n\nOpenCode loop instructions:\n- ${additions.join("\n- ")}`
|
|
1327
|
+
/\bdeploy\b.*\bproduction\b/
|
|
1328
|
+
].some((pattern) => pattern.test(text));
|
|
1137
1329
|
}
|
|
1138
|
-
|
|
1139
|
-
function isGoalJob(job) {
|
|
1140
|
-
return String(job?.kind || "").toLowerCase() === "goal"
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function goalStatusText(job) {
|
|
1144
|
-
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "")
|
|
1145
|
-
if (!status) return ""
|
|
1146
|
-
if (status === "completed") return "completed"
|
|
1147
|
-
if (status === "blocked") return "blocked"
|
|
1148
|
-
if (job?.paused) return "paused"
|
|
1149
|
-
return status
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
1330
|
async function buildGoalPrompt(directory, job) {
|
|
1153
|
-
const sections = []
|
|
1154
|
-
sections.push(`Working directory
|
|
1155
|
-
|
|
1156
|
-
|
|
1331
|
+
const sections = [];
|
|
1332
|
+
sections.push(`Working directory:
|
|
1333
|
+
${path3.resolve(directory)}
|
|
1334
|
+
Keep every file operation inside this directory. Prefer workspace-relative paths such as "src/index.js"; never turn a relative path into a root path such as "/src/index.js".`);
|
|
1335
|
+
const objective = String(job.action || "").trim();
|
|
1336
|
+
if (objective)
|
|
1337
|
+
sections.push(`Goal objective:
|
|
1338
|
+
${objective}`);
|
|
1157
1339
|
if (job.goalFile) {
|
|
1158
|
-
const text = await readSmallTextFile(
|
|
1159
|
-
if (text.trim())
|
|
1160
|
-
|
|
1340
|
+
const text = await readSmallTextFile(path3.resolve(directory, job.goalFile), 120000);
|
|
1341
|
+
if (text.trim())
|
|
1342
|
+
sections.push(`Goal file ${job.goalFile}:
|
|
1343
|
+
${text.trim()}`);
|
|
1344
|
+
else
|
|
1345
|
+
sections.push(`Goal file ${job.goalFile} was requested but could not be read. Continue from the inline goal objective.`);
|
|
1161
1346
|
}
|
|
1162
1347
|
if (job.promptFile) {
|
|
1163
|
-
const text = await readSmallTextFile(
|
|
1164
|
-
if (text.trim())
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
if (job.
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
if (
|
|
1173
|
-
|
|
1348
|
+
const text = await readSmallTextFile(path3.resolve(directory, job.promptFile), 120000);
|
|
1349
|
+
if (text.trim())
|
|
1350
|
+
sections.push(`Extra goal instructions from ${job.promptFile}:
|
|
1351
|
+
${text.trim()}`);
|
|
1352
|
+
}
|
|
1353
|
+
if (job.goalAcceptance?.length)
|
|
1354
|
+
sections.push(`Acceptance criteria:
|
|
1355
|
+
` + job.goalAcceptance.map((item, index) => `${index + 1}. ${item}`).join(`
|
|
1356
|
+
`));
|
|
1357
|
+
if (job.goalChecks?.length)
|
|
1358
|
+
sections.push(`Verification commands that define useful evidence:
|
|
1359
|
+
` + job.goalChecks.map((item, index) => `${index + 1}. ${item}`).join(`
|
|
1360
|
+
`));
|
|
1361
|
+
if (job.verifyCommand)
|
|
1362
|
+
sections.push(`Post-turn verify command configured by the loop: ${job.verifyCommand}`);
|
|
1363
|
+
if (job.lastGoalChecks?.length)
|
|
1364
|
+
sections.push(`Latest goal check results:
|
|
1365
|
+
` + job.lastGoalChecks.map((item) => `- ${item.command}: exit ${item.code}`).join(`
|
|
1366
|
+
`));
|
|
1367
|
+
if (job.lastVerifyFailure)
|
|
1368
|
+
sections.push(`Previous verify/check failure summary:
|
|
1369
|
+
` + String(job.lastVerifyFailure).slice(0, 1600));
|
|
1370
|
+
if (job.goalCompletionRejectedReason)
|
|
1371
|
+
sections.push(`Previous completion attempt was rejected:
|
|
1372
|
+
${job.goalCompletionRejectedReason}`);
|
|
1373
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0)
|
|
1374
|
+
sections.push(`No-progress guard:
|
|
1375
|
+
${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS} recent turn(s) without recorded meaningful progress.`);
|
|
1376
|
+
if (job.goalProgress?.length)
|
|
1377
|
+
sections.push(`Recent goal progress:
|
|
1378
|
+
` + job.goalProgress.slice(-5).map((item) => `- ${item.time}: ${item.summary}`).join(`
|
|
1379
|
+
`));
|
|
1174
1380
|
for (const file of job.includeFiles || []) {
|
|
1175
|
-
const text = await readSmallTextFile(
|
|
1176
|
-
if (text.trim())
|
|
1381
|
+
const text = await readSmallTextFile(path3.resolve(directory, file), 80000);
|
|
1382
|
+
if (text.trim())
|
|
1383
|
+
sections.push(`Context from ${file}:
|
|
1384
|
+
${text.trim().slice(0, 20000)}`);
|
|
1177
1385
|
}
|
|
1178
|
-
|
|
1179
1386
|
return `${GOAL_PROMPT_PREFIX}.
|
|
1180
1387
|
|
|
1181
1388
|
You are pursuing an experimental persistent goal for this OpenCode session. This is not a timer loop and not a one-shot prompt. Keep working toward the goal until it is completed, blocked, paused, cleared, or stopped by safety limits.
|
|
@@ -1194,594 +1401,638 @@ Rules:
|
|
|
1194
1401
|
- Do not ask the user questions unless blocked; make reasonable assumptions and continue.
|
|
1195
1402
|
- Follow safety rules: no destructive commands, force pushes, production deploys, production database resets, or deleting user data.
|
|
1196
1403
|
|
|
1197
|
-
${sections.join(
|
|
1198
|
-
}
|
|
1404
|
+
${sections.join(`
|
|
1199
1405
|
|
|
1406
|
+
---
|
|
1407
|
+
|
|
1408
|
+
`)}`;
|
|
1409
|
+
}
|
|
1200
1410
|
async function buildPrompt(directory, job) {
|
|
1201
|
-
if (isGoalJob(job))
|
|
1202
|
-
|
|
1411
|
+
if (isGoalJob(job))
|
|
1412
|
+
return await buildGoalPrompt(directory, job);
|
|
1413
|
+
const sections = [];
|
|
1203
1414
|
if (job.promptFile) {
|
|
1204
|
-
const text = await readSmallTextFile(
|
|
1205
|
-
if (text.trim())
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1415
|
+
const text = await readSmallTextFile(path3.resolve(directory, job.promptFile));
|
|
1416
|
+
if (text.trim())
|
|
1417
|
+
sections.push(`Instructions from ${job.promptFile}:
|
|
1418
|
+
${text.trim()}`);
|
|
1419
|
+
else
|
|
1420
|
+
sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`);
|
|
1421
|
+
}
|
|
1422
|
+
if (job.action)
|
|
1423
|
+
sections.push(decoratePrompt(job));
|
|
1209
1424
|
for (const file of job.includeFiles || []) {
|
|
1210
|
-
const text = await readSmallTextFile(
|
|
1211
|
-
if (text.trim())
|
|
1425
|
+
const text = await readSmallTextFile(path3.resolve(directory, file), 80000);
|
|
1426
|
+
if (text.trim())
|
|
1427
|
+
sections.push(`Context from ${file}:
|
|
1428
|
+
${text.trim().slice(0, 20000)}`);
|
|
1212
1429
|
}
|
|
1213
|
-
return sections.join(
|
|
1214
|
-
|
|
1430
|
+
return sections.join(`
|
|
1431
|
+
|
|
1432
|
+
---
|
|
1215
1433
|
|
|
1434
|
+
`) || decoratePrompt(job);
|
|
1435
|
+
}
|
|
1216
1436
|
async function ensureBranch(directory, job, client, sessionID) {
|
|
1217
|
-
if (!job.branch || job.branchDone)
|
|
1218
|
-
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
await
|
|
1226
|
-
|
|
1437
|
+
if (!job.branch || job.branchDone)
|
|
1438
|
+
return job;
|
|
1439
|
+
const branch = safeID(job.branch);
|
|
1440
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
|
|
1441
|
+
if (inRepo.code !== 0) {
|
|
1442
|
+
job.branchDone = true;
|
|
1443
|
+
return job;
|
|
1444
|
+
}
|
|
1445
|
+
let result = await runProcess("git", ["switch", branch], directory, 30000);
|
|
1446
|
+
if (result.code !== 0)
|
|
1447
|
+
result = await runProcess("git", ["switch", "-c", branch], directory, 30000);
|
|
1448
|
+
job.branchDone = true;
|
|
1449
|
+
await toast(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
|
|
1450
|
+
await appendLoopLog(directory, "branch", { sessionID, branch, code: result.code });
|
|
1451
|
+
return job;
|
|
1227
1452
|
}
|
|
1228
|
-
|
|
1229
1453
|
async function maybeCompact(directory, client, sessionID, job) {
|
|
1230
|
-
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount
|
|
1231
|
-
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs)
|
|
1232
|
-
if (!dueRuns && !dueTime)
|
|
1233
|
-
|
|
1454
|
+
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount;
|
|
1455
|
+
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now() - job.lastCompactAt >= job.compactEveryMs);
|
|
1456
|
+
if (!dueRuns && !dueTime)
|
|
1457
|
+
return { job, started: false };
|
|
1458
|
+
beginLoopCompaction(sessionID, job.id, true);
|
|
1234
1459
|
if (await compactSession(directory, client, sessionID, job.model)) {
|
|
1235
|
-
job.lastCompactAt = now()
|
|
1236
|
-
job.lastCompactRunCount = job.runCount || 0
|
|
1237
|
-
return { job, started: true }
|
|
1460
|
+
job.lastCompactAt = now();
|
|
1461
|
+
job.lastCompactRunCount = job.runCount || 0;
|
|
1462
|
+
return { job, started: true };
|
|
1238
1463
|
}
|
|
1239
|
-
loopCompactionRequests.delete(sessionID)
|
|
1240
|
-
return { job, started: false }
|
|
1464
|
+
loopCompactionRequests.delete(sessionID);
|
|
1465
|
+
return { job, started: false };
|
|
1241
1466
|
}
|
|
1242
|
-
|
|
1243
1467
|
async function snapshotPaths(directory, files) {
|
|
1244
|
-
const snapshot = {}
|
|
1468
|
+
const snapshot = {};
|
|
1245
1469
|
for (const file of files || []) {
|
|
1246
1470
|
try {
|
|
1247
|
-
const stat = await
|
|
1248
|
-
snapshot[file] = `${stat.mtimeMs}:${stat.size}
|
|
1249
|
-
} catch {
|
|
1471
|
+
const stat = await fs3.stat(path3.resolve(directory, file));
|
|
1472
|
+
snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
|
|
1473
|
+
} catch {
|
|
1474
|
+
snapshot[file] = "missing";
|
|
1475
|
+
}
|
|
1250
1476
|
}
|
|
1251
|
-
return snapshot
|
|
1477
|
+
return snapshot;
|
|
1252
1478
|
}
|
|
1253
|
-
|
|
1254
1479
|
async function watchChanged(directory, job) {
|
|
1255
|
-
if (!job.watchPaths?.length)
|
|
1256
|
-
|
|
1257
|
-
const
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
1480
|
+
if (!job.watchPaths?.length)
|
|
1481
|
+
return false;
|
|
1482
|
+
const next = await snapshotPaths(directory, job.watchPaths);
|
|
1483
|
+
const previous = job.watchSnapshot || {};
|
|
1484
|
+
const changed = job.watchPaths.some((file) => previous[file] !== next[file]);
|
|
1485
|
+
if (changed)
|
|
1486
|
+
job.watchSnapshot = next;
|
|
1487
|
+
return changed;
|
|
1261
1488
|
}
|
|
1262
|
-
|
|
1263
1489
|
async function fileContains(filePath, needle) {
|
|
1264
1490
|
try {
|
|
1265
|
-
const stat = await
|
|
1266
|
-
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
|
|
1267
|
-
|
|
1268
|
-
|
|
1491
|
+
const stat = await fs3.stat(filePath);
|
|
1492
|
+
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
|
|
1493
|
+
return false;
|
|
1494
|
+
return (await fs3.readFile(filePath, "utf8")).includes(needle);
|
|
1495
|
+
} catch {
|
|
1496
|
+
return false;
|
|
1497
|
+
}
|
|
1269
1498
|
}
|
|
1270
|
-
|
|
1271
1499
|
async function untilReached(directory, job) {
|
|
1272
|
-
if (!job.until)
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1500
|
+
if (!job.until)
|
|
1501
|
+
return false;
|
|
1502
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path3.join(".opencode", "opencode-loop", "until.txt")];
|
|
1503
|
+
for (const file of files)
|
|
1504
|
+
if (await fileContains(path3.resolve(directory, file), job.until))
|
|
1505
|
+
return true;
|
|
1506
|
+
let scanned = 0;
|
|
1276
1507
|
async function walk(current) {
|
|
1277
|
-
if (scanned >= MAX_SCAN_FILES)
|
|
1278
|
-
|
|
1279
|
-
|
|
1508
|
+
if (scanned >= MAX_SCAN_FILES)
|
|
1509
|
+
return false;
|
|
1510
|
+
let entries;
|
|
1511
|
+
try {
|
|
1512
|
+
entries = await fs3.readdir(current, { withFileTypes: true });
|
|
1513
|
+
} catch {
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1280
1516
|
for (const entry of entries) {
|
|
1281
|
-
if (scanned >= MAX_SCAN_FILES)
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1517
|
+
if (scanned >= MAX_SCAN_FILES)
|
|
1518
|
+
return false;
|
|
1519
|
+
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
|
|
1520
|
+
continue;
|
|
1521
|
+
const full = path3.join(current, entry.name);
|
|
1522
|
+
if (entry.isDirectory()) {
|
|
1523
|
+
if (await walk(full))
|
|
1524
|
+
return true;
|
|
1525
|
+
} else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) {
|
|
1526
|
+
scanned++;
|
|
1527
|
+
if (await fileContains(full, job.until))
|
|
1528
|
+
return true;
|
|
1529
|
+
}
|
|
1286
1530
|
}
|
|
1287
|
-
return false
|
|
1531
|
+
return false;
|
|
1288
1532
|
}
|
|
1289
|
-
return await walk(directory)
|
|
1533
|
+
return await walk(directory);
|
|
1290
1534
|
}
|
|
1291
|
-
|
|
1292
1535
|
async function createCheckpoint(directory, sessionID, job, client) {
|
|
1293
|
-
if (!job.checkpointOnly && !job.gitCheckpoint)
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
const
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
await
|
|
1305
|
-
await
|
|
1536
|
+
if (!job.checkpointOnly && !job.gitCheckpoint)
|
|
1537
|
+
return;
|
|
1538
|
+
const inRepo = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
|
|
1539
|
+
if (inRepo.code !== 0)
|
|
1540
|
+
return;
|
|
1541
|
+
const status = await runProcess("git", ["status", "--short"], directory, 30000);
|
|
1542
|
+
if (!status.stdout.trim())
|
|
1543
|
+
return;
|
|
1544
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1545
|
+
const checkpointDir = path3.join(stateDir(directory), "checkpoints", safeID(sessionID));
|
|
1546
|
+
await ensureDir(checkpointDir);
|
|
1547
|
+
const diff = await runProcess("git", ["diff", "--binary"], directory, 120000);
|
|
1548
|
+
const staged = await runProcess("git", ["diff", "--cached", "--binary"], directory, 120000);
|
|
1549
|
+
const prefix = `${timestamp}-${safeID(job.name || job.id)}`;
|
|
1550
|
+
await fs3.writeFile(path3.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
|
|
1551
|
+
await fs3.writeFile(path3.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
|
|
1552
|
+
${staged.stdout}`);
|
|
1306
1553
|
if (job.gitCheckpoint) {
|
|
1307
|
-
await runProcess("git", ["add", "-A"], directory,
|
|
1308
|
-
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory,
|
|
1554
|
+
await runProcess("git", ["add", "-A"], directory, 120000);
|
|
1555
|
+
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
|
|
1309
1556
|
}
|
|
1310
|
-
await toast(client, `Loop checkpoint saved: ${prefix}`, "success")
|
|
1557
|
+
await toast(client, `Loop checkpoint saved: ${prefix}`, "success");
|
|
1311
1558
|
}
|
|
1312
|
-
|
|
1313
1559
|
function updateSessionStatusFromEvent(event) {
|
|
1314
|
-
const sessionID = event?.properties?.sessionID
|
|
1315
|
-
if (typeof sessionID !== "string")
|
|
1560
|
+
const sessionID = event?.properties?.sessionID;
|
|
1561
|
+
if (typeof sessionID !== "string")
|
|
1562
|
+
return;
|
|
1316
1563
|
if (event?.type === "session.idle") {
|
|
1317
|
-
sessionStatuses.set(sessionID, "idle")
|
|
1318
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1319
|
-
return { sessionID, idle: true }
|
|
1564
|
+
sessionStatuses.set(sessionID, "idle");
|
|
1565
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1566
|
+
return { sessionID, idle: true };
|
|
1320
1567
|
}
|
|
1321
1568
|
if (event?.type === "session.status") {
|
|
1322
|
-
const status = event?.properties?.status
|
|
1323
|
-
const type = status && typeof status === "object" ? status.type : undefined
|
|
1569
|
+
const status = event?.properties?.status;
|
|
1570
|
+
const type = status && typeof status === "object" ? status.type : undefined;
|
|
1324
1571
|
if (typeof type === "string") {
|
|
1325
|
-
sessionStatuses.set(sessionID, type)
|
|
1326
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1572
|
+
sessionStatuses.set(sessionID, type);
|
|
1573
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1327
1574
|
}
|
|
1328
|
-
return { sessionID, idle: type === "idle" }
|
|
1575
|
+
return { sessionID, idle: type === "idle" };
|
|
1329
1576
|
}
|
|
1330
|
-
return
|
|
1577
|
+
return;
|
|
1331
1578
|
}
|
|
1332
|
-
|
|
1333
1579
|
function userInterruptSessionFromEvent(event) {
|
|
1334
|
-
if (!["message.updated", "message.created"].includes(String(event?.type || "")))
|
|
1335
|
-
|
|
1336
|
-
const
|
|
1337
|
-
const
|
|
1338
|
-
const
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1341
|
-
if (
|
|
1342
|
-
|
|
1580
|
+
if (!["message.updated", "message.created"].includes(String(event?.type || "")))
|
|
1581
|
+
return;
|
|
1582
|
+
const props = event?.properties || {};
|
|
1583
|
+
const info = props.info || props.message || props;
|
|
1584
|
+
const role = info?.role;
|
|
1585
|
+
const sessionID = info?.sessionID || props.sessionID;
|
|
1586
|
+
const messageID = info?.id || props.messageID;
|
|
1587
|
+
if (role !== "user" || typeof sessionID !== "string")
|
|
1588
|
+
return;
|
|
1589
|
+
if (loopOwnedUserMessageGuardActive(sessionID, messageID))
|
|
1590
|
+
return;
|
|
1591
|
+
return sessionID;
|
|
1343
1592
|
}
|
|
1344
|
-
|
|
1345
1593
|
async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
|
|
1346
|
-
const state = await readState(directory, sessionID)
|
|
1347
|
-
const interruptedAt = now()
|
|
1348
|
-
let count = 0
|
|
1594
|
+
const state = await readState(directory, sessionID);
|
|
1595
|
+
const interruptedAt = now();
|
|
1596
|
+
let count = 0;
|
|
1349
1597
|
state.jobs = (state.jobs || []).map((job) => {
|
|
1350
|
-
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1351
|
-
|
|
1598
|
+
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1599
|
+
return job;
|
|
1600
|
+
count++;
|
|
1352
1601
|
return {
|
|
1353
1602
|
...job,
|
|
1354
1603
|
paused: true,
|
|
1355
1604
|
lastUserInterruptAt: interruptedAt,
|
|
1356
|
-
goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active."
|
|
1357
|
-
}
|
|
1358
|
-
})
|
|
1359
|
-
if (!count)
|
|
1360
|
-
|
|
1361
|
-
await
|
|
1362
|
-
await
|
|
1363
|
-
await
|
|
1364
|
-
|
|
1605
|
+
goalInterruptedReason: "Paused because the user sent a new message while the experimental goal was active."
|
|
1606
|
+
};
|
|
1607
|
+
});
|
|
1608
|
+
if (!count)
|
|
1609
|
+
return false;
|
|
1610
|
+
await writeState(directory, sessionID, state);
|
|
1611
|
+
await toast(client, `Paused ${count} experimental goal(s) because a new user message arrived. Resume with /loop-goal-resume.`, "warning");
|
|
1612
|
+
await appendLoopLog(directory, "goal-user-interrupt", { sessionID, count });
|
|
1613
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1614
|
+
return true;
|
|
1365
1615
|
}
|
|
1366
|
-
|
|
1367
1616
|
function staleActiveRun(sessionID) {
|
|
1368
|
-
const active = activeRuns.get(sessionID)
|
|
1369
|
-
if (!active)
|
|
1370
|
-
|
|
1371
|
-
const
|
|
1372
|
-
const
|
|
1373
|
-
|
|
1617
|
+
const active = activeRuns.get(sessionID);
|
|
1618
|
+
if (!active)
|
|
1619
|
+
return false;
|
|
1620
|
+
const age = now() - (active.startedAt || 0);
|
|
1621
|
+
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0);
|
|
1622
|
+
const threshold = Number.isFinite(configured) && configured > 0 ? configured : STALE_ACTIVE_RECOVERY_MS;
|
|
1623
|
+
return age >= threshold;
|
|
1374
1624
|
}
|
|
1375
|
-
|
|
1376
1625
|
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
1377
|
-
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (!options.requireIdle)
|
|
1385
|
-
|
|
1386
|
-
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1387
|
-
if (live?.type === "idle")
|
|
1626
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
1627
|
+
return false;
|
|
1628
|
+
if (!options.requireIdle && !options.forceStale)
|
|
1629
|
+
return true;
|
|
1630
|
+
const completion = options.forceStale ? await activeRunCompletionFromMessages(directory, client, sessionID, active) : undefined;
|
|
1631
|
+
if (completion === "completed")
|
|
1632
|
+
return true;
|
|
1633
|
+
if (!options.requireIdle)
|
|
1634
|
+
return completion === "unknown" && staleActiveRun(sessionID);
|
|
1635
|
+
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
1636
|
+
if (live?.type === "idle")
|
|
1637
|
+
return true;
|
|
1388
1638
|
if (live?.type) {
|
|
1389
|
-
if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
1390
|
-
|
|
1639
|
+
if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
1640
|
+
return true;
|
|
1641
|
+
return false;
|
|
1391
1642
|
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
const cached = sessionStatuses.get(sessionID)
|
|
1395
|
-
const seenAt = sessionStatusSeenAt.get(sessionID) || 0
|
|
1396
|
-
return cached === "idle" && seenAt > (active.startedAt || 0)
|
|
1643
|
+
if (options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
1644
|
+
return true;
|
|
1645
|
+
const cached = sessionStatuses.get(sessionID);
|
|
1646
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
1647
|
+
return cached === "idle" && seenAt > (active.startedAt || 0);
|
|
1397
1648
|
}
|
|
1398
|
-
|
|
1399
1649
|
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
1400
|
-
const argsList = []
|
|
1401
|
-
if (directory)
|
|
1402
|
-
|
|
1650
|
+
const argsList = [];
|
|
1651
|
+
if (directory)
|
|
1652
|
+
argsList.push({ query: { directory } }, { directory }, { workspace: directory });
|
|
1653
|
+
argsList.push({});
|
|
1403
1654
|
for (const args of argsList) {
|
|
1404
1655
|
try {
|
|
1405
|
-
const result = await client.session.status(args)
|
|
1406
|
-
const error = sdkError(result)
|
|
1407
|
-
if (error)
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1656
|
+
const result = await client.session.status(args);
|
|
1657
|
+
const error = sdkError(result);
|
|
1658
|
+
if (error)
|
|
1659
|
+
continue;
|
|
1660
|
+
const data = sdkData(result);
|
|
1661
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
1662
|
+
continue;
|
|
1663
|
+
const observedAt = now();
|
|
1411
1664
|
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
1412
|
-
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined
|
|
1413
|
-
if (typeof observedType !== "string")
|
|
1414
|
-
|
|
1415
|
-
|
|
1665
|
+
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined;
|
|
1666
|
+
if (typeof observedType !== "string")
|
|
1667
|
+
continue;
|
|
1668
|
+
sessionStatuses.set(observedSessionID, observedType);
|
|
1669
|
+
sessionStatusSeenAt.set(observedSessionID, observedAt);
|
|
1416
1670
|
}
|
|
1417
|
-
// OpenCode's status list contains active sessions; idle sessions are
|
|
1418
|
-
// normally omitted. Clear a completed descendant that was previously busy.
|
|
1419
1671
|
for (const childID of sessionParents.keys()) {
|
|
1420
|
-
if (!isDescendantSession(childID, sessionID) || data[childID])
|
|
1421
|
-
|
|
1422
|
-
|
|
1672
|
+
if (!isDescendantSession(childID, sessionID) || data[childID])
|
|
1673
|
+
continue;
|
|
1674
|
+
sessionStatuses.set(childID, "idle");
|
|
1675
|
+
sessionStatusSeenAt.set(childID, observedAt);
|
|
1423
1676
|
}
|
|
1424
|
-
if (hasBusyDescendant(sessionID))
|
|
1425
|
-
|
|
1426
|
-
const
|
|
1427
|
-
|
|
1428
|
-
|
|
1677
|
+
if (hasBusyDescendant(sessionID))
|
|
1678
|
+
return { type: "busy", source: "descendant" };
|
|
1679
|
+
const status = data?.[sessionID];
|
|
1680
|
+
const type = status && typeof status === "object" ? status.type : undefined;
|
|
1681
|
+
if (typeof type === "string")
|
|
1682
|
+
return { type, source: "sdk" };
|
|
1683
|
+
return { type: "idle", source: "sdk" };
|
|
1429
1684
|
} catch {}
|
|
1430
1685
|
}
|
|
1431
|
-
return
|
|
1686
|
+
return;
|
|
1432
1687
|
}
|
|
1433
|
-
|
|
1434
1688
|
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
1435
|
-
// OpenCode can briefly report an idle session while a long-running tool or
|
|
1436
|
-
// subtask is still executing. Tool lifecycle hooks are the more specific
|
|
1437
|
-
// signal here, so never enqueue another turn until every active call ends.
|
|
1438
1689
|
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
1439
|
-
sessionStatuses.set(sessionID, "busy")
|
|
1440
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1441
|
-
return "busy"
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
// idle event after that turn. If we cache busy forever, due loop work can get
|
|
1451
|
-
// stuck at "due in every idle" until the user types another command.
|
|
1452
|
-
if (cached === "idle") return cached
|
|
1453
|
-
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS) return cached
|
|
1454
|
-
|
|
1455
|
-
const live = await readLiveSessionStatus(client, sessionID, directory)
|
|
1690
|
+
sessionStatuses.set(sessionID, "busy");
|
|
1691
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1692
|
+
return "busy";
|
|
1693
|
+
}
|
|
1694
|
+
const cached = sessionStatuses.get(sessionID);
|
|
1695
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
1696
|
+
if (cached === "idle")
|
|
1697
|
+
return cached;
|
|
1698
|
+
if (cached && now() - seenAt < SESSION_STATUS_CACHE_MS)
|
|
1699
|
+
return cached;
|
|
1700
|
+
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
1456
1701
|
if (live?.type) {
|
|
1457
|
-
// Some OpenCode 1.15.x TUI builds can leave session.status at busy after a
|
|
1458
|
-
// plugin-injected turn until the next user command touches the session.
|
|
1459
|
-
// When the only reason we still think the session is busy is our own stale
|
|
1460
|
-
// active-run guard, recover instead of waiting for another manual command.
|
|
1461
1702
|
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
1462
|
-
const active = activeRuns.get(sessionID)
|
|
1703
|
+
const active = activeRuns.get(sessionID);
|
|
1463
1704
|
if (active) {
|
|
1464
|
-
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active)
|
|
1465
|
-
if (completion === "completed" ||
|
|
1466
|
-
sessionStatuses.set(sessionID, "idle")
|
|
1467
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1705
|
+
const completion = await activeRunCompletionFromMessages(directory, client, sessionID, active);
|
|
1706
|
+
if (completion === "completed" || completion === "unknown" && staleActiveRun(sessionID)) {
|
|
1707
|
+
sessionStatuses.set(sessionID, "idle");
|
|
1708
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1468
1709
|
await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
|
|
1469
1710
|
sessionID,
|
|
1470
1711
|
job: active.job?.name || active.jobId,
|
|
1471
|
-
startedAt: active.startedAt
|
|
1472
|
-
})
|
|
1473
|
-
return "idle"
|
|
1712
|
+
startedAt: active.startedAt
|
|
1713
|
+
});
|
|
1714
|
+
return "idle";
|
|
1474
1715
|
}
|
|
1475
1716
|
}
|
|
1476
1717
|
}
|
|
1477
|
-
sessionStatuses.set(sessionID, live.type)
|
|
1478
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
1479
|
-
return live.type
|
|
1718
|
+
sessionStatuses.set(sessionID, live.type);
|
|
1719
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1720
|
+
return live.type;
|
|
1480
1721
|
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
return fallback
|
|
1722
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle";
|
|
1723
|
+
sessionStatuses.set(sessionID, fallback);
|
|
1724
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1725
|
+
return fallback;
|
|
1486
1726
|
}
|
|
1487
|
-
|
|
1488
1727
|
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
1489
|
-
return await sessionStatusType(client, sessionID, directory, options) === "idle"
|
|
1728
|
+
return await sessionStatusType(client, sessionID, directory, options) === "idle";
|
|
1490
1729
|
}
|
|
1491
|
-
|
|
1492
1730
|
function scheduleIdleWork(directory, client, sessionID) {
|
|
1493
|
-
const previous = idleTimers.get(sessionID)
|
|
1494
|
-
if (previous)
|
|
1731
|
+
const previous = idleTimers.get(sessionID);
|
|
1732
|
+
if (previous)
|
|
1733
|
+
clearTimeout(previous);
|
|
1495
1734
|
const timer = setTimeout(() => {
|
|
1496
|
-
idleTimers.delete(sessionID)
|
|
1497
|
-
Promise.resolve()
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
})
|
|
1510
|
-
.catch((
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
}, IDLE_DEBOUNCE_MS)
|
|
1515
|
-
idleTimers.set(sessionID, timer)
|
|
1735
|
+
idleTimers.delete(sessionID);
|
|
1736
|
+
Promise.resolve().then(async () => {
|
|
1737
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1738
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS);
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
await finalizeActiveRun(directory, client, sessionID);
|
|
1742
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1743
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS);
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
await maybeRunDueJobs(directory, client, sessionID);
|
|
1747
|
+
}).catch((error) => {
|
|
1748
|
+
toast(client, `Loop idle handler failed: ${sdkErrorMessage(error)}`, "error").catch(() => {});
|
|
1749
|
+
appendLoopLog(directory, "idle-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {});
|
|
1750
|
+
});
|
|
1751
|
+
}, IDLE_DEBOUNCE_MS);
|
|
1752
|
+
idleTimers.set(sessionID, timer);
|
|
1516
1753
|
}
|
|
1517
|
-
|
|
1518
1754
|
function jobDueAt(job, current = now()) {
|
|
1519
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1520
|
-
|
|
1521
|
-
if (job.
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
if (job.
|
|
1526
|
-
|
|
1527
|
-
|
|
1755
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1756
|
+
return Infinity;
|
|
1757
|
+
if (!job.enabled || job.paused)
|
|
1758
|
+
return Infinity;
|
|
1759
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
1760
|
+
return Infinity;
|
|
1761
|
+
if (job.watchPaths?.length)
|
|
1762
|
+
return Infinity;
|
|
1763
|
+
const created = Date.parse(job.createdAt || new Date().toISOString());
|
|
1764
|
+
if (job.maxRuntimeMs > 0 && current - created >= job.maxRuntimeMs)
|
|
1765
|
+
return current;
|
|
1766
|
+
if (job.intervalMs === 0)
|
|
1767
|
+
return current;
|
|
1768
|
+
if (!job.lastRunAt)
|
|
1769
|
+
return current;
|
|
1770
|
+
return job.lastRunAt + (job.intervalMs || 0);
|
|
1528
1771
|
}
|
|
1529
|
-
|
|
1530
1772
|
function nextDueDelay(state) {
|
|
1531
|
-
const current = now()
|
|
1532
|
-
let soonest = Infinity
|
|
1533
|
-
for (const job of state.jobs || [])
|
|
1534
|
-
|
|
1535
|
-
|
|
1773
|
+
const current = now();
|
|
1774
|
+
let soonest = Infinity;
|
|
1775
|
+
for (const job of state.jobs || [])
|
|
1776
|
+
soonest = Math.min(soonest, jobDueAt(job, current));
|
|
1777
|
+
if (!Number.isFinite(soonest))
|
|
1778
|
+
return Infinity;
|
|
1779
|
+
return Math.max(0, soonest - current);
|
|
1536
1780
|
}
|
|
1537
|
-
|
|
1538
1781
|
async function startWatchdog(directory, client, sessionID) {
|
|
1539
|
-
if (watchdogTimers.has(sessionID))
|
|
1782
|
+
if (watchdogTimers.has(sessionID))
|
|
1783
|
+
return;
|
|
1540
1784
|
const timer = setInterval(() => {
|
|
1541
|
-
Promise.resolve()
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
// when no manual /loop-status command is typed.
|
|
1560
|
-
watchdogTimers.set(sessionID, timer)
|
|
1785
|
+
Promise.resolve().then(async () => {
|
|
1786
|
+
const state = await readState(directory, sessionID);
|
|
1787
|
+
const delay2 = nextDueDelay(state);
|
|
1788
|
+
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)));
|
|
1789
|
+
if (!hasJobs || !Number.isFinite(delay2)) {
|
|
1790
|
+
const existing = watchdogTimers.get(sessionID);
|
|
1791
|
+
if (existing)
|
|
1792
|
+
clearInterval(existing);
|
|
1793
|
+
watchdogTimers.delete(sessionID);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
if (delay2 <= 0)
|
|
1797
|
+
await maybeRunDueJobs(directory, client, sessionID);
|
|
1798
|
+
else
|
|
1799
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1800
|
+
}).catch((error) => appendLoopLog(directory, "watchdog-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}));
|
|
1801
|
+
}, Math.max(1000, BUSY_RETRY_MS));
|
|
1802
|
+
watchdogTimers.set(sessionID, timer);
|
|
1561
1803
|
}
|
|
1562
|
-
|
|
1563
1804
|
function stopWatchdog(sessionID) {
|
|
1564
|
-
const timer = watchdogTimers.get(sessionID)
|
|
1565
|
-
if (timer)
|
|
1566
|
-
|
|
1805
|
+
const timer = watchdogTimers.get(sessionID);
|
|
1806
|
+
if (timer)
|
|
1807
|
+
clearInterval(timer);
|
|
1808
|
+
watchdogTimers.delete(sessionID);
|
|
1567
1809
|
}
|
|
1568
|
-
|
|
1569
1810
|
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
1570
|
-
const previous = dueTimers.get(sessionID)
|
|
1571
|
-
if (previous)
|
|
1572
|
-
|
|
1573
|
-
const state = await readState(directory, sessionID)
|
|
1574
|
-
const
|
|
1575
|
-
if (!Number.isFinite(
|
|
1576
|
-
dueTimers.delete(sessionID)
|
|
1577
|
-
return
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
const wait = Math.min(Math.max(delay, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS)
|
|
1811
|
+
const previous = dueTimers.get(sessionID);
|
|
1812
|
+
if (previous)
|
|
1813
|
+
clearTimeout(previous);
|
|
1814
|
+
const state = await readState(directory, sessionID);
|
|
1815
|
+
const delay2 = nextDueDelay(state);
|
|
1816
|
+
if (!Number.isFinite(delay2)) {
|
|
1817
|
+
dueTimers.delete(sessionID);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
const wait = Math.min(Math.max(delay2, minDelayMs, MIN_DUE_TIMER_MS), MAX_DUE_TIMER_MS);
|
|
1581
1821
|
const timer = setTimeout(() => {
|
|
1582
|
-
dueTimers.delete(sessionID)
|
|
1583
|
-
Promise.resolve()
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
})
|
|
1596
|
-
.catch((
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
dueTimers.set(sessionID, timer)
|
|
1602
|
-
await startWatchdog(directory, client, sessionID)
|
|
1822
|
+
dueTimers.delete(sessionID);
|
|
1823
|
+
Promise.resolve().then(async () => {
|
|
1824
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1825
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS);
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
await finalizeActiveRun(directory, client, sessionID);
|
|
1829
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
1830
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
await maybeRunDueJobs(directory, client, sessionID);
|
|
1834
|
+
}).catch((error) => {
|
|
1835
|
+
toast(client, `Loop due timer failed: ${sdkErrorMessage(error)}`, "error").catch(() => {});
|
|
1836
|
+
appendLoopLog(directory, "due-timer-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {});
|
|
1837
|
+
});
|
|
1838
|
+
}, wait);
|
|
1839
|
+
dueTimers.set(sessionID, timer);
|
|
1840
|
+
await startWatchdog(directory, client, sessionID);
|
|
1603
1841
|
}
|
|
1604
|
-
|
|
1605
1842
|
function dueJobs(state, force = false) {
|
|
1606
|
-
const current = now()
|
|
1843
|
+
const current = now();
|
|
1607
1844
|
return (state.jobs || []).filter((job) => {
|
|
1608
|
-
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1609
|
-
|
|
1610
|
-
if (job.
|
|
1611
|
-
|
|
1612
|
-
if (
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1845
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1846
|
+
return false;
|
|
1847
|
+
if (!job.enabled || job.paused)
|
|
1848
|
+
return false;
|
|
1849
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
1850
|
+
return false;
|
|
1851
|
+
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs)
|
|
1852
|
+
return true;
|
|
1853
|
+
if (force)
|
|
1854
|
+
return true;
|
|
1855
|
+
if (job.watchPaths?.length)
|
|
1856
|
+
return job.watchTriggered === true;
|
|
1857
|
+
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs;
|
|
1858
|
+
});
|
|
1616
1859
|
}
|
|
1617
|
-
|
|
1618
1860
|
function clearActiveRun(sessionID) {
|
|
1619
|
-
const active = activeRuns.get(sessionID)
|
|
1620
|
-
if (active?.timer)
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1861
|
+
const active = activeRuns.get(sessionID);
|
|
1862
|
+
if (active?.timer)
|
|
1863
|
+
clearTimeout(active.timer);
|
|
1864
|
+
const compact = loopCompactionRequests.get(sessionID);
|
|
1865
|
+
if (!compact || !active || compact.jobId === active.jobId)
|
|
1866
|
+
loopCompactionRequests.delete(sessionID);
|
|
1867
|
+
activeRuns.delete(sessionID);
|
|
1624
1868
|
}
|
|
1625
|
-
|
|
1626
1869
|
function beginLoopCompaction(sessionID, jobId, resumeAfter = false) {
|
|
1627
1870
|
loopCompactionRequests.set(sessionID, {
|
|
1628
1871
|
jobId,
|
|
1629
1872
|
resumeAfter,
|
|
1630
1873
|
requestedAt: now(),
|
|
1631
1874
|
startedAt: 0,
|
|
1632
|
-
completedAt: 0
|
|
1633
|
-
})
|
|
1875
|
+
completedAt: 0
|
|
1876
|
+
});
|
|
1634
1877
|
}
|
|
1635
|
-
|
|
1636
1878
|
async function noteLoopCompactionStarted(directory, sessionID) {
|
|
1637
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1638
|
-
if (!pending)
|
|
1879
|
+
const pending = loopCompactionRequests.get(sessionID);
|
|
1880
|
+
if (!pending)
|
|
1881
|
+
return false;
|
|
1639
1882
|
if (!pending.startedAt) {
|
|
1640
|
-
pending.startedAt = now()
|
|
1641
|
-
loopCompactionRequests.set(sessionID, pending)
|
|
1642
|
-
await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter })
|
|
1883
|
+
pending.startedAt = now();
|
|
1884
|
+
loopCompactionRequests.set(sessionID, pending);
|
|
1885
|
+
await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter });
|
|
1643
1886
|
}
|
|
1644
|
-
return true
|
|
1887
|
+
return true;
|
|
1645
1888
|
}
|
|
1646
|
-
|
|
1647
1889
|
async function finalizeLoopCompaction(directory, client, sessionID) {
|
|
1648
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1649
|
-
const active = activeRuns.get(sessionID)
|
|
1650
|
-
if (!pending || !active || pending.jobId !== active.jobId)
|
|
1651
|
-
|
|
1890
|
+
const pending = loopCompactionRequests.get(sessionID);
|
|
1891
|
+
const active = activeRuns.get(sessionID);
|
|
1892
|
+
if (!pending || !active || pending.jobId !== active.jobId)
|
|
1893
|
+
return false;
|
|
1894
|
+
return await finalizeActiveRun(directory, client, sessionID);
|
|
1652
1895
|
}
|
|
1653
|
-
|
|
1654
1896
|
async function noteLoopCompactionCompleted(directory, client, sessionID) {
|
|
1655
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1656
|
-
if (!pending)
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1897
|
+
const pending = loopCompactionRequests.get(sessionID);
|
|
1898
|
+
if (!pending)
|
|
1899
|
+
return false;
|
|
1900
|
+
pending.completedAt = now();
|
|
1901
|
+
loopCompactionRequests.set(sessionID, pending);
|
|
1902
|
+
await appendLoopLog(directory, "compact-event", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter });
|
|
1660
1903
|
const timer = setTimeout(() => {
|
|
1661
|
-
finalizeLoopCompaction(directory, client, sessionID)
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
return true
|
|
1904
|
+
finalizeLoopCompaction(directory, client, sessionID).catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) }));
|
|
1905
|
+
}, 0);
|
|
1906
|
+
timer.unref?.();
|
|
1907
|
+
return true;
|
|
1666
1908
|
}
|
|
1667
|
-
|
|
1668
1909
|
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
1669
|
-
const active = activeRuns.get(sessionID)
|
|
1670
|
-
if (!active || active.jobId !== jobId || active.runToken !== runToken)
|
|
1671
|
-
|
|
1672
|
-
clearActiveRun(sessionID)
|
|
1673
|
-
sessionStatuses.delete(sessionID)
|
|
1674
|
-
sessionStatusSeenAt.delete(sessionID)
|
|
1675
|
-
|
|
1676
|
-
const
|
|
1677
|
-
const
|
|
1678
|
-
const job = (state.jobs || []).find((candidate) => candidate.id === jobId)
|
|
1910
|
+
const active = activeRuns.get(sessionID);
|
|
1911
|
+
if (!active || active.jobId !== jobId || active.runToken !== runToken)
|
|
1912
|
+
return false;
|
|
1913
|
+
clearActiveRun(sessionID);
|
|
1914
|
+
sessionStatuses.delete(sessionID);
|
|
1915
|
+
sessionStatusSeenAt.delete(sessionID);
|
|
1916
|
+
const message = sdkErrorMessage(error);
|
|
1917
|
+
const state = await readState(directory, sessionID);
|
|
1918
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
|
|
1679
1919
|
if (job) {
|
|
1680
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1681
|
-
job.lastFailureReason = "dispatch_failed"
|
|
1682
|
-
job.lastDispatchFailure = message.slice(0, 4000)
|
|
1683
|
-
job.lastDispatchFailureAt = now()
|
|
1920
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
1921
|
+
job.lastFailureReason = "dispatch_failed";
|
|
1922
|
+
job.lastDispatchFailure = message.slice(0, 4000);
|
|
1923
|
+
job.lastDispatchFailureAt = now();
|
|
1684
1924
|
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1685
|
-
job.paused = true
|
|
1686
|
-
await notifyJob(directory, job, "dispatch_failed")
|
|
1925
|
+
job.paused = true;
|
|
1926
|
+
await notifyJob(directory, job, "dispatch_failed");
|
|
1687
1927
|
}
|
|
1688
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1689
|
-
await writeState(directory, sessionID, state)
|
|
1928
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
1929
|
+
await writeState(directory, sessionID, state);
|
|
1690
1930
|
}
|
|
1691
|
-
|
|
1692
|
-
await
|
|
1693
|
-
await
|
|
1694
|
-
|
|
1695
|
-
return true
|
|
1931
|
+
await appendLoopLog(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message });
|
|
1932
|
+
await toast(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
|
|
1933
|
+
await scheduleDueWork(directory, client, sessionID, BUSY_RETRY_MS);
|
|
1934
|
+
return true;
|
|
1696
1935
|
}
|
|
1697
|
-
|
|
1698
1936
|
function goalReportPath(directory, sessionID, job) {
|
|
1699
|
-
return
|
|
1937
|
+
return path3.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`);
|
|
1700
1938
|
}
|
|
1701
|
-
|
|
1702
1939
|
function goalReportText(job) {
|
|
1703
|
-
const lines = []
|
|
1704
|
-
lines.push(`# OpenCode Loop Goal Report`)
|
|
1705
|
-
lines.push("")
|
|
1706
|
-
lines.push(`Status: ${goalStatusText(job) || "unknown"}`)
|
|
1707
|
-
lines.push(`Goal: ${job.action || job.goalFile || ""}`)
|
|
1708
|
-
lines.push(`Created: ${job.createdAt || ""}`)
|
|
1709
|
-
if (job.goalCompletedAt)
|
|
1710
|
-
|
|
1711
|
-
if (job.
|
|
1712
|
-
|
|
1713
|
-
if (job.
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
if (job.
|
|
1718
|
-
|
|
1719
|
-
if (job.
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
if (job.
|
|
1940
|
+
const lines = [];
|
|
1941
|
+
lines.push(`# OpenCode Loop Goal Report`);
|
|
1942
|
+
lines.push("");
|
|
1943
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`);
|
|
1944
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`);
|
|
1945
|
+
lines.push(`Created: ${job.createdAt || ""}`);
|
|
1946
|
+
if (job.goalCompletedAt)
|
|
1947
|
+
lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`);
|
|
1948
|
+
if (job.goalBlockedAt)
|
|
1949
|
+
lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`);
|
|
1950
|
+
if (job.lastUserInterruptAt)
|
|
1951
|
+
lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`);
|
|
1952
|
+
if (job.goalNoProgressPausedAt)
|
|
1953
|
+
lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`);
|
|
1954
|
+
if (job.runCount)
|
|
1955
|
+
lines.push(`Turns: ${job.runCount}`);
|
|
1956
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0)
|
|
1957
|
+
lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`);
|
|
1958
|
+
lines.push("");
|
|
1959
|
+
if (job.goalSummary)
|
|
1960
|
+
lines.push("## Summary", "", String(job.goalSummary), "");
|
|
1961
|
+
if (job.goalEvidence)
|
|
1962
|
+
lines.push("## Evidence", "", String(job.goalEvidence), "");
|
|
1963
|
+
if (job.goalBlockedReason)
|
|
1964
|
+
lines.push("## Blocked reason", "", String(job.goalBlockedReason), "");
|
|
1965
|
+
if (job.goalCompletionRejectedReason)
|
|
1966
|
+
lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "");
|
|
1967
|
+
if (job.goalInterruptedReason)
|
|
1968
|
+
lines.push("## Interrupt", "", String(job.goalInterruptedReason), "");
|
|
1969
|
+
if (job.goalNoProgressReason)
|
|
1970
|
+
lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "");
|
|
1971
|
+
if (job.goalAcceptance?.length)
|
|
1972
|
+
lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "");
|
|
1723
1973
|
if (job.lastGoalChecks?.length) {
|
|
1724
|
-
lines.push("## Latest checks", "")
|
|
1725
|
-
for (const item of job.lastGoalChecks)
|
|
1726
|
-
|
|
1974
|
+
lines.push("## Latest checks", "");
|
|
1975
|
+
for (const item of job.lastGoalChecks)
|
|
1976
|
+
lines.push(`- ${item.command}: exit ${item.code}`);
|
|
1977
|
+
lines.push("");
|
|
1727
1978
|
}
|
|
1728
1979
|
if (job.goalProgress?.length) {
|
|
1729
|
-
lines.push("## Progress", "")
|
|
1730
|
-
for (const item of job.goalProgress)
|
|
1731
|
-
|
|
1980
|
+
lines.push("## Progress", "");
|
|
1981
|
+
for (const item of job.goalProgress)
|
|
1982
|
+
lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`);
|
|
1983
|
+
lines.push("");
|
|
1732
1984
|
}
|
|
1733
|
-
return lines.join(
|
|
1985
|
+
return lines.join(`
|
|
1986
|
+
`);
|
|
1734
1987
|
}
|
|
1735
|
-
|
|
1736
1988
|
async function writeGoalReport(directory, sessionID, job) {
|
|
1737
|
-
if (!isGoalJob(job))
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
await
|
|
1989
|
+
if (!isGoalJob(job))
|
|
1990
|
+
return;
|
|
1991
|
+
const target = job.goalEvidenceFile ? path3.resolve(directory, job.goalEvidenceFile) : goalReportPath(directory, sessionID, job);
|
|
1992
|
+
await ensureDir(path3.dirname(target));
|
|
1993
|
+
await fs3.writeFile(target, goalReportText(job), "utf8");
|
|
1741
1994
|
}
|
|
1742
|
-
|
|
1743
1995
|
function pickGoalJob(state, target = "") {
|
|
1744
|
-
const goals = (state.jobs || []).filter(isGoalJob)
|
|
1745
|
-
if (!goals.length)
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1996
|
+
const goals = (state.jobs || []).filter(isGoalJob);
|
|
1997
|
+
if (!goals.length)
|
|
1998
|
+
return;
|
|
1999
|
+
const text = String(target || "").trim();
|
|
2000
|
+
if (!text || ["active", "current", "goal"].includes(text.toLowerCase()))
|
|
2001
|
+
return goals.find((job) => job.goalStatus === "active" && job.enabled !== false) || goals[0];
|
|
2002
|
+
return goals.find((job, index) => matchJob(job, text, index));
|
|
1749
2003
|
}
|
|
1750
|
-
|
|
1751
2004
|
function parseGoalToolText(args, fields) {
|
|
1752
|
-
const result = {}
|
|
1753
|
-
for (const field of fields)
|
|
1754
|
-
|
|
2005
|
+
const result = {};
|
|
2006
|
+
for (const field of fields)
|
|
2007
|
+
result[field] = String(args?.[field] || "").trim();
|
|
2008
|
+
return result;
|
|
1755
2009
|
}
|
|
1756
|
-
|
|
1757
2010
|
function hasConcreteGoalEvidence(value) {
|
|
1758
|
-
const text = String(value || "").trim()
|
|
1759
|
-
if (text.length < 24)
|
|
1760
|
-
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
|
|
2011
|
+
const text = String(value || "").trim();
|
|
2012
|
+
if (text.length < 24)
|
|
2013
|
+
return false;
|
|
2014
|
+
const normalized = text.toLowerCase().replace(/\s+/g, " ");
|
|
2015
|
+
const weak = new Set(["done", "complete", "completed", "ok", "looks good", "n/a", "none", "no evidence", "no evidence provided", "goal completed", "marked complete"]);
|
|
2016
|
+
if (weak.has(normalized) || normalized.startsWith("marked complete by /loop-goal-done"))
|
|
2017
|
+
return false;
|
|
2018
|
+
return /(\b(npm|pnpm|yarn|bun|node|pytest|cargo|dotnet|go test|tsc|typecheck|test|tests|lint|build|check|checks|exit\s*\d|passed|verified|changed|updated|created|fixed|file|files|diff|commit)\b|[`\\/][\w./:-]+)/i.test(text) || text.length >= 80;
|
|
1764
2019
|
}
|
|
1765
|
-
|
|
1766
2020
|
function goalChecksPassed(job) {
|
|
1767
|
-
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0)
|
|
2021
|
+
return Array.isArray(job?.lastGoalChecks) && job.lastGoalChecks.length > 0 && job.lastGoalChecks.every((item) => Number(item?.code) === 0);
|
|
1768
2022
|
}
|
|
1769
|
-
|
|
1770
2023
|
function goalRequiresPassingChecks(job) {
|
|
1771
|
-
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0
|
|
2024
|
+
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0;
|
|
1772
2025
|
}
|
|
1773
|
-
|
|
1774
2026
|
async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
1775
|
-
job.goalCompletionRejectedAt = now()
|
|
1776
|
-
job.goalCompletionRejectedReason = reason
|
|
1777
|
-
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1
|
|
1778
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1779
|
-
await writeState(directory, sessionID, state)
|
|
1780
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1781
|
-
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason })
|
|
1782
|
-
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` }
|
|
2027
|
+
job.goalCompletionRejectedAt = now();
|
|
2028
|
+
job.goalCompletionRejectedReason = reason;
|
|
2029
|
+
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1;
|
|
2030
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2031
|
+
await writeState(directory, sessionID, state);
|
|
2032
|
+
await writeGoalReport(directory, sessionID, job);
|
|
2033
|
+
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason });
|
|
2034
|
+
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` };
|
|
1783
2035
|
}
|
|
1784
|
-
|
|
1785
2036
|
function goalProgressSnapshot(job) {
|
|
1786
2037
|
return {
|
|
1787
2038
|
status: job?.goalStatus || "",
|
|
@@ -1790,653 +2041,668 @@ function goalProgressSnapshot(job) {
|
|
|
1790
2041
|
checksPassedAt: Number(job?.goalChecksPassedAt || 0),
|
|
1791
2042
|
lastGoalCheckAt: Number(job?.lastGoalCheckAt || 0),
|
|
1792
2043
|
lastVerifyAt: Number(job?.lastVerifyAt || 0),
|
|
1793
|
-
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined
|
|
1794
|
-
}
|
|
2044
|
+
lastVerifyCode: Number.isFinite(Number(job?.lastVerifyCode)) ? Number(job.lastVerifyCode) : undefined
|
|
2045
|
+
};
|
|
1795
2046
|
}
|
|
1796
|
-
|
|
1797
2047
|
function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
1798
|
-
const before = goalProgressSnapshot(beforeJob || {})
|
|
1799
|
-
const after = goalProgressSnapshot(afterJob || {})
|
|
1800
|
-
if (["completed", "blocked"].includes(after.status) && after.status !== before.status)
|
|
1801
|
-
|
|
1802
|
-
if (after.
|
|
1803
|
-
|
|
1804
|
-
if (after.
|
|
1805
|
-
|
|
2048
|
+
const before = goalProgressSnapshot(beforeJob || {});
|
|
2049
|
+
const after = goalProgressSnapshot(afterJob || {});
|
|
2050
|
+
if (["completed", "blocked"].includes(after.status) && after.status !== before.status)
|
|
2051
|
+
return true;
|
|
2052
|
+
if (after.progressCount > before.progressCount)
|
|
2053
|
+
return true;
|
|
2054
|
+
if (after.evidence !== before.evidence && hasConcreteGoalEvidence(after.evidence))
|
|
2055
|
+
return true;
|
|
2056
|
+
if (after.checksPassedAt > before.checksPassedAt || goalChecksPassed(afterJob) && after.lastGoalCheckAt > before.lastGoalCheckAt)
|
|
2057
|
+
return true;
|
|
2058
|
+
if (after.lastVerifyAt > before.lastVerifyAt && after.lastVerifyCode === 0)
|
|
2059
|
+
return true;
|
|
2060
|
+
return false;
|
|
1806
2061
|
}
|
|
1807
|
-
|
|
1808
2062
|
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
1809
|
-
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false)
|
|
1810
|
-
|
|
1811
|
-
|
|
2063
|
+
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false)
|
|
2064
|
+
return job;
|
|
2065
|
+
const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS);
|
|
2066
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
2067
|
+
return job;
|
|
1812
2068
|
if (goalMadeMeaningfulProgress(beforeJob, job)) {
|
|
1813
|
-
job.noProgressCount = 0
|
|
1814
|
-
job.lastProgressAt = now()
|
|
1815
|
-
return job
|
|
2069
|
+
job.noProgressCount = 0;
|
|
2070
|
+
job.lastProgressAt = now();
|
|
2071
|
+
return job;
|
|
1816
2072
|
}
|
|
1817
|
-
job.noProgressCount = (job.noProgressCount || 0) + 1
|
|
1818
|
-
job.lastNoProgressAt = now()
|
|
1819
|
-
await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
2073
|
+
job.noProgressCount = (job.noProgressCount || 0) + 1;
|
|
2074
|
+
job.lastNoProgressAt = now();
|
|
2075
|
+
await appendLoopLog(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
1820
2076
|
if (job.noProgressCount >= limit) {
|
|
1821
|
-
job.paused = true
|
|
1822
|
-
job.goalNoProgressPausedAt = now()
|
|
1823
|
-
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence
|
|
1824
|
-
await toast(client, job.goalNoProgressReason, "warning")
|
|
1825
|
-
await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit })
|
|
2077
|
+
job.paused = true;
|
|
2078
|
+
job.goalNoProgressPausedAt = now();
|
|
2079
|
+
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`;
|
|
2080
|
+
await toast(client, job.goalNoProgressReason, "warning");
|
|
2081
|
+
await appendLoopLog(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
1826
2082
|
}
|
|
1827
|
-
return job
|
|
2083
|
+
return job;
|
|
1828
2084
|
}
|
|
1829
|
-
|
|
1830
2085
|
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
1831
|
-
const state = await readState(directory, sessionID)
|
|
1832
|
-
const job = pickGoalJob(state, args.target)
|
|
1833
|
-
if (!job)
|
|
1834
|
-
|
|
1835
|
-
const
|
|
1836
|
-
const
|
|
1837
|
-
const
|
|
1838
|
-
const
|
|
2086
|
+
const state = await readState(directory, sessionID);
|
|
2087
|
+
const job = pickGoalJob(state, args.target);
|
|
2088
|
+
if (!job)
|
|
2089
|
+
return { ok: false, message: "No active experimental goal was found." };
|
|
2090
|
+
const parsed = parseGoalToolText(args, ["summary", "evidence"]);
|
|
2091
|
+
const manualOverride = args.manual === true || args.manualOverride === true;
|
|
2092
|
+
const completionEvidence = parsed.evidence || job.goalEvidence || "";
|
|
2093
|
+
const skipEvidenceGate = manualOverride || args.allowWeakEvidence === true || job.goalRequireEvidence === false;
|
|
2094
|
+
const skipCheckGate = manualOverride || args.allowFailingChecks === true || job.goalRequireChecksPass === false;
|
|
1839
2095
|
if (!skipEvidenceGate && !hasConcreteGoalEvidence(completionEvidence)) {
|
|
1840
|
-
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal")
|
|
2096
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "concrete evidence is required before the goal tool can complete the goal");
|
|
1841
2097
|
}
|
|
1842
2098
|
if (!skipCheckGate && goalRequiresPassingChecks(job) && !goalChecksPassed(job)) {
|
|
1843
|
-
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet")
|
|
1844
|
-
}
|
|
1845
|
-
job.goalStatus = "completed"
|
|
1846
|
-
job.enabled = false
|
|
1847
|
-
job.paused = true
|
|
1848
|
-
job.goalCompletedAt = now()
|
|
1849
|
-
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed."
|
|
1850
|
-
job.goalEvidence = completionEvidence || "No evidence provided."
|
|
1851
|
-
job.noProgressCount = 0
|
|
1852
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
1853
|
-
await writeState(directory, sessionID, state)
|
|
1854
|
-
await writeGoalReport(directory, sessionID, job)
|
|
1855
|
-
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary })
|
|
1856
|
-
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` }
|
|
2099
|
+
return await rejectGoalCompletion(directory, sessionID, state, job, "configured goal checks have not passed yet");
|
|
2100
|
+
}
|
|
2101
|
+
job.goalStatus = "completed";
|
|
2102
|
+
job.enabled = false;
|
|
2103
|
+
job.paused = true;
|
|
2104
|
+
job.goalCompletedAt = now();
|
|
2105
|
+
job.goalSummary = parsed.summary || job.goalSummary || "Goal completed.";
|
|
2106
|
+
job.goalEvidence = completionEvidence || "No evidence provided.";
|
|
2107
|
+
job.noProgressCount = 0;
|
|
2108
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2109
|
+
await writeState(directory, sessionID, state);
|
|
2110
|
+
await writeGoalReport(directory, sessionID, job);
|
|
2111
|
+
await appendLoopLog(directory, "goal-complete", { sessionID, job: job.name || job.id, summary: job.goalSummary });
|
|
2112
|
+
return { ok: true, job, message: `Goal completed: ${job.goalSummary}` };
|
|
1857
2113
|
}
|
|
1858
|
-
|
|
1859
2114
|
async function setGoalBlocked(directory, sessionID, args = {}) {
|
|
1860
|
-
const state = await readState(directory, sessionID)
|
|
1861
|
-
const job = pickGoalJob(state, args.target)
|
|
1862
|
-
if (!job)
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
job.
|
|
1866
|
-
job.
|
|
1867
|
-
job.
|
|
1868
|
-
job.
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
2115
|
+
const state = await readState(directory, sessionID);
|
|
2116
|
+
const job = pickGoalJob(state, args.target);
|
|
2117
|
+
if (!job)
|
|
2118
|
+
return { ok: false, message: "No active experimental goal was found." };
|
|
2119
|
+
const parsed = parseGoalToolText(args, ["reason", "needed", "evidence"]);
|
|
2120
|
+
job.goalStatus = "blocked";
|
|
2121
|
+
job.enabled = false;
|
|
2122
|
+
job.paused = true;
|
|
2123
|
+
job.goalBlockedAt = now();
|
|
2124
|
+
job.goalBlockedReason = [parsed.reason, parsed.needed ? `Needed: ${parsed.needed}` : ""].filter(Boolean).join(`
|
|
2125
|
+
`) || "Goal blocked.";
|
|
2126
|
+
if (parsed.evidence)
|
|
2127
|
+
job.goalEvidence = parsed.evidence;
|
|
2128
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2129
|
+
await writeState(directory, sessionID, state);
|
|
2130
|
+
await writeGoalReport(directory, sessionID, job);
|
|
2131
|
+
await appendLoopLog(directory, "goal-blocked", { sessionID, job: job.name || job.id, reason: job.goalBlockedReason });
|
|
2132
|
+
return { ok: true, job, message: `Goal blocked: ${job.goalBlockedReason}` };
|
|
1875
2133
|
}
|
|
1876
|
-
|
|
1877
2134
|
async function setGoalProgress(directory, sessionID, args = {}) {
|
|
1878
|
-
const state = await readState(directory, sessionID)
|
|
1879
|
-
const job = pickGoalJob(state, args.target)
|
|
1880
|
-
if (!job)
|
|
1881
|
-
|
|
1882
|
-
const
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
await
|
|
1891
|
-
|
|
2135
|
+
const state = await readState(directory, sessionID);
|
|
2136
|
+
const job = pickGoalJob(state, args.target);
|
|
2137
|
+
if (!job)
|
|
2138
|
+
return { ok: false, message: "No active experimental goal was found." };
|
|
2139
|
+
const parsed = parseGoalToolText(args, ["summary", "next", "evidence"]);
|
|
2140
|
+
const item = { time: new Date().toISOString(), summary: parsed.summary || "Progress recorded.", next: parsed.next || "", evidence: parsed.evidence || "" };
|
|
2141
|
+
job.goalProgress = [...job.goalProgress || [], item].slice(-30);
|
|
2142
|
+
if (parsed.evidence)
|
|
2143
|
+
job.goalEvidence = parsed.evidence;
|
|
2144
|
+
job.noProgressCount = 0;
|
|
2145
|
+
job.lastProgressAt = now();
|
|
2146
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2147
|
+
await writeState(directory, sessionID, state);
|
|
2148
|
+
await writeGoalReport(directory, sessionID, job);
|
|
2149
|
+
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary });
|
|
2150
|
+
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` };
|
|
1892
2151
|
}
|
|
1893
|
-
|
|
1894
2152
|
async function runGoalChecks(directory, sessionID, job, client) {
|
|
1895
|
-
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus))
|
|
1896
|
-
|
|
2153
|
+
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus))
|
|
2154
|
+
return job;
|
|
2155
|
+
const results = [];
|
|
1897
2156
|
for (const command of job.goalChecks) {
|
|
1898
2157
|
if (job.safe && dangerousShell(command)) {
|
|
1899
|
-
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." })
|
|
1900
|
-
continue
|
|
2158
|
+
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
|
|
2159
|
+
continue;
|
|
1901
2160
|
}
|
|
1902
|
-
const result = await runShellCommand(command, directory, job.timeoutMs ||
|
|
1903
|
-
results.push({ command, code: result.code, output: (result.stdout +
|
|
2161
|
+
const result = await runShellCommand(command, directory, job.timeoutMs || 300000);
|
|
2162
|
+
results.push({ command, code: result.code, output: (result.stdout + `
|
|
2163
|
+
` + result.stderr).slice(0, 1200) });
|
|
1904
2164
|
}
|
|
1905
|
-
job.lastGoalCheckAt = now()
|
|
1906
|
-
job.lastGoalChecks = results
|
|
1907
|
-
const allPassed = results.length > 0 && results.every((item) => item.code === 0)
|
|
2165
|
+
job.lastGoalCheckAt = now();
|
|
2166
|
+
job.lastGoalChecks = results;
|
|
2167
|
+
const allPassed = results.length > 0 && results.every((item) => item.code === 0);
|
|
1908
2168
|
if (allPassed) {
|
|
1909
|
-
job.goalChecksPassedAt = now()
|
|
1910
|
-
job.failureCount = 0
|
|
1911
|
-
await toast(client, "Goal checks passed.", "success")
|
|
2169
|
+
job.goalChecksPassedAt = now();
|
|
2170
|
+
job.failureCount = 0;
|
|
2171
|
+
await toast(client, "Goal checks passed.", "success");
|
|
1912
2172
|
if (job.goalCompleteWhenChecksPass) {
|
|
1913
|
-
job.goalStatus = "completed"
|
|
1914
|
-
job.enabled = false
|
|
1915
|
-
job.paused = true
|
|
1916
|
-
job.goalCompletedAt = now()
|
|
1917
|
-
job.goalSummary = job.goalSummary || "All configured goal checks passed."
|
|
1918
|
-
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join(
|
|
1919
|
-
|
|
2173
|
+
job.goalStatus = "completed";
|
|
2174
|
+
job.enabled = false;
|
|
2175
|
+
job.paused = true;
|
|
2176
|
+
job.goalCompletedAt = now();
|
|
2177
|
+
job.goalSummary = job.goalSummary || "All configured goal checks passed.";
|
|
2178
|
+
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join(`
|
|
2179
|
+
`);
|
|
2180
|
+
await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id });
|
|
1920
2181
|
}
|
|
1921
2182
|
} else {
|
|
1922
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1923
|
-
job.lastVerifyFailure = results.map((item) => `${item.command}
|
|
1924
|
-
|
|
2183
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2184
|
+
job.lastVerifyFailure = results.map((item) => `${item.command}
|
|
2185
|
+
exit=${item.code}
|
|
2186
|
+
${item.output}`).join(`
|
|
2187
|
+
|
|
2188
|
+
`).slice(0, 4000);
|
|
2189
|
+
await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
|
|
1925
2190
|
}
|
|
1926
|
-
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) })
|
|
1927
|
-
return job
|
|
2191
|
+
await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
|
|
2192
|
+
return job;
|
|
1928
2193
|
}
|
|
1929
|
-
|
|
1930
2194
|
async function finalizeActiveRun(directory, client, sessionID, options = {}) {
|
|
1931
|
-
const active = activeRuns.get(sessionID)
|
|
1932
|
-
if (!active)
|
|
1933
|
-
|
|
1934
|
-
|
|
2195
|
+
const active = activeRuns.get(sessionID);
|
|
2196
|
+
if (!active)
|
|
2197
|
+
return;
|
|
2198
|
+
if (!await canFinalizeActiveRun(directory, client, sessionID, active, options))
|
|
2199
|
+
return false;
|
|
2200
|
+
const recoveredStale = staleActiveRun(sessionID);
|
|
1935
2201
|
if (active.compactionOnly) {
|
|
1936
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
1937
|
-
clearActiveRun(sessionID)
|
|
1938
|
-
sessionStatuses.delete(sessionID)
|
|
1939
|
-
sessionStatusSeenAt.delete(sessionID)
|
|
2202
|
+
const pending = loopCompactionRequests.get(sessionID);
|
|
2203
|
+
clearActiveRun(sessionID);
|
|
2204
|
+
sessionStatuses.delete(sessionID);
|
|
2205
|
+
sessionStatusSeenAt.delete(sessionID);
|
|
1940
2206
|
await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
|
|
1941
2207
|
sessionID,
|
|
1942
2208
|
job: active.job?.name || active.jobId,
|
|
1943
2209
|
startedAt: active.startedAt,
|
|
1944
|
-
nativeEvent: Boolean(pending?.completedAt)
|
|
1945
|
-
})
|
|
1946
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
1947
|
-
return true
|
|
1948
|
-
}
|
|
1949
|
-
clearActiveRun(sessionID)
|
|
1950
|
-
const state = await readState(directory, sessionID)
|
|
1951
|
-
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId)
|
|
1952
|
-
if (!job)
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2210
|
+
nativeEvent: Boolean(pending?.completedAt)
|
|
2211
|
+
});
|
|
2212
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2213
|
+
return true;
|
|
2214
|
+
}
|
|
2215
|
+
clearActiveRun(sessionID);
|
|
2216
|
+
const state = await readState(directory, sessionID);
|
|
2217
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
|
|
2218
|
+
if (!job)
|
|
2219
|
+
return;
|
|
2220
|
+
job.lastFinishedAt = now();
|
|
2221
|
+
if (recoveredStale)
|
|
2222
|
+
await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt });
|
|
1956
2223
|
if (job.verifyCommand) {
|
|
1957
|
-
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs ||
|
|
1958
|
-
job.lastVerifyAt = now()
|
|
1959
|
-
job.lastVerifyCode = verify.code
|
|
2224
|
+
const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300000);
|
|
2225
|
+
job.lastVerifyAt = now();
|
|
2226
|
+
job.lastVerifyCode = verify.code;
|
|
1960
2227
|
if (verify.code === 0) {
|
|
1961
|
-
job.failureCount = 0
|
|
1962
|
-
job.lastVerifyFailure = ""
|
|
1963
|
-
await toast(client, "Loop verify passed: " + job.verifyCommand, "success")
|
|
2228
|
+
job.failureCount = 0;
|
|
2229
|
+
job.lastVerifyFailure = "";
|
|
2230
|
+
await toast(client, "Loop verify passed: " + job.verifyCommand, "success");
|
|
1964
2231
|
} else {
|
|
1965
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1966
|
-
job.lastVerifyFailure = (job.verifyCommand +
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
2232
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2233
|
+
job.lastVerifyFailure = (job.verifyCommand + `
|
|
2234
|
+
exit=` + verify.code + `
|
|
2235
|
+
` + verify.stdout + `
|
|
2236
|
+
` + verify.stderr).slice(0, 4000);
|
|
2237
|
+
await toast(client, "Loop verify failed: " + job.verifyCommand, "warning");
|
|
2238
|
+
if (job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
2239
|
+
job.paused = true;
|
|
2240
|
+
await notifyJob(directory, job, "verify_failed");
|
|
1971
2241
|
}
|
|
1972
2242
|
}
|
|
1973
|
-
await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 })
|
|
2243
|
+
await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 });
|
|
1974
2244
|
}
|
|
1975
|
-
|
|
1976
2245
|
if (job.postrunCommand) {
|
|
1977
|
-
if (job.safe && dangerousShell(job.postrunCommand))
|
|
2246
|
+
if (job.safe && dangerousShell(job.postrunCommand))
|
|
2247
|
+
await appendLoopLog(directory, "postrun-blocked", { sessionID, job: job.name || job.id, command: job.postrunCommand });
|
|
1978
2248
|
else {
|
|
1979
|
-
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs ||
|
|
1980
|
-
job.lastPostrunCode = postrun.code
|
|
1981
|
-
job.lastPostrunAt = now()
|
|
2249
|
+
const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300000);
|
|
2250
|
+
job.lastPostrunCode = postrun.code;
|
|
2251
|
+
job.lastPostrunAt = now();
|
|
1982
2252
|
if (postrun.code !== 0) {
|
|
1983
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
1984
|
-
job.lastPostrunFailure = (job.postrunCommand +
|
|
2253
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2254
|
+
job.lastPostrunFailure = (job.postrunCommand + `
|
|
2255
|
+
exit=` + postrun.code + `
|
|
2256
|
+
` + postrun.stdout + `
|
|
2257
|
+
` + postrun.stderr).slice(0, 4000);
|
|
1985
2258
|
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1986
|
-
job.paused = true
|
|
1987
|
-
await notifyJob(directory, job, "postrun_failed")
|
|
2259
|
+
job.paused = true;
|
|
2260
|
+
await notifyJob(directory, job, "postrun_failed");
|
|
1988
2261
|
}
|
|
1989
2262
|
}
|
|
1990
|
-
await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code })
|
|
2263
|
+
await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code });
|
|
1991
2264
|
}
|
|
1992
2265
|
}
|
|
1993
|
-
|
|
1994
2266
|
if (isGoalJob(job)) {
|
|
1995
|
-
job = await runGoalChecks(directory, sessionID, job, client)
|
|
1996
|
-
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job)
|
|
2267
|
+
job = await runGoalChecks(directory, sessionID, job, client);
|
|
2268
|
+
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job);
|
|
1997
2269
|
}
|
|
1998
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
1999
|
-
await writeState(directory, sessionID, state)
|
|
2000
|
-
if (isGoalJob(job))
|
|
2001
|
-
|
|
2002
|
-
await
|
|
2003
|
-
|
|
2270
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
|
|
2271
|
+
await writeState(directory, sessionID, state);
|
|
2272
|
+
if (isGoalJob(job))
|
|
2273
|
+
await writeGoalReport(directory, sessionID, job);
|
|
2274
|
+
await createCheckpoint(directory, sessionID, job, client);
|
|
2275
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2276
|
+
return true;
|
|
2004
2277
|
}
|
|
2005
|
-
|
|
2006
2278
|
async function fireAction(directory, client, sessionID, job) {
|
|
2007
|
-
const action = String(job.action || "").trim()
|
|
2008
|
-
const kind = actionKind(action, job)
|
|
2009
|
-
const agent = job.agent || "build"
|
|
2010
|
-
const model = normalizedModelRef(job.model)
|
|
2279
|
+
const action = String(job.action || "").trim();
|
|
2280
|
+
const kind = actionKind(action, job);
|
|
2281
|
+
const agent = job.agent || "build";
|
|
2282
|
+
const model = normalizedModelRef(job.model);
|
|
2011
2283
|
if (kind === "compact") {
|
|
2012
|
-
beginLoopCompaction(sessionID, job.id, false)
|
|
2013
|
-
const ok = await compactSession(directory, client, sessionID, model)
|
|
2014
|
-
if (!ok)
|
|
2015
|
-
|
|
2284
|
+
beginLoopCompaction(sessionID, job.id, false);
|
|
2285
|
+
const ok = await compactSession(directory, client, sessionID, model);
|
|
2286
|
+
if (!ok)
|
|
2287
|
+
loopCompactionRequests.delete(sessionID);
|
|
2288
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
|
|
2016
2289
|
}
|
|
2017
2290
|
if (kind === "command") {
|
|
2018
|
-
const normalized = action.startsWith("/") ? action.slice(1) : action
|
|
2019
|
-
const [command, argumentsText] = splitFirst(normalized)
|
|
2291
|
+
const normalized = action.startsWith("/") ? action.slice(1) : action;
|
|
2292
|
+
const [command, argumentsText] = splitFirst(normalized);
|
|
2020
2293
|
if (!command) {
|
|
2021
|
-
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning")
|
|
2022
|
-
return { startsAssistantTurn: false, pause: true, reason: "empty_command" }
|
|
2294
|
+
await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning");
|
|
2295
|
+
return { startsAssistantTurn: false, pause: true, reason: "empty_command" };
|
|
2023
2296
|
}
|
|
2024
|
-
const tuiCommand = compactTuiCommandName(command)
|
|
2297
|
+
const tuiCommand = compactTuiCommandName(command);
|
|
2025
2298
|
if (tuiCommand) {
|
|
2026
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
2027
|
-
beginLoopCompaction(sessionID, job.id, false)
|
|
2028
|
-
const ok = await compactSession(directory, client, sessionID, model)
|
|
2029
|
-
if (!ok)
|
|
2030
|
-
|
|
2299
|
+
guardLoopOwnedUserMessage(sessionID);
|
|
2300
|
+
beginLoopCompaction(sessionID, job.id, false);
|
|
2301
|
+
const ok = await compactSession(directory, client, sessionID, model);
|
|
2302
|
+
if (!ok)
|
|
2303
|
+
loopCompactionRequests.delete(sessionID);
|
|
2304
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
|
|
2031
2305
|
}
|
|
2032
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
2033
|
-
const commandBody = { command, arguments: argumentsText, agent }
|
|
2034
|
-
if (model)
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
{ path: { sessionID }, body: commandBody },
|
|
2039
|
-
{ sessionID, ...commandBody },
|
|
2040
|
-
)
|
|
2041
|
-
return { startsAssistantTurn: true }
|
|
2306
|
+
guardLoopOwnedUserMessage(sessionID);
|
|
2307
|
+
const commandBody = { command, arguments: argumentsText, agent };
|
|
2308
|
+
if (model)
|
|
2309
|
+
commandBody.model = `${model.providerID}/${model.modelID}`;
|
|
2310
|
+
await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: commandBody }, { path: { sessionID }, body: commandBody }, { sessionID, ...commandBody });
|
|
2311
|
+
return { startsAssistantTurn: true };
|
|
2042
2312
|
}
|
|
2043
2313
|
if (kind === "shell") {
|
|
2044
|
-
const command = action.replace(/^[!$]\s*/, "").trim()
|
|
2314
|
+
const command = action.replace(/^[!$]\s*/, "").trim();
|
|
2045
2315
|
if (job.safe && dangerousShell(command)) {
|
|
2046
|
-
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error")
|
|
2047
|
-
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command })
|
|
2048
|
-
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" }
|
|
2316
|
+
await toast(client, `Blocked dangerous shell command in safe mode: ${command}`, "error");
|
|
2317
|
+
await appendLoopLog(directory, "blocked", { sessionID, job: job.name || job.id, command });
|
|
2318
|
+
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" };
|
|
2049
2319
|
}
|
|
2050
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
2051
|
-
const shellBody = { command, agent }
|
|
2052
|
-
if (model)
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
{ sessionID, ...shellBody },
|
|
2060
|
-
)
|
|
2061
|
-
return { startsAssistantTurn: true, dispatch }
|
|
2062
|
-
}
|
|
2063
|
-
const prompt = await buildPrompt(directory, job)
|
|
2064
|
-
const prefix = kind === "goal"
|
|
2065
|
-
? "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."
|
|
2066
|
-
: "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."
|
|
2320
|
+
guardLoopOwnedUserMessage(sessionID);
|
|
2321
|
+
const shellBody = { command, agent };
|
|
2322
|
+
if (model)
|
|
2323
|
+
shellBody.model = model;
|
|
2324
|
+
const dispatch2 = fireSdk(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
|
|
2325
|
+
return { startsAssistantTurn: true, dispatch: dispatch2 };
|
|
2326
|
+
}
|
|
2327
|
+
const prompt = await buildPrompt(directory, job);
|
|
2328
|
+
const prefix = kind === "goal" ? "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." : "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.";
|
|
2067
2329
|
const promptText = `${prefix}
|
|
2068
2330
|
|
|
2069
|
-
${prompt}
|
|
2070
|
-
guardLoopOwnedUserMessage(sessionID)
|
|
2071
|
-
const promptBody = { agent, parts: [{ type: "text", text: promptText }] }
|
|
2072
|
-
if (model)
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
client.session.prompt.bind(client.session),
|
|
2077
|
-
{ path: { id: sessionID }, body: promptBody },
|
|
2078
|
-
{ path: { sessionID }, body: promptBody },
|
|
2079
|
-
{ sessionID, ...promptBody },
|
|
2080
|
-
)
|
|
2081
|
-
return { startsAssistantTurn: true, dispatch }
|
|
2331
|
+
${prompt}`;
|
|
2332
|
+
guardLoopOwnedUserMessage(sessionID);
|
|
2333
|
+
const promptBody = { agent, parts: [{ type: "text", text: promptText }] };
|
|
2334
|
+
if (model)
|
|
2335
|
+
promptBody.model = model;
|
|
2336
|
+
const dispatch = fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: promptBody }, { path: { sessionID }, body: promptBody }, { sessionID, ...promptBody });
|
|
2337
|
+
return { startsAssistantTurn: true, dispatch };
|
|
2082
2338
|
}
|
|
2083
|
-
|
|
2084
2339
|
async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
|
|
2085
|
-
rememberSession(directory, client, sessionID)
|
|
2086
|
-
const reschedule = async (minDelayMs = 0) => {
|
|
2087
|
-
|
|
2340
|
+
rememberSession(directory, client, sessionID);
|
|
2341
|
+
const reschedule = async (minDelayMs = 0) => {
|
|
2342
|
+
await scheduleDueWork(directory, client, sessionID, minDelayMs);
|
|
2343
|
+
};
|
|
2088
2344
|
if (runLocks.has(sessionID)) {
|
|
2089
|
-
await reschedule(BUSY_RETRY_MS)
|
|
2090
|
-
return
|
|
2345
|
+
await reschedule(BUSY_RETRY_MS);
|
|
2346
|
+
return;
|
|
2091
2347
|
}
|
|
2092
|
-
runLocks.set(sessionID, now())
|
|
2093
|
-
let job
|
|
2348
|
+
runLocks.set(sessionID, now());
|
|
2349
|
+
let job;
|
|
2094
2350
|
try {
|
|
2095
|
-
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true })
|
|
2351
|
+
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
|
|
2096
2352
|
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
2097
|
-
if (options.force)
|
|
2098
|
-
|
|
2099
|
-
|
|
2353
|
+
if (options.force)
|
|
2354
|
+
await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
|
|
2355
|
+
await reschedule(BUSY_RETRY_MS);
|
|
2356
|
+
return;
|
|
2100
2357
|
}
|
|
2101
|
-
|
|
2102
|
-
const
|
|
2103
|
-
const
|
|
2104
|
-
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS
|
|
2358
|
+
const active = activeRuns.get(sessionID);
|
|
2359
|
+
const activeAge = active ? now() - (active.startedAt || 0) : 0;
|
|
2360
|
+
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS;
|
|
2105
2361
|
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
2106
|
-
await reschedule(BUSY_RETRY_MS)
|
|
2107
|
-
return
|
|
2362
|
+
await reschedule(BUSY_RETRY_MS);
|
|
2363
|
+
return;
|
|
2108
2364
|
}
|
|
2109
|
-
if (active && activeAge >= activeGuard)
|
|
2110
|
-
|
|
2111
|
-
const state = await readState(directory, sessionID)
|
|
2365
|
+
if (active && activeAge >= activeGuard)
|
|
2366
|
+
clearActiveRun(sessionID);
|
|
2367
|
+
const state = await readState(directory, sessionID);
|
|
2112
2368
|
for (const candidate of state.jobs || []) {
|
|
2113
|
-
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate))
|
|
2369
|
+
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate))
|
|
2370
|
+
candidate.watchTriggered = true;
|
|
2114
2371
|
}
|
|
2115
|
-
const due = dueJobs(state, options.force)
|
|
2372
|
+
const due = dueJobs(state, options.force);
|
|
2116
2373
|
if (!due.length) {
|
|
2117
|
-
await writeState(directory, sessionID, state)
|
|
2118
|
-
await reschedule()
|
|
2119
|
-
return
|
|
2374
|
+
await writeState(directory, sessionID, state);
|
|
2375
|
+
await reschedule();
|
|
2376
|
+
return;
|
|
2120
2377
|
}
|
|
2121
|
-
job = due[0]
|
|
2122
|
-
|
|
2378
|
+
job = due[0];
|
|
2123
2379
|
if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
2124
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
2125
|
-
await writeState(directory, sessionID, state)
|
|
2126
|
-
await notifyJob(directory, job, "max_runtime_reached")
|
|
2127
|
-
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success")
|
|
2128
|
-
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id })
|
|
2129
|
-
await reschedule()
|
|
2130
|
-
return
|
|
2380
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
|
|
2381
|
+
await writeState(directory, sessionID, state);
|
|
2382
|
+
await notifyJob(directory, job, "max_runtime_reached");
|
|
2383
|
+
await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success");
|
|
2384
|
+
await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id });
|
|
2385
|
+
await reschedule();
|
|
2386
|
+
return;
|
|
2131
2387
|
}
|
|
2132
|
-
if (job.stopFile && await pathExists(
|
|
2133
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
2134
|
-
await writeState(directory, sessionID, state)
|
|
2135
|
-
await notifyJob(directory, job, "stop_file")
|
|
2136
|
-
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success")
|
|
2137
|
-
await reschedule()
|
|
2138
|
-
return
|
|
2388
|
+
if (job.stopFile && await pathExists(path3.resolve(directory, job.stopFile))) {
|
|
2389
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
|
|
2390
|
+
await writeState(directory, sessionID, state);
|
|
2391
|
+
await notifyJob(directory, job, "stop_file");
|
|
2392
|
+
await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success");
|
|
2393
|
+
await reschedule();
|
|
2394
|
+
return;
|
|
2139
2395
|
}
|
|
2140
2396
|
if (await untilReached(directory, job)) {
|
|
2141
|
-
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id)
|
|
2142
|
-
await writeState(directory, sessionID, state)
|
|
2143
|
-
await notifyJob(directory, job, "until_reached")
|
|
2144
|
-
await toast(client, `Loop stopped by --until: ${job.until}`, "success")
|
|
2145
|
-
await reschedule()
|
|
2146
|
-
return
|
|
2397
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
|
|
2398
|
+
await writeState(directory, sessionID, state);
|
|
2399
|
+
await notifyJob(directory, job, "until_reached");
|
|
2400
|
+
await toast(client, `Loop stopped by --until: ${job.until}`, "success");
|
|
2401
|
+
await reschedule();
|
|
2402
|
+
return;
|
|
2147
2403
|
}
|
|
2148
|
-
|
|
2149
2404
|
if (job.preflightCommand) {
|
|
2150
2405
|
if (job.safe && dangerousShell(job.preflightCommand)) {
|
|
2151
|
-
job.paused = true
|
|
2152
|
-
await writeState(directory, sessionID, state)
|
|
2153
|
-
await notifyJob(directory, job, "preflight_blocked")
|
|
2154
|
-
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error")
|
|
2155
|
-
await reschedule()
|
|
2156
|
-
return
|
|
2406
|
+
job.paused = true;
|
|
2407
|
+
await writeState(directory, sessionID, state);
|
|
2408
|
+
await notifyJob(directory, job, "preflight_blocked");
|
|
2409
|
+
await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error");
|
|
2410
|
+
await reschedule();
|
|
2411
|
+
return;
|
|
2157
2412
|
}
|
|
2158
|
-
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs ||
|
|
2159
|
-
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code })
|
|
2413
|
+
const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300000);
|
|
2414
|
+
await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code });
|
|
2160
2415
|
if (preflight.code !== 0) {
|
|
2161
|
-
job.paused = true
|
|
2162
|
-
job.failureCount = (job.failureCount || 0) + 1
|
|
2163
|
-
job.lastPreflightFailure = (job.preflightCommand +
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
await
|
|
2169
|
-
|
|
2416
|
+
job.paused = true;
|
|
2417
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2418
|
+
job.lastPreflightFailure = (job.preflightCommand + `
|
|
2419
|
+
exit=` + preflight.code + `
|
|
2420
|
+
` + preflight.stdout + `
|
|
2421
|
+
` + preflight.stderr).slice(0, 4000);
|
|
2422
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2423
|
+
await writeState(directory, sessionID, state);
|
|
2424
|
+
await notifyJob(directory, job, "preflight_failed");
|
|
2425
|
+
await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning");
|
|
2426
|
+
await reschedule();
|
|
2427
|
+
return;
|
|
2170
2428
|
}
|
|
2171
2429
|
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
job = compactResult.job
|
|
2430
|
+
job = await ensureBranch(directory, job, client, sessionID);
|
|
2431
|
+
const compactResult = await maybeCompact(directory, client, sessionID, job);
|
|
2432
|
+
job = compactResult.job;
|
|
2176
2433
|
if (compactResult.started) {
|
|
2177
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
2178
|
-
await writeState(directory, sessionID, state)
|
|
2179
|
-
let timer
|
|
2180
|
-
if (job.timeoutMs > 0)
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2434
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2435
|
+
await writeState(directory, sessionID, state);
|
|
2436
|
+
let timer;
|
|
2437
|
+
if (job.timeoutMs > 0)
|
|
2438
|
+
timer = setTimeout(() => {
|
|
2439
|
+
fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
2440
|
+
toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {});
|
|
2441
|
+
}, job.timeoutMs);
|
|
2442
|
+
const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}`;
|
|
2443
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true });
|
|
2444
|
+
const pending = loopCompactionRequests.get(sessionID);
|
|
2184
2445
|
if (pending?.jobId === job.id && pending.completedAt) {
|
|
2185
|
-
await finalizeLoopCompaction(directory, client, sessionID)
|
|
2186
|
-
return
|
|
2446
|
+
await finalizeLoopCompaction(directory, client, sessionID);
|
|
2447
|
+
return;
|
|
2187
2448
|
}
|
|
2188
|
-
sessionStatuses.set(sessionID, "busy")
|
|
2189
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
2190
|
-
await reschedule(BUSY_RETRY_MS)
|
|
2191
|
-
return
|
|
2449
|
+
sessionStatuses.set(sessionID, "busy");
|
|
2450
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
2451
|
+
await reschedule(BUSY_RETRY_MS);
|
|
2452
|
+
return;
|
|
2192
2453
|
}
|
|
2193
|
-
job.watchTriggered = false
|
|
2194
|
-
job.lastRunAt = now()
|
|
2195
|
-
job.runCount = (job.runCount || 0) + 1
|
|
2454
|
+
job.watchTriggered = false;
|
|
2455
|
+
job.lastRunAt = now();
|
|
2456
|
+
job.runCount = (job.runCount || 0) + 1;
|
|
2196
2457
|
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
2197
|
-
job.enabled = false
|
|
2198
|
-
await notifyJob(directory, job, "max_runs_reached")
|
|
2458
|
+
job.enabled = false;
|
|
2459
|
+
await notifyJob(directory, job, "max_runs_reached");
|
|
2199
2460
|
}
|
|
2200
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate)
|
|
2201
|
-
await writeState(directory, sessionID, state)
|
|
2202
|
-
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount })
|
|
2203
|
-
await toast(client, `Loop running: ${job.name || job.id}`, "info")
|
|
2204
|
-
|
|
2461
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2462
|
+
await writeState(directory, sessionID, state);
|
|
2463
|
+
await appendLoopLog(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount });
|
|
2464
|
+
await toast(client, `Loop running: ${job.name || job.id}`, "info");
|
|
2205
2465
|
try {
|
|
2206
|
-
const result = await fireAction(directory, client, sessionID, job)
|
|
2466
|
+
const result = await fireAction(directory, client, sessionID, job);
|
|
2207
2467
|
if (!result.startsAssistantTurn) {
|
|
2208
|
-
const fresh = await readState(directory, sessionID)
|
|
2468
|
+
const fresh = await readState(directory, sessionID);
|
|
2209
2469
|
if (result.pause) {
|
|
2210
2470
|
fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
|
|
2211
2471
|
...candidate,
|
|
2212
2472
|
paused: true,
|
|
2213
2473
|
failureCount: (candidate.failureCount || 0) + 1,
|
|
2214
|
-
lastFailureReason: result.reason || "action_did_not_start"
|
|
2215
|
-
} : candidate)
|
|
2474
|
+
lastFailureReason: result.reason || "action_did_not_start"
|
|
2475
|
+
} : candidate);
|
|
2216
2476
|
}
|
|
2217
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate))
|
|
2218
|
-
await writeState(directory, sessionID, fresh)
|
|
2219
|
-
await reschedule()
|
|
2220
|
-
return
|
|
2477
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
|
|
2478
|
+
await writeState(directory, sessionID, fresh);
|
|
2479
|
+
await reschedule();
|
|
2480
|
+
return;
|
|
2221
2481
|
}
|
|
2222
|
-
let timer
|
|
2223
|
-
if (job.timeoutMs > 0)
|
|
2224
|
-
|
|
2225
|
-
|
|
2482
|
+
let timer;
|
|
2483
|
+
if (job.timeoutMs > 0)
|
|
2484
|
+
timer = setTimeout(() => {
|
|
2485
|
+
fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
2486
|
+
toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {});
|
|
2487
|
+
}, job.timeoutMs);
|
|
2488
|
+
const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`;
|
|
2489
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true });
|
|
2226
2490
|
if (result.compaction) {
|
|
2227
|
-
const pending = loopCompactionRequests.get(sessionID)
|
|
2491
|
+
const pending = loopCompactionRequests.get(sessionID);
|
|
2228
2492
|
if (pending?.jobId === job.id && pending.completedAt) {
|
|
2229
|
-
await finalizeLoopCompaction(directory, client, sessionID)
|
|
2230
|
-
return
|
|
2493
|
+
await finalizeLoopCompaction(directory, client, sessionID);
|
|
2494
|
+
return;
|
|
2231
2495
|
}
|
|
2232
2496
|
}
|
|
2233
2497
|
if (result.dispatch) {
|
|
2234
|
-
|
|
2235
|
-
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error)
|
|
2236
|
-
|
|
2237
|
-
})
|
|
2498
|
+
result.dispatch.catch((error) => {
|
|
2499
|
+
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error).catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: sdkErrorMessage(recoveryError) }));
|
|
2500
|
+
});
|
|
2238
2501
|
}
|
|
2239
|
-
sessionStatuses.set(sessionID, "busy")
|
|
2240
|
-
sessionStatusSeenAt.set(sessionID, now())
|
|
2241
|
-
await reschedule(BUSY_RETRY_MS)
|
|
2502
|
+
sessionStatuses.set(sessionID, "busy");
|
|
2503
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
2504
|
+
await reschedule(BUSY_RETRY_MS);
|
|
2242
2505
|
} catch (error) {
|
|
2243
|
-
clearActiveRun(sessionID)
|
|
2244
|
-
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error")
|
|
2245
|
-
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) })
|
|
2246
|
-
await reschedule(BUSY_RETRY_MS)
|
|
2506
|
+
clearActiveRun(sessionID);
|
|
2507
|
+
await toast(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2508
|
+
await appendLoopLog(directory, "error", { sessionID, job: job?.name || job?.id, error: error instanceof Error ? error.message : String(error) });
|
|
2509
|
+
await reschedule(BUSY_RETRY_MS);
|
|
2247
2510
|
}
|
|
2248
2511
|
} finally {
|
|
2249
|
-
runLocks.delete(sessionID)
|
|
2512
|
+
runLocks.delete(sessionID);
|
|
2250
2513
|
}
|
|
2251
2514
|
}
|
|
2252
|
-
|
|
2253
2515
|
function normalizeActionForCompare(value) {
|
|
2254
|
-
return String(value || "").replace(/\s+/g, " ").trim()
|
|
2516
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
2255
2517
|
}
|
|
2256
|
-
|
|
2257
2518
|
function sameLoopDefinition(a, b) {
|
|
2258
|
-
if (!a || !b)
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) &&
|
|
2262
|
-
normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) &&
|
|
2263
|
-
normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile)
|
|
2519
|
+
if (!a || !b)
|
|
2520
|
+
return false;
|
|
2521
|
+
return (a.name || "") === (b.name || "") && Number(a.intervalMs || 0) === Number(b.intervalMs || 0) && normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) && normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) && normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile);
|
|
2264
2522
|
}
|
|
2265
|
-
|
|
2266
2523
|
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
2267
|
-
const parsed = parseLoopArgs(args, defaults)
|
|
2268
|
-
if (!parsed.ok) {
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2524
|
+
const parsed = parseLoopArgs(args, defaults);
|
|
2525
|
+
if (!parsed.ok) {
|
|
2526
|
+
await toast(client, parsed.error, "warning");
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
const executionContext = getSessionExecutionContext(sessionID) || { agent: "build" };
|
|
2530
|
+
parsed.job.agent = defaults.agent || executionContext.agent || "build";
|
|
2531
|
+
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model;
|
|
2272
2532
|
if (defaults.testfixPreset) {
|
|
2273
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test")
|
|
2274
|
-
const parsedAction = String(parsed.job.action || "").trim()
|
|
2275
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim()
|
|
2533
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test");
|
|
2534
|
+
const parsedAction = String(parsed.job.action || "").trim();
|
|
2535
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
|
|
2276
2536
|
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
2277
|
-
parsed.job.verifyCommand = parsedAction
|
|
2278
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}
|
|
2537
|
+
parsed.job.verifyCommand = parsedAction;
|
|
2538
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
|
|
2279
2539
|
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
2280
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}
|
|
2540
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`;
|
|
2281
2541
|
}
|
|
2282
2542
|
}
|
|
2283
|
-
if (parsed.job.watchPaths.length)
|
|
2543
|
+
if (parsed.job.watchPaths.length)
|
|
2544
|
+
parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths);
|
|
2284
2545
|
if (!parsed.job.activeRecoveryMs) {
|
|
2285
|
-
parsed.job.activeRecoveryMs = isGoalJob(parsed.job)
|
|
2286
|
-
? DEFAULT_GOAL_ACTIVE_RECOVERY_MS
|
|
2287
|
-
: Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90_000, (parsed.job.intervalMs || 0) + 10_000))
|
|
2546
|
+
parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(DEFAULT_ACTIVE_GUARD_MS, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4));
|
|
2288
2547
|
}
|
|
2289
|
-
if (parsed.job.dryRun) {
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
let replaced = false
|
|
2548
|
+
if (parsed.job.dryRun) {
|
|
2549
|
+
await toast(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
2550
|
+
await say(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
const state = await readState(directory, sessionID);
|
|
2554
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : [];
|
|
2555
|
+
let replaced = false;
|
|
2297
2556
|
if (!parsed.job.multi) {
|
|
2298
|
-
const targetName = parsed.job.name || "default"
|
|
2299
|
-
parsed.job.name = targetName
|
|
2557
|
+
const targetName = parsed.job.name || "default";
|
|
2558
|
+
parsed.job.name = targetName;
|
|
2300
2559
|
state.jobs = jobs.filter((existing) => {
|
|
2301
|
-
const existingName = existing.name || "default"
|
|
2302
|
-
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job)
|
|
2303
|
-
if (shouldReplace)
|
|
2304
|
-
|
|
2305
|
-
|
|
2560
|
+
const existingName = existing.name || "default";
|
|
2561
|
+
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job);
|
|
2562
|
+
if (shouldReplace)
|
|
2563
|
+
replaced = true;
|
|
2564
|
+
return !shouldReplace;
|
|
2565
|
+
});
|
|
2306
2566
|
} else {
|
|
2307
|
-
state.jobs = jobs
|
|
2567
|
+
state.jobs = jobs;
|
|
2308
2568
|
}
|
|
2309
|
-
|
|
2310
|
-
state
|
|
2311
|
-
await
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success")
|
|
2315
|
-
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) })
|
|
2569
|
+
state.jobs.push(parsed.job);
|
|
2570
|
+
await writeState(directory, sessionID, state);
|
|
2571
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2572
|
+
if (parsed.job.immediate)
|
|
2573
|
+
scheduleIdleWork(directory, client, sessionID);
|
|
2574
|
+
await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
2575
|
+
await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) });
|
|
2316
2576
|
}
|
|
2317
|
-
|
|
2318
2577
|
async function stopLoop(directory, client, sessionID, args) {
|
|
2319
|
-
const target = String(args || "").trim()
|
|
2578
|
+
const target = String(args || "").trim();
|
|
2320
2579
|
if (!target || target.toLowerCase() === "all") {
|
|
2321
|
-
await removeState(directory, sessionID)
|
|
2322
|
-
clearActiveRun(sessionID)
|
|
2323
|
-
const due = dueTimers.get(sessionID);
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
await
|
|
2332
|
-
|
|
2333
|
-
|
|
2580
|
+
await removeState(directory, sessionID);
|
|
2581
|
+
clearActiveRun(sessionID);
|
|
2582
|
+
const due = dueTimers.get(sessionID);
|
|
2583
|
+
if (due)
|
|
2584
|
+
clearTimeout(due);
|
|
2585
|
+
dueTimers.delete(sessionID);
|
|
2586
|
+
stopWatchdog(sessionID);
|
|
2587
|
+
await toast(client, "All loops stopped for this session.", "success");
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
const state = await readState(directory, sessionID);
|
|
2591
|
+
const before = state.jobs.length;
|
|
2592
|
+
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
|
|
2593
|
+
await writeState(directory, sessionID, state);
|
|
2594
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2595
|
+
await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
|
|
2334
2596
|
}
|
|
2335
|
-
|
|
2336
2597
|
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
2337
|
-
const target = String(args || "").trim() || "all"
|
|
2338
|
-
const state = await readState(directory, sessionID)
|
|
2339
|
-
let count = 0
|
|
2340
|
-
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job)
|
|
2341
|
-
await writeState(directory, sessionID, state)
|
|
2342
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2343
|
-
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning")
|
|
2598
|
+
const target = String(args || "").trim() || "all";
|
|
2599
|
+
const state = await readState(directory, sessionID);
|
|
2600
|
+
let count = 0;
|
|
2601
|
+
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
|
|
2602
|
+
await writeState(directory, sessionID, state);
|
|
2603
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2604
|
+
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
|
|
2344
2605
|
}
|
|
2345
|
-
|
|
2346
2606
|
async function statusGoal(directory, client, sessionID) {
|
|
2347
|
-
const state = await readState(directory, sessionID)
|
|
2348
|
-
const goals = (state.jobs || []).filter(isGoalJob)
|
|
2607
|
+
const state = await readState(directory, sessionID);
|
|
2608
|
+
const goals = (state.jobs || []).filter(isGoalJob);
|
|
2349
2609
|
const lines = goals.length ? goals.map((job, index) => {
|
|
2350
|
-
const status = goalStatusText(job)
|
|
2351
|
-
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : ""
|
|
2352
|
-
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : ""
|
|
2353
|
-
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : ""
|
|
2354
|
-
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : ""
|
|
2355
|
-
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : ""
|
|
2356
|
-
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}
|
|
2357
|
-
}) : ["No experimental goal jobs."]
|
|
2358
|
-
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning")
|
|
2359
|
-
await say(client, sessionID,
|
|
2610
|
+
const status = goalStatusText(job);
|
|
2611
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : "";
|
|
2612
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : "";
|
|
2613
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : "";
|
|
2614
|
+
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : "";
|
|
2615
|
+
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : "";
|
|
2616
|
+
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}`;
|
|
2617
|
+
}) : ["No experimental goal jobs."];
|
|
2618
|
+
await toast(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
|
|
2619
|
+
await say(client, sessionID, `OpenCode Loop experimental goal status:
|
|
2620
|
+
` + lines.join(`
|
|
2621
|
+
`));
|
|
2360
2622
|
}
|
|
2361
|
-
|
|
2362
2623
|
async function pauseGoal(directory, client, sessionID, args) {
|
|
2363
|
-
const target = String(args || "").trim() || "goal"
|
|
2364
|
-
const state = await readState(directory, sessionID)
|
|
2365
|
-
let count = 0
|
|
2366
|
-
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job)
|
|
2367
|
-
await writeState(directory, sessionID, state)
|
|
2368
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2369
|
-
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning")
|
|
2624
|
+
const target = String(args || "").trim() || "goal";
|
|
2625
|
+
const state = await readState(directory, sessionID);
|
|
2626
|
+
let count = 0;
|
|
2627
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job);
|
|
2628
|
+
await writeState(directory, sessionID, state);
|
|
2629
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2630
|
+
await toast(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
2370
2631
|
}
|
|
2371
|
-
|
|
2372
2632
|
async function resumeGoal(directory, client, sessionID, args) {
|
|
2373
|
-
const target = String(args || "").trim() || "goal"
|
|
2374
|
-
const state = await readState(directory, sessionID)
|
|
2375
|
-
let count = 0
|
|
2633
|
+
const target = String(args || "").trim() || "goal";
|
|
2634
|
+
const state = await readState(directory, sessionID);
|
|
2635
|
+
let count = 0;
|
|
2376
2636
|
state.jobs = (state.jobs || []).map((job, index) => {
|
|
2377
|
-
if (!isGoalJob(job) || !matchJob(job, target, index))
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
await
|
|
2637
|
+
if (!isGoalJob(job) || !matchJob(job, target, index))
|
|
2638
|
+
return job;
|
|
2639
|
+
count++;
|
|
2640
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : job.goalStatus || "active", lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" };
|
|
2641
|
+
});
|
|
2642
|
+
await writeState(directory, sessionID, state);
|
|
2643
|
+
await toast(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
2383
2644
|
if (count) {
|
|
2384
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2385
|
-
scheduleIdleWork(directory, client, sessionID)
|
|
2645
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2646
|
+
scheduleIdleWork(directory, client, sessionID);
|
|
2386
2647
|
}
|
|
2387
2648
|
}
|
|
2388
|
-
|
|
2389
2649
|
async function clearGoal(directory, client, sessionID, args) {
|
|
2390
|
-
const target = String(args || "").trim()
|
|
2391
|
-
const state = await readState(directory, sessionID)
|
|
2392
|
-
const before = state.jobs.length
|
|
2393
|
-
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) ||
|
|
2394
|
-
await writeState(directory, sessionID, state)
|
|
2395
|
-
await scheduleDueWork(directory, client, sessionID)
|
|
2396
|
-
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning")
|
|
2650
|
+
const target = String(args || "").trim();
|
|
2651
|
+
const state = await readState(directory, sessionID);
|
|
2652
|
+
const before = state.jobs.length;
|
|
2653
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
|
|
2654
|
+
await writeState(directory, sessionID, state);
|
|
2655
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2656
|
+
await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
|
|
2397
2657
|
}
|
|
2398
|
-
|
|
2399
2658
|
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
2400
|
-
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true })
|
|
2401
|
-
await toast(client, result.message, result.ok ? "success" : "warning")
|
|
2659
|
+
const result = await setGoalComplete(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true });
|
|
2660
|
+
await toast(client, result.message, result.ok ? "success" : "warning");
|
|
2402
2661
|
}
|
|
2403
|
-
|
|
2404
2662
|
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
2405
|
-
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." })
|
|
2406
|
-
await toast(client, result.message, result.ok ? "warning" : "warning")
|
|
2663
|
+
const result = await setGoalBlocked(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." });
|
|
2664
|
+
await toast(client, result.message, result.ok ? "warning" : "warning");
|
|
2407
2665
|
}
|
|
2408
|
-
|
|
2409
2666
|
async function addGoal(directory, client, sessionID, args) {
|
|
2410
|
-
const text = String(args || "").trim()
|
|
2411
|
-
const [maybeCommand, rest] = splitFirst(text)
|
|
2412
|
-
const sub = maybeCommand.toLowerCase()
|
|
2413
|
-
if (!text || sub === "status")
|
|
2414
|
-
|
|
2415
|
-
if (sub === "
|
|
2416
|
-
|
|
2417
|
-
if (
|
|
2418
|
-
|
|
2419
|
-
|
|
2667
|
+
const text = String(args || "").trim();
|
|
2668
|
+
const [maybeCommand, rest] = splitFirst(text);
|
|
2669
|
+
const sub = maybeCommand.toLowerCase();
|
|
2670
|
+
if (!text || sub === "status")
|
|
2671
|
+
return await statusGoal(directory, client, sessionID);
|
|
2672
|
+
if (sub === "pause")
|
|
2673
|
+
return await pauseGoal(directory, client, sessionID, rest);
|
|
2674
|
+
if (sub === "resume")
|
|
2675
|
+
return await resumeGoal(directory, client, sessionID, rest);
|
|
2676
|
+
if (["clear", "remove", "stop"].includes(sub))
|
|
2677
|
+
return await clearGoal(directory, client, sessionID, rest);
|
|
2678
|
+
if (["done", "complete", "completed"].includes(sub))
|
|
2679
|
+
return await completeGoalCommand(directory, client, sessionID, rest);
|
|
2680
|
+
if (["blocked", "block"].includes(sub))
|
|
2681
|
+
return await blockGoalCommand(directory, client, sessionID, rest);
|
|
2682
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" });
|
|
2420
2683
|
}
|
|
2421
|
-
|
|
2422
2684
|
async function statusLoop(directory, client, sessionID) {
|
|
2423
|
-
const state = await readState(directory, sessionID)
|
|
2424
|
-
const jobs = state.jobs || []
|
|
2685
|
+
const state = await readState(directory, sessionID);
|
|
2686
|
+
const jobs = state.jobs || [];
|
|
2425
2687
|
const lines = jobs.length ? jobs.map((job, index) => {
|
|
2426
|
-
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)))
|
|
2427
|
-
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", 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(",")
|
|
2428
|
-
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}
|
|
2429
|
-
}) : ["No active loop jobs."]
|
|
2430
|
-
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning")
|
|
2431
|
-
await say(client, sessionID,
|
|
2688
|
+
const dueIn = Math.max(0, job.intervalMs - (now() - (job.lastRunAt || 0)));
|
|
2689
|
+
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", 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(",");
|
|
2690
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`;
|
|
2691
|
+
}) : ["No active loop jobs."];
|
|
2692
|
+
await toast(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
2693
|
+
await say(client, sessionID, `OpenCode loop status:
|
|
2694
|
+
` + lines.join(`
|
|
2695
|
+
`));
|
|
2432
2696
|
}
|
|
2433
|
-
|
|
2434
2697
|
async function logsLoop(directory, client, sessionID) {
|
|
2435
|
-
let text = "No loop log found."
|
|
2436
|
-
try {
|
|
2437
|
-
|
|
2698
|
+
let text = "No loop log found.";
|
|
2699
|
+
try {
|
|
2700
|
+
text = (await fs3.readFile(path3.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
2701
|
+
`) || text;
|
|
2702
|
+
} catch {}
|
|
2703
|
+
await say(client, sessionID, `OpenCode loop logs:
|
|
2704
|
+
` + text);
|
|
2438
2705
|
}
|
|
2439
|
-
|
|
2440
2706
|
async function helpLoop(client, sessionID) {
|
|
2441
2707
|
await say(client, sessionID, [
|
|
2442
2708
|
"OpenCode Loop help:",
|
|
@@ -2446,28 +2712,32 @@ async function helpLoop(client, sessionID) {
|
|
|
2446
2712
|
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
2447
2713
|
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
2448
2714
|
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
2449
|
-
|
|
2715
|
+
'/loop-goal --check "npm run build" --check "npm test" --complete-when-checks-pass ship it',
|
|
2450
2716
|
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
2451
2717
|
"/loop 200m --command /compact same as command loop",
|
|
2452
|
-
|
|
2718
|
+
'/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
|
|
2453
2719
|
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
2454
2720
|
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
2455
|
-
"/loop-doctor | /loop-init | /loop-export"
|
|
2456
|
-
].join(
|
|
2721
|
+
"/loop-doctor | /loop-init | /loop-export"
|
|
2722
|
+
].join(`
|
|
2723
|
+
`));
|
|
2457
2724
|
}
|
|
2458
|
-
|
|
2459
2725
|
async function runNow(directory, client, sessionID, args) {
|
|
2460
|
-
const target = String(args || "").trim() || "all"
|
|
2461
|
-
const state = await readState(directory, sessionID)
|
|
2462
|
-
let count = 0
|
|
2463
|
-
for (const [index, job] of (state.jobs || []).entries())
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2726
|
+
const target = String(args || "").trim() || "all";
|
|
2727
|
+
const state = await readState(directory, sessionID);
|
|
2728
|
+
let count = 0;
|
|
2729
|
+
for (const [index, job] of (state.jobs || []).entries())
|
|
2730
|
+
if (matchJob(job, target, index)) {
|
|
2731
|
+
job.lastRunAt = 0;
|
|
2732
|
+
job.paused = false;
|
|
2733
|
+
count++;
|
|
2734
|
+
}
|
|
2735
|
+
await writeState(directory, sessionID, state);
|
|
2736
|
+
await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
|
|
2737
|
+
await maybeRunDueJobs(directory, client, sessionID, { force: true });
|
|
2467
2738
|
}
|
|
2468
|
-
|
|
2469
2739
|
async function doctorLoop(directory, client, sessionID) {
|
|
2470
|
-
const state = await readState(directory, sessionID)
|
|
2740
|
+
const state = await readState(directory, sessionID);
|
|
2471
2741
|
await say(client, sessionID, [
|
|
2472
2742
|
"OpenCode Loop doctor:",
|
|
2473
2743
|
`- plugin: ${SERVICE}`,
|
|
@@ -2477,150 +2747,240 @@ async function doctorLoop(directory, client, sessionID) {
|
|
|
2477
2747
|
`- node: ${process.version}`,
|
|
2478
2748
|
`- platform: ${process.platform}`,
|
|
2479
2749
|
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
2480
|
-
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
|
|
2481
|
-
].join(
|
|
2750
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
|
|
2751
|
+
].join(`
|
|
2752
|
+
`));
|
|
2482
2753
|
}
|
|
2483
|
-
|
|
2484
2754
|
async function initLoop(directory, client, sessionID, args) {
|
|
2485
|
-
const target = String(args || "").trim() || "progress.md"
|
|
2486
|
-
const full =
|
|
2487
|
-
if (await pathExists(full)) {
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2755
|
+
const target = String(args || "").trim() || "progress.md";
|
|
2756
|
+
const full = path3.resolve(directory, target);
|
|
2757
|
+
if (await pathExists(full)) {
|
|
2758
|
+
await toast(client, `${target} already exists.`, "warning");
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
await fs3.writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
|
|
2762
|
+
await toast(client, `Created ${target}.`, "success");
|
|
2763
|
+
await appendLoopLog(directory, "init", { sessionID, file: target });
|
|
2491
2764
|
}
|
|
2492
|
-
|
|
2493
2765
|
async function exportLoop(directory, client, sessionID) {
|
|
2494
|
-
const state = await readState(directory, sessionID)
|
|
2495
|
-
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```")
|
|
2766
|
+
const state = await readState(directory, sessionID);
|
|
2767
|
+
await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
|
|
2496
2768
|
}
|
|
2497
|
-
|
|
2498
2769
|
async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
|
|
2499
|
-
const name = commandName(input?.command ?? input?.name ?? fallbackName)
|
|
2500
|
-
const sessionID = input?.sessionID
|
|
2501
|
-
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "")
|
|
2502
|
-
if (!sessionID || !name)
|
|
2503
|
-
|
|
2504
|
-
|
|
2770
|
+
const name = commandName(input?.command ?? input?.name ?? fallbackName);
|
|
2771
|
+
const sessionID = input?.sessionID;
|
|
2772
|
+
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "");
|
|
2773
|
+
if (!sessionID || !name)
|
|
2774
|
+
return false;
|
|
2775
|
+
rememberSession(directory, client, sessionID);
|
|
2776
|
+
if (isLoopCommandName(name))
|
|
2777
|
+
await captureSessionExecutionContext(client, sessionID);
|
|
2505
2778
|
if (source === "event") {
|
|
2506
|
-
if (consumeHandled(sessionID, name, args))
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
handledCommandEvents.
|
|
2779
|
+
if (consumeHandled(sessionID, name, args))
|
|
2780
|
+
return true;
|
|
2781
|
+
const eventKey = commandEventKey(sessionID, input?.messageID);
|
|
2782
|
+
if (handledCommandEvents.has(eventKey))
|
|
2783
|
+
return true;
|
|
2784
|
+
handledCommandEvents.set(eventKey, now());
|
|
2510
2785
|
} else {
|
|
2511
|
-
|
|
2512
|
-
// count only so the matching command.executed compatibility event can be
|
|
2513
|
-
// consumed without suppressing a genuine repeated command.
|
|
2514
|
-
markHandled(sessionID, name, args)
|
|
2786
|
+
markHandled(sessionID, name, args);
|
|
2515
2787
|
}
|
|
2516
|
-
if (isLoopCommandName(name))
|
|
2517
|
-
|
|
2788
|
+
if (isLoopCommandName(name))
|
|
2789
|
+
guardLoopOwnedUserMessage(sessionID);
|
|
2518
2790
|
const handled = () => {
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
if (name === "loop-goal-
|
|
2528
|
-
|
|
2529
|
-
if (name === "loop-goal-resume")
|
|
2530
|
-
|
|
2531
|
-
if (name === "loop-goal-
|
|
2532
|
-
|
|
2533
|
-
if (name === "loop"
|
|
2534
|
-
|
|
2535
|
-
if (name === "loop-
|
|
2536
|
-
|
|
2537
|
-
if (name === "loop
|
|
2538
|
-
|
|
2539
|
-
if (name
|
|
2540
|
-
|
|
2541
|
-
if (name === "loop-
|
|
2542
|
-
|
|
2543
|
-
if (name === "loop-
|
|
2544
|
-
|
|
2545
|
-
if (name === "loop-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2791
|
+
if (output && typeof output === "object")
|
|
2792
|
+
output.noReply = true;
|
|
2793
|
+
return true;
|
|
2794
|
+
};
|
|
2795
|
+
if (name === "loop-goal")
|
|
2796
|
+
return await addGoal(directory, client, sessionID, args), handled();
|
|
2797
|
+
if (name === "loop-goal-status")
|
|
2798
|
+
return await statusGoal(directory, client, sessionID), handled();
|
|
2799
|
+
if (name === "loop-goal-pause")
|
|
2800
|
+
return await pauseGoal(directory, client, sessionID, args), handled();
|
|
2801
|
+
if (name === "loop-goal-resume")
|
|
2802
|
+
return await resumeGoal(directory, client, sessionID, args), handled();
|
|
2803
|
+
if (name === "loop-goal-clear")
|
|
2804
|
+
return await clearGoal(directory, client, sessionID, args), handled();
|
|
2805
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete")
|
|
2806
|
+
return await completeGoalCommand(directory, client, sessionID, args), handled();
|
|
2807
|
+
if (name === "loop-goal-blocked")
|
|
2808
|
+
return await blockGoalCommand(directory, client, sessionID, args), handled();
|
|
2809
|
+
if (name === "loop")
|
|
2810
|
+
return await addLoop(directory, client, sessionID, args), handled();
|
|
2811
|
+
if (isPreset(name))
|
|
2812
|
+
return await addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled();
|
|
2813
|
+
if (name === "loop-stop" || name === "loop-remove")
|
|
2814
|
+
return await stopLoop(directory, client, sessionID, args), handled();
|
|
2815
|
+
if (name === "loop-clear")
|
|
2816
|
+
return await stopLoop(directory, client, sessionID, "all"), handled();
|
|
2817
|
+
if (name === "loop-status")
|
|
2818
|
+
return await statusLoop(directory, client, sessionID), handled();
|
|
2819
|
+
if (name === "loop-logs")
|
|
2820
|
+
return await logsLoop(directory, client, sessionID), handled();
|
|
2821
|
+
if (name === "loop-help")
|
|
2822
|
+
return await helpLoop(client, sessionID), handled();
|
|
2823
|
+
if (name === "loop-now")
|
|
2824
|
+
return await runNow(directory, client, sessionID, args), handled();
|
|
2825
|
+
if (name === "loop-pause")
|
|
2826
|
+
return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled();
|
|
2827
|
+
if (name === "loop-resume")
|
|
2828
|
+
return await updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled();
|
|
2829
|
+
if (name === "loop-doctor")
|
|
2830
|
+
return await doctorLoop(directory, client, sessionID), handled();
|
|
2831
|
+
if (name === "loop-init")
|
|
2832
|
+
return await initLoop(directory, client, sessionID, args), handled();
|
|
2833
|
+
if (name === "loop-export")
|
|
2834
|
+
return await exportLoop(directory, client, sessionID), handled();
|
|
2835
|
+
if (source === "event")
|
|
2836
|
+
handledCommandEvents.delete(commandEventKey(sessionID, input?.messageID));
|
|
2837
|
+
else
|
|
2838
|
+
consumeHandled(sessionID, name, args);
|
|
2839
|
+
return false;
|
|
2549
2840
|
}
|
|
2550
|
-
|
|
2551
2841
|
function goalTools(defaultDirectory) {
|
|
2552
2842
|
return {
|
|
2553
2843
|
opencode_loop_goal_complete: tool({
|
|
2554
2844
|
description: "Mark the current OpenCode Loop experimental goal as completed. Use only after acceptance criteria are satisfied and you have evidence from tests, typecheck, build, or code inspection.",
|
|
2555
2845
|
args: {
|
|
2556
2846
|
summary: tool.schema.string().describe("Short human-readable summary of what was completed."),
|
|
2557
|
-
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
|
|
2847
|
+
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
|
|
2558
2848
|
},
|
|
2559
2849
|
execute: async (args, context) => {
|
|
2560
|
-
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args)
|
|
2561
|
-
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message }
|
|
2562
|
-
}
|
|
2850
|
+
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args);
|
|
2851
|
+
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message };
|
|
2852
|
+
}
|
|
2563
2853
|
}),
|
|
2564
2854
|
opencode_loop_goal_blocked: tool({
|
|
2565
2855
|
description: "Mark the current OpenCode Loop experimental goal as blocked when user input or manual intervention is required.",
|
|
2566
2856
|
args: {
|
|
2567
2857
|
reason: tool.schema.string().describe("Why the goal is blocked."),
|
|
2568
|
-
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
|
|
2858
|
+
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
|
|
2569
2859
|
},
|
|
2570
2860
|
execute: async (args, context) => {
|
|
2571
|
-
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args)
|
|
2572
|
-
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message }
|
|
2573
|
-
}
|
|
2861
|
+
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args);
|
|
2862
|
+
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message };
|
|
2863
|
+
}
|
|
2574
2864
|
}),
|
|
2575
2865
|
opencode_loop_goal_progress: tool({
|
|
2576
2866
|
description: "Record meaningful progress on the current OpenCode Loop experimental goal without completing it.",
|
|
2577
2867
|
args: {
|
|
2578
2868
|
summary: tool.schema.string().describe("What useful progress was made."),
|
|
2579
|
-
next: tool.schema.string().describe("The next step toward completing the goal.")
|
|
2869
|
+
next: tool.schema.string().describe("The next step toward completing the goal.")
|
|
2580
2870
|
},
|
|
2581
2871
|
execute: async (args, context) => {
|
|
2582
|
-
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args)
|
|
2583
|
-
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message }
|
|
2584
|
-
}
|
|
2585
|
-
})
|
|
2586
|
-
}
|
|
2872
|
+
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args);
|
|
2873
|
+
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message };
|
|
2874
|
+
}
|
|
2875
|
+
})
|
|
2876
|
+
};
|
|
2587
2877
|
}
|
|
2588
|
-
|
|
2589
|
-
export const OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
2590
|
-
// OpenCode's local SDK can be slow or unavailable while a project instance
|
|
2591
|
-
// is still waiting for its plugins to return their hooks. Defer bootstrap
|
|
2592
|
-
// calls so headless/server sessions cannot deadlock during plugin loading.
|
|
2878
|
+
var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
2593
2879
|
const bootstrap = setTimeout(() => {
|
|
2594
|
-
log(client, "info", "Plugin initialized", { directory }).catch(() => {})
|
|
2595
|
-
refreshSessionRelationships(client, directory).catch(() => {})
|
|
2596
|
-
}, 0)
|
|
2597
|
-
bootstrap.unref?.()
|
|
2880
|
+
log(client, "info", "Plugin initialized", { directory }).catch(() => {});
|
|
2881
|
+
refreshSessionRelationships(client, directory).catch(() => {});
|
|
2882
|
+
}, 0);
|
|
2883
|
+
bootstrap.unref?.();
|
|
2598
2884
|
return {
|
|
2599
|
-
dispose: async () => {
|
|
2885
|
+
dispose: async () => {
|
|
2886
|
+
disposeRuntime(directory, client);
|
|
2887
|
+
},
|
|
2600
2888
|
tool: goalTools(directory),
|
|
2601
|
-
"command.execute.before": async (input, output) => {
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
"
|
|
2889
|
+
"command.execute.before": async (input, output) => {
|
|
2890
|
+
await handleCommand(directory, client, input, undefined, undefined, output);
|
|
2891
|
+
},
|
|
2892
|
+
"tool.execute.before": async (input) => {
|
|
2893
|
+
markToolCallActive(input);
|
|
2894
|
+
},
|
|
2895
|
+
"tool.execute.after": async (input) => {
|
|
2896
|
+
markToolCallFinished(input);
|
|
2897
|
+
},
|
|
2898
|
+
"experimental.session.compacting": async (input) => {
|
|
2899
|
+
await noteLoopCompactionStarted(directory, input?.sessionID);
|
|
2900
|
+
},
|
|
2605
2901
|
event: async ({ event }) => {
|
|
2606
|
-
if (event.type === "session.compacted")
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2902
|
+
if (event.type === "session.compacted")
|
|
2903
|
+
await noteLoopCompactionCompleted(directory, client, event?.properties?.sessionID);
|
|
2904
|
+
updateSessionRelationshipFromEvent(event);
|
|
2905
|
+
if (event.type === "message.updated")
|
|
2906
|
+
updateSessionExecutionContext(event?.properties?.info);
|
|
2907
|
+
updateToolActivityFromEvent(event);
|
|
2610
2908
|
if (event.type === "command.executed") {
|
|
2611
|
-
const props = event.properties || {}
|
|
2612
|
-
await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event")
|
|
2909
|
+
const props = event.properties || {};
|
|
2910
|
+
await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event");
|
|
2613
2911
|
}
|
|
2614
|
-
const interruptedSessionID = userInterruptSessionFromEvent(event)
|
|
2912
|
+
const interruptedSessionID = userInterruptSessionFromEvent(event);
|
|
2615
2913
|
if (interruptedSessionID) {
|
|
2616
|
-
rememberSession(directory, client, interruptedSessionID)
|
|
2617
|
-
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID)
|
|
2914
|
+
rememberSession(directory, client, interruptedSessionID);
|
|
2915
|
+
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID);
|
|
2618
2916
|
}
|
|
2619
|
-
const statusUpdate = updateSessionStatusFromEvent(event)
|
|
2620
|
-
if (statusUpdate?.sessionID)
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
|
|
2917
|
+
const statusUpdate = updateSessionStatusFromEvent(event);
|
|
2918
|
+
if (statusUpdate?.sessionID)
|
|
2919
|
+
rememberSession(directory, client, statusUpdate.sessionID);
|
|
2920
|
+
if (statusUpdate?.idle)
|
|
2921
|
+
scheduleIdleWork(directory, client, statusUpdate.sessionID);
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
};
|
|
2925
|
+
var legacy_v1_default = OpenCodeLoopPlugin;
|
|
2926
|
+
|
|
2927
|
+
// src/source/v1.js
|
|
2928
|
+
var clientGenerations = new WeakMap;
|
|
2929
|
+
function reserveClientGeneration(client, directory) {
|
|
2930
|
+
if (!client || typeof client !== "object")
|
|
2931
|
+
return () => true;
|
|
2932
|
+
let generations = clientGenerations.get(client);
|
|
2933
|
+
if (!generations) {
|
|
2934
|
+
generations = new Map;
|
|
2935
|
+
clientGenerations.set(client, generations);
|
|
2936
|
+
}
|
|
2937
|
+
const key = String(directory || "");
|
|
2938
|
+
const generation = (generations.get(key) || 0) + 1;
|
|
2939
|
+
generations.set(key, generation);
|
|
2940
|
+
return () => generations.get(key) === generation;
|
|
2941
|
+
}
|
|
2942
|
+
function scopedClient(client) {
|
|
2943
|
+
if (!client || typeof client !== "object")
|
|
2944
|
+
return client;
|
|
2945
|
+
return new Proxy(client, {
|
|
2946
|
+
get(target, property) {
|
|
2947
|
+
const value = Reflect.get(target, property, target);
|
|
2948
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
2949
|
+
}
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
function scheduleLegacyCleanup(dispose, isCurrentGeneration) {
|
|
2953
|
+
for (const delay2 of [0, 25, 100, 500, 2000, 1e4, 30000]) {
|
|
2954
|
+
const timer = setTimeout(() => {
|
|
2955
|
+
if (!isCurrentGeneration())
|
|
2956
|
+
return;
|
|
2957
|
+
Promise.resolve(dispose()).catch(() => {});
|
|
2958
|
+
}, delay2);
|
|
2959
|
+
timer.unref?.();
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
var OpenCodeLoopPlugin2 = async (input = {}) => {
|
|
2963
|
+
const realClient = input?.client;
|
|
2964
|
+
const isCurrentGeneration = reserveClientGeneration(realClient, input?.directory);
|
|
2965
|
+
const client = scopedClient(realClient);
|
|
2966
|
+
const hooks = await legacy_v1_default({ ...input, client });
|
|
2967
|
+
const legacyDispose = typeof hooks?.dispose === "function" ? hooks.dispose.bind(hooks) : undefined;
|
|
2968
|
+
if (!legacyDispose)
|
|
2969
|
+
return hooks;
|
|
2970
|
+
let disposed = false;
|
|
2971
|
+
return {
|
|
2972
|
+
...hooks,
|
|
2973
|
+
dispose: async () => {
|
|
2974
|
+
if (disposed)
|
|
2975
|
+
return;
|
|
2976
|
+
disposed = true;
|
|
2977
|
+
await legacyDispose();
|
|
2978
|
+
scheduleLegacyCleanup(legacyDispose, isCurrentGeneration);
|
|
2979
|
+
}
|
|
2980
|
+
};
|
|
2981
|
+
};
|
|
2982
|
+
var v1_default = OpenCodeLoopPlugin2;
|
|
2983
|
+
export {
|
|
2984
|
+
v1_default as default,
|
|
2985
|
+
OpenCodeLoopPlugin2 as OpenCodeLoopPlugin
|
|
2986
|
+
};
|