create-walle 0.9.26 → 0.9.28

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 (45) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/template/claude-task-manager/api-prompts.js +11 -6
  4. package/template/claude-task-manager/docs/session-status-redesign.html +554 -0
  5. package/template/claude-task-manager/docs/terminal-rendering-redesign.html +529 -0
  6. package/template/claude-task-manager/lib/flush-redraw-markers.js +72 -0
  7. package/template/claude-task-manager/lib/macos-capabilities.js +190 -0
  8. package/template/claude-task-manager/lib/session-messages-projection.js +224 -3
  9. package/template/claude-task-manager/lib/ttl-memo.js +61 -0
  10. package/template/claude-task-manager/public/index.html +892 -11
  11. package/template/claude-task-manager/public/js/activation-render-check.js +40 -2
  12. package/template/claude-task-manager/public/js/session-phase.js +370 -0
  13. package/template/claude-task-manager/public/js/setup.js +74 -1
  14. package/template/claude-task-manager/public/js/stream-view.js +56 -2
  15. package/template/claude-task-manager/server.js +643 -68
  16. package/template/claude-task-manager/workers/read-pool-worker.js +10 -0
  17. package/template/package.json +1 -1
  18. package/template/wall-e/agent.js +130 -24
  19. package/template/wall-e/api-walle.js +12 -1
  20. package/template/wall-e/brain.js +290 -4
  21. package/template/wall-e/chat.js +30 -25
  22. package/template/wall-e/coding/session-plan.js +79 -0
  23. package/template/wall-e/coding-orchestrator.js +9 -3
  24. package/template/wall-e/coding-prompts.js +10 -3
  25. package/template/wall-e/embeddings.js +192 -17
  26. package/template/wall-e/http/model-admin.js +109 -0
  27. package/template/wall-e/lib/event-loop-monitor.js +2 -2
  28. package/template/wall-e/lib/scheduler-worker-jobs.js +156 -121
  29. package/template/wall-e/lib/scheduler.js +226 -13
  30. package/template/wall-e/lib/worker-thread-pool.js +58 -4
  31. package/template/wall-e/llm/ollama-library.js +126 -0
  32. package/template/wall-e/llm/ollama.js +13 -0
  33. package/template/wall-e/llm/provider-backpressure.js +134 -0
  34. package/template/wall-e/llm/provider-health-state.js +24 -0
  35. package/template/wall-e/loops/backfill.js +43 -16
  36. package/template/wall-e/loops/initiative.js +1 -0
  37. package/template/wall-e/loops/think.js +38 -5
  38. package/template/wall-e/mcp-server.js +20 -4
  39. package/template/wall-e/skills/skill-fallback.js +34 -1
  40. package/template/wall-e/skills/skill-planner.js +60 -2
  41. package/template/wall-e/sources/jsonl-utils.js +84 -11
  42. package/template/wall-e/telemetry.js +42 -7
  43. package/template/wall-e/tools/local-tools.js +16 -0
  44. package/template/wall-e/workers/runtime-worker.js +33 -1
  45. package/template/website/index.html +5 -0
