@cluesmith/codev 3.3.2 → 3.3.4

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 (257) hide show
  1. package/dashboard-dist/assets/{index-D3XaKpZW.js → index-DZf6pNPE.js} +41 -41
  2. package/dashboard-dist/assets/index-DZf6pNPE.js.map +1 -0
  3. package/dashboard-dist/assets/index-DnYLy5C_.css +32 -0
  4. package/dashboard-dist/index.html +2 -2
  5. package/dist/agent-farm/cli.d.ts.map +1 -1
  6. package/dist/agent-farm/cli.js +48 -0
  7. package/dist/agent-farm/cli.js.map +1 -1
  8. package/dist/agent-farm/commands/ide.d.ts +20 -0
  9. package/dist/agent-farm/commands/ide.d.ts.map +1 -0
  10. package/dist/agent-farm/commands/ide.js +138 -0
  11. package/dist/agent-farm/commands/ide.js.map +1 -0
  12. package/dist/agent-farm/commands/inbox.d.ts.map +1 -1
  13. package/dist/agent-farm/commands/inbox.js +79 -10
  14. package/dist/agent-farm/commands/inbox.js.map +1 -1
  15. package/dist/agent-farm/commands/interrupt.d.ts +4 -1
  16. package/dist/agent-farm/commands/interrupt.d.ts.map +1 -1
  17. package/dist/agent-farm/commands/interrupt.js +6 -3
  18. package/dist/agent-farm/commands/interrupt.js.map +1 -1
  19. package/dist/agent-farm/commands/reset.d.ts +4 -1
  20. package/dist/agent-farm/commands/reset.d.ts.map +1 -1
  21. package/dist/agent-farm/commands/reset.js +6 -3
  22. package/dist/agent-farm/commands/reset.js.map +1 -1
  23. package/dist/agent-farm/commands/send.d.ts +28 -0
  24. package/dist/agent-farm/commands/send.d.ts.map +1 -1
  25. package/dist/agent-farm/commands/send.js +79 -6
  26. package/dist/agent-farm/commands/send.js.map +1 -1
  27. package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -1
  28. package/dist/agent-farm/commands/spawn-worktree.js +16 -7
  29. package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
  30. package/dist/agent-farm/commands/tower.d.ts +21 -9
  31. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  32. package/dist/agent-farm/commands/tower.js +90 -30
  33. package/dist/agent-farm/commands/tower.js.map +1 -1
  34. package/dist/agent-farm/db/index.d.ts +3 -1
  35. package/dist/agent-farm/db/index.d.ts.map +1 -1
  36. package/dist/agent-farm/db/index.js +8 -443
  37. package/dist/agent-farm/db/index.js.map +1 -1
  38. package/dist/agent-farm/db/mailbox.d.ts +47 -7
  39. package/dist/agent-farm/db/mailbox.d.ts.map +1 -1
  40. package/dist/agent-farm/db/mailbox.js +56 -12
  41. package/dist/agent-farm/db/mailbox.js.map +1 -1
  42. package/dist/agent-farm/db/migrations.d.ts +57 -0
  43. package/dist/agent-farm/db/migrations.d.ts.map +1 -0
  44. package/dist/agent-farm/db/migrations.js +530 -0
  45. package/dist/agent-farm/db/migrations.js.map +1 -0
  46. package/dist/agent-farm/db/schema.d.ts +1 -1
  47. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  48. package/dist/agent-farm/db/schema.js +1 -0
  49. package/dist/agent-farm/db/schema.js.map +1 -1
  50. package/dist/agent-farm/db/tower-owner.d.ts +168 -0
  51. package/dist/agent-farm/db/tower-owner.d.ts.map +1 -0
  52. package/dist/agent-farm/db/tower-owner.js +369 -0
  53. package/dist/agent-farm/db/tower-owner.js.map +1 -0
  54. package/dist/agent-farm/db/types.d.ts +47 -0
  55. package/dist/agent-farm/db/types.d.ts.map +1 -1
  56. package/dist/agent-farm/db/types.js.map +1 -1
  57. package/dist/agent-farm/lib/ide-record.d.ts +65 -0
  58. package/dist/agent-farm/lib/ide-record.d.ts.map +1 -0
  59. package/dist/agent-farm/lib/ide-record.js +118 -0
  60. package/dist/agent-farm/lib/ide-record.js.map +1 -0
  61. package/dist/agent-farm/lib/tunnel-client.d.ts +22 -0
  62. package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
  63. package/dist/agent-farm/lib/tunnel-client.js +96 -19
  64. package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
  65. package/dist/agent-farm/servers/cron-delivery.d.ts +7 -1
  66. package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -1
  67. package/dist/agent-farm/servers/cron-delivery.js +2 -1
  68. package/dist/agent-farm/servers/cron-delivery.js.map +1 -1
  69. package/dist/agent-farm/servers/gate-profiles.d.ts +44 -2
  70. package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -1
  71. package/dist/agent-farm/servers/gate-profiles.js +79 -6
  72. package/dist/agent-farm/servers/gate-profiles.js.map +1 -1
  73. package/dist/agent-farm/servers/ide-forward.d.ts +62 -0
  74. package/dist/agent-farm/servers/ide-forward.d.ts.map +1 -0
  75. package/dist/agent-farm/servers/ide-forward.js +274 -0
  76. package/dist/agent-farm/servers/ide-forward.js.map +1 -0
  77. package/dist/agent-farm/servers/ide-server.d.ts +68 -0
  78. package/dist/agent-farm/servers/ide-server.d.ts.map +1 -0
  79. package/dist/agent-farm/servers/ide-server.js +281 -0
  80. package/dist/agent-farm/servers/ide-server.js.map +1 -0
  81. package/dist/agent-farm/servers/mailbox-delivery.d.ts +296 -12
  82. package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -1
  83. package/dist/agent-farm/servers/mailbox-delivery.js +566 -69
  84. package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -1
  85. package/dist/agent-farm/servers/mailbox-wiring.d.ts +66 -19
  86. package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -1
  87. package/dist/agent-farm/servers/mailbox-wiring.js +204 -37
  88. package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -1
  89. package/dist/agent-farm/servers/message-write.d.ts +138 -11
  90. package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
  91. package/dist/agent-farm/servers/message-write.js +144 -35
  92. package/dist/agent-farm/servers/message-write.js.map +1 -1
  93. package/dist/agent-farm/servers/overview.d.ts +28 -12
  94. package/dist/agent-farm/servers/overview.d.ts.map +1 -1
  95. package/dist/agent-farm/servers/overview.js +130 -79
  96. package/dist/agent-farm/servers/overview.js.map +1 -1
  97. package/dist/agent-farm/servers/render-gate.d.ts +77 -1
  98. package/dist/agent-farm/servers/render-gate.d.ts.map +1 -1
  99. package/dist/agent-farm/servers/render-gate.js +174 -25
  100. package/dist/agent-farm/servers/render-gate.js.map +1 -1
  101. package/dist/agent-farm/servers/session-submit.d.ts +7 -4
  102. package/dist/agent-farm/servers/session-submit.d.ts.map +1 -1
  103. package/dist/agent-farm/servers/session-submit.js +7 -4
  104. package/dist/agent-farm/servers/session-submit.js.map +1 -1
  105. package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
  106. package/dist/agent-farm/servers/tower-cron.js +7 -3
  107. package/dist/agent-farm/servers/tower-cron.js.map +1 -1
  108. package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
  109. package/dist/agent-farm/servers/tower-instances.js +17 -10
  110. package/dist/agent-farm/servers/tower-instances.js.map +1 -1
  111. package/dist/agent-farm/servers/tower-routes.d.ts +32 -0
  112. package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
  113. package/dist/agent-farm/servers/tower-routes.js +186 -26
  114. package/dist/agent-farm/servers/tower-routes.js.map +1 -1
  115. package/dist/agent-farm/servers/tower-server.js +79 -9
  116. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  117. package/dist/agent-farm/servers/tower-terminals.d.ts +21 -0
  118. package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
  119. package/dist/agent-farm/servers/tower-terminals.js +106 -13
  120. package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
  121. package/dist/agent-farm/servers/tower-utils.d.ts +35 -0
  122. package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
  123. package/dist/agent-farm/servers/tower-utils.js +39 -0
  124. package/dist/agent-farm/servers/tower-utils.js.map +1 -1
  125. package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
  126. package/dist/agent-farm/servers/tower-websocket.js +26 -2
  127. package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
  128. package/dist/agent-farm/utils/message-format.d.ts +78 -2
  129. package/dist/agent-farm/utils/message-format.d.ts.map +1 -1
  130. package/dist/agent-farm/utils/message-format.js +107 -11
  131. package/dist/agent-farm/utils/message-format.js.map +1 -1
  132. package/dist/agent-farm/utils/port.d.ts +22 -0
  133. package/dist/agent-farm/utils/port.d.ts.map +1 -0
  134. package/dist/agent-farm/utils/port.js +35 -0
  135. package/dist/agent-farm/utils/port.js.map +1 -0
  136. package/dist/agent-farm/utils/server-utils.d.ts +26 -0
  137. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  138. package/dist/agent-farm/utils/server-utils.js +36 -0
  139. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  140. package/dist/cli.d.ts.map +1 -1
  141. package/dist/cli.js +3 -1
  142. package/dist/cli.js.map +1 -1
  143. package/dist/commands/consult/index.d.ts.map +1 -1
  144. package/dist/commands/consult/index.js +61 -1
  145. package/dist/commands/consult/index.js.map +1 -1
  146. package/dist/commands/consult/tree-tripwire.d.ts +177 -0
  147. package/dist/commands/consult/tree-tripwire.d.ts.map +1 -0
  148. package/dist/commands/consult/tree-tripwire.js +356 -0
  149. package/dist/commands/consult/tree-tripwire.js.map +1 -0
  150. package/dist/commands/doctor.d.ts +69 -0
  151. package/dist/commands/doctor.d.ts.map +1 -1
  152. package/dist/commands/doctor.js +165 -2
  153. package/dist/commands/doctor.js.map +1 -1
  154. package/dist/commands/generate-image.d.ts +25 -1
  155. package/dist/commands/generate-image.d.ts.map +1 -1
  156. package/dist/commands/generate-image.js +410 -2
  157. package/dist/commands/generate-image.js.map +1 -1
  158. package/dist/commands/porch/prompts.d.ts.map +1 -1
  159. package/dist/commands/porch/prompts.js +18 -2
  160. package/dist/commands/porch/prompts.js.map +1 -1
  161. package/dist/lib/agent-env.d.ts +86 -0
  162. package/dist/lib/agent-env.d.ts.map +1 -0
  163. package/dist/lib/agent-env.js +126 -0
  164. package/dist/lib/agent-env.js.map +1 -0
  165. package/dist/lib/forge-contracts.d.ts +76 -5
  166. package/dist/lib/forge-contracts.d.ts.map +1 -1
  167. package/dist/lib/forge-contracts.js.map +1 -1
  168. package/dist/lib/forge-rate-limit.d.ts +45 -0
  169. package/dist/lib/forge-rate-limit.d.ts.map +1 -0
  170. package/dist/lib/forge-rate-limit.js +89 -0
  171. package/dist/lib/forge-rate-limit.js.map +1 -0
  172. package/dist/lib/forge.d.ts +29 -1
  173. package/dist/lib/forge.d.ts.map +1 -1
  174. package/dist/lib/forge.js +96 -21
  175. package/dist/lib/forge.js.map +1 -1
  176. package/dist/lib/github.d.ts +13 -1
  177. package/dist/lib/github.d.ts.map +1 -1
  178. package/dist/lib/github.js +58 -15
  179. package/dist/lib/github.js.map +1 -1
  180. package/dist/lib/test-env.d.ts +17 -0
  181. package/dist/lib/test-env.d.ts.map +1 -1
  182. package/dist/lib/test-env.js +20 -0
  183. package/dist/lib/test-env.js.map +1 -1
  184. package/dist/terminal/pty-manager.d.ts +10 -1
  185. package/dist/terminal/pty-manager.d.ts.map +1 -1
  186. package/dist/terminal/pty-manager.js +38 -9
  187. package/dist/terminal/pty-manager.js.map +1 -1
  188. package/dist/terminal/pty-session.d.ts +174 -8
  189. package/dist/terminal/pty-session.d.ts.map +1 -1
  190. package/dist/terminal/pty-session.js +299 -25
  191. package/dist/terminal/pty-session.js.map +1 -1
  192. package/dist/terminal/session-manager.d.ts +23 -2
  193. package/dist/terminal/session-manager.d.ts.map +1 -1
  194. package/dist/terminal/session-manager.js +115 -36
  195. package/dist/terminal/session-manager.js.map +1 -1
  196. package/dist/terminal/session-screen.d.ts +34 -5
  197. package/dist/terminal/session-screen.d.ts.map +1 -1
  198. package/dist/terminal/session-screen.js +52 -6
  199. package/dist/terminal/session-screen.js.map +1 -1
  200. package/dist/terminal/shellper-client.d.ts +73 -0
  201. package/dist/terminal/shellper-client.d.ts.map +1 -1
  202. package/dist/terminal/shellper-client.js +151 -0
  203. package/dist/terminal/shellper-client.js.map +1 -1
  204. package/dist/terminal/shellper-main.js +20 -3
  205. package/dist/terminal/shellper-main.js.map +1 -1
  206. package/dist/terminal/shellper-process.d.ts +2 -0
  207. package/dist/terminal/shellper-process.d.ts.map +1 -1
  208. package/dist/terminal/shellper-process.js +17 -1
  209. package/dist/terminal/shellper-process.js.map +1 -1
  210. package/dist/terminal/shellper-protocol.d.ts +28 -0
  211. package/dist/terminal/shellper-protocol.d.ts.map +1 -1
  212. package/dist/terminal/shellper-protocol.js.map +1 -1
  213. package/dist/terminal/terminal-replies.d.ts +102 -0
  214. package/dist/terminal/terminal-replies.d.ts.map +1 -0
  215. package/dist/terminal/terminal-replies.js +181 -0
  216. package/dist/terminal/terminal-replies.js.map +1 -0
  217. package/package.json +4 -4
  218. package/scripts/forge/gitea/_lib.sh +214 -0
  219. package/scripts/forge/gitea/issue-comment.sh +8 -1
  220. package/scripts/forge/gitea/issue-view.sh +95 -4
  221. package/scripts/forge/gitea/pr-create.sh +155 -0
  222. package/scripts/forge/gitea/pr-exists.sh +32 -4
  223. package/scripts/forge/gitea/pr-list.sh +40 -31
  224. package/scripts/forge/gitea/pr-view.sh +65 -4
  225. package/scripts/forge/gitea/recently-merged.sh +91 -21
  226. package/scripts/forge/gitea/user-identity.sh +30 -1
  227. package/scripts/forge/github/issue-view.sh +5 -2
  228. package/scripts/forge/github/pr-create.sh +47 -0
  229. package/scripts/forge/github/pr-list.sh +6 -2
  230. package/scripts/forge/github/pr-search.sh +6 -2
  231. package/scripts/forge/gitlab/issue-search.sh +1 -0
  232. package/scripts/forge/gitlab/issue-view.sh +24 -4
  233. package/scripts/forge/gitlab/pr-create.sh +53 -0
  234. package/scripts/forge/gitlab/pr-search.sh +37 -2
  235. package/scripts/forge/linear/auth-status.sh +1 -0
  236. package/scripts/forge/linear/issue-comment.sh +1 -0
  237. package/scripts/forge/linear/issue-list.sh +1 -0
  238. package/scripts/forge/linear/issue-search.sh +1 -0
  239. package/scripts/forge/linear/issue-view.sh +14 -2
  240. package/scripts/forge/linear/recently-closed.sh +1 -0
  241. package/scripts/forge/linear/user-identity.sh +1 -0
  242. package/skeleton/.claude/skills/generate-image/SKILL.md +39 -1
  243. package/skeleton/.codex/skills/generate-image/SKILL.md +39 -1
  244. package/skeleton/protocols/air/prompts/pr.md +6 -1
  245. package/skeleton/protocols/aspir/prompts/review.md +6 -1
  246. package/skeleton/protocols/bugfix/prompts/pr.md +6 -1
  247. package/skeleton/protocols/bugfix/protocol.md +2 -1
  248. package/skeleton/protocols/maintain/prompts/review.md +6 -1
  249. package/skeleton/protocols/pir/prompts/review.md +10 -7
  250. package/skeleton/protocols/spir/prompts/review.md +6 -1
  251. package/skeleton/resources/commands/agent-farm.md +28 -3
  252. package/skeleton/roles/consultant.md +32 -0
  253. package/skeleton/templates/AGENTS.md +1 -1
  254. package/skeleton/templates/CLAUDE.md +1 -1
  255. package/templates/tower.html +16 -1
  256. package/dashboard-dist/assets/index-BZRTEMtH.css +0 -32
  257. package/dashboard-dist/assets/index-D3XaKpZW.js.map +0 -1
