@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/scripts/command-lifecycle-test.mjs +47 -0
  5. package/scripts/command-router-test.mjs +175 -0
  6. package/scripts/compaction-runtime-test.mjs +128 -0
  7. package/scripts/comprehensive-test.mjs +11 -3
  8. package/scripts/executor-runtime-test.mjs +310 -0
  9. package/scripts/fixtures/opencode2-real-adapter-probe.js +70 -0
  10. package/scripts/goal-command-handlers-test.mjs +236 -0
  11. package/scripts/goal-policy-test.mjs +143 -0
  12. package/scripts/goal-runtime-test.mjs +203 -0
  13. package/scripts/goal-steering-test.mjs +153 -0
  14. package/scripts/host-goal-steering-canary.mjs +443 -0
  15. package/scripts/host-loop-canary.mjs +54 -12
  16. package/scripts/job-workspace-test.mjs +164 -0
  17. package/scripts/loop-command-handlers-test.mjs +279 -0
  18. package/scripts/loop-registration-test.mjs +220 -0
  19. package/scripts/opencode2-loop-canary.mjs +433 -0
  20. package/scripts/scheduler-runtime-test.mjs +147 -0
  21. package/scripts/session-activity-test.mjs +95 -0
  22. package/scripts/session-status-test.mjs +220 -0
  23. package/scripts/v2-command-adapter-test.mjs +113 -0
  24. package/scripts/v2-command-runtime-test.mjs +98 -0
  25. package/scripts/v2-host-contract-test.mjs +27 -0
  26. package/scripts/v2-plugin-sentinel-test.mjs +57 -8
  27. package/scripts/v2-prompt-interval-test.mjs +171 -0
  28. package/scripts/v2-prompt-runtime-test.mjs +267 -0
  29. package/scripts/v2-runtime-adapter-test.mjs +326 -0
  30. package/src/index.js +2751 -1594
  31. package/src/source/legacy-v1.js +168 -1860
  32. package/src/source/opencode/command-router.js +103 -0
  33. package/src/source/opencode/commands.js +83 -0
  34. package/src/source/opencode/goal-commands.js +107 -0
  35. package/src/source/opencode/loop-commands.js +195 -0
  36. package/src/source/opencode/loop-registration.js +100 -0
  37. package/src/source/opencode/messages.js +55 -0
  38. package/src/source/opencode2/capabilities.js +3 -1
  39. package/src/source/opencode2/commands.js +60 -0
  40. package/src/source/opencode2/event-bridge.js +19 -1
  41. package/src/source/opencode2/events.js +64 -0
  42. package/src/source/opencode2/experimental.js +32 -1
  43. package/src/source/opencode2/host-contract.js +26 -0
  44. package/src/source/opencode2/prompt-runtime.js +381 -0
  45. package/src/source/opencode2/runtime-adapter.js +56 -0
  46. package/src/source/opencode2/status.js +40 -0
  47. package/src/source/runtime/compaction.js +122 -0
  48. package/src/source/runtime/executor.js +558 -0
  49. package/src/source/runtime/goal-policy.js +77 -0
  50. package/src/source/runtime/goal-runtime.js +231 -0
  51. package/src/source/runtime/goal-steering.js +198 -0
  52. package/src/source/runtime/job-workspace.js +148 -0
  53. package/src/source/runtime/scheduler.js +212 -0
  54. package/src/source/runtime/session-activity.js +112 -0
  55. package/src/source/runtime/session-status.js +197 -0
  56. 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/jobs.js