@@ -0,0 +1,529 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CTM Terminal Rendering — Current Design &amp; Redesign</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0e1116; --panel:#161b22; --panel2:#1c2430; --ink:#e6edf3; --muted:#9aa7b4;
10
+ --line:#2a3441; --accent:#58a6ff; --good:#3fb950; --warn:#d29922; --bad:#f85149;
11
+ --violet:#bc8cff; --teal:#56d4c4; --code:#0b0f14;
12
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
13
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
14
+ }
15
+ *{box-sizing:border-box}
16
+ html{scroll-behavior:smooth}
17
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6;font-size:16px}
18
+ .wrap{max-width:1080px;margin:0 auto;padding:0 24px 120px}
19
+ header.hero{padding:56px 24px 28px;border-bottom:1px solid var(--line);background:
20
+ radial-gradient(1200px 320px at 20% -10%, rgba(88,166,255,.14), transparent 60%),
21
+ radial-gradient(900px 300px at 90% -20%, rgba(188,140,255,.12), transparent 60%);}
22
+ .hero .wrap{padding-bottom:0}
23
+ h1{font-size:34px;line-height:1.15;margin:0 0 8px;letter-spacing:-.02em}
24
+ h1 .sub{display:block;font-size:16px;color:var(--muted);font-weight:500;margin-top:10px}
25
+ .tag{display:inline-block;font-family:var(--mono);font-size:12px;color:var(--accent);
26
+ border:1px solid var(--line);border-radius:999px;padding:3px 10px;margin-bottom:18px;background:#0d1320}
27
+ h2{font-size:24px;margin:56px 0 6px;letter-spacing:-.01em;padding-top:8px}
28
+ h2 .n{color:var(--accent);font-family:var(--mono);font-size:15px;margin-right:10px;opacity:.8}
29
+ h3{font-size:18px;margin:30px 0 6px;color:#cdd9e5}
30
+ p{margin:12px 0}
31
+ .lead{font-size:17px;color:#cdd9e5}
32
+ a{color:var(--accent);text-decoration:none}
33
+ a:hover{text-decoration:underline}
34
+ code{font-family:var(--mono);font-size:.86em;background:#0c1422;border:1px solid #1d2733;
35
+ border-radius:5px;padding:1px 6px;color:#cfe3ff}
36
+ .muted{color:var(--muted)}
37
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin:18px 0}
38
+ .callout{border-left:4px solid var(--accent);background:linear-gradient(90deg,rgba(88,166,255,.08),transparent);
39
+ border-radius:8px;padding:14px 18px;margin:18px 0}
40
+ .callout.bad{border-color:var(--bad);background:linear-gradient(90deg,rgba(248,81,73,.10),transparent)}
41
+ .callout.good{border-color:var(--good);background:linear-gradient(90deg,rgba(63,185,80,.10),transparent)}
42
+ .callout.warn{border-color:var(--warn);background:linear-gradient(90deg,rgba(210,153,34,.10),transparent)}
43
+ .callout b{color:#fff}
44
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
45
+ @media(max-width:780px){.grid2{grid-template-columns:1fr}}
46
+ table{width:100%;border-collapse:collapse;margin:18px 0;font-size:14px;background:var(--panel);
47
+ border:1px solid var(--line);border-radius:10px;overflow:hidden}
48
+ th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
49
+ th{background:var(--panel2);color:#cdd9e5;font-weight:600;font-size:13px;text-transform:uppercase;letter-spacing:.04em}
50
+ tr:last-child td{border-bottom:none}
51
+ td code{font-size:.82em}
52
+ .pill{display:inline-block;font-family:var(--mono);font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600}
53
+ .pill.codex{background:rgba(86,212,196,.14);color:var(--teal);border:1px solid rgba(86,212,196,.3)}
54
+ .pill.claude{background:rgba(210,153,34,.14);color:var(--warn);border:1px solid rgba(210,153,34,.3)}
55
+ .pill.both{background:rgba(188,140,255,.14);color:var(--violet);border:1px solid rgba(188,140,255,.3)}
56
+ .pill.ok{background:rgba(63,185,80,.14);color:var(--good);border:1px solid rgba(63,185,80,.3)}
57
+ .pill.fail{background:rgba(248,81,73,.14);color:var(--bad);border:1px solid rgba(248,81,73,.3)}
58
+ .ref{font-family:var(--mono);font-size:11px;color:#6f8196;white-space:nowrap}
59
+ figure{margin:22px 0;text-align:center}
60
+ figcaption{color:var(--muted);font-size:13px;margin-top:8px}
61
+ svg{max-width:100%;height:auto}
62
+ .scoreboard{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}
63
+ @media(max-width:780px){.scoreboard{grid-template-columns:repeat(2,1fr)}}
64
+ .metric{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;text-align:center}
65
+ .metric .big{font-size:34px;font-weight:700;color:var(--bad);font-family:var(--mono);line-height:1}
66
+ .metric .lbl{font-size:12px;color:var(--muted);margin-top:8px;text-transform:uppercase;letter-spacing:.04em}
67
+ ul.tight{margin:10px 0;padding-left:22px}
68
+ ul.tight li{margin:6px 0}
69
+ .opt{border:1px solid var(--line);border-radius:12px;padding:0;margin:20px 0;overflow:hidden}
70
+ .opt .head{padding:14px 20px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--line)}
71
+ .opt .head .badge{font-family:var(--mono);font-weight:700;font-size:15px;width:30px;height:30px;border-radius:8px;
72
+ display:grid;place-items:center}
73
+ .opt.A .head{background:linear-gradient(90deg,rgba(63,185,80,.12),transparent)}
74
+ .opt.A .badge{background:rgba(63,185,80,.18);color:var(--good)}
75
+ .opt.B .head{background:linear-gradient(90deg,rgba(188,140,255,.12),transparent)}
76
+ .opt.B .badge{background:rgba(188,140,255,.18);color:var(--violet)}
77
+ .opt.C .head{background:linear-gradient(90deg,rgba(88,166,255,.12),transparent)}
78
+ .opt.C .badge{background:rgba(88,166,255,.18);color:var(--accent)}
79
+ .opt .head h3{margin:0;font-size:18px}
80
+ .opt .head .rec{margin-left:auto;font-size:11px;font-weight:700;color:var(--good);
81
+ border:1px solid rgba(63,185,80,.4);border-radius:999px;padding:3px 10px;background:rgba(63,185,80,.08)}
82
+ .opt .body{padding:8px 20px 18px}
83
+ .kv{display:grid;grid-template-columns:120px 1fr;gap:6px 14px;margin:10px 0;font-size:14px}
84
+ .kv .k{color:var(--muted);font-size:13px}
85
+ .toc{position:sticky;top:0;background:rgba(14,17,22,.86);backdrop-filter:blur(8px);
86
+ border-bottom:1px solid var(--line);z-index:10;padding:10px 0;margin-bottom:0}
87
+ .toc .wrap{padding-top:0;padding-bottom:0;display:flex;gap:6px;flex-wrap:wrap;font-size:13px}
88
+ .toc a{color:var(--muted);padding:4px 9px;border-radius:6px;border:1px solid transparent}
89
+ .toc a:hover{color:var(--ink);border-color:var(--line);text-decoration:none;background:var(--panel)}
90
+ .legend{display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--muted);margin:6px 0 0}
91
+ .legend span{display:inline-flex;align-items:center;gap:6px}
92
+ .dot{width:10px;height:10px;border-radius:3px;display:inline-block}
93
+ .del{color:var(--bad)} .keep{color:var(--good)}
94
+ .strike{text-decoration:line-through;color:#6f8196}
95
+ footer{margin-top:60px;padding-top:24px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}
96
+ .src{font-size:13px}
97
+ .src li{margin:4px 0}
98
+ hr.soft{border:none;border-top:1px solid var(--line);margin:36px 0}
99
+ </style>
100
+ </head>
101
+ <body>
102
+
103
+ <header class="hero">
104
+ <div class="wrap">
105
+ <span class="tag">CTM · design review · 2026-06-17</span>
106
+ <h1>Terminal Rendering in CTM: Current Design &amp; a Systematic Redesign
107
+ <span class="sub">Why switching tabs leaves a stale frame — and why the fix is to delete the repair machinery, not add to it.</span>
108
+ </h1>
109
+ </div>
110
+ </header>
111
+
112
+ <nav class="toc">
113
+ <div class="wrap">
114
+ <a href="#tldr">TL;DR</a>
115
+ <a href="#diagnosis">1 · Diagnosis</a>
116
+ <a href="#current">2 · Current architecture</a>
117
+ <a href="#matrix">3 · Codex×Claude matrix</a>
118
+ <a href="#scoreboard">4 · Complexity scoreboard</a>
119
+ <a href="#should">5 · How it should work</a>
120
+ <a href="#options">6 · Redesign options</a>
121
+ <a href="#migration">7 · Migration</a>
122
+ </div>
123
+ </nav>
124
+
125
+ <div class="wrap">
126
+
127
+ <!-- ============ TLDR ============ -->
128
+ <section id="tldr">
129
+ <div class="callout">
130
+ <p style="margin-top:0"><b>TL;DR.</b> CTM keeps <b>two sources of truth</b> for the terminal: a <i>live raw-byte stream</i>
131
+ replayed into the browser (fast, but positioned for exactly one width) and a <i>server-side headless terminal mirror</i>
132
+ that holds the real state. Because the byte stream desyncs whenever width drifts, a tab is hidden, or a frame is huge,
133
+ a <b>second system</b> — fingerprint divergence detection + snapshot/reflow/reconcile pulls — is bolted on to
134
+ <i>detect and repair</i> the drift. Divergence becomes a steady state that must be continuously policed, so every
135
+ newly-discovered desync mode needs its own patch. Today that overlay is <b>~13 heal paths, 7+ timers, 2 status state
136
+ machines, and ~180 env knobs.</b> The reported bug (switching to an idle tab never heals the frame) is simply one
137
+ desync mode the overlay doesn't cover.</p>
138
+ <p style="margin-bottom:0"><b>The fix is structural:</b> CTM already owns the hard part — an authoritative headless
139
+ mirror per session. Make that mirror the <b>single source of truth</b> and continuously synchronize the client
140
+ <i>to</i> it (the model mosh and VS Code use), instead of replaying bytes and policing drift. That collapses the
141
+ repair zoo into one deterministic rule and makes "stranded fragment on switch" <b>structurally impossible.</b></p>
142
+ </div>
143
+ </section>
144
+
145
+ <!-- ============ 1 DIAGNOSIS ============ -->
146
+ <section id="diagnosis">
147
+ <h2><span class="n">01</span>The diagnosis: two sources of truth</h2>
148
+ <p class="lead">A terminal frame is <i>absolute-positioned</i>: the bytes <code>\x1b[5;1H…</code> mean "row 5, col 1 —
149
+ at the width the program thinks it has." Replay those bytes into a viewport of a different width, or miss a resize while
150
+ a tab is hidden, and the screen shears. That single fact is the root of every rendering bug CTM has fought.</p>
151
+
152
+ <figure>
153
+ <svg viewBox="0 0 900 300" role="img" aria-label="Two sources of truth diagram">
154
+ <defs>
155
+ <marker id="arr" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
156
+ <path d="M0,0 L8,3 L0,6 Z" fill="#6f8196"/>
157
+ </marker>
158
+ <marker id="arrb" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
159
+ <path d="M0,0 L8,3 L0,6 Z" fill="#f85149"/>
160
+ </marker>
161
+ </defs>
162
+ <!-- PTY -->
163
+ <rect x="20" y="120" width="120" height="60" rx="10" fill="#1c2430" stroke="#2a3441"/>
164
+ <text x="80" y="146" text-anchor="middle" fill="#e6edf3" font-size="14" font-weight="700">PTY</text>
165
+ <text x="80" y="164" text-anchor="middle" fill="#9aa7b4" font-size="11">agent process</text>
166
+ <!-- raw bytes split -->
167
+ <line x1="140" y1="150" x2="250" y2="80" stroke="#6f8196" marker-end="url(#arr)"/>
168
+ <line x1="140" y1="150" x2="250" y2="220" stroke="#6f8196" marker-end="url(#arr)"/>
169
+ <text x="180" y="100" fill="#56d4c4" font-size="11" transform="rotate(-30 180 100)">raw bytes</text>
170
+ <text x="180" y="210" fill="#56d4c4" font-size="11" transform="rotate(30 180 205)">raw bytes</text>
171
+ <!-- headless mirror -->
172
+ <rect x="250" y="50" width="180" height="64" rx="10" fill="#13241b" stroke="#2f5e3c"/>
173
+ <text x="340" y="76" text-anchor="middle" fill="#3fb950" font-size="13" font-weight="700">Headless mirror</text>
174
+ <text x="340" y="94" text-anchor="middle" fill="#9aa7b4" font-size="11">@xterm/headless · authoritative</text>
175
+ <!-- byte stream to client -->
176
+ <rect x="250" y="190" width="180" height="64" rx="10" fill="#1c2430" stroke="#2a3441"/>
177
+ <text x="340" y="216" text-anchor="middle" fill="#e6edf3" font-size="13" font-weight="700">WS output stream</text>
178
+ <text x="340" y="234" text-anchor="middle" fill="#9aa7b4" font-size="11">ordered bytes · 1 width</text>
179
+ <!-- client -->
180
+ <rect x="540" y="190" width="170" height="64" rx="10" fill="#1c2430" stroke="#2a3441"/>
181
+ <text x="625" y="216" text-anchor="middle" fill="#e6edf3" font-size="13" font-weight="700">Browser xterm</text>
182
+ <text x="625" y="234" text-anchor="middle" fill="#9aa7b4" font-size="11">what the user sees</text>
183
+ <line x1="430" y1="222" x2="540" y2="222" stroke="#6f8196" marker-end="url(#arr)"/>
184
+ <!-- divergence -->
185
+ <line x1="625" y1="190" x2="430" y2="114" stroke="#f85149" stroke-dasharray="5 4" marker-end="url(#arrb)"/>
186
+ <text x="470" y="150" fill="#f85149" font-size="11">fingerprint compare →</text>
187
+ <text x="470" y="166" fill="#f85149" font-size="11">divergence? pull snapshot</text>
188
+ <!-- repair overlay box -->
189
+ <rect x="540" y="50" width="340" height="64" rx="10" fill="#2a160f" stroke="#5e2f1f"/>
190
+ <text x="710" y="74" text-anchor="middle" fill="#f0883e" font-size="13" font-weight="700">Repair overlay (the patches)</text>
191
+ <text x="710" y="92" text-anchor="middle" fill="#9aa7b4" font-size="11">~13 heals · 7+ timers · 2 status machines</text>
192
+ <line x1="710" y1="114" x2="640" y2="190" stroke="#5e2f1f" stroke-dasharray="3 3"/>
193
+ </svg>
194
+ <figcaption>Two sources of truth. The byte stream (bottom) is fast but width-fragile; the headless mirror (top) is
195
+ correct but only consulted reactively, through a growing repair overlay.</figcaption>
196
+ </figure>
197
+
198
+ <p>The server feeds <b>both</b> a headless <code>@xterm/headless</code> terminal (the authoritative full state,
199
+ serializable to any width) and a raw byte stream to the browser. The browser's xterm is driven by the byte stream for
200
+ latency; the headless mirror is only consulted <i>reactively</i> when a heuristic decides the browser has drifted.</p>
201
+
202
+ <div class="callout bad">
203
+ <p style="margin:0"><b>The reported bug, precisely.</b> When a session goes quiet, the server emits a
204
+ <code>heartbeat</code> fingerprint <b>twice, then stops</b> <span class="ref">server.js:1334–1377</span>. Every client
205
+ heal path compares the browser grid against that last-received fingerprint <code>s._serverFp</code>. Switch to an
206
+ <i>idle</i> tab whose quiet episode ended long ago and whose width changed while hidden: <code>s._serverFp</code> is
207
+ stale (or measured at the old width), and <b>no new heartbeat will ever arrive to refresh it</b>. The activation check
208
+ runs <span class="ref">index.html:34065</span> but every validator re-compares against the same stale truth → nothing
209
+ heals. The stranded <code>ctm-main-loop-hardening</code> fragment + detached <code>&gt;</code> composer in the
210
+ screenshot is exactly this.</p>
211
+ </div>
212
+ </section>
213
+
214
+ <!-- ============ 2 CURRENT ARCH ============ -->
215
+ <section id="current">
216
+ <h2><span class="n">02</span>Current architecture, end to end</h2>
217
+
218
+ <h3>The live (running) path — raw byte streaming</h3>
219
+ <figure>
220
+ <svg viewBox="0 0 900 210" role="img" aria-label="Live streaming pipeline">
221
+ <defs><marker id="a2" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
222
+ <path d="M0,0 L8,3 L0,6 Z" fill="#58a6ff"/></marker></defs>
223
+ <g font-size="11" text-anchor="middle">
224
+ <rect x="10" y="70" width="120" height="62" rx="9" fill="#1c2430" stroke="#2a3441"/>
225
+ <text x="70" y="94" fill="#e6edf3" font-weight="700">PTY onData</text>
226
+ <text x="70" y="110" fill="#9aa7b4">queue + drain</text>
227
+ <text x="70" y="124" fill="#56607a">24640 · 8ms budget</text>
228
+
229
+ <rect x="160" y="70" width="140" height="62" rx="9" fill="#1c2430" stroke="#2a3441"/>
230
+ <text x="230" y="90" fill="#e6edf3" font-weight="700">processPtyData</text>
231
+ <text x="230" y="106" fill="#9aa7b4">activity classifiers</text>
232
+ <text x="230" y="120" fill="#56607a">24451</text>
233
+
234
+ <rect x="330" y="58" width="170" height="86" rx="9" fill="#231a10" stroke="#5e4a1f"/>
235
+ <text x="415" y="78" fill="#f0c674" font-weight="700">flushOutput()</text>
236
+ <text x="415" y="94" fill="#9aa7b4">TUI redraw classifiers,</text>
237
+ <text x="415" y="108" fill="#9aa7b4">sync-frame holds, budget</text>
238
+ <text x="415" y="122" fill="#56607a">24222 · the hot path</text>
239
+
240
+ <rect x="530" y="70" width="140" height="62" rx="9" fill="#1c2430" stroke="#2a3441"/>
241
+ <text x="600" y="90" fill="#e6edf3" font-weight="700">WS 'output'</text>
242
+ <text x="600" y="106" fill="#9aa7b4">data + seq marker</text>
243
+ <text x="600" y="120" fill="#56607a">23281</text>
244
+
245
+ <rect x="700" y="58" width="180" height="86" rx="9" fill="#1c2430" stroke="#2a3441"/>
246
+ <text x="790" y="78" fill="#e6edf3" font-weight="700">client writer</text>
247
+ <text x="790" y="94" fill="#9aa7b4">onOutput → RAF queue</text>
248
+ <text x="790" y="108" fill="#9aa7b4">→ chunkedWrite → xterm</text>
249
+ <text x="790" y="122" fill="#56607a">33448 · 33162</text>
250
+ </g>
251
+ <line x1="130" y1="101" x2="160" y2="101" stroke="#58a6ff" marker-end="url(#a2)"/>
252
+ <line x1="300" y1="101" x2="330" y2="101" stroke="#58a6ff" marker-end="url(#a2)"/>
253
+ <line x1="500" y1="101" x2="530" y2="101" stroke="#58a6ff" marker-end="url(#a2)"/>
254
+ <line x1="670" y1="101" x2="700" y2="101" stroke="#58a6ff" marker-end="url(#a2)"/>
255
+ </svg>
256
+ <figcaption>The running pipeline. Each box adds latency-sensitive heuristics; <code>flushOutput()</code> alone runs the
257
+ Codex/Claude TUI-redraw classifiers, DEC-2026 synchronized-frame holds, a 64&nbsp;KB output budget, a 256&nbsp;KB bulk
258
+ fast-path, and flow-control watermarks.</figcaption>
259
+ </figure>
260
+
261
+ <div class="grid2">
262
+ <div class="card">
263
+ <h3 style="margin-top:0">Kept-worthy core (genuinely good)</h3>
264
+ <ul class="tight">
265
+ <li><b>Headless authoritative mirror</b> per session, serializable to any width <span class="ref">headless-term-service.js:54</span>.</li>
266
+ <li><b>Flush-delay classes</b> (1 / 8 / 32 ms) for snappy echo vs. coalesced frames <span class="ref">terminal-output-flush.js:47</span>.</li>
267
+ <li><b>Flow control</b> with PTY pause/resume at 128 KB/16 KB watermarks + 15 s watchdog <span class="ref">flow-control-policy.js:37</span>.</li>
268
+ <li><b>DEC-2026 synchronized-frame coalescing</b> (drop superseded full-clears) <span class="ref">coalesce-sync-frames.js</span>.</li>
269
+ <li><b>Primary/secondary viewer</b> width arbitration; secondaries get reflowed snapshots <span class="ref">server.js:26783</span>.</li>
270
+ </ul>
271
+ </div>
272
+ <div class="card">
273
+ <h3 style="margin-top:0">The reactive overlay (the problem)</h3>
274
+ <ul class="tight">
275
+ <li><b>Fingerprint divergence</b> — FNV-1a hash of the viewport, compared client vs. server <span class="ref">terminal-fingerprint.js:30</span>.</li>
276
+ <li><b>Pull repairs</b> — <code>snapshot</code> / <code>reflow</code> (SIGWINCH) / <code>reconcile</code> (re-serialize) <span class="ref">server.js:27315–27521</span>.</li>
277
+ <li><b>Heartbeat</b> — server emits fp twice per quiet episode, then silent <span class="ref">server.js:1334</span>.</li>
278
+ <li><b>Confirm gates</b> — divergence must persist 1 s; reflow cooldown 12 s → ×2ⁿ capped 5 min.</li>
279
+ <li><b>Heuristic garble detectors</b> — Claude stranded-right-fragment scans, stable-diverge while typing, flash-loop counters.</li>
280
+ </ul>
281
+ </div>
282
+ </div>
283
+ </section>
284
+
285
+ <!-- ============ 3 MATRIX ============ -->
286
+ <section id="matrix">
287
+ <h2><span class="n">03</span>The Codex × Claude × running/stopped matrix</h2>
288
+ <p>What actually drives the frame in each state, and where it breaks. <span class="legend">
289
+ <span><span class="dot" style="background:#56d4c4"></span>Codex (ratatui, cursor-up repaints)</span>
290
+ <span><span class="dot" style="background:#d29922"></span>Claude (Ink, cursor-forward + truecolor)</span></span></p>
291
+
292
+ <table>
293
+ <thead><tr><th>State</th><th>What renders it</th><th>Failure mode</th><th>Current "fix"</th></tr></thead>
294
+ <tbody>
295
+ <tr>
296
+ <td><span class="pill codex">Codex</span> <span class="pill ok">running</span></td>
297
+ <td>Live byte stream; full-screen <code>\x1b[?2026h</code> synchronized repaints coalesced; "Working" busy-line keeps status <code>running</code> (15 s hold).</td>
298
+ <td>O(n²) SGR composer classifier &amp; sync-frame holds can stall the single event loop while typing; arrow keys vs. auto-approver race.</td>
299
+ <td><span class="pill both">patch</span> single-pass SGR boundaries + 32 KB ceiling + approver back-off <span class="ref">17efbb34</span></td>
300
+ </tr>
301
+ <tr>
302
+ <td><span class="pill codex">Codex</span> <span class="pill fail">stopped/idle</span></td>
303
+ <td>Last byte frame + heartbeat fp. Idle-frame divergence → <code>codexReconcileAction</code> drives a reflow with exponential backoff.</td>
304
+ <td>Giant session (12.5 k lines, ~800 KB/serialize) → serialize exceeds 3 s → server bridges a <i>dirty</i> cached frame → frame oscillates good↔bad.</td>
305
+ <td><span class="pill both">patch</span> refuse-to-bridge-stale-over-reflow + client backoff <span class="ref">ae4ac429</span></td>
306
+ </tr>
307
+ <tr>
308
+ <td><span class="pill claude">Claude</span> <span class="pill ok">running</span></td>
309
+ <td>Live byte stream; Ink redraws (<code>\x1b[?2026</code> or cursor-forward + truecolor); "Crunching"/"Working" busy-line.</td>
310
+ <td>Composer "wired state" garble mid-typing; width-reflow strands the status line into scrollback → false-idle.</td>
311
+ <td><span class="pill both">patch</span> stable-divergence draft-preserving SIGWINCH heal <span class="ref">ctm-composer-stable-diverge</span></td>
312
+ </tr>
313
+ <tr>
314
+ <td><span class="pill claude">Claude</span> <span class="pill fail">stopped/idle</span></td>
315
+ <td>Heartbeat fp; three garble detectors (viewport / scrollback / stable-diverge), each needing 2 sightings ≥1 s apart.</td>
316
+ <td><b>Switch to an idle tab at a stale width → no heartbeat → no 2nd sighting → never heals.</b> (The reported bug.)</td>
317
+ <td><span class="pill fail">none</span> — uncovered desync mode</td>
318
+ </tr>
319
+ <tr>
320
+ <td><span class="pill both">Both</span> <span class="pill ok">on tab switch</span></td>
321
+ <td><code>activateTab</code> → fit → <code>attach</code> (cached snapshot fast-path, else serialize at client dims) + 200 ms activation render check.</td>
322
+ <td>Idle tab: the one activation check compares vs. stale fp and bails; dims-reassert "so the next heartbeat is comparable" — but there is no next heartbeat.</td>
323
+ <td><span class="pill fail">gap</span> — activation grace exists but the signal to act on never arrives</td>
324
+ </tr>
325
+ </tbody>
326
+ </table>
327
+
328
+ <div class="callout warn"><p style="margin:0">Notice the pattern: <b>every cell's "fix" is a different mechanism</b> with
329
+ its own timers and provider conditionals. Four states × two providers have produced a fix surface that no longer fits in
330
+ one person's head — which is why the fifth gap (idle tab switch) went uncovered.</p></div>
331
+ </section>
332
+
333
+ <!-- ============ 4 SCOREBOARD ============ -->
334
+ <section id="scoreboard">
335
+ <h2><span class="n">04</span>Complexity scoreboard — the "patch-on-patch" evidence</h2>
336
+ <div class="scoreboard">
337
+ <div class="metric"><div class="big">13</div><div class="lbl">distinct render-heal paths</div></div>
338
+ <div class="metric"><div class="big">7+</div><div class="lbl">independent timers / cooldowns</div></div>
339
+ <div class="metric"><div class="big">2</div><div class="lbl">status state machines (server + client)</div></div>
340
+ <div class="metric"><div class="big">~180</div><div class="lbl"><code>CTM_*</code> env knobs</div></div>
341
+ </div>
342
+
343
+ <h3>The 13 heal paths</h3>
344
+ <table>
345
+ <thead><tr><th>Heal</th><th>Trigger</th><th>Action</th><th>Scope</th><th>Window</th></tr></thead>
346
+ <tbody>
347
+ <tr><td>Heartbeat reconcile</td><td>output quiet 1 s</td><td>fp compare → maybe reconcile</td><td><span class="pill both">all</span></td><td>2 emits/episode</td></tr>
348
+ <tr><td>Settle-recheck</td><td>divergence armed</td><td>2nd sighting → confirm</td><td><span class="pill both">all</span></td><td>1.15 s</td></tr>
349
+ <tr><td>Activation render check</td><td>tab switch</td><td>run validators @200 ms</td><td><span class="pill both">all</span></td><td>200 ms + 1.5 s grace</td></tr>
350
+ <tr><td>Dims-reassert</td><td>fp dims ≠ grid</td><td>force resize</td><td><span class="pill both">all</span></td><td>2 s cooldown</td></tr>
351
+ <tr><td>Claude viewport garble</td><td>stranded right fragment</td><td>reflow (SIGWINCH)</td><td><span class="pill claude">claude</span></td><td>2 sightings, 12 s</td></tr>
352
+ <tr><td>Claude scrollback garble</td><td>fragment in scrollback</td><td>reconcile</td><td><span class="pill claude">claude</span></td><td>400-row scan, 12 s</td></tr>
353
+ <tr><td>Claude stable-diverge</td><td>stable fp mismatch while typing</td><td>draft-preserving reflow</td><td><span class="pill claude">claude</span></td><td>1.8 s</td></tr>
354
+ <tr><td>Codex idle-frame reflow</td><td>divergence on Codex</td><td>reflow + exp backoff</td><td><span class="pill codex">codex</span></td><td>12 s → ×2ⁿ ≤5 min</td></tr>
355
+ <tr><td>Flash-loop heal</td><td>3 reconciles/15 s</td><td>give-up memo or redraw</td><td><span class="pill both">split</span></td><td>30 s backoff</td></tr>
356
+ <tr><td>Giant-Codex oscillation guard</td><td>secondary reflow mismatch</td><td>restore headless to PTY dims</td><td><span class="pill codex">codex</span></td><td>per-serialize</td></tr>
357
+ <tr><td>Serialize-timeout bridge</td><td>serialize &gt; 3 s</td><td>serve cached frame (clean only for reflow)</td><td><span class="pill both">all</span></td><td>3 s</td></tr>
358
+ <tr><td>Lifecycle-adopt</td><td>pure lifecycle bump</td><td>adopt server seq</td><td><span class="pill both">all</span></td><td>per-heartbeat</td></tr>
359
+ <tr><td>3 s self-check loop</td><td>idle active tab</td><td>fp compare</td><td><span class="pill both">all</span></td><td>3 s</td></tr>
360
+ </tbody>
361
+ </table>
362
+ <p class="muted">Each row is individually defensible. Collectively they form a reactive controller with no single owner of
363
+ "is the frame correct?", overlapping cooldowns that interact in non-obvious ways (e.g. activation grace exists to punch
364
+ through the Codex 12 s reflow cooldown), and provider conditionals threaded through the hot path.</p>
365
+ </section>
366
+
367
+ <!-- ============ 5 SHOULD ============ -->
368
+ <section id="should">
369
+ <h2><span class="n">05</span>How it should work — the established designs</h2>
370
+
371
+ <div class="grid2">
372
+ <div class="card">
373
+ <h3 style="margin-top:0">mosh — State Synchronization Protocol</h3>
374
+ <p style="margin-bottom:8px">mosh treats the remote terminal "more like a videoconference": the server runs the
375
+ authoritative emulator, and the protocol's <b>only</b> job is to <i>"fast-forward the client to the most recent
376
+ screen as efficiently as possible."</i> It synchronizes <b>screen-state objects (diffs)</b>, not byte streams, at a
377
+ controlled frame rate, and adds <b>predictive local echo</b> (≈70% of keystrokes shown in &lt;5 ms).</p>
378
+ <p style="margin-bottom:0" class="muted">Consequence: divergence <b>cannot persist</b> — every sync drives the
379
+ client toward truth. There is no "detect drift, then repair." There is only "render the current state."</p>
380
+ </div>
381
+ <div class="card">
382
+ <h3 style="margin-top:0">VS Code / @xterm/headless</h3>
383
+ <p style="margin-bottom:8px">VS Code runs a headless xterm server-side; <code>SerializeAddon.serialize()</code>
384
+ produces the full-state string that <b>is</b> the reconnect/revive path — applied <b>unconditionally</b> on attach
385
+ and reload, not as a divergence escape hatch.</p>
386
+ <p style="margin-bottom:0" class="muted">tmux and xterm.js agree: becoming visible / attaching triggers an
387
+ <b>unconditional</b> authoritative redraw (<code>server_redraw_client</code>; <code>clearTextureAtlas()</code> /
388
+ pause→resume → immediate catch-up). Never heuristic garble-sniffing.</p>
389
+ </div>
390
+ </div>
391
+
392
+ <div class="callout good">
393
+ <p style="margin:0"><b>The principle CTM is missing:</b> the client should be <b>continuously synchronized to the
394
+ server's authoritative screen state</b>, with "becoming visible / resizing / going quiet" as <i>unconditional</i> sync
395
+ triggers — not signals to <i>guess</i> whether a repair is needed. CTM already has the authoritative object (the
396
+ headless mirror). It just consults it reactively instead of treating it as the source of truth.</p>
397
+ </div>
398
+ </section>
399
+
400
+ <!-- ============ 6 OPTIONS ============ -->
401
+ <section id="options">
402
+ <h2><span class="n">06</span>Redesign options</h2>
403
+
404
+ <div class="opt A">
405
+ <div class="head"><span class="badge">A</span><h3>"Keyframe on every boundary" — collapse the overlay into one rule</h3>
406
+ <span class="rec">RECOMMENDED · do now</span></div>
407
+ <div class="body">
408
+ <p>Keep the live byte stream for responsiveness (the <b>P-frames</b>), but <b>delete the entire detect-then-repair
409
+ zoo</b> and replace it with one deterministic rule: on every <b>activation / resize / output-quiet</b> boundary, the
410
+ server serializes the authoritative headless state <b>at the client's current dims</b> and the client renders it — an
411
+ unconditional <b>I-frame</b>. The keyframe is always truth at the right width, so a stale/stranded frame on switch is
412
+ structurally impossible.</p>
413
+ <figure>
414
+ <svg viewBox="0 0 860 96" role="img" aria-label="P-frames and I-frames timeline">
415
+ <g font-size="11" text-anchor="middle">
416
+ <line x1="20" y1="60" x2="840" y2="60" stroke="#2a3441"/>
417
+ <!-- P frames -->
418
+ <g fill="#58a6ff">
419
+ <rect x="60" y="46" width="8" height="28" rx="2"/><rect x="90" y="46" width="8" height="28" rx="2"/>
420
+ <rect x="120" y="46" width="8" height="28" rx="2"/><rect x="150" y="46" width="8" height="28" rx="2"/>
421
+ <rect x="320" y="46" width="8" height="28" rx="2"/><rect x="350" y="46" width="8" height="28" rx="2"/>
422
+ <rect x="560" y="46" width="8" height="28" rx="2"/><rect x="590" y="46" width="8" height="28" rx="2"/>
423
+ <rect x="620" y="46" width="8" height="28" rx="2"/>
424
+ </g>
425
+ <!-- I frames -->
426
+ <g fill="#3fb950">
427
+ <rect x="200" y="34" width="14" height="40" rx="3"/>
428
+ <rect x="430" y="34" width="14" height="40" rx="3"/>
429
+ <rect x="700" y="34" width="14" height="40" rx="3"/>
430
+ </g>
431
+ <text x="207" y="26" fill="#3fb950">switch</text>
432
+ <text x="437" y="26" fill="#3fb950">resize</text>
433
+ <text x="707" y="26" fill="#3fb950">quiet</text>
434
+ <text x="110" y="92" fill="#58a6ff">live bytes (P)</text>
435
+ <text x="430" y="92" fill="#3fb950">authoritative keyframe (I)</text>
436
+ </g>
437
+ </svg>
438
+ <figcaption>Like video: cheap byte deltas between deterministic keyframes at every boundary.</figcaption>
439
+ </figure>
440
+ <div class="kv">
441
+ <div class="k">Deletes</div><div><span class="del">fingerprint divergence, settle-recheck, dims-reassert, Claude stale-width (×3 detectors), Codex idle-frame backoff, flash-loop heal, stable-diverge, 3 s self-check, heartbeat-as-repair-signal.</span></div>
442
+ <div class="k">Keeps</div><div><span class="keep">byte stream + flush classes, flow control, DEC-2026 coalescing, headless serialize, primary/secondary arbitration.</span></div>
443
+ <div class="k">Risk</div><div><b>Low–medium.</b> Keyframe cost on giant sessions (~800 KB serialize) is bounded by the existing 3 s timeout + cache; debounce quiet-keyframes; reuse the oscillation guard's "don't bridge stale over a keyframe" rule.</div>
444
+ <div class="k">Effort</div><div>Server: one keyframe emitter at dims. Client: render keyframe on boundary, delete ~13 heal paths + their timers. Net <b>large deletion</b>.</div>
445
+ </div>
446
+ <p class="muted" style="margin-bottom:0">Note: my earlier "fp-probe on activation" idea was ~1/13th of this — it
447
+ <i>added</i> a 14th mechanism. Option A <i>removes</i> the other 13 instead.</p>
448
+ </div>
449
+ </div>
450
+
451
+ <div class="opt B">
452
+ <div class="head"><span class="badge">B</span><h3>Full mosh-style state-sync — the north star</h3></div>
453
+ <div class="body">
454
+ <p>Go all the way: the client never treats raw bytes as authoritative. The server computes <b>screen-cell diffs</b>
455
+ against the client's last-acked frame and ships those at a controlled frame rate, plus predictive local echo for
456
+ keystrokes. Absolute-position fragility disappears entirely — a "stranded right fragment" is unrepresentable because
457
+ the client only ever holds whole, server-computed frames.</p>
458
+ <div class="kv">
459
+ <div class="k">Wins</div><div>Eliminates the entire desync class; natural roaming/reconnect; frame-rate control already solves giant-session flooding.</div>
460
+ <div class="k">Cost</div><div><b>High.</b> New diff protocol + applier; reworks the live path, not just the repair path; predictive echo is subtle. A multi-month effort.</div>
461
+ <div class="k">Verdict</div><div>Document as the target. Option A is a strict subset of this thinking and a stepping stone — its keyframes become B's periodic full-syncs.</div>
462
+ </div>
463
+ </div>
464
+ </div>
465
+
466
+ <div class="opt C">
467
+ <div class="head"><span class="badge">C</span><h3>Unify status — one server-authoritative state machine</h3></div>
468
+ <div class="body">
469
+ <p>Orthogonal but compounding: collapse the <b>two</b> status machines (server <code>_standupLiveStatusWithReason</code>
470
+ + client <code>getSessionStatus</code>, ~10 freshness TTLs) into <b>one</b> server-authoritative status pushed to the
471
+ client. Remove client-side spinner / busy-line re-derivation (the "Working"/"Crunching" scanning, Codex running-holds,
472
+ garbled-busy-line regexes).</p>
473
+ <div class="kv">
474
+ <div class="k">Why now</div><div>Status currently <i>gates</i> rendering (running ⇒ skip heal). Once A makes keyframes deterministic, the client no longer needs to <i>infer</i> running-ness to decide whether to repair — it just renders keyframes. Status becomes pure display.</div>
475
+ <div class="k">Risk</div><div>Low; mostly deletion. Server already computes the authoritative status; the client half is redundant inference.</div>
476
+ </div>
477
+ </div>
478
+ </div>
479
+
480
+ <div class="callout"><p style="margin:0"><b>Recommendation:</b> <b>A + C now.</b> Together they capture ~90% of the
481
+ benefit primarily by <i>deletion</i> — one keyframe rule, one status owner — and directly close the reported bug as a
482
+ structural impossibility rather than a 14th patch. Keep <b>B</b> on the roadmap as the eventual target if A's keyframe
483
+ cost on the largest sessions becomes the limiting factor.</p></div>
484
+ </section>
485
+
486
+ <!-- ============ 7 MIGRATION ============ -->
487
+ <section id="migration">
488
+ <h2><span class="n">07</span>Migration sketch (A + C)</h2>
489
+ <table>
490
+ <thead><tr><th>Phase</th><th>Change</th><th>Reversible?</th></tr></thead>
491
+ <tbody>
492
+ <tr><td><b>1 · Keyframe emitter</b></td><td>Server: a single "serialize headless at client dims → send frame" used by attach, resize, and a debounced output-quiet boundary. (Generalizes today's <code>reconcile</code>/secondary-push.)</td><td>Behind <code>CTM_KEYFRAME_SYNC</code>, default off → on.</td></tr>
493
+ <tr><td><b>2 · Client renders boundaries</b></td><td>Client requests/applies a keyframe unconditionally on activation + resize; keeps applying P-frame bytes between keyframes.</td><td>Flag-gated; old path still present.</td></tr>
494
+ <tr><td><b>3 · Delete the overlay</b></td><td>Remove the 13 heal paths + their timers/constants once keyframes prove out in <code>/ctm-dev</code> + render tests. <span class="strike">fingerprint divergence, stale-width ×3, flash-loop, dims-reassert, …</span></td><td>Staged; each deletion behind green render tests.</td></tr>
495
+ <tr><td><b>4 · Unify status (C)</b></td><td>Push authoritative status from server; delete client busy-line inference + the second state machine.</td><td>Independent of 1–3.</td></tr>
496
+ <tr><td><b>5 · (optional) B</b></td><td>Promote keyframes to periodic cell-diffs + predictive echo when/if giant-session keyframe cost demands it.</td><td>Long-horizon.</td></tr>
497
+ </tbody>
498
+ </table>
499
+
500
+ <div class="callout good"><p style="margin:0"><b>Success criteria.</b> The Codex×Claude×running/stopped matrix has one
501
+ rendering rule per axis, not one per cell. The reported bug is closed by construction (switching renders a keyframe at
502
+ current dims). The heal-path count drops from 13 → ~1, timers from 7+ → ~1 (keyframe debounce), status machines from
503
+ 2 → 1. Verified on an isolated <code>/ctm-dev</code> instance with the existing Playwright render suite — never the
504
+ primary.</p></div>
505
+ </section>
506
+
507
+ <hr class="soft">
508
+ <section>
509
+ <h3>Sources</h3>
510
+ <ul class="src">
511
+ <li><a href="https://mosh.org/mosh-paper.pdf">Mosh: An Interactive Remote Shell for Mobile Clients</a> — the State Synchronization Protocol (screen-state objects, frame-rate control, predictive echo).</li>
512
+ <li><a href="https://mosh.org/">mosh.org</a> — "synchronizes only the visible state of the terminal."</li>
513
+ <li><a href="https://code.visualstudio.com/docs/terminal/advanced">VS Code — Terminal Advanced</a> — process reconnection &amp; revive via serialized headless state.</li>
514
+ <li><a href="https://github.com/xtermjs/xterm.js/issues/880">xterm.js #880</a> — pause/resume renderer &amp; force-redraw-on-resume for hidden terminals.</li>
515
+ <li><a href="https://man7.org/linux/man-pages/man1/tmux.1.html">tmux(1)</a> — server-driven redraw on attach/resize.</li>
516
+ </ul>
517
+ <p class="muted src">Every <code>path:line</code> anchor (e.g. <code>server.js:24222</code>, <code>index.html:33448</code>,
518
+ <code>headless-term-service.js:54</code>, <code>terminal-reconciler.js:169</code>) is traceable in the current
519
+ <code>ux</code> worktree and was gathered from four read-only code sweeps of the live tree on 2026-06-17.</p>
520
+ </section>
521
+
522
+ <footer>
523
+ CTM terminal-rendering design review · prepared 2026-06-17 · read-only analysis, no app code changed in this round.
524
+ Implementation of Options&nbsp;A/B/C is deferred until a direction is chosen.
525
+ </footer>
526
+
527
+ </div>
528
+ </body>
529
+ </html>
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Pure marker predicates for the PTY flush "is this an interactive TUI redraw?"
4
+ // classifiers (server.js _isRecentCodexNativeSkillRedraw / _isCodexSynchronizedTuiRedraw /
5
+ // _isClaudeTuiRedraw). Extracted out of the per-session closures so they can be unit-tested
6
+ // and so the expensive stripAnsi + alternation-regex work runs over the VISIBLE TAIL of the
7
+ // batch instead of the whole coalesced batch.
8
+ //
9
+ // WHY THE TAIL: during heavy Codex/Claude repaint bursts a single flush batch can hold many
10
+ // stacked full-screen frames (observed 132KB, [freeze-probe] flush:classify-detect blocked
11
+ // 342ms). The classifiers only need to know whether the VISIBLE frame is an interactive redraw,
12
+ // and the visible state is always at the END of the batch (later frames paint over earlier
13
+ // ones). So we strip + regex-test only the last `tailBytes` of the batch. A marker that appears
14
+ // ONLY in an earlier, superseded frame is by definition not visible, so bounding to the tail is
15
+ // behavior-preserving for the on-screen result (and strictly cheaper). Set tailBytes<=0 to scan
16
+ // the whole batch (the pre-extraction behavior).
17
+ //
18
+ // The regexes below are copied VERBATIM from the server.js closures so detection is byte-identical
19
+ // on any batch whose markers fall within the tail window.
20
+
21
+ // Default visible-tail window. A single rendered screen (even ~100 rows x ~300 cols with SGR)
22
+ // strips to well under this; 64KB of raw tail is a comfortable margin. Tunable via
23
+ // CTM_REDRAW_CLASSIFY_TAIL_BYTES (0 = scan whole batch).
24
+ const DEFAULT_TAIL_BYTES = (() => {
25
+ const v = Number(process.env.CTM_REDRAW_CLASSIFY_TAIL_BYTES);
26
+ return Number.isFinite(v) && v >= 0 ? v : 65536;
27
+ })();
28
+
29
+ // Strip ANSI from only the visible tail of `batch` using the caller's (memoized) stripAnsi.
30
+ // Passing the SAME stripAnsi keeps server.js's single-slot memo authoritative; equal-content
31
+ // slices are === in JS so repeated calls within one flush hit the memo.
32
+ function markerTailText(batch, stripAnsiFn, tailBytes = DEFAULT_TAIL_BYTES) {
33
+ const raw = String(batch == null ? '' : batch);
34
+ const tail = tailBytes > 0 && raw.length > tailBytes ? raw.slice(-tailBytes) : raw;
35
+ return stripAnsiFn(tail);
36
+ }
37
+
38
+ // --- Codex native $-skill picker / composer redraw (was _isRecentCodexNativeSkillRedraw tail) ---
39
+ const _CODEX_DOLLAR_PROMPT_RE = /(?:^|\n)\s*[›>]\s*[^\n]*\$/m;
40
+ const _CODEX_DOLLAR_INLINE_RE = /[›>]\s*[^\n\r]{0,240}\$[A-Za-z0-9_.:-]*/;
41
+ const _CODEX_PICKER_RE = /\[Skill\]|Press enter to insert|esc to close|no matches/i;
42
+ const _CODEX_MODEL_CONT_RE = /\b(?:gpt-[\w.-]+|o\d|claude|sonnet|opus|haiku|deepseek|gemini|qwen|llama|mistral|kimi|moonshot)\b[^\n]*(?:\b(?:x?high|medium|low|fast)\b|[~/]|[-·])/i;
43
+
44
+ function hasCodexSkillRedrawMarkers(text) {
45
+ const hasPromptWithDollarTrigger =
46
+ _CODEX_DOLLAR_PROMPT_RE.test(text) || _CODEX_DOLLAR_INLINE_RE.test(text);
47
+ if (!hasPromptWithDollarTrigger) return false;
48
+ if (_CODEX_PICKER_RE.test(text)) return true;
49
+ return _CODEX_MODEL_CONT_RE.test(text);
50
+ }
51
+
52
+ // --- Codex synchronized full-screen TUI redraw (was _isCodexSynchronizedTuiRedraw tail) ---
53
+ const _CODEX_SYNC_TUI_RE = /Working|esc to interrupt|Conversation interrupted|Explored|Ran|Read|Search|Use \/skills|\bgpt-[\w.-]+|\b(?:x?high|medium|low)\b.*(?:fast|[~/])/i;
54
+
55
+ function hasCodexSyncTuiMarkers(text) {
56
+ return _CODEX_SYNC_TUI_RE.test(text);
57
+ }
58
+
59
+ // --- Claude Code Ink TUI redraw (was _isClaudeTuiRedraw tail) ---
60
+ const _CLAUDE_TUI_RE = /Crunching|esc to interrupt|Esc to cancel|Do you want to|Bash(?:\(| command)?|Read\(|Edit\(|Write\(|Grep\(|Glob\(|TodoWrite|tokens|thought for|ctrl\+o to expand|⎿|Claude Code|Working|Cogitating|Forming|plan mode|accept edits|shift\+tab|for agents|running stop hooks|[❯⏵]/i;
61
+
62
+ function hasClaudeTuiMarkers(text) {
63
+ return _CLAUDE_TUI_RE.test(text);
64
+ }
65
+
66
+ module.exports = {
67
+ DEFAULT_TAIL_BYTES,
68
+ markerTailText,
69
+ hasCodexSkillRedrawMarkers,
70
+ hasCodexSyncTuiMarkers,
71
+ hasClaudeTuiMarkers,
72
+ };