@tea-agent/loop-agent 0.27.1 → 0.28.1-beta.1

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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/application/task-lifecycle/observe.js +5 -0
  3. package/dist/application/task-lifecycle/plan-transitions.js +7 -2
  4. package/dist/cli/program.js +1 -1
  5. package/dist/commands/client-recovery.js +439 -20
  6. package/dist/commands/init.js +42 -6
  7. package/dist/executors/dag-pi-executor.js +161 -60
  8. package/dist/executors/pi-playwright-cli-tool.js +955 -0
  9. package/dist/executors/pi-sdk-executor.js +56 -0
  10. package/dist/executors/playwright-cli-launcher.js +63 -0
  11. package/dist/executors/shell-executor.js +128 -0
  12. package/dist/shared/playwright-cli-command-policy.js +41 -0
  13. package/dist/task/task-demand-routing.js +1 -15
  14. package/dist/worker/observability/read-model.js +66 -8
  15. package/dist/worker/observe/static/dag-model.js +85 -13
  16. package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
  17. package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
  18. package/dist/workflows/dag/frontend-implementation-contract.js +124 -6
  19. package/dist/workflows/dag/frontend-prewrite-gate.js +22 -8
  20. package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
  21. package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
  22. package/dist/workflows/dag/init-hybrid.js +181 -68
  23. package/dist/workflows/dag/lifecycle.js +33 -2
  24. package/dist/workflows/dag/node-execution.js +11 -5
  25. package/dist/workflows/dag/output-protocol.js +48 -83
  26. package/dist/workflows/dag/report.js +9 -2
  27. package/dist/workflows/dag/rerun-run.js +62 -3
  28. package/dist/workflows/dag/run-store.js +6 -1
  29. package/dist/workflows/dag/runner.js +15 -3
  30. package/dist/workflows/dag/types.js +27 -0
  31. package/dist/workflows/dag/validate.js +121 -1
  32. package/docs/architecture/runtime-boundaries.md +13 -11
  33. package/docs/init-surface.manifest.json +6 -2
  34. package/docs/templates/README.md +9 -1
  35. package/docs/templates/frontend-implementation-contract.schema.json +2 -2
  36. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
  37. package/docs/templates/frontend-test-dag.json +55 -15
  38. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
  39. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  41. package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
  42. package/harness.json +1 -1
  43. package/package.json +1 -1
  44. package/skills/loop-agent/SKILL.md +1 -1
  45. package/skills/loop-agent/references/command-reference.md +18 -6
  46. package/skills/playwright-cli/SKILL.md +69 -402
  47. package/skills/playwright-cli/references/tracing.md +3 -137
  48. package/skills/playwright-cli/references/video-recording.md +3 -141
  49. package/skills/playwright-cli-case-generator/SKILL.md +53 -46
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.28.0] - 2026-08-05
6
+
7
+ ### 重点更新
8
+
9
+ - 新增客户端上下文溢出(Context Overflow)的自动压缩与恢复机制,支持 OpenCode 与 Pi 环境
10
+ - DAG 总览面板全面隔离模拟运行,确保仅展示真实执行的任务状态
11
+ - 修复多项 DAG 生命周期与状态恢复问题,提升任务执行稳定性
12
+
13
+ ### 新增
14
+
15
+ - 新增内网/通用客户端上下文溢出恢复机制:支持精准识别中英文溢出提示,并为 OpenCode 和 Pi 环境自动生成压缩与恢复插件
16
+
17
+ ### 改进
18
+
19
+ - DAG 总览面板全面隔离模拟运行(dry-run/init-only),确保其不再污染活跃任务视图,仅展示真实执行的任务状态
20
+
21
+ ### 修复
22
+
23
+ - 修复 Playwright 浏览器执行未受限制的问题,恢复其有界执行机制
24
+ - 修复结构化审查在遇到重复或无效 JSON 时无法正确收敛的问题,确保严格按规范完成或进入有界修复
25
+ - 修复通用 writer 声明结果与实际改动不一致仍误判成功的问题,现强制要求结果与 diff 保持一致
26
+ - 修复任务在审计或失败运行后无法正确恢复生命周期的问题,现支持在存在待审批写入集时安全恢复执行
27
+ - 修复客户端上下文溢出恢复在压缩后未正确延迟恢复执行的问题
28
+
5
29
  ## [0.27.1] - 2026-08-04
