synabun 2026.4.21 → 2026.4.25-beta.1

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 (59) hide show
  1. package/CHANGELOG.md +161 -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 +44 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +23 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +384 -70
  25. package/neural-interface/public/shared/constants.js +3 -2
  26. package/neural-interface/public/shared/html-shell.js +32 -11
  27. package/neural-interface/public/shared/ocp/ocp-panel.js +45 -28
  28. package/neural-interface/public/shared/ocp/ocp-render.js +50 -43
  29. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  30. package/neural-interface/public/shared/styles.css +635 -0
  31. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  32. package/neural-interface/public/shared/ui-claude-panel.js +216 -21
  33. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  34. package/neural-interface/public/shared/ui-menubar.js +34 -10
  35. package/neural-interface/public/shared/ui-navbar.js +8 -6
  36. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  37. package/neural-interface/public/shared/ui-settings.js +274 -50
  38. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  39. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  40. package/neural-interface/public/shared/ui-sync.js +9 -5
  41. package/neural-interface/public/shared/ui-terminal.js +76 -27
  42. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  43. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  44. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  45. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  46. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  47. package/neural-interface/public/variant/2d/main.js +9 -7
  48. package/neural-interface/public/variant/3d/main.js +10 -8
  49. package/neural-interface/server.js +1281 -252
  50. package/package.json +1 -1
  51. package/skills/leonardo/SKILL.md +5 -1
  52. package/skills/leonardo/modules/image-prompter.md +5 -1
  53. package/skills/leonardo/modules/video-prompter.md +5 -1
  54. package/skills/synabun/SKILL.md +44 -26
  55. package/skills/synabun/modules/audit.md +40 -36
  56. package/skills/synabun/modules/changelog.md +22 -18
  57. package/skills/synabun/modules/memorize.md +19 -15
  58. package/skills/synabun/modules/schedule.md +285 -0
  59. package/CLAUDE.md +0 -217
