create-walle 0.9.11 → 0.9.13

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 (167) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/template/bin/dev.sh +7 -1
  4. package/template/bin/setup.js +53 -9
  5. package/template/bin/sync-images.js +53 -0
  6. package/template/builder-journal.md +17 -0
  7. package/template/claude-task-manager/api-prompts.js +98 -13
  8. package/template/claude-task-manager/api-reviews.js +82 -5
  9. package/template/claude-task-manager/db.js +32 -5
  10. package/template/claude-task-manager/docs/session-capture-foundation-design.md +1273 -0
  11. package/template/claude-task-manager/lib/claude-desktop-sessions.js +696 -0
  12. package/template/claude-task-manager/lib/coding-agent-models.js +49 -1
  13. package/template/claude-task-manager/lib/session-capture.js +421 -0
  14. package/template/claude-task-manager/lib/session-history.js +135 -15
  15. package/template/claude-task-manager/lib/session-jobs.js +10 -5
  16. package/template/claude-task-manager/lib/session-stream.js +87 -19
  17. package/template/claude-task-manager/lib/setup-provider-config.js +115 -0
  18. package/template/claude-task-manager/lib/walle-ctm-history.js +72 -0
  19. package/template/claude-task-manager/lib/walle-session-context.js +61 -0
  20. package/template/claude-task-manager/lib/walle-transcript.js +176 -0
  21. package/template/claude-task-manager/public/css/setup.css +35 -8
  22. package/template/claude-task-manager/public/css/walle-session.css +56 -0
  23. package/template/claude-task-manager/public/css/walle.css +120 -0
  24. package/template/claude-task-manager/public/index.html +814 -181
  25. package/template/claude-task-manager/public/js/message-renderer.js +148 -19
  26. package/template/claude-task-manager/public/js/reviews.js +120 -62
  27. package/template/claude-task-manager/public/js/setup.js +75 -31
  28. package/template/claude-task-manager/public/js/stream-view.js +115 -55
  29. package/template/claude-task-manager/public/js/walle-session.js +84 -2
  30. package/template/claude-task-manager/public/js/walle.js +308 -54
  31. package/template/claude-task-manager/server.js +1092 -146
  32. package/template/claude-task-manager/session-integrity.js +181 -54
  33. package/template/claude-task-manager/session-utils.js +123 -41
  34. package/template/claude-task-manager/workers/state-detectors/codex.js +5 -2
  35. package/template/package.json +1 -1
  36. package/template/wall-e/adapters/ctm.js +39 -18
  37. package/template/wall-e/agent-runners/contract.js +17 -0
  38. package/template/wall-e/agent-runners/index.js +22 -0
  39. package/template/wall-e/agent-runtime/harness.js +212 -0
  40. package/template/wall-e/agent-runtime/index.js +8 -0
  41. package/template/wall-e/agent-runtime/registry.js +67 -0
  42. package/template/wall-e/agent-runtime/session-store.js +179 -0
  43. package/template/wall-e/agent-runtime/spawn.js +208 -0
  44. package/template/wall-e/api-walle.js +174 -7
  45. package/template/wall-e/brain.js +266 -28
  46. package/template/wall-e/channels/policy.js +88 -0
  47. package/template/wall-e/channels/registry.js +15 -1
  48. package/template/wall-e/channels/reply-dispatcher.js +70 -0
  49. package/template/wall-e/channels/session-bindings.js +51 -0
  50. package/template/wall-e/chat/code-review-context.js +29 -0
  51. package/template/wall-e/chat.js +188 -42
  52. package/template/wall-e/coding/acp-adapter.js +188 -0
  53. package/template/wall-e/coding/agent-catalog.js +129 -0
  54. package/template/wall-e/coding/compaction-service.js +247 -0
  55. package/template/wall-e/coding/execution-trace.js +3 -0
  56. package/template/wall-e/coding/instruction-service.js +224 -0
  57. package/template/wall-e/coding/model-message.js +67 -0
  58. package/template/wall-e/coding/permission-rules-store.js +111 -0
  59. package/template/wall-e/coding/permission-service.js +266 -0
  60. package/template/wall-e/coding/prompt-bundle.js +67 -0
  61. package/template/wall-e/coding/prompt-runtime.js +243 -0
  62. package/template/wall-e/coding/provider-transform.js +188 -0
  63. package/template/wall-e/coding/runtime-mode.js +132 -0
  64. package/template/wall-e/coding/snapshot-service.js +155 -0
  65. package/template/wall-e/coding/stream-processor.js +268 -0
  66. package/template/wall-e/coding/task-tool.js +255 -0
  67. package/template/wall-e/coding/tool-registry.js +361 -0
  68. package/template/wall-e/coding/transcript-writer.js +143 -0
  69. package/template/wall-e/coding/workspace-replay.js +324 -0
  70. package/template/wall-e/coding-context.js +4 -22
  71. package/template/wall-e/coding-orchestrator.js +307 -18
  72. package/template/wall-e/coding-prompts.js +44 -3
  73. package/template/wall-e/context/context-builder.js +43 -1
  74. package/template/wall-e/context/topic-matcher.js +1 -1
  75. package/template/wall-e/eval/agent-runner.js +59 -13
  76. package/template/wall-e/eval/benchmarks/memory-retrieval.json +155 -57
  77. package/template/wall-e/eval/benchmarks.js +100 -16
  78. package/template/wall-e/eval/eval-orchestrator.js +218 -8
  79. package/template/wall-e/eval/harvester.js +62 -5
  80. package/template/wall-e/eval/head-to-head.js +23 -2
  81. package/template/wall-e/eval/humaneval-adapter.js +30 -5
  82. package/template/wall-e/eval/livecodebench-adapter.js +29 -5
  83. package/template/wall-e/eval/manifest.js +186 -0
  84. package/template/wall-e/eval/run-agent-benchmarks.js +66 -2
  85. package/template/wall-e/eval/session-retrieval-benchmark.js +150 -0
  86. package/template/wall-e/eval/session-transcripts.js +57 -4
  87. package/template/wall-e/eval/swebench-adapter.js +109 -3
  88. package/template/wall-e/evaluation/agent-router.js +53 -1
  89. package/template/wall-e/evaluation/coding-quorum.js +48 -1
  90. package/template/wall-e/evaluation/router.js +4 -2
  91. package/template/wall-e/evaluation/tier-selector.js +11 -1
  92. package/template/wall-e/extraction/contradiction.js +2 -2
  93. package/template/wall-e/extraction/indexer.js +2 -1
  94. package/template/wall-e/extraction/knowledge-extractor.js +2 -2
  95. package/template/wall-e/hooks/cli.js +92 -0
  96. package/template/wall-e/hooks/discovery.js +119 -0
  97. package/template/wall-e/hooks/index.js +7 -0
  98. package/template/wall-e/hooks/manifest.js +55 -0
  99. package/template/wall-e/hooks/runtime.js +84 -0
  100. package/template/wall-e/hooks/session-memory.js +225 -0
  101. package/template/wall-e/http/auth.js +6 -2
  102. package/template/wall-e/http/chat-api.js +54 -8
  103. package/template/wall-e/integrations/claude-plugin/hooks/hooks.json +27 -0
  104. package/template/wall-e/integrations/claude-plugin/hooks/walle-precompact-hook.sh +5 -0
  105. package/template/wall-e/integrations/claude-plugin/hooks/walle-stop-hook.sh +5 -0
  106. package/template/wall-e/integrations/codex-plugin/hooks/walle-hook.sh +7 -0
  107. package/template/wall-e/integrations/codex-plugin/hooks.json +37 -0
  108. package/template/wall-e/listening/calendar.js +3 -1
  109. package/template/wall-e/llm/client.js +64 -10
  110. package/template/wall-e/llm/google.js +39 -5
  111. package/template/wall-e/llm/ollama.js +1 -1
  112. package/template/wall-e/llm/ollama.plugin.json +1 -1
  113. package/template/wall-e/llm/provider-availability.js +10 -0
  114. package/template/wall-e/llm/provider-error.js +269 -0
  115. package/template/wall-e/llm/tool-adapter.js +48 -12
  116. package/template/wall-e/loops/boot.js +2 -1
  117. package/template/wall-e/loops/initiative.js +2 -2
  118. package/template/wall-e/loops/tasks.js +8 -47
  119. package/template/wall-e/loops/workspace-prompts.js +20 -0
  120. package/template/wall-e/mcp-server.js +442 -1
  121. package/template/wall-e/memory/session-ingest-service.js +159 -0
  122. package/template/wall-e/memory/source-indexer.js +289 -0
  123. package/template/wall-e/plugins/discovery.js +83 -0
  124. package/template/wall-e/plugins/manifest-loader.js +50 -10
  125. package/template/wall-e/plugins/manifest-schema.js +69 -0
  126. package/template/wall-e/plugins/model-catalog.js +55 -0
  127. package/template/wall-e/prompts/coding/base.txt +2 -0
  128. package/template/wall-e/prompts/coding/deepseek.txt +1 -0
  129. package/template/wall-e/prompts/coding/memory-protocol.md +9 -0
  130. package/template/wall-e/prompts/coding/plan.txt +1 -0
  131. package/template/wall-e/runtime/execution-trace.js +220 -0
  132. package/template/wall-e/security/audit.js +266 -0
  133. package/template/wall-e/security/ssrf.js +236 -0
  134. package/template/wall-e/session-files.js +303 -0
  135. package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +3 -0
  136. package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +3 -0
  137. package/template/wall-e/skills/internal-skill-registry.js +2 -2
  138. package/template/wall-e/skills/script-skill-runner.js +143 -0
  139. package/template/wall-e/skills/skill-executor.js +5 -6
  140. package/template/wall-e/skills/skill-fallback.js +3 -1
  141. package/template/wall-e/skills/skill-harness-registry.js +7 -8
  142. package/template/wall-e/skills/skill-planner.js +52 -4
  143. package/template/wall-e/skills/slack-ingest.js +11 -3
  144. package/template/wall-e/sources/base.js +90 -0
  145. package/template/wall-e/sources/builtin.js +33 -0
  146. package/template/wall-e/sources/claude-code-jsonl.js +78 -0
  147. package/template/wall-e/sources/codex-jsonl.js +125 -0
  148. package/template/wall-e/sources/coding-session-utils.js +117 -0
  149. package/template/wall-e/sources/contract-suite.js +59 -0
  150. package/template/wall-e/sources/gemini-jsonl.js +85 -0
  151. package/template/wall-e/sources/index.js +9 -0
  152. package/template/wall-e/sources/jsonl-utils.js +181 -0
  153. package/template/wall-e/sources/record-types.js +252 -0
  154. package/template/wall-e/sources/registry.js +92 -0
  155. package/template/wall-e/sources/transforms.js +100 -0
  156. package/template/wall-e/sources/walle-jsonl.js +108 -0
  157. package/template/wall-e/tools/coding-middleware.js +31 -1
  158. package/template/wall-e/tools/file-tracker.js +25 -1
  159. package/template/wall-e/tools/local-tools.js +75 -47
  160. package/template/wall-e/tools/session-sharing.js +68 -1
  161. package/template/wall-e/tools/shell-analyzer.js +1 -1
  162. package/template/wall-e/tools/shell-policy.js +47 -0
  163. package/template/wall-e/tools/snapshot.js +42 -0
  164. package/template/wall-e/training/harvester.js +62 -5
  165. package/template/wall-e/utils/repair.js +253 -1
  166. package/template/website/index.html +3 -3
  167. package/template/wall-e/skills/_bundled/slack-mentions/.watched-threads.json +0 -18
@@ -191,6 +191,15 @@
191
191
  transition: all 0.15s;
192
192
  }
193
193
  .btn:hover { background: var(--border); }
