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