@bybrawe/opencode-loop 0.5.28 → 0.5.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/scripts/command-lifecycle-test.mjs +47 -0
- package/scripts/command-router-test.mjs +175 -0
- package/scripts/compaction-runtime-test.mjs +128 -0
- package/scripts/comprehensive-test.mjs +11 -3
- package/scripts/executor-runtime-test.mjs +310 -0
- package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
- package/scripts/goal-command-handlers-test.mjs +236 -0
- package/scripts/goal-policy-test.mjs +143 -0
- package/scripts/goal-runtime-test.mjs +203 -0
- package/scripts/goal-steering-test.mjs +153 -0
- package/scripts/host-goal-steering-canary.mjs +443 -0
- package/scripts/host-loop-canary.mjs +54 -12
- package/scripts/job-workspace-test.mjs +164 -0
- package/scripts/loop-command-handlers-test.mjs +279 -0
- package/scripts/loop-registration-test.mjs +220 -0
- package/scripts/opencode2-loop-canary.mjs +433 -0
- package/scripts/scheduler-runtime-test.mjs +147 -0
- package/scripts/session-activity-test.mjs +95 -0
- package/scripts/session-status-test.mjs +220 -0
- package/scripts/v2-command-adapter-test.mjs +113 -0
- package/scripts/v2-command-runtime-test.mjs +98 -0
- package/scripts/v2-host-contract-test.mjs +27 -0
- package/scripts/v2-plugin-sentinel-test.mjs +57 -8
- package/scripts/v2-prompt-interval-test.mjs +171 -0
- package/scripts/v2-prompt-runtime-test.mjs +267 -0
- package/scripts/v2-runtime-adapter-test.mjs +326 -0
- package/src/index.js +2751 -1594
- package/src/source/legacy-v1.js +168 -1860
- package/src/source/opencode/command-router.js +103 -0
- package/src/source/opencode/commands.js +83 -0
- package/src/source/opencode/goal-commands.js +107 -0
- package/src/source/opencode/loop-commands.js +195 -0
- package/src/source/opencode/loop-registration.js +100 -0
- package/src/source/opencode/messages.js +55 -0
- package/src/source/opencode2/capabilities.js +3 -1
- package/src/source/opencode2/commands.js +60 -0
- package/src/source/opencode2/event-bridge.js +19 -1
- package/src/source/opencode2/events.js +64 -0
- package/src/source/opencode2/experimental.js +32 -1
- package/src/source/opencode2/host-contract.js +26 -0
- package/src/source/opencode2/prompt-runtime.js +381 -0
- package/src/source/opencode2/runtime-adapter.js +56 -0
- package/src/source/opencode2/status.js +40 -0
- package/src/source/runtime/compaction.js +122 -0
- package/src/source/runtime/executor.js +558 -0
- package/src/source/runtime/goal-policy.js +77 -0
- package/src/source/runtime/goal-runtime.js +231 -0
- package/src/source/runtime/goal-steering.js +198 -0
- package/src/source/runtime/job-workspace.js +148 -0
- package/src/source/runtime/scheduler.js +212 -0
- package/src/source/runtime/session-activity.js +112 -0
- package/src/source/runtime/session-status.js +197 -0
- package/scripts/host-bridge-parity-test.mjs +0 -22
package/src/index.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/source/legacy-v1.js
|
|
3
|
-
import { promises as fs3 } from "fs";
|
|
4
|
-
import path3 from "path";
|
|
5
3
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
6
4
|
|
|
7
5
|
// src/source/core/args.js
|
|
@@ -376,110 +374,10 @@ function parseLoopArgs(raw, defaults = {}) {
|
|
|
376
374
|
return { ok: true, job };
|
|
377
375
|
}
|
|
378
376
|
|
|
379
|
-
// src/source/core/
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
}
|
|
377
|
+
// src/source/core/process.js
|
|
378
|
+
import { promises as fs2 } from "fs";
|
|
379
|
+
import path2 from "path";
|
|
380
|
+
import { spawn } from "child_process";
|
|
483
381
|
|
|
484
382
|
// src/source/core/state.js
|
|
485
383
|
import { promises as fs } from "fs";
|
|
@@ -699,9 +597,6 @@ async function removeState(directory, sessionID) {
|
|
|
699
597
|
}
|
|
700
598
|
|
|
701
599
|
// src/source/core/process.js
|
|
702
|
-
import { promises as fs2 } from "fs";
|
|
703
|
-
import path2 from "path";
|
|
704
|
-
import { spawn } from "child_process";
|
|
705
600
|
async function appendLoopLog(directory, line, extra = {}) {
|
|
706
601
|
try {
|
|
707
602
|
await ensureDir(stateDir(directory));
|
|
@@ -771,12 +666,12 @@ async function notifyJob(directory, job, reason) {
|
|
|
771
666
|
}
|
|
772
667
|
|
|
773
668
|
// src/source/opencode/sdk.js
|
|
774
|
-
function
|
|
669
|
+
function sdkError(result) {
|
|
775
670
|
if (!result || typeof result !== "object")
|
|
776
671
|
return;
|
|
777
672
|
return result.error || result.error === null ? result.error : undefined;
|
|
778
673
|
}
|
|
779
|
-
function
|
|
674
|
+
function sdkData(result) {
|
|
780
675
|
if (!result || typeof result !== "object")
|
|
781
676
|
return result;
|
|
782
677
|
return Object.prototype.hasOwnProperty.call(result, "data") ? result.data : result;
|
|
@@ -806,9 +701,9 @@ async function sdkCall(method, ...argsList) {
|
|
|
806
701
|
continue;
|
|
807
702
|
try {
|
|
808
703
|
const result = await method(args);
|
|
809
|
-
const error =
|
|
704
|
+
const error = sdkError(result);
|
|
810
705
|
if (!error)
|
|
811
|
-
return
|
|
706
|
+
return sdkData(result);
|
|
812
707
|
firstError = firstError || new Error(sdkErrorMessage(error));
|
|
813
708
|
} catch (error) {
|
|
814
709
|
firstError = firstError || error;
|
|
@@ -866,64 +761,8 @@ function deleteSessionExecutionContext(sessionID) {
|
|
|
866
761
|
sessionExecutionContexts.delete(sessionID);
|
|
867
762
|
}
|
|
868
763
|
|
|
869
|
-
// src/source/
|
|
764
|
+
// src/source/opencode/host.js
|
|
870
765
|
var SERVICE = "opencode-loop";
|
|
871
|
-
var DEFAULT_ACTIVE_GUARD_MS = 45000;
|
|
872
|
-
var STALE_ACTIVE_RECOVERY_MS = 45000;
|
|
873
|
-
var IDLE_DEBOUNCE_MS = 1200;
|
|
874
|
-
var BUSY_RETRY_MS = 5000;
|
|
875
|
-
var SESSION_STATUS_CACHE_MS = 1500;
|
|
876
|
-
var MIN_DUE_TIMER_MS = 250;
|
|
877
|
-
var MAX_DUE_TIMER_MS = 2147000000;
|
|
878
|
-
var HEARTBEAT_MS = 2500;
|
|
879
|
-
var MAX_SCAN_FILES = 200;
|
|
880
|
-
var MAX_SCAN_BYTES = 2000000;
|
|
881
|
-
var GOAL_REPORT_DIR = "goals";
|
|
882
|
-
var GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION";
|
|
883
|
-
var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
|
|
884
|
-
var LOOP_OWNED_USER_MESSAGE_GUARD_MS = 1e4;
|
|
885
|
-
var LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60000;
|
|
886
|
-
var activeRuns = new Map;
|
|
887
|
-
var handledCommands = new Map;
|
|
888
|
-
var handledCommandEvents = new Map;
|
|
889
|
-
var loopOwnedUserMessageGuards = new Map;
|
|
890
|
-
var idleTimers = new Map;
|
|
891
|
-
var dueTimers = new Map;
|
|
892
|
-
var watchdogTimers = new Map;
|
|
893
|
-
var runLocks = new Map;
|
|
894
|
-
var knownSessions = new Map;
|
|
895
|
-
var activeToolCalls = new Map;
|
|
896
|
-
var sessionParents = new Map;
|
|
897
|
-
var heartbeatTimer;
|
|
898
|
-
var sessionStatuses = new Map;
|
|
899
|
-
var sessionStatusSeenAt = new Map;
|
|
900
|
-
var loopCompactionRequests = new Map;
|
|
901
|
-
var DEFAULT_PROGRESS_MD = `# Progress
|
|
902
|
-
|
|
903
|
-
## Current Goal
|
|
904
|
-
Describe the current project goal here.
|
|
905
|
-
|
|
906
|
-
## Agent Rules
|
|
907
|
-
- Do not ask questions unless truly blocked.
|
|
908
|
-
- Make reasonable assumptions and continue.
|
|
909
|
-
- Work on unfinished TODOs in order.
|
|
910
|
-
- Mark completed TODOs with [x].
|
|
911
|
-
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
912
|
-
- Run tests, lint, or build when available.
|
|
913
|
-
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
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
766
|
function fireSdk(client, label, method, ...argsList) {
|
|
928
767
|
const pending = Promise.resolve().then(() => sdkCall(method, ...argsList));
|
|
929
768
|
pending.catch((error) => {
|
|
@@ -1036,6 +875,11 @@ async function toast(client, message, variant = "info") {
|
|
|
1036
875
|
await sdkCall(client.tui.showToast.bind(client.tui), { body: { message, variant } }, { message, variant });
|
|
1037
876
|
} catch {}
|
|
1038
877
|
}
|
|
878
|
+
|
|
879
|
+
// src/source/opencode/messages.js
|
|
880
|
+
var LOOP_OWNED_USER_MESSAGE_GUARD_MS = 1e4;
|
|
881
|
+
var LOOP_OWNED_USER_MESSAGE_RETENTION_MS = 10 * 60000;
|
|
882
|
+
var loopOwnedUserMessageGuards = new Map;
|
|
1039
883
|
function guardLoopOwnedUserMessage(sessionID) {
|
|
1040
884
|
if (!sessionID)
|
|
1041
885
|
return;
|
|
@@ -1065,9 +909,10 @@ function loopOwnedUserMessageGuardActive(sessionID, messageID) {
|
|
|
1065
909
|
if (id && entry.messageIDs.has(id))
|
|
1066
910
|
return true;
|
|
1067
911
|
if ((entry.pending || 0) > 0 && (entry.until || 0) >= now()) {
|
|
912
|
+
if (!id)
|
|
913
|
+
return true;
|
|
1068
914
|
entry.pending -= 1;
|
|
1069
|
-
|
|
1070
|
-
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS);
|
|
915
|
+
entry.messageIDs.set(id, now() + LOOP_OWNED_USER_MESSAGE_RETENTION_MS);
|
|
1071
916
|
loopOwnedUserMessageGuards.set(sessionID, entry);
|
|
1072
917
|
return true;
|
|
1073
918
|
}
|
|
@@ -1081,25 +926,49 @@ async function say(client, sessionID, text) {
|
|
|
1081
926
|
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 }] });
|
|
1082
927
|
} catch {}
|
|
1083
928
|
}
|
|
929
|
+
function clearLoopOwnedUserMessageGuard(sessionID) {
|
|
930
|
+
loopOwnedUserMessageGuards.delete(sessionID);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/source/opencode/commands.js
|
|
934
|
+
var COMMAND_DEDUPE_MS = 30000;
|
|
935
|
+
var handledCommands = new Map;
|
|
936
|
+
var handledCommandEvents = new Map;
|
|
937
|
+
function normalizeArgsForKey(args) {
|
|
938
|
+
if (args === undefined || args === null)
|
|
939
|
+
return "";
|
|
940
|
+
if (typeof args === "string")
|
|
941
|
+
return args.trim().replace(/\s+/g, " ");
|
|
942
|
+
if (Array.isArray(args))
|
|
943
|
+
return args.map(normalizeArgsForKey).join(" ").trim().replace(/\s+/g, " ");
|
|
944
|
+
try {
|
|
945
|
+
return JSON.stringify(args);
|
|
946
|
+
} catch {
|
|
947
|
+
return String(args);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
1084
950
|
function commandKey(sessionID, name, args) {
|
|
1085
951
|
return `${sessionID || "no-session"}:${name || ""}:${normalizeArgsForKey(args)}`;
|
|
1086
952
|
}
|
|
953
|
+
function commandEventKey(sessionID, messageID) {
|
|
954
|
+
return `${sessionID || "no-session"}:event:${messageID || "no-message"}`;
|
|
955
|
+
}
|
|
1087
956
|
function markHandled(sessionID, name, args) {
|
|
1088
957
|
const key = commandKey(sessionID, name, args);
|
|
1089
958
|
const previous = handledCommands.get(key);
|
|
1090
|
-
const pending = previous && now() - previous.time <
|
|
959
|
+
const pending = previous && now() - previous.time < COMMAND_DEDUPE_MS ? previous.pending + 1 : 1;
|
|
1091
960
|
handledCommands.set(key, { time: now(), pending });
|
|
1092
961
|
for (const [entryKey, entry] of handledCommands.entries())
|
|
1093
|
-
if (now() - entry.time >
|
|
962
|
+
if (now() - entry.time > COMMAND_DEDUPE_MS)
|
|
1094
963
|
handledCommands.delete(entryKey);
|
|
1095
964
|
for (const [entryKey, time] of handledCommandEvents.entries())
|
|
1096
|
-
if (now() - time >
|
|
965
|
+
if (now() - time > COMMAND_DEDUPE_MS)
|
|
1097
966
|
handledCommandEvents.delete(entryKey);
|
|
1098
967
|
}
|
|
1099
968
|
function consumeHandled(sessionID, name, args) {
|
|
1100
969
|
const key = commandKey(sessionID, name, args);
|
|
1101
970
|
const entry = handledCommands.get(key);
|
|
1102
|
-
if (!entry || now() - entry.time >=
|
|
971
|
+
if (!entry || now() - entry.time >= COMMAND_DEDUPE_MS) {
|
|
1103
972
|
handledCommands.delete(key);
|
|
1104
973
|
return false;
|
|
1105
974
|
}
|
|
@@ -1109,8 +978,23 @@ function consumeHandled(sessionID, name, args) {
|
|
|
1109
978
|
handledCommands.set(key, { time: entry.time, pending: entry.pending - 1 });
|
|
1110
979
|
return true;
|
|
1111
980
|
}
|
|
1112
|
-
function
|
|
1113
|
-
return
|
|
981
|
+
function hasHandledCommandEvent(sessionID, messageID) {
|
|
982
|
+
return handledCommandEvents.has(commandEventKey(sessionID, messageID));
|
|
983
|
+
}
|
|
984
|
+
function markHandledCommandEvent(sessionID, messageID) {
|
|
985
|
+
handledCommandEvents.set(commandEventKey(sessionID, messageID), now());
|
|
986
|
+
}
|
|
987
|
+
function forgetHandledCommandEvent(sessionID, messageID) {
|
|
988
|
+
handledCommandEvents.delete(commandEventKey(sessionID, messageID));
|
|
989
|
+
}
|
|
990
|
+
function clearCommandLifecycle(sessionID) {
|
|
991
|
+
const prefix = `${sessionID}:`;
|
|
992
|
+
for (const key of handledCommands.keys())
|
|
993
|
+
if (key.startsWith(prefix))
|
|
994
|
+
handledCommands.delete(key);
|
|
995
|
+
for (const key of handledCommandEvents.keys())
|
|
996
|
+
if (key.startsWith(prefix))
|
|
997
|
+
handledCommandEvents.delete(key);
|
|
1114
998
|
}
|
|
1115
999
|
function commandName(name) {
|
|
1116
1000
|
return String(name || "");
|
|
@@ -1121,19 +1005,6 @@ function isPreset(name) {
|
|
|
1121
1005
|
function isLoopCommandName(name) {
|
|
1122
1006
|
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);
|
|
1123
1007
|
}
|
|
1124
|
-
function normalizeArgsForKey(args) {
|
|
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
|
-
}
|
|
1136
|
-
}
|
|
1137
1008
|
function commandArgsText(args) {
|
|
1138
1009
|
if (args === undefined || args === null)
|
|
1139
1010
|
return "";
|
|
@@ -1149,184 +1020,222 @@ function commandArgsText(args) {
|
|
|
1149
1020
|
}
|
|
1150
1021
|
return String(args);
|
|
1151
1022
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1023
|
+
|
|
1024
|
+
// src/source/core/jobs.js
|
|
1025
|
+
function presetDefaults(name) {
|
|
1026
|
+
if (name === "loop-compact")
|
|
1027
|
+
return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false };
|
|
1028
|
+
if (name === "loop-command" || name === "loop-cmd")
|
|
1029
|
+
return { intervalMs: 0, kind: "command", name: "command", immediate: false };
|
|
1030
|
+
if (name === "loop-prompt")
|
|
1031
|
+
return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true };
|
|
1032
|
+
if (name === "loop-ask")
|
|
1033
|
+
return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false };
|
|
1034
|
+
if (name === "loop-shell")
|
|
1035
|
+
return { intervalMs: 0, kind: "shell", name: "shell", immediate: false };
|
|
1036
|
+
if (name === "loop-testfix")
|
|
1037
|
+
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" };
|
|
1038
|
+
if (name === "loop-progress")
|
|
1039
|
+
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." };
|
|
1040
|
+
if (name === "loop-safe-dev")
|
|
1041
|
+
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." };
|
|
1042
|
+
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." };
|
|
1157
1043
|
}
|
|
1158
|
-
function
|
|
1159
|
-
|
|
1044
|
+
function jobLabel(job) {
|
|
1045
|
+
const title = job.name ? `${job.name}: ` : "";
|
|
1046
|
+
const kind = job.kind ? ` [${job.kind}]` : "";
|
|
1047
|
+
const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : "";
|
|
1048
|
+
const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : "";
|
|
1049
|
+
const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : "";
|
|
1050
|
+
const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : "";
|
|
1051
|
+
const verify = job.verifyCommand ? ", verify" : "";
|
|
1052
|
+
const preflight = job.preflightCommand ? ", preflight" : "";
|
|
1053
|
+
const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : "";
|
|
1054
|
+
const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : "";
|
|
1055
|
+
const stopFile = job.stopFile ? ", stop-file" : "";
|
|
1056
|
+
const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : "";
|
|
1057
|
+
const paused = job.paused ? ", paused" : "";
|
|
1058
|
+
return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`;
|
|
1160
1059
|
}
|
|
1161
|
-
function
|
|
1162
|
-
const
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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());
|
|
1060
|
+
function matchJob(job, target, index) {
|
|
1061
|
+
const text = String(target || "").trim();
|
|
1062
|
+
if (!text || text.toLowerCase() === "all")
|
|
1063
|
+
return true;
|
|
1064
|
+
return job.id === text || job.name === text || String(index + 1) === text;
|
|
1171
1065
|
}
|
|
1172
|
-
function
|
|
1173
|
-
const
|
|
1174
|
-
const
|
|
1175
|
-
if (
|
|
1176
|
-
return;
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
if (
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
if (!removed)
|
|
1193
|
-
updateSessionExecutionContext(info);
|
|
1194
|
-
else
|
|
1195
|
-
deleteSessionExecutionContext(sessionID);
|
|
1196
|
-
}
|
|
1197
|
-
function updateSessionRelationshipFromEvent(event) {
|
|
1198
|
-
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type))
|
|
1199
|
-
return;
|
|
1200
|
-
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted");
|
|
1201
|
-
}
|
|
1202
|
-
function isDescendantSession(sessionID, ancestorID) {
|
|
1203
|
-
const visited = new Set;
|
|
1204
|
-
let current = sessionID;
|
|
1205
|
-
while (sessionParents.has(current) && !visited.has(current)) {
|
|
1206
|
-
visited.add(current);
|
|
1207
|
-
current = sessionParents.get(current);
|
|
1208
|
-
if (current === ancestorID)
|
|
1209
|
-
return true;
|
|
1210
|
-
}
|
|
1211
|
-
return false;
|
|
1066
|
+
function actionKind(action, job = {}) {
|
|
1067
|
+
const text = String(action || "").trim();
|
|
1068
|
+
const forced = String(job.kind || "").trim().toLowerCase();
|
|
1069
|
+
if (forced === "compact")
|
|
1070
|
+
return "compact";
|
|
1071
|
+
if (forced === "goal")
|
|
1072
|
+
return "goal";
|
|
1073
|
+
if (text === "/compact" || text === "/summarize")
|
|
1074
|
+
return "compact";
|
|
1075
|
+
if (forced === "prompt" || forced === "ask")
|
|
1076
|
+
return "prompt";
|
|
1077
|
+
if (forced === "command" || forced === "cmd" || forced === "slash")
|
|
1078
|
+
return "command";
|
|
1079
|
+
if (forced === "shell")
|
|
1080
|
+
return "shell";
|
|
1081
|
+
if (text.startsWith("/"))
|
|
1082
|
+
return "command";
|
|
1083
|
+
if (text.startsWith("!") || text.startsWith("$"))
|
|
1084
|
+
return "shell";
|
|
1085
|
+
return "prompt";
|
|
1212
1086
|
}
|
|
1213
|
-
function
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1087
|
+
function decoratePrompt(job) {
|
|
1088
|
+
const additions = [];
|
|
1089
|
+
if (job.progressFile)
|
|
1090
|
+
additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
|
|
1091
|
+
if (job.lastVerifyFailure)
|
|
1092
|
+
additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
|
|
1093
|
+
if (job.askNever)
|
|
1094
|
+
additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.");
|
|
1095
|
+
if (job.safe)
|
|
1096
|
+
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.");
|
|
1097
|
+
if (job.batch > 0)
|
|
1098
|
+
additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`);
|
|
1099
|
+
if (job.quiet)
|
|
1100
|
+
additions.push("Keep replies short. Summarize only what changed, tests run, and next step.");
|
|
1101
|
+
if (job.testCommand)
|
|
1102
|
+
additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`);
|
|
1103
|
+
if (job.checkpointOnly || job.gitCheckpoint)
|
|
1104
|
+
additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.");
|
|
1105
|
+
if (!additions.length)
|
|
1106
|
+
return job.action;
|
|
1107
|
+
return `${job.action}
|
|
1108
|
+
|
|
1109
|
+
OpenCode loop instructions:
|
|
1110
|
+
- ${additions.join(`
|
|
1111
|
+
- `)}`;
|
|
1222
1112
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
return;
|
|
1226
|
-
try {
|
|
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);
|
|
1231
|
-
} catch {}
|
|
1113
|
+
function isGoalJob(job) {
|
|
1114
|
+
return String(job?.kind || "").toLowerCase() === "goal";
|
|
1232
1115
|
}
|
|
1233
|
-
function
|
|
1234
|
-
const
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))];
|
|
1245
|
-
for (const callID of identifiers)
|
|
1246
|
-
markToolCallFinished({ sessionID, callID });
|
|
1247
|
-
}
|
|
1248
|
-
return;
|
|
1249
|
-
}
|
|
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);
|
|
1116
|
+
function goalStatusText(job) {
|
|
1117
|
+
const status = job?.goalStatus || (isGoalJob(job) ? "active" : "");
|
|
1118
|
+
if (!status)
|
|
1119
|
+
return "";
|
|
1120
|
+
if (status === "completed")
|
|
1121
|
+
return "completed";
|
|
1122
|
+
if (status === "blocked")
|
|
1123
|
+
return "blocked";
|
|
1124
|
+
if (job?.paused)
|
|
1125
|
+
return "paused";
|
|
1126
|
+
return status;
|
|
1256
1127
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1128
|
+
|
|
1129
|
+
// src/source/opencode/command-router.js
|
|
1130
|
+
var HANDLER_NAMES = [
|
|
1131
|
+
"addGoal",
|
|
1132
|
+
"statusGoal",
|
|
1133
|
+
"pauseGoal",
|
|
1134
|
+
"resumeGoal",
|
|
1135
|
+
"clearGoal",
|
|
1136
|
+
"completeGoalCommand",
|
|
1137
|
+
"blockGoalCommand",
|
|
1138
|
+
"addLoop",
|
|
1139
|
+
"stopLoop",
|
|
1140
|
+
"statusLoop",
|
|
1141
|
+
"logsLoop",
|
|
1142
|
+
"helpLoop",
|
|
1143
|
+
"runNow",
|
|
1144
|
+
"updateJobState",
|
|
1145
|
+
"doctorLoop",
|
|
1146
|
+
"initLoop",
|
|
1147
|
+
"exportLoop"
|
|
1148
|
+
];
|
|
1149
|
+
function requireFunction(value, name) {
|
|
1150
|
+
if (typeof value !== "function")
|
|
1151
|
+
throw new TypeError(`createCommandRouter requires ${name}`);
|
|
1152
|
+
return value;
|
|
1153
|
+
}
|
|
1154
|
+
function createCommandRouter(options = {}) {
|
|
1155
|
+
const rememberSession = requireFunction(options.rememberSession, "rememberSession");
|
|
1156
|
+
const captureSessionExecutionContext2 = typeof options.captureSessionExecutionContext === "function" ? options.captureSessionExecutionContext : captureSessionExecutionContext;
|
|
1157
|
+
const guardLoopOwnedUserMessage2 = typeof options.guardLoopOwnedUserMessage === "function" ? options.guardLoopOwnedUserMessage : guardLoopOwnedUserMessage;
|
|
1158
|
+
const handlers = {};
|
|
1159
|
+
for (const name of HANDLER_NAMES)
|
|
1160
|
+
handlers[name] = requireFunction(options.handlers?.[name], `handlers.${name}`);
|
|
1161
|
+
return async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
|
|
1162
|
+
const name = commandName(input?.command ?? input?.name ?? fallbackName);
|
|
1163
|
+
const sessionID = input?.sessionID;
|
|
1164
|
+
const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "");
|
|
1165
|
+
if (!sessionID || !name)
|
|
1166
|
+
return false;
|
|
1167
|
+
rememberSession(directory, client, sessionID);
|
|
1168
|
+
if (isLoopCommandName(name))
|
|
1169
|
+
await captureSessionExecutionContext2(client, sessionID);
|
|
1170
|
+
if (source === "event") {
|
|
1171
|
+
if (consumeHandled(sessionID, name, args))
|
|
1172
|
+
return true;
|
|
1173
|
+
if (hasHandledCommandEvent(sessionID, input?.messageID))
|
|
1174
|
+
return true;
|
|
1175
|
+
markHandledCommandEvent(sessionID, input?.messageID);
|
|
1176
|
+
} else {
|
|
1177
|
+
markHandled(sessionID, name, args);
|
|
1274
1178
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
if (
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
if (
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
].some((pattern) => pattern.test(text));
|
|
1179
|
+
if (isLoopCommandName(name))
|
|
1180
|
+
guardLoopOwnedUserMessage2(sessionID);
|
|
1181
|
+
const handled = () => {
|
|
1182
|
+
if (output && typeof output === "object")
|
|
1183
|
+
output.noReply = true;
|
|
1184
|
+
return true;
|
|
1185
|
+
};
|
|
1186
|
+
if (name === "loop-goal")
|
|
1187
|
+
return await handlers.addGoal(directory, client, sessionID, args), handled();
|
|
1188
|
+
if (name === "loop-goal-status")
|
|
1189
|
+
return await handlers.statusGoal(directory, client, sessionID), handled();
|
|
1190
|
+
if (name === "loop-goal-pause")
|
|
1191
|
+
return await handlers.pauseGoal(directory, client, sessionID, args), handled();
|
|
1192
|
+
if (name === "loop-goal-resume")
|
|
1193
|
+
return await handlers.resumeGoal(directory, client, sessionID, args), handled();
|
|
1194
|
+
if (name === "loop-goal-clear")
|
|
1195
|
+
return await handlers.clearGoal(directory, client, sessionID, args), handled();
|
|
1196
|
+
if (name === "loop-goal-done" || name === "loop-goal-complete")
|
|
1197
|
+
return await handlers.completeGoalCommand(directory, client, sessionID, args), handled();
|
|
1198
|
+
if (name === "loop-goal-blocked")
|
|
1199
|
+
return await handlers.blockGoalCommand(directory, client, sessionID, args), handled();
|
|
1200
|
+
if (name === "loop")
|
|
1201
|
+
return await handlers.addLoop(directory, client, sessionID, args), handled();
|
|
1202
|
+
if (isPreset(name))
|
|
1203
|
+
return await handlers.addLoop(directory, client, sessionID, args, presetDefaults(name, args)), handled();
|
|
1204
|
+
if (name === "loop-stop" || name === "loop-remove")
|
|
1205
|
+
return await handlers.stopLoop(directory, client, sessionID, args), handled();
|
|
1206
|
+
if (name === "loop-clear")
|
|
1207
|
+
return await handlers.stopLoop(directory, client, sessionID, "all"), handled();
|
|
1208
|
+
if (name === "loop-status")
|
|
1209
|
+
return await handlers.statusLoop(directory, client, sessionID), handled();
|
|
1210
|
+
if (name === "loop-logs")
|
|
1211
|
+
return await handlers.logsLoop(directory, client, sessionID), handled();
|
|
1212
|
+
if (name === "loop-help")
|
|
1213
|
+
return await handlers.helpLoop(client, sessionID), handled();
|
|
1214
|
+
if (name === "loop-now")
|
|
1215
|
+
return await handlers.runNow(directory, client, sessionID, args), handled();
|
|
1216
|
+
if (name === "loop-pause")
|
|
1217
|
+
return await handlers.updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: true }), "Paused"), handled();
|
|
1218
|
+
if (name === "loop-resume")
|
|
1219
|
+
return await handlers.updateJobState(directory, client, sessionID, args, (job) => ({ ...job, paused: false, lastRunAt: 0 }), "Resumed"), handled();
|
|
1220
|
+
if (name === "loop-doctor")
|
|
1221
|
+
return await handlers.doctorLoop(directory, client, sessionID), handled();
|
|
1222
|
+
if (name === "loop-init")
|
|
1223
|
+
return await handlers.initLoop(directory, client, sessionID, args), handled();
|
|
1224
|
+
if (name === "loop-export")
|
|
1225
|
+
return await handlers.exportLoop(directory, client, sessionID), handled();
|
|
1226
|
+
if (source === "event")
|
|
1227
|
+
forgetHandledCommandEvent(sessionID, input?.messageID);
|
|
1228
|
+
else
|
|
1229
|
+
consumeHandled(sessionID, name, args);
|
|
1230
|
+
return false;
|
|
1231
|
+
};
|
|
1329
1232
|
}
|
|
1233
|
+
|
|
1234
|
+
// src/source/runtime/goal-runtime.js
|
|
1235
|
+
import { promises as fs3 } from "fs";
|
|
1236
|
+
import path3 from "path";
|
|
1237
|
+
var GOAL_REPORT_DIR = "goals";
|
|
1238
|
+
var GOAL_PROMPT_PREFIX = "EXPERIMENTAL OPENCODE GOAL MODE ITERATION";
|
|
1330
1239
|
async function buildGoalPrompt(directory, job) {
|
|
1331
1240
|
const sections = [];
|
|
1332
1241
|
sections.push(`Working directory:
|
|
@@ -1407,583 +1316,57 @@ ${sections.join(`
|
|
|
1407
1316
|
|
|
1408
1317
|
`)}`;
|
|
1409
1318
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
${
|
|
1419
|
-
|
|
1420
|
-
|
|
1319
|
+
function goalReportPath(directory, sessionID, job) {
|
|
1320
|
+
return path3.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`);
|
|
1321
|
+
}
|
|
1322
|
+
function goalReportText(job) {
|
|
1323
|
+
const lines = [];
|
|
1324
|
+
lines.push(`# OpenCode Loop Goal Report`);
|
|
1325
|
+
lines.push("");
|
|
1326
|
+
lines.push(`Status: ${goalStatusText(job) || "unknown"}`);
|
|
1327
|
+
lines.push(`Goal: ${job.action || job.goalFile || ""}`);
|
|
1328
|
+
lines.push(`Created: ${job.createdAt || ""}`);
|
|
1329
|
+
if (job.goalCompletedAt)
|
|
1330
|
+
lines.push(`Completed: ${new Date(job.goalCompletedAt).toISOString()}`);
|
|
1331
|
+
if (job.goalBlockedAt)
|
|
1332
|
+
lines.push(`Blocked: ${new Date(job.goalBlockedAt).toISOString()}`);
|
|
1333
|
+
if (job.lastUserInterruptAt)
|
|
1334
|
+
lines.push(`Paused by user message: ${new Date(job.lastUserInterruptAt).toISOString()}`);
|
|
1335
|
+
if (job.goalNoProgressPausedAt)
|
|
1336
|
+
lines.push(`Paused by no-progress guard: ${new Date(job.goalNoProgressPausedAt).toISOString()}`);
|
|
1337
|
+
if (job.runCount)
|
|
1338
|
+
lines.push(`Turns: ${job.runCount}`);
|
|
1339
|
+
if ((job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0)
|
|
1340
|
+
lines.push(`No-progress: ${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}`);
|
|
1341
|
+
lines.push("");
|
|
1342
|
+
if (job.goalSummary)
|
|
1343
|
+
lines.push("## Summary", "", String(job.goalSummary), "");
|
|
1344
|
+
if (job.goalEvidence)
|
|
1345
|
+
lines.push("## Evidence", "", String(job.goalEvidence), "");
|
|
1346
|
+
if (job.goalBlockedReason)
|
|
1347
|
+
lines.push("## Blocked reason", "", String(job.goalBlockedReason), "");
|
|
1348
|
+
if (job.goalCompletionRejectedReason)
|
|
1349
|
+
lines.push("## Last completion rejection", "", String(job.goalCompletionRejectedReason), "");
|
|
1350
|
+
if (job.goalInterruptedReason)
|
|
1351
|
+
lines.push("## Interrupt", "", String(job.goalInterruptedReason), "");
|
|
1352
|
+
if (job.goalNoProgressReason)
|
|
1353
|
+
lines.push("## No-progress guard", "", String(job.goalNoProgressReason), "");
|
|
1354
|
+
if (job.goalAcceptance?.length)
|
|
1355
|
+
lines.push("## Acceptance criteria", "", ...job.goalAcceptance.map((item) => `- ${item}`), "");
|
|
1356
|
+
if (job.lastGoalChecks?.length) {
|
|
1357
|
+
lines.push("## Latest checks", "");
|
|
1358
|
+
for (const item of job.lastGoalChecks)
|
|
1359
|
+
lines.push(`- ${item.command}: exit ${item.code}`);
|
|
1360
|
+
lines.push("");
|
|
1421
1361
|
}
|
|
1422
|
-
if (job.
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
sections.push(`Context from ${file}:
|
|
1428
|
-
${text.trim().slice(0, 20000)}`);
|
|
1362
|
+
if (job.goalProgress?.length) {
|
|
1363
|
+
lines.push("## Progress", "");
|
|
1364
|
+
for (const item of job.goalProgress)
|
|
1365
|
+
lines.push(`- ${item.time}: ${item.summary}${item.next ? ` Next: ${item.next}` : ""}`);
|
|
1366
|
+
lines.push("");
|
|
1429
1367
|
}
|
|
1430
|
-
return
|
|
1431
|
-
|
|
1432
|
-
---
|
|
1433
|
-
|
|
1434
|
-
`) || decoratePrompt(job);
|
|
1435
|
-
}
|
|
1436
|
-
async function ensureBranch(directory, job, client, sessionID) {
|
|
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;
|
|
1452
|
-
}
|
|
1453
|
-
async function maybeCompact(directory, client, sessionID, job) {
|
|
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);
|
|
1459
|
-
if (await compactSession(directory, client, sessionID, job.model)) {
|
|
1460
|
-
job.lastCompactAt = now();
|
|
1461
|
-
job.lastCompactRunCount = job.runCount || 0;
|
|
1462
|
-
return { job, started: true };
|
|
1463
|
-
}
|
|
1464
|
-
loopCompactionRequests.delete(sessionID);
|
|
1465
|
-
return { job, started: false };
|
|
1466
|
-
}
|
|
1467
|
-
async function snapshotPaths(directory, files) {
|
|
1468
|
-
const snapshot = {};
|
|
1469
|
-
for (const file of files || []) {
|
|
1470
|
-
try {
|
|
1471
|
-
const stat = await fs3.stat(path3.resolve(directory, file));
|
|
1472
|
-
snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
|
|
1473
|
-
} catch {
|
|
1474
|
-
snapshot[file] = "missing";
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
return snapshot;
|
|
1478
|
-
}
|
|
1479
|
-
async function watchChanged(directory, job) {
|
|
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;
|
|
1488
|
-
}
|
|
1489
|
-
async function fileContains(filePath, needle) {
|
|
1490
|
-
try {
|
|
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
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
async function untilReached(directory, job) {
|
|
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;
|
|
1507
|
-
async function walk(current) {
|
|
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
|
-
}
|
|
1516
|
-
for (const entry of entries) {
|
|
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
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
return false;
|
|
1532
|
-
}
|
|
1533
|
-
return await walk(directory);
|
|
1534
|
-
}
|
|
1535
|
-
async function createCheckpoint(directory, sessionID, job, client) {
|
|
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}`);
|
|
1553
|
-
if (job.gitCheckpoint) {
|
|
1554
|
-
await runProcess("git", ["add", "-A"], directory, 120000);
|
|
1555
|
-
await runProcess("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
|
|
1556
|
-
}
|
|
1557
|
-
await toast(client, `Loop checkpoint saved: ${prefix}`, "success");
|
|
1558
|
-
}
|
|
1559
|
-
function updateSessionStatusFromEvent(event) {
|
|
1560
|
-
const sessionID = event?.properties?.sessionID;
|
|
1561
|
-
if (typeof sessionID !== "string")
|
|
1562
|
-
return;
|
|
1563
|
-
if (event?.type === "session.idle") {
|
|
1564
|
-
sessionStatuses.set(sessionID, "idle");
|
|
1565
|
-
sessionStatusSeenAt.set(sessionID, now());
|
|
1566
|
-
return { sessionID, idle: true };
|
|
1567
|
-
}
|
|
1568
|
-
if (event?.type === "session.status") {
|
|
1569
|
-
const status = event?.properties?.status;
|
|
1570
|
-
const type = status && typeof status === "object" ? status.type : undefined;
|
|
1571
|
-
if (typeof type === "string") {
|
|
1572
|
-
sessionStatuses.set(sessionID, type);
|
|
1573
|
-
sessionStatusSeenAt.set(sessionID, now());
|
|
1574
|
-
}
|
|
1575
|
-
return { sessionID, idle: type === "idle" };
|
|
1576
|
-
}
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
function userInterruptSessionFromEvent(event) {
|
|
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;
|
|
1592
|
-
}
|
|
1593
|
-
async function pauseGoalsForUserInterrupt(directory, client, sessionID) {
|
|
1594
|
-
const state = await readState(directory, sessionID);
|
|
1595
|
-
const interruptedAt = now();
|
|
1596
|
-
let count = 0;
|
|
1597
|
-
state.jobs = (state.jobs || []).map((job) => {
|
|
1598
|
-
if (!isGoalJob(job) || job.paused || job.enabled === false || ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
1599
|
-
return job;
|
|
1600
|
-
count++;
|
|
1601
|
-
return {
|
|
1602
|
-
...job,
|
|
1603
|
-
paused: true,
|
|
1604
|
-
lastUserInterruptAt: interruptedAt,
|
|
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;
|
|
1615
|
-
}
|
|
1616
|
-
function staleActiveRun(sessionID) {
|
|
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;
|
|
1624
|
-
}
|
|
1625
|
-
async function canFinalizeActiveRun(directory, client, sessionID, active, options = {}) {
|
|
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;
|
|
1638
|
-
if (live?.type) {
|
|
1639
|
-
if ((live.type === "busy" || live.type === "retry") && options.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
1640
|
-
return true;
|
|
1641
|
-
return false;
|
|
1642
|
-
}
|
|
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);
|
|
1648
|
-
}
|
|
1649
|
-
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
1650
|
-
const argsList = [];
|
|
1651
|
-
if (directory)
|
|
1652
|
-
argsList.push({ query: { directory } }, { directory }, { workspace: directory });
|
|
1653
|
-
argsList.push({});
|
|
1654
|
-
for (const args of argsList) {
|
|
1655
|
-
try {
|
|
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();
|
|
1664
|
-
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
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);
|
|
1670
|
-
}
|
|
1671
|
-
for (const childID of sessionParents.keys()) {
|
|
1672
|
-
if (!isDescendantSession(childID, sessionID) || data[childID])
|
|
1673
|
-
continue;
|
|
1674
|
-
sessionStatuses.set(childID, "idle");
|
|
1675
|
-
sessionStatusSeenAt.set(childID, observedAt);
|
|
1676
|
-
}
|
|
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" };
|
|
1684
|
-
} catch {}
|
|
1685
|
-
}
|
|
1686
|
-
return;
|
|
1687
|
-
}
|
|
1688
|
-
async function sessionStatusType(client, sessionID, directory, options = {}) {
|
|
1689
|
-
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
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);
|
|
1701
|
-
if (live?.type) {
|
|
1702
|
-
if ((live.type === "busy" || live.type === "retry") && options.recoverStaleActive !== false) {
|
|
1703
|
-
const active = activeRuns.get(sessionID);
|
|
1704
|
-
if (active) {
|
|
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());
|
|
1709
|
-
await appendLoopLog(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
|
|
1710
|
-
sessionID,
|
|
1711
|
-
job: active.job?.name || active.jobId,
|
|
1712
|
-
startedAt: active.startedAt
|
|
1713
|
-
});
|
|
1714
|
-
return "idle";
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
sessionStatuses.set(sessionID, live.type);
|
|
1719
|
-
sessionStatusSeenAt.set(sessionID, now());
|
|
1720
|
-
return live.type;
|
|
1721
|
-
}
|
|
1722
|
-
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle";
|
|
1723
|
-
sessionStatuses.set(sessionID, fallback);
|
|
1724
|
-
sessionStatusSeenAt.set(sessionID, now());
|
|
1725
|
-
return fallback;
|
|
1726
|
-
}
|
|
1727
|
-
async function sessionIsIdle(client, sessionID, directory, options = {}) {
|
|
1728
|
-
return await sessionStatusType(client, sessionID, directory, options) === "idle";
|
|
1729
|
-
}
|
|
1730
|
-
function scheduleIdleWork(directory, client, sessionID) {
|
|
1731
|
-
const previous = idleTimers.get(sessionID);
|
|
1732
|
-
if (previous)
|
|
1733
|
-
clearTimeout(previous);
|
|
1734
|
-
const timer = setTimeout(() => {
|
|
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);
|
|
1753
|
-
}
|
|
1754
|
-
function jobDueAt(job, current = now()) {
|
|
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);
|
|
1771
|
-
}
|
|
1772
|
-
function nextDueDelay(state) {
|
|
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);
|
|
1780
|
-
}
|
|
1781
|
-
async function startWatchdog(directory, client, sessionID) {
|
|
1782
|
-
if (watchdogTimers.has(sessionID))
|
|
1783
|
-
return;
|
|
1784
|
-
const timer = setInterval(() => {
|
|
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);
|
|
1803
|
-
}
|
|
1804
|
-
function stopWatchdog(sessionID) {
|
|
1805
|
-
const timer = watchdogTimers.get(sessionID);
|
|
1806
|
-
if (timer)
|
|
1807
|
-
clearInterval(timer);
|
|
1808
|
-
watchdogTimers.delete(sessionID);
|
|
1809
|
-
}
|
|
1810
|
-
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
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);
|
|
1821
|
-
const timer = setTimeout(() => {
|
|
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);
|
|
1841
|
-
}
|
|
1842
|
-
function dueJobs(state, force = false) {
|
|
1843
|
-
const current = now();
|
|
1844
|
-
return (state.jobs || []).filter((job) => {
|
|
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
|
-
});
|
|
1859
|
-
}
|
|
1860
|
-
function clearActiveRun(sessionID) {
|
|
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);
|
|
1868
|
-
}
|
|
1869
|
-
function beginLoopCompaction(sessionID, jobId, resumeAfter = false) {
|
|
1870
|
-
loopCompactionRequests.set(sessionID, {
|
|
1871
|
-
jobId,
|
|
1872
|
-
resumeAfter,
|
|
1873
|
-
requestedAt: now(),
|
|
1874
|
-
startedAt: 0,
|
|
1875
|
-
completedAt: 0
|
|
1876
|
-
});
|
|
1877
|
-
}
|
|
1878
|
-
async function noteLoopCompactionStarted(directory, sessionID) {
|
|
1879
|
-
const pending = loopCompactionRequests.get(sessionID);
|
|
1880
|
-
if (!pending)
|
|
1881
|
-
return false;
|
|
1882
|
-
if (!pending.startedAt) {
|
|
1883
|
-
pending.startedAt = now();
|
|
1884
|
-
loopCompactionRequests.set(sessionID, pending);
|
|
1885
|
-
await appendLoopLog(directory, "compact-started", { sessionID, job: pending.jobId, resumeAfter: pending.resumeAfter });
|
|
1886
|
-
}
|
|
1887
|
-
return true;
|
|
1888
|
-
}
|
|
1889
|
-
async function finalizeLoopCompaction(directory, client, sessionID) {
|
|
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);
|
|
1895
|
-
}
|
|
1896
|
-
async function noteLoopCompactionCompleted(directory, client, sessionID) {
|
|
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 });
|
|
1903
|
-
const timer = setTimeout(() => {
|
|
1904
|
-
finalizeLoopCompaction(directory, client, sessionID).catch((error) => log(client, "error", "compaction finalization failed", { error: sdkErrorMessage(error) }));
|
|
1905
|
-
}, 0);
|
|
1906
|
-
timer.unref?.();
|
|
1907
|
-
return true;
|
|
1908
|
-
}
|
|
1909
|
-
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
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);
|
|
1919
|
-
if (job) {
|
|
1920
|
-
job.failureCount = (job.failureCount || 0) + 1;
|
|
1921
|
-
job.lastFailureReason = "dispatch_failed";
|
|
1922
|
-
job.lastDispatchFailure = message.slice(0, 4000);
|
|
1923
|
-
job.lastDispatchFailureAt = now();
|
|
1924
|
-
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
1925
|
-
job.paused = true;
|
|
1926
|
-
await notifyJob(directory, job, "dispatch_failed");
|
|
1927
|
-
}
|
|
1928
|
-
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
1929
|
-
await writeState(directory, sessionID, state);
|
|
1930
|
-
}
|
|
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;
|
|
1935
|
-
}
|
|
1936
|
-
function goalReportPath(directory, sessionID, job) {
|
|
1937
|
-
return path3.join(stateDir(directory), GOAL_REPORT_DIR, `${safeID(sessionID)}-${safeID(job.name || job.id)}.md`);
|
|
1938
|
-
}
|
|
1939
|
-
function goalReportText(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}`), "");
|
|
1973
|
-
if (job.lastGoalChecks?.length) {
|
|
1974
|
-
lines.push("## Latest checks", "");
|
|
1975
|
-
for (const item of job.lastGoalChecks)
|
|
1976
|
-
lines.push(`- ${item.command}: exit ${item.code}`);
|
|
1977
|
-
lines.push("");
|
|
1978
|
-
}
|
|
1979
|
-
if (job.goalProgress?.length) {
|
|
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("");
|
|
1984
|
-
}
|
|
1985
|
-
return lines.join(`
|
|
1986
|
-
`);
|
|
1368
|
+
return lines.join(`
|
|
1369
|
+
`);
|
|
1987
1370
|
}
|
|
1988
1371
|
async function writeGoalReport(directory, sessionID, job) {
|
|
1989
1372
|
if (!isGoalJob(job))
|
|
@@ -2023,16 +1406,6 @@ function goalChecksPassed(job) {
|
|
|
2023
1406
|
function goalRequiresPassingChecks(job) {
|
|
2024
1407
|
return job?.goalRequireChecksPass !== false && Array.isArray(job?.goalChecks) && job.goalChecks.length > 0;
|
|
2025
1408
|
}
|
|
2026
|
-
async function rejectGoalCompletion(directory, sessionID, state, job, 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}` };
|
|
2035
|
-
}
|
|
2036
1409
|
function goalProgressSnapshot(job) {
|
|
2037
1410
|
return {
|
|
2038
1411
|
status: job?.goalStatus || "",
|
|
@@ -2059,28 +1432,15 @@ function goalMadeMeaningfulProgress(beforeJob, afterJob) {
|
|
|
2059
1432
|
return true;
|
|
2060
1433
|
return false;
|
|
2061
1434
|
}
|
|
2062
|
-
async function
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
return job;
|
|
2072
|
-
}
|
|
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 });
|
|
2076
|
-
if (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 });
|
|
2082
|
-
}
|
|
2083
|
-
return job;
|
|
1435
|
+
async function rejectGoalCompletion(directory, sessionID, state, job, reason) {
|
|
1436
|
+
job.goalCompletionRejectedAt = now();
|
|
1437
|
+
job.goalCompletionRejectedReason = reason;
|
|
1438
|
+
job.goalCompletionRejectedCount = (job.goalCompletionRejectedCount || 0) + 1;
|
|
1439
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
1440
|
+
await writeState(directory, sessionID, state);
|
|
1441
|
+
await writeGoalReport(directory, sessionID, job);
|
|
1442
|
+
await appendLoopLog(directory, "goal-complete-rejected", { sessionID, job: job.name || job.id, reason });
|
|
1443
|
+
return { ok: false, job, rejected: true, message: `Goal completion rejected: ${reason}` };
|
|
2084
1444
|
}
|
|
2085
1445
|
async function setGoalComplete(directory, sessionID, args = {}) {
|
|
2086
1446
|
const state = await readState(directory, sessionID);
|
|
@@ -2149,694 +1509,2064 @@ async function setGoalProgress(directory, sessionID, args = {}) {
|
|
|
2149
1509
|
await appendLoopLog(directory, "goal-progress", { sessionID, job: job.name || job.id, summary: item.summary });
|
|
2150
1510
|
return { ok: true, job, message: `Goal progress recorded: ${item.summary}` };
|
|
2151
1511
|
}
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
1512
|
+
|
|
1513
|
+
// src/source/opencode/goal-commands.js
|
|
1514
|
+
function requireFunction2(value, name) {
|
|
1515
|
+
if (typeof value !== "function")
|
|
1516
|
+
throw new TypeError(`createGoalCommandHandlers requires ${name}`);
|
|
1517
|
+
return value;
|
|
1518
|
+
}
|
|
1519
|
+
function createGoalCommandHandlers(options = {}) {
|
|
1520
|
+
const addLoop = requireFunction2(options.addLoop, "addLoop");
|
|
1521
|
+
const scheduleDueWork = requireFunction2(options.scheduleDueWork, "scheduleDueWork");
|
|
1522
|
+
const scheduleIdleWork = requireFunction2(options.scheduleIdleWork, "scheduleIdleWork");
|
|
1523
|
+
const toast2 = requireFunction2(options.toast, "toast");
|
|
1524
|
+
const say2 = requireFunction2(options.say, "say");
|
|
1525
|
+
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1526
|
+
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1527
|
+
const setGoalComplete2 = typeof options.setGoalComplete === "function" ? options.setGoalComplete : setGoalComplete;
|
|
1528
|
+
const setGoalBlocked2 = typeof options.setGoalBlocked === "function" ? options.setGoalBlocked : setGoalBlocked;
|
|
1529
|
+
async function statusGoal(directory, client, sessionID) {
|
|
1530
|
+
const state = await readState2(directory, sessionID);
|
|
1531
|
+
const goals = (state.jobs || []).filter(isGoalJob);
|
|
1532
|
+
const lines = goals.length ? goals.map((job, index) => {
|
|
1533
|
+
const status = goalStatusText(job);
|
|
1534
|
+
const checks = job.goalChecks?.length ? ` | checks=${job.goalChecks.length}` : "";
|
|
1535
|
+
const acceptance = job.goalAcceptance?.length ? ` | acceptance=${job.goalAcceptance.length}` : "";
|
|
1536
|
+
const progress = job.goalProgress?.length ? ` | progress=${job.goalProgress.length}` : "";
|
|
1537
|
+
const noProgress = (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? ` | no-progress=${job.noProgressCount || 0}/${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : "";
|
|
1538
|
+
const rejected = job.goalCompletionRejectedReason ? " | completion-rejected" : "";
|
|
1539
|
+
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}`;
|
|
1540
|
+
}) : ["No experimental goal jobs."];
|
|
1541
|
+
await toast2(client, goals.length ? `${goals.length} experimental goal(s).` : "No experimental goal jobs.", goals.length ? "info" : "warning");
|
|
1542
|
+
await say2(client, sessionID, `OpenCode Loop experimental goal status:
|
|
1543
|
+
` + lines.join(`
|
|
1544
|
+
`));
|
|
1545
|
+
}
|
|
1546
|
+
async function pauseGoal(directory, client, sessionID, args) {
|
|
1547
|
+
const target = String(args || "").trim() || "goal";
|
|
1548
|
+
const state = await readState2(directory, sessionID);
|
|
1549
|
+
let count = 0;
|
|
1550
|
+
state.jobs = (state.jobs || []).map((job, index) => isGoalJob(job) && matchJob(job, target, index) ? (count++, { ...job, paused: true }) : job);
|
|
1551
|
+
await writeState2(directory, sessionID, state);
|
|
1552
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1553
|
+
await toast2(client, `Paused ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1554
|
+
}
|
|
1555
|
+
async function resumeGoal(directory, client, sessionID, args) {
|
|
1556
|
+
const target = String(args || "").trim() || "goal";
|
|
1557
|
+
const state = await readState2(directory, sessionID);
|
|
1558
|
+
let count = 0;
|
|
1559
|
+
state.jobs = (state.jobs || []).map((job, index) => {
|
|
1560
|
+
if (!isGoalJob(job) || !matchJob(job, target, index))
|
|
1561
|
+
return job;
|
|
1562
|
+
count++;
|
|
1563
|
+
return { ...job, paused: false, enabled: true, goalStatus: job.goalStatus === "blocked" ? "active" : job.goalStatus || "active", lastRunAt: 0, noProgressCount: 0, goalNoProgressReason: "", goalInterruptedReason: "" };
|
|
1564
|
+
});
|
|
1565
|
+
await writeState2(directory, sessionID, state);
|
|
1566
|
+
await toast2(client, `Resumed ${count} experimental goal(s).`, count ? "success" : "warning");
|
|
1567
|
+
if (count) {
|
|
1568
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1569
|
+
scheduleIdleWork(directory, client, sessionID);
|
|
2160
1570
|
}
|
|
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) });
|
|
2164
1571
|
}
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
await
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
1572
|
+
async function clearGoal(directory, client, sessionID, args) {
|
|
1573
|
+
const target = String(args || "").trim();
|
|
1574
|
+
const state = await readState2(directory, sessionID);
|
|
1575
|
+
const before = state.jobs.length;
|
|
1576
|
+
state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
|
|
1577
|
+
await writeState2(directory, sessionID, state);
|
|
1578
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1579
|
+
await toast2(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
|
|
1580
|
+
}
|
|
1581
|
+
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
1582
|
+
const result = await setGoalComplete2(directory, sessionID, { summary: String(args || "").trim() || "Goal manually marked complete.", evidence: "Marked complete by /loop-goal-done.", manual: true });
|
|
1583
|
+
await toast2(client, result.message, result.ok ? "success" : "warning");
|
|
1584
|
+
}
|
|
1585
|
+
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
1586
|
+
const result = await setGoalBlocked2(directory, sessionID, { reason: String(args || "").trim() || "Goal manually marked blocked.", needed: "User input or manual intervention." });
|
|
1587
|
+
await toast2(client, result.message, "warning");
|
|
1588
|
+
}
|
|
1589
|
+
async function addGoal(directory, client, sessionID, args) {
|
|
1590
|
+
const text = String(args || "").trim();
|
|
1591
|
+
const [maybeCommand, rest] = splitFirst(text);
|
|
1592
|
+
const sub = maybeCommand.toLowerCase();
|
|
1593
|
+
if (!text || sub === "status")
|
|
1594
|
+
return await statusGoal(directory, client, sessionID);
|
|
1595
|
+
if (sub === "pause")
|
|
1596
|
+
return await pauseGoal(directory, client, sessionID, rest);
|
|
1597
|
+
if (sub === "resume")
|
|
1598
|
+
return await resumeGoal(directory, client, sessionID, rest);
|
|
1599
|
+
if (["clear", "remove", "stop"].includes(sub))
|
|
1600
|
+
return await clearGoal(directory, client, sessionID, rest);
|
|
1601
|
+
if (["done", "complete", "completed"].includes(sub))
|
|
1602
|
+
return await completeGoalCommand(directory, client, sessionID, rest);
|
|
1603
|
+
if (["blocked", "block"].includes(sub))
|
|
1604
|
+
return await blockGoalCommand(directory, client, sessionID, rest);
|
|
1605
|
+
return await addLoop(directory, client, sessionID, text, { intervalMs: 0, kind: "goal", name: "goal", immediate: true, safe: true, askNever: true, noOverlap: true, goalStatus: "active" });
|
|
1606
|
+
}
|
|
1607
|
+
return {
|
|
1608
|
+
addGoal,
|
|
1609
|
+
statusGoal,
|
|
1610
|
+
pauseGoal,
|
|
1611
|
+
resumeGoal,
|
|
1612
|
+
clearGoal,
|
|
1613
|
+
completeGoalCommand,
|
|
1614
|
+
blockGoalCommand
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// src/source/opencode/loop-commands.js
|
|
1619
|
+
import { promises as fs4 } from "fs";
|
|
1620
|
+
import path4 from "path";
|
|
1621
|
+
var SERVICE2 = "opencode-loop";
|
|
1622
|
+
var DEFAULT_PROGRESS_MD = `# Progress
|
|
1623
|
+
|
|
1624
|
+
## Current Goal
|
|
1625
|
+
Describe the current project goal here.
|
|
1626
|
+
|
|
1627
|
+
## Agent Rules
|
|
1628
|
+
- Do not ask questions unless truly blocked.
|
|
1629
|
+
- Make reasonable assumptions and continue.
|
|
1630
|
+
- Work on unfinished TODOs in order.
|
|
1631
|
+
- Mark completed TODOs with [x].
|
|
1632
|
+
- Add new bugs, ideas, and follow-up work as TODOs.
|
|
1633
|
+
- Run tests, lint, or build when available.
|
|
1634
|
+
- Do not run destructive commands, force pushes, production deploys, or database resets.
|
|
1635
|
+
|
|
1636
|
+
## Active TODO
|
|
1637
|
+
- [ ] Review the project structure and pick the next safe improvement.
|
|
1638
|
+
|
|
1639
|
+
## Completed
|
|
1640
|
+
- [x] Created progress.md.
|
|
1641
|
+
|
|
1642
|
+
## Backlog Ideas
|
|
1643
|
+
- [ ] Add more project-specific tasks here.
|
|
1644
|
+
|
|
1645
|
+
## Blocked
|
|
1646
|
+
- None.
|
|
1647
|
+
`;
|
|
1648
|
+
function requireFunction3(value, name) {
|
|
1649
|
+
if (typeof value !== "function")
|
|
1650
|
+
throw new TypeError(`createLoopCommandHandlers requires ${name}`);
|
|
1651
|
+
return value;
|
|
1652
|
+
}
|
|
1653
|
+
function createLoopCommandHandlers(options = {}) {
|
|
1654
|
+
const clearActiveRun = requireFunction3(options.clearActiveRun, "clearActiveRun");
|
|
1655
|
+
const cancelDueWork = requireFunction3(options.cancelDueWork, "cancelDueWork");
|
|
1656
|
+
const stopWatchdog = requireFunction3(options.stopWatchdog, "stopWatchdog");
|
|
1657
|
+
const scheduleDueWork = requireFunction3(options.scheduleDueWork, "scheduleDueWork");
|
|
1658
|
+
const maybeRunDueJobs = requireFunction3(options.maybeRunDueJobs, "maybeRunDueJobs");
|
|
1659
|
+
const toast2 = requireFunction3(options.toast, "toast");
|
|
1660
|
+
const say2 = requireFunction3(options.say, "say");
|
|
1661
|
+
const now2 = typeof options.now === "function" ? options.now : now;
|
|
1662
|
+
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1663
|
+
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1664
|
+
const removeState2 = typeof options.removeState === "function" ? options.removeState : removeState;
|
|
1665
|
+
const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
|
|
1666
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
1667
|
+
const readFile = typeof options.readFile === "function" ? options.readFile : (...args) => fs4.readFile(...args);
|
|
1668
|
+
const writeFile = typeof options.writeFile === "function" ? options.writeFile : (...args) => fs4.writeFile(...args);
|
|
1669
|
+
const runtimeVersion = options.runtimeVersion || process.version;
|
|
1670
|
+
const runtimePlatform = options.runtimePlatform || process.platform;
|
|
1671
|
+
async function stopLoop(directory, client, sessionID, args) {
|
|
1672
|
+
const target = String(args || "").trim();
|
|
1673
|
+
if (!target || target.toLowerCase() === "all") {
|
|
1674
|
+
await removeState2(directory, sessionID);
|
|
1675
|
+
clearActiveRun(sessionID);
|
|
1676
|
+
cancelDueWork(sessionID);
|
|
1677
|
+
stopWatchdog(sessionID);
|
|
1678
|
+
await toast2(client, "All loops stopped for this session.", "success");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const state = await readState2(directory, sessionID);
|
|
1682
|
+
const before = state.jobs.length;
|
|
1683
|
+
state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
|
|
1684
|
+
await writeState2(directory, sessionID, state);
|
|
1685
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1686
|
+
await toast2(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
|
|
1687
|
+
}
|
|
1688
|
+
async function updateJobState(directory, client, sessionID, args, updater, message) {
|
|
1689
|
+
const target = String(args || "").trim() || "all";
|
|
1690
|
+
const state = await readState2(directory, sessionID);
|
|
1691
|
+
let count = 0;
|
|
1692
|
+
state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
|
|
1693
|
+
await writeState2(directory, sessionID, state);
|
|
1694
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1695
|
+
await toast2(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
|
|
1696
|
+
}
|
|
1697
|
+
async function statusLoop(directory, client, sessionID) {
|
|
1698
|
+
const state = await readState2(directory, sessionID);
|
|
1699
|
+
const jobs = state.jobs || [];
|
|
1700
|
+
const lines = jobs.length ? jobs.map((job, index) => {
|
|
1701
|
+
const current = now2();
|
|
1702
|
+
const intervalMs = Number(job.intervalMs || 0);
|
|
1703
|
+
const lastRunAt = Number(job.lastRunAt || 0);
|
|
1704
|
+
const createdAt = Date.parse(job.createdAt || "");
|
|
1705
|
+
const dueAt = Number(job.runNowRequestedAt || 0) > 0 ? current : lastRunAt > 0 ? lastRunAt + intervalMs : job.immediate === false && Number.isFinite(createdAt) ? createdAt + intervalMs : current;
|
|
1706
|
+
const dueIn = Math.max(0, dueAt - current);
|
|
1707
|
+
const flags = [isGoalJob(job) ? `goal:${goalStatusText(job)}` : undefined, job.paused ? "paused" : "active", Number(job.runNowRequestedAt || 0) > 0 ? "run-now" : undefined, job.safe ? "safe" : undefined, job.askNever ? "ask-never" : undefined, job.noOverlap ? "no-overlap" : undefined, job.checkpointOnly ? "checkpoint-only" : undefined, job.gitCheckpoint ? "git-checkpoint" : undefined].filter(Boolean).join(",");
|
|
1708
|
+
return `${index + 1}. ${job.id}${job.name ? ` (${job.name})` : ""}: ${jobLabel(job)} | runs=${job.runCount || 0} | failures=${job.failureCount || 0} | due in ${durationToText(dueIn)} | ${flags}`;
|
|
1709
|
+
}) : ["No active loop jobs."];
|
|
1710
|
+
await toast2(client, jobs.length ? `${jobs.length} loop job(s).` : "No active loop jobs.", jobs.length ? "info" : "warning");
|
|
1711
|
+
await say2(client, sessionID, `OpenCode loop status:
|
|
1712
|
+
` + lines.join(`
|
|
1713
|
+
`));
|
|
1714
|
+
}
|
|
1715
|
+
async function logsLoop(directory, client, sessionID) {
|
|
1716
|
+
let text = "No loop log found.";
|
|
1717
|
+
try {
|
|
1718
|
+
text = (await readFile(path4.join(stateDir(directory), "loop.log"), "utf8")).trim().split(/\r?\n/).slice(-80).join(`
|
|
1719
|
+
`) || text;
|
|
1720
|
+
} catch {}
|
|
1721
|
+
await say2(client, sessionID, `OpenCode loop logs:
|
|
1722
|
+
` + text);
|
|
1723
|
+
}
|
|
1724
|
+
async function helpLoop(client, sessionID) {
|
|
1725
|
+
await say2(client, sessionID, [
|
|
1726
|
+
"OpenCode Loop help:",
|
|
1727
|
+
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
1728
|
+
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
1729
|
+
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
1730
|
+
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
1731
|
+
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
1732
|
+
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
1733
|
+
'/loop-goal --check "npm run build" --check "npm test" --complete-when-checks-pass ship it',
|
|
1734
|
+
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
1735
|
+
"/loop 200m --command /compact same as command loop",
|
|
1736
|
+
'/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
|
|
1737
|
+
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
1738
|
+
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
1739
|
+
"/loop-doctor | /loop-init | /loop-export"
|
|
1740
|
+
].join(`
|
|
1741
|
+
`));
|
|
1742
|
+
}
|
|
1743
|
+
async function runNow(directory, client, sessionID, args) {
|
|
1744
|
+
const target = String(args || "").trim() || "all";
|
|
1745
|
+
const state = await readState2(directory, sessionID);
|
|
1746
|
+
const requestedAt = Math.max(1, Number(now2()) || Date.now());
|
|
1747
|
+
let count = 0;
|
|
1748
|
+
for (const [index, job] of (state.jobs || []).entries()) {
|
|
1749
|
+
if (!matchJob(job, target, index))
|
|
1750
|
+
continue;
|
|
1751
|
+
job.lastRunAt = 0;
|
|
1752
|
+
job.paused = false;
|
|
1753
|
+
job.runNowRequestedAt = requestedAt;
|
|
1754
|
+
count += 1;
|
|
1755
|
+
}
|
|
1756
|
+
await writeState2(directory, sessionID, state);
|
|
1757
|
+
await toast2(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
|
|
1758
|
+
if (count)
|
|
1759
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1760
|
+
}
|
|
1761
|
+
async function doctorLoop(directory, client, sessionID) {
|
|
1762
|
+
const state = await readState2(directory, sessionID);
|
|
1763
|
+
await say2(client, sessionID, [
|
|
1764
|
+
"OpenCode Loop doctor:",
|
|
1765
|
+
`- plugin: ${SERVICE2}`,
|
|
1766
|
+
`- project directory: ${directory}`,
|
|
1767
|
+
`- state directory: ${stateDir(directory)}`,
|
|
1768
|
+
`- active jobs: ${(state.jobs || []).length}`,
|
|
1769
|
+
`- node: ${runtimeVersion}`,
|
|
1770
|
+
`- platform: ${runtimePlatform}`,
|
|
1771
|
+
"- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
|
|
1772
|
+
"- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
|
|
1773
|
+
].join(`
|
|
1774
|
+
`));
|
|
1775
|
+
}
|
|
1776
|
+
async function initLoop(directory, client, sessionID, args) {
|
|
1777
|
+
const target = String(args || "").trim() || "progress.md";
|
|
1778
|
+
const full = path4.resolve(directory, target);
|
|
1779
|
+
if (await pathExists2(full)) {
|
|
1780
|
+
await toast2(client, `${target} already exists.`, "warning");
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
await writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
|
|
1784
|
+
await toast2(client, `Created ${target}.`, "success");
|
|
1785
|
+
await appendLoopLog2(directory, "init", { sessionID, file: target });
|
|
1786
|
+
}
|
|
1787
|
+
async function exportLoop(directory, client, sessionID) {
|
|
1788
|
+
const state = await readState2(directory, sessionID);
|
|
1789
|
+
await say2(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
|
|
1790
|
+
}
|
|
1791
|
+
return {
|
|
1792
|
+
stopLoop,
|
|
1793
|
+
updateJobState,
|
|
1794
|
+
statusLoop,
|
|
1795
|
+
logsLoop,
|
|
1796
|
+
helpLoop,
|
|
1797
|
+
runNow,
|
|
1798
|
+
doctorLoop,
|
|
1799
|
+
initLoop,
|
|
1800
|
+
exportLoop
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// src/source/opencode/loop-registration.js
|
|
1805
|
+
var DEFAULT_GOAL_ACTIVE_RECOVERY_MS = 180000;
|
|
1806
|
+
var FALLBACK_ACTIVE_GUARD_MS = 45000;
|
|
1807
|
+
function requireFunction4(value, name) {
|
|
1808
|
+
if (typeof value !== "function")
|
|
1809
|
+
throw new TypeError(`createLoopRegistration requires ${name}`);
|
|
1810
|
+
return value;
|
|
1811
|
+
}
|
|
1812
|
+
function normalizeActionForCompare(value) {
|
|
1813
|
+
return String(value || "").replace(/\s+/g, " ").trim();
|
|
1814
|
+
}
|
|
1815
|
+
function sameLoopDefinition(a, b) {
|
|
1816
|
+
if (!a || !b)
|
|
1817
|
+
return false;
|
|
1818
|
+
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);
|
|
1819
|
+
}
|
|
1820
|
+
function createLoopRegistration(options = {}) {
|
|
1821
|
+
const snapshotPaths = requireFunction4(options.snapshotPaths, "snapshotPaths");
|
|
1822
|
+
const scheduleDueWork = requireFunction4(options.scheduleDueWork, "scheduleDueWork");
|
|
1823
|
+
const scheduleIdleWork = requireFunction4(options.scheduleIdleWork, "scheduleIdleWork");
|
|
1824
|
+
const toast2 = requireFunction4(options.toast, "toast");
|
|
1825
|
+
const say2 = requireFunction4(options.say, "say");
|
|
1826
|
+
const parseLoopArgs2 = typeof options.parseLoopArgs === "function" ? options.parseLoopArgs : parseLoopArgs;
|
|
1827
|
+
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
1828
|
+
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
1829
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
1830
|
+
const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
|
|
1831
|
+
const getSessionExecutionContext2 = typeof options.getSessionExecutionContext === "function" ? options.getSessionExecutionContext : getSessionExecutionContext;
|
|
1832
|
+
const configuredGuard = Number(options.defaultActiveGuardMs);
|
|
1833
|
+
const defaultActiveGuardMs = Number.isFinite(configuredGuard) && configuredGuard > 0 ? configuredGuard : FALLBACK_ACTIVE_GUARD_MS;
|
|
1834
|
+
async function addLoop(directory, client, sessionID, args, defaults = {}) {
|
|
1835
|
+
const parsed = parseLoopArgs2(args, defaults);
|
|
1836
|
+
if (!parsed.ok) {
|
|
1837
|
+
await toast2(client, parsed.error, "warning");
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
const executionContext = getSessionExecutionContext2(sessionID) || { agent: "build" };
|
|
1841
|
+
parsed.job.agent = defaults.agent || executionContext.agent || "build";
|
|
1842
|
+
parsed.job.model = normalizedModelRef2(defaults.model) || executionContext.model;
|
|
1843
|
+
if (defaults.testfixPreset) {
|
|
1844
|
+
const defaultCommand = String(defaults.verifyCommand || "npm test");
|
|
1845
|
+
const parsedAction = String(parsed.job.action || "").trim();
|
|
1846
|
+
const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
|
|
1847
|
+
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
1848
|
+
parsed.job.verifyCommand = parsedAction;
|
|
1849
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
|
|
1850
|
+
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
1851
|
+
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (parsed.job.watchPaths.length)
|
|
1855
|
+
parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths);
|
|
1856
|
+
if (!parsed.job.activeRecoveryMs) {
|
|
1857
|
+
parsed.job.activeRecoveryMs = isGoalJob(parsed.job) ? DEFAULT_GOAL_ACTIVE_RECOVERY_MS : Math.max(defaultActiveGuardMs, Math.min(90000, (parsed.job.intervalMs || 0) + 1e4));
|
|
1858
|
+
}
|
|
1859
|
+
if (parsed.job.dryRun) {
|
|
1860
|
+
await toast2(client, `Loop dry run: ${jobLabel(parsed.job)}`, "info");
|
|
1861
|
+
await say2(client, sessionID, "OpenCode loop dry run:\n```json\n" + JSON.stringify(parsed.job, null, 2) + "\n```");
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
const state = await readState2(directory, sessionID);
|
|
1865
|
+
const jobs = Array.isArray(state.jobs) ? state.jobs : [];
|
|
1866
|
+
let replaced = false;
|
|
1867
|
+
if (!parsed.job.multi) {
|
|
1868
|
+
const targetName = parsed.job.name || "default";
|
|
1869
|
+
parsed.job.name = targetName;
|
|
1870
|
+
state.jobs = jobs.filter((existing) => {
|
|
1871
|
+
const existingName = existing.name || "default";
|
|
1872
|
+
const shouldReplace = existingName === targetName || sameLoopDefinition(existing, parsed.job);
|
|
1873
|
+
if (shouldReplace)
|
|
1874
|
+
replaced = true;
|
|
1875
|
+
return !shouldReplace;
|
|
1876
|
+
});
|
|
1877
|
+
} else {
|
|
1878
|
+
state.jobs = jobs;
|
|
1879
|
+
}
|
|
1880
|
+
state.jobs.push(parsed.job);
|
|
1881
|
+
await writeState2(directory, sessionID, state);
|
|
1882
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
1883
|
+
if (parsed.job.immediate)
|
|
1884
|
+
scheduleIdleWork(directory, client, sessionID);
|
|
1885
|
+
await toast2(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
|
|
1886
|
+
await appendLoopLog2(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) });
|
|
1887
|
+
}
|
|
1888
|
+
return { addLoop };
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// src/source/runtime/session-activity.js
|
|
1892
|
+
var activeToolCalls = new Map;
|
|
1893
|
+
var sessionParents = new Map;
|
|
1894
|
+
var sessionStatuses = new Map;
|
|
1895
|
+
var sessionStatusSeenAt = new Map;
|
|
1896
|
+
function hasActiveToolCalls(sessionID) {
|
|
1897
|
+
return (activeToolCalls.get(sessionID)?.size || 0) > 0;
|
|
1898
|
+
}
|
|
1899
|
+
function markToolCallActive(input) {
|
|
1900
|
+
const sessionID = input?.sessionID;
|
|
1901
|
+
const callID = input?.callID;
|
|
1902
|
+
if (typeof sessionID !== "string" || typeof callID !== "string")
|
|
1903
|
+
return;
|
|
1904
|
+
const calls = activeToolCalls.get(sessionID) || new Set;
|
|
1905
|
+
calls.add(callID);
|
|
1906
|
+
activeToolCalls.set(sessionID, calls);
|
|
1907
|
+
sessionStatuses.set(sessionID, "busy");
|
|
1908
|
+
sessionStatusSeenAt.set(sessionID, now());
|
|
1909
|
+
}
|
|
1910
|
+
function markToolCallFinished(input) {
|
|
1911
|
+
const sessionID = input?.sessionID;
|
|
1912
|
+
const callID = input?.callID;
|
|
1913
|
+
if (typeof sessionID !== "string" || typeof callID !== "string")
|
|
1914
|
+
return;
|
|
1915
|
+
const calls = activeToolCalls.get(sessionID);
|
|
1916
|
+
if (!calls)
|
|
1917
|
+
return;
|
|
1918
|
+
calls.delete(callID);
|
|
1919
|
+
if (!calls.size)
|
|
1920
|
+
activeToolCalls.delete(sessionID);
|
|
1921
|
+
}
|
|
1922
|
+
function updateSessionRelationship(info, removed = false) {
|
|
1923
|
+
const sessionID = info?.id;
|
|
1924
|
+
if (typeof sessionID !== "string")
|
|
1925
|
+
return;
|
|
1926
|
+
if (removed || typeof info?.parentID !== "string")
|
|
1927
|
+
sessionParents.delete(sessionID);
|
|
1928
|
+
else
|
|
1929
|
+
sessionParents.set(sessionID, info.parentID);
|
|
1930
|
+
if (!removed)
|
|
1931
|
+
updateSessionExecutionContext(info);
|
|
1932
|
+
else
|
|
1933
|
+
deleteSessionExecutionContext(sessionID);
|
|
1934
|
+
}
|
|
1935
|
+
function updateSessionRelationshipFromEvent(event) {
|
|
1936
|
+
if (!["session.created", "session.updated", "session.deleted"].includes(event?.type))
|
|
1937
|
+
return;
|
|
1938
|
+
updateSessionRelationship(event?.properties?.info, event.type === "session.deleted");
|
|
1939
|
+
}
|
|
1940
|
+
function isDescendantSession(sessionID, ancestorID) {
|
|
1941
|
+
const visited = new Set;
|
|
1942
|
+
let current = sessionID;
|
|
1943
|
+
while (sessionParents.has(current) && !visited.has(current)) {
|
|
1944
|
+
visited.add(current);
|
|
1945
|
+
current = sessionParents.get(current);
|
|
1946
|
+
if (current === ancestorID)
|
|
1947
|
+
return true;
|
|
1948
|
+
}
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
function hasBusyDescendant(sessionID) {
|
|
1952
|
+
for (const childID of sessionParents.keys()) {
|
|
1953
|
+
if (!isDescendantSession(childID, sessionID))
|
|
1954
|
+
continue;
|
|
1955
|
+
const status = sessionStatuses.get(childID);
|
|
1956
|
+
if (status === "busy" || status === "retry" || hasActiveToolCalls(childID))
|
|
1957
|
+
return true;
|
|
1958
|
+
}
|
|
1959
|
+
return false;
|
|
1960
|
+
}
|
|
1961
|
+
async function refreshSessionRelationships(client, directory) {
|
|
1962
|
+
if (!client?.session?.list)
|
|
1963
|
+
return;
|
|
1964
|
+
try {
|
|
1965
|
+
const sessions = await sdkCall(client.session.list.bind(client.session), { query: { directory } }, { directory }, {});
|
|
1966
|
+
if (Array.isArray(sessions))
|
|
1967
|
+
for (const info of sessions)
|
|
1968
|
+
updateSessionRelationship(info);
|
|
1969
|
+
} catch {}
|
|
1970
|
+
}
|
|
1971
|
+
function updateToolActivityFromEvent(event) {
|
|
1972
|
+
const props = event?.properties || {};
|
|
1973
|
+
if (event?.type === "message.part.updated") {
|
|
1974
|
+
const part = props.part;
|
|
1975
|
+
if (part?.type !== "tool")
|
|
1976
|
+
return;
|
|
1977
|
+
const sessionID = part.sessionID || props.sessionID;
|
|
1978
|
+
if (["pending", "running"].includes(part.state?.status)) {
|
|
1979
|
+
markToolCallActive({ sessionID, callID: part.callID });
|
|
1980
|
+
}
|
|
1981
|
+
if (["completed", "error"].includes(part.state?.status)) {
|
|
1982
|
+
const identifiers = [...new Set([part.callID, part.id].filter((value) => typeof value === "string"))];
|
|
1983
|
+
for (const callID of identifiers)
|
|
1984
|
+
markToolCallFinished({ sessionID, callID });
|
|
1985
|
+
}
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const started = ["session.next.shell.started", "session.next.tool.called"].includes(event?.type);
|
|
1989
|
+
const finished = ["session.next.shell.ended", "session.next.tool.success", "session.next.tool.failed"].includes(event?.type);
|
|
1990
|
+
if (started)
|
|
1991
|
+
markToolCallActive(props);
|
|
1992
|
+
if (finished)
|
|
1993
|
+
markToolCallFinished(props);
|
|
1994
|
+
}
|
|
1995
|
+
function clearSessionActivity(sessionID) {
|
|
1996
|
+
activeToolCalls.delete(sessionID);
|
|
1997
|
+
sessionParents.delete(sessionID);
|
|
1998
|
+
sessionStatuses.delete(sessionID);
|
|
1999
|
+
sessionStatusSeenAt.delete(sessionID);
|
|
2000
|
+
deleteSessionExecutionContext(sessionID);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// src/source/runtime/scheduler.js
|
|
2004
|
+
var DEFAULT_IDLE_DEBOUNCE_MS = 1200;
|
|
2005
|
+
var DEFAULT_BUSY_RETRY_MS = 5000;
|
|
2006
|
+
var DEFAULT_MIN_DUE_TIMER_MS = 250;
|
|
2007
|
+
var DEFAULT_MAX_DUE_TIMER_MS = 2147000000;
|
|
2008
|
+
var DEFAULT_HEARTBEAT_MS = 2500;
|
|
2009
|
+
var DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
2010
|
+
function jobDueAt(job, current = now()) {
|
|
2011
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
2012
|
+
return Infinity;
|
|
2013
|
+
if (!job.enabled || job.paused)
|
|
2014
|
+
return Infinity;
|
|
2015
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
2016
|
+
return Infinity;
|
|
2017
|
+
if (Number(job.runNowRequestedAt || 0) > 0)
|
|
2018
|
+
return current;
|
|
2019
|
+
if (job.watchPaths?.length)
|
|
2020
|
+
return Infinity;
|
|
2021
|
+
const created = Date.parse(job.createdAt || "");
|
|
2022
|
+
if (job.maxRuntimeMs > 0 && Number.isFinite(created) && current - created >= job.maxRuntimeMs)
|
|
2023
|
+
return current;
|
|
2024
|
+
if (job.intervalMs === 0)
|
|
2025
|
+
return current;
|
|
2026
|
+
if (!job.lastRunAt) {
|
|
2027
|
+
if (job.immediate === false)
|
|
2028
|
+
return (Number.isFinite(created) ? created : current) + (job.intervalMs || 0);
|
|
2029
|
+
return current;
|
|
2030
|
+
}
|
|
2031
|
+
return job.lastRunAt + (job.intervalMs || 0);
|
|
2032
|
+
}
|
|
2033
|
+
function nextDueDelay(state, current = now()) {
|
|
2034
|
+
let soonest = Infinity;
|
|
2035
|
+
for (const job of state.jobs || [])
|
|
2036
|
+
soonest = Math.min(soonest, jobDueAt(job, current));
|
|
2037
|
+
if (!Number.isFinite(soonest))
|
|
2038
|
+
return Infinity;
|
|
2039
|
+
return Math.max(0, soonest - current);
|
|
2040
|
+
}
|
|
2041
|
+
function createSchedulerRuntime(options = {}) {
|
|
2042
|
+
const idleTimers = new Map;
|
|
2043
|
+
const dueTimers = new Map;
|
|
2044
|
+
const watchdogTimers = new Map;
|
|
2045
|
+
const knownSessions = new Map;
|
|
2046
|
+
let heartbeatTimer;
|
|
2047
|
+
const clock = options.now || now;
|
|
2048
|
+
const readStateFn = options.readState || readState;
|
|
2049
|
+
const setTimeoutFn = options.setTimeout || setTimeout;
|
|
2050
|
+
const clearTimeoutFn = options.clearTimeout || clearTimeout;
|
|
2051
|
+
const setIntervalFn = options.setInterval || setInterval;
|
|
2052
|
+
const clearIntervalFn = options.clearInterval || clearInterval;
|
|
2053
|
+
const idleDebounceMs = options.idleDebounceMs ?? DEFAULT_IDLE_DEBOUNCE_MS;
|
|
2054
|
+
const busyRetryMs = options.busyRetryMs ?? DEFAULT_BUSY_RETRY_MS;
|
|
2055
|
+
const minDueTimerMs = options.minDueTimerMs ?? DEFAULT_MIN_DUE_TIMER_MS;
|
|
2056
|
+
const maxDueTimerMs = options.maxDueTimerMs ?? DEFAULT_MAX_DUE_TIMER_MS;
|
|
2057
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
2058
|
+
const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
2059
|
+
const errorMessage = (error) => options.errorMessage ? options.errorMessage(error) : error instanceof Error ? error.message : String(error || "unknown error");
|
|
2060
|
+
const appendLog = async (directory, event, extra) => {
|
|
2061
|
+
if (options.appendLoopLog)
|
|
2062
|
+
await options.appendLoopLog(directory, event, extra);
|
|
2063
|
+
};
|
|
2064
|
+
const toast2 = async (client, message, level) => {
|
|
2065
|
+
if (options.toast)
|
|
2066
|
+
await options.toast(client, message, level);
|
|
2067
|
+
};
|
|
2068
|
+
function stopHeartbeatIfIdle() {
|
|
2069
|
+
if (!knownSessions.size && heartbeatTimer) {
|
|
2070
|
+
clearIntervalFn(heartbeatTimer);
|
|
2071
|
+
heartbeatTimer = undefined;
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
function startHeartbeat() {
|
|
2075
|
+
if (heartbeatTimer)
|
|
2076
|
+
return;
|
|
2077
|
+
heartbeatTimer = setIntervalFn(() => {
|
|
2078
|
+
for (const [sessionID, info] of [...knownSessions.entries()]) {
|
|
2079
|
+
if (!info || clock() - (info.seenAt || 0) > sessionTtlMs) {
|
|
2080
|
+
knownSessions.delete(sessionID);
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
Promise.resolve().then(async () => {
|
|
2084
|
+
await options.finalizeActiveRun?.(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true });
|
|
2085
|
+
await options.maybeRunDueJobs?.(info.directory, info.client, sessionID, { heartbeat: true });
|
|
2086
|
+
}).catch((error) => appendLog(info.directory, "heartbeat-error", { sessionID, error: errorMessage(error) }).catch(() => {}));
|
|
2087
|
+
}
|
|
2088
|
+
stopHeartbeatIfIdle();
|
|
2089
|
+
}, heartbeatMs);
|
|
2090
|
+
}
|
|
2091
|
+
function rememberSession(directory, client, sessionID) {
|
|
2092
|
+
if (!sessionID)
|
|
2093
|
+
return;
|
|
2094
|
+
knownSessions.set(sessionID, { directory, client, seenAt: clock() });
|
|
2095
|
+
startHeartbeat();
|
|
2096
|
+
}
|
|
2097
|
+
function cancelIdleWork(sessionID) {
|
|
2098
|
+
const timer = idleTimers.get(sessionID);
|
|
2099
|
+
if (timer)
|
|
2100
|
+
clearTimeoutFn(timer);
|
|
2101
|
+
idleTimers.delete(sessionID);
|
|
2102
|
+
}
|
|
2103
|
+
function cancelDueWork(sessionID) {
|
|
2104
|
+
const timer = dueTimers.get(sessionID);
|
|
2105
|
+
if (timer)
|
|
2106
|
+
clearTimeoutFn(timer);
|
|
2107
|
+
dueTimers.delete(sessionID);
|
|
2108
|
+
}
|
|
2109
|
+
function scheduleIdleWork(directory, client, sessionID) {
|
|
2110
|
+
cancelIdleWork(sessionID);
|
|
2111
|
+
const timer = setTimeoutFn(() => {
|
|
2112
|
+
idleTimers.delete(sessionID);
|
|
2113
|
+
Promise.resolve().then(async () => {
|
|
2114
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2115
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
await options.finalizeActiveRun?.(directory, client, sessionID);
|
|
2119
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2120
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2124
|
+
}).catch((error) => {
|
|
2125
|
+
toast2(client, `Loop idle handler failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2126
|
+
appendLog(directory, "idle-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2127
|
+
});
|
|
2128
|
+
}, idleDebounceMs);
|
|
2129
|
+
idleTimers.set(sessionID, timer);
|
|
2130
|
+
}
|
|
2131
|
+
async function startWatchdog(directory, client, sessionID) {
|
|
2132
|
+
if (watchdogTimers.has(sessionID))
|
|
2133
|
+
return;
|
|
2134
|
+
const timer = setIntervalFn(() => {
|
|
2135
|
+
Promise.resolve().then(async () => {
|
|
2136
|
+
const state = await readStateFn(directory, sessionID);
|
|
2137
|
+
const delay2 = nextDueDelay(state, clock());
|
|
2138
|
+
const hasJobs = (state.jobs || []).some((job) => job.enabled !== false && !job.paused && (!isGoalJob(job) || !["completed", "blocked", "cleared"].includes(job.goalStatus)));
|
|
2139
|
+
if (!hasJobs || !Number.isFinite(delay2)) {
|
|
2140
|
+
stopWatchdog(sessionID);
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
if (delay2 <= 0)
|
|
2144
|
+
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2145
|
+
else
|
|
2146
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2147
|
+
}).catch((error) => appendLog(directory, "watchdog-error", { sessionID, error: errorMessage(error) }).catch(() => {}));
|
|
2148
|
+
}, Math.max(1000, busyRetryMs));
|
|
2149
|
+
watchdogTimers.set(sessionID, timer);
|
|
2150
|
+
}
|
|
2151
|
+
function stopWatchdog(sessionID) {
|
|
2152
|
+
const timer = watchdogTimers.get(sessionID);
|
|
2153
|
+
if (timer)
|
|
2154
|
+
clearIntervalFn(timer);
|
|
2155
|
+
watchdogTimers.delete(sessionID);
|
|
2156
|
+
}
|
|
2157
|
+
async function scheduleDueWork(directory, client, sessionID, minDelayMs = 0) {
|
|
2158
|
+
cancelDueWork(sessionID);
|
|
2159
|
+
const state = await readStateFn(directory, sessionID);
|
|
2160
|
+
const delay2 = nextDueDelay(state, clock());
|
|
2161
|
+
if (!Number.isFinite(delay2))
|
|
2162
|
+
return;
|
|
2163
|
+
const wait = Math.min(Math.max(delay2, minDelayMs, minDueTimerMs), maxDueTimerMs);
|
|
2164
|
+
const timer = setTimeoutFn(() => {
|
|
2165
|
+
dueTimers.delete(sessionID);
|
|
2166
|
+
Promise.resolve().then(async () => {
|
|
2167
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2168
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
await options.finalizeActiveRun?.(directory, client, sessionID);
|
|
2172
|
+
if (!await options.sessionIsIdle?.(client, sessionID, directory)) {
|
|
2173
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
await options.maybeRunDueJobs?.(directory, client, sessionID);
|
|
2177
|
+
}).catch((error) => {
|
|
2178
|
+
toast2(client, `Loop due timer failed: ${errorMessage(error)}`, "error").catch(() => {});
|
|
2179
|
+
appendLog(directory, "due-timer-error", { sessionID, error: errorMessage(error) }).catch(() => {});
|
|
2180
|
+
});
|
|
2181
|
+
}, wait);
|
|
2182
|
+
dueTimers.set(sessionID, timer);
|
|
2183
|
+
await startWatchdog(directory, client, sessionID);
|
|
2184
|
+
}
|
|
2185
|
+
function sessionIDsForHost(directory, client) {
|
|
2186
|
+
return [...knownSessions.entries()].filter(([, info]) => info?.directory === directory && info?.client === client).map(([sessionID]) => sessionID);
|
|
2187
|
+
}
|
|
2188
|
+
function clearSessionScheduling(sessionID) {
|
|
2189
|
+
cancelIdleWork(sessionID);
|
|
2190
|
+
cancelDueWork(sessionID);
|
|
2191
|
+
stopWatchdog(sessionID);
|
|
2192
|
+
knownSessions.delete(sessionID);
|
|
2193
|
+
stopHeartbeatIfIdle();
|
|
2194
|
+
}
|
|
2195
|
+
return {
|
|
2196
|
+
rememberSession,
|
|
2197
|
+
scheduleIdleWork,
|
|
2198
|
+
scheduleDueWork,
|
|
2199
|
+
startWatchdog,
|
|
2200
|
+
stopWatchdog,
|
|
2201
|
+
cancelIdleWork,
|
|
2202
|
+
cancelDueWork,
|
|
2203
|
+
sessionIDsForHost,
|
|
2204
|
+
clearSessionScheduling,
|
|
2205
|
+
knownSessionCount: () => knownSessions.size
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/source/runtime/goal-policy.js
|
|
2210
|
+
function requireFunction5(value, name) {
|
|
2211
|
+
if (typeof value !== "function")
|
|
2212
|
+
throw new TypeError(`createGoalExecutionPolicy requires ${name}`);
|
|
2213
|
+
return value;
|
|
2214
|
+
}
|
|
2215
|
+
function createGoalExecutionPolicy(options = {}) {
|
|
2216
|
+
const runShellCommand2 = requireFunction5(options.runShellCommand, "runShellCommand");
|
|
2217
|
+
const dangerousShell = requireFunction5(options.dangerousShell, "dangerousShell");
|
|
2218
|
+
const toast2 = requireFunction5(options.toast, "toast");
|
|
2219
|
+
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2220
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2221
|
+
async function applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
|
|
2222
|
+
if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false)
|
|
2223
|
+
return job;
|
|
2224
|
+
const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS);
|
|
2225
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
2226
|
+
return job;
|
|
2227
|
+
if (goalMadeMeaningfulProgress(beforeJob, job)) {
|
|
2228
|
+
job.noProgressCount = 0;
|
|
2229
|
+
job.lastProgressAt = now2();
|
|
2230
|
+
return job;
|
|
2231
|
+
}
|
|
2232
|
+
job.noProgressCount = (job.noProgressCount || 0) + 1;
|
|
2233
|
+
job.lastNoProgressAt = now2();
|
|
2234
|
+
await appendLoopLog2(directory, "goal-no-progress", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
2235
|
+
if (job.noProgressCount >= limit) {
|
|
2175
2236
|
job.paused = true;
|
|
2176
|
-
job.
|
|
2177
|
-
job.
|
|
2178
|
-
|
|
2237
|
+
job.goalNoProgressPausedAt = now2();
|
|
2238
|
+
job.goalNoProgressReason = `Paused after ${job.noProgressCount} turn(s) without recorded progress. Resume with /loop-goal-resume after adjusting the goal or evidence.`;
|
|
2239
|
+
await toast2(client, job.goalNoProgressReason, "warning");
|
|
2240
|
+
await appendLoopLog2(directory, "goal-no-progress-paused", { sessionID, job: job.name || job.id, count: job.noProgressCount, limit });
|
|
2241
|
+
}
|
|
2242
|
+
return job;
|
|
2243
|
+
}
|
|
2244
|
+
async function runGoalChecks(directory, sessionID, job, client) {
|
|
2245
|
+
if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus))
|
|
2246
|
+
return job;
|
|
2247
|
+
const results = [];
|
|
2248
|
+
for (const command of job.goalChecks) {
|
|
2249
|
+
if (job.safe && dangerousShell(command)) {
|
|
2250
|
+
results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
const result = await runShellCommand2(command, directory, job.timeoutMs || 300000);
|
|
2254
|
+
results.push({ command, code: result.code, output: (result.stdout + `
|
|
2255
|
+
` + result.stderr).slice(0, 1200) });
|
|
2256
|
+
}
|
|
2257
|
+
job.lastGoalCheckAt = now2();
|
|
2258
|
+
job.lastGoalChecks = results;
|
|
2259
|
+
const allPassed = results.length > 0 && results.every((item) => item.code === 0);
|
|
2260
|
+
if (allPassed) {
|
|
2261
|
+
job.goalChecksPassedAt = now2();
|
|
2262
|
+
job.failureCount = 0;
|
|
2263
|
+
await toast2(client, "Goal checks passed.", "success");
|
|
2264
|
+
if (job.goalCompleteWhenChecksPass) {
|
|
2265
|
+
job.goalStatus = "completed";
|
|
2266
|
+
job.enabled = false;
|
|
2267
|
+
job.paused = true;
|
|
2268
|
+
job.goalCompletedAt = now2();
|
|
2269
|
+
job.goalSummary = job.goalSummary || "All configured goal checks passed.";
|
|
2270
|
+
job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join(`
|
|
2179
2271
|
`);
|
|
2180
|
-
|
|
2272
|
+
await appendLoopLog2(directory, "goal-auto-complete", { sessionID, job: job.name || job.id });
|
|
2273
|
+
}
|
|
2274
|
+
} else {
|
|
2275
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2276
|
+
job.lastVerifyFailure = results.map((item) => `${item.command}
|
|
2277
|
+
exit=${item.code}
|
|
2278
|
+
${item.output}`).join(`
|
|
2279
|
+
|
|
2280
|
+
`).slice(0, 4000);
|
|
2281
|
+
await toast2(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
|
|
2282
|
+
}
|
|
2283
|
+
await appendLoopLog2(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
|
|
2284
|
+
return job;
|
|
2285
|
+
}
|
|
2286
|
+
return { runGoalChecks, applyGoalNoProgressGuard };
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/source/runtime/job-workspace.js
|
|
2290
|
+
import { promises as fs5 } from "fs";
|
|
2291
|
+
import path5 from "path";
|
|
2292
|
+
var MAX_SCAN_FILES = 200;
|
|
2293
|
+
var MAX_SCAN_BYTES = 2000000;
|
|
2294
|
+
function requireFunction6(value, name) {
|
|
2295
|
+
if (typeof value !== "function")
|
|
2296
|
+
throw new TypeError(`createJobWorkspaceRuntime requires ${name}`);
|
|
2297
|
+
return value;
|
|
2298
|
+
}
|
|
2299
|
+
function dangerousShell(command) {
|
|
2300
|
+
const text = String(command || "").toLowerCase();
|
|
2301
|
+
return [
|
|
2302
|
+
/\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)/,
|
|
2303
|
+
/\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
|
|
2304
|
+
/\bgit\s+reset\b/,
|
|
2305
|
+
/\bgit\s+clean\b/,
|
|
2306
|
+
/\bgit\s+push\b/,
|
|
2307
|
+
/\bdel\b[^\r\n]*\s\/s\b/,
|
|
2308
|
+
/\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
|
|
2309
|
+
/(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
|
|
2310
|
+
/\bterraform\s+destroy\b/,
|
|
2311
|
+
/\bkubectl\s+delete\b/,
|
|
2312
|
+
/\bdeploy\b.*\bproduction\b/
|
|
2313
|
+
].some((pattern) => pattern.test(text));
|
|
2314
|
+
}
|
|
2315
|
+
function createJobWorkspaceRuntime(options = {}) {
|
|
2316
|
+
const toast2 = requireFunction6(options.toast, "toast");
|
|
2317
|
+
const runProcess2 = typeof options.runProcess === "function" ? options.runProcess : runProcess;
|
|
2318
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2319
|
+
const readSmallTextFile2 = typeof options.readSmallTextFile === "function" ? options.readSmallTextFile : readSmallTextFile;
|
|
2320
|
+
const buildGoalPrompt2 = typeof options.buildGoalPrompt === "function" ? options.buildGoalPrompt : buildGoalPrompt;
|
|
2321
|
+
async function buildPrompt(directory, job) {
|
|
2322
|
+
if (isGoalJob(job))
|
|
2323
|
+
return await buildGoalPrompt2(directory, job);
|
|
2324
|
+
const sections = [];
|
|
2325
|
+
if (job.promptFile) {
|
|
2326
|
+
const text = await readSmallTextFile2(path5.resolve(directory, job.promptFile));
|
|
2327
|
+
if (text.trim())
|
|
2328
|
+
sections.push(`Instructions from ${job.promptFile}:
|
|
2329
|
+
${text.trim()}`);
|
|
2330
|
+
else
|
|
2331
|
+
sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`);
|
|
2181
2332
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
job.
|
|
2185
|
-
|
|
2186
|
-
|
|
2333
|
+
if (job.action)
|
|
2334
|
+
sections.push(decoratePrompt(job));
|
|
2335
|
+
for (const file of job.includeFiles || []) {
|
|
2336
|
+
const text = await readSmallTextFile2(path5.resolve(directory, file), 80000);
|
|
2337
|
+
if (text.trim())
|
|
2338
|
+
sections.push(`Context from ${file}:
|
|
2339
|
+
${text.trim().slice(0, 20000)}`);
|
|
2340
|
+
}
|
|
2341
|
+
return sections.join(`
|
|
2187
2342
|
|
|
2188
|
-
|
|
2189
|
-
|
|
2343
|
+
---
|
|
2344
|
+
|
|
2345
|
+
`) || decoratePrompt(job);
|
|
2346
|
+
}
|
|
2347
|
+
async function ensureBranch(directory, job, client, sessionID) {
|
|
2348
|
+
if (!job.branch || job.branchDone)
|
|
2349
|
+
return job;
|
|
2350
|
+
const branch = safeID(job.branch);
|
|
2351
|
+
const inRepo = await runProcess2("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
|
|
2352
|
+
if (inRepo.code !== 0) {
|
|
2353
|
+
job.branchDone = true;
|
|
2354
|
+
return job;
|
|
2355
|
+
}
|
|
2356
|
+
let result = await runProcess2("git", ["switch", branch], directory, 30000);
|
|
2357
|
+
if (result.code !== 0)
|
|
2358
|
+
result = await runProcess2("git", ["switch", "-c", branch], directory, 30000);
|
|
2359
|
+
job.branchDone = true;
|
|
2360
|
+
await toast2(client, result.code === 0 ? `Loop branch active: ${branch}` : `Could not switch/create branch: ${branch}`, result.code === 0 ? "success" : "warning");
|
|
2361
|
+
await appendLoopLog2(directory, "branch", { sessionID, branch, code: result.code });
|
|
2362
|
+
return job;
|
|
2363
|
+
}
|
|
2364
|
+
async function snapshotPaths(directory, files) {
|
|
2365
|
+
const snapshot = {};
|
|
2366
|
+
for (const file of files || []) {
|
|
2367
|
+
try {
|
|
2368
|
+
const stat = await fs5.stat(path5.resolve(directory, file));
|
|
2369
|
+
snapshot[file] = `${stat.mtimeMs}:${stat.size}`;
|
|
2370
|
+
} catch {
|
|
2371
|
+
snapshot[file] = "missing";
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return snapshot;
|
|
2375
|
+
}
|
|
2376
|
+
async function watchChanged(directory, job) {
|
|
2377
|
+
if (!job.watchPaths?.length)
|
|
2378
|
+
return false;
|
|
2379
|
+
const next = await snapshotPaths(directory, job.watchPaths);
|
|
2380
|
+
const previous = job.watchSnapshot || {};
|
|
2381
|
+
const changed = job.watchPaths.some((file) => previous[file] !== next[file]);
|
|
2382
|
+
if (changed)
|
|
2383
|
+
job.watchSnapshot = next;
|
|
2384
|
+
return changed;
|
|
2385
|
+
}
|
|
2386
|
+
async function fileContains(filePath, needle) {
|
|
2387
|
+
try {
|
|
2388
|
+
const stat = await fs5.stat(filePath);
|
|
2389
|
+
if (!stat.isFile() || stat.size > MAX_SCAN_BYTES)
|
|
2390
|
+
return false;
|
|
2391
|
+
return (await fs5.readFile(filePath, "utf8")).includes(needle);
|
|
2392
|
+
} catch {
|
|
2393
|
+
return false;
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
async function untilReached(directory, job) {
|
|
2397
|
+
if (!job.until)
|
|
2398
|
+
return false;
|
|
2399
|
+
const files = ["progress.md", "PROGRESS.md", "todo.md", "TODO.md", "todolist.md", "TODOLIST.md", path5.join(".opencode", "opencode-loop", "until.txt")];
|
|
2400
|
+
for (const file of files)
|
|
2401
|
+
if (await fileContains(path5.resolve(directory, file), job.until))
|
|
2402
|
+
return true;
|
|
2403
|
+
let scanned = 0;
|
|
2404
|
+
async function walk(current) {
|
|
2405
|
+
if (scanned >= MAX_SCAN_FILES)
|
|
2406
|
+
return false;
|
|
2407
|
+
let entries;
|
|
2408
|
+
try {
|
|
2409
|
+
entries = await fs5.readdir(current, { withFileTypes: true });
|
|
2410
|
+
} catch {
|
|
2411
|
+
return false;
|
|
2412
|
+
}
|
|
2413
|
+
for (const entry of entries) {
|
|
2414
|
+
if (scanned >= MAX_SCAN_FILES)
|
|
2415
|
+
return false;
|
|
2416
|
+
if ([".git", "node_modules", "dist", "build", ".next", "coverage"].includes(entry.name))
|
|
2417
|
+
continue;
|
|
2418
|
+
const full = path5.join(current, entry.name);
|
|
2419
|
+
if (entry.isDirectory()) {
|
|
2420
|
+
if (await walk(full))
|
|
2421
|
+
return true;
|
|
2422
|
+
} else if (entry.isFile() && /\.(md|txt|json|yaml|yml)$/i.test(entry.name)) {
|
|
2423
|
+
scanned++;
|
|
2424
|
+
if (await fileContains(full, job.until))
|
|
2425
|
+
return true;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
return false;
|
|
2429
|
+
}
|
|
2430
|
+
return await walk(directory);
|
|
2431
|
+
}
|
|
2432
|
+
async function createCheckpoint(directory, sessionID, job, client) {
|
|
2433
|
+
if (!job.checkpointOnly && !job.gitCheckpoint)
|
|
2434
|
+
return;
|
|
2435
|
+
const inRepo = await runProcess2("git", ["rev-parse", "--is-inside-work-tree"], directory, 1e4);
|
|
2436
|
+
if (inRepo.code !== 0)
|
|
2437
|
+
return;
|
|
2438
|
+
const status = await runProcess2("git", ["status", "--short"], directory, 30000);
|
|
2439
|
+
if (!status.stdout.trim())
|
|
2440
|
+
return;
|
|
2441
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2442
|
+
const checkpointDir = path5.join(stateDir(directory), "checkpoints", safeID(sessionID));
|
|
2443
|
+
await ensureDir(checkpointDir);
|
|
2444
|
+
const diff = await runProcess2("git", ["diff", "--binary"], directory, 120000);
|
|
2445
|
+
const staged = await runProcess2("git", ["diff", "--cached", "--binary"], directory, 120000);
|
|
2446
|
+
const prefix = `${timestamp}-${safeID(job.name || job.id)}`;
|
|
2447
|
+
await fs5.writeFile(path5.join(checkpointDir, `${prefix}.status.txt`), status.stdout + status.stderr);
|
|
2448
|
+
await fs5.writeFile(path5.join(checkpointDir, `${prefix}.patch`), `${diff.stdout}
|
|
2449
|
+
${staged.stdout}`);
|
|
2450
|
+
if (job.gitCheckpoint) {
|
|
2451
|
+
await runProcess2("git", ["add", "-A"], directory, 120000);
|
|
2452
|
+
await runProcess2("git", ["commit", "-m", `chore: opencode loop checkpoint ${timestamp}`], directory, 120000);
|
|
2453
|
+
}
|
|
2454
|
+
await toast2(client, `Loop checkpoint saved: ${prefix}`, "success");
|
|
2190
2455
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2456
|
+
return {
|
|
2457
|
+
buildPrompt,
|
|
2458
|
+
ensureBranch,
|
|
2459
|
+
snapshotPaths,
|
|
2460
|
+
watchChanged,
|
|
2461
|
+
untilReached,
|
|
2462
|
+
createCheckpoint
|
|
2463
|
+
};
|
|
2193
2464
|
}
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2465
|
+
|
|
2466
|
+
// src/source/runtime/executor.js
|
|
2467
|
+
import path6 from "path";
|
|
2468
|
+
|
|
2469
|
+
// src/source/runtime/session-status.js
|
|
2470
|
+
var DEFAULT_STALE_ACTIVE_RECOVERY_MS = 45000;
|
|
2471
|
+
var DEFAULT_SESSION_STATUS_CACHE_MS = 1500;
|
|
2472
|
+
function positiveNumber(value, fallback) {
|
|
2473
|
+
const number = Number(value);
|
|
2474
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
2475
|
+
}
|
|
2476
|
+
function nonNegativeNumber(value, fallback) {
|
|
2477
|
+
const number = Number(value);
|
|
2478
|
+
return Number.isFinite(number) && number >= 0 ? number : fallback;
|
|
2479
|
+
}
|
|
2480
|
+
function createSessionStatusRuntime(options = {}) {
|
|
2481
|
+
const activeRuns = options.activeRuns;
|
|
2482
|
+
if (!(activeRuns instanceof Map))
|
|
2483
|
+
throw new TypeError("createSessionStatusRuntime requires activeRuns Map");
|
|
2484
|
+
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2485
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2486
|
+
const activeRunCompletionFromMessages2 = typeof options.activeRunCompletionFromMessages === "function" ? options.activeRunCompletionFromMessages : activeRunCompletionFromMessages;
|
|
2487
|
+
const staleActiveRecoveryMs = positiveNumber(options.staleActiveRecoveryMs, DEFAULT_STALE_ACTIVE_RECOVERY_MS);
|
|
2488
|
+
const sessionStatusCacheMs = nonNegativeNumber(options.sessionStatusCacheMs, DEFAULT_SESSION_STATUS_CACHE_MS);
|
|
2489
|
+
function markSessionStatus(sessionID, type, observedAt = now2()) {
|
|
2490
|
+
if (typeof sessionID !== "string" || typeof type !== "string")
|
|
2491
|
+
return false;
|
|
2492
|
+
sessionStatuses.set(sessionID, type);
|
|
2493
|
+
sessionStatusSeenAt.set(sessionID, observedAt);
|
|
2494
|
+
return true;
|
|
2495
|
+
}
|
|
2496
|
+
function clearSessionStatus(sessionID) {
|
|
2204
2497
|
sessionStatuses.delete(sessionID);
|
|
2205
2498
|
sessionStatusSeenAt.delete(sessionID);
|
|
2206
|
-
await appendLoopLog(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
|
|
2207
|
-
sessionID,
|
|
2208
|
-
job: active.job?.name || active.jobId,
|
|
2209
|
-
startedAt: active.startedAt,
|
|
2210
|
-
nativeEvent: Boolean(pending?.completedAt)
|
|
2211
|
-
});
|
|
2212
|
-
await scheduleDueWork(directory, client, sessionID);
|
|
2213
|
-
return true;
|
|
2214
2499
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2500
|
+
function updateSessionStatusFromEvent(event) {
|
|
2501
|
+
const sessionID = event?.properties?.sessionID;
|
|
2502
|
+
if (typeof sessionID !== "string")
|
|
2503
|
+
return;
|
|
2504
|
+
if (event?.type === "session.idle") {
|
|
2505
|
+
markSessionStatus(sessionID, "idle");
|
|
2506
|
+
return { sessionID, idle: true };
|
|
2507
|
+
}
|
|
2508
|
+
if (event?.type === "session.status") {
|
|
2509
|
+
const status = event?.properties?.status;
|
|
2510
|
+
const type = status && typeof status === "object" ? status.type : undefined;
|
|
2511
|
+
if (typeof type === "string")
|
|
2512
|
+
markSessionStatus(sessionID, type);
|
|
2513
|
+
return { sessionID, idle: type === "idle" };
|
|
2514
|
+
}
|
|
2219
2515
|
return;
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
job.
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
await
|
|
2241
|
-
|
|
2516
|
+
}
|
|
2517
|
+
function staleActiveRun(sessionID) {
|
|
2518
|
+
const active = activeRuns.get(sessionID);
|
|
2519
|
+
if (!active)
|
|
2520
|
+
return false;
|
|
2521
|
+
const age = now2() - (active.startedAt || 0);
|
|
2522
|
+
const configured = Number(active.job?.staleActiveRecoveryMs || active.job?.activeRecoveryMs || 0);
|
|
2523
|
+
const threshold = Number.isFinite(configured) && configured > 0 ? configured : staleActiveRecoveryMs;
|
|
2524
|
+
return age >= threshold;
|
|
2525
|
+
}
|
|
2526
|
+
async function readLiveSessionStatus(client, sessionID, directory) {
|
|
2527
|
+
const statusMethod = client?.session?.status;
|
|
2528
|
+
if (typeof statusMethod !== "function")
|
|
2529
|
+
return;
|
|
2530
|
+
const argsList = [];
|
|
2531
|
+
if (directory)
|
|
2532
|
+
argsList.push({ query: { directory } }, { directory }, { workspace: directory });
|
|
2533
|
+
argsList.push({});
|
|
2534
|
+
for (const args of argsList) {
|
|
2535
|
+
try {
|
|
2536
|
+
const result = await statusMethod.call(client.session, args);
|
|
2537
|
+
const error = sdkError(result);
|
|
2538
|
+
if (error)
|
|
2539
|
+
continue;
|
|
2540
|
+
const data = sdkData(result);
|
|
2541
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
2542
|
+
continue;
|
|
2543
|
+
const observedAt = now2();
|
|
2544
|
+
for (const [observedSessionID, observedStatus] of Object.entries(data)) {
|
|
2545
|
+
const observedType = observedStatus && typeof observedStatus === "object" ? observedStatus.type : undefined;
|
|
2546
|
+
if (typeof observedType !== "string")
|
|
2547
|
+
continue;
|
|
2548
|
+
markSessionStatus(observedSessionID, observedType, observedAt);
|
|
2549
|
+
}
|
|
2550
|
+
for (const childID of sessionParents.keys()) {
|
|
2551
|
+
if (!isDescendantSession(childID, sessionID) || data[childID])
|
|
2552
|
+
continue;
|
|
2553
|
+
markSessionStatus(childID, "idle", observedAt);
|
|
2554
|
+
}
|
|
2555
|
+
if (hasBusyDescendant(sessionID))
|
|
2556
|
+
return { type: "busy", source: "descendant" };
|
|
2557
|
+
const status = data?.[sessionID];
|
|
2558
|
+
const type = status && typeof status === "object" ? status.type : undefined;
|
|
2559
|
+
if (typeof type === "string")
|
|
2560
|
+
return { type, source: "sdk" };
|
|
2561
|
+
return { type: "idle", source: "sdk" };
|
|
2562
|
+
} catch {}
|
|
2242
2563
|
}
|
|
2243
|
-
|
|
2564
|
+
return;
|
|
2244
2565
|
}
|
|
2245
|
-
|
|
2246
|
-
if (
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2566
|
+
async function canFinalizeActiveRun(directory, client, sessionID, active, options2 = {}) {
|
|
2567
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID))
|
|
2568
|
+
return false;
|
|
2569
|
+
if (!options2.requireIdle && !options2.forceStale)
|
|
2570
|
+
return true;
|
|
2571
|
+
const completion = options2.forceStale ? await activeRunCompletionFromMessages2(directory, client, sessionID, active) : undefined;
|
|
2572
|
+
if (completion === "completed")
|
|
2573
|
+
return true;
|
|
2574
|
+
if (!options2.requireIdle)
|
|
2575
|
+
return completion === "unknown" && staleActiveRun(sessionID);
|
|
2576
|
+
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
2577
|
+
if (live?.type === "idle")
|
|
2578
|
+
return true;
|
|
2579
|
+
if (live?.type) {
|
|
2580
|
+
if ((live.type === "busy" || live.type === "retry") && options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2581
|
+
return true;
|
|
2582
|
+
return false;
|
|
2583
|
+
}
|
|
2584
|
+
if (options2.forceStale && completion === "unknown" && staleActiveRun(sessionID))
|
|
2585
|
+
return true;
|
|
2586
|
+
const cached = sessionStatuses.get(sessionID);
|
|
2587
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
2588
|
+
return cached === "idle" && seenAt > (active.startedAt || 0);
|
|
2589
|
+
}
|
|
2590
|
+
async function sessionStatusType(client, sessionID, directory, options2 = {}) {
|
|
2591
|
+
if (hasActiveToolCalls(sessionID) || hasBusyDescendant(sessionID)) {
|
|
2592
|
+
markSessionStatus(sessionID, "busy");
|
|
2593
|
+
return "busy";
|
|
2594
|
+
}
|
|
2595
|
+
const cached = sessionStatuses.get(sessionID);
|
|
2596
|
+
const seenAt = sessionStatusSeenAt.get(sessionID) || 0;
|
|
2597
|
+
if (cached === "idle")
|
|
2598
|
+
return cached;
|
|
2599
|
+
if (cached && now2() - seenAt < sessionStatusCacheMs)
|
|
2600
|
+
return cached;
|
|
2601
|
+
const live = await readLiveSessionStatus(client, sessionID, directory);
|
|
2602
|
+
if (live?.type) {
|
|
2603
|
+
if ((live.type === "busy" || live.type === "retry") && options2.recoverStaleActive !== false) {
|
|
2604
|
+
const active = activeRuns.get(sessionID);
|
|
2605
|
+
if (active) {
|
|
2606
|
+
const completion = await activeRunCompletionFromMessages2(directory, client, sessionID, active);
|
|
2607
|
+
if (completion === "completed" || completion === "unknown" && staleActiveRun(sessionID)) {
|
|
2608
|
+
markSessionStatus(sessionID, "idle");
|
|
2609
|
+
await appendLoopLog2(directory, completion === "completed" ? "status-message-complete-recovery" : "status-stale-recovery", {
|
|
2610
|
+
sessionID,
|
|
2611
|
+
job: active.job?.name || active.jobId,
|
|
2612
|
+
startedAt: active.startedAt
|
|
2613
|
+
});
|
|
2614
|
+
return "idle";
|
|
2615
|
+
}
|
|
2261
2616
|
}
|
|
2262
2617
|
}
|
|
2263
|
-
|
|
2618
|
+
markSessionStatus(sessionID, live.type);
|
|
2619
|
+
return live.type;
|
|
2264
2620
|
}
|
|
2621
|
+
const fallback = activeRuns.has(sessionID) && !staleActiveRun(sessionID) ? "busy" : "idle";
|
|
2622
|
+
markSessionStatus(sessionID, fallback);
|
|
2623
|
+
return fallback;
|
|
2265
2624
|
}
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job);
|
|
2625
|
+
async function sessionIsIdle(client, sessionID, directory, options2 = {}) {
|
|
2626
|
+
return await sessionStatusType(client, sessionID, directory, options2) === "idle";
|
|
2269
2627
|
}
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2628
|
+
return {
|
|
2629
|
+
markSessionStatus,
|
|
2630
|
+
clearSessionStatus,
|
|
2631
|
+
updateSessionStatusFromEvent,
|
|
2632
|
+
staleActiveRun,
|
|
2633
|
+
canFinalizeActiveRun,
|
|
2634
|
+
readLiveSessionStatus,
|
|
2635
|
+
sessionStatusType,
|
|
2636
|
+
sessionIsIdle
|
|
2637
|
+
};
|
|
2277
2638
|
}
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
const
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2639
|
+
|
|
2640
|
+
// src/source/runtime/compaction.js
|
|
2641
|
+
function createCompactionRuntime(options = {}) {
|
|
2642
|
+
const activeRuns = options.activeRuns;
|
|
2643
|
+
if (!(activeRuns instanceof Map))
|
|
2644
|
+
throw new TypeError("createCompactionRuntime requires activeRuns Map");
|
|
2645
|
+
if (typeof options.finalizeActiveRun !== "function")
|
|
2646
|
+
throw new TypeError("createCompactionRuntime requires finalizeActiveRun");
|
|
2647
|
+
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2648
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2649
|
+
const compactSession2 = typeof options.compactSession === "function" ? options.compactSession : compactSession;
|
|
2650
|
+
const log2 = typeof options.log === "function" ? options.log : log;
|
|
2651
|
+
const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : sdkErrorMessage;
|
|
2652
|
+
const finalizeActiveRun = options.finalizeActiveRun;
|
|
2653
|
+
const requests = new Map;
|
|
2654
|
+
function begin(sessionID, jobId, resumeAfter = false) {
|
|
2655
|
+
if (typeof sessionID !== "string" || typeof jobId !== "string")
|
|
2656
|
+
return;
|
|
2657
|
+
const request = {
|
|
2658
|
+
jobId,
|
|
2659
|
+
resumeAfter: Boolean(resumeAfter),
|
|
2660
|
+
requestedAt: now2(),
|
|
2661
|
+
startedAt: 0,
|
|
2662
|
+
completedAt: 0
|
|
2663
|
+
};
|
|
2664
|
+
requests.set(sessionID, request);
|
|
2665
|
+
return request;
|
|
2666
|
+
}
|
|
2667
|
+
function getPending(sessionID) {
|
|
2668
|
+
return requests.get(sessionID);
|
|
2669
|
+
}
|
|
2670
|
+
function clear(sessionID) {
|
|
2671
|
+
return requests.delete(sessionID);
|
|
2672
|
+
}
|
|
2673
|
+
function clearForActiveRun(sessionID, active) {
|
|
2674
|
+
const pending = requests.get(sessionID);
|
|
2675
|
+
if (!pending || !active || pending.jobId === active.jobId)
|
|
2676
|
+
requests.delete(sessionID);
|
|
2677
|
+
}
|
|
2678
|
+
function isCompleted(sessionID, jobId) {
|
|
2679
|
+
const pending = requests.get(sessionID);
|
|
2680
|
+
return Boolean(pending && pending.jobId === jobId && pending.completedAt);
|
|
2681
|
+
}
|
|
2682
|
+
async function start(directory, client, sessionID, jobId, model, resumeAfter = false) {
|
|
2683
|
+
begin(sessionID, jobId, resumeAfter);
|
|
2684
|
+
const ok = await compactSession2(directory, client, sessionID, model);
|
|
2286
2685
|
if (!ok)
|
|
2287
|
-
|
|
2288
|
-
return
|
|
2289
|
-
}
|
|
2290
|
-
|
|
2291
|
-
const
|
|
2292
|
-
const
|
|
2293
|
-
if (!
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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 };
|
|
2686
|
+
clear(sessionID);
|
|
2687
|
+
return ok;
|
|
2688
|
+
}
|
|
2689
|
+
async function maybeCompact(directory, client, sessionID, job) {
|
|
2690
|
+
const dueRuns = job.compactEveryRuns > 0 && (job.runCount || 0) > 0 && (job.runCount || 0) % job.compactEveryRuns === 0 && job.lastCompactRunCount !== job.runCount;
|
|
2691
|
+
const dueTime = job.compactEveryMs > 0 && (!job.lastCompactAt || now2() - job.lastCompactAt >= job.compactEveryMs);
|
|
2692
|
+
if (!dueRuns && !dueTime)
|
|
2693
|
+
return { job, started: false };
|
|
2694
|
+
if (await start(directory, client, sessionID, job.id, job.model, true)) {
|
|
2695
|
+
job.lastCompactAt = now2();
|
|
2696
|
+
job.lastCompactRunCount = job.runCount || 0;
|
|
2697
|
+
return { job, started: true };
|
|
2305
2698
|
}
|
|
2306
|
-
|
|
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 };
|
|
2312
|
-
}
|
|
2313
|
-
if (kind === "shell") {
|
|
2314
|
-
const command = action.replace(/^[!$]\s*/, "").trim();
|
|
2315
|
-
if (job.safe && dangerousShell(command)) {
|
|
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" };
|
|
2319
|
-
}
|
|
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 };
|
|
2699
|
+
return { job, started: false };
|
|
2326
2700
|
}
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2701
|
+
async function noteStarted(directory, sessionID) {
|
|
2702
|
+
const pending = requests.get(sessionID);
|
|
2703
|
+
if (!pending)
|
|
2704
|
+
return false;
|
|
2705
|
+
if (!pending.startedAt) {
|
|
2706
|
+
pending.startedAt = now2();
|
|
2707
|
+
requests.set(sessionID, pending);
|
|
2708
|
+
await appendLoopLog2(directory, "compact-started", {
|
|
2709
|
+
sessionID,
|
|
2710
|
+
job: pending.jobId,
|
|
2711
|
+
resumeAfter: pending.resumeAfter
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
return true;
|
|
2715
|
+
}
|
|
2716
|
+
async function finalize(directory, client, sessionID) {
|
|
2717
|
+
const pending = requests.get(sessionID);
|
|
2718
|
+
const active = activeRuns.get(sessionID);
|
|
2719
|
+
if (!pending || !active || pending.jobId !== active.jobId)
|
|
2720
|
+
return false;
|
|
2721
|
+
return await finalizeActiveRun(directory, client, sessionID);
|
|
2722
|
+
}
|
|
2723
|
+
async function noteCompleted(directory, client, sessionID) {
|
|
2724
|
+
const pending = requests.get(sessionID);
|
|
2725
|
+
if (!pending)
|
|
2726
|
+
return false;
|
|
2727
|
+
pending.completedAt = now2();
|
|
2728
|
+
requests.set(sessionID, pending);
|
|
2729
|
+
await appendLoopLog2(directory, "compact-event", {
|
|
2730
|
+
sessionID,
|
|
2731
|
+
job: pending.jobId,
|
|
2732
|
+
resumeAfter: pending.resumeAfter
|
|
2733
|
+
});
|
|
2734
|
+
const timer = setTimeout(() => {
|
|
2735
|
+
finalize(directory, client, sessionID).catch((error) => log2(client, "error", "compaction finalization failed", { error: errorMessage(error) }));
|
|
2736
|
+
}, 0);
|
|
2737
|
+
timer.unref?.();
|
|
2738
|
+
return true;
|
|
2739
|
+
}
|
|
2740
|
+
return {
|
|
2741
|
+
begin,
|
|
2742
|
+
getPending,
|
|
2743
|
+
clear,
|
|
2744
|
+
clearForActiveRun,
|
|
2745
|
+
isCompleted,
|
|
2746
|
+
start,
|
|
2747
|
+
maybeCompact,
|
|
2748
|
+
noteStarted,
|
|
2749
|
+
finalize,
|
|
2750
|
+
noteCompleted
|
|
2343
2751
|
};
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// src/source/runtime/executor.js
|
|
2755
|
+
var DEFAULT_ACTIVE_GUARD_MS = 45000;
|
|
2756
|
+
var DEFAULT_BUSY_RETRY_MS2 = 5000;
|
|
2757
|
+
function requireFunction7(value, label) {
|
|
2758
|
+
if (typeof value !== "function")
|
|
2759
|
+
throw new TypeError(`createLoopExecutor requires ${label}`);
|
|
2760
|
+
return value;
|
|
2761
|
+
}
|
|
2762
|
+
function createLoopExecutor(options = {}) {
|
|
2763
|
+
const workspace = options.workspace || {};
|
|
2764
|
+
const goalPolicy = options.goalPolicy || {};
|
|
2765
|
+
const scheduler = options.scheduler || {};
|
|
2766
|
+
const buildPrompt = requireFunction7(workspace.buildPrompt, "workspace.buildPrompt");
|
|
2767
|
+
const ensureBranch = requireFunction7(workspace.ensureBranch, "workspace.ensureBranch");
|
|
2768
|
+
const watchChanged = requireFunction7(workspace.watchChanged, "workspace.watchChanged");
|
|
2769
|
+
const untilReached = requireFunction7(workspace.untilReached, "workspace.untilReached");
|
|
2770
|
+
const createCheckpoint = requireFunction7(workspace.createCheckpoint, "workspace.createCheckpoint");
|
|
2771
|
+
const runGoalChecks = requireFunction7(goalPolicy.runGoalChecks, "goalPolicy.runGoalChecks");
|
|
2772
|
+
const applyGoalNoProgressGuard = requireFunction7(goalPolicy.applyGoalNoProgressGuard, "goalPolicy.applyGoalNoProgressGuard");
|
|
2773
|
+
const rememberSession = requireFunction7(scheduler.rememberSession, "scheduler.rememberSession");
|
|
2774
|
+
const scheduleDueWork = requireFunction7(scheduler.scheduleDueWork, "scheduler.scheduleDueWork");
|
|
2775
|
+
const now2 = typeof options.now === "function" ? options.now : now;
|
|
2776
|
+
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
2777
|
+
const writeState2 = typeof options.writeState === "function" ? options.writeState : writeState;
|
|
2778
|
+
const pathExists2 = typeof options.pathExists === "function" ? options.pathExists : pathExists;
|
|
2779
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
2780
|
+
const runShellCommand2 = typeof options.runShellCommand === "function" ? options.runShellCommand : runShellCommand;
|
|
2781
|
+
const notifyJob2 = typeof options.notifyJob === "function" ? options.notifyJob : notifyJob;
|
|
2782
|
+
const errorMessage = typeof options.errorMessage === "function" ? options.errorMessage : sdkErrorMessage;
|
|
2783
|
+
const sdkCall2 = typeof options.sdkCall === "function" ? options.sdkCall : sdkCall;
|
|
2784
|
+
const normalizedModelRef2 = typeof options.normalizedModelRef === "function" ? options.normalizedModelRef : normalizedModelRef;
|
|
2785
|
+
const fireSdk2 = typeof options.fireSdk === "function" ? options.fireSdk : fireSdk;
|
|
2786
|
+
const compactTuiCommandName2 = typeof options.compactTuiCommandName === "function" ? options.compactTuiCommandName : compactTuiCommandName;
|
|
2787
|
+
const log2 = typeof options.log === "function" ? options.log : log;
|
|
2788
|
+
const toast2 = typeof options.toast === "function" ? options.toast : toast;
|
|
2789
|
+
const guardLoopOwnedUserMessage2 = typeof options.guardLoopOwnedUserMessage === "function" ? options.guardLoopOwnedUserMessage : guardLoopOwnedUserMessage;
|
|
2790
|
+
const writeGoalReport2 = typeof options.writeGoalReport === "function" ? options.writeGoalReport : writeGoalReport;
|
|
2791
|
+
const dangerousShell2 = typeof options.dangerousShell === "function" ? options.dangerousShell : dangerousShell;
|
|
2792
|
+
const activeGuardMs = Number.isFinite(Number(options.activeGuardMs)) && Number(options.activeGuardMs) > 0 ? Number(options.activeGuardMs) : DEFAULT_ACTIVE_GUARD_MS;
|
|
2793
|
+
const busyRetryMs = Number.isFinite(Number(options.busyRetryMs)) && Number(options.busyRetryMs) > 0 ? Number(options.busyRetryMs) : DEFAULT_BUSY_RETRY_MS2;
|
|
2794
|
+
const activeRuns = new Map;
|
|
2795
|
+
const runLocks = new Map;
|
|
2796
|
+
const statusRuntime = createSessionStatusRuntime({
|
|
2797
|
+
activeRuns,
|
|
2798
|
+
appendLoopLog: appendLoopLog2,
|
|
2799
|
+
now: now2,
|
|
2800
|
+
activeRunCompletionFromMessages: options.activeRunCompletionFromMessages,
|
|
2801
|
+
staleActiveRecoveryMs: options.staleActiveRecoveryMs,
|
|
2802
|
+
sessionStatusCacheMs: options.sessionStatusCacheMs
|
|
2803
|
+
});
|
|
2804
|
+
const {
|
|
2805
|
+
updateSessionStatusFromEvent,
|
|
2806
|
+
staleActiveRun,
|
|
2807
|
+
canFinalizeActiveRun,
|
|
2808
|
+
sessionIsIdle,
|
|
2809
|
+
markSessionStatus,
|
|
2810
|
+
clearSessionStatus
|
|
2811
|
+
} = statusRuntime;
|
|
2812
|
+
const compactionRuntime = createCompactionRuntime({
|
|
2813
|
+
activeRuns,
|
|
2814
|
+
finalizeActiveRun,
|
|
2815
|
+
appendLoopLog: appendLoopLog2,
|
|
2816
|
+
compactSession: options.compactSession,
|
|
2817
|
+
log: log2,
|
|
2818
|
+
errorMessage,
|
|
2819
|
+
now: now2
|
|
2820
|
+
});
|
|
2821
|
+
function dueJobs(state, force = false) {
|
|
2822
|
+
const current = now2();
|
|
2823
|
+
const due = (state.jobs || []).filter((job) => {
|
|
2824
|
+
if (isGoalJob(job) && ["completed", "blocked", "cleared"].includes(job.goalStatus))
|
|
2825
|
+
return false;
|
|
2826
|
+
if (!job.enabled || job.paused)
|
|
2827
|
+
return false;
|
|
2828
|
+
if (job.maxRuns > 0 && (job.runCount || 0) >= job.maxRuns)
|
|
2829
|
+
return false;
|
|
2830
|
+
if (job.maxRuntimeMs > 0 && current - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs)
|
|
2831
|
+
return true;
|
|
2832
|
+
if (Number(job.runNowRequestedAt || 0) > 0)
|
|
2833
|
+
return true;
|
|
2834
|
+
if (force)
|
|
2835
|
+
return true;
|
|
2836
|
+
if (job.watchPaths?.length)
|
|
2837
|
+
return job.watchTriggered === true;
|
|
2838
|
+
return job.intervalMs === 0 || !job.lastRunAt || current - job.lastRunAt >= job.intervalMs;
|
|
2839
|
+
});
|
|
2840
|
+
return due.sort((a, b) => Number(Number(b.runNowRequestedAt || 0) > 0) - Number(Number(a.runNowRequestedAt || 0) > 0));
|
|
2347
2841
|
}
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2842
|
+
function clearActiveRun(sessionID) {
|
|
2843
|
+
const active = activeRuns.get(sessionID);
|
|
2844
|
+
if (active?.timer)
|
|
2845
|
+
clearTimeout(active.timer);
|
|
2846
|
+
compactionRuntime.clearForActiveRun(sessionID, active);
|
|
2847
|
+
activeRuns.delete(sessionID);
|
|
2848
|
+
}
|
|
2849
|
+
function disposeSession(sessionID) {
|
|
2850
|
+
clearActiveRun(sessionID);
|
|
2851
|
+
runLocks.delete(sessionID);
|
|
2852
|
+
compactionRuntime.clear(sessionID);
|
|
2853
|
+
clearSessionStatus(sessionID);
|
|
2854
|
+
}
|
|
2855
|
+
async function recoverActiveDispatchFailure(directory, client, sessionID, jobId, runToken, error) {
|
|
2856
|
+
const active = activeRuns.get(sessionID);
|
|
2857
|
+
if (!active || active.jobId !== jobId || active.runToken !== runToken)
|
|
2858
|
+
return false;
|
|
2859
|
+
clearActiveRun(sessionID);
|
|
2860
|
+
clearSessionStatus(sessionID);
|
|
2861
|
+
const message = errorMessage(error);
|
|
2862
|
+
const state = await readState2(directory, sessionID);
|
|
2863
|
+
const job = (state.jobs || []).find((candidate) => candidate.id === jobId);
|
|
2864
|
+
if (job) {
|
|
2865
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2866
|
+
job.lastFailureReason = "dispatch_failed";
|
|
2867
|
+
job.lastDispatchFailure = message.slice(0, 4000);
|
|
2868
|
+
job.lastDispatchFailureAt = now2();
|
|
2869
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
2870
|
+
job.paused = true;
|
|
2871
|
+
await notifyJob2(directory, job, "dispatch_failed");
|
|
2872
|
+
}
|
|
2873
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
2874
|
+
await writeState2(directory, sessionID, state);
|
|
2357
2875
|
}
|
|
2876
|
+
await appendLoopLog2(directory, "dispatch-error", { sessionID, job: job?.name || jobId, error: message });
|
|
2877
|
+
await toast2(client, `Loop dispatch failed${job?.paused ? " and paused" : ""}: ${message}`, job?.paused ? "error" : "warning");
|
|
2878
|
+
await scheduleDueWork(directory, client, sessionID, busyRetryMs);
|
|
2879
|
+
return true;
|
|
2880
|
+
}
|
|
2881
|
+
async function finalizeActiveRun(directory, client, sessionID, finalizeOptions = {}) {
|
|
2358
2882
|
const active = activeRuns.get(sessionID);
|
|
2359
|
-
|
|
2360
|
-
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || DEFAULT_ACTIVE_GUARD_MS;
|
|
2361
|
-
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
2362
|
-
await reschedule(BUSY_RETRY_MS);
|
|
2883
|
+
if (!active)
|
|
2363
2884
|
return;
|
|
2364
|
-
|
|
2365
|
-
|
|
2885
|
+
if (!await canFinalizeActiveRun(directory, client, sessionID, active, finalizeOptions))
|
|
2886
|
+
return false;
|
|
2887
|
+
const recoveredStale = staleActiveRun(sessionID);
|
|
2888
|
+
if (active.compactionOnly) {
|
|
2889
|
+
const pending = compactionRuntime.getPending(sessionID);
|
|
2366
2890
|
clearActiveRun(sessionID);
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
await
|
|
2375
|
-
|
|
2376
|
-
return;
|
|
2891
|
+
clearSessionStatus(sessionID);
|
|
2892
|
+
await appendLoopLog2(directory, pending?.completedAt ? "compact-finished" : "compact-idle-fallback", {
|
|
2893
|
+
sessionID,
|
|
2894
|
+
job: active.job?.name || active.jobId,
|
|
2895
|
+
startedAt: active.startedAt,
|
|
2896
|
+
nativeEvent: Boolean(pending?.completedAt)
|
|
2897
|
+
});
|
|
2898
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2899
|
+
return true;
|
|
2377
2900
|
}
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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();
|
|
2901
|
+
clearActiveRun(sessionID);
|
|
2902
|
+
const state = await readState2(directory, sessionID);
|
|
2903
|
+
let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
|
|
2904
|
+
if (!job)
|
|
2386
2905
|
return;
|
|
2906
|
+
job.lastFinishedAt = now2();
|
|
2907
|
+
if (recoveredStale) {
|
|
2908
|
+
await appendLoopLog2(directory, "active-stale-recovery", {
|
|
2909
|
+
sessionID,
|
|
2910
|
+
job: job.name || job.id,
|
|
2911
|
+
startedAt: active.startedAt
|
|
2912
|
+
});
|
|
2387
2913
|
}
|
|
2388
|
-
if (job.
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2914
|
+
if (job.verifyCommand) {
|
|
2915
|
+
const verify = await runShellCommand2(job.verifyCommand, directory, job.timeoutMs || 300000);
|
|
2916
|
+
job.lastVerifyAt = now2();
|
|
2917
|
+
job.lastVerifyCode = verify.code;
|
|
2918
|
+
if (verify.code === 0) {
|
|
2919
|
+
job.failureCount = 0;
|
|
2920
|
+
job.lastVerifyFailure = "";
|
|
2921
|
+
await toast2(client, "Loop verify passed: " + job.verifyCommand, "success");
|
|
2922
|
+
} else {
|
|
2923
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2924
|
+
job.lastVerifyFailure = (job.verifyCommand + `
|
|
2925
|
+
exit=` + verify.code + `
|
|
2926
|
+
` + verify.stdout + `
|
|
2927
|
+
` + verify.stderr).slice(0, 4000);
|
|
2928
|
+
await toast2(client, "Loop verify failed: " + job.verifyCommand, "warning");
|
|
2929
|
+
if (job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
2930
|
+
job.paused = true;
|
|
2931
|
+
await notifyJob2(directory, job, "verify_failed");
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
await appendLoopLog2(directory, "verify", {
|
|
2935
|
+
sessionID,
|
|
2936
|
+
job: job.name || job.id,
|
|
2937
|
+
command: job.verifyCommand,
|
|
2938
|
+
code: verify.code,
|
|
2939
|
+
failures: job.failureCount || 0
|
|
2940
|
+
});
|
|
2395
2941
|
}
|
|
2396
|
-
if (
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2942
|
+
if (job.postrunCommand) {
|
|
2943
|
+
if (job.safe && dangerousShell2(job.postrunCommand)) {
|
|
2944
|
+
await appendLoopLog2(directory, "postrun-blocked", {
|
|
2945
|
+
sessionID,
|
|
2946
|
+
job: job.name || job.id,
|
|
2947
|
+
command: job.postrunCommand
|
|
2948
|
+
});
|
|
2949
|
+
} else {
|
|
2950
|
+
const postrun = await runShellCommand2(job.postrunCommand, directory, job.timeoutMs || 300000);
|
|
2951
|
+
job.lastPostrunCode = postrun.code;
|
|
2952
|
+
job.lastPostrunAt = now2();
|
|
2953
|
+
if (postrun.code !== 0) {
|
|
2954
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
2955
|
+
job.lastPostrunFailure = (job.postrunCommand + `
|
|
2956
|
+
exit=` + postrun.code + `
|
|
2957
|
+
` + postrun.stdout + `
|
|
2958
|
+
` + postrun.stderr).slice(0, 4000);
|
|
2959
|
+
if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
|
|
2960
|
+
job.paused = true;
|
|
2961
|
+
await notifyJob2(directory, job, "postrun_failed");
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
await appendLoopLog2(directory, "postrun", {
|
|
2965
|
+
sessionID,
|
|
2966
|
+
job: job.name || job.id,
|
|
2967
|
+
command: job.postrunCommand,
|
|
2968
|
+
code: postrun.code
|
|
2969
|
+
});
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
if (isGoalJob(job)) {
|
|
2973
|
+
job = await runGoalChecks(directory, sessionID, job, client);
|
|
2974
|
+
job = await applyGoalNoProgressGuard(directory, client, sessionID, job, active.job);
|
|
2975
|
+
}
|
|
2976
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
|
|
2977
|
+
await writeState2(directory, sessionID, state);
|
|
2978
|
+
if (isGoalJob(job))
|
|
2979
|
+
await writeGoalReport2(directory, sessionID, job);
|
|
2980
|
+
await createCheckpoint(directory, sessionID, job, client);
|
|
2981
|
+
await scheduleDueWork(directory, client, sessionID);
|
|
2982
|
+
return true;
|
|
2983
|
+
}
|
|
2984
|
+
async function fireAction(directory, client, sessionID, job) {
|
|
2985
|
+
const action = String(job.action || "").trim();
|
|
2986
|
+
const kind = actionKind(action, job);
|
|
2987
|
+
const agent = job.agent || "build";
|
|
2988
|
+
const model = normalizedModelRef2(job.model);
|
|
2989
|
+
if (kind === "compact") {
|
|
2990
|
+
const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false);
|
|
2991
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
|
|
2992
|
+
}
|
|
2993
|
+
if (kind === "command") {
|
|
2994
|
+
const normalized = action.startsWith("/") ? action.slice(1) : action;
|
|
2995
|
+
const [command, argumentsText] = splitFirst(normalized);
|
|
2996
|
+
if (!command) {
|
|
2997
|
+
await toast2(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning");
|
|
2998
|
+
return { startsAssistantTurn: false, pause: true, reason: "empty_command" };
|
|
2999
|
+
}
|
|
3000
|
+
const tuiCommand = compactTuiCommandName2(command);
|
|
3001
|
+
if (tuiCommand) {
|
|
3002
|
+
guardLoopOwnedUserMessage2(sessionID);
|
|
3003
|
+
const ok = await compactionRuntime.start(directory, client, sessionID, job.id, model, false);
|
|
3004
|
+
return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
|
|
3005
|
+
}
|
|
3006
|
+
guardLoopOwnedUserMessage2(sessionID);
|
|
3007
|
+
const commandBody = { command, arguments: argumentsText, agent };
|
|
3008
|
+
if (model)
|
|
3009
|
+
commandBody.model = `${model.providerID}/${model.modelID}`;
|
|
3010
|
+
await sdkCall2(client.session.command.bind(client.session), { path: { id: sessionID }, body: commandBody }, { path: { sessionID }, body: commandBody }, { sessionID, ...commandBody });
|
|
3011
|
+
return { startsAssistantTurn: true };
|
|
3012
|
+
}
|
|
3013
|
+
if (kind === "shell") {
|
|
3014
|
+
const command = action.replace(/^[!$]\s*/, "").trim();
|
|
3015
|
+
if (job.safe && dangerousShell2(command)) {
|
|
3016
|
+
await toast2(client, `Blocked dangerous shell command in safe mode: ${command}`, "error");
|
|
3017
|
+
await appendLoopLog2(directory, "blocked", { sessionID, job: job.name || job.id, command });
|
|
3018
|
+
return { startsAssistantTurn: false, pause: true, reason: "safe_shell_blocked" };
|
|
3019
|
+
}
|
|
3020
|
+
guardLoopOwnedUserMessage2(sessionID);
|
|
3021
|
+
const shellBody = { command, agent };
|
|
3022
|
+
if (model)
|
|
3023
|
+
shellBody.model = model;
|
|
3024
|
+
const dispatch2 = fireSdk2(client, "session.shell", client.session.shell.bind(client.session), { path: { id: sessionID }, body: shellBody }, { path: { sessionID }, body: shellBody }, { sessionID, ...shellBody });
|
|
3025
|
+
return { startsAssistantTurn: true, dispatch: dispatch2 };
|
|
3026
|
+
}
|
|
3027
|
+
const prompt = await buildPrompt(directory, job);
|
|
3028
|
+
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.";
|
|
3029
|
+
const promptText = `${prefix}
|
|
3030
|
+
|
|
3031
|
+
${prompt}`;
|
|
3032
|
+
guardLoopOwnedUserMessage2(sessionID);
|
|
3033
|
+
const promptBody = { agent, parts: [{ type: "text", text: promptText }] };
|
|
3034
|
+
if (model)
|
|
3035
|
+
promptBody.model = model;
|
|
3036
|
+
const dispatch = fireSdk2(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: promptBody }, { path: { sessionID }, body: promptBody }, { sessionID, ...promptBody });
|
|
3037
|
+
return { startsAssistantTurn: true, dispatch };
|
|
3038
|
+
}
|
|
3039
|
+
async function maybeRunDueJobs(directory, client, sessionID, runOptions = {}) {
|
|
3040
|
+
rememberSession(directory, client, sessionID);
|
|
3041
|
+
const reschedule = async (minDelayMs = 0) => {
|
|
3042
|
+
await scheduleDueWork(directory, client, sessionID, minDelayMs);
|
|
3043
|
+
};
|
|
3044
|
+
if (runLocks.has(sessionID)) {
|
|
3045
|
+
await reschedule(busyRetryMs);
|
|
2402
3046
|
return;
|
|
2403
3047
|
}
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
3048
|
+
runLocks.set(sessionID, now2());
|
|
3049
|
+
let job;
|
|
3050
|
+
try {
|
|
3051
|
+
await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
|
|
3052
|
+
if (!await sessionIsIdle(client, sessionID, directory)) {
|
|
3053
|
+
if (runOptions.force)
|
|
3054
|
+
await toast2(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
|
|
3055
|
+
await reschedule(busyRetryMs);
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
const active = activeRuns.get(sessionID);
|
|
3059
|
+
const activeAge = active ? now2() - (active.startedAt || 0) : 0;
|
|
3060
|
+
const activeGuard = active?.job?.timeoutMs || active?.job?.activeRecoveryMs || activeGuardMs;
|
|
3061
|
+
if (active && active.job?.noOverlap !== false && activeAge < activeGuard) {
|
|
3062
|
+
await reschedule(busyRetryMs);
|
|
3063
|
+
return;
|
|
3064
|
+
}
|
|
3065
|
+
if (active && activeAge >= activeGuard)
|
|
3066
|
+
clearActiveRun(sessionID);
|
|
3067
|
+
const state = await readState2(directory, sessionID);
|
|
3068
|
+
for (const candidate of state.jobs || []) {
|
|
3069
|
+
if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate)) {
|
|
3070
|
+
candidate.watchTriggered = true;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
const due = dueJobs(state, Boolean(runOptions.force));
|
|
3074
|
+
if (!due.length) {
|
|
3075
|
+
await writeState2(directory, sessionID, state);
|
|
2410
3076
|
await reschedule();
|
|
2411
3077
|
return;
|
|
2412
3078
|
}
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
if (
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
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");
|
|
3079
|
+
job = due[0];
|
|
3080
|
+
const runNowRequested = Number(job.runNowRequestedAt || 0) > 0;
|
|
3081
|
+
if (job.maxRuntimeMs > 0 && now2() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
|
|
3082
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
|
|
3083
|
+
await writeState2(directory, sessionID, state);
|
|
3084
|
+
await notifyJob2(directory, job, "max_runtime_reached");
|
|
3085
|
+
await toast2(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success");
|
|
3086
|
+
await appendLoopLog2(directory, "max-runtime", { sessionID, job: job.name || job.id });
|
|
2426
3087
|
await reschedule();
|
|
2427
3088
|
return;
|
|
2428
3089
|
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
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);
|
|
2445
|
-
if (pending?.jobId === job.id && pending.completedAt) {
|
|
2446
|
-
await finalizeLoopCompaction(directory, client, sessionID);
|
|
3090
|
+
if (job.stopFile && await pathExists2(path6.resolve(directory, job.stopFile))) {
|
|
3091
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
|
|
3092
|
+
await writeState2(directory, sessionID, state);
|
|
3093
|
+
await notifyJob2(directory, job, "stop_file");
|
|
3094
|
+
await toast2(client, "Loop stopped by --stop-file: " + job.stopFile, "success");
|
|
3095
|
+
await reschedule();
|
|
2447
3096
|
return;
|
|
2448
3097
|
}
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
job.watchTriggered = false;
|
|
2455
|
-
job.lastRunAt = now();
|
|
2456
|
-
job.runCount = (job.runCount || 0) + 1;
|
|
2457
|
-
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
2458
|
-
job.enabled = false;
|
|
2459
|
-
await notifyJob(directory, job, "max_runs_reached");
|
|
2460
|
-
}
|
|
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");
|
|
2465
|
-
try {
|
|
2466
|
-
const result = await fireAction(directory, client, sessionID, job);
|
|
2467
|
-
if (!result.startsAssistantTurn) {
|
|
2468
|
-
const fresh = await readState(directory, sessionID);
|
|
2469
|
-
if (result.pause) {
|
|
2470
|
-
fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
|
|
2471
|
-
...candidate,
|
|
2472
|
-
paused: true,
|
|
2473
|
-
failureCount: (candidate.failureCount || 0) + 1,
|
|
2474
|
-
lastFailureReason: result.reason || "action_did_not_start"
|
|
2475
|
-
} : candidate);
|
|
2476
|
-
}
|
|
2477
|
-
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
|
|
2478
|
-
await writeState(directory, sessionID, fresh);
|
|
3098
|
+
if (await untilReached(directory, job)) {
|
|
3099
|
+
state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
|
|
3100
|
+
await writeState2(directory, sessionID, state);
|
|
3101
|
+
await notifyJob2(directory, job, "until_reached");
|
|
3102
|
+
await toast2(client, `Loop stopped by --until: ${job.until}`, "success");
|
|
2479
3103
|
await reschedule();
|
|
2480
3104
|
return;
|
|
2481
3105
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
3106
|
+
if (job.preflightCommand) {
|
|
3107
|
+
if (job.safe && dangerousShell2(job.preflightCommand)) {
|
|
3108
|
+
if (runNowRequested)
|
|
3109
|
+
delete job.runNowRequestedAt;
|
|
3110
|
+
job.paused = true;
|
|
3111
|
+
await writeState2(directory, sessionID, state);
|
|
3112
|
+
await notifyJob2(directory, job, "preflight_blocked");
|
|
3113
|
+
await toast2(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error");
|
|
3114
|
+
await reschedule();
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
const preflight = await runShellCommand2(job.preflightCommand, directory, job.timeoutMs || 300000);
|
|
3118
|
+
await appendLoopLog2(directory, "preflight", {
|
|
3119
|
+
sessionID,
|
|
3120
|
+
job: job.name || job.id,
|
|
3121
|
+
command: job.preflightCommand,
|
|
3122
|
+
code: preflight.code
|
|
3123
|
+
});
|
|
3124
|
+
if (preflight.code !== 0) {
|
|
3125
|
+
if (runNowRequested)
|
|
3126
|
+
delete job.runNowRequestedAt;
|
|
3127
|
+
job.paused = true;
|
|
3128
|
+
job.failureCount = (job.failureCount || 0) + 1;
|
|
3129
|
+
job.lastPreflightFailure = (job.preflightCommand + `
|
|
3130
|
+
exit=` + preflight.code + `
|
|
3131
|
+
` + preflight.stdout + `
|
|
3132
|
+
` + preflight.stderr).slice(0, 4000);
|
|
3133
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3134
|
+
await writeState2(directory, sessionID, state);
|
|
3135
|
+
await notifyJob2(directory, job, "preflight_failed");
|
|
3136
|
+
await toast2(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning");
|
|
3137
|
+
await reschedule();
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
job = await ensureBranch(directory, job, client, sessionID);
|
|
3142
|
+
const compactResult = await compactionRuntime.maybeCompact(directory, client, sessionID, job);
|
|
3143
|
+
job = compactResult.job;
|
|
3144
|
+
if (compactResult.started) {
|
|
3145
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3146
|
+
await writeState2(directory, sessionID, state);
|
|
3147
|
+
let timer;
|
|
3148
|
+
if (job.timeoutMs > 0) {
|
|
3149
|
+
timer = setTimeout(() => {
|
|
3150
|
+
fireSdk2(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
3151
|
+
toast2(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {});
|
|
3152
|
+
}, job.timeoutMs);
|
|
3153
|
+
}
|
|
3154
|
+
const runToken = `${job.id}:compact:${now2().toString(36)}:${Math.random().toString(16).slice(2)}`;
|
|
3155
|
+
activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now2(), timer, runToken, compactionOnly: true });
|
|
3156
|
+
if (compactionRuntime.isCompleted(sessionID, job.id)) {
|
|
3157
|
+
await compactionRuntime.finalize(directory, client, sessionID);
|
|
3158
|
+
return;
|
|
3159
|
+
}
|
|
3160
|
+
markSessionStatus(sessionID, "busy");
|
|
3161
|
+
await reschedule(busyRetryMs);
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
if (runNowRequested)
|
|
3165
|
+
delete job.runNowRequestedAt;
|
|
3166
|
+
job.watchTriggered = false;
|
|
3167
|
+
job.lastRunAt = now2();
|
|
3168
|
+
job.runCount = (job.runCount || 0) + 1;
|
|
3169
|
+
if (job.maxRuns > 0 && job.runCount >= job.maxRuns) {
|
|
3170
|
+
job.enabled = false;
|
|
3171
|
+
await notifyJob2(directory, job, "max_runs_reached");
|
|
3172
|
+
}
|
|
3173
|
+
state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
|
|
3174
|
+
await writeState2(directory, sessionID, state);
|
|
3175
|
+
await appendLoopLog2(directory, "run", { sessionID, job: job.name || job.id, runCount: job.runCount });
|
|
3176
|
+
await toast2(client, `Loop running: ${job.name || job.id}`, "info");
|
|
3177
|
+
try {
|
|
3178
|
+
const result = await fireAction(directory, client, sessionID, job);
|
|
3179
|
+
if (!result.startsAssistantTurn) {
|
|
3180
|
+
const fresh = await readState2(directory, sessionID);
|
|
3181
|
+
if (result.pause) {
|
|
3182
|
+
fresh.jobs = (fresh.jobs || []).map((candidate) => candidate.id === job.id ? {
|
|
3183
|
+
...candidate,
|
|
3184
|
+
paused: true,
|
|
3185
|
+
failureCount: (candidate.failureCount || 0) + 1,
|
|
3186
|
+
lastFailureReason: result.reason || "action_did_not_start"
|
|
3187
|
+
} : candidate);
|
|
3188
|
+
}
|
|
3189
|
+
fresh.jobs = (fresh.jobs || []).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
|
|
3190
|
+
await writeState2(directory, sessionID, fresh);
|
|
3191
|
+
await reschedule();
|
|
2494
3192
|
return;
|
|
2495
3193
|
}
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
3194
|
+
let timer;
|
|
3195
|
+
if (job.timeoutMs > 0) {
|
|
3196
|
+
timer = setTimeout(() => {
|
|
3197
|
+
fireSdk2(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
3198
|
+
toast2(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {});
|
|
3199
|
+
}, job.timeoutMs);
|
|
3200
|
+
}
|
|
3201
|
+
const runToken = `${job.id}:${now2().toString(36)}:${Math.random().toString(16).slice(2)}`;
|
|
3202
|
+
activeRuns.set(sessionID, {
|
|
3203
|
+
jobId: job.id,
|
|
3204
|
+
job,
|
|
3205
|
+
startedAt: now2(),
|
|
3206
|
+
timer,
|
|
3207
|
+
runToken,
|
|
3208
|
+
compactionAction: result.compaction === true
|
|
3209
|
+
});
|
|
3210
|
+
if (result.compaction && compactionRuntime.isCompleted(sessionID, job.id)) {
|
|
3211
|
+
await compactionRuntime.finalize(directory, client, sessionID);
|
|
3212
|
+
return;
|
|
3213
|
+
}
|
|
3214
|
+
if (result.dispatch) {
|
|
3215
|
+
result.dispatch.catch((error) => {
|
|
3216
|
+
recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error).catch((recoveryError) => log2(client, "error", "dispatch recovery failed", { error: errorMessage(recoveryError) }));
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3219
|
+
markSessionStatus(sessionID, "busy");
|
|
3220
|
+
await reschedule(busyRetryMs);
|
|
3221
|
+
} catch (error) {
|
|
3222
|
+
clearActiveRun(sessionID);
|
|
3223
|
+
await toast2(client, `Loop job failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3224
|
+
await appendLoopLog2(directory, "error", {
|
|
3225
|
+
sessionID,
|
|
3226
|
+
job: job?.name || job?.id,
|
|
3227
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2500
3228
|
});
|
|
3229
|
+
await reschedule(busyRetryMs);
|
|
2501
3230
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
await reschedule(BUSY_RETRY_MS);
|
|
2505
|
-
} catch (error) {
|
|
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);
|
|
3231
|
+
} finally {
|
|
3232
|
+
runLocks.delete(sessionID);
|
|
2510
3233
|
}
|
|
2511
|
-
} finally {
|
|
2512
|
-
runLocks.delete(sessionID);
|
|
2513
3234
|
}
|
|
3235
|
+
return {
|
|
3236
|
+
dueJobs,
|
|
3237
|
+
clearActiveRun,
|
|
3238
|
+
disposeSession,
|
|
3239
|
+
recoverActiveDispatchFailure,
|
|
3240
|
+
finalizeActiveRun,
|
|
3241
|
+
fireAction,
|
|
3242
|
+
maybeRunDueJobs,
|
|
3243
|
+
sessionIsIdle,
|
|
3244
|
+
updateSessionStatusFromEvent,
|
|
3245
|
+
markSessionStatus,
|
|
3246
|
+
clearSessionStatus,
|
|
3247
|
+
noteLoopCompactionStarted: compactionRuntime.noteStarted,
|
|
3248
|
+
noteLoopCompactionCompleted: compactionRuntime.noteCompleted,
|
|
3249
|
+
getActiveRun: (sessionID) => activeRuns.get(sessionID),
|
|
3250
|
+
isRunLocked: (sessionID) => runLocks.has(sessionID)
|
|
3251
|
+
};
|
|
2514
3252
|
}
|
|
2515
|
-
|
|
2516
|
-
|
|
3253
|
+
|
|
3254
|
+
// src/source/runtime/goal-steering.js
|
|
3255
|
+
var DEFAULT_STEERING_SUPPRESSION_MS = 5 * 60000;
|
|
3256
|
+
var DEFAULT_SEEN_USER_MESSAGE_MS = 10 * 60000;
|
|
3257
|
+
function requireFunction8(value, label) {
|
|
3258
|
+
if (typeof value !== "function")
|
|
3259
|
+
throw new TypeError(`createGoalSteeringRuntime requires ${label}`);
|
|
3260
|
+
return value;
|
|
3261
|
+
}
|
|
3262
|
+
function messageInfo(event) {
|
|
3263
|
+
if (!["message.updated", "message.created"].includes(String(event?.type || "")))
|
|
3264
|
+
return;
|
|
3265
|
+
const props = event?.properties || {};
|
|
3266
|
+
return props.info || props.message || props;
|
|
2517
3267
|
}
|
|
2518
|
-
function
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
3268
|
+
function userMessageFromEvent(event) {
|
|
3269
|
+
const info = messageInfo(event);
|
|
3270
|
+
if (!info || info.role !== "user")
|
|
3271
|
+
return;
|
|
3272
|
+
const props = event?.properties || {};
|
|
3273
|
+
const sessionID = info.sessionID || props.sessionID;
|
|
3274
|
+
if (typeof sessionID !== "string" || !sessionID)
|
|
3275
|
+
return;
|
|
3276
|
+
const messageID = typeof (info.id || props.messageID) === "string" ? info.id || props.messageID : "";
|
|
3277
|
+
return { sessionID, messageID };
|
|
2522
3278
|
}
|
|
2523
|
-
|
|
2524
|
-
const
|
|
2525
|
-
if (!
|
|
2526
|
-
await toast(client, parsed.error, "warning");
|
|
3279
|
+
function assistantMessageFromEvent(event) {
|
|
3280
|
+
const info = messageInfo(event);
|
|
3281
|
+
if (!info || info.role !== "assistant")
|
|
2527
3282
|
return;
|
|
2528
|
-
}
|
|
2529
|
-
const
|
|
2530
|
-
|
|
2531
|
-
parsed.job.model = normalizedModelRef(defaults.model) || executionContext.model;
|
|
2532
|
-
if (defaults.testfixPreset) {
|
|
2533
|
-
const defaultCommand = String(defaults.verifyCommand || "npm test");
|
|
2534
|
-
const parsedAction = String(parsed.job.action || "").trim();
|
|
2535
|
-
const usedDefaultAction = parsedAction === String(defaults.action || "").trim();
|
|
2536
|
-
if (!usedDefaultAction && parsed.job.verifyCommand === defaults.verifyCommand) {
|
|
2537
|
-
parsed.job.verifyCommand = parsedAction;
|
|
2538
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsedAction}`;
|
|
2539
|
-
} else if (usedDefaultAction && parsed.job.verifyCommand !== defaults.verifyCommand) {
|
|
2540
|
-
parsed.job.action = `Run the project tests. Fix failures. Re-run the tests. Test command hint: ${parsed.job.verifyCommand || defaultCommand}`;
|
|
2541
|
-
}
|
|
2542
|
-
}
|
|
2543
|
-
if (parsed.job.watchPaths.length)
|
|
2544
|
-
parsed.job.watchSnapshot = await snapshotPaths(directory, parsed.job.watchPaths);
|
|
2545
|
-
if (!parsed.job.activeRecoveryMs) {
|
|
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));
|
|
2547
|
-
}
|
|
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```");
|
|
3283
|
+
const props = event?.properties || {};
|
|
3284
|
+
const sessionID = info.sessionID || props.sessionID;
|
|
3285
|
+
if (typeof sessionID !== "string" || !sessionID)
|
|
2551
3286
|
return;
|
|
3287
|
+
const parentID = typeof (info.parentID || props.parentID) === "string" ? info.parentID || props.parentID : "";
|
|
3288
|
+
const createdAt = Number(info?.time?.created || 0);
|
|
3289
|
+
return { sessionID, parentID, createdAt: Number.isFinite(createdAt) ? createdAt : 0 };
|
|
3290
|
+
}
|
|
3291
|
+
function activeGoalJobs(state) {
|
|
3292
|
+
return (state?.jobs || []).filter((job) => {
|
|
3293
|
+
if (!isGoalJob(job))
|
|
3294
|
+
return false;
|
|
3295
|
+
if (job.paused || job.enabled === false)
|
|
3296
|
+
return false;
|
|
3297
|
+
return !["completed", "blocked", "cleared"].includes(job.goalStatus);
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
function createGoalSteeringRuntime(options = {}) {
|
|
3301
|
+
const getActiveRun = requireFunction8(options.getActiveRun, "getActiveRun");
|
|
3302
|
+
const clearActiveRun = requireFunction8(options.clearActiveRun, "clearActiveRun");
|
|
3303
|
+
const isLoopOwnedUserMessage = typeof options.isLoopOwnedUserMessage === "function" ? options.isLoopOwnedUserMessage : () => false;
|
|
3304
|
+
const readState2 = typeof options.readState === "function" ? options.readState : readState;
|
|
3305
|
+
const appendLoopLog2 = typeof options.appendLoopLog === "function" ? options.appendLoopLog : appendLoopLog;
|
|
3306
|
+
const fireSdk2 = typeof options.fireSdk === "function" ? options.fireSdk : fireSdk;
|
|
3307
|
+
const now2 = typeof options.now === "function" ? options.now : now;
|
|
3308
|
+
const suppressionMs = Number.isFinite(Number(options.suppressionMs)) && Number(options.suppressionMs) > 0 ? Number(options.suppressionMs) : DEFAULT_STEERING_SUPPRESSION_MS;
|
|
3309
|
+
const seenUserMessageMs = Number.isFinite(Number(options.seenUserMessageMs)) && Number(options.seenUserMessageMs) > 0 ? Number(options.seenUserMessageMs) : DEFAULT_SEEN_USER_MESSAGE_MS;
|
|
3310
|
+
const pendingSteering = new Map;
|
|
3311
|
+
const seenUserMessages = new Map;
|
|
3312
|
+
function pendingForSession(sessionID) {
|
|
3313
|
+
const entry = pendingSteering.get(sessionID);
|
|
3314
|
+
if (!entry)
|
|
3315
|
+
return;
|
|
3316
|
+
if (entry.expiresAt <= now2()) {
|
|
3317
|
+
pendingSteering.delete(sessionID);
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
return entry;
|
|
2552
3321
|
}
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
let replaced = false;
|
|
2556
|
-
if (!parsed.job.multi) {
|
|
2557
|
-
const targetName = parsed.job.name || "default";
|
|
2558
|
-
parsed.job.name = targetName;
|
|
2559
|
-
state.jobs = jobs.filter((existing) => {
|
|
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
|
-
});
|
|
2566
|
-
} else {
|
|
2567
|
-
state.jobs = jobs;
|
|
3322
|
+
function shouldSuppressIdle(sessionID) {
|
|
3323
|
+
return Boolean(pendingForSession(sessionID));
|
|
2568
3324
|
}
|
|
2569
|
-
|
|
2570
|
-
|
|
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) });
|
|
2576
|
-
}
|
|
2577
|
-
async function stopLoop(directory, client, sessionID, args) {
|
|
2578
|
-
const target = String(args || "").trim();
|
|
2579
|
-
if (!target || target.toLowerCase() === "all") {
|
|
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;
|
|
3325
|
+
function seenKey(sessionID, messageID) {
|
|
3326
|
+
return messageID ? `${sessionID}\x00${messageID}` : "";
|
|
2589
3327
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
await writeState(directory, sessionID, state);
|
|
2603
|
-
await scheduleDueWork(directory, client, sessionID);
|
|
2604
|
-
await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
|
|
2605
|
-
}
|
|
2606
|
-
async function statusGoal(directory, client, sessionID) {
|
|
2607
|
-
const state = await readState(directory, sessionID);
|
|
2608
|
-
const goals = (state.jobs || []).filter(isGoalJob);
|
|
2609
|
-
const lines = goals.length ? goals.map((job, index) => {
|
|
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
|
-
`));
|
|
2622
|
-
}
|
|
2623
|
-
async function pauseGoal(directory, client, sessionID, args) {
|
|
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");
|
|
2631
|
-
}
|
|
2632
|
-
async function resumeGoal(directory, client, sessionID, args) {
|
|
2633
|
-
const target = String(args || "").trim() || "goal";
|
|
2634
|
-
const state = await readState(directory, sessionID);
|
|
2635
|
-
let count = 0;
|
|
2636
|
-
state.jobs = (state.jobs || []).map((job, index) => {
|
|
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");
|
|
2644
|
-
if (count) {
|
|
2645
|
-
await scheduleDueWork(directory, client, sessionID);
|
|
2646
|
-
scheduleIdleWork(directory, client, sessionID);
|
|
3328
|
+
function alreadyHandled(sessionID, messageID) {
|
|
3329
|
+
const key = seenKey(sessionID, messageID);
|
|
3330
|
+
if (!key)
|
|
3331
|
+
return false;
|
|
3332
|
+
const expiresAt = seenUserMessages.get(key);
|
|
3333
|
+
if (!expiresAt)
|
|
3334
|
+
return false;
|
|
3335
|
+
if (expiresAt <= now2()) {
|
|
3336
|
+
seenUserMessages.delete(key);
|
|
3337
|
+
return false;
|
|
3338
|
+
}
|
|
3339
|
+
return true;
|
|
2647
3340
|
}
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
}
|
|
2658
|
-
async function completeGoalCommand(directory, client, sessionID, args) {
|
|
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");
|
|
2661
|
-
}
|
|
2662
|
-
async function blockGoalCommand(directory, client, sessionID, args) {
|
|
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");
|
|
2665
|
-
}
|
|
2666
|
-
async function addGoal(directory, client, sessionID, args) {
|
|
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" });
|
|
2683
|
-
}
|
|
2684
|
-
async function statusLoop(directory, client, sessionID) {
|
|
2685
|
-
const state = await readState(directory, sessionID);
|
|
2686
|
-
const jobs = state.jobs || [];
|
|
2687
|
-
const lines = jobs.length ? jobs.map((job, index) => {
|
|
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
|
-
`));
|
|
2696
|
-
}
|
|
2697
|
-
async function logsLoop(directory, client, sessionID) {
|
|
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);
|
|
2705
|
-
}
|
|
2706
|
-
async function helpLoop(client, sessionID) {
|
|
2707
|
-
await say(client, sessionID, [
|
|
2708
|
-
"OpenCode Loop help:",
|
|
2709
|
-
"/loop 0s <prompt> Claude Code style auto-continue",
|
|
2710
|
-
"/loop 5m --ask-never --safe <prompt> interval autonomous prompt loop",
|
|
2711
|
-
"/loop-command 200m /compact OpenCode slash-command loop, waits for idle",
|
|
2712
|
-
"/loop-ask 1h did you run tests and tsc --noEmit? scheduled question/check prompt",
|
|
2713
|
-
"/loop-shell 10m npm test shell loop, waits for idle",
|
|
2714
|
-
"/loop-goal finish the feature and keep tests green experimental persistent goal mode",
|
|
2715
|
-
'/loop-goal --check "npm run build" --check "npm test" --complete-when-checks-pass ship it',
|
|
2716
|
-
"/loop-goal status | pause | resume | clear manage experimental goals",
|
|
2717
|
-
"/loop 200m --command /compact same as command loop",
|
|
2718
|
-
'/loop 0s --verify "npm test" <prompt> verify after each assistant turn',
|
|
2719
|
-
"/loop 0s --prompt-file loop-prompt.md load prompt from a file",
|
|
2720
|
-
"/loop 0s --max-runtime 6h --max-failures 3 <task> stop safely after limits",
|
|
2721
|
-
"/loop-doctor | /loop-init | /loop-export"
|
|
2722
|
-
].join(`
|
|
2723
|
-
`));
|
|
2724
|
-
}
|
|
2725
|
-
async function runNow(directory, client, sessionID, args) {
|
|
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++;
|
|
3341
|
+
function rememberHandled(sessionID, messageID) {
|
|
3342
|
+
const key = seenKey(sessionID, messageID);
|
|
3343
|
+
if (!key)
|
|
3344
|
+
return;
|
|
3345
|
+
const current = now2();
|
|
3346
|
+
seenUserMessages.set(key, current + seenUserMessageMs);
|
|
3347
|
+
for (const [candidate, expiresAt] of seenUserMessages.entries()) {
|
|
3348
|
+
if (expiresAt <= current)
|
|
3349
|
+
seenUserMessages.delete(candidate);
|
|
2734
3350
|
}
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
3351
|
+
}
|
|
3352
|
+
function observeAssistantMessage(event) {
|
|
3353
|
+
const assistant = assistantMessageFromEvent(event);
|
|
3354
|
+
if (!assistant)
|
|
3355
|
+
return false;
|
|
3356
|
+
const entry = pendingForSession(assistant.sessionID);
|
|
3357
|
+
if (!entry)
|
|
3358
|
+
return false;
|
|
3359
|
+
const matchesMessage = entry.messageID && assistant.parentID === entry.messageID;
|
|
3360
|
+
const matchesFallback = !entry.messageID && (!assistant.createdAt || assistant.createdAt >= entry.armedAt);
|
|
3361
|
+
if (!matchesMessage && !matchesFallback)
|
|
3362
|
+
return false;
|
|
3363
|
+
pendingSteering.delete(assistant.sessionID);
|
|
3364
|
+
return true;
|
|
3365
|
+
}
|
|
3366
|
+
async function handleUserMessage(directory, client, user) {
|
|
3367
|
+
const sessionID = typeof user?.sessionID === "string" ? user.sessionID : "";
|
|
3368
|
+
const messageID = typeof user?.messageID === "string" ? user.messageID : "";
|
|
3369
|
+
if (!sessionID)
|
|
3370
|
+
return;
|
|
3371
|
+
if (alreadyHandled(sessionID, messageID))
|
|
3372
|
+
return { handled: false, duplicate: true, sessionID, messageID };
|
|
3373
|
+
rememberHandled(sessionID, messageID);
|
|
3374
|
+
if (isLoopOwnedUserMessage(sessionID, messageID)) {
|
|
3375
|
+
return { handled: false, loopOwned: true, sessionID, messageID };
|
|
3376
|
+
}
|
|
3377
|
+
const state = await readState2(directory, sessionID);
|
|
3378
|
+
const goals = activeGoalJobs(state);
|
|
3379
|
+
if (!goals.length)
|
|
3380
|
+
return { handled: false, sessionID, messageID };
|
|
3381
|
+
const active = getActiveRun(sessionID);
|
|
3382
|
+
const activeGoalIDs = new Set(goals.map((goal) => goal.id));
|
|
3383
|
+
const canPreempt = active && activeGoalIDs.has(active.jobId) && isGoalJob(active.job) && typeof client?.session?.abort === "function";
|
|
3384
|
+
let preempted = false;
|
|
3385
|
+
let abortError = "";
|
|
3386
|
+
if (canPreempt) {
|
|
3387
|
+
pendingSteering.set(sessionID, {
|
|
3388
|
+
messageID,
|
|
3389
|
+
goalID: active.jobId,
|
|
3390
|
+
armedAt: now2(),
|
|
3391
|
+
expiresAt: now2() + suppressionMs
|
|
3392
|
+
});
|
|
3393
|
+
try {
|
|
3394
|
+
await fireSdk2(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
|
|
3395
|
+
clearActiveRun(sessionID);
|
|
3396
|
+
preempted = true;
|
|
3397
|
+
} catch (error) {
|
|
3398
|
+
pendingSteering.delete(sessionID);
|
|
3399
|
+
abortError = error instanceof Error ? error.message : String(error);
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
await appendLoopLog2(directory, "goal-user-steering", {
|
|
3403
|
+
sessionID,
|
|
3404
|
+
messageID,
|
|
3405
|
+
goals: goals.length,
|
|
3406
|
+
preempted,
|
|
3407
|
+
...abortError ? { abortError } : {}
|
|
3408
|
+
});
|
|
3409
|
+
return { handled: true, preempted, sessionID, messageID };
|
|
3410
|
+
}
|
|
3411
|
+
async function handleEvent(directory, client, event) {
|
|
3412
|
+
observeAssistantMessage(event);
|
|
3413
|
+
const user = userMessageFromEvent(event);
|
|
3414
|
+
if (!user)
|
|
3415
|
+
return;
|
|
3416
|
+
return await handleUserMessage(directory, client, user);
|
|
3417
|
+
}
|
|
3418
|
+
function clearSession(sessionID) {
|
|
3419
|
+
pendingSteering.delete(sessionID);
|
|
3420
|
+
const prefix = `${sessionID}\x00`;
|
|
3421
|
+
for (const key of seenUserMessages.keys())
|
|
3422
|
+
if (key.startsWith(prefix))
|
|
3423
|
+
seenUserMessages.delete(key);
|
|
3424
|
+
}
|
|
3425
|
+
return {
|
|
3426
|
+
handleUserMessage,
|
|
3427
|
+
handleEvent,
|
|
3428
|
+
observeAssistantMessage,
|
|
3429
|
+
shouldSuppressIdle,
|
|
3430
|
+
hasPendingSteering: shouldSuppressIdle,
|
|
3431
|
+
pendingForSession,
|
|
3432
|
+
clearSession
|
|
3433
|
+
};
|
|
2753
3434
|
}
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
3435
|
+
|
|
3436
|
+
// src/source/legacy-v1.js
|
|
3437
|
+
var DEFAULT_ACTIVE_GUARD_MS2 = 45000;
|
|
3438
|
+
var workspaceRuntime = createJobWorkspaceRuntime({ toast });
|
|
3439
|
+
var { snapshotPaths } = workspaceRuntime;
|
|
3440
|
+
var goalPolicy = createGoalExecutionPolicy({ runShellCommand, dangerousShell, toast, appendLoopLog, now });
|
|
3441
|
+
var schedulerRuntime;
|
|
3442
|
+
var goalSteeringRuntime;
|
|
3443
|
+
var schedulerBridge = {
|
|
3444
|
+
rememberSession: (...args) => schedulerRuntime.rememberSession(...args),
|
|
3445
|
+
scheduleDueWork: (...args) => schedulerRuntime.scheduleDueWork(...args)
|
|
3446
|
+
};
|
|
3447
|
+
var executorRuntime = createLoopExecutor({
|
|
3448
|
+
workspace: workspaceRuntime,
|
|
3449
|
+
goalPolicy,
|
|
3450
|
+
scheduler: schedulerBridge,
|
|
3451
|
+
toast,
|
|
3452
|
+
log,
|
|
3453
|
+
appendLoopLog,
|
|
3454
|
+
errorMessage: sdkErrorMessage,
|
|
3455
|
+
now
|
|
3456
|
+
});
|
|
3457
|
+
var {
|
|
3458
|
+
clearActiveRun,
|
|
3459
|
+
finalizeActiveRun,
|
|
3460
|
+
maybeRunDueJobs: runDueJobs,
|
|
3461
|
+
sessionIsIdle,
|
|
3462
|
+
updateSessionStatusFromEvent,
|
|
3463
|
+
noteLoopCompactionStarted,
|
|
3464
|
+
noteLoopCompactionCompleted
|
|
3465
|
+
} = executorRuntime;
|
|
3466
|
+
async function maybeRunDueJobs(directory, client, sessionID, runOptions) {
|
|
3467
|
+
if (goalSteeringRuntime?.shouldSuppressIdle(sessionID))
|
|
2759
3468
|
return;
|
|
3469
|
+
return await runDueJobs(directory, client, sessionID, runOptions);
|
|
3470
|
+
}
|
|
3471
|
+
schedulerRuntime = createSchedulerRuntime({
|
|
3472
|
+
sessionIsIdle,
|
|
3473
|
+
finalizeActiveRun,
|
|
3474
|
+
maybeRunDueJobs,
|
|
3475
|
+
appendLoopLog,
|
|
3476
|
+
toast,
|
|
3477
|
+
errorMessage: sdkErrorMessage
|
|
3478
|
+
});
|
|
3479
|
+
var { rememberSession, scheduleIdleWork, scheduleDueWork, stopWatchdog, cancelDueWork } = schedulerRuntime;
|
|
3480
|
+
goalSteeringRuntime = createGoalSteeringRuntime({
|
|
3481
|
+
getActiveRun: executorRuntime.getActiveRun,
|
|
3482
|
+
clearActiveRun,
|
|
3483
|
+
isLoopOwnedUserMessage: loopOwnedUserMessageGuardActive,
|
|
3484
|
+
appendLoopLog,
|
|
3485
|
+
now
|
|
3486
|
+
});
|
|
3487
|
+
var { addLoop } = createLoopRegistration({
|
|
3488
|
+
snapshotPaths,
|
|
3489
|
+
scheduleDueWork,
|
|
3490
|
+
scheduleIdleWork,
|
|
3491
|
+
toast,
|
|
3492
|
+
say,
|
|
3493
|
+
defaultActiveGuardMs: DEFAULT_ACTIVE_GUARD_MS2
|
|
3494
|
+
});
|
|
3495
|
+
var {
|
|
3496
|
+
addGoal,
|
|
3497
|
+
statusGoal,
|
|
3498
|
+
pauseGoal,
|
|
3499
|
+
resumeGoal,
|
|
3500
|
+
clearGoal,
|
|
3501
|
+
completeGoalCommand,
|
|
3502
|
+
blockGoalCommand
|
|
3503
|
+
} = createGoalCommandHandlers({
|
|
3504
|
+
addLoop,
|
|
3505
|
+
scheduleDueWork,
|
|
3506
|
+
scheduleIdleWork,
|
|
3507
|
+
toast,
|
|
3508
|
+
say
|
|
3509
|
+
});
|
|
3510
|
+
var {
|
|
3511
|
+
stopLoop,
|
|
3512
|
+
updateJobState,
|
|
3513
|
+
statusLoop,
|
|
3514
|
+
logsLoop,
|
|
3515
|
+
helpLoop,
|
|
3516
|
+
runNow,
|
|
3517
|
+
doctorLoop,
|
|
3518
|
+
initLoop,
|
|
3519
|
+
exportLoop
|
|
3520
|
+
} = createLoopCommandHandlers({
|
|
3521
|
+
clearActiveRun,
|
|
3522
|
+
cancelDueWork,
|
|
3523
|
+
stopWatchdog,
|
|
3524
|
+
scheduleDueWork,
|
|
3525
|
+
maybeRunDueJobs,
|
|
3526
|
+
toast,
|
|
3527
|
+
say,
|
|
3528
|
+
now
|
|
3529
|
+
});
|
|
3530
|
+
var handleCommand = createCommandRouter({
|
|
3531
|
+
rememberSession,
|
|
3532
|
+
handlers: {
|
|
3533
|
+
addGoal,
|
|
3534
|
+
statusGoal,
|
|
3535
|
+
pauseGoal,
|
|
3536
|
+
resumeGoal,
|
|
3537
|
+
clearGoal,
|
|
3538
|
+
completeGoalCommand,
|
|
3539
|
+
blockGoalCommand,
|
|
3540
|
+
addLoop,
|
|
3541
|
+
stopLoop,
|
|
3542
|
+
statusLoop,
|
|
3543
|
+
logsLoop,
|
|
3544
|
+
helpLoop,
|
|
3545
|
+
runNow,
|
|
3546
|
+
updateJobState,
|
|
3547
|
+
doctorLoop,
|
|
3548
|
+
initLoop,
|
|
3549
|
+
exportLoop
|
|
2760
3550
|
}
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
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);
|
|
2778
|
-
if (source === "event") {
|
|
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());
|
|
2785
|
-
} else {
|
|
2786
|
-
markHandled(sessionID, name, args);
|
|
3551
|
+
});
|
|
3552
|
+
function disposeRuntime(directory, client) {
|
|
3553
|
+
const sessions = schedulerRuntime.sessionIDsForHost(directory, client);
|
|
3554
|
+
for (const sessionID of sessions) {
|
|
3555
|
+
executorRuntime.disposeSession(sessionID);
|
|
3556
|
+
schedulerRuntime.clearSessionScheduling(sessionID);
|
|
3557
|
+
goalSteeringRuntime.clearSession(sessionID);
|
|
3558
|
+
clearLoopOwnedUserMessageGuard(sessionID);
|
|
3559
|
+
clearSessionActivity(sessionID);
|
|
3560
|
+
clearCommandLifecycle(sessionID);
|
|
2787
3561
|
}
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
3562
|
+
}
|
|
3563
|
+
function steeringToolRejection(sessionID) {
|
|
3564
|
+
if (!goalSteeringRuntime.hasPendingSteering(sessionID))
|
|
3565
|
+
return;
|
|
3566
|
+
return {
|
|
3567
|
+
title: "Goal steering pending",
|
|
3568
|
+
output: "Goal lifecycle update deferred because queued user steering is pending. The experimental Goal remains active and unchanged."
|
|
2794
3569
|
};
|
|
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;
|
|
2840
3570
|
}
|
|
2841
3571
|
function goalTools(defaultDirectory) {
|
|
2842
3572
|
return {
|
|
@@ -2847,6 +3577,9 @@ function goalTools(defaultDirectory) {
|
|
|
2847
3577
|
evidence: tool.schema.string().describe("Concrete evidence that the goal is complete, such as commands run, passing checks, files changed, and important results.")
|
|
2848
3578
|
},
|
|
2849
3579
|
execute: async (args, context) => {
|
|
3580
|
+
const steering = steeringToolRejection(context.sessionID);
|
|
3581
|
+
if (steering)
|
|
3582
|
+
return steering;
|
|
2850
3583
|
const result = await setGoalComplete(context.directory || defaultDirectory, context.sessionID, args);
|
|
2851
3584
|
return { title: result.ok ? "Goal completed" : result.rejected ? "Goal completion rejected" : "Goal not found", output: result.message };
|
|
2852
3585
|
}
|
|
@@ -2858,6 +3591,9 @@ function goalTools(defaultDirectory) {
|
|
|
2858
3591
|
needed: tool.schema.string().describe("What user input, credential, decision, or manual action is needed to continue.")
|
|
2859
3592
|
},
|
|
2860
3593
|
execute: async (args, context) => {
|
|
3594
|
+
const steering = steeringToolRejection(context.sessionID);
|
|
3595
|
+
if (steering)
|
|
3596
|
+
return steering;
|
|
2861
3597
|
const result = await setGoalBlocked(context.directory || defaultDirectory, context.sessionID, args);
|
|
2862
3598
|
return { title: result.ok ? "Goal blocked" : "Goal not found", output: result.message };
|
|
2863
3599
|
}
|
|
@@ -2869,6 +3605,9 @@ function goalTools(defaultDirectory) {
|
|
|
2869
3605
|
next: tool.schema.string().describe("The next step toward completing the goal.")
|
|
2870
3606
|
},
|
|
2871
3607
|
execute: async (args, context) => {
|
|
3608
|
+
const steering = steeringToolRejection(context.sessionID);
|
|
3609
|
+
if (steering)
|
|
3610
|
+
return steering;
|
|
2872
3611
|
const result = await setGoalProgress(context.directory || defaultDirectory, context.sessionID, args);
|
|
2873
3612
|
return { title: result.ok ? "Goal progress" : "Goal not found", output: result.message };
|
|
2874
3613
|
}
|
|
@@ -2889,6 +3628,14 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
2889
3628
|
"command.execute.before": async (input, output) => {
|
|
2890
3629
|
await handleCommand(directory, client, input, undefined, undefined, output);
|
|
2891
3630
|
},
|
|
3631
|
+
"chat.message": async (input) => {
|
|
3632
|
+
const steering = await goalSteeringRuntime.handleUserMessage(directory, client, {
|
|
3633
|
+
sessionID: input?.sessionID,
|
|
3634
|
+
messageID: input?.messageID
|
|
3635
|
+
});
|
|
3636
|
+
if (steering?.handled && steering.sessionID)
|
|
3637
|
+
rememberSession(directory, client, steering.sessionID);
|
|
3638
|
+
},
|
|
2892
3639
|
"tool.execute.before": async (input) => {
|
|
2893
3640
|
markToolCallActive(input);
|
|
2894
3641
|
},
|
|
@@ -2909,21 +3656,420 @@ var OpenCodeLoopPlugin = async ({ client, directory }) => {
|
|
|
2909
3656
|
const props = event.properties || {};
|
|
2910
3657
|
await handleCommand(directory, client, props, props.name, props.arguments, undefined, "event");
|
|
2911
3658
|
}
|
|
2912
|
-
const
|
|
2913
|
-
if (
|
|
2914
|
-
rememberSession(directory, client,
|
|
2915
|
-
await pauseGoalsForUserInterrupt(directory, client, interruptedSessionID);
|
|
2916
|
-
}
|
|
3659
|
+
const steering = await goalSteeringRuntime.handleEvent(directory, client, event);
|
|
3660
|
+
if (steering?.handled && steering.sessionID)
|
|
3661
|
+
rememberSession(directory, client, steering.sessionID);
|
|
2917
3662
|
const statusUpdate = updateSessionStatusFromEvent(event);
|
|
2918
3663
|
if (statusUpdate?.sessionID)
|
|
2919
3664
|
rememberSession(directory, client, statusUpdate.sessionID);
|
|
2920
|
-
if (statusUpdate?.idle)
|
|
3665
|
+
if (statusUpdate?.idle && !goalSteeringRuntime.shouldSuppressIdle(statusUpdate.sessionID)) {
|
|
2921
3666
|
scheduleIdleWork(directory, client, statusUpdate.sessionID);
|
|
3667
|
+
}
|
|
2922
3668
|
}
|
|
2923
3669
|
};
|
|
2924
3670
|
};
|
|
2925
3671
|
var legacy_v1_default = OpenCodeLoopPlugin;
|
|
2926
3672
|
|
|
3673
|
+
// src/source/runtime/session-registry.js
|
|
3674
|
+
var DEFAULT_SESSION_STALE_MS = 12 * 60 * 60 * 1000;
|
|
3675
|
+
function sessionKey(sessionID) {
|
|
3676
|
+
return String(sessionID || "").trim();
|
|
3677
|
+
}
|
|
3678
|
+
function createSessionRegistry({ now: now2 = Date.now, staleAfterMs = DEFAULT_SESSION_STALE_MS } = {}) {
|
|
3679
|
+
if (typeof now2 !== "function")
|
|
3680
|
+
throw new TypeError("session registry requires a clock function");
|
|
3681
|
+
if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0)
|
|
3682
|
+
throw new TypeError("session registry requires a non-negative staleAfterMs");
|
|
3683
|
+
const sessions = new Map;
|
|
3684
|
+
function observeExternal(sessionID, runtime) {
|
|
3685
|
+
const key = sessionKey(sessionID);
|
|
3686
|
+
if (!key)
|
|
3687
|
+
throw new TypeError("session registry requires a session ID");
|
|
3688
|
+
const seenAt = Number(now2());
|
|
3689
|
+
if (!Number.isFinite(seenAt))
|
|
3690
|
+
throw new TypeError("session registry clock must return a finite number");
|
|
3691
|
+
const entry = Object.freeze({ sessionID: key, runtime, seenAt });
|
|
3692
|
+
sessions.set(key, entry);
|
|
3693
|
+
return entry;
|
|
3694
|
+
}
|
|
3695
|
+
function peek(sessionID) {
|
|
3696
|
+
return sessions.get(sessionKey(sessionID));
|
|
3697
|
+
}
|
|
3698
|
+
function remove(sessionID, expectedRuntime) {
|
|
3699
|
+
const key = sessionKey(sessionID);
|
|
3700
|
+
const current = sessions.get(key);
|
|
3701
|
+
if (!current)
|
|
3702
|
+
return false;
|
|
3703
|
+
if (arguments.length > 1 && current.runtime !== expectedRuntime)
|
|
3704
|
+
return false;
|
|
3705
|
+
return sessions.delete(key);
|
|
3706
|
+
}
|
|
3707
|
+
function pruneStale(at = now2()) {
|
|
3708
|
+
const timestamp = Number(at);
|
|
3709
|
+
if (!Number.isFinite(timestamp))
|
|
3710
|
+
throw new TypeError("session registry prune time must be finite");
|
|
3711
|
+
const removed = [];
|
|
3712
|
+
for (const [key, entry] of sessions) {
|
|
3713
|
+
if (timestamp - entry.seenAt < staleAfterMs)
|
|
3714
|
+
continue;
|
|
3715
|
+
sessions.delete(key);
|
|
3716
|
+
removed.push(key);
|
|
3717
|
+
}
|
|
3718
|
+
return removed;
|
|
3719
|
+
}
|
|
3720
|
+
function entries() {
|
|
3721
|
+
return [...sessions.values()];
|
|
3722
|
+
}
|
|
3723
|
+
return Object.freeze({
|
|
3724
|
+
observeExternal,
|
|
3725
|
+
peek,
|
|
3726
|
+
remove,
|
|
3727
|
+
pruneStale,
|
|
3728
|
+
entries
|
|
3729
|
+
});
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
// src/source/runtime/scope.js
|
|
3733
|
+
function createRuntimeScope() {
|
|
3734
|
+
const controller = new AbortController;
|
|
3735
|
+
const cleanups = new Set;
|
|
3736
|
+
let disposed = false;
|
|
3737
|
+
function track(cleanup) {
|
|
3738
|
+
if (typeof cleanup !== "function")
|
|
3739
|
+
throw new TypeError("runtime scope cleanup must be a function");
|
|
3740
|
+
if (disposed) {
|
|
3741
|
+
cleanup();
|
|
3742
|
+
return () => false;
|
|
3743
|
+
}
|
|
3744
|
+
const entry = { cleanup };
|
|
3745
|
+
cleanups.add(entry);
|
|
3746
|
+
let tracked = true;
|
|
3747
|
+
return () => {
|
|
3748
|
+
if (!tracked)
|
|
3749
|
+
return false;
|
|
3750
|
+
tracked = false;
|
|
3751
|
+
return cleanups.delete(entry);
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
function guard(callback) {
|
|
3755
|
+
if (typeof callback !== "function")
|
|
3756
|
+
throw new TypeError("runtime scope guard requires a function");
|
|
3757
|
+
return function(...args) {
|
|
3758
|
+
if (disposed)
|
|
3759
|
+
return;
|
|
3760
|
+
return callback.apply(this, args);
|
|
3761
|
+
};
|
|
3762
|
+
}
|
|
3763
|
+
function dispose(reason) {
|
|
3764
|
+
if (disposed)
|
|
3765
|
+
return false;
|
|
3766
|
+
disposed = true;
|
|
3767
|
+
controller.abort(reason);
|
|
3768
|
+
const errors = [];
|
|
3769
|
+
for (const entry of [...cleanups].reverse()) {
|
|
3770
|
+
cleanups.delete(entry);
|
|
3771
|
+
try {
|
|
3772
|
+
entry.cleanup();
|
|
3773
|
+
} catch (error) {
|
|
3774
|
+
errors.push(error);
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
if (errors.length)
|
|
3778
|
+
throw new AggregateError(errors, "runtime scope cleanup failed");
|
|
3779
|
+
return true;
|
|
3780
|
+
}
|
|
3781
|
+
return Object.freeze({
|
|
3782
|
+
signal: controller.signal,
|
|
3783
|
+
isActive: () => !disposed,
|
|
3784
|
+
track,
|
|
3785
|
+
guard,
|
|
3786
|
+
dispose
|
|
3787
|
+
});
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
// src/source/runtime/timers.js
|
|
3791
|
+
function createOwnedTimer(scope, callback, delay2, repeat, api, ref) {
|
|
3792
|
+
if (!scope?.isActive?.())
|
|
3793
|
+
return;
|
|
3794
|
+
let active = true;
|
|
3795
|
+
let release;
|
|
3796
|
+
let handle;
|
|
3797
|
+
const invoke = scope.guard((...args) => {
|
|
3798
|
+
if (!active)
|
|
3799
|
+
return;
|
|
3800
|
+
if (!repeat) {
|
|
3801
|
+
active = false;
|
|
3802
|
+
release?.();
|
|
3803
|
+
}
|
|
3804
|
+
callback(...args);
|
|
3805
|
+
});
|
|
3806
|
+
handle = repeat ? api.setInterval(invoke, delay2) : api.setTimeout(invoke, delay2);
|
|
3807
|
+
release = scope.track(() => {
|
|
3808
|
+
if (repeat)
|
|
3809
|
+
api.clearInterval(handle);
|
|
3810
|
+
else
|
|
3811
|
+
api.clearTimeout(handle);
|
|
3812
|
+
});
|
|
3813
|
+
if (!ref)
|
|
3814
|
+
handle?.unref?.();
|
|
3815
|
+
return Object.freeze({
|
|
3816
|
+
handle,
|
|
3817
|
+
cancel() {
|
|
3818
|
+
if (!active)
|
|
3819
|
+
return false;
|
|
3820
|
+
active = false;
|
|
3821
|
+
if (repeat)
|
|
3822
|
+
api.clearInterval(handle);
|
|
3823
|
+
else
|
|
3824
|
+
api.clearTimeout(handle);
|
|
3825
|
+
release();
|
|
3826
|
+
return true;
|
|
3827
|
+
}
|
|
3828
|
+
});
|
|
3829
|
+
}
|
|
3830
|
+
function createRuntimeTimers(scope, api = globalThis) {
|
|
3831
|
+
return Object.freeze({
|
|
3832
|
+
timeout(callback, delay2, options = {}) {
|
|
3833
|
+
return createOwnedTimer(scope, callback, delay2, false, api, options.ref !== false);
|
|
3834
|
+
},
|
|
3835
|
+
interval(callback, delay2, options = {}) {
|
|
3836
|
+
return createOwnedTimer(scope, callback, delay2, true, api, options.ref !== false);
|
|
3837
|
+
}
|
|
3838
|
+
});
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
// src/source/runtime/session-manager.js
|
|
3842
|
+
function defaultRuntimeFactory({ sessionID, timerAPI }) {
|
|
3843
|
+
const scope = createRuntimeScope();
|
|
3844
|
+
return Object.freeze({
|
|
3845
|
+
sessionID,
|
|
3846
|
+
scope,
|
|
3847
|
+
timers: createRuntimeTimers(scope, timerAPI),
|
|
3848
|
+
dispose: (reason) => scope.dispose(reason)
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
function validateRuntime(runtime, sessionID) {
|
|
3852
|
+
if (!runtime || runtime.sessionID !== sessionID)
|
|
3853
|
+
throw new TypeError("session runtime factory must preserve the session ID");
|
|
3854
|
+
if (typeof runtime?.scope?.isActive !== "function")
|
|
3855
|
+
throw new TypeError("session runtime factory must provide an active scope");
|
|
3856
|
+
if (typeof runtime.dispose !== "function")
|
|
3857
|
+
throw new TypeError("session runtime factory must provide dispose()");
|
|
3858
|
+
return runtime;
|
|
3859
|
+
}
|
|
3860
|
+
function createSessionRuntimeManager({
|
|
3861
|
+
now: now2 = Date.now,
|
|
3862
|
+
staleAfterMs = DEFAULT_SESSION_STALE_MS,
|
|
3863
|
+
timerAPI = globalThis,
|
|
3864
|
+
runtimeFactory = defaultRuntimeFactory
|
|
3865
|
+
} = {}) {
|
|
3866
|
+
if (typeof runtimeFactory !== "function")
|
|
3867
|
+
throw new TypeError("session runtime manager requires a runtime factory");
|
|
3868
|
+
const registry = createSessionRegistry({ now: now2, staleAfterMs });
|
|
3869
|
+
let disposed = false;
|
|
3870
|
+
function observeExternal(sessionID) {
|
|
3871
|
+
if (disposed)
|
|
3872
|
+
throw new Error("session runtime manager is disposed");
|
|
3873
|
+
const key = String(sessionID || "").trim();
|
|
3874
|
+
if (!key)
|
|
3875
|
+
throw new TypeError("session runtime manager requires a session ID");
|
|
3876
|
+
const current = registry.peek(key);
|
|
3877
|
+
const runtime = current?.runtime?.scope?.isActive?.() ? current.runtime : validateRuntime(runtimeFactory({ sessionID: key, timerAPI }), key);
|
|
3878
|
+
registry.observeExternal(key, runtime);
|
|
3879
|
+
return runtime;
|
|
3880
|
+
}
|
|
3881
|
+
function peek(sessionID) {
|
|
3882
|
+
return registry.peek(sessionID)?.runtime;
|
|
3883
|
+
}
|
|
3884
|
+
function entries() {
|
|
3885
|
+
return registry.entries();
|
|
3886
|
+
}
|
|
3887
|
+
function remove(sessionID, { expectedRuntime, reason } = {}) {
|
|
3888
|
+
const current = registry.peek(sessionID);
|
|
3889
|
+
if (!current)
|
|
3890
|
+
return false;
|
|
3891
|
+
if (expectedRuntime !== undefined && current.runtime !== expectedRuntime)
|
|
3892
|
+
return false;
|
|
3893
|
+
if (!registry.remove(sessionID, current.runtime))
|
|
3894
|
+
return false;
|
|
3895
|
+
current.runtime.dispose(reason);
|
|
3896
|
+
return true;
|
|
3897
|
+
}
|
|
3898
|
+
function pruneStale(at = now2()) {
|
|
3899
|
+
const before = new Map(registry.entries().map((entry) => [entry.sessionID, entry.runtime]));
|
|
3900
|
+
const removed = registry.pruneStale(at);
|
|
3901
|
+
const errors = [];
|
|
3902
|
+
for (const sessionID of removed) {
|
|
3903
|
+
try {
|
|
3904
|
+
before.get(sessionID)?.dispose("stale-session");
|
|
3905
|
+
} catch (error) {
|
|
3906
|
+
errors.push(error);
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
if (errors.length)
|
|
3910
|
+
throw new AggregateError(errors, "stale session cleanup failed");
|
|
3911
|
+
return removed;
|
|
3912
|
+
}
|
|
3913
|
+
function dispose(reason) {
|
|
3914
|
+
if (disposed)
|
|
3915
|
+
return false;
|
|
3916
|
+
disposed = true;
|
|
3917
|
+
const errors = [];
|
|
3918
|
+
for (const entry of registry.entries()) {
|
|
3919
|
+
registry.remove(entry.sessionID, entry.runtime);
|
|
3920
|
+
try {
|
|
3921
|
+
entry.runtime.dispose(reason);
|
|
3922
|
+
} catch (error) {
|
|
3923
|
+
errors.push(error);
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
if (errors.length)
|
|
3927
|
+
throw new AggregateError(errors, "session runtime manager cleanup failed");
|
|
3928
|
+
return true;
|
|
3929
|
+
}
|
|
3930
|
+
return Object.freeze({
|
|
3931
|
+
observeExternal,
|
|
3932
|
+
peek,
|
|
3933
|
+
entries,
|
|
3934
|
+
remove,
|
|
3935
|
+
pruneStale,
|
|
3936
|
+
dispose
|
|
3937
|
+
});
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// src/source/runtime/events.js
|
|
3941
|
+
function record(value) {
|
|
3942
|
+
return value && typeof value === "object" ? value : undefined;
|
|
3943
|
+
}
|
|
3944
|
+
function text(value) {
|
|
3945
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
3946
|
+
}
|
|
3947
|
+
function envelope(input) {
|
|
3948
|
+
const outer = record(input);
|
|
3949
|
+
if (!outer)
|
|
3950
|
+
return;
|
|
3951
|
+
const payload = record(outer.payload);
|
|
3952
|
+
if (payload && text(payload.type)) {
|
|
3953
|
+
return { directory: text(outer.directory), event: payload };
|
|
3954
|
+
}
|
|
3955
|
+
const wrappedEvent = record(outer.event);
|
|
3956
|
+
if (wrappedEvent && text(wrappedEvent.type)) {
|
|
3957
|
+
return { directory: text(outer.directory), event: wrappedEvent };
|
|
3958
|
+
}
|
|
3959
|
+
if (!text(outer.type))
|
|
3960
|
+
return;
|
|
3961
|
+
return { directory: undefined, event: outer };
|
|
3962
|
+
}
|
|
3963
|
+
function freezeEvent(value) {
|
|
3964
|
+
return Object.freeze(value);
|
|
3965
|
+
}
|
|
3966
|
+
function normalizeOpenCodeEvent(input) {
|
|
3967
|
+
const parsed = envelope(input);
|
|
3968
|
+
if (!parsed)
|
|
3969
|
+
return;
|
|
3970
|
+
const { directory, event } = parsed;
|
|
3971
|
+
const properties = record(event.properties) || {};
|
|
3972
|
+
if (event.type === "session.status") {
|
|
3973
|
+
const sessionID = text(properties.sessionID);
|
|
3974
|
+
const status = text(record(properties.status)?.type);
|
|
3975
|
+
if (!sessionID || !status)
|
|
3976
|
+
return;
|
|
3977
|
+
return freezeEvent({ kind: "session", action: "status", sessionID, directory, status });
|
|
3978
|
+
}
|
|
3979
|
+
if (event.type === "session.idle" || event.type === "session.compacted") {
|
|
3980
|
+
const sessionID = text(properties.sessionID);
|
|
3981
|
+
if (!sessionID)
|
|
3982
|
+
return;
|
|
3983
|
+
return freezeEvent({
|
|
3984
|
+
kind: "session",
|
|
3985
|
+
action: event.type === "session.idle" ? "idle" : "compacted",
|
|
3986
|
+
sessionID,
|
|
3987
|
+
directory
|
|
3988
|
+
});
|
|
3989
|
+
}
|
|
3990
|
+
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
|
3991
|
+
const info = record(properties.info);
|
|
3992
|
+
const sessionID = text(info?.id);
|
|
3993
|
+
if (!sessionID)
|
|
3994
|
+
return;
|
|
3995
|
+
return freezeEvent({
|
|
3996
|
+
kind: "session",
|
|
3997
|
+
action: event.type.slice("session.".length),
|
|
3998
|
+
sessionID,
|
|
3999
|
+
directory: directory || text(info?.directory),
|
|
4000
|
+
parentID: text(info?.parentID)
|
|
4001
|
+
});
|
|
4002
|
+
}
|
|
4003
|
+
if (event.type === "session.error") {
|
|
4004
|
+
const sessionID = text(properties.sessionID);
|
|
4005
|
+
if (!sessionID)
|
|
4006
|
+
return;
|
|
4007
|
+
return freezeEvent({ kind: "session", action: "error", sessionID, directory });
|
|
4008
|
+
}
|
|
4009
|
+
if (event.type === "message.updated") {
|
|
4010
|
+
const info = record(properties.info);
|
|
4011
|
+
const sessionID = text(info?.sessionID);
|
|
4012
|
+
const messageID = text(info?.id);
|
|
4013
|
+
const role = text(info?.role);
|
|
4014
|
+
if (!sessionID || !messageID || !role)
|
|
4015
|
+
return;
|
|
4016
|
+
const time = record(info?.time);
|
|
4017
|
+
return freezeEvent({
|
|
4018
|
+
kind: "message",
|
|
4019
|
+
action: "updated",
|
|
4020
|
+
sessionID,
|
|
4021
|
+
directory,
|
|
4022
|
+
messageID,
|
|
4023
|
+
role,
|
|
4024
|
+
completedAt: Number.isFinite(time?.completed) ? time.completed : undefined,
|
|
4025
|
+
finish: text(info?.finish)
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
if (event.type === "command.executed") {
|
|
4029
|
+
const sessionID = text(properties.sessionID);
|
|
4030
|
+
const name = text(properties.name);
|
|
4031
|
+
if (!sessionID || !name)
|
|
4032
|
+
return;
|
|
4033
|
+
return freezeEvent({
|
|
4034
|
+
kind: "command",
|
|
4035
|
+
action: "executed",
|
|
4036
|
+
sessionID,
|
|
4037
|
+
directory,
|
|
4038
|
+
name,
|
|
4039
|
+
arguments: typeof properties.arguments === "string" ? properties.arguments : "",
|
|
4040
|
+
messageID: text(properties.messageID)
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
if (event.type === "server.instance.disposed") {
|
|
4044
|
+
const disposedDirectory = directory || text(properties.directory);
|
|
4045
|
+
if (!disposedDirectory)
|
|
4046
|
+
return;
|
|
4047
|
+
return freezeEvent({ kind: "server", action: "disposed", directory: disposedDirectory });
|
|
4048
|
+
}
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
// src/source/runtime/observer.js
|
|
4053
|
+
function observeRuntimeEvent(manager, input) {
|
|
4054
|
+
const event = normalizeOpenCodeEvent(input);
|
|
4055
|
+
if (!event || !manager)
|
|
4056
|
+
return event;
|
|
4057
|
+
try {
|
|
4058
|
+
if (event.kind === "server" && event.action === "disposed") {
|
|
4059
|
+
manager.dispose?.("server-disposed");
|
|
4060
|
+
return event;
|
|
4061
|
+
}
|
|
4062
|
+
if (!event.sessionID)
|
|
4063
|
+
return event;
|
|
4064
|
+
if (event.kind === "session" && event.action === "deleted") {
|
|
4065
|
+
manager.remove?.(event.sessionID, { reason: "session-deleted" });
|
|
4066
|
+
return event;
|
|
4067
|
+
}
|
|
4068
|
+
manager.observeExternal?.(event.sessionID);
|
|
4069
|
+
} catch {}
|
|
4070
|
+
return event;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
2927
4073
|
// src/source/v1.js
|
|
2928
4074
|
var clientGenerations = new WeakMap;
|
|
2929
4075
|
function reserveClientGeneration(client, directory) {
|
|
@@ -2967,13 +4113,24 @@ var OpenCodeLoopPlugin2 = async (input = {}) => {
|
|
|
2967
4113
|
const legacyDispose = typeof hooks?.dispose === "function" ? hooks.dispose.bind(hooks) : undefined;
|
|
2968
4114
|
if (!legacyDispose)
|
|
2969
4115
|
return hooks;
|
|
4116
|
+
const runtimeManager = createSessionRuntimeManager();
|
|
4117
|
+
const legacyEvent = typeof hooks?.event === "function" ? hooks.event.bind(hooks) : undefined;
|
|
2970
4118
|
let disposed = false;
|
|
2971
4119
|
return {
|
|
2972
4120
|
...hooks,
|
|
4121
|
+
...legacyEvent ? {
|
|
4122
|
+
event: async (payload) => {
|
|
4123
|
+
observeRuntimeEvent(runtimeManager, payload);
|
|
4124
|
+
return await legacyEvent(payload);
|
|
4125
|
+
}
|
|
4126
|
+
} : {},
|
|
2973
4127
|
dispose: async () => {
|
|
2974
4128
|
if (disposed)
|
|
2975
4129
|
return;
|
|
2976
4130
|
disposed = true;
|
|
4131
|
+
try {
|
|
4132
|
+
runtimeManager.dispose("plugin-disposed");
|
|
4133
|
+
} catch {}
|
|
2977
4134
|
await legacyDispose();
|
|
2978
4135
|
scheduleLegacyCleanup(legacyDispose, isCurrentGeneration);
|
|
2979
4136
|
}
|