@@ -0,0 +1,264 @@
1
+ import { spawnSync } from 'child_process';
2
+ import {
3
+ existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync,
4
+ lstatSync, renameSync, rmSync,
5
+ } from 'fs';
6
+ import { join, resolve, dirname } from 'path';
7
+ import os from 'node:os';
8
+
9
+ const PLUGINS_ROOT = () => join(os.homedir(), '.claude', 'plugins');
10
+ const INSTALLED_PLUGINS_FILE = () => join(PLUGINS_ROOT(), 'installed_plugins.json');
11
+ const KNOWN_MARKETPLACES_FILE = () => join(PLUGINS_ROOT(), 'known_marketplaces.json');
12
+
13
+ let trackerPath = null;
14
+ export function setTrackerPath(p) { trackerPath = p; }
15
+ const trackerFile = () => trackerPath || join(process.cwd(), 'data', 'synabun-plugins.json');
16
+
17
+ function ensureDir(p) { if (!existsSync(p)) mkdirSync(p, { recursive: true }); }
18
+
19
+ function readJson(p, fallback) {
20
+ try { return existsSync(p) ? JSON.parse(readFileSync(p, 'utf-8')) : fallback; }
21
+ catch { return fallback; }
22
+ }
23
+
24
+ function writeJsonAtomic(p, obj) {
25
+ ensureDir(dirname(p));
26
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
27
+ writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
28
+ try { renameSync(tmp, p); }
29
+ catch { writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf-8'); try { unlinkSync(tmp); } catch {} }
30
+ }
31
+
32
+ function runClaude(args, { timeoutMs = 180000 } = {}) {
33
+ const r = spawnSync('claude', args, {
34
+ encoding: 'utf-8',
35
+ stdio: ['ignore', 'pipe', 'pipe'],
36
+ timeout: timeoutMs,
37
+ });
38
+ const stdout = (r.stdout || '').toString();
39
+ const stderr = (r.stderr || '').toString();
40
+ const combined = `${stdout}${stderr ? '\n' + stderr : ''}`.trim();
41
+ if (r.error && r.error.code === 'ENOENT') {
42
+ return { ok: false, code: -1, stdout, stderr, combined, notFound: true };
43
+ }
44
+ return { ok: r.status === 0, code: r.status ?? -1, stdout, stderr, combined };
45
+ }
46
+
47
+ function claudeNotFoundErr() {
48
+ return { ok: false, error: 'Claude Code CLI not found on PATH. Install `claude` to manage plugins.' };
49
+ }
50
+
51
+ function gitHeadSha(repoPath) {
52
+ if (!repoPath || !existsSync(repoPath)) return 'unknown';
53
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repoPath, encoding: 'utf-8' });
54
+ const out = (r.stdout || '').trim();
55
+ return out || 'unknown';
56
+ }
57
+
58
+ function readTracker() { return readJson(trackerFile(), { version: 1, plugins: {} }); }
59
+ function writeTracker(obj) { writeJsonAtomic(trackerFile(), obj); }
60
+
61
+ function isSynabunSymlinkMarketplace(entry) {
62
+ return entry && entry.source && entry.source.source === 'synabun-symlink';
63
+ }
64
+
65
+ // Purge legacy "synabun-symlink" entries that break CC's marketplace loader,
66
+ // plus orphaned installed_plugins rows whose cache path no longer exists.
67
+ function cleanLegacyState() {
68
+ try {
69
+ const known = readJson(KNOWN_MARKETPLACES_FILE(), null);
70
+ if (known && typeof known === 'object') {
71
+ let changed = false;
72
+ for (const [name, entry] of Object.entries(known)) {
73
+ if (isSynabunSymlinkMarketplace(entry)) {
74
+ delete known[name];
75
+ changed = true;
76
+ const mkLink = join(PLUGINS_ROOT(), 'marketplaces', name);
77
+ try { if (lstatSync(mkLink).isSymbolicLink()) unlinkSync(mkLink); } catch {}
78
+ try { rmSync(join(PLUGINS_ROOT(), 'cache', name), { recursive: true, force: true }); } catch {}
79
+ }
80
+ }
81
+ if (changed) writeJsonAtomic(KNOWN_MARKETPLACES_FILE(), known);
82
+ }
83
+
84
+ const installed = readJson(INSTALLED_PLUGINS_FILE(), null);
85
+ if (installed && installed.plugins && typeof installed.plugins === 'object') {
86
+ let changed2 = false;
87
+ for (const key of Object.keys(installed.plugins)) {
88
+ const list = installed.plugins[key] || [];
89
+ const filtered = list.filter(e => e && e.installPath && existsSync(e.installPath));
90
+ if (filtered.length !== list.length) {
91
+ if (filtered.length) installed.plugins[key] = filtered;
92
+ else delete installed.plugins[key];
93
+ changed2 = true;
94
+ }
95
+ }
96
+ if (changed2) writeJsonAtomic(INSTALLED_PLUGINS_FILE(), installed);
97
+ }
98
+ } catch {}
99
+ }
100
+
101
+ function parsePluginManifest(repoPath) {
102
+ const pluginJson = readJson(join(repoPath, '.claude-plugin', 'plugin.json'), null);
103
+ if (!pluginJson || !pluginJson.name) return { ok: false, error: `Missing .claude-plugin/plugin.json at ${repoPath}` };
104
+ const marketplaceJson = readJson(join(repoPath, '.claude-plugin', 'marketplace.json'), null);
105
+ const pluginName = String(pluginJson.name).trim();
106
+ const marketplaceName = (marketplaceJson && marketplaceJson.name) || pluginName;
107
+ const hooks = pluginJson.hooks && typeof pluginJson.hooks === 'object' ? Object.keys(pluginJson.hooks) : [];
108
+ return { ok: true, pluginName, marketplaceName, hooks, manifest: pluginJson };
109
+ }
110
+
111
+ // Install via `claude plugin marketplace add` + `claude plugin install`.
112
+ // Prefers githubUrl (CC manages its own clone); falls back to local repoPath.
113
+ export function installClaudePlugin({ repoPath, githubUrl } = {}) {
114
+ cleanLegacyState();
115
+
116
+ if (!repoPath || !existsSync(repoPath)) {
117
+ return { ok: false, error: `Repo path required to read plugin manifest: ${repoPath}` };
118
+ }
119
+ const meta = parsePluginManifest(repoPath);
120
+ if (!meta.ok) return meta;
121
+ const { pluginName, marketplaceName, hooks } = meta;
122
+
123
+ const source = githubUrl || resolve(repoPath);
124
+
125
+ const addRes = runClaude(['plugin', 'marketplace', 'add', source, '--scope', 'user']);
126
+ if (addRes.notFound) return claudeNotFoundErr();
127
+ const alreadyAdded = /already (added|exists|configured|registered)/i.test(addRes.combined);
128
+ if (!addRes.ok && !alreadyAdded) {
129
+ return { ok: false, error: `claude plugin marketplace add failed: ${addRes.combined || `exit ${addRes.code}`}` };
130
+ }
131
+
132
+ const installRes = runClaude(['plugin', 'install', `${pluginName}@${marketplaceName}`, '--scope', 'user']);
133
+ if (installRes.notFound) return claudeNotFoundErr();
134
+ const alreadyInstalled = /already installed/i.test(installRes.combined);
135
+ if (!installRes.ok && !alreadyInstalled) {
136
+ return { ok: false, error: `claude plugin install failed: ${installRes.combined || `exit ${installRes.code}`}` };
137
+ }
138
+
139
+ const installed = readJson(INSTALLED_PLUGINS_FILE(), { plugins: {} });
140
+ const key = `${pluginName}@${marketplaceName}`;
141
+ const ccList = installed.plugins?.[key] || [];
142
+ const ccEntry = ccList.find(e => e && e.scope === 'user') || ccList[0] || null;
143
+ const installPath = ccEntry?.installPath || null;
144
+ const gitCommitSha = ccEntry?.gitCommitSha || gitHeadSha(repoPath);
145
+
146
+ const tracker = readTracker();
147
+ if (!tracker.plugins || typeof tracker.plugins !== 'object') tracker.plugins = {};
148
+ tracker.plugins[key] = {
149
+ pluginName,
150
+ marketplaceName,
151
+ source,
152
+ githubUrl: githubUrl || null,
153
+ repoPath: resolve(repoPath),
154
+ gitCommitSha,
155
+ installedAt: tracker.plugins[key]?.installedAt || new Date().toISOString(),
156
+ lastUpdated: new Date().toISOString(),
157
+ };
158
+ writeTracker(tracker);
159
+
160
+ return {
161
+ ok: true,
162
+ pluginName,
163
+ marketplaceName,
164
+ installPath,
165
+ marketplacePath: join(PLUGINS_ROOT(), 'marketplaces', marketplaceName),
166
+ key,
167
+ hooks,
168
+ gitCommitSha,
169
+ alreadyInstalled,
170
+ };
171
+ }
172
+
173
+ export function uninstallClaudePlugin({ marketplaceName, pluginName } = {}) {
174
+ cleanLegacyState();
175
+ if (!marketplaceName) return { ok: false, error: 'marketplaceName required' };
176
+
177
+ const tracker = readTracker();
178
+ const managedKeys = Object.keys(tracker.plugins || {}).filter(k => {
179
+ const [pName, mName] = k.split('@');
180
+ if (mName !== marketplaceName) return false;
181
+ if (pluginName && pName !== pluginName) return false;
182
+ return true;
183
+ });
184
+ if (!managedKeys.length) return { ok: false, error: `No SynaBun-managed plugin found for ${marketplaceName}${pluginName ? '/' + pluginName : ''}` };
185
+
186
+ const removedPlugins = [];
187
+ for (const key of managedKeys) {
188
+ const [pName] = key.split('@');
189
+ const r = runClaude(['plugin', 'uninstall', key]);
190
+ if (r.notFound) return claudeNotFoundErr();
191
+ const notFound = /not (installed|found)/i.test(r.combined);
192
+ if (!r.ok && !notFound) {
193
+ return { ok: false, error: `claude plugin uninstall failed: ${r.combined || `exit ${r.code}`}` };
194
+ }
195
+ delete tracker.plugins[key];
196
+ removedPlugins.push(pName);
197
+ }
198
+
199
+ const remaining = Object.keys(tracker.plugins || {}).some(k => k.endsWith(`@${marketplaceName}`));
200
+ if (!remaining) {
201
+ runClaude(['plugin', 'marketplace', 'remove', marketplaceName]);
202
+ }
203
+ writeTracker(tracker);
204
+ return { ok: true, removedPlugins };
205
+ }
206
+
207
+ export function listSynabunPlugins() {
208
+ const tracker = readTracker();
209
+ const installed = readJson(INSTALLED_PLUGINS_FILE(), { plugins: {} });
210
+ const out = [];
211
+ for (const [key, meta] of Object.entries(tracker.plugins || {})) {
212
+ const ccList = installed.plugins?.[key] || [];
213
+ const ccEntry = ccList.find(e => e && e.scope === 'user') || ccList[0] || null;
214
+ const installPath = ccEntry?.installPath || null;
215
+ const installPresent = installPath ? existsSync(installPath) : false;
216
+ const repoExists = meta.repoPath ? existsSync(meta.repoPath) : true;
217
+ out.push({
218
+ key,
219
+ pluginName: meta.pluginName,
220
+ marketplaceName: meta.marketplaceName,
221
+ scope: ccEntry?.scope || 'user',
222
+ installPath,
223
+ installedAt: meta.installedAt,
224
+ lastUpdated: meta.lastUpdated,
225
+ gitCommitSha: ccEntry?.gitCommitSha || meta.gitCommitSha,
226
+ repoPath: meta.repoPath || null,
227
+ githubUrl: meta.githubUrl || null,
228
+ broken: !ccEntry || !installPresent || !repoExists,
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+
234
+ // Migration: purge legacy synabun-symlink state + reinstall via CLI for any
235
+ // entries whose original repo path still exists.
236
+ export function repairSynabunPlugins() {
237
+ const before = readJson(KNOWN_MARKETPLACES_FILE(), {}) || {};
238
+ const legacy = Object.entries(before)
239
+ .filter(([, v]) => isSynabunSymlinkMarketplace(v))
240
+ .map(([name, v]) => ({ name, path: v.source?.path || null }));
241
+
242
+ cleanLegacyState();
243
+
244
+ const repaired = [];
245
+ const failed = [];
246
+ for (const { name, path } of legacy) {
247
+ if (!path || !existsSync(path)) {
248
+ failed.push({ marketplaceName: name, error: 'Original repo path missing — cannot reinstall automatically' });
249
+ continue;
250
+ }
251
+ const meta = parsePluginManifest(path);
252
+ if (!meta.ok) { failed.push({ marketplaceName: name, error: meta.error }); continue; }
253
+ let githubUrl = null;
254
+ try {
255
+ const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: path, encoding: 'utf-8' });
256
+ const u = (r.stdout || '').trim();
257
+ if (/^https?:\/\//.test(u)) githubUrl = u.replace(/\.git$/, '');
258
+ } catch {}
259
+ const res = installClaudePlugin({ repoPath: path, githubUrl });
260
+ if (res.ok) repaired.push({ marketplaceName: name, pluginName: res.pluginName });
261
+ else failed.push({ marketplaceName: name, error: res.error });
262
+ }
263
+ return { repaired, failed };
264
+ }
@@ -21,7 +21,7 @@
21
21
  "express": "^4.21.2",
22
22
  "node-html-markdown": "^2.0.0",
23
23
  "node-pty": "^1.1.0",
24
- "playwright": "^1.58.2",
24
+ "playwright": "1.54.2",
25
25
  "undici": "^8.1.0",
26
26
  "ws": "^8.19.0"
27
27
  }
@@ -975,6 +975,24 @@ export async function launchLoop(params) {
975
975
  });
976
976
  }
