@huseynsnmz/pi-suite 0.0.0-stage → 0.2.0

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 (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * LEGACY FALLBACK (issue #91 phase 6 marking): the shrink-and-hold jiggle is
3
+ * kept only for pre-protocol runners and the AGENT_BOARD_TERMINAL_SNAPSHOT=0
4
+ * kill switch. It is NOT a success condition of the snapshot+subscribe
5
+ * protocol path — the protocol re-baselines the view from runner-owned
6
+ * snapshots (phase 4), and jiggle arming is skipped once the probe resolves
7
+ * to the protocol attach mode. Removal condition: the installed runner fleet
8
+ * is on the snapshot protocol baseline (wire-detectable via hello protocol
9
+ * fields).
10
+ *
11
+ * Injectable orchestration for the attach shrink-and-hold jiggle protocol.
12
+ *
13
+ * Replaces the pulse-pair jiggle (shrink → 200ms → restore) with a
14
+ * shrink-and-hold protocol: on connect we resize the child PTY to a safe
15
+ * alternate size and KEEP it there until the child's own rendering proves it
16
+ * observed the size change (a full clear, \x1b[2J, which pi-tui emits from
17
+ * fullRender(true) whenever widthChanged fires). At normal sizes the alternate
18
+ * size is one column/row smaller; near the minimum it changes only a safe
19
+ * dimension, and at 20x5 no jiggle is attempted. Because there is no
20
+ * "restore" that can cancel the shrink before a clear is seen, event-loop
21
+ * coalescing on either side (outer dashboard timers or the child's
22
+ * SIGWINCH/render throttle) can no longer collapse a jiggle into a
23
+ * net-zero size change.
24
+ *
25
+ * Cold-start (issue #25): if the child TUI starts rendering AFTER we shrink,
26
+ * its first frame baselines at the shrunk size and never clears. The
27
+ * re-arm — the first \x1b[?2026h frame — therefore restores the original
28
+ * size: the now-rendering child sees a width different from its baseline
29
+ * and fullRenders. Guards:
30
+ * G1 NO_FRAME_RESTORE_MS — no TUI frame within 6s → restore (no renderer
31
+ * to trigger; continuing to hold has no purpose). If the TUI boots even
32
+ * later, its first frame re-arms a fresh hold (F1 slow-boot probe), so
33
+ * healing still fires the moment the child actually starts rendering.
34
+ * G2 backoff budget exhausted without a clear → restore (non-pi children,
35
+ * dead sessions).
36
+ * G3 restoreAndStop() — component close/detach restores while the socket
37
+ * is still usable.
38
+ * G4 notifyExternalResize() — a real user resize cancels the hold and
39
+ * adopts the new size.
40
+ * G5 start() restores any previous hold before arming a new one (reconnect).
41
+ * G6 post-restore verify (issue #42): the frameStart fast path restores the
42
+ * hold on the FIRST frame, which may predate the shrink — if the restore
43
+ * then collapses both resizes into a net-zero change at the child, the
44
+ * child renders nothing and never clears. While a frame has been seen
45
+ * and no clear followed, each backoff tick re-arms the shrink (which
46
+ * lands alone and cannot collapse), bounded by the same retry budget.
47
+ *
48
+ * If pi-tui ever drops the 2026h sequence, re-arm degrades to G1: the PTY
49
+ * is restored within 6s — still better than the pre-#10 behavior. At the
50
+ * minimum supported PTY size (20x5), jiggle is disabled because the runner's
51
+ * clamp leaves no valid alternate size.
52
+ */
53
+ import { resizeJiggleSize } from "./pty-scroll.mjs";
54
+ import {
55
+ advanceRetry,
56
+ createJiggleRetryState,
57
+ feedOutput,
58
+ nextRetryDelay,
59
+ stopRetry,
60
+ } from "./pty-attach-jiggle-retry.mjs";
61
+
62
+ /** Restore the held (shrunk) child PTY when no TUI frame arrives this long. */
63
+ const NO_FRAME_RESTORE_MS = 6000;
64
+ /**
65
+ * Grace window after the frameStart fast-path restore before the chain may
66
+ * re-poke (issue #42). A healthy child proves the restore landed with a
67
+ * fullRender clear within a few hundred ms even on large screens; without the
68
+ * grace a slow-but-healthy clear would trigger a pointless re-shrink flicker.
69
+ */
70
+ const POST_RESTORE_VERIFY_MS = 900;
71
+
72
+ /** Lifetime cap on runtime desync heals (issue #11): a persistent misdiagnosis
73
+ * must not flicker the screen forever; after this many attempts the backstop
74
+ * stays quiet until the controller is recreated. Consumed on heal() entry. */
75
+ const HEAL_MAX_PER_LIFETIME = 5;
76
+
77
+ /**
78
+ * @typedef {Object} JiggleRetryControllerDeps
79
+ * @property {(cols: number, rows: number) => void} sendResize - Resize the child PTY.
80
+ * @property {(fn: () => void, ms: number) => unknown} setTimeoutFn - Timer factory.
81
+ * @property {(timer: unknown) => void} clearTimeoutFn - Timer canceller.
82
+ */
83
+
84
+ /**
85
+ * @param {JiggleRetryControllerDeps} deps
86
+ */
87
+ export function createJiggleRetryController(deps) {
88
+ const { sendResize, setTimeoutFn, clearTimeoutFn } = deps;
89
+ let state = createJiggleRetryState();
90
+ let carry = "";
91
+ let tuiFrameSeen = false;
92
+ /** True while the child PTY is parked at the shrunk size. */
93
+ let held = false;
94
+ /** True once the original size has been sent back (restore is one-shot). */
95
+ let restored = false;
96
+ /** @type {[number, number]} */
97
+ let originalCols = 0;
98
+ let originalRows = 0;
99
+ /** The safe alternate size used while a jiggle hold is active, or null when unsupported. */
100
+ let holdSize = null;
101
+ /** @type {unknown | null} */
102
+ let chainTimer = null;
103
+ /** @type {unknown | null} */
104
+ let g1Timer = null;
105
+ /** Runtime heals spent (issue #11); never reset by start()/restoreAndStop(). */
106
+ let healCount = 0;
107
+
108
+ function clearChainTimer() {
109
+ if (chainTimer === null) return;
110
+ clearTimeoutFn(chainTimer);
111
+ chainTimer = null;
112
+ }
113
+
114
+ function clearG1Timer() {
115
+ if (g1Timer === null) return;
116
+ clearTimeoutFn(g1Timer);
117
+ g1Timer = null;
118
+ }
119
+
120
+ function clearAllTimers() {
121
+ clearChainTimer();
122
+ clearG1Timer();
123
+ }
124
+
125
+ /**
126
+ * Restore the child PTY to the original size exactly once. No-op while
127
+ * not held or after the restore already happened.
128
+ */
129
+ function restoreIfHeld() {
130
+ if (!held || restored) return;
131
+ sendResize(originalCols, originalRows);
132
+ restored = true;
133
+ held = false;
134
+ }
135
+
136
+ /**
137
+ * One chain tick: advance the backoff budget, then re-arm the hold if the
138
+ * child has a renderer (a TUI frame was seen) but no clear ever proved the
139
+ * last size delta landed and the hold is currently inactive (issue #42:
140
+ * the frameStart fast-path restore can race the shrink into a net-zero
141
+ * size change at the child — its throttled renderer reads back the
142
+ * original size and stays silent forever — so a clear-less restore must
143
+ * be followed by a fresh shrink, which lands alone and cannot collapse).
144
+ * Children that never rendered a frame (shells, cold boots) keep the old
145
+ * pure-countdown behavior and are handled by G1/G2 only.
146
+ */
147
+ function tickChain() {
148
+ chainTimer = null;
149
+ state = advanceRetry(state);
150
+ ensureHold();
151
+ if (state.clearDetected || state.stopped) return;
152
+ scheduleNextRetry();
153
+ }
154
+
155
+ function ensureHold() {
156
+ if (!tuiFrameSeen || held || state.clearDetected || state.stopped || !holdSize) return;
157
+ sendResize(holdSize.cols, holdSize.rows);
158
+ held = true;
159
+ restored = false;
160
+ }
161
+
162
+ function scheduleNextRetry() {
163
+ const delay = nextRetryDelay(state);
164
+ if (delay === null) {
165
+ state = stopRetry(state);
166
+ restoreIfHeld(); // G2
167
+ return;
168
+ }
169
+ chainTimer = setTimeoutFn(tickChain, delay);
170
+ }
171
+
172
+ function armG1() {
173
+ clearG1Timer();
174
+ g1Timer = setTimeoutFn(() => {
175
+ g1Timer = null;
176
+ if (!tuiFrameSeen) restoreIfHeld(); // G1
177
+ }, NO_FRAME_RESTORE_MS);
178
+ }
179
+
180
+ /**
181
+ * Arm a fresh hold for a (re)connected attach at the given PTY size.
182
+ * Restores any previous hold first (G5), then shrinks and holds.
183
+ * @param {number} cols
184
+ * @param {number} rows
185
+ */
186
+ function start(cols, rows) {
187
+ clearAllTimers();
188
+ if (held) {
189
+ sendResize(originalCols, originalRows); // G5: unwind previous hold
190
+ restored = true;
191
+ held = false;
192
+ }
193
+ state = createJiggleRetryState();
194
+ carry = "";
195
+ tuiFrameSeen = false;
196
+ originalCols = cols;
197
+ originalRows = rows;
198
+ holdSize = resizeJiggleSize(cols, rows);
199
+ sendResize(cols, rows);
200
+ if (!holdSize) {
201
+ // The runner clamps below 20x5, so a smaller request would be a
202
+ // net-zero resize and cannot trigger a redraw. Leave the child at the
203
+ // requested original size and disable this attach's jiggle chain.
204
+ state = stopRetry(state);
205
+ held = false;
206
+ restored = true;
207
+ return;
208
+ }
209
+ sendResize(holdSize.cols, holdSize.rows);
210
+ held = true;
211
+ restored = false;
212
+ armG1();
213
+ scheduleNextRetry();
214
+ }
215
+
216
+ /**
217
+ * Feed one socket output chunk. A clear wins over the re-arm when both
218
+ * appear in one chunk — but frame cognition is still learned from that
219
+ * chunk: a clear only proves the child redraws, not that it isn't a TUI
220
+ * (a live fullRender chunk bundles a frame start with its clear, issue #11).
221
+ * The first TUI frame restores the held size (the
222
+ * child is now rendering and will fullRender on the width delta) and
223
+ * does NOT reschedule the chain; if G1 already released the hold before
224
+ * the TUI booted, the frame instead re-arms a fresh hold so the running
225
+ * child still sees a width delta (F1 slow-boot probe).
226
+ * Terminal chain states still learn frame cognition from later output
227
+ * (issue #106) — cognition only; re-opening the protocol stays heal()'s job.
228
+ * @param {string} data
229
+ */
230
+ function feed(data) {
231
+ // Terminal chain states: a clear was seen (chain done) or the chain ended
232
+ // (G2/G3/G4). Output no longer drives the retry protocol — but frame
233
+ // cognition must still be learned from it (issue #106): a TUI whose first
234
+ // frame lands after the chain settled must still open the runtime desync
235
+ // backstop's gate 2 (issue #11), otherwise heal() stays unreachable for the
236
+ // rest of this connection. Cognition only — no timers, no resizes, and no
237
+ // retry-state change: re-opening the protocol is heal()'s job (rate-limited
238
+ // and lifetime-capped), not an output chunk's.
239
+ if (state.clearDetected || state.stopped) {
240
+ if (tuiFrameSeen) return; // latched already — nothing left to learn
241
+ const terminal = feedOutput(state, data, carry);
242
+ carry = terminal.carry; // keep cross-chunk marker detection intact
243
+ if (terminal.frameStartFound) tuiFrameSeen = true;
244
+ return;
245
+ }
246
+ const result = feedOutput(state, data, carry);
247
+ state = result.state;
248
+ carry = result.carry;
249
+ // Learn frame cognition unconditionally, BEFORE the clear branch: a
250
+ // clear-wins chunk must not swallow it (issue #11), and only the FIRST
251
+ // frame ever seen drives the re-arm/fast-path logic below.
252
+ const firstFrame = result.frameStartFound && !tuiFrameSeen;
253
+ if (result.frameStartFound) tuiFrameSeen = true;
254
+ if (result.clearFound) {
255
+ clearAllTimers();
256
+ restoreIfHeld();
257
+ state = stopRetry({ ...state, clearDetected: true });
258
+ return;
259
+ }
260
+ if (firstFrame) {
261
+ clearG1Timer();
262
+ if (held) {
263
+ restoreIfHeld(); // fast path: child is rendering, width delta now lands
264
+ // …unless that frame was in flight BEFORE the shrink and the restore
265
+ // collapses both into a net-zero change at the child (issue #42).
266
+ // Restart the chain on the post-restore grace window: no clear within
267
+ // POST_RESTORE_VERIFY_MS → tickChain re-arms the hold (ensureHold).
268
+ clearChainTimer();
269
+ chainTimer = setTimeoutFn(tickChain, POST_RESTORE_VERIFY_MS);
270
+ } else {
271
+ // Slow boot: G1 released the hold before the TUI came up, so the
272
+ // child baselined at the original size. Re-arm a fresh hold — its
273
+ // next frame then sees a width delta and fullRenders. Guards: the
274
+ // clear path (primary) and G2 budget exhaustion (chain still
275
+ // ticking). G1 is NOT re-armed: frames are now flowing.
276
+ if (holdSize) sendResize(holdSize.cols, holdSize.rows);
277
+ held = holdSize !== null;
278
+ restored = false;
279
+ }
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Runtime desync backstop (issue #11): re-arm the shrink-and-hold protocol
285
+ * mid-session. Unlike start(), tuiFrameSeen is preserved (the child has
286
+ * rendered), G1 is not armed (frames are flowing), and the budget is
287
+ * lifetime-capped so a misdiagnosis cannot flicker the screen forever.
288
+ * Consumes one budget slot on entry, including the tiny-terminal give-up.
289
+ * @param {number} cols
290
+ * @param {number} rows
291
+ * @returns {boolean} true when a heal hold was armed.
292
+ */
293
+ function heal(cols, rows) {
294
+ if (healCount >= HEAL_MAX_PER_LIFETIME) return false;
295
+ healCount++;
296
+ clearAllTimers();
297
+ if (held) {
298
+ // Unwind any live hold (e.g. a previous clear-less heal) first.
299
+ sendResize(originalCols, originalRows);
300
+ restored = true;
301
+ held = false;
302
+ }
303
+ state = createJiggleRetryState();
304
+ carry = "";
305
+ originalCols = cols;
306
+ originalRows = rows;
307
+ holdSize = resizeJiggleSize(cols, rows);
308
+ if (!holdSize) {
309
+ state = stopRetry(state);
310
+ held = false;
311
+ restored = true;
312
+ return false;
313
+ }
314
+ sendResize(holdSize.cols, holdSize.rows);
315
+ held = true;
316
+ restored = false;
317
+ scheduleNextRetry(); // G2 backoff re-shrinks while a renderer is seen but no clear follows
318
+ return true;
319
+ }
320
+
321
+ /**
322
+ * Restore the held size (if any) and stop all chain activity. Used by the
323
+ * component on close/detach while the socket is still usable (G3).
324
+ */
325
+ function restoreAndStop() {
326
+ clearAllTimers();
327
+ restoreIfHeld();
328
+ state = stopRetry(state);
329
+ }
330
+
331
+ /**
332
+ * A real user resize supersedes the hold protocol: cancel all timers,
333
+ * mark the hold void, adopt the new size as the new original, and stop
334
+ * the chain (G4).
335
+ * @param {number} cols
336
+ * @param {number} rows
337
+ */
338
+ function notifyExternalResize(cols, rows) {
339
+ clearAllTimers();
340
+ held = false;
341
+ restored = false;
342
+ originalCols = cols;
343
+ originalRows = rows;
344
+ holdSize = null;
345
+ state = stopRetry(state);
346
+ }
347
+
348
+ return {
349
+ start,
350
+ feed,
351
+ heal,
352
+ restoreAndStop,
353
+ notifyExternalResize,
354
+ getState: () => ({ ...state, held, tuiFrameSeen, originalCols, originalRows, holdSize, healCount }),
355
+ };
356
+ }
@@ -0,0 +1,109 @@
1
+ /** Pure logic for attach jiggle-retry: detect full-clear, schedule backoff retries. */
2
+
3
+ export const BACKOFF_MS = Object.freeze([120, 500, 1500, 3000, 6000, 10000, 15000, 20000]);
4
+ export const MAX_RETRIES = BACKOFF_MS.length;
5
+
6
+ // Verified empirically in issue #2: a resize jiggle makes the child pi-tui run
7
+ // fullRender(true), whose output begins with a full clear — a controlled test
8
+ // against a live idle session received 2 clear sequences + ~920KB full repaint
9
+ // per jiggle. So \x1b[2J in the socket output is a reliable "jiggle landed"
10
+ // signal. If pi-tui ever changes fullRender to skip the clear, the detector
11
+ // silently degrades to the old one-shot behavior after MAX_RETRIES.
12
+ const FULL_CLEAR = "\x1b[2J";
13
+ // pi-tui begins every frame (including differential frames) with a synchronized
14
+ // output start sequence; boot-time extension output never contains it, so the
15
+ // first occurrence marks "child TUI has started rendering" (issue #10).
16
+ const TUI_FRAME_START = "\x1b[?2026h";
17
+ /** Carry enough bytes to catch either target sequence split at a chunk boundary. */
18
+ const CARRY_LEN = TUI_FRAME_START.length - 1; // 7 bytes
19
+
20
+ /**
21
+ * @typedef {Object} JiggleRetryState
22
+ * @property {number} retryIndex - Current retry round (0 = initial, not yet retried).
23
+ * @property {boolean} clearDetected - Whether a full-clear sequence was seen.
24
+ * @property {boolean} stopped - Whether the retry chain has been stopped.
25
+ */
26
+
27
+ /** Create initial retry state. */
28
+ export function createJiggleRetryState() {
29
+ return { retryIndex: 0, clearDetected: false, stopped: false };
30
+ }
31
+
32
+ /**
33
+ * Feed PTY output data into the state machine.
34
+ * Returns new state, updated carry buffer, and which target sequences were found.
35
+ * @param {JiggleRetryState} state
36
+ * @param {string} data - New output data chunk.
37
+ * @param {string} carry - Leftover partial escape sequence from previous chunk.
38
+ * @returns {{ state: JiggleRetryState, carry: string, clearFound: boolean, frameStartFound: boolean }}
39
+ */
40
+ export function feedOutput(state, data, carry) {
41
+ const combined = carry + data;
42
+ const clearFound = hasFullClearSequence(combined);
43
+ const frameStartFound = hasTuiFrameStart(combined);
44
+ // Always keep the tail carry: a clear hit stops the chain (carry irrelevant), and
45
+ // a frame-start hit must still preserve a trailing half-sequence so an immediately
46
+ // following clear split across the next chunk is not missed.
47
+ const newCarry = tailCarry(combined);
48
+ const newState = clearFound ? { ...state, clearDetected: true } : state;
49
+ return { state: newState, carry: newCarry, clearFound, frameStartFound };
50
+ }
51
+
52
+ /**
53
+ * Get the delay before the next retry, or null if no more retries.
54
+ * @param {JiggleRetryState} state
55
+ * @returns {number | null}
56
+ */
57
+ export function nextRetryDelay(state) {
58
+ if (state.stopped || state.clearDetected) return null;
59
+ if (state.retryIndex >= MAX_RETRIES) return null;
60
+ return BACKOFF_MS[state.retryIndex];
61
+ }
62
+
63
+ /**
64
+ * Advance to the next retry round.
65
+ * @param {JiggleRetryState} state
66
+ * @returns {JiggleRetryState}
67
+ */
68
+ export function advanceRetry(state) {
69
+ return { ...state, retryIndex: state.retryIndex + 1 };
70
+ }
71
+
72
+ /**
73
+ * Stop the retry chain (attach settled, component closed, etc.).
74
+ * @param {JiggleRetryState} state
75
+ * @returns {JiggleRetryState}
76
+ */
77
+ export function stopRetry(state) {
78
+ return { ...state, stopped: true };
79
+ }
80
+
81
+ /**
82
+ * Check if data contains the full-clear escape sequence.
83
+ * @param {string} data
84
+ * @returns {boolean}
85
+ */
86
+ export function hasFullClearSequence(data) {
87
+ return data.includes(FULL_CLEAR);
88
+ }
89
+
90
+ /**
91
+ * Check if data contains the TUI frame-start (synchronized output) sequence.
92
+ * pi-tui begins every frame with \x1b[?2026h; boot-time extension output never
93
+ * contains it, so the first occurrence marks "child TUI has started rendering".
94
+ * @param {string} data
95
+ * @returns {boolean}
96
+ */
97
+ export function hasTuiFrameStart(data) {
98
+ return data.includes(TUI_FRAME_START);
99
+ }
100
+
101
+ /** Extract the tail carry for cross-chunk boundary detection. */
102
+ function tailCarry(data) {
103
+ // We only need to carry up to CARRY_LEN bytes to catch a split \x1b[2J or
104
+ // \x1b[?2026h. Find the last \x1b in the tail — everything before it can't be
105
+ // part of a split escape sequence.
106
+ const tail = data.slice(-CARRY_LEN);
107
+ const escIdx = tail.lastIndexOf("\x1b");
108
+ return escIdx >= 0 ? tail.slice(escIdx) : "";
109
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Attach reconnect policy (issue #48). The pty-runner can die without sending
3
+ * an exit message (uncaught crash), which previously left the attach view in a
4
+ * 150 ms reconnect loop forever with no way out. Two regimes:
5
+ * - ever connected: a crashed host is unrecoverable quickly — give up after a
6
+ * short window and report "host exited";
7
+ * - never connected: the host may still be cold-starting (service launches it
8
+ * right before attach) — allow a long window before declaring it unreachable.
9
+ */
10
+ import { resolveTestMs } from "./test-knobs.mjs";
11
+
12
+ /** Give-up delay after a previously-established connection drops (ms). */
13
+ export const ATTACH_RECONNECT_TIMEOUT_MS = 15_000;
14
+ /** Give-up delay while waiting for the initial host connection (ms). */
15
+ export const ATTACH_HOST_START_TIMEOUT_MS = 120_000;
16
+
17
+ /**
18
+ * Dynamic reader for ATTACH_RECONNECT_TIMEOUT_MS (issue #95 F4): env set after
19
+ * module load must still reach the reconnect policy, so read per evaluation.
20
+ * @param {NodeJS.ProcessEnv} [env]
21
+ * @returns {number}
22
+ */
23
+ export function resolveAttachReconnectTimeoutMs(env = process.env) {
24
+ return resolveTestMs(env, "AGENT_BOARD_TEST_ATTACH_RECONNECT_TIMEOUT_MS", ATTACH_RECONNECT_TIMEOUT_MS);
25
+ }
26
+
27
+ /**
28
+ * @param {{ everConnected: boolean, disconnectedAt: number|null, connectStartedAt: number, now: number }} input
29
+ * @returns {{ giveUp: boolean, status: string | null }}
30
+ */
31
+ export function evaluateAttachReconnect({ everConnected, disconnectedAt, connectStartedAt, now }) {
32
+ if (everConnected && disconnectedAt !== null) {
33
+ const elapsed = now - disconnectedAt;
34
+ return elapsed >= resolveAttachReconnectTimeoutMs()
35
+ ? { giveUp: true, status: "host exited" }
36
+ : { giveUp: false, status: null };
37
+ }
38
+ const elapsed = now - connectStartedAt;
39
+ return elapsed >= ATTACH_HOST_START_TIMEOUT_MS
40
+ ? { giveUp: true, status: "host not reachable" }
41
+ : { giveUp: false, status: null };
42
+ }
43
+
44
+ /**
45
+ * Detach-key policy (issue #91 Phase 6, spec §D1): while the socket is down a
46
+ * key can never reach the child, so treat ← as "leave the view"
47
+ * unconditionally; otherwise detach only when the pushed editorEmpty side
48
+ * channel is exactly true. false and null/undefined mean "forward" — the
49
+ * explicit conservative policy; callers pass `editorEmpty === true` so an
50
+ * unknown state can never arm the detach, and no terminal-buffer heuristic is
51
+ * consulted (the deleted terminal-buffer heuristic family — issues
52
+ * #42/#66/#69/#103 — stays deleted).
53
+ * @param {boolean} connected
54
+ * @param {boolean | null | undefined} editorEmpty
55
+ * @returns {boolean}
56
+ */
57
+ export function shouldEscapeAttach(connected, editorEmpty) {
58
+ // Normalize to a strict boolean: a null/undefined editorEmpty (unknown)
59
+ // forwards — never escapes — and must not leak through the return value.
60
+ return !connected || editorEmpty === true;
61
+ }