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
@@ -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 ──
@@ -4,9 +4,10 @@
4
4
  import { storage } from '../storage.js';
5
5
  import { state, emit, on } from '../state.js';
6
6
  import { reserveRightPanelLayout, clearRightPanelLayout } from '../ui-sidepanel-layout.js';
7
+ import { subscribeCliStatus, recheckCliStatus, getCliDocUrl, getCliInstallCommand, getCliLabel } from '../cli-status.js';
7
8
  import { isClaudePanelOpen, toggleClaudePanel } from '../ui-claude-panel.js';
8
9
  import { injectStyles } from './cdx-styles.js';
9
- import { flushCardBody } from './cdx-render.js';
10
+ import { appendAssistantMarkdownMessage, flushCardBody, renderPostPlanActions } from './cdx-render.js';
10
11
  import {
11
12
  OPENAI_ICON, ICON_SPARK, ICON_PLUS, ICON_MINIMIZE, ICON_X, ICON_STOP,
12
13
  ICON_EDIT, ICON_SHIELD, ICON_PLAN, ICON_BRAIN, SYNABUN_LOGO_ICON,
@@ -17,7 +18,7 @@ import {
17
18
  createTab, switchTab, closeTab, closeActiveTab, saveTabs, restoreTabs,
18
19
  initContextBridge,
19
20
  renderPills, renderProjects, updateActiveTabView,
20
- sendPrompt, dispatchPrompt, interruptTurn, interruptAllTabs, anyTabRunning, queueCurrentDraft, steerActiveTurn,
21
+ sendPrompt, dispatchPrompt, interruptTurn, queueCurrentDraft, steerActiveTurn,
21
22
  cycleEffort, togglePlanMode, toggleAutoAccept, syncToolbarState,
22
23
  requestModelList, populateModelDropdown,
23
24
  setSessionLabel, renameActiveSession, promptNameNewSession, openSettingsPanel, renderSessionMenu,
@@ -27,11 +28,18 @@ import {
27
28
  syncQueueTray, editQueueItem, toggleQueuePause, clearQueue,
28
29
  startCompaction,
29
30
  syncInputEnabled, syncReservedWidth, scheduleThreadSnapshotSave,
31
+ flushAllThreadSnapshots,
30
32
  resetStallTimer, stopStallTimer,
31
33
  withTab,
32
34
  ensureProjectsLoaded,
33
35
  } from './cdx-tabs.js';
34
36
 
37
+ window.addEventListener('beforeunload', () => { try { flushAllThreadSnapshots(); } catch {} });
38
+ window.addEventListener('pagehide', () => { try { flushAllThreadSnapshots(); } catch {} });
39
+ document.addEventListener('visibilitychange', () => {
40
+ if (document.hidden) { try { flushAllThreadSnapshots(); } catch {} }
41
+ });
42
+
35
43
  // ═══════════════════════════════════════════
36
44
  // Module-level State (panel owns)
37
45
  // ═══════════════════════════════════════════
@@ -39,6 +47,8 @@ import {
39
47
  let _panel = null;
40
48
  let _visible = false;
41
49
  let _resizeBound = false;
50
+ let _cliInstalled = null;
51
+ let _cliUnsub = null;
42
52
 
43
53
  // ═══════════════════════════════════════════
44
54
  // Bound State (mirrors active tab — managed by cdx-tabs)
@@ -68,6 +78,9 @@ let _planContent = '';
68
78
  let _editedPlanContent = '';
69
79
  let _showPostPlanActions = false;
70
80
  let _postPlanHeader = 'PLAN COMPLETE';
81
+ let _planTurnActive = false;
82
+ let _planApprovalPending = false;
83
+ let _lastPlanTurnId = '';
71
84
  let _planFilePath = '';
72
85
 
73
86
  // ═══════════════════════════════════════════
@@ -85,8 +98,6 @@ let _host = {};
85
98
  * flushCardBody(itemState)
86
99
  * resetThreadState(opts)
87
100
  * appendSystem(text, tone)
88
- * appendAssistantMarkdownMessage(tab, markdown)
89
- * renderPostPlanActions(tab, headerText)
90
101
  * sendServerRequestReply(requestId, opts)
91
102
  * getRequestCardEntry(requestId) -> object|null
92
103
  * restoreChangelogButtons(buttons)
@@ -133,6 +144,9 @@ export function syncBoundState(s) {
133
144
  _editedPlanContent = s.editedPlanContent ?? _editedPlanContent;
134
145
  _showPostPlanActions = s.showPostPlanActions ?? _showPostPlanActions;
135
146
  _postPlanHeader = s.postPlanHeader ?? _postPlanHeader;
147
+ _planTurnActive = s.planTurnActive ?? _planTurnActive;
148
+ _planApprovalPending = s.planApprovalPending ?? _planApprovalPending;
149
+ _lastPlanTurnId = s.lastPlanTurnId ?? _lastPlanTurnId;
136
150
  _planFilePath = s.planFilePath ?? _planFilePath;
137
151
  }
138
152
 
@@ -319,7 +333,7 @@ function buildPanel() {
319
333
  </button>
320
334
  </div>
321
335
  <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>
336
+ <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
337
  <button class="cxp-toolbar-toggle" id="cxp-plan-toggle" title="Plan mode: off">${ICON_PLAN}<span class="cxp-btn-label">Plan</span></button>
324
338
  <button class="cxp-toolbar-toggle" id="cxp-autoaccept-toggle" title="Auto-accept: off">${ICON_SHIELD}<span class="cxp-btn-label">Auto</span></button>
325
339
  <div class="cxp-dropdown" id="cxp-model" data-placeholder="model...">
@@ -379,6 +393,74 @@ function startupEmptyText() {
379
393
  return '';
380
394
  }
381
395
 
396
+ // ── CLI installation status (banner + send-block) ──
397
+ function renderCliMissingBanner(emptyEl) {
398
+ if (!emptyEl) return;
399
+ const label = getCliLabel('codex');
400
+ const cmd = getCliInstallCommand('codex');
401
+ const url = getCliDocUrl('codex');
402
+ const statusEl = emptyEl.querySelector('.cxp-empty-status');
403
+ if (statusEl) statusEl.style.display = 'none';
404
+ let banner = emptyEl.querySelector('.cxp-cli-missing');
405
+ if (!banner) {
406
+ banner = document.createElement('div');
407
+ banner.className = 'cxp-cli-missing';
408
+ banner.innerHTML = `
409
+ <div class="cxp-cli-missing-title">${label} CLI not installed</div>
410
+ <div class="cxp-cli-missing-body">Run: <code>${cmd}</code></div>
411
+ <div class="cxp-cli-missing-actions">
412
+ <a class="cxp-cli-missing-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
413
+ <button class="cxp-cli-missing-recheck" type="button">Re-check</button>
414
+ </div>
415
+ `;
416
+ emptyEl.appendChild(banner);
417
+ const btn = banner.querySelector('.cxp-cli-missing-recheck');
418
+ btn?.addEventListener('click', async () => {
419
+ btn.disabled = true;
420
+ btn.textContent = 'Checking…';
421
+ try { await recheckCliStatus('codex'); }
422
+ finally {
423
+ if (btn.isConnected) {
424
+ btn.disabled = false;
425
+ btn.textContent = 'Re-check';
426
+ }
427
+ }
428
+ });
429
+ }
430
+ }
431
+
432
+ function refreshCliBanners() {
433
+ if (!_panel) return;
434
+ const empties = _panel.querySelectorAll('.cxp-empty');
435
+ if (_cliInstalled === null) {
436
+ _panel.classList.add('cxp-cli-blocked');
437
+ for (const el of empties) renderCliMissingBanner(el);
438
+ } else {
439
+ _panel.classList.remove('cxp-cli-blocked');
440
+ for (const el of empties) {
441
+ const banner = el.querySelector('.cxp-cli-missing');
442
+ if (banner) banner.remove();
443
+ const statusEl = el.querySelector('.cxp-empty-status');
444
+ if (statusEl) statusEl.style.display = '';
445
+ }
446
+ }
447
+ // Re-evaluate send button state with new CLI flag
448
+ try { syncInputEnabled(); } catch {}
449
+ }
450
+
451
+ export function isCdxCliInstalled() {
452
+ return _cliInstalled !== null;
453
+ }
454
+
455
+ function ensureCliSubscription() {
456
+ if (_cliUnsub) return;
457
+ _cliUnsub = subscribeCliStatus('codex', (info) => {
458
+ _cliInstalled = info?.installed || null;
459
+ refreshCliBanners();
460
+ });
461
+ document.addEventListener('cdx-empty-rebuilt', () => refreshCliBanners());
462
+ }
463
+
382
464
  // ═══════════════════════════════════════════
383
465
  // setVisible — Show / Hide Panel
384
466
  // ═══════════════════════════════════════════
@@ -386,12 +468,12 @@ function startupEmptyText() {
386
468
  function _cdxDocEscHandler(event) {
387
469
  if (event.key !== 'Escape') return;
388
470
  if (!_visible) return;
389
- if (!anyTabRunning()) return;
471
+ if (!activeTab()?.running) return;
390
472
  const hintsEl = panelEl('#cxp-slash-hints');
391
473
  if (hintsEl && !hintsEl.hidden) return; // let input-level handler dismiss hints first
392
474
  event.preventDefault();
393
475
  event.stopImmediatePropagation();
394
- interruptAllTabs();
476
+ interruptTurn();
395
477
  }
396
478
 
397
479
  function setVisible(nextVisible) {
@@ -415,6 +497,7 @@ function setVisible(nextVisible) {
415
497
  loadCurrentProfile();
416
498
  loadRecallProfile();
417
499
  panelEl('#cxp-input')?.focus();
500
+ refreshCliBanners();
418
501
  } else {
419
502
  document.removeEventListener('keydown', _cdxDocEscHandler, { capture: true });
420
503
  }
@@ -629,7 +712,7 @@ function wireEvents() {
629
712
  if (event.key === 'Escape') {
630
713
  const hintsEl = panelEl('#cxp-slash-hints');
631
714
  if (hintsEl && !hintsEl.hidden) { hintsEl.hidden = true; _slashActiveIdx = -1; event.preventDefault(); return; }
632
- if (anyTabRunning()) { event.preventDefault(); interruptAllTabs(); return; }
715
+ if (activeTab()?.running) { event.preventDefault(); interruptTurn(); return; }
633
716
  }
634
717
  if (event.key === 'Tab' && activeTab()?.running) {
635
718
  const tab = activeTab();
@@ -673,15 +756,16 @@ function wireEvents() {
673
756
  fileInput.value = '';
674
757
  });
675
758
  send?.addEventListener('click', () => {
676
- if (anyTabRunning()) {
677
- interruptAllTabs();
759
+ if (activeTab()?.running) {
760
+ interruptTurn();
678
761
  return;
679
762
  }
680
763
  sendPrompt();
681
764
  });
682
765
  minimizeBtn?.addEventListener('click', () => setVisible(false));
683
766
  newBtn?.addEventListener('click', () => {
684
- createTab({ project: activeTab()?.project || storage.getItem(STOR.project) || '' });
767
+ const tab = createTab({ project: activeTab()?.project || storage.getItem(STOR.project) || '' });
768
+ if (!tab) return;
685
769
  promptNameNewSession();
686
770
  });
687
771
  closeBtn?.addEventListener('click', () => closeActiveTab());
@@ -855,16 +939,22 @@ function wireEvents() {
855
939
  tab.editedPlanContent = content || tab.editedPlanContent || '';
856
940
  tab.showPostPlanActions = true;
857
941
  tab.postPlanHeader = 'PLAN UPDATED';
942
+ tab.planApprovalPending = true;
943
+ tab.planTurnActive = false;
944
+ tab.lastPlanTurnId = '';
858
945
  if (filePath) tab.planFilePath = filePath;
859
946
  if (isActiveTab(tab)) {
860
947
  _planContent = tab.planContent;
861
948
  _editedPlanContent = tab.editedPlanContent;
862
949
  _showPostPlanActions = true;
863
950
  _postPlanHeader = 'PLAN UPDATED';
951
+ _planApprovalPending = true;
952
+ _planTurnActive = false;
953
+ _lastPlanTurnId = '';
864
954
  if (filePath) _planFilePath = filePath;
865
955
  }
866
- _host.appendAssistantMarkdownMessage?.(tab, content || '');
867
- _host.renderPostPlanActions?.(tab, 'PLAN UPDATED');
956
+ appendAssistantMarkdownMessage(tab, content || '');
957
+ renderPostPlanActions(tab, 'PLAN UPDATED');
868
958
  saveTabs();
869
959
  });
870
960
 
@@ -896,7 +986,18 @@ function wireEvents() {
896
986
  if (source && source !== 'codex') return;
897
987
  const tab = _tabs.find((entry) => entry.id === tabId) || activeTab();
898
988
  if (!tab) return;
899
- _host.renderPostPlanActions?.(tab);
989
+ tab.showPostPlanActions = true;
990
+ tab.planApprovalPending = true;
991
+ tab.planTurnActive = false;
992
+ tab.postPlanHeader = tab.postPlanHeader || 'PLAN COMPLETE';
993
+ if (isActiveTab(tab)) {
994
+ _showPostPlanActions = true;
995
+ _planApprovalPending = true;
996
+ _planTurnActive = false;
997
+ _postPlanHeader = tab.postPlanHeader;
998
+ }
999
+ renderPostPlanActions(tab, tab.postPlanHeader);
1000
+ saveTabs();
900
1001
  });
901
1002
 
902
1003
  on('changelog-edit-cancelled', () => {
@@ -942,6 +1043,8 @@ function ensurePanel() {
942
1043
  autosizeInput();
943
1044
  restoreTabs();
944
1045
  syncInputEnabled();
1046
+ ensureCliSubscription();
1047
+ window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'codex' } }));
945
1048
  // Close Codex when a Claude/OpenCode pill is clicked (mutual exclusion)
946
1049
  const tray = document.getElementById('term-minimized-tray');
947
1050
  if (tray) {