@@ -1,17 +1,113 @@
1
1
  /**
2
- * Paced message writing for PTY sessions (Bugfix #584).
2
+ * Paced message writing for PTY sessions (Bugfix #584, Issue #1567).
3
3
  *
4
4
  * Extracted to a shared module to avoid circular imports between
5
5
  * tower-routes.ts and tower-cron.ts.
6
6
  */
7
7
  import { trySubmitToSession, watchBypasses } from './session-submit.js';
8
- // Messages longer than this threshold are written line-by-line with delays
9
- // to prevent the receiving terminal from classifying the input as a paste
10
- // and swallowing the final Enter.
11
- const PACED_WRITE_LINE_THRESHOLD = 4;
12
- const INTER_LINE_DELAY_MS = 10;
13
- const PACED_ENTER_DELAY_MS = 80;
14
8
  const SIMPLE_ENTER_DELAY_MS = 50;
9
+ /**
10
+ * Issue #1567 — why long frames are no longer handed to the PTY in one write.
11
+ *
12
+ * The kernel splits a large master-side write at the slave's input-queue high-water mark
13
+ * (TTYHOG − 2 = 1022 bytes on macOS). The receiving TUI reads the first ~1 KB as one chunk,
14
+ * its paste heuristic classifies that chunk as a paste and DISCARDS it, then types the
15
+ * remainder and the Enter normally — so the agent saw a mid-sentence tail-only fragment
16
+ * while every `write()` reported success. Measured on claude 2.1.263 (harness in
17
+ * `scripts/bugfix-1567-head-loss-harness.mts`, evidence in `codev/evidence/1567-head-loss/`):
18
+ * a 1,172-byte 3-line frame lost its head 14/20 times on an IDLE composer; a 1,000-byte frame
19
+ * never did. Two independent changes each measured 0 losses, and both are applied:
20
+ *
21
+ * 1. **No single write exceeds {@link PASTE_CHUNK_BYTES}** — comfortably under the queue
22
+ * limit, so the kernel never splits a read and the reader drains between writes.
23
+ * 2. **Long frames travel as ONE explicit bracketed paste** (`ESC[200~` … `ESC[201~`), so the
24
+ * TUI's paste heuristic never runs on chunk boundaries and newlines inside the body can
25
+ * never be read as Enter. The Enter is a separate write OUTSIDE the bracket, after a
26
+ * settle. Inside the bracket newlines travel as `\r`, which is what a terminal emulator
27
+ * emits on paste (xterm.js) and what claude's composer expects there — the same convention
28
+ * `apps/vscode/src/review-queue/queue.ts` already uses to type a review into a builder.
29
+ *
30
+ * This replaces the #584-era per-line pacing for frames of {@link BRACKET_MIN_LINES} lines or
31
+ * more, which was tuned against an older TUI's heuristic. Short frames keep the pre-existing
32
+ * single write + delayed Enter, byte for byte, so `--raw` slash commands and short sends are
33
+ * untouched.
34
+ */
35
+ export const PASTE_CHUNK_BYTES = 512;
36
+ /** Gap between consecutive chunk writes; the reader drains a chunk in well under this. */
37
+ export const PASTE_CHUNK_GAP_MS = 5;
38
+ /** Settle between the paste's closing marker and the Enter that submits it (measured: 0/29 losses). */
39
+ export const PASTE_ENTER_DELAY_MS = 80;
40
+ /**
41
+ * Frames LONGER than this many bytes, or of {@link BRACKET_MIN_LINES} lines or more, are
42
+ * bracketed and chunked. Well under the 1,022-byte queue limit where the loss starts: the
43
+ * field's smallest confirmed truncations were ~500-byte bodies, and a bracketed short paste
44
+ * costs nothing on the measured harnesses.
45
+ */
46
+ export const BRACKET_MIN_BYTES = 256;
47
+ /** The #584 threshold, kept: any frame of this many lines or more is never typed line by line. */
48
+ export const BRACKET_MIN_LINES = 4;
49
+ /** Gap between line pieces on the non-bracketed (opt-out) strategy — the #584 pacing. */
50
+ const INTER_LINE_DELAY_MS = 10;
51
+ export const PASTE_BEGIN = '\x1b[200~';
52
+ export const PASTE_END = '\x1b[201~';
53
+ export const BRACKETED_PASTE = { bracketedPaste: true };
54
+ export const PLAIN_CHUNKED = { bracketedPaste: false };
55
+ /**
56
+ * The write strategy for a gate profile's `app`. agy is the one modeled harness whose paste
57
+ * handling was not measurable (unauthenticated), so it keeps the per-line shape; anything
58
+ * else is bracketed. Unknown apps never reach the write edge (the gate holds `no-profile`).
59
+ */
60
+ export function writeStrategyForApp(app) {
61
+ return app === 'agy' ? PLAIN_CHUNKED : BRACKETED_PASTE;
62
+ }
63
+ /** Does this frame take the bracketed/chunked path rather than the single short write? */
64
+ export function isLongFrame(message) {
65
+ return message.split('\n').length >= BRACKET_MIN_LINES || Buffer.byteLength(message, 'utf8') > BRACKET_MIN_BYTES;
66
+ }
67
+ /**
68
+ * Split `text` into pieces of at most `max` bytes, never inside a UTF-8 sequence (a split
69
+ * continuation byte would reach the TUI as two invalid characters).
70
+ */
71
+ export function chunkForPty(text, max = PASTE_CHUNK_BYTES) {
72
+ const buf = Buffer.from(text, 'utf8');
73
+ const out = [];
74
+ for (let at = 0; at < buf.length;) {
75
+ let end = Math.min(buf.length, at + max);
76
+ while (end < buf.length && end > at && (buf[end] & 0xc0) === 0x80)
77
+ end--;
78
+ out.push(buf.subarray(at, end).toString('utf8'));
79
+ at = end;
80
+ }
81
+ return out;
82
+ }
83
+ /**
84
+ * The pieces of a long frame, in write order, for a strategy. Bracketed: the body (newlines
85
+ * as `\r`) chunked, with the opening marker on the first piece and the closing marker on the
86
+ * last — the markers are never split across writes, so no chunk boundary can land inside an
87
+ * escape sequence, and every piece INCLUDING its marker stays within {@link PASTE_CHUNK_BYTES}.
88
+ * Plain: one piece per line (`\n` kept, as #584 wrote it), with any line over
89
+ * the chunk size split further.
90
+ */
91
+ export function framePieces(message, strategy) {
92
+ if (strategy.bracketedPaste) {
93
+ // A literal paste marker inside the body (a pasted terminal log, say) would end or restart
94
+ // the paste mid-frame and let the remainder be typed as keys; strip both so the bracket
95
+ // we add is the only one the TUI sees.
96
+ const body = message.replace(/\r?\n/g, '\r').split(PASTE_BEGIN).join('').split(PASTE_END).join('');
97
+ // Chunk with room for the markers so NO write — first or last piece included — exceeds
98
+ // PASTE_CHUNK_BYTES on the wire.
99
+ const pieces = chunkForPty(body, PASTE_CHUNK_BYTES - PASTE_BEGIN.length - PASTE_END.length);
100
+ // A body that was NOTHING but markers is empty after stripping; still emit one (empty) paste
101
+ // so the frame is a well-formed no-op rather than the literal text "undefined".
102
+ if (pieces.length === 0)
103
+ pieces.push('');
104
+ pieces[0] = PASTE_BEGIN + pieces[0];
105
+ pieces[pieces.length - 1] += PASTE_END;
106
+ return pieces;
107
+ }
108
+ const lines = message.split('\n');
109
+ return lines.flatMap((line, i) => chunkForPty(i < lines.length - 1 ? line + '\n' : line));
110
+ }
15
111
  /** ESC keystroke — ends the agent's current turn (Spec 1273). */
