synabun 2026.4.21 → 2026.4.25-beta.2

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 (61) hide show
  1. package/CHANGELOG.md +175 -881
  2. package/README.md +1 -1
  3. package/hooks/claude-code/prompt-submit.mjs +39 -18
  4. package/hooks/claude-code/session-start.mjs +10 -3
  5. package/hooks/claude-code/shared.mjs +39 -2
  6. package/hooks/claude-code/stop.mjs +33 -6
  7. package/mcp-server/src/index.ts +9 -1
  8. package/mcp-server/src/services/neural-interface.ts +42 -14
  9. package/mcp-server/src/services/profiles.ts +8 -7
  10. package/mcp-server/src/tools/browser-advanced.ts +12 -10
  11. package/mcp-server/src/tools/browser-interact.ts +39 -38
  12. package/mcp-server/src/tools/browser-navigate.ts +12 -4
  13. package/mcp-server/src/tools/loop.ts +7 -3
  14. package/mcp-server/src/tools/profile.ts +19 -1
  15. package/neural-interface/lib/loop-logger.js +103 -0
  16. package/neural-interface/lib/mcp-installer.js +109 -0
  17. package/neural-interface/lib/plugin-installer.js +264 -0
  18. package/neural-interface/package.json +1 -1
  19. package/neural-interface/public/shared/api.js +66 -17
  20. package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
  21. package/neural-interface/public/shared/cdx/cdx-panel.js +117 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +55 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +394 -72
  25. package/neural-interface/public/shared/cli-status.js +157 -0
  26. package/neural-interface/public/shared/constants.js +3 -2
  27. package/neural-interface/public/shared/html-shell.js +32 -11
  28. package/neural-interface/public/shared/ocp/ocp-panel.js +127 -28
  29. package/neural-interface/public/shared/ocp/ocp-render.js +52 -44
  30. package/neural-interface/public/shared/ocp/ocp-styles.js +33 -0
  31. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  32. package/neural-interface/public/shared/styles.css +635 -0
  33. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  34. package/neural-interface/public/shared/ui-claude-panel.js +322 -15
  35. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  36. package/neural-interface/public/shared/ui-menubar.js +34 -10
  37. package/neural-interface/public/shared/ui-navbar.js +8 -6
  38. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  39. package/neural-interface/public/shared/ui-settings.js +274 -50
  40. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  41. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  42. package/neural-interface/public/shared/ui-sync.js +9 -5
  43. package/neural-interface/public/shared/ui-terminal.js +76 -27
  44. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  45. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  46. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  47. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  48. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  49. package/neural-interface/public/variant/2d/main.js +9 -7
  50. package/neural-interface/public/variant/3d/main.js +10 -8
  51. package/neural-interface/server.js +1292 -255
  52. package/package.json +1 -1
  53. package/skills/leonardo/SKILL.md +5 -1
  54. package/skills/leonardo/modules/image-prompter.md +5 -1
  55. package/skills/leonardo/modules/video-prompter.md +5 -1
  56. package/skills/synabun/SKILL.md +44 -26
  57. package/skills/synabun/modules/audit.md +40 -36
  58. package/skills/synabun/modules/changelog.md +22 -18
  59. package/skills/synabun/modules/memorize.md +19 -15
  60. package/skills/synabun/modules/schedule.md +285 -0
  61. package/CLAUDE.md +0 -217
@@ -42,8 +42,29 @@ export const STOR = {
42
42
  agent: 'synabun-ocp-agent',
43
43
  project: 'synabun-ocp-project',
44
44
  activity: 'synabun-ocp-activity-open',
45
+ sessionSnapshots: 'synabun-ocp-session-snapshots', // global, per-sessionId rendered HTML cache
45
46
  };
46
47
 
48
+ // ── Session HTML snapshot cache ──
49
+ // Mirrors the Codex/Claude snapshot pattern: every render fills a per-session
50
+ // HTML cache so a fresh browser load can blit the exact transcript back into
51
+ // the DOM instead of rebuilding from upstream `messages:list`, which loses
52
+ // streaming state, MCP card formatting, and intermediate UI cards.
53
+ const MAX_OCP_SESSION_SNAPSHOTS = 24;
54
+ const MAX_OCP_SESSION_SNAPSHOT_CHARS = 2_500_000;
55
+ let _sessionSnapshots = (() => {
56
+ try {
57
+ const raw = storage.getItem('synabun-ocp-session-snapshots');
58
+ if (!raw) return {};
59
+ const parsed = JSON.parse(raw);
60
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
61
+ } catch { return {}; }
62
+ })();
63
+ let _snapshotObserver = null;
64
+ let _snapshotObserverContainer = null;
65
+ let _snapshotDebounceTimer = null;
66
+ let _snapshotRestoring = false;
67
+
47
68
  const ACTIVITY_MAX = 500;
48
69
 
