@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,3184 @@
1
+ /** Live PTY attach surface for hosted agent-board rows. */
2
+ import { spawn } from "node:child_process";
3
+ import { createRequire } from "node:module";
4
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
5
+ import { createConnection, type Socket } from "node:net";
6
+ import { StringDecoder } from "node:string_decoder";
7
+ import type { Component, KeybindingsManager, RgbColor, TUI, TerminalColorScheme, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
8
+ import { CURSOR_MARKER, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { resolveAttachDeleteKey } from "../core/attach-delete.mjs";
10
+ import { createDoublePress, QUIT_PRESS_MS } from "../core/double-press.mjs";
11
+ import { isOpenableLink, findHttpUrlAtCells, findWordRangeAtCells } from "../core/pty-links.mjs";
12
+ import { holdInputListeners } from "../core/hold-input.mjs";
13
+ import { suspendToShell } from "../core/suspend.mjs";
14
+ import { canPublishAttachView, createAttachOutputRenderScheduler, decideAttachPresentation, detectCursorDesync, isPtyCursorHidden, nextAttachRender, projectPtyCursor, shouldScheduleAttachRenderForMessage } from "../core/pty-attach-render.mjs";
15
+ import { evaluateAttachReconnect, shouldEscapeAttach } from "../core/pty-attach-reconnect.mjs";
16
+ import { installImeCursorCoalesce } from "../core/ime-cursor-coalesce.mjs";
17
+ import { createJiggleRetryController } from "../core/pty-attach-jiggle-controller.mjs";
18
+ import { createTerminalAttachClient } from "../core/terminal-attach-client.mjs";
19
+ import { MAX_VIEW_PROOF_RECORDS } from "../core/terminal-attach-protocol.mjs";
20
+ import { createSafeTrailingClassifier } from "../core/attach-view-proof.mjs";
21
+ import { backgroundRgbFromTerminalColors, buildSettleSchemePatch, colorSchemeForBackgroundRgb, extractOscQuerySequences, resolveProbeApi, toColorSchemeReport } from "../core/terminal-query-sequences.mjs";
22
+ import { REPLY_GUARD_WINDOW_MS, createReplyGuard, isReplyGuardEnabled } from "../core/terminal-reply-guard.mjs";
23
+ import { clampInt, encodeWheelReport, fullscreenMouseEnableSequence, historyFloorAfterHydrate, isFullscreenSelectionEnd, resolveAttachMouseAction, resolveAttachWheelRoute, resolvePasteSelectionSource, resolveWheelLines, sameMousePoint, scrollableHistoryLines, scrollViewportTop, selectionDragScrollLines, selectionReleaseIsDrag, shouldOwnOuterMouseMode, splitAttachInput, wheelScrollLinesUp } from "../core/pty-scroll.mjs";
24
+
25
+ export type PtyAttachResult = { action: "detached" } | { action: "closed"; exitCode?: number | null } | { action: "delete" } | { action: "quit" };
26
+
27
+ type ThemeLike = {
28
+ fg(color: string, text: string): string;
29
+ bold(text: string): string;
30
+ };
31
+
32
+ /** The pi-tui members the attach view reads without a public typing contract:
33
+ * getActiveSelectionText / clearTextSelection are private in TuiAltScreen's d.ts
34
+ * (runtime-reachable plain methods), getCopyOnSelect is public. Every access is
35
+ * feature-detected so an older or newer pi-tui degrades instead of throwing. */
36
+ interface FullscreenSelectionHost {
37
+ getActiveSelectionText?: () => string | undefined;
38
+ getCopyOnSelect?: () => boolean;
39
+ clearTextSelection?: () => void;
40
+ }
41
+
42
+ /** Diagnostic patch emitted at attach settle (DiagnosticEvent-compatible; built by buildSettleSchemePatch). */
43
+ export interface AttachSettleDiagnosticPatch {
44
+ source: string;
45
+ level: "info" | "warn";
46
+ code: string;
47
+ message: string;
48
+ details: { probeApi?: string; outcome: string; report?: string; late?: boolean };
49
+ }
50
+
51
+ export interface PtyAttachOptions {
52
+ socketPath: string;
53
+ screenLogPath?: string;
54
+ title: string;
55
+ /** Issue #161: receives settle-scheme diagnostic patches. Default: no-op. */
56
+ onDiagnostic?: (event: AttachSettleDiagnosticPatch) => void;
57
+ }
58
+
59
+ const require = createRequire(import.meta.url);
60
+ const { Terminal } = require("@xterm/headless") as { Terminal: new (opts: Record<string, unknown>) => XtermLike };
61
+
62
+
63
+ const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h";
64
+ const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l";
65
+ const XTSHIFTESCAPE_SELECT = "\x1b[>0s";
66
+ const DOUBLE_CLICK_MS = 260;
67
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
68
+ const LOADING_TICK_MS = 120;
69
+ /** How long to keep the loading banner after the last resize-jiggle during attach. */
70
+ const ATTACH_SETTLE_MS = 250;
71
+ /** Hard cap on the attach transition so a silent session can't stall the banner. */
72
+ const ATTACH_HARD_TIMEOUT_MS = 2500;
73
+ /** A session can redraw for a resize without proving it, and then idles, so nothing would ever
74
+ * finish the attach. It's resized away and back to draw again, a few times at most. */
75
+ const GEOMETRY_NUDGE_LIMIT = 3;
76
+ const GEOMETRY_NUDGE_SETTLE_MS = 100;
77
+ const GEOMETRY_NUDGE_WAIT_MS = 1000;
78
+ /** Protocol-race guard for legacy jiggle arming (issue #91 phase 4): the
79
+ * undecided window must not pulse the child before the snapshot probe
80
+ * resolves — a resize-free attach is the protocol-mode contract. Invisible
81
+ * for legacy (the probe timeout decides at 1500ms ≫ 100ms) and the protocol
82
+ * answer (~ms) reliably beats it; a pathologically late snapshot merely costs
83
+ * one harmless shrink pulse that the protocol mode event unwinds. */
84
+ const LEGACY_JIGGLE_ARM_DELAY_MS = 100;
85
+ /** Give ordered detach packets time to flush before using destroy as a fallback. */
86
+ const GRACEFUL_SOCKET_CLOSE_MS = 1000;
87
+ /** Desync detection window: how long output must stay silent before a
88
+ * misaligned cursor counts as desync (issue #11). Streaming keeps the cursor
89
+ * on plain output cells legitimately; a healthy idle child re-parks it on the
90
+ * inverse fake-cursor cell on its last rendered frame. */
91
+ const DESYNC_QUIET_MS = 1500;
92
+ /** How often the post-settle probe runs checkDesync() (issue #11). */
93
+ const DESYNC_PROBE_INTERVAL_MS = 2000;
94
+ /** Minimum spacing between two runtime heals (issue #11). */
95
+ const HEAL_RATELIMIT_MS = 10000;
96
+ /** Issue #148: probe budget for asking the REAL terminal for its background
97
+ * color. Deliberately ONE probe: every timed-out probe leaves a settled entry in
98
+ * the LOCAL pi-tui's pending-query queue (its timeout path neither dequeues nor
99
+ * decrements) and that stale entry eats the next reply, so a retry loop cannot
100
+ * converge — it would only add another stale entry per attempt and degrade the
101
+ * host's own background detection. One probe keeps the clean-queue case working
102
+ * and the leaked case degrading silently. */
103
+ const REAL_TERMINAL_SCHEME_PROBE_TIMEOUT_MS = 500;
104
+ /** How many tail bytes of the screen log to replay on attach. Read from the file tail
105
+ * (not the whole file) so multi-MB logs don't block startup; ~60KB covers the last
106
+ * handful of screens, which is all a fresh attach needs. */
107
+ const ATTACH_REPLAY_BYTES = 60_000;
108
+ /** A frame the session draws this soon after a key or wheel event is the answer to it, so it paints
109
+ * as soon as it's whole rather than waiting out the output throttle. */
110
+ const INTERACTIVE_RENDER_WINDOW_MS = 250;
111
+ const OSC52_PREFIX = "\x1b]52;";
112
+ const OSC52_MAX_BYTES = 1_000_000;
113
+ const OSC52_CARRY_MAX_BYTES = OSC52_MAX_BYTES + 4096;
114
+ const TERMINAL_PASSTHROUGH_MAX_BYTES = 5_000_000;
115
+ const TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES = TERMINAL_PASSTHROUGH_MAX_BYTES + 4096;
116
+ const KITTY_IMAGE_PREFIX = "\x1b_G";
117
+ const ITERM2_FILE_PREFIX = "\x1b]1337;File=";
118
+
119
+ interface XtermParserLike {
120
+ registerCsiHandler(opts: { prefix: string; final: string }, cb: (params: (number | number[])[]) => boolean): { dispose(): void };
121
+ }
122
+
123
+ interface XtermLike {
124
+ write(data: string, cb?: () => void): void;
125
+ resize(cols: number, rows: number): void;
126
+ parser?: XtermParserLike;
127
+ dispose?(): void;
128
+ // Full buffer wipe (@xterm/headless Terminal.reset): the attach client's
129
+ // "empty/resnapshot → UI resets its buffer" contract (phase 4, F3).
130
+ reset(): void;
131
+ buffer: {
132
+ active: {
133
+ baseY: number;
134
+ // "normal" | "alternate" (@xterm/headless IBuffer.type): an alternate
135
+ // buffer keeps no scrollback, which is what routes the fullscreen wheel
136
+ // to the child instead of the attach's own viewport (issue #185).
137
+ type: string;
138
+ cursorX?: number;
139
+ cursorY?: number;
140
+ length: number;
141
+ getLine(index: number): BufferLineLike | undefined;
142
+ getNullCell(): BufferCellLike;
143
+ };
144
+ };
145
+ _core?: {
146
+ coreService?: { isCursorHidden?: boolean };
147
+ _oscLinkService?: {
148
+ getLinkData?: (id: number) => { uri?: string } | undefined;
149
+ _dataByLinkId?: Map<number, { data?: { uri?: string } }>;
150
+ };
151
+ };
152
+ }
153
+
154
+ interface BufferLineLike {
155
+ length: number;
156
+ getCell(x: number, cell?: BufferCellLike): BufferCellLike | undefined;
157
+ translateToString(trimRight?: boolean): string;
158
+ }
159
+
160
+ interface BufferCellLike {
161
+ getWidth(): number;
162
+ getChars(): string;
163
+ hasExtendedAttrs(): number;
164
+ extended?: { urlId?: number; _urlId?: number };
165
+ getFgColor(): number;
166
+ getBgColor(): number;
167
+ isFgRGB(): boolean;
168
+ isBgRGB(): boolean;
169
+ isFgPalette(): boolean;
170
+ isBgPalette(): boolean;
171
+ isFgDefault(): boolean;
172
+ isBgDefault(): boolean;
173
+ isBold(): number;
174
+ isItalic(): number;
175
+ isDim(): number;
176
+ isUnderline(): number;
177
+ isBlink(): number;
178
+ isInverse(): number;
179
+ isInvisible(): number;
180
+ isStrikethrough(): number;
181
+ isOverline(): number;
182
+ }
183
+
184
+ interface CommittedRow { cells: SnapshotLine; }
185
+
186
+ interface CommittedView {
187
+ identity: {
188
+ connectionToken: number;
189
+ baselineToken: number;
190
+ generation: string;
191
+ parser: XtermLike;
192
+ geometryRevision: number;
193
+ targetCols: number;
194
+ targetRows: number;
195
+ closed: boolean;
196
+ };
197
+ start: number;
198
+ end: number;
199
+ rows: CommittedRow[];
200
+ cursor: { row: number; col: number } | null;
201
+ cursorHidden: boolean;
202
+ viewportTop: number | null;
203
+ proofSeq: number | null;
204
+ }
205
+
206
+ /** Primitive cell snapshot used by committed rendering and interactions. */
207
+ class SnapshotCell implements BufferCellLike {
208
+ private constructor(
209
+ private readonly charsValue: string,
210
+ private readonly widthValue: number,
211
+ private readonly attrs: {
212
+ bold: number; dim: number; italic: number; underline: number; blink: number; inverse: number;
213
+ invisible: number; strikethrough: number; overline: number;
214
+ fgRGB: boolean; fgPalette: boolean; fg: number;
215
+ bgRGB: boolean; bgPalette: boolean; bg: number;
216
+ },
217
+ private readonly uriValue: string,
218
+ ) {
219
+ this.extended = this.uriValue ? { urlId: 1, _urlId: 1 } : undefined;
220
+ }
221
+ static empty(): SnapshotCell {
222
+ return new SnapshotCell("", 0, {
223
+ bold: 0, dim: 0, italic: 0, underline: 0, blink: 0, inverse: 0,
224
+ invisible: 0, strikethrough: 0, overline: 0,
225
+ fgRGB: false, fgPalette: false, fg: -1, bgRGB: false, bgPalette: false, bg: -1,
226
+ }, "");
227
+ }
228
+ static fromBufferCell(cell: BufferCellLike, uri: string): SnapshotCell {
229
+ return new SnapshotCell(cell.getChars(), cell.getWidth(), {
230
+ bold: cell.isBold(), dim: cell.isDim(), italic: cell.isItalic(), underline: cell.isUnderline(),
231
+ blink: cell.isBlink(), inverse: cell.isInverse(), invisible: cell.isInvisible(),
232
+ strikethrough: cell.isStrikethrough(), overline: cell.isOverline(),
233
+ fgRGB: cell.isFgRGB(), fgPalette: cell.isFgPalette(), fg: cell.getFgColor(),
234
+ bgRGB: cell.isBgRGB(), bgPalette: cell.isBgPalette(), bg: cell.getBgColor(),
235
+ }, uri);
236
+ }
237
+ getWidth(): number { return this.widthValue; }
238
+ getChars(): string { return this.charsValue; }
239
+ hasExtendedAttrs(): number { return this.uriValue ? 1 : 0; }
240
+ extended?: { urlId?: number; _urlId?: number };
241
+ getFgColor(): number { return this.attrs.fg; }
242
+ getBgColor(): number { return this.attrs.bg; }
243
+ isFgRGB(): boolean { return this.attrs.fgRGB; }
244
+ isBgRGB(): boolean { return this.attrs.bgRGB; }
245
+ isFgPalette(): boolean { return this.attrs.fgPalette; }
246
+ isBgPalette(): boolean { return this.attrs.bgPalette; }
247
+ isFgDefault(): boolean { return !this.attrs.fgRGB && !this.attrs.fgPalette; }
248
+ isBgDefault(): boolean { return !this.attrs.bgRGB && !this.attrs.bgPalette; }
249
+ isBold(): number { return this.attrs.bold; }
250
+ isItalic(): number { return this.attrs.italic; }
251
+ isDim(): number { return this.attrs.dim; }
252
+ isUnderline(): number { return this.attrs.underline; }
253
+ isBlink(): number { return this.attrs.blink; }
254
+ isInverse(): number { return this.attrs.inverse; }
255
+ isInvisible(): number { return this.attrs.invisible; }
256
+ isStrikethrough(): number { return this.attrs.strikethrough; }
257
+ isOverline(): number { return this.attrs.overline; }
258
+ osc8Uri(): string { return this.uriValue; }
259
+ }
260
+
261
+ class SnapshotLine implements BufferLineLike {
262
+ constructor(readonly cells: SnapshotCell[]) {}
263
+ get length(): number { return this.cells.length; }
264
+ getCell(x: number): BufferCellLike | undefined { return this.cells[x]; }
265
+ translateToString(trimRight = false): string {
266
+ const text = this.cells.map((cell) => cell.getChars() || " ").join("");
267
+ return trimRight ? text.replace(/\s+$/u, "") : text;
268
+ }
269
+ }
270
+
271
+ type MousePoint = { line: number; col: number };
272
+ type ScreenCell = { row: number; col: number };
273
+ /** Issue #187: `screenAnchor` is the RAW reported cell of the press; the drag/click
274
+ * decision compares it against the release cell, because buffer coordinates drift
275
+ * while the session streams. Absent on word selections (they are created on release). */
276
+ type MouseSelection = { anchor: MousePoint; focus: MousePoint; screenAnchor?: ScreenCell };
277
+ type NormalizedSelection = { start: MousePoint; end: MousePoint };
278
+
279
+ export class PtyAttachComponent implements Component {
280
+ private socket: Socket | null = null;
281
+ private connected = false;
282
+ private closed = false;
283
+ private status = "connecting";
284
+ /** Issue #147: in-place delete confirm is armed (first ctrl+x). */
285
+ private confirmArmed = false;
286
+ private parserBuffer = "";
287
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
288
+ private loadingTimer: ReturnType<typeof setInterval> | null = null;
289
+ private mouseRefreshTimers: Array<ReturnType<typeof setTimeout>> = [];
290
+ // Shrink-and-hold jiggle protocol (issue #25): on attach we resize the child to
291
+ // (cols-1, rows-1) and hold it there until the child emits a full clear
292
+ // (\x1b[2J) — any render the child makes while held sees a width delta and
293
+ // must fullRender, so boot-storm coalescing of ±1 pulses can no longer
294
+ // produce a net-zero change. The controller restores the original size on
295
+ // clear / first TUI frame (re-arm) / no-frame 6s guard / budget exhaustion /
296
+ // close / external resize (guards G1-G5, see controller module).
297
+ private readonly jiggleRetry = createJiggleRetryController({
298
+ sendResize: (cols, rows) => this.sendResize(cols, rows),
299
+ setTimeoutFn: (fn, ms) => {
300
+ const t = setTimeout(fn, ms);
301
+ t.unref?.();
302
+ return t;
303
+ },
304
+ clearTimeoutFn: (t) => clearTimeout(t as ReturnType<typeof setTimeout>),
305
+ });
306
+ // Snapshot+subscribe attach client (issue #91 phase 4, D2). Created ONCE —
307
+ // reconnects re-point send at the CURRENT socket (this.send routes through
308
+ // this.socket) and resume from the applied cursor; wiring shape per the
309
+ // verified reference in test/terminal-snapshot.integration.test.mjs
310
+ // (wireClient). AGENT_BOARD_TERMINAL_SNAPSHOT=0 forces the legacy path
311
+ // (escape hatch + deterministic legacy tests).
312
+ private readonly attachClient = createTerminalAttachClient({
313
+ send: (msg) => this.send(msg),
314
+ emit: (event, payload) => this.handleAttachEvent(event, payload),
315
+ });
316
+ /** Decided attach path: "undecided" until the probe resolves (snapshot
317
+ * begin → protocol; probe timeout / version mismatch → legacy). Gates
318
+ * jiggle arming and probe-vs-cursor reconnects. */
319
+ private attachMode: "undecided" | "protocol" | "legacy" = "undecided";
320
+ private jiggleStartTimer: ReturnType<typeof setTimeout> | null = null;
321
+ private osc52Carry = "";
322
+ private passthroughCarry = "";
323
+ private oscQueryCarry = "";
324
+ // Issue #170: inbound reply fragments (device attributes, kitty flags, cell
325
+ // size, DCS) are swallowed, and color replies are forwarded as one whole
326
+ // sequence instead of leaking into the child as garbage. Null when
327
+ // AGENT_BOARD_REPLY_GUARD=0 — the pre-#170 passthrough is the escape hatch.
328
+ private readonly replyGuard: ReturnType<typeof createReplyGuard> | null;
329
+ private replyGuardTimer: ReturnType<typeof setTimeout> | null = null;
330
+ private readonly connectStartedAt = Date.now();
331
+ // Set once the first socket connect succeeds; drives the reconnect give-up
332
+ // policy (short window after a live session dies, long window during cold
333
+ // host start — see pty-attach-reconnect.mjs).
334
+ private everConnected = false;
335
+ // Timestamp of the first disconnect that started the current reconnect
336
+ // cycle; reset on every successful (re)connect.
337
+ private disconnectedAt: number | null = null;
338
+ // Lines scrolled per mouse-wheel notch on the legacy (regular-mode) raw-SGR path,
339
+ // configurable via $AGENT_BOARD_WHEEL_LINES. The fullscreen handleMouse path uses
340
+ // the TUI's wheelDelta instead, which is already accelerated and line-quantified.
341
+ private readonly mouseWheelLines = resolveWheelLines();
342
+ /** Current local parser. Replaced atomically at an accepted snapshotReady. */
343
+ private term: XtermLike;
344
+ /** Monotonic identity axes: one per accepted socket / authoritative baseline / parser. */
345
+ private connectionToken = 0;
346
+ private baselineToken = 0;
347
+ private parserEpoch = 1;
348
+ private parserObservers: Array<{ dispose(): void }> = [];
349
+ private parserObservationSupported = true;
350
+ private replacementFailureCount = 0;
351
+ private parserSyncOpen = false;
352
+ private parserObserverEvents = 0;
353
+ private collectingConnectionToken = 0;
354
+ private collectingBaselineToken = 0;
355
+ private collectingParserEpoch = 0;
356
+ /** Distinct facts; none of these is presentation truth in this stage. */
357
+ private protocolBaselineState: "unverified" | "valid-empty" | "valid-content" = "unverified";
358
+ private localHydrationState: "idle" | "hydrating" | "complete" | "failed" = "idle";
359
+ private publishabilityState: "wait" | "unknown" | "ready" = "wait";
360
+ private firstAttachSideEffectsApplied = false;
361
+ private latestViewProof: unknown = null;
362
+ private viewProofRecords: unknown[] = [];
363
+ private viewProofRecordCount = 0;
364
+ /** Identity captured when the current proof/coverage was received. */
365
+ private viewProofIdentity: ReturnType<PtyAttachComponent["currentViewIdentity"]> | null = null;
366
+ /** Local geometry revision: incremented once per real differing-size transition. */
367
+ private geometryRevision = 0;
368
+ private resizeIntentRequired = false;
369
+ private resizeIntentSatisfied = false;
370
+ private resizeIntentId = 0;
371
+ // What the runner calls this resize. Its count spans every attach to the session and ours starts
372
+ // again at each attach, so a frame's proof can only be matched against the runner's own numbers.
373
+ private geometryNudges = 0;
374
+ private proofGeometryRevision = 0;
375
+ private lastProofMetadata: { geometryRevision?: unknown; intentId?: unknown } | undefined;
376
+ private proofIntentId = 0;
377
+ private pendingEarlySyncTarget: string | null = null;
378
+ /** Protocol transition facts; timers may mark expiry but never publish. */
379
+ private deadlineState: "pending" | "expired" = "pending";
380
+ private notReadyReason: string | null = null;
381
+ private parserWritesInFlight = 0;
382
+ private readonly attachStartedAt = Date.now();
383
+ private readonly viewProofTrailing = createSafeTrailingClassifier();
384
+ private recentProtocolOutput: Array<{ seq: number; data: string }> = [];
385
+ private proofResyncRequested = false;
386
+ /** Runner-reported reporter capability. Without it the attach keeps the
387
+ * accepted legacy deadline path (old/no-reporter hosts are not stranded). */
388
+ private frameWitnessAvailable = false;
389
+ /** Last complete modern projection installed at a real ?2026l boundary. */
390
+ private committedView: CommittedView | null = null;
391
+ /** Display-only same-generation/geometry recovery view; never readiness. */
392
+ private recoveryView: CommittedView | null = null;
393
+ private recoveryGeneration: string | null = null;
394
+ private currentWireGeneration: string | null = null;
395
+ private emptyResetPending = false;
396
+ private attachWaitToken = 0;
397
+ private cols = 120;
398
+ private rows = 24;
399
+ // Absolute buffer line shown at the top of the viewport. null means follow bottom.
400
+ private viewportTop: number | null = null;
401
+ // Issue #205: buffer line below which every row is pseudo scrollback that
402
+ // hydrating a snapshot frame into this (smaller) terminal manufactured. Only
403
+ // rows above it may answer a local wheel notch; the rest is forwarded.
404
+ private historyFloor = 0;
405
+ // Runtime desync backstop state (issue #11): last socket-output timestamp,
406
+ // last heal timestamp, the probe timer, and an injectable clock for tests.
407
+ private lastOutputAt = 0;
408
+ private lastHealAt = 0;
409
+ private desyncProbeTimer: ReturnType<typeof setInterval> | null = null;
410
+ /** Injectable clock for desync gating (tests override this). */
411
+ private nowFn: () => number = () => Date.now();
412
+ private selection: MouseSelection | null = null;
413
+ /** Issue #172: bumped on every left press so a settle still queued for the previous
414
+ * gesture cannot read or clear a selection the next press has already started. */
415
+ private selectionGestureSerial = 0;
416
+ private selectionDragging = false;
417
+ private selectionAutoScrollTimer: ReturnType<typeof setInterval> | null = null;
418
+ private selectionAutoScrollLines = 0;
419
+ private selectionAutoScrollMouse: { row: number; col: number } | null = null;
420
+ private pendingClickTimer: ReturnType<typeof setTimeout> | null = null;
421
+ private readonly quitPresses = createDoublePress(Date.now, QUIT_PRESS_MS);
422
+ private lastClickPoint: MousePoint | null = null;
423
+ private lastClickAt = 0;
424
+ /** Authoritative editor emptiness pushed by the child Pi extension via the
425
+ * control socket (issue #68). null = unknown — forward conservatively
426
+ * (spec §D1); only true detaches. */
427
+ private editorEmpty: boolean | null = null;
428
+ // Whether any PTY output (live or replayed) has been shown yet. Until then we paint a
429
+ // loading banner instead of an empty buffer so a slow (cold) host start doesn't leave
430
+ // the previous screen visible.
431
+ private receivedOutput = false;
432
+ // Attach transition: hold the loading banner until the screen-log replay and the
433
+ // initial resize-jiggle redraws settle, so attach never visibly scrolls/flashes the
434
+ // buffer. `receivedOutput` tracks buffer content; `attaching` gates whether we paint it.
435
+ private attaching = true;
436
+ private attachSettleTimer: ReturnType<typeof setTimeout> | null = null;
437
+ private attachHardTimeout: ReturnType<typeof setTimeout> | null = null;
438
+ // Last scheme delivered to the child at settle (issue #161). Starts null:
439
+ // null never matches the duplicate check, so a timeout-then-late sequence
440
+ // still delivers. Single-shot per attach — settle runs at most once.
441
+ private sentScheme: TerminalColorScheme | null = null;
442
+ // Pi enables bracketed paste AFTER entering raw mode and disables it at
443
+ // shutdown. Only current runner output/snapshots may establish readiness;
444
+ // the screen.log warm-start can belong to a previous child incarnation.
445
+ private childInputReady = false;
446
+ private childInputModeCarry = "";
447
+ private pendingSchemeReport: { scheme: TerminalColorScheme; api?: "colors" | "background" | "none"; late?: boolean } | null = null;
448
+ // Issue #128 D2: unsubscriber for the color-scheme bridge registered at
449
+ // attach settle; nulled on every terminal path so the TUI never keeps a
450
+ // listener pointing at a dead attach surface.
451
+ private colorSchemeBridgeUnsubscribe: (() => void) | null = null;
452
+ private gracefulSocketCloseTimer: ReturnType<typeof setTimeout> | null = null;
453
+ // Force a single full-clear on the first paint so the prior session/dashboard can't
454
+ // ghost behind this overlay; every later paint uses the TUI's coalesced, throttled,
455
+ // differential renderer so wheel/output bursts don't each trigger a full repaint.
456
+ private firstPaint = true;
457
+ // Coalesce live PTY output repaints to ~25fps. node-pty splits one child-TUI update
458
+ // into many small chunks; painting after each chunk would drive the outer TUI to its
459
+ // frame cap and expose intermediate frames (visible as flicker on a busy session).
460
+ private readonly outputRenderScheduler = createAttachOutputRenderScheduler(() => this.scheduleRender());
461
+ // Fold pi-tui's post-frame cursor-park writes back into the frame's sync block so the
462
+ // terminal reports one stable IME cursor rect per frame instead of two (issue #28).
463
+ // Never active when AGENT_BOARD_IME_FIX=0; no-op passthrough if pi-tui changes shape.
464
+ private readonly imeCoalesceUninstall: (() => void) | null;
465
+ private readonly releaseInput: () => void;
466
+ private lastUserInputAt = Number.NEGATIVE_INFINITY;
467
+
468
+ constructor(
469
+ private readonly tui: TUI,
470
+ private readonly theme: ThemeLike,
471
+ private readonly keybindings: KeybindingsManager,
472
+ private readonly done: (result: PtyAttachResult) => void,
473
+ private readonly opts: PtyAttachOptions,
474
+ ) {
475
+ const size = this.currentSize();
476
+ this.cols = size.cols;
477
+ this.rows = size.rows;
478
+ // Assigned in the body (not as a field initializer) so it runs after the `tui`
479
+ // parameter property is set regardless of the TS loader's field-init semantics.
480
+ this.imeCoalesceUninstall = installImeCursorCoalesce(this.tui);
481
+ this.releaseInput = holdInputListeners(this.tui);
482
+ this.term = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
483
+ this.parserObservers = this.installParserObservers(this.term, this.parserEpoch);
484
+ this.parserObservationSupported = this.parserObservers.length > 0;
485
+ if (!this.parserObservationSupported) this.attachClient.forceLegacy("parser_observation_unavailable");
486
+ // Keep mouse reporting enabled by default so wheel scrolling and local drag-to-copy
487
+ // selection can coexist inside the attach surface. Set AGENT_BOARD_ATTACH_MOUSE=0
488
+ // to fall back to terminal-native selection only.
489
+ this.disableMouseScroll();
490
+ this.enableMouseScroll();
491
+ this.refreshMouseScrollMode();
492
+ this.assertFullscreenMouseMode();
493
+ this.replayScreenLog();
494
+ this.replyGuard = isReplyGuardEnabled(process.env.AGENT_BOARD_REPLY_GUARD) ? createReplyGuard() : null;
495
+ this.connect();
496
+ this.startLoadingTicker();
497
+ // Paint immediately (forced once) so the loading banner replaces the previous
498
+ // surface the instant we attach, rather than after the first reconnect tick.
499
+ this.scheduleRender();
500
+ }
501
+
502
+ handleInput(data: string): void {
503
+ // Issue #189: dispatch per segment instead of all-or-nothing. A read that mixes mouse
504
+ // reports with other bytes used to fall through to the keyboard branch as a whole: the
505
+ // gesture was dropped, an in-progress selection cleared, the viewport reset, and the raw
506
+ // mouse bytes forwarded into the hosted session as typed input.
507
+ //
508
+ // Issue #170/#194 composition: the inbound reply guard runs per non-mouse segment, so a
509
+ // recognized report segment can never absorb keys that arrived in the same read — the
510
+ // coalescing case #189 exists for (a still-fragmented head keeps the bounded #194
511
+ // absorption: 64 bytes / 250ms, kill-switchable). The guard's decision wins for complete
512
+ // protocol replies (dropped) and colour replies (forwarded as one whole sequence);
513
+ // whatever it passes falls through to the kind-based dispatch below.
514
+ for (const segment of splitAttachInput(data)) {
515
+ if (segment.kind === "mouse") {
516
+ // Issue #147: a mouse gesture on either channel cancels the armed
517
+ // delete confirm — it is real user intent, unlike a terminal report.
518
+ this.clearConfirmArmed();
519
+ this.handleMouseInputChunk(segment.events);
520
+ } else if (!this.consumeByReplyGuard(segment.data)) {
521
+ if (segment.kind === "report") this.send({ type: "input", data: segment.data });
522
+ else this.handleKeyInput(segment.data);
523
+ }
524
+ // A detach/close inside a segment must stop the rest of this read.
525
+ if (this.closed) return;
526
+ }
527
+ }
528
+
529
+ /** Issue #170/#194: feed one non-mouse segment through the inbound reply guard. Returns true
530
+ * when the guard took the segment — a complete protocol reply is dropped, a colour reply is
531
+ * forwarded as one whole sequence — and false when the caller should handle it. */
532
+ private consumeByReplyGuard(data: string): boolean {
533
+ const guard = this.replyGuard;
534
+ if (!guard) return false;
535
+ const decision = guard.feed(data, this.nowFn());
536
+ if (guard.pendingSince() !== null) this.armReplyGuardTimer();
537
+ if (decision.action === "forward") {
538
+ // Color replies only work for the child's parsers as one sequence.
539
+ this.send({ type: "input", data: decision.data });
540
+ return true;
541
+ }
542
+ return decision.action === "swallow";
543
+ }
544
+
545
+ /** Keyboard branch of `handleInput` (issue #189: one segment of the read at a time). Terminal
546
+ * *reports* never reach the key branches: a report the reply guard does not own is forwarded
547
+ * by `handleInput` without touching the selection or the viewport, because it carries no user
548
+ * intent (focus events, query replies). */
549
+ private handleKeyInput(data: string): void {
550
+ this.lastUserInputAt = this.nowFn();
551
+ if (this.keybindings?.matches?.(data, "app.suspend")) {
552
+ suspendToShell(this.tui, {
553
+ // This view's mouse reporting isn't Pi's to turn off, and the shell mustn't get it.
554
+ beforeStop: () => this.disableMouseScroll(),
555
+ afterResume: () => {
556
+ this.enableMouseScroll();
557
+ this.assertFullscreenMouseMode();
558
+ },
559
+ });
560
+ return;
561
+ }
562
+ // Issue #147: in-place delete gesture. The resolver is pure and never
563
+ // reads connection state, so the gesture works even while the host is
564
+ // down. cancel falls through so input is never swallowed.
565
+ const deleteKey = resolveAttachDeleteKey({ confirmArmed: this.confirmArmed }, data);
566
+ this.confirmArmed = deleteKey.confirmArmed;
567
+ if (deleteKey.action === "arm") {
568
+ this.scheduleRender();
569
+ return;
570
+ }
571
+ if (deleteKey.action === "confirm") {
572
+ // Teardown first — same shape as detach() minus the control frame:
573
+ // close(true) restores a held PTY (G3, issue #42) and gracefully
574
+ // ends the socket; the consumer archives and the host is terminated,
575
+ // so no detach packet is sent. done() before archive so the host's
576
+ // death is never rendered here.
577
+ this.close(true);
578
+ this.done({ action: "delete" });
579
+ return;
580
+ }
581
+ if (deleteKey.action === "cancel-swallow") {
582
+ this.scheduleRender();
583
+ return;
584
+ }
585
+ if (deleteKey.action === "cancel") this.scheduleRender();
586
+ if (matchesKey(data, Key.pageUp)) {
587
+ this.clearPendingClick();
588
+ this.clearSelection();
589
+ if (this.tryScrollBy(this.pageSize())) return;
590
+ this.send({ type: "input", data });
591
+ return;
592
+ }
593
+ if (matchesKey(data, Key.pageDown)) {
594
+ this.clearPendingClick();
595
+ this.clearSelection();
596
+ if (this.tryScrollBy(-this.pageSize())) return;
597
+ this.send({ type: "input", data });
598
+ return;
599
+ }
600
+ if (matchesKey(data, Key.home)) {
601
+ this.clearPendingClick();
602
+ this.clearSelection();
603
+ if (this.tryScrollToTop()) return;
604
+ this.send({ type: "input", data });
605
+ return;
606
+ }
607
+ if (matchesKey(data, Key.end)) {
608
+ this.clearPendingClick();
609
+ this.clearSelection();
610
+ if (this.tryScrollToBottom()) return;
611
+ this.send({ type: "input", data });
612
+ return;
613
+ }
614
+ // The first goes to the session as usual. The second never does, so the session lives on.
615
+ if (!matchesKey(data, Key.ctrl("c"))) this.quitPresses.reset();
616
+ else if (this.quitPresses.press()) return this.detach({ action: "quit" });
617
+ if (matchesKey(data, "ctrl+\\")) {
618
+ // Unconditional, terminal-independent escape (issue #126): 0x1c is a
619
+ // single raw ASCII byte every terminal forwards unchanged in raw
620
+ // mode — no modifier-encoding dependency like Ctrl+Left (macOS
621
+ // Terminal.app never emits it, WezTerm consumes it for tab
622
+ // switching). Only caveat (documented in README): layouts where
623
+ // `\` sits on AltGr may never produce this byte; the escape ladder
624
+ // still covers those. matchesKey also covers the kitty CSI-u and
625
+ // modifyOtherKeys encodings of the same chord.
626
+ this.detach();
627
+ return;
628
+ }
629
+ if (matchesKey(data, Key.ctrl("left"))) {
630
+ // Explicit detach chord (issue #89): single ← is gated on editor state
631
+ // (it doubles as cursor-left inside a non-empty draft), so a user with
632
+ // a draft had no way out short of Ctrl+C/D, which kills the child Pi.
633
+ // Ctrl+← is unambiguous intent — detach unconditionally, regardless of
634
+ // editor state or socket liveness (same always-exitable guarantee as
635
+ // the disconnected-← escape, issue #48).
636
+ this.detach();
637
+ return;
638
+ }
639
+ if (matchesKey(data, Key.left)) {
640
+ // While the socket is down the key can never reach the child, so
641
+ // escape unconditionally — the view must always be exitable, even
642
+ // after the host crashes mid-output (issue #48). Note: ctrl+] is NOT
643
+ // a detach key — it passes through to Pi (tui.editor.jumpForward).
644
+ // Issue #91 Phase 6 (spec §D1): the gate reads ONLY the pushed
645
+ // editorEmpty side channel — true detaches; false and null (child
646
+ // extension missing, hello not yet arrived, old runner) forward via
647
+ // the explicit conservative policy. The old terminal-buffer
648
+ // heuristics are deleted for good: rendered bytes carry no
649
+ // input-buffer semantics, so that chain guessed at a fact it
650
+ // could not know (issues #42/#66/#69/#103). Escape without a pushed
651
+ // state is Ctrl+← (issue #89), never a buffer guess.
652
+ if (shouldEscapeAttach(this.connected, this.editorEmpty === true)) {
653
+ this.detach();
654
+ return;
655
+ }
656
+ this.clearPendingClick();
657
+ this.clearSelection();
658
+ this.scrollToBottom();
659
+ this.send({ type: "input", data });
660
+ return;
661
+ }
662
+ this.clearPendingClick();
663
+ this.clearSelection();
664
+ this.scrollToBottom();
665
+ this.send({ type: "input", data });
666
+ }
667
+
668
+ /** Issue #167: fullscreen TUI (pi ≥1.0) consumes raw SGR mouse sequences in
669
+ * TuiAltScreen and delivers normalized events to overlays here instead — the
670
+ * legacy handleInput mouse path never runs in that mode. This handler owns the
671
+ * wheel and the middle-click paste; every other event returns undefined so pi's
672
+ * own fullscreen selection keeps working. */
673
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
674
+ // Issue #147: clear the armed delete confirm on the fullscreen mouse
675
+ // channel BEFORE any routing — wheel consumed locally, wheel forwarded to
676
+ // the child (#185), selection mirror, and paste all count as "any other
677
+ // key cancels", whether or not the event ends up consumed.
678
+ this.clearConfirmArmed();
679
+ // Issue #173 follow-up: the renderer's routeWheel() fallback only scrolls real
680
+ // ScrollView components, while the attach viewport paints its own xterm
681
+ // scrollback — so a wheel event returned unconsumed scrolls nothing and the
682
+ // fullscreen wheel goes dead (regular mode is unaffected: it arrives as a raw
683
+ // SGR sequence through handleInput instead). Consume it locally, honoring the
684
+ // same kill switches as the legacy path, and fall through when scrolling is
685
+ // disabled or the viewport cannot actually move.
686
+ //
687
+ // Issue #185: an alternate-screen child leaves that scrollback inert (an
688
+ // alternate buffer keeps no history), so "local" routing can never move
689
+ // there. Forward one synthesized SGR wheel report instead and let the child
690
+ // scroll its own viewport — the same fallback PageUp/PageDown already use
691
+ // when the local viewport cannot move.
692
+ //
693
+ // Issue #198: the buffer type alone cannot decide this. A real fullscreen
694
+ // child sends `\x1b[?1049h` once at startup, so for any session older than
695
+ // the 100 KB screen-log replay window (and with no alternate-screen state
696
+ // in the snapshot DTO) this buffer reads `normal` while holding no
697
+ // scrollback at all. The wheel must therefore forward whenever the local
698
+ // viewport cannot actually move, not only when the type is known.
699
+ const wheelLinesUp = wheelScrollLinesUp(event);
700
+ if (wheelLinesUp !== 0) {
701
+ const route = resolveAttachWheelRoute({
702
+ bufferType: this.term.buffer.active.type,
703
+ wheelLinesUp,
704
+ mouseEnabled: this.mouseScrollEnabled(),
705
+ });
706
+ if (route === "none") return undefined;
707
+ if (route === "local" && this.tryScrollBy(wheelLinesUp)) return { handled: true, render: false };
708
+ // "child", or "local" with a viewport that cannot move. Consume only on
709
+ // effect: when the runner socket is down the forward would be a silent
710
+ // no-op (send() drops it), so hand the event back to pi instead of
711
+ // swallowing it pointlessly.
712
+ if (!this.socket || !this.connected) return undefined;
713
+ this.clearPendingClick();
714
+ this.clearSelection();
715
+ this.forwardWheelToChild(wheelLinesUp, event);
716
+ return { handled: true, render: false };
717
+ }
718
+ const nativePasteEnabled = process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE !== "0";
719
+ // Issue #172: a press starts a new gesture. Two reports can share one stdin chunk
720
+ // (pi-tui emits them synchronously in a loop), so the settle queued by the previous
721
+ // release must not consume the selection this press just started.
722
+ if (event.type === "press" && event.button === "left") this.selectionGestureSerial += 1;
723
+ if (isFullscreenSelectionEnd(event)) {
724
+ // Observe only — pi's fullscreen selection keeps the event. Overlays are
725
+ // dispatched before TuiAltScreen finalizes (and copies) the selection for this
726
+ // event, so settling must run deferred (issue #174).
727
+ const serial = this.selectionGestureSerial;
728
+ process.nextTick(() => {
729
+ if (serial !== this.selectionGestureSerial) return;
730
+ this.settleFullscreenSelection(nativePasteEnabled);
731
+ });
732
+ return undefined;
733
+ }
734
+ const action = resolveAttachMouseAction(event, { nativePasteEnabled });
735
+ if (action !== "paste-primary") return undefined;
736
+ this.clearPendingClick();
737
+ this.clearSelection();
738
+ this.pastePrimarySelection();
739
+ return { handled: true, render: false };
740
+ }
741
+
742
+ render(width: number): string[] {
743
+ if (this.closed) return [];
744
+ this.resizeIfNeeded(width);
745
+ const height = this.tui.terminal?.rows ?? 24;
746
+ const bodyHeight = Math.max(1, height - 2);
747
+ let body: string[];
748
+ if (this.modernCommittedMode() && this.emptyResetPending) {
749
+ body = Array.from({ length: bodyHeight }, () => "");
750
+ } else if (!this.attaching && this.receivedOutput) {
751
+ const projected = this.project(bodyHeight);
752
+ body = projected.lines;
753
+ while (body.length < bodyHeight) body.unshift("");
754
+ } else {
755
+ body = this.renderLoading(bodyHeight, width);
756
+ }
757
+ const header = this.confirmArmed
758
+ ? this.theme.fg("accent", this.theme.bold(` Delete "${truncateToWidth(this.opts.title, 24, "")}"? `)) +
759
+ this.theme.fg("muted", "y confirms · any other key cancels")
760
+ : this.theme.fg("accent", this.theme.bold(` ${this.opts.title} `)) +
761
+ this.theme.fg("muted", `${this.status} · opens links · drag selects+copies · Ctrl+←/Ctrl+\\ detach`);
762
+ return [clip(header, width), ...body.map((l) => clipTerminalLine(l, width)), this.theme.fg("dim", "─".repeat(width))];
763
+ }
764
+
765
+ /** Centered "loading" surface shown until the first PTY output paints the session. */
766
+ private renderLoading(height: number, width: number): string[] {
767
+ const elapsedMs = Date.now() - this.connectStartedAt;
768
+ const spinner = this.closed ? "·" : SPINNER[Math.floor(elapsedMs / LOADING_TICK_MS) % SPINNER.length];
769
+ const title = `${spinner} Loading "${this.opts.title}"…`;
770
+ const detail = this.loadingDetail(Math.max(0, Math.round(elapsedMs / 1000)));
771
+ const out: string[] = [];
772
+ const top = Math.max(0, Math.floor((height - 3) / 2));
773
+ for (let i = 0; i < top; i++) out.push("");
774
+ out.push(center(this.theme.fg("accent", this.theme.bold(title)), width));
775
+ out.push(center(this.theme.fg("muted", detail), width));
776
+ out.push("");
777
+ out.push(center(this.theme.fg("dim", "Ctrl+←/Ctrl+\\ to detach"), width));
778
+ while (out.length < height) out.push("");
779
+ return out.slice(0, height);
780
+ }
781
+
782
+ private loadingDetail(elapsedSeconds: number): string {
783
+ if (this.status === "attached") return "Attached · waiting for the session to render…";
784
+ if (this.status.startsWith("error") || this.status === "host exited" || this.status === "host not reachable") return this.status;
785
+ if (this.status === "disconnected") return `Reconnecting to the session host… ${elapsedSeconds}s`;
786
+ return `Starting the session host… ${elapsedSeconds}s`;
787
+ }
788
+
789
+ invalidate(): void {}
790
+
791
+ dispose(): void {
792
+ this.close();
793
+ }
794
+
795
+ private clearConfirmArmed(): void {
796
+ if (!this.confirmArmed) return;
797
+ this.confirmArmed = false;
798
+ this.scheduleRender();
799
+ }
800
+
801
+ private detach(result: PtyAttachResult = { action: "detached" }): void {
802
+ // Restore a held PTY before ending the control socket. The runner closes
803
+ // the socket immediately after receiving detach, so sending detach first
804
+ // can drop the G3 restore resize (issue #42).
805
+ this.jiggleRetry.restoreAndStop();
806
+ // Phase 5 (D4): enveloped detach (correlated applied ack) with legacy
807
+ // fallback; both reach the same runner detach handler.
808
+ if (!this.attachClient.sendControl("detach")) {
809
+ this.send({ type: "detach" });
810
+ }
811
+ this.close(true);
812
+ this.done(result);
813
+ }
814
+
815
+ /** Install the component's public ?2026 observers; false return keeps xterm defaults. */
816
+ private installParserObservers(term: XtermLike, epoch: number): Array<{ dispose(): void }> {
817
+ try {
818
+ const parser = term.parser;
819
+ if (!parser || typeof parser.registerCsiHandler !== "function") return [];
820
+ const has2026 = (params: readonly (number | number[])[]) => params.some((p) => p === 2026 || (Array.isArray(p) && p.includes(2026)));
821
+ return [
822
+ parser.registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
823
+ if (this.closed || this.term !== term || this.parserEpoch !== epoch) return false;
824
+ if (has2026(params)) {
825
+ this.parserSyncOpen = true;
826
+ this.parserObserverEvents += 1;
827
+ }
828
+ return false;
829
+ }),
830
+ parser.registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
831
+ if (this.closed || this.term !== term || this.parserEpoch !== epoch) return false;
832
+ if (has2026(params)) {
833
+ this.parserSyncOpen = false;
834
+ this.parserObserverEvents += 1;
835
+ this.commitCommittedView(term, epoch);
836
+ }
837
+ return false;
838
+ }),
839
+ ];
840
+ } catch {
841
+ return [];
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Replace the local parser exactly at an accepted authoritative snapshotReady:
847
+ * same constructor options, observers installed before the first frame write.
848
+ * Creation/observer failure is fail-closed (old parser retained, no write).
849
+ */
850
+ private replaceParser(): boolean {
851
+ // Preserve the last complete same-generation view before a nonempty
852
+ // resnapshot replaces the parser. An empty baseline clears it later in
853
+ // the same snapshotReady event.
854
+ this.captureRecoveryView();
855
+ let next: XtermLike;
856
+ try {
857
+ next = new Terminal({ cols: this.cols, rows: this.rows, scrollback: 2000, allowProposedApi: true });
858
+ } catch {
859
+ return false;
860
+ }
861
+ const nextEpoch = this.parserEpoch + 1;
862
+ const observers = this.installParserObservers(next, nextEpoch);
863
+ if (observers.length === 0) {
864
+ try { next.dispose?.(); } catch { /* best effort */ }
865
+ return false;
866
+ }
867
+ const previous = this.term;
868
+ const previousObservers = this.parserObservers;
869
+ this.term = next;
870
+ this.parserObservers = observers;
871
+ this.parserEpoch = nextEpoch;
872
+ this.baselineToken += 1;
873
+ this.replacementFailureCount = 0;
874
+ this.parserObservationSupported = true;
875
+ this.parserSyncOpen = false;
876
+ this.parserWritesInFlight = 0;
877
+ this.committedView = null;
878
+ this.historyFloor = 0;
879
+ this.viewportTop = null;
880
+ this.selection = null;
881
+ this.selectionDragging = false;
882
+ this.localHydrationState = "idle";
883
+ for (const observer of previousObservers) {
884
+ try { observer.dispose(); } catch { /* best effort */ }
885
+ }
886
+ try { previous.dispose?.(); } catch { /* best effort */ }
887
+ return true;
888
+ }
889
+
890
+ private disposeCurrentParser(): void {
891
+ for (const observer of this.parserObservers) {
892
+ try { observer.dispose(); } catch { /* best effort */ }
893
+ }
894
+ this.parserObservers = [];
895
+ try { this.term.dispose?.(); } catch { /* best effort */ }
896
+ }
897
+
898
+ /** Bounded fail-closed policy for a failed authoritative parser replacement. */
899
+ private handleParserReplacementFailure(): void {
900
+ this.protocolBaselineState = "unverified";
901
+ this.localHydrationState = "failed";
902
+ this.publishabilityState = "wait";
903
+ this.latestViewProof = null;
904
+ this.viewProofRecords = [];
905
+ this.viewProofRecordCount = 0;
906
+ this.replacementFailureCount += 1;
907
+ if (this.replacementFailureCount <= 3) this.attachClient.requestResync("parser_replacement_failed");
908
+ else this.attachClient.forceLegacy("parser_replacement_failed");
909
+ }
910
+
911
+ private connect(): void {
912
+ if (this.closed || this.connected || this.socket) return;
913
+ // On Windows the control socket is a named pipe — it never exists as a
914
+ // filesystem entry, so existsSync can't gate the connect; a missing pipe
915
+ // surfaces as an error event on the socket and retries via scheduleReconnect.
916
+ if (process.platform !== "win32" && !existsSync(this.opts.socketPath)) {
917
+ this.status = `starting host… ${Math.ceil((Date.now() - this.connectStartedAt) / 1000)}s`;
918
+ this.scheduleReconnect();
919
+ return;
920
+ }
921
+ const socket = createConnection(this.opts.socketPath);
922
+ this.socket = socket;
923
+ // A partial protocol line left over from a failed socket would prefix the
924
+ // replacement connection's first line; start each socket with a clean buffer.
925
+ this.parserBuffer = "";
926
+ socket.on("connect", () => {
927
+ if (this.socket !== socket) {
928
+ try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
929
+ return;
930
+ }
931
+ this.clearRetry();
932
+ this.outputRenderScheduler.cancel();
933
+ this.captureRecoveryView();
934
+ this.connected = true;
935
+ this.everConnected = true;
936
+ this.connectionToken += 1;
937
+ this.committedView = null;
938
+ this.currentWireGeneration = null;
939
+ // Do not paint the previous generation between reconnect and its
940
+ // confirming hello/status; same-generation status clears this gate.
941
+ this.emptyResetPending = true;
942
+ this.disconnectedAt = null;
943
+ this.status = this.recoveryView !== null ? "reconnecting…" : "attached";
944
+ this.send({ type: "hello", clientId: `ui-${Date.now()}`, wantOutput: true });
945
+ // Snapshot+subscribe attach (issue #91 phase 4): the first connect
946
+ // probes with subscribe_terminal (old runners stay silent → probe
947
+ // timeout → legacy); protocol reconnects resume from the applied
948
+ // cursor over the SAME client instance (ring replay is seamless;
949
+ // eviction/restart answers a fresh snapshot). hello stays UI-owned
950
+ // and precedes the client's subscribe (established ordering).
951
+ if (this.attachMode === "undecided") this.attachClient.start();
952
+ else if (this.attachMode === "protocol") this.attachClient.reconnect(this.attachClient.getLastSeq());
953
+ // The shrink-and-hold redraw protocol is a LEGACY-path correctness
954
+ // tool: snapshot mode owns screen correctness via canonical frames,
955
+ // so the jiggle never runs there (armLegacyJiggle guards the race).
956
+ this.armLegacyJiggle();
957
+ this.enableMouseScroll();
958
+ if (!this.emptyResetPending) this.scheduleRender();
959
+ this.startAttachSettle();
960
+ });
961
+ // One read can end halfway through a character, and big frames always cross reads.
962
+ const decoder = new StringDecoder("utf8");
963
+ socket.on("data", (chunk) => {
964
+ if (this.socket !== socket) return;
965
+ this.onSocketData(decoder.write(chunk));
966
+ });
967
+ socket.on("close", () => {
968
+ // A failed socket can close after a replacement connection has already
969
+ // succeeded. Never let that stale event clear the replacement state.
970
+ if (this.socket !== socket) return;
971
+ this.captureRecoveryView();
972
+ this.socket = null;
973
+ this.connected = false;
974
+ this.disconnectedAt ??= Date.now();
975
+ // A drop inside the probe window must not leave the absolute 1500ms
976
+ // deadline armed: firing during the dead window would permanently
977
+ // downgrade a protocol-capable runner to legacy (CR R1 advisory). The
978
+ // reconnect's start()/reconnect() re-arms on the new socket.
979
+ this.attachClient.onDisconnect();
980
+ if (!this.closed && this.status !== "host exited") {
981
+ this.status = "disconnected";
982
+ this.scheduleReconnect();
983
+ }
984
+ if (!this.closed) this.scheduleRender();
985
+ });
986
+ socket.on("error", (err) => {
987
+ // The error may belong to an old socket after reconnect. Dispose that
988
+ // socket, but do not alter the state of the current connection.
989
+ if (this.socket !== socket) {
990
+ try { socket.destroy(); } catch { /* best-effort stale-socket cleanup */ }
991
+ return;
992
+ }
993
+ this.captureRecoveryView();
994
+ this.socket = null;
995
+ this.connected = false;
996
+ // Notify before nulling: the close event that follows will hit the
997
+ // stale-socket guard (this.socket no longer matches) and must not be
998
+ // the only path that cancels the probe deadline (CR R1 advisory).
999
+ this.attachClient.onDisconnect();
1000
+ try { socket.destroy(); } catch { /* already handling socket failure */ }
1001
+ this.disconnectedAt ??= Date.now();
1002
+ if (this.closed) return;
1003
+ this.status = `waiting for host… ${err.message}`;
1004
+ this.scheduleReconnect();
1005
+ this.scheduleRender();
1006
+ });
1007
+ }
1008
+
1009
+ /**
1010
+ * Request a repaint. The first paint is forced (clears any prior screen so the previous
1011
+ * session can't ghost behind us); all later paints are coalesced + throttled + differential
1012
+ * by the TUI, so bursts of wheel/output events don't each clear-and-repaint the whole screen.
1013
+ */
1014
+ private scheduleRender(force = false): void {
1015
+ const next = nextAttachRender(this.firstPaint, force);
1016
+ this.firstPaint = next.firstPaint;
1017
+ this.tui.requestRender(next.force);
1018
+ }
1019
+
1020
+ private scheduleReconnect(): void {
1021
+ if (this.closed || this.retryTimer) return;
1022
+ const verdict = evaluateAttachReconnect({
1023
+ everConnected: this.everConnected,
1024
+ disconnectedAt: this.disconnectedAt,
1025
+ connectStartedAt: this.connectStartedAt,
1026
+ now: Date.now(),
1027
+ });
1028
+ if (verdict.giveUp) {
1029
+ this.status = verdict.status ?? "host exited";
1030
+ this.scheduleRender();
1031
+ return;
1032
+ }
1033
+ this.retryTimer = setTimeout(() => {
1034
+ this.retryTimer = null;
1035
+ this.connect();
1036
+ this.scheduleRender();
1037
+ }, 150);
1038
+ }
1039
+
1040
+ /** Animate the loading banner until the first PTY output arrives (or we close). */
1041
+ private startLoadingTicker(): void {
1042
+ if (this.loadingTimer || !this.attaching || this.closed) return;
1043
+ this.loadingTimer = setInterval(() => {
1044
+ if (this.closed || !this.attaching) return this.stopLoadingTicker();
1045
+ this.tui.requestRender();
1046
+ }, LOADING_TICK_MS);
1047
+ this.loadingTimer.unref?.();
1048
+ }
1049
+
1050
+ private stopLoadingTicker(): void {
1051
+ if (!this.loadingTimer) return;
1052
+ clearInterval(this.loadingTimer);
1053
+ this.loadingTimer = null;
1054
+ }
1055
+
1056
+ /**
1057
+ * Attach transition lifecycle. Keep the loading banner up while the screen-log replay
1058
+ * and the initial resize-jiggle redraws settle, so the buffer doesn't visibly scroll or
1059
+ * flash through the viewport on attach. Each output chunk defers the settle
1060
+ * window; a hard timeout guards against a session that never produces output.
1061
+ */
1062
+ private startAttachSettle(): void {
1063
+ if (!this.attaching) return;
1064
+ this.attachWaitToken += 1;
1065
+ const waitToken = this.attachWaitToken;
1066
+ this.deferAttachSettle();
1067
+ if (this.attachHardTimeout) clearTimeout(this.attachHardTimeout);
1068
+ const remainingHard = Math.max(0, ATTACH_HARD_TIMEOUT_MS - (Date.now() - this.attachStartedAt));
1069
+ this.attachHardTimeout = setTimeout(() => this.handleAttachHardTimeout(waitToken), remainingHard);
1070
+ this.attachHardTimeout.unref?.();
1071
+ }
1072
+
1073
+ private deferAttachSettle(): void {
1074
+ if (!this.attaching || (this.attachMode === "protocol" && this.frameWitnessAvailable)) return;
1075
+ if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer);
1076
+ const waitToken = this.attachWaitToken;
1077
+ this.attachSettleTimer = setTimeout(() => this.handleAttachSettleTimeout(waitToken), ATTACH_SETTLE_MS);
1078
+ this.attachSettleTimer.unref?.();
1079
+ }
1080
+
1081
+ /** Legacy settle callback. In protocol mode the timer is cancelled; an
1082
+ * undecided timer may not consume the first transition either. */
1083
+ private handleAttachSettleTimeout(waitToken = this.attachWaitToken): void {
1084
+ if (waitToken !== this.attachWaitToken) return;
1085
+ this.attachSettleTimer = null;
1086
+ if (!this.attaching || this.closed) return;
1087
+ const legacyCompatible = this.attachMode === "legacy" || (this.attachMode === "protocol" && !this.frameWitnessAvailable);
1088
+ if (legacyCompatible) this.finishAttachTransition();
1089
+ }
1090
+
1091
+ /** The 2500 ms cap never forces a modern publish of an UNFINISHED frame. It
1092
+ * records an explicit expired/degraded wait; legacy keeps its old reveal.
1093
+ * Idle-session downgrade (post-#261 regression): when the cap fires with a
1094
+ * COMPLETE committed snapshot already hydrated, no pending geometry intent,
1095
+ * and the proof channel never covered this baseline (e.g. the child emitted
1096
+ * S and went idle, so viewProof stays unavailable/candidate-open forever),
1097
+ * reveal that complete runner-synthesized frame through the standard legacy
1098
+ * transition instead of waiting for a proof that cannot arrive. Empty
1099
+ * baselines and geometry-gated waits keep the original never-publish wait. */
1100
+ private handleAttachHardTimeout(waitToken = this.attachWaitToken): void {
1101
+ if (waitToken !== this.attachWaitToken) return;
1102
+ this.attachHardTimeout = null;
1103
+ if (!this.attaching || this.closed) return;
1104
+ const legacyCompatible = this.attachMode === "legacy" || (this.attachMode === "protocol" && !this.frameWitnessAvailable);
1105
+ if (legacyCompatible) {
1106
+ this.finishAttachTransition();
1107
+ return;
1108
+ }
1109
+ if (this.attachMode === "protocol" && this.resizeIntentRequired && this.connected && this.geometryNudges < GEOMETRY_NUDGE_LIMIT) {
1110
+ this.nudgeGeometry(waitToken);
1111
+ return;
1112
+ }
1113
+ this.deadlineState = "expired";
1114
+ this.notReadyReason = this.attachMode === "protocol" ? "deadline-expired:waiting-for-complete-frame" : "deadline-expired:mode-undecided";
1115
+ this.publishabilityState = "unknown";
1116
+ const proof = this.latestViewProof as { status?: string } | null;
1117
+ const nudgedInVain = this.resizeIntentRequired && this.geometryNudges >= GEOMETRY_NUDGE_LIMIT;
1118
+ if (
1119
+ this.committedView !== null &&
1120
+ (!this.resizeIntentRequired || nudgedInVain) &&
1121
+ (proof === null || proof.status !== "covered" || nudgedInVain)
1122
+ ) {
1123
+ this.finishAttachTransition();
1124
+ return;
1125
+ }
1126
+ this.scheduleRender();
1127
+ }
1128
+
1129
+ /** A late legacy decision restores the ORIGINAL deadlines (remaining time),
1130
+ * not a fresh extended window. */
1131
+ private restoreLegacyDeadlines(): void {
1132
+ if (!this.attaching || this.closed) return;
1133
+ const now = Date.now();
1134
+ if (now - this.attachStartedAt >= ATTACH_HARD_TIMEOUT_MS) {
1135
+ this.finishAttachTransition();
1136
+ return;
1137
+ }
1138
+ const settleBase = this.lastOutputAt > 0 ? this.lastOutputAt : this.attachStartedAt;
1139
+ const remaining = Math.max(0, ATTACH_SETTLE_MS - (now - settleBase));
1140
+ if (this.attachSettleTimer) clearTimeout(this.attachSettleTimer);
1141
+ const waitToken = this.attachWaitToken;
1142
+ this.attachSettleTimer = setTimeout(() => this.handleAttachSettleTimeout(waitToken), remaining);
1143
+ this.attachSettleTimer.unref?.();
1144
+ if (!this.attachHardTimeout) {
1145
+ this.attachHardTimeout = setTimeout(() => this.handleAttachHardTimeout(waitToken), Math.max(0, ATTACH_HARD_TIMEOUT_MS - (now - this.attachStartedAt)));
1146
+ this.attachHardTimeout.unref?.();
1147
+ }
1148
+ }
1149
+
1150
+ private currentViewIdentity() {
1151
+ return {
1152
+ connectionToken: this.connectionToken,
1153
+ baselineToken: this.baselineToken,
1154
+ generation: this.attachClient.getIdentity().generation ?? "local",
1155
+ parser: this.term,
1156
+ geometryRevision: this.geometryRevision,
1157
+ targetCols: this.cols,
1158
+ targetRows: this.rows,
1159
+ closed: this.closed,
1160
+ };
1161
+ }
1162
+
1163
+ /** Snapshot the visible complete grid at a real close boundary. Only
1164
+ * primitives are copied; no xterm BufferLine/Cell survives this call. */
1165
+ private commitCommittedView(term: XtermLike, epoch: number): void {
1166
+ if (this.closed || this.term !== term || this.parserEpoch !== epoch) return;
1167
+ if (!this.modernCommittedMode() || this.localHydrationState === "hydrating") return;
1168
+ const height = this.bodyHeight();
1169
+ const buf = term.buffer.active;
1170
+ const start = Math.max(0, Math.min(this.viewportTop ?? this.liveBottomViewportTop(height), Math.max(0, buf.length - height)));
1171
+ const end = Math.min(buf.length, start + height);
1172
+ const rows: CommittedRow[] = [];
1173
+ for (let lineIndex = start; lineIndex < end; lineIndex += 1) {
1174
+ const line = buf.getLine(lineIndex);
1175
+ const cells: SnapshotCell[] = [];
1176
+ if (line) {
1177
+ for (let x = 0; x < line.length; x += 1) {
1178
+ const cell = line.getCell(x);
1179
+ if (!cell || cell.getWidth() === 0) {
1180
+ cells.push(SnapshotCell.empty());
1181
+ continue;
1182
+ }
1183
+ cells.push(SnapshotCell.fromBufferCell(cell, osc8UriForCell(term, cell)));
1184
+ }
1185
+ }
1186
+ rows.push({ cells: new SnapshotLine(cells) });
1187
+ }
1188
+ const cursor = projectPtyCursor(buf, start, height);
1189
+ this.committedView = {
1190
+ identity: this.currentViewIdentity(),
1191
+ start,
1192
+ end,
1193
+ rows,
1194
+ cursor,
1195
+ cursorHidden: isPtyCursorHidden(term),
1196
+ viewportTop: this.viewportTop,
1197
+ proofSeq: null,
1198
+ };
1199
+ this.recoveryView = null;
1200
+ this.recoveryGeneration = null;
1201
+ this.emptyResetPending = false;
1202
+ if (this.connected) this.status = "attached";
1203
+ }
1204
+
1205
+ private captureRecoveryView(): void {
1206
+ if (!this.modernCommittedMode() || this.committedView === null || this.closed) return;
1207
+ if (!canPublishAttachView(this.committedView.identity, this.currentViewIdentity())) return;
1208
+ this.recoveryView = this.committedView;
1209
+ this.recoveryGeneration = this.committedView.identity.generation;
1210
+ }
1211
+
1212
+ private invalidateRecoveryView(): void {
1213
+ this.recoveryView = null;
1214
+ this.recoveryGeneration = null;
1215
+ }
1216
+
1217
+ private recoveryViewValid(): boolean {
1218
+ if (!this.modernCommittedMode() || this.recoveryView === null || this.closed) return false;
1219
+ if (this.recoveryView.identity.closed === true) return false;
1220
+ if (this.recoveryView.identity.targetCols !== this.cols || this.recoveryView.identity.targetRows !== this.rows) return false;
1221
+ if (this.recoveryView.identity.geometryRevision !== this.geometryRevision) return false;
1222
+ if (typeof this.currentWireGeneration !== "string" || this.currentWireGeneration.length === 0) return false;
1223
+ if (this.currentWireGeneration !== this.recoveryGeneration) return false;
1224
+ return true;
1225
+ }
1226
+
1227
+ private activeCommittedView(): CommittedView | null {
1228
+ if (this.committedViewValid()) return this.committedView;
1229
+ if (this.recoveryViewValid()) return this.recoveryView;
1230
+ return null;
1231
+ }
1232
+
1233
+ private modernCommittedMode(): boolean {
1234
+ return this.attachMode === "protocol" && this.frameWitnessAvailable;
1235
+ }
1236
+
1237
+ private committedViewValid(): boolean {
1238
+ if (this.committedView === null) return false;
1239
+ return canPublishAttachView(this.committedView.identity, this.currentViewIdentity());
1240
+ }
1241
+
1242
+ private liveBottomViewportTop(height: number): number {
1243
+ const buf = this.term.buffer.active;
1244
+ const bottom = Math.min(buf.length, buf.baseY + this.rows);
1245
+ return Math.max(0, bottom - height);
1246
+ }
1247
+
1248
+ private committedLineAt(lineIndex: number): BufferLineLike | null {
1249
+ if (!this.modernCommittedMode()) return null;
1250
+ const view = this.activeCommittedView();
1251
+ if (view === null || lineIndex < view.start || lineIndex >= view.end) return null;
1252
+ return view.rows[lineIndex - view.start]?.cells ?? null;
1253
+ }
1254
+
1255
+ private captureViewProofIdentity(): void {
1256
+ this.viewProofIdentity = this.currentViewIdentity();
1257
+ }
1258
+
1259
+ private invalidateViewProof(reason: string): void {
1260
+ this.latestViewProof = { status: "unknown", reason, proof: null, throughSeq: null, cursorVisible: null };
1261
+ this.viewProofIdentity = null;
1262
+ this.viewProofTrailing.reset();
1263
+ this.resizeIntentSatisfied = false;
1264
+ }
1265
+
1266
+ /** Feed ordinary output after the last F proof through the safe-trailing
1267
+ * classifier. Only a completely safe window may extend coverage. */
1268
+ private noteViewProofOutput(data: string): void {
1269
+ const coverage = this.latestViewProof as { status?: string; proof?: unknown } | null;
1270
+ if (this.attachMode !== "protocol" || coverage === null || coverage.status !== "covered" || coverage.proof === null) return;
1271
+ const localSeq = this.attachClient.getLastSeq();
1272
+ const state = this.viewProofTrailing.push(data);
1273
+ if (state.status === "safe") {
1274
+ this.latestViewProof = {
1275
+ status: "covered",
1276
+ reason: "safe-trailing",
1277
+ proof: (coverage as { proof: unknown }).proof,
1278
+ throughSeq: localSeq,
1279
+ cursorVisible: state.cursorVisible,
1280
+ };
1281
+ this.captureViewProofIdentity();
1282
+ } else {
1283
+ this.invalidateViewProof(state.status === "pending" ? "view-proof-trailing-pending" : `view-proof-trailing-${state.reason ?? "unsafe"}`);
1284
+ }
1285
+ }
1286
+
1287
+ private noteViewProofRecord(record: Record<string, unknown>): void {
1288
+ if (record.marker === "S") {
1289
+ this.viewProofRecords.push(record);
1290
+ if (this.viewProofRecords.length > MAX_VIEW_PROOF_RECORDS) this.viewProofRecords.shift();
1291
+ this.viewProofRecordCount += 1;
1292
+ this.invalidateViewProof("candidate-open");
1293
+ return;
1294
+ }
1295
+ if (record.marker === "A") {
1296
+ this.viewProofRecords.push(record);
1297
+ if (this.viewProofRecords.length > MAX_VIEW_PROOF_RECORDS) this.viewProofRecords.shift();
1298
+ this.viewProofRecordCount += 1;
1299
+ this.invalidateViewProof("candidate-aborted");
1300
+ return;
1301
+ }
1302
+ if (record.marker !== "F") return;
1303
+ this.viewProofRecords.push(record);
1304
+ if (this.viewProofRecords.length > MAX_VIEW_PROOF_RECORDS) this.viewProofRecords.shift();
1305
+ this.viewProofRecordCount += 1;
1306
+ this.latestViewProof = {
1307
+ status: "covered",
1308
+ reason: "safe-trailing",
1309
+ proof: record,
1310
+ throughSeq: record.seq,
1311
+ cursorVisible: typeof record.cursorVisible === "boolean" ? record.cursorVisible : null,
1312
+ };
1313
+ this.captureViewProofIdentity();
1314
+ this.viewProofTrailing.reset(typeof record.cursorVisible === "boolean" ? { cursorVisible: record.cursorVisible } : {});
1315
+ const proofSeq = Number(record.seq);
1316
+ const following = this.recentProtocolOutput.filter((chunk) => chunk.seq > proofSeq).sort((a, b) => a.seq - b.seq);
1317
+ let expectedSeq = proofSeq + 1;
1318
+ for (const chunk of following) {
1319
+ if (chunk.seq !== expectedSeq) {
1320
+ this.invalidateViewProof("view-proof-replay-gap");
1321
+ return;
1322
+ }
1323
+ expectedSeq = chunk.seq + 1;
1324
+ const state = this.viewProofTrailing.push(chunk.data);
1325
+ if (state.status !== "safe") {
1326
+ this.invalidateViewProof(state.status === "pending" ? "view-proof-trailing-pending" : `view-proof-trailing-${state.reason ?? "unsafe"}`);
1327
+ return;
1328
+ }
1329
+ this.latestViewProof = {
1330
+ status: "covered",
1331
+ reason: "safe-trailing",
1332
+ proof: record,
1333
+ throughSeq: chunk.seq,
1334
+ cursorVisible: state.cursorVisible,
1335
+ };
1336
+ this.captureViewProofIdentity();
1337
+ }
1338
+ if (this.attachClient.getLastSeq() >= expectedSeq) {
1339
+ this.invalidateViewProof("view-proof-replay-gap");
1340
+ }
1341
+ }
1342
+
1343
+ /** Pure fact assembly for the accepted decideAttachPresentation gate. */
1344
+ private evaluateAttachReady(): void {
1345
+ if (!this.attaching || this.closed || this.attachMode !== "protocol" || !this.frameWitnessAvailable) return;
1346
+ if (this.parserWritesInFlight > 0) return;
1347
+ if (this.committedView === null) {
1348
+ this.notReadyReason = "committed-view-missing";
1349
+ return;
1350
+ }
1351
+ const coverage = this.latestViewProof as { status?: string; proof?: { metadata?: unknown } | null } | null;
1352
+ const proof = coverage?.status === "covered" && coverage.proof !== null ? coverage.proof : null;
1353
+ const localSeq = this.attachClient.getLastSeq();
1354
+ const baselineState =
1355
+ this.protocolBaselineState === "valid-empty" || this.protocolBaselineState === "valid-content"
1356
+ ? this.protocolBaselineState
1357
+ : this.localHydrationState === "hydrating"
1358
+ ? "hydrating"
1359
+ : this.protocolBaselineState;
1360
+ const decision = decideAttachPresentation({
1361
+ closed: this.closed,
1362
+ mode: this.attachMode,
1363
+ baselineState,
1364
+ localParseComplete: this.parserWritesInFlight === 0 && this.localHydrationState !== "hydrating",
1365
+ frameState: this.parserSyncOpen ? "open" : this.parserWritesInFlight > 0 ? "unknown" : "complete",
1366
+ contentAvailable: this.receivedOutput === true && proof !== null,
1367
+ contentKind: proof === null ? "unknown" : "complete",
1368
+ localSeq,
1369
+ viewProof: coverage,
1370
+ viewIdentity: this.viewProofIdentity,
1371
+ currentIdentity: this.currentViewIdentity(),
1372
+ targetCols: this.cols,
1373
+ targetRows: this.rows,
1374
+ geometryRevision: this.geometryRevision,
1375
+ requiredGeometryRevision: this.resizeIntentRequired ? this.proofGeometryRevision : undefined,
1376
+ resizeIntentRequired: this.resizeIntentRequired,
1377
+ resizeIntentSatisfied: this.resizeIntentSatisfied,
1378
+ intentId: this.proofIntentId,
1379
+ deadlineState: this.deadlineState,
1380
+ });
1381
+ this.notReadyReason = decision.action === "wait" ? decision.reason : null;
1382
+ if (decision.action === "publish") {
1383
+ this.publishabilityState = "ready";
1384
+ this.finishAttachTransition();
1385
+ } else if (decision.action === "wait") {
1386
+ this.publishabilityState = this.deadlineState === "expired" ? "unknown" : "wait";
1387
+ }
1388
+ }
1389
+
1390
+ private finishAttachTransition(): void {
1391
+ if (!this.attaching || this.closed) return;
1392
+ // Note: jiggle retry chain is NOT cancelled here — it survives the settle
1393
+ // transition because any successful jiggle triggers a fullRender which emits
1394
+ // \x1b[2J, self-cancelling the chain. Post-settle jiggles only occur when the
1395
+ // child consumed none of the earlier ones (the exact failure mode this
1396
+ // feature fixes); in that case the screen is already stale, so the trade-off
1397
+ // of a brief full-render flicker (which also clears any in-progress text
1398
+ // selection) is acceptable in exchange for self-healing. When the child is
1399
+ // healthy, the very first jiggle's fullRender is detected and the chain
1400
+ // stops before settle ends, so no post-settle jiggle fires at all.
1401
+ this.attaching = false;
1402
+ this.publishabilityState = "ready";
1403
+ this.notReadyReason = null;
1404
+ this.firstAttachSideEffectsApplied = true;
1405
+ if (this.attachSettleTimer) {
1406
+ clearTimeout(this.attachSettleTimer);
1407
+ this.attachSettleTimer = null;
1408
+ }
1409
+ if (this.attachHardTimeout) {
1410
+ clearTimeout(this.attachHardTimeout);
1411
+ this.attachHardTimeout = null;
1412
+ }
1413
+ this.stopLoadingTicker();
1414
+ // Force a full clear so the loading banner is replaced atomically by the settled
1415
+ // buffer, instead of diffing banner lines into buffer lines.
1416
+ this.scheduleRender(true);
1417
+ this.startDesyncProbe();
1418
+ // Issue #128: the settle transition is the single point where `attaching`
1419
+ // flips false (never re-armed), so these hooks run at most once per attach.
1420
+ this.attachColorSchemeBridge();
1421
+ // Use only the tracked TUI probe. An extra terminal.write(OSC 11) has
1422
+ // no local query owner, so handleInput's report branch forwards its
1423
+ // reply to the child PTY. Settle can precede the child's raw-mode
1424
+ // startup, making the line discipline echo that reply as visible
1425
+ // ^[]11;rgb:... text.
1426
+ this.reportRealTerminalColorScheme();
1427
+ }
1428
+
1429
+ /** Issue #128 D2: the local pi-tui consumes the child's color-scheme
1430
+ * traffic (996 queries / 2031 notifications forwarded by D1) — reports
1431
+ * never reach the child. onTerminalColorSchemeChange fires from that same
1432
+ * consumption point, so the bridge repackages each event as a 997 report
1433
+ * on the child pty, where the child's pi-tui parser consumes it. Once per
1434
+ * attach (settle is single-shot + explicit guard); kill switch skips it. */
1435
+ private attachColorSchemeBridge(): void {
1436
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") return;
1437
+ if (this.colorSchemeBridgeUnsubscribe) return;
1438
+ try {
1439
+ this.colorSchemeBridgeUnsubscribe = this.tui.onTerminalColorSchemeChange((scheme) => {
1440
+ if (!toColorSchemeReport(scheme)) return;
1441
+ this.pendingSchemeReport = { scheme };
1442
+ this.flushPendingSchemeReport();
1443
+ });
1444
+ } catch {
1445
+ /* best-effort: an unbridgeable TUI keeps pre-#128 behavior */
1446
+ }
1447
+ }
1448
+
1449
+ /** Called on every terminal path (close() covers detach/dispose; the exit
1450
+ * branch calls done() without close(), so it unsubscribes explicitly). */
1451
+ private detachColorSchemeBridge(): void {
1452
+ this.pendingSchemeReport = null;
1453
+ this.childInputReady = false;
1454
+ const unsubscribe = this.colorSchemeBridgeUnsubscribe;
1455
+ this.colorSchemeBridgeUnsubscribe = null;
1456
+ if (!unsubscribe) return;
1457
+ try {
1458
+ unsubscribe();
1459
+ } catch {
1460
+ /* best-effort: a stale listener must not block teardown */
1461
+ }
1462
+ }
1463
+
1464
+ /** Issue #148/#161: at settle, ask the REAL terminal itself (feature-detected
1465
+ * public query API — pi-tui >= 0.99 renamed it to queryTerminalColors) and
1466
+ * hand the child an unconditional 997 color-scheme report. One probe per
1467
+ * attach; the pre-#148 silence on failure is now observable via
1468
+ * onDiagnostic. Same kill switch as #128 D1-D3. */
1469
+ private reportRealTerminalColorScheme(): void {
1470
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES === "0") {
1471
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ outcome: "suppressed" }));
1472
+ return;
1473
+ }
1474
+ void this.probeAndReportRealTerminalColorScheme();
1475
+ }
1476
+
1477
+ private async probeAndReportRealTerminalColorScheme(): Promise<void> {
1478
+ const probe = resolveProbeApi(this.tui as never);
1479
+ if (probe.api === "none") {
1480
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: "none", outcome: "no_probe_api" }));
1481
+ return;
1482
+ }
1483
+ let result: { rgb?: RgbColor; anyColors: boolean };
1484
+ try {
1485
+ result = await probe.invoke(
1486
+ this.tui as never,
1487
+ REAL_TERMINAL_SCHEME_PROBE_TIMEOUT_MS,
1488
+ (colors) => this.handleLateSettleColors(probe.api, colors),
1489
+ );
1490
+ } catch {
1491
+ /* best-effort: a failed probe keeps the pre-#148 behavior, but recorded */
1492
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: probe.api, outcome: "error" }));
1493
+ return;
1494
+ }
1495
+ const rgb = result?.rgb;
1496
+ if (!rgb) {
1497
+ // Outcome rule (spec §2.3): any color answered but no background =>
1498
+ // no_background; nothing answered at all => timeout. The old API only
1499
+ // queries OSC 11, so its undefined is always a timeout.
1500
+ const outcome = result && result.anyColors ? "no_background" : "timeout";
1501
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: probe.api, outcome }));
1502
+ return;
1503
+ }
1504
+ this.deliverSettleScheme(colorSchemeForBackgroundRgb(rgb) ?? undefined, probe.api, false);
1505
+ }
1506
+
1507
+ /** pi-tui >= 0.99 late replies: deliver only when the scheme differs from
1508
+ * what was already sent (sentScheme starts null and never matches, so the
1509
+ * timeout-then-late path delivers). Closed => dropped, but recorded. */
1510
+ private handleLateSettleColors(api: "colors" | "background" | "none", colors: unknown): void {
1511
+ if (this.closed) {
1512
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_closed" }));
1513
+ return;
1514
+ }
1515
+ const rgb = backgroundRgbFromTerminalColors(colors as never);
1516
+ const scheme = rgb ? colorSchemeForBackgroundRgb(rgb) ?? undefined : undefined;
1517
+ if (!scheme) return;
1518
+ if (this.sentScheme === scheme && this.childInputReady) {
1519
+ this.pendingSchemeReport = null;
1520
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "duplicate_skipped" }));
1521
+ return;
1522
+ }
1523
+ this.deliverSettleScheme(scheme, api, true);
1524
+ }
1525
+
1526
+ private deliverSettleScheme(scheme: TerminalColorScheme | undefined, api: "colors" | "background" | "none", late: boolean): void {
1527
+ if (!scheme || this.closed) return;
1528
+ const data = toColorSchemeReport(scheme);
1529
+ if (!data) return;
1530
+ // CR round-1 advisory: send() silently no-ops while the control socket is
1531
+ // disconnected (hard-timeout settle before the host finished connecting).
1532
+ // Claiming "reported" there would be a lie the dedup then acts on, so
1533
+ // record the drop and leave sentScheme unset — a later same-scheme late
1534
+ // reply re-attempts instead of being swallowed as duplicate_skipped.
1535
+ if (!this.connected) {
1536
+ this.opts.onDiagnostic?.(buildSettleSchemePatch({ probeApi: api, outcome: "dropped_disconnected" }));
1537
+ return;
1538
+ }
1539
+ if (!this.childInputReady) {
1540
+ // Settle/probe completion is not a child-readiness handshake. Keep
1541
+ // only the latest scheme, then send when the child enters raw mode.
1542
+ this.pendingSchemeReport = { scheme, api, late };
1543
+ return;
1544
+ }
1545
+ this.sentScheme = scheme;
1546
+ this.send({ type: "input", data });
1547
+ const details: { probeApi: "colors" | "background" | "none"; outcome: "reported"; report: string; late?: boolean } = {
1548
+ probeApi: api,
1549
+ outcome: "reported",
1550
+ report: scheme === "light" ? "997;2" : "997;1",
1551
+ };
1552
+ if (late) details.late = true;
1553
+ this.opts.onDiagnostic?.(buildSettleSchemePatch(details));
1554
+ }
1555
+
1556
+ private updateChildInputReadiness(data: string): void {
1557
+ // Scan only LIVE bytes, not warm-start history. A reused xterm buffer
1558
+ // can still have the old child's paste mode enabled during cold boot.
1559
+ const input = this.childInputModeCarry + data;
1560
+ for (const match of input.matchAll(/\x1b\[\?2004([hl])/g)) {
1561
+ this.childInputReady = match[1] === "h";
1562
+ }
1563
+ // Keep at most an incomplete eight-byte mode sequence across chunks.
1564
+ this.childInputModeCarry = input.slice(-7);
1565
+ }
1566
+
1567
+ private flushPendingSchemeReport(): void {
1568
+ if (this.closed || !this.connected || !this.childInputReady) return;
1569
+ const pending = this.pendingSchemeReport;
1570
+ this.pendingSchemeReport = null;
1571
+ if (!pending) return;
1572
+ if (pending.api) this.deliverSettleScheme(pending.scheme, pending.api, pending.late ?? false);
1573
+ else this.send({ type: "input", data: toColorSchemeReport(pending.scheme) });
1574
+ }
1575
+
1576
+ private startDesyncProbe(): void {
1577
+ this.stopDesyncProbe();
1578
+ // The probe is deliberately NOT hooked into the render path: rendering is
1579
+ // event-driven (socket output / keypress / resize) and stops exactly when
1580
+ // desync strikes (output goes quiet). A self-contained timer is the only
1581
+ // way an idle desynced screen gets its self-heal without user action.
1582
+ this.desyncProbeTimer = setInterval(() => this.checkDesync(), DESYNC_PROBE_INTERVAL_MS);
1583
+ this.desyncProbeTimer.unref?.();
1584
+ }
1585
+
1586
+ private stopDesyncProbe(): void {
1587
+ if (this.desyncProbeTimer) {
1588
+ clearInterval(this.desyncProbeTimer);
1589
+ this.desyncProbeTimer = null;
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * Runtime desync backstop (issue #11). Seven gates, cheapest first:
1595
+ * settled+connected, child is a TUI (frame seen), chain idle, output quiet,
1596
+ * heal rate limit, misaligned cursor. All pass → heal() re-arms
1597
+ * shrink-and-hold; the child's fullRender clear then restores the size
1598
+ * and repaints a consistent screen.
1599
+ */
1600
+ private checkDesync(): void {
1601
+ if (this.closed || this.attaching || !this.connected) return; // gates 1, 7
1602
+ // Protocol mode owns screen correctness via canonical frames (issue #91
1603
+ // phase 4, CR R1 advisory): a "misaligned cursor" there is frame content
1604
+ // or a stale-frame transient that resync heals — a jiggle heal would fire
1605
+ // resize pulses in a mode whose e2e pins zero resizes. The undecided
1606
+ // window keeps legacy semantics on purpose: until the snapshot answer
1607
+ // arrives the jiggle IS armed and the legacy fallthrough owns the screen.
1608
+ if (this.attachMode === "protocol") return;
1609
+ const chain = this.jiggleRetry.getState();
1610
+ if (!chain.tuiFrameSeen) return; // gate 2: shell/vim children never heal
1611
+ if (!chain.stopped || chain.held) return; // gate 5: attach/heal chain active
1612
+ const now = this.nowFn();
1613
+ if (now - this.lastOutputAt <= DESYNC_QUIET_MS) return; // gate 4: streaming
1614
+ if (now - this.lastHealAt <= HEAL_RATELIMIT_MS) return; // gate 6: rate limit
1615
+ const height = this.bodyHeight();
1616
+ this.clampViewportTop(height);
1617
+ const start = this.viewportTop ?? this.bottomViewportTop(height);
1618
+ const buf = this.term.buffer.active;
1619
+ if (detectCursorDesync(buf, projectPtyCursor(buf, start, height)) !== "misaligned") return; // gate 3
1620
+ this.lastHealAt = now;
1621
+ this.jiggleRetry.heal(this.cols, this.rows);
1622
+ }
1623
+
1624
+ private clearMouseRefreshTimers(): void {
1625
+ for (const timer of this.mouseRefreshTimers) clearTimeout(timer);
1626
+ this.mouseRefreshTimers = [];
1627
+ }
1628
+
1629
+ /**
1630
+ * Legacy-path jiggle arming with a protocol-race guard (issue #91 phase 4).
1631
+ * Legacy decided immediately (env-forced) → start now; undecided → start
1632
+ * after the guard delay unless the snapshot answer wins (protocol). The
1633
+ * timer guard accepts ANY non-protocol mode, so a fast in-flight decision
1634
+ * (e.g. frame_version_mismatch landing ~1ms into the probe window) still
1635
+ * arms the chain — an early-decided legacy session must not end up
1636
+ * jiggle-less. start() re-arms idempotently, so a late duplicate is safe.
1637
+ */
1638
+ private armLegacyJiggle(): void {
1639
+ this.clearJiggleStartTimer();
1640
+ if (this.attachMode === "protocol") return;
1641
+ if (this.attachMode === "legacy") {
1642
+ if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
1643
+ return;
1644
+ }
1645
+ this.jiggleStartTimer = setTimeout(() => {
1646
+ this.jiggleStartTimer = null;
1647
+ if (!this.closed && this.connected && this.attachMode !== "protocol") {
1648
+ this.jiggleRetry.start(this.cols, this.rows);
1649
+ }
1650
+ }, LEGACY_JIGGLE_ARM_DELAY_MS);
1651
+ this.jiggleStartTimer.unref?.();
1652
+ }
1653
+
1654
+ private clearJiggleStartTimer(): void {
1655
+ if (!this.jiggleStartTimer) return;
1656
+ clearTimeout(this.jiggleStartTimer);
1657
+ this.jiggleStartTimer = null;
1658
+ }
1659
+
1660
+ private clearRetry(): void {
1661
+ if (!this.retryTimer) return;
1662
+ clearTimeout(this.retryTimer);
1663
+ this.retryTimer = null;
1664
+ }
1665
+
1666
+ private clearGracefulSocketCloseTimer(): void {
1667
+ if (!this.gracefulSocketCloseTimer) return;
1668
+ clearTimeout(this.gracefulSocketCloseTimer);
1669
+ this.gracefulSocketCloseTimer = null;
1670
+ }
1671
+
1672
+ /** Issue #170: an open reply window arms exactly one timer. Fragments do not
1673
+ * re-arm it (the window is anchored at the head); a timer that fires while
1674
+ * the window is still open — a newer head moved `since` forward — re-arms
1675
+ * instead of dropping the still-open sequence. */
1676
+ private armReplyGuardTimer(): void {
1677
+ if (this.replyGuardTimer) return;
1678
+ this.replyGuardTimer = setTimeout(() => {
1679
+ this.replyGuardTimer = null;
1680
+ this.replyGuard?.expire(this.nowFn());
1681
+ if (this.replyGuard?.pendingSince() !== null) this.armReplyGuardTimer();
1682
+ }, REPLY_GUARD_WINDOW_MS);
1683
+ this.replyGuardTimer.unref?.();
1684
+ }
1685
+
1686
+ private clearReplyGuardTimer(): void {
1687
+ if (!this.replyGuardTimer) return;
1688
+ clearTimeout(this.replyGuardTimer);
1689
+ this.replyGuardTimer = null;
1690
+ }
1691
+
1692
+ private enableMouseScroll(): void {
1693
+ if (!this.ownsOuterMouseMode()) return;
1694
+ try {
1695
+ this.tui.terminal.write(XTSHIFTESCAPE_SELECT);
1696
+ this.tui.terminal.write(MOUSE_ENABLE);
1697
+ } catch {
1698
+ /* best-effort: some terminals reject these sequences; mouse reporting is optional */
1699
+ }
1700
+ }
1701
+
1702
+ /** Issue #169: in fullscreen mode TuiAltScreen owns outer-terminal mouse
1703
+ * reporting (asserted once at startup, never re-asserted) — writing any
1704
+ * mouse-mode sequence from here can only downgrade or kill it. tui.mode is
1705
+ * typed non-optional but old pi runtimes lack it; the strict inequality
1706
+ * then keeps today's (regular) behavior. */
1707
+ private ownsOuterMouseMode(): boolean {
1708
+ return shouldOwnOuterMouseMode(this.tui.mode, this.mouseScrollEnabled());
1709
+ }
1710
+
1711
+ private mouseScrollEnabled(): boolean {
1712
+ const mode = (process.env.AGENT_BOARD_ATTACH_MOUSE ?? "").trim().toLowerCase();
1713
+ if (mode === "0" || mode === "off" || mode === "false") return false;
1714
+ if (mode === "1" || mode === "on" || mode === "true" || mode === "classic") return true;
1715
+ if ((process.env.AGENT_BOARD_ENABLE_MOUSE_SCROLL ?? "").trim() === "0") return false;
1716
+ return true;
1717
+ }
1718
+
1719
+ private refreshMouseScrollMode(): void {
1720
+ this.clearMouseRefreshTimers();
1721
+ if (!this.mouseScrollEnabled()) return;
1722
+ for (const delay of [0, 50, 250]) {
1723
+ const timer = setTimeout(() => {
1724
+ if (!this.closed) this.enableMouseScroll();
1725
+ }, delay);
1726
+ timer.unref?.();
1727
+ this.mouseRefreshTimers.push(timer);
1728
+ }
1729
+ }
1730
+
1731
+ private disableMouseScroll(): void {
1732
+ if (!this.ownsOuterMouseMode()) return;
1733
+ try {
1734
+ this.tui.terminal.write(MOUSE_DISABLE);
1735
+ } catch {
1736
+ /* best-effort: terminal may already be gone at teardown */
1737
+ }
1738
+ }
1739
+
1740
+ private handleMouseInputChunk(events: Array<{ raw: string; mouse: { button: number; row: number; col: number; action: string } }>): boolean {
1741
+ let handled = false;
1742
+ for (const entry of events) {
1743
+ const { mouse, raw } = entry;
1744
+ if ((mouse.button & 64) !== 0) {
1745
+ handled = true;
1746
+ const wheelButton = mouse.button & 3;
1747
+ const wheel = wheelButton === 0 ? 1 : wheelButton === 1 ? -1 : 0;
1748
+ if (wheel === 0) continue;
1749
+ this.clearPendingClick();
1750
+ this.clearSelection();
1751
+ this.lastUserInputAt = this.nowFn();
1752
+ if (!this.tryScrollBy(wheel * this.mouseWheelLines)) this.send({ type: "input", data: raw });
1753
+ continue;
1754
+ }
1755
+ handled = true;
1756
+ this.handleLocalMouseEvent(mouse);
1757
+ }
1758
+ return handled;
1759
+ }
1760
+
1761
+ private updateSelectionAutoScroll(row: number, col: number): void {
1762
+ this.selectionAutoScrollMouse = { row, col };
1763
+ if (!this.selection || !this.selectionDragging) return this.clearSelectionAutoScroll(false);
1764
+ const lines = selectionDragScrollLines(row, this.bodyHeight());
1765
+ if (lines === 0) return this.clearSelectionAutoScroll(false);
1766
+ if (this.selectionAutoScrollTimer && this.selectionAutoScrollLines === lines) return;
1767
+ this.clearSelectionAutoScroll(false);
1768
+ this.selectionAutoScrollLines = lines;
1769
+ this.selectionAutoScrollTimer = setInterval(() => this.tickSelectionAutoScroll(), 50);
1770
+ this.selectionAutoScrollTimer.unref?.();
1771
+ }
1772
+
1773
+ private tickSelectionAutoScroll(): void {
1774
+ if (this.closed || !this.selection || !this.selectionDragging || !this.selectionAutoScrollMouse || !this.selectionAutoScrollLines) {
1775
+ this.clearSelectionAutoScroll();
1776
+ return;
1777
+ }
1778
+ const changed = this.tryScrollBy(this.selectionAutoScrollLines);
1779
+ const point = this.mousePointForEvent(this.selectionAutoScrollMouse.row, this.selectionAutoScrollMouse.col, true);
1780
+ if (point) this.selection.focus = point;
1781
+ if (!changed) this.clearSelectionAutoScroll(false);
1782
+ }
1783
+
1784
+ private clearSelectionAutoScroll(clearPointer = true): void {
1785
+ if (this.selectionAutoScrollTimer) {
1786
+ clearInterval(this.selectionAutoScrollTimer);
1787
+ this.selectionAutoScrollTimer = null;
1788
+ }
1789
+ this.selectionAutoScrollLines = 0;
1790
+ if (clearPointer) this.selectionAutoScrollMouse = null;
1791
+ }
1792
+
1793
+ private handleLocalMouseEvent(mouse: { button: number; row: number; col: number; action: string }): void {
1794
+ const primary = (mouse.button & 3) === 0;
1795
+ const middleButton = (mouse.button & 3) === 1;
1796
+ if (mouse.action === "press") {
1797
+ this.clearSelectionAutoScroll();
1798
+ if (middleButton) {
1799
+ // Middle-click paste: forward the X11 PRIMARY selection to the hosted
1800
+ // session as input, mimicking the terminal-native middle-click paste
1801
+ // that mouse reporting (kept on for wheel scrolling) would swallow.
1802
+ this.pastePrimarySelection();
1803
+ this.clearPendingClick();
1804
+ this.clearSelection();
1805
+ return;
1806
+ }
1807
+ if (!primary) {
1808
+ this.clearPendingClick();
1809
+ this.clearSelection();
1810
+ return;
1811
+ }
1812
+ const point = this.mousePointForEvent(mouse.row, mouse.col, false);
1813
+ if (!point) {
1814
+ this.clearPendingClick();
1815
+ this.clearSelection();
1816
+ return;
1817
+ }
1818
+ if (this.isDoubleClickCandidate(point)) this.clearPendingClick();
1819
+ this.selection = { anchor: point, focus: point, screenAnchor: { row: mouse.row, col: mouse.col } };
1820
+ this.selectionDragging = false;
1821
+ this.scheduleRender();
1822
+ return;
1823
+ }
1824
+ if (!this.selection) return;
1825
+ if (mouse.action === "move") {
1826
+ if (!primary) return;
1827
+ const point = this.mousePointForEvent(mouse.row, mouse.col, true);
1828
+ if (!point) return;
1829
+ this.selection.focus = point;
1830
+ this.selectionDragging = true;
1831
+ this.updateSelectionAutoScroll(mouse.row, mouse.col);
1832
+ this.scheduleRender();
1833
+ return;
1834
+ }
1835
+ if (mouse.action === "release") {
1836
+ this.clearSelectionAutoScroll();
1837
+ const point = this.mousePointForEvent(mouse.row, mouse.col, true);
1838
+ if (point) this.selection.focus = point;
1839
+ // Issue #187: a Warp fast flick arrives as press + release with no motion
1840
+ // event, so the move flag alone cannot decide. Compare the RAW reported
1841
+ // screen cells (never the clamped buffer points, whose base drifts as the
1842
+ // session streams).
1843
+ const shouldCopy = selectionReleaseIsDrag({
1844
+ dragging: this.selectionDragging,
1845
+ pressScreen: this.selection.screenAnchor,
1846
+ releaseScreen: { row: mouse.row, col: mouse.col },
1847
+ });
1848
+ this.selectionDragging = false;
1849
+ if (shouldCopy) {
1850
+ this.clearPendingClick();
1851
+ this.lastClickPoint = null;
1852
+ this.lastClickAt = 0;
1853
+ this.copySelectionToClipboard();
1854
+ this.scheduleRender();
1855
+ return;
1856
+ }
1857
+ if (!point) {
1858
+ this.clearPendingClick();
1859
+ this.clearSelection();
1860
+ return;
1861
+ }
1862
+ if (this.isDoubleClickCandidate(point)) {
1863
+ this.clearPendingClick();
1864
+ this.lastClickPoint = null;
1865
+ this.lastClickAt = 0;
1866
+ if (!this.selectWordAtPoint(point)) this.selection = null;
1867
+ this.scheduleRender();
1868
+ return;
1869
+ }
1870
+ this.lastClickPoint = point;
1871
+ this.lastClickAt = Date.now();
1872
+ this.schedulePendingClick(point);
1873
+ }
1874
+ }
1875
+
1876
+ private interactionLine(lineIndex: number): BufferLineLike | null {
1877
+ if (this.modernCommittedMode()) return this.committedLineAt(lineIndex);
1878
+ return this.term.buffer.active.getLine(lineIndex) ?? null;
1879
+ }
1880
+
1881
+ private interactionNullCell(): BufferCellLike {
1882
+ return this.modernCommittedMode() ? SnapshotCell.empty() : this.term.buffer.active.getNullCell();
1883
+ }
1884
+
1885
+ private mousePointForEvent(row: number, col: number, clampToBody: boolean): MousePoint | null {
1886
+ const height = this.bodyHeight();
1887
+ const bodyRow = row - 2;
1888
+ if (!clampToBody && (bodyRow < 0 || bodyRow >= height)) return null;
1889
+ const clampedRow = clampInt(bodyRow, 0, Math.max(0, height - 1));
1890
+ if (this.modernCommittedMode()) {
1891
+ const view = this.activeCommittedView();
1892
+ if (view === null) return null;
1893
+ const line = clampInt(view.start + clampedRow, view.start, Math.max(view.start, view.end - 1));
1894
+ return { line, col: clampInt(col - 1, 0, Math.max(0, this.cols - 1)) };
1895
+ }
1896
+ this.clampViewportTop(height);
1897
+ const start = this.viewportTop ?? this.bottomViewportTop(height);
1898
+ const line = clampInt(start + clampedRow, 0, Math.max(0, this.term.buffer.active.length - 1));
1899
+ const cellCol = clampInt(col - 1, 0, Math.max(0, this.cols - 1));
1900
+ return { line, col: cellCol };
1901
+ }
1902
+
1903
+ private isDoubleClickCandidate(point: MousePoint): boolean {
1904
+ return !!this.lastClickPoint && Date.now() - this.lastClickAt <= DOUBLE_CLICK_MS && sameMousePoint(this.lastClickPoint, point);
1905
+ }
1906
+
1907
+ private schedulePendingClick(point: MousePoint): void {
1908
+ this.clearPendingClick();
1909
+ this.pendingClickTimer = setTimeout(() => {
1910
+ this.pendingClickTimer = null;
1911
+ this.lastClickPoint = null;
1912
+ this.lastClickAt = 0;
1913
+ if (this.closed) return;
1914
+ this.openLinkAtPoint(point);
1915
+ this.selection = null;
1916
+ this.selectionDragging = false;
1917
+ this.tui.requestRender();
1918
+ }, DOUBLE_CLICK_MS);
1919
+ this.pendingClickTimer.unref?.();
1920
+ }
1921
+
1922
+ private clearPendingClick(): void {
1923
+ if (!this.pendingClickTimer) return;
1924
+ clearTimeout(this.pendingClickTimer);
1925
+ this.pendingClickTimer = null;
1926
+ }
1927
+
1928
+ private selectWordAtPoint(point: MousePoint): boolean {
1929
+ const line = this.interactionLine(point.line);
1930
+ if (!line) return false;
1931
+ const reusable = this.interactionNullCell();
1932
+ const range = findWordRangeAtCells(asciiCellsForBufferLine(line, reusable), point.col);
1933
+ if (!range) return false;
1934
+ this.selection = {
1935
+ anchor: { line: point.line, col: range.start },
1936
+ focus: { line: point.line, col: range.end },
1937
+ };
1938
+ this.selectionDragging = false;
1939
+ this.copySelectionToClipboard();
1940
+ return true;
1941
+ }
1942
+
1943
+ private openLinkAtPoint(point: MousePoint): boolean {
1944
+ const target = this.linkAtPoint(point);
1945
+ return target ? openExternalTarget(target) : false;
1946
+ }
1947
+
1948
+ private linkAtPoint(point: MousePoint): string | null {
1949
+ const line = this.interactionLine(point.line);
1950
+ if (!line) return null;
1951
+ const reusable = this.interactionNullCell();
1952
+ const cell = line.getCell(point.col, reusable);
1953
+ const osc8 = cell ? osc8UriForCell(this.term, cell) : "";
1954
+ if (osc8) return osc8;
1955
+ return findHttpUrlAtCells(asciiCellsForBufferLine(line, reusable), point.col);
1956
+ }
1957
+
1958
+ private clearSelection(): void {
1959
+ this.clearSelectionAutoScroll();
1960
+ if (!this.selection && !this.selectionDragging) return;
1961
+ this.selection = null;
1962
+ this.selectionDragging = false;
1963
+ this.scheduleRender();
1964
+ }
1965
+
1966
+ private copySelectionToClipboard(): void {
1967
+ const text = this.selectionText();
1968
+ if (!text) return;
1969
+ const seq = osc52CopySequence(text);
1970
+ if (seq) {
1971
+ try {
1972
+ this.tui.terminal.write(seq);
1973
+ } catch {
1974
+ /* best-effort: OSC52 clipboard support is optional */
1975
+ }
1976
+ }
1977
+ // Also mirror the selection into the X11 PRIMARY selection so the rest of the
1978
+ // desktop can middle-click-paste it — closes the loop with pastePrimarySelection().
1979
+ this.writePrimarySelection(text);
1980
+ }
1981
+
1982
+ /**
1983
+ * Middle-click paste. Reads the X11 selection (PRIMARY by default, or CLIPBOARD via
1984
+ * AGENT_BOARD_ATTACH_PASTE_SOURCE=clipboard) via xclip and forwards it to the hosted
1985
+ * session as input, mimicking the terminal-native middle-click paste that mouse
1986
+ * reporting (kept on for wheel scrolling) would otherwise swallow. Silent no-op when
1987
+ * xclip is absent or AGENT_BOARD_ATTACH_NATIVE_PASTE=0.
1988
+ */
1989
+ private pastePrimarySelection(): void {
1990
+ if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
1991
+ try {
1992
+ const child = spawn("xclip", ["-o", "-selection", resolvePasteSelectionSource(process.env.AGENT_BOARD_ATTACH_PASTE_SOURCE)], { stdio: ["ignore", "pipe", "ignore"] });
1993
+ let out = "";
1994
+ const timer = setTimeout(() => {
1995
+ try {
1996
+ child.kill("SIGKILL");
1997
+ } catch {
1998
+ /* the child may have already exited before the timeout fired */
1999
+ }
2000
+ }, 800);
2001
+ child.stdout?.on("data", (chunk: Buffer) => {
2002
+ out += chunk.toString("utf8");
2003
+ });
2004
+ child.on("error", () => clearTimeout(timer));
2005
+ child.on("close", () => {
2006
+ clearTimeout(timer);
2007
+ if (!this.closed && out) this.send({ type: "input", data: out });
2008
+ });
2009
+ } catch {
2010
+ /* silent no-op when xclip is absent — documented contract of this helper */
2011
+ }
2012
+ }
2013
+
2014
+ /** Issue #174 (B2) + #172: a selection gesture ended inside this overlay and pi has
2015
+ * finalized (and copied) its selection. Mirror the text into X11 PRIMARY so
2016
+ * middle-click paste closes the loop, then drop pi's selection so the inverse-video
2017
+ * highlight does not outlive the copy. Clearing is skipped when copy-on-select is
2018
+ * off (the user copies later with pi's Ctrl+X) and when the pi-tui method is absent
2019
+ * (older/newer runtime) — both degrade to the previous behaviour instead of throwing.
2020
+ * clearTextSelection preserves pi's multi-click history; resetTextSelection would
2021
+ * clear lastClick and break double-click word / triple-click line selection. */
2022
+ private settleFullscreenSelection(nativePasteEnabled: boolean): void {
2023
+ if (this.closed) return;
2024
+ const alt = this.tui as FullscreenSelectionHost;
2025
+ const text = alt.getActiveSelectionText?.();
2026
+ if (typeof text !== "string" || text.length === 0) return;
2027
+ if (nativePasteEnabled) this.writePrimarySelection(text);
2028
+ if (alt.getCopyOnSelect?.() === false) return;
2029
+ if (typeof alt.clearTextSelection !== "function") return;
2030
+ alt.clearTextSelection();
2031
+ this.scheduleRender();
2032
+ }
2033
+
2034
+ /** Write `text` to the X11 PRIMARY selection so other apps can middle-click-paste it. */
2035
+ private writePrimarySelection(text: string): void {
2036
+ if (process.env.AGENT_BOARD_ATTACH_NATIVE_PASTE === "0") return;
2037
+ try {
2038
+ const child = spawn("xclip", ["-selection", "primary"], { stdio: ["pipe", "ignore", "ignore"] });
2039
+ child.stdin?.on("error", () => {});
2040
+ child.on("error", () => {});
2041
+ child.stdin?.end(text);
2042
+ } catch {
2043
+ /* silent no-op when xclip is absent */
2044
+ }
2045
+ }
2046
+
2047
+ private selectionText(): string {
2048
+ const range = normalizeSelection(this.selection);
2049
+ if (!range) return "";
2050
+ const reusable = this.interactionNullCell();
2051
+ const parts: string[] = [];
2052
+ for (let lineIndex = range.start.line; lineIndex <= range.end.line; lineIndex++) {
2053
+ const line = this.interactionLine(lineIndex);
2054
+ if (!line) {
2055
+ parts.push("");
2056
+ continue;
2057
+ }
2058
+ const from = lineIndex === range.start.line ? range.start.col : 0;
2059
+ const to = lineIndex === range.end.line ? range.end.col : line.length - 1;
2060
+ let text = "";
2061
+ for (let x = Math.max(0, from); x <= Math.max(from, to); x++) {
2062
+ const cell = line.getCell(x, reusable);
2063
+ if (!cell || cell.getWidth() === 0) continue;
2064
+ text += cell.getChars() || " ";
2065
+ }
2066
+ parts.push(text.replace(/\s+$/u, ""));
2067
+ }
2068
+ return parts.join("\n").replace(/^\n+|\n+$/gu, "");
2069
+ }
2070
+
2071
+ private currentSize(): { cols: number; rows: number } {
2072
+ // SAFETY: duck-typed read — Pi TUI's Terminal type does not consistently expose
2073
+ // cols/columns/rows across versions (see resizeIfNeeded below). Runtime
2074
+ // fallbacks (120/24) keep this safe when the fields are absent.
2075
+ const term = this.tui.terminal as unknown as { cols?: number; columns?: number; rows?: number } | undefined;
2076
+ return {
2077
+ cols: Math.max(20, term?.cols ?? term?.columns ?? 120),
2078
+ rows: Math.max(5, (term?.rows ?? 24) - 2),
2079
+ };
2080
+ }
2081
+
2082
+ private resizeIfNeeded(width: number): void {
2083
+ const size = this.currentSize();
2084
+ // Render width is authoritative inside ctx.ui.custom; terminal.cols is not
2085
+ // consistently exposed by all Pi TUI versions.
2086
+ size.cols = Math.max(20, width);
2087
+ if (size.cols === this.cols && size.rows === this.rows) {
2088
+ // A same-size render after the early protocol size-sync consumes that
2089
+ // one-shot pair; it must not mint another intent.
2090
+ if (this.pendingEarlySyncTarget === `${size.cols}x${size.rows}`) this.pendingEarlySyncTarget = null;
2091
+ return;
2092
+ }
2093
+ this.cols = size.cols;
2094
+ this.rows = size.rows;
2095
+ this.noteGeometryIntent(size.cols, size.rows);
2096
+ // A real terminal resize supersedes the hold protocol: cancel any armed
2097
+ // hold and adopt the new size as the baseline (guard G4, issue #25).
2098
+ this.jiggleRetry.notifyExternalResize(size.cols, size.rows);
2099
+ this.term.resize(this.cols, this.rows);
2100
+ // Issue #205: reflow after a resize rewrites baseY, so the previous hydrate
2101
+ // floor no longer describes the buffer. Re-base it on the observed baseY —
2102
+ // losing local history is the safe side of this trade. A zero floor means no
2103
+ // hydrate has established one yet, so a legacy attach keeps its replayed
2104
+ // scrollback as real local history.
2105
+ if (this.historyFloor > 0) this.historyFloor = this.term.buffer.active.baseY;
2106
+ this.sendResize();
2107
+ this.enableMouseScroll();
2108
+ }
2109
+
2110
+ /** A differing resize is exactly one local geometry revision/intent; an
2111
+ * applied ack is not completion, only a matching proof is. */
2112
+ private noteGeometryIntent(cols = this.cols, rows = this.rows, options: { early?: boolean } = {}): void {
2113
+ const target = `${cols}x${rows}`;
2114
+ if (options.early === true) {
2115
+ // Every actual early size-sync/mismatch advances once. De-dup is
2116
+ // ONLY early→next render; early→early is never collapsed. The latest
2117
+ // pending target is consumed by the next render of that target.
2118
+ this.pendingEarlySyncTarget = target;
2119
+ } else if (this.pendingEarlySyncTarget === target) {
2120
+ // The next render of the same target consumes the early pair.
2121
+ this.pendingEarlySyncTarget = null;
2122
+ return;
2123
+ } else {
2124
+ this.pendingEarlySyncTarget = null;
2125
+ }
2126
+ this.geometryRevision += 1;
2127
+ this.resizeIntentId = this.geometryRevision;
2128
+ this.proofGeometryRevision = this.geometryRevision;
2129
+ this.proofIntentId = this.resizeIntentId;
2130
+ this.resizeIntentRequired = true;
2131
+ this.resizeIntentSatisfied = false;
2132
+ this.committedView = null;
2133
+ this.recoveryView = null;
2134
+ this.recoveryGeneration = null;
2135
+ this.invalidateViewProof("geometry-resize");
2136
+ }
2137
+
2138
+ private sendResize(cols = this.cols, rows = this.rows): void {
2139
+ // Phase 5 (D4): enveloped resize when the host has an instance fence
2140
+ // (correlated applied/superseded acks; starting-window host_starting is
2141
+ // retried client-side); legacy plain message otherwise. Both paths reach
2142
+ // the same runner resize handler.
2143
+ if (!this.attachClient.sendControl("resize", { cols, rows })) {
2144
+ this.send({ type: "resize", cols, rows });
2145
+ }
2146
+ this.clampViewportTop(this.bodyHeight());
2147
+ }
2148
+
2149
+ /** Feed socket output into the jiggle retry controller (clear/frame detection). */
2150
+ private checkClearSequence(data: string): void {
2151
+ this.jiggleRetry.feed(data);
2152
+ }
2153
+
2154
+ /** Issue #185: forward one synthesized SGR wheel report so an alternate-screen
2155
+ * child scrolls its own viewport (the attach's scrollback is inert there).
2156
+ * One report per received event — the child applies its own acceleration,
2157
+ * exactly as it would for a real terminal. Coordinates are child-screen
2158
+ * cells: the normalized event is zero-based and local to this overlay, the
2159
+ * body starts at local row 1 (header row 0, footer last row), and SGR is
2160
+ * one-based. */
2161
+ private forwardWheelToChild(linesUp: number, event: TuiMouseEvent): void {
2162
+ this.lastUserInputAt = this.nowFn();
2163
+ this.send({
2164
+ type: "input",
2165
+ data: encodeWheelReport(linesUp > 0 ? "up" : "down", {
2166
+ col: clampInt(event.x + 1, 1, this.cols),
2167
+ row: clampInt(event.y, 1, Math.max(1, this.bodyHeight())),
2168
+ // Issue #214: the overlay already received the Alt bit from pi-tui; carry it
2169
+ // into the synthesized report so the child keeps its 5x wheel accelerator.
2170
+ alt: event.alt === true,
2171
+ }),
2172
+ });
2173
+ }
2174
+
2175
+ private tryScrollBy(linesUp: number): boolean {
2176
+ const height = this.bodyHeight();
2177
+ if (this.modernCommittedMode()) {
2178
+ // The committed view is the only visible cache. Record the requested
2179
+ // viewport intent but let the child own history: the display stays on
2180
+ // the current complete range until a later verified boundary.
2181
+ const intent = scrollViewportTop(this.viewportTop, this.bottomViewportTop(height), linesUp, this.scrollFloorStart(height));
2182
+ this.viewportTop = intent.viewportTop;
2183
+ return false;
2184
+ }
2185
+ const result = scrollViewportTop(this.viewportTop, this.bottomViewportTop(height), linesUp, this.scrollFloorStart(height));
2186
+ this.viewportTop = result.viewportTop;
2187
+ if (result.changed) this.requestScrollRender();
2188
+ return result.changed;
2189
+ }
2190
+
2191
+ private tryScrollToTop(): boolean {
2192
+ const height = this.bodyHeight();
2193
+ const floorStart = this.scrollFloorStart(height);
2194
+ // Issue #205: compare against follow-bottom rather than against 0. A legacy
2195
+ // attach has `historyFloor === 0`, so `floorStart` is 0 while `bottom` is not —
2196
+ // this is then provably the parent "no local history" guard, and Home still
2197
+ // scrolls the local viewport to line 0. A hydrated overflow frame sits at
2198
+ // `floorStart === bottom`, so Home is forwarded to the child instead of being
2199
+ // spent scrolling hydration residue.
2200
+ if (floorStart >= this.bottomViewportTop(height) || this.viewportTop === floorStart) return false;
2201
+ this.viewportTop = floorStart;
2202
+ this.requestScrollRender();
2203
+ return true;
2204
+ }
2205
+
2206
+ private tryScrollToBottom(): boolean {
2207
+ if (this.viewportTop === null) return false;
2208
+ this.viewportTop = null;
2209
+ this.requestScrollRender();
2210
+ return true;
2211
+ }
2212
+
2213
+ private scrollToBottom(): void {
2214
+ this.viewportTop = null;
2215
+ this.requestScrollRender();
2216
+ }
2217
+
2218
+ private requestScrollRender(): void {
2219
+ this.scheduleRender();
2220
+ }
2221
+
2222
+ private bodyHeight(): number {
2223
+ return Math.max(1, (this.tui.terminal?.rows ?? 24) - 2);
2224
+ }
2225
+
2226
+ private pageSize(): number {
2227
+ return Math.max(1, this.bodyHeight() - 2);
2228
+ }
2229
+
2230
+ private bottomViewportTop(height: number): number {
2231
+ const buf = this.term.buffer.active;
2232
+ const bottom = Math.min(buf.length, buf.baseY + this.rows);
2233
+ return Math.max(0, bottom - height);
2234
+ }
2235
+
2236
+ /** Issue #205: lowest buffer line the local viewport may scroll to — the bottom
2237
+ * minus the rows the runner vouched for as real scrollback. */
2238
+ private scrollFloorStart(height: number): number {
2239
+ const bottom = this.bottomViewportTop(height);
2240
+ return Math.max(0, bottom - scrollableHistoryLines(this.term.buffer.active.baseY, this.historyFloor));
2241
+ }
2242
+
2243
+ /** Issue #207: re-assert the outer terminal mouse mode when the full-screen TUI owns it.
2244
+ * pi-tui writes ENABLE_MOUSE exactly once in `beforeTerminalStart` and never again, so a
2245
+ * single lost handshake (WezTerm on Windows is the reported case) turns every wheel
2246
+ * notch into an arrow key for the rest of the session: pi-tui keeps dispatching mouse
2247
+ * events that never arrive, and the attach surface only ever sees ``/``.
2248
+ * The sequence is pi-tui's own multiplexer-aware choice, so hover (1003) and focus
2249
+ * (1004) reporting cannot be downgraded. We never write the disable form here, so this
2250
+ * cannot break pi's own state either (the #173 contract stays intact). */
2251
+ private assertFullscreenMouseMode(): void {
2252
+ if (this.tui.mode !== "fullscreen") return;
2253
+ if (!this.mouseScrollEnabled()) return;
2254
+ try {
2255
+ this.tui.terminal.write(fullscreenMouseEnableSequence());
2256
+ } catch {
2257
+ /* best-effort: a dead terminal must not break attach */
2258
+ }
2259
+ }
2260
+
2261
+ private clampViewportTop(height: number): void {
2262
+ if (this.viewportTop === null) return;
2263
+ // scrollViewportTop snaps a below-floor viewport up to the floor, so a floor
2264
+ // that rose after the viewport parked (hydrate/resize) has to settle here too.
2265
+ this.viewportTop = clampInt(this.viewportTop, this.scrollFloorStart(height), this.bottomViewportTop(height));
2266
+ }
2267
+
2268
+ private send(msg: Record<string, unknown>): void {
2269
+ if (!this.socket || !this.connected) return;
2270
+ this.socket.write(JSON.stringify(msg) + "\n");
2271
+ }
2272
+
2273
+ private onSocketData(text: string): void {
2274
+ this.parserBuffer += text;
2275
+ const lines = this.parserBuffer.split("\n");
2276
+ this.parserBuffer = lines.pop() ?? "";
2277
+ let needsRender = false;
2278
+ let suppressGenerationRender = false;
2279
+ for (const line of lines) {
2280
+ if (!line.trim()) continue;
2281
+ try {
2282
+ const msg = JSON.parse(line);
2283
+ // Protocol-managed messages (snapshot windows, seq-checked live
2284
+ // output, recovery) are consumed by the attach client and re-emitted
2285
+ // through handleAttachEvent. UI-owned messages (hello/status/
2286
+ // editor_state/exit/error) and pre-decision broadcast output return
2287
+ // false and fall through to the legacy path below.
2288
+ if (this.attachClient.handleMessage(msg)) continue;
2289
+ if (msg.type === "output" && typeof msg.data === "string") {
2290
+ this.pushOutput(msg.data, { forwardProtocols: true });
2291
+ this.checkClearSequence(msg.data);
2292
+ continue;
2293
+ }
2294
+ if (msg.type === "hello" || msg.type === "status") {
2295
+ const wireGeneration = typeof msg.generation === "string" ? msg.generation : typeof msg.status?.generation === "string" ? msg.status.generation : null;
2296
+ if (wireGeneration !== null) {
2297
+ this.currentWireGeneration = wireGeneration;
2298
+ this.emptyResetPending = this.recoveryGeneration !== null && wireGeneration !== this.recoveryGeneration;
2299
+ if (
2300
+ (this.committedView !== null && wireGeneration !== this.committedView.identity.generation) ||
2301
+ (this.recoveryGeneration !== null && wireGeneration !== this.recoveryGeneration)
2302
+ ) {
2303
+ this.outputRenderScheduler.cancel();
2304
+ this.committedView = null;
2305
+ this.invalidateRecoveryView();
2306
+ this.invalidateViewProof("generation-change");
2307
+ // Do not paint the previous generation from a status render
2308
+ // before the new empty/snapshot arrives.
2309
+ suppressGenerationRender = true;
2310
+ }
2311
+ }
2312
+ this.status = "attached";
2313
+ const status = (msg.status ?? msg) as { frameWitness?: { enabled?: unknown } };
2314
+ if (status.frameWitness?.enabled === true) {
2315
+ const wasAvailable = this.frameWitnessAvailable;
2316
+ this.frameWitnessAvailable = true;
2317
+ if (!wasAvailable && this.attaching && this.attachMode === "protocol") {
2318
+ if (this.attachSettleTimer) {
2319
+ clearTimeout(this.attachSettleTimer);
2320
+ this.attachSettleTimer = null;
2321
+ }
2322
+ this.deadlineState = "pending";
2323
+ this.evaluateAttachReady();
2324
+ }
2325
+ } else if (status.frameWitness?.enabled === false && this.frameWitnessAvailable) {
2326
+ this.frameWitnessAvailable = false;
2327
+ this.latestViewProof = null;
2328
+ this.viewProofIdentity = null;
2329
+ if (this.attaching && this.attachMode === "protocol") {
2330
+ this.deadlineState = "pending";
2331
+ this.notReadyReason = "legacy-deadlines-without-reporter";
2332
+ this.restoreLegacyDeadlines();
2333
+ }
2334
+ }
2335
+ if (msg.type === "hello") this.editorEmpty = typeof msg.editorEmpty === "boolean" ? msg.editorEmpty : null;
2336
+ } else if (msg.type === "editor_state") {
2337
+ this.editorEmpty = typeof msg.empty === "boolean" ? msg.empty : null;
2338
+ } else if (msg.type === "exit") {
2339
+ this.status = "host exited";
2340
+ // The exit path calls done() directly (host disposes later);
2341
+ // drop the #128 bridge now so A7 symmetry doesn't depend on
2342
+ // the host honoring dispose().
2343
+ this.detachColorSchemeBridge();
2344
+ this.clearReplyGuardTimer();
2345
+ this.replyGuard?.reset();
2346
+ this.done({ action: "closed", exitCode: msg.exitCode ?? null });
2347
+ } else if (msg.type === "error") this.status = `error: ${msg.message ?? "host error"}`;
2348
+ if (shouldScheduleAttachRenderForMessage(msg.type)) needsRender = true;
2349
+ } catch {
2350
+ // Ignore malformed protocol lines; raw PTY data is only legal inside output.data.
2351
+ }
2352
+ }
2353
+ if (needsRender && !suppressGenerationRender) this.scheduleRender();
2354
+ }
2355
+
2356
+ /**
2357
+ * Attach-client events (issue #91 phase 4). The client owns protocol
2358
+ * recovery (it resubscribes internally); the UI only switches paths and
2359
+ * hydrates frames.
2360
+ */
2361
+ private handleAttachEvent(
2362
+ event: "mode" | "snapshotBegin" | "snapshotReady" | "output" | "viewProof" | "viewProofRecord" | "resubscribing" | "protocolError" | "cmdAck" | "reconciled" | "epochReset",
2363
+ payload: any,
2364
+ ): void {
2365
+ if (event === "mode") {
2366
+ const prev = this.attachMode;
2367
+ this.attachMode = payload as "protocol" | "legacy";
2368
+ if (this.attachMode === "protocol") {
2369
+ // Snapshot mode owns screen correctness: cancel any pending legacy
2370
+ // jiggle arming and unwind anything already armed (restores a held
2371
+ // resize; no-ops otherwise). The legacy 250 ms settle is cancelled
2372
+ // permanently for this attach; the hard deadline only marks an
2373
+ // explicit expired wait, never a forced publish.
2374
+ this.clearJiggleStartTimer();
2375
+ this.jiggleRetry.restoreAndStop();
2376
+ if (this.frameWitnessAvailable && this.attachSettleTimer) {
2377
+ clearTimeout(this.attachSettleTimer);
2378
+ this.attachSettleTimer = null;
2379
+ }
2380
+ this.deadlineState = "pending";
2381
+ this.notReadyReason = this.frameWitnessAvailable ? "awaiting-complete-frame" : "legacy-deadlines-without-reporter";
2382
+ this.evaluateAttachReady();
2383
+ } else if (prev === "protocol") {
2384
+ // Downgrade after a protocol session (recovery budget exhausted):
2385
+ // re-arm the legacy screen-healing machinery. This must run whenever
2386
+ // the socket is alive — not only during the attach transition. A
2387
+ // post-settle downgrade that skipped start() left the chain stopped
2388
+ // forever (restoreAndStop on the protocol side), so feed() early-
2389
+ // returned and raw output could never heal a desynced screen.
2390
+ this.clearJiggleStartTimer();
2391
+ if (this.connected) this.jiggleRetry.start(this.cols, this.rows);
2392
+ }
2393
+ if (this.attachMode === "legacy") this.restoreLegacyDeadlines();
2394
+ // Legacy decided during the undecided window: the race-guard timer
2395
+ // already fired at 100ms (≪ the 1500ms probe timeout) — nothing to do.
2396
+ return;
2397
+ }
2398
+ if (event === "snapshotBegin") {
2399
+ // Collecting identity only: replacement happens at snapshotReady.
2400
+ const beginGeneration = (payload ?? {}) as { generation?: unknown; empty?: unknown };
2401
+ if (beginGeneration.empty === true) {
2402
+ // Empty baseline invalidates old views/proof AT THE BEGIN, before
2403
+ // any status/hello render can present the dead generation again.
2404
+ this.outputRenderScheduler.cancel();
2405
+ this.committedView = null;
2406
+ this.invalidateRecoveryView();
2407
+ this.invalidateViewProof("new-empty-baseline");
2408
+ this.emptyResetPending = true;
2409
+ this.scheduleRender();
2410
+ }
2411
+ if (typeof beginGeneration.generation === "string") {
2412
+ const generationChanged =
2413
+ (this.committedView !== null && beginGeneration.generation !== this.committedView.identity.generation) ||
2414
+ (this.recoveryGeneration !== null && beginGeneration.generation !== this.recoveryGeneration);
2415
+ if (generationChanged) {
2416
+ this.committedView = null;
2417
+ this.invalidateRecoveryView();
2418
+ this.invalidateViewProof("generation-change");
2419
+ }
2420
+ }
2421
+ this.collectingConnectionToken = this.connectionToken;
2422
+ this.collectingBaselineToken = this.baselineToken;
2423
+ this.collectingParserEpoch = this.parserEpoch;
2424
+ // Protocol attach size-sync (CR R1 blocking): the legacy attach resized
2425
+ // the child at every connect (jiggle start); subscribe_terminal carries
2426
+ // no size, so without this the child keeps its host-creation geometry
2427
+ // and full-screen TUI children lay out at a stale size. begin is
2428
+ // authoritative for the runner's CURRENT size; the in-flight frame stays
2429
+ // old-geometry either way, but resizing now starts the child's redraw at
2430
+ // the true size sooner. Matched sizes send nothing (zero-resize
2431
+ // contract for same-size attach stays intact).
2432
+ const beginSize = (payload ?? {}) as { cols?: number; rows?: number };
2433
+ if (
2434
+ typeof beginSize.cols === "number" &&
2435
+ typeof beginSize.rows === "number" &&
2436
+ (beginSize.cols !== this.cols || beginSize.rows !== this.rows)
2437
+ ) {
2438
+ // One early size-sync pairs with the next render of the same target.
2439
+ // Later independent size-syncs to an already-seen target still advance.
2440
+ this.noteGeometryIntent(this.cols, this.rows, { early: true });
2441
+ this.sendResize();
2442
+ }
2443
+ return;
2444
+ }
2445
+ if (event === "snapshotReady") {
2446
+ const snap = (payload ?? {}) as { frame?: string; empty?: boolean; resnapshot?: boolean; scrollbackLines?: number; viewProof?: unknown; viewProofRecords?: unknown[] };
2447
+ if (this.collectingConnectionToken !== this.connectionToken) return;
2448
+ // The synthesized frame is self-contained on dirty terminals (phase 3
2449
+ // torture-proven: DECSTR + clear preamble wipes the constructor
2450
+ // screen.log replay and any pre-probe broadcast bytes), so hydrating is
2451
+ // a plain push: the write callback drives receivedOutput/settle/render
2452
+ // exactly like the legacy replay path. No protocol forwarding — the
2453
+ // frame is runner-synthesized grid content, not child passthrough
2454
+ // sequences. Empty baselines (the COMMON initial attach state: the host
2455
+ // publishes alive+childPid before the child's first output) carry no
2456
+ // frame; the loading banner persists until the first live output.
2457
+ // Empty/resnapshot answers ALWAYS wipe the local buffer, attaching or
2458
+ // not: while attaching the banner hides it (zero visual cost), and the
2459
+ // constructor's screen.log warm-start must not outlive the protocol
2460
+ // answer — a gated reset let the dead session's tail render as a
2461
+ // cold-start ghost once the banner lifted (final-review F1).
2462
+ if (!this.replaceParser()) {
2463
+ this.handleParserReplacementFailure();
2464
+ return; // fail-closed: no write to the old parser
2465
+ }
2466
+ const records = Array.isArray(snap.viewProofRecords) ? snap.viewProofRecords.slice(0, MAX_VIEW_PROOF_RECORDS) : [];
2467
+ this.viewProofRecords = records;
2468
+ this.viewProofRecordCount = records.length;
2469
+ if (
2470
+ !this.proofResyncRequested &&
2471
+ records.some((record) => (record as { marker?: unknown } | null)?.marker === "F") &&
2472
+ (snap.viewProof as { status?: string } | null | undefined)?.status !== "covered"
2473
+ ) {
2474
+ this.proofResyncRequested = true;
2475
+ this.attachClient.requestResync("view-proof-records-without-coverage");
2476
+ }
2477
+ this.viewProofTrailing.reset();
2478
+ this.recentProtocolOutput = [];
2479
+ this.resizeIntentSatisfied = false;
2480
+ if (snap.viewProof !== undefined) {
2481
+ this.latestViewProof = snap.viewProof;
2482
+ this.viewProofIdentity = (snap.viewProof as { status?: string } | null)?.status === "covered" ? this.currentViewIdentity() : null;
2483
+ if ((snap.viewProof as { status?: string } | null)?.status === "covered") this.proofResyncRequested = false;
2484
+ } else if (snap.empty === true) {
2485
+ this.latestViewProof = { status: "unknown", reason: "empty-baseline", proof: null, throughSeq: null, cursorVisible: null };
2486
+ this.viewProofIdentity = null;
2487
+ } else {
2488
+ this.latestViewProof = null;
2489
+ this.viewProofIdentity = null;
2490
+ }
2491
+ if (typeof snap.frame === "string") {
2492
+ this.protocolBaselineState = "valid-content";
2493
+ this.localHydrationState = "hydrating";
2494
+ this.pushOutput(snap.frame, { authoritative: true, scrollbackLines: snap.scrollbackLines });
2495
+ } else if (snap.empty || snap.resnapshot) {
2496
+ if (snap.empty === true) {
2497
+ this.committedView = null;
2498
+ this.invalidateRecoveryView();
2499
+ this.invalidateViewProof("new-empty-baseline");
2500
+ this.emptyResetPending = true;
2501
+ }
2502
+ this.protocolBaselineState = "valid-empty";
2503
+ this.childInputReady = false;
2504
+ this.childInputModeCarry = "";
2505
+ // Issue #205: the replacement parser is empty, so no stale floor/poison
2506
+ // survives. Empty/resnapshot never triggers the first-attach hooks.
2507
+ this.historyFloor = 0;
2508
+ this.scheduleRender();
2509
+ this.evaluateAttachReady();
2510
+ }
2511
+ return;
2512
+ }
2513
+ if (event === "output") {
2514
+ // Replacement failure is a hard fence: protocol bytes cannot be mixed into
2515
+ // the old parser while waiting for a bounded fresh resync/legacy decision.
2516
+ if (this.localHydrationState === "failed") return;
2517
+ // Live/replay protocol output: no jiggle clear-detection — that feed
2518
+ // exists to cancel the shrink-and-hold chain, which never runs in
2519
+ // protocol mode.
2520
+ if (typeof payload === "string") {
2521
+ this.recentProtocolOutput.push({ seq: this.attachClient.getLastSeq(), data: payload });
2522
+ if (this.recentProtocolOutput.length > 16) this.recentProtocolOutput.shift();
2523
+ this.noteViewProofOutput(payload);
2524
+ this.pushOutput(payload, { forwardProtocols: true });
2525
+ }
2526
+ return;
2527
+ }
2528
+ if (event === "viewProof") {
2529
+ this.latestViewProof = payload ?? null;
2530
+ if ((payload as { status?: string } | null)?.status === "covered") {
2531
+ this.viewProofIdentity = this.currentViewIdentity();
2532
+ this.viewProofTrailing.reset({ cursorVisible: typeof (payload as { cursorVisible?: unknown }).cursorVisible === "boolean" ? (payload as { cursorVisible: boolean }).cursorVisible : undefined });
2533
+ } else {
2534
+ this.viewProofIdentity = null;
2535
+ this.viewProofTrailing.reset();
2536
+ if (String((payload as { reason?: string } | null)?.reason ?? "").includes("reporter-replaced")) {
2537
+ this.viewProofRecords = [];
2538
+ this.viewProofRecordCount = 0;
2539
+ }
2540
+ }
2541
+ this.evaluateAttachReady();
2542
+ return;
2543
+ }
2544
+ if (event === "viewProofRecord") {
2545
+ if (payload !== null && typeof payload === "object") {
2546
+ const record = payload as Record<string, unknown>;
2547
+ this.noteViewProofRecord(record);
2548
+ if (record.marker === "F") {
2549
+ this.lastProofMetadata = record.metadata as { geometryRevision?: unknown; intentId?: unknown } | undefined;
2550
+ this.resizeIntentSatisfied = this.proofMeetsResizeIntent();
2551
+ }
2552
+ this.evaluateAttachReady();
2553
+ }
2554
+ return;
2555
+ }
2556
+ if (event === "cmdAck") {
2557
+ this.noteResizeAck(payload);
2558
+ return;
2559
+ }
2560
+ if (event === "epochReset") {
2561
+ this.childInputReady = false;
2562
+ this.childInputModeCarry = "";
2563
+ this.committedView = null;
2564
+ this.invalidateRecoveryView();
2565
+ this.invalidateViewProof("epoch-reset");
2566
+ }
2567
+ // "viewProof"/"viewProofRecord": independent protocol facts in this stage;
2568
+ // the component has no readiness/publish hook for them yet.
2569
+ // "resubscribing": recovery is client-internal (informational only).
2570
+ // "protocolError": observability only — recovery is automatic until the
2571
+ // legacy fallback, whose mode event the UI acts on above.
2572
+ // "cmdAck"/"reconciled"/"epochReset" (phase 5): correlation/observability
2573
+ // only — applied/superseded bookkeeping, reconcile baselines and the
2574
+ // generation epoch rule live inside the client; the UI's behavioral
2575
+ // surface (hydrate, mode switching, size-sync) is unchanged. host_starting
2576
+ // resize retries are also client-internal (parity with the legacy runner-
2577
+ // side cachedResize).
2578
+ }
2579
+
2580
+ private nudgeGeometry(waitToken: number): void {
2581
+ this.geometryNudges += 1;
2582
+ const { cols, rows } = this;
2583
+ this.sendResize(cols, Math.max(5, rows - 1));
2584
+ const back = setTimeout(() => {
2585
+ if (!this.closed && this.cols === cols && this.rows === rows) this.sendResize(cols, rows);
2586
+ }, GEOMETRY_NUDGE_SETTLE_MS);
2587
+ back.unref?.();
2588
+ this.attachHardTimeout = setTimeout(() => this.handleAttachHardTimeout(waitToken), GEOMETRY_NUDGE_WAIT_MS);
2589
+ }
2590
+
2591
+ private proofMeetsResizeIntent(): boolean {
2592
+ if (!this.resizeIntentRequired) return true;
2593
+ const metadata = this.lastProofMetadata;
2594
+ return metadata?.geometryRevision === this.proofGeometryRevision && metadata?.intentId === this.proofIntentId;
2595
+ }
2596
+
2597
+ /** Learns the runner's numbers for the resize we're waiting on. Runners from before it sent them
2598
+ * outlive an upgrade, and keep being compared with ours. */
2599
+ private noteResizeAck(ack: { type?: string; stage?: string; cols?: number; rows?: number; resized?: boolean; geometryRevision?: unknown; intentId?: unknown }): void {
2600
+ if (ack?.type !== "resize" || ack.stage !== "applied") return;
2601
+ if (ack.cols !== this.cols || ack.rows !== this.rows) return; // A newer resize owns the gate.
2602
+ if (typeof ack.geometryRevision !== "number" || typeof ack.intentId !== "number") return;
2603
+ if (ack.resized === false) {
2604
+ // Already that size, so the session won't draw a frame for it.
2605
+ this.resizeIntentRequired = false;
2606
+ } else {
2607
+ this.proofGeometryRevision = ack.geometryRevision;
2608
+ this.proofIntentId = ack.intentId;
2609
+ }
2610
+ this.resizeIntentSatisfied = this.proofMeetsResizeIntent();
2611
+ this.evaluateAttachReady();
2612
+ }
2613
+
2614
+ private forwardTerminalProtocols(data: string): void {
2615
+ const toWrite: string[] = [];
2616
+ if (process.env.AGENT_BOARD_FORWARD_OSC52 !== "0") {
2617
+ const { sequences, carry } = extractOsc52Sequences(this.osc52Carry + data);
2618
+ this.osc52Carry = carry;
2619
+ toWrite.push(...sequences);
2620
+ }
2621
+ if (process.env.AGENT_BOARD_FORWARD_IMAGES !== "0") {
2622
+ const { sequences, carry } = extractTerminalPassthroughSequences(this.passthroughCarry + data);
2623
+ this.passthroughCarry = carry;
2624
+ toWrite.push(...sequences);
2625
+ }
2626
+ if (process.env.AGENT_BOARD_FORWARD_TERMINAL_QUERIES !== "0") {
2627
+ // Issue #128 D1: forward the child's terminal capability queries
2628
+ // (OSC 11 background probe, 2031 scheme-notify switch) to the real
2629
+ // terminal; set-forms and kitty/DA negotiation stay excluded (see
2630
+ // extractOscQuerySequences).
2631
+ const { sequences, carry } = extractOscQuerySequences(this.oscQueryCarry + data);
2632
+ this.oscQueryCarry = carry;
2633
+ toWrite.push(...sequences);
2634
+ }
2635
+ for (const seq of toWrite) {
2636
+ try {
2637
+ this.tui.terminal.write(seq);
2638
+ } catch {
2639
+ /* best-effort: forwarded sequences are enhancements, never critical */
2640
+ }
2641
+ }
2642
+ }
2643
+
2644
+ /** LEGACY FALLBACK (issue #91 phase 6 marking): the screen.log tail replay
2645
+ * is kept only for pre-protocol runners and the
2646
+ * AGENT_BOARD_TERMINAL_SNAPSHOT=0 kill switch. It is NOT a correctness
2647
+ * path of the snapshot+subscribe protocol — the protocol hydrates from
2648
+ * runner-owned snapshots and resumes from a sequence cursor (phase 4),
2649
+ * and this replay runs before the probe resolves. Removal condition: the
2650
+ * installed runner fleet is on the snapshot protocol baseline
2651
+ * (wire-detectable via hello protocol fields).
2652
+ */
2653
+ private replayScreenLog(): void {
2654
+ if (!this.opts.screenLogPath || !existsSync(this.opts.screenLogPath)) return;
2655
+ try {
2656
+ // Read only the tail of the log instead of the whole file: large logs (tens of MB)
2657
+ // would otherwise block the attach on a full readFileSync + UTF-8 decode before the
2658
+ // first frame. Skip a leading partial line so we don't inject a half escape sequence.
2659
+ const { size } = statSync(this.opts.screenLogPath);
2660
+ const start = Math.max(0, size - ATTACH_REPLAY_BYTES);
2661
+ const length = size - start;
2662
+ if (length <= 0) return;
2663
+ const fd = openSync(this.opts.screenLogPath, "r");
2664
+ try {
2665
+ const buf = Buffer.alloc(length);
2666
+ readSync(fd, buf, 0, length, start);
2667
+ let tail = buf.toString("utf8");
2668
+ if (start > 0) {
2669
+ const nl = tail.indexOf("\n");
2670
+ tail = nl >= 0 ? tail.slice(nl + 1) : tail;
2671
+ }
2672
+ this.pushOutput(tail);
2673
+ } finally {
2674
+ closeSync(fd);
2675
+ }
2676
+ } catch {
2677
+ /* best-effort: a missing or racing screen.log must not block attach */
2678
+ }
2679
+ }
2680
+
2681
+ private pushOutput(data: string, opts: { forwardProtocols?: boolean; authoritative?: boolean; scrollbackLines?: number } = {}): void {
2682
+ if (data.length === 0) return;
2683
+ // Recorded synchronously BEFORE term.write (whose callback is async): the
2684
+ // desync quiet-window must measure when output ARRIVED, not when parsing finished.
2685
+ this.lastOutputAt = this.nowFn();
2686
+ // Current snapshots explicitly restore all modes, including paste mode.
2687
+ // Read their readiness synchronously so an older parse callback cannot
2688
+ // overwrite a newer live shutdown/reload signal.
2689
+ if (opts.authoritative) {
2690
+ this.childInputReady = false;
2691
+ this.childInputModeCarry = "";
2692
+ }
2693
+ if (opts.forwardProtocols || opts.authoritative) this.updateChildInputReadiness(data);
2694
+ if (opts.forwardProtocols) this.forwardTerminalProtocols(data);
2695
+ const term = this.term;
2696
+ const connectionToken = this.connectionToken;
2697
+ const baselineToken = this.baselineToken;
2698
+ const parserEpoch = this.parserEpoch;
2699
+ const generation = this.attachClient.getIdentity().generation ?? "local";
2700
+ const capturedIdentity = { connectionToken, baselineToken, generation, parser: term, geometryRevision: this.geometryRevision, targetCols: this.cols, targetRows: this.rows, closed: false };
2701
+ // @xterm/headless parses asynchronously; the buffer is only populated once this
2702
+ // callback fires. Mark the buffer as ready (so the project path can paint it), but
2703
+ // stay on the loading banner while `attaching` — otherwise the screen-log replay and
2704
+ // the initial resize-jiggle redraws would flash through the viewport. Each parsed
2705
+ // chunk defers the settle window, so the banner holds until output actually
2706
+ // stops arriving (i.e. the redraw finished), not just until the resize jiggle ends.
2707
+ // Current-epoch invariant: replaceParser() resets this counter to 0 when it
2708
+ // advances parserEpoch. A stale callback returns below WITHOUT decrementing;
2709
+ // decrementing it would cancel a new-epoch write and allow premature publish.
2710
+ this.parserWritesInFlight += 1;
2711
+ try {
2712
+ term.write(data, () => {
2713
+ if (this.parserEpoch !== parserEpoch) return;
2714
+ if (this.parserWritesInFlight > 0) this.parserWritesInFlight -= 1;
2715
+ const currentIdentity = {
2716
+ connectionToken: this.connectionToken,
2717
+ baselineToken: this.baselineToken,
2718
+ generation: this.attachClient.getIdentity().generation ?? "local",
2719
+ parser: this.term,
2720
+ geometryRevision: this.geometryRevision,
2721
+ targetCols: this.cols,
2722
+ targetRows: this.rows,
2723
+ closed: this.closed,
2724
+ };
2725
+ if (!canPublishAttachView(capturedIdentity, currentIdentity)) return;
2726
+ if (opts.forwardProtocols || opts.authoritative) this.flushPendingSchemeReport();
2727
+ // Issue #205: baseY is only final once this chunk parsed, and the callback
2728
+ // has no buffer binding — re-read it. Rows past the runner-vouched
2729
+ // scrollback are overflow the hydration itself scrolled out.
2730
+ if (opts.authoritative) {
2731
+ this.historyFloor = historyFloorAfterHydrate(term.buffer.active.baseY, opts.scrollbackLines);
2732
+ this.localHydrationState = "complete";
2733
+ if (!this.parserSyncOpen) this.commitCommittedView(term, parserEpoch);
2734
+ }
2735
+ this.receivedOutput = true;
2736
+ this.deferAttachSettle();
2737
+ if (this.parserWritesInFlight === 0 && !this.parserSyncOpen && this.nowFn() - this.lastUserInputAt < INTERACTIVE_RENDER_WINDOW_MS) {
2738
+ this.outputRenderScheduler.cancel();
2739
+ this.scheduleRender();
2740
+ } else {
2741
+ this.outputRenderScheduler.request();
2742
+ }
2743
+ this.evaluateAttachReady();
2744
+ });
2745
+ } catch {
2746
+ if (this.parserEpoch === parserEpoch && this.parserWritesInFlight > 0) this.parserWritesInFlight -= 1;
2747
+ }
2748
+ }
2749
+
2750
+ private projectCommitted(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
2751
+ const view = this.activeCommittedView();
2752
+ if (view === null) return { lines: [], cursor: null };
2753
+ const selection = normalizeSelection(this.selection);
2754
+ const out: string[] = [];
2755
+ for (let index = 0; index < view.rows.length && out.length < height; index += 1) {
2756
+ const lineIndex = view.start + index;
2757
+ out.push(lineToAnsi(view.rows[index].cells, SnapshotCell.empty(), this.term, lineIndex, selection, view.cursor, view.cursorHidden));
2758
+ }
2759
+ return { lines: out, cursor: view.cursor };
2760
+ }
2761
+
2762
+ private project(height: number): { lines: string[]; cursor: { row: number; col: number } | null } {
2763
+ if (this.closed) return { lines: [], cursor: null };
2764
+ if (this.modernCommittedMode()) return this.projectCommitted(height);
2765
+ const out: string[] = [];
2766
+ const buf = this.term.buffer.active;
2767
+ const selection = normalizeSelection(this.selection);
2768
+ this.clampViewportTop(height);
2769
+ const start = this.viewportTop ?? this.bottomViewportTop(height);
2770
+ const end = Math.min(buf.length, start + height);
2771
+ const reusable = buf.getNullCell();
2772
+ // The PTY cursor: xterm buffer cursorY is relative to baseY (the viewport top of
2773
+ // the child terminal); cursorX may equal cols (one past the last cell). Only render
2774
+ // it when it lands inside the projected viewport.
2775
+ const cursor = projectPtyCursor(buf, start, height);
2776
+ const cursorHidden = isPtyCursorHidden(this.term);
2777
+ for (let i = start; i < end; i++) {
2778
+ out.push(lineToAnsi(buf.getLine(i), reusable, this.term, i, selection, cursor, cursorHidden));
2779
+ }
2780
+ if (out.length === 0) out.push("Waiting for PTY output…");
2781
+ return { lines: out.slice(-height), cursor };
2782
+ }
2783
+
2784
+ private close(gracefulSocket = false): void {
2785
+ if (this.closed && !this.socket) {
2786
+ // Already closed (e.g. dispose() after detach()); a pending graceful-close
2787
+ // fallback timer stays armed on purpose — it self-clears on socket close
2788
+ // or destroys the socket after the grace window.
2789
+ return;
2790
+ }
2791
+ this.closed = true;
2792
+ this.attachWaitToken += 1;
2793
+ this.committedView = null;
2794
+ this.recoveryView = null;
2795
+ this.recoveryGeneration = null;
2796
+ this.disposeCurrentParser();
2797
+ this.imeCoalesceUninstall?.();
2798
+ this.releaseInput();
2799
+ this.detachColorSchemeBridge();
2800
+ this.clearReplyGuardTimer();
2801
+ this.replyGuard?.reset();
2802
+ this.attachClient.close();
2803
+ this.jiggleRetry.restoreAndStop();
2804
+ this.disableMouseScroll();
2805
+ this.clearMouseRefreshTimers();
2806
+ this.clearPendingClick();
2807
+ this.clearSelectionAutoScroll();
2808
+ this.clearRetry();
2809
+ this.stopLoadingTicker();
2810
+ this.outputRenderScheduler.dispose();
2811
+ // Narrow feature-detected host seam (repo pi-tui 1.1.0, host pi 1.4.0):
2812
+ // cancel the already-queued TUI frame without stopping the TUI. A closed
2813
+ // flag inside render cannot cancel a render queued before dispose.
2814
+ const renderSeam = this.tui as unknown as {
2815
+ cancelRenderTimer?: () => void;
2816
+ renderRequested?: boolean;
2817
+ };
2818
+ if (typeof renderSeam.cancelRenderTimer === "function") {
2819
+ try { renderSeam.cancelRenderTimer(); } catch { /* host seam is best-effort */ }
2820
+ }
2821
+ // TuiBase.requestImmediateRender() queues a process.nextTick that checks
2822
+ // renderRequested; clearing it cancels an already queued immediate paint
2823
+ // without stopping the TUI. Verified host pi 1.4.0 shape.
2824
+ if (typeof renderSeam.renderRequested === "boolean") renderSeam.renderRequested = false;
2825
+ this.stopDesyncProbe();
2826
+ if (this.attachSettleTimer) {
2827
+ clearTimeout(this.attachSettleTimer);
2828
+ this.attachSettleTimer = null;
2829
+ }
2830
+ if (this.attachHardTimeout) {
2831
+ clearTimeout(this.attachHardTimeout);
2832
+ this.attachHardTimeout = null;
2833
+ }
2834
+ this.clearGracefulSocketCloseTimer();
2835
+ const socket = this.socket;
2836
+ this.socket = null;
2837
+ this.connected = false;
2838
+ if (!socket) return;
2839
+ if (!gracefulSocket) {
2840
+ try { socket.destroy(); } catch { /* best-effort teardown */ }
2841
+ return;
2842
+ }
2843
+ // socket.end() preserves the ordering of the already-buffered restore and
2844
+ // detach packets. destroy() would discard buffered writes under backpressure.
2845
+ socket.once("close", () => this.clearGracefulSocketCloseTimer());
2846
+ try { socket.end(); } catch { /* fallback timer below forces cleanup */ }
2847
+ this.gracefulSocketCloseTimer = setTimeout(() => {
2848
+ this.gracefulSocketCloseTimer = null;
2849
+ try { socket.destroy(); } catch { /* best-effort teardown */ }
2850
+ }, GRACEFUL_SOCKET_CLOSE_MS);
2851
+ this.gracefulSocketCloseTimer.unref?.();
2852
+ }
2853
+ }
2854
+
2855
+ function asciiCellsForBufferLine(line: BufferLineLike, reusable: BufferCellLike): string[] {
2856
+ const cells: string[] = [];
2857
+ for (let x = 0; x < line.length; x++) {
2858
+ const cell = line.getCell(x, reusable);
2859
+ if (!cell || cell.getWidth() === 0) {
2860
+ cells.push(" ");
2861
+ continue;
2862
+ }
2863
+ const chars = cell.getChars() || " ";
2864
+ cells.push(chars.length === 1 && chars >= " " && chars <= "~" ? chars : " ");
2865
+ }
2866
+ return cells;
2867
+ }
2868
+
2869
+ function openExternalTarget(target: string): boolean {
2870
+ const sanitized = sanitizeOscPayload(target).trim();
2871
+ if (!isOpenableLink(sanitized)) return false;
2872
+ // Opening the target is best-effort: an async spawn failure (e.g. xdg-open
2873
+ // missing on a minimal server) must be swallowed, not crash the host (issue #86).
2874
+ try {
2875
+ if (process.platform === "darwin") {
2876
+ const child = spawn("open", [sanitized], { detached: true, stdio: "ignore" });
2877
+ child.on("error", () => {});
2878
+ child.unref();
2879
+ return true;
2880
+ }
2881
+ if (process.platform === "win32") {
2882
+ const child = spawn("cmd", ["/c", "start", "", sanitized], { detached: true, stdio: "ignore" });
2883
+ child.on("error", () => {});
2884
+ child.unref();
2885
+ return true;
2886
+ }
2887
+ const child = spawn("xdg-open", [sanitized], { detached: true, stdio: "ignore" });
2888
+ child.on("error", () => {});
2889
+ child.unref();
2890
+ return true;
2891
+ } catch {
2892
+ return false;
2893
+ }
2894
+ }
2895
+
2896
+ function compareMousePoints(a: MousePoint, b: MousePoint): number {
2897
+ return a.line === b.line ? a.col - b.col : a.line - b.line;
2898
+ }
2899
+
2900
+ function normalizeSelection(selection: MouseSelection | null): NormalizedSelection | null {
2901
+ if (!selection) return null;
2902
+ return compareMousePoints(selection.anchor, selection.focus) <= 0
2903
+ ? { start: selection.anchor, end: selection.focus }
2904
+ : { start: selection.focus, end: selection.anchor };
2905
+ }
2906
+
2907
+ function pointWithinSelection(line: number, col: number, selection: NormalizedSelection | null): boolean {
2908
+ if (!selection) return false;
2909
+ if (line < selection.start.line || line > selection.end.line) return false;
2910
+ if (selection.start.line === selection.end.line) return col >= selection.start.col && col <= selection.end.col;
2911
+ if (line === selection.start.line) return col >= selection.start.col;
2912
+ if (line === selection.end.line) return col <= selection.end.col;
2913
+ return true;
2914
+ }
2915
+
2916
+ function osc52CopySequence(text: string): string {
2917
+ if (!text) return "";
2918
+ const data = Buffer.from(text, "utf8").toString("base64");
2919
+ const seq = `\x1b]52;c;${data}\x07`;
2920
+ return Buffer.byteLength(seq, "utf8") <= OSC52_MAX_BYTES ? seq : "";
2921
+ }
2922
+
2923
+ function lineToAnsi(
2924
+ line: BufferLineLike | undefined,
2925
+ reusable: BufferCellLike,
2926
+ term: XtermLike,
2927
+ lineIndex: number,
2928
+ selection: NormalizedSelection | null,
2929
+ cursor: { row: number; col: number } | null,
2930
+ cursorHidden = false,
2931
+ ): string {
2932
+ const isCursorRow = cursor !== null && cursor.row === lineIndex;
2933
+ let last = -1;
2934
+ if (!line) {
2935
+ // No buffer line: keep the marker (IME positioning) and paint the inverse block
2936
+ // only while the child reports a visible cursor.
2937
+ if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
2938
+ return "";
2939
+ }
2940
+ let lastBg = -1;
2941
+ for (let x = 0; x < line.length; x++) {
2942
+ const cell = line.getCell(x, reusable);
2943
+ if (!cell || cell.getWidth() === 0) continue;
2944
+ if (cell.getChars()) last = x;
2945
+ if (!cell.isBgDefault()) lastBg = x;
2946
+ }
2947
+ // Issue #232: full-screen TUIs paint an explicit theme bg over otherwise empty
2948
+ // regions. Trimming at the last character drops that paint and lets the host
2949
+ // terminal's default bg show through, so the render bound covers the last
2950
+ // painted cell too. Default-bg lines keep the old bound, byte for byte.
2951
+ const renderEnd = Math.max(last, lastBg);
2952
+ if (renderEnd < 0) {
2953
+ // Empty line: same split as above.
2954
+ if (isCursorRow && cursor!.col >= 0) return cursorHidden ? CURSOR_MARKER : CURSOR_MARKER + "\x1b[7m \x1b[0m";
2955
+ return "";
2956
+ }
2957
+
2958
+ let out = "";
2959
+ let prevAttr = "";
2960
+ let prevUri = "";
2961
+ for (let x = 0; x <= renderEnd; x++) {
2962
+ const cell = line.getCell(x, reusable);
2963
+ if (!cell || cell.getWidth() === 0) continue;
2964
+ const uri = osc8UriForCell(term, cell);
2965
+ if (uri !== prevUri) {
2966
+ if (prevUri) out += closeOsc8();
2967
+ if (uri) out += openOsc8(uri);
2968
+ prevUri = uri;
2969
+ }
2970
+ const selected = pointWithinSelection(lineIndex, x, selection);
2971
+ // Position and visibility are separate concerns: the zero-width CURSOR_MARKER
2972
+ // (stripped by the TUI) always marks where the hardware cursor belongs for IME
2973
+ // and PI_HARDWARE_CURSOR=1 terminals, while the solid inverse block is only
2974
+ // painted when the child terminal itself reports the cursor as visible. pi-tui
2975
+ // parks a hidden cursor at a diff-write byproduct position, so painting it
2976
+ // unconditionally showed a ghost block (issue #102).
2977
+ const isCursor = isCursorRow && x === cursor!.col;
2978
+ if (isCursor) out += CURSOR_MARKER;
2979
+ const paintCursor = isCursor && !cursorHidden;
2980
+ const key = attrKey(cell, selected, paintCursor);
2981
+ if (key !== prevAttr) {
2982
+ out += attrsToAnsi(cell, selected, paintCursor);
2983
+ prevAttr = key;
2984
+ }
2985
+ out += cell.getChars() || " ";
2986
+ }
2987
+ if (prevUri) out += closeOsc8();
2988
+ // Cursor past the end of the painted region (cursorX == cols or beyond the last
2989
+ // rendered cell): append an inverse space so a VISIBLE position shows, keeping the
2990
+ // marker either way.
2991
+ if (isCursorRow && cursor!.col > renderEnd) {
2992
+ out += CURSOR_MARKER + (cursorHidden ? "" : "\x1b[7m \x1b[0m");
2993
+ }
2994
+ return out + "\x1b[0m";
2995
+ }
2996
+
2997
+ function attrKey(cell: BufferCellLike, selected = false, isCursor = false): string {
2998
+ // Runs for every cell of every frame, so no array to build and join.
2999
+ const flags =
3000
+ (selected ? 1 : 0) |
3001
+ (isCursor ? 2 : 0) |
3002
+ (cell.isBold() ? 4 : 0) |
3003
+ (cell.isDim() ? 8 : 0) |
3004
+ (cell.isItalic() ? 16 : 0) |
3005
+ (cell.isUnderline() ? 32 : 0) |
3006
+ (cell.isBlink() ? 64 : 0) |
3007
+ (cell.isInverse() ? 128 : 0) |
3008
+ (cell.isInvisible() ? 256 : 0) |
3009
+ (cell.isStrikethrough() ? 512 : 0) |
3010
+ (cell.isOverline() ? 1024 : 0) |
3011
+ (cell.isFgRGB() ? 2048 : 0) |
3012
+ (cell.isFgPalette() ? 4096 : 0) |
3013
+ (cell.isBgRGB() ? 8192 : 0) |
3014
+ (cell.isBgPalette() ? 16384 : 0);
3015
+ return `${flags};${cell.getFgColor()};${cell.getBgColor()}`;
3016
+ }
3017
+
3018
+ function attrsToAnsi(cell: BufferCellLike, selected = false, isCursor = false): string {
3019
+ const codes: string[] = ["0"];
3020
+ if (cell.isBold()) codes.push("1");
3021
+ if (cell.isDim()) codes.push("2");
3022
+ if (cell.isItalic()) codes.push("3");
3023
+ if (cell.isUnderline()) codes.push("4");
3024
+ if (cell.isBlink()) codes.push("5");
3025
+ if (cell.isInverse() || selected || isCursor) codes.push("7");
3026
+ if (cell.isInvisible()) codes.push("8");
3027
+ if (cell.isStrikethrough()) codes.push("9");
3028
+ if (cell.isOverline()) codes.push("53");
3029
+ codes.push(...colorCodes(cell, "fg"));
3030
+ codes.push(...colorCodes(cell, "bg"));
3031
+ return `\x1b[${codes.join(";")}m`;
3032
+ }
3033
+
3034
+ function colorCodes(cell: BufferCellLike, kind: "fg" | "bg"): string[] {
3035
+ const isFg = kind === "fg";
3036
+ const color = isFg ? cell.getFgColor() : cell.getBgColor();
3037
+ if (isFg ? cell.isFgRGB() : cell.isBgRGB()) {
3038
+ return [isFg ? "38" : "48", "2", String((color >> 16) & 255), String((color >> 8) & 255), String(color & 255)];
3039
+ }
3040
+ if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
3041
+ if (color >= 0 && color <= 7) return [String((isFg ? 30 : 40) + color)];
3042
+ if (color >= 8 && color <= 15) return [String((isFg ? 90 : 100) + color - 8)];
3043
+ return [isFg ? "38" : "48", "5", String(color)];
3044
+ }
3045
+ return [isFg ? "39" : "49"];
3046
+ }
3047
+
3048
+ function clip(line: string, width: number): string {
3049
+ return truncateToWidth(line, width, "");
3050
+ }
3051
+
3052
+ function clipTerminalLine(line: string, width: number): string {
3053
+ // pi-tui's width helpers are ANSI-aware, but OSC sequences are terminal
3054
+ // protocols rather than SGR styling. Avoid truncating inside OSC 8 hyperlinks;
3055
+ // these lines are already projected from an xterm buffer sized to the viewport.
3056
+ return line.includes("\x1b]") ? line : clip(line, width);
3057
+ }
3058
+
3059
+ function osc8UriForCell(term: XtermLike, cell: BufferCellLike): string {
3060
+ const committed = (cell as { osc8Uri?: () => string }).osc8Uri?.();
3061
+ if (typeof committed === "string" && committed.length > 0) return committed;
3062
+ // xterm leaves cached extended data on reused cells when this flag is unset.
3063
+ if (!cell.hasExtendedAttrs()) return "";
3064
+ const id = cell.extended?.urlId ?? cell.extended?._urlId ?? 0;
3065
+ if (!id) return "";
3066
+ const service = term._core?._oscLinkService;
3067
+ const uri = service?.getLinkData?.(id)?.uri ?? service?._dataByLinkId?.get(id)?.data?.uri;
3068
+ return sanitizeOscPayload(uri ?? "");
3069
+ }
3070
+
3071
+ function openOsc8(uri: string): string {
3072
+ return uri ? `\x1b]8;;${uri}\x07` : "";
3073
+ }
3074
+
3075
+ function closeOsc8(): string {
3076
+ return "\x1b]8;;\x07";
3077
+ }
3078
+
3079
+ function extractOsc52Sequences(input: string): { sequences: string[]; carry: string } {
3080
+ const sequences: string[] = [];
3081
+ let scanFrom = 0;
3082
+ let carryStart = -1;
3083
+ while (scanFrom < input.length) {
3084
+ const start = input.indexOf(OSC52_PREFIX, scanFrom);
3085
+ if (start < 0) break;
3086
+ const bel = input.indexOf("\x07", start + OSC52_PREFIX.length);
3087
+ const st = input.indexOf("\x1b\\", start + OSC52_PREFIX.length);
3088
+ const end = firstTerminator(bel, st);
3089
+ if (!end) {
3090
+ carryStart = start;
3091
+ break;
3092
+ }
3093
+ const [endIndex, terminatorLength] = end;
3094
+ const seq = input.slice(start, endIndex + terminatorLength);
3095
+ if (isForwardableOsc52(seq)) sequences.push(seq);
3096
+ scanFrom = endIndex + terminatorLength;
3097
+ }
3098
+ const carry = carryStart >= 0 ? input.slice(carryStart).slice(-OSC52_CARRY_MAX_BYTES) : osc52PrefixSuffix(input);
3099
+ return { sequences, carry };
3100
+ }
3101
+
3102
+ function osc52PrefixSuffix(input: string): string {
3103
+ const max = Math.min(input.length, OSC52_PREFIX.length - 1);
3104
+ for (let len = max; len > 0; len--) {
3105
+ const suffix = input.slice(-len);
3106
+ if (OSC52_PREFIX.startsWith(suffix)) return suffix;
3107
+ }
3108
+ return "";
3109
+ }
3110
+
3111
+ function firstTerminator(bel: number, st: number): [number, number] | null {
3112
+ if (bel < 0 && st < 0) return null;
3113
+ if (bel >= 0 && (st < 0 || bel < st)) return [bel, 1];
3114
+ return [st, 2];
3115
+ }
3116
+
3117
+ function extractTerminalPassthroughSequences(input: string): { sequences: string[]; carry: string } {
3118
+ const sequences: string[] = [];
3119
+ let scanFrom = 0;
3120
+ let carryStart = -1;
3121
+ while (scanFrom < input.length) {
3122
+ const kitty = input.indexOf(KITTY_IMAGE_PREFIX, scanFrom);
3123
+ const iterm = input.indexOf(ITERM2_FILE_PREFIX, scanFrom);
3124
+ const start = firstIndex(kitty, iterm);
3125
+ if (start < 0) break;
3126
+ const prefix = start === kitty ? KITTY_IMAGE_PREFIX : ITERM2_FILE_PREFIX;
3127
+ const bel = prefix === ITERM2_FILE_PREFIX ? input.indexOf("\x07", start + prefix.length) : -1;
3128
+ const st = input.indexOf("\x1b\\", start + prefix.length);
3129
+ const end = firstTerminator(bel, st);
3130
+ if (!end) {
3131
+ carryStart = start;
3132
+ break;
3133
+ }
3134
+ const [endIndex, terminatorLength] = end;
3135
+ const seq = input.slice(start, endIndex + terminatorLength);
3136
+ // Without inline=1, iTerm2 takes this as a file download rather than an image.
3137
+ const isInlineImage = prefix !== ITERM2_FILE_PREFIX || /^[^:]*\binline=1\b/.test(seq.slice(prefix.length));
3138
+ if (seq.length <= TERMINAL_PASSTHROUGH_MAX_BYTES && isInlineImage) sequences.push(seq);
3139
+ scanFrom = endIndex + terminatorLength;
3140
+ }
3141
+ const carry = carryStart >= 0 ? input.slice(carryStart).slice(-TERMINAL_PASSTHROUGH_CARRY_MAX_BYTES) : terminalPassthroughPrefixSuffix(input);
3142
+ return { sequences, carry };
3143
+ }
3144
+
3145
+ function firstIndex(a: number, b: number): number {
3146
+ if (a < 0) return b;
3147
+ if (b < 0) return a;
3148
+ return Math.min(a, b);
3149
+ }
3150
+
3151
+ function terminalPassthroughPrefixSuffix(input: string): string {
3152
+ let best = "";
3153
+ for (const prefix of [KITTY_IMAGE_PREFIX, ITERM2_FILE_PREFIX]) {
3154
+ const max = Math.min(input.length, prefix.length - 1);
3155
+ for (let len = max; len > best.length; len--) {
3156
+ const suffix = input.slice(-len);
3157
+ if (prefix.startsWith(suffix)) best = suffix;
3158
+ }
3159
+ }
3160
+ return best;
3161
+ }
3162
+
3163
+ function isForwardableOsc52(seq: string): boolean {
3164
+ if (seq.length > OSC52_MAX_BYTES) return false;
3165
+ const terminatorLength = seq.endsWith("\x1b\\") ? 2 : 1;
3166
+ const body = seq.slice(2, -terminatorLength); // strip ESC] and BEL/ST
3167
+ const firstSemi = body.indexOf(";");
3168
+ const secondSemi = body.indexOf(";", firstSemi + 1);
3169
+ if (!body.startsWith("52;") || secondSemi < 0) return false;
3170
+ const payload = body.slice(secondSemi + 1).replace(/[\r\n]/g, "");
3171
+ // Do not forward clipboard-read requests (OSC 52 ; ... ; ?) to the outer terminal.
3172
+ if (payload === "?") return false;
3173
+ return /^[A-Za-z0-9+/=]*$/.test(payload);
3174
+ }
3175
+
3176
+ function sanitizeOscPayload(value: string): string {
3177
+ return value.replace(/[\x00-\x1f\x7f]/g, "");
3178
+ }
3179
+
3180
+ function center(text: string, width: number): string {
3181
+ const w = visibleWidth(text);
3182
+ if (w >= width) return clip(text, width);
3183
+ return " ".repeat(Math.floor((width - w) / 2)) + text;
3184
+ }