977
977
 
978
+ export async function fetchLoopLog(terminalSessionId) {
979
+ const res = await fetch(`/api/loop/log/${encodeURIComponent(terminalSessionId)}`);
980
+ if (!res.ok) {
981
+ const text = await res.text().catch(() => '');
982
+ throw new Error(text || `HTTP ${res.status}`);
983
+ }
984
+ return res.text();
985
+ }
986
+
987
+ export async function sendClientLoopLog(terminalSessionId, tag, msg, meta) {
988
+ try {
989
+ return await jsonFetch('/api/loop/client-log', {
990
+ method: 'POST',
991
+ ...jsonBody({ terminalSessionId: terminalSessionId || null, tag, msg, meta }),
992
+ });
993
+ } catch { /* logging best-effort */ }
994
+ }
995
+
978
996
  export async function stopLoop(terminalSessionId) {
979
997
  return jsonFetch('/api/loop/stop', {
980
998
  method: 'POST',
@@ -1004,11 +1022,42 @@ export async function searchMemoriesByCategory(query, category, limit = 15) {
1004
1022
  });
1005
1023
  }
1006
1024
 
1007
- // ── Loop Schedules ──
1008
-
1009
- export async function fetchSchedules() {
1010
- return jsonFetch('/api/schedules');
1011
- }
1025
+ // ── Loop Schedules ──
1026
+
1027
+ export async function fetchScheduleGroups() {
1028
+ return jsonFetch('/api/schedule-groups');
1029
+ }
1030
+
1031
+ export async function createScheduleGroup(payload) {
1032
+ return jsonFetch('/api/schedule-groups', {
1033
+ method: 'POST',
1034
+ ...jsonBody(payload),
1035
+ });
1036
+ }
1037
+
1038
+ export async function updateScheduleGroup(id, payload) {
1039
+ return jsonFetch(`/api/schedule-groups/${encodeURIComponent(id)}`, {
1040
+ method: 'PUT',
1041
+ ...jsonBody(payload),
1042
+ });
1043
+ }
1044
+
1045
+ export async function deleteScheduleGroup(id) {
1046
+ return jsonFetch(`/api/schedule-groups/${encodeURIComponent(id)}`, {
1047
+ method: 'DELETE',
1048
+ });
1049
+ }
1050
+
1051
+ export async function reorderScheduleGroups(orderedIds) {
1052
+ return jsonFetch('/api/schedule-groups/reorder', {
1053
+ method: 'POST',
1054
+ ...jsonBody({ orderedIds }),
1055
+ });
1056
+ }
1057
+
1058
+ export async function fetchSchedules() {
1059
+ return jsonFetch('/api/schedules');
1060
+ }
1012
1061
 
1013
1062
  export async function createSchedule(params) {
1014
1063
  return jsonFetch('/api/schedules', {
@@ -1059,12 +1108,12 @@ export async function fetchScheduleTimers() {
1059
1108
 
1060
1109
  // ── Quick Timers ──
1061
1110
 
1062
- export async function createQuickTimer(templateId, minutes, { profile, model, usesBrowser } = {}) {
1063
- return jsonFetch('/api/quick-timer', {
1064
- method: 'POST',
1065
- ...jsonBody({ templateId, minutes, profile, model, usesBrowser }),
1066
- });
1067
- }
1111
+ export async function createQuickTimer(templateId, minutes, { profile, model, effort, usesBrowser } = {}) {
1112
+ return jsonFetch('/api/quick-timer', {
1113
+ method: 'POST',
1114
+ ...jsonBody({ templateId, minutes, profile, model, effort, usesBrowser }),
1115
+ });
1116
+ }
1068
1117
 
1069
1118
  export async function fetchQuickTimers() {
1070
1119
  return jsonFetch('/api/quick-timers');
@@ -1076,12 +1125,12 @@ export async function cancelQuickTimer(id) {
1076
1125
  });
1077
1126
  }
1078
1127
 
1079
- export async function triggerQuickTimerNow(templateId, { profile, model, usesBrowser } = {}) {
1080
- return jsonFetch('/api/quick-timer/now', {
1081
- method: 'POST',
1082
- ...jsonBody({ templateId, profile, model, usesBrowser }),
1083
- });
1084
- }
1128
+ export async function triggerQuickTimerNow(templateId, { profile, model, effort, usesBrowser } = {}) {
1129
+ return jsonFetch('/api/quick-timer/now', {
1130
+ method: 'POST',
1131
+ ...jsonBody({ templateId, profile, model, effort, usesBrowser }),
1132
+ });
1133
+ }
1085
1134
 
1086
1135
  // ── Isolated Agents ──
1087
1136
 
@@ -49,13 +49,12 @@ export const STOR = {
49
49
  };
50
50
 
51
51
  // ── Effort Levels ──
52
- export const EFFORT_LEVELS = ['off', 'low', 'medium', 'high'];
52
+ export const EFFORT_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
53
53
 
54
54
  // ── Panel Identity ──
55
55
  export const PANEL_OWNER = 'codex-sidepanel';
56
56
 
57
- // ── Limits ──
58
- export const MAX_TABS = 10;
57
+ // ── Snapshot Limits ──
59
58
  export const MAX_THREAD_SNAPSHOTS = 18;
60
59
  export const MAX_THREAD_SNAPSHOT_CHARS = 2_000_000;
61
60
 
@@ -69,7 +68,9 @@ CRITICAL — When you need clarification during planning:
69
68
  1. Use request_user_input to ask concise multiple-choice questions.
70
69
  2. After calling request_user_input, stop and wait for the user's reply before continuing.
71
70
  3. Do NOT ask clarification questions as plain text.
72
- 4. Do NOT make code changes until the plan is approved.`,
71
+ 4. Do NOT make code changes until the plan is approved.
72
+ 5. When the plan is ready, stop after the plan. Do NOT continue into implementation.
73
+ 6. The sidepanel will ask the user whether to Edit plan, Compact context, or Continue with implementation.`,
73
74
  ];
74
75
 
75
76
  // ── Stall Detection ──
@@ -6,7 +6,7 @@ import { state, emit, on } from '../state.js';
6
6
  import { reserveRightPanelLayout, clearRightPanelLayout } from '../ui-sidepanel-layout.js';
7
7
  import { isClaudePanelOpen, toggleClaudePanel } from '../ui-claude-panel.js';
8
8
  import { injectStyles } from './cdx-styles.js';
9
- import { flushCardBody } from './cdx-render.js';
9
+ import { appendAssistantMarkdownMessage, flushCardBody, renderPostPlanActions } from './cdx-render.js';
10
10
  import {
11
11
  OPENAI_ICON, ICON_SPARK, ICON_PLUS, ICON_MINIMIZE, ICON_X, ICON_STOP,
12
12
  ICON_EDIT, ICON_SHIELD, ICON_PLAN, ICON_BRAIN, SYNABUN_LOGO_ICON,
@@ -17,7 +17,7 @@ import {
17
17
  createTab, switchTab, closeTab, closeActiveTab, saveTabs, restoreTabs,
18
18
  initContextBridge,
19
19
  renderPills, renderProjects, updateActiveTabView,
20
- sendPrompt, dispatchPrompt, interruptTurn, interruptAllTabs, anyTabRunning, queueCurrentDraft, steerActiveTurn,
20
+ sendPrompt, dispatchPrompt, interruptTurn, queueCurrentDraft, steerActiveTurn,
21
21
  cycleEffort, togglePlanMode, toggleAutoAccept, syncToolbarState,
22
22
  requestModelList, populateModelDropdown,
23
23
  setSessionLabel, renameActiveSession, promptNameNewSession, openSettingsPanel, renderSessionMenu,
@@ -27,11 +27,18 @@ import {
27
27
  syncQueueTray, editQueueItem, toggleQueuePause, clearQueue,
28
28
  startCompaction,
29
29
  syncInputEnabled, syncReservedWidth, scheduleThreadSnapshotSave,
30
+ flushAllThreadSnapshots,
30
31
  resetStallTimer, stopStallTimer,
31
32
  withTab,
32
33
  ensureProjectsLoaded,
33
34
  } from './cdx-tabs.js';
34
35
 
36
+ window.addEventListener('beforeunload', () => { try { flushAllThreadSnapshots(); } catch {} });
37
+ window.addEventListener('pagehide', () => { try { flushAllThreadSnapshots(); } catch {} });
38
+ document.addEventListener('visibilitychange', () => {
39
+ if (document.hidden) { try { flushAllThreadSnapshots(); } catch {} }
40
+ });
41
+
35
42
  // ═══════════════════════════════════════════
36
43
  // Module-level State (panel owns)
37
44
  // ═══════════════════════════════════════════
@@ -68,6 +75,9 @@ let _planContent = '';
68
75
  let _editedPlanContent = '';
69
76
  let _showPostPlanActions = false;
70
77
  let _postPlanHeader = 'PLAN COMPLETE';
78
+ let _planTurnActive = false;
79
+ let _planApprovalPending = false;
80
+ let _lastPlanTurnId = '';
71
81
  let _planFilePath = '';
72
82
 
73
83
  // ═══════════════════════════════════════════
@@ -85,8 +95,6 @@ let _host = {};
85
95
  * flushCardBody(itemState)
86
96
  * resetThreadState(opts)
87
97
  * appendSystem(text, tone)
88
- * appendAssistantMarkdownMessage(tab, markdown)
89
- * renderPostPlanActions(tab, headerText)
90
98
  * sendServerRequestReply(requestId, opts)
91
99
  * getRequestCardEntry(requestId) -> object|null
92
100
  * restoreChangelogButtons(buttons)
@@ -133,6 +141,9 @@ export function syncBoundState(s) {
133
141
  _editedPlanContent = s.editedPlanContent ?? _editedPlanContent;
134
142
  _showPostPlanActions = s.showPostPlanActions ?? _showPostPlanActions;
135
143
  _postPlanHeader = s.postPlanHeader ?? _postPlanHeader;
144
+ _planTurnActive = s.planTurnActive ?? _planTurnActive;
145
+ _planApprovalPending = s.planApprovalPending ?? _planApprovalPending;
146
+ _lastPlanTurnId = s.lastPlanTurnId ?? _lastPlanTurnId;
136
147
  _planFilePath = s.planFilePath ?? _planFilePath;
137
148
  }
138
149
 
@@ -319,7 +330,7 @@ function buildPanel() {
319
330
  </button>
320
331
  </div>
321
332
  <div class="cxp-footer-right">
322
- <button class="cxp-toolbar-toggle" id="cxp-effort-toggle" title="Effort level: off" data-effort="off">${ICON_BRAIN}<span class="cxp-btn-label">Effort</span><span class="cxp-effort-dots"><i></i><i></i><i></i></span></button>
333
+ <button class="cxp-toolbar-toggle" id="cxp-effort-toggle" title="Effort level: off" data-effort="off">${ICON_BRAIN}<span class="cxp-btn-label">Effort</span><span class="cxp-effort-dots"><i></i><i></i><i></i><i></i><i></i></span></button>
323
334
  <button class="cxp-toolbar-toggle" id="cxp-plan-toggle" title="Plan mode: off">${ICON_PLAN}<span class="cxp-btn-label">Plan</span></button>
324
335
  <button class="cxp-toolbar-toggle" id="cxp-autoaccept-toggle" title="Auto-accept: off">${ICON_SHIELD}<span class="cxp-btn-label">Auto</span></button>
325
336
  <div class="cxp-dropdown" id="cxp-model" data-placeholder="model...">
@@ -386,12 +397,12 @@ function startupEmptyText() {
386
397
  function _cdxDocEscHandler(event) {
387
398
  if (event.key !== 'Escape') return;
388
399
  if (!_visible) return;
389
- if (!anyTabRunning()) return;
400
+ if (!activeTab()?.running) return;
390
401
  const hintsEl = panelEl('#cxp-slash-hints');
391
402
  if (hintsEl && !hintsEl.hidden) return; // let input-level handler dismiss hints first
392
403
  event.preventDefault();
393
404
  event.stopImmediatePropagation();
394
- interruptAllTabs();
405
+ interruptTurn();
395
406
  }
396
407
 
397
408
  function setVisible(nextVisible) {
@@ -629,7 +640,7 @@ function wireEvents() {
629
640
  if (event.key === 'Escape') {
630
641
  const hintsEl = panelEl('#cxp-slash-hints');
631
642
  if (hintsEl && !hintsEl.hidden) { hintsEl.hidden = true; _slashActiveIdx = -1; event.preventDefault(); return; }
632
- if (anyTabRunning()) { event.preventDefault(); interruptAllTabs(); return; }
643
+ if (activeTab()?.running) { event.preventDefault(); interruptTurn(); return; }
633
644
  }
634
645
  if (event.key === 'Tab' && activeTab()?.running) {
635
646
  const tab = activeTab();
@@ -673,15 +684,16 @@ function wireEvents() {
673
684
  fileInput.value = '';
674
685
  });
675
686
  send?.addEventListener('click', () => {
676
- if (anyTabRunning()) {
677
- interruptAllTabs();
687
+ if (activeTab()?.running) {
688
+ interruptTurn();
678
689
  return;
679
690
  }
680
691
  sendPrompt();
681
692
  });
682
693
  minimizeBtn?.addEventListener('click', () => setVisible(false));
683
694
  newBtn?.addEventListener('click', () => {
684
- createTab({ project: activeTab()?.project || storage.getItem(STOR.project) || '' });
695
+ const tab = createTab({ project: activeTab()?.project || storage.getItem(STOR.project) || '' });
696
+ if (!tab) return;
685
697
  promptNameNewSession();
686
698
  });
687
699
  closeBtn?.addEventListener('click', () => closeActiveTab());
@@ -855,16 +867,22 @@ function wireEvents() {
855
867
  tab.editedPlanContent = content || tab.editedPlanContent || '';
856
868
  tab.showPostPlanActions = true;
857
869
  tab.postPlanHeader = 'PLAN UPDATED';
870
+ tab.planApprovalPending = true;
871
+ tab.planTurnActive = false;
872
+ tab.lastPlanTurnId = '';
858
873
  if (filePath) tab.planFilePath = filePath;
859
874
  if (isActiveTab(tab)) {
860
875
  _planContent = tab.planContent;
861
876
  _editedPlanContent = tab.editedPlanContent;
862
877
  _showPostPlanActions = true;
863
878
  _postPlanHeader = 'PLAN UPDATED';
879
+ _planApprovalPending = true;
880
+ _planTurnActive = false;
881
+ _lastPlanTurnId = '';
864
882
  if (filePath) _planFilePath = filePath;
865
883
  }
866
- _host.appendAssistantMarkdownMessage?.(tab, content || '');
867
- _host.renderPostPlanActions?.(tab, 'PLAN UPDATED');
884
+ appendAssistantMarkdownMessage(tab, content || '');
885
+ renderPostPlanActions(tab, 'PLAN UPDATED');
868
886
  saveTabs();
869
887
  });
870
888
 
@@ -896,7 +914,18 @@ function wireEvents() {
896
914
  if (source && source !== 'codex') return;
897
915
  const tab = _tabs.find((entry) => entry.id === tabId) || activeTab();
898
916
  if (!tab) return;
899
- _host.renderPostPlanActions?.(tab);
917
+ tab.showPostPlanActions = true;
918
+ tab.planApprovalPending = true;
919
+ tab.planTurnActive = false;
920
+ tab.postPlanHeader = tab.postPlanHeader || 'PLAN COMPLETE';
921
+ if (isActiveTab(tab)) {
922
+ _showPostPlanActions = true;
923
+ _planApprovalPending = true;
924
+ _planTurnActive = false;
925
+ _postPlanHeader = tab.postPlanHeader;
926
+ }
927
+ renderPostPlanActions(tab, tab.postPlanHeader);
928
+ saveTabs();
900
929
  });
901
930
 
902
931
  on('changelog-edit-cancelled', () => {
@@ -942,6 +971,7 @@ function ensurePanel() {
942
971
  autosizeInput();
943
972
  restoreTabs();
944
973
  syncInputEnabled();
974
+ window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'codex' } }));
945
975
  // Close Codex when a Claude/OpenCode pill is clicked (mutual exclusion)
946
976
  const tray = document.getElementById('term-minimized-tray');
947
977
  if (tray) {