16
112
  export const ESC = '\x1b';
17
113
  /**
@@ -43,21 +139,22 @@ export function writeEscapeToSession(session, noEnter) {
43
139
  return ESCAPE_ENTER_DELAY_MS;
44
140
  }
45
141
  /**
46
- * Write a message to a PTY session, pacing multi-line output to prevent
47
- * the terminal from treating it as a paste (Bugfix #584).
142
+ * Write a message to a PTY session (Bugfix #584, Issue #1567).
143
+ *
144
+ * Short frames (under {@link BRACKET_MIN_LINES} lines AND at most {@link BRACKET_MIN_BYTES}
145
+ * bytes): a single write, then Enter after 50 ms — unchanged since #584.
48
146
  *
49
- * Short messages (≤3 lines): single write + delayed Enter.
50
- * Long messages (>3 lines): line-by-line writes with 10ms gaps, then Enter
51
- * after all lines are delivered.
147
+ * Long frames: {@link framePieces} written {@link PASTE_CHUNK_GAP_MS} apart (bracketed by
148
+ * default; see {@link WriteStrategy}), then Enter as its own write
149
+ * {@link PASTE_ENTER_DELAY_MS} after the last piece. The Enter is always outside the paste
150
+ * bracket, so a TUI that collapses the paste into a placard still submits it.
52
151
  *
53
152
  * @param delayOffset ms offset for all scheduled writes (used to serialize
54
153
  * multiple messages to the same session without interleaving)
55
154
  * @returns ms timestamp (from call time) when all writes complete
56
155
  */
