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
@@ -802,6 +802,115 @@
802
802
  color: var(--fg);
803
803
  background: color-mix(in srgb, var(--danger) 18%, var(--bg-light));
804
804
  }
805
+ .setup-phone-url-card {
806
+ display: flex;
807
+ align-items: center;
808
+ justify-content: space-between;
809
+ gap: 12px;
810
+ margin: 0 0 14px;
811
+ padding: 12px 12px 12px 14px;
812
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));
813
+ border-radius: 12px;
814
+ background:
815
+ linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, transparent), transparent 58%),
816
+ color-mix(in srgb, var(--bg-light) 88%, #000 12%);
817
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
818
+ }
819
+ .setup-phone-url-copy {
820
+ display: flex;
821
+ align-items: center;
822
+ flex-wrap: wrap;
823
+ gap: 8px;
824
+ min-width: 0;
825
+ color: var(--fg-dim);
826
+ font-size: 13px;
827
+ line-height: 1.4;
828
+ }
829
+ .setup-phone-url-label {
830
+ color: color-mix(in srgb, var(--fg) 70%, var(--fg-dim));
831
+ font-weight: 800;
832
+ }
833
+ .setup-phone-url-value {
834
+ max-width: min(100%, 720px);
835
+ padding: 4px 8px;
836
+ border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border));
837
+ border-radius: 8px;
838
+ background: color-mix(in srgb, var(--bg) 86%, #000 14%);
839
+ color: var(--fg);
840
+ font-size: 12px;
841
+ line-height: 1.35;
842
+ word-break: break-all;
843
+ }
844
+ .setup-copy-chip {
845
+ flex: 0 0 auto;
846
+ display: inline-flex;
847
+ align-items: center;
848
+ justify-content: center;
849
+ gap: 7px;
850
+ min-height: 32px;
851
+ padding: 7px 12px;
852
+ border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
853
+ border-radius: 999px;
854
+ background:
855
+ linear-gradient(180deg, color-mix(in srgb, var(--accent) 14%, transparent), transparent),
856
+ color-mix(in srgb, var(--bg-light) 88%, #000 12%);
857
+ color: color-mix(in srgb, var(--accent) 72%, var(--fg));
858
+ cursor: pointer;
859
+ font-size: 12px;
860
+ font-weight: 850;
861
+ letter-spacing: 0.01em;
862
+ transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease, opacity 140ms ease, transform 140ms ease;
863
+ }
864
+ .setup-copy-chip:not(:disabled):hover {
865
+ border-color: color-mix(in srgb, var(--accent) 62%, var(--border));
866
+ background: color-mix(in srgb, var(--accent) 15%, var(--bg-light));
867
+ color: var(--fg);
868
+ transform: translateY(-1px);
869
+ }
870
+ .setup-copy-chip:disabled {
871
+ opacity: 0.45;
872
+ cursor: not-allowed;
873
+ transform: none;
874
+ }
875
+ .setup-copy-chip.is-copied {
876
+ border-color: color-mix(in srgb, var(--success) 55%, var(--border));
877
+ background: color-mix(in srgb, var(--success) 15%, var(--bg-light));
878
+ color: color-mix(in srgb, var(--success) 72%, var(--fg));
879
+ }
880
+ .setup-copy-glyph {
881
+ position: relative;
882
+ width: 13px;
883
+ height: 13px;
884
+ display: inline-block;
885
+ }
886
+ .setup-copy-glyph::before,
887
+ .setup-copy-glyph::after {
888
+ content: "";
889
+ position: absolute;
890
+ width: 8px;
891
+ height: 8px;
892
+ border: 1.5px solid currentColor;
893
+ border-radius: 3px;
894
+ }
895
+ .setup-copy-glyph::before {
896
+ left: 1px;
897
+ top: 4px;
898
+ opacity: 0.72;
899
+ }
900
+ .setup-copy-glyph::after {
901
+ right: 1px;
902
+ top: 1px;
903
+ background: color-mix(in srgb, var(--bg-light) 75%, transparent);
904
+ }
905
+ @media (max-width: 760px) {
906
+ .setup-phone-url-card {
907
+ align-items: stretch;
908
+ flex-direction: column;
909
+ }
910
+ .setup-copy-chip {
911
+ width: 100%;
912
+ }
913
+ }
805
914
  .backup-db-grid {
806
915
  display: grid;
807
916
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -6254,6 +6363,27 @@
6254
6363
  background: none; color: var(--fg-dim); cursor: pointer;
6255
6364
  }
6256
6365
  .session-esc-x:hover { color: var(--fg); }
6366
+ /* App-level Full Disk Access banner — fixed top bar. Shown only when macOS FDA is
6367
+ missing AND there is at least one network/Dropbox-backed session (so it never nags
6368
+ users who don't need it). One grant stops the repeating "network volume" prompt. */
6369
+ .fda-capability-banner {
6370
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 4100;
6371
+ display: flex; align-items: center; gap: 10px;
6372
+ padding: 8px 14px; font-size: 12.5px; color: var(--fg);
6373
+ background: rgba(45,37,15,0.97); backdrop-filter: blur(3px);
6374
+ border-top: 1px solid var(--yellow);
6375
+ box-shadow: 0 -2px 12px rgba(0,0,0,0.4);
6376
+ }
6377
+ .fda-banner-icon { font-size: 15px; }
6378
+ .fda-banner-text { flex: 1; }
6379
+ .fda-banner-text strong { font-family: var(--mono, monospace); }
6380
+ .fda-capability-banner button { font-size: 11px; padding: 4px 11px; border-radius: 5px; white-space: nowrap; cursor: pointer; border: 1px solid transparent; }
6381
+ .fda-banner-grant { border-color: rgba(158,206,106,0.6); background: rgba(158,206,106,0.2); color: var(--fg); }
6382
+ .fda-banner-grant:hover { background: rgba(158,206,106,0.34); }
6383
+ .fda-banner-later { border-color: rgba(122,162,247,0.5); background: rgba(122,162,247,0.14); color: var(--fg); }
6384
+ .fda-banner-later:hover { background: rgba(122,162,247,0.28); }
6385
+ .fda-banner-never { border: none; background: none; color: var(--fg-dim); }
6386
+ .fda-banner-never:hover { color: var(--fg); text-decoration: underline; }
6257
6387
  /* Pending-approval banner: real permission approvals only. Normal terminal
6258
6388
  choices (AskUserQuestion) stay in the terminal/choice flow and must not use
6259
6389
  approval wording or Approve/Deny actions. */
@@ -7723,8 +7853,15 @@ url = "http://localhost:<span id="setup-mcp-port-display-toml">3457</span>/mcp"<
7723
7853
  <div class="setup-phone-guidance-title" id="setup-phone-guidance-title">Phone setup</div>
7724
7854
  <div class="setup-phone-guidance-body" id="setup-phone-guidance-body">Choose an access method above to see the phone steps.</div>
7725
7855
  </div>
7726
- <div id="setup-phone-recommended" class="setup-access-status" style="margin-bottom:10px;color:var(--fg-dim);">
7727
- <span id="setup-phone-best-label">Recommended phone URL</span>: <code id="setup-phone-best-url" style="font-size:11px;background:var(--bg);padding:1px 5px;border-radius:4px;word-break:break-all;">Resolving…</code>
7856
+ <div id="setup-phone-recommended" class="setup-phone-url-card" aria-live="polite">
7857
+ <div class="setup-phone-url-copy">
7858
+ <span id="setup-phone-best-label" class="setup-phone-url-label">Recommended phone URL</span>
7859
+ <code id="setup-phone-best-url" class="setup-phone-url-value">Resolving…</code>
7860
+ </div>
7861
+ <button class="setup-copy-chip" id="setup-phone-best-copy" type="button" onclick="SETUP.copyRecommendedPhoneUrl()" disabled aria-label="Copy recommended phone URL">
7862
+ <span class="setup-copy-glyph" aria-hidden="true"></span>
7863
+ <span id="setup-phone-best-copy-label">Copy</span>
7864
+ </button>
7728
7865
  </div>
7729
7866
  <div class="input-row">
7730
7867
  <label>New Phone Label</label>
@@ -8700,6 +8837,7 @@ url = "http://localhost:<span id="setup-mcp-port-display-toml">3457</span>/mcp"<
8700
8837
  <script src="js/session-search-utils.js"></script>
8701
8838
  <script src="js/session-activity-utils.js"></script>
8702
8839
  <script src="js/session-status-precedence.js"></script>
8840
+ <script src="js/session-phase.js"></script>
8703
8841
  <script src="js/terminal-restore-state.js"></script>
8704
8842
  <script src="js/terminal-reconciler.js"></script>
8705
8843
  <script src="js/stream-resize-policy.js"></script>
@@ -9394,6 +9532,14 @@ function broadcastInstalledUpdateReload(server, reason) {
9394
9532
 
9395
9533
  function handleServerHello(msg) {
9396
9534
  if (msg && msg.clientId) state._clientId = msg.clientId;
9535
+ // Option A "keyframe on every boundary": the server is the source of truth for whether
9536
+ // keyframe-sync is on (CTM_KEYFRAME_SYNC). Mirror it into the client flag so _keyframeSyncEnabled()
9537
+ // gates the boundary-render path + retires the heal overlay. Set before any heartbeat/snapshot
9538
+ // is processed (hello is the first message on the connection).
9539
+ if (msg && typeof msg.keyframeSync === 'boolean') window.CTM_KEYFRAME_SYNC = msg.keyframeSync;
9540
+ // Option A.5 "streaming keyframe heartbeat": mirror whether the server pushes keyframes
9541
+ // mid-stream (CTM_STREAM_KEYFRAME) so _streamKeyframeEnabled() gates the mid-stream apply.
9542
+ if (msg && typeof msg.streamKeyframe === 'boolean') window.CTM_STREAM_KEYFRAME = msg.streamKeyframe;
9397
9543
  const server = normalizeAppVersionInfo(msg && msg.appVersion);
9398
9544
  if (!server) return;
9399
9545
  if (!state.appRuntime.loaded) {
@@ -9631,6 +9777,8 @@ function _markTerminalLayoutChanged(s, reason) {
9631
9777
  function _markTerminalContentChanged(s, reason) {
9632
9778
  const v = _bumpTerminalViewEpoch(s, 'contentEpoch', reason || 'content');
9633
9779
  if (v) _setTerminalViewPhase(s, _terminalRestorePhases().PAINT_SETTLING, reason || 'content');
9780
+ // Content changed = the previously validated render frame is no longer authoritative.
9781
+ if (typeof _markRenderUnstable === 'function') _markRenderUnstable(s, reason || 'content');
9634
9782
  }
9635
9783
 
9636
9784
  function _updateTerminalVisibilityState(id, reason) {
@@ -9689,6 +9837,7 @@ function _markTerminalRestoreRequest(s, source, extra) {
9689
9837
  if (nextPhase) _setTerminalViewPhase(s, nextPhase, 'restore-request:' + type);
9690
9838
  }
9691
9839
  _terminalRenderMarkDirty(s, 'restore-request:' + s._snapshotRequestSource);
9840
+ if (typeof _markRenderUnstable === 'function') _markRenderUnstable(s, 'restore-request:' + s._snapshotRequestSource);
9692
9841
  _maybeDismissRestartOverlay('restore-request');
9693
9842
  return now;
9694
9843
  }
@@ -10186,6 +10335,8 @@ function connect() {
10186
10335
  _sendQueue = [];
10187
10336
  if (_sendRetryTimer) { clearTimeout(_sendRetryTimer); _sendRetryTimer = null; }
10188
10337
  }
10338
+ // One-shot Full Disk Access check after boot settles (off the critical attach path).
10339
+ if (!_fdaCapabilityChecked) { _fdaCapabilityChecked = true; setTimeout(checkFdaCapability, 4000); }
10189
10340
  };
10190
10341
 
10191
10342
  ws.onmessage = (e) => {
@@ -10998,6 +11149,82 @@ function formatUptime(seconds) {
10998
11149
  return h + 'h ' + m + 'm';
10999
11150
  }
11000
11151
 
11152
+ // ── Full Disk Access capability banner ─────────────────────────────────────
11153
+ // Many sessions live under ~/Library/CloudStorage/Dropbox (a File Provider = "network volume"
11154
+ // to macOS) or NFS mounts, so macOS re-prompts for file access on every restart. Full Disk
11155
+ // Access is a superset that, under the stable Dev-ID identity, persists — one grant ends it.
11156
+ // We can only detect + guide; Apple forbids granting FDA programmatically.
11157
+ let _fdaCapabilityChecked = false;
11158
+ const FDA_BANNER_DISMISS_KEY = 'ctm_fda_banner_dismissed_v1';
11159
+
11160
+ async function checkFdaCapability() {
11161
+ try {
11162
+ if (localStorage.getItem(FDA_BANNER_DISMISS_KEY) === '1') return;
11163
+ const r = await fetch(`/api/capabilities?token=${state.token}`);
11164
+ if (!r.ok) return;
11165
+ const cap = await r.json();
11166
+ if (cap && cap.needsAttention) showFdaBanner(cap);
11167
+ else hideFdaBanner();
11168
+ } catch { /* best-effort — never block the UI on this */ }
11169
+ }
11170
+
11171
+ function hideFdaBanner() {
11172
+ const b = document.getElementById('fda-capability-banner');
11173
+ if (b) b.remove();
11174
+ }
11175
+
11176
+ function showFdaBanner(cap) {
11177
+ if (document.getElementById('fda-capability-banner')) return;
11178
+ const count = Number(cap.networkSessionCount) || 0;
11179
+ const denials = Array.isArray(cap.accessDenials) ? cap.accessDenials : [];
11180
+ // Prefer the specific, observed cause (e.g. Codex couldn't read ~/.codex) over the generic
11181
+ // count — it's actionable and explains why a session just failed to load.
11182
+ let message;
11183
+ if (denials.length) {
11184
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
11185
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
11186
+ const d = denials[0];
11187
+ const who = d.provider ? d.provider.charAt(0).toUpperCase() + d.provider.slice(1) : 'An agent';
11188
+ const where = (d.path || '').replace(/\/config\.toml$/, '') || d.path || 'a file';
11189
+ message = `${esc(who)} couldn't read <code>${esc(where)}</code> (it's on Dropbox/iCloud or a network volume), so the session failed to load. <strong>Grant Full Disk Access once</strong> to fix it.`;
11190
+ } else {
11191
+ message = `CTM has ${count} session${count === 1 ? '' : 's'} on a network/Dropbox volume, so macOS keeps asking for file access on every restart. <strong>Grant Full Disk Access once</strong> to stop it.`;
11192
+ }
11193
+ const banner = document.createElement('div');
11194
+ banner.id = 'fda-capability-banner';
11195
+ banner.className = 'fda-capability-banner';
11196
+ banner.innerHTML = `
11197
+ <span class="fda-banner-icon">&#128274;</span>
11198
+ <span class="fda-banner-text">${message}</span>
11199
+ <button class="fda-banner-grant" type="button">Grant Full Disk Access</button>
11200
+ <button class="fda-banner-later" type="button">Later</button>
11201
+ <button class="fda-banner-never" type="button" title="Don't show again">Don't show again</button>`;
11202
+ document.body.appendChild(banner);
11203
+ banner.querySelector('.fda-banner-grant').addEventListener('click', () => grantFdaAccess(cap));
11204
+ banner.querySelector('.fda-banner-later').addEventListener('click', hideFdaBanner);
11205
+ banner.querySelector('.fda-banner-never').addEventListener('click', () => {
11206
+ try { localStorage.setItem(FDA_BANNER_DISMISS_KEY, '1'); } catch {}
11207
+ hideFdaBanner();
11208
+ });
11209
+ }
11210
+
11211
+ async function grantFdaAccess(cap) {
11212
+ const fallbackHint = (cap && cap.hint) ||
11213
+ 'Open System Settings → Privacy & Security → Full Disk Access and add "Coding Task Manager".';
11214
+ try {
11215
+ const r = await fetch(`/api/capabilities/open-fda?token=${state.token}`, { method: 'POST' });
11216
+ const d = await r.json().catch(() => ({}));
11217
+ if (d && d.ok) {
11218
+ toast('Add "Coding Task Manager" to the opened Full Disk Access list, then restart CTM once — macOS will stop asking.', { type: 'info', title: 'Full Disk Access', duration: 12000 });
11219
+ } else {
11220
+ toast(fallbackHint, { type: 'warning', title: 'Grant Full Disk Access', duration: 14000 });
11221
+ }
11222
+ } catch {
11223
+ toast(fallbackHint, { type: 'warning', title: 'Grant Full Disk Access', duration: 14000 });
11224
+ }
11225
+ hideFdaBanner();
11226
+ }
11227
+
11001
11228
  async function pollServiceStatus() {
11002
11229
  try {
11003
11230
  const r = await fetch(`/api/services/status?token=${state.token}`);
@@ -12327,6 +12554,15 @@ const _CODEX_BUSY_STATUS_LINE_RE = /^(?:(?:waiting\s+for\s+background\s+terminal
12327
12554
  const _CODEX_BUSY_COMPACT_STATUS_LINE_RE = /^working\s+(?:\d+(?::\d+){0,2}|\d+(?:\.\d+)?\s*(?:ms|s|sec|secs|m|min|mins|h|hr|hrs))$/iu;
12328
12555
  const _CODEX_BUSY_WORD = 'working';
12329
12556
  const _CODEX_BUSY_HINT_RE = /esc\s+to\s+interrupt/i;
12557
+ // A width-reflow strand can paint the Codex "Working (12s • esc to interrupt)"
12558
+ // status line over trailing scrollback content, leaving a merged line such as
12559
+ // "Working RSA-1ES046-SHA256:DHE-RSA-AES256-SHA256:HIGH:!aNULL:!…". The strict
12560
+ // anchored REs above miss it, so a genuinely-running turn whose PTY has gone
12561
+ // briefly quiet gets demoted to Idle. Recognize the surviving "Working" prefix
12562
+ // when the trailing remainder is clearly NOT prose (code/cipher-like garble),
12563
+ // and never for ordinary "Working <word>" output ("working tree", "working on").
12564
+ const _CODEX_BUSY_GARBLE_PROSE_RE = /^working\s+(?:tree|dir|directory|copy|set|version|branch|on|in|with|through|from|for|as|at|to|and|but|so|the|a|an|my|your|our|this|that|it|now|here|hard|fine|well|great|correctly|properly)\b/i;
12565
+ const _CODEX_BUSY_GARBLE_SIGNAL_RE = /[:!]|[A-Z0-9]{3,}[-:]|\besc\b/;
12330
12566
  const _CODEX_SKILL_WARNING_RE = /^(?:⚠\s*)?Skipped loading\s+\d+\s+skill\(s\)\s+due to invalid SKILL\.md files\./i;
12331
12567
  const _CODEX_SKILL_DESCRIPTION_RE = /^\/.*\/SKILL\.md:\s+invalid description:\s+exceeds maximum length of\s+\d+\s+characters$/i;
12332
12568
  const _GEMINI_STATUS_FRAGMENT_RE = /^(?:[\s\d•◦·∙●○✦✧◆◇◐◓◑◒|\/\\-]+|Thinking\.{0,3}|Working\.{0,3}|Running\.{0,3}|Responding\.{0,3}|Loading\.{0,3}|esc\s+to\s+(?:cancel|interrupt)|ctrl\+c\s+to\s+(?:quit|cancel)|press\s+enter\s+to\s+send|shift\+enter\s+for\s+newline)$/i;
@@ -12357,11 +12593,18 @@ function _normalizeCodexStatusLineText(text) {
12357
12593
  .replace(/^[\s•◦·∙●○]+\s*/, '');
12358
12594
  }
12359
12595
 
12596
+ function _isGarbledCodexBusyStatusLine(line) {
12597
+ if (!/^working\b/i.test(line)) return false;
12598
+ if (_CODEX_BUSY_GARBLE_PROSE_RE.test(line)) return false;
12599
+ return _CODEX_BUSY_GARBLE_SIGNAL_RE.test(line.slice(_CODEX_BUSY_WORD.length));
12600
+ }
12601
+
12360
12602
  function _isCodexBusyStatusLineText(text) {
12361
12603
  const line = _normalizeCodexStatusLineText(text);
12362
12604
  if (!line || line.length > 240) return false;
12363
12605
  return _CODEX_BUSY_STATUS_LINE_RE.test(line) ||
12364
- _CODEX_BUSY_COMPACT_STATUS_LINE_RE.test(line);
12606
+ _CODEX_BUSY_COMPACT_STATUS_LINE_RE.test(line) ||
12607
+ _isGarbledCodexBusyStatusLine(line);
12365
12608
  }
12366
12609
 
12367
12610
  function _inputMayResolveWaiting(data, session) {
@@ -16491,6 +16734,8 @@ function _confirmClaudePersistentViewportGarble(s, now) {
16491
16734
  }
16492
16735
 
16493
16736
  function _maybeRecoverClaudeStaleWidthRender(id, reason) {
16737
+ // Option A: keyframes replace the stale-width garble heals (typeof-guarded for source-slice tests).
16738
+ if (typeof _keyframeSyncEnabled === 'function' && _keyframeSyncEnabled()) return false;
16494
16739
  const s = state.sessions.get(id);
16495
16740
  if (!s || !s.term) return false;
16496
16741
  // Width arbitration: a SECONDARY viewer must never drive a redraw-reflow — it
@@ -19841,6 +20086,10 @@ function createTerminal(id, opts) {
19841
20086
  if (s && s._suppressResize) return; // Skip during font metric refresh
19842
20087
  _markTerminalLayoutChanged(s, 'xterm-resize');
19843
20088
  _sendTerminalResizeIfChanged(s, id, 'xterm-resize', { cols, rows });
20089
+ // Option A: a resize is a render boundary — xterm reflows locally (can shear an idle
20090
+ // frame), so PULL an authoritative keyframe at the new dims (debounced to coalesce a
20091
+ // drag). No-op unless keyframe-sync is on.
20092
+ _requestKeyframeDebounced(s, id, 'resize');
19844
20093
  });
19845
20094
 
19846
20095
  // Alt screen buffer switch listener — when Claude Code's TUI (Ink) exits,
@@ -21066,9 +21315,14 @@ window._ctmEnsureTerminalRestoreForVisibleView = _ensureTerminalRestoreForVisibl
21066
21315
 
21067
21316
  function _markTerminalActivationRestoreScheduled(s, id, source) {
21068
21317
  if (!s) return;
21069
- s._activationRestoreScheduledAt = Date.now();
21318
+ const now = Date.now();
21319
+ s._activationRestoreScheduledAt = now;
21070
21320
  s._activationRestoreScheduledId = id || s._id || '';
21071
21321
  s._activationRestoreScheduledSource = source || 'activation';
21322
+ s._terminalActivationSeq = Number(s._terminalActivationSeq || 0) + 1;
21323
+ s._activatedAt = now;
21324
+ s._reconciledSinceActivation = false;
21325
+ if (typeof _markRenderUnstable === 'function') _markRenderUnstable(s, 'activation:' + s._activationRestoreScheduledSource);
21072
21326
  }
21073
21327
 
21074
21328
  function _clearTerminalActivationRestoreScheduled(s) {
@@ -21751,6 +22005,11 @@ function activateTab(id) {
21751
22005
  // INSIDE the activation grace so a malformed switch heals in <1s instead of
21752
22006
  // 5-10s. No-op when the session is already latched stable (nothing changed).
21753
22007
  _scheduleActivationRenderCheck(sReal, id);
22008
+ // Option A: tab activation is a render boundary — PULL an authoritative keyframe at
22009
+ // our current dims so the frame is correct-by-construction (no divergence-detect heal
22010
+ // needed). No-op unless keyframe-sync is on. This is the structural fix for the
22011
+ // "switch to an idle tab → stale/stranded frame" bug.
22012
+ _requestKeyframe(sReal, id, 'activation');
21754
22013
  if (!_sessionConversationViewActive(id)) focusTerminalIfSafe(id);
21755
22014
  }));
21756
22015
  // Final safety net — regardless of which restore path fired, make sure
@@ -28524,6 +28783,248 @@ function _modelsProviderEditPayload(type, options) {
28524
28783
  return body;
28525
28784
  }
28526
28785
 
28786
+ // Ollama-only "Manage models" dialog: browse the ollama.com library, download
28787
+ // (pull) models with live progress, and uninstall local models from disk. All
28788
+ // requests go through CTM's /api/models/* proxy to Wall-E (which owns the model
28789
+ // registry and the Ollama client). On any install/remove, the parent Edit
28790
+ // dialog's DEFAULT MODEL dropdown is refreshed via the registry.
28791
+ function showOllamaManageModelsDialog(providerType, baseUrl) {
28792
+ providerType = String(providerType || 'ollama').trim().toLowerCase();
28793
+ var existing = document.getElementById('ollama-manage-dialog');
28794
+ if (existing) existing.remove();
28795
+
28796
+ var SANITIZE_OPTS = { ADD_ATTR: ['style', 'id', 'type', 'placeholder', 'value', 'title', 'data-action', 'data-model'] };
28797
+ function fmtSize(b) {
28798
+ b = Number(b) || 0;
28799
+ if (!b) return '';
28800
+ var u = ['B', 'KB', 'MB', 'GB', 'TB'], i = 0;
28801
+ while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
28802
+ return (b >= 10 || i === 0 ? Math.round(b) : b.toFixed(1)) + ' ' + u[i];
28803
+ }
28804
+
28805
+ var dialog = document.createElement('div');
28806
+ dialog.id = 'ollama-manage-dialog';
28807
+ dialog.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.62);display:flex;align-items:center;justify-content:center;z-index:10000;padding:18px;';
28808
+ var inner = document.createElement('div');
28809
+ inner.style.cssText = 'background:#24283b;border:1px solid #414868;border-radius:10px;width:640px;max-width:96vw;max-height:88vh;overflow:auto;box-shadow:0 24px 80px rgba(0,0,0,0.35);padding:20px;';
28810
+ inner.innerHTML = DOMPurify.sanitize(
28811
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">' +
28812
+ '<div><div style="font-size:17px;font-weight:700;color:#c0caf5;">Manage Ollama models</div>' +
28813
+ '<div style="font-size:12px;color:#565f89;margin-top:2px;">Download models from the Ollama library or uninstall local ones.</div></div>' +
28814
+ '<button type="button" id="btn-close-ollama-manage" style="padding:5px 10px;border-radius:6px;border:1px solid #414868;background:#1a1b26;color:#c0caf5;cursor:pointer;">✕</button>' +
28815
+ '</div>' +
28816
+ '<input id="ollama-manage-filter" type="search" placeholder="Filter models..." autocomplete="off" spellcheck="false" style="width:100%;box-sizing:border-box;margin:8px 0;background:#1a1b26;color:#c0caf5;border:1px solid #414868;border-radius:6px;padding:7px 9px;font-size:12px;">' +
28817
+ '<div id="ollama-manage-status" style="min-height:16px;font-size:12px;color:#565f89;margin-bottom:6px;"></div>' +
28818
+ '<div id="ollama-manage-body"><div style="color:#565f89;font-size:12px;padding:10px 0;">Loading…</div></div>' +
28819
+ '<div style="border-top:1px solid #2a2e44;margin-top:12px;padding-top:12px;">' +
28820
+ '<div style="font-size:11px;color:#565f89;text-transform:uppercase;letter-spacing:0.4px;margin-bottom:5px;">Pull by name</div>' +
28821
+ '<div style="display:flex;gap:8px;">' +
28822
+ '<input id="ollama-pull-name" type="text" placeholder="e.g. llama3.2:3b" autocomplete="off" spellcheck="false" style="flex:1;box-sizing:border-box;background:#1a1b26;color:#c0caf5;border:1px solid #414868;border-radius:6px;padding:7px 9px;font-size:12px;">' +
28823
+ '<button type="button" id="ollama-pull-name-btn" style="padding:7px 14px;border-radius:6px;background:#7aa2f7;color:#1a1b26;border:none;cursor:pointer;font-weight:700;">Pull</button>' +
28824
+ '</div>' +
28825
+ '</div>',
28826
+ SANITIZE_OPTS
28827
+ );
28828
+ dialog.appendChild(inner);
28829
+ document.body.appendChild(dialog);
28830
+
28831
+ var bodyEl = inner.querySelector('#ollama-manage-body');
28832
+ var statusEl = inner.querySelector('#ollama-manage-status');
28833
+ var filterEl = inner.querySelector('#ollama-manage-filter');
28834
+ var dirty = false; // set when something was installed/removed → refresh registry on close
28835
+ var pullCount = 0; // in-flight downloads; auto-reload only when idle
28836
+ var lastInstalled = [];
28837
+ var lastLibrary = { models: [], degraded: false };
28838
+
28839
+ function setStatus(msg, kind) {
28840
+ statusEl.textContent = msg || '';
28841
+ statusEl.style.color = kind === 'error' ? '#f7768e' : (kind === 'success' ? '#9ece6a' : '#565f89');
28842
+ }
28843
+
28844
+ function close() {
28845
+ dialog.remove();
28846
+ if (dirty) {
28847
+ // Refresh the registry so the parent Edit dialog's model dropdown updates.
28848
+ _modelsRegistryLoaded = false;
28849
+ _ensureModelsRegistryLoaded(_modelsLoadSeq).then(function() {
28850
+ if (document.getElementById('edit-provider-dialog')) showModelProviderEditDialog(providerType);
28851
+ });
28852
+ }
28853
+ }
28854
+
28855
+ function familyInstalled(family, installedIds) {
28856
+ var f = String(family).toLowerCase();
28857
+ return installedIds.some(function(id) {
28858
+ id = String(id).toLowerCase();
28859
+ return id === f || id.indexOf(f + ':') === 0;
28860
+ });
28861
+ }
28862
+
28863
+ function render() {
28864
+ var q = String(filterEl.value || '').trim().toLowerCase();
28865
+ var installed = lastInstalled || [];
28866
+ var installedIds = installed.map(function(m) { return m.id || m.name; });
28867
+ var available = (lastLibrary.models || []).filter(function(m) { return !familyInstalled(m.name, installedIds); });
28868
+ var instShown = installed.filter(function(m) { return !q || String(m.id || m.name).toLowerCase().indexOf(q) >= 0; });
28869
+ var availShown = available.filter(function(m) {
28870
+ return !q || String(m.name).toLowerCase().indexOf(q) >= 0 || String(m.description || '').toLowerCase().indexOf(q) >= 0;
28871
+ });
28872
+
28873
+ var h = '';
28874
+ h += '<div style="font-size:11px;color:#565f89;text-transform:uppercase;letter-spacing:0.4px;margin:4px 0 6px;">Installed (' + installed.length + ')</div>';
28875
+ if (!instShown.length) {
28876
+ h += '<div style="color:#565f89;font-size:12px;padding:2px 0 8px;">' + (installed.length ? 'No matches.' : 'No models installed locally.') + '</div>';
28877
+ } else {
28878
+ instShown.forEach(function(m) {
28879
+ var id = m.id || m.name;
28880
+ h += '<div class="ollama-row" style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px solid #2a2e44;">' +
28881
+ '<div style="min-width:0;"><span style="color:#c0caf5;font-size:13px;">' + escHtml(id) + '</span>' +
28882
+ (m.size ? ' <span style="color:#565f89;font-size:11px;">' + escHtml(fmtSize(m.size)) + '</span>' : '') + '</div>' +
28883
+ '<div class="ollama-row-action" style="flex:none;">' +
28884
+ '<button type="button" data-action="remove" data-model="' + escHtml(id) + '" style="padding:4px 10px;border-radius:5px;border:1px solid #f7768e;background:transparent;color:#f7768e;cursor:pointer;font-size:11px;">Remove</button>' +
28885
+ '</div></div>';
28886
+ });
28887
+ }
28888
+
28889
+ h += '<div style="font-size:11px;color:#565f89;text-transform:uppercase;letter-spacing:0.4px;margin:14px 0 6px;">Available to download</div>';
28890
+ if (lastLibrary.degraded) {
28891
+ h += '<div style="color:#e0af68;font-size:12px;padding:2px 0 8px;">Catalog unavailable right now — use “Pull by name” below to download a specific tag.</div>';
28892
+ }
28893
+ if (!availShown.length && !lastLibrary.degraded) {
28894
+ h += '<div style="color:#565f89;font-size:12px;padding:2px 0 8px;">' + ((lastLibrary.models || []).length ? 'No matches.' : 'No catalog models available.') + '</div>';
28895
+ } else {
28896
+ availShown.slice(0, 200).forEach(function(m) {
28897
+ var sizes = Array.isArray(m.sizes) ? m.sizes : [];
28898
+ var chips = sizes.length
28899
+ ? sizes.map(function(s) {
28900
+ var tag = m.name + ':' + s;
28901
+ return '<button type="button" data-action="pull" data-model="' + escHtml(tag) + '" style="padding:3px 9px;border-radius:5px;border:1px solid #414868;background:#1a1b26;color:#7aa2f7;cursor:pointer;font-size:11px;">' + escHtml(s) + ' ↓</button>';
28902
+ }).join(' ')
28903
+ : '<button type="button" data-action="pull" data-model="' + escHtml(m.name) + '" style="padding:4px 10px;border-radius:5px;border:1px solid #7aa2f7;background:transparent;color:#7aa2f7;cursor:pointer;font-size:11px;">Download</button>';
28904
+ h += '<div class="ollama-row" style="padding:8px 0;border-bottom:1px solid #2a2e44;">' +
28905
+ '<div style="display:flex;align-items:baseline;justify-content:space-between;gap:10px;">' +
28906
+ '<span style="color:#c0caf5;font-size:13px;font-weight:600;">' + escHtml(m.name) + '</span>' +
28907
+ '<span style="color:#565f89;font-size:11px;flex:none;">' + escHtml(m.pulls ? m.pulls + ' pulls' : '') + '</span>' +
28908
+ '</div>' +
28909
+ (m.description ? '<div style="color:#787c99;font-size:11px;margin:3px 0 6px;">' + escHtml(m.description) + '</div>' : '') +
28910
+ '<div class="ollama-row-action" style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;">' + chips + '</div>' +
28911
+ '</div>';
28912
+ });
28913
+ if (availShown.length > 200) {
28914
+ h += '<div style="color:#565f89;font-size:11px;padding:6px 0;">Showing first 200 of ' + availShown.length + ' — filter to narrow.</div>';
28915
+ }
28916
+ }
28917
+ bodyEl.innerHTML = DOMPurify.sanitize(h, SANITIZE_OPTS);
28918
+ }
28919
+
28920
+ function reload(force) {
28921
+ var instUrl = _modelsApiUrl('/api/models/ollama/models');
28922
+ var libUrl = _modelsApiUrl('/api/models/ollama/library' + (force ? '?force=1' : ''));
28923
+ return Promise.all([
28924
+ fetch(instUrl).then(function(r) { return r.json(); }).catch(function() { return { models: [] }; }),
28925
+ fetch(libUrl).then(function(r) { return r.json(); }).catch(function() { return { models: [], degraded: true }; }),
28926
+ ]).then(function(res) {
28927
+ lastInstalled = (res[0] && res[0].models) || [];
28928
+ lastLibrary = res[1] || { models: [], degraded: true };
28929
+ render();
28930
+ });
28931
+ }
28932
+
28933
+ function pull(model, actionCell) {
28934
+ model = String(model || '').trim();
28935
+ if (!model) return;
28936
+ var report = function(txt) { if (actionCell) actionCell.textContent = txt; else setStatus(txt, 'info'); };
28937
+ pullCount++;
28938
+ report('Starting…');
28939
+ fetch(_modelsApiUrl('/api/models/ollama/pull'), {
28940
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
28941
+ body: JSON.stringify({ model: model, baseUrl: baseUrl || undefined }),
28942
+ }).then(function(resp) {
28943
+ if (!resp.ok || !resp.body) throw new Error('Download failed (' + resp.status + ')');
28944
+ var reader = resp.body.getReader();
28945
+ var dec = new TextDecoder();
28946
+ var buf = '';
28947
+ var lastErr = null;
28948
+ function pump() {
28949
+ return reader.read().then(function(r) {
28950
+ if (r.done) return;
28951
+ buf += dec.decode(r.value, { stream: true });
28952
+ var lines = buf.split('\n');
28953
+ buf = lines.pop();
28954
+ lines.forEach(function(ln) {
28955
+ ln = ln.trim();
28956
+ if (!ln) return;
28957
+ var j; try { j = JSON.parse(ln); } catch (e) { return; }
28958
+ if (j.status === 'error' || j.error) { lastErr = j.error || j.message || 'Download error'; }
28959
+ else if (typeof j.progress === 'number') { report('Downloading ' + j.progress + '%'); }
28960
+ else if (j.message) { report(j.message); }
28961
+ });
28962
+ return pump();
28963
+ });
28964
+ }
28965
+ return pump().then(function() { if (lastErr) throw new Error(lastErr); });
28966
+ }).then(function() {
28967
+ dirty = true;
28968
+ report('Installed ✓');
28969
+ setStatus(model + ' downloaded.', 'success');
28970
+ }).catch(function(e) {
28971
+ setStatus(e && e.message ? e.message : 'Download failed', 'error');
28972
+ if (actionCell) actionCell.textContent = 'Failed';
28973
+ }).then(function() {
28974
+ pullCount--;
28975
+ if (pullCount === 0) reload(false);
28976
+ });
28977
+ }
28978
+
28979
+ function remove(model) {
28980
+ model = String(model || '').trim();
28981
+ if (!model) return;
28982
+ if (!window.confirm('Uninstall ' + model + ' from disk? This frees space and removes it from CTM.')) return;
28983
+ setStatus('Removing ' + model + '…', 'info');
28984
+ fetch(_modelsApiUrl('/api/models/ollama/delete'), {
28985
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
28986
+ body: JSON.stringify({ model: model, baseUrl: baseUrl || undefined }),
28987
+ }).then(function(resp) {
28988
+ return resp.json().catch(function() { return {}; }).then(function(d) {
28989
+ if (!resp.ok || d.error) throw new Error(d.error || 'Remove failed (' + resp.status + ')');
28990
+ return d;
28991
+ });
28992
+ }).then(function() {
28993
+ dirty = true;
28994
+ setStatus(model + ' removed.', 'success');
28995
+ return reload(false);
28996
+ }).catch(function(e) {
28997
+ setStatus(e && e.message ? e.message : 'Remove failed', 'error');
28998
+ });
28999
+ }
29000
+
29001
+ bodyEl.addEventListener('click', function(e) {
29002
+ var btn = e.target.closest('button[data-action]');
29003
+ if (!btn) return;
29004
+ var action = btn.getAttribute('data-action');
29005
+ var model = btn.getAttribute('data-model');
29006
+ if (action === 'pull') {
29007
+ var cell = btn.closest('.ollama-row') ? btn.closest('.ollama-row').querySelector('.ollama-row-action') : null;
29008
+ pull(model, cell);
29009
+ } else if (action === 'remove') {
29010
+ remove(model);
29011
+ }
29012
+ });
29013
+ filterEl.addEventListener('input', render);
29014
+ inner.querySelector('#ollama-pull-name-btn').addEventListener('click', function() {
29015
+ var nameInput = inner.querySelector('#ollama-pull-name');
29016
+ var val = String(nameInput.value || '').trim();
29017
+ if (val) { pull(val, null); nameInput.value = ''; }
29018
+ });
29019
+ inner.querySelector('#ollama-pull-name').addEventListener('keydown', function(e) {
29020
+ if (e.key === 'Enter') { e.preventDefault(); inner.querySelector('#ollama-pull-name-btn').click(); }
29021
+ });
29022
+ inner.querySelector('#btn-close-ollama-manage').addEventListener('click', close);
29023
+ dialog.addEventListener('click', function(e) { if (e.target === dialog) close(); });
29024
+
29025
+ reload(false);
29026
+ }
29027
+
28527
29028
  function showModelProviderEditDialog(type) {
28528
29029
  type = String(type || '').trim().toLowerCase();
28529
29030
  if (!type) return;
@@ -28663,7 +29164,10 @@ function showModelProviderEditDialog(type) {
28663
29164
  '<select id="edit-provider-default-model"' + defaultDisabledAttr + ' style="width:100%;box-sizing:border-box;background:#24283b;color:#c0caf5;border:1px solid #414868;border-radius:7px;padding:8px 10px;font-size:13px;"></select>' +
28664
29165
  '<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;flex-wrap:wrap;margin-top:7px;">' +
28665
29166
  '<div style="font-size:11px;color:#565f89;">' + escHtml(modelHelp) + '</div>' +
28666
- '<button type="button" id="btn-refresh-provider-models" style="padding:4px 8px;border-radius:5px;border:1px solid #414868;background:transparent;color:#7aa2f7;cursor:pointer;font-size:11px;">Refresh models</button>' +
29167
+ '<div style="display:flex;gap:6px;align-items:center;">' +
29168
+ (type === 'ollama' ? '<button type="button" id="btn-manage-ollama-models" style="padding:4px 8px;border-radius:5px;border:1px solid #414868;background:transparent;color:#9ece6a;cursor:pointer;font-size:11px;">Manage models →</button>' : '') +
29169
+ '<button type="button" id="btn-refresh-provider-models" style="padding:4px 8px;border-radius:5px;border:1px solid #414868;background:transparent;color:#7aa2f7;cursor:pointer;font-size:11px;">Refresh models</button>' +
29170
+ '</div>' +
28667
29171
  '</div>' +
28668
29172
  unsupportedNote +
28669
29173
  '</div>' +
@@ -28772,6 +29276,12 @@ function showModelProviderEditDialog(type) {
28772
29276
  if (document.getElementById('edit-provider-dialog')) showModelProviderEditDialog(type);
28773
29277
  });
28774
29278
  });
29279
+ var manageOllamaBtn = document.getElementById('btn-manage-ollama-models');
29280
+ if (manageOllamaBtn) {
29281
+ manageOllamaBtn.addEventListener('click', function() {
29282
+ showOllamaManageModelsDialog(type, baseUrl);
29283
+ });
29284
+ }
28775
29285
  var detectButton = document.getElementById('btn-detect-edit-provider');
28776
29286
  var maybeDetectGeminiCli = async function() {
28777
29287
  if (type !== 'google' || !authSelect || authSelect.value !== 'gemini_cli' || (keyInput && keyInput.value.trim())) return null;
@@ -33154,6 +33664,11 @@ function onHeartbeat(msg) {
33154
33664
  s._serverSeq = msg.seq;
33155
33665
  s._serverFpCols = msg.cols;
33156
33666
  s._serverFpRows = msg.rows;
33667
+ // Option A: the heartbeat fingerprint exists only to drive the divergence-detect heal
33668
+ // overlay. With keyframe-sync on, correctness comes from authoritative keyframe pushes
33669
+ // (output-quiet / activation / resize), so we record the fp for status/diagnostics but
33670
+ // skip the dims-reassert + reconcile + settle-recheck heal cascade entirely.
33671
+ if (_keyframeSyncEnabled()) return;
33157
33672
  // Adopt a pure-lifecycle-bump heartbeat into our localSeq. A status change
33158
33673
  // (idle / waiting-for-input) bumps the server's terminal lifecycle and emits this
33159
33674
  // heartbeat at the new lifecycle, but carries NO new output bytes — and localSeq
@@ -33227,6 +33742,9 @@ function _reconcileConfirmAction(divergedSince, now) {
33227
33742
  return 'fire';
33228
33743
  }
33229
33744
  function _maybeReconcile(s, id, reason, extra) {
33745
+ // Option A: keyframes replace divergence-driven reconcile. typeof-guarded so the source-
33746
+ // slice unit tests (which extract this fn standalone, without the helper) stay flag-off.
33747
+ if (typeof _keyframeSyncEnabled === 'function' && _keyframeSyncEnabled()) return;
33230
33748
  if (!s || !s.term || !window.TerminalReconciler) return;
33231
33749
  // Width arbitration: a SECONDARY viewer is rendered from server-pushed reflowed
33232
33750
  // snapshots at its own width. The heartbeat fingerprint is measured at the
@@ -33241,7 +33759,32 @@ function _maybeReconcile(s, id, reason, extra) {
33241
33759
  // Only compare when the server measured at the client's CURRENT dims; otherwise
33242
33760
  // the fingerprints legitimately differ on dims and a resize/reconcile is already
33243
33761
  // in flight elsewhere. Comparing across dims would cause a reconcile loop.
33244
- if (s._serverFpCols != null && s._serverFpRows != null && (s.term.cols !== s._serverFpCols || s.term.rows !== s._serverFpRows)) return;
33762
+ if (s._serverFpCols != null && s._serverFpRows != null && (s.term.cols !== s._serverFpCols || s.term.rows !== s._serverFpRows)) {
33763
+ // The server's fingerprint was measured at a DIFFERENT width than our grid now
33764
+ // holds — e.g. a background-tab pane-resize refit the grid on switch but no fresh
33765
+ // heartbeat has arrived at the new width. We still skip the cross-dims compare
33766
+ // (it would loop), but a plain skip here leaves an idle session you just switched
33767
+ // to STUCK forever: no output ⇒ no heartbeat ⇒ the onHeartbeat dims-reassert never
33768
+ // runs, so the server keeps serializing its stale-wide frame and the stranded
33769
+ // composer / branch chip never heals (the on-switch "check + redraw" the user
33770
+ // expects). Re-assert our dims ONCE here too (rate-limited via the SAME
33771
+ // _dimsReassertAt the heartbeat path uses) so the PTY resizes, the agent repaints
33772
+ // clean at our width, and the next heartbeat is comparable. Skipped while streaming
33773
+ // (force bypasses the SIGWINCH-during-stream deferral → duplicate scrollback); the
33774
+ // heartbeat reassert covers that case once output quiesces. Uniform for every
33775
+ // provider — unlike the claude-only stale-width detector, this needs no agent type
33776
+ // or stranded-fragment shape, so it also unsticks an idle Codex frame.
33777
+ const nowTs = Date.now();
33778
+ if (!_isSessionStreaming(s)
33779
+ && (!s._dimsReassertAt || nowTs - s._dimsReassertAt > 2000)
33780
+ && Number.isFinite(s.term.cols) && Number.isFinite(s.term.rows)) {
33781
+ s._dimsReassertAt = nowTs;
33782
+ try { _sendTerminalResizeIfChanged(s, id, 'reconcile-dims-reassert', { force: true, cols: s.term.cols, rows: s.term.rows }); } catch {}
33783
+ _markRenderUnstable(s, 'reconcile-dims-reassert');
33784
+ _scheduleDimsReassertRecheck(s, id);
33785
+ }
33786
+ return;
33787
+ }
33245
33788
  let clientFp;
33246
33789
  try { clientFp = window.TerminalReconciler.clientFingerprint(s.term); } catch { return; }
33247
33790
  const focusHelper = (typeof _activeTerminalHasFocus === 'function') ? _activeTerminalHasFocus(s) : (document.hasFocus() && state.activeTab === id);
@@ -33286,7 +33829,7 @@ function _maybeReconcile(s, id, reason, extra) {
33286
33829
  s._benignDivergeFp = null; // frame converged — drop any benign-divergence give-up memo
33287
33830
  s._claudeStableDivergeFp = null; // frame converged — drop any stable-divergence redraw arm
33288
33831
  s._activationHealPending = 0; // converged — drop the one-shot on-switch heal marker
33289
- _markRenderStable(s); // confirmed good — latch so the proactive on-switch check skips redundant work
33832
+ _markRenderStable(s, id); // confirmed good — latch this exact activation/render state
33290
33833
  return;
33291
33834
  }
33292
33835
  if (verdict === 'reconcile') {
@@ -33422,6 +33965,8 @@ window._ctmMaybeReconcile = _maybeReconcile;
33422
33965
  // idle safety net; the 4s in-flight debounce + flash-loop backoff bound any churn.
33423
33966
  const SETTLE_RECHECK_DELAY_MS = RECONCILE_DIVERGENCE_CONFIRM_MS + 150;
33424
33967
  function _scheduleSettleRecheck(s, id) {
33968
+ // Option A: no divergence confirm/heal under keyframe-sync (typeof-guarded for source-slice tests).
33969
+ if (typeof _keyframeSyncEnabled === 'function' && _keyframeSyncEnabled()) return;
33425
33970
  if (!s || s._exited) return;
33426
33971
  // A reconcile/redraw is already in flight — its result will re-settle the frame; a
33427
33972
  // re-check now would only no-op against the in-flight debounce. Skip to stay quiet.
@@ -33440,6 +33985,24 @@ function _scheduleSettleRecheck(s, id) {
33440
33985
  // matching window._ctmMaybeReconcile / window._ctmMaybeRecoverClaudeStaleWidth.
33441
33986
  window._ctmScheduleSettleRecheck = _scheduleSettleRecheck;
33442
33987
 
33988
+ const DIMS_REASSERT_RECHECK_MS = 350;
33989
+ function _scheduleDimsReassertRecheck(s, id) {
33990
+ if (!s || s._exited || !id) return;
33991
+ if (!_activationRenderCheckEnabled()) return;
33992
+ if (s._dimsReassertRecheckTimer) clearTimeout(s._dimsReassertRecheckTimer);
33993
+ s._dimsReassertRecheckTimer = setTimeout(() => {
33994
+ s._dimsReassertRecheckTimer = null;
33995
+ if (!_activationRenderCheckEnabled()) return;
33996
+ if (s._exited) return;
33997
+ if (state.activeTab !== id && !isSessionVisibleInSplit(id)) return;
33998
+ try { _maybeReconcile(s, id, 'dims-reassert-recheck'); } catch {}
33999
+ try { _maybeRecoverClaudeStaleWidthRender(id, 'dims-reassert-recheck'); } catch {}
34000
+ if (s._reconcileDivergedSince || s._claudeViewportGarbleSince) _scheduleSettleRecheck(s, id);
34001
+ }, DIMS_REASSERT_RECHECK_MS);
34002
+ if (s._dimsReassertRecheckTimer && s._dimsReassertRecheckTimer.unref) s._dimsReassertRecheckTimer.unref();
34003
+ }
34004
+ window._ctmScheduleDimsReassertRecheck = _scheduleDimsReassertRecheck;
34005
+
33443
34006
  // --- Input-settle re-check (heal promptly after the user stops typing) ---
33444
34007
  // While the user types, the editing-defer keeps _maybeReconcile from healing a diverged
33445
34008
  // frame (so a reconcile can't paint over the live composer). After they stop, the next heal
@@ -33494,35 +34057,152 @@ const CTM_ACTIVATION_RENDER_CHECK_MS = 200;
33494
34057
  // cleared on convergence, and self-expires so it can never bypass the cooldown later.
33495
34058
  const ACTIVATION_HEAL_PENDING_MAX_MS = 3500;
33496
34059
  function _activationRenderCheckEnabled() {
34060
+ // Option A retires the activation-render-check (and the rest of the heal overlay) when
34061
+ // keyframe-sync is on — the server pushes ground truth on every boundary instead.
34062
+ // typeof-guarded so source-slice unit tests that extract this fn stay flag-off.
34063
+ if (typeof _keyframeSyncEnabled === 'function' && _keyframeSyncEnabled()) return false;
33497
34064
  return typeof window === 'undefined' || window.CTM_ACTIVATION_RENDER_CHECK !== false;
33498
34065
  }
34066
+ // Option A — "keyframe on every boundary". When ON (server sets it via hello), the client
34067
+ // renders the server's authoritative keyframe on activation/resize/quiet and SKIPS the
34068
+ // ~13-path divergence-detect heal overlay (which only existed to repair byte-stream drift).
34069
+ // Default OFF — keyframe-off is byte-for-byte the legacy render path.
34070
+ function _keyframeSyncEnabled() {
34071
+ return typeof window !== 'undefined' && window.CTM_KEYFRAME_SYNC === true;
34072
+ }
34073
+ // Option A.5 — "streaming keyframe heartbeat". The server pushes authoritative keyframes
34074
+ // (restoreReason 'stream-keyframe') at a controlled frame rate DURING sustained output, so a
34075
+ // mid-turn byte-stream shear self-corrects within one interval rather than persisting until the
34076
+ // turn quiets. Requires keyframe-sync; default OFF.
34077
+ function _streamKeyframeEnabled() {
34078
+ return _keyframeSyncEnabled() && typeof window !== 'undefined' && window.CTM_STREAM_KEYFRAME === true;
34079
+ }
34080
+ // Pure decision core (unit-testable without the DOM): given the safety signals, return what to
34081
+ // do with a streaming keyframe. PAINT only when the client frame is SETTLED (write queue drained
34082
+ // — so its fingerprint is a reliable divergence signal), the user is NOT mid-input (so we never
34083
+ // clobber an un-acked composer/draft), the keyframe is not behind what we've applied, and its
34084
+ // grid matches ours. Anything else defers to the next keyframe (or the final output-quiet one),
34085
+ // so this can only make an inevitable correction land sooner — never paint something unsafe.
34086
+ function _streamKeyframeActionCore(f) {
34087
+ if (!f || !f.enabled) return 'skip-disabled';
34088
+ if (!f.hasData) return 'skip-no-data';
34089
+ if (!f.visible) return 'skip-hidden';
34090
+ if (f.typing) return 'skip-typing';
34091
+ if (f.busy) return 'skip-busy'; // writer still applying bytes → fp compare unreliable
34092
+ if (f.behind) return 'skip-stale'; // marker older than what we've already applied
34093
+ if (f.dimsMismatch) return 'skip-dims'; // a resize keyframe / boundary path owns dim fixes
34094
+ return 'paint';
34095
+ }
34096
+ function _streamKeyframeAction(s, msg) {
34097
+ if (!s || !msg) return 'skip-no-data';
34098
+ const id = msg.id;
34099
+ const incomingMarker = msg.snapshotCurrentMarker != null ? msg.snapshotCurrentMarker : msg.snapshotMarker;
34100
+ const localCols = s.term ? s.term.cols : 0;
34101
+ const localRows = s.term ? s.term.rows : 0;
34102
+ const ptyCols = msg.ptyCols || msg.cols || localCols;
34103
+ const ptyRows = msg.ptyRows || msg.rows || localRows;
34104
+ let behind = false;
34105
+ try {
34106
+ behind = incomingMarker != null && s._localSeq != null && window.TerminalReconciler
34107
+ && typeof window.TerminalReconciler.seqLess === 'function'
34108
+ && window.TerminalReconciler.seqLess(incomingMarker, s._localSeq);
34109
+ } catch {}
34110
+ return _streamKeyframeActionCore({
34111
+ enabled: _streamKeyframeEnabled() && !!s.term,
34112
+ hasData: !!msg.data,
34113
+ visible: state.activeTab === id || isSessionVisibleInSplit(id),
34114
+ typing: _terminalRecentUserInputActive(s) || _terminalInputDisplayHoldActive(s),
34115
+ busy: !!(s.writer && (s.writer.scheduled || s.writer._chunking || s.writer.queue)),
34116
+ behind: !!behind,
34117
+ dimsMismatch: Math.abs(localCols - ptyCols) > 0 || Math.abs(localRows - ptyRows) > 0,
34118
+ });
34119
+ }
34120
+ window._ctmStreamKeyframeAction = _streamKeyframeAction;
34121
+ // Option A: PULL an authoritative keyframe at the client's CURRENT dims on a render
34122
+ // boundary (activation / resize). Reuses the 'reconcile' request — the server serializes
34123
+ // the headless mirror at our dims and returns a snapshot (sent even if output lands
34124
+ // mid-serialize). This is the deterministic on-boundary I-frame that replaces the
34125
+ // fingerprint-divergence heal paths; the live byte stream still applies between keyframes.
34126
+ function _requestKeyframe(s, id, reason) {
34127
+ if (!_keyframeSyncEnabled() || !s || !s.term || s._exited) return;
34128
+ if (!Number.isFinite(s.term.cols) || !Number.isFinite(s.term.rows)) return;
34129
+ if (state.activeTab !== id && !isSessionVisibleInSplit(id)) return;
34130
+ try { _sendTerminalRestoreRequest(id, 'reconcile', { cols: s.term.cols, rows: s.term.rows }); } catch {}
34131
+ }
34132
+ const KEYFRAME_REQUEST_DEBOUNCE_MS = 160;
34133
+ function _requestKeyframeDebounced(s, id, reason) {
34134
+ if (!_keyframeSyncEnabled() || !s || s._exited) return;
34135
+ if (s._keyframeReqTimer) clearTimeout(s._keyframeReqTimer);
34136
+ s._keyframeReqTimer = setTimeout(() => {
34137
+ s._keyframeReqTimer = null;
34138
+ _requestKeyframe(s, id, reason);
34139
+ }, KEYFRAME_REQUEST_DEBOUNCE_MS);
34140
+ if (s._keyframeReqTimer && s._keyframeReqTimer.unref) s._keyframeReqTimer.unref();
34141
+ }
34142
+ window._ctmRequestKeyframe = _requestKeyframe;
33499
34143
  // Stable latch: set on convergence (_maybeReconcile verdict 'ok'); cleared by
33500
34144
  // _markRenderUnstable on the situation-changed triggers (output / resize / reflow /
33501
34145
  // reconnect). Sole consumer is _scheduleActivationRenderCheck.
33502
- function _markRenderStable(s) {
34146
+ function _terminalActivationStableKey(s, id) {
34147
+ if (!s) return null;
34148
+ const sid = id || s._id || '';
34149
+ const v = _sessionViewState(s, sid);
34150
+ const tuple = _terminalEpochTuple(v || {});
34151
+ return {
34152
+ id: sid,
34153
+ visibility: _sessionVisibility(sid),
34154
+ activationSeq: Number(s._terminalActivationSeq || 0),
34155
+ mutationSeq: Number(s._terminalMutationSeq || 0),
34156
+ localSeq: s._localSeq == null ? null : String(s._localSeq),
34157
+ serverSeq: s._serverSeq == null ? null : String(s._serverSeq),
34158
+ serverFp: s._serverFp || null,
34159
+ serverCols: Number(s._serverFpCols || 0),
34160
+ serverRows: Number(s._serverFpRows || 0),
34161
+ cols: Number(s.term?.cols || 0),
34162
+ rows: Number(s.term?.rows || 0),
34163
+ viewerRole: s._viewerRole || 'primary',
34164
+ contentEpoch: Number(tuple.contentEpoch || 0),
34165
+ layoutEpoch: Number(tuple.layoutEpoch || 0),
34166
+ rendererEpoch: Number(tuple.rendererEpoch || 0),
34167
+ attachEpoch: Number(tuple.attachEpoch || 0),
34168
+ restoreEpoch: Number(tuple.restoreEpoch || 0),
34169
+ restoreRequestSeq: Number(s._terminalRestoreRequestSeq || 0),
34170
+ };
34171
+ }
34172
+ function _markRenderStable(s, id) {
33503
34173
  if (!s) return;
34174
+ const key = _terminalActivationStableKey(s, id || s._id || '');
33504
34175
  s._renderStableSince = window.ActivationRenderCheck
33505
34176
  ? window.ActivationRenderCheck.markStable(Date.now())
33506
34177
  : Date.now();
34178
+ s._renderStableKey = window.ActivationRenderCheck && window.ActivationRenderCheck.markStableKey
34179
+ ? window.ActivationRenderCheck.markStableKey(key)
34180
+ : key;
33507
34181
  }
33508
- function _markRenderUnstable(s) {
34182
+ function _markRenderUnstable(s, reason) {
33509
34183
  if (!s) return;
33510
34184
  s._renderStableSince = window.ActivationRenderCheck
33511
34185
  ? window.ActivationRenderCheck.markUnstable()
33512
34186
  : 0;
34187
+ s._renderStableKey = null;
34188
+ s._renderUnstableReason = reason || '';
33513
34189
  }
33514
34190
  function _scheduleActivationRenderCheck(s, id) {
33515
34191
  if (!s || s._exited) return;
34192
+ const currentKey = _terminalActivationStableKey(s, id || s._id || '');
33516
34193
  const should = window.ActivationRenderCheck
33517
34194
  ? window.ActivationRenderCheck.shouldScheduleCheck({
33518
34195
  enabled: _activationRenderCheckEnabled(),
33519
34196
  renderStableSince: s._renderStableSince,
34197
+ stableKey: s._renderStableKey,
34198
+ currentKey,
33520
34199
  timerPending: !!s._activationRenderCheckTimer,
33521
34200
  })
33522
34201
  : (_activationRenderCheckEnabled() && !s._renderStableSince && !s._activationRenderCheckTimer);
33523
34202
  if (!should) return;
33524
34203
  s._activationRenderCheckTimer = setTimeout(() => {
33525
34204
  s._activationRenderCheckTimer = null;
34205
+ if (!_activationRenderCheckEnabled()) return;
33526
34206
  if (s._exited) return;
33527
34207
  if (state.activeTab !== id && !isSessionVisibleInSplit(id)) return;
33528
34208
  // Run both validators (same ones the loop uses). The FIRST sighting only ARMS a
@@ -35568,6 +36248,20 @@ function _needsAuthoritativeSnapshotRecovery(s) {
35568
36248
  ));
35569
36249
  }
35570
36250
 
36251
+ // Reflow stale-skip backoff: a reflow can come back {stale:true} (output arrived after the
36252
+ // request) or time out. The post-/compact burst case wants a FAST retry (~900ms) so the
36253
+ // render converges as soon as output quiets. But a pathologically large, continuously-
36254
+ // repainting Codex session (12k+ history lines, ~800KB/serialize) NEVER goes quiet — every
36255
+ // retry re-serializes ~800KB, saturating the single worker so a clean frame can never land
36256
+ // (the "fixes then reverts, stays bad" loop). So we back the retry off exponentially per
36257
+ // consecutive non-convergence and RESET the moment a fresh frame lands — fast when it can
36258
+ // converge, patient (giving the worker a quiet window) when it can't.
36259
+ const REFLOW_STALE_BACKOFF_BASE_MS = 900;
36260
+ const REFLOW_STALE_BACKOFF_CAP_MS = 15000;
36261
+ function _reflowStaleBackoffMs(s) {
36262
+ const streak = Math.max(1, Number(s && s._reflowStaleStreak) || 1);
36263
+ return Math.min(REFLOW_STALE_BACKOFF_BASE_MS * Math.pow(2, Math.min(streak - 1, 5)), REFLOW_STALE_BACKOFF_CAP_MS);
36264
+ }
35571
36265
  function _scheduleAuthoritativeSnapshotRecovery(s, id, reason, opts) {
35572
36266
  opts = opts || {};
35573
36267
  if (!s || !id || !s.term) return false;
@@ -35682,6 +36376,18 @@ function _snapshotClientRestoreSeq(msg) {
35682
36376
  return Number.isFinite(seq) && seq > 0 ? seq : 0;
35683
36377
  }
35684
36378
 
36379
+ function _snapshotAuthoritativeMarker(msg) {
36380
+ if (!msg) return null;
36381
+ if (msg.snapshotCurrentMarker != null) return msg.snapshotCurrentMarker;
36382
+ if (msg.snapshotMarker != null) return msg.snapshotMarker;
36383
+ return null;
36384
+ }
36385
+
36386
+ function _snapshotPositiveDimension(value) {
36387
+ const n = Number(value);
36388
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
36389
+ }
36390
+
35685
36391
  function _snapshotRestoreSource(s, msg) {
35686
36392
  const explicit = String((msg && (msg.restoreSource || msg.source)) || '');
35687
36393
  if (explicit) return explicit;
@@ -35749,6 +36455,22 @@ function _recordSnapshotRestoreApplied(s, msg, source) {
35749
36455
  const seq = _snapshotClientRestoreSeq(msg);
35750
36456
  if (seq) s._terminalRestoreAppliedSeq = Math.max(Number(s._terminalRestoreAppliedSeq || 0), seq);
35751
36457
  const restoreSource = source || _snapshotRestoreSource(s, msg);
36458
+ // A non-interim snapshot/reflow is authoritative for the client grid it paints.
36459
+ // Keep the reconciler's server-side fingerprint/seq aligned with that accepted
36460
+ // frame; otherwise the 3s self-check can compare the just-fixed terminal against
36461
+ // a stale heartbeat and request a reconcile that reverts the render a few seconds
36462
+ // after a successful reflow.
36463
+ if (msg.fp) {
36464
+ s._serverFp = String(msg.fp);
36465
+ const cols = _snapshotPositiveDimension(msg.ptyCols) || _snapshotPositiveDimension(msg.cols) || _snapshotPositiveDimension(s.term && s.term.cols);
36466
+ const rows = _snapshotPositiveDimension(msg.ptyRows) || _snapshotPositiveDimension(msg.rows) || _snapshotPositiveDimension(s.term && s.term.rows);
36467
+ if (cols) s._serverFpCols = cols;
36468
+ if (rows) s._serverFpRows = rows;
36469
+ const marker = _snapshotAuthoritativeMarker(msg);
36470
+ if (marker != null) s._serverSeq = marker;
36471
+ s._snapshotAdoptedFpAt = Date.now();
36472
+ s._snapshotAdoptedFpSource = restoreSource;
36473
+ }
35752
36474
  if (restoreSource === 'reflow') {
35753
36475
  s._lastReflowSnapshotAppliedAt = Date.now();
35754
36476
  s._lastReflowSnapshotSeq = seq || Number(s._terminalRestoreRequestSeq || 0);
@@ -35758,6 +36480,7 @@ function _recordSnapshotRestoreApplied(s, msg, source) {
35758
36480
  s._terminalFinalSnapshotProtectedSource = restoreSource;
35759
36481
  }
35760
36482
  }
36483
+ window._ctmRecordSnapshotRestoreApplied = _recordSnapshotRestoreApplied;
35761
36484
 
35762
36485
  function _snapshotRestoreReason(msg) {
35763
36486
  return String((msg && (msg.restoreReason || msg.reason || msg.snapshotReason)) || '');
@@ -35904,6 +36627,58 @@ function onSnapshot(msg) {
35904
36627
  }
35905
36628
  }
35906
36629
  const earlyRestoreSource = _snapshotRestoreSource(s, msg);
36630
+ // Option A idempotency: an authoritative keyframe that already matches the on-screen
36631
+ // frame needs no repaint — skip it so the PRIMARY viewer (which rendered the same output
36632
+ // via the live byte stream) doesn't flash on every output-quiet boundary. fp parity is
36633
+ // guaranteed (terminal-reconciler ↔ terminal-fingerprint). Only when output is quiet
36634
+ // (no pending writes), so we never compare against a mid-stream frame.
36635
+ if (_keyframeSyncEnabled() && (earlyRestoreSource === 'keyframe' || earlyRestoreSource === 'reconcile')
36636
+ && msg.fp && s.term && window.TerminalReconciler && typeof window.TerminalReconciler.clientFingerprint === 'function'
36637
+ && !(s.writer && (s.writer.scheduled || s.writer._chunking || s.writer.queue))) {
36638
+ let clientFp = null;
36639
+ try { clientFp = window.TerminalReconciler.clientFingerprint(s.term); } catch {}
36640
+ if (clientFp && clientFp === msg.fp) {
36641
+ s._keyframeIdempotentSkipAt = Date.now();
36642
+ _terminalRenderCheckFullyRendered(s, 'keyframe-idempotent-skip');
36643
+ _dismissLoadingOverlay(s);
36644
+ return;
36645
+ }
36646
+ }
36647
+ // Option A.5: a streaming keyframe (pushed mid-turn at a controlled frame rate). It must
36648
+ // NOT fall into the late-snapshot rejection gates below (clobber/stale/hot-activation) —
36649
+ // those assume a cold restore and would drop every mid-stream frame. Route it through a
36650
+ // narrow, settled-frame-only apply: paint ONLY when the client frame is quiescent and its
36651
+ // fingerprint has actually diverged (a confirmed byte-stream shear); otherwise defer.
36652
+ // _restoreSnapshotData's own fp-match guard makes the converged case a hard no-op, so this
36653
+ // never flickers a correct frame. Boundary keyframes (activation/resize/quiet) are unchanged.
36654
+ if (msg.restoreReason === 'stream-keyframe') {
36655
+ const action = _streamKeyframeAction(s, msg);
36656
+ s._streamKeyframeLastAction = action;
36657
+ s._streamKeyframeLastActionAt = Date.now();
36658
+ if (action === 'paint') {
36659
+ const intent = _captureTerminalViewportIntent(s);
36660
+ const incomingMarker = msg.snapshotCurrentMarker != null ? msg.snapshotCurrentMarker : msg.snapshotMarker;
36661
+ _restoreSnapshotData(s, msg.data, (info) => {
36662
+ if (!(info && info.skipped)) {
36663
+ if (!_restoreTerminalViewportIntent(s, intent)) { _ensureScrolledToBottom(s); }
36664
+ s._streamKeyframeAppliedAt = Date.now();
36665
+ }
36666
+ // Advance our applied-marker so a later stale frame can't revert this correction.
36667
+ try {
36668
+ if (incomingMarker != null && window.TerminalReconciler
36669
+ && !window.TerminalReconciler.seqLess(incomingMarker, s._localSeq || 0)) {
36670
+ s._localSeq = incomingMarker;
36671
+ }
36672
+ } catch {}
36673
+ _terminalRenderCheckFullyRendered(s, 'stream-keyframe');
36674
+ }, { fp: msg.fp, cols: msg.ptyCols || msg.cols, rows: msg.ptyRows || msg.rows, source: 'stream-keyframe' });
36675
+ } else {
36676
+ _terminalRenderCheckFullyRendered(s, 'stream-keyframe-' + action);
36677
+ }
36678
+ _dismissLoadingOverlay(s);
36679
+ _primeLatestAnswerForTerminalView(msg.id);
36680
+ return;
36681
+ }
35907
36682
  if (_snapshotShouldRejectStaleData(s, msg, earlyRestoreSource)) {
35908
36683
  _dismissLoadingOverlay(s);
35909
36684
  _rememberSkippedSnapshot(s, 'stale-snapshot-data', _snapshotRestoreReason(msg) || earlyRestoreSource);
@@ -35986,6 +36761,20 @@ function onSnapshot(msg) {
35986
36761
  _terminalRenderCheckFullyRendered(s, 'snapshot-stale-no-data-skip');
35987
36762
  if (state.activeTab === msg.id) focusTerminalIfSafe(msg.id);
35988
36763
  _primeLatestAnswerForTerminalView(msg.id);
36764
+ // The server skipped this reflow because output arrived after the request — the agent is
36765
+ // still emitting (e.g. a post-/compact burst). Nothing else re-requests promptly, so the
36766
+ // render (and the user's typed-input echo) can lag until the 3s self-check. Schedule an
36767
+ // authoritative recovery so a fresh reflow lands soon after the output quiets; it
36768
+ // rate-limits itself, defers while typing, and — because each retry that ALSO stale-skips
36769
+ // re-enters this branch — forms a retry-until-settle loop that self-terminates when a fresh
36770
+ // frame lands (snapshot-done clears _needsAuthoritativeSnapshot at ~36319, and resets the
36771
+ // streak below). The retry interval BACKS OFF exponentially per consecutive stale-skip so a
36772
+ // never-quiet giant Codex session stops re-serializing ~800KB every ~900ms (worker
36773
+ // saturation = the frame can never converge); the FIRST stale-skip still retries fast.
36774
+ s._needsAuthoritativeSnapshot = true;
36775
+ s._reflowStaleStreak = (Number(s._reflowStaleStreak) || 0) + 1;
36776
+ const reflowBackoff = _reflowStaleBackoffMs(s);
36777
+ _scheduleAuthoritativeSnapshotRecovery(s, msg.id, 'reflow-stale-skip', { delayMs: reflowBackoff, minGapMs: reflowBackoff });
35989
36778
  return;
35990
36779
  }
35991
36780
  // A hot terminal that already has healthy buffer content does not need a
@@ -36062,6 +36851,10 @@ function onSnapshot(msg) {
36062
36851
  }
36063
36852
  // Reset auto-reflow flag on successful snapshot
36064
36853
  s._autoReflowAttempted = false;
36854
+ // A fresh AUTHORITATIVE (non-interim) frame converged → clear the reflow stale-skip
36855
+ // backoff streak so the next stall retries fast again (interim/cache bridges are stale,
36856
+ // so they must NOT reset it).
36857
+ if (msg.data && !msg.interim) s._reflowStaleStreak = 0;
36065
36858
  const restoreSource = _snapshotRestoreSource(s, msg);
36066
36859
 
36067
36860
  // Sequence guard: never apply a snapshot that is strictly BEHIND what we've
@@ -36277,6 +37070,14 @@ function onSnapshot(msg) {
36277
37070
  const snapshotDone = (info) => {
36278
37071
  _setTerminalViewPhase(s, _terminalRestorePhases().PAINT_SETTLING, 'snapshot-done');
36279
37072
  if (info && info.skipped) {
37073
+ if (info.fpMatch) {
37074
+ _recordSnapshotRestoreApplied(s, msg, restoreSource);
37075
+ const marker = _snapshotAuthoritativeMarker(msg);
37076
+ if (marker != null) s._localSeq = marker;
37077
+ if (msg.restoreReason === 'reconcile' || msg.restoreSource === 'reconcile') {
37078
+ s._reconcileInFlightUntil = 0;
37079
+ }
37080
+ }
36280
37081
  if (snapshotIncludesExitBanner) _markExitBannerRendered(s, msg.exitCode);
36281
37082
  if (state.activeTab === msg.id) focusTerminalIfSafe(msg.id);
36282
37083
  scanPromptLines(msg.id);
@@ -36303,8 +37104,8 @@ function onSnapshot(msg) {
36303
37104
  // Advance the reconciler's local seq to the server's snapshot marker so we
36304
37105
  // don't immediately re-detect divergence against pre-snapshot seq. Prefer the
36305
37106
  // server's current marker; fall back to the request-time marker.
36306
- if (msg.snapshotCurrentMarker != null) s._localSeq = msg.snapshotCurrentMarker;
36307
- else if (msg.snapshotMarker != null) s._localSeq = msg.snapshotMarker;
37107
+ const marker = _snapshotAuthoritativeMarker(msg);
37108
+ if (marker != null) s._localSeq = marker;
36308
37109
  // A reconcile-sourced snapshot is the resolution of an in-flight reconcile;
36309
37110
  // release the guard so future divergence can trigger a fresh request.
36310
37111
  if (msg.restoreReason === 'reconcile' || msg.restoreSource === 'reconcile') {
@@ -36912,6 +37713,14 @@ async function onSessionsList(msg) {
36912
37713
  existing._serverLiveStatusAt = _clientTimeMs(sess.liveStatusAt) || Date.now();
36913
37714
  existing._serverLiveStatusEventAt = liveStatusEventAt;
36914
37715
  }
37716
+ // P2: server-derived phase is the single source of truth (stored unconditionally).
37717
+ if (sess.phase) {
37718
+ existing._serverPhase = sess.phase;
37719
+ existing._serverPhaseCls = sess.phaseCls || '';
37720
+ existing._serverPhaseSource = sess.phaseSource || '';
37721
+ existing._serverPhaseReason = sess.phaseReason || '';
37722
+ existing._serverPhaseAt = _clientTimeMs(sess.phaseAt) || Date.now();
37723
+ }
36915
37724
  existing._restoreResuming = !!(sess.restoreResuming || sess.restore_resuming);
36916
37725
  existing._restoreStarting = !!(sess.restoreStarting || sess.restore_starting);
36917
37726
  existing._restoreStatus = sess.restoreStatus || sess.restore_status || '';
@@ -38755,7 +39564,65 @@ function _walleSessionHasRunningWork(s) {
38755
39564
  return active.some(t => String(t && t.status || '').toLowerCase() === 'running');
38756
39565
  }
38757
39566
 
39567
+ // P2 (redesign — docs/session-status-redesign.html): the server's derived phase is
39568
+ // the single source of truth. The client renders it directly and may apply ONLY an
39569
+ // optimistic, escalate-only overlay (idle→running for instant local feedback). It
39570
+ // NEVER demotes or second-guesses the server. This supersedes both the keyframe
39571
+ // liveStatus shortcut and the precedence ladder below, which remain as the degraded
39572
+ // fallback for when no fresh server phase is available (just-opened tab, dropped ws).
39573
+ const SERVER_PHASE_TTL_MS = 15000;
39574
+
39575
+ // Escalate-only: a fresh keystroke or local PTY byte may lift a server idle to
39576
+ // running until the next server push reconciles it. Never the reverse.
39577
+ function _clientHasFreshOptimisticRunning(s, now) {
39578
+ now = now || Date.now();
39579
+ const lastInput = s._lastStatusInputAt || 0;
39580
+ const lastOut = Math.max(s._lastOutputAt || 0, s.meta?.lastPtyActivity || 0);
39581
+ return (lastInput && (now - lastInput) < 3000) || (lastOut && (now - lastOut) < 5000);
39582
+ }
39583
+
39584
+ function _clientServerPhaseStatus(s) {
39585
+ if (typeof SessionPhase === 'undefined' || !s || !s._serverPhase || !s._serverPhaseAt) return null;
39586
+ const now = Date.now();
39587
+ if ((now - s._serverPhaseAt) >= SERVER_PHASE_TTL_MS) return null; // stale → fall back
39588
+ let phase = s._serverPhase;
39589
+ if (phase === 'idle' && _clientHasFreshOptimisticRunning(s, now)) phase = 'running';
39590
+ const cls = SessionPhase.phaseToCls(phase);
39591
+ if (!cls) return null;
39592
+ return {
39593
+ cls,
39594
+ text: SessionPhase.PHASE_TEXT[phase] || 'Idle',
39595
+ source: 'server-phase:' + (s._serverPhaseSource || phase),
39596
+ phase,
39597
+ };
39598
+ }
39599
+
38758
39600
  function getSessionStatus(s) {
39601
+ // Single source of truth: render the server's derived phase when fresh.
39602
+ if (s && !(s._exited || s.meta?.liveStatus === 'exited' || s.meta?.status === 'exited')) {
39603
+ const serverPhase = _clientServerPhaseStatus(s);
39604
+ if (serverPhase) return statusAfterAttentionDismissal(s, serverPhase);
39605
+ }
39606
+ // Phase 4 (Option C): ONE status owner. With keyframe-sync on, trust the server's
39607
+ // authoritative liveStatus directly when fresh — skipping the client-side busy-line /
39608
+ // running-hold inference ladder (the "second state machine"). The server already computes
39609
+ // a complete status (_standupLiveStatusWithReasonForSession). Exited + Wall-E special
39610
+ // cases still apply; an explicit server-flagged waiting prompt still wins over a generic
39611
+ // idle heartbeat; and if the server status is missing/stale we fall through to the
39612
+ // existing resolver as a safety net. Flag-off keeps the legacy precedence machine.
39613
+ if (_keyframeSyncEnabled() && s
39614
+ && !(s._exited || s.meta?.liveStatus === 'exited' || s.meta?.status === 'exited')
39615
+ && s.meta?.type !== 'walle') {
39616
+ const now = Date.now();
39617
+ const live = liveStatusResult(s._serverLiveStatus);
39618
+ if (live && s._serverLiveStatusAt && (now - s._serverLiveStatusAt) < SERVER_LIVE_STATUS_TTL_MS) {
39619
+ if (live.cls === 'idle' && s._waitingForInput) {
39620
+ return statusAfterAttentionDismissal(s, { cls: 'waiting', text: 'Needs You' });
39621
+ }
39622
+ return statusAfterAttentionDismissal(s, live);
39623
+ }
39624
+ // else: server status silent/stale → fall through to the full resolver below.
39625
+ }
38759
39626
  if (typeof SessionStatusPrecedence !== 'undefined' && SessionStatusPrecedence.resolveSessionStatus) {
38760
39627
  const now = Date.now();
38761
39628
  const codexRunningHold = _clientCodexRunningHoldResult(s, now);
@@ -48514,12 +49381,26 @@ function onSessionActivity(msg) {
48514
49381
  waitingForInput,
48515
49382
  waitingForInputAt,
48516
49383
  waitingReason,
49384
+ phase,
49385
+ phaseCls,
49386
+ phaseSource,
49387
+ phaseReason,
48517
49388
  tokens,
48518
49389
  } of msg.sessions) {
48519
49390
  const s = state.sessions.get(id);
48520
49391
  if (!s) continue;
48521
49392
  if (tokens !== undefined) _applySessionTokens(s, tokens);
48522
49393
  s._serverLiveStatusReason = statusReason || status_reason || '';
49394
+ // P2: the server's derived phase is the single source of truth. Store it
49395
+ // unconditionally — the server already did all staleness/authority reasoning,
49396
+ // so the client must NOT re-gate or second-guess it here.
49397
+ if (phase) {
49398
+ s._serverPhase = phase;
49399
+ s._serverPhaseCls = phaseCls || '';
49400
+ s._serverPhaseSource = phaseSource || '';
49401
+ s._serverPhaseReason = phaseReason || '';
49402
+ s._serverPhaseAt = Date.now();
49403
+ }
48523
49404
  const oldBucket = activeActivityBucket(s);
48524
49405
  const oldStatus = getSessionStatus(s).cls;
48525
49406
  const hasServerTs = ts !== null && ts !== undefined && ts !== '';