380
- function presetDefaults(name) {
381
- if (name === "loop-compact")
382
- return { intervalMs: parseDuration("200m"), action: "/compact", kind: "compact", name: "compact", immediate: false };
383
- if (name === "loop-command" || name === "loop-cmd")
384
- return { intervalMs: 0, kind: "command", name: "command", immediate: false };
385
- if (name === "loop-prompt")
386
- return { intervalMs: 0, kind: "prompt", name: "prompt", immediate: true };
387
- if (name === "loop-ask")
388
- return { intervalMs: 0, kind: "prompt", name: "ask", immediate: false };
389
- if (name === "loop-shell")
390
- return { intervalMs: 0, kind: "shell", name: "shell", immediate: false };
391
- if (name === "loop-testfix")
392
- return { intervalMs: 0, name: "testfix", safe: true, askNever: true, verifyCommand: "npm test", testfixPreset: true, action: "Run the project tests. Fix failures. Re-run the tests. Test command hint: npm test" };
393
- if (name === "loop-progress")
394
- return { intervalMs: 0, name: "progress", safe: true, askNever: true, progressFile: "progress.md", action: "Read progress.md and continue the next unfinished TODO. Mark completed TODOs with [x]. Add useful TODOs when you discover them." };
395
- if (name === "loop-safe-dev")
396
- return { intervalMs: 0, name: "safe-dev", safe: true, askNever: true, noOverlap: true, checkpointOnly: true, batch: 5, progressFile: "progress.md", action: "Develop the project from progress.md. Work in small safe batches. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." };
397
- return { intervalMs: 0, name: "dev", askNever: true, progressFile: "progress.md", action: "Continue developing the project from progress.md. Mark completed TODOs with [x]. Add new ideas to progress.md. Run tests/lint/build if available." };
398
- }
399
- function jobLabel(job) {
400
- const title = job.name ? `${job.name}: ` : "";
401
- const kind = job.kind ? ` [${job.kind}]` : "";
402
- const limit = job.maxRuns > 0 ? `, max ${job.maxRuns}` : "";
403
- const runtime = job.maxRuntimeMs > 0 ? `, runtime ${durationToText(job.maxRuntimeMs)}` : "";
404
- const timeout = job.timeoutMs > 0 ? `, timeout ${durationToText(job.timeoutMs)}` : "";
405
- const compact = job.compactEveryRuns > 0 ? `, compact every ${job.compactEveryRuns} runs` : job.compactEveryMs > 0 ? `, compact every ${durationToText(job.compactEveryMs)}` : "";
406
- const verify = job.verifyCommand ? ", verify" : "";
407
- const preflight = job.preflightCommand ? ", preflight" : "";
408
- const failures = job.maxFailures > 0 ? `, max failures ${job.maxFailures}` : "";
409
- const noProgress = isGoalJob(job) && (job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS) > 0 ? `, max no-progress ${job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS}` : "";
410
- const stopFile = job.stopFile ? ", stop-file" : "";
411
- const watch = job.watchPaths?.length ? `, watch ${job.watchPaths.join(",")}` : "";
412
- const paused = job.paused ? ", paused" : "";
413
- return `${title}${durationToText(job.intervalMs)}${kind} -> ${job.action || `[prompt-file: ${job.promptFile}]`}${limit}${runtime}${timeout}${compact}${verify}${preflight}${failures}${noProgress}${stopFile}${watch}${paused}`;
414
- }
415
- function matchJob(job, target, index) {
416
- const text = String(target || "").trim();
417
- if (!text || text.toLowerCase() === "all")
418
- return true;
419
- return job.id === text || job.name === text || String(index + 1) === text;
420
- }
421
- function actionKind(action, job = {}) {
422
- const text = String(action || "").trim();
423
- const forced = String(job.kind || "").trim().toLowerCase();
424
- if (forced === "compact")
425
- return "compact";
426
- if (forced === "goal")
427
- return "goal";
428
- if (text === "/compact" || text === "/summarize")
429
- return "compact";
430
- if (forced === "prompt" || forced === "ask")
431
- return "prompt";
432
- if (forced === "command" || forced === "cmd" || forced === "slash")
433
- return "command";
434
- if (forced === "shell")
435
- return "shell";
436
- if (text.startsWith("/"))
437
- return "command";
438
- if (text.startsWith("!") || text.startsWith("$"))
439
- return "shell";
440
- return "prompt";
441
- }
442
- function decoratePrompt(job) {
443
- const additions = [];
444
- if (job.progressFile)
445
- additions.push(`Use ${job.progressFile} as the main progress/TODO state file. Read it before choosing the next task and update it after work.`);
446
- if (job.lastVerifyFailure)
447
- additions.push("Previous verify command failed. Fix this before moving on. Failure summary: " + String(job.lastVerifyFailure).slice(0, 1200));
448
- if (job.askNever)
449
- additions.push("Do not ask the user questions. Make reasonable assumptions and continue. Only write a short BLOCKED note if truly blocked.");
450
- if (job.safe)
451
- additions.push("Safety rules: do not run destructive commands such as git reset, git clean, rm -rf, del /s, rmdir /s, force push, production deploys, production migrations, terraform destroy, or deleting user data. If such an action seems needed, write a BLOCKED note instead.");
452
- if (job.batch > 0)
453
- additions.push(`Batch rule: in this run, work on at most ${job.batch} unfinished TODO item(s). Mark completed items with [x].`);
454
- if (job.quiet)
455
- additions.push("Keep replies short. Summarize only what changed, tests run, and next step.");
456
- if (job.testCommand)
457
- additions.push(`After making changes, run this test/check command if applicable: ${job.testCommand}. If it fails, fix the failure and try again.`);
458
- if (job.checkpointOnly || job.gitCheckpoint)
459
- additions.push("Keep changes incremental and easy to review because the loop will create a checkpoint after the run.");
460
- if (!additions.length)
461
- return job.action;
462
- return `${job.action}
463
-
464
- OpenCode loop instructions:
465
- - ${additions.join(`
466
- - `)}`;
467
- }
468
- function isGoalJob(job) {
469
- return String(job?.kind || "").toLowerCase() === "goal";
470
- }
471
- function goalStatusText(job) {
472
- const status = job?.goalStatus || (isGoalJob(job) ? "active" : "");
473
- if (!status)
474
- return "";
475
- if (status === "completed")
476
- return "completed";
477
- if (status === "blocked")
478
- return "blocked";
479
- if (job?.paused)
480
- return "paused";
481
- return status;
482
- }
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 sdkError2(result) {
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 sdkData2(result) {
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 = sdkError2(result);
704
+ const error = sdkError(result);
810
705
  if (!error)
811
- return sdkData2(result);
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/legacy-v1.js
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
- if (id)
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 < 30000 ? previous.pending + 1 : 1;
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 > 30000)
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 > 30000)
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 >= 30000) {
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 commandEventKey(sessionID, messageID) {
1113
- return `${sessionID || "no-session"}:event:${messageID || "no-message"}`;
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
- function rememberSession(directory, client, sessionID) {
1153
- if (!sessionID)
1154
- return;
1155
- knownSessions.set(sessionID, { directory, client, seenAt: now() });
1156
- startHeartbeat();
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 hasActiveToolCalls(sessionID) {
1159
- return (activeToolCalls.get(sessionID)?.size || 0) > 0;
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 markToolCallActive(input) {
1162
- const sessionID = input?.sessionID;
1163
- const callID = input?.callID;
1164
- if (typeof sessionID !== "string" || typeof callID !== "string")
1165
- return;
1166
- const calls = activeToolCalls.get(sessionID) || new Set;
1167
- calls.add(callID);
1168
- activeToolCalls.set(sessionID, calls);
1169
- sessionStatuses.set(sessionID, "busy");
1170
- sessionStatusSeenAt.set(sessionID, now());
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 markToolCallFinished(input) {
1173
- const sessionID = input?.sessionID;
1174
- const callID = input?.callID;
1175
- if (typeof sessionID !== "string" || typeof callID !== "string")
1176
- return;
1177
- const calls = activeToolCalls.get(sessionID);
1178
- if (!calls)
1179
- return;
1180
- calls.delete(callID);
1181
- if (!calls.size)
1182
- activeToolCalls.delete(sessionID);
1183
- }
1184
- function updateSessionRelationship(info, removed = false) {
1185
- const sessionID = info?.id;
1186
- if (typeof sessionID !== "string")
1187
- return;
1188
- if (removed || typeof info?.parentID !== "string")
1189
- sessionParents.delete(sessionID);
1190
- else
1191
- sessionParents.set(sessionID, info.parentID);
1192
- if (!removed)
1193
- updateSessionExecutionContext(info);
1194
- else
1195
- deleteSessionExecutionContext(sessionID);
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 hasBusyDescendant(sessionID) {
1214
- for (const childID of sessionParents.keys()) {
1215
- if (!isDescendantSession(childID, sessionID))
1216
- continue;
1217
- const status = sessionStatuses.get(childID);
1218
- if (status === "busy" || status === "retry" || hasActiveToolCalls(childID))
1219
- return true;
1220
- }
1221
- return false;
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
- async function refreshSessionRelationships(client, directory) {
1224
- if (!client?.session?.list)
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 updateToolActivityFromEvent(event) {
1234
- const props = event?.properties || {};
1235
- if (event?.type === "message.part.updated") {
1236
- const part = props.part;
1237
- if (part?.type !== "tool")
1238
- return;
1239
- const sessionID = part.sessionID || props.sessionID;
1240
- if (["pending", "running"].includes(part.state?.status)) {
1241
- markToolCallActive({ sessionID, callID: part.callID });
1242
- }
1243
- if (["completed", "error"].includes(part.state?.status)) {
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
- function startHeartbeat() {
1258
- if (heartbeatTimer)
1259
- return;
1260
- heartbeatTimer = setInterval(() => {
1261
- for (const [sessionID, info] of [...knownSessions.entries()]) {
1262
- if (!info || now() - (info.seenAt || 0) > 12 * 60 * 60 * 1000) {
1263
- knownSessions.delete(sessionID);
1264
- continue;
1265
- }
1266
- Promise.resolve().then(async () => {
1267
- await finalizeActiveRun(info.directory, info.client, sessionID, { requireIdle: true, forceStale: true });
1268
- await maybeRunDueJobs(info.directory, info.client, sessionID, { heartbeat: true });
1269
- }).catch((error) => appendLoopLog(info.directory, "heartbeat-error", { sessionID, error: sdkErrorMessage(error) }).catch(() => {}));
1270
- }
1271
- if (!knownSessions.size && heartbeatTimer) {
1272
- clearInterval(heartbeatTimer);
1273
- heartbeatTimer = undefined;
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
- }, HEARTBEAT_MS);
1276
- }
1277
- function disposeRuntime(directory, client) {
1278
- const sessions = [...knownSessions.entries()].filter(([, info]) => info?.directory === directory && info?.client === client).map(([sessionID]) => sessionID);
1279
- for (const sessionID of sessions) {
1280
- clearActiveRun(sessionID);
1281
- const idle = idleTimers.get(sessionID);
1282
- if (idle)
1283
- clearTimeout(idle);
1284
- const due = dueTimers.get(sessionID);
1285
- if (due)
1286
- clearTimeout(due);
1287
- const watchdog = watchdogTimers.get(sessionID);
1288
- if (watchdog)
1289
- clearInterval(watchdog);
1290
- idleTimers.delete(sessionID);
1291
- dueTimers.delete(sessionID);
1292
- watchdogTimers.delete(sessionID);
1293
- runLocks.delete(sessionID);
1294
- knownSessions.delete(sessionID);
1295
- loopOwnedUserMessageGuards.delete(sessionID);
1296
- activeToolCalls.delete(sessionID);
1297
- sessionParents.delete(sessionID);
1298
- sessionStatuses.delete(sessionID);
1299
- sessionStatusSeenAt.delete(sessionID);
1300
- deleteSessionExecutionContext(sessionID);
1301
- loopCompactionRequests.delete(sessionID);
1302
- for (const key of handledCommands.keys())
1303
- if (key.startsWith(`${sessionID}:`))
1304
- handledCommands.delete(key);
1305
- for (const key of handledCommandEvents.keys())
1306
- if (key.startsWith(`${sessionID}:`))
1307
- handledCommandEvents.delete(key);
1308
- }
1309
- if (!knownSessions.size && heartbeatTimer) {
1310
- clearInterval(heartbeatTimer);
1311
- heartbeatTimer = undefined;
1312
- }
1313
- }
1314
- function dangerousShell(command) {
1315
- const text = String(command || "").toLowerCase();
1316
- return [
1317
- /\brm\b(?=[^\r\n]*\s-{1,2}(?:[a-z]*r[a-z]*|recursive)\b)(?=[^\r\n]*\s-{1,2}(?:[a-z]*f[a-z]*|force)\b)/,
1318
- /\bremove-item\b[^\r\n]*(?:-recurse|-force)/,
1319
- /\bgit\s+reset\b/,
1320
- /\bgit\s+clean\b/,
1321
- /\bgit\s+push\b/,
1322
- /\bdel\b[^\r\n]*\s\/s\b/,
1323
- /\b(?:rmdir|rd)\b[^\r\n]*\s\/s\b/,
1324
- /(?:^|[;&|]\s*)format(?:\.com)?\s+(?:[a-z]:|\/(?:fs|q)\b)/,
1325
- /\bterraform\s+destroy\b/,
1326
- /\bkubectl\s+delete\b/,
1327
- /\bdeploy\b.*\bproduction\b/
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
- async function buildPrompt(directory, job) {
1411
- if (isGoalJob(job))
1412
- return await buildGoalPrompt(directory, job);
1413
- const sections = [];
1414
- if (job.promptFile) {
1415
- const text = await readSmallTextFile(path3.resolve(directory, job.promptFile));
1416
- if (text.trim())
1417
- sections.push(`Instructions from ${job.promptFile}:
1418
- ${text.trim()}`);
1419
- else
1420
- sections.push(`Prompt file ${job.promptFile} was requested but could not be read. Continue from the regular action instead.`);
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.action)
1423
- sections.push(decoratePrompt(job));
1424
- for (const file of job.includeFiles || []) {
1425
- const text = await readSmallTextFile(path3.resolve(directory, file), 80000);
1426
- if (text.trim())
1427
- sections.push(`Context from ${file}:
1428
- ${text.trim().slice(0, 20000)}`);
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 sections.join(`
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 applyGoalNoProgressGuard(directory, client, sessionID, job, beforeJob) {
2063
- if (!isGoalJob(job) || ["completed", "blocked"].includes(job.goalStatus) || job.paused || job.enabled === false)
2064
- return job;
2065
- const limit = Number(job.maxNoProgress ?? DEFAULT_GOAL_MAX_NO_PROGRESS);
2066
- if (!Number.isFinite(limit) || limit <= 0)
2067
- return job;
2068
- if (goalMadeMeaningfulProgress(beforeJob, job)) {
2069
- job.noProgressCount = 0;
2070
- job.lastProgressAt = now();
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
- async function runGoalChecks(directory, sessionID, job, client) {
2153
- if (!isGoalJob(job) || !job.goalChecks?.length || ["completed", "blocked"].includes(job.goalStatus))
2154
- return job;
2155
- const results = [];
2156
- for (const command of job.goalChecks) {
2157
- if (job.safe && dangerousShell(command)) {
2158
- results.push({ command, code: -1, output: "Blocked dangerous command in safe mode." });
2159
- continue;
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
- job.lastGoalCheckAt = now();
2166
- job.lastGoalChecks = results;
2167
- const allPassed = results.length > 0 && results.every((item) => item.code === 0);
2168
- if (allPassed) {
2169
- job.goalChecksPassedAt = now();
2170
- job.failureCount = 0;
2171
- await toast(client, "Goal checks passed.", "success");
2172
- if (job.goalCompleteWhenChecksPass) {
2173
- job.goalStatus = "completed";
2174
- job.enabled = false;
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.goalCompletedAt = now();
2177
- job.goalSummary = job.goalSummary || "All configured goal checks passed.";
2178
- job.goalEvidence = results.map((item) => `${item.command}: exit ${item.code}`).join(`
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
- await appendLoopLog(directory, "goal-auto-complete", { sessionID, job: job.name || job.id });
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
- } else {
2183
- job.failureCount = (job.failureCount || 0) + 1;
2184
- job.lastVerifyFailure = results.map((item) => `${item.command}
2185
- exit=${item.code}
2186
- ${item.output}`).join(`
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
- `).slice(0, 4000);
2189
- await toast(client, "Goal checks still failing; goal will continue on next idle turn.", "warning");
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
- await appendLoopLog(directory, "goal-checks", { sessionID, job: job.name || job.id, results: results.map((item) => ({ command: item.command, code: item.code })) });
2192
- return job;
2456
+ return {
2457
+ buildPrompt,
2458
+ ensureBranch,
2459
+ snapshotPaths,
2460
+ watchChanged,
2461
+ untilReached,
2462
+ createCheckpoint
2463
+ };
2193
2464
  }
2194
- async function finalizeActiveRun(directory, client, sessionID, options = {}) {
2195
- const active = activeRuns.get(sessionID);
2196
- if (!active)
2197
- return;
2198
- if (!await canFinalizeActiveRun(directory, client, sessionID, active, options))
2199
- return false;
2200
- const recoveredStale = staleActiveRun(sessionID);
2201
- if (active.compactionOnly) {
2202
- const pending = loopCompactionRequests.get(sessionID);
2203
- clearActiveRun(sessionID);
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
- clearActiveRun(sessionID);
2216
- const state = await readState(directory, sessionID);
2217
- let job = (state.jobs || []).find((candidate) => candidate.id === active.jobId);
2218
- if (!job)
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
- job.lastFinishedAt = now();
2221
- if (recoveredStale)
2222
- await appendLoopLog(directory, "active-stale-recovery", { sessionID, job: job.name || job.id, startedAt: active.startedAt });
2223
- if (job.verifyCommand) {
2224
- const verify = await runShellCommand(job.verifyCommand, directory, job.timeoutMs || 300000);
2225
- job.lastVerifyAt = now();
2226
- job.lastVerifyCode = verify.code;
2227
- if (verify.code === 0) {
2228
- job.failureCount = 0;
2229
- job.lastVerifyFailure = "";
2230
- await toast(client, "Loop verify passed: " + job.verifyCommand, "success");
2231
- } else {
2232
- job.failureCount = (job.failureCount || 0) + 1;
2233
- job.lastVerifyFailure = (job.verifyCommand + `
2234
- exit=` + verify.code + `
2235
- ` + verify.stdout + `
2236
- ` + verify.stderr).slice(0, 4000);
2237
- await toast(client, "Loop verify failed: " + job.verifyCommand, "warning");
2238
- if (job.pauseOnVerifyFail || job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
2239
- job.paused = true;
2240
- await notifyJob(directory, job, "verify_failed");
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
- await appendLoopLog(directory, "verify", { sessionID, job: job.name || job.id, command: job.verifyCommand, code: verify.code, failures: job.failureCount || 0 });
2564
+ return;
2244
2565
  }
2245
- if (job.postrunCommand) {
2246
- if (job.safe && dangerousShell(job.postrunCommand))
2247
- await appendLoopLog(directory, "postrun-blocked", { sessionID, job: job.name || job.id, command: job.postrunCommand });
2248
- else {
2249
- const postrun = await runShellCommand(job.postrunCommand, directory, job.timeoutMs || 300000);
2250
- job.lastPostrunCode = postrun.code;
2251
- job.lastPostrunAt = now();
2252
- if (postrun.code !== 0) {
2253
- job.failureCount = (job.failureCount || 0) + 1;
2254
- job.lastPostrunFailure = (job.postrunCommand + `
2255
- exit=` + postrun.code + `
2256
- ` + postrun.stdout + `
2257
- ` + postrun.stderr).slice(0, 4000);
2258
- if (job.maxFailures > 0 && job.failureCount >= job.maxFailures) {
2259
- job.paused = true;
2260
- await notifyJob(directory, job, "postrun_failed");
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
- await appendLoopLog(directory, "postrun", { sessionID, job: job.name || job.id, command: job.postrunCommand, code: postrun.code });
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
- if (isGoalJob(job)) {
2267
- job = await runGoalChecks(directory, sessionID, job, client);
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
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate).filter((candidate) => candidate.enabled !== false || isGoalJob(candidate));
2271
- await writeState(directory, sessionID, state);
2272
- if (isGoalJob(job))
2273
- await writeGoalReport(directory, sessionID, job);
2274
- await createCheckpoint(directory, sessionID, job, client);
2275
- await scheduleDueWork(directory, client, sessionID);
2276
- return true;
2628
+ return {
2629
+ markSessionStatus,
2630
+ clearSessionStatus,
2631
+ updateSessionStatusFromEvent,
2632
+ staleActiveRun,
2633
+ canFinalizeActiveRun,
2634
+ readLiveSessionStatus,
2635
+ sessionStatusType,
2636
+ sessionIsIdle
2637
+ };
2277
2638
  }
2278
- async function fireAction(directory, client, sessionID, job) {
2279
- const action = String(job.action || "").trim();
2280
- const kind = actionKind(action, job);
2281
- const agent = job.agent || "build";
2282
- const model = normalizedModelRef(job.model);
2283
- if (kind === "compact") {
2284
- beginLoopCompaction(sessionID, job.id, false);
2285
- const ok = await compactSession(directory, client, sessionID, model);
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
- loopCompactionRequests.delete(sessionID);
2288
- return { startsAssistantTurn: ok, pause: !ok, reason: "compact_failed", compaction: ok };
2289
- }
2290
- if (kind === "command") {
2291
- const normalized = action.startsWith("/") ? action.slice(1) : action;
2292
- const [command, argumentsText] = splitFirst(normalized);
2293
- if (!command) {
2294
- await toast(client, "Loop command action is empty. Example: /loop-command 200m /compact", "warning");
2295
- return { startsAssistantTurn: false, pause: true, reason: "empty_command" };
2296
- }
2297
- const tuiCommand = compactTuiCommandName(command);
2298
- if (tuiCommand) {
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
- guardLoopOwnedUserMessage(sessionID);
2307
- const commandBody = { command, arguments: argumentsText, agent };
2308
- if (model)
2309
- commandBody.model = `${model.providerID}/${model.modelID}`;
2310
- await sdkCall(client.session.command.bind(client.session), { path: { id: sessionID }, body: commandBody }, { path: { sessionID }, body: commandBody }, { sessionID, ...commandBody });
2311
- return { startsAssistantTurn: true };
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
- const prompt = await buildPrompt(directory, job);
2328
- const prefix = kind === "goal" ? "EXPERIMENTAL GOAL MODE CONTINUATION. Continue pursuing the active goal. Do not explain the /loop-goal command. Use the goal tools only when progress/completion/block state is real." : "AUTONOMOUS OPENCODE LOOP ITERATION. Continue the configured task now. Do not explain the /loop command. Do not search for documentation about this plugin. Do not create scheduler files. Do not ask questions. Make reasonable assumptions and work directly.";
2329
- const promptText = `${prefix}
2330
-
2331
- ${prompt}`;
2332
- guardLoopOwnedUserMessage(sessionID);
2333
- const promptBody = { agent, parts: [{ type: "text", text: promptText }] };
2334
- if (model)
2335
- promptBody.model = model;
2336
- const dispatch = fireSdk(client, "session.prompt", client.session.prompt.bind(client.session), { path: { id: sessionID }, body: promptBody }, { path: { sessionID }, body: promptBody }, { sessionID, ...promptBody });
2337
- return { startsAssistantTurn: true, dispatch };
2338
- }
2339
- async function maybeRunDueJobs(directory, client, sessionID, options = {}) {
2340
- rememberSession(directory, client, sessionID);
2341
- const reschedule = async (minDelayMs = 0) => {
2342
- await scheduleDueWork(directory, client, sessionID, minDelayMs);
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
- if (runLocks.has(sessionID)) {
2345
- await reschedule(BUSY_RETRY_MS);
2346
- return;
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
- runLocks.set(sessionID, now());
2349
- let job;
2350
- try {
2351
- await finalizeActiveRun(directory, client, sessionID, { requireIdle: true, forceStale: true });
2352
- if (!await sessionIsIdle(client, sessionID, directory)) {
2353
- if (options.force)
2354
- await toast(client, "Loop queued: session is busy; it will run on the next idle check.", "info");
2355
- await reschedule(BUSY_RETRY_MS);
2356
- return;
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
- const activeAge = active ? now() - (active.startedAt || 0) : 0;
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
- if (active && activeAge >= activeGuard)
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
- const state = await readState(directory, sessionID);
2368
- for (const candidate of state.jobs || []) {
2369
- if (candidate.watchPaths?.length && !candidate.paused && candidate.enabled && await watchChanged(directory, candidate))
2370
- candidate.watchTriggered = true;
2371
- }
2372
- const due = dueJobs(state, options.force);
2373
- if (!due.length) {
2374
- await writeState(directory, sessionID, state);
2375
- await reschedule();
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
- job = due[0];
2379
- if (job.maxRuntimeMs > 0 && now() - Date.parse(job.createdAt || new Date().toISOString()) >= job.maxRuntimeMs) {
2380
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
2381
- await writeState(directory, sessionID, state);
2382
- await notifyJob(directory, job, "max_runtime_reached");
2383
- await toast(client, `Loop stopped by --max-runtime: ${job.name || job.id}`, "success");
2384
- await appendLoopLog(directory, "max-runtime", { sessionID, job: job.name || job.id });
2385
- await reschedule();
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.stopFile && await pathExists(path3.resolve(directory, job.stopFile))) {
2389
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
2390
- await writeState(directory, sessionID, state);
2391
- await notifyJob(directory, job, "stop_file");
2392
- await toast(client, "Loop stopped by --stop-file: " + job.stopFile, "success");
2393
- await reschedule();
2394
- return;
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 (await untilReached(directory, job)) {
2397
- state.jobs = (state.jobs || []).filter((candidate) => candidate.id !== job.id);
2398
- await writeState(directory, sessionID, state);
2399
- await notifyJob(directory, job, "until_reached");
2400
- await toast(client, `Loop stopped by --until: ${job.until}`, "success");
2401
- await reschedule();
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
- if (job.preflightCommand) {
2405
- if (job.safe && dangerousShell(job.preflightCommand)) {
2406
- job.paused = true;
2407
- await writeState(directory, sessionID, state);
2408
- await notifyJob(directory, job, "preflight_blocked");
2409
- await toast(client, "Preflight blocked in safe mode and loop paused: " + job.preflightCommand, "error");
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
- const preflight = await runShellCommand(job.preflightCommand, directory, job.timeoutMs || 300000);
2414
- await appendLoopLog(directory, "preflight", { sessionID, job: job.name || job.id, command: job.preflightCommand, code: preflight.code });
2415
- if (preflight.code !== 0) {
2416
- job.paused = true;
2417
- job.failureCount = (job.failureCount || 0) + 1;
2418
- job.lastPreflightFailure = (job.preflightCommand + `
2419
- exit=` + preflight.code + `
2420
- ` + preflight.stdout + `
2421
- ` + preflight.stderr).slice(0, 4000);
2422
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
2423
- await writeState(directory, sessionID, state);
2424
- await notifyJob(directory, job, "preflight_failed");
2425
- await toast(client, "Preflight failed and loop paused: " + job.preflightCommand, "warning");
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
- job = await ensureBranch(directory, job, client, sessionID);
2431
- const compactResult = await maybeCompact(directory, client, sessionID, job);
2432
- job = compactResult.job;
2433
- if (compactResult.started) {
2434
- state.jobs = (state.jobs || []).map((candidate) => candidate.id === job.id ? job : candidate);
2435
- await writeState(directory, sessionID, state);
2436
- let timer;
2437
- if (job.timeoutMs > 0)
2438
- timer = setTimeout(() => {
2439
- fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
2440
- toast(client, `Loop compact timeout fired: ${job.name || job.id}`, "warning").catch(() => {});
2441
- }, job.timeoutMs);
2442
- const runToken = `${job.id}:compact:${now().toString(36)}:${Math.random().toString(16).slice(2)}`;
2443
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionOnly: true });
2444
- const pending = loopCompactionRequests.get(sessionID);
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
- sessionStatuses.set(sessionID, "busy");
2450
- sessionStatusSeenAt.set(sessionID, now());
2451
- await reschedule(BUSY_RETRY_MS);
2452
- return;
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
- let timer;
2483
- if (job.timeoutMs > 0)
2484
- timer = setTimeout(() => {
2485
- fireSdk(client, "session.abort", client.session.abort.bind(client.session), { path: { id: sessionID }, body: {} }, { path: { sessionID }, body: {} }, { sessionID });
2486
- toast(client, `Loop timeout fired: ${job.name || job.id}`, "warning").catch(() => {});
2487
- }, job.timeoutMs);
2488
- const runToken = `${job.id}:${now().toString(36)}:${Math.random().toString(16).slice(2)}`;
2489
- activeRuns.set(sessionID, { jobId: job.id, job, startedAt: now(), timer, runToken, compactionAction: result.compaction === true });
2490
- if (result.compaction) {
2491
- const pending = loopCompactionRequests.get(sessionID);
2492
- if (pending?.jobId === job.id && pending.completedAt) {
2493
- await finalizeLoopCompaction(directory, client, sessionID);
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
- if (result.dispatch) {
2498
- result.dispatch.catch((error) => {
2499
- recoverActiveDispatchFailure(directory, client, sessionID, job.id, runToken, error).catch((recoveryError) => log(client, "error", "dispatch recovery failed", { error: sdkErrorMessage(recoveryError) }));
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
- sessionStatuses.set(sessionID, "busy");
2503
- sessionStatusSeenAt.set(sessionID, now());
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
- function normalizeActionForCompare(value) {
2516
- return String(value || "").replace(/\s+/g, " ").trim();
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 sameLoopDefinition(a, b) {
2519
- if (!a || !b)
2520
- return false;
2521
- return (a.name || "") === (b.name || "") && Number(a.intervalMs || 0) === Number(b.intervalMs || 0) && normalizeActionForCompare(a.action) === normalizeActionForCompare(b.action) && normalizeActionForCompare(a.kind) === normalizeActionForCompare(b.kind) && normalizeActionForCompare(a.promptFile) === normalizeActionForCompare(b.promptFile);
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
- async function addLoop(directory, client, sessionID, args, defaults = {}) {
2524
- const parsed = parseLoopArgs(args, defaults);
2525
- if (!parsed.ok) {
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 executionContext = getSessionExecutionContext(sessionID) || { agent: "build" };
2530
- parsed.job.agent = defaults.agent || executionContext.agent || "build";
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
- const state = await readState(directory, sessionID);
2554
- const jobs = Array.isArray(state.jobs) ? state.jobs : [];
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
- state.jobs.push(parsed.job);
2570
- await writeState(directory, sessionID, state);
2571
- await scheduleDueWork(directory, client, sessionID);
2572
- if (parsed.job.immediate)
2573
- scheduleIdleWork(directory, client, sessionID);
2574
- await toast(client, `${replaced ? "Loop replaced" : "Loop added"}: ${jobLabel(parsed.job)}`, "success");
2575
- await appendLoopLog(directory, replaced ? "replace" : "add", { sessionID, job: parsed.job.name || parsed.job.id, label: jobLabel(parsed.job) });
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
- const state = await readState(directory, sessionID);
2591
- const before = state.jobs.length;
2592
- state.jobs = state.jobs.filter((job, index) => !matchJob(job, target, index));
2593
- await writeState(directory, sessionID, state);
2594
- await scheduleDueWork(directory, client, sessionID);
2595
- await toast(client, `Stopped ${before - state.jobs.length} loop(s).`, "success");
2596
- }
2597
- async function updateJobState(directory, client, sessionID, args, updater, message) {
2598
- const target = String(args || "").trim() || "all";
2599
- const state = await readState(directory, sessionID);
2600
- let count = 0;
2601
- state.jobs = (state.jobs || []).map((job, index) => matchJob(job, target, index) ? (count++, updater(job)) : job);
2602
- await writeState(directory, sessionID, state);
2603
- await scheduleDueWork(directory, client, sessionID);
2604
- await toast(client, `${message}: ${count} loop(s).`, count ? "success" : "warning");
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
- async function clearGoal(directory, client, sessionID, args) {
2650
- const target = String(args || "").trim();
2651
- const state = await readState(directory, sessionID);
2652
- const before = state.jobs.length;
2653
- state.jobs = (state.jobs || []).filter((job, index) => !isGoalJob(job) || target && !matchJob(job, target, index));
2654
- await writeState(directory, sessionID, state);
2655
- await scheduleDueWork(directory, client, sessionID);
2656
- await toast(client, `Cleared ${before - state.jobs.length} experimental goal(s).`, before !== state.jobs.length ? "success" : "warning");
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
- await writeState(directory, sessionID, state);
2736
- await toast(client, `Marked ${count} loop job(s) due now.`, count ? "success" : "warning");
2737
- await maybeRunDueJobs(directory, client, sessionID, { force: true });
2738
- }
2739
- async function doctorLoop(directory, client, sessionID) {
2740
- const state = await readState(directory, sessionID);
2741
- await say(client, sessionID, [
2742
- "OpenCode Loop doctor:",
2743
- `- plugin: ${SERVICE}`,
2744
- `- project directory: ${directory}`,
2745
- `- state directory: ${stateDir(directory)}`,
2746
- `- active jobs: ${(state.jobs || []).length}`,
2747
- `- node: ${process.version}`,
2748
- `- platform: ${process.platform}`,
2749
- "- smoke test: /loop 0s --max-runs 1 --dry-run continue from progress.md",
2750
- "- experimental goal smoke test: /loop-goal --dry-run finish the current task and verify it"
2751
- ].join(`
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
- async function initLoop(directory, client, sessionID, args) {
2755
- const target = String(args || "").trim() || "progress.md";
2756
- const full = path3.resolve(directory, target);
2757
- if (await pathExists(full)) {
2758
- await toast(client, `${target} already exists.`, "warning");
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
- await fs3.writeFile(full, DEFAULT_PROGRESS_MD, "utf8");
2762
- await toast(client, `Created ${target}.`, "success");
2763
- await appendLoopLog(directory, "init", { sessionID, file: target });
2764
- }
2765
- async function exportLoop(directory, client, sessionID) {
2766
- const state = await readState(directory, sessionID);
2767
- await say(client, sessionID, "OpenCode loop state export:\n```json\n" + JSON.stringify(state, null, 2) + "\n```");
2768
- }
2769
- async function handleCommand(directory, client, input, fallbackName, fallbackArgs, output, source = "before") {
2770
- const name = commandName(input?.command ?? input?.name ?? fallbackName);
2771
- const sessionID = input?.sessionID;
2772
- const args = commandArgsText(input?.arguments ?? fallbackArgs ?? "");
2773
- if (!sessionID || !name)
2774
- return false;
2775
- rememberSession(directory, client, sessionID);
2776
- if (isLoopCommandName(name))
2777
- await captureSessionExecutionContext(client, sessionID);
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
- if (isLoopCommandName(name))
2789
- guardLoopOwnedUserMessage(sessionID);
2790
- const handled = () => {
2791
- if (output && typeof output === "object")
2792
- output.noReply = true;
2793
- return true;
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 interruptedSessionID = userInterruptSessionFromEvent(event);
2913
- if (interruptedSessionID) {
2914
- rememberSession(directory, client, interruptedSessionID);
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
  }