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
@@ -45,6 +45,8 @@ import { NodeHtmlMarkdown } from 'node-html-markdown';
45
45
  import { VTermBuffer } from './public/shared/vterm-buffer.js';
46
46
  import { startIndexing, getIndexingStatus, mirrorExistingChunks } from './lib/session-indexer.js';
47
47
  import * as mcpInstaller from './lib/mcp-installer.js';
48
+ import * as pluginInstaller from './lib/plugin-installer.js';
49
+ import { loopLog, loopLogPath } from './lib/loop-logger.js';
48
50
  import { ensureProjectCategories } from '../hooks/claude-code/shared.mjs';
49
51
  import {
50
52
  getDb, closeDb, getDbPath, getEmbedding, getEmbeddingBatch, getEmbeddingDims, warmupEmbeddings,
@@ -215,6 +217,7 @@ if (!process.env.SYNABUN_DATA_HOME && PACKAGE_ROOT.includes('node_modules')) {
215
217
  }
216
218
  const IMAGES_DIR = resolve(DATA_HOME, 'data', 'images');
217
219
  if (!existsSync(IMAGES_DIR)) mkdirSync(IMAGES_DIR, { recursive: true });
220
+ pluginInstaller.setTrackerPath(resolve(DATA_HOME, 'data', 'synabun-plugins.json'));
218
221
 
219
222
  // ── Image favorites persistence ──
220
223
  const IMAGE_FAVORITES_PATH = resolve(DATA_HOME, 'data', 'image-favorites.json');
@@ -2297,6 +2300,8 @@ const CODEX_CONFIG_KEYS = [
2297
2300
  'model_auto_compact_token_limit',
2298
2301
  'web_search',
2299
2302
  'model_reasoning_effort',
2303
+ 'model_reasoning_summary',
2304
+ 'model_verbosity',
2300
2305
  'plan_mode_reasoning_effort',
2301
2306
  'personality',
2302
2307
  'service_tier',
@@ -2379,8 +2384,9 @@ function buildCodexSandboxPolicy(cwd = PACKAGE_ROOT, sandboxMode) {
2379
2384
  }
2380
2385
 
2381
2386
  const _codexOrphanedProcs = new Map(); // windowId:sessionId → orphan
2382
- function _codexOrphanKey(wid, sid) { return sid ? `${wid}:${sid}` : wid; }
2387
+ function _codexOrphanKey(wid, sid) { return wid && sid ? `${wid}:${sid}` : null; }
2383
2388
  const CODEX_ORPHAN_GRACE_MS = 30 * 60 * 1000; // 30 minutes — survive minimize, sleep, and network hiccups
2389
+ const CODEX_RESUME_TIMEOUT_MS = 120000;
2384
2390
 
2385
2391
  function handleCodexSkinWebSocket(ws) {
2386
2392
  const CODEX_GREETING_MARKER_START = '[SYNABUN_CODEX_GREETING_START]';
@@ -2392,7 +2398,9 @@ CRITICAL — When you need clarification during planning:
2392
2398
  1. Use request_user_input to ask concise multiple-choice questions.
2393
2399
  2. After calling request_user_input, stop and wait for the user's reply before continuing.
2394
2400
  3. Do NOT ask clarification questions as plain text.
2395
- 4. Do NOT make code changes until the plan is approved.`,
2401
+ 4. Do NOT make code changes until the plan is approved.
2402
+ 5. When the plan is ready, stop after the plan. Do NOT continue into implementation.
2403
+ 6. The sidepanel will ask the user whether to Edit plan, Compact context, or Continue with implementation.`,
2396
2404
  ];
2397
2405
  let child = null;
2398
2406
  let childStdoutBuf = '';
@@ -2410,6 +2418,7 @@ CRITICAL — When you need clarification during planning:
2410
2418
  const pendingServerRequests = new Set();
2411
2419
  const pendingGreetingThreads = new Set();
2412
2420
  const _cachedConfig = {}; // effective config from config/read + config/batchWrite
2421
+ let activeTurnAutoAccept = false;
2413
2422
 
2414
2423
  // Load permitted MCP tools from ~/.claude/settings.json (shared source of truth)
2415
2424
  const _codexPermittedTools = (() => {
@@ -2424,6 +2433,38 @@ CRITICAL — When you need clarification during planning:
2424
2433
  if (ws.readyState === 1) ws.send(JSON.stringify(data));
2425
2434
  }
2426
2435
 
2436
+ function validateCodexControlMessage(msg, action) {
2437
+ const messageSessionId = msg?.sessionId ? String(msg.sessionId) : '';
2438
+ const messageThreadId = msg?.threadId ? String(msg.threadId) : '';
2439
+ if (!messageSessionId) {
2440
+ console.warn(`[codex-skin] Ignoring ${action} without sessionId (bound=${wsSessionId || 'none'}, thread=${activeThreadId || 'none'}, pid=${child?.pid || 'none'})`);
2441
+ sendToClient({ type: 'error', requestId: msg?.requestId || null, message: `${action} ignored: missing session id` });
2442
+ return false;
2443
+ }
2444
+ if (wsSessionId && messageSessionId !== wsSessionId) {
2445
+ console.warn(`[codex-skin] Ignoring ${action} for mismatched session ${messageSessionId} (bound=${wsSessionId}, thread=${activeThreadId || 'none'}, pid=${child?.pid || 'none'})`);
2446
+ sendToClient({ type: 'error', requestId: msg?.requestId || null, message: `${action} ignored: session mismatch` });
2447
+ return false;
2448
+ }
2449
+ if (messageThreadId && activeThreadId && messageThreadId !== String(activeThreadId)) {
2450
+ console.warn(`[codex-skin] Ignoring ${action} for thread ${messageThreadId} on active thread ${activeThreadId} (session=${wsSessionId || 'none'}, pid=${child?.pid || 'none'})`);
2451
+ sendToClient({ type: 'error', requestId: msg?.requestId || null, message: `${action} ignored: thread mismatch` });
2452
+ return false;
2453
+ }
2454
+ return true;
2455
+ }
2456
+
2457
+ function forceKillCodexChild(reason) {
2458
+ if (!child) return false;
2459
+ const proc = child;
2460
+ console.log(`[codex-skin] Force-killing Codex app-server process pid ${proc.pid || 'unknown'} session ${wsSessionId || 'unknown'} thread ${activeThreadId || 'unknown'} (${reason})`);
2461
+ try { proc.kill('SIGTERM'); } catch {}
2462
+ setTimeout(() => {
2463
+ try { if (!proc.killed && proc.exitCode === null) proc.kill('SIGKILL'); } catch {}
2464
+ }, 2000);
2465
+ return true;
2466
+ }
2467
+
2427
2468
  function rpcError(message, code = -32000, data = null) {
2428
2469
  return data == null ? { code, message } : { code, message, data };
2429
2470
  }
@@ -2695,6 +2736,13 @@ CRITICAL — When you need clarification during planning:
2695
2736
  return stripInjectedCodexSkillPreamble(stripInjectedCodexPlanPreamble(stripInjectedCodexGreeting(text)));
2696
2737
  }
2697
2738
 
2739
+ function isCodexPlanModePrompt(text) {
2740
+ const value = String(text || '').trim();
2741
+ if (!value) return false;
2742
+ return /^\[PLAN MODE\b/i.test(value)
2743
+ || CODEX_PLAN_MODE_PREFIXES.some((prefix) => value.startsWith(prefix));
2744
+ }
2745
+
2698
2746
  function sanitizeCodexThreadSummary(thread) {
2699
2747
  if (!thread || typeof thread !== 'object') return thread;
2700
2748
  const next = { ...thread };
@@ -2969,13 +3017,16 @@ CRITICAL — When you need clarification during planning:
2969
3017
  if (msg.id !== undefined && msg.method) {
2970
3018
  const requestId = msg.id;
2971
3019
 
2972
- // Auto-accept MCP elicitations for tools permitted in Settings > Permissions
3020
+ // Auto-accept MCP elicitations only when the active Codex tab's Auto
3021
+ // toggle is enabled. The Claude permissions list controls eligibility;
3022
+ // it should not bypass Codex approval cards by itself.
2973
3023
  if (msg.method === 'mcpServer/elicitation/request'
2974
3024
  && msg.params?.meta?.codex_approval_kind === 'mcp_tool_call') {
2975
3025
  const toolShort = msg.params?.meta?.tool_name || '';
2976
3026
  const fullKey = toolShort.startsWith('mcp__') ? toolShort : `mcp__SynaBun__${toolShort}`;
2977
- console.log('[codex-skin] MCP elicitation for tool:', toolShort, '→', fullKey, 'permitted:', _codexPermittedTools.has(fullKey));
2978
- if (_codexPermittedTools.has(fullKey)) {
3027
+ const permitted = _codexPermittedTools.has(fullKey);
3028
+ console.log('[codex-skin] MCP elicitation for tool:', toolShort, '→', fullKey, 'auto:', activeTurnAutoAccept, 'permitted:', permitted);
3029
+ if (activeTurnAutoAccept && permitted) {
2979
3030
  try { writeRpc({ jsonrpc: '2.0', id: requestId, result: { action: 'accept', content: {}, _meta: {} } }); } catch {}
2980
3031
  continue;
2981
3032
  }
@@ -2995,11 +3046,13 @@ CRITICAL — When you need clarification during planning:
2995
3046
  activeTurnId = params.turn.id;
2996
3047
  } else if (method === 'turn/completed') {
2997
3048
  activeTurnId = null;
3049
+ activeTurnAutoAccept = false;
2998
3050
  lastTurnLocalImages = [];
2999
3051
  } else if (method === 'thread/closed' && params.threadId && params.threadId === activeThreadId) {
3000
3052
  pendingGreetingThreads.delete(params.threadId);
3001
3053
  activeThreadId = null;
3002
3054
  activeTurnId = null;
3055
+ activeTurnAutoAccept = false;
3003
3056
  }
3004
3057
  sendToClient({ type: 'notify', method, params: sanitizeCodexNotifyParams(method, params) });
3005
3058
  }
@@ -3026,6 +3079,7 @@ CRITICAL — When you need clarification during planning:
3026
3079
  initialized = false;
3027
3080
  bootPromise = null;
3028
3081
  activeTurnId = null;
3082
+ activeTurnAutoAccept = false;
3029
3083
  if (childStdoutBuf.trim()) {
3030
3084
  console.log('[codex-skin] trailing stdout:', childStdoutBuf.trim().slice(0, 200));
3031
3085
  }
@@ -3127,13 +3181,36 @@ CRITICAL — When you need clarification during planning:
3127
3181
  if (!threadId) throw new Error('No thread provided');
3128
3182
  if (activeTurnId) throw new Error('Codex is already processing a turn');
3129
3183
  const readThread = await readThreadHistory(threadId).catch(() => null);
3130
- const resumed = await request('thread/resume', getDefaultResumeParams(threadId, cwd));
3184
+ const fallbackItems = loadCodexThreadHistoryFallback(threadId);
3185
+ const previewThread = readThread || (fallbackItems.length ? { id: threadId, turns: [] } : null);
3186
+ if (previewThread) {
3187
+ sendToClient({
3188
+ type: 'history',
3189
+ thread: sanitizeCodexThreadForClient(previewThread),
3190
+ fallbackItems,
3191
+ historyMode: 'read',
3192
+ });
3193
+ }
3194
+
3195
+ let resumed;
3196
+ try {
3197
+ resumed = await request('thread/resume', getDefaultResumeParams(threadId, cwd), CODEX_RESUME_TIMEOUT_MS);
3198
+ } catch (err) {
3199
+ if (!previewThread) throw err;
3200
+ activeTurnId = null;
3201
+ sendToClient({ type: 'ready', threadId });
3202
+ sendToClient({
3203
+ type: 'error',
3204
+ message: `History restored, but Codex could not activate this thread yet. ${err.message}`,
3205
+ });
3206
+ return previewThread;
3207
+ }
3131
3208
  activeThreadId = resumed.thread?.id || threadId;
3132
3209
  activeTurnId = null;
3133
3210
  sendToClient({
3134
3211
  type: 'history',
3135
3212
  thread: sanitizeCodexThreadForClient(mergeCodexThreadHistory(readThread, resumed.thread)),
3136
- fallbackItems: loadCodexThreadHistoryFallback(activeThreadId),
3213
+ fallbackItems,
3137
3214
  historyMode: 'resume',
3138
3215
  });
3139
3216
  sendToClient({ type: 'ready', threadId: activeThreadId });
@@ -3157,7 +3234,7 @@ CRITICAL — When you need clarification during planning:
3157
3234
  // Always resume before compacting. The Codex panel can hydrate a thread into
3158
3235
  // the UI via thread/read without the app-server treating it as the live
3159
3236
  // active thread, which leads to "Thread not found" on compact.
3160
- const resumed = await request('thread/resume', getDefaultResumeParams(targetThreadId, cwd));
3237
+ const resumed = await request('thread/resume', getDefaultResumeParams(targetThreadId, cwd), CODEX_RESUME_TIMEOUT_MS);
3161
3238
  const liveThreadId = resumed.thread?.id || targetThreadId;
3162
3239
  activeThreadId = liveThreadId;
3163
3240
  await request('thread/compact/start', { threadId: liveThreadId || targetThreadId }, 10000);
@@ -3199,10 +3276,11 @@ CRITICAL — When you need clarification during planning:
3199
3276
  if (activeTurnId) {
3200
3277
  throw new Error('Codex is already processing a turn');
3201
3278
  }
3279
+ activeTurnAutoAccept = !!opts.autoAccept;
3202
3280
  let threadId = opts.fresh ? null : (opts.threadId || activeThreadId);
3203
3281
  let startedFreshThread = false;
3204
3282
  if (threadId && threadId !== activeThreadId) {
3205
- const resumed = await request('thread/resume', getDefaultResumeParams(threadId, cwd));
3283
+ const resumed = await request('thread/resume', getDefaultResumeParams(threadId, cwd), CODEX_RESUME_TIMEOUT_MS);
3206
3284
  activeThreadId = resumed.thread?.id || threadId;
3207
3285
  threadId = activeThreadId;
3208
3286
  }
@@ -3211,6 +3289,7 @@ CRITICAL — When you need clarification during planning:
3211
3289
  threadId = startedThread?.id || null;
3212
3290
  startedFreshThread = true;
3213
3291
  }
3292
+ const isPlanModeTurn = !!opts.planMode || isCodexPlanModePrompt(prompt);
3214
3293
  const isSlashCommand = /^\/\w/.test(String(prompt || '').trim());
3215
3294
  const shouldInjectGreeting = !!threadId && !isSlashCommand && (startedFreshThread || pendingGreetingThreads.has(threadId));
3216
3295
  const turnPrompt = shouldInjectGreeting
@@ -3226,7 +3305,7 @@ CRITICAL — When you need clarification during planning:
3226
3305
  cwd,
3227
3306
  approvalPolicy: getCachedConfigValue('approval_policy', 'never'),
3228
3307
  personality: getCachedConfigValue('personality', 'pragmatic'),
3229
- sandboxPolicy: buildCodexSandboxPolicy(cwd, getCachedConfigValue('sandbox_mode')),
3308
+ sandboxPolicy: buildCodexSandboxPolicy(cwd, isPlanModeTurn ? 'read-only' : getCachedConfigValue('sandbox_mode')),
3230
3309
  input,
3231
3310
  };
3232
3311
  if (Array.isArray(opts.localImages) && opts.localImages.length) {
@@ -3237,9 +3316,14 @@ CRITICAL — When you need clarification during planning:
3237
3316
  else if (getCachedConfigValue('model')) turnParams.model = getCachedConfigValue('model');
3238
3317
  if (opts.effort) turnParams.effort = opts.effort;
3239
3318
  else if (getCachedConfigValue('model_reasoning_effort')) turnParams.effort = getCachedConfigValue('model_reasoning_effort');
3240
- const result = await request('turn/start', turnParams, 15000);
3241
- activeTurnId = result.turn?.id || activeTurnId;
3242
- sendToClient({ type: 'turn', turn: result.turn });
3319
+ try {
3320
+ const result = await request('turn/start', turnParams, 15000);
3321
+ activeTurnId = result.turn?.id || activeTurnId;
3322
+ sendToClient({ type: 'turn', turn: result.turn });
3323
+ } catch (err) {
3324
+ activeTurnAutoAccept = false;
3325
+ throw err;
3326
+ }
3243
3327
  }
3244
3328
 
3245
3329
  ws.on('message', async (raw) => {
@@ -3251,7 +3335,15 @@ CRITICAL — When you need clarification during planning:
3251
3335
  }
3252
3336
 
3253
3337
  try {
3254
- if (msg.sessionId) wsSessionId = String(msg.sessionId);
3338
+ const incomingSessionId = msg.sessionId ? String(msg.sessionId) : null;
3339
+ if (incomingSessionId) {
3340
+ if (wsSessionId && wsSessionId !== incomingSessionId) {
3341
+ console.warn(`[codex-skin] Ignoring message for mismatched session ${incomingSessionId} (bound=${wsSessionId}, type=${msg.type || 'unknown'})`);
3342
+ sendToClient({ type: 'error', requestId: msg.requestId || null, message: 'Codex message ignored: session mismatch' });
3343
+ return;
3344
+ }
3345
+ wsSessionId = incomingSessionId;
3346
+ }
3255
3347
  if (msg.type === 'heartbeat') {
3256
3348
  return; // no-op — TCP activity from the message itself keeps the WS alive
3257
3349
  }
@@ -3260,6 +3352,7 @@ CRITICAL — When you need clarification during planning:
3260
3352
  if (!wid) { sendToClient({ type: 'reattach_result', ok: false }); return; }
3261
3353
  wsWindowId = wid;
3262
3354
  const okey = _codexOrphanKey(wid, wsSessionId);
3355
+ if (!okey) { sendToClient({ type: 'reattach_result', ok: false }); return; }
3263
3356
  const orphan = _codexOrphanedProcs.get(okey);
3264
3357
  if (!orphan || !orphan.child || orphan.child.killed || orphan.child.exitCode !== null) {
3265
3358
  sendToClient({ type: 'reattach_result', ok: false });
@@ -3424,6 +3517,8 @@ CRITICAL — When you need clarification during planning:
3424
3517
  effort: msg.effort || null,
3425
3518
  localImages,
3426
3519
  mentions: Array.isArray(msg.mentions) ? msg.mentions : [],
3520
+ planMode: !!msg.planMode,
3521
+ autoAccept: !!msg.autoAccept,
3427
3522
  });
3428
3523
  return;
3429
3524
  }
@@ -3670,6 +3765,7 @@ CRITICAL — When you need clarification during planning:
3670
3765
  return;
3671
3766
  }
3672
3767
  if (msg.type === 'interrupt') {
3768
+ if (!validateCodexControlMessage(msg, 'interrupt')) return;
3673
3769
  const threadToInterrupt = msg.threadId || activeThreadId;
3674
3770
  const turnToInterrupt = activeTurnId;
3675
3771
  let rpcOk = false;
@@ -3689,14 +3785,7 @@ CRITICAL — When you need clarification during planning:
3689
3785
  if (!rpcOk) {
3690
3786
  // No active turn id OR interrupt RPC failed — force-kill the child so
3691
3787
  // the run actually stops instead of hanging.
3692
- if (child) {
3693
- console.log('[codex-skin] Force-killing Codex app-server (interrupt fallback)');
3694
- const proc = child;
3695
- try { proc.kill('SIGTERM'); } catch {}
3696
- setTimeout(() => {
3697
- try { if (!proc.killed && proc.exitCode === null) proc.kill('SIGKILL'); } catch {}
3698
- }, 2000);
3699
- }
3788
+ forceKillCodexChild('interrupt fallback');
3700
3789
  // Reject all in-flight RPCs so the client doesn't hang waiting.
3701
3790
  for (const key of Array.from(pending.keys())) {
3702
3791
  const entry = clearPendingRequest(key);
@@ -3711,14 +3800,8 @@ CRITICAL — When you need clarification during planning:
3711
3800
  return;
3712
3801
  }
3713
3802
  if (msg.type === 'force_kill') {
3714
- if (child) {
3715
- console.log('[codex-skin] Force-killing Codex app-server process');
3716
- const proc = child;
3717
- try { proc.kill('SIGTERM'); } catch {}
3718
- // Escalate to SIGKILL after 2s if process survives SIGTERM
3719
- setTimeout(() => {
3720
- try { if (!proc.killed && proc.exitCode === null) proc.kill('SIGKILL'); } catch {}
3721
- }, 2000);
3803
+ if (!validateCodexControlMessage(msg, 'force_kill')) return;
3804
+ if (forceKillCodexChild('force_kill')) {
3722
3805
  // Reject all in-flight RPCs so the client doesn't hang waiting.
3723
3806
  for (const key of Array.from(pending.keys())) {
3724
3807
  const entry = clearPendingRequest(key);
@@ -3770,37 +3853,41 @@ CRITICAL — When you need clarification during planning:
3770
3853
  ws.on('close', () => {
3771
3854
  if (child && child.exitCode == null && !child.killed && wsWindowId) {
3772
3855
  const okey = _codexOrphanKey(wsWindowId, wsSessionId);
3773
- console.log(`[codex-skin] WS closed — orphaning process pid ${child.pid} for ${okey} (${CODEX_ORPHAN_GRACE_MS / 1000}s grace)`);
3774
- _codexOrphanBuffer = [];
3775
- const orphan = {
3776
- child,
3777
- initialized,
3778
- activeThreadId,
3779
- activeTurnId,
3780
- nextRequestId,
3781
- pendingGreetingThreads: new Set(pendingGreetingThreads),
3782
- cachedConfig: { ..._cachedConfig },
3783
- pending: new Map(pending),
3784
- pendingServerRequests: new Set(pendingServerRequests),
3785
- buffer: _codexOrphanBuffer,
3786
- swapWs(newWs) {
3787
- ws = newWs;
3788
- _codexOrphanBuffer = null;
3789
- },
3790
- kill() {
3791
- shuttingDown = true;
3792
- if (child && child.exitCode == null && !child.killed) {
3793
- try { child.kill(); } catch {}
3794
- }
3795
- },
3796
- killTimer: setTimeout(() => {
3797
- console.log(`[codex-skin] Orphan grace expired for ${okey} — killing pid ${orphan.child?.pid}`);
3798
- orphan.kill();
3799
- _codexOrphanedProcs.delete(okey);
3800
- }, CODEX_ORPHAN_GRACE_MS),
3801
- };
3802
- _codexOrphanedProcs.set(okey, orphan);
3803
- return;
3856
+ if (!okey) {
3857
+ console.warn('[codex-skin] WS closed without a session id; killing process instead of creating a window-wide orphan');
3858
+ } else {
3859
+ console.log(`[codex-skin] WS closed — orphaning process pid ${child.pid} for ${okey} (${CODEX_ORPHAN_GRACE_MS / 1000}s grace)`);
3860
+ _codexOrphanBuffer = [];
3861
+ const orphan = {
3862
+ child,
3863
+ initialized,
3864
+ activeThreadId,
3865
+ activeTurnId,
3866
+ nextRequestId,
3867
+ pendingGreetingThreads: new Set(pendingGreetingThreads),
3868
+ cachedConfig: { ..._cachedConfig },
3869
+ pending: new Map(pending),
3870
+ pendingServerRequests: new Set(pendingServerRequests),
3871
+ buffer: _codexOrphanBuffer,
3872
+ swapWs(newWs) {
3873
+ ws = newWs;
3874
+ _codexOrphanBuffer = null;
3875
+ },
3876
+ kill() {
3877
+ shuttingDown = true;
3878
+ if (child && child.exitCode == null && !child.killed) {
3879
+ try { child.kill(); } catch {}
3880
+ }
3881
+ },
3882
+ killTimer: setTimeout(() => {
3883
+ console.log(`[codex-skin] Orphan grace expired for ${okey} — killing pid ${orphan.child?.pid}`);
3884
+ orphan.kill();
3885
+ _codexOrphanedProcs.delete(okey);
3886
+ }, CODEX_ORPHAN_GRACE_MS),
3887
+ };
3888
+ _codexOrphanedProcs.set(okey, orphan);
3889
+ return;
3890
+ }
3804
3891
  }
3805
3892
  shuttingDown = true;
3806
3893
  if (child && child.exitCode == null && !child.killed) {
@@ -4253,6 +4340,29 @@ function startOpencodeSSERelay() {
4253
4340
  _ocpSseReader = resp.body.getReader();
4254
4341
  const decoder = new TextDecoder();
4255
4342
  let buffer = '';
4343
+ let eventType = 'message';
4344
+ let dataLines = [];
4345
+
4346
+ const flushSseEvent = () => {
4347
+ if (!dataLines.length) {
4348
+ eventType = 'message';
4349
+ return;
4350
+ }
4351
+ const raw = dataLines.join('\n');
4352
+ const currentType = eventType || 'message';
4353
+ dataLines = [];
4354
+ eventType = 'message';
4355
+ try {
4356
+ const parsed = JSON.parse(raw);
4357
+ // Unwrap GlobalEvent envelope: { directory, payload: { type, properties } }
4358
+ const payload = parsed?.payload;
4359
+ const evType = payload?.type || parsed?.type || currentType;
4360
+ const evData = payload?.properties || parsed?.properties || parsed;
4361
+ broadcastToOpencodeClients({ type: 'event', eventType: evType, event: evData });
4362
+ } catch {
4363
+ broadcastToOpencodeClients({ type: 'event', eventType: currentType, event: raw });
4364
+ }
4365
+ };
4256
4366
 
4257
4367
  while (true) {
4258
4368
  const { done, value } = await _ocpSseReader.read();
@@ -4260,30 +4370,27 @@ function startOpencodeSSERelay() {
4260
4370
  buffer += decoder.decode(value, { stream: true });
4261
4371
  const lines = buffer.split('\n');
4262
4372
  buffer = lines.pop() || '';
4263
- let eventType = 'message';
4264
- let dataLines = [];
4265
4373
  for (const line of lines) {
4266
- if (line.startsWith('event: ')) {
4267
- eventType = line.slice(7).trim();
4268
- } else if (line.startsWith('data: ')) {
4269
- dataLines.push(line.slice(6));
4270
- } else if (line === '' && dataLines.length > 0) {
4271
- const raw = dataLines.join('\n');
4272
- try {
4273
- const parsed = JSON.parse(raw);
4274
- // Unwrap GlobalEvent envelope: { directory, payload: { type, properties } }
4275
- const payload = parsed?.payload;
4276
- const evType = payload?.type || eventType;
4277
- const evData = payload?.properties || parsed;
4278
- broadcastToOpencodeClients({ type: 'event', eventType: evType, event: evData });
4279
- } catch {
4280
- broadcastToOpencodeClients({ type: 'event', eventType, event: raw });
4281
- }
4282
- dataLines = [];
4283
- eventType = 'message';
4374
+ const clean = line.endsWith('\r') ? line.slice(0, -1) : line;
4375
+ if (clean === '') {
4376
+ flushSseEvent();
4377
+ } else if (clean.startsWith('event:')) {
4378
+ eventType = clean.slice(6).trim() || 'message';
4379
+ } else if (clean.startsWith('data:')) {
4380
+ dataLines.push(clean.slice(5).replace(/^ /, ''));
4381
+ } else if (clean.startsWith(':')) {
4382
+ // SSE comment/heartbeat.
4284
4383
  }
4285
4384
  }
4286
4385
  }
4386
+ if (buffer || dataLines.length) {
4387
+ if (buffer) {
4388
+ const clean = buffer.endsWith('\r') ? buffer.slice(0, -1) : buffer;
4389
+ if (clean.startsWith('data:')) dataLines.push(clean.slice(5).replace(/^ /, ''));
4390
+ else if (clean.startsWith('event:')) eventType = clean.slice(6).trim() || 'message';
4391
+ }
4392
+ flushSseEvent();
4393
+ }
4287
4394
  } catch (err) {
4288
4395
  if (err.name !== 'AbortError') {
4289
4396
  console.error(`[opencode] SSE relay error: ${err.message}`);
@@ -4494,6 +4601,16 @@ async function ocpProxy(method, path, body = null, timeoutMs = 30000) {
4494
4601
  return { status: resp.status, data };
4495
4602
  }
4496
4603
 
4604
+ async function ocpProxyFirstSupported(method, paths, body = null, timeoutMs = 30000) {
4605
+ let last = null;
4606
+ for (const path of paths) {
4607
+ const r = await ocpProxy(method, path, body, timeoutMs);
4608
+ last = r;
4609
+ if (r.status !== 404 && r.status !== 405) return r;
4610
+ }
4611
+ return last || { status: 404, data: { error: 'No supported OpenCode endpoint found' } };
4612
+ }
4613
+
4497
4614
  // ── WebSocket handler: /ws/opencode-skin ──
4498
4615
 
4499
4616
  function handleOpencodeWs(ws) {
@@ -4546,7 +4663,11 @@ function handleOpencodeWs(ws) {
4546
4663
  break;
4547
4664
  }
4548
4665
  case 'session:create': {
4549
- const r = await ocpProxy('POST', '/session', msg.body || {});
4666
+ const input = (msg.body && typeof msg.body === 'object') ? msg.body : {};
4667
+ const body = {};
4668
+ if (input.title) body.title = input.title;
4669
+ if (input.parentID || input.parentId) body.parentID = input.parentID || input.parentId;
4670
+ const r = await ocpProxy('POST', '/session', body);
4550
4671
  const newSid = r?.data?.id || r?.data?.sessionID || r?.data?.info?.id || null;
4551
4672
  if (newSid) pendingGreetingSessions.set(newSid, msg.cwd || PACKAGE_ROOT);
4552
4673
  sendToClient({ type: 'session:create:result', id, ...r });
@@ -4684,21 +4805,35 @@ function handleOpencodeWs(ws) {
4684
4805
  break;
4685
4806
  }
4686
4807
  case 'question:reply': {
4687
- const r = await ocpProxy('POST', `/question/${msg.requestID}/reply`, msg.body || { answers: [] });
4808
+ const requestID = encodeURIComponent(String(msg.requestID || ''));
4809
+ const r = await ocpProxyFirstSupported('POST', [
4810
+ `/question/${requestID}`,
4811
+ `/question/${requestID}/reply`,
4812
+ ], msg.body || { answers: [] });
4688
4813
  sendToClient({ type: 'question:reply:result', id, ...r });
4689
4814
  break;
4690
4815
  }
4691
4816
  case 'question:reject': {
4692
- const r = await ocpProxy('POST', `/question/${msg.requestID}/reject`);
4817
+ const requestID = encodeURIComponent(String(msg.requestID || ''));
4818
+ const r = await ocpProxyFirstSupported('POST', [
4819
+ `/question/${requestID}/reject`,
4820
+ `/question/${requestID}`,
4821
+ ], msg.body || { answers: [] });
4693
4822
  sendToClient({ type: 'question:reject:result', id, ...r });
4694
4823
  break;
4695
4824
  }
4696
4825
  case 'permission:respond': {
4697
4826
  const sid = msg.sessionId || msg.sessionID;
4698
4827
  const permId = msg.permissionID || msg.permissionId || msg.requestID;
4828
+ const encodedPermId = encodeURIComponent(String(permId || ''));
4829
+ const encodedSid = encodeURIComponent(String(sid || ''));
4699
4830
  const body = { response: msg.response || 'once' };
4700
4831
  if (typeof msg.remember === 'boolean') body.remember = msg.remember;
4701
- const r = await ocpProxy('POST', `/session/${sid}/permissions/${permId}`, body);
4832
+ const r = await ocpProxyFirstSupported('POST', [
4833
+ `/permission/${encodedPermId}`,
4834
+ `/permission/${encodedPermId}/respond`,
4835
+ `/session/${encodedSid}/permissions/${encodedPermId}`,
4836
+ ], body);
4702
4837
  sendToClient({ type: 'permission:respond:result', id, ...r });
4703
4838
  break;
4704
4839
  }
@@ -8112,6 +8247,35 @@ app.get('/api/loop/active', (req, res) => {
8112
8247
  }
8113
8248
  });
8114
8249
 
8250
+ // GET /api/loop/log/:terminalSessionId — fetch raw per-loop logfile (text)
8251
+ app.get('/api/loop/log/:terminalSessionId', (req, res) => {
8252
+ try {
8253
+ const tsid = req.params.terminalSessionId;
8254
+ if (!tsid || !/^[a-f0-9]{32}$/i.test(tsid)) {
8255
+ return res.status(400).json({ error: 'invalid terminalSessionId' });
8256
+ }
8257
+ const p = loopLogPath(tsid);
8258
+ if (!p || !existsSync(p)) {
8259
+ return res.status(404).type('text/plain').send(`No log file for ${tsid} (yet?)`);
8260
+ }
8261
+ res.type('text/plain').send(readFileSync(p, 'utf-8'));
8262
+ } catch (err) {
8263
+ res.status(500).json({ error: err.message });
8264
+ }
8265
+ });
8266
+
8267
+ // POST /api/loop/client-log — accept browser-side logs, append to per-loop file
8268
+ app.post('/api/loop/client-log', (req, res) => {
8269
+ try {
8270
+ const { terminalSessionId, tag, msg, meta } = req.body || {};
8271
+ if (!tag || !msg) return res.status(400).json({ error: 'tag and msg required' });
8272
+ loopLog(terminalSessionId || null, `ui:${tag}`, msg, meta || undefined);
8273
+ res.json({ ok: true });
8274
+ } catch (err) {
8275
+ res.status(500).json({ error: err.message });
8276
+ }
8277
+ });
8278
+
8115
8279
  // POST /api/loop/launch — create loop state + terminal session atomically
8116
8280
  app.post('/api/loop/launch', async (req, res) => {
8117
8281
  try {
@@ -8125,6 +8289,21 @@ app.post('/api/loop/launch', async (req, res) => {
8125
8289
  return res.status(400).json({ error: `Invalid profile: ${cliProfile}. Valid: ${validProfiles.join(', ')}` });
8126
8290
  }
8127
8291
 
8292
+ // Log incoming launch request (terminalSessionId not yet generated — log to console only,
8293
+ // file logging starts after we mint the id below)
8294
+ loopLog(null, 'launch:request', 'POST /api/loop/launch received', {
8295
+ profile: cliProfile,
8296
+ taskLen: task.trim().length,
8297
+ contextLen: context?.trim()?.length || 0,
8298
+ iterations: iterations || 10,
8299
+ maxMinutes: maxMinutes || 30,
8300
+ usesBrowser: !!usesBrowser,
8301
+ cwd: cwd || null,
8302
+ model: model || null,
8303
+ effort: effort || null,
8304
+ requestedBrowserSessionId: requestedBrowserSessionId || null,
8305
+ });
8306
+
8128
8307
  // 1. Ensure loop directory exists
8129
8308
  if (!existsSync(LOOP_DIR)) mkdirSync(LOOP_DIR, { recursive: true });
8130
8309
 
@@ -8144,13 +8323,13 @@ app.post('/api/loop/launch', async (req, res) => {
8144
8323
  } catch { /* skip corrupt */ }
8145
8324
  }
8146
8325
 
8147
- // 1c. Acquire browser for this loop — reuse existing system browser session.
8148
- // Opens a new TAB in the shared CDP session instead of creating isolated sessions.
8149
- // Generate terminalSessionId early so we can mark loop ownership immediately after
8150
- // acquiring a session (before any async tab-creation awaits that create a TOCTOU window).
8326
+ // 1c. Acquire the shared browser session and create a fresh tab for this loop.
8327
+ // Automations share the same browser/profile, but each loop owns a distinct
8328
+ // tabId and all browser tools are pinned to that tab.
8151
8329
  let browserSessionId = null;
8152
8330
  let browserTabId = null;
8153
8331
  const terminalSessionId = randomBytes(16).toString('hex');
8332
+ loopLog(terminalSessionId, 'launch:id', 'minted terminalSessionId', { terminalSessionId });
8154
8333
  if (usesBrowser) {
8155
8334
  // Strategy 0: Best-effort pin to the caller-provided browser session.
8156
8335
  if (requestedBrowserSessionId) {
@@ -8159,67 +8338,79 @@ app.post('/api/loop/launch', async (req, res) => {
8159
8338
  try {
8160
8339
  await requested.page.evaluate('1');
8161
8340
  browserSessionId = requestedBrowserSessionId;
8162
- console.log(`[loop] Using requested browser session ${browserSessionId}`);
8341
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 0: pinned to requested browser', { browserSessionId });
8163
8342
  } catch (err) {
8164
- console.warn(`[loop] Requested browser session ${requestedBrowserSessionId} is unhealthy — destroying`);
8343
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 0 failed: requested browser unhealthy — destroying', { requestedBrowserSessionId, err: err.message });
8165
8344
  await destroyBrowserSession(requestedBrowserSessionId).catch(() => {});
8166
8345
  }
8167
8346
  } else {
8168
- console.warn(`[loop] Requested browser session ${requestedBrowserSessionId} not found — falling back to shared discovery`);
8347
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 0 skipped: requested browser not in registry', { requestedBrowserSessionId });
8169
8348
  }
8170
8349
  }
8171
8350
 
8172
- // Strategy 1: Reuse an existing healthy browser session
8351
+ // Strategy 1: Reuse the existing shared browser session, even if another
8352
+ // loop owns a different tab in it. Agent-owned sessions stay excluded.
8173
8353
  if (!browserSessionId) {
8174
- const reusable = await findReusableBrowserSession({ excludeAgentOwned: true });
8354
+ const reusable = await findReusableBrowserSession({ excludeAgentOwned: true, excludeLoopOwned: false });
8175
8355
  if (reusable) {
8176
8356
  browserSessionId = reusable.id;
8177
- console.log(`[loop] Reusing existing browser session ${browserSessionId}`);
8357
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 1: reusing shared browser session', { browserSessionId });
8358
+ } else {
8359
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 1: no reusable shared browser session found');
8178
8360
  }
8179
8361
  }
8180
8362
 
8181
- // Strategy 2: No existing session — create one using the selected browser profile
8363
+ // Strategy 2: No shared browser exists — create one using the selected browser profile.
8182
8364
  if (!browserSessionId) {
8183
8365
  try {
8184
8366
  const result = await createBrowserSession({ url: 'about:blank' });
8185
8367
  browserSessionId = result.sessionId;
8186
- console.log(`[loop] Created shared browser session ${browserSessionId}`);
8368
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 2: created shared browser session', {
8369
+ browserSessionId,
8370
+ profileMode: result.profileMode,
8371
+ profileSource: result.profileSource,
8372
+ });
8187
8373
  broadcastSync({
8188
8374
  type: 'browser:session-created',
8189
8375
  sessionId: browserSessionId, url: 'about:blank',
8190
8376
  profileMode: result.profileMode, profileSource: result.profileSource,
8191
8377
  });
8192
8378
  } catch (err) {
8193
- console.warn(`[loop] Failed to create browser session: ${err.message}`);
8379
+ loopLog(terminalSessionId, 'launch:browser', 'strategy 2 FAILED: createBrowserSession threw', { err: err.message, stack: err.stack?.slice(0, 600) });
8194
8380
  }
8195
8381
  }
8196
8382
 
8197
8383
  if (!browserSessionId) {
8384
+ loopLog(terminalSessionId, 'launch:browser', 'ABORT: no browser available after all strategies — returning 500');
8198
8385
  return res.status(500).json({ error: 'Could not start browser session for this loop. Open Browser Settings and verify the selected profile can launch.' });
8199
8386
  }
8200
8387
 
8201
- // Mark loop-owned IMMEDIATELY after acquisition — before any await points
8202
- // (tab creation, tab switching) that could let a concurrent launch steal this session.
8388
+ // Mark loop-owned immediately after acquisition. The session can have
8389
+ // multiple loop owners, one per tab.
8203
8390
  const claimedSession = browserSessions.get(browserSessionId);
8204
- if (claimedSession) claimedSession._loopOwned.add(terminalSessionId);
8391
+ if (claimedSession) {
8392
+ claimedSession._loopOwned.add(terminalSessionId);
8393
+ }
8205
8394
  if (claimedSession?.graceTimer) {
8206
8395
  clearTimeout(claimedSession.graceTimer);
8207
8396
  claimedSession.graceTimer = null;
8208
8397
  }
8209
8398
 
8210
- // Open a new tab in the shared session for this loop
8211
8399
  if (claimedSession?.tabs && claimedSession.tabs.size > 0) {
8212
8400
  try {
8213
8401
  const newTab = await createSessionTab(claimedSession, browserSessionId, 'about:blank');
8214
8402
  browserTabId = newTab.tabId;
8215
- await _switchSessionTab(claimedSession, browserSessionId, browserTabId);
8216
- console.log(`[loop] Created new tab ${browserTabId} in session ${browserSessionId}`);
8403
+ // Do NOT call _switchSessionTab here. Each loop pins its own tab via SYNABUN_BROWSER_TAB env;
8404
+ // switching the shared session's active tab pollutes other concurrent loops.
8405
+ loopLog(terminalSessionId, 'launch:browser', 'created new loop tab in shared session', { browserSessionId, browserTabId });
8217
8406
  } catch (err) {
8218
- console.warn(`[loop] Failed to create new tab, reusing active tab:`, err.message);
8219
- browserTabId = claimedSession.activeTabId;
8407
+ claimedSession?._loopOwned?.delete(terminalSessionId);
8408
+ loopLog(terminalSessionId, 'launch:browser', 'ABORT: could not create loop tab', { err: err.message, browserSessionId });
8409
+ return res.status(500).json({ error: `Could not create a new browser tab for this loop: ${err.message}` });
8220
8410
  }
8221
8411
  } else {
8222
8412
  browserTabId = claimedSession?.activeTabId || null;
8413
+ loopLog(terminalSessionId, 'launch:browser', 'using first tab in new shared session', { browserSessionId, browserTabId });
8223
8414
  }
8224
8415
 
8225
8416
  }
@@ -8250,6 +8441,7 @@ app.post('/api/loop/launch', async (req, res) => {
8250
8441
  cwd: loopCwd,
8251
8442
  };
8252
8443
  writeFileSync(resolve(LOOP_DIR, `${pendingId}.json`), JSON.stringify(loopState, null, 2));
8444
+ loopLog(terminalSessionId, 'launch:state', 'wrote pending loop file', { pendingId, loopFile: `${pendingId}.json`, loopState });
8253
8445
 
8254
8446
  // 3. Create terminal PTY session + attach appropriate loop driver.
8255
8447
  // SYNABUN_TERMINAL_SESSION lets hooks correlate with the correct loop file
@@ -8260,23 +8452,36 @@ app.post('/api/loop/launch', async (req, res) => {
8260
8452
  // Claude Code uses ToolSearch for deferred loading — always give it the full profile
8261
8453
  if (cliProfile === 'claude-code') loopExtraEnv.SYNABUN_PROFILE = 'full';
8262
8454
 
8455
+ loopLog(terminalSessionId, 'launch:env', 'extraEnv prepared for spawned PTY', {
8456
+ keys: Object.keys(loopExtraEnv),
8457
+ SYNABUN_TERMINAL_SESSION: loopExtraEnv.SYNABUN_TERMINAL_SESSION,
8458
+ SYNABUN_BROWSER_SESSION: loopExtraEnv.SYNABUN_BROWSER_SESSION || null,
8459
+ SYNABUN_BROWSER_TAB: loopExtraEnv.SYNABUN_BROWSER_TAB || null,
8460
+ });
8461
+
8263
8462
  if (cliProfile === 'claude-code') {
8264
8463
  // Claude Code: spawn interactive CLI, hook-driven iteration transitions (/clear + re-prompt)
8464
+ loopLog(terminalSessionId, 'launch:spawn', 'creating claude-code PTY session', { profile: cliProfile, cwd: loopCwd, model, effort });
8265
8465
  createTerminalSession(cliProfile, 120, 30, loopCwd, { model, effort, extraEnv: loopExtraEnv, sessionId: terminalSessionId });
8466
+ loopLog(terminalSessionId, 'launch:driver', 'attaching hook-driven loop driver (claude-code)');
8266
8467
  attachLoopDriver(terminalSessionId);
8267
8468
  } else {
8268
8469
  // Codex/Gemini/other: spawn shell, server drives `<cli> exec` commands per iteration.
8269
8470
  // Sentinel echo approach: driver sends `<cmd>; echo SYNABUN_ITER_DONE_<N>`
8270
8471
  // and detects the echo in PTY output. Immune to PS1 overrides.
8472
+ loopLog(terminalSessionId, 'launch:spawn', 'creating shell PTY for exec-driven CLI', { profile: cliProfile, cwd: loopCwd });
8271
8473
  createTerminalSession('shell', 120, 30, loopCwd, { extraEnv: loopExtraEnv, sessionId: terminalSessionId });
8474
+ loopLog(terminalSessionId, 'launch:driver', 'attaching exec loop driver (codex/gemini/other)', { profile: cliProfile });
8272
8475
  attachExecLoopDriver(terminalSessionId);
8273
8476
  }
8274
8477
 
8275
8478
  broadcastSync({ type: 'terminal:session-created', sessionId: terminalSessionId, profile: cliProfile });
8276
8479
 
8277
- res.json({ ok: true, pendingId, terminalSessionId, browserSessionId, browserTabId: loopState.browserTabId });
8480
+ const responsePayload = { ok: true, pendingId, terminalSessionId, browserSessionId, browserTabId: loopState.browserTabId };
8481
+ loopLog(terminalSessionId, 'launch:response', 'sending 200 response', responsePayload);
8482
+ res.json(responsePayload);
8278
8483
  } catch (err) {
8279
- console.error('POST /api/loop/launch error:', err.message);
8484
+ loopLog(null, 'launch:error', 'POST /api/loop/launch threw', { err: err.message, stack: err.stack?.slice(0, 800) });
8280
8485
  res.status(500).json({ error: err.message });
8281
8486
  }
8282
8487
  });
@@ -8302,28 +8507,22 @@ app.post('/api/loop/stop', async (req, res) => {
8302
8507
  try { session.pty.kill(); } catch {}
8303
8508
  terminalSessions.delete(data.terminalSessionId);
8304
8509
  }
8305
- // Remove loop ownership marker before closing tab
8306
8510
  if (data.browserSessionId && browserSessions.has(data.browserSessionId)) {
8307
- browserSessions.get(data.browserSessionId)?._loopOwned?.delete(data.terminalSessionId);
8308
- }
8309
- // Close the loop's browser tab but keep the shared session alive.
8310
- // The system browser persists for the next loop or manual use.
8311
- if (data.browserSessionId && data.browserTabId && browserSessions.has(data.browserSessionId)) {
8312
8511
  const bSession = browserSessions.get(data.browserSessionId);
8313
- if (bSession?.tabs?.has(data.browserTabId) && bSession.tabs.size > 1) {
8314
- // Only close the tab if there's more than one (don't close the last tab)
8512
+ bSession?._loopOwned?.delete(data.terminalSessionId);
8513
+ if (data.browserTabId && bSession?.tabs?.has(data.browserTabId) && bSession.tabs.size > 1) {
8514
+ // Close only this loop's tab and keep the shared browser session alive.
8315
8515
  try {
8316
8516
  const tab = bSession.tabs.get(data.browserTabId);
8317
8517
  if (tab?.page) await tab.page.close().catch(() => {});
8318
8518
  bSession.tabs.delete(data.browserTabId);
8319
- // Switch to another tab if we closed the active one
8320
8519
  if (bSession.activeTabId === data.browserTabId) {
8321
8520
  const nextTabId = bSession.tabs.keys().next().value;
8322
8521
  if (nextTabId) _switchSessionTab(bSession, data.browserSessionId, nextTabId).catch(() => {});
8323
8522
  }
8324
- console.log(`[loop/stop] Closed tab ${data.browserTabId} in session ${data.browserSessionId}`);
8523
+ console.log(`[loop/stop] Closed loop tab ${data.browserTabId} in shared session ${data.browserSessionId}`);
8325
8524
  } catch (err) {
8326
- console.warn(`[loop/stop] Failed to close tab: ${err.message}`);
8525
+ console.warn(`[loop/stop] Failed to close loop tab: ${err.message}`);
8327
8526
  }
8328
8527
  }
8329
8528
  }
@@ -8386,18 +8585,65 @@ app.post('/api/loop/complete', async (req, res) => {
8386
8585
  // at scheduled times, respecting timezones and day themes.
8387
8586
  // ═══════════════════════════════════════════════════════════════
8388
8587
 
8389
- function loadSchedules() {
8588
+ function loadScheduleStore() {
8390
8589
  try {
8391
8590
  if (existsSync(LOOP_SCHEDULES_PATH)) {
8392
8591
  const data = JSON.parse(readFileSync(LOOP_SCHEDULES_PATH, 'utf-8'));
8393
- return data.schedules || [];
8592
+ if (Array.isArray(data)) return { schedules: data, groups: [] };
8593
+ return {
8594
+ schedules: Array.isArray(data.schedules) ? data.schedules : [],
8595
+ groups: Array.isArray(data.groups) ? data.groups : [],
8596
+ };
8394
8597
  }
8395
8598
  } catch { /* corrupt */ }
8396
- return [];
8599
+ return { schedules: [], groups: [] };
8600
+ }
8601
+
8602
+ function saveScheduleStore(store) {
8603
+ writeFileSync(LOOP_SCHEDULES_PATH, JSON.stringify({
8604
+ schedules: Array.isArray(store?.schedules) ? store.schedules : [],
8605
+ groups: Array.isArray(store?.groups) ? store.groups : [],
8606
+ }, null, 2));
8607
+ }
8608
+
8609
+ function loadSchedules() {
8610
+ return loadScheduleStore().schedules;
8397
8611
  }
8398
8612
 
8399
8613
  function saveSchedules(schedules) {
8400
- writeFileSync(LOOP_SCHEDULES_PATH, JSON.stringify({ schedules }, null, 2));
8614
+ const store = loadScheduleStore();
8615
+ store.schedules = Array.isArray(schedules) ? schedules : [];
8616
+ saveScheduleStore(store);
8617
+ }
8618
+
8619
+ function loadScheduleGroups() {
8620
+ return loadScheduleStore().groups.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
8621
+ }
8622
+
8623
+ function saveScheduleGroups(groups) {
8624
+ const store = loadScheduleStore();
8625
+ store.groups = Array.isArray(groups) ? groups : [];
8626
+ saveScheduleStore(store);
8627
+ }
8628
+
8629
+ function validateScheduleGroupId(groupId) {
8630
+ if (!groupId) return null;
8631
+ const id = String(groupId);
8632
+ return loadScheduleGroups().some(g => g.id === id) ? id : false;
8633
+ }
8634
+
8635
+ function applyScheduleLaunchOverrides(schedule, source) {
8636
+ const fields = ['profile', 'model', 'effort', 'mcpProfile'];
8637
+ for (const field of fields) {
8638
+ if (source[field] !== undefined) {
8639
+ if (source[field] === null || source[field] === '') delete schedule[field];
8640
+ else schedule[field] = source[field];
8641
+ }
8642
+ }
8643
+ if (source.usesBrowser !== undefined) {
8644
+ if (source.usesBrowser === null) delete schedule.usesBrowser;
8645
+ else schedule.usesBrowser = !!source.usesBrowser;
8646
+ }
8401
8647
  }
8402
8648
 
8403
8649
  // ── Cron Parser ──
@@ -8559,16 +8805,22 @@ async function launchScheduledLoop(schedule) {
8559
8805
  }
8560
8806
  }
8561
8807
 
8562
- // Reuse an existing healthy browser session or create one
8808
+ // Pre-generate terminalSessionId so isolation env vars can carry it.
8809
+ // Without SYNABUN_TERMINAL_SESSION, hooks in the spawned CLI fall back to
8810
+ // legacy "match any loop" logic and leak context between concurrent sessions
8811
+ // (e.g., a sidepanel plan session and a scheduled CLI loop in the same cwd).
8812
+ const terminalSessionId = randomBytes(16).toString('hex');
8813
+
8814
+ // Reuse the shared browser session and create a fresh tab for this scheduled loop.
8563
8815
  let scheduledBrowserSessionId = null;
8564
8816
  let scheduledBrowserTabId = null;
8565
- const schedExtraEnv = {};
8817
+ const schedExtraEnv = { SYNABUN_TERMINAL_SESSION: terminalSessionId };
8818
+ if (cliProfile === 'claude-code') schedExtraEnv.SYNABUN_PROFILE = 'full';
8566
8819
  if (scheduledUsesBrowser) {
8567
- const reusable = await findReusableBrowserSession({ excludeAgentOwned: true });
8820
+ const reusable = await findReusableBrowserSession({ excludeAgentOwned: true, excludeLoopOwned: false });
8568
8821
  if (reusable) {
8569
8822
  scheduledBrowserSessionId = reusable.id;
8570
8823
  }
8571
- // Create if none exists
8572
8824
  if (!scheduledBrowserSessionId) {
8573
8825
  try {
8574
8826
  const result = await createBrowserSession({ url: 'about:blank' });
@@ -8586,24 +8838,27 @@ async function launchScheduledLoop(schedule) {
8586
8838
  if (!scheduledBrowserSessionId) {
8587
8839
  throw new Error('Could not start browser session for scheduled loop');
8588
8840
  }
8589
- // Open a new tab for this scheduled loop
8590
- if (scheduledBrowserSessionId) {
8591
- schedExtraEnv.SYNABUN_BROWSER_SESSION = scheduledBrowserSessionId;
8592
- const bSession = browserSessions.get(scheduledBrowserSessionId);
8593
- if (bSession?.graceTimer) { clearTimeout(bSession.graceTimer); bSession.graceTimer = null; }
8594
- if (bSession?.tabs && bSession.tabs.size > 0) {
8841
+ schedExtraEnv.SYNABUN_BROWSER_SESSION = scheduledBrowserSessionId;
8842
+ const bSession = browserSessions.get(scheduledBrowserSessionId);
8843
+ if (bSession) {
8844
+ bSession._loopOwned.add(terminalSessionId);
8845
+ if (bSession.graceTimer) { clearTimeout(bSession.graceTimer); bSession.graceTimer = null; }
8846
+ if (bSession.tabs && bSession.tabs.size > 0) {
8595
8847
  try {
8596
8848
  const newTab = await createSessionTab(bSession, scheduledBrowserSessionId, 'about:blank');
8597
8849
  scheduledBrowserTabId = newTab.tabId;
8598
- await _switchSessionTab(bSession, scheduledBrowserSessionId, scheduledBrowserTabId);
8599
- } catch { scheduledBrowserTabId = bSession.activeTabId; }
8850
+ // Do NOT switch active tab — would pollute concurrent loops sharing this session.
8851
+ } catch (err) {
8852
+ bSession._loopOwned?.delete(terminalSessionId);
8853
+ throw new Error(`Could not create a new browser tab for scheduled loop: ${err.message}`);
8854
+ }
8855
+ } else {
8856
+ scheduledBrowserTabId = bSession.activeTabId || null;
8600
8857
  }
8601
- if (scheduledBrowserTabId) schedExtraEnv.SYNABUN_BROWSER_TAB = scheduledBrowserTabId;
8602
8858
  }
8859
+ if (scheduledBrowserTabId) schedExtraEnv.SYNABUN_BROWSER_TAB = scheduledBrowserTabId;
8603
8860
  }
8604
8861
 
8605
- const terminalSessionId = createTerminalSession(cliProfile, 120, 30, PACKAGE_ROOT, { model: cliModel, effort: cliEffort, extraEnv: schedExtraEnv });
8606
-
8607
8862
  const pendingId = 'pending-' + randomBytes(8).toString('hex');
8608
8863
  const loopState = {
8609
8864
  active: true,
@@ -8622,11 +8877,23 @@ async function launchScheduledLoop(schedule) {
8622
8877
  browserTabId: scheduledBrowserTabId || null,
8623
8878
  profile: cliProfile,
8624
8879
  model: cliModel,
8880
+ effort: cliEffort || null,
8881
+ driverType: cliProfile === 'claude-code' ? 'hook' : 'exec',
8882
+ cwd: PACKAGE_ROOT,
8625
8883
  scheduledBy: schedule.id,
8626
8884
  };
8627
8885
  writeFileSync(resolve(LOOP_DIR, `${pendingId}.json`), JSON.stringify(loopState, null, 2));
8628
8886
 
8629
- attachLoopDriver(terminalSessionId);
8887
+ // Profile-specific terminal + driver. Claude Code uses the interactive CLI
8888
+ // with hook-driven iteration. Codex/OpenCode/others use a shell with the
8889
+ // exec-mode driver (sentinel-echo based). Mirror regular /api/loop/launch.
8890
+ if (cliProfile === 'claude-code') {
8891
+ createTerminalSession(cliProfile, 120, 30, PACKAGE_ROOT, { model: cliModel, effort: cliEffort, extraEnv: schedExtraEnv, sessionId: terminalSessionId });
8892
+ attachLoopDriver(terminalSessionId);
8893
+ } else {
8894
+ createTerminalSession('shell', 120, 30, PACKAGE_ROOT, { extraEnv: schedExtraEnv, sessionId: terminalSessionId });
8895
+ attachExecLoopDriver(terminalSessionId);
8896
+ }
8630
8897
  broadcastSync({ type: 'terminal:session-created', sessionId: terminalSessionId, profile: cliProfile });
8631
8898
 
8632
8899
  // Update schedule metadata
@@ -8870,6 +9137,113 @@ app.post('/api/quick-timer/now', (req, res) => {
8870
9137
  }
8871
9138
  });
8872
9139
 
9140
+ // ── Schedule Group REST API ──
9141
+
9142
+ app.get('/api/schedule-groups', (_req, res) => {
9143
+ res.json(loadScheduleGroups());
9144
+ });
9145
+
9146
+ app.post('/api/schedule-groups', (req, res) => {
9147
+ try {
9148
+ const { name, color, icon, collapsed } = req.body || {};
9149
+ if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
9150
+
9151
+ const groups = loadScheduleGroups();
9152
+ const group = {
9153
+ id: `sg_${randomBytes(6).toString('hex')}`,
9154
+ name: name.trim(),
9155
+ color: color || null,
9156
+ icon: icon || null,
9157
+ order: groups.length,
9158
+ collapsed: !!collapsed,
9159
+ createdAt: new Date().toISOString(),
9160
+ updatedAt: new Date().toISOString(),
9161
+ };
9162
+ groups.push(group);
9163
+ saveScheduleGroups(groups);
9164
+ broadcastSync({ type: 'schedule-group:created', group });
9165
+ res.json(group);
9166
+ } catch (err) {
9167
+ res.status(500).json({ error: err.message });
9168
+ }
9169
+ });
9170
+
9171
+ app.put('/api/schedule-groups/:id', (req, res) => {
9172
+ try {
9173
+ const groups = loadScheduleGroups();
9174
+ const idx = groups.findIndex(g => g.id === req.params.id);
9175
+ if (idx === -1) return res.status(404).json({ error: 'Group not found' });
9176
+
9177
+ const { name, color, icon, collapsed, order } = req.body || {};
9178
+ if (name !== undefined) {
9179
+ if (!name.trim()) return res.status(400).json({ error: 'name is required' });
9180
+ groups[idx].name = name.trim();
9181
+ }
9182
+ if (color !== undefined) groups[idx].color = color || null;
9183
+ if (icon !== undefined) groups[idx].icon = icon || null;
9184
+ if (collapsed !== undefined) groups[idx].collapsed = !!collapsed;
9185
+ if (order !== undefined && typeof order === 'number') groups[idx].order = order;
9186
+ groups[idx].updatedAt = new Date().toISOString();
9187
+
9188
+ saveScheduleGroups(groups);
9189
+ broadcastSync({ type: 'schedule-group:updated', group: groups[idx] });
9190
+ res.json(groups[idx]);
9191
+ } catch (err) {
9192
+ res.status(500).json({ error: err.message });
9193
+ }
9194
+ });
9195
+
9196
+ app.delete('/api/schedule-groups/:id', (req, res) => {
9197
+ try {
9198
+ const groups = loadScheduleGroups();
9199
+ const idx = groups.findIndex(g => g.id === req.params.id);
9200
+ if (idx === -1) return res.status(404).json({ error: 'Group not found' });
9201
+ groups.splice(idx, 1);
9202
+
9203
+ const schedules = loadSchedules();
9204
+ let unassigned = 0;
9205
+ for (const schedule of schedules) {
9206
+ if (schedule.groupId === req.params.id) {
9207
+ schedule.groupId = null;
9208
+ schedule.updatedAt = new Date().toISOString();
9209
+ unassigned++;
9210
+ }
9211
+ }
9212
+
9213
+ saveScheduleStore({ schedules, groups });
9214
+ broadcastSync({ type: 'schedule-group:deleted', groupId: req.params.id, schedulesUnassigned: unassigned });
9215
+ res.json({ ok: true, id: req.params.id, schedulesUnassigned: unassigned });
9216
+ } catch (err) {
9217
+ res.status(500).json({ error: err.message });
9218
+ }
9219
+ });
9220
+
9221
+ app.post('/api/schedule-groups/reorder', (req, res) => {
9222
+ try {
9223
+ const { orderedIds } = req.body || {};
9224
+ if (!Array.isArray(orderedIds)) return res.status(400).json({ error: 'orderedIds array required' });
9225
+
9226
+ const groups = loadScheduleGroups();
9227
+ const byId = new Map(groups.map(g => [g.id, g]));
9228
+ let order = 0;
9229
+ for (const id of orderedIds) {
9230
+ const group = byId.get(id);
9231
+ if (group) {
9232
+ group.order = order++;
9233
+ group.updatedAt = new Date().toISOString();
9234
+ }
9235
+ }
9236
+ for (const group of groups) {
9237
+ if (!orderedIds.includes(group.id)) group.order = order++;
9238
+ }
9239
+ saveScheduleGroups(groups);
9240
+ broadcastSync({ type: 'schedule-group:reordered', groups });
9241
+ res.json({ ok: true });
9242
+ } catch (err) {
9243
+ res.status(500).json({ error: err.message });
9244
+ }
9245
+ });
9246
+
8873
9247
  // ── Schedule REST API ──
8874
9248
 
8875
9249
  // GET /api/schedules — list all schedules
@@ -8889,7 +9263,7 @@ app.get('/api/schedules/timers', (_req, res) => {
8889
9263
  // POST /api/schedules — create a new schedule
8890
9264
  app.post('/api/schedules', (req, res) => {
8891
9265
  try {
8892
- const { name, templateId, cron, timezone, enabled, dayThemes, overrides } = req.body;
9266
+ const { name, templateId, cron, timezone, enabled, dayThemes, overrides, groupId, profile, model, effort, mcpProfile, usesBrowser } = req.body;
8893
9267
  if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
8894
9268
  if (!templateId?.trim()) return res.status(400).json({ error: 'templateId is required' });
8895
9269
  if (!cron?.trim()) return res.status(400).json({ error: 'cron is required' });
@@ -8897,6 +9271,8 @@ app.post('/api/schedules', (req, res) => {
8897
9271
 
8898
9272
  const templates = readLoopTemplates();
8899
9273
  if (!templates.find(t => t.id === templateId)) return res.status(400).json({ error: 'Template not found' });
9274
+ const normalizedGroupId = validateScheduleGroupId(groupId);
9275
+ if (normalizedGroupId === false) return res.status(400).json({ error: 'Group not found' });
8900
9276
 
8901
9277
  const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
8902
9278
  const schedule = {
@@ -8906,6 +9282,7 @@ app.post('/api/schedules', (req, res) => {
8906
9282
  cron: cron.trim(),
8907
9283
  timezone: tz,
8908
9284
  enabled: enabled !== false,
9285
+ groupId: normalizedGroupId,
8909
9286
  dayThemes: dayThemes || {},
8910
9287
  overrides: overrides || {},
8911
9288
  lastRun: null,
@@ -8915,6 +9292,7 @@ app.post('/api/schedules', (req, res) => {
8915
9292
  createdAt: new Date().toISOString(),
8916
9293
  updatedAt: new Date().toISOString(),
8917
9294
  };
9295
+ applyScheduleLaunchOverrides(schedule, { profile, model, effort, mcpProfile, usesBrowser });
8918
9296
 
8919
9297
  const schedules = loadSchedules();
8920
9298
  schedules.push(schedule);
@@ -8942,7 +9320,7 @@ app.put('/api/schedules/:id', (req, res) => {
8942
9320
  const idx = schedules.findIndex(s => s.id === req.params.id);
8943
9321
  if (idx === -1) return res.status(404).json({ error: 'Schedule not found' });
8944
9322
 
8945
- const { name, templateId, cron, timezone, enabled, dayThemes, overrides } = req.body;
9323
+ const { name, templateId, cron, timezone, enabled, dayThemes, overrides, groupId, profile, model, effort, mcpProfile, usesBrowser } = req.body;
8946
9324
  if (name !== undefined) schedules[idx].name = name.trim();
8947
9325
  if (templateId !== undefined) {
8948
9326
  const templates = readLoopTemplates();
@@ -8957,6 +9335,12 @@ app.put('/api/schedules/:id', (req, res) => {
8957
9335
  if (typeof enabled === 'boolean') schedules[idx].enabled = enabled;
8958
9336
  if (dayThemes !== undefined) schedules[idx].dayThemes = dayThemes;
8959
9337
  if (overrides !== undefined) schedules[idx].overrides = overrides;
9338
+ if (groupId !== undefined) {
9339
+ const normalizedGroupId = validateScheduleGroupId(groupId);
9340
+ if (normalizedGroupId === false) return res.status(400).json({ error: 'Group not found' });
9341
+ schedules[idx].groupId = normalizedGroupId;
9342
+ }
9343
+ applyScheduleLaunchOverrides(schedules[idx], { profile, model, effort, mcpProfile, usesBrowser });
8960
9344
 
8961
9345
  schedules[idx].updatedAt = new Date().toISOString();
8962
9346
  schedules[idx].nextRun = getNextCronRun(schedules[idx].cron, schedules[idx].timezone);
@@ -10070,7 +10454,8 @@ app.get('/api/system/backup', async (req, res) => {
10070
10454
  'loop-templates.json', 'loop-schedules.json', 'loop-folders.json', 'auto-backup-config.json',
10071
10455
  'cost-tracking.json', 'skin-config.json', 'image-favorites.json',
10072
10456
  'browser-config.json', 'browser-storage.json', 'last-session.json',
10073
- 'invite-sessions.json']) {
10457
+ 'invite-sessions.json', 'stored-plans.json', 'synabun-plugins.json',
10458
+ 'mcp-registry.json', 'active-profile.json']) {
10074
10459
  addFile(`data/${f}`, resolve(dataDir, f));
10075
10460
  }
10076
10461
 
@@ -10094,6 +10479,9 @@ app.get('/api/system/backup', async (req, res) => {
10094
10479
  // Screenshots, whiteboard captures, staged images
10095
10480
  addDirRecursive('data/images', resolve(dataDir, 'images'));
10096
10481
 
10482
+ // Plan markdown history (data/plans/YYYY-MM-DD/*.md)
10483
+ addDirRecursive('data/plans', resolve(dataDir, 'plans'));
10484
+
10097
10485
  // 3. mcp-server/data/
10098
10486
  if (existsSync(CATEGORIES_DATA_DIR)) {
10099
10487
  for (const f of readdirSync(CATEGORIES_DATA_DIR)) {
@@ -10229,7 +10617,7 @@ app.post('/api/system/restore',
10229
10617
  // data/ files
10230
10618
  const dataDir = resolve(DATA_HOME, 'data');
10231
10619
  mkdirSync(dataDir, { recursive: true });
10232
- for (const subdir of ['pending-remember', 'pending-compact', 'loop']) {
10620
+ for (const subdir of ['pending-remember', 'pending-compact', 'loop', 'plans']) {
10233
10621
  mkdirSync(resolve(dataDir, subdir), { recursive: true });
10234
10622
  }
10235
10623
 
@@ -10453,7 +10841,8 @@ function buildBackupZipToFile(destPath) {
10453
10841
  'loop-templates.json', 'loop-schedules.json', 'loop-folders.json', 'auto-backup-config.json',
10454
10842
  'cost-tracking.json', 'skin-config.json', 'image-favorites.json',
10455
10843
  'browser-config.json', 'browser-storage.json', 'last-session.json',
10456
- 'invite-sessions.json']) {
10844
+ 'invite-sessions.json', 'stored-plans.json', 'synabun-plugins.json',
10845
+ 'mcp-registry.json', 'active-profile.json']) {
10457
10846
  addFile(`data/${f}`, resolve(dataDir, f));
10458
10847
  }
10459
10848
 
@@ -10475,6 +10864,9 @@ function buildBackupZipToFile(destPath) {
10475
10864
  // Screenshots, whiteboard captures, staged images
10476
10865
  addDirRecursive('data/images', resolve(dataDir, 'images'));
10477
10866
 
10867
+ // Plan markdown history (data/plans/YYYY-MM-DD/*.md)
10868
+ addDirRecursive('data/plans', resolve(dataDir, 'plans'));
10869
+
10478
10870
  if (existsSync(CATEGORIES_DATA_DIR)) {
10479
10871
  for (const f of readdirSync(CATEGORIES_DATA_DIR)) {
10480
10872
  if (f.endsWith('.json')) addFile(`mcp-data/${f}`, resolve(CATEGORIES_DATA_DIR, f));
@@ -10802,10 +11194,25 @@ app.get('/api/latest-plan', (req, res) => {
10802
11194
  }
10803
11195
  });
10804
11196
 
11197
+ function cleanupMatchingRootPlan(cwd, content) {
11198
+ if (!cwd || typeof cwd !== 'string' || typeof content !== 'string') return false;
11199
+ try {
11200
+ const rootPlan = resolve(cwd, 'PLAN.md');
11201
+ if (!validateProjectPath(rootPlan) || !existsSync(rootPlan) || !statSync(rootPlan).isFile()) return false;
11202
+ const existing = readFileSync(rootPlan, 'utf-8').replace(/\r\n/g, '\n').trim();
11203
+ const next = content.replace(/\r\n/g, '\n').trim();
11204
+ if (!existing || existing !== next) return false;
11205
+ unlinkSync(rootPlan);
11206
+ return true;
11207
+ } catch {
11208
+ return false;
11209
+ }
11210
+ }
11211
+
10805
11212
  // POST /api/create-plan — Materialize plan content into a file in data/plans/YYYY-MM-DD/slug.md
10806
11213
  app.post('/api/create-plan', (req, res) => {
10807
11214
  try {
10808
- const { content } = req.body;
11215
+ const { content, cwd, projectPath } = req.body;
10809
11216
  if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Missing content' });
10810
11217
 
10811
11218
  // Date-organized folder
@@ -10835,7 +11242,8 @@ app.post('/api/create-plan', (req, res) => {
10835
11242
  }
10836
11243
 
10837
11244
  writeFileSync(fullPath, content, 'utf-8');
10838
- res.json({ ok: true, path: fullPath.replace(/\\/g, '/'), name });
11245
+ const cleanedRootPlan = cleanupMatchingRootPlan(projectPath || cwd || '', content);
11246
+ res.json({ ok: true, path: fullPath.replace(/\\/g, '/'), name, cleanedRootPlan });
10839
11247
  } catch (err) {
10840
11248
  res.status(500).json({ error: err.message });
10841
11249
  }
@@ -12473,12 +12881,15 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
12473
12881
  const text = typeof content === 'string' ? content
12474
12882
  : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n')
12475
12883
  : '';
12476
- if (text) { messages.push({ role: 'user', text: text.slice(0, 8000) }); turns++; }
12884
+ if (text) { messages.push({ role: 'user', text: text.slice(0, 64000) }); turns++; }
12477
12885
  } else if (obj.type === 'assistant') {
12478
12886
  const content = obj.message?.content;
12479
12887
  const textBlocks = Array.isArray(content)
12480
12888
  ? content.filter(b => b.type === 'text').map(b => b.text)
12481
12889
  : [];
12890
+ const thinkingBlocks = Array.isArray(content)
12891
+ ? content.filter(b => b.type === 'thinking').map(b => b.thinking || b.text || '').filter(Boolean)
12892
+ : [];
12482
12893
  const toolUseBlocks = Array.isArray(content)
12483
12894
  ? content.filter(b => b.type === 'tool_use').map(b => ({
12484
12895
  id: b.id,
@@ -12487,10 +12898,12 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
12487
12898
  }))
12488
12899
  : [];
12489
12900
  const text = textBlocks.join('\n');
12490
- if (text || toolUseBlocks.length) {
12901
+ const thinking = thinkingBlocks.join('\n\n');
12902
+ if (text || toolUseBlocks.length || thinking) {
12491
12903
  messages.push({
12492
12904
  role: 'assistant',
12493
- text: text.slice(0, 8000) || undefined,
12905
+ text: text.slice(0, 64000) || undefined,
12906
+ thinking: thinking.slice(0, 32000) || undefined,
12494
12907
  tools: toolUseBlocks.length ? toolUseBlocks : undefined,
12495
12908
  });
12496
12909
  }
@@ -12511,7 +12924,7 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
12511
12924
  messages.push({
12512
12925
  role: 'tool_result',
12513
12926
  toolUseId,
12514
- text: resultText.slice(0, 4000) || undefined,
12927
+ text: resultText.slice(0, 32000) || undefined,
12515
12928
  isError: obj.is_error || obj.message?.is_error || false,
12516
12929
  });
12517
12930
  }
@@ -13700,20 +14113,24 @@ function syncProfileToCodex(profile) {
13700
14113
  const envMatch = section.match(envRe);
13701
14114
  let newSection;
13702
14115
  if (envMatch) {
13703
- const body = envMatch[1];
13704
- const profileRe = /SYNABUN_PROFILE\s*=\s*"[^"]*"/;
13705
- let newBody;
13706
- if (profileRe.test(body)) {
13707
- newBody = body.replace(profileRe, `SYNABUN_PROFILE = "${profile}"`);
13708
- } else {
13709
- const trimmed = body.trim().replace(/,$/, '');
13710
- newBody = trimmed
13711
- ? ` ${trimmed}, SYNABUN_PROFILE = "${profile}" `
13712
- : ` SYNABUN_PROFILE = "${profile}" `;
14116
+ let body = envMatch[1].trim().replace(/,$/, '');
14117
+ const upserts = {
14118
+ SYNABUN_PROFILE: profile,
14119
+ SYNABUN_BROWSER_FAST: '1',
14120
+ SYNABUN_BROWSER_COMPACT: '1',
14121
+ };
14122
+ for (const [key, value] of Object.entries(upserts)) {
14123
+ const pair = `${key} = "${value}"`;
14124
+ const keyRe = new RegExp(`${key}\\s*=\\s*"[^"]*"`);
14125
+ if (keyRe.test(body)) {
14126
+ body = body.replace(keyRe, pair);
14127
+ } else {
14128
+ body = body ? `${body}, ${pair}` : pair;
14129
+ }
13713
14130
  }
13714
- newSection = section.replace(envRe, `env = {${newBody}}`);
14131
+ newSection = section.replace(envRe, `env = { ${body} }`);
13715
14132
  } else {
13716
- newSection = section.trimEnd() + `\nenv = { SYNABUN_PROFILE = "${profile}" }\n`;
14133
+ newSection = section.trimEnd() + `\nenv = { SYNABUN_PROFILE = "${profile}", SYNABUN_BROWSER_FAST = "1", SYNABUN_BROWSER_COMPACT = "1" }\n`;
13717
14134
  }
13718
14135
  if (newSection === section) return false;
13719
14136
  const updated = content.slice(0, range.start) + newSection + content.slice(range.end);
@@ -13783,6 +14200,28 @@ function getHomePath() {
13783
14200
  return process.env.USERPROFILE || process.env.HOME || '';
13784
14201
  }
13785
14202
 
14203
+ function getCodexMcpProfileForSetup() {
14204
+ const current = readActiveMcpProfile();
14205
+ return current && current !== 'full' ? current : 'codex-browser';
14206
+ }
14207
+
14208
+ function buildCodexMcpEnv(profile = getCodexMcpProfileForSetup()) {
14209
+ const { envPath } = getMcpPaths();
14210
+ return {
14211
+ DOTENV_PATH: envPath,
14212
+ SYNABUN_DATA_HOME: DATA_HOME,
14213
+ MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data'),
14214
+ SYNABUN_PROFILE: profile,
14215
+ SYNABUN_BROWSER_FAST: '1',
14216
+ SYNABUN_BROWSER_COMPACT: '1',
14217
+ };
14218
+ }
14219
+
14220
+ function buildCodexMcpToml(profile = getCodexMcpProfileForSetup()) {
14221
+ const { mcpIndexPath } = getMcpPaths();
14222
+ return `[mcp_servers.SynaBun]\ncommand = "node"\nargs = ["${mcpIndexPath}"]\nenv = ${tomlSerializeValue(buildCodexMcpEnv(profile))}\n`;
14223
+ }
14224
+
13786
14225
  // ── Gemini MCP endpoints ──
13787
14226
 
13788
14227
  app.get('/api/setup/gemini/mcp', (req, res) => {
@@ -13839,10 +14278,11 @@ app.delete('/api/setup/gemini/mcp', (req, res) => {
13839
14278
  app.get('/api/setup/codex/mcp', (req, res) => {
13840
14279
  try {
13841
14280
  const configPath = join(getHomePath(), '.codex', 'config.toml');
13842
- if (!existsSync(configPath)) return res.json({ ok: true, connected: false });
14281
+ const toml = buildCodexMcpToml();
14282
+ if (!existsSync(configPath)) return res.json({ ok: true, connected: false, toml });
13843
14283
  const content = readFileSync(configPath, 'utf-8');
13844
14284
  const connected = tomlHasSection(content, 'mcp_servers.SynaBun');
13845
- res.json({ ok: true, connected });
14285
+ res.json({ ok: true, connected, toml });
13846
14286
  } catch (err) {
13847
14287
  res.status(500).json({ error: err.message });
13848
14288
  }
@@ -13852,14 +14292,14 @@ app.post('/api/setup/codex/mcp', (req, res) => {
13852
14292
  try {
13853
14293
  const dir = join(getHomePath(), '.codex');
13854
14294
  const configPath = join(dir, 'config.toml');
13855
- const { mcpIndexPath, envPath } = getMcpPaths();
14295
+ const { mcpIndexPath } = getMcpPaths();
13856
14296
  mkdirSync(dir, { recursive: true });
13857
14297
  let content = '';
13858
14298
  try { content = readFileSync(configPath, 'utf-8'); } catch {}
13859
14299
  content = tomlUpsertSection(content, 'mcp_servers.SynaBun', {
13860
14300
  command: 'node',
13861
14301
  args: [mcpIndexPath],
13862
- env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
14302
+ env: buildCodexMcpEnv(),
13863
14303
  });
13864
14304
  writeFileSync(configPath, content, 'utf-8');
13865
14305
  res.json({ ok: true, message: 'SynaBun MCP registered in Codex CLI. Restart Codex to connect.' });
@@ -14216,6 +14656,54 @@ app.get('/api/claude-code/skills', (req, res) => {
14216
14656
  }
14217
14657
  });
14218
14658
 
14659
+ // GET /api/claude-code/plugin-commands — scan installed plugin cache dirs for slash commands
14660
+ app.get('/api/claude-code/plugin-commands', (req, res) => {
14661
+ try {
14662
+ const out = [];
14663
+ const cacheRoot = join(os.homedir(), '.claude', 'plugins', 'cache');
14664
+ if (!existsSync(cacheRoot)) return res.json({ ok: true, commands: [] });
14665
+ const readDesc = (filePath) => {
14666
+ try {
14667
+ const txt = readFileSync(filePath, 'utf-8');
14668
+ const toml = txt.match(/^\s*description\s*=\s*"((?:[^"\\]|\\.)*)"/m);
14669
+ if (toml) return toml[1].replace(/\\"/g, '"');
14670
+ const fm = txt.match(/^---\s*\n([\s\S]*?)\n---/);
14671
+ if (fm) {
14672
+ const m = fm[1].match(/^\s*description\s*:\s*(.+?)\s*$/m);
14673
+ if (m) return m[1].replace(/^["']|["']$/g, '');
14674
+ }
14675
+ return '';
14676
+ } catch { return ''; }
14677
+ };
14678
+ const walkCommands = (pluginRoot, marketplace, plugin) => {
14679
+ const cmdDir = join(pluginRoot, 'commands');
14680
+ if (!existsSync(cmdDir)) return;
14681
+ let entries; try { entries = readdirSync(cmdDir); } catch { return; }
14682
+ for (const f of entries) {
14683
+ if (!/\.(toml|md)$/i.test(f)) continue;
14684
+ const name = f.replace(/\.(toml|md)$/i, '');
14685
+ const description = readDesc(join(cmdDir, f));
14686
+ out.push({ name, description, source: 'plugin', plugin, marketplace });
14687
+ }
14688
+ };
14689
+ let markets; try { markets = readdirSync(cacheRoot); } catch { markets = []; }
14690
+ for (const m of markets) {
14691
+ const mDir = join(cacheRoot, m);
14692
+ let plugins; try { plugins = readdirSync(mDir); } catch { continue; }
14693
+ for (const p of plugins) {
14694
+ const pDir = join(mDir, p);
14695
+ let versions; try { versions = readdirSync(pDir); } catch { continue; }
14696
+ for (const v of versions) {
14697
+ walkCommands(join(pDir, v), m, p);
14698
+ }
14699
+ }
14700
+ }
14701
+ res.json({ ok: true, commands: out });
14702
+ } catch (err) {
14703
+ res.status(500).json({ ok: false, error: err.message });
14704
+ }
14705
+ });
14706
+
14219
14707
  // POST /api/claude-code/skills — install a skill globally (copies entire directory)
14220
14708
  app.post('/api/claude-code/skills', (req, res) => {
14221
14709
  try {
@@ -15183,6 +15671,7 @@ function readMcpRegistry() {
15183
15671
  profiles: {
15184
15672
  core: { label: 'Core', groups: ['git', 'image'], servers: [] },
15185
15673
  standard: { label: 'Standard', groups: ['git', 'image', 'whiteboard', 'card', 'tictactoe'], servers: [] },
15674
+ 'codex-browser': { label: 'Codex Browser', groups: ['git', 'image', 'browser', 'browser_twitter'], servers: [] },
15186
15675
  twitter: { label: 'Twitter/X', groups: ['git', 'image', 'browser', 'browser_twitter'], servers: [] },
15187
15676
  facebook: { label: 'Facebook', groups: ['git', 'image', 'browser', 'browser_facebook'], servers: [] },
15188
15677
  tiktok: { label: 'TikTok', groups: ['git', 'image', 'browser', 'browser_tiktok'], servers: [] },
@@ -15369,7 +15858,11 @@ app.get('/api/mcp/platforms', (req, res) => {
15369
15858
  });
15370
15859
 
15371
15860
  // ── GitHub install flow ──
15372
- // POST /api/mcp/install/github { url, name?, runInstall? } → clones, detects, installs.
15861
+ // POST /api/mcp/install/github { url, name?, runInstall? }
15862
+ // Clones the repo, classifies it, then either:
15863
+ // - kind=mcp: detects + installs deps, returns detected config for MCP registry save
15864
+ // - kind=claude-plugin: symlinks into ~/.claude/plugins/ + edits CC's plugin JSON files
15865
+ // - kind=unknown: returns 422 with hints
15373
15866
  app.post('/api/mcp/install/github', async (req, res) => {
15374
15867
  try {
15375
15868
  const { url, name: nameOverride, runInstall = true } = req.body || {};
@@ -15384,29 +15877,91 @@ app.post('/api/mcp/install/github', async (req, res) => {
15384
15877
  const clone = await mcpInstaller.cloneRepo({ dataHome: DATA_HOME, name, cloneUrl: parsed.cloneUrl, onLine });
15385
15878
  if (!clone.ok) return res.status(500).json({ ok: false, error: clone.error, stderr: clone.stderr, log: lines });
15386
15879
 
15387
- const detect = mcpInstaller.detectServer(clone.path);
15388
- if (!detect.ok) return res.status(422).json({ ok: false, error: detect.error, name, repoPath: clone.path, log: lines });
15880
+ const classification = mcpInstaller.classifyRepo(clone.path);
15881
+
15882
+ if (classification.kind === 'claude-plugin') {
15883
+ mcpInstaller.logInstall(DATA_HOME, name, `classified as claude-plugin (${classification.plugin?.manifest?.name || 'unknown'})`);
15884
+ const githubUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
15885
+ const install = pluginInstaller.installClaudePlugin({ repoPath: clone.path, githubUrl });
15886
+ if (!install.ok) {
15887
+ return res.status(500).json({ ok: false, kind: 'claude-plugin', error: install.error, name, repoPath: clone.path, log: lines });
15888
+ }
15889
+ return res.json({
15890
+ ok: true,
15891
+ kind: 'claude-plugin',
15892
+ name,
15893
+ repoPath: clone.path,
15894
+ cloneReused: clone.reused,
15895
+ plugin: {
15896
+ pluginName: install.pluginName,
15897
+ marketplaceName: install.marketplaceName,
15898
+ installPath: install.installPath,
15899
+ marketplacePath: install.marketplacePath,
15900
+ hooks: install.hooks,
15901
+ gitCommitSha: install.gitCommitSha,
15902
+ },
15903
+ hints: classification.hints,
15904
+ log: lines.slice(-50),
15905
+ });
15906
+ }
15907
+
15908
+ if (classification.kind !== 'mcp') {
15909
+ return res.status(422).json({
15910
+ ok: false,
15911
+ kind: 'unknown',
15912
+ error: classification.error || 'Could not identify repo type',
15913
+ name,
15914
+ repoPath: clone.path,
15915
+ hints: classification.hints || [],
15916
+ log: lines,
15917
+ });
15918
+ }
15389
15919
 
15920
+ // Skip local dep install when the detected command fetches at runtime
15921
+ // (uvx, npx, pipx, etc.) — local pip/npm install would be wasted work
15922
+ // AND its failure would mask an otherwise-working server.
15923
+ const selfFetches = mcpInstaller.commandSelfFetches(classification.detected);
15390
15924
  let installResult = { ok: true, skipped: true };
15391
- if (runInstall) {
15925
+ let installWarning = null;
15926
+ if (runInstall && !selfFetches) {
15392
15927
  installResult = await mcpInstaller.installDependencies({ dataHome: DATA_HOME, name, repoPath: clone.path, onLine });
15393
15928
  if (!installResult.ok) {
15394
- return res.status(500).json({ ok: false, error: installResult.error, name, repoPath: clone.path, detected: detect.detected, log: lines });
15929
+ // Soft-fail: keep going so the server is still registered.
15930
+ // The user can fix deps separately; if deps are truly required, the
15931
+ // server will fail at launch and they'll see the error there.
15932
+ installWarning = `Local dependency install failed: ${installResult.error}. The server is still registered — fix deps manually if it fails to launch.`;
15933
+ mcpInstaller.logInstall(DATA_HOME, name, `[warn] ${installWarning}`);
15934
+ }
15935
+ } else if (selfFetches) {
15936
+ installResult = { ok: true, skipped: true, reason: `command '${classification.detected.command}' self-fetches at runtime` };
15937
+ mcpInstaller.logInstall(DATA_HOME, name, `skipping local deps install — ${installResult.reason}`);
15938
+ // Preflight: verify the self-fetcher binary is actually on PATH.
15939
+ // If missing the server still registers, but it will fail at launch
15940
+ // until the user installs the runtime.
15941
+ const preflight = mcpInstaller.checkSelfFetcherAvailable(classification.detected);
15942
+ if (!preflight.available) {
15943
+ const hint = preflight.installHint ? ` Install: ${preflight.installHint}` : '';
15944
+ installWarning = `'${preflight.command}' not found on PATH.${hint}. Server registered anyway — it will fail at launch until the runtime is installed.`;
15945
+ mcpInstaller.logInstall(DATA_HOME, name, `[warn] ${installWarning}`);
15395
15946
  }
15396
15947
  }
15397
15948
 
15398
15949
  // After install, re-detect in case build produced dist/ paths
15399
15950
  const detect2 = mcpInstaller.detectServer(clone.path);
15400
- const detected = detect2.ok ? detect2.detected : detect.detected;
15951
+ const detected = detect2.ok ? detect2.detected : classification.detected;
15401
15952
 
15402
15953
  res.json({
15403
15954
  ok: true,
15955
+ kind: 'mcp',
15404
15956
  name,
15405
15957
  repoPath: clone.path,
15406
15958
  cloneReused: clone.reused,
15407
15959
  detected,
15408
- detectSource: detect.detected?.source,
15960
+ detectSource: classification.detected?.source,
15409
15961
  install: installResult,
15962
+ installWarning,
15963
+ hints: classification.hints,
15964
+ alsoClaudePlugin: classification.alsoClaudePlugin || null,
15410
15965
  log: lines.slice(-50),
15411
15966
  });
15412
15967
  } catch (err) {
@@ -15414,6 +15969,161 @@ app.post('/api/mcp/install/github', async (req, res) => {
15414
15969
  }
15415
15970
  });
15416
15971
 
15972
+ // GET /api/mcp/installed-all — unified view of every externally-installed MCP
15973
+ // across Claude Code / OpenCode / Codex / Gemini + SynaBun's registry + Claude Code plugins.
15974
+ // Excludes SynaBun's built-in tool-group servers (they're baked in, not "installed").
15975
+ app.get('/api/mcp/installed-all', (req, res) => {
15976
+ try {
15977
+ const home = getHomePath();
15978
+ const byName = new Map();
15979
+ const touch = (name, cli, fields = {}) => {
15980
+ if (!byName.has(name)) {
15981
+ byName.set(name, { name, registeredWith: new Set(), transport: null, command: null, args: null, url: null });
15982
+ }
15983
+ const s = byName.get(name);
15984
+ for (const [k, v] of Object.entries(fields)) {
15985
+ if (v !== undefined && (s[k] === null || s[k] === undefined)) s[k] = v;
15986
+ }
15987
+ s.registeredWith.add(cli);
15988
+ };
15989
+
15990
+ // SynaBun registry (external only)
15991
+ try {
15992
+ const reg = readMcpRegistry();
15993
+ for (const [name, info] of Object.entries(reg.servers || {})) {
15994
+ if (info.builtin) continue;
15995
+ touch(name, 'synabun', {
15996
+ transport: info.type || 'stdio',
15997
+ command: info.command || null,
15998
+ args: info.args || null,
15999
+ url: info.url || null,
16000
+ });
16001
+ }
16002
+ } catch {}
16003
+
16004
+ // Claude Code
16005
+ try {
16006
+ const p = join(home, '.claude.json');
16007
+ if (existsSync(p)) {
16008
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
16009
+ for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
16010
+ touch(name, 'claudeCode', {
16011
+ transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
16012
+ command: cfg.command || null,
16013
+ args: cfg.args || null,
16014
+ url: cfg.url || null,
16015
+ });
16016
+ }
16017
+ }
16018
+ } catch {}
16019
+
16020
+ // Gemini
16021
+ try {
16022
+ const p = join(home, '.gemini', 'settings.json');
16023
+ if (existsSync(p)) {
16024
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
16025
+ for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
16026
+ touch(name, 'gemini', {
16027
+ transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
16028
+ command: cfg.command || null,
16029
+ args: cfg.args || null,
16030
+ url: cfg.url || null,
16031
+ });
16032
+ }
16033
+ }
16034
+ } catch {}
16035
+
16036
+ // Codex TOML — only match top-level [mcp_servers.<name>], not [...tools.<name>]
16037
+ try {
16038
+ const p = join(home, '.codex', 'config.toml');
16039
+ if (existsSync(p)) {
16040
+ const txt = readFileSync(p, 'utf-8');
16041
+ const re = /^\[mcp_servers\.([^\].]+)\]\s*$/gm;
16042
+ let m;
16043
+ while ((m = re.exec(txt))) {
16044
+ touch(m[1], 'codex', { transport: 'stdio' });
16045
+ }
16046
+ }
16047
+ } catch {}
16048
+
16049
+ // OpenCode
16050
+ try {
16051
+ const ocPath = getOpencodeConfigPath();
16052
+ if (existsSync(ocPath)) {
16053
+ const oc = JSON.parse(readFileSync(ocPath, 'utf-8'));
16054
+ for (const [name, cfg] of Object.entries(oc?.mcp || {})) {
16055
+ const cmd = Array.isArray(cfg.command) ? cfg.command[0] : cfg.command;
16056
+ const args = Array.isArray(cfg.command) ? cfg.command.slice(1) : cfg.args;
16057
+ touch(name, 'opencode', {
16058
+ transport: cfg.type === 'local' ? 'stdio' : (cfg.type || 'stdio'),
16059
+ command: cmd || null,
16060
+ args: args || null,
16061
+ url: cfg.url || null,
16062
+ });
16063
+ }
16064
+ }
16065
+ } catch {}
16066
+
16067
+ const servers = Array.from(byName.values())
16068
+ .map(s => ({ ...s, registeredWith: [...s.registeredWith].sort() }))
16069
+ .sort((a, b) => a.name.localeCompare(b.name));
16070
+
16071
+ let plugins = [];
16072
+ try { plugins = pluginInstaller.listSynabunPlugins(); } catch {}
16073
+
16074
+ res.json({ ok: true, servers, plugins });
16075
+ } catch (err) {
16076
+ res.status(500).json({ ok: false, error: err.message });
16077
+ }
16078
+ });
16079
+
16080
+ // ── Claude Code plugin management ──
16081
+ // GET /api/plugins/list — all SynaBun-managed Claude Code plugins
16082
+ app.get('/api/plugins/list', (req, res) => {
16083
+ try {
16084
+ const plugins = pluginInstaller.listSynabunPlugins();
16085
+ res.json({ ok: true, plugins });
16086
+ } catch (err) {
16087
+ res.status(500).json({ ok: false, error: err.message });
16088
+ }
16089
+ });
16090
+
16091
+ // DELETE /api/plugins/:marketplace?pluginName=&purgeFiles=1
16092
+ app.delete('/api/plugins/:marketplace', (req, res) => {
16093
+ try {
16094
+ const { marketplace } = req.params;
16095
+ const { pluginName, purgeFiles } = req.query || {};
16096
+ const result = pluginInstaller.uninstallClaudePlugin({
16097
+ marketplaceName: marketplace,
16098
+ pluginName: pluginName ? String(pluginName) : undefined,
16099
+ });
16100
+ if (!result.ok) return res.status(400).json(result);
16101
+
16102
+ let purged = false;
16103
+ if (String(purgeFiles) === '1') {
16104
+ try {
16105
+ const dir = mcpInstaller.serverDir(DATA_HOME, marketplace);
16106
+ if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); purged = true; }
16107
+ } catch (err) {
16108
+ console.warn(`[plugins] purge files failed for ${marketplace}:`, err.message);
16109
+ }
16110
+ }
16111
+ res.json({ ok: true, ...result, purged });
16112
+ } catch (err) {
16113
+ res.status(500).json({ ok: false, error: err.message });
16114
+ }
16115
+ });
16116
+
16117
+ // POST /api/plugins/repair — clean legacy synabun-symlink state and reinstall via CLI
16118
+ app.post('/api/plugins/repair', (req, res) => {
16119
+ try {
16120
+ const result = pluginInstaller.repairSynabunPlugins();
16121
+ res.json({ ok: true, ...result });
16122
+ } catch (err) {
16123
+ res.status(500).json({ ok: false, error: err.message });
16124
+ }
16125
+ });
16126
+
15417
16127
  // POST /api/mcp/install/detect { path } or { name } → re-run detection.
15418
16128
  app.post('/api/mcp/install/detect', (req, res) => {
15419
16129
  try {
@@ -16006,6 +16716,7 @@ const IS_WIN = process.platform === 'win32';
16006
16716
  const DEFAULT_SHELL = IS_WIN ? (process.env.COMSPEC || 'cmd.exe') : (process.env.SHELL || '/bin/bash');
16007
16717
  const TERMINAL_BUFFER_MAX_BYTES = 100 * 1024; // 100KB ring buffer per session
16008
16718
  const TERMINAL_GRACE_PERIOD_MS = 30 * 60 * 1000; // 30 min before orphaned PTY is killed
16719
+ const TERMINAL_SNAPSHOT_SCROLLBACK_ROWS = 1000;
16009
16720
 
16010
16721
  // ── CLI Config (custom CLI paths for terminal profiles) ──
16011
16722
 
@@ -16098,6 +16809,8 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16098
16809
  if (!pty) throw new Error('Terminal not available: node-pty failed to load. Run: cd neural-interface && npm run postinstall');
16099
16810
  const sessionId = opts.sessionId || randomBytes(16).toString('hex');
16100
16811
  const profileCfg = getTerminalProfile(profile, opts);
16812
+ const initialCols = cols || 120;
16813
+ const initialRows = rows || 30;
16101
16814
 
16102
16815
  // Strip VSCode env vars so spawned CLIs (e.g. claude) don't think they're inside VSCode
16103
16816
  const cleanEnv = Object.fromEntries(
@@ -16106,20 +16819,35 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16106
16819
  )
16107
16820
  );
16108
16821
 
16822
+ const isLoopOwned = !!(opts.extraEnv && opts.extraEnv.SYNABUN_TERMINAL_SESSION === sessionId);
16823
+ if (isLoopOwned) {
16824
+ loopLog(sessionId, 'pty:resolve', 'profile resolved', {
16825
+ profile, shell: profileCfg.shell, args: profileCfg.args,
16826
+ envKeys: Object.keys(profileCfg.env || {}),
16827
+ extraEnvKeys: Object.keys(opts.extraEnv || {}),
16828
+ cwd, cols: initialCols, rows: initialRows,
16829
+ });
16830
+ }
16831
+
16109
16832
  const ptyProcess = pty.spawn(profileCfg.shell, profileCfg.args, {
16110
16833
  name: 'xterm-256color',
16111
- cols: cols || 120,
16112
- rows: rows || 30,
16834
+ cols: initialCols,
16835
+ rows: initialRows,
16113
16836
  cwd: cwd || process.env.USERPROFILE || process.env.HOME || process.cwd(),
16114
16837
  env: { ...cleanEnv, ...profileCfg.env, ...(opts.extraEnv || {}) },
16115
16838
  useConpty: IS_WIN,
16116
16839
  });
16117
16840
 
16841
+ if (isLoopOwned) {
16842
+ loopLog(sessionId, 'pty:spawned', 'pty spawned', { pid: ptyProcess.pid });
16843
+ }
16844
+
16118
16845
  const session = {
16119
16846
  pty: ptyProcess, clients: new Set(), profile, cwd,
16120
16847
  createdAt: Date.now(),
16121
16848
  outputBuffer: [], // ring buffer of output strings
16122
16849
  outputBufferBytes: 0, // running byte count
16850
+ vterm: new VTermBuffer(initialCols, initialRows, TERMINAL_SNAPSHOT_SCROLLBACK_ROWS + initialRows),
16123
16851
  graceTimer: null, // setTimeout handle for orphan cleanup
16124
16852
  claudeSessionId: opts.resume || null, // known Claude session ID (from resume or detect)
16125
16853
  };
@@ -16161,8 +16889,14 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16161
16889
  });
16162
16890
  };
16163
16891
 
16892
+ // Loop boot signature: log first ~500 bytes of PTY output (only for loop-owned sessions)
16893
+ let _loopBootCaptured = false;
16894
+ let _loopBootBuf = '';
16895
+
16164
16896
  const OUTPUT_BURST_CAP = 64 * 1024; // flush early when this much buffered in one tick
16165
16897
  ptyProcess.onData((data) => {
16898
+ try { session.vterm?.write(data); } catch {}
16899
+
16166
16900
  // Accumulate into coalesce buffer
16167
16901
  _outputCoalesceBuf += data;
16168
16902
 
@@ -16179,6 +16913,19 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16179
16913
  }
16180
16914
  }
16181
16915
 
16916
+ // Boot signature capture for loop-owned sessions (first 500 bytes only)
16917
+ if (isLoopOwned && !_loopBootCaptured) {
16918
+ _loopBootBuf += data;
16919
+ if (_loopBootBuf.length >= 500) {
16920
+ _loopBootCaptured = true;
16921
+ loopLog(sessionId, 'pty:boot-output', 'first 500 bytes of pty output captured', {
16922
+ uptimeMs: Date.now() - session.createdAt,
16923
+ sample: _loopBootBuf.slice(0, 500),
16924
+ });
16925
+ _loopBootBuf = '';
16926
+ }
16927
+ }
16928
+
16182
16929
  // Burst cap: if a huge chunk arrives in one tick, flush synchronously so a
16183
16930
  // multi-MB JSON doesn't pile up and block the client write loop (which
16184
16931
  // stalls xterm rendering and causes drawn-glitch artifacts).
@@ -16198,6 +16945,39 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16198
16945
  // Clear grace timer if set
16199
16946
  if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
16200
16947
 
16948
+ if (isLoopOwned) {
16949
+ // Flush any remaining boot buffer that didn't reach 500 bytes
16950
+ if (!_loopBootCaptured && _loopBootBuf.length > 0) {
16951
+ loopLog(sessionId, 'pty:boot-output', `pty exited before 500 bytes (got ${_loopBootBuf.length})`, {
16952
+ uptimeMs: Date.now() - session.createdAt,
16953
+ sample: _loopBootBuf,
16954
+ });
16955
+ }
16956
+ loopLog(sessionId, 'pty:exit', 'pty exited', {
16957
+ exitCode,
16958
+ uptimeMs: Date.now() - session.createdAt,
16959
+ bootCaptured: _loopBootCaptured,
16960
+ });
16961
+ // Mark loop file inactive if PTY died with loop still active
16962
+ try {
16963
+ const loopFile = findLoopFileForTerminal(sessionId);
16964
+ if (loopFile) {
16965
+ const loopPath = resolve(LOOP_DIR, loopFile);
16966
+ const ls = JSON.parse(readFileSync(loopPath, 'utf-8'));
16967
+ if (ls.active) {
16968
+ ls.active = false;
16969
+ ls.completedAt = new Date().toISOString();
16970
+ ls.stoppedReason = ls.stoppedReason || `pty-exit-${exitCode}`;
16971
+ ls.lastExitCode = exitCode;
16972
+ writeFileSync(loopPath, JSON.stringify(ls, null, 2));
16973
+ loopLog(sessionId, 'pty:exit', 'marked loop inactive', { stoppedReason: ls.stoppedReason, loopFile });
16974
+ }
16975
+ }
16976
+ } catch (err) {
16977
+ loopLog(sessionId, 'pty:exit', 'failed to mark loop inactive', { err: err.message });
16978
+ }
16979
+ }
16980
+
16201
16981
  const msg = JSON.stringify({ type: 'exit', exitCode });
16202
16982
  session.clients.forEach(ws => {
16203
16983
  if (ws.readyState === 1) ws.send(msg);
@@ -16225,6 +17005,60 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16225
17005
  return sessionId;
16226
17006
  }
16227
17007
 
17008
+ function terminalSnapshotToAnsi(session) {
17009
+ const vterm = session?.vterm;
17010
+ if (!vterm) return '';
17011
+
17012
+ const cols = Math.max(1, vterm.cols || session?.pty?.cols || 120);
17013
+ const rows = Math.max(1, vterm.rows || session?.pty?.rows || 30);
17014
+ const parts = ['\x1b[0m'];
17015
+
17016
+ if (vterm.useAltBuffer) {
17017
+ parts.push('\x1b[?1049h');
17018
+ } else {
17019
+ parts.push('\x1b[?1049l');
17020
+ const sbLen = vterm.scrollbackLength || 0;
17021
+ const start = Math.max(0, sbLen - TERMINAL_SNAPSHOT_SCROLLBACK_ROWS);
17022
+ for (let r = start; r < sbLen; r++) {
17023
+ const row = vterm.getScrollbackRow(r);
17024
+ if (!row) continue;
17025
+ let line = '';
17026
+ for (let c = 0; c < cols; c++) line += row[c]?.char || ' ';
17027
+ parts.push(line.replace(/\s+$/g, ''), '\r\n');
17028
+ }
17029
+ }
17030
+
17031
+ // Clear only the visible viewport. In normal-buffer mode this preserves the
17032
+ // scrollback rows emitted above while rebuilding the current screen from the
17033
+ // server-side terminal model instead of replaying an arbitrary ANSI tail.
17034
+ parts.push('\x1b[2J\x1b[H');
17035
+ for (let r = 0; r < rows; r++) {
17036
+ const row = vterm.getRow(r);
17037
+ if (!row) continue;
17038
+ let line = '';
17039
+ for (let c = 0; c < cols; c++) line += row[c]?.char || ' ';
17040
+ parts.push(`\x1b[${r + 1};1H`, line.replace(/\s+$/g, ''));
17041
+ }
17042
+
17043
+ parts.push(
17044
+ '\x1b[0m',
17045
+ vterm.cursorVisible ? '\x1b[?25h' : '\x1b[?25l',
17046
+ `\x1b[${Math.min(rows, Math.max(1, vterm.cursorY + 1))};${Math.min(cols, Math.max(1, vterm.cursorX + 1))}H`,
17047
+ );
17048
+
17049
+ return parts.join('');
17050
+ }
17051
+
17052
+ function terminalSnapshotToText(session) {
17053
+ const vterm = session?.vterm;
17054
+ if (!vterm) return '';
17055
+ const sb = vterm.scrollbackLength || 0;
17056
+ const totalRows = sb + (vterm.rows || 0);
17057
+ if (totalRows <= 0) return '';
17058
+ const startRow = vterm.useAltBuffer ? sb : Math.max(0, totalRows - TERMINAL_SNAPSHOT_SCROLLBACK_ROWS - (vterm.rows || 0));
17059
+ return vterm.getText(startRow, 0, totalRows - 1, Math.max(0, (vterm.cols || 1) - 1));
17060
+ }
17061
+
16228
17062
  // ── Loop Driver — sends /clear + next iteration prompt between loop iterations ──
16229
17063
 
16230
17064
  // Comprehensive ANSI escape stripping: CSI sequences, OSC sequences, charset selects, cursor keys, and \r
@@ -16238,15 +17072,31 @@ function writeToLoopPty(session, text, sendEnter = true) {
16238
17072
  const full = sendEnter ? text + '\r' : text;
16239
17073
  const CHUNK = 256;
16240
17074
  const DELAY = 30;
17075
+ const chunkCount = Math.ceil(full.length / CHUNK);
17076
+ const totalSettleMs = chunkCount * DELAY;
17077
+ const tsid = session?._loopTerminalSessionId || null;
17078
+ let writeFailures = 0;
17079
+ loopLog(tsid, 'pty:write-begin', 'writeToLoopPty start', {
17080
+ bytes: full.length, chunkCount, totalSettleMs, sendEnter,
17081
+ preview: text.slice(0, 200),
17082
+ });
16241
17083
  for (let i = 0; i < full.length; i += CHUNK) {
16242
17084
  const chunk = full.slice(i, i + CHUNK);
16243
17085
  const delay = (i / CHUNK) * DELAY;
16244
17086
  setTimeout(() => {
16245
- try { session.pty.write(chunk); } catch { /* pty may be dead */ }
17087
+ try { session.pty.write(chunk); } catch (err) {
17088
+ writeFailures++;
17089
+ loopLog(tsid, 'pty:write-fail', 'pty.write threw', { offset: i, err: err.message });
17090
+ }
16246
17091
  }, delay);
16247
17092
  }
17093
+ setTimeout(() => {
17094
+ loopLog(tsid, 'pty:write-end', 'writeToLoopPty all chunks scheduled', {
17095
+ bytes: full.length, chunkCount, writeFailures,
17096
+ });
17097
+ }, totalSettleMs + 50);
16248
17098
  // Return total time for all chunks to be sent
16249
- return Math.ceil(full.length / CHUNK) * DELAY;
17099
+ return totalSettleMs;
16250
17100
  }
16251
17101
 
16252
17102
  /**
@@ -16284,25 +17134,41 @@ function loopPromptReady(buffer) {
16284
17134
  */
16285
17135
  function attachLoopDriver(terminalSessionId) {
16286
17136
  const session = terminalSessions.get(terminalSessionId);
16287
- if (!session) return;
17137
+ if (!session) {
17138
+ loopLog(terminalSessionId, 'driver:attach', 'ABORT: terminal session not found in registry');
17139
+ return;
17140
+ }
16288
17141
 
16289
17142
  // Initialize capture buffer (used only for initial boot prompt detection)
16290
17143
  session._loopDriverBuffer = '';
16291
17144
  session._loopDriverStartedAt = Date.now();
17145
+ session._loopTerminalSessionId = terminalSessionId;
16292
17146
  session._pendingClaimed = false;
16293
17147
  session._pendingClaimedAt = null;
16294
17148
  let driving = false; // prevent re-entrance
16295
17149
 
16296
17150
  let consecutiveFailures = 0;
16297
17151
  const MAX_DRIVE_FAILURES = 5; // with deterministic delays, failures are real problems
17152
+ let tickCount = 0;
17153
+
17154
+ loopLog(terminalSessionId, 'driver:attach', 'hook-driven loop driver attached (claude-code)', { intervalMs: 2000 });
16298
17155
 
16299
17156
  const interval = setInterval(async () => {
17157
+ tickCount++;
16300
17158
  if (driving) return;
16301
17159
  if (!terminalSessions.has(terminalSessionId)) {
16302
17160
  clearInterval(interval);
16303
- console.log('[loop-driver] Terminal session gone, clearing driver interval');
17161
+ loopLog(terminalSessionId, 'driver:tick', 'terminal session gone — clearing driver interval', { ticks: tickCount });
16304
17162
  return;
16305
17163
  }
17164
+ // Throttled tick log: every 10th tick (20s)
17165
+ if (tickCount % 10 === 0) {
17166
+ loopLog(terminalSessionId, 'driver:tick', `tick #${tickCount}`, {
17167
+ pendingClaimed: !!session._pendingClaimed,
17168
+ bootElapsedMs: Date.now() - (session._loopDriverStartedAt || 0),
17169
+ bufferBytes: (session._loopDriverBuffer || '').length,
17170
+ });
17171
+ }
16306
17172
 
16307
17173
  // Cooldown after pending claim: don't scan for awaitingNext while the first
16308
17174
  // iteration is still running. Once the pending file is gone (renamed by hook)
@@ -16376,10 +17242,17 @@ function attachLoopDriver(terminalSessionId) {
16376
17242
  } else {
16377
17243
  const pendingFiles = readdirSync(LOOP_DIR)
16378
17244
  .filter(f => f.startsWith('pending-') && f.endsWith('.json'));
17245
+ if (tickCount % 5 === 0) {
17246
+ loopLog(terminalSessionId, 'driver:scan', 'scanning for pending file', {
17247
+ pendingCount: pendingFiles.length, bootElapsedMs: bootElapsed,
17248
+ });
17249
+ }
17250
+ let foundOwnPending = false;
16379
17251
  for (const pf of pendingFiles) {
16380
17252
  let pendingState;
16381
17253
  try { pendingState = JSON.parse(readFileSync(resolve(LOOP_DIR, pf), 'utf-8')); } catch { continue; }
16382
17254
  if (pendingState.terminalSessionId !== terminalSessionId) continue;
17255
+ foundOwnPending = true;
16383
17256
  if (!pendingState.active && !pendingState.pending) continue;
16384
17257
 
16385
17258
  // Check for prompt OR use fallback after 30s of waiting
@@ -16388,7 +17261,11 @@ function attachLoopDriver(terminalSessionId) {
16388
17261
  const fallbackFire = waitedForPrompt > 30000;
16389
17262
 
16390
17263
  if (promptDetected || fallbackFire) {
16391
- console.log(`[loop-driver] Claiming pending loop ${pf} via PTY write (prompt=${promptDetected}, fallback=${fallbackFire}, waited=${Math.round(bootElapsed / 1000)}s)`);
17264
+ loopLog(terminalSessionId, 'driver:claim', `claiming pending loop`, {
17265
+ file: pf, promptDetected, fallbackFire,
17266
+ waitedForPromptMs: waitedForPrompt, bootElapsedMs: bootElapsed,
17267
+ bufferTail: (session._loopDriverBuffer || '').slice(-300),
17268
+ });
16392
17269
  session._pendingClaimed = true;
16393
17270
  session._pendingClaimedAt = Date.now();
16394
17271
  session._loopDriverBuffer = '';
@@ -16400,12 +17277,23 @@ function attachLoopDriver(terminalSessionId) {
16400
17277
  setTimeout(() => {
16401
17278
  try {
16402
17279
  session.pty.write('\r');
16403
- console.log('[loop-driver] Sent auto-confirm Enter for pending claim');
16404
- } catch { /* pty may be dead */ }
17280
+ loopLog(terminalSessionId, 'driver:claim', 'auto-confirm Enter sent for pending claim');
17281
+ } catch (err) {
17282
+ loopLog(terminalSessionId, 'driver:claim', 'auto-confirm Enter FAILED', { err: err.message });
17283
+ }
16405
17284
  }, chunkTime + 4000);
17285
+ } else if (tickCount % 5 === 0) {
17286
+ loopLog(terminalSessionId, 'driver:claim', 'pending file matched but not claiming yet', {
17287
+ promptDetected, waitedForPromptMs: waitedForPrompt, bufferBytes: (session._loopDriverBuffer || '').length,
17288
+ });
16406
17289
  }
16407
17290
  break;
16408
17291
  }
17292
+ if (!foundOwnPending && tickCount % 5 === 0 && pendingFiles.length > 0) {
17293
+ loopLog(terminalSessionId, 'driver:scan', 'no pending file matches this terminal session', {
17294
+ pendingFiles, ourTerminalSessionId: terminalSessionId,
17295
+ });
17296
+ }
16409
17297
  }
16410
17298
  }
16411
17299
  } catch { /* ok */ }
@@ -16423,16 +17311,23 @@ function attachLoopDriver(terminalSessionId) {
16423
17311
  async function driveNextIteration(session, loopState, filename) {
16424
17312
  const loopPath = resolve(LOOP_DIR, filename);
16425
17313
  const iter = loopState.currentIteration;
17314
+ const tsid = loopState.terminalSessionId || session?._loopTerminalSessionId || null;
16426
17315
 
16427
- console.log(`[loop-driver] Driving iteration ${iter}/${loopState.totalIterations}`);
17316
+ loopLog(tsid, 'driver:iter-begin', `driving iteration ${iter}/${loopState.totalIterations}`, {
17317
+ file: filename, awaitingNext: !!loopState.awaitingNext,
17318
+ });
16428
17319
 
16429
17320
  // Step 1: Grace delay — let PTY finish rendering after Claude stopped
16430
17321
  await new Promise(r => setTimeout(r, 2000));
16431
17322
 
16432
17323
  // Step 2: Send /clear to reset conversation context
16433
17324
  session._loopDriverBuffer = '';
16434
- session.pty.write('/clear\r');
16435
- console.log('[loop-driver] Sent /clear');
17325
+ try {
17326
+ session.pty.write('/clear\r');
17327
+ loopLog(tsid, 'driver:iter', '/clear sent');
17328
+ } catch (err) {
17329
+ loopLog(tsid, 'driver:iter', '/clear write FAILED', { err: err.message });
17330
+ }
16436
17331
 
16437
17332
  // Step 3: Wait for /clear to process (CLI clears conversation, renders fresh prompt)
16438
17333
  await new Promise(r => setTimeout(r, 5000));
@@ -16441,12 +17336,16 @@ async function driveNextIteration(session, loopState, filename) {
16441
17336
  session._loopDriverBuffer = '';
16442
17337
  const iterMsg = `[SynaBun Loop] Iteration ${iter}. Begin task.`;
16443
17338
  const chunkTime = writeToLoopPty(session, iterMsg, true);
16444
- console.log(`[loop-driver] Sent iteration ${iter} message`);
17339
+ loopLog(tsid, 'driver:iter', 'iteration message scheduled', { iter, msg: iterMsg, chunkTime });
16445
17340
 
16446
17341
  // Step 5: Auto-confirm (Enter after all chunks + settle time)
16447
17342
  setTimeout(() => {
16448
- try { session.pty.write('\r'); } catch { /* ok */ }
16449
- console.log('[loop-driver] Sent auto-confirm Enter');
17343
+ try {
17344
+ session.pty.write('\r');
17345
+ loopLog(tsid, 'driver:iter', 'auto-confirm Enter sent', { iter });
17346
+ } catch (err) {
17347
+ loopLog(tsid, 'driver:iter', 'auto-confirm Enter FAILED', { iter, err: err.message });
17348
+ }
16450
17349
  }, chunkTime + 3000);
16451
17350
 
16452
17351
  // Step 6: Clear awaitingNext flag
@@ -16455,8 +17354,9 @@ async function driveNextIteration(session, loopState, filename) {
16455
17354
  freshState.awaitingNext = false;
16456
17355
  delete freshState._driveRetries; // no longer needed
16457
17356
  writeFileSync(loopPath, JSON.stringify(freshState, null, 2));
17357
+ loopLog(tsid, 'driver:iter-end', `iteration ${iter} dispatched, awaitingNext cleared`);
16458
17358
  } catch (err) {
16459
- console.warn('[loop-driver] Failed to clear awaitingNext:', err.message);
17359
+ loopLog(tsid, 'driver:iter-end', 'clear awaitingNext FAILED', { iter, err: err.message });
16460
17360
  }
16461
17361
 
16462
17362
  return true;
@@ -16480,7 +17380,21 @@ const EXEC_BOOT_SENTINEL = 'SYNABUN_EXEC_BOOT_READY';
16480
17380
  */
16481
17381
  function prepareExecTaskFile(terminalSessionId, loopState) {
16482
17382
  const taskFile = resolve('/tmp', `synabun-loop-${terminalSessionId}.txt`);
16483
- const wrappedTask = `AUTHORIZATION: This task is running inside SynaBun Automation Studio, a user-supervised automation platform. The user has explicitly configured and launched this automation. All browser tools are first-party MCP tools under user control. Execute the task as instructed.\n\nTASK:\n${loopState.task}`;
17383
+ const browserBlock = loopState.usesBrowser ? [
17384
+ '',
17385
+ '=== BROWSER ENFORCEMENT (MANDATORY) ===',
17386
+ 'This automation REQUIRES the SynaBun internal browser.',
17387
+ loopState.browserSessionId ? `YOUR BROWSER SESSION: ${loopState.browserSessionId}` : '',
17388
+ loopState.browserTabId ? `YOUR BROWSER TAB: ${loopState.browserTabId}` : '',
17389
+ loopState.browserSessionId
17390
+ ? `Pass sessionId: "${loopState.browserSessionId}"${loopState.browserTabId ? ` and tabId: "${loopState.browserTabId}"` : ''} to EVERY browser tool call. Omitting them can hijack another loop's browser.`
17391
+ : 'Use the SynaBun browser tools only. Do not use external browser/search tools for visual browsing.',
17392
+ 'Use ONLY SynaBun MCP browser tools such as browser_navigate, browser_click, browser_snapshot, browser_content, browser_evaluate, browser_wait, and the browser_extract_* tools.',
17393
+ 'If the browser shows a login page, CAPTCHA, 2FA, or any wall requiring human action, stop and report the blocker. Do not bypass it or fall back to web search.',
17394
+ '=== END BROWSER ENFORCEMENT ===',
17395
+ '',
17396
+ ].filter(Boolean).join('\n') : '';
17397
+ const wrappedTask = `AUTHORIZATION: This task is running inside SynaBun Automation Studio, a user-supervised automation platform. The user has explicitly configured and launched this automation. All browser tools are first-party MCP tools under user control. Execute the task as instructed.${browserBlock}\n\nTASK:\n${loopState.task}`;
16484
17398
  const fullTask = loopState.context ? `${wrappedTask}\n\nCONTEXT:\n${loopState.context}` : wrappedTask;
16485
17399
  writeFileSync(taskFile, fullTask, 'utf-8');
16486
17400
  return taskFile;
@@ -16559,23 +17473,41 @@ function findLoopFileForTerminal(terminalSessionId) {
16559
17473
  */
16560
17474
  function attachExecLoopDriver(terminalSessionId) {
16561
17475
  const session = terminalSessions.get(terminalSessionId);
16562
- if (!session) return;
17476
+ if (!session) {
17477
+ loopLog(terminalSessionId, 'exec-driver:attach', 'ABORT: terminal session not found in registry');
17478
+ return;
17479
+ }
16563
17480
 
16564
17481
  session._loopDriverBuffer = '';
16565
17482
  session._loopDriverStartedAt = Date.now();
17483
+ session._loopTerminalSessionId = terminalSessionId;
16566
17484
  session._execDriverState = 'booting';
16567
17485
  session._execBootSent = false;
16568
17486
  session._execCurrentIter = 0;
16569
17487
  session._execTaskFile = null; // prepared on first ready
17488
+ session._execIterStartedAt = null;
16570
17489
  let driving = false;
17490
+ let tickCount = 0;
17491
+ let consecutiveFastFails = 0;
17492
+ let consecutiveNonZero = 0;
17493
+ const FAST_FAIL_THRESHOLD_MS = 30_000;
17494
+ const FAIL_LIMIT = 3;
17495
+ loopLog(terminalSessionId, 'exec-driver:attach', 'exec loop driver attached', { intervalMs: 1500 });
16571
17496
 
16572
17497
  const interval = setInterval(async () => {
17498
+ tickCount++;
16573
17499
  if (driving) return;
16574
17500
  if (!terminalSessions.has(terminalSessionId)) {
16575
17501
  clearInterval(interval);
16576
- console.log('[exec-loop-driver] Terminal session gone, clearing driver interval');
17502
+ loopLog(terminalSessionId, 'exec-driver:tick', 'terminal session gone — clearing driver interval', { ticks: tickCount });
16577
17503
  return;
16578
17504
  }
17505
+ if (tickCount % 10 === 0) {
17506
+ loopLog(terminalSessionId, 'exec-driver:tick', `tick #${tickCount}`, {
17507
+ state: session._execDriverState, currentIter: session._execCurrentIter,
17508
+ bootSent: session._execBootSent,
17509
+ });
17510
+ }
16579
17511
 
16580
17512
  // Load loop state file for this terminal
16581
17513
  const loopFile = findLoopFileForTerminal(terminalSessionId);
@@ -16608,14 +17540,19 @@ function attachExecLoopDriver(terminalSessionId) {
16608
17540
  session._loopDriverBuffer = '';
16609
17541
  writeToLoopPty(session, `echo ${EXEC_BOOT_SENTINEL}`, true);
16610
17542
  session._execBootSent = true;
16611
- console.log('[exec-loop-driver] Sent boot sentinel echo');
17543
+ loopLog(terminalSessionId, 'exec-driver:boot', 'boot sentinel echo sent', { sentinel: EXEC_BOOT_SENTINEL, bootElapsedMs: bootElapsed });
16612
17544
  }
16613
17545
 
16614
17546
  // Detect boot sentinel OR use fallback timeout
16615
- if ((session._execBootSent && stripped.includes(EXEC_BOOT_SENTINEL)) || bootElapsed > 20000) {
17547
+ const sentinelDetected = session._execBootSent && stripped.includes(EXEC_BOOT_SENTINEL);
17548
+ const fallbackFire = bootElapsed > 20000;
17549
+ if (sentinelDetected || fallbackFire) {
16616
17550
  session._execDriverState = 'ready';
16617
17551
  session._loopDriverBuffer = '';
16618
- console.log(`[exec-loop-driver] Shell ready (sentinel=${stripped.includes(EXEC_BOOT_SENTINEL)}, elapsed=${Math.round(bootElapsed / 1000)}s)`);
17552
+ loopLog(terminalSessionId, 'exec-driver:boot', 'shell ready', {
17553
+ sentinelDetected, fallbackFire, bootElapsedMs: bootElapsed,
17554
+ bufferTail: stripped.slice(-200),
17555
+ });
16619
17556
  }
16620
17557
  }
16621
17558
 
@@ -16650,15 +17587,19 @@ function attachExecLoopDriver(terminalSessionId) {
16650
17587
  // Prepare task file on first iteration (reused across all iterations)
16651
17588
  if (!session._execTaskFile) {
16652
17589
  session._execTaskFile = prepareExecTaskFile(terminalSessionId, loopState);
16653
- console.log(`[exec-loop-driver] Task file prepared: ${session._execTaskFile}`);
17590
+ loopLog(terminalSessionId, 'exec-driver:taskfile', 'task file prepared', { path: session._execTaskFile });
16654
17591
  }
16655
17592
 
16656
- // Fire next iteration with sentinel echo appended
17593
+ // Fire next iteration with sentinel echo appended (captures exit code)
16657
17594
  const cmd = buildExecCommand(loopState.profile, loopState, session._execTaskFile);
16658
17595
  const nextIter = iter + 1;
16659
17596
  const sentinel = `${EXEC_SENTINEL_PREFIX}${nextIter}`;
16660
- const fullCmd = `${cmd}; echo ${sentinel}`;
16661
- console.log(`[exec-loop-driver] Starting iteration ${nextIter}/${loopState.totalIterations}`);
17597
+ const fullCmd = `${cmd}; rc=$?; echo ${sentinel} rc=$rc`;
17598
+ loopLog(terminalSessionId, 'exec-driver:iter-begin', `starting iteration ${nextIter}/${loopState.totalIterations}`, {
17599
+ profile: loopState.profile, sentinel,
17600
+ cmdPreview: fullCmd.slice(0, 300),
17601
+ cmdBytes: fullCmd.length,
17602
+ });
16662
17603
 
16663
17604
  // Grace delay before sending command
16664
17605
  await new Promise(r => setTimeout(r, 2000));
@@ -16666,6 +17607,7 @@ function attachExecLoopDriver(terminalSessionId) {
16666
17607
  session._loopDriverBuffer = '';
16667
17608
  session._execDriverState = 'running';
16668
17609
  session._execCurrentIter = nextIter;
17610
+ session._execIterStartedAt = Date.now();
16669
17611
  writeToLoopPty(session, fullCmd, true);
16670
17612
 
16671
17613
  // Update loop state
@@ -16676,10 +17618,41 @@ function attachExecLoopDriver(terminalSessionId) {
16676
17618
  }
16677
17619
 
16678
17620
  else if (state === 'running') {
16679
- // Detect iteration completion via sentinel echo in PTY output
16680
- const sentinel = `${EXEC_SENTINEL_PREFIX}${session._execCurrentIter}`;
16681
- if (stripped.includes(sentinel)) {
16682
- console.log(`[exec-loop-driver] Iteration ${session._execCurrentIter} complete — sentinel detected`);
17621
+ // Detect iteration completion via sentinel echo + capture rc
17622
+ const sentinelRe = new RegExp(`${EXEC_SENTINEL_PREFIX}${session._execCurrentIter} rc=(-?\\d+)`);
17623
+ const m = stripped.match(sentinelRe);
17624
+ if (m) {
17625
+ const rc = parseInt(m[1], 10);
17626
+ const iterDurationMs = Date.now() - (session._execIterStartedAt || Date.now());
17627
+ const isFastFail = iterDurationMs < FAST_FAIL_THRESHOLD_MS;
17628
+ const isNonZero = rc !== 0;
17629
+
17630
+ if (isNonZero) consecutiveNonZero++; else consecutiveNonZero = 0;
17631
+ if (isFastFail) consecutiveFastFails++; else consecutiveFastFails = 0;
17632
+
17633
+ loopLog(terminalSessionId, 'exec-driver:iter-end', `iteration ${session._execCurrentIter} complete`, {
17634
+ rc, iterDurationMs, isFastFail, isNonZero,
17635
+ consecutiveNonZero, consecutiveFastFails,
17636
+ });
17637
+
17638
+ // Halt on failure streak (3 consecutive non-zero OR 3 consecutive fast)
17639
+ if (consecutiveNonZero >= FAIL_LIMIT || consecutiveFastFails >= FAIL_LIMIT) {
17640
+ const reason = consecutiveNonZero >= FAIL_LIMIT ? 'fast-fail-rc' : 'fast-fail-duration';
17641
+ loopState.active = false;
17642
+ loopState.completedAt = new Date().toISOString();
17643
+ loopState.stoppedReason = reason;
17644
+ loopState.lastExitCode = rc;
17645
+ try { writeFileSync(loopPath, JSON.stringify(loopState, null, 2)); } catch { /* ok */ }
17646
+ loopLog(terminalSessionId, 'exec-driver:halt', `halting loop: ${reason}`, {
17647
+ rc, iterDurationMs, consecutiveNonZero, consecutiveFastFails,
17648
+ });
17649
+ clearInterval(interval);
17650
+ if (session._execTaskFile) try { unlinkSync(session._execTaskFile); } catch { /* ok */ }
17651
+ try { session.pty?.kill?.(); } catch { /* ok */ }
17652
+ driving = false;
17653
+ return;
17654
+ }
17655
+
16683
17656
  session._loopDriverBuffer = '';
16684
17657
  session._execDriverState = 'ready';
16685
17658
  // Cooldown before next iteration
@@ -19525,7 +20498,7 @@ async function createBrowserSession(options = {}) {
19525
20498
  const extra = savedCfg.extraArgs.split(/\s+/).filter(Boolean);
19526
20499
  stealthArgs.push(...extra);
19527
20500
  }
19528
- const launchOpts = { headless: headlessVal, args: stealthArgs };
20501
+ const launchOpts = { headless: headlessVal, args: stealthArgs, timeout: 45000 };
19529
20502
  // Priority: user-configured executablePath > channel > auto-detected executablePath.
19530
20503
  // User explicitly set executablePath = intentional override, always respect it.
19531
20504
  // Channel = let Playwright resolve the binary for that browser brand.
@@ -19649,6 +20622,46 @@ async function createBrowserSession(options = {}) {
19649
20622
  const launchPersistentProfile = async (launchRoot, launchProfileDir, launchMode) => {
19650
20623
  const launchArgs = [...stealthArgs];
19651
20624
 
20625
+ // Kill orphan Chrome processes holding this user-data-dir BEFORE removing locks.
20626
+ // Otherwise launchPersistentContext hangs indefinitely waiting for the singleton.
20627
+ try {
20628
+ const { execSync } = await import('node:child_process');
20629
+ let pids = [];
20630
+ if (IS_WIN) {
20631
+ // Windows: PowerShell Get-CimInstance filters by CommandLine substring.
20632
+ // Pass launchRoot via env var to avoid quoting hell.
20633
+ const psScript = `$root = $env:SYNABUN_KILL_ROOT; Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='msedge.exe' OR Name='chromium.exe'" | Where-Object { $_.CommandLine -like "*user-data-dir=$root*" } | ForEach-Object { $_.ProcessId }`;
20634
+ const out = execSync(`powershell -NoProfile -NonInteractive -Command -`, {
20635
+ input: psScript,
20636
+ encoding: 'utf-8',
20637
+ timeout: 10000,
20638
+ env: { ...process.env, SYNABUN_KILL_ROOT: launchRoot },
20639
+ windowsHide: true,
20640
+ }).trim();
20641
+ pids = out.split(/\s+/).filter(Boolean).map(p => parseInt(p, 10)).filter(p => p > 0 && p !== process.pid);
20642
+ } else {
20643
+ const needle = `user-data-dir=${launchRoot}`;
20644
+ const out = execSync(`pgrep -f ${JSON.stringify(needle)} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
20645
+ pids = out.split(/\s+/).filter(Boolean).map(p => parseInt(p, 10)).filter(p => p > 0 && p !== process.pid);
20646
+ }
20647
+ if (pids.length) {
20648
+ console.log(`[browser] Killing ${pids.length} orphan Chrome process(es) holding ${launchMode} root: ${pids.join(',')}`);
20649
+ for (const pid of pids) {
20650
+ try {
20651
+ if (IS_WIN) {
20652
+ // process.kill on Windows only does SIGTERM-like; taskkill /F is reliable.
20653
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true });
20654
+ } else {
20655
+ process.kill(pid, 'SIGKILL');
20656
+ }
20657
+ } catch {}
20658
+ }
20659
+ await new Promise(r => setTimeout(r, 300));
20660
+ }
20661
+ } catch (err) {
20662
+ console.warn(`[browser] Orphan kill scan failed: ${err.message}`);
20663
+ }
20664
+
19652
20665
  for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'DevToolsActivePort']) {
19653
20666
  const lf = resolve(launchRoot, lockFile);
19654
20667
  try {
@@ -19724,34 +20737,24 @@ async function createBrowserSession(options = {}) {
19724
20737
  if (_selectedProfileKind === 'system') {
19725
20738
  const requestedProfileDir = _profileDirectory || 'Default';
19726
20739
 
19727
- // CDP mode with mirrored profile: Chrome refuses remote debugging when
19728
- // using its own default user-data-dir. Copy the profile to a mirror
19729
- // directory (non-default) and spawn Chrome from there via CDP.
20740
+ // System browser profiles cannot be launched directly from Chrome's real
20741
+ // User Data dir while the user's browser may be running. Launch an
20742
+ // isolated mirror instead, using Playwright's persistent-context path.
19730
20743
  const { mirrorRoot } = prepareMirroredProfile(userDataDir, requestedProfileDir, _sourceBrowser || 'chrome');
19731
20744
 
19732
20745
  try {
19733
- const cdpResult = await ensureChromeDebuggable({
19734
- userDataDir: mirrorRoot,
19735
- profileDirectory: requestedProfileDir,
19736
- executablePath: launchOpts.executablePath || undefined,
19737
- channel: launchOpts.channel || undefined,
19738
- viewport: { width: vpW, height: vpH },
19739
- });
19740
-
19741
- browser = await chromium.connectOverCDP(cdpResult.endpoint);
19742
- context = browser.contexts()[0];
19743
- if (!context) throw new Error('CDP connected but no browser context available');
19744
-
19745
- page = await context.newPage();
19746
- _isPersistent = false;
19747
- _profileMode = 'cdp';
20746
+ const launched = await launchPersistentProfile(mirrorRoot, requestedProfileDir, 'mirror');
20747
+ browser = launched.launchBrowser;
20748
+ context = launched.launchContext;
20749
+ page = launched.launchPage;
20750
+ _profileMode = 'mirror';
19748
20751
  _launchUserDataDir = mirrorRoot;
19749
20752
  _launchProfileDirectory = requestedProfileDir;
19750
20753
 
19751
- console.log(`[browser] CDP connected to mirrored ${_profileSourceName || requestedProfileDir} via ${cdpResult.source}`);
19752
- } catch (cdpErr) {
20754
+ console.log(`[browser] Launched mirrored ${_profileSourceName || requestedProfileDir} profile`);
20755
+ } catch (mirrorErr) {
19753
20756
  throw new Error(
19754
- `Could not connect to ${_browserLabel} via CDP: ${cdpErr.message}`
20757
+ `Could not launch mirrored ${_browserLabel} profile: ${mirrorErr.message}`
19755
20758
  );
19756
20759
  }
19757
20760
  } else {
@@ -20227,6 +21230,16 @@ async function syncPageState(session, routed) {
20227
21230
  return { url, title };
20228
21231
  }
20229
21232
 
21233
+ function syncPageStateCompact(session, routed) {
21234
+ const url = routed.page.url();
21235
+ if (routed.tabId === session.activeTabId) session.currentUrl = url;
21236
+ return { url, title: routed.tabId === session.activeTabId ? (session.title || '') : '' };
21237
+ }
21238
+
21239
+ async function getBrowserActionState(session, routed, compact) {
21240
+ return compact ? syncPageStateCompact(session, routed) : syncPageState(session, routed);
21241
+ }
21242
+
20230
21243
  // ── Browser REST endpoints ──
20231
21244
 
20232
21245
  app.get('/api/browser/sessions', (req, res) => {
@@ -20334,10 +21347,10 @@ app.post('/api/browser/sessions/:id/navigate', async (req, res) => {
20334
21347
  if (!session) return res.status(404).json({ error: 'Session not found' });
20335
21348
  const routed = getTargetPage(session, req);
20336
21349
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20337
- const { url, returnSnapshot } = req.body;
21350
+ const { url, returnSnapshot, compact } = req.body;
20338
21351
  try {
20339
21352
  await routed.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
20340
- const state = await syncPageState(session, routed);
21353
+ const state = await getBrowserActionState(session, routed, compact);
20341
21354
  const body = { ok: true, url: state.url, title: state.title };
20342
21355
  if (returnSnapshot) {
20343
21356
  const snap = await buildSnapshotResponse(routed.page, {
@@ -20360,7 +21373,7 @@ app.post('/api/browser/sessions/:id/back', async (req, res) => {
20360
21373
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20361
21374
  try {
20362
21375
  await routed.page.goBack({ timeout: 10000 });
20363
- const state = await syncPageState(session, routed);
21376
+ const state = await getBrowserActionState(session, routed, req.body?.compact);
20364
21377
  res.json({ ok: true, url: state.url, title: state.title });
20365
21378
  } catch (err) {
20366
21379
  res.json({ ok: false, error: err.message });
@@ -20374,7 +21387,7 @@ app.post('/api/browser/sessions/:id/forward', async (req, res) => {
20374
21387
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20375
21388
  try {
20376
21389
  await routed.page.goForward({ timeout: 10000 });
20377
- const state = await syncPageState(session, routed);
21390
+ const state = await getBrowserActionState(session, routed, req.body?.compact);
20378
21391
  res.json({ ok: true, url: state.url, title: state.title });
20379
21392
  } catch (err) {
20380
21393
  res.json({ ok: false, error: err.message });
@@ -20388,7 +21401,7 @@ app.post('/api/browser/sessions/:id/reload', async (req, res) => {
20388
21401
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20389
21402
  try {
20390
21403
  await routed.page.reload({ timeout: 15000 });
20391
- const state = await syncPageState(session, routed);
21404
+ const state = await getBrowserActionState(session, routed, req.body?.compact);
20392
21405
  res.json({ ok: true, url: state.url, title: state.title });
20393
21406
  } catch (err) {
20394
21407
  res.json({ ok: false, error: err.message });
@@ -20646,14 +21659,14 @@ app.post('/api/browser/sessions/:id/click', async (req, res) => {
20646
21659
  if (!session) return res.status(404).json({ error: 'Session not found' });
20647
21660
  const routed = getTargetPage(session, req);
20648
21661
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20649
- const { selector: rawSelector, timeout, nthMatch, textHint, returnSnapshot } = req.body;
21662
+ const { selector: rawSelector, timeout, nthMatch, textHint, returnSnapshot, compact } = req.body;
20650
21663
  if (!rawSelector) return res.status(400).json({ error: 'selector required' });
20651
21664
  const selector = normalizeSelector(rawSelector);
20652
21665
  try {
20653
21666
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20654
21667
  if (resolved.error) return res.status(400).json(resolved.error);
20655
21668
  await resolved.target.click({ timeout: timeout || 5000 });
20656
- const state = await syncPageState(session, routed);
21669
+ const state = await getBrowserActionState(session, routed, compact);
20657
21670
  const body = { ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) };
20658
21671
  if (returnSnapshot) {
20659
21672
  const snap = await buildSnapshotResponse(routed.page, {
@@ -20675,14 +21688,14 @@ app.post('/api/browser/sessions/:id/fill', async (req, res) => {
20675
21688
  if (!session) return res.status(404).json({ error: 'Session not found' });
20676
21689
  const routed = getTargetPage(session, req);
20677
21690
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20678
- const { selector: rawFillSel, value, timeout, nthMatch, textHint } = req.body;
21691
+ const { selector: rawFillSel, value, timeout, nthMatch, textHint, compact } = req.body;
20679
21692
  if (!rawFillSel) return res.status(400).json({ error: 'selector required' });
20680
21693
  const selector = normalizeSelector(rawFillSel);
20681
21694
  try {
20682
21695
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20683
21696
  if (resolved.error) return res.status(400).json(resolved.error);
20684
21697
  await resolved.target.fill(value ?? '', { timeout: timeout || 5000 });
20685
- const state = await syncPageState(session, routed);
21698
+ const state = await getBrowserActionState(session, routed, compact);
20686
21699
  res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
20687
21700
  } catch (err) {
20688
21701
  res.status(500).json({ error: err.message });
@@ -20694,7 +21707,7 @@ app.post('/api/browser/sessions/:id/type', async (req, res) => {
20694
21707
  if (!session) return res.status(404).json({ error: 'Session not found' });
20695
21708
  const routed = getTargetPage(session, req);
20696
21709
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20697
- const { selector: rawTypeSel, text, timeout, nthMatch, textHint } = req.body;
21710
+ const { selector: rawTypeSel, text, timeout, nthMatch, textHint, mode, compact } = req.body;
20698
21711
  try {
20699
21712
  let healed = false;
20700
21713
  if (rawTypeSel) {
@@ -20702,11 +21715,20 @@ app.post('/api/browser/sessions/:id/type', async (req, res) => {
20702
21715
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20703
21716
  if (resolved.error) return res.status(400).json(resolved.error);
20704
21717
  healed = resolved.healed;
20705
- await resolved.target.pressSequentially(text ?? '', { timeout: timeout || 5000 });
21718
+ if (mode === 'insert') {
21719
+ await resolved.target.focus({ timeout: timeout || 5000 });
21720
+ await routed.page.keyboard.insertText(text ?? '');
21721
+ } else {
21722
+ await resolved.target.pressSequentially(text ?? '', { timeout: timeout || 5000 });
21723
+ }
20706
21724
  } else {
20707
- await routed.page.keyboard.type(text ?? '');
21725
+ if (mode === 'insert') {
21726
+ await routed.page.keyboard.insertText(text ?? '');
21727
+ } else {
21728
+ await routed.page.keyboard.type(text ?? '');
21729
+ }
20708
21730
  }
20709
- const state = await syncPageState(session, routed);
21731
+ const state = await getBrowserActionState(session, routed, compact);
20710
21732
  res.json({ ok: true, url: state.url, title: state.title, ...(healed ? { healed: true } : {}) });
20711
21733
  } catch (err) {
20712
21734
  res.status(500).json({ error: err.message });
@@ -20718,14 +21740,14 @@ app.post('/api/browser/sessions/:id/hover', async (req, res) => {
20718
21740
  if (!session) return res.status(404).json({ error: 'Session not found' });
20719
21741
  const routed = getTargetPage(session, req);
20720
21742
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20721
- const { selector: rawHoverSel, timeout, nthMatch, textHint } = req.body;
21743
+ const { selector: rawHoverSel, timeout, nthMatch, textHint, compact } = req.body;
20722
21744
  if (!rawHoverSel) return res.status(400).json({ error: 'selector required' });
20723
21745
  const selector = normalizeSelector(rawHoverSel);
20724
21746
  try {
20725
21747
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20726
21748
  if (resolved.error) return res.status(400).json(resolved.error);
20727
21749
  await resolved.target.hover({ timeout: timeout || 5000 });
20728
- const state = await syncPageState(session, routed);
21750
+ const state = await getBrowserActionState(session, routed, compact);
20729
21751
  res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
20730
21752
  } catch (err) {
20731
21753
  res.status(500).json({ error: err.message });
@@ -20737,14 +21759,14 @@ app.post('/api/browser/sessions/:id/select', async (req, res) => {
20737
21759
  if (!session) return res.status(404).json({ error: 'Session not found' });
20738
21760
  const routed = getTargetPage(session, req);
20739
21761
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20740
- const { selector: rawSelSel, value, timeout, nthMatch, textHint } = req.body;
21762
+ const { selector: rawSelSel, value, timeout, nthMatch, textHint, compact } = req.body;
20741
21763
  if (!rawSelSel) return res.status(400).json({ error: 'selector required' });
20742
21764
  const selector = normalizeSelector(rawSelSel);
20743
21765
  try {
20744
21766
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20745
21767
  if (resolved.error) return res.status(400).json(resolved.error);
20746
21768
  await resolved.target.selectOption(value ?? '', { timeout: timeout || 5000 });
20747
- const state = await syncPageState(session, routed);
21769
+ const state = await getBrowserActionState(session, routed, compact);
20748
21770
  res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
20749
21771
  } catch (err) {
20750
21772
  res.status(500).json({ error: err.message });
@@ -20756,11 +21778,11 @@ app.post('/api/browser/sessions/:id/press', async (req, res) => {
20756
21778
  if (!session) return res.status(404).json({ error: 'Session not found' });
20757
21779
  const routed = getTargetPage(session, req);
20758
21780
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20759
- const { key } = req.body;
21781
+ const { key, compact } = req.body;
20760
21782
  if (!key) return res.status(400).json({ error: 'key required' });
20761
21783
  try {
20762
21784
  await routed.page.keyboard.press(key);
20763
- const state = await syncPageState(session, routed);
21785
+ const state = await getBrowserActionState(session, routed, compact);
20764
21786
  res.json({ ok: true, url: state.url, title: state.title });
20765
21787
  } catch (err) {
20766
21788
  res.status(500).json({ error: err.message });
@@ -20923,13 +21945,13 @@ app.post('/api/browser/sessions/:id/wait', async (req, res) => {
20923
21945
  if (!session) return res.status(404).json({ error: 'Session not found' });
20924
21946
  const routed = getTargetPage(session, req);
20925
21947
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20926
- const { selector, state, loadState, timeout } = req.body;
21948
+ const { selector, state, loadState, timeout, compact } = req.body;
20927
21949
  try {
20928
21950
  if (loadState) {
20929
21951
  const valid = ['load', 'domcontentloaded', 'networkidle'];
20930
21952
  if (!valid.includes(loadState)) return res.status(400).json({ error: `Invalid loadState. Use: ${valid.join(', ')}` });
20931
21953
  await routed.page.waitForLoadState(loadState, { timeout: timeout || 15000 });
20932
- const ps = await syncPageState(session, routed);
21954
+ const ps = await getBrowserActionState(session, routed, compact);
20933
21955
  res.json({ ok: true, loadState, url: ps.url, title: ps.title });
20934
21956
  } else if (selector) {
20935
21957
  await routed.page.locator(selector).waitFor({
@@ -20939,7 +21961,7 @@ app.post('/api/browser/sessions/:id/wait', async (req, res) => {
20939
21961
  res.json({ ok: true, selector, state: state || 'visible' });
20940
21962
  } else {
20941
21963
  await routed.page.waitForTimeout(timeout || 1000);
20942
- const ps = await syncPageState(session, routed);
21964
+ const ps = await getBrowserActionState(session, routed, compact);
20943
21965
  res.json({ ok: true, url: ps.url, title: ps.title });
20944
21966
  }
20945
21967
  } catch (err) {
@@ -21096,7 +22118,7 @@ app.post('/api/browser/sessions/:id/scroll', async (req, res) => {
21096
22118
  if (!session) return res.status(404).json({ error: 'Session not found' });
21097
22119
  const routed = getTargetPage(session, req);
21098
22120
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
21099
- const { direction = 'down', distance = 500, selector: rawSel, returnSnapshot } = req.body;
22121
+ const { direction = 'down', distance = 500, selector: rawSel, returnSnapshot, compact } = req.body;
21100
22122
  const deltaX = direction === 'right' ? distance : direction === 'left' ? -distance : 0;
21101
22123
  const deltaY = direction === 'down' ? distance : direction === 'up' ? -distance : 0;
21102
22124
  try {
@@ -21108,7 +22130,7 @@ app.post('/api/browser/sessions/:id/scroll', async (req, res) => {
21108
22130
  await routed.page.evaluate(({ dx, dy }) => window.scrollBy(dx, dy), { dx: deltaX, dy: deltaY });
21109
22131
  }
21110
22132
  await routed.page.waitForTimeout(300);
21111
- const state = await syncPageState(session, routed);
22133
+ const state = await getBrowserActionState(session, routed, compact);
21112
22134
  const body = { ok: true, url: state.url, title: state.title };
21113
22135
  if (returnSnapshot) {
21114
22136
  const snap = await buildSnapshotResponse(routed.page, {
@@ -21129,7 +22151,7 @@ app.post('/api/browser/sessions/:id/upload', async (req, res) => {
21129
22151
  if (!session) return res.status(404).json({ error: 'Session not found' });
21130
22152
  const routed = getTargetPage(session, req);
21131
22153
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
21132
- const { selector: rawSel, filePaths, nthMatch } = req.body;
22154
+ const { selector: rawSel, filePaths, nthMatch, compact } = req.body;
21133
22155
  if (!rawSel) return res.status(400).json({ error: 'selector required' });
21134
22156
  if (!Array.isArray(filePaths) || !filePaths.length) return res.status(400).json({ error: 'filePaths must be a non-empty array' });
21135
22157
  const selector = normalizeSelector(rawSel);
@@ -21146,7 +22168,7 @@ app.post('/api/browser/sessions/:id/upload', async (req, res) => {
21146
22168
  const target = (count > 1 && nthMatch !== undefined) ? loc.nth(nthMatch) : loc;
21147
22169
  await target.setInputFiles(filePaths);
21148
22170
  await routed.page.waitForTimeout(500);
21149
- const state = await syncPageState(session, routed);
22171
+ const state = await getBrowserActionState(session, routed, compact);
21150
22172
  res.json({ ok: true, url: state.url, title: state.title });
21151
22173
  } catch (err) {
21152
22174
  res.status(500).json({ error: err.message });
@@ -21288,6 +22310,15 @@ const httpServer = app.listen(PORT, async () => {
21288
22310
  setKvConfig('embedding_dims', String(EMBEDDING_DIMS));
21289
22311
  } catch {}
21290
22312
 
22313
+ // Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
22314
+ try {
22315
+ const repair = pluginInstaller.repairSynabunPlugins();
22316
+ if (repair?.repaired?.length) console.log(`[plugins] repaired legacy installs: ${repair.repaired.map(r => r.marketplaceName).join(', ')}`);
22317
+ if (repair?.failed?.length) console.warn(`[plugins] repair failed for: ${repair.failed.map(r => r.marketplaceName + ' (' + r.error + ')').join(', ')}`);
22318
+ } catch (err) {
22319
+ console.warn('[plugins] repair on startup failed:', err.message);
22320
+ }
22321
+
21291
22322
  // SQLite indexes are created in schema — no runtime index creation needed
21292
22323
 
21293
22324
  // Warm the session_cache + session_fts tables so the hybrid search has data
@@ -21469,10 +22500,15 @@ wss.on('connection', (ws, req) => {
21469
22500
  // Cancel grace timer — a client reconnected
21470
22501
  if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
21471
22502
 
21472
- // Replay buffered output so reconnecting clients see prior content
21473
- if (session.outputBuffer.length > 0) {
21474
- const replay = session.outputBuffer.join('');
21475
- ws.send(JSON.stringify({ type: 'replay', data: replay }));
22503
+ // Restore reconnecting clients from the server-side terminal model instead
22504
+ // of replaying a truncated raw ANSI tail. Raw tail replay can start inside an
22505
+ // alternate-screen frame, OSC string, or partial cursor sequence, corrupting
22506
+ // xterm state for long-running TUIs.
22507
+ if (session.outputBufferBytes > 0) {
22508
+ const snapshot = terminalSnapshotToAnsi(session);
22509
+ if (snapshot) {
22510
+ ws.send(JSON.stringify({ type: 'snapshot', data: snapshot, plain: terminalSnapshotToText(session) }));
22511
+ }
21476
22512
  }
21477
22513
 
21478
22514
  ws.on('message', (raw) => {
@@ -21500,6 +22536,7 @@ wss.on('connection', (ws, req) => {
21500
22536
  session._appliedResize.cols === r.cols &&
21501
22537
  session._appliedResize.rows === r.rows) return;
21502
22538
  session._appliedResize = { cols: r.cols, rows: r.rows };
22539
+ try { session.vterm?.resize(r.cols, r.rows); } catch {}
21503
22540
  try { session.pty.resize(r.cols, r.rows); } catch {}
21504
22541
  };
21505
22542
  if (!last) {