194
+ .btn[aria-disabled="true"],
195
+ .btn:disabled {
196
+ opacity: 0.55;
197
+ cursor: not-allowed;
198
+ }
199
+ .btn[aria-disabled="true"]:hover,
200
+ .btn:disabled:hover {
201
+ background: var(--bg-lighter);
202
+ }
194
203
  .pe-copilot-tab.active { background: var(--accent); color: #1a1b26; border-color: var(--accent); }
195
204
  .pe-copilot-tab.active:hover { background: var(--accent-hover); }
196
205
  .btn.primary { background: var(--accent); color: #1a1b26; border-color: var(--accent); }
@@ -302,6 +311,7 @@
302
311
  .session-item.active .idle-hint { color: #1a1b26; opacity: 0.6; }
303
312
  .session-item .label {
304
313
  flex: 1;
314
+ min-width: 0;
305
315
  overflow: hidden;
306
316
  text-overflow: ellipsis;
307
317
  white-space: nowrap;
@@ -414,6 +424,10 @@
414
424
  #session-list .agent-badge { display: none; }
415
425
  /* Branch badge */
416
426
  .branch-badge {
427
+ display: inline-block;
428
+ max-width: 92px;
429
+ overflow: hidden;
430
+ text-overflow: ellipsis;
417
431
  font-size: 9px;
418
432
  background: rgba(125, 211, 252, 0.15);
419
433
  color: #7dd3fc;
@@ -423,7 +437,38 @@
423
437
  flex-shrink: 0;
424
438
  margin-left: 2px;
425
439
  }
440
+ .session-item.has-worktree-attn .branch-badge { max-width: 60px; }
426
441
  .session-item.active .branch-badge { color: #0ea5e9; background: rgba(14, 165, 233, 0.15); }
442
+ .worktree-attn-badge {
443
+ display: inline-flex;
444
+ align-items: center;
445
+ gap: 4px;
446
+ min-width: 0;
447
+ max-width: 70px;
448
+ font-size: 9px;
449
+ font-weight: 700;
450
+ line-height: 1.3;
451
+ letter-spacing: 0.01em;
452
+ background: rgba(245, 158, 11, 0.14);
453
+ border: 1px solid rgba(245, 158, 11, 0.28);
454
+ color: #f6c177;
455
+ padding: 1px 5px;
456
+ border-radius: 5px;
457
+ white-space: nowrap;
458
+ overflow: hidden;
459
+ text-overflow: ellipsis;
460
+ flex-shrink: 0;
461
+ }
462
+ .worktree-attn-badge .wt-part {
463
+ display: inline-flex;
464
+ align-items: center;
465
+ gap: 1px;
466
+ }
467
+ .session-item.active .worktree-attn-badge {
468
+ background: rgba(245, 158, 11, 0.28);
469
+ border-color: rgba(120, 53, 15, 0.22);
470
+ color: #3f2a05;
471
+ }
427
472
  /* cwd conflict banner */
428
473
  .cwd-conflict-banner {
429
474
  background: rgba(251, 191, 36, 0.12);
@@ -997,6 +1042,18 @@
997
1042
  opacity: 0.6;
998
1043
  }
999
1044
  .thought-group:hover { opacity: 0.8; }
1045
+ .thought-group.tool-activity-group {
1046
+ background: rgba(224, 175, 104, 0.025);
1047
+ border-left-color: rgba(224, 175, 104, 0.32);
1048
+ }
1049
+ .thought-group.tool-activity-group .thought-group-label {
1050
+ color: var(--yellow);
1051
+ }
1052
+ .thought-group.tool-activity-group .thought-group-preview {
1053
+ font-family: 'SF Mono', ui-monospace, monospace;
1054
+ font-style: normal;
1055
+ font-size: 10.5px;
1056
+ }
1000
1057
  .thought-group .thought-group-header {
1001
1058
  padding: 6px 12px;
1002
1059
  cursor: pointer;
@@ -1065,6 +1122,14 @@
1065
1122
  .thought-group .thought-group-items .review-msg .msg-header {
1066
1123
  margin-bottom: 2px;
1067
1124
  }
1125
+ .thought-group.tool-activity-group .thought-group-items .review-msg {
1126
+ background: rgba(224, 175, 104, 0.025);
1127
+ }
1128
+ .thought-group .tool-result-item .msg-text {
1129
+ color: var(--fg-dim);
1130
+ font-family: 'SF Mono', ui-monospace, monospace;
1131
+ font-size: 11.5px;
1132
+ }
1068
1133
 
1069
1134
  /* Summary/system messages */
1070
1135
  .review-msg.summary {
@@ -2090,6 +2155,78 @@
2090
2155
  gap: 8px;
2091
2156
  margin-top: 16px;
2092
2157
  }
2158
+ .update-wizard-overlay { z-index: 130; }
2159
+ .update-wizard-modal {
2160
+ width: min(520px, 92vw);
2161
+ min-width: 0;
2162
+ padding: 0;
2163
+ overflow: hidden;
2164
+ }
2165
+ .update-wizard-head {
2166
+ display: flex;
2167
+ gap: 12px;
2168
+ padding: 18px 20px 14px;
2169
+ border-bottom: 1px solid var(--border);
2170
+ background: linear-gradient(135deg, rgba(122,162,247,0.14), rgba(187,154,247,0.07));
2171
+ }
2172
+ .update-wizard-icon {
2173
+ width: 34px;
2174
+ height: 34px;
2175
+ border-radius: 8px;
2176
+ background: var(--accent);
2177
+ color: #1a1b26;
2178
+ display: flex;
2179
+ align-items: center;
2180
+ justify-content: center;
2181
+ font-size: 18px;
2182
+ font-weight: 800;
2183
+ flex-shrink: 0;
2184
+ }
2185
+ .update-wizard-title h3 { margin: 0 0 4px; font-size: 16px; }
2186
+ .update-wizard-title p { margin: 0; color: var(--fg-dim); font-size: 12px; line-height: 1.5; }
2187
+ .update-wizard-body { padding: 16px 20px 18px; }
2188
+ .update-version-grid {
2189
+ display: grid;
2190
+ grid-template-columns: 1fr 1fr;
2191
+ gap: 8px;
2192
+ margin-bottom: 12px;
2193
+ }
2194
+ .update-version-cell {
2195
+ border: 1px solid var(--border);
2196
+ border-radius: 8px;
2197
+ padding: 10px 12px;
2198
+ background: var(--bg);
2199
+ }
2200
+ .update-version-cell span {
2201
+ display: block;
2202
+ color: var(--fg-dim);
2203
+ font-size: 10px;
2204
+ text-transform: uppercase;
2205
+ letter-spacing: 0.04em;
2206
+ margin-bottom: 4px;
2207
+ }
2208
+ .update-version-cell strong {
2209
+ font-family: 'SF Mono', 'Fira Code', monospace;
2210
+ color: var(--fg);
2211
+ font-size: 13px;
2212
+ }
2213
+ .update-wizard-note {
2214
+ color: var(--fg-dim);
2215
+ font-size: 12px;
2216
+ line-height: 1.5;
2217
+ margin: 0;
2218
+ }
2219
+ .update-wizard-actions {
2220
+ display: flex;
2221
+ justify-content: flex-end;
2222
+ gap: 8px;
2223
+ margin-top: 16px;
2224
+ }
2225
+ @media (max-width: 520px) {
2226
+ .update-version-grid { grid-template-columns: 1fr; }
2227
+ .update-wizard-actions { flex-wrap: wrap; }
2228
+ .update-wizard-actions .btn { flex: 1 1 auto; }
2229
+ }
2093
2230
 
2094
2231
  /* Agent Picker Grid */
2095
2232
  .ns-agent-grid {
@@ -2827,6 +2964,35 @@
2827
2964
  <button id="update-apply-btn" onclick="applyUpdate()" style="background:#7aa2f7;color:#1a1b26;border:none;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;">Update Now</button>
2828
2965
  <button onclick="dismissUpdate()" style="background:none;border:none;color:var(--fg-dim);cursor:pointer;margin-left:auto;opacity:0.6;font-size:14px;">&times;</button>
2829
2966
  </div>
2967
+ <div class="modal-overlay update-wizard-overlay hidden" id="update-wizard" role="dialog" aria-modal="true" aria-labelledby="update-wizard-heading">
2968
+ <div class="modal update-wizard-modal">
2969
+ <div class="update-wizard-head">
2970
+ <div class="update-wizard-icon">&#x2191;</div>
2971
+ <div class="update-wizard-title">
2972
+ <h3 id="update-wizard-heading">Upgrade CTM?</h3>
2973
+ <p>A newer CTM release is available.</p>
2974
+ </div>
2975
+ </div>
2976
+ <div class="update-wizard-body">
2977
+ <div class="update-version-grid">
2978
+ <div class="update-version-cell">
2979
+ <span>Installed</span>
2980
+ <strong id="update-current-version">v?</strong>
2981
+ </div>
2982
+ <div class="update-version-cell">
2983
+ <span>Available</span>
2984
+ <strong id="update-latest-version">v?</strong>
2985
+ </div>
2986
+ </div>
2987
+ <p class="update-wizard-note">The updater will run in the background and CTM will restart when the upgrade is ready.</p>
2988
+ <div class="update-wizard-actions">
2989
+ <button class="btn" onclick="snoozeUpdateWizard()">Later</button>
2990
+ <button class="btn" onclick="dismissUpdate()">Skip This Version</button>
2991
+ <button class="btn primary" id="update-wizard-apply-btn" onclick="applyUpdate()">Upgrade CTM</button>
2992
+ </div>
2993
+ </div>
2994
+ </div>
2995
+ </div>
2830
2996
  <div id="main">
2831
2997
  <div id="sidebar">
2832
2998
  <div class="sidebar-section">
@@ -3035,7 +3201,7 @@
3035
3201
  </div>
3036
3202
  <div style="display:flex;gap:8px;align-items:center;">
3037
3203
  <button id="wt-prune-all-btn" class="btn" style="display:none;font-size:12px;padding:6px 10px;color:var(--red,#f7768e);border-color:var(--red,#f7768e);" onclick="submitPruneGhosts()" title="Remove ghost worktrees with corrupt or missing paths">🗑 Prune ghosts</button>
3038
- <button id="wt-sync-all-btn" class="btn" style="display:none;font-size:12px;padding:6px 10px;background:rgba(224,175,104,0.10);color:#e0af68;border:1px solid rgba(224,175,104,0.35);" onclick="submitSyncAllWorktrees()" title="Sync every clean branch that is behind main">↓ Sync all</button>
3204
+ <button id="wt-sync-all-btn" class="btn" style="display:none;font-size:12px;padding:6px 10px;background:rgba(224,175,104,0.10);color:#e0af68;border:1px solid rgba(224,175,104,0.35);" onclick="submitSyncAllWorktrees()" title="Sync every clean inactive branch that is behind main">↓ Sync all</button>
3039
3205
  <button class="btn" style="font-size:12px;padding:6px 10px;" onclick="loadWorktreesPanel()" title="Refresh worktree list">↻</button>
3040
3206
  <button class="btn primary" style="font-size:12px;padding:6px 14px;" onclick="showCreateWorktreeDialog()">+ New Worktree</button>
3041
3207
  </div>
@@ -3095,7 +3261,7 @@
3095
3261
  <span class="pcard-default-tag" data-default-tag="anthropic" style="display:none;font-size:10px;background:var(--accent);color:#0d1117;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.5px;">DEFAULT</span>
3096
3262
  </div>
3097
3263
  <div style="display:flex;align-items:center;gap:8px;">
3098
- <button class="pcard-star" data-default-btn="anthropic" title="Set as default provider" aria-label="Set Anthropic as default" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3264
+ <button class="pcard-star" data-default-btn="anthropic" title="Set as default Wall-E provider" aria-label="Set Anthropic as default Wall-E provider" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3099
3265
  <label class="pcard-toggle" style="position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;">
3100
3266
  <input type="checkbox" data-toggle="anthropic" style="opacity:0;width:0;height:0;">
3101
3267
  <span class="pcard-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--border);border-radius:20px;transition:0.2s;"></span>
@@ -3183,7 +3349,7 @@
3183
3349
  <span class="pcard-default-tag" data-default-tag="openai" style="display:none;font-size:10px;background:var(--accent);color:#0d1117;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.5px;">DEFAULT</span>
3184
3350
  </div>
3185
3351
  <div style="display:flex;align-items:center;gap:8px;">
3186
- <button class="pcard-star" data-default-btn="openai" title="Set as default provider" aria-label="Set OpenAI as default" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3352
+ <button class="pcard-star" data-default-btn="openai" title="Set as default Wall-E provider" aria-label="Set OpenAI as default Wall-E provider" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3187
3353
  <label class="pcard-toggle" style="position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;">
3188
3354
  <input type="checkbox" data-toggle="openai" style="opacity:0;width:0;height:0;">
3189
3355
  <span class="pcard-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--border);border-radius:20px;transition:0.2s;"></span>
@@ -3245,7 +3411,7 @@
3245
3411
  <span class="pcard-default-tag" data-default-tag="google" style="display:none;font-size:10px;background:var(--accent);color:#0d1117;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.5px;">DEFAULT</span>
3246
3412
  </div>
3247
3413
  <div style="display:flex;align-items:center;gap:8px;">
3248
- <button class="pcard-star" data-default-btn="google" title="Set as default provider" aria-label="Set Google as default" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3414
+ <button class="pcard-star" data-default-btn="google" title="Set as default Wall-E provider" aria-label="Set Google as default Wall-E provider" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3249
3415
  <label class="pcard-toggle" style="position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;">
3250
3416
  <input type="checkbox" data-toggle="google" style="opacity:0;width:0;height:0;">
3251
3417
  <span class="pcard-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--border);border-radius:20px;transition:0.2s;"></span>
@@ -3306,7 +3472,7 @@
3306
3472
  <span class="pcard-default-tag" data-default-tag="ollama" style="display:none;font-size:10px;background:var(--accent);color:#0d1117;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.5px;">DEFAULT</span>
3307
3473
  </div>
3308
3474
  <div style="display:flex;align-items:center;gap:8px;">
3309
- <button class="pcard-star" data-default-btn="ollama" title="Set as default provider" aria-label="Set Ollama as default" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3475
+ <button class="pcard-star" data-default-btn="ollama" title="Set as default Wall-E provider" aria-label="Set Ollama as default Wall-E provider" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3310
3476
  <label class="pcard-toggle" style="position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;">
3311
3477
  <input type="checkbox" data-toggle="ollama" style="opacity:0;width:0;height:0;">
3312
3478
  <span class="pcard-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--border);border-radius:20px;transition:0.2s;"></span>
@@ -3349,7 +3515,7 @@
3349
3515
  <span class="pcard-default-tag" data-default-tag="deepseek" style="display:none;font-size:10px;background:var(--accent);color:#0d1117;padding:1px 6px;border-radius:3px;font-weight:600;letter-spacing:0.5px;">DEFAULT</span>
3350
3516
  </div>
3351
3517
  <div style="display:flex;align-items:center;gap:8px;">
3352
- <button class="pcard-star" data-default-btn="deepseek" title="Set as default provider" aria-label="Set DeepSeek as default" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3518
+ <button class="pcard-star" data-default-btn="deepseek" title="Set as default Wall-E provider" aria-label="Set DeepSeek as default Wall-E provider" style="background:none;border:none;cursor:pointer;font-size:18px;color:var(--fg-dim);padding:0 4px;line-height:1;">☆</button>
3353
3519
  <label class="pcard-toggle" style="position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;">
3354
3520
  <input type="checkbox" data-toggle="deepseek" style="opacity:0;width:0;height:0;">
3355
3521
  <span class="pcard-slider" style="position:absolute;cursor:pointer;inset:0;background:var(--border);border-radius:20px;transition:0.2s;"></span>
@@ -4290,32 +4456,102 @@ function showCwdConflict(msg) {
4290
4456
  }
4291
4457
 
4292
4458
  // --- Update Banner ---
4293
- let _updateDismissedVersion = localStorage.getItem('update_dismissed_version') || '';
4459
+ function _safeStorageGet(storage, key) {
4460
+ try { return storage.getItem(key) || ''; } catch { return ''; }
4461
+ }
4462
+ function _safeStorageSet(storage, key, value) {
4463
+ try { storage.setItem(key, value); } catch {}
4464
+ }
4465
+
4466
+ let _updateDismissedVersion = _safeStorageGet(localStorage, 'update_dismissed_version');
4467
+ let _updateWizardSnoozedVersion = _safeStorageGet(sessionStorage, 'update_wizard_snoozed_version');
4468
+ let _updateCurrentVersion = '';
4469
+ let _updateLatestVersion = '';
4470
+ let _updateApplying = false;
4471
+
4472
+ function _setUpdateVersions(current, latest) {
4473
+ _updateCurrentVersion = current || '';
4474
+ _updateLatestVersion = latest || '';
4475
+
4476
+ const banner = document.getElementById('update-banner');
4477
+ if (banner) {
4478
+ banner.dataset.currentVersion = _updateCurrentVersion;
4479
+ banner.dataset.latestVersion = _updateLatestVersion;
4480
+ }
4481
+
4482
+ const cur = document.getElementById('update-current-version');
4483
+ const lat = document.getElementById('update-latest-version');
4484
+ if (cur) cur.textContent = _updateCurrentVersion ? `v${_updateCurrentVersion}` : 'v?';
4485
+ if (lat) lat.textContent = _updateLatestVersion ? `v${_updateLatestVersion}` : 'v?';
4486
+ }
4487
+
4488
+ function _hideUpdateWizard() {
4489
+ const wizard = document.getElementById('update-wizard');
4490
+ if (wizard) wizard.classList.add('hidden');
4491
+ }
4492
+
4493
+ function _hideUpdatePrompts() {
4494
+ const banner = document.getElementById('update-banner');
4495
+ if (banner) banner.style.display = 'none';
4496
+ _hideUpdateWizard();
4497
+ }
4498
+
4499
+ function _setUpdateApplying(applying) {
4500
+ _updateApplying = applying;
4501
+ const bannerBtn = document.getElementById('update-apply-btn');
4502
+ const wizardBtn = document.getElementById('update-wizard-apply-btn');
4503
+ if (bannerBtn) {
4504
+ bannerBtn.textContent = applying ? 'Updating...' : 'Update Now';
4505
+ bannerBtn.disabled = applying;
4506
+ }
4507
+ if (wizardBtn) {
4508
+ wizardBtn.textContent = applying ? 'Updating...' : 'Upgrade CTM';
4509
+ wizardBtn.disabled = applying;
4510
+ }
4511
+ }
4294
4512
 
4295
4513
  function showUpdateBanner(current, latest) {
4296
- if (_updateDismissedVersion === latest) return;
4514
+ if (!latest || _updateDismissedVersion === latest) return;
4515
+ _setUpdateVersions(current, latest);
4297
4516
  const banner = document.getElementById('update-banner');
4298
4517
  const msg = document.getElementById('update-banner-msg');
4299
4518
  if (!banner || !msg) return;
4300
4519
  msg.textContent = `Update available: v${current} \u2192 v${latest}`;
4301
4520
  banner.style.display = 'flex';
4521
+ showUpdateWizard(current, latest);
4522
+ }
4523
+
4524
+ function showUpdateWizard(current, latest) {
4525
+ if (!latest || _updateDismissedVersion === latest || _updateWizardSnoozedVersion === latest || _updateApplying) return;
4526
+ _setUpdateVersions(current, latest);
4527
+ const wizard = document.getElementById('update-wizard');
4528
+ if (!wizard) return;
4529
+ wizard.classList.remove('hidden');
4530
+ setTimeout(() => {
4531
+ const btn = document.getElementById('update-wizard-apply-btn');
4532
+ if (btn && !btn.disabled) btn.focus();
4533
+ }, 0);
4534
+ }
4535
+
4536
+ function snoozeUpdateWizard() {
4537
+ if (_updateLatestVersion) {
4538
+ _updateWizardSnoozedVersion = _updateLatestVersion;
4539
+ _safeStorageSet(sessionStorage, 'update_wizard_snoozed_version', _updateLatestVersion);
4540
+ }
4541
+ _hideUpdateWizard();
4302
4542
  }
4303
4543
 
4304
4544
  function dismissUpdate() {
4305
- const banner = document.getElementById('update-banner');
4306
- if (banner) banner.style.display = 'none';
4307
- // Remember dismissal for this version
4308
- const msg = document.getElementById('update-banner-msg')?.textContent || '';
4309
- const m = msg.match(/v([\d.]+)$/);
4310
- if (m) {
4311
- _updateDismissedVersion = m[1];
4312
- localStorage.setItem('update_dismissed_version', m[1]);
4545
+ _hideUpdatePrompts();
4546
+ if (_updateLatestVersion) {
4547
+ _updateDismissedVersion = _updateLatestVersion;
4548
+ _safeStorageSet(localStorage, 'update_dismissed_version', _updateLatestVersion);
4313
4549
  }
4314
4550
  }
4315
4551
 
4316
4552
  async function applyUpdate() {
4317
- const btn = document.getElementById('update-apply-btn');
4318
- if (btn) { btn.textContent = 'Updating...'; btn.disabled = true; }
4553
+ if (_updateApplying) return;
4554
+ _setUpdateApplying(true);
4319
4555
  try {
4320
4556
  const resp = await fetch('/api/updates/apply', { method: 'POST' });
4321
4557
  const data = await resp.json();
@@ -4324,11 +4560,12 @@ async function applyUpdate() {
4324
4560
  dismissUpdate();
4325
4561
  } else {
4326
4562
  toast('Already up to date.', { type: 'success' });
4327
- if (btn) { btn.textContent = 'Update Now'; btn.disabled = false; }
4563
+ _hideUpdatePrompts();
4564
+ _setUpdateApplying(false);
4328
4565
  }
4329
4566
  } catch (e) {
4330
4567
  toast('Update failed: ' + e.message, { type: 'error' });
4331
- if (btn) { btn.textContent = 'Update Now'; btn.disabled = false; }
4568
+ _setUpdateApplying(false);
4332
4569
  }
4333
4570
  }
4334
4571
 
@@ -4356,6 +4593,7 @@ const state = window._ctmState = {
4356
4593
  token: getCookie('ctm_token'),
4357
4594
  sessions: new Map(), // id -> { term, fitAddon, container }
4358
4595
  activeTab: null, // session id or 'rules'
4596
+ lastActiveWorkSessionId: null, // last non-Wall-E session used as repo context
4359
4597
  tabOrder: [], // session ids in tab order
4360
4598
  reviewingSessionId: null, // currently reviewed session
4361
4599
  sidebarCollapsed: false,
@@ -4491,7 +4729,7 @@ function connect() {
4491
4729
  (state._sessionsListDone || Promise.resolve()).then(() => onServerReady());
4492
4730
  break;
4493
4731
  case 'walle-error':
4494
- if (msg.error && (msg.error.includes('API key') || msg.error.includes('ANTHROPIC_API_KEY')))
4732
+ if (!msg.providerError && msg.error && (msg.error.includes('API key') || msg.error.includes('ANTHROPIC_API_KEY')))
4495
4733
  msg.error = 'Wall-E needs an API key configured. Go to Settings to add one.';
4496
4734
  WalleSession.handleError(msg); break;
4497
4735
  }
@@ -4924,6 +5162,7 @@ function _clientDetectAgentType(cmd) {
4924
5162
 
4925
5163
  const CLIENT_AGENT_CAPABILITIES = {
4926
5164
  claude: { structuredTranscript: true, promptNavigation: 'transcript', review: true, resume: true },
5165
+ 'claude-desktop': { structuredTranscript: true, promptNavigation: 'transcript', review: true, resume: false },
4927
5166
  codex: { structuredTranscript: true, promptNavigation: 'transcript', review: true, resume: true },
4928
5167
  gemini: { structuredTranscript: false, promptNavigation: 'terminal', review: false, resume: true },
4929
5168
  walle: { structuredTranscript: true, promptNavigation: 'none', review: true, resume: false },
@@ -4935,6 +5174,7 @@ function _clientNormalizeAgentType(value) {
4935
5174
  const v = String(value).toLowerCase().replace(/_/g, '-');
4936
5175
  if (CLIENT_AGENT_CAPABILITIES[v]) return v;
4937
5176
  if (v === 'claude-code') return 'claude';
5177
+ if (v === 'claude-desktop-session' || v === 'desktop') return 'claude-desktop';
4938
5178
  if (v === 'gemini-cli') return 'gemini';
4939
5179
  return _clientDetectAgentType(v);
4940
5180
  }
@@ -4961,7 +5201,9 @@ function _stripAnsiForActivity(data) {
4961
5201
 
4962
5202
  const _CLAUDE_STATUS_FRAGMENT_RE = /^(?:[✻✳✢✶◐◓◑◒⏺●○■□✔✓]+|Run|Runn|Runni|Runnin|Running\.{0,3}|unning|Thinking\.{0,3}|\(?ctrl\+o\s*to\s*expand\)?|>?\s*esc\s+to\s+interrupt|[⏵>]+\s*(?:accept edits|auto mode)\s+on\s+\(shift\+tab\s+to\s+cycle\)(?:\s*·\s*esc\s+to\s+interrupt)?(?:\s*·\s*ctrl\+t\s+to\s+(?:show|hide)\s+task)?|ctrl\+t\s+to\s+show\s+tasks?|\?\s+for\s+shortcuts|Native installation exists but .*? is not in your PATH\. Run:|echo\s+['"]export\s+PATH=.*)$/i;
4963
5203
  const _CODEX_STATUS_FRAGMENT_RE = /^[\s\d•◦·∙●○WwOoRrKkIiNnGg]+$/u;
5204
+ const _CODEX_BUSY_STATUS_LINE_RE = /^(?:working(?:\s*\([^)]*\))?(?:\s*[•◦·∙●○]\s*esc\s+to\s+interrupt)?|(?:working\s*)?esc\s+to\s+interrupt)$/iu;
4964
5205
  const _CODEX_BUSY_WORD = 'working';
5206
+ const _CODEX_BUSY_HINT_RE = /esc\s+to\s+interrupt/i;
4965
5207
  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;
4966
5208
 
4967
5209
  function _isClaudeRedraw(data) {
@@ -4982,6 +5224,7 @@ function _isCodexRedraw(data) {
4982
5224
  }
4983
5225
 
4984
5226
  function _hasCodexBusyStatusFragment(text) {
5227
+ if (_CODEX_BUSY_HINT_RE.test(String(text || ''))) return true;
4985
5228
  const letters = String(text || '').toLowerCase().replace(/[^a-z]/g, '');
4986
5229
  if (letters.length < 3) return false;
4987
5230
  if (letters.includes(_CODEX_BUSY_WORD)) return true;
@@ -4996,8 +5239,8 @@ function _isClientCodexStatusOnlyOutput(session, data) {
4996
5239
  if (_clientAgentTypeForSession(session) !== 'codex') return false;
4997
5240
  const stripped = _stripAnsiForActivity(data).trim();
4998
5241
  return (
4999
- stripped.length <= 80 &&
5000
- _CODEX_STATUS_FRAGMENT_RE.test(stripped) &&
5242
+ stripped.length <= 160 &&
5243
+ (_CODEX_STATUS_FRAGMENT_RE.test(stripped) || _CODEX_BUSY_STATUS_LINE_RE.test(stripped)) &&
5001
5244
  _isCodexRedraw(data)
5002
5245
  );
5003
5246
  }
@@ -5022,8 +5265,8 @@ function _isClientActiveOutput(session, data) {
5022
5265
  return false;
5023
5266
  }
5024
5267
  if (agentType === 'codex' &&
5025
- stripped.length <= 80 &&
5026
- _CODEX_STATUS_FRAGMENT_RE.test(stripped) &&
5268
+ stripped.length <= 160 &&
5269
+ (_CODEX_STATUS_FRAGMENT_RE.test(stripped) || _CODEX_BUSY_STATUS_LINE_RE.test(stripped)) &&
5027
5270
  _isCodexRedraw(data)) {
5028
5271
  return _hasCodexBusyStatusFragment(stripped);
5029
5272
  }
@@ -5393,7 +5636,7 @@ function createTerminal(id, opts) {
5393
5636
  const size = s.term.options.fontSize;
5394
5637
  s.term.options.fontSize = size + 1;
5395
5638
  s.term.options.fontSize = size;
5396
- if (s.fitAddon) s.fitAddon.fit();
5639
+ _fitTerminalPreservingViewport(s, id);
5397
5640
  }
5398
5641
  });
5399
5642
  });
@@ -5528,13 +5771,13 @@ function createTerminal(id, opts) {
5528
5771
  s.writer.queue = '';
5529
5772
  s.writer.scheduled = false;
5530
5773
  if (batch) chunkedWrite(s, batch, () => {
5531
- if (s.writer.followMode && !s.writer._userScrollLocked && s.term) s.term.scrollToBottom();
5774
+ if (s.writer.followMode && !s.writer._userScrollLocked && s.term) _ensureScrolledToBottom(s);
5532
5775
  });
5533
5776
  });
5534
5777
  }
5535
5778
  // Skip scrollToBottom if the RAF batcher is already scheduled —
5536
5779
  // it will handle scrolling, and doing it here too competes for main thread.
5537
- if (!s.writer.scheduled) s.term.scrollToBottom();
5780
+ if (!s.writer.scheduled) _ensureScrolledToBottom(s);
5538
5781
  }
5539
5782
  });
5540
5783
 
@@ -5551,11 +5794,11 @@ function createTerminal(id, opts) {
5551
5794
  requestAnimationFrame(() => {
5552
5795
  const s = state.sessions.get(id);
5553
5796
  if (s && s.term) {
5554
- s.fitAddon.fit();
5555
5797
  // Returning from alt screen — scroll to bottom and re-enable follow
5556
5798
  s.writer.followMode = true;
5557
5799
  s.writer._userScrollLocked = false;
5558
- s.term.scrollToBottom();
5800
+ _fitTerminalPreservingViewport(s, id);
5801
+ _ensureScrolledToBottom(s);
5559
5802
  // Re-focus after fit — fitAddon.fit() can cause textarea focus loss
5560
5803
  // during the alt→normal buffer transition, leaving input unresponsive.
5561
5804
  if (state.activeTab === id) focusTerminalIfSafe(id);
@@ -5591,8 +5834,9 @@ function createTerminal(id, opts) {
5591
5834
  // Scroll: handle locally instead of forwarding as mouse escape sequences
5592
5835
  screenEl.addEventListener('wheel', (e) => {
5593
5836
  const buf = term.buffer.active;
5837
+ const current = state.sessions.get(id) || { _id: id, term, writer, container };
5594
5838
  const hasScrollback = buf.baseY > 0;
5595
- const atBottom = buf.viewportY >= buf.baseY;
5839
+ const atBottom = _isAtTerminalFollowBottom(current);
5596
5840
  if (e.deltaY < 0 && hasScrollback) {
5597
5841
  // Scrolling UP — only lock follow mode if there's scrollback to scroll through.
5598
5842
  // Without this guard, scrolling up on a terminal with no scrollback (e.g., during
@@ -5609,7 +5853,7 @@ function createTerminal(id, opts) {
5609
5853
  term.scrollLines(Math.max(1, Math.round(Math.abs(e.deltaY) / 20)));
5610
5854
  }
5611
5855
  // Check if user scrolled back to bottom — unlock follow mode
5612
- if (buf.viewportY >= buf.baseY && writer._userScrollLocked) {
5856
+ if (_isAtTerminalFollowBottom(current) && writer._userScrollLocked) {
5613
5857
  writer.followMode = true;
5614
5858
  writer._userScrollLocked = false;
5615
5859
  }
@@ -5623,9 +5867,10 @@ function createTerminal(id, opts) {
5623
5867
  // both user and programmatic scrolls (fit reflow, scrollToLine, etc.).
5624
5868
  // Unlock only happens via the wheel handler (real user scroll to bottom).
5625
5869
  term.onScroll(() => {
5870
+ if (writer._programmaticScrollDepth) return;
5626
5871
  if (writer._userScrollLocked) return; // locked — only wheel can unlock
5627
- const buf = term.buffer.active;
5628
- writer.followMode = buf.viewportY >= buf.baseY;
5872
+ const current = state.sessions.get(id) || { _id: id, term, writer, container };
5873
+ writer.followMode = _isAtTerminalFollowBottom(current);
5629
5874
  });
5630
5875
 
5631
5876
  state.sessions.set(id, { _id: id, term, fitAddon, searchAddon, container, needsFontRefresh: true, writer, promptLines: [], promptNavIdx: -1, _webglAddon, _loadWebgl });
@@ -6047,6 +6292,14 @@ function activateTab(id) {
6047
6292
 
6048
6293
  const specialPanels = ['rules', 'insights', 'permissions', 'prompts', 'codereview', 'walle', 'models', 'backups', 'worktrees', 'setup'];
6049
6294
  const isPanel = specialPanels.includes(id);
6295
+ if (state.activeTab && state.activeTab !== id && state.sessions.has(state.activeTab)) {
6296
+ const prev = state.sessions.get(state.activeTab);
6297
+ if (prev && prev.meta?.type !== 'walle') state.lastActiveWorkSessionId = state.activeTab;
6298
+ }
6299
+ if (!isPanel && state.sessions.has(id)) {
6300
+ const next = state.sessions.get(id);
6301
+ if (next && next.meta?.type !== 'walle') state.lastActiveWorkSessionId = id;
6302
+ }
6050
6303
 
6051
6304
  // Phase 2B: dispose the PREVIOUS session's xterm.js terminal to free memory.
6052
6305
  // Only one xterm.js instance should exist at a time (tmux-style). The headless
@@ -6244,6 +6497,7 @@ function activateTab(id) {
6244
6497
  const savedQueue = s.writer.queue; // preserve output buffered while stub
6245
6498
  // Preserve status state — createTerminal replaces the session object entirely
6246
6499
  const savedWaiting = s._waitingForInput;
6500
+ const savedWaitingAt = s._waitingForInputAt;
6247
6501
  const savedLastOut = s._lastOutputAt;
6248
6502
  const savedLastIn = s._lastInputAt;
6249
6503
  const savedAuthoritativeSource = s._authoritativeSource;
@@ -6262,6 +6516,7 @@ function activateTab(id) {
6262
6516
  s2.meta = savedMeta;
6263
6517
  s2.writer.queue = savedQueue; // restore buffered output for flush
6264
6518
  s2._waitingForInput = savedWaiting;
6519
+ s2._waitingForInputAt = savedWaitingAt;
6265
6520
  s2._lastOutputAt = savedLastOut;
6266
6521
  s2._lastInputAt = savedLastIn;
6267
6522
  s2._authoritativeSource = savedAuthoritativeSource;
@@ -7583,6 +7838,37 @@ function _buildSparklineSVG(data) {
7583
7838
  return '<svg width="' + w + '" height="' + ht + '" viewBox="0 0 ' + w + ' ' + ht + '" style="vertical-align:middle;"><polyline points="' + points + '" fill="none" stroke="#7aa2f7" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
7584
7839
  }
7585
7840
 
7841
+ function _benchScoreValue(b) {
7842
+ var v = b && b.trusted_avg_score != null ? b.trusted_avg_score : b && b.avg_score;
7843
+ v = Number(v);
7844
+ return isFinite(v) ? v : null;
7845
+ }
7846
+ function _benchTrustStatus(b) {
7847
+ if (b && b.trust_status) return String(b.trust_status);
7848
+ var trusted = Number((b && b.trusted_evals) || 0);
7849
+ var minTrusted = Number((b && b.min_trusted_evals) || 10);
7850
+ if (trusted >= minTrusted) return 'trusted';
7851
+ return trusted > 0 ? 'provisional' : 'legacy';
7852
+ }
7853
+ function _benchTrustColor(status) {
7854
+ if (status === 'trusted') return '#9ece6a';
7855
+ if (status === 'provisional') return '#e0af68';
7856
+ return '#f7768e';
7857
+ }
7858
+ function _benchCiLabel(b) {
7859
+ var low = b && b.trusted_score_confidence_low != null ? b.trusted_score_confidence_low : b && b.score_confidence_low;
7860
+ var high = b && b.trusted_score_confidence_high != null ? b.trusted_score_confidence_high : b && b.score_confidence_high;
7861
+ low = Number(low);
7862
+ high = Number(high);
7863
+ if (!isFinite(low) || !isFinite(high)) return '-';
7864
+ return low.toFixed(2) + '-' + high.toFixed(2);
7865
+ }
7866
+ function _benchMetaValue(v) {
7867
+ if (Array.isArray(v)) return v.length ? v.join(', ') : '-';
7868
+ if (v == null || v === '') return '-';
7869
+ return String(v);
7870
+ }
7871
+
7586
7872
  function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCoverage) {
7587
7873
  var el = document.getElementById('models-scorecard-dashboard');
7588
7874
  var filtered = scorecard.filter(function(m) { return m.stats && m.stats.total_evals > 0; });
@@ -7630,12 +7916,25 @@ function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCovera
7630
7916
  if (coverageBits.length) {
7631
7917
  h += '<div style="margin:-2px 0 8px 0;color:var(--fg-dim,#565f89);font-size:11px;">' + escHtml(coverageBits.join(' | ')) + '</div>';
7632
7918
  }
7919
+ var trustedGroups = 0, provisionalGroups = 0, legacyGroups = 0;
7920
+ benchmarks.forEach(function(b) {
7921
+ var status = _benchTrustStatus(b);
7922
+ if (status === 'trusted') trustedGroups++;
7923
+ else if (status === 'provisional') provisionalGroups++;
7924
+ else legacyGroups++;
7925
+ });
7926
+ h += '<div style="margin:-2px 0 8px 0;color:var(--fg-dim,#565f89);font-size:11px;">Trust coverage: ' +
7927
+ '<span style="color:#9ece6a;">' + trustedGroups + ' trusted</span>, ' +
7928
+ '<span style="color:#e0af68;">' + provisionalGroups + ' provisional</span>, ' +
7929
+ '<span style="color:#f7768e;">' + legacyGroups + ' legacy</span> model groups</div>';
7633
7930
 
7634
7931
  // Comparison table
7635
7932
  h += '<div style="overflow-x:auto;margin-bottom:14px;"><table style="width:100%;border-collapse:collapse;font-size:12px;">' +
7636
7933
  '<thead><tr style="background:var(--bg-card,#24283b);color:var(--fg-dim,#565f89);text-align:left;">' +
7637
7934
  '<th style="padding:8px 14px;font-weight:500;">Model</th>' +
7638
7935
  '<th style="padding:8px;font-weight:500;">Composite</th>' +
7936
+ '<th style="padding:8px;font-weight:500;">Trust</th>' +
7937
+ '<th style="padding:8px;font-weight:500;">CI</th>' +
7639
7938
  '<th style="padding:8px;font-weight:500;">Code Gen</th>' +
7640
7939
  '<th style="padding:8px;font-weight:500;">Tool Use</th>' +
7641
7940
  '<th style="padding:8px;font-weight:500;">Planning</th>' +
@@ -7646,10 +7945,17 @@ function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCovera
7646
7945
  '</tr></thead><tbody>';
7647
7946
 
7648
7947
  benchmarks.forEach(function(b, idx) {
7649
- var q = b.avg_score || 0;
7650
- var qColor = _qualityZoneColor(q);
7948
+ var q = _benchScoreValue(b);
7949
+ var qColor = _qualityZoneColor(q || 0);
7651
7950
  var provBadge = '<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:' + (pColors[b.provider] || '#565f89') + '22;color:' + (pColors[b.provider] || '#565f89') + ';margin-left:6px;">' + escHtml(b.provider) + '</span>';
7652
7951
  var rankBadge = idx === 0 ? '<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#9ece6a22;color:#9ece6a;margin-left:4px;">#1</span>' : '';
7952
+ var trustStatus = _benchTrustStatus(b);
7953
+ var trustColor = _benchTrustColor(trustStatus);
7954
+ var trustedEvals = Number(b.trusted_evals || 0);
7955
+ var minTrusted = Number(b.min_trusted_evals || 10);
7956
+ var trustBadge = '<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:' + trustColor + '22;color:' + trustColor + ';font-weight:600;">' + escHtml(trustStatus.toUpperCase()) + '</span>';
7957
+ var trustDetail = trustedEvals + '/' + minTrusted + ' trusted';
7958
+ var ciLabel = _benchCiLabel(b);
7653
7959
 
7654
7960
  var successfulEvals = b.successful_evals != null ? b.successful_evals : b.total_evals;
7655
7961
  var errorCount = b.errors || 0;
@@ -7658,7 +7964,9 @@ function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCovera
7658
7964
 
7659
7965
  h += '<tr data-bench-idx="' + idx + '" style="border-top:1px solid var(--border,#292e42);cursor:pointer;">' +
7660
7966
  '<td style="padding:6px 14px;color:var(--fg,#c0caf5);font-weight:500;">' + escHtml(b.model) + provBadge + rankBadge + '</td>' +
7661
- '<td style="padding:6px 8px;color:' + qColor + ';font-weight:600;">' + (successfulEvals ? q.toFixed(3) : '-') + '</td>';
7967
+ '<td style="padding:6px 8px;color:' + qColor + ';font-weight:600;">' + (successfulEvals && q != null ? q.toFixed(3) : '-') + '</td>' +
7968
+ '<td style="padding:6px 8px;">' + trustBadge + '<div style="font-size:9px;color:var(--fg-dim,#565f89);margin-top:2px;">' + escHtml(trustDetail) + '</div></td>' +
7969
+ '<td style="padding:6px 8px;color:var(--fg-dim,#565f89);font-variant-numeric:tabular-nums;">' + escHtml(ciLabel) + '</td>';
7662
7970
 
7663
7971
  // Category scores with inline bars
7664
7972
  ['codeGen', 'toolUse', 'planning', 'efficiency'].forEach(function(cat) {
@@ -7680,7 +7988,7 @@ function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCovera
7680
7988
  '</tr>';
7681
7989
 
7682
7990
  // Expandable detail row with radar + all 11 dimensions
7683
- h += '<tr data-bench-detail="' + idx + '" style="display:none;"><td colspan="9" style="padding:0;background:var(--bg,#1a1b26);"></td></tr>';
7991
+ h += '<tr data-bench-detail="' + idx + '" style="display:none;"><td colspan="11" style="padding:0;background:var(--bg,#1a1b26);"></td></tr>';
7684
7992
  });
7685
7993
  h += '</tbody></table></div>';
7686
7994
 
@@ -7759,7 +8067,41 @@ function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCovera
7759
8067
  detailRow.style.display = '';
7760
8068
  var td = detailRow.querySelector('td');
7761
8069
  var b = benchmarks[idx];
7762
- if (!b || !b.dimensions) { td.textContent = 'No dimension data.'; return; }
8070
+ if (!b) { td.textContent = 'No benchmark data.'; return; }
8071
+
8072
+ var detailScore = _benchScoreValue(b);
8073
+ var detailTrust = _benchTrustStatus(b);
8074
+ var detailTrustColor = _benchTrustColor(detailTrust);
8075
+ var metaItems = [
8076
+ ['Score Source', b.trusted_avg_score != null ? 'trusted mean' : 'successful mean'],
8077
+ ['Composite', detailScore != null ? detailScore.toFixed(3) : '-'],
8078
+ ['Trust', detailTrust + ' (' + Number(b.trusted_evals || 0) + '/' + Number(b.min_trusted_evals || 10) + ')'],
8079
+ ['95% CI', _benchCiLabel(b)],
8080
+ ['Samples', Number(b.sample_count || 0) + ' unique / ' + Number(b.total_evals || 0) + ' rows'],
8081
+ ['Datasets', _benchMetaValue(b.dataset_versions)],
8082
+ ['Scorers', _benchMetaValue(b.scorer_versions)],
8083
+ ['Methods', _benchMetaValue(b.scoring_methods)],
8084
+ ['Evaluators', _benchMetaValue(b.evaluator_versions)],
8085
+ ['Artifacts', String(b.artifact_count || 0)],
8086
+ ['Last Eval', _benchMetaValue(b.last_eval_at)]
8087
+ ];
8088
+ var metaHtml = '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin-bottom:14px;">';
8089
+ metaItems.forEach(function(item) {
8090
+ var color = item[0] === 'Trust' ? detailTrustColor : 'var(--fg,#c0caf5)';
8091
+ metaHtml += '<div style="min-width:0;border-top:1px solid var(--border,#292e42);padding-top:6px;">' +
8092
+ '<div style="font-size:9px;color:var(--fg-dim,#565f89);text-transform:uppercase;">' + escHtml(item[0]) + '</div>' +
8093
+ '<div style="font-size:11px;color:' + color + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + escHtml(item[1]) + '">' + escHtml(item[1]) + '</div>' +
8094
+ '</div>';
8095
+ });
8096
+ metaHtml += '</div>';
8097
+
8098
+ if (!b.dimensions) {
8099
+ var noDimsHtml = '<div style="padding:14px;">' + metaHtml +
8100
+ '<div style="font-size:11px;color:var(--fg-dim,#565f89);">No dimension data for this benchmark group.</div>' +
8101
+ '</div>';
8102
+ td.innerHTML = DOMPurify.sanitize(noDimsHtml, { ADD_TAGS: svgTags, ADD_ATTR: svgAttrs });
8103
+ return;
8104
+ }
7763
8105
 
7764
8106
  var dimNames = ['correctness', 'codeQuality', 'diffAccuracy', 'partialProgress', 'toolEfficiency', 'contextManagement', 'errorHandling', 'planQuality', 'turnEconomy', 'iterativeRefinement', 'costEfficiency'];
7765
8107
  var dimLabels = ['Correct', 'Code Qual', 'Diff Acc', 'Partial', 'Tool Eff', 'Ctx Mgmt', 'Err Hand', 'Plan Qual', 'Turn Econ', 'Iter Refine', 'Cost Eff'];
@@ -7821,9 +8163,12 @@ function renderScorecardDashboard(scorecard, models, benchmarks, benchmarkCovera
7821
8163
  });
7822
8164
  barsHtml += '</div>';
7823
8165
 
7824
- var detailHtml = '<div style="padding:14px;display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;">' +
8166
+ var detailHtml = '<div style="padding:14px;">' +
8167
+ metaHtml +
8168
+ '<div style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;">' +
7825
8169
  '<div>' + radarSvg + '</div>' +
7826
8170
  '<div style="flex:1;min-width:200px;">' + barsHtml + '</div>' +
8171
+ '</div>' +
7827
8172
  '</div>';
7828
8173
 
7829
8174
  td.innerHTML = DOMPurify.sanitize(detailHtml, { ADD_TAGS: svgTags, ADD_ATTR: svgAttrs });
@@ -8990,6 +9335,9 @@ function _restoreBackup(type, name) {
8990
9335
  var _wtCache = { repoRoot: '', items: [], counts: {} };
8991
9336
  var _wtModalState = { branch: '', name: '', mode: null, cwd: '' };
8992
9337
  var _wtCurrentFilter = 'all';
9338
+ var _wtLoadSeq = 0;
9339
+ var _wtRefreshSeq = 0;
9340
+ var _wtLastActionNotice = null;
8993
9341
  var _wtSanitize = function(s) {
8994
9342
  return String(s || '').trim().replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
8995
9343
  };
@@ -9070,7 +9418,7 @@ function _wtRunRecommendedAction(wt) {
9070
9418
  if (action.kind === 'prune') return submitPruneGhosts();
9071
9419
  if (action.kind === 'recover_branch') return submitRecoverDetached(wt);
9072
9420
  if (action.kind === 'sync_branch') return openSyncModal(wt);
9073
- if (action.kind === 'finish_work') return openMergeModal(wt);
9421
+ if (action.kind === 'finish_work') return _wtOpenMergeOrExplain(wt);
9074
9422
  if (action.kind === 'cleanup') return openDeleteModal(wt);
9075
9423
  if (action.kind === 'update_main' || action.kind === 'push_main' || action.kind === 'reconcile_main') {
9076
9424
  toast(action.reason || 'Review local main in a terminal before worktree operations.', { type: 'warning', title: 'Main needs attention' });
@@ -9116,14 +9464,56 @@ function _wtSyncAllEligible(wts) {
9116
9464
  });
9117
9465
  }
9118
9466
 
9119
- async function loadWorktreesPanel() {
9467
+ function _wtWorktreesListUrl(token) {
9468
+ var params = new URLSearchParams();
9469
+ params.set('token', token || '');
9470
+ params.set('_wt_refresh', String(Date.now()) + '-' + (++_wtRefreshSeq));
9471
+ return '/api/worktrees?' + params.toString();
9472
+ }
9473
+
9474
+ function _wtSkippedReasonSummary(skippedBranches) {
9475
+ var counts = {};
9476
+ var rows = Array.isArray(skippedBranches) ? skippedBranches : [];
9477
+ rows.forEach(function(row) {
9478
+ var reason = row && row.reason ? String(row.reason) : 'skipped';
9479
+ counts[reason] = (counts[reason] || 0) + 1;
9480
+ });
9481
+ return Object.keys(counts).map(function(reason) {
9482
+ return counts[reason] + ' ' + reason;
9483
+ }).join(', ');
9484
+ }
9485
+
9486
+ function _wtBuildSyncAllNotice(d) {
9487
+ var failed = d.failed || 0;
9488
+ var skipped = d.skipped || 0;
9489
+ var synced = d.synced || 0;
9490
+ var msg = 'Sync all finished: ' + synced + ' synced';
9491
+ if (failed) msg += ', ' + failed + ' failed';
9492
+ if (skipped) {
9493
+ var reasons = _wtSkippedReasonSummary(d.skippedBranches || []);
9494
+ msg += ', ' + skipped + ' skipped';
9495
+ if (reasons) msg += ' (' + reasons + ')';
9496
+ msg += '. Skipped worktrees can still show behind until active sessions are closed and dirty files are committed or stashed';
9497
+ }
9498
+ return {
9499
+ tone: failed ? 'warning' : (skipped ? 'warning' : 'success'),
9500
+ title: 'Worktrees refreshed',
9501
+ message: msg + '.',
9502
+ };
9503
+ }
9504
+
9505
+ async function loadWorktreesPanel(opts) {
9506
+ opts = opts || {};
9120
9507
  var body = document.getElementById('worktrees-body');
9121
9508
  if (!body) return;
9122
- body.textContent = 'Loading…';
9509
+ var requestId = ++_wtLoadSeq;
9510
+ if (!opts.silent) body.textContent = 'Loading…';
9123
9511
  try {
9124
9512
  var token = (state && state.token) ? state.token : '';
9125
- var r = await fetch('/api/worktrees?token=' + encodeURIComponent(token));
9513
+ var r = await fetch(_wtWorktreesListUrl(token), { cache: 'no-store' });
9514
+ if (!r.ok) throw new Error('HTTP ' + r.status);
9126
9515
  var d = await r.json();
9516
+ if (requestId !== _wtLoadSeq) return;
9127
9517
  var wts = d.worktrees || [];
9128
9518
  _wtCache = { repoRoot: d.cwd || '', items: wts, counts: d.counts || {}, mainRemote: d.mainRemote || null };
9129
9519
 
@@ -9136,18 +9526,36 @@ async function loadWorktreesPanel() {
9136
9526
  syncAllBtn.textContent = '↓ Sync all' + (syncTargets.length > 0 ? ' (' + syncTargets.length + ')' : '');
9137
9527
  syncAllBtn.disabled = false;
9138
9528
  syncAllBtn.title = syncTargets.length > 0
9139
- ? 'Sync ' + syncTargets.length + ' clean branch(es) that are behind main'
9140
- : 'No clean branches are behind main';
9529
+ ? 'Sync ' + syncTargets.length + ' clean inactive branch(es) that are behind main'
9530
+ : 'No clean inactive branches are behind main';
9141
9531
  }
9142
9532
 
9143
9533
  _wtRenderFilterChips(d.counts || {}, wts);
9144
9534
  _wtRenderBody(body, wts);
9145
9535
  _wtUpdateNavBadge(d.counts || {});
9146
9536
  } catch (e) {
9537
+ if (requestId !== _wtLoadSeq) return;
9147
9538
  body.textContent = 'Failed to load worktrees: ' + e.message;
9148
9539
  }
9149
9540
  }
9150
9541
 
9542
+ function _wtRenderActionNotice(frag) {
9543
+ var notice = _wtLastActionNotice;
9544
+ if (!notice || !notice.message) return;
9545
+ var color = notice.tone === 'success' ? '#9ece6a' : notice.tone === 'warning' ? '#e0af68' : '#7aa2f7';
9546
+ var bg = notice.tone === 'success' ? 'rgba(158,206,106,0.08)' : notice.tone === 'warning' ? 'rgba(224,175,104,0.08)' : 'rgba(122,162,247,0.08)';
9547
+ var wrap = document.createElement('div');
9548
+ wrap.style.cssText = 'border:1px solid var(--border);border-left:3px solid ' + color + ';border-radius:8px;background:' + bg + ';padding:10px 12px;margin-bottom:12px;font-size:12px;color:var(--fg,#c0caf5);line-height:1.45;';
9549
+ var title = document.createElement('div');
9550
+ title.style.cssText = 'font-weight:600;margin-bottom:2px;color:' + color + ';';
9551
+ title.textContent = notice.title || 'Worktree action';
9552
+ var msg = document.createElement('div');
9553
+ msg.style.cssText = 'color:var(--fg-dim,#a9b1d6);';
9554
+ msg.textContent = notice.message;
9555
+ wrap.append(title, msg);
9556
+ frag.appendChild(wrap);
9557
+ }
9558
+
9151
9559
  function _wtRenderBody(body, wts) {
9152
9560
  if (wts.length === 0) {
9153
9561
  _wtClearChildren(body);
@@ -9158,6 +9566,7 @@ function _wtRenderBody(body, wts) {
9158
9566
  return;
9159
9567
  }
9160
9568
  var frag = document.createDocumentFragment();
9569
+ _wtRenderActionNotice(frag);
9161
9570
  _wtRenderSummary(frag, wts, _wtCache.counts || {});
9162
9571
  var visible = 0;
9163
9572
  for (var wt of wts) {
@@ -9282,7 +9691,7 @@ function _wtRenderCard(frag, wt) {
9282
9691
  if (wt.state === 'ahead') {
9283
9692
  pill.style.cursor = 'pointer';
9284
9693
  pill.title = 'Click to merge into main';
9285
- pill.onclick = function() { openMergeModal(wt); };
9694
+ pill.onclick = function() { _wtOpenMergeOrExplain(wt); };
9286
9695
  } else if (wt.state === 'behind' || wt.state === 'diverged') {
9287
9696
  pill.style.cursor = 'pointer';
9288
9697
  pill.title = 'Click to sync from main';
@@ -9382,12 +9791,15 @@ function _wtRenderCard(frag, wt) {
9382
9791
  mergeBtn.className = 'btn';
9383
9792
  mergeBtn.style.cssText = 'font-size:11px;padding:4px 10px;background:rgba(74,222,128,0.12);color:#4ade80;border:1px solid rgba(74,222,128,0.3);';
9384
9793
  mergeBtn.textContent = 'Merge';
9385
- mergeBtn.disabled = !!wt.sessionId || wt.unmergedCommits === 0 || wt.dirtyFiles > 0 || wt.behind > 0;
9386
- if (wt.sessionId) mergeBtn.title = 'Close the active session before merging';
9387
- else if (wt.dirtyFiles > 0) mergeBtn.title = 'Commit or stash dirty files before merging';
9388
- else if (wt.behind > 0) mergeBtn.title = 'Sync from main before merging';
9389
- else if (wt.unmergedCommits === 0) mergeBtn.title = 'Nothing to merge — already integrated with main';
9390
- mergeBtn.onclick = function() { openMergeModal(wt); };
9794
+ var mergeBlockReason = _wtMergeBlockReason(wt);
9795
+ if (mergeBlockReason) {
9796
+ mergeBtn.setAttribute('aria-disabled', 'true');
9797
+ mergeBtn.title = mergeBlockReason;
9798
+ } else {
9799
+ mergeBtn.removeAttribute('aria-disabled');
9800
+ mergeBtn.title = 'Merge this branch into main';
9801
+ }
9802
+ mergeBtn.onclick = function() { _wtOpenMergeOrExplain(wt); };
9391
9803
  actions.appendChild(mergeBtn);
9392
9804
  }
9393
9805
 
@@ -9482,6 +9894,7 @@ async function submitSyncWorktree() {
9482
9894
  }
9483
9895
  if (!r.ok || d.error) throw new Error(d.error || ('HTTP ' + r.status));
9484
9896
  closeModal('wt-sync-modal');
9897
+ _wtLastActionNotice = { tone: 'success', title: 'Worktrees refreshed', message: 'Synced ' + st.branch + ' from main.' };
9485
9898
  toast('Synced ' + st.branch + ' from main', { type: 'success' });
9486
9899
  await loadWorktreesPanel();
9487
9900
  } catch (e) {
@@ -9494,10 +9907,10 @@ async function submitSyncWorktree() {
9494
9907
  async function submitSyncAllWorktrees() {
9495
9908
  var targets = _wtSyncAllEligible((_wtCache && _wtCache.items) || []);
9496
9909
  if (targets.length === 0) {
9497
- toast('No clean branches are behind main', { type: 'info' });
9910
+ toast('No clean inactive branches are behind main', { type: 'info' });
9498
9911
  return;
9499
9912
  }
9500
- if (!confirm('Sync ' + targets.length + ' clean branch(es) from main? Dirty, detached, and ghost worktrees will be skipped.')) return;
9913
+ if (!confirm('Sync ' + targets.length + ' clean branch(es) from main? Dirty, active-session, detached, and ghost worktrees will be skipped.')) return;
9501
9914
  var btn = document.getElementById('wt-sync-all-btn');
9502
9915
  if (btn) { btn.disabled = true; btn.textContent = 'Syncing all…'; }
9503
9916
  try {
@@ -9517,6 +9930,11 @@ async function submitSyncAllWorktrees() {
9517
9930
  var msg = 'Synced ' + synced + ' branch(es)';
9518
9931
  if (failed) msg += ', ' + failed + ' failed';
9519
9932
  if (skipped) msg += ', ' + skipped + ' skipped';
9933
+ if (skipped && d.skippedBranches) {
9934
+ var reasons = _wtSkippedReasonSummary(d.skippedBranches);
9935
+ if (reasons) msg += ' (' + reasons + ')';
9936
+ }
9937
+ _wtLastActionNotice = _wtBuildSyncAllNotice(d);
9520
9938
  toast(msg, { type: failed ? 'warning' : 'success', duration: failed ? 10000 : 5000 });
9521
9939
  await loadWorktreesPanel();
9522
9940
  } catch (e) {
@@ -9783,7 +10201,7 @@ async function wtFinishAction(action) {
9783
10201
  }
9784
10202
 
9785
10203
  closeModal('wt-finish-modal');
9786
- if (action === 'merge') return openMergeModal(resolved);
10204
+ if (action === 'merge') return _wtOpenMergeOrExplain(resolved);
9787
10205
  if (action === 'pr') return openCreatePRModal(resolved);
9788
10206
  if (action === 'delete') return openDeleteModal(resolved);
9789
10207
  }
@@ -9855,7 +10273,31 @@ async function submitCreateWorktree() {
9855
10273
  }
9856
10274
 
9857
10275
  // ── Merge modal ─────────────────────────────────────────────────────
10276
+ function _wtMergeBlockReason(wt) {
10277
+ wt = wt || {};
10278
+ if (!wt.branch || wt.branch === 'HEAD') return 'Recover this worktree onto a branch before merging.';
10279
+ if (wt.sessionId) return 'Close the active session before merging: ' + (wt.sessionLabel || wt.branch) + '.';
10280
+ if ((wt.dirtyFiles || 0) > 0) return 'Commit or stash dirty files before merging.';
10281
+ if ((wt.behind || 0) > 0) return 'Sync from main before merging.';
10282
+ if ((wt.unmergedCommits || 0) === 0 && (wt.ahead || 0) === 0) return 'Nothing to merge — this branch is already integrated with main.';
10283
+ return '';
10284
+ }
10285
+
10286
+ function _wtOpenMergeOrExplain(wt) {
10287
+ var reason = _wtMergeBlockReason(wt);
10288
+ if (reason) {
10289
+ toast(reason, { type: 'warning', title: 'Merge unavailable', duration: 7000 });
10290
+ return;
10291
+ }
10292
+ openMergeModal(wt);
10293
+ }
10294
+
9858
10295
  function openMergeModal(wt) {
10296
+ var blockReason = _wtMergeBlockReason(wt);
10297
+ if (blockReason) {
10298
+ toast(blockReason, { type: 'warning', title: 'Merge unavailable', duration: 7000 });
10299
+ return;
10300
+ }
9859
10301
  _wtModalState = { branch: wt.branch, name: wt.branch, worktreeName: wt.worktreeName || '', mode: 'merge', cwd: _wtCache.repoRoot || '' };
9860
10302
  var sum = document.getElementById('wt-merge-summary');
9861
10303
  if (sum) sum.textContent = 'Merge "' + wt.branch + '" into main.';
@@ -10071,8 +10513,7 @@ function onCreated(msg) {
10071
10513
 
10072
10514
  requestAnimationFrame(() => {
10073
10515
  const s = state.sessions.get(id);
10074
- s.fitAddon.fit();
10075
- send({ type: 'resize', id, cols: term.cols, rows: term.rows });
10516
+ _fitTerminalPreservingViewport(s, id, { sendResize: true });
10076
10517
  });
10077
10518
 
10078
10519
  // Notify the prompt editor so it can send pending prompts
@@ -10211,7 +10652,7 @@ function chunkedWrite(s, data, onDone) {
10211
10652
  s.writer.scheduled = false;
10212
10653
  if (!next) return;
10213
10654
  chunkedWrite(s, next, () => {
10214
- if (f && !s.writer._userScrollLocked) s.term.scrollToBottom();
10655
+ if (f && !s.writer._userScrollLocked) _ensureScrolledToBottom(s);
10215
10656
  });
10216
10657
  });
10217
10658
  }
@@ -10245,7 +10686,7 @@ function onOutput(msg) {
10245
10686
  && (data.length <= 10 || _visibleEchoChars(data).length <= 64)) {
10246
10687
  s.term.write(data);
10247
10688
  s._lastOutputAt = Date.now(); // keystroke echoes are always real activity
10248
- if (s.writer.followMode && !s.writer._userScrollLocked) s.term.scrollToBottom();
10689
+ if (s.writer.followMode && !s.writer._userScrollLocked) _ensureScrolledToBottom(s);
10249
10690
  return;
10250
10691
  }
10251
10692
 
@@ -10289,7 +10730,7 @@ function onOutput(msg) {
10289
10730
  s.writer.scheduled = false;
10290
10731
  if (follow) {
10291
10732
  chunkedWrite(s, batch, () => {
10292
- if (follow && !s.writer._userScrollLocked) s.term.scrollToBottom();
10733
+ if (follow && !s.writer._userScrollLocked) _ensureScrolledToBottom(s);
10293
10734
  });
10294
10735
  } else {
10295
10736
  // Not following — write without moving viewport
@@ -10309,28 +10750,12 @@ function onOutput(msg) {
10309
10750
  try {
10310
10751
  const dims = s.fitAddon.proposeDimensions();
10311
10752
  if (dims && (dims.cols !== s.term.cols || dims.rows !== s.term.rows)) {
10312
- const buf = s.term.buffer.active;
10313
- const wasAtBottom = buf.viewportY >= buf.baseY;
10314
- const oldCols = s.term.cols;
10315
- const savedLine2 = buf.viewportY;
10316
- const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
10317
- const savedFollow2 = s.writer.followMode;
10318
- s.fitAddon.fit();
10319
- s.writer.followMode = savedFollow2;
10320
- send({ type: 'resize', id: sid, cols: s.term.cols, rows: s.term.rows });
10321
- if (wasAtBottom) {
10322
- s.term.scrollToBottom();
10323
- } else if (s.term.cols !== oldCols) {
10324
- _restoreScrollAnchor(s.term, savedAnchor);
10325
- s._promptLinesResolved = false;
10326
- } else {
10327
- s.term.scrollToLine(savedLine2);
10328
- }
10753
+ _fitTerminalPreservingViewport(s, sid, { sendResize: true });
10329
10754
  return;
10330
10755
  }
10331
10756
  } catch (_) { /* proposeDimensions may fail if container hidden */ }
10332
10757
  if (s.writer.followMode) {
10333
- s.term.scrollToBottom();
10758
+ _ensureScrolledToBottom(s);
10334
10759
  }
10335
10760
  }
10336
10761
  }, 1500);
@@ -10341,11 +10766,7 @@ function onOutput(msg) {
10341
10766
  if (banner) {
10342
10767
  banner.remove();
10343
10768
  s._bannerCleared = true;
10344
- const ln = s.term.buffer.active.viewportY;
10345
- const ab = s.writer.followMode;
10346
- s.fitAddon.fit();
10347
- s.writer.followMode = ab; // restore after fit reflow
10348
- if (!ab) s.term.scrollToLine(ln);
10769
+ _fitTerminalPreservingViewport(s, sid);
10349
10770
  } else {
10350
10771
  s._bannerCleared = true; // no banner exists, skip future checks
10351
10772
  }
@@ -10366,8 +10787,7 @@ setInterval(() => {
10366
10787
  if (!id) return;
10367
10788
  const s = state.sessions.get(id);
10368
10789
  if (!s || !s.term) return;
10369
- const buf = s.term.buffer.active;
10370
- const atBottom = buf.baseY > 0 && buf.viewportY >= buf.baseY;
10790
+ const atBottom = _isAtTerminalFollowBottom(s);
10371
10791
 
10372
10792
  // Fix 1: followMode is false but viewport IS at bottom — unlock
10373
10793
  if (!s.writer.followMode && atBottom && !s.writer._chunking) {
@@ -10390,31 +10810,177 @@ setInterval(() => {
10390
10810
  s.writer.queue = '';
10391
10811
  s.writer.scheduled = false;
10392
10812
  if (batch) chunkedWrite(s, batch, () => {
10393
- if (s.writer.followMode && !s.writer._userScrollLocked && s.term) s.term.scrollToBottom();
10813
+ if (s.writer.followMode && !s.writer._userScrollLocked && s.term) _ensureScrolledToBottom(s);
10394
10814
  });
10395
10815
  });
10396
10816
  }
10397
10817
  }, 2000);
10398
10818
 
10399
- // Ensure the terminal viewport is scrolled to the bottom — defensive helper
10400
- // that syncs BOTH xterm's internal viewportY AND the .xterm-viewport DOM
10401
- // scrollTop, across 2 animation frames. Handles edge cases where a tab-switch
10402
- // container toggle (display:none display:flex) desynchronizes the DOM scroll
10403
- // from xterm's internal state.
10819
+ function _terminalFollowViewportTarget(s) {
10820
+ if (!s || !s.term) return 0;
10821
+ const buf = s.term.buffer.active;
10822
+ const baseY = buf.baseY || 0;
10823
+ const rows = s.term.rows || 0;
10824
+ if (rows <= 0) return baseY;
10825
+
10826
+ const agentType = _clientAgentTypeForSession(s);
10827
+ if (agentType !== 'codex') return baseY;
10828
+
10829
+ // Codex restores often carry a screen drawn at the previous PTY height. When
10830
+ // CTM opens a taller browser viewport, xterm appends blank screen rows below
10831
+ // the live prompt. Literal scrollToBottom then anchors those blanks, making
10832
+ // the input look stuck halfway up the terminal. Anchor the last meaningful
10833
+ // screen row at the viewport bottom when the blank tail is large.
10834
+ const screenStart = baseY;
10835
+ const screenEnd = baseY + rows - 1;
10836
+ let lastMeaningful = -1;
10837
+ for (let row = screenEnd; row >= screenStart; row--) {
10838
+ const line = buf.getLine(row);
10839
+ if (line && line.translateToString(true).trim()) {
10840
+ lastMeaningful = row;
10841
+ break;
10842
+ }
10843
+ }
10844
+ if (lastMeaningful < 0) return baseY;
10845
+ const cursorAbs = baseY + (buf.cursorY || 0);
10846
+ const anchor = Math.max(lastMeaningful, cursorAbs);
10847
+ const blankTailRows = screenEnd - anchor;
10848
+ const threshold = Math.max(6, Math.floor(rows * 0.20));
10849
+ if (blankTailRows < threshold) return baseY;
10850
+ return Math.max(0, Math.min(baseY, anchor - rows + 1));
10851
+ }
10852
+
10853
+ function _withProgrammaticTerminalScroll(s, fn) {
10854
+ if (!s || !s.writer) {
10855
+ try { fn(); } catch {}
10856
+ return;
10857
+ }
10858
+ s.writer._programmaticScrollDepth = (s.writer._programmaticScrollDepth || 0) + 1;
10859
+ try { fn(); } catch {}
10860
+ requestAnimationFrame(() => requestAnimationFrame(() => {
10861
+ if (!s.writer) return;
10862
+ s.writer._programmaticScrollDepth = Math.max(0, (s.writer._programmaticScrollDepth || 1) - 1);
10863
+ }));
10864
+ }
10865
+
10866
+ function _scrollTerminalToFollowBottom(s) {
10867
+ if (!s || !s.term) return;
10868
+ const target = _terminalFollowViewportTarget(s);
10869
+ _withProgrammaticTerminalScroll(s, () => {
10870
+ s.term.scrollToLine(target);
10871
+ const buf = s.term.buffer.active;
10872
+ const vp = s.container ? s.container.querySelector('.xterm-viewport') : null;
10873
+ if (vp && target >= buf.baseY) {
10874
+ try { vp.scrollTop = vp.scrollHeight; } catch {}
10875
+ }
10876
+ });
10877
+ }
10878
+
10879
+ function _isAtTerminalFollowBottom(s) {
10880
+ if (!s || !s.term) return false;
10881
+ const target = _terminalFollowViewportTarget(s);
10882
+ const buf = s.term.buffer.active;
10883
+ const viewportY = buf.viewportY || 0;
10884
+ const baseY = buf.baseY || 0;
10885
+ return Math.abs(viewportY - target) <= 1 || Math.abs(viewportY - baseY) <= 1;
10886
+ }
10887
+
10888
+ function _findCodexInternalBlankGap(s) {
10889
+ if (!s || !s.term) return null;
10890
+ if (_clientAgentTypeForSession(s) !== 'codex') return null;
10891
+ if (s.writer && s.writer._userScrollLocked) return null;
10892
+ const buf = s.term.buffer.active;
10893
+ const rows = s.term.rows || 0;
10894
+ const baseY = buf.baseY || 0;
10895
+ if (rows < 20 || Math.abs((buf.viewportY || 0) - baseY) > 1) return null;
10896
+
10897
+ const meaningful = [];
10898
+ let promptAbs = -1;
10899
+ let hasCompletedTurnOutputAfterPrompt = false;
10900
+ for (let offset = 0; offset < rows; offset++) {
10901
+ const abs = baseY + offset;
10902
+ const line = buf.getLine(abs);
10903
+ const text = line ? line.translateToString(true) : '';
10904
+ if (text.trim().startsWith('›')) promptAbs = abs;
10905
+ if (text.trim()) meaningful.push(abs);
10906
+ }
10907
+ if (promptAbs < 0 || meaningful.length < 2) return null;
10908
+ for (const abs of meaningful) {
10909
+ if (abs <= promptAbs) continue;
10910
+ const line = buf.getLine(abs);
10911
+ const text = line ? line.translateToString(true).trim() : '';
10912
+ if (!text) continue;
10913
+ if (/^gpt-[\w.-]+\s+/i.test(text) || /\b(x?high|medium|low)\b.*\s-\s/.test(text)) continue;
10914
+ hasCompletedTurnOutputAfterPrompt = true;
10915
+ break;
10916
+ }
10917
+ if (!hasCompletedTurnOutputAfterPrompt) return null;
10918
+
10919
+ let best = null;
10920
+ for (let i = 1; i < meaningful.length; i++) {
10921
+ const prev = meaningful[i - 1];
10922
+ const next = meaningful[i];
10923
+ if (next > promptAbs) break;
10924
+ const gapRows = next - prev - 1;
10925
+ if (!best || gapRows > best.gapRows) best = { startAbs: prev + 1, nextAbs: next, gapRows };
10926
+ }
10927
+ const threshold = Math.max(8, Math.floor(rows * 0.18));
10928
+ if (!best || best.gapRows < threshold) return null;
10929
+ const keepRows = 2;
10930
+ const deleteRows = best.gapRows - keepRows;
10931
+ if (deleteRows <= 0) return null;
10932
+ return { startAbs: best.startAbs, deleteRows };
10933
+ }
10934
+
10935
+ function _compactCodexInternalBlankGap(s, onDone) {
10936
+ if (!s || !s.term || s._codexBlankGapCompacting) return false;
10937
+ const gap = _findCodexInternalBlankGap(s);
10938
+ if (!gap) return false;
10939
+ const buf = s.term.buffer.active;
10940
+ const baseY = buf.baseY || 0;
10941
+ const deleteScreenRow = Math.max(1, gap.startAbs - baseY + 1);
10942
+ const cursorAbs = baseY + (buf.cursorY || 0);
10943
+ const nextCursorAbs = cursorAbs >= gap.startAbs ? Math.max(baseY, cursorAbs - gap.deleteRows) : cursorAbs;
10944
+ const nextCursorRow = Math.max(1, nextCursorAbs - baseY + 1);
10945
+ const nextCursorCol = Math.max(1, (buf.cursorX || 0) + 1);
10946
+ const seq = '\x1b[' + deleteScreenRow + ';1H\x1b[' + gap.deleteRows + 'M\x1b[' + nextCursorRow + ';' + nextCursorCol + 'H';
10947
+
10948
+ s._codexBlankGapCompacting = true;
10949
+ if (s.writer) s.writer._programmaticScrollDepth = (s.writer._programmaticScrollDepth || 0) + 1;
10950
+ let finished = false;
10951
+ const finish = () => {
10952
+ if (finished) return;
10953
+ finished = true;
10954
+ s._codexBlankGapCompacting = false;
10955
+ requestAnimationFrame(() => requestAnimationFrame(() => {
10956
+ if (s.writer) s.writer._programmaticScrollDepth = Math.max(0, (s.writer._programmaticScrollDepth || 1) - 1);
10957
+ }));
10958
+ if (onDone) onDone();
10959
+ };
10960
+ try {
10961
+ s.term.write(seq, finish);
10962
+ setTimeout(finish, 500);
10963
+ } catch (_) {
10964
+ finish();
10965
+ }
10966
+ return true;
10967
+ }
10968
+
10969
+ // Ensure the terminal viewport is scrolled to the user's active bottom. For
10970
+ // most terminals this is xterm's literal bottom. For Codex restored TUI
10971
+ // screens, it is the last meaningful row before a large blank tail.
10404
10972
  //
10405
10973
  // Skip if the user has manually scrolled (`_userScrollLocked`) — we don't want
10406
10974
  // to yank them back to the bottom if they were reading scrollback.
10407
10975
  function _ensureScrolledToBottom(s) {
10408
10976
  if (!s || !s.term) return;
10409
10977
  if (s.writer && s.writer._userScrollLocked) return;
10410
- try { s.term.scrollToBottom(); } catch {}
10411
- const vp = s.container ? s.container.querySelector('.xterm-viewport') : null;
10412
- if (vp) { try { vp.scrollTop = vp.scrollHeight; } catch {} }
10978
+ if (_compactCodexInternalBlankGap(s, () => _ensureScrolledToBottom(s))) return;
10979
+ _scrollTerminalToFollowBottom(s);
10413
10980
  requestAnimationFrame(() => {
10414
10981
  if (!s.term) return;
10415
10982
  if (s.writer && s.writer._userScrollLocked) return;
10416
- try { s.term.scrollToBottom(); } catch {}
10417
- if (vp) { try { vp.scrollTop = vp.scrollHeight; } catch {} }
10983
+ _scrollTerminalToFollowBottom(s);
10418
10984
  });
10419
10985
  }
10420
10986
 
@@ -10518,7 +11084,7 @@ function onSnapshot(msg) {
10518
11084
  s._autoReflowAttempted = true;
10519
11085
  send({ type: 'snapshot', id: msg.id, cols: s.term.cols, rows: s.term.rows });
10520
11086
  } else {
10521
- s.term.scrollToBottom();
11087
+ _ensureScrolledToBottom(s);
10522
11088
  }
10523
11089
  return;
10524
11090
  }
@@ -10532,7 +11098,7 @@ function onSnapshot(msg) {
10532
11098
  try {
10533
11099
  const dims = s.fitAddon.proposeDimensions();
10534
11100
  if (dims && (dims.cols !== s.term.cols || dims.rows !== s.term.rows)) {
10535
- s.fitAddon.fit();
11101
+ _fitTerminalPreservingViewport(s, id);
10536
11102
  }
10537
11103
  } catch {}
10538
11104
  // [DO-NOT-REMOVE] misformatted-output fix — fixes user-reported bug:
@@ -10589,7 +11155,6 @@ function onSnapshot(msg) {
10589
11155
  const snapshotDone = () => {
10590
11156
  s.writer.followMode = true;
10591
11157
  s.writer._userScrollLocked = false;
10592
- s.term.scrollToBottom();
10593
11158
  // Double-RAF the scroll — the browser may reset .xterm-viewport scrollTop to 0
10594
11159
  // when the container goes display:none → display:flex during tab switch.
10595
11160
  // xterm's internal viewportY is correct, but the DOM scrollTop can lag a frame
@@ -10897,6 +11462,29 @@ function tabOrderSortCmp(aEntry, bEntry) {
10897
11462
  return (ai === -1 ? 9999 : ai) - (bi === -1 ? 9999 : bi);
10898
11463
  }
10899
11464
 
11465
+ function worktreeAttentionBadge(s) {
11466
+ const wt = s?.meta?.worktreeStatus;
11467
+ if (!wt || !wt.needsAttention) return '';
11468
+ const branch = wt.branch || s?.meta?.branch || '';
11469
+ if (!branch || branch === 'main' || branch === 'master') return '';
11470
+ const dirtyFiles = Number(wt.dirtyFiles || 0);
11471
+ const unmergedCommits = Number(wt.unmergedCommits || 0);
11472
+ if (dirtyFiles <= 0 && unmergedCommits <= 0) return '';
11473
+
11474
+ const parts = [];
11475
+ const titleParts = [];
11476
+ if (dirtyFiles > 0) {
11477
+ parts.push(`<span class="wt-part" aria-hidden="true">&#9998;${dirtyFiles}</span>`);
11478
+ titleParts.push(`${dirtyFiles} uncommitted file${dirtyFiles === 1 ? '' : 's'}`);
11479
+ }
11480
+ if (unmergedCommits > 0) {
11481
+ parts.push(`<span class="wt-part" aria-hidden="true">&#8593;${unmergedCommits}</span>`);
11482
+ titleParts.push(`${unmergedCommits} commit${unmergedCommits === 1 ? '' : 's'} not on main`);
11483
+ }
11484
+ const title = `${branch}: ${titleParts.join(', ')}`;
11485
+ return `<span class="worktree-attn-badge" title="${escHtml(title)}" aria-label="${escHtml(title)}">${parts.join('')}</span>`;
11486
+ }
11487
+
10900
11488
  let _renderSessionListTimer = null;
10901
11489
  function renderSessionList(force) {
10902
11490
  if (!force) {
@@ -10977,11 +11565,13 @@ function renderSessionList(force) {
10977
11565
  ).join('');
10978
11566
  const branchName = s.meta?.branch || '';
10979
11567
  const branchBadge = branchName && branchName !== 'main' ? `<span class="branch-badge" title="Branch: ${escHtml(branchName)}">&#9741; ${escHtml(branchName.length > 15 ? branchName.slice(0, 15) + '...' : branchName)}</span>` : '';
10980
- return `${groupSep}<div class="session-group${isActive ? ' active' : ''}" data-session-id="${id}"><div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''} ${sStatus.cls === 'running' ? 'running' : ''}" data-session-id="${id}" data-agent="${agentType}" onclick="sessionItemClick('${id}', event)" ondblclick="sessionItemDblClick('${id}', event)">
11568
+ const worktreeBadge = worktreeAttentionBadge(s);
11569
+ return `${groupSep}<div class="session-group${isActive ? ' active' : ''}" data-session-id="${id}"><div class="session-item ${isActive ? 'active' : ''} ${isStale ? 'stale' : ''} ${sStatus.cls === 'running' ? 'running' : ''} ${worktreeBadge ? 'has-worktree-attn' : ''}" data-session-id="${id}" data-agent="${agentType}" onclick="sessionItemClick('${id}', event)" ondblclick="sessionItemDblClick('${id}', event)">
10981
11570
  <span class="dot"></span>
10982
11571
  ${providerIconSvg(agentType, 14)}
10983
11572
  <span class="label">${escHtml(label)}</span>
10984
11573
  ${branchBadge}
11574
+ ${worktreeBadge}
10985
11575
  ${statusTag}
10986
11576
  <span class="agent-badge ${agentType}">${agentLabel}</span>
10987
11577
  ${idleHint}
@@ -11047,6 +11637,13 @@ function getSessionStatus(s) {
11047
11637
  }
11048
11638
  }
11049
11639
 
11640
+ const serverWorkingAt = s._serverWorkingAt || 0;
11641
+ const waitingAt = s._waitingForInputAt || 0;
11642
+ const serverWorking = serverWorkingAt &&
11643
+ (now - serverWorkingAt) < 10000 &&
11644
+ (!s._waitingForInput || !waitingAt || serverWorkingAt >= waitingAt);
11645
+ if (serverWorking) return { cls: 'running', text: 'Running' };
11646
+
11050
11647
  if (s._waitingForInput) return { cls: 'waiting', text: 'Waiting' };
11051
11648
 
11052
11649
  // Primary: SessionStream status (received via WS stream-status events).
@@ -11061,8 +11658,6 @@ function getSessionStatus(s) {
11061
11658
  // Fallback: old PTY-based signals (for sessions not tracked by SessionStream)
11062
11659
  const lastOut = Math.max(s._lastOutputAt || 0, s.meta?.lastActivity || 0);
11063
11660
  const recentOutput = lastOut && (now - lastOut) < 5000;
11064
- const serverWorking = s._serverWorkingAt && (now - s._serverWorkingAt) < 10000;
11065
- if (serverWorking) return { cls: 'running', text: 'Running' };
11066
11661
  if (s._waitingForInput && !recentOutput) return { cls: 'waiting', text: 'Waiting' };
11067
11662
  if (recentOutput) return { cls: 'running', text: 'Running' };
11068
11663
  const justSentInput = s._lastInputAt && (now - s._lastInputAt) < 3000;
@@ -11383,11 +11978,7 @@ function showCompactBannerIfStale(id, s) {
11383
11978
  s.container.prepend(banner);
11384
11979
  // Refit terminal since banner takes space — preserve scroll position
11385
11980
  requestAnimationFrame(() => {
11386
- const ln = s.term.buffer.active.viewportY;
11387
- const atBot = s.writer.followMode;
11388
- s.fitAddon.fit();
11389
- s.writer.followMode = atBot; // restore after fit reflow
11390
- if (!atBot) s.term.scrollToLine(ln);
11981
+ _fitTerminalPreservingViewport(s, id);
11391
11982
  });
11392
11983
  }
11393
11984
 
@@ -11404,11 +11995,7 @@ function dismissCompactBanner(id) {
11404
11995
  if (banner) {
11405
11996
  banner.remove();
11406
11997
  requestAnimationFrame(() => {
11407
- const ln = s.term.buffer.active.viewportY;
11408
- const atBot = s.writer.followMode;
11409
- s.fitAddon.fit();
11410
- s.writer.followMode = atBot; // restore after fit reflow
11411
- if (!atBot) s.term.scrollToLine(ln);
11998
+ _fitTerminalPreservingViewport(s, id);
11412
11999
  });
11413
12000
  }
11414
12001
  }
@@ -12140,20 +12727,7 @@ function toggleSidebar() {
12140
12727
  setTimeout(() => {
12141
12728
  const s = state.sessions.get(state.activeTab);
12142
12729
  if (!s) return;
12143
- const wasAtBottom = s.writer.followMode;
12144
- const savedLine = s.term.buffer.active.viewportY;
12145
- const oldCols = s.term.cols;
12146
- const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
12147
- s.fitAddon.fit();
12148
- s.writer.followMode = wasAtBottom; // restore after fit reflow
12149
- if (!wasAtBottom) {
12150
- if (s.term.cols !== oldCols) {
12151
- _restoreScrollAnchor(s.term, savedAnchor);
12152
- s._promptLinesResolved = false;
12153
- } else {
12154
- s.term.scrollToLine(savedLine);
12155
- }
12156
- }
12730
+ _fitTerminalPreservingViewport(s, state.activeTab);
12157
12731
  }, 100);
12158
12732
  }
12159
12733
  }
@@ -12606,8 +13180,7 @@ function renderPaneTree() {
12606
13180
  s.term.options.fontSize = sz;
12607
13181
  s._suppressResize = false;
12608
13182
  }
12609
- s.fitAddon.fit();
12610
- send({ type: 'resize', id: leaf.sessionId, cols: s.term.cols, rows: s.term.rows });
13183
+ _fitTerminalPreservingViewport(s, leaf.sessionId, { sendResize: true });
12611
13184
  });
12612
13185
  });
12613
13186
  }
@@ -12714,11 +13287,51 @@ function fitAllVisibleTerminals() {
12714
13287
  if (!leaf.sessionId) return;
12715
13288
  var s = state.sessions.get(leaf.sessionId);
12716
13289
  if (!s || !s.fitAddon || !s.term) return;
12717
- s.fitAddon.fit();
12718
- send({ type: 'resize', id: leaf.sessionId, cols: s.term.cols, rows: s.term.rows });
13290
+ _fitTerminalPreservingViewport(s, leaf.sessionId, { sendResize: true });
12719
13291
  });
12720
13292
  }
12721
13293
 
13294
+ function _isTerminalFollowingVisualBottom(s) {
13295
+ if (!s || !s.term) return false;
13296
+ if (s.writer && s.writer._userScrollLocked) return false;
13297
+ return !!(s.writer && s.writer.followMode) || _isAtTerminalFollowBottom(s);
13298
+ }
13299
+
13300
+ function _fitTerminalPreservingViewport(s, sessionId, opts) {
13301
+ opts = opts || {};
13302
+ if (!s || !s.term || !s.fitAddon) return;
13303
+ const buf = s.term.buffer.active;
13304
+ const wasFollowing = _isTerminalFollowingVisualBottom(s);
13305
+ const savedLine = buf.viewportY;
13306
+ const oldCols = s.term.cols;
13307
+ const savedAnchor = wasFollowing ? null : _getScrollAnchor(s.term);
13308
+ const savedFollow = s.writer ? !!s.writer.followMode : false;
13309
+ const savedLocked = s.writer ? !!s.writer._userScrollLocked : false;
13310
+
13311
+ s.fitAddon.fit();
13312
+
13313
+ if (s.writer) {
13314
+ s.writer.followMode = savedFollow;
13315
+ s.writer._userScrollLocked = savedLocked;
13316
+ }
13317
+ if (opts.sendResize && sessionId) {
13318
+ send({ type: 'resize', id: sessionId, cols: s.term.cols, rows: s.term.rows });
13319
+ }
13320
+
13321
+ if (wasFollowing) {
13322
+ if (s.writer) {
13323
+ s.writer.followMode = true;
13324
+ s.writer._userScrollLocked = false;
13325
+ }
13326
+ _ensureScrolledToBottom(s);
13327
+ } else if (s.term.cols !== oldCols) {
13328
+ _withProgrammaticTerminalScroll(s, () => _restoreScrollAnchor(s.term, savedAnchor));
13329
+ s._promptLinesResolved = false;
13330
+ } else {
13331
+ _withProgrammaticTerminalScroll(s, () => s.term.scrollToLine(savedLine));
13332
+ }
13333
+ }
13334
+
12722
13335
  function saveSplitLayout() {
12723
13336
  // Save whichever layout is active (current or saved/suspended)
12724
13337
  var layout = state.splitRoot || state.savedSplitLayout;
@@ -12946,26 +13559,7 @@ function fitActiveTerminal() {
12946
13559
  if (state.activeTab && state.sessions.has(state.activeTab)) {
12947
13560
  const s = state.sessions.get(state.activeTab);
12948
13561
  if (!s.term || !s.fitAddon) return; // stub session — no terminal yet
12949
- const buf = s.term.buffer.active;
12950
- const wasAtBottom = buf.viewportY >= buf.baseY;
12951
- const savedLine = buf.viewportY;
12952
- const oldCols = s.term.cols;
12953
- const savedAnchor = wasAtBottom ? null : _getScrollAnchor(s.term);
12954
- const savedFollow = s.writer.followMode;
12955
- s.fitAddon.fit();
12956
- s.writer.followMode = savedFollow; // restore after fit reflow
12957
- send({ type: 'resize', id: state.activeTab, cols: s.term.cols, rows: s.term.rows });
12958
- if (wasAtBottom) {
12959
- s.term.scrollToBottom();
12960
- } else if (s.term.cols !== oldCols) {
12961
- // Cols changed — reflow shifted content, use anchor search
12962
- _restoreScrollAnchor(s.term, savedAnchor);
12963
- // Invalidate prompt line positions — reflow shifts them
12964
- s._promptLinesResolved = false;
12965
- } else {
12966
- // Same cols — just restore line position
12967
- s.term.scrollToLine(savedLine);
12968
- }
13562
+ _fitTerminalPreservingViewport(s, state.activeTab, { sendResize: true });
12969
13563
  }
12970
13564
  });
12971
13565
  }
@@ -13050,8 +13644,7 @@ window.addEventListener('resize', updateTabOverflowBtn);
13050
13644
  const size = s.term.options.fontSize;
13051
13645
  s.term.options.fontSize = size + 1;
13052
13646
  s.term.options.fontSize = size;
13053
- if (s.fitAddon) s.fitAddon.fit();
13054
- send({ type: 'resize', id: sid, cols: s.term.cols, rows: s.term.rows });
13647
+ _fitTerminalPreservingViewport(s, sid, { sendResize: true });
13055
13648
  }, 200);
13056
13649
  }
13057
13650
  // Re-watch with the new DPI value
@@ -13376,8 +13969,9 @@ async function loadRecentSessions() {
13376
13969
  populateModelFilter(allRecentSessions);
13377
13970
  renderFilteredSessions();
13378
13971
 
13379
- // Resolve pending hash session (deep link to session review)
13380
- if (state.pendingHashSession) {
13972
+ // Resolve pending review hash sessions here. Active #session=<id> hashes are
13973
+ // handled by onServerReady(), after live PTY sessions have been restored.
13974
+ if (state.pendingHashSession && state.pendingHashType === 'review') {
13381
13975
  const s = allRecentSessions.find(x => x.sessionId === state.pendingHashSession)
13382
13976
  || allRecentSessions.find(x => x.provisionalId === state.pendingHashSession);
13383
13977
  if (s) {
@@ -14854,7 +15448,7 @@ function escHtml(s) {
14854
15448
  // element (.session-item or .tab) sets color via CSS so the icon inherits the
14855
15449
  // per-provider hue and inverts cleanly when the row is active (light bg, dark text).
14856
15450
  // Returns an HTML string with title for screen-readers + hover tooltip.
14857
- const AGENT_LABELS = { walle: 'Wall-E', codex: 'Codex', gemini: 'Gemini', claude: 'Claude Code', shell: 'Shell' };
15451
+ const AGENT_LABELS = { walle: 'Wall-E', codex: 'Codex', gemini: 'Gemini', claude: 'Claude Code', 'claude-desktop': 'Claude Desktop', shell: 'Shell' };
14858
15452
  function getAgentType(s) {
14859
15453
  if (!s) return 'shell';
14860
15454
  if (s.meta?.type === 'walle') return 'walle';
@@ -14870,6 +15464,7 @@ function providerIconSvg(agentType, sizePx) {
14870
15464
  // 16x16 viewBox, fill / stroke = currentColor.
14871
15465
  let inner;
14872
15466
  switch (agentType) {
15467
+ case 'claude-desktop':
14873
15468
  case 'claude':
14874
15469
  // Anthropic / Claude brand mark (the asterisk-sparkle from claude.ai's
14875
15470
  // favicon). Original path is on a 248×248 grid; we keep that viewBox
@@ -15652,7 +16247,7 @@ function onSkillSessionCreated(msg) {
15652
16247
  const newWidth = Math.max(150, Math.min(600, e.clientX));
15653
16248
  sidebar.style.width = newWidth + 'px';
15654
16249
  if (state.activeTab && state.sessions.has(state.activeTab)) {
15655
- state.sessions.get(state.activeTab).fitAddon.fit();
16250
+ _fitTerminalPreservingViewport(state.sessions.get(state.activeTab), state.activeTab);
15656
16251
  }
15657
16252
  });
15658
16253
 
@@ -17136,13 +17731,18 @@ function onDataChanged(msg) {
17136
17731
  // Track active title flash so it can be cancelled when input is sent
17137
17732
  let _titleFlashStop = null;
17138
17733
 
17139
- // Clear waiting state for a session (tab indicator + title flash)
17140
- function clearWaitingState(sessionId) {
17734
+ // Clear waiting state for a session (tab indicator + title flash).
17735
+ // markInput is only for actual user input paths; server-side resume signals
17736
+ // should not manufacture a "just sent input" running window.
17737
+ function clearWaitingState(sessionId, opts = {}) {
17141
17738
  const s = state.sessions.get(sessionId);
17142
17739
  if (!s) return;
17143
17740
  const wasWaiting = s._waitingForInput;
17144
17741
  s._waitingForInput = false;
17145
- s._lastInputAt = Date.now();
17742
+ s._waitingForInputAt = 0;
17743
+ const now = opts.timestamp || Date.now();
17744
+ if (opts.markInput !== false) s._lastInputAt = now;
17745
+ if (opts.markWorking) s._serverWorkingAt = now;
17146
17746
  // Only do DOM work if the session was actually in waiting state
17147
17747
  if (wasWaiting) {
17148
17748
  const tabs = document.querySelectorAll('#tabbar .tab');
@@ -17205,10 +17805,18 @@ function onSessionActivity(msg) {
17205
17805
  if (!s) continue;
17206
17806
  const oldBucket = activeActivityBucket(s);
17207
17807
  const oldStatus = getSessionStatus(s).cls;
17208
- if (serverState === 'thinking') s._serverWorkingAt = Date.now();
17808
+ const serverTs = SessionActivityUtils.parseTimeMs(ts) || Date.now();
17809
+ if (serverState === 'thinking' || serverState === 'active') {
17810
+ const waitingAt = s._waitingForInputAt || 0;
17811
+ const activityIsNewerThanWaiting = !s._waitingForInput || !waitingAt || serverTs >= waitingAt;
17812
+ if (activityIsNewerThanWaiting) s._serverWorkingAt = Date.now();
17813
+ if (s._waitingForInput && activityIsNewerThanWaiting) {
17814
+ clearWaitingState(id, { markInput: false, markWorking: true });
17815
+ }
17816
+ }
17209
17817
  if (s.meta && ts) {
17210
17818
  const prev = SessionActivityUtils.parseTimeMs(s.meta.lastActivity);
17211
- const next = SessionActivityUtils.parseTimeMs(ts);
17819
+ const next = serverTs;
17212
17820
  if (next && next > prev) s.meta.lastActivity = ts;
17213
17821
  }
17214
17822
  if (currentActiveSort === 'by_activity' && (oldBucket !== activeActivityBucket(s) || oldStatus !== getSessionStatus(s).cls)) {
@@ -17223,7 +17831,7 @@ function onSessionActivity(msg) {
17223
17831
  // cannot reliably distinguish TUI redraws from real output.
17224
17832
  function onSessionResumed(msg) {
17225
17833
  const s = state.sessions.get(msg.id);
17226
- if (s) s._waitingForInput = false;
17834
+ if (s) clearWaitingState(msg.id, { markInput: false, markWorking: true, timestamp: msg.timestamp || Date.now() });
17227
17835
  }
17228
17836
 
17229
17837
  // Authoritative session state (hooks or OTEL). Wins over the regex fallback.
@@ -17236,7 +17844,7 @@ function onAuthoritativeStatus(msg) {
17236
17844
  s._authoritativeStatusAt = msg.timestamp || Date.now();
17237
17845
  // When the agent is working, explicitly clear "waiting for input" state —
17238
17846
  // the regex fallback may have left it set before hooks took over.
17239
- if (s._working && s._waitingForInput) clearWaitingState(msg.id);
17847
+ if (s._working && s._waitingForInput) clearWaitingState(msg.id, { markInput: false, markWorking: true, timestamp: s._authoritativeStatusAt });
17240
17848
  // Update sidebar dot class
17241
17849
  const item = document.querySelector('.session-item[data-session-id="' + CSS.escape(msg.id) + '"]');
17242
17850
  if (item) {
@@ -17324,7 +17932,10 @@ const _lastNotif = {};
17324
17932
  function onWaitingForInput(msg) {
17325
17933
  const sessionId = msg.id;
17326
17934
  const session = state.sessions.get(sessionId);
17327
- if (session) session._waitingForInput = true;
17935
+ if (session) {
17936
+ session._waitingForInput = true;
17937
+ session._waitingForInputAt = msg.timestamp || Date.now();
17938
+ }
17328
17939
  // Re-scan prompts — JSONL is fully written when Claude yields back to user.
17329
17940
  // Invalidate cache so we get fresh data (not stale 30s cached results).
17330
17941
  for (const key of Object.keys(_promptScanCache)) {
@@ -17525,8 +18136,30 @@ window.addEventListener('message', (e) => {
17525
18136
  // --- Hash routing ---
17526
18137
  const NAV_TARGETS = ['sessions', 'prompts', 'rules', 'insights', 'permissions', 'codereview', 'walle', 'models', 'backups', 'worktrees', 'setup'];
17527
18138
 
17528
- function handleHashRoute() {
18139
+ function _parseHashRoute() {
17529
18140
  const hash = location.hash.slice(1);
18141
+ const parts = hash ? hash.split('&') : [];
18142
+ const firstPart = parts[0] || '';
18143
+ const isNav = NAV_TARGETS.includes(firstPart);
18144
+ const params = {};
18145
+ for (const part of (isNav ? parts.slice(1) : parts)) {
18146
+ const eq = part.indexOf('=');
18147
+ if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1));
18148
+ }
18149
+ return { hash, firstPart, isNav, params };
18150
+ }
18151
+
18152
+ function captureInitialSessionHashIntent() {
18153
+ const route = _parseHashRoute();
18154
+ if (route.params.session) {
18155
+ state.pendingHashSession = route.params.session;
18156
+ state.pendingHashType = 'active';
18157
+ }
18158
+ }
18159
+
18160
+ function handleHashRoute() {
18161
+ const route = _parseHashRoute();
18162
+ const hash = route.hash;
17530
18163
 
17531
18164
  // No hash — fall back to saved nav pref from DB
17532
18165
  if (!hash) {
@@ -17545,16 +18178,9 @@ function handleHashRoute() {
17545
18178
  }
17546
18179
 
17547
18180
  // Parse: first segment may be a nav target, rest are key=value params
17548
- const parts = hash.split('&');
17549
- const firstPart = parts[0];
17550
- const isNav = NAV_TARGETS.includes(firstPart);
17551
-
17552
- // Parse key=value params (skip first part if it's a nav target)
17553
- const params = {};
17554
- for (const part of (isNav ? parts.slice(1) : parts)) {
17555
- const eq = part.indexOf('=');
17556
- if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1));
17557
- }
18181
+ const firstPart = route.firstPart;
18182
+ const isNav = route.isNav;
18183
+ const params = route.params;
17558
18184
 
17559
18185
  // Check for nav target: #permissions, #prompts, #rules, #insights, #sessions, #codereview
17560
18186
  if (isNav && !Object.keys(params).length) {
@@ -17687,6 +18313,7 @@ if (sessionStorage.getItem('ctm_restarting')) {
17687
18313
  }
17688
18314
  }, 30000);
17689
18315
  }
18316
+ captureInitialSessionHashIntent();
17690
18317
  connect();
17691
18318
  state._prefsLoaded = loadPrefs().then(() => {
17692
18319
  loadRecentSessions();
@@ -17706,7 +18333,7 @@ function onQueueState(msg) {
17706
18333
  // Scroll terminal to bottom when queue is active (only if user hasn't scrolled up)
17707
18334
  if (msg.sessionId) {
17708
18335
  const s = state.sessions.get(msg.sessionId);
17709
- if (s && s.writer.followMode) s.term.scrollToBottom();
18336
+ if (s && s.writer.followMode) _ensureScrolledToBottom(s);
17710
18337
  }
17711
18338
  }
17712
18339
 
@@ -18165,7 +18792,7 @@ async function refreshQpPromptItems() {
18165
18792
  if (p.images && p.images.length > 0) {
18166
18793
  const newImages = p.images.map(img => ({
18167
18794
  id: img.id, filename: img.filename, path: img.file_path,
18168
- url: '/api/images/file/' + img.filename,
18795
+ url: qpImageUrl(img),
18169
18796
  }));
18170
18797
  if (JSON.stringify(item.images || []) !== JSON.stringify(newImages)) {
18171
18798
  item.images = newImages;
@@ -18199,6 +18826,12 @@ function promptContentToText(prompt) {
18199
18826
  return prompt.content || '';
18200
18827
  }
18201
18828
 
18829
+ function qpImageUrl(img) {
18830
+ const source = String(img?.path || img?.file_path || img?.filename || '');
18831
+ const basename = source.split(/[\\/]/).filter(Boolean).pop() || '';
18832
+ return '/api/images/file/' + encodeURIComponent(basename);
18833
+ }
18834
+
18202
18835
  async function addSelectedPromptToQp() {
18203
18836
  const sel = document.getElementById('qp-prompt-select');
18204
18837
  const promptId = parseInt(sel.value);
@@ -18213,7 +18846,7 @@ async function addSelectedPromptToQp() {
18213
18846
  text: promptContentToText(prompt),
18214
18847
  images: (prompt.images || []).map(img => ({
18215
18848
  id: img.id, filename: img.filename, path: img.file_path,
18216
- url: '/api/images/file/' + img.filename,
18849
+ url: qpImageUrl(img),
18217
18850
  })),
18218
18851
  });
18219
18852
  saveQpDraft();
@@ -18789,7 +19422,7 @@ async function sendQueueFromPanel() {
18789
19422
  item.status = 'sent';
18790
19423
  saveQpDraft();
18791
19424
  renderQpItems();
18792
- session.term.scrollToBottom();
19425
+ _ensureScrolledToBottom(session);
18793
19426
  } catch (e) {
18794
19427
  toast('Failed to send: ' + e.message, { type: 'error' });
18795
19428
  }
@@ -18828,7 +19461,7 @@ activateTab = function(id) {
18828
19461
  // Re-fit terminal after queue panel may have changed layout
18829
19462
  requestAnimationFrame(() => {
18830
19463
  const s = state.sessions.get(id);
18831
- if (s && s.fitAddon) s.fitAddon.fit();
19464
+ if (s && s.fitAddon) _fitTerminalPreservingViewport(s, id);
18832
19465
  });
18833
19466
  };
18834
19467