pi-harness-runtime 1.1.49 → 1.1.71-3

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 (203) hide show
  1. package/CHANGELOG.md +30 -9
  2. package/README.md +119 -0
  3. package/harness/langchain/agents.d.ts +143 -0
  4. package/harness/langchain/agents.test.d.ts +2 -0
  5. package/harness/langchain/agents.test.ts +0 -1
  6. package/harness/langchain/agents.ts +196 -13
  7. package/harness/langchain/checkpointer.d.ts +48 -0
  8. package/harness/langchain/checkpointer.test.d.ts +8 -0
  9. package/harness/langchain/cron-cli.d.ts +12 -0
  10. package/harness/langchain/cron-cli.ts +169 -0
  11. package/harness/langchain/daemon.d.ts +161 -0
  12. package/harness/langchain/daemon.test.d.ts +11 -0
  13. package/harness/langchain/daemon.test.ts +62 -5
  14. package/harness/langchain/daemon.ts +231 -12
  15. package/harness/langchain/file-tools.d.ts +97 -0
  16. package/harness/langchain/file-tools.ts +29 -11
  17. package/harness/langchain/graph.d.ts +706 -0
  18. package/harness/langchain/graph.ts +184 -26
  19. package/harness/langchain/model-router.d.ts +63 -0
  20. package/harness/langchain/model-router.test.d.ts +2 -0
  21. package/harness/langchain/model-router.test.ts +191 -0
  22. package/harness/langchain/model-router.ts +188 -0
  23. package/harness/langchain/models.d.ts +72 -0
  24. package/harness/langchain/models.ts +111 -49
  25. package/harness/langchain/ping-pong-middleware.d.ts +28 -0
  26. package/harness/langchain/ping-pong-middleware.test.d.ts +2 -0
  27. package/harness/langchain/ping-pong-middleware.test.ts +69 -0
  28. package/harness/langchain/ping-pong-middleware.ts +204 -0
  29. package/harness/langchain/run.d.ts +15 -0
  30. package/harness/langchain/run.ts +15 -3
  31. package/harness/langchain/scoreboard.test.d.ts +13 -0
  32. package/harness/langchain/scoreboard.test.ts +28 -5
  33. package/harness/langchain/status-line.d.ts +91 -0
  34. package/harness/langchain/status-line.test.d.ts +8 -0
  35. package/harness/langchain/surge.d.ts +103 -0
  36. package/harness/langchain/surge.test.d.ts +10 -0
  37. package/harness/langchain/surge.test.ts +179 -0
  38. package/harness/langchain/surge.ts +188 -19
  39. package/harness/langchain/widget.d.ts +105 -0
  40. package/harness/langchain/widget.test.d.ts +8 -0
  41. package/harness/ssh-detach-interceptor.test.ts +263 -0
  42. package/harness/ssh-detach-interceptor.ts +438 -0
  43. package/index.ts +212 -47
  44. package/package.json +4 -2
  45. package/packages/a2a-adapter/package.json +1 -1
  46. package/packages/architecture-generator/package.json +1 -1
  47. package/packages/auth/package.json +1 -1
  48. package/packages/autonomous-refactor/package.json +1 -1
  49. package/packages/autonomous-runtime/package.json +1 -1
  50. package/packages/cache-strategy/package.json +1 -1
  51. package/packages/capability-registry/package.json +1 -1
  52. package/packages/checkpoint/package.json +1 -1
  53. package/packages/cli-plugin-sdk/package.json +1 -1
  54. package/packages/clipboard/package.json +1 -1
  55. package/packages/clipboard-plugin/package.json +1 -1
  56. package/packages/code-generation/package.json +1 -1
  57. package/packages/code-review/package.json +1 -1
  58. package/packages/codex-adapter/package.json +1 -1
  59. package/packages/config-capture/package.json +1 -1
  60. package/packages/context-compiler/package.json +1 -1
  61. package/packages/context-discovery/package.json +1 -1
  62. package/packages/context-manager/package.json +1 -1
  63. package/packages/cookie-sanitizer/package.json +1 -1
  64. package/packages/cost-optimizer/package.json +1 -1
  65. package/packages/dependency-analyzer/package.json +1 -1
  66. package/packages/django-plugin/package.json +1 -1
  67. package/packages/doc-generator/package.json +1 -1
  68. package/packages/evaluation-engine/package.json +1 -1
  69. package/packages/evaluation-runner/package.json +1 -1
  70. package/packages/event-bus/package.json +1 -1
  71. package/packages/event-store/package.json +1 -1
  72. package/packages/experience-replay/package.json +1 -1
  73. package/packages/feedback-collector/package.json +1 -1
  74. package/packages/file-copy-helper/package.json +1 -1
  75. package/packages/framework-detector/package.json +1 -1
  76. package/packages/framework-plugin-sdk/package.json +1 -1
  77. package/packages/frappe-plugin/package.json +1 -1
  78. package/packages/generic-web-plugin/package.json +1 -1
  79. package/packages/health-monitor/package.json +1 -1
  80. package/packages/intent-analyzer/dist/src/index.d.ts +2 -0
  81. package/packages/intent-analyzer/dist/src/index.d.ts.map +1 -1
  82. package/packages/intent-analyzer/dist/src/index.js +1 -0
  83. package/packages/intent-analyzer/dist/src/index.js.map +1 -1
  84. package/packages/intent-analyzer/dist/src/ping-pong.d.ts +56 -0
  85. package/packages/intent-analyzer/dist/src/ping-pong.d.ts.map +1 -0
  86. package/packages/intent-analyzer/dist/src/ping-pong.js +286 -0
  87. package/packages/intent-analyzer/dist/src/ping-pong.js.map +1 -0
  88. package/packages/intent-analyzer/package.json +1 -1
  89. package/packages/intent-analyzer/src/index.ts +13 -0
  90. package/packages/intent-analyzer/src/ping-pong.ts +387 -0
  91. package/packages/intent-analyzer/test/ping-pong.test.ts +96 -0
  92. package/packages/jev-judge/README.md +234 -0
  93. package/packages/jev-judge/dist/decision-engine.d.ts +100 -0
  94. package/packages/jev-judge/dist/decision-engine.d.ts.map +1 -0
  95. package/packages/jev-judge/dist/decision-engine.js +151 -0
  96. package/packages/jev-judge/dist/decision-engine.js.map +1 -0
  97. package/packages/jev-judge/dist/e2e-judge.d.ts +61 -0
  98. package/packages/jev-judge/dist/e2e-judge.d.ts.map +1 -0
  99. package/packages/jev-judge/dist/e2e-judge.js +268 -0
  100. package/packages/jev-judge/dist/e2e-judge.js.map +1 -0
  101. package/packages/jev-judge/dist/flaky-detector.d.ts +34 -0
  102. package/packages/jev-judge/dist/flaky-detector.d.ts.map +1 -0
  103. package/packages/jev-judge/dist/flaky-detector.js +154 -0
  104. package/packages/jev-judge/dist/flaky-detector.js.map +1 -0
  105. package/packages/jev-judge/dist/index.d.ts +77 -0
  106. package/packages/jev-judge/dist/index.d.ts.map +1 -0
  107. package/packages/jev-judge/dist/index.js +271 -0
  108. package/packages/jev-judge/dist/index.js.map +1 -0
  109. package/packages/jev-judge/dist/types.d.ts +97 -0
  110. package/packages/jev-judge/dist/types.d.ts.map +1 -0
  111. package/packages/jev-judge/dist/types.js +8 -0
  112. package/packages/jev-judge/dist/types.js.map +1 -0
  113. package/packages/jev-judge/package.json +46 -0
  114. package/packages/jev-judge/src/decision-engine.ts +231 -0
  115. package/packages/jev-judge/src/e2e-judge.ts +364 -0
  116. package/packages/jev-judge/src/flaky-detector.ts +190 -0
  117. package/packages/jev-judge/src/index.ts +345 -0
  118. package/packages/jev-judge/src/types.ts +131 -0
  119. package/packages/knowledge-graph/package.json +1 -1
  120. package/packages/knowledge-retrieval/package.json +1 -1
  121. package/packages/laravel-plugin/package.json +1 -1
  122. package/packages/learning-engine/package.json +1 -1
  123. package/packages/mcp-adapter/package.json +1 -1
  124. package/packages/memory-engine/package.json +1 -1
  125. package/packages/milestone-manager/package.json +1 -1
  126. package/packages/model-registry/package.json +1 -1
  127. package/packages/nextjs-plugin/package.json +1 -1
  128. package/packages/notification/package.json +1 -1
  129. package/packages/observability/package.json +1 -1
  130. package/packages/okf-indexer/package.json +1 -1
  131. package/packages/performance-optimizer/package.json +1 -1
  132. package/packages/privilege-broker/package.json +1 -1
  133. package/packages/project-analyzer/package.json +1 -1
  134. package/packages/project-bootstrap/package.json +1 -1
  135. package/packages/projection-engine/package.json +1 -1
  136. package/packages/prompt-compiler/package.json +1 -1
  137. package/packages/prompt-versioning/package.json +1 -1
  138. package/packages/provider-adapter-sdk/package.json +1 -1
  139. package/packages/provider-router/package.json +1 -1
  140. package/packages/provider-selector/package.json +1 -1
  141. package/packages/providers/package.json +1 -1
  142. package/packages/quota-manager/package.json +1 -1
  143. package/packages/rate-limiter/package.json +1 -1
  144. package/packages/react-vite-plugin/package.json +1 -1
  145. package/packages/release-manager/package.json +1 -1
  146. package/packages/requirement-compiler/package.json +1 -1
  147. package/packages/runtime/package.json +1 -1
  148. package/packages/scheduler/package.json +1 -1
  149. package/packages/scheduler-adapter/package.json +1 -1
  150. package/packages/session/package.json +1 -1
  151. package/packages/session-api/package.json +1 -1
  152. package/packages/session-export/package.json +1 -1
  153. package/packages/shared-context/package.json +1 -1
  154. package/packages/skill-mcp-client/package.json +1 -1
  155. package/packages/skill-registry/package.json +1 -1
  156. package/packages/sprint-planner/package.json +1 -1
  157. package/packages/subscription-engine/package.json +1 -1
  158. package/packages/task-compiler/package.json +1 -1
  159. package/packages/tencentdb-memory/package.json +1 -1
  160. package/packages/tencentdb-sync/package.json +1 -1
  161. package/packages/test-data-generator/package.json +1 -1
  162. package/packages/test-generator/package.json +1 -1
  163. package/packages/todo-bd-sync/README.md +71 -0
  164. package/packages/todo-bd-sync/package.json +1 -1
  165. package/packages/todo-bd-sync/src/extension.ts +16 -1
  166. package/packages/token-estimation/package.json +1 -1
  167. package/packages/token-optimizer/package.json +1 -1
  168. package/packages/trajectory/dist/src/approved-patterns.d.ts +51 -0
  169. package/packages/trajectory/dist/src/approved-patterns.d.ts.map +1 -0
  170. package/packages/trajectory/dist/src/approved-patterns.js +134 -0
  171. package/packages/trajectory/dist/src/approved-patterns.js.map +1 -0
  172. package/packages/trajectory/dist/src/index.d.ts +13 -0
  173. package/packages/trajectory/dist/src/index.d.ts.map +1 -0
  174. package/packages/trajectory/dist/src/index.js +12 -0
  175. package/packages/trajectory/dist/src/index.js.map +1 -0
  176. package/packages/trajectory/dist/src/store.d.ts +69 -0
  177. package/packages/trajectory/dist/src/store.d.ts.map +1 -0
  178. package/packages/trajectory/dist/src/store.js +253 -0
  179. package/packages/trajectory/dist/src/store.js.map +1 -0
  180. package/packages/trajectory/dist/src/types.d.ts +73 -0
  181. package/packages/trajectory/dist/src/types.d.ts.map +1 -0
  182. package/packages/trajectory/dist/src/types.js +9 -0
  183. package/packages/trajectory/dist/src/types.js.map +1 -0
  184. package/packages/trajectory/dist/test/store.test.d.ts +2 -0
  185. package/packages/trajectory/dist/test/store.test.d.ts.map +1 -0
  186. package/packages/trajectory/dist/test/store.test.js +228 -0
  187. package/packages/trajectory/dist/test/store.test.js.map +1 -0
  188. package/packages/trajectory/package.json +29 -0
  189. package/packages/trajectory/src/approved-patterns.ts +180 -0
  190. package/packages/trajectory/src/index.ts +26 -0
  191. package/packages/trajectory/src/store.ts +285 -0
  192. package/packages/trajectory/src/types.ts +84 -0
  193. package/packages/trajectory/test/store.test.d.ts +2 -0
  194. package/packages/trajectory/test/store.test.ts +250 -0
  195. package/packages/tui/package.json +1 -1
  196. package/packages/types/package.json +1 -1
  197. package/packages/workflow-events/package.json +1 -1
  198. package/packages/workspace-scanner/package.json +1 -1
  199. package/packages/worktree/package.json +1 -1
  200. package/packages/write-review/package.json +1 -1
  201. package/packages/write-review/src/blackboard.ts +38 -5
  202. package/packages/write-review/src/types.ts +13 -0
  203. package/skills/harness-runtime/SKILL.md +67 -4