49
70
  function ensureNotifState(tab) {
@@ -110,6 +131,7 @@ let _onUpdate = null; // callback to refresh UI
110
131
  let _panelVisible = false;
111
132
  let _showPanel = null; // callback to show panel from tray pill click
112
133
  let _hidePanel = null; // callback to close panel (last-tab-close)
134
+ let _activeChildSessionId = null; // sessionId of the currently active child agent's subprocess
113
135
 
114
136
  export function getTabs() { return _tabs; }
115
137
  export function getActiveTabIdx() { return _activeTabIdx; }
@@ -177,6 +199,140 @@ function getModelContextWindow(modelStr) {
177
199
 
178
200
  function panelEl(sel) { return _panelEl?.querySelector(sel) || null; }
179
201
 
202
+ function runtimeSet(tab, key) {
203
+ if (!tab) return new Set();
204
+ if (!(tab[key] instanceof Set)) tab[key] = new Set();
205
+ return tab[key];
206
+ }
207
+
208
+ function runtimeMap(tab, key) {
209
+ if (!tab) return new Map();
210
+ if (!(tab[key] instanceof Map)) tab[key] = new Map();
211
+ return tab[key];
212
+ }
213
+
214
+ function userMsgIds(tab) {
215
+ return runtimeSet(tab, '_userMsgIds');
216
+ }
217
+
218
+ function partTypes(tab) {
219
+ return runtimeMap(tab, '_partTypes');
220
+ }
221
+
222
+ function renderedAssistantMsgIds(tab) {
223
+ return runtimeSet(tab, '_renderedAssistantMsgIds');
224
+ }
225
+
226
+ function currentTurn(tab) {
227
+ if (!tab) return null;
228
+ if (!tab._currentTurn) {
229
+ tab._currentTurn = {
230
+ id: '',
231
+ startAssistantCount: 0,
232
+ streamedText: false,
233
+ assistantIds: new Set(),
234
+ };
235
+ }
236
+ if (!(tab._currentTurn.assistantIds instanceof Set)) {
237
+ tab._currentTurn.assistantIds = new Set(tab._currentTurn.assistantIds || []);
238
+ }
239
+ return tab._currentTurn;
240
+ }
241
+
242
+ function beginTurn(tab, container) {
243
+ const turn = currentTurn(tab);
244
+ turn.id = (globalThis.crypto?.randomUUID?.() || `turn-${Date.now()}-${Math.random()}`);
245
+ turn.startAssistantCount = container?.querySelectorAll('.ocp-msg-assistant').length || 0;
246
+ turn.streamedText = false;
247
+ turn.assistantIds.clear();
248
+ return turn;
249
+ }
250
+
251
+ function markAssistantRendered(tab, messageId) {
252
+ if (!tab || !messageId) return;
253
+ renderedAssistantMsgIds(tab).add(messageId);
254
+ _renderedAssistantMsgIds.add(messageId);
255
+ }
256
+
257
+ function hasAssistantRendered(tab, messageId) {
258
+ if (!messageId) return false;
259
+ return renderedAssistantMsgIds(tab).has(messageId) || _renderedAssistantMsgIds.has(messageId);
260
+ }
261
+
262
+ function tagLatestAssistant(container, tab, messageId = '') {
263
+ if (!container || !tab) return null;
264
+ const els = container.querySelectorAll('.ocp-msg-assistant');
265
+ const el = els[els.length - 1] || null;
266
+ if (!el) return null;
267
+ const turn = currentTurn(tab);
268
+ if (turn?.id) el.dataset.turnId = turn.id;
269
+ if (messageId) el.dataset.messageId = String(messageId);
270
+ return el;
271
+ }
272
+
273
+ function cssEscape(value) {
274
+ if (globalThis.CSS?.escape) return globalThis.CSS.escape(String(value));
275
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
276
+ }
277
+
278
+ function markCurrentTurnAssistantContent(tab, container, messageId = '') {
279
+ const turn = currentTurn(tab);
280
+ if (!turn) return;
281
+ turn.streamedText = true;
282
+ if (messageId) turn.assistantIds.add(messageId);
283
+ tagLatestAssistant(container, tab, messageId);
284
+ }
285
+
286
+ function currentTurnHasAssistantContent(tab, container, messageId = '') {
287
+ const turn = currentTurn(tab);
288
+ if (!turn) return false;
289
+ if (messageId && turn.assistantIds.has(messageId)) return true;
290
+ if (turn.streamedText) return true;
291
+ if (!container) return false;
292
+ if (turn.id && container.querySelector(`.ocp-msg-assistant[data-turn-id="${cssEscape(turn.id)}"]`)) return true;
293
+ const count = container.querySelectorAll('.ocp-msg-assistant').length;
294
+ return count > (turn.startAssistantCount || 0);
295
+ }
296
+
297
+ function endTurn(tab) {
298
+ if (!tab) return;
299
+ const turn = currentTurn(tab);
300
+ turn.id = '';
301
+ turn.startAssistantCount = 0;
302
+ turn.streamedText = false;
303
+ turn.assistantIds.clear();
304
+ }
305
+
306
+ function eventSessionId(event) {
307
+ return event?.sessionID || event?.sessionId || event?.session?.id || event?.part?.sessionID || event?.part?.sessionId || '';
308
+ }
309
+
310
+ function payloadHasAssistantText(value) {
311
+ if (value == null) return false;
312
+ if (typeof value === 'string') return Boolean(value.trim());
313
+ if (typeof value === 'number' || typeof value === 'boolean') return true;
314
+ if (Array.isArray(value)) return value.some(payloadHasAssistantText);
315
+ if (typeof value !== 'object') return false;
316
+ const type = String(value.type || '').toLowerCase();
317
+ if (type.includes('tool')) return false;
318
+ if (['text', 'reasoning', 'thinking', 'thought'].includes(type)) {
319
+ return payloadHasAssistantText(value.text ?? value.content ?? value.value ?? value.message ?? value.reasoning ?? '');
320
+ }
321
+ return payloadHasAssistantText(value.content)
322
+ || payloadHasAssistantText(value.parts)
323
+ || payloadHasAssistantText(value.text)
324
+ || payloadHasAssistantText(value.value)
325
+ || payloadHasAssistantText(value.message);
326
+ }
327
+
328
+ function findTabBySessionId(sessionId) {
329
+ if (!sessionId) return null;
330
+ const sid = String(sessionId);
331
+ return _tabs.find((tab) => tab?.sessionId === sid)
332
+ || _tabs.find((tab) => tab?.toolActivityChildSessions && tab.toolActivityChildSessions[sid])
333
+ || null;
334
+ }
335
+
180
336
  function hasActiveQuestion() {
181
337
  return _activeQuestionToolId !== null;
182
338
  }
@@ -336,6 +492,7 @@ export function createTab(opts = {}) {
336
492
  planFilePath: opts.planFilePath || '',
337
493
  editedPlanContent: opts.editedPlanContent || '',
338
494
  showPostPlanActions: opts.showPostPlanActions || false,
495
+ postPlanHeader: opts.postPlanHeader || 'PLAN COMPLETE',
339
496
  pendingTitle: opts.pendingTitle || null, // user-named before server session exists; applied via renameSession once sessionId assigned
340
497
  toolActivity: [],
341
498
  toolActivityChildSessions: {},
@@ -358,6 +515,7 @@ export function switchTab(idx) {
358
515
  const prev = activeTab();
359
516
  const input = panelEl('#ocp-input');
360
517
  if (prev && input) prev.draft = input.value;
518
+ if (prev) flushSessionSnapshotSave(prev);
361
519
 
362
520
  _activeTabIdx = idx;
363
521
 
@@ -375,6 +533,7 @@ export function switchTab(idx) {
375
533
  export function closeTab(idx) {
376
534
  if (idx < 0 || idx >= _tabs.length) return;
377
535
  const tab = _tabs[idx];
536
+ flushSessionSnapshotSave(tab);
378
537
  if (tab.pillEl) tab.pillEl.remove();
379
538
  if (tab.trayPillEl) tab.trayPillEl.remove();
380
539
  _tabs.splice(idx, 1);
@@ -470,7 +629,8 @@ export function saveTabs() {
470
629
  const data = _tabs.map(t => ({
471
630
  id: t.id, sessionId: t.sessionId, sessionTitle: t.sessionTitle,
472
631
  model: t.model, mode: t.mode, agent: t.agent, project: t.project, draft: t.draft,
473
- planContent: t.planContent, planFilePath: t.planFilePath, showPostPlanActions: t.showPostPlanActions,
632
+ planContent: t.planContent, planFilePath: t.planFilePath, editedPlanContent: t.editedPlanContent,
633
+ showPostPlanActions: t.showPostPlanActions, postPlanHeader: t.postPlanHeader,
474
634
  threadTokenUsage: t.threadTokenUsage,
475
635
  }));
476
636
  storage.setItem(STOR.tabs, JSON.stringify({ activeIdx: _activeTabIdx, tabs: data }));
@@ -509,8 +669,7 @@ export async function createSession() {
509
669
  const tab = activeTab();
510
670
  reconcileTabMode(tab);
511
671
  const body = {};
512
- if (tab?.model) body.model = modelObj(tab.model);
513
- if (tab?.agent) body.agent = tab.agent;
672
+ if (tab?.pendingTitle) body.title = tab.pendingTitle;
514
673
  const cwd = tab?.project || '';
515
674
  const resp = await requestWs('session:create', { body, ...(cwd ? { cwd } : {}) });
516
675
  const session = resp.data;
@@ -622,25 +781,61 @@ export async function executeCommand(sessionId, command, args = '') {
622
781
  // ── Plan mode helpers ──
623
782
 
624
783
  function capturePlanContent(tab) {
625
- if (!tab || tab.mode !== 'plan') return;
784
+ if (!tab || tab.mode !== 'plan') return tab?.planContent || '';
626
785
  const container = panelEl('#ocp-messages');
627
- if (!container) return;
786
+ if (!container) return tab.planContent || '';
628
787
  const assistantEls = container.querySelectorAll('.ocp-msg-assistant');
629
- if (!assistantEls.length) return;
788
+ if (!assistantEls.length) return tab.planContent || '';
630
789
  const lastEl = assistantEls[assistantEls.length - 1];
631
790
  const text = (lastEl.textContent || '').trim();
632
791
  if (text.length > 80) tab.planContent = text;
792
+ return tab.planContent || '';
793
+ }
794
+
795
+ export async function ensurePlanFile(tab) {
796
+ if (!tab) return '';
797
+ if (tab.planFilePath) return tab.planFilePath;
798
+ if (tab._planFilePromise) return tab._planFilePromise;
799
+ const planText = tab.planContent || capturePlanContent(tab);
800
+ if (!planText) return '';
801
+
802
+ tab._planFilePromise = fetch('/api/create-plan', {
803
+ method: 'POST',
804
+ headers: { 'Content-Type': 'application/json' },
805
+ body: JSON.stringify({ content: planText, cwd: tab.project || '' }),
806
+ })
807
+ .then(async (res) => {
808
+ const result = await res.json().catch(() => ({}));
809
+ if (!res.ok || !result?.ok || !result.path) {
810
+ throw new Error(result?.error || 'create-plan failed');
811
+ }
812
+ tab.planFilePath = result.path;
813
+ saveTabs();
814
+ return result.path;
815
+ })
816
+ .catch((err) => {
817
+ console.error('[ocp-tabs] create-plan failed:', err);
818
+ return '';
819
+ })
820
+ .finally(() => {
821
+ tab._planFilePromise = null;
822
+ });
823
+
824
+ return tab._planFilePromise;
633
825
  }
634
826
 
635
- function showPostPlanUI(tab) {
827
+ export function showPostPlanUI(tab, headerText = null) {
636
828
  if (!tab) return;
637
829
  const container = panelEl('#ocp-messages');
638
830
  if (!container) return;
639
831
  // Remove any existing post-plan cards before appending a new one
640
832
  removePostPlanCards(container);
641
833
  tab.showPostPlanActions = true;
834
+ tab.postPlanHeader = headerText || tab.postPlanHeader || 'PLAN COMPLETE';
835
+ ensurePlanFile(tab);
642
836
  saveTabs();
643
837
  renderPostPlanCard(container, {
838
+ headerText: tab.postPlanHeader,
644
839
  onContinue: () => {
645
840
  tab.showPostPlanActions = false;
646
841
  saveTabs();
@@ -656,9 +851,32 @@ function showPostPlanUI(tab) {
656
851
  onEditPlan: () => {
657
852
  if (typeof _onEditPlan === 'function') _onEditPlan(tab);
658
853
  },
854
+ onContinuePlanning: () => {
855
+ tab.showPostPlanActions = false;
856
+ saveTabs();
857
+ update();
858
+ },
659
859
  });
660
860
  }
661
861
 
862
+ export function applySavedPlan(tab, content, filePath = '') {
863
+ if (!tab || !content) return;
864
+ tab.planContent = content;
865
+ tab.editedPlanContent = content;
866
+ tab.showPostPlanActions = true;
867
+ tab.postPlanHeader = 'PLAN UPDATED';
868
+ if (filePath) tab.planFilePath = filePath;
869
+
870
+ const container = panelEl('#ocp-messages');
871
+ if (container && tab === activeTab()) {
872
+ removePostPlanCards(container);
873
+ renderAssistantPayload(container, content, { textMode: 'render' });
874
+ showPostPlanUI(tab, 'PLAN UPDATED');
875
+ } else {
876
+ saveTabs();
877
+ }
878
+ }
879
+
662
880
  // ── Send message ──
663
881
 
664
882
  export async function sendMessage(content, options = {}) {
@@ -666,6 +884,11 @@ export async function sendMessage(content, options = {}) {
666
884
  const text = String(content || '').trim();
667
885
  const images = Array.isArray(options.images) ? options.images.filter(Boolean) : [];
668
886
  if (!tab || tab.running || (!text && !images.length)) return false;
887
+ if (tab.showPostPlanActions) {
888
+ const container = panelEl('#ocp-messages');
889
+ if (container) renderErrorMessage(container, 'Choose Continue with implementation, Continue planning, Compact context, or Edit plan before sending another message.');
890
+ return false;
891
+ }
669
892
  reconcileTabMode(tab);
670
893
  // Clear any pending question queue when user sends a new message
671
894
  clearQuestionQueue();
@@ -683,6 +906,7 @@ export async function sendMessage(content, options = {}) {
683
906
  update();
684
907
 
685
908
  const container = panelEl('#ocp-messages');
909
+ beginTurn(tab, container);
686
910
  if (container) {
687
911
  const userParts = [];
688
912
  if (text) userParts.push({ type: 'text', text });
@@ -713,6 +937,7 @@ export async function sendMessage(content, options = {}) {
713
937
  }
714
938
  tab.running = false;
715
939
  tab.turnStartedAt = 0;
940
+ endTurn(tab);
716
941
  setTurnStatus(tab);
717
942
  update();
718
943
  return false;
@@ -750,21 +975,26 @@ export async function sendMessage(content, options = {}) {
750
975
  if (tab.running && resp?.data) {
751
976
  const info = resp.data.info || resp.data;
752
977
  const parts = resp.data.parts || info.parts || info.content;
753
- // Only render from POST if SSE didn't already stream content
754
- const hasStreamed = container?.querySelector('.ocp-msg-assistant.streaming')
755
- || container?.querySelector('.ocp-msg-assistant');
756
- if (parts && !hasStreamed) {
757
- removeThinking(container);
758
- renderAssistantPayload(container, parts, { textMode: 'stream' });
759
- finalizeStreamingMessage(container);
760
- } else {
761
- removeThinking(container);
762
- finalizeStreamingMessage(container);
978
+ const assistantMid = info.id || resp.data.messageID || resp.data.messageId || '';
979
+ const hasCurrentTurnContent = currentTurnHasAssistantContent(tab, container, assistantMid);
980
+ if (container) {
981
+ if (parts && !hasCurrentTurnContent) {
982
+ removeThinking(container);
983
+ renderAssistantPayload(container, parts, { textMode: 'stream' });
984
+ if (payloadHasAssistantText(parts)) markCurrentTurnAssistantContent(tab, container, assistantMid);
985
+ finalizeStreamingMessage(container);
986
+ } else {
987
+ removeThinking(container);
988
+ finalizeStreamingMessage(container);
989
+ }
990
+ } else if (parts) {
991
+ tab._needsHistoryRefresh = true;
763
992
  }
764
993
  // Mark this assistant message as rendered so late terminal SSE
765
994
  // message.updated events (which arrive after POST resolves on some
766
995
  // Windows/model combos) don't re-stream the full payload into a new bubble.
767
- if (info.id) _renderedAssistantMsgIds.add(info.id);
996
+ if (assistantMid) markAssistantRendered(tab, assistantMid);
997
+ partTypes(tab).clear();
768
998
  tab.running = false;
769
999
  tab.turnStartedAt = 0;
770
1000
  if (info.tokens || info.usage) {
@@ -774,7 +1004,12 @@ export async function sendMessage(content, options = {}) {
774
1004
  tab.outputTokens = raw.output || raw.outputTokens || raw.output_tokens || 0;
775
1005
  }
776
1006
  setTurnStatus(tab);
1007
+ if (tab.mode === 'plan' && !tab._exitPlanDetected) {
1008
+ capturePlanContent(tab);
1009
+ showPostPlanUI(tab);
1010
+ }
777
1011
  notifyOpenCodeOutcome(NOTIF_TYPE.DONE, tab, info.id || tab.sessionId || tab.id);
1012
+ endTurn(tab);
778
1013
  update();
779
1014
  }
780
1015
  return true;
@@ -790,8 +1025,10 @@ export async function sendMessage(content, options = {}) {
790
1025
  if (!isAbort) {
791
1026
  tab.running = false;
792
1027
  tab.turnStartedAt = 0;
1028
+ endTurn(tab);
793
1029
  setTurnStatus(tab);
794
1030
  notifyOpenCodeOutcome(NOTIF_TYPE.ERROR, tab, tab.sessionId || tab.id);
1031
+ _activeChildSessionId = null;
795
1032
  update();
796
1033
  }
797
1034
  return false;
@@ -803,7 +1040,33 @@ export function anyTabRunning() {
803
1040
  }
804
1041
 
805
1042
  export async function abortMessage() {
806
- return abortAllTabs();
1043
+ const tab = activeTab();
1044
+ if (!tab?.running) return false;
1045
+ const container = panelEl('#ocp-messages');
1046
+ const stopped = abortTab(tab, container);
1047
+ if (stopped) update();
1048
+ return stopped;
1049
+ }
1050
+
1051
+ function abortTab(tab, container = null) {
1052
+ if (!tab?.running) return false;
1053
+ if (tab.sessionId) {
1054
+ try { sendWs({ type: 'message:abort', sessionId: tab.sessionId }); } catch {}
1055
+ }
1056
+ if (tab._activeSendRequestId) {
1057
+ rejectPending(tab._activeSendRequestId, 'Aborted by user');
1058
+ if (_activeSendRequestId === tab._activeSendRequestId) _activeSendRequestId = null;
1059
+ tab._activeSendRequestId = null;
1060
+ }
1061
+ if (container) {
1062
+ removeThinking(container);
1063
+ finalizeStreamingMessage(container);
1064
+ }
1065
+ tab.running = false;
1066
+ tab.turnStartedAt = 0;
1067
+ endTurn(tab);
1068
+ setTurnStatus(tab);
1069
+ return true;
807
1070
  }
808
1071
 
809
1072
  export async function abortAllTabs() {
@@ -811,24 +1074,10 @@ export async function abortAllTabs() {
811
1074
  const activeContainer = panelEl('#ocp-messages');
812
1075
  const active = activeTab();
813
1076
  for (const tab of _tabs) {
814
- if (!tab?.running) continue;
815
- stoppedAny = true;
816
- if (tab.sessionId) {
817
- try { sendWs({ type: 'message:abort', sessionId: tab.sessionId }); } catch {}
818
- }
819
- if (tab._activeSendRequestId) {
820
- rejectPending(tab._activeSendRequestId, 'Aborted by user');
821
- tab._activeSendRequestId = null;
822
- }
823
- if (tab === active && activeContainer) {
824
- removeThinking(activeContainer);
825
- finalizeStreamingMessage(activeContainer);
826
- }
827
- tab.running = false;
828
- tab.turnStartedAt = 0;
829
- setTurnStatus(tab);
1077
+ stoppedAny = abortTab(tab, tab === active ? activeContainer : null) || stoppedAny;
830
1078
  }
831
1079
  _activeSendRequestId = null;
1080
+ _activeChildSessionId = null;
832
1081
  if (stoppedAny) update();
833
1082
  return stoppedAny;
834
1083
  }
@@ -910,6 +1159,11 @@ export function findAgentEntryByChildSession(tab, sessionId) {
910
1159
  return findActivityEntry(tab, key);
911
1160
  }
912
1161
 
1162
+ export function findAgentEntryByToolId(tab, toolId) {
1163
+ if (!tab || !toolId || !Array.isArray(tab.toolActivity)) return null;
1164
+ return tab.toolActivity.find(e => e.toolId === String(toolId) && e.status === 'running') || null;
1165
+ }
1166
+
913
1167
  export function recordToolStart(tab, rawName, toolId, toolInput) {
914
1168
  if (!tab) return;
915
1169
  const name = String(rawName || 'tool');
@@ -979,6 +1233,7 @@ export function recordToolResult(tab, rawName, toolId, toolInput, result, isErro
979
1233
  step.updatedAt = now;
980
1234
  }
981
1235
  }
1236
+ _activeChildSessionId = null;
982
1237
  return;
983
1238
  }
984
1239
  const entry = {
@@ -1001,6 +1256,7 @@ export function recordToolResult(tab, rawName, toolId, toolInput, result, isErro
1001
1256
  };
1002
1257
  tab.toolActivity.push(entry);
1003
1258
  if (childSid) linkChildSession(tab, entry, childSid);
1259
+ if (isError) _activeChildSessionId = null;
1004
1260
  }
1005
1261
 
1006
1262
  export function recordAgentChildEvent(tab, entry, eventType, event) {
@@ -1080,6 +1336,87 @@ export function recordAgentChildEvent(tab, entry, eventType, event) {
1080
1336
  return false;
1081
1337
  }
1082
1338
 
1339
+ function toolStatusDetail(rawName, toolInput) {
1340
+ const name = String(rawName || 'tool');
1341
+ const desc = describeTool(name, toolInput) || {};
1342
+ const display = desc.displayName || name;
1343
+ const summary = desc.summary || '';
1344
+ return summary && summary !== display ? `${display} · ${summary}` : display;
1345
+ }
1346
+
1347
+ function activeAgentStep(tab) {
1348
+ const entries = Array.isArray(tab?.toolActivity) ? tab.toolActivity : [];
1349
+ let latest = null;
1350
+ for (const entry of entries) {
1351
+ if (entry?.status !== 'running') continue;
1352
+ const steps = Array.isArray(entry.steps) ? entry.steps : [];
1353
+ const activeKey = entry.activeStep || '';
1354
+ const active = activeKey ? steps.find((step) => step.key === activeKey && step.status === 'running') : null;
1355
+ const fallback = active || [...steps].reverse().find((step) => step.status === 'running');
1356
+ if (!fallback) continue;
1357
+ const latestAt = latest ? (latest.updatedAt || latest.startedAt || 0) : 0;
1358
+ const fallbackAt = fallback.updatedAt || fallback.startedAt || 0;
1359
+ if (!latest || fallbackAt >= latestAt) latest = fallback;
1360
+ }
1361
+ return latest;
1362
+ }
1363
+
1364
+ function agentStepStatusDetail(step) {
1365
+ if (!step) return '';
1366
+ const name = step.name || step.rawName || 'tool';
1367
+ const summary = step.summary || '';
1368
+ return summary && summary !== name ? `${name} · ${summary}` : name;
1369
+ }
1370
+
1371
+ function syncActingIndicator(container, tab, options = {}) {
1372
+ if (!container || !tab?.running) return;
1373
+ if (hasActiveQuestion() || _activePermissionId) return;
1374
+ const step = options.ignoreActiveStep ? null : activeAgentStep(tab);
1375
+ const title = options.title || (step ? 'Using tool…' : (tab.statusText || 'Thinking…'));
1376
+ const detail = options.detail !== undefined
1377
+ ? options.detail
1378
+ : (step ? agentStepStatusDetail(step) : (tab.statusDetail || tabStatusDetail(tab)));
1379
+ updateThinking(container, {
1380
+ title,
1381
+ detail,
1382
+ startedAt: tab.turnStartedAt || Date.now(),
1383
+ waiting: options.waiting !== undefined ? !!options.waiting : title !== 'Using tool…',
1384
+ });
1385
+ }
1386
+
1387
+ function syncAgentChildIndicator(container, tab, eventType, event) {
1388
+ if (!tab?.running) return;
1389
+ if (eventType === 'tool.result') {
1390
+ setTurnStatus(tab, 'Thinking…', 'Processing tool result');
1391
+ syncActingIndicator(container, tab, {
1392
+ title: 'Thinking…',
1393
+ detail: 'Processing tool result',
1394
+ waiting: true,
1395
+ ignoreActiveStep: true,
1396
+ });
1397
+ return;
1398
+ }
1399
+ if (eventType === 'session.idle'
1400
+ || (eventType === 'session.status' && (event?.status?.type || event?.type) === 'idle')) {
1401
+ setTurnStatus(tab, 'Thinking…', 'Agent completed');
1402
+ syncActingIndicator(container, tab, {
1403
+ title: 'Thinking…',
1404
+ detail: 'Agent completed',
1405
+ waiting: true,
1406
+ ignoreActiveStep: true,
1407
+ });
1408
+ return;
1409
+ }
1410
+ const step = activeAgentStep(tab);
1411
+ if (!step) return;
1412
+ const detail = agentStepStatusDetail(step);
1413
+ setTurnStatus(tab, 'Using tool…', detail);
1414
+ syncActingIndicator(container, tab, {
1415
+ title: 'Using tool…',
1416
+ detail,
1417
+ });
1418
+ }
1419
+
1083
1420
  export function markStuckActivityAsAborted(tab) {
1084
1421
  if (!tab || !Array.isArray(tab.toolActivity)) return;
1085
1422
  const now = Date.now();
@@ -1097,6 +1434,7 @@ export function markStuckActivityAsAborted(tab) {
1097
1434
  }
1098
1435
  }
1099
1436
  }
1437
+ _activeChildSessionId = null;
1100
1438
  }
1101
1439
 
1102
1440
  export function setActivityVisible(tab, visible) {
@@ -1132,32 +1470,118 @@ export function abortAgent(tab, key) {
1132
1470
  step.updatedAt = now;
1133
1471
  }
1134
1472
  }
1473
+ _activeChildSessionId = null;
1135
1474
  update();
1136
1475
  return true;
1137
1476
  }
1138
1477
 
1139
1478
  // ── SSE Event handling ──
1140
1479
 
1480
+ function isIdleEvent(eventType, event) {
1481
+ return eventType === 'session.idle'
1482
+ || (eventType === 'session.status' && (event?.status?.type || event?.type) === 'idle');
1483
+ }
1484
+
1485
+ function handleBackgroundEvent(tab, eventType, event) {
1486
+ if (!tab) return;
1487
+ tab._needsHistoryRefresh = true;
1488
+ if (eventType === 'message.updated') {
1489
+ const info = event.info || event;
1490
+ const mid = info.id || event.messageID;
1491
+ if (info.role === 'user') {
1492
+ if (mid) {
1493
+ userMsgIds(tab).add(mid);
1494
+ _userMsgIds.add(mid);
1495
+ }
1496
+ } else if (info.tokens || info.usage) {
1497
+ const raw = info.tokens || info.usage || {};
1498
+ tab.threadTokenUsage = normalizeThreadTokenUsage(raw);
1499
+ tab.inputTokens = raw.input || raw.inputTokens || raw.input_tokens || 0;
1500
+ tab.outputTokens = raw.output || raw.outputTokens || raw.output_tokens || 0;
1501
+ if (mid) markAssistantRendered(tab, mid);
1502
+ }
1503
+ } else if (eventType === 'session.updated') {
1504
+ if (event.session?.title || event.title) {
1505
+ const sid = event.session?.id || event.sessionID || event.sessionId || tab.sessionId;
1506
+ updateSessionTitleState(sid, event.session?.title || event.title);
1507
+ renderPills();
1508
+ saveTabs();
1509
+ }
1510
+ } else if (isIdleEvent(eventType, event)) {
1511
+ tab.running = false;
1512
+ tab.turnStartedAt = 0;
1513
+ endTurn(tab);
1514
+ setTurnStatus(tab);
1515
+ markStuckActivityAsAborted(tab);
1516
+ if (tab.mode === 'plan' && !tab._exitPlanDetected) {
1517
+ tab._pendingPostPlanCheck = true;
1518
+ tab.showPostPlanActions = true;
1519
+ }
1520
+ notifyOpenCodeOutcome(NOTIF_TYPE.DONE, tab, tab.sessionId || tab.id);
1521
+ } else if (eventType === 'session.error' || eventType === 'error') {
1522
+ tab.running = false;
1523
+ tab.turnStartedAt = 0;
1524
+ endTurn(tab);
1525
+ setTurnStatus(tab, 'Error', '');
1526
+ notifyOpenCodeOutcome(NOTIF_TYPE.ERROR, tab, event.id || event.messageID || tab.sessionId || tab.id);
1527
+ }
1528
+ update();
1529
+ }
1530
+
1141
1531
  export function handleSSEEvent(eventType, event) {
1142
- const tab = activeTab();
1532
+ let tab = eventSessionId(event) ? findTabBySessionId(eventSessionId(event)) : activeTab();
1533
+ if (!tab) tab = activeTab();
1143
1534
  if (!tab) return;
1144
1535
 
1145
- const container = panelEl('#ocp-messages');
1146
- if (!container) return;
1536
+ const isActiveTab = tab === activeTab();
1537
+ const container = isActiveTab ? panelEl('#ocp-messages') : null;
1147
1538
 
1148
1539
  // Filter events to current session (OpenCode uses sessionID). When the active
1149
1540
  // tab has no sessionId yet (freshly created via "+"), drop any session-scoped
1150
1541
  // event so the still-running previous session doesn't render into it.
1151
- const eventSessionId = event?.sessionID || event?.sessionId || event?.session?.id;
1152
- if (eventSessionId && eventSessionId !== tab.sessionId) {
1153
- // Route events from tracked subagent child sessions into their parent agent entry.
1154
- const agentEntry = findAgentEntryByChildSession(tab, eventSessionId);
1155
- if (agentEntry && recordAgentChildEvent(tab, agentEntry, eventType, event)) {
1156
- update();
1542
+ const eventSid = eventSessionId(event);
1543
+ const isAgentEvent = (eventType === 'tool.start' || eventType === 'tool.result' || eventType === 'message.part.updated');
1544
+ const isAgentLifecycleEvent = eventType === 'session.idle' || eventType === 'session.status';
1545
+
1546
+ // Helper to extract toolId from event or its part field
1547
+ function extractToolId(evt) {
1548
+ return evt?.toolCallId || evt?.id ||
1549
+ (evt?.part ? (evt.part?.toolCallId || evt.part?.callID || evt.part?.tool_use_id || evt.part?.id) : '');
1550
+ }
1551
+
1552
+ // Route agent tool events to the running agent entry by toolId — events may arrive
1553
+ // under the parent session ID even when they're for the child agent's tool loop.
1554
+ if (isAgentEvent) {
1555
+ const toolId = extractToolId(event);
1556
+ if (toolId) {
1557
+ const agentEntry = findAgentEntryByToolId(tab, toolId);
1558
+ if (agentEntry && recordAgentChildEvent(tab, agentEntry, eventType, event)) {
1559
+ if (container) syncAgentChildIndicator(container, tab, eventType, event);
1560
+ update();
1561
+ return;
1562
+ }
1563
+ }
1564
+ // eventSid is falsy or matches tab.sessionId — fall through to normal switch
1565
+ }
1566
+ if (eventSid && eventSid !== tab.sessionId) {
1567
+ // Route tracked child-agent events, then drop all other session-scoped
1568
+ // events so another OpenCode session cannot mutate the active render area.
1569
+ if (isAgentEvent || isAgentLifecycleEvent) {
1570
+ const agentEntry = findAgentEntryByChildSession(tab, eventSid);
1571
+ if (agentEntry && recordAgentChildEvent(tab, agentEntry, eventType, event)) {
1572
+ if (container) syncAgentChildIndicator(container, tab, eventType, event);
1573
+ update();
1574
+ return;
1575
+ }
1157
1576
  }
1158
1577
  return;
1159
1578
  }
1160
1579
 
1580
+ if (!container) {
1581
+ handleBackgroundEvent(tab, eventType, event);
1582
+ return;
1583
+ }
1584
+
1161
1585
  switch (eventType) {
1162
1586
  // Message updated — fires for both user and assistant messages
1163
1587
  case 'message.updated': {
@@ -1165,7 +1589,10 @@ export function handleSSEEvent(eventType, event) {
1165
1589
  // Track user messages so their parts don't echo in the chat
1166
1590
  if (info.role === 'user') {
1167
1591
  const mid = info.id || event.messageID;
1168
- if (mid) _userMsgIds.add(mid);
1592
+ if (mid) {
1593
+ userMsgIds(tab).add(mid);
1594
+ _userMsgIds.add(mid);
1595
+ }
1169
1596
  break;
1170
1597
  }
1171
1598
  // Guard: once we've rendered this assistant message's final payload, ignore
@@ -1173,7 +1600,7 @@ export function handleSSEEvent(eventType, event) {
1173
1600
  // late duplicate after finalize, which would otherwise re-stream the whole
1174
1601
  // response into a brand-new assistant bubble).
1175
1602
  const assistantMid = info.id || event.messageID;
1176
- if (assistantMid && _renderedAssistantMsgIds.has(assistantMid)) {
1603
+ if (assistantMid && hasAssistantRendered(tab, assistantMid)) {
1177
1604
  update();
1178
1605
  break;
1179
1606
  }
@@ -1186,16 +1613,19 @@ export function handleSSEEvent(eventType, event) {
1186
1613
  const rendered = renderAssistantPayload(container, info.content ?? info.parts ?? info.text ?? '', {
1187
1614
  textMode: hasStreaming ? 'skip' : 'stream',
1188
1615
  });
1616
+ if (rendered && !hasStreaming && payloadHasAssistantText(info.content ?? info.parts ?? info.text ?? '')) {
1617
+ markCurrentTurnAssistantContent(tab, container, assistantMid);
1618
+ }
1189
1619
  if (rendered || hasStreaming || (explicitTerminal && hasToolCards) || (info.tokens && hasToolCards)) {
1190
1620
  removeThinking(container);
1191
1621
  finalizeStreamingMessage(container);
1192
- _partTypes.clear();
1622
+ partTypes(tab).clear();
1193
1623
  // Only mark the ID as rendered at the terminal signal (tokens/usage
1194
1624
  // arrived or status is explicitly terminal). Mid-turn message.updated
1195
1625
  // events may fire before streaming starts and shouldn't lock out later
1196
1626
  // part.delta / part.updated events.
1197
1627
  if (assistantMid && (explicitTerminal || info.tokens || info.usage)) {
1198
- _renderedAssistantMsgIds.add(assistantMid);
1628
+ markAssistantRendered(tab, assistantMid);
1199
1629
  }
1200
1630
  if (info.tokens || info.usage) {
1201
1631
  const raw = info.tokens || info.usage || {};
@@ -1208,12 +1638,12 @@ export function handleSSEEvent(eventType, event) {
1208
1638
  // running here hides the Stop button while OpenCode is still working.
1209
1639
  // Authoritative end-of-turn: session.idle, session.status:idle, or the
1210
1640
  // synchronous POST /message response returning.
1641
+ syncActingIndicator(container, tab);
1211
1642
  } else if (tab.running) {
1212
1643
  setTurnStatus(tab, 'Waiting for response…', tabStatusDetail(tab));
1213
- updateThinking(container, {
1644
+ syncActingIndicator(container, tab, {
1214
1645
  title: 'Waiting for response…',
1215
1646
  detail: tabStatusDetail(tab),
1216
- startedAt: tab.turnStartedAt || Date.now(),
1217
1647
  waiting: true,
1218
1648
  });
1219
1649
  }
@@ -1222,17 +1652,18 @@ export function handleSSEEvent(eventType, event) {
1222
1652
  }
1223
1653
  // Streaming text delta — the primary streaming event from OpenCode
1224
1654
  case 'message.part.delta': {
1225
- if (_userMsgIds.has(event.messageID)) break; // skip user message echoes
1655
+ if (userMsgIds(tab).has(event.messageID) || _userMsgIds.has(event.messageID)) break; // skip user message echoes
1226
1656
  // Skip deltas for assistant messages already finalized (late echoes).
1227
- if (event.messageID && _renderedAssistantMsgIds.has(event.messageID)) break;
1657
+ if (event.messageID && hasAssistantRendered(tab, event.messageID)) break;
1228
1658
  if (event.delta) {
1229
- const partType = String(event.part?.type || event.partType || _partTypes.get(event.partID) || '').trim().toLowerCase();
1659
+ const partType = String(event.part?.type || event.partType || partTypes(tab).get(event.partID) || '').trim().toLowerCase();
1230
1660
  const isReasoning = partType === 'reasoning' || partType === 'thinking' || partType === 'thought';
1231
1661
  if (isReasoning) {
1232
1662
  appendThinkChunk(container, event.delta);
1233
1663
  setTurnStatus(tab, 'Thinking…', tabStatusDetail(tab));
1234
1664
  } else {
1235
1665
  appendStreamChunk(container, event.delta);
1666
+ markCurrentTurnAssistantContent(tab, container, event.messageID || '');
1236
1667
  setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
1237
1668
  }
1238
1669
  if (tab.running) repositionThinking(container);
@@ -1243,10 +1674,10 @@ export function handleSSEEvent(eventType, event) {
1243
1674
  // Part updated — full part state (text finalized, tool state change, etc.)
1244
1675
  case 'message.part.updated': {
1245
1676
  const part = event.part || event;
1246
- if (_userMsgIds.has(part.messageID)) break; // skip user message echoes
1677
+ if (userMsgIds(tab).has(part.messageID) || _userMsgIds.has(part.messageID)) break; // skip user message echoes
1247
1678
  const updatedType = String(part?.type || '').trim().toLowerCase();
1248
1679
  // Track part type so message.part.delta can look it up by partID
1249
- if (part.id && updatedType) _partTypes.set(part.id, updatedType);
1680
+ if (part.id && updatedType) partTypes(tab).set(part.id, updatedType);
1250
1681
  if (updatedType === 'reasoning' || updatedType === 'thinking' || updatedType === 'thought') {
1251
1682
  // Reasoning part finalized — mark think block as complete (not partial)
1252
1683
  const streamingEl = container.querySelector('.ocp-msg-assistant.streaming');
@@ -1263,8 +1694,9 @@ export function handleSSEEvent(eventType, event) {
1263
1694
  // finalized by POST response or a prior message.updated — otherwise we
1264
1695
  // create a duplicate bubble when late text part.updated events arrive.
1265
1696
  const partMsgId = part.messageID || part.messageId;
1266
- if (!hasStreaming && !(partMsgId && _renderedAssistantMsgIds.has(partMsgId))) {
1697
+ if (!hasStreaming && !(partMsgId && hasAssistantRendered(tab, partMsgId))) {
1267
1698
  renderAssistantPayload(container, part, { textMode: 'stream' });
1699
+ markCurrentTurnAssistantContent(tab, container, partMsgId || '');
1268
1700
  }
1269
1701
  setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
1270
1702
  if (tab.running) {
@@ -1294,11 +1726,11 @@ export function handleSSEEvent(eventType, event) {
1294
1726
 
1295
1727
  renderAssistantPayload(container, part, { textMode: 'skip' });
1296
1728
  recordToolStart(tab, toolName, toolId, toolInput);
1297
- setTurnStatus(tab, 'Using tool…', toolName);
1298
- updateThinking(container, {
1729
+ const detail = toolStatusDetail(toolName, toolInput);
1730
+ setTurnStatus(tab, 'Using tool…', detail);
1731
+ syncActingIndicator(container, tab, {
1299
1732
  title: 'Using tool…',
1300
- detail: toolName,
1301
- startedAt: tab.turnStartedAt || Date.now(),
1733
+ detail,
1302
1734
  });
1303
1735
  update();
1304
1736
  }
@@ -1315,9 +1747,10 @@ export function handleSSEEvent(eventType, event) {
1315
1747
  case 'message.completed': {
1316
1748
  removeThinking(container);
1317
1749
  const completedMid = event.messageID || event.id;
1318
- if (completedMid && _renderedAssistantMsgIds.has(completedMid)) {
1750
+ if (completedMid && hasAssistantRendered(tab, completedMid)) {
1319
1751
  tab.running = false;
1320
1752
  tab.turnStartedAt = 0;
1753
+ endTurn(tab);
1321
1754
  setTurnStatus(tab);
1322
1755
  update();
1323
1756
  break;
@@ -1327,11 +1760,13 @@ export function handleSSEEvent(eventType, event) {
1327
1760
  renderAssistantPayload(container, event.content ?? event.parts ?? event.text ?? '', {
1328
1761
  textMode: hasStreamingLegacy ? 'skip' : 'stream',
1329
1762
  });
1763
+ if (!hasStreamingLegacy) markCurrentTurnAssistantContent(tab, container, completedMid || '');
1330
1764
  finalizeStreamingMessage(container);
1331
- _partTypes.clear();
1332
- if (completedMid) _renderedAssistantMsgIds.add(completedMid);
1765
+ partTypes(tab).clear();
1766
+ if (completedMid) markAssistantRendered(tab, completedMid);
1333
1767
  tab.running = false;
1334
1768
  tab.turnStartedAt = 0;
1769
+ endTurn(tab);
1335
1770
  setTurnStatus(tab);
1336
1771
  if (event.usage) {
1337
1772
  const raw = event.usage || {};
@@ -1393,11 +1828,11 @@ export function handleSSEEvent(eventType, event) {
1393
1828
  capturePlanContent(tab);
1394
1829
  showPostPlanUI(tab);
1395
1830
  }
1396
- setTurnStatus(tab, 'Using tool…', toolName);
1397
- updateThinking(container, {
1831
+ const detail = toolStatusDetail(toolName, toolInput);
1832
+ setTurnStatus(tab, 'Using tool…', detail);
1833
+ syncActingIndicator(container, tab, {
1398
1834
  title: 'Using tool…',
1399
- detail: toolName,
1400
- startedAt: tab.turnStartedAt || Date.now(),
1835
+ detail,
1401
1836
  });
1402
1837
  update();
1403
1838
  break;
@@ -1425,11 +1860,11 @@ export function handleSSEEvent(eventType, event) {
1425
1860
  recordToolResult(tab, toolName, toolId, event.input ?? event.args, event.result ?? event.output ?? event.error, !!event.error);
1426
1861
  if (tab.running) {
1427
1862
  setTurnStatus(tab, 'Thinking…', 'Processing tool result');
1428
- updateThinking(container, {
1863
+ syncActingIndicator(container, tab, {
1429
1864
  title: 'Thinking…',
1430
1865
  detail: 'Processing tool result',
1431
- startedAt: tab.turnStartedAt || Date.now(),
1432
1866
  waiting: true,
1867
+ ignoreActiveStep: true,
1433
1868
  });
1434
1869
  update();
1435
1870
  }
@@ -1448,8 +1883,10 @@ export function handleSSEEvent(eventType, event) {
1448
1883
  case 'session.status': {
1449
1884
  const statusType = event.status?.type || event.type || '';
1450
1885
  if (statusType === 'idle') {
1886
+ finalizeStreamingMessage(container);
1451
1887
  tab.running = false;
1452
1888
  tab.turnStartedAt = 0;
1889
+ endTurn(tab);
1453
1890
  setTurnStatus(tab);
1454
1891
  removeThinking(container);
1455
1892
  markStuckActivityAsAborted(tab);
@@ -1464,8 +1901,10 @@ export function handleSSEEvent(eventType, event) {
1464
1901
  break;
1465
1902
  }
1466
1903
  case 'session.idle': {
1904
+ finalizeStreamingMessage(container);
1467
1905
  tab.running = false;
1468
1906
  tab.turnStartedAt = 0;
1907
+ endTurn(tab);
1469
1908
  setTurnStatus(tab);
1470
1909
  removeThinking(container);
1471
1910
  markStuckActivityAsAborted(tab);
@@ -1498,9 +1937,16 @@ export function handleSSEEvent(eventType, event) {
1498
1937
  }
1499
1938
  break;
1500
1939
  }
1501
- case 'permission.asked': {
1940
+ case 'permission.asked':
1941
+ case 'permission.updated': {
1502
1942
  const permId = event.id || event.permissionID || '';
1503
1943
  if (!permId) break;
1944
+ const status = String(event.status || event.state || '').toLowerCase();
1945
+ if (status === 'replied' || status === 'resolved' || status === 'approved' || status === 'rejected') {
1946
+ lockPermissionCard(container, permId);
1947
+ if (_activePermissionId === permId) _activePermissionId = null;
1948
+ break;
1949
+ }
1504
1950
  if (_activePermissionId === permId) break;
1505
1951
  renderOpencodePermission(tab, container, event, permId);
1506
1952
  break;
@@ -1523,8 +1969,10 @@ export function handleSSEEvent(eventType, event) {
1523
1969
  renderErrorMessage(container, msg);
1524
1970
  tab.running = false;
1525
1971
  tab.turnStartedAt = 0;
1972
+ endTurn(tab);
1526
1973
  setTurnStatus(tab, 'Error', msg);
1527
1974
  notifyOpenCodeOutcome(NOTIF_TYPE.ERROR, tab, event.id || event.messageID || tab.sessionId || tab.id);
1975
+ _activeChildSessionId = null;
1528
1976
  update();
1529
1977
  break;
1530
1978
  }
@@ -1544,6 +1992,12 @@ function renderOpencodeQuestion(tab, container, event, requestID) {
1544
1992
  lockQuestionCard(container, requestID);
1545
1993
  const payload = buildQuestionReplyPayload(questions, answers);
1546
1994
  requestWs('question:reply', { requestID, body: payload }, 15000)
1995
+ .then(() => {
1996
+ if (_activeQuestionToolId === requestID) {
1997
+ _activeQuestionToolId = null;
1998
+ processQuestionQueue(tab, container);
1999
+ }
2000
+ })
1547
2001
  .catch((err) => console.error('[ocp-tabs] question:reply failed:', err));
1548
2002
  },
1549
2003
  });
@@ -1723,7 +2177,12 @@ function lockPermissionCard(container, permissionID) {
1723
2177
  // Convert the question card's answer payload (string | {questionText: answerString})
1724
2178
  // into OpenCode's expected shape: { answers: string[][] } with one string[] per question.
1725
2179
  function buildQuestionReplyPayload(questions, answers) {
1726
- const asArray = (val) => (val == null ? [] : String(val).split(',').map((s) => s.trim()).filter(Boolean));
2180
+ const asArray = (val) => {
2181
+ if (val == null) return [];
2182
+ if (Array.isArray(val)) return val.map((entry) => String(entry).trim()).filter(Boolean);
2183
+ const text = String(val).trim();
2184
+ return text ? [text] : [];
2185
+ };
1727
2186
  if (typeof answers === 'string') {
1728
2187
  const list = [asArray(answers)];
1729
2188
  while (list.length < questions.length) list.push([]);
@@ -1731,9 +2190,9 @@ function buildQuestionReplyPayload(questions, answers) {
1731
2190
  }
1732
2191
  if (answers && typeof answers === 'object') {
1733
2192
  return {
1734
- answers: questions.map((q) => {
1735
- const key = q.question || q.header || '';
1736
- const v = answers[key] ?? answers[q.header] ?? '';
2193
+ answers: questions.map((q, idx) => {
2194
+ const key = q.id || q.question || q.header || `question_${idx}`;
2195
+ const v = answers[key] ?? answers[q.question] ?? answers[q.header] ?? answers[q.id] ?? answers[`question_${idx}`] ?? '';
1737
2196
  return asArray(v);
1738
2197
  }),
1739
2198
  };
@@ -1783,6 +2242,14 @@ export function setTabMode(mode) {
1783
2242
  if (!tab) return;
1784
2243
  const nextMode = normalizeMode(mode) || DEFAULT_MODE;
1785
2244
  if (nextMode === 'plan') tab._exitPlanDetected = false;
2245
+ // Leaving plan mode invalidates any pending post-plan card — clear the flag
2246
+ // so a later renderTabMessages (tab switch, WS reconnect) can't resurrect it.
2247
+ if (tab.mode === 'plan' && nextMode !== 'plan') {
2248
+ tab.showPostPlanActions = false;
2249
+ tab._pendingPostPlanCheck = false;
2250
+ const container = panelEl('#ocp-messages');
2251
+ if (container) removePostPlanCards(container);
2252
+ }
1786
2253
  tab.mode = nextMode;
1787
2254
  tab.agent = resolveAgentForMode(nextMode);
1788
2255
  storage.setItem(STOR.mode, nextMode);
@@ -2044,11 +2511,130 @@ export function populateModelDropdown(ddEl) {
2044
2511
  }
2045
2512
  }
2046
2513
 
2514
+ // ── Session HTML snapshot helpers ──
2515
+
2516
+ function _normalizeSnapshotEntry(entry) {
2517
+ if (!entry || typeof entry !== 'object') return null;
2518
+ const html = typeof entry.html === 'string' ? entry.html : '';
2519
+ if (!html) return null;
2520
+ return {
2521
+ html,
2522
+ updatedAt: Number(entry.updatedAt) || 0,
2523
+ title: typeof entry.title === 'string' ? entry.title : '',
2524
+ itemCount: Number(entry.itemCount) || 0,
2525
+ threadTokenUsage: entry.threadTokenUsage || null,
2526
+ };
2527
+ }
2528
+
2529
+ function _persistSessionSnapshots() {
2530
+ try {
2531
+ const source = (_sessionSnapshots && typeof _sessionSnapshots === 'object' && !Array.isArray(_sessionSnapshots))
2532
+ ? _sessionSnapshots : {};
2533
+ const entries = Object.entries(source)
2534
+ .map(([sid, e]) => [sid, _normalizeSnapshotEntry(e)])
2535
+ .filter(([, e]) => !!e)
2536
+ .sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0));
2537
+ const next = {};
2538
+ let totalChars = 0;
2539
+ for (const [sid, entry] of entries) {
2540
+ const size = entry.html.length;
2541
+ if (Object.keys(next).length >= MAX_OCP_SESSION_SNAPSHOTS) break;
2542
+ if (totalChars + size > MAX_OCP_SESSION_SNAPSHOT_CHARS && Object.keys(next).length) continue;
2543
+ totalChars += size;
2544
+ next[sid] = entry;
2545
+ }
2546
+ _sessionSnapshots = next;
2547
+ storage.setItem('synabun-ocp-session-snapshots', JSON.stringify(next));
2548
+ } catch {}
2549
+ }
2550
+
2551
+ export function getSessionSnapshot(sid) {
2552
+ if (!sid) return null;
2553
+ return _normalizeSnapshotEntry(_sessionSnapshots?.[sid]);
2554
+ }
2555
+
2556
+ export function writeSessionSnapshot(tab) {
2557
+ const sid = tab?.sessionId || null;
2558
+ const container = panelEl('#ocp-messages');
2559
+ if (!sid || !container) return;
2560
+ // Only snapshot when this tab is the one currently rendered in #ocp-messages.
2561
+ if (tab !== activeTab()) return;
2562
+ const html = container.innerHTML || '';
2563
+ if (!html.trim()) return;
2564
+ // Skip empty-state placeholder
2565
+ const onlyChild = container.children.length === 1 ? container.firstElementChild : null;
2566
+ if (onlyChild?.classList?.contains('ocp-empty')) return;
2567
+ if (onlyChild?.classList?.contains('ocp-thinking') && container.children.length === 1) return;
2568
+ if (!_sessionSnapshots || typeof _sessionSnapshots !== 'object' || Array.isArray(_sessionSnapshots)) {
2569
+ _sessionSnapshots = {};
2570
+ }
2571
+ const itemCount = [...container.children].filter(n =>
2572
+ !n.classList?.contains('ocp-empty') && !n.classList?.contains('ocp-thinking')
2573
+ ).length;
2574
+ _sessionSnapshots[sid] = {
2575
+ html,
2576
+ updatedAt: Date.now(),
2577
+ title: tab.sessionTitle || '',
2578
+ itemCount,
2579
+ threadTokenUsage: tab.threadTokenUsage || null,
2580
+ };
2581
+ _persistSessionSnapshots();
2582
+ }
2583
+
2584
+ export function scheduleSessionSnapshotSave(tab, ms = 350) {
2585
+ if (!tab?.sessionId) return;
2586
+ if (_snapshotDebounceTimer) clearTimeout(_snapshotDebounceTimer);
2587
+ _snapshotDebounceTimer = setTimeout(() => {
2588
+ _snapshotDebounceTimer = null;
2589
+ writeSessionSnapshot(tab);
2590
+ }, ms);
2591
+ }
2592
+
2593
+ export function flushSessionSnapshotSave(tab) {
2594
+ if (!tab) return;
2595
+ if (_snapshotDebounceTimer) { clearTimeout(_snapshotDebounceTimer); _snapshotDebounceTimer = null; }
2596
+ writeSessionSnapshot(tab);
2597
+ }
2598
+
2599
+ export function flushAllSessionSnapshots() {
2600
+ // Only the active tab is rendered into #ocp-messages, so flush just that one.
2601
+ flushSessionSnapshotSave(activeTab());
2602
+ }
2603
+
2604
+ function _installSnapshotObserver(container) {
2605
+ if (!container || _snapshotObserverContainer === container) return;
2606
+ if (_snapshotObserver) try { _snapshotObserver.disconnect(); } catch {}
2607
+ _snapshotObserverContainer = container;
2608
+ _snapshotObserver = new MutationObserver(() => {
2609
+ if (_snapshotRestoring) return;
2610
+ const tab = activeTab();
2611
+ if (tab?.sessionId) scheduleSessionSnapshotSave(tab);
2612
+ });
2613
+ _snapshotObserver.observe(container, { childList: true, subtree: true, characterData: true });
2614
+ }
2615
+
2616
+ function _renderStoredSession(snapshot, container) {
2617
+ const norm = _normalizeSnapshotEntry(snapshot);
2618
+ if (!norm || !container) return false;
2619
+ _snapshotRestoring = true;
2620
+ try {
2621
+ container.innerHTML = norm.html;
2622
+ // Restored DOM has no event handlers and any mid-turn UI (permission cards,
2623
+ // ask cards, post-plan, thinking dots) is stale — disable / strip.
2624
+ container.querySelectorAll('button, input, select, textarea').forEach(node => { node.disabled = true; });
2625
+ container.querySelectorAll('.ocp-thinking, .ocp-permission-card, .ocp-question-card.active, .ocp-post-plan-card').forEach(n => n.remove());
2626
+ } finally {
2627
+ _snapshotRestoring = false;
2628
+ }
2629
+ return true;
2630
+ }
2631
+
2047
2632
  // ── Render current tab's messages ──
2048
2633
 
2049
2634
  export async function renderTabMessages() {
2050
2635
  const container = panelEl('#ocp-messages');
2051
2636
  if (!container) return;
2637
+ _installSnapshotObserver(container);
2052
2638
  const tab = activeTab();
2053
2639
  const seq = ++_renderSeq;
2054
2640
 
@@ -2057,24 +2643,49 @@ export async function renderTabMessages() {
2057
2643
  return;
2058
2644
  }
2059
2645
 
2646
+ // Try local snapshot first — exact replay of the live render. Skip while
2647
+ // a turn is actively running so streaming state isn't masked by a stale snapshot.
2648
+ if (!tab.running) {
2649
+ const snap = getSessionSnapshot(tab.sessionId);
2650
+ if (snap && _renderStoredSession(snap, container)) {
2651
+ // Background refresh: pull latest messages and rebuild only if upstream
2652
+ // is materially ahead of the snapshot, otherwise the cheap restore wins.
2653
+ try {
2654
+ const messages = await loadMessages(tab.sessionId);
2655
+ if (seq !== _renderSeq) return;
2656
+ const upstreamCount = Array.isArray(messages) ? messages.length : 0;
2657
+ if (upstreamCount > (snap.itemCount || 0) + 1) {
2658
+ renderHistory(container, messages);
2659
+ writeSessionSnapshot(tab);
2660
+ }
2661
+ } catch {}
2662
+ if (tab.mode === 'plan' && (tab.showPostPlanActions || tab._pendingPostPlanCheck)) {
2663
+ if (!tab.planContent || tab._pendingPostPlanCheck) capturePlanContent(tab);
2664
+ tab._pendingPostPlanCheck = false;
2665
+ if (tab.planContent) showPostPlanUI(tab);
2666
+ }
2667
+ return;
2668
+ }
2669
+ }
2670
+
2060
2671
  container.innerHTML = '<div class="ocp-thinking"><span class="ocp-thinking-dots"><span></span><span></span><span></span></span> Loading…</div>';
2061
2672
 
2062
2673
  try {
2063
2674
  const messages = await loadMessages(tab.sessionId);
2064
2675
  if (seq !== _renderSeq) return; // stale render — a newer one took over
2065
2676
  renderHistory(container, messages);
2066
- // Restore post-plan card if it was active
2067
- if (tab.showPostPlanActions && tab.planContent) {
2068
- showPostPlanUI(tab);
2677
+ writeSessionSnapshot(tab);
2678
+ // Restore post-plan card only while still in plan mode — leaving plan
2679
+ // mode must not allow the card to resurrect on tab switch / WS reconnect.
2680
+ if (tab.mode === 'plan' && (tab.showPostPlanActions || tab._pendingPostPlanCheck)) {
2681
+ if (!tab.planContent || tab._pendingPostPlanCheck) capturePlanContent(tab);
2682
+ tab._pendingPostPlanCheck = false;
2683
+ if (tab.planContent) showPostPlanUI(tab);
2069
2684
  }
2070
2685
  // Re-show thinking indicator if the tab is mid-turn — history render wipes it,
2071
2686
  // and SSE events may not fire again for a while.
2072
2687
  if (tab.running) {
2073
- updateThinking(container, {
2074
- title: tab.statusText || 'Thinking…',
2075
- detail: tab.statusDetail || '',
2076
- startedAt: tab.turnStartedAt || Date.now(),
2077
- });
2688
+ syncActingIndicator(container, tab);
2078
2689
  }
2079
2690
  } catch {
2080
2691
  if (seq !== _renderSeq) return;