57
- export function writeMessageToSession(session, message, noEnter, delayOffset = 0) {
58
- const lines = message.split('\n');
59
- if (lines.length < PACED_WRITE_LINE_THRESHOLD) {
60
- // Short messages: single write (existing behavior, works fine)
156
+ export function writeMessageToSession(session, message, noEnter, delayOffset = 0, strategy = BRACKETED_PASTE) {
157
+ if (!isLongFrame(message)) {
61
158
  if (delayOffset === 0) {
62
159
  session.write(message);
63
160
  }
@@ -70,26 +167,23 @@ export function writeMessageToSession(session, message, noEnter, delayOffset = 0
70
167
  }
71
168
  return enterTime;
72
169
  }
73
- // Multi-line: pace output line-by-line to avoid paste detection.
74
- // Writing all lines in a single write() causes the terminal to treat it
75
- // as a paste, swallowing the final Enter.
76
- for (let i = 0; i < lines.length; i++) {
77
- const text = i < lines.length - 1 ? lines[i] + '\n' : lines[i];
78
- const lineDelay = delayOffset + i * INTER_LINE_DELAY_MS;
79
- if (lineDelay === 0) {
80
- session.write(text);
170
+ const pieces = framePieces(message, strategy);
171
+ const gap = strategy.bracketedPaste ? PASTE_CHUNK_GAP_MS : INTER_LINE_DELAY_MS;
172
+ for (let i = 0; i < pieces.length; i++) {
173
+ const at = delayOffset + i * gap;
174
+ if (at === 0) {
175
+ session.write(pieces[i]);
81
176
  }
82
177
  else {
83
- setTimeout(() => session.write(text), lineDelay);
178
+ setTimeout(() => session.write(pieces[i]), at);
84
179
  }
85
180
  }
86
- const lastLineTime = delayOffset + (lines.length - 1) * INTER_LINE_DELAY_MS;
87
- if (!noEnter) {
88
- const enterTime = lastLineTime + PACED_ENTER_DELAY_MS;
89
- setTimeout(() => session.write('\r'), enterTime);
90
- return enterTime;
91
- }
92
- return lastLineTime;
181
+ const lastPieceTime = delayOffset + (pieces.length - 1) * gap;
182
+ if (noEnter)
183
+ return lastPieceTime;
184
+ const enterTime = lastPieceTime + PASTE_ENTER_DELAY_MS;
185
+ setTimeout(() => session.write('\r'), enterTime);
186
+ return enterTime;
93
187
  }
94
188
  /**
95
189
  * Paced write of a message (text + trailing Enter unless `noEnter`) performed as ONE
@@ -117,7 +211,7 @@ export function writeMessageToSession(session, message, noEnter, delayOffset = 0
117
211
  * `submitToSession` schedules its own equal-offset sleep, so the Enter still executes
118
212
  * first — which is what makes the per-agent serializer's completion-chaining real.
119
213
  */
120
- export async function submitMessagePaced(session, message, noEnter, precheck, clock) {
214
+ export async function submitMessagePaced(session, message, noEnter, precheck, clock, strategy = BRACKETED_PASTE) {
121
215
  // Fail LOUD on a missing id rather than keying the lock on `undefined`. Sessions reach
122
216
  // this through structurally-typed ports, so a double without an id compiles fine and
123
217
  // would silently collapse every per-terminal lock into one global lock — serialization
@@ -137,9 +231,17 @@ export async function submitMessagePaced(session, message, noEnter, precheck, cl
137
231
  try {
138
232
  let delivered = true;
139
233
  let abort = null;
234
+ // Sampled inside the lock, immediately before the first byte (below), so the comparison
235
+ // spans exactly the paced write and not the lock wait that preceded it.
236
+ let inputSeqBefore = session.inputSeq;
237
+ // The origin is HARD-CODED here rather than forwarded through a parameter (Issue #1473).
238
+ // A one-arg function is assignable to a two-arg function type, so a wrapper that forgot to
239
+ // pass the origin along would compile cleanly — and its failure mode is the delivery
240
+ // counting its own bytes as human input, i.e. "mail never delivers". Removing the parameter
241
+ // removes the opportunity.
140
242
  const tracked = {
141
243
  write: (data) => {
142
- const ok = session.write(data);
244
+ const ok = session.write(data, 'delivery');
143
245
  if (!ok)
144
246
  delivered = false;
145
247
  return ok;
@@ -149,7 +251,8 @@ export async function submitMessagePaced(session, message, noEnter, precheck, cl
149
251
  abort = precheck();
150
252
  if (abort !== null)
151
253
  return 0; // refused in-lock: not one byte goes out
152
- return writeMessageToSession(tracked, message, noEnter);
254
+ inputSeqBefore = session.inputSeq;
255
+ return writeMessageToSession(tracked, message, noEnter, 0, strategy);
153
256
  }, clock);
154
257
  if (!ran)
155
258
  return { status: 'contended' };
@@ -162,6 +265,12 @@ export async function submitMessagePaced(session, message, noEnter, precheck, cl
162
265
  return { status: 'dropped' };
163
266
  if (bypasses.raced())
164
267
  return { status: 'preempted' };
268
+ // The prechecks close everything up to the first byte; between the first byte and the
269
+ // trailing Enter there is still an 80 ms-to-seconds window, and by then the bytes are out.
270
+ // So this is a REPORTING signal — flag it and let the caller tell the operator, rather than
271
+ // hold and re-write a message that may well have landed (#1584). Omitted when false.
272
+ if (session.inputSeq !== inputSeqBefore)
273
+ return { status: 'written', racedByInput: true };
165
274
  return { status: 'written' };
166
275
  }
167
276
  finally {
@@ -1 +1 @@
1
- {"version":3,"file":"message-write.js","sourceRoot":"","sources":["../../../src/agent-farm/servers/message-write.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"message-write.js","sourceRoot":"","sources":["../../../src/agent-farm/servers/message-write.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,7 @@
7
7
  * mode when the `gh` CLI is unavailable.
8
8
  */
9
9
  import type { ForgeIssueListItem } from '../../lib/github.js';
10
+ import { type RateLimitProbe } from '../../lib/forge-rate-limit.js';
10
11
  import type { PlanPhase, OverviewBuilder, OverviewBacklogItem, OverviewData } from '@cluesmith/codev-types';
11
12
  interface ParsedStatus {
12
13
  id: string;
@@ -161,15 +162,28 @@ export declare function readFeedbackMode(workspaceRoot: string): 'forward' | 'qu
161
162
  * Derive backlog from open GitHub issues cross-referenced with specs and builders.
162
163
  */
163
164
  export declare function deriveBacklog(issues: ForgeIssueListItem[], workspaceRoot: string, activeBuilderIssues: Set<string>, prLinkedIssues: Set<string>): OverviewBacklogItem[];
165
+ export interface OverviewCacheOptions {
166
+ /** Clock, injectable for tests. */
167
+ now?: () => number;
168
+ /** Reset probe run once per fresh suspension (Tower passes the gh probe). */
169
+ probe?: RateLimitProbe;
170
+ }
164
171
  export declare class OverviewCache {
165
172
  private prCache;
166
173
  private issueCache;
167
174
  private closedCache;
168
175
  private mergedPRCache;
169
176
  private currentUserCache;
177
+ /** Single-flight per `<workspace>:<concept>` (#1645). */
178
+ private inflight;
179
+ /** Escalating failure window per `<workspace>:<budget>`. */
180
+ private failureWindows;
181
+ /** Epoch of the last invalidate(); open-list entries fetched before it are stale once debounced. */
182
+ private invalidatedAt;
183
+ private readonly limiter;
184
+ private readonly now;
185
+ constructor(options?: OverviewCacheOptions);
170
186
  private resolvedEnrichment;
171
- private readonly TTL;
172
- private readonly USER_TTL;
173
187
  /**
174
188
  * Build the overview response. Aggregates builder state, PRs, and backlog.
175
189
  *
@@ -180,20 +194,22 @@ export declare class OverviewCache {
180
194
  */
181
195
  getOverview(workspaceRoot: string, activeBuilderRoleIds?: Set<string>): Promise<OverviewData>;
182
196
  /**
183
- * Invalidate all cached data.
197
+ * Mark the open lists (PRs, issues) stale. Porch, cleanup and the VS Code
198
+ * review queue call this after every mutation, so it is not a human signal
199
+ * (#1645): it never clears a suspension or a cached failure, never touches
200
+ * the search or identity caches, and a list is refetched for it at most once
201
+ * per `INVALIDATE_DEBOUNCE_MS` per workspace.
184
202
  */
185
203
  invalidate(): void;
186
- private fetchPRsCached;
187
- private fetchIssuesCached;
188
204
  /**
189
- * Resolve the current user's forge login via the `user-identity` concept.
190
- * Long TTL — identity is stable for the lifetime of a Tower session.
191
- * Only successful resolutions are cached, so a transient failure (gh
192
- * logged out, offline) self-heals on the next overview poll.
205
+ * Serve `concept` for `cwd` from cache, or dispatch exactly one fetch.
206
+ * `listCache` entries additionally honour invalidate() (debounced).
193
207
  */
194
- private fetchCurrentUserCached;
195
- private fetchRecentlyClosedCached;
196
- private fetchMergedPRsCached;
208
+ private cachedFetch;
209
+ /** Run the concept once and record the outcome. Never rejects. */
210
+ private dispatch;
211
+ /** Current failure window for a key; escalates once per elapsed window, not per failure. */
212
+ private failureWindow;
197
213
  }
198
214
  export {};
199
215
  //# sourceMappingURL=overview.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"overview.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/servers/overview.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"overview.d.ts","sourceRoot":"","sources":["../../../src/agent-farm/servers/overview.ts"],"names":[],"mappings":"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"}
@@ -9,7 +9,8 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { UNCATEGORIZED_AREA } from '@cluesmith/codev-sdk/constants';
12
- import { fetchPRList, fetchIssueList, fetchRecentlyClosed, fetchRecentMergedPRs, fetchCurrentUser, parseLinkedIssue, parseLabelDefaults, parseArea, } from '../../lib/github.js';
12
+ import { fetchForOverview, overviewBackend, parseLinkedIssue, parseLabelDefaults, parseArea, } from '../../lib/github.js';
13
+ import { ForgeRateLimiter, budgetKeyFor } from '../../lib/forge-rate-limit.js';
13
14
  import { loadProtocol } from '../../commands/porch/protocol.js';
14
15
  import { ResolvedEnrichmentCache } from './resolved-enrichment-cache.js';
15
16
  import Database from 'better-sqlite3';
@@ -761,23 +762,41 @@ export function deriveBacklog(issues, workspaceRoot, activeBuilderIssues, prLink
761
762
  return item;
762
763
  });
763
764
  }
764
- // =============================================================================
765
- // OverviewCache
766
- // =============================================================================
765
+ /** Open lists: what porch/VS Code invalidation refreshes. */
766
+ const LIST_TTL_MS = 180_000;
767
+ /** The two 24 h searches: invalidation never touches these. */
768
+ const SEARCH_TTL_MS = 600_000;
769
+ /** GitHub identity is session-stable. */
770
+ const USER_TTL_MS = 3_600_000;
771
+ /** A list refetches at most once per minute per workspace because of invalidation. */
772
+ const INVALIDATE_DEBOUNCE_MS = 60_000;
773
+ /** Failure window: 60 s, doubling per window (not per failed command) to 15 min. */
774
+ const FAILURE_WINDOW_MIN_MS = 60_000;
775
+ const FAILURE_WINDOW_MAX_MS = 15 * 60_000;
767
776
  export class OverviewCache {
768
777
  prCache = new Map();
769
778
  issueCache = new Map();
770
779
  closedCache = new Map();
771
780
  mergedPRCache = new Map();
772
781
  currentUserCache = new Map();
782
+ /** Single-flight per `<workspace>:<concept>` (#1645). */
783
+ inflight = new Map();
784
+ /** Escalating failure window per `<workspace>:<budget>`. */
785
+ failureWindows = new Map();
786
+ /** Epoch of the last invalidate(); open-list entries fetched before it are stale once debounced. */
787
+ invalidatedAt = 0;
788
+ limiter;
789
+ now;
790
+ constructor(options = {}) {
791
+ this.now = options.now ?? (() => Date.now());
792
+ this.limiter = new ForgeRateLimiter(options.probe);
793
+ }
773
794
  // Cache of issue-derived builder fields (today: `area`) keyed by worktreePath,
774
795
  // so they survive refreshes where the source issue is unreachable rather than
775
796
  // snapping back to their default while the builder is still listed (PIR #907).
776
797
  // Intentionally NOT cleared by invalidate(): surviving cache invalidation is
777
798
  // the whole point. See ResolvedEnrichmentCache.
778
799
  resolvedEnrichment = new ResolvedEnrichmentCache();
779
- TTL = 30_000;
780
- USER_TTL = 3_600_000; // 1h — GitHub identity is session-stable
781
800
  /**
782
801
  * Build the overview response. Aggregates builder state, PRs, and backlog.
783
802
  *
@@ -849,17 +868,29 @@ export class OverviewCache {
849
868
  .filter((id) => id !== null));
850
869
  // 2. Fetch PRs, issues, recently closed, merged PRs, and current user in
851
870
  // parallel (each is independently cached)
852
- const [prs, issues, closed, mergedPRs, currentUser] = await Promise.all([
853
- this.fetchPRsCached(workspaceRoot),
854
- this.fetchIssuesCached(workspaceRoot),
855
- this.fetchRecentlyClosedCached(workspaceRoot),
856
- this.fetchMergedPRsCached(workspaceRoot),
857
- this.fetchCurrentUserCached(workspaceRoot),
871
+ const [prsOutcome, issuesOutcome, closedOutcome, mergedOutcome, userOutcome] = await Promise.all([
872
+ this.cachedFetch(this.prCache, workspaceRoot, 'pr-list', LIST_TTL_MS, true),
873
+ this.cachedFetch(this.issueCache, workspaceRoot, 'issue-list', LIST_TTL_MS, true),
874
+ this.cachedFetch(this.closedCache, workspaceRoot, 'recently-closed', SEARCH_TTL_MS, false),
875
+ this.cachedFetch(this.mergedPRCache, workspaceRoot, 'recently-merged', SEARCH_TTL_MS, false),
876
+ this.cachedFetch(this.currentUserCache, workspaceRoot, 'user-identity', USER_TTL_MS, false),
858
877
  ]);
878
+ const prs = prsOutcome.data;
879
+ const issues = issuesOutcome.data;
880
+ const closed = closedOutcome.data;
881
+ const mergedPRs = mergedOutcome.data;
882
+ const currentUser = userOutcome.data;
883
+ // Every forge-backed section counts, the two searches included (#1645).
884
+ const sections = [prsOutcome, issuesOutcome, closedOutcome, mergedOutcome];
885
+ const rateLimited = sections.find(o => o.rateLimitedUntil !== null);
886
+ const forgeStatus = rateLimited
887
+ ? 'rate-limited'
888
+ : sections.some(o => o.data === null) ? 'unavailable' : 'ok';
889
+ const forgeResetAt = rateLimited ? new Date(rateLimited.rateLimitedUntil).toISOString() : undefined;
859
890
  // 3. Process PRs
860
891
  let pendingPRs = [];
861
892
  if (prs === null) {
862
- errors.prs = 'GitHub CLI unavailable — could not fetch PRs';
893
+ errors.prs = describeMissing(prsOutcome, 'PRs');
863
894
  }
864
895
  else {
865
896
  pendingPRs = prs.map(pr => ({
@@ -885,7 +916,7 @@ export class OverviewCache {
885
916
  ? new Map(issues.map(i => [String(i.number), parseArea(i.labels)]))
886
917
  : null;
887
918
  if (issues === null) {
888
- errors.issues = 'GitHub CLI unavailable — could not fetch issues';
919
+ errors.issues = describeMissing(issuesOutcome, 'issues');
889
920
  }
890
921
  else {
891
922
  backlog = deriveBacklog(issues, workspaceRoot, activeBuilderIssues, prLinkedIssues);
@@ -978,7 +1009,10 @@ export class OverviewCache {
978
1009
  // has no view of the live terminal sessions. `handleOverview` (tower-routes.ts)
979
1010
  // injects the real architect list via `liveArchitects` before serialization,
980
1011
  // mirroring how it enriches `lastDataAt`.
981
- const result = { builders, pendingPRs, backlog, recentlyClosed, architects: [], heldCount, mailboxEscalated, queuedFeedback, feedbackMode };
1012
+ const result = { builders, pendingPRs, backlog, recentlyClosed, architects: [], heldCount, mailboxEscalated, queuedFeedback, feedbackMode, forgeStatus };
1013
+ if (forgeResetAt) {
1014
+ result.forgeResetAt = forgeResetAt;
1015
+ }
982
1016
  if (currentUser) {
983
1017
  result.currentUser = currentUser;
984
1018
  }
@@ -988,87 +1022,104 @@ export class OverviewCache {
988
1022
  return result;
989
1023
  }
990
1024
  /**
991
- * Invalidate all cached data.
1025
+ * Mark the open lists (PRs, issues) stale. Porch, cleanup and the VS Code
1026
+ * review queue call this after every mutation, so it is not a human signal
1027
+ * (#1645): it never clears a suspension or a cached failure, never touches
1028
+ * the search or identity caches, and a list is refetched for it at most once
1029
+ * per `INVALIDATE_DEBOUNCE_MS` per workspace.
992
1030
  */
993
1031
  invalidate() {
994
- this.prCache.clear();
995
- this.issueCache.clear();
996
- this.closedCache.clear();
997
- this.mergedPRCache.clear();
998
- this.currentUserCache.clear();
1032
+ this.invalidatedAt = this.now();
999
1033
  // Note: resolvedEnrichment is deliberately NOT cleared here. It must survive
1000
1034
  // invalidation so a cleanup-triggered refresh (which calls invalidate()) can
1001
1035
  // still fall back to a builder's resolved area instead of UNCATEGORIZED
1002
1036
  // (PIR #907). It self-prunes in getOverview when a builder disappears.
1003
1037
  }
1004
1038
  // ===========================================================================
1005
- // Private cache helpers
1039
+ // Private cache helpers (#1645)
1006
1040
  // ===========================================================================
1007
- async fetchPRsCached(cwd) {
1008
- const now = Date.now();
1009
- const cached = this.prCache.get(cwd);
1010
- if (cached && (now - cached.fetchedAt) < this.TTL) {
1011
- return cached.data;
1012
- }
1013
- const data = await fetchPRList(cwd);
1014
- if (data !== null) {
1015
- this.prCache.set(cwd, { data, fetchedAt: now });
1016
- }
1017
- return data;
1018
- }
1019
- async fetchIssuesCached(cwd) {
1020
- const now = Date.now();
1021
- const cached = this.issueCache.get(cwd);
1022
- if (cached && (now - cached.fetchedAt) < this.TTL) {
1023
- return cached.data;
1024
- }
1025
- const data = await fetchIssueList(cwd);
1026
- if (data !== null) {
1027
- this.issueCache.set(cwd, { data, fetchedAt: now });
1028
- }
1029
- return data;
1030
- }
1031
1041
  /**
1032
- * Resolve the current user's forge login via the `user-identity` concept.
1033
- * Long TTL — identity is stable for the lifetime of a Tower session.
1034
- * Only successful resolutions are cached, so a transient failure (gh
1035
- * logged out, offline) self-heals on the next overview poll.
1042
+ * Serve `concept` for `cwd` from cache, or dispatch exactly one fetch.
1043
+ * `listCache` entries additionally honour invalidate() (debounced).
1036
1044
  */
1037
- async fetchCurrentUserCached(cwd) {
1038
- const now = Date.now();
1039
- const cached = this.currentUserCache.get(cwd);
1040
- if (cached && (now - cached.fetchedAt) < this.USER_TTL) {
1041
- return cached.data;
1042
- }
1043
- const login = await fetchCurrentUser(cwd);
1044
- if (login !== null) {
1045
- this.currentUserCache.set(cwd, { data: login, fetchedAt: now });
1045
+ cachedFetch(cache, cwd, concept, ttl, listCache) {
1046
+ const now = this.now();
1047
+ const backend = overviewBackend(concept, cwd);
1048
+ const budget = backend === null ? null : budgetKeyFor(backend, concept);
1049
+ const entry = cache.get(cwd);
1050
+ const withinTtl = entry !== undefined && now < entry.expiresAt;
1051
+ // Only a *positive* entry goes stale on invalidation: a refresh means "my
1052
+ // data may be stale", never "the forge has recovered".
1053
+ const invalidated = listCache && entry !== undefined && entry.data !== null
1054
+ && entry.fetchedAt < this.invalidatedAt && now - entry.fetchedAt >= INVALIDATE_DEBOUNCE_MS;
1055
+ const rateLimitedUntil = budget === null ? null : this.limiter.resetAt(budget, now);
1056
+ if (withinTtl && !invalidated) {
1057
+ return Promise.resolve({ data: entry.data, rateLimitedUntil, backend });
1046
1058
  }
1047
- return login;
1048
- }
1049
- async fetchRecentlyClosedCached(cwd) {
1050
- const now = Date.now();
1051
- const cached = this.closedCache.get(cwd);
1052
- if (cached && (now - cached.fetchedAt) < this.TTL) {
1053
- return cached.data;
1059
+ if (rateLimitedUntil !== null) {
1060
+ // Suspended: a list still inside its TTL is served stale; an expired one is empty.
1061
+ return Promise.resolve({ data: withinTtl ? entry.data : null, rateLimitedUntil, backend });
1054
1062
  }
1055
- const data = await fetchRecentlyClosed(cwd);
1056
- if (data !== null) {
1057
- this.closedCache.set(cwd, { data, fetchedAt: now });
1063
+ if (backend === null || budget === null) {
1064
+ return Promise.resolve({ data: null, rateLimitedUntil: null, backend: null });
1058
1065
  }
1059
- return data;
1066
+ const key = `${cwd}:${concept}`;
1067
+ const existing = this.inflight.get(key);
1068
+ if (existing)
1069
+ return existing;
1070
+ const flight = this.dispatch(cache, cwd, concept, ttl, backend, budget).finally(() => {
1071
+ // Identity check: a settling flight must never unregister a newer one.
1072
+ if (this.inflight.get(key) === flight)
1073
+ this.inflight.delete(key);
1074
+ });
1075
+ this.inflight.set(key, flight);
1076
+ return flight;
1060
1077
  }
1061
- async fetchMergedPRsCached(cwd) {
1062
- const now = Date.now();
1063
- const cached = this.mergedPRCache.get(cwd);
1064
- if (cached && (now - cached.fetchedAt) < this.TTL) {
1065
- return cached.data;
1078
+ /** Run the concept once and record the outcome. Never rejects. */
1079
+ async dispatch(cache, cwd, concept, ttl, backend, budget) {
1080
+ const dispatchedAt = this.now();
1081
+ let data = null;
1082
+ let errorText = null;
1083
+ try {
1084
+ const result = await fetchForOverview(concept, cwd);
1085
+ data = result.data;
1086
+ if (result.error)
1087
+ errorText = `${result.error.stderr}\n${result.error.message}`;
1088
+ else if (data === null)
1089
+ errorText = '';
1066
1090
  }
1067
- const data = await fetchRecentMergedPRs(cwd);
1068
- if (data !== null) {
1069
- this.mergedPRCache.set(cwd, { data, fetchedAt: now });
1091
+ catch (err) {
1092
+ errorText = err instanceof Error ? err.message : String(err);
1070
1093
  }
1071
- return data;
1094
+ const now = this.now();
1095
+ if (errorText !== null) {
1096
+ const suspended = this.limiter.noteFailure(budget, backend, cwd, errorText, now);
1097
+ cache.set(cwd, { data: null, fetchedAt: dispatchedAt, expiresAt: now + this.failureWindow(`${cwd}:${budget}`, now) });
1098
+ return { data: null, rateLimitedUntil: suspended ? this.limiter.resetAt(budget, now) : null, backend };
1099
+ }
1100
+ this.limiter.noteSuccess(budget, dispatchedAt);
1101
+ this.failureWindows.delete(`${cwd}:${budget}`);
1102
+ cache.set(cwd, { data, fetchedAt: dispatchedAt, expiresAt: now + ttl });
1103
+ return { data, rateLimitedUntil: null, backend };
1104
+ }
1105
+ /** Current failure window for a key; escalates once per elapsed window, not per failure. */
1106
+ failureWindow(key, now) {
1107
+ const w = this.failureWindows.get(key);
1108
+ if (w && now < w.startedAt + w.ttl)
1109
+ return w.ttl;
1110
+ const level = w ? w.level + 1 : 0;
1111
+ const ttl = Math.min(FAILURE_WINDOW_MIN_MS * 2 ** level, FAILURE_WINDOW_MAX_MS);
1112
+ this.failureWindows.set(key, { level, startedAt: now, ttl });
1113
+ return ttl;
1114
+ }
1115
+ }
1116
+ /** Degraded-mode text naming the real reason and the real backend (never "GitHub" on GitLab). */
1117
+ function describeMissing(outcome, what) {
1118
+ if (outcome.rateLimitedUntil !== null) {
1119
+ return `${outcome.backend} rate limited until ${new Date(outcome.rateLimitedUntil).toISOString()} — ${what} unavailable`;
1072
1120
  }
1121
+ if (outcome.backend === null)
1122
+ return `forge concept disabled — ${what} unavailable`;
1123
+ return `${outcome.backend} CLI unavailable — could not fetch ${what}`;
1073
1124
  }
1074
1125
  //# sourceMappingURL=overview.js.map