6
30
 
7
31
  ### 重点更新
@@ -144,6 +144,11 @@ function deriveLifecycleState(input) {
144
144
  if (input.latestRun?.lifecycle === "completed") {
145
145
  if (input.latestRun.status === "finished")
146
146
  return "run-succeeded";
147
+ // A newly opened current writeSet gate supersedes an older failed run so
148
+ // operators can approve and start the next DAG run. Success stays terminal
149
+ // here so promote/closeout is not hijacked by a residual open gate.
150
+ if (input.gateOpen)
151
+ return "awaiting-write-set-approval";
147
152
  return "run-failed";
148
153
  }
149
154
  if (input.gateOpen)
@@ -116,9 +116,13 @@ export function planTransitions(input) {
116
116
  }
117
117
  // After validate, open write-set gate unless already approved/rejected for current digest.
118
118
  const gate = snapshot.gate ?? null;
119
+ // run-failed is terminal only when there is no current open gate. An open
120
+ // current writeSet gate supersedes an older failed run so recovery approve
121
+ // can start the next DAG run (observe may already report awaiting).
122
+ const failedRecoverable = snapshot.lifecycleState === "run-failed" && gate !== null;
119
123
  const postRunState = snapshot.lifecycleState === "running" ||
120
124
  snapshot.lifecycleState === "run-succeeded" ||
121
- snapshot.lifecycleState === "run-failed" ||
125
+ (snapshot.lifecycleState === "run-failed" && !failedRecoverable) ||
122
126
  snapshot.lifecycleState === "evidence-closed" ||
123
127
  snapshot.lifecycleState === "needs-attention" ||
124
128
  snapshot.lifecycleState === "awaiting-decision";
@@ -149,8 +153,9 @@ export function planTransitions(input) {
149
153
  }
150
154
  }
151
155
  // Only start/monitor when not already in a terminal post-run state.
156
+ // Keep run-succeeded / evidence-closed terminal; allow run-failed + open gate.
152
157
  const alreadyTerminalRun = snapshot.lifecycleState === "run-succeeded" ||
153
- snapshot.lifecycleState === "run-failed" ||
158
+ (snapshot.lifecycleState === "run-failed" && !failedRecoverable) ||
154
159
  snapshot.lifecycleState === "evidence-closed";