@@ -212,3 +212,182 @@ describe("T8 — loop deps surviving a mid-graph 529", () => {
212
212
  assert.deepEqual(sleeps, [100]);
213
213
  });
214
214
  });
215
+
216
+ // ─── GLM Quota Tests ─────────────────────────────────────────────────────────
217
+
218
+ import { classifyGLMQuota, invokeWithGLMRetry } from "./surge.js";
219
+
220
+ describe("classifyGLMQuota", () => {
221
+ // The dates in the error messages are in the past (2026-09-04).
222
+ // classifyGLMQuota shifts them to the NEXT occurrence (+1 day).
223
+ // The tests verify: shift happened + time preserved verbatim.
224
+ it("parses GLM 1308 error with reset timestamp", () => {
225
+ const err = new Error(
226
+ '429: {"code":"1308","message":"Usage limit reached for 5 hour. Your limit will reset at 2026-09-04 20:29:24"}',
227
+ );
228
+ const sig = classifyGLMQuota(err);
229
+ assert.ok(sig, "should be classified as GLM quota");
230
+ // 2026-09-04 is past → shifted to 2026-09-05; epoch must be in the future
231
+ assert.ok(sig!.resetAtEpoch > Date.now(), "resetAtEpoch is in the future");
232
+ assert.ok(
233
+ sig!.resetAt.includes("2026-09-05"),
234
+ "date shifted to next occurrence",
235
+ );
236
+ assert.ok(sig!.resetAt.includes("20:29:24"), "time preserved verbatim");
237
+ });
238
+
239
+ it("parses 1308 from plain string error", () => {
240
+ const err =
241
+ '{"code":"1308","message":"Usage limit reached. Your limit will reset at 2026-09-04 20:29:24"}';
242
+ const sig = classifyGLMQuota(err);
243
+ assert.ok(sig, "should be classified as GLM quota");
244
+ // space separator → normalised to T; past date → shifted to next occurrence
245
+ assert.ok(
246
+ sig!.resetAt.includes("2026-09-05"),
247
+ "space normalised and date shifted",
248
+ );
249
+ });
250
+
251
+ it("parses reset time with T separator", () => {
252
+ const err = new Error(
253
+ '{"code":"1308","message":"Usage limit reached. Your limit will reset at 2026-09-04T20:29:24"}',
254
+ );
255
+ const sig = classifyGLMQuota(err);
256
+ assert.ok(sig, "should be classified as GLM quota");
257
+ // past date → next occurrence (2026-09-05); T separator preserved verbatim
258
+ assert.ok(
259
+ sig!.resetAt.includes("2026-09-05"),
260
+ "date shifted to next occurrence",
261
+ );
262
+ assert.ok(sig!.resetAt.includes("T"), "T separator preserved verbatim");
263
+ assert.ok(sig!.resetAt.includes("20:29:24"), "time preserved verbatim");
264
+ });
265
+
266
+ it("returns null for non-GLM errors", () => {
267
+ const err = new Error("529: overloaded error");
268
+ assert.equal(classifyGLMQuota(err), null);
269
+ });
270
+
271
+ it("returns null for plain errors without 1308", () => {
272
+ const err = new Error("Something went wrong");
273
+ assert.equal(classifyGLMQuota(err), null);
274
+ });
275
+
276
+ it("1308 without reset time uses 5-min fallback", () => {
277
+ const err = new Error('{"code":"1308","message":"Usage limit reached"}');
278
+ const sig = classifyGLMQuota(err);
279
+ assert.ok(sig, "should be classified");
280
+ const diffMs = sig!.resetAtEpoch - Date.now();
281
+ // Should be approximately 5 minutes
282
+ assert.ok(diffMs >= 4 * 60_000 && diffMs <= 6 * 60_000, "5-min fallback");
283
+ });
284
+ });
285
+
286
+ describe("invokeWithGLMRetry", () => {
287
+ it("succeeds immediately when fn succeeds", async () => {
288
+ const result = await invokeWithGLMRetry(() => Promise.resolve("ok"));
289
+ assert.equal(result, "ok");
290
+ });
291
+
292
+ it("throws non-GLM errors immediately", async () => {
293
+ await assert.rejects(
294
+ () =>
295
+ invokeWithGLMRetry(() => {
296
+ throw new Error("not a GLM error");
297
+ }),
298
+ /not a GLM error/,
299
+ );
300
+ });
301
+
302
+ it("waits until reset time before retry", async () => {
303
+ const sleeps: number[] = [];
304
+ const sleep = async (ms: number) => void sleeps.push(ms);
305
+ // Use a reset time 100ms in the future
306
+ const resetIn = 100;
307
+ const resetAt = new Date(Date.now() + resetIn).toISOString();
308
+
309
+ let attempts = 0;
310
+ const result = await invokeWithGLMRetry(
311
+ async () => {
312
+ attempts++;
313
+ if (attempts === 1) {
314
+ throw new Error(
315
+ `{"code":"1308","message":"Usage limit. Reset at ${resetAt}"}`,
316
+ );
317
+ }
318
+ return "success after retry";
319
+ },
320
+ { sleep, tickMs: 10 },
321
+ );
322
+
323
+ assert.equal(result, "success after retry");
324
+ assert.equal(attempts, 2, "retried after wait");
325
+ assert.ok(sleeps.length >= 1, "slept at least once during countdown");
326
+ assert.ok(
327
+ sleeps.every((ms) => ms === 10),
328
+ "tick interval used",
329
+ );
330
+ });
331
+
332
+ it("fires onGLMQuota callback during countdown", async () => {
333
+ const ticks: string[] = [];
334
+ const sleep = async (ms: number) => void ticks.push(`sleep:${ms}`);
335
+ const resetAt = new Date(Date.now() + 50).toISOString();
336
+
337
+ let attempts = 0;
338
+ await invokeWithGLMRetry(
339
+ async () => {
340
+ attempts++;
341
+ if (attempts === 1) {
342
+ throw new Error(
343
+ `{"code":"1308","message":"Usage limit. Reset at ${resetAt}"}`,
344
+ );
345
+ }
346
+ return "done";
347
+ },
348
+ {
349
+ sleep,
350
+ tickMs: 10,
351
+ onGLMQuota: (sig) => ticks.push(`quota:${sig.resetAtEpoch}`),
352
+ },
353
+ );
354
+
355
+ assert.ok(
356
+ ticks.some((t) => t.startsWith("quota:")),
357
+ "onGLMQuota was called at least once",
358
+ );
359
+ });
360
+
361
+ it("fires onGLMRetry callback after countdown expires", async () => {
362
+ const events: string[] = [];
363
+ const sleep = async (_ms: number) => void null;
364
+ const resetAt = new Date(Date.now() + 20).toISOString();
365
+
366
+ let attempts = 0;
367
+ await invokeWithGLMRetry(
368
+ async () => {
369
+ attempts++;
370
+ if (attempts === 1) {
371
+ throw new Error(
372
+ `{"code":"1308","message":"Usage limit. Reset at ${resetAt}"}`,
373
+ );
374
+ }
375
+ return "done";
376
+ },
377
+ {
378
+ sleep,
379
+ tickMs: 10,
380
+ onGLMQuota: () => events.push("quota"),
381
+ onGLMRetry: () => events.push("retry"),
382
+ },
383
+ );
384
+
385
+ assert.ok(events.includes("quota"), "quota fired");
386
+ assert.ok(events.includes("retry"), "retry fired");
387
+ // retry fires AFTER quota
388
+ assert.ok(
389
+ events.indexOf("retry") > events.indexOf("quota"),
390
+ "retry after quota",
391
+ );
392
+ });
393
+ });
@@ -1,15 +1,10 @@
1
1
  /**
2
- * Surge auto-resume — 529 / overloaded_error detection, escalation backoff,
3
- * and a retry wrapper for the daemon's graph invocations.
2
+ * Surge auto-resume — 529 / overloaded_error (MiniMax) + 1308 (GLM quota) detection,
3
+ * escalation backoff, and retry wrappers for the daemon's graph invocations.
4
4
  *
5
- * Implements S2 + S4 of wiki/peak-hour-surge-auto-resume.md:
6
- * seconds-scale blits stay in the provider client; minutes-scale surges are
7
- * handled HERE with scheduled pauses (3 → 6 → 12 min, jittered, capped).
8
- *
9
- * Verdict vocabulary and patterns intentionally match:
10
- * - harness/loop-runtime.ts 529 path (commit 9ae0ffd)
11
- * - harness/e2e/glm-quota-scraper.ts parseMinimaxOverloadResetTime()
12
- * - packages/providers/src/adapters.ts isOverloaded
5
+ * Two wrappers:
6
+ * invokeWithSurgeRetry — MiniMax 529/overload; exponential backoff
7
+ * invokeWithGLMRetry — GLM 1308 quota; waits until reset time
13
8
  *
14
9
  * Wiki: wiki/peak-hour-surge-auto-resume.md
15
10
  */
@@ -37,7 +32,7 @@ const SIMPLE_DELAY = /retry (?:after|in) (\d+)\s*(seconds?|secs?|s)\b/i;
37
32
  const RETRY_AFTER_MS = /retry.?after[^0-9]{0,12}(\d{4,})\s*ms/i;
38
33
 
39
34
  /**
40
- * Classify an unknown error as a transient provider surge.
35
+ * Classify an unknown error as a transient provider surge (MiniMax).
41
36
  * Returns null for anything that is not a 529/overload-class failure.
42
37
  */
43
38
  export function classifySurge(err: unknown): SurgeSignal | null {
@@ -92,6 +87,87 @@ export function classifySurge(err: unknown): SurgeSignal | null {
92
87
  return { retryAfterMs: 120_000, explicit: false, sourceText };
93
88
  }
94
89
 
90
+ // ─── GLM Quota Classification ─────────────────────────────────────────────────
91
+
92
+ /** Matches the reset-at timestamp from a GLM 1308 error. */
93
+ const GLM_RESET_AT = /reset at (\d{4}-\d{2}-\d{2}[T ]?\d{2}:\d{2}:\d{2})/i;
94
+
95
+ /**
96
+ * Add 1 calendar day to an ISO date string "YYYY-MM-DDTHH:MM:SS".
97
+ * Used to shift the reset date when it has already passed.
98
+ */
99
+ function _shiftDateByOneDay(isoDateTime: string): string {
100
+ const d = new Date(isoDateTime + "Z");
101
+ d.setUTCDate(d.getUTCDate() + 1);
102
+ // Re-format: YYYY-MM-DDTHH:MM:SS (always 19 chars, seconds included)
103
+ const yyyy = d.getUTCFullYear();
104
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
105
+ const dd = String(d.getUTCDate()).padStart(2, "0");
106
+ // Preserve the time part from the original string
107
+ const timePart = isoDateTime.slice(11); // "HH:MM:SS"
108
+ return `${yyyy}-${mm}-${dd}T${timePart}`;
109
+ }
110
+
111
+ /**
112
+ * Classify a GLM 1308 quota exhaustion error.
113
+ * Returns the reset-at ISO timestamp, or null if not a GLM quota error.
114
+ */
115
+ export function classifyGLMQuota(err: unknown): GLMQuotaSignal | null {
116
+ const sourceText =
117
+ err instanceof Error
118
+ ? err.message
119
+ : typeof err === "string"
120
+ ? err
121
+ : String(err);
122
+
123
+ // Detect GLM 1308 — quota exhaustion
124
+ const has1308 = /"code"\s*:\s*"1308"|code.*1308/i.test(sourceText);
125
+ const hasReset = GLM_RESET_AT.test(sourceText);
126
+ if (!has1308 && !hasReset) return null;
127
+
128
+ // Extract reset timestamp
129
+ const match = sourceText.match(GLM_RESET_AT);
130
+ if (!match) {
131
+ // 1308 without a reset time — use a conservative default (5 min)
132
+ return {
133
+ resetAt: new Date(Date.now() + 5 * 60_000).toISOString(),
134
+ resetAtEpoch: Date.now() + 5 * 60_000,
135
+ sourceText,
136
+ };
137
+ }
138
+
139
+ // Normalise: space → T, then ensure HH:MM:SS (append :00 if only HH:MM)
140
+ const normalised = match[1].replace(" ", "T");
141
+ // "2026-09-04T20:29:24" (19 chars, with seconds) → use as-is
142
+ // "2026-09-04T20:29" (16 chars, no seconds) → append :00
143
+ const resetAt = normalised.length === 16 ? `${normalised}:00` : normalised;
144
+ // Parse as UTC so the date string is preserved exactly (no TZ shift).
145
+ const resetAtEpoch = new Date(resetAt + "Z").getTime();
146
+ const now = Date.now();
147
+
148
+ // If the parsed time is in the past, the date is already "next occurrence".
149
+ // Preserve the original resetAt string so callers can include it verbatim.
150
+ const adjustedResetAt =
151
+ resetAtEpoch <= now ? _shiftDateByOneDay(resetAt) : resetAt;
152
+ const adjustedEpoch =
153
+ resetAtEpoch <= now ? resetAtEpoch + 24 * 60 * 60 * 1000 : resetAtEpoch;
154
+
155
+ return {
156
+ resetAt: adjustedResetAt,
157
+ resetAtEpoch: adjustedEpoch,
158
+ sourceText,
159
+ };
160
+ }
161
+
162
+ export interface GLMQuotaSignal {
163
+ /** ISO timestamp when the quota resets */
164
+ resetAt: string;
165
+ /** Epoch ms when the quota resets */
166
+ resetAtEpoch: number;
167
+ /** Raw error text for logs */
168
+ sourceText: string;
169
+ }
170
+
95
171
  // ─── Policy ─────────────────────────────────────────────────────────────────
96
172
 
97
173
  export interface SurgePolicy {
@@ -163,18 +239,29 @@ export class SurgeScheduler {
163
239
  }
164
240
  }
165
241
 
166
- // ─── Retry wrapper ──────────────────────────────────────────────────────────
242
+ // ─── Retry wrappers ─────────────────────────────────────────────────────────
167
243
 
168
244
  export interface SurgeRetryOptions {
169
245
  policy?: Partial<SurgePolicy>;
246
+ /**
247
+ * Pre-existing scheduler to use. When provided, the attempt counter persists
248
+ * across calls — enabling true exponential escalation across multiple surges
249
+ * within the same task (e.g. surge on 2nd loop.invoke, surge on 3rd loop.invoke).
250
+ * If omitted, a fresh scheduler is created (attempt counter resets per call).
251
+ */
252
+ scheduler?: SurgeScheduler;
170
253
  /** Called before each surge pause (logging / notifications) */
171
254
  onSurge?: (info: {
172
255
  attempt: number;
173
256
  delayMs: number;
174
257
  signal: SurgeSignal;
175
258
  }) => void;
176
- /** Called once when attempts are exhausted (before rethrow) */
177
- onExhausted?: (signal: SurgeSignal) => void;
259
+ /** Called once when attempts are exhausted (no more retries in this scheduler).
260
+ * Can be async to allow sleeping. Return true to retry, false/undefined to throw.
261
+ */
262
+ onExhausted?: (
263
+ signal: SurgeSignal,
264
+ ) => boolean | void | Promise<boolean | void>;
178
265
  /** Injectable sleep for tests. Default: real setTimeout */
179
266
  sleep?: (ms: number) => Promise<void>;
180
267
  }
@@ -182,15 +269,16 @@ export interface SurgeRetryOptions {
182
269
  const realSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
183
270
 
184
271
  /**
185
- * Run `fn`, retrying only on surge-classified failures with escalated,
186
- * jittered pauses. Non-surge errors rethrow immediately. On exhaustion the
187
- * last error is rethrown after `onExhausted`.
272
+ * Run `fn`, retrying only on surge-classified failures (MiniMax 529/overload)
273
+ * with exponential backoff. Non-surge errors rethrow immediately.
188
274
  */
189
275
  export async function invokeWithSurgeRetry<T>(
190
276
  fn: () => Promise<T>,
191
277
  opts: SurgeRetryOptions = {},
192
278
  ): Promise<T> {
193
- const scheduler = new SurgeScheduler(opts.policy);
279
+ // Reuse provided scheduler to persist attempt counter across calls.
280
+ // A fresh scheduler is created only when none is provided.
281
+ const scheduler = opts.scheduler ?? new SurgeScheduler(opts.policy);
194
282
  const sleep = opts.sleep ?? realSleep;
195
283
 
196
284
  for (;;) {
@@ -202,7 +290,12 @@ export async function invokeWithSurgeRetry<T>(
202
290
 
203
291
  const attempt = scheduler.nextAttempt();
204
292
  if (attempt === null) {
205
- opts.onExhausted?.(signal);
293
+ // Scheduler exhausted — call onExhausted. If it returns true, do NOT throw
294
+ // (caller handled it, e.g. slept and will retry). If false/undefined, throw.
295
+ const handled = await opts.onExhausted?.(signal);
296
+ if (handled === true) {
297
+ continue; // retry
298
+ }
206
299
  throw err;
207
300
  }
208
301
 
@@ -215,3 +308,79 @@ export async function invokeWithSurgeRetry<T>(
215
308
  }
216
309
  }
217
310
  }
311
+
312
+ // ─── GLM Quota Retry ────────────────────────────────────────────────────────
313
+
314
+ export interface GLMRetryOptions {
315
+ /** Called when GLM quota exhaustion is detected (for status-line countdown) */
316
+ onGLMQuota?: (signal: GLMQuotaSignal) => void;
317
+ /** Called after the countdown expires and the loop retries */
318
+ onGLMRetry?: (signal: GLMQuotaSignal) => void;
319
+ /** Called when the GLM quota error is non-retryable (e.g., no reset time) */
320
+ onGLMExhausted?: (signal: GLMQuotaSignal) => void;
321
+ /** Injectable sleep for tests. Default: real setTimeout */
322
+ sleep?: (ms: number) => Promise<void>;
323
+ /** How often to tick the countdown callback (default: 30s) */
324
+ tickMs?: number;
325
+ }
326
+
327
+ /**
328
+ * Run `fn`, retrying on GLM 1308 quota errors.
329
+ * After the first 1308, waits until the reset time before retrying.
330
+ * Subsequent 1308s within the same wait window are silently ignored.
331
+ * Non-GLM-quota errors rethrow immediately.
332
+ */
333
+ export async function invokeWithGLMRetry<T>(
334
+ fn: () => Promise<T>,
335
+ opts: GLMRetryOptions = {},
336
+ ): Promise<T> {
337
+ const sleep = opts.sleep ?? realSleep;
338
+ const tickMs = opts.tickMs ?? 30_000;
339
+
340
+ let waitingForReset = false;
341
+
342
+ while (true) {
343
+ try {
344
+ return await fn();
345
+ } catch (err) {
346
+ const signal = classifyGLMQuota(err);
347
+
348
+ if (!signal) throw err; // not a GLM quota error — fail fast
349
+
350
+ // If we are already waiting for a reset and get another 1308,
351
+ // ignore it — the existing wait is already in progress.
352
+ if (waitingForReset) {
353
+ // Just log and continue waiting (the sleep loop handles the timeout)
354
+ await sleep(tickMs);
355
+ continue;
356
+ }
357
+
358
+ const delayMs = Math.max(0, signal.resetAtEpoch - Date.now());
359
+
360
+ opts.onGLMQuota?.(signal);
361
+ waitingForReset = true;
362
+
363
+ if (delayMs <= 0) {
364
+ // Reset time already passed — retry immediately
365
+ waitingForReset = false;
366
+ opts.onGLMRetry?.(signal);
367
+ continue;
368
+ }
369
+
370
+ // Wait in chunks so onGLMQuota fires periodically (drives countdown display)
371
+ const startMs = Date.now();
372
+ while (Date.now() - startMs < delayMs) {
373
+ await sleep(tickMs);
374
+ // Re-fire the callback to refresh the countdown display
375
+ opts.onGLMQuota?.({
376
+ ...signal,
377
+ resetAtEpoch: signal.resetAtEpoch, // unchanged
378
+ });
379
+ }
380
+
381
+ // Countdown expired — retry
382
+ waitingForReset = false;
383
+ opts.onGLMRetry?.(signal);
384
+ }
385
+ }
386
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * LoopWidget — harness progress display for the pi host status line.
3
+ *
4
+ * Architecture mirrors pi-lens's `clients/widget-state.ts` + `renderWidget()`:
5
+ * - State store: per-file step counts, iteration, phase
6
+ * - Render function: builds the `!NW ●ME` style summary line
7
+ * - Pi host integration: wired via ExtensionUIContext.setWidget() when available
8
+ *
9
+ * The widget is designed as a dependency injected into LoopDaemon/LoopRuntime,
10
+ * so it works in two modes:
11
+ * 1. Standalone (console): logs human-readable progress to stderr
12
+ * 2. Pi host: calls ui.setWidget("harness-loop", renderFn) for TUI display
13
+ *
14
+ * Wiki: wiki/multi-agent-langchain.md
15
+ */
16
+ import type { TUI } from "@earendil-works/pi-tui";
17
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
+ /** Valid theme color keys passed to `theme.fg()`.*/
19
+ type ColorKey = "dim" | "error" | "warning" | "success" | "accent";
20
+ export type LoopPhase = "idle" | "planning" | "writing" | "reviewing" | "fixing" | "approving" | "blocked" | "surge-pause" | "complete" | "error";
21
+ export interface LoopWidgetOptions {
22
+ /** Called with the rendered lines each time the widget updates. */
23
+ onRender?: (lines: string[]) => void;
24
+ }
25
+ export declare class LoopWidget {
26
+ private files;
27
+ private phase;
28
+ private iteration;
29
+ private maxIterations;
30
+ private stepLabel;
31
+ private errors;
32
+ private warnings;
33
+ private readonly onRender?;
34
+ constructor(options?: LoopWidgetOptions);
35
+ /** Called when the loop starts a new iteration. */
36
+ startIteration(iter: number, max: number): void;
37
+ /** Called when the loop transitions to a new phase. */
38
+ setPhase(phase: LoopPhase, label?: string): void;
39
+ /** Called when the coder writes to a file. */
40
+ recordWrite(filePath: string): void;
41
+ /** Called when the reviewer reports blockers for a file. */
42
+ recordBlockers(filePath: string, count: number): void;
43
+ /** Called when the reviewer passes a file (no blockers). */
44
+ recordReviewPass(filePath: string): void;
45
+ /** Called on surge-pause / quota-wait events. */
46
+ setSurgePause(resetAt?: Date): void;
47
+ /** Called on unrecoverable error. */
48
+ setError(message: string): void;
49
+ /** Called when the loop completes with a verdict. */
50
+ setComplete(verdict: "approved" | "rejected" | "max_iterations"): void;
51
+ /** Reset to idle state. */
52
+ reset(): void;
53
+ /**
54
+ * Build widget lines for the pi host's `setWidget()` renderer.
55
+ *
56
+ * Returns 0-3 lines:
57
+ * Line 1: `harness [iter 2/5] ✍ coder !2W` — header + summary
58
+ * Line 2: ` surge pause — resets 19:17` — surge detail (when applicable)
59
+ * Line 3: ` ●surge.ts ✍2 🔍3 !1` — file rows (up to 5)
60
+ */
61
+ renderWidget(width: number, theme: {
62
+ fg: (color: ColorKey, s: string) => string;
63
+ }): string[];
64
+ /** Console fallback (standalone mode). */
65
+ toConsoleString(): string;
66
+ /**
67
+ * Returns a pi-tui Component factory for use with `ui.setWidget("harness-loop", factory)`.
68
+ * The caller (pi host integration) owns the `setWidget` call — this factory is just
69
+ * the render function.
70
+ *
71
+ * Usage in pi host:
72
+ * ```
73
+ * setWidget("harness-loop", makeWidgetRenderer(widget, theme), { placement: "belowEditor" });
74
+ * ```
75
+ */
76
+ /**
77
+ * Returns a factory suitable for `ui.setWidget("harness-loop", factory)` in the
78
+ * pi host. The factory's `render(width)` is called by the pi-tui render loop
79
+ * on every frame; `invalidate` is called by the harness to trigger a re-render.
80
+ */
81
+ makeRenderer(): {
82
+ factory: (tui: TUI, theme: Theme) => {
83
+ render: (width: number) => string[];
84
+ invalidate: () => void;
85
+ dispose: () => void;
86
+ };
87
+ invalidate: () => void;
88
+ };
89
+ private getOrCreate;
90
+ private fileRecords;
91
+ private fileBasename;
92
+ private classifyFileTier;
93
+ private phaseLabel;
94
+ private phaseIcon;
95
+ private formatResetTime;
96
+ /**
97
+ * Truncate `line` to `width` visible columns.
98
+ * Uses `visibleWidth` (not raw char count) so ANSI codes don't cause
99
+ * false wrapping — matching how the terminal measures columns.
100
+ */
101
+ private fitLine;
102
+ private render;
103
+ }
104
+ export declare function getLoopWidget(): LoopWidget;
105
+ //# sourceMappingURL=widget.d.ts.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * LoopWidget tests — T14
3
+ *
4
+ * Run:
5
+ * bun test harness/langchain/widget.test.ts
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=widget.test.d.ts.map