155
160
  if (!alreadyTerminalRun) {
156
161
  if (input.approveGate && !postRunState) {
@@ -594,7 +594,7 @@ export function buildLoopAgentProgram(options) {
594
594
  .option("--markdown", "print Markdown")
595
595
  .option("--bootstrap-surface", "write an inferred .harness/init-surface.json baseline")
596
596
  .option("--apply-safe", "apply deterministic safe init updates")
597
- .option("--client-recovery <mode>", "auto|project|user|off — OpenCode project plugin and optional Pi user retry config", "auto");
597
+ .option("--client-recovery <mode>", "auto|project|user|off — OpenCode transient+overflow plugins, Pi overflow extension, optional Pi user retry config", "auto");
598
598
  command.action(async (args, _options, actionCommand) => runInitCommand(args, actionCommand, options.defaultRepoRoot));
599
599
  addStandaloneSubcommands(command, entry.subcommands ?? [], (args, actionCommand) => runInitCommand(args, actionCommand, options.defaultRepoRoot));
600
600
  program.addCommand(command);
@@ -8,9 +8,13 @@ export const CLIENT_RECOVERY_MODES = [
8
8
  "off",
9
9
  ];
10
10
  export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
11
+ export const OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH = ".opencode/plugins/loop-agent-context-overflow-compact.js";
12
+ export const PI_CONTEXT_OVERFLOW_EXTENSION_PATH = ".pi/extensions/loop-agent-context-overflow.js";
11
13
  export const PERMANENT_ERROR_INTERACTION = "plugin-ignore-permanent-error";
12
14
  export const BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
13
15
  export const MAX_SESSION_RETRIES = 5;
16
+ /** Hard cap for OpenCode overflow compact+resume recoveries (≤ 2). */
17
+ export const MAX_OVERFLOW_RECOVERIES = 1;
14
18
  export const PI_RECOMMENDED_RETRY = {
15
19
  enabled: true,
16
20
  maxRetries: 5,
@@ -20,6 +24,36 @@ export const PI_RECOMMENDED_RETRY = {
20
24
  maxRetryDelayMs: 60000,
21
25
  },
22
26
  };
27
+ /**
28
+ * Shared overflow phrase sources used by isContextOverflow and generated plugins.
29
+ * Keep English + Chinese intranet gateway messages in one place to avoid drift.
30
+ */
31
+ export const CONTEXT_OVERFLOW_PHRASES = [
32
+ "请求上下文过大",
33
+ "context_length_exceeded",
34
+ "context overflow",
35
+ "context length overflow",
36
+ "too many tokens",
37
+ "maximum context",
38
+ "prompt is too long",
39
+ "request_too_large",
40
+ "context window",
41
+ ];
42
+ function escapeRegExpLiteral(value) {
43
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
+ }
45
+ const CONTEXT_OVERFLOW_PATTERN = new RegExp(CONTEXT_OVERFLOW_PHRASES.map(escapeRegExpLiteral).join("|"), "i");
46
+ function buildGeneratedContextOverflowPatternSource() {
47
+ return `const CONTEXT_OVERFLOW_PHRASES = ${JSON.stringify([
48
+ ...CONTEXT_OVERFLOW_PHRASES,
49
+ ])};
50
+ const OVERFLOW_PATTERN = new RegExp(
51
+ CONTEXT_OVERFLOW_PHRASES.map((phrase) =>
52
+ phrase.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&"),
53
+ ).join("|"),
54
+ "i",
55
+ );`;
56
+ }
23
57
  const PERMANENT_MESSAGE_PATTERNS = [
24
58
  {
25
59
  reason: "auth",
@@ -35,7 +69,7 @@ const PERMANENT_MESSAGE_PATTERNS = [
35
69
  },
36
70
  {
37
71
  reason: "context-overflow",
38
- pattern: /\b(context (length )?overflow|too many tokens|maximum context|context window)\b/i,
72
+ pattern: CONTEXT_OVERFLOW_PATTERN,
39
73
  },
40
74
  {
41
75
  reason: "cancelled",
@@ -156,6 +190,14 @@ function permanentReason(error) {
156
190
  }
157
191
  return undefined;
158
192
  }
193
+ /**
194
+ * Shared context-overflow recognition for OpenCode overflow-compact plugin,
195
+ * Pi project extension, and pure tests. Does not treat 401/403, quota, cancel,
196
+ * business validation, or standard 502/LLMRequestError as overflow.
197
+ */
198
+ export function isContextOverflow(error) {
199
+ return CONTEXT_OVERFLOW_PATTERN.test(collectErrorText(error));
200
+ }
159
201
  function looksTransient(error) {
160
202
  const text = collectErrorText(error);
161
203
  if (TRANSIENT_MESSAGE_PATTERNS.some((pattern) => pattern.test(text)))
@@ -471,6 +513,7 @@ const PLUGIN_PATH = ${JSON.stringify(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH)};
471
513
  const PERMANENT_INTERACTION = ${JSON.stringify(PERMANENT_ERROR_INTERACTION)};
472
514
  const BACKOFF_MS = ${JSON.stringify([...BACKOFF_MS])};
473
515
  const MAX_RETRIES = ${MAX_SESSION_RETRIES};
516
+ ${buildGeneratedContextOverflowPatternSource()}
474
517
 
475
518
  const sessionState = new Map();
476
519
  const sessionStatus = new Map();
@@ -502,8 +545,9 @@ function isPermanent(error) {
502
545
  if (/^APIError$/i.test(error.name)) return true;
503
546
  if (/^(AuthError|PermissionError)$/i.test(error.name)) return true;
504
547
  }
505
- return /\\b(401|403|unauthorized|forbidden|quota|rate.?limit|context (length )?overflow|too many tokens|cancelled|canceled|business validation|invalid task|schema validation)\\b/i.test(
506
- text,
548
+ return (
549
+ OVERFLOW_PATTERN.test(text) ||
550
+ /\\b(401|403|unauthorized|forbidden|quota|rate.?limit|cancelled|canceled|business validation|invalid task|schema validation)\\b/i.test(text)
507
551
  );
508
552
  }
509
553
 
@@ -718,22 +762,390 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
718
762
  }
719
763
  `;
720
764
  }
721
- export async function installProjectOpenCodePlugin(input) {
722
- const relativePath = OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH;
723
- const target = path.join(input.repoRoot, relativePath);
724
- const content = buildOpenCodeTransientRetryPluginSource();
765
+ async function installGeneratedProjectFile(input) {
766
+ const target = path.join(input.repoRoot, input.relativePath);
725
767
  if (input.onlyIfMissing) {
726
768
  try {
727
769
  await readFile(target, "utf-8");
728
- return { path: relativePath, written: false, reason: "unchanged" };
770
+ return {
771
+ path: input.relativePath,
772
+ written: false,
773
+ reason: "unchanged",
774
+ };
729
775
  }
730
776
  catch {
731
777
  // missing → write
732
778
  }
733
779
  }
734
780
  await mkdir(path.dirname(target), { recursive: true });
735
- await writeFile(target, content, "utf-8");
736
- return { path: relativePath, written: true, reason: "written" };
781
+ await writeFile(target, input.content, "utf-8");
782
+ return { path: input.relativePath, written: true, reason: "written" };
783
+ }
784
+ export async function installProjectOpenCodePlugin(input) {
785
+ return installGeneratedProjectFile({
786
+ repoRoot: input.repoRoot,
787
+ relativePath: OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
788
+ content: buildOpenCodeTransientRetryPluginSource(),
789
+ onlyIfMissing: input.onlyIfMissing,
790
+ });
791
+ }
792
+ /**
793
+ * Stable generated source for the OpenCode context-overflow compact plugin.
794
+ * Separate from transient-retry: only overflow → compact/summarize + limited resume.
795
+ */
796
+ export function buildOpenCodeContextOverflowCompactPluginSource() {
797
+ return `/**
798
+ * loop-agent OpenCode context overflow compact plugin
799
+ * Path: ${OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH}
800
+ *
801
+ * On context overflow (including Chinese "请求上下文过大"), compact/summarize
802
+ * then resume the same session a limited number of times. Does NOT handle
803
+ * provider transport / 502 recovery (that is loop-agent-transient-retry.js).
804
+ */
805
+ const PLUGIN_PATH = ${JSON.stringify(OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH)};
806
+ const MAX_OVERFLOW_RECOVERIES = ${MAX_OVERFLOW_RECOVERIES};
807
+ ${buildGeneratedContextOverflowPatternSource()}
808
+
809
+ const sessionState = new Map();
810
+ const sessionStatus = new Map();
811
+ const pendingOverflow = new Map();
812
+ const deferredResume = new Map();
813
+ const recoveryWorkers = new Map();
814
+
815
+ function getState(sessionId) {
816
+ let state = sessionState.get(sessionId);
817
+ if (!state) {
818
+ state = {
819
+ attempt: 0,
820
+ locked: false,
821
+ rerunRequested: false,
822
+ deferredIdleConfirmed: false,
823
+ };
824
+ sessionState.set(sessionId, state);
825
+ }
826
+ return state;
827
+ }
828
+
829
+ function textOf(error) {
830
+ if (!error) return "";
831
+ if (typeof error === "string") return error;
832
+ try {
833
+ return JSON.stringify(error);
834
+ } catch {
835
+ return String(error);
836
+ }
837
+ }
838
+
839
+ function isContextOverflow(error) {
840
+ return OVERFLOW_PATTERN.test(textOf(error));
841
+ }
842
+
843
+ function buildOverflowResumePrompt(sessionId, error, attempt) {
844
+ return [
845
+ "[loop-agent context overflow recovery] session=" + sessionId + " attempt=" + attempt,
846
+ "Previous model turn failed because the request context was too large: " + textOf(error),
847
+ "Session history was compacted/summarized before this resume.",
848
+ "Before continuing any work, first inspect already completed tool calls and existing file modifications in this session.",
849
+ "Do not re-run side-effecting tools or rewrite files that already reflect successful prior work.",
850
+ "Resume only the remaining unfinished work after that check.",
851
+ "续接前请先检查本 session 已有工具调用与文件改动,避免重复执行有副作用的操作。",
852
+ ].join("\\n");
853
+ }
854
+
855
+ function statusType(status) {
856
+ const type = status && typeof status.type === "string" ? status.type : undefined;
857
+ return type === "idle" || type === "retry" || type === "busy" ? type : undefined;
858
+ }
859
+
860
+ async function readSessionStatus(client, sessionId) {
861
+ const cached = sessionStatus.get(sessionId);
862
+ if (cached === "retry" || cached === "busy") {
863
+ return { ok: true, type: cached };
864
+ }
865
+ try {
866
+ const response = await client.session.status({ throwOnError: true });
867
+ if (!response || typeof response !== "object" || response.error != null) {
868
+ return { ok: false };
869
+ }
870
+ const current = statusType(response?.data?.[sessionId]);
871
+ if (!current) return { ok: false };
872
+ sessionStatus.set(sessionId, current);
873
+ return { ok: true, type: current };
874
+ } catch {
875
+ return { ok: false };
876
+ }
877
+ }
878
+
879
+ function promptWasAccepted(response) {
880
+ return Boolean(
881
+ response &&
882
+ typeof response === "object" &&
883
+ response.error == null &&
884
+ Object.prototype.hasOwnProperty.call(response, "data") &&
885
+ response.data !== null &&
886
+ typeof response.data === "object",
887
+ );
888
+ }
889
+
890
+ async function compactOrSummarize(client, sessionId) {
891
+ const body = { path: { id: sessionId }, throwOnError: true };
892
+ if (typeof client.session.compact === "function") {
893
+ await client.session.compact(body);
894
+ return "compact";
895
+ }
896
+ if (typeof client.session.summarize === "function") {
897
+ await client.session.summarize(body);
898
+ return "summarize";
899
+ }
900
+ throw new Error("session.compact and session.summarize are unavailable");
901
+ }
902
+
903
+ export default async function loopAgentContextOverflowCompactPlugin({ client, $ }) {
904
+ void $;
905
+ void PLUGIN_PATH;
906
+
907
+ async function promptDeferredResume(sessionId, idleConfirmed = false) {
908
+ const deferred = deferredResume.get(sessionId);
909
+ if (!deferred) return;
910
+
911
+ if (!idleConfirmed) {
912
+ const status = await readSessionStatus(client, sessionId);
913
+ if (deferredResume.get(sessionId) !== deferred) return;
914
+ if (!status.ok || status.type !== "idle") return;
915
+ }
916
+
917
+ // An explicit idle event is authoritative even if the SDK status snapshot
918
+ // still lags at busy. Keep deferred facts until promptAsync is accepted so
919
+ // later idle can retry without another compact/summarize.
920
+ const prompt = buildOverflowResumePrompt(sessionId, deferred.error, deferred.attempt);
921
+ let response;
922
+ try {
923
+ response = await client.session.promptAsync({
924
+ path: { id: sessionId },
925
+ body: { parts: [{ type: "text", text: prompt }] },
926
+ throwOnError: true,
927
+ });
928
+ } catch {
929
+ return;
930
+ }
931
+ if (deferredResume.get(sessionId) !== deferred) return;
932
+ if (!promptWasAccepted(response)) return;
933
+ deferredResume.delete(sessionId);
934
+ getState(sessionId).deferredIdleConfirmed = false;
935
+ }
936
+
937
+ async function drainPendingOverflow(sessionId) {
938
+ const state = getState(sessionId);
939
+ // Deferred resume path: only promptAsync, never compact/summarize again.
940
+ if (deferredResume.has(sessionId)) {
941
+ const idleConfirmed = state.deferredIdleConfirmed;
942
+ state.deferredIdleConfirmed = false;
943
+ await promptDeferredResume(sessionId, idleConfirmed);
944
+ return;
945
+ }
946
+
947
+ const pending = pendingOverflow.get(sessionId);
948
+ if (!pending) return;
949
+ if (state.attempt >= MAX_OVERFLOW_RECOVERIES) {
950
+ pendingOverflow.delete(sessionId);
951
+ return;
952
+ }
953
+
954
+ const before = await readSessionStatus(client, sessionId);
955
+ if (pendingOverflow.get(sessionId) !== pending) return;
956
+ if (!before.ok || before.type !== "idle") return;
957
+
958
+ try {
959
+ await compactOrSummarize(client, sessionId);
960
+ } catch {
961
+ // Compact itself failed: do not consume budget (no compact side-effect yet).
962
+ return;
963
+ }
964
+ // Session cleanup may remove this work while compact is in flight. Do not
965
+ // let that stale worker consume the reset budget or recreate deferred state.
966
+ // Concurrent overflow cannot replace pending because session.error preserves
967
+ // the first in-flight recovery facts below.
968
+ if (pendingOverflow.get(sessionId) !== pending) return;
969
+
970
+ // Consume budget immediately and exactly once after an active recovery's
971
+ // successful compact/summarize.
972
+ const nextAttempt = Math.min(state.attempt + 1, MAX_OVERFLOW_RECOVERIES);
973
+ state.attempt = nextAttempt;
974
+ pendingOverflow.delete(sessionId);
975
+ // Keep resume facts across temporary post-compact busy/unknown status.
976
+ deferredResume.set(sessionId, { error: pending.error, attempt: nextAttempt });
977
+ await promptDeferredResume(sessionId);
978
+ }
979
+
980
+ function startRecoveryWorker(sessionId, deferredIdleConfirmed = false) {
981
+ const state = getState(sessionId);
982
+ if (deferredIdleConfirmed && deferredResume.has(sessionId)) {
983
+ state.deferredIdleConfirmed = true;
984
+ }
985
+ if (recoveryWorkers.has(sessionId)) {
986
+ state.rerunRequested = true;
987
+ return;
988
+ }
989
+ state.locked = true;
990
+ state.rerunRequested = false;
991
+ const worker = drainPendingOverflow(sessionId)
992
+ .catch(() => {})
993
+ .finally(() => {
994
+ recoveryWorkers.delete(sessionId);
995
+ const rerun = state.rerunRequested;
996
+ state.rerunRequested = false;
997
+ state.locked = false;
998
+ if (rerun && (pendingOverflow.has(sessionId) || deferredResume.has(sessionId))) {
999
+ startRecoveryWorker(sessionId);
1000
+ }
1001
+ });
1002
+ recoveryWorkers.set(sessionId, worker);
1003
+ }
1004
+
1005
+ function clearSession(sessionId) {
1006
+ pendingOverflow.delete(sessionId);
1007
+ deferredResume.delete(sessionId);
1008
+ sessionStatus.delete(sessionId);
1009
+ const state = sessionState.get(sessionId);
1010
+ if (state) {
1011
+ state.attempt = 0;
1012
+ state.rerunRequested = false;
1013
+ state.deferredIdleConfirmed = false;
1014
+ if (!recoveryWorkers.has(sessionId)) state.locked = false;
1015
+ }
1016
+ }
1017
+
1018
+ function hasWork(sessionId) {
1019
+ return pendingOverflow.has(sessionId) || deferredResume.has(sessionId);
1020
+ }
1021
+
1022
+ return {
1023
+ event: async ({ event }) => {
1024
+ if (!event || typeof event !== "object") return;
1025
+ const properties = event.properties || {};
1026
+ const sessionId =
1027
+ properties.sessionID || properties.sessionId || properties.id || properties.info?.id;
1028
+
1029
+ if (event.type === "session.status") {
1030
+ const current = statusType(properties.status);
1031
+ if (sessionId && current) sessionStatus.set(sessionId, current);
1032
+ if (sessionId && current === "idle" && hasWork(sessionId)) {
1033
+ startRecoveryWorker(sessionId, deferredResume.has(sessionId));
1034
+ }
1035
+ return;
1036
+ }
1037
+
1038
+ if (event.type === "session.idle") {
1039
+ if (sessionId) {
1040
+ sessionStatus.set(sessionId, "idle");
1041
+ if (hasWork(sessionId)) {
1042
+ startRecoveryWorker(sessionId, deferredResume.has(sessionId));
1043
+ }
1044
+ }
1045
+ return;
1046
+ }
1047
+
1048
+ if (event.type === "session.deleted") {
1049
+ if (sessionId) clearSession(sessionId);
1050
+ return;
1051
+ }
1052
+
1053
+ if (event.type === "message.updated") {
1054
+ const info = properties.info;
1055
+ if (
1056
+ info?.role === "assistant" &&
1057
+ info.sessionID &&
1058
+ (info.status === undefined || info.status === "completed") &&
1059
+ info.time?.completed != null &&
1060
+ info.error == null
1061
+ ) {
1062
+ clearSession(info.sessionID);
1063
+ }
1064
+ return;
1065
+ }
1066
+
1067
+ if (event.type === "session.error") {
1068
+ if (!sessionId) return;
1069
+ const error = properties.error || properties;
1070
+ if (!isContextOverflow(error)) {
1071
+ return;
1072
+ }
1073
+ if (getState(sessionId).attempt >= MAX_OVERFLOW_RECOVERIES) {
1074
+ pendingOverflow.delete(sessionId);
1075
+ return { interaction: "plugin-ignore-overflow-cap", reason: "plugin-ignore-overflow-cap" };
1076
+ }
1077
+ // Preserve the first overflow facts while compact or deferred resume is
1078
+ // in flight. Concurrent overflow must not replace them or queue compact.
1079
+ if (pendingOverflow.has(sessionId) || deferredResume.has(sessionId)) {
1080
+ return { pending: true };
1081
+ }
1082
+ pendingOverflow.set(sessionId, { error });
1083
+ startRecoveryWorker(sessionId);
1084
+ return { pending: true };
1085
+ }
1086
+ },
1087
+ };
1088
+ }
1089
+ `;
1090
+ }
1091
+ export async function installProjectOpenCodeOverflowCompactPlugin(input) {
1092
+ return installGeneratedProjectFile({
1093
+ repoRoot: input.repoRoot,
1094
+ relativePath: OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH,
1095
+ content: buildOpenCodeContextOverflowCompactPluginSource(),
1096
+ onlyIfMissing: input.onlyIfMissing,
1097
+ });
1098
+ }
1099
+ /**
1100
+ * Project-level Pi extension: normalize overflow errorMessage so native Pi
1101
+ * overflow → compact → retry can fire. Does not touch settings or model windows.
1102
+ */
1103
+ export function buildPiContextOverflowExtensionSource() {
1104
+ return `/**
1105
+ * loop-agent Pi context overflow extension
1106
+ * Path: ${PI_CONTEXT_OVERFLOW_EXTENSION_PATH}
1107
+ *
1108
+ * On assistant message_end, rewrite overflow errorMessage to start with
1109
+ * "context_length_exceeded:" so Pi's native overflow recovery can run.
1110
+ * Idempotent. Does not write user settings or model context windows.
1111
+ */
1112
+ const EXTENSION_PATH = ${JSON.stringify(PI_CONTEXT_OVERFLOW_EXTENSION_PATH)};
1113
+ ${buildGeneratedContextOverflowPatternSource()}
1114
+ const NORMALIZED_PREFIX = "context_length_exceeded:";
1115
+
1116
+ function isOverflowMessage(text) {
1117
+ if (typeof text !== "string" || !text) return false;
1118
+ return OVERFLOW_PATTERN.test(text);
1119
+ }
1120
+
1121
+ export default function loopAgentContextOverflowExtension(pi) {
1122
+ void EXTENSION_PATH;
1123
+ pi.on("message_end", (event) => {
1124
+ const message = event && event.message;
1125
+ if (!message || message.role !== "assistant") return;
1126
+ const current = message.errorMessage;
1127
+ if (typeof current !== "string" || !current) return;
1128
+ if (current.startsWith(NORMALIZED_PREFIX) || /context_length_exceeded/i.test(current)) {
1129
+ return;
1130
+ }
1131
+ if (!isOverflowMessage(current)) return;
1132
+ return {
1133
+ message: {
1134
+ ...message,
1135
+ errorMessage: NORMALIZED_PREFIX + " " + current,
1136
+ },
1137
+ };
1138
+ });
1139
+ }
1140
+ `;
1141
+ }
1142
+ export async function installProjectPiContextOverflowExtension(input) {
1143
+ return installGeneratedProjectFile({
1144
+ repoRoot: input.repoRoot,
1145
+ relativePath: PI_CONTEXT_OVERFLOW_EXTENSION_PATH,
1146
+ content: buildPiContextOverflowExtensionSource(),
1147
+ onlyIfMissing: input.onlyIfMissing,
1148
+ });
737
1149
  }
738
1150
  export async function inspectPiRetryConfig(input) {
739
1151
  const homeDir = input.homeDir ?? os.homedir();
@@ -774,10 +1186,13 @@ export async function inspectPiRetryConfig(input) {
774
1186
  };
775
1187
  }
776
1188
  }
1189
+ function skippedInstall(relativePath) {
1190
+ return { path: relativePath, written: false, reason: "skipped" };
1191
+ }
777
1192
  /**
778
- * Install project OpenCode plugin and optionally merge Pi user settings.
779
- * - auto/project: project plugin only (no home writes)
780
- * - user: project plugin + explicit Pi merge
1193
+ * Install project recovery artifacts and optionally merge Pi user settings.
1194
+ * - auto/project: transient plugin + overflow-compact plugin + Pi project extension (no home writes)
1195
+ * - user: the three project files + explicit Pi retry merge
781
1196
  * - off: skip all
782
1197
  */
783
1198
  export async function runClientRecovery(input) {
@@ -785,20 +1200,24 @@ export async function runClientRecovery(input) {
785
1200
  if (mode === "off") {
786
1201
  return {
787
1202
  mode,
788
- plugin: {
789
- path: OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
790
- written: false,
791
- reason: "skipped",
792
- },
1203
+ plugin: skippedInstall(OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH),
1204
+ overflowPlugin: skippedInstall(OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH),
1205
+ piExtension: skippedInstall(PI_CONTEXT_OVERFLOW_EXTENSION_PATH),
793
1206
  };
794
1207
  }
795
1208
  const plugin = await installProjectOpenCodePlugin({
796
1209
  repoRoot: input.repoRoot,
797
1210
  });
1211
+ const overflowPlugin = await installProjectOpenCodeOverflowCompactPlugin({
1212
+ repoRoot: input.repoRoot,
1213
+ });
1214
+ const piExtension = await installProjectPiContextOverflowExtension({
1215
+ repoRoot: input.repoRoot,
1216
+ });
798
1217
  if (mode !== "user") {
799
- return { mode, plugin };
1218
+ return { mode, plugin, overflowPlugin, piExtension };
800
1219
  }
801
1220
  const homeDir = input.homeDir ?? os.homedir();
802
1221
  const pi = await applyPiRetryMerge({ homeDir });
803
- return { mode, plugin, pi };
1222
+ return { mode, plugin, overflowPlugin, piExtension, pi };
804
1223
  }