synabun 2026.4.21 → 2026.4.25-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +161 -881
  2. package/README.md +1 -1
  3. package/hooks/claude-code/prompt-submit.mjs +39 -18
  4. package/hooks/claude-code/session-start.mjs +10 -3
  5. package/hooks/claude-code/shared.mjs +39 -2
  6. package/hooks/claude-code/stop.mjs +33 -6
  7. package/mcp-server/src/index.ts +9 -1
  8. package/mcp-server/src/services/neural-interface.ts +42 -14
  9. package/mcp-server/src/services/profiles.ts +8 -7
  10. package/mcp-server/src/tools/browser-advanced.ts +12 -10
  11. package/mcp-server/src/tools/browser-interact.ts +39 -38
  12. package/mcp-server/src/tools/browser-navigate.ts +12 -4
  13. package/mcp-server/src/tools/loop.ts +7 -3
  14. package/mcp-server/src/tools/profile.ts +19 -1
  15. package/neural-interface/lib/loop-logger.js +103 -0
  16. package/neural-interface/lib/mcp-installer.js +109 -0
  17. package/neural-interface/lib/plugin-installer.js +264 -0
  18. package/neural-interface/package.json +1 -1
  19. package/neural-interface/public/shared/api.js +66 -17
  20. package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
  21. package/neural-interface/public/shared/cdx/cdx-panel.js +44 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +23 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +384 -70
  25. package/neural-interface/public/shared/constants.js +3 -2
  26. package/neural-interface/public/shared/html-shell.js +32 -11
  27. package/neural-interface/public/shared/ocp/ocp-panel.js +45 -28
  28. package/neural-interface/public/shared/ocp/ocp-render.js +50 -43
  29. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  30. package/neural-interface/public/shared/styles.css +635 -0
  31. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  32. package/neural-interface/public/shared/ui-claude-panel.js +216 -21
  33. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  34. package/neural-interface/public/shared/ui-menubar.js +34 -10
  35. package/neural-interface/public/shared/ui-navbar.js +8 -6
  36. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  37. package/neural-interface/public/shared/ui-settings.js +274 -50
  38. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  39. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  40. package/neural-interface/public/shared/ui-sync.js +9 -5
  41. package/neural-interface/public/shared/ui-terminal.js +76 -27
  42. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  43. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  44. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  45. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  46. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  47. package/neural-interface/public/variant/2d/main.js +9 -7
  48. package/neural-interface/public/variant/3d/main.js +10 -8
  49. package/neural-interface/server.js +1281 -252
  50. package/package.json +1 -1
  51. package/skills/leonardo/SKILL.md +5 -1
  52. package/skills/leonardo/modules/image-prompter.md +5 -1
  53. package/skills/leonardo/modules/video-prompter.md +5 -1
  54. package/skills/synabun/SKILL.md +44 -26
  55. package/skills/synabun/modules/audit.md +40 -36
  56. package/skills/synabun/modules/changelog.md +22 -18
  57. package/skills/synabun/modules/memorize.md +19 -15
  58. package/skills/synabun/modules/schedule.md +285 -0
  59. package/CLAUDE.md +0 -217
@@ -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);
@@ -10802,10 +11186,25 @@ app.get('/api/latest-plan', (req, res) => {
10802
11186
  }
10803
11187
  });
10804
11188
 
11189
+ function cleanupMatchingRootPlan(cwd, content) {
11190
+ if (!cwd || typeof cwd !== 'string' || typeof content !== 'string') return false;
11191
+ try {
11192
+ const rootPlan = resolve(cwd, 'PLAN.md');
11193
+ if (!validateProjectPath(rootPlan) || !existsSync(rootPlan) || !statSync(rootPlan).isFile()) return false;
11194
+ const existing = readFileSync(rootPlan, 'utf-8').replace(/\r\n/g, '\n').trim();
11195
+ const next = content.replace(/\r\n/g, '\n').trim();
11196
+ if (!existing || existing !== next) return false;
11197
+ unlinkSync(rootPlan);
11198
+ return true;
11199
+ } catch {
11200
+ return false;
11201
+ }
11202
+ }
11203
+
10805
11204
  // POST /api/create-plan — Materialize plan content into a file in data/plans/YYYY-MM-DD/slug.md
10806
11205
  app.post('/api/create-plan', (req, res) => {
10807
11206
  try {
10808
- const { content } = req.body;
11207
+ const { content, cwd, projectPath } = req.body;
10809
11208
  if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Missing content' });
10810
11209
 
10811
11210
  // Date-organized folder
@@ -10835,7 +11234,8 @@ app.post('/api/create-plan', (req, res) => {
10835
11234
  }
10836
11235
 
10837
11236
  writeFileSync(fullPath, content, 'utf-8');
10838
- res.json({ ok: true, path: fullPath.replace(/\\/g, '/'), name });
11237
+ const cleanedRootPlan = cleanupMatchingRootPlan(projectPath || cwd || '', content);
11238
+ res.json({ ok: true, path: fullPath.replace(/\\/g, '/'), name, cleanedRootPlan });
10839
11239
  } catch (err) {
10840
11240
  res.status(500).json({ error: err.message });
10841
11241
  }
@@ -12473,12 +12873,15 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
12473
12873
  const text = typeof content === 'string' ? content
12474
12874
  : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n')
12475
12875
  : '';
12476
- if (text) { messages.push({ role: 'user', text: text.slice(0, 8000) }); turns++; }
12876
+ if (text) { messages.push({ role: 'user', text: text.slice(0, 64000) }); turns++; }
12477
12877
  } else if (obj.type === 'assistant') {
12478
12878
  const content = obj.message?.content;
12479
12879
  const textBlocks = Array.isArray(content)
12480
12880
  ? content.filter(b => b.type === 'text').map(b => b.text)
12481
12881
  : [];
12882
+ const thinkingBlocks = Array.isArray(content)
12883
+ ? content.filter(b => b.type === 'thinking').map(b => b.thinking || b.text || '').filter(Boolean)
12884
+ : [];
12482
12885
  const toolUseBlocks = Array.isArray(content)
12483
12886
  ? content.filter(b => b.type === 'tool_use').map(b => ({
12484
12887
  id: b.id,
@@ -12487,10 +12890,12 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
12487
12890
  }))
12488
12891
  : [];
12489
12892
  const text = textBlocks.join('\n');
12490
- if (text || toolUseBlocks.length) {
12893
+ const thinking = thinkingBlocks.join('\n\n');
12894
+ if (text || toolUseBlocks.length || thinking) {
12491
12895
  messages.push({
12492
12896
  role: 'assistant',
12493
- text: text.slice(0, 8000) || undefined,
12897
+ text: text.slice(0, 64000) || undefined,
12898
+ thinking: thinking.slice(0, 32000) || undefined,
12494
12899
  tools: toolUseBlocks.length ? toolUseBlocks : undefined,
12495
12900
  });
12496
12901
  }
@@ -12511,7 +12916,7 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
12511
12916
  messages.push({
12512
12917
  role: 'tool_result',
12513
12918
  toolUseId,
12514
- text: resultText.slice(0, 4000) || undefined,
12919
+ text: resultText.slice(0, 32000) || undefined,
12515
12920
  isError: obj.is_error || obj.message?.is_error || false,
12516
12921
  });
12517
12922
  }
@@ -13700,20 +14105,24 @@ function syncProfileToCodex(profile) {
13700
14105
  const envMatch = section.match(envRe);
13701
14106
  let newSection;
13702
14107
  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}" `;
14108
+ let body = envMatch[1].trim().replace(/,$/, '');
14109
+ const upserts = {
14110
+ SYNABUN_PROFILE: profile,
14111
+ SYNABUN_BROWSER_FAST: '1',
14112
+ SYNABUN_BROWSER_COMPACT: '1',
14113
+ };
14114
+ for (const [key, value] of Object.entries(upserts)) {
14115
+ const pair = `${key} = "${value}"`;
14116
+ const keyRe = new RegExp(`${key}\\s*=\\s*"[^"]*"`);
14117
+ if (keyRe.test(body)) {
14118
+ body = body.replace(keyRe, pair);
14119
+ } else {
14120
+ body = body ? `${body}, ${pair}` : pair;
14121
+ }
13713
14122
  }
13714
- newSection = section.replace(envRe, `env = {${newBody}}`);
14123
+ newSection = section.replace(envRe, `env = { ${body} }`);
13715
14124
  } else {
13716
- newSection = section.trimEnd() + `\nenv = { SYNABUN_PROFILE = "${profile}" }\n`;
14125
+ newSection = section.trimEnd() + `\nenv = { SYNABUN_PROFILE = "${profile}", SYNABUN_BROWSER_FAST = "1", SYNABUN_BROWSER_COMPACT = "1" }\n`;
13717
14126
  }
13718
14127
  if (newSection === section) return false;
13719
14128
  const updated = content.slice(0, range.start) + newSection + content.slice(range.end);
@@ -13783,6 +14192,28 @@ function getHomePath() {
13783
14192
  return process.env.USERPROFILE || process.env.HOME || '';
13784
14193
  }
13785
14194
 
14195
+ function getCodexMcpProfileForSetup() {
14196
+ const current = readActiveMcpProfile();
14197
+ return current && current !== 'full' ? current : 'codex-browser';
14198
+ }
14199
+
14200
+ function buildCodexMcpEnv(profile = getCodexMcpProfileForSetup()) {
14201
+ const { envPath } = getMcpPaths();
14202
+ return {
14203
+ DOTENV_PATH: envPath,
14204
+ SYNABUN_DATA_HOME: DATA_HOME,
14205
+ MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data'),
14206
+ SYNABUN_PROFILE: profile,
14207
+ SYNABUN_BROWSER_FAST: '1',
14208
+ SYNABUN_BROWSER_COMPACT: '1',
14209
+ };
14210
+ }
14211
+
14212
+ function buildCodexMcpToml(profile = getCodexMcpProfileForSetup()) {
14213
+ const { mcpIndexPath } = getMcpPaths();
14214
+ return `[mcp_servers.SynaBun]\ncommand = "node"\nargs = ["${mcpIndexPath}"]\nenv = ${tomlSerializeValue(buildCodexMcpEnv(profile))}\n`;
14215
+ }
14216
+
13786
14217
  // ── Gemini MCP endpoints ──
13787
14218
 
13788
14219
  app.get('/api/setup/gemini/mcp', (req, res) => {
@@ -13839,10 +14270,11 @@ app.delete('/api/setup/gemini/mcp', (req, res) => {
13839
14270
  app.get('/api/setup/codex/mcp', (req, res) => {
13840
14271
  try {
13841
14272
  const configPath = join(getHomePath(), '.codex', 'config.toml');
13842
- if (!existsSync(configPath)) return res.json({ ok: true, connected: false });
14273
+ const toml = buildCodexMcpToml();
14274
+ if (!existsSync(configPath)) return res.json({ ok: true, connected: false, toml });
13843
14275
  const content = readFileSync(configPath, 'utf-8');
13844
14276
  const connected = tomlHasSection(content, 'mcp_servers.SynaBun');
13845
- res.json({ ok: true, connected });
14277
+ res.json({ ok: true, connected, toml });
13846
14278
  } catch (err) {
13847
14279
  res.status(500).json({ error: err.message });
13848
14280
  }
@@ -13852,14 +14284,14 @@ app.post('/api/setup/codex/mcp', (req, res) => {
13852
14284
  try {
13853
14285
  const dir = join(getHomePath(), '.codex');
13854
14286
  const configPath = join(dir, 'config.toml');
13855
- const { mcpIndexPath, envPath } = getMcpPaths();
14287
+ const { mcpIndexPath } = getMcpPaths();
13856
14288
  mkdirSync(dir, { recursive: true });
13857
14289
  let content = '';
13858
14290
  try { content = readFileSync(configPath, 'utf-8'); } catch {}
13859
14291
  content = tomlUpsertSection(content, 'mcp_servers.SynaBun', {
13860
14292
  command: 'node',
13861
14293
  args: [mcpIndexPath],
13862
- env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
14294
+ env: buildCodexMcpEnv(),
13863
14295
  });
13864
14296
  writeFileSync(configPath, content, 'utf-8');
13865
14297
  res.json({ ok: true, message: 'SynaBun MCP registered in Codex CLI. Restart Codex to connect.' });
@@ -14216,6 +14648,54 @@ app.get('/api/claude-code/skills', (req, res) => {
14216
14648
  }
14217
14649
  });
14218
14650
 
14651
+ // GET /api/claude-code/plugin-commands — scan installed plugin cache dirs for slash commands
14652
+ app.get('/api/claude-code/plugin-commands', (req, res) => {
14653
+ try {
14654
+ const out = [];
14655
+ const cacheRoot = join(os.homedir(), '.claude', 'plugins', 'cache');
14656
+ if (!existsSync(cacheRoot)) return res.json({ ok: true, commands: [] });
14657
+ const readDesc = (filePath) => {
14658
+ try {
14659
+ const txt = readFileSync(filePath, 'utf-8');
14660
+ const toml = txt.match(/^\s*description\s*=\s*"((?:[^"\\]|\\.)*)"/m);
14661
+ if (toml) return toml[1].replace(/\\"/g, '"');
14662
+ const fm = txt.match(/^---\s*\n([\s\S]*?)\n---/);
14663
+ if (fm) {
14664
+ const m = fm[1].match(/^\s*description\s*:\s*(.+?)\s*$/m);
14665
+ if (m) return m[1].replace(/^["']|["']$/g, '');
14666
+ }
14667
+ return '';
14668
+ } catch { return ''; }
14669
+ };
14670
+ const walkCommands = (pluginRoot, marketplace, plugin) => {
14671
+ const cmdDir = join(pluginRoot, 'commands');
14672
+ if (!existsSync(cmdDir)) return;
14673
+ let entries; try { entries = readdirSync(cmdDir); } catch { return; }
14674
+ for (const f of entries) {
14675
+ if (!/\.(toml|md)$/i.test(f)) continue;
14676
+ const name = f.replace(/\.(toml|md)$/i, '');
14677
+ const description = readDesc(join(cmdDir, f));
14678
+ out.push({ name, description, source: 'plugin', plugin, marketplace });
14679
+ }
14680
+ };
14681
+ let markets; try { markets = readdirSync(cacheRoot); } catch { markets = []; }
14682
+ for (const m of markets) {
14683
+ const mDir = join(cacheRoot, m);
14684
+ let plugins; try { plugins = readdirSync(mDir); } catch { continue; }
14685
+ for (const p of plugins) {
14686
+ const pDir = join(mDir, p);
14687
+ let versions; try { versions = readdirSync(pDir); } catch { continue; }
14688
+ for (const v of versions) {
14689
+ walkCommands(join(pDir, v), m, p);
14690
+ }
14691
+ }
14692
+ }
14693
+ res.json({ ok: true, commands: out });
14694
+ } catch (err) {
14695
+ res.status(500).json({ ok: false, error: err.message });
14696
+ }
14697
+ });
14698
+
14219
14699
  // POST /api/claude-code/skills — install a skill globally (copies entire directory)
14220
14700
  app.post('/api/claude-code/skills', (req, res) => {
14221
14701
  try {
@@ -15183,6 +15663,7 @@ function readMcpRegistry() {
15183
15663
  profiles: {
15184
15664
  core: { label: 'Core', groups: ['git', 'image'], servers: [] },
15185
15665
  standard: { label: 'Standard', groups: ['git', 'image', 'whiteboard', 'card', 'tictactoe'], servers: [] },
15666
+ 'codex-browser': { label: 'Codex Browser', groups: ['git', 'image', 'browser', 'browser_twitter'], servers: [] },
15186
15667
  twitter: { label: 'Twitter/X', groups: ['git', 'image', 'browser', 'browser_twitter'], servers: [] },
15187
15668
  facebook: { label: 'Facebook', groups: ['git', 'image', 'browser', 'browser_facebook'], servers: [] },
15188
15669
  tiktok: { label: 'TikTok', groups: ['git', 'image', 'browser', 'browser_tiktok'], servers: [] },
@@ -15369,7 +15850,11 @@ app.get('/api/mcp/platforms', (req, res) => {
15369
15850
  });
15370
15851
 
15371
15852
  // ── GitHub install flow ──
15372
- // POST /api/mcp/install/github { url, name?, runInstall? } → clones, detects, installs.
15853
+ // POST /api/mcp/install/github { url, name?, runInstall? }
15854
+ // Clones the repo, classifies it, then either:
15855
+ // - kind=mcp: detects + installs deps, returns detected config for MCP registry save
15856
+ // - kind=claude-plugin: symlinks into ~/.claude/plugins/ + edits CC's plugin JSON files
15857
+ // - kind=unknown: returns 422 with hints
15373
15858
  app.post('/api/mcp/install/github', async (req, res) => {
15374
15859
  try {
15375
15860
  const { url, name: nameOverride, runInstall = true } = req.body || {};
@@ -15384,29 +15869,91 @@ app.post('/api/mcp/install/github', async (req, res) => {
15384
15869
  const clone = await mcpInstaller.cloneRepo({ dataHome: DATA_HOME, name, cloneUrl: parsed.cloneUrl, onLine });
15385
15870
  if (!clone.ok) return res.status(500).json({ ok: false, error: clone.error, stderr: clone.stderr, log: lines });
15386
15871
 
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 });
15872
+ const classification = mcpInstaller.classifyRepo(clone.path);
15873
+
15874
+ if (classification.kind === 'claude-plugin') {
15875
+ mcpInstaller.logInstall(DATA_HOME, name, `classified as claude-plugin (${classification.plugin?.manifest?.name || 'unknown'})`);
15876
+ const githubUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
15877
+ const install = pluginInstaller.installClaudePlugin({ repoPath: clone.path, githubUrl });
15878
+ if (!install.ok) {
15879
+ return res.status(500).json({ ok: false, kind: 'claude-plugin', error: install.error, name, repoPath: clone.path, log: lines });
15880
+ }
15881
+ return res.json({
15882
+ ok: true,
15883
+ kind: 'claude-plugin',
15884
+ name,
15885
+ repoPath: clone.path,
15886
+ cloneReused: clone.reused,
15887
+ plugin: {
15888
+ pluginName: install.pluginName,
15889
+ marketplaceName: install.marketplaceName,
15890
+ installPath: install.installPath,
15891
+ marketplacePath: install.marketplacePath,
15892
+ hooks: install.hooks,
15893
+ gitCommitSha: install.gitCommitSha,
15894
+ },
15895
+ hints: classification.hints,
15896
+ log: lines.slice(-50),
15897
+ });
15898
+ }
15899
+
15900
+ if (classification.kind !== 'mcp') {
15901
+ return res.status(422).json({
15902
+ ok: false,
15903
+ kind: 'unknown',
15904
+ error: classification.error || 'Could not identify repo type',
15905
+ name,
15906
+ repoPath: clone.path,
15907
+ hints: classification.hints || [],
15908
+ log: lines,
15909
+ });
15910
+ }
15389
15911
 
15912
+ // Skip local dep install when the detected command fetches at runtime
15913
+ // (uvx, npx, pipx, etc.) — local pip/npm install would be wasted work
15914
+ // AND its failure would mask an otherwise-working server.
15915
+ const selfFetches = mcpInstaller.commandSelfFetches(classification.detected);
15390
15916
  let installResult = { ok: true, skipped: true };
15391
- if (runInstall) {
15917
+ let installWarning = null;
15918
+ if (runInstall && !selfFetches) {
15392
15919
  installResult = await mcpInstaller.installDependencies({ dataHome: DATA_HOME, name, repoPath: clone.path, onLine });
15393
15920
  if (!installResult.ok) {
15394
- return res.status(500).json({ ok: false, error: installResult.error, name, repoPath: clone.path, detected: detect.detected, log: lines });
15921
+ // Soft-fail: keep going so the server is still registered.
15922
+ // The user can fix deps separately; if deps are truly required, the
15923
+ // server will fail at launch and they'll see the error there.
15924
+ installWarning = `Local dependency install failed: ${installResult.error}. The server is still registered — fix deps manually if it fails to launch.`;
15925
+ mcpInstaller.logInstall(DATA_HOME, name, `[warn] ${installWarning}`);
15926
+ }
15927
+ } else if (selfFetches) {
15928
+ installResult = { ok: true, skipped: true, reason: `command '${classification.detected.command}' self-fetches at runtime` };
15929
+ mcpInstaller.logInstall(DATA_HOME, name, `skipping local deps install — ${installResult.reason}`);
15930
+ // Preflight: verify the self-fetcher binary is actually on PATH.
15931
+ // If missing the server still registers, but it will fail at launch
15932
+ // until the user installs the runtime.
15933
+ const preflight = mcpInstaller.checkSelfFetcherAvailable(classification.detected);
15934
+ if (!preflight.available) {
15935
+ const hint = preflight.installHint ? ` Install: ${preflight.installHint}` : '';
15936
+ installWarning = `'${preflight.command}' not found on PATH.${hint}. Server registered anyway — it will fail at launch until the runtime is installed.`;
15937
+ mcpInstaller.logInstall(DATA_HOME, name, `[warn] ${installWarning}`);
15395
15938
  }
15396
15939
  }
15397
15940
 
15398
15941
  // After install, re-detect in case build produced dist/ paths
15399
15942
  const detect2 = mcpInstaller.detectServer(clone.path);
15400
- const detected = detect2.ok ? detect2.detected : detect.detected;
15943
+ const detected = detect2.ok ? detect2.detected : classification.detected;
15401
15944
 
15402
15945
  res.json({
15403
15946
  ok: true,
15947
+ kind: 'mcp',
15404
15948
  name,
15405
15949
  repoPath: clone.path,
15406
15950
  cloneReused: clone.reused,
15407
15951
  detected,
15408
- detectSource: detect.detected?.source,
15952
+ detectSource: classification.detected?.source,
15409
15953
  install: installResult,
15954
+ installWarning,
15955
+ hints: classification.hints,
15956
+ alsoClaudePlugin: classification.alsoClaudePlugin || null,
15410
15957
  log: lines.slice(-50),
15411
15958
  });
15412
15959
  } catch (err) {
@@ -15414,6 +15961,161 @@ app.post('/api/mcp/install/github', async (req, res) => {
15414
15961
  }
15415
15962
  });
15416
15963
 
15964
+ // GET /api/mcp/installed-all — unified view of every externally-installed MCP
15965
+ // across Claude Code / OpenCode / Codex / Gemini + SynaBun's registry + Claude Code plugins.
15966
+ // Excludes SynaBun's built-in tool-group servers (they're baked in, not "installed").
15967
+ app.get('/api/mcp/installed-all', (req, res) => {
15968
+ try {
15969
+ const home = getHomePath();
15970
+ const byName = new Map();
15971
+ const touch = (name, cli, fields = {}) => {
15972
+ if (!byName.has(name)) {
15973
+ byName.set(name, { name, registeredWith: new Set(), transport: null, command: null, args: null, url: null });
15974
+ }
15975
+ const s = byName.get(name);
15976
+ for (const [k, v] of Object.entries(fields)) {
15977
+ if (v !== undefined && (s[k] === null || s[k] === undefined)) s[k] = v;
15978
+ }
15979
+ s.registeredWith.add(cli);
15980
+ };
15981
+
15982
+ // SynaBun registry (external only)
15983
+ try {
15984
+ const reg = readMcpRegistry();
15985
+ for (const [name, info] of Object.entries(reg.servers || {})) {
15986
+ if (info.builtin) continue;
15987
+ touch(name, 'synabun', {
15988
+ transport: info.type || 'stdio',
15989
+ command: info.command || null,
15990
+ args: info.args || null,
15991
+ url: info.url || null,
15992
+ });
15993
+ }
15994
+ } catch {}
15995
+
15996
+ // Claude Code
15997
+ try {
15998
+ const p = join(home, '.claude.json');
15999
+ if (existsSync(p)) {
16000
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
16001
+ for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
16002
+ touch(name, 'claudeCode', {
16003
+ transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
16004
+ command: cfg.command || null,
16005
+ args: cfg.args || null,
16006
+ url: cfg.url || null,
16007
+ });
16008
+ }
16009
+ }
16010
+ } catch {}
16011
+
16012
+ // Gemini
16013
+ try {
16014
+ const p = join(home, '.gemini', 'settings.json');
16015
+ if (existsSync(p)) {
16016
+ const data = JSON.parse(readFileSync(p, 'utf-8'));
16017
+ for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
16018
+ touch(name, 'gemini', {
16019
+ transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
16020
+ command: cfg.command || null,
16021
+ args: cfg.args || null,
16022
+ url: cfg.url || null,
16023
+ });
16024
+ }
16025
+ }
16026
+ } catch {}
16027
+
16028
+ // Codex TOML — only match top-level [mcp_servers.<name>], not [...tools.<name>]
16029
+ try {
16030
+ const p = join(home, '.codex', 'config.toml');
16031
+ if (existsSync(p)) {
16032
+ const txt = readFileSync(p, 'utf-8');
16033
+ const re = /^\[mcp_servers\.([^\].]+)\]\s*$/gm;
16034
+ let m;
16035
+ while ((m = re.exec(txt))) {
16036
+ touch(m[1], 'codex', { transport: 'stdio' });
16037
+ }
16038
+ }
16039
+ } catch {}
16040
+
16041
+ // OpenCode
16042
+ try {
16043
+ const ocPath = getOpencodeConfigPath();
16044
+ if (existsSync(ocPath)) {
16045
+ const oc = JSON.parse(readFileSync(ocPath, 'utf-8'));
16046
+ for (const [name, cfg] of Object.entries(oc?.mcp || {})) {
16047
+ const cmd = Array.isArray(cfg.command) ? cfg.command[0] : cfg.command;
16048
+ const args = Array.isArray(cfg.command) ? cfg.command.slice(1) : cfg.args;
16049
+ touch(name, 'opencode', {
16050
+ transport: cfg.type === 'local' ? 'stdio' : (cfg.type || 'stdio'),
16051
+ command: cmd || null,
16052
+ args: args || null,
16053
+ url: cfg.url || null,
16054
+ });
16055
+ }
16056
+ }
16057
+ } catch {}
16058
+
16059
+ const servers = Array.from(byName.values())
16060
+ .map(s => ({ ...s, registeredWith: [...s.registeredWith].sort() }))
16061
+ .sort((a, b) => a.name.localeCompare(b.name));
16062
+
16063
+ let plugins = [];
16064
+ try { plugins = pluginInstaller.listSynabunPlugins(); } catch {}
16065
+
16066
+ res.json({ ok: true, servers, plugins });
16067
+ } catch (err) {
16068
+ res.status(500).json({ ok: false, error: err.message });
16069
+ }
16070
+ });
16071
+
16072
+ // ── Claude Code plugin management ──
16073
+ // GET /api/plugins/list — all SynaBun-managed Claude Code plugins
16074
+ app.get('/api/plugins/list', (req, res) => {
16075
+ try {
16076
+ const plugins = pluginInstaller.listSynabunPlugins();
16077
+ res.json({ ok: true, plugins });
16078
+ } catch (err) {
16079
+ res.status(500).json({ ok: false, error: err.message });
16080
+ }
16081
+ });
16082
+
16083
+ // DELETE /api/plugins/:marketplace?pluginName=&purgeFiles=1
16084
+ app.delete('/api/plugins/:marketplace', (req, res) => {
16085
+ try {
16086
+ const { marketplace } = req.params;
16087
+ const { pluginName, purgeFiles } = req.query || {};
16088
+ const result = pluginInstaller.uninstallClaudePlugin({
16089
+ marketplaceName: marketplace,
16090
+ pluginName: pluginName ? String(pluginName) : undefined,
16091
+ });
16092
+ if (!result.ok) return res.status(400).json(result);
16093
+
16094
+ let purged = false;
16095
+ if (String(purgeFiles) === '1') {
16096
+ try {
16097
+ const dir = mcpInstaller.serverDir(DATA_HOME, marketplace);
16098
+ if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); purged = true; }
16099
+ } catch (err) {
16100
+ console.warn(`[plugins] purge files failed for ${marketplace}:`, err.message);
16101
+ }
16102
+ }
16103
+ res.json({ ok: true, ...result, purged });
16104
+ } catch (err) {
16105
+ res.status(500).json({ ok: false, error: err.message });
16106
+ }
16107
+ });
16108
+
16109
+ // POST /api/plugins/repair — clean legacy synabun-symlink state and reinstall via CLI
16110
+ app.post('/api/plugins/repair', (req, res) => {
16111
+ try {
16112
+ const result = pluginInstaller.repairSynabunPlugins();
16113
+ res.json({ ok: true, ...result });
16114
+ } catch (err) {
16115
+ res.status(500).json({ ok: false, error: err.message });
16116
+ }
16117
+ });
16118
+
15417
16119
  // POST /api/mcp/install/detect { path } or { name } → re-run detection.
15418
16120
  app.post('/api/mcp/install/detect', (req, res) => {
15419
16121
  try {
@@ -16006,6 +16708,7 @@ const IS_WIN = process.platform === 'win32';
16006
16708
  const DEFAULT_SHELL = IS_WIN ? (process.env.COMSPEC || 'cmd.exe') : (process.env.SHELL || '/bin/bash');
16007
16709
  const TERMINAL_BUFFER_MAX_BYTES = 100 * 1024; // 100KB ring buffer per session
16008
16710
  const TERMINAL_GRACE_PERIOD_MS = 30 * 60 * 1000; // 30 min before orphaned PTY is killed
16711
+ const TERMINAL_SNAPSHOT_SCROLLBACK_ROWS = 1000;
16009
16712
 
16010
16713
  // ── CLI Config (custom CLI paths for terminal profiles) ──
16011
16714
 
@@ -16098,6 +16801,8 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16098
16801
  if (!pty) throw new Error('Terminal not available: node-pty failed to load. Run: cd neural-interface && npm run postinstall');
16099
16802
  const sessionId = opts.sessionId || randomBytes(16).toString('hex');
16100
16803
  const profileCfg = getTerminalProfile(profile, opts);
16804
+ const initialCols = cols || 120;
16805
+ const initialRows = rows || 30;
16101
16806
 
16102
16807
  // Strip VSCode env vars so spawned CLIs (e.g. claude) don't think they're inside VSCode
16103
16808
  const cleanEnv = Object.fromEntries(
@@ -16106,20 +16811,35 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16106
16811
  )
16107
16812
  );
16108
16813
 
16814
+ const isLoopOwned = !!(opts.extraEnv && opts.extraEnv.SYNABUN_TERMINAL_SESSION === sessionId);
16815
+ if (isLoopOwned) {
16816
+ loopLog(sessionId, 'pty:resolve', 'profile resolved', {
16817
+ profile, shell: profileCfg.shell, args: profileCfg.args,
16818
+ envKeys: Object.keys(profileCfg.env || {}),
16819
+ extraEnvKeys: Object.keys(opts.extraEnv || {}),
16820
+ cwd, cols: initialCols, rows: initialRows,
16821
+ });
16822
+ }
16823
+
16109
16824
  const ptyProcess = pty.spawn(profileCfg.shell, profileCfg.args, {
16110
16825
  name: 'xterm-256color',
16111
- cols: cols || 120,
16112
- rows: rows || 30,
16826
+ cols: initialCols,
16827
+ rows: initialRows,
16113
16828
  cwd: cwd || process.env.USERPROFILE || process.env.HOME || process.cwd(),
16114
16829
  env: { ...cleanEnv, ...profileCfg.env, ...(opts.extraEnv || {}) },
16115
16830
  useConpty: IS_WIN,
16116
16831
  });
16117
16832
 
16833
+ if (isLoopOwned) {
16834
+ loopLog(sessionId, 'pty:spawned', 'pty spawned', { pid: ptyProcess.pid });
16835
+ }
16836
+
16118
16837
  const session = {
16119
16838
  pty: ptyProcess, clients: new Set(), profile, cwd,
16120
16839
  createdAt: Date.now(),
16121
16840
  outputBuffer: [], // ring buffer of output strings
16122
16841
  outputBufferBytes: 0, // running byte count
16842
+ vterm: new VTermBuffer(initialCols, initialRows, TERMINAL_SNAPSHOT_SCROLLBACK_ROWS + initialRows),
16123
16843
  graceTimer: null, // setTimeout handle for orphan cleanup
16124
16844
  claudeSessionId: opts.resume || null, // known Claude session ID (from resume or detect)
16125
16845
  };
@@ -16161,8 +16881,14 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16161
16881
  });
16162
16882
  };
16163
16883
 
16884
+ // Loop boot signature: log first ~500 bytes of PTY output (only for loop-owned sessions)
16885
+ let _loopBootCaptured = false;
16886
+ let _loopBootBuf = '';
16887
+
16164
16888
  const OUTPUT_BURST_CAP = 64 * 1024; // flush early when this much buffered in one tick
16165
16889
  ptyProcess.onData((data) => {
16890
+ try { session.vterm?.write(data); } catch {}
16891
+
16166
16892
  // Accumulate into coalesce buffer
16167
16893
  _outputCoalesceBuf += data;
16168
16894
 
@@ -16179,6 +16905,19 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16179
16905
  }
16180
16906
  }
16181
16907
 
16908
+ // Boot signature capture for loop-owned sessions (first 500 bytes only)
16909
+ if (isLoopOwned && !_loopBootCaptured) {
16910
+ _loopBootBuf += data;
16911
+ if (_loopBootBuf.length >= 500) {
16912
+ _loopBootCaptured = true;
16913
+ loopLog(sessionId, 'pty:boot-output', 'first 500 bytes of pty output captured', {
16914
+ uptimeMs: Date.now() - session.createdAt,
16915
+ sample: _loopBootBuf.slice(0, 500),
16916
+ });
16917
+ _loopBootBuf = '';
16918
+ }
16919
+ }
16920
+
16182
16921
  // Burst cap: if a huge chunk arrives in one tick, flush synchronously so a
16183
16922
  // multi-MB JSON doesn't pile up and block the client write loop (which
16184
16923
  // stalls xterm rendering and causes drawn-glitch artifacts).
@@ -16198,6 +16937,39 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16198
16937
  // Clear grace timer if set
16199
16938
  if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
16200
16939
 
16940
+ if (isLoopOwned) {
16941
+ // Flush any remaining boot buffer that didn't reach 500 bytes
16942
+ if (!_loopBootCaptured && _loopBootBuf.length > 0) {
16943
+ loopLog(sessionId, 'pty:boot-output', `pty exited before 500 bytes (got ${_loopBootBuf.length})`, {
16944
+ uptimeMs: Date.now() - session.createdAt,
16945
+ sample: _loopBootBuf,
16946
+ });
16947
+ }
16948
+ loopLog(sessionId, 'pty:exit', 'pty exited', {
16949
+ exitCode,
16950
+ uptimeMs: Date.now() - session.createdAt,
16951
+ bootCaptured: _loopBootCaptured,
16952
+ });
16953
+ // Mark loop file inactive if PTY died with loop still active
16954
+ try {
16955
+ const loopFile = findLoopFileForTerminal(sessionId);
16956
+ if (loopFile) {
16957
+ const loopPath = resolve(LOOP_DIR, loopFile);
16958
+ const ls = JSON.parse(readFileSync(loopPath, 'utf-8'));
16959
+ if (ls.active) {
16960
+ ls.active = false;
16961
+ ls.completedAt = new Date().toISOString();
16962
+ ls.stoppedReason = ls.stoppedReason || `pty-exit-${exitCode}`;
16963
+ ls.lastExitCode = exitCode;
16964
+ writeFileSync(loopPath, JSON.stringify(ls, null, 2));
16965
+ loopLog(sessionId, 'pty:exit', 'marked loop inactive', { stoppedReason: ls.stoppedReason, loopFile });
16966
+ }
16967
+ }
16968
+ } catch (err) {
16969
+ loopLog(sessionId, 'pty:exit', 'failed to mark loop inactive', { err: err.message });
16970
+ }
16971
+ }
16972
+
16201
16973
  const msg = JSON.stringify({ type: 'exit', exitCode });
16202
16974
  session.clients.forEach(ws => {
16203
16975
  if (ws.readyState === 1) ws.send(msg);
@@ -16225,6 +16997,60 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
16225
16997
  return sessionId;
16226
16998
  }
16227
16999
 
17000
+ function terminalSnapshotToAnsi(session) {
17001
+ const vterm = session?.vterm;
17002
+ if (!vterm) return '';
17003
+
17004
+ const cols = Math.max(1, vterm.cols || session?.pty?.cols || 120);
17005
+ const rows = Math.max(1, vterm.rows || session?.pty?.rows || 30);
17006
+ const parts = ['\x1b[0m'];
17007
+
17008
+ if (vterm.useAltBuffer) {
17009
+ parts.push('\x1b[?1049h');
17010
+ } else {
17011
+ parts.push('\x1b[?1049l');
17012
+ const sbLen = vterm.scrollbackLength || 0;
17013
+ const start = Math.max(0, sbLen - TERMINAL_SNAPSHOT_SCROLLBACK_ROWS);
17014
+ for (let r = start; r < sbLen; r++) {
17015
+ const row = vterm.getScrollbackRow(r);
17016
+ if (!row) continue;
17017
+ let line = '';
17018
+ for (let c = 0; c < cols; c++) line += row[c]?.char || ' ';
17019
+ parts.push(line.replace(/\s+$/g, ''), '\r\n');
17020
+ }
17021
+ }
17022
+
17023
+ // Clear only the visible viewport. In normal-buffer mode this preserves the
17024
+ // scrollback rows emitted above while rebuilding the current screen from the
17025
+ // server-side terminal model instead of replaying an arbitrary ANSI tail.
17026
+ parts.push('\x1b[2J\x1b[H');
17027
+ for (let r = 0; r < rows; r++) {
17028
+ const row = vterm.getRow(r);
17029
+ if (!row) continue;
17030
+ let line = '';
17031
+ for (let c = 0; c < cols; c++) line += row[c]?.char || ' ';
17032
+ parts.push(`\x1b[${r + 1};1H`, line.replace(/\s+$/g, ''));
17033
+ }
17034
+
17035
+ parts.push(
17036
+ '\x1b[0m',
17037
+ vterm.cursorVisible ? '\x1b[?25h' : '\x1b[?25l',
17038
+ `\x1b[${Math.min(rows, Math.max(1, vterm.cursorY + 1))};${Math.min(cols, Math.max(1, vterm.cursorX + 1))}H`,
17039
+ );
17040
+
17041
+ return parts.join('');
17042
+ }
17043
+
17044
+ function terminalSnapshotToText(session) {
17045
+ const vterm = session?.vterm;
17046
+ if (!vterm) return '';
17047
+ const sb = vterm.scrollbackLength || 0;
17048
+ const totalRows = sb + (vterm.rows || 0);
17049
+ if (totalRows <= 0) return '';
17050
+ const startRow = vterm.useAltBuffer ? sb : Math.max(0, totalRows - TERMINAL_SNAPSHOT_SCROLLBACK_ROWS - (vterm.rows || 0));
17051
+ return vterm.getText(startRow, 0, totalRows - 1, Math.max(0, (vterm.cols || 1) - 1));
17052
+ }
17053
+
16228
17054
  // ── Loop Driver — sends /clear + next iteration prompt between loop iterations ──
16229
17055
 
16230
17056
  // Comprehensive ANSI escape stripping: CSI sequences, OSC sequences, charset selects, cursor keys, and \r
@@ -16238,15 +17064,31 @@ function writeToLoopPty(session, text, sendEnter = true) {
16238
17064
  const full = sendEnter ? text + '\r' : text;
16239
17065
  const CHUNK = 256;
16240
17066
  const DELAY = 30;
17067
+ const chunkCount = Math.ceil(full.length / CHUNK);
17068
+ const totalSettleMs = chunkCount * DELAY;
17069
+ const tsid = session?._loopTerminalSessionId || null;
17070
+ let writeFailures = 0;
17071
+ loopLog(tsid, 'pty:write-begin', 'writeToLoopPty start', {
17072
+ bytes: full.length, chunkCount, totalSettleMs, sendEnter,
17073
+ preview: text.slice(0, 200),
17074
+ });
16241
17075
  for (let i = 0; i < full.length; i += CHUNK) {
16242
17076
  const chunk = full.slice(i, i + CHUNK);
16243
17077
  const delay = (i / CHUNK) * DELAY;
16244
17078
  setTimeout(() => {
16245
- try { session.pty.write(chunk); } catch { /* pty may be dead */ }
17079
+ try { session.pty.write(chunk); } catch (err) {
17080
+ writeFailures++;
17081
+ loopLog(tsid, 'pty:write-fail', 'pty.write threw', { offset: i, err: err.message });
17082
+ }
16246
17083
  }, delay);
16247
17084
  }
17085
+ setTimeout(() => {
17086
+ loopLog(tsid, 'pty:write-end', 'writeToLoopPty all chunks scheduled', {
17087
+ bytes: full.length, chunkCount, writeFailures,
17088
+ });
17089
+ }, totalSettleMs + 50);
16248
17090
  // Return total time for all chunks to be sent
16249
- return Math.ceil(full.length / CHUNK) * DELAY;
17091
+ return totalSettleMs;
16250
17092
  }
16251
17093
 
16252
17094
  /**
@@ -16284,25 +17126,41 @@ function loopPromptReady(buffer) {
16284
17126
  */
16285
17127
  function attachLoopDriver(terminalSessionId) {
16286
17128
  const session = terminalSessions.get(terminalSessionId);
16287
- if (!session) return;
17129
+ if (!session) {
17130
+ loopLog(terminalSessionId, 'driver:attach', 'ABORT: terminal session not found in registry');
17131
+ return;
17132
+ }
16288
17133
 
16289
17134
  // Initialize capture buffer (used only for initial boot prompt detection)
16290
17135
  session._loopDriverBuffer = '';
16291
17136
  session._loopDriverStartedAt = Date.now();
17137
+ session._loopTerminalSessionId = terminalSessionId;
16292
17138
  session._pendingClaimed = false;
16293
17139
  session._pendingClaimedAt = null;
16294
17140
  let driving = false; // prevent re-entrance
16295
17141
 
16296
17142
  let consecutiveFailures = 0;
16297
17143
  const MAX_DRIVE_FAILURES = 5; // with deterministic delays, failures are real problems
17144
+ let tickCount = 0;
17145
+
17146
+ loopLog(terminalSessionId, 'driver:attach', 'hook-driven loop driver attached (claude-code)', { intervalMs: 2000 });
16298
17147
 
16299
17148
  const interval = setInterval(async () => {
17149
+ tickCount++;
16300
17150
  if (driving) return;
16301
17151
  if (!terminalSessions.has(terminalSessionId)) {
16302
17152
  clearInterval(interval);
16303
- console.log('[loop-driver] Terminal session gone, clearing driver interval');
17153
+ loopLog(terminalSessionId, 'driver:tick', 'terminal session gone — clearing driver interval', { ticks: tickCount });
16304
17154
  return;
16305
17155
  }
17156
+ // Throttled tick log: every 10th tick (20s)
17157
+ if (tickCount % 10 === 0) {
17158
+ loopLog(terminalSessionId, 'driver:tick', `tick #${tickCount}`, {
17159
+ pendingClaimed: !!session._pendingClaimed,
17160
+ bootElapsedMs: Date.now() - (session._loopDriverStartedAt || 0),
17161
+ bufferBytes: (session._loopDriverBuffer || '').length,
17162
+ });
17163
+ }
16306
17164
 
16307
17165
  // Cooldown after pending claim: don't scan for awaitingNext while the first
16308
17166
  // iteration is still running. Once the pending file is gone (renamed by hook)
@@ -16376,10 +17234,17 @@ function attachLoopDriver(terminalSessionId) {
16376
17234
  } else {
16377
17235
  const pendingFiles = readdirSync(LOOP_DIR)
16378
17236
  .filter(f => f.startsWith('pending-') && f.endsWith('.json'));
17237
+ if (tickCount % 5 === 0) {
17238
+ loopLog(terminalSessionId, 'driver:scan', 'scanning for pending file', {
17239
+ pendingCount: pendingFiles.length, bootElapsedMs: bootElapsed,
17240
+ });
17241
+ }
17242
+ let foundOwnPending = false;
16379
17243
  for (const pf of pendingFiles) {
16380
17244
  let pendingState;
16381
17245
  try { pendingState = JSON.parse(readFileSync(resolve(LOOP_DIR, pf), 'utf-8')); } catch { continue; }
16382
17246
  if (pendingState.terminalSessionId !== terminalSessionId) continue;
17247
+ foundOwnPending = true;
16383
17248
  if (!pendingState.active && !pendingState.pending) continue;
16384
17249
 
16385
17250
  // Check for prompt OR use fallback after 30s of waiting
@@ -16388,7 +17253,11 @@ function attachLoopDriver(terminalSessionId) {
16388
17253
  const fallbackFire = waitedForPrompt > 30000;
16389
17254
 
16390
17255
  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)`);
17256
+ loopLog(terminalSessionId, 'driver:claim', `claiming pending loop`, {
17257
+ file: pf, promptDetected, fallbackFire,
17258
+ waitedForPromptMs: waitedForPrompt, bootElapsedMs: bootElapsed,
17259
+ bufferTail: (session._loopDriverBuffer || '').slice(-300),
17260
+ });
16392
17261
  session._pendingClaimed = true;
16393
17262
  session._pendingClaimedAt = Date.now();
16394
17263
  session._loopDriverBuffer = '';
@@ -16400,12 +17269,23 @@ function attachLoopDriver(terminalSessionId) {
16400
17269
  setTimeout(() => {
16401
17270
  try {
16402
17271
  session.pty.write('\r');
16403
- console.log('[loop-driver] Sent auto-confirm Enter for pending claim');
16404
- } catch { /* pty may be dead */ }
17272
+ loopLog(terminalSessionId, 'driver:claim', 'auto-confirm Enter sent for pending claim');
17273
+ } catch (err) {
17274
+ loopLog(terminalSessionId, 'driver:claim', 'auto-confirm Enter FAILED', { err: err.message });
17275
+ }
16405
17276
  }, chunkTime + 4000);
17277
+ } else if (tickCount % 5 === 0) {
17278
+ loopLog(terminalSessionId, 'driver:claim', 'pending file matched but not claiming yet', {
17279
+ promptDetected, waitedForPromptMs: waitedForPrompt, bufferBytes: (session._loopDriverBuffer || '').length,
17280
+ });
16406
17281
  }
16407
17282
  break;
16408
17283
  }
17284
+ if (!foundOwnPending && tickCount % 5 === 0 && pendingFiles.length > 0) {
17285
+ loopLog(terminalSessionId, 'driver:scan', 'no pending file matches this terminal session', {
17286
+ pendingFiles, ourTerminalSessionId: terminalSessionId,
17287
+ });
17288
+ }
16409
17289
  }
16410
17290
  }
16411
17291
  } catch { /* ok */ }
@@ -16423,16 +17303,23 @@ function attachLoopDriver(terminalSessionId) {
16423
17303
  async function driveNextIteration(session, loopState, filename) {
16424
17304
  const loopPath = resolve(LOOP_DIR, filename);
16425
17305
  const iter = loopState.currentIteration;
17306
+ const tsid = loopState.terminalSessionId || session?._loopTerminalSessionId || null;
16426
17307
 
16427
- console.log(`[loop-driver] Driving iteration ${iter}/${loopState.totalIterations}`);
17308
+ loopLog(tsid, 'driver:iter-begin', `driving iteration ${iter}/${loopState.totalIterations}`, {
17309
+ file: filename, awaitingNext: !!loopState.awaitingNext,
17310
+ });
16428
17311
 
16429
17312
  // Step 1: Grace delay — let PTY finish rendering after Claude stopped
16430
17313
  await new Promise(r => setTimeout(r, 2000));
16431
17314
 
16432
17315
  // Step 2: Send /clear to reset conversation context
16433
17316
  session._loopDriverBuffer = '';
16434
- session.pty.write('/clear\r');
16435
- console.log('[loop-driver] Sent /clear');
17317
+ try {
17318
+ session.pty.write('/clear\r');
17319
+ loopLog(tsid, 'driver:iter', '/clear sent');
17320
+ } catch (err) {
17321
+ loopLog(tsid, 'driver:iter', '/clear write FAILED', { err: err.message });
17322
+ }
16436
17323
 
16437
17324
  // Step 3: Wait for /clear to process (CLI clears conversation, renders fresh prompt)
16438
17325
  await new Promise(r => setTimeout(r, 5000));
@@ -16441,12 +17328,16 @@ async function driveNextIteration(session, loopState, filename) {
16441
17328
  session._loopDriverBuffer = '';
16442
17329
  const iterMsg = `[SynaBun Loop] Iteration ${iter}. Begin task.`;
16443
17330
  const chunkTime = writeToLoopPty(session, iterMsg, true);
16444
- console.log(`[loop-driver] Sent iteration ${iter} message`);
17331
+ loopLog(tsid, 'driver:iter', 'iteration message scheduled', { iter, msg: iterMsg, chunkTime });
16445
17332
 
16446
17333
  // Step 5: Auto-confirm (Enter after all chunks + settle time)
16447
17334
  setTimeout(() => {
16448
- try { session.pty.write('\r'); } catch { /* ok */ }
16449
- console.log('[loop-driver] Sent auto-confirm Enter');
17335
+ try {
17336
+ session.pty.write('\r');
17337
+ loopLog(tsid, 'driver:iter', 'auto-confirm Enter sent', { iter });
17338
+ } catch (err) {
17339
+ loopLog(tsid, 'driver:iter', 'auto-confirm Enter FAILED', { iter, err: err.message });
17340
+ }
16450
17341
  }, chunkTime + 3000);
16451
17342
 
16452
17343
  // Step 6: Clear awaitingNext flag
@@ -16455,8 +17346,9 @@ async function driveNextIteration(session, loopState, filename) {
16455
17346
  freshState.awaitingNext = false;
16456
17347
  delete freshState._driveRetries; // no longer needed
16457
17348
  writeFileSync(loopPath, JSON.stringify(freshState, null, 2));
17349
+ loopLog(tsid, 'driver:iter-end', `iteration ${iter} dispatched, awaitingNext cleared`);
16458
17350
  } catch (err) {
16459
- console.warn('[loop-driver] Failed to clear awaitingNext:', err.message);
17351
+ loopLog(tsid, 'driver:iter-end', 'clear awaitingNext FAILED', { iter, err: err.message });
16460
17352
  }
16461
17353
 
16462
17354
  return true;
@@ -16480,7 +17372,21 @@ const EXEC_BOOT_SENTINEL = 'SYNABUN_EXEC_BOOT_READY';
16480
17372
  */
16481
17373
  function prepareExecTaskFile(terminalSessionId, loopState) {
16482
17374
  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}`;
17375
+ const browserBlock = loopState.usesBrowser ? [
17376
+ '',
17377
+ '=== BROWSER ENFORCEMENT (MANDATORY) ===',
17378
+ 'This automation REQUIRES the SynaBun internal browser.',
17379
+ loopState.browserSessionId ? `YOUR BROWSER SESSION: ${loopState.browserSessionId}` : '',
17380
+ loopState.browserTabId ? `YOUR BROWSER TAB: ${loopState.browserTabId}` : '',
17381
+ loopState.browserSessionId
17382
+ ? `Pass sessionId: "${loopState.browserSessionId}"${loopState.browserTabId ? ` and tabId: "${loopState.browserTabId}"` : ''} to EVERY browser tool call. Omitting them can hijack another loop's browser.`
17383
+ : 'Use the SynaBun browser tools only. Do not use external browser/search tools for visual browsing.',
17384
+ '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.',
17385
+ '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.',
17386
+ '=== END BROWSER ENFORCEMENT ===',
17387
+ '',
17388
+ ].filter(Boolean).join('\n') : '';
17389
+ 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
17390
  const fullTask = loopState.context ? `${wrappedTask}\n\nCONTEXT:\n${loopState.context}` : wrappedTask;
16485
17391
  writeFileSync(taskFile, fullTask, 'utf-8');
16486
17392
  return taskFile;
@@ -16559,23 +17465,41 @@ function findLoopFileForTerminal(terminalSessionId) {
16559
17465
  */
16560
17466
  function attachExecLoopDriver(terminalSessionId) {
16561
17467
  const session = terminalSessions.get(terminalSessionId);
16562
- if (!session) return;
17468
+ if (!session) {
17469
+ loopLog(terminalSessionId, 'exec-driver:attach', 'ABORT: terminal session not found in registry');
17470
+ return;
17471
+ }
16563
17472
 
16564
17473
  session._loopDriverBuffer = '';
16565
17474
  session._loopDriverStartedAt = Date.now();
17475
+ session._loopTerminalSessionId = terminalSessionId;
16566
17476
  session._execDriverState = 'booting';
16567
17477
  session._execBootSent = false;
16568
17478
  session._execCurrentIter = 0;
16569
17479
  session._execTaskFile = null; // prepared on first ready
17480
+ session._execIterStartedAt = null;
16570
17481
  let driving = false;
17482
+ let tickCount = 0;
17483
+ let consecutiveFastFails = 0;
17484
+ let consecutiveNonZero = 0;
17485
+ const FAST_FAIL_THRESHOLD_MS = 30_000;
17486
+ const FAIL_LIMIT = 3;
17487
+ loopLog(terminalSessionId, 'exec-driver:attach', 'exec loop driver attached', { intervalMs: 1500 });
16571
17488
 
16572
17489
  const interval = setInterval(async () => {
17490
+ tickCount++;
16573
17491
  if (driving) return;
16574
17492
  if (!terminalSessions.has(terminalSessionId)) {
16575
17493
  clearInterval(interval);
16576
- console.log('[exec-loop-driver] Terminal session gone, clearing driver interval');
17494
+ loopLog(terminalSessionId, 'exec-driver:tick', 'terminal session gone — clearing driver interval', { ticks: tickCount });
16577
17495
  return;
16578
17496
  }
17497
+ if (tickCount % 10 === 0) {
17498
+ loopLog(terminalSessionId, 'exec-driver:tick', `tick #${tickCount}`, {
17499
+ state: session._execDriverState, currentIter: session._execCurrentIter,
17500
+ bootSent: session._execBootSent,
17501
+ });
17502
+ }
16579
17503
 
16580
17504
  // Load loop state file for this terminal
16581
17505
  const loopFile = findLoopFileForTerminal(terminalSessionId);
@@ -16608,14 +17532,19 @@ function attachExecLoopDriver(terminalSessionId) {
16608
17532
  session._loopDriverBuffer = '';
16609
17533
  writeToLoopPty(session, `echo ${EXEC_BOOT_SENTINEL}`, true);
16610
17534
  session._execBootSent = true;
16611
- console.log('[exec-loop-driver] Sent boot sentinel echo');
17535
+ loopLog(terminalSessionId, 'exec-driver:boot', 'boot sentinel echo sent', { sentinel: EXEC_BOOT_SENTINEL, bootElapsedMs: bootElapsed });
16612
17536
  }
16613
17537
 
16614
17538
  // Detect boot sentinel OR use fallback timeout
16615
- if ((session._execBootSent && stripped.includes(EXEC_BOOT_SENTINEL)) || bootElapsed > 20000) {
17539
+ const sentinelDetected = session._execBootSent && stripped.includes(EXEC_BOOT_SENTINEL);
17540
+ const fallbackFire = bootElapsed > 20000;
17541
+ if (sentinelDetected || fallbackFire) {
16616
17542
  session._execDriverState = 'ready';
16617
17543
  session._loopDriverBuffer = '';
16618
- console.log(`[exec-loop-driver] Shell ready (sentinel=${stripped.includes(EXEC_BOOT_SENTINEL)}, elapsed=${Math.round(bootElapsed / 1000)}s)`);
17544
+ loopLog(terminalSessionId, 'exec-driver:boot', 'shell ready', {
17545
+ sentinelDetected, fallbackFire, bootElapsedMs: bootElapsed,
17546
+ bufferTail: stripped.slice(-200),
17547
+ });
16619
17548
  }
16620
17549
  }
16621
17550
 
@@ -16650,15 +17579,19 @@ function attachExecLoopDriver(terminalSessionId) {
16650
17579
  // Prepare task file on first iteration (reused across all iterations)
16651
17580
  if (!session._execTaskFile) {
16652
17581
  session._execTaskFile = prepareExecTaskFile(terminalSessionId, loopState);
16653
- console.log(`[exec-loop-driver] Task file prepared: ${session._execTaskFile}`);
17582
+ loopLog(terminalSessionId, 'exec-driver:taskfile', 'task file prepared', { path: session._execTaskFile });
16654
17583
  }
16655
17584
 
16656
- // Fire next iteration with sentinel echo appended
17585
+ // Fire next iteration with sentinel echo appended (captures exit code)
16657
17586
  const cmd = buildExecCommand(loopState.profile, loopState, session._execTaskFile);
16658
17587
  const nextIter = iter + 1;
16659
17588
  const sentinel = `${EXEC_SENTINEL_PREFIX}${nextIter}`;
16660
- const fullCmd = `${cmd}; echo ${sentinel}`;
16661
- console.log(`[exec-loop-driver] Starting iteration ${nextIter}/${loopState.totalIterations}`);
17589
+ const fullCmd = `${cmd}; rc=$?; echo ${sentinel} rc=$rc`;
17590
+ loopLog(terminalSessionId, 'exec-driver:iter-begin', `starting iteration ${nextIter}/${loopState.totalIterations}`, {
17591
+ profile: loopState.profile, sentinel,
17592
+ cmdPreview: fullCmd.slice(0, 300),
17593
+ cmdBytes: fullCmd.length,
17594
+ });
16662
17595
 
16663
17596
  // Grace delay before sending command
16664
17597
  await new Promise(r => setTimeout(r, 2000));
@@ -16666,6 +17599,7 @@ function attachExecLoopDriver(terminalSessionId) {
16666
17599
  session._loopDriverBuffer = '';
16667
17600
  session._execDriverState = 'running';
16668
17601
  session._execCurrentIter = nextIter;
17602
+ session._execIterStartedAt = Date.now();
16669
17603
  writeToLoopPty(session, fullCmd, true);
16670
17604
 
16671
17605
  // Update loop state
@@ -16676,10 +17610,41 @@ function attachExecLoopDriver(terminalSessionId) {
16676
17610
  }
16677
17611
 
16678
17612
  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`);
17613
+ // Detect iteration completion via sentinel echo + capture rc
17614
+ const sentinelRe = new RegExp(`${EXEC_SENTINEL_PREFIX}${session._execCurrentIter} rc=(-?\\d+)`);
17615
+ const m = stripped.match(sentinelRe);
17616
+ if (m) {
17617
+ const rc = parseInt(m[1], 10);
17618
+ const iterDurationMs = Date.now() - (session._execIterStartedAt || Date.now());
17619
+ const isFastFail = iterDurationMs < FAST_FAIL_THRESHOLD_MS;
17620
+ const isNonZero = rc !== 0;
17621
+
17622
+ if (isNonZero) consecutiveNonZero++; else consecutiveNonZero = 0;
17623
+ if (isFastFail) consecutiveFastFails++; else consecutiveFastFails = 0;
17624
+
17625
+ loopLog(terminalSessionId, 'exec-driver:iter-end', `iteration ${session._execCurrentIter} complete`, {
17626
+ rc, iterDurationMs, isFastFail, isNonZero,
17627
+ consecutiveNonZero, consecutiveFastFails,
17628
+ });
17629
+
17630
+ // Halt on failure streak (3 consecutive non-zero OR 3 consecutive fast)
17631
+ if (consecutiveNonZero >= FAIL_LIMIT || consecutiveFastFails >= FAIL_LIMIT) {
17632
+ const reason = consecutiveNonZero >= FAIL_LIMIT ? 'fast-fail-rc' : 'fast-fail-duration';
17633
+ loopState.active = false;
17634
+ loopState.completedAt = new Date().toISOString();
17635
+ loopState.stoppedReason = reason;
17636
+ loopState.lastExitCode = rc;
17637
+ try { writeFileSync(loopPath, JSON.stringify(loopState, null, 2)); } catch { /* ok */ }
17638
+ loopLog(terminalSessionId, 'exec-driver:halt', `halting loop: ${reason}`, {
17639
+ rc, iterDurationMs, consecutiveNonZero, consecutiveFastFails,
17640
+ });
17641
+ clearInterval(interval);
17642
+ if (session._execTaskFile) try { unlinkSync(session._execTaskFile); } catch { /* ok */ }
17643
+ try { session.pty?.kill?.(); } catch { /* ok */ }
17644
+ driving = false;
17645
+ return;
17646
+ }
17647
+
16683
17648
  session._loopDriverBuffer = '';
16684
17649
  session._execDriverState = 'ready';
16685
17650
  // Cooldown before next iteration
@@ -19525,7 +20490,7 @@ async function createBrowserSession(options = {}) {
19525
20490
  const extra = savedCfg.extraArgs.split(/\s+/).filter(Boolean);
19526
20491
  stealthArgs.push(...extra);
19527
20492
  }
19528
- const launchOpts = { headless: headlessVal, args: stealthArgs };
20493
+ const launchOpts = { headless: headlessVal, args: stealthArgs, timeout: 45000 };
19529
20494
  // Priority: user-configured executablePath > channel > auto-detected executablePath.
19530
20495
  // User explicitly set executablePath = intentional override, always respect it.
19531
20496
  // Channel = let Playwright resolve the binary for that browser brand.
@@ -19649,6 +20614,46 @@ async function createBrowserSession(options = {}) {
19649
20614
  const launchPersistentProfile = async (launchRoot, launchProfileDir, launchMode) => {
19650
20615
  const launchArgs = [...stealthArgs];
19651
20616
 
20617
+ // Kill orphan Chrome processes holding this user-data-dir BEFORE removing locks.
20618
+ // Otherwise launchPersistentContext hangs indefinitely waiting for the singleton.
20619
+ try {
20620
+ const { execSync } = await import('node:child_process');
20621
+ let pids = [];
20622
+ if (IS_WIN) {
20623
+ // Windows: PowerShell Get-CimInstance filters by CommandLine substring.
20624
+ // Pass launchRoot via env var to avoid quoting hell.
20625
+ 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 }`;
20626
+ const out = execSync(`powershell -NoProfile -NonInteractive -Command -`, {
20627
+ input: psScript,
20628
+ encoding: 'utf-8',
20629
+ timeout: 10000,
20630
+ env: { ...process.env, SYNABUN_KILL_ROOT: launchRoot },
20631
+ windowsHide: true,
20632
+ }).trim();
20633
+ pids = out.split(/\s+/).filter(Boolean).map(p => parseInt(p, 10)).filter(p => p > 0 && p !== process.pid);
20634
+ } else {
20635
+ const needle = `user-data-dir=${launchRoot}`;
20636
+ const out = execSync(`pgrep -f ${JSON.stringify(needle)} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
20637
+ pids = out.split(/\s+/).filter(Boolean).map(p => parseInt(p, 10)).filter(p => p > 0 && p !== process.pid);
20638
+ }
20639
+ if (pids.length) {
20640
+ console.log(`[browser] Killing ${pids.length} orphan Chrome process(es) holding ${launchMode} root: ${pids.join(',')}`);
20641
+ for (const pid of pids) {
20642
+ try {
20643
+ if (IS_WIN) {
20644
+ // process.kill on Windows only does SIGTERM-like; taskkill /F is reliable.
20645
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true });
20646
+ } else {
20647
+ process.kill(pid, 'SIGKILL');
20648
+ }
20649
+ } catch {}
20650
+ }
20651
+ await new Promise(r => setTimeout(r, 300));
20652
+ }
20653
+ } catch (err) {
20654
+ console.warn(`[browser] Orphan kill scan failed: ${err.message}`);
20655
+ }
20656
+
19652
20657
  for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'DevToolsActivePort']) {
19653
20658
  const lf = resolve(launchRoot, lockFile);
19654
20659
  try {
@@ -19724,34 +20729,24 @@ async function createBrowserSession(options = {}) {
19724
20729
  if (_selectedProfileKind === 'system') {
19725
20730
  const requestedProfileDir = _profileDirectory || 'Default';
19726
20731
 
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.
20732
+ // System browser profiles cannot be launched directly from Chrome's real
20733
+ // User Data dir while the user's browser may be running. Launch an
20734
+ // isolated mirror instead, using Playwright's persistent-context path.
19730
20735
  const { mirrorRoot } = prepareMirroredProfile(userDataDir, requestedProfileDir, _sourceBrowser || 'chrome');
19731
20736
 
19732
20737
  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';
20738
+ const launched = await launchPersistentProfile(mirrorRoot, requestedProfileDir, 'mirror');
20739
+ browser = launched.launchBrowser;
20740
+ context = launched.launchContext;
20741
+ page = launched.launchPage;
20742
+ _profileMode = 'mirror';
19748
20743
  _launchUserDataDir = mirrorRoot;
19749
20744
  _launchProfileDirectory = requestedProfileDir;
19750
20745
 
19751
- console.log(`[browser] CDP connected to mirrored ${_profileSourceName || requestedProfileDir} via ${cdpResult.source}`);
19752
- } catch (cdpErr) {
20746
+ console.log(`[browser] Launched mirrored ${_profileSourceName || requestedProfileDir} profile`);
20747
+ } catch (mirrorErr) {
19753
20748
  throw new Error(
19754
- `Could not connect to ${_browserLabel} via CDP: ${cdpErr.message}`
20749
+ `Could not launch mirrored ${_browserLabel} profile: ${mirrorErr.message}`
19755
20750
  );
19756
20751
  }
19757
20752
  } else {
@@ -20227,6 +21222,16 @@ async function syncPageState(session, routed) {
20227
21222
  return { url, title };
20228
21223
  }
20229
21224
 
21225
+ function syncPageStateCompact(session, routed) {
21226
+ const url = routed.page.url();
21227
+ if (routed.tabId === session.activeTabId) session.currentUrl = url;
21228
+ return { url, title: routed.tabId === session.activeTabId ? (session.title || '') : '' };
21229
+ }
21230
+
21231
+ async function getBrowserActionState(session, routed, compact) {
21232
+ return compact ? syncPageStateCompact(session, routed) : syncPageState(session, routed);
21233
+ }
21234
+
20230
21235
  // ── Browser REST endpoints ──
20231
21236
 
20232
21237
  app.get('/api/browser/sessions', (req, res) => {
@@ -20334,10 +21339,10 @@ app.post('/api/browser/sessions/:id/navigate', async (req, res) => {
20334
21339
  if (!session) return res.status(404).json({ error: 'Session not found' });
20335
21340
  const routed = getTargetPage(session, req);
20336
21341
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20337
- const { url, returnSnapshot } = req.body;
21342
+ const { url, returnSnapshot, compact } = req.body;
20338
21343
  try {
20339
21344
  await routed.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
20340
- const state = await syncPageState(session, routed);
21345
+ const state = await getBrowserActionState(session, routed, compact);
20341
21346
  const body = { ok: true, url: state.url, title: state.title };
20342
21347
  if (returnSnapshot) {
20343
21348
  const snap = await buildSnapshotResponse(routed.page, {
@@ -20360,7 +21365,7 @@ app.post('/api/browser/sessions/:id/back', async (req, res) => {
20360
21365
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20361
21366
  try {
20362
21367
  await routed.page.goBack({ timeout: 10000 });
20363
- const state = await syncPageState(session, routed);
21368
+ const state = await getBrowserActionState(session, routed, req.body?.compact);
20364
21369
  res.json({ ok: true, url: state.url, title: state.title });
20365
21370
  } catch (err) {
20366
21371
  res.json({ ok: false, error: err.message });
@@ -20374,7 +21379,7 @@ app.post('/api/browser/sessions/:id/forward', async (req, res) => {
20374
21379
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20375
21380
  try {
20376
21381
  await routed.page.goForward({ timeout: 10000 });
20377
- const state = await syncPageState(session, routed);
21382
+ const state = await getBrowserActionState(session, routed, req.body?.compact);
20378
21383
  res.json({ ok: true, url: state.url, title: state.title });
20379
21384
  } catch (err) {
20380
21385
  res.json({ ok: false, error: err.message });
@@ -20388,7 +21393,7 @@ app.post('/api/browser/sessions/:id/reload', async (req, res) => {
20388
21393
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20389
21394
  try {
20390
21395
  await routed.page.reload({ timeout: 15000 });
20391
- const state = await syncPageState(session, routed);
21396
+ const state = await getBrowserActionState(session, routed, req.body?.compact);
20392
21397
  res.json({ ok: true, url: state.url, title: state.title });
20393
21398
  } catch (err) {
20394
21399
  res.json({ ok: false, error: err.message });
@@ -20646,14 +21651,14 @@ app.post('/api/browser/sessions/:id/click', async (req, res) => {
20646
21651
  if (!session) return res.status(404).json({ error: 'Session not found' });
20647
21652
  const routed = getTargetPage(session, req);
20648
21653
  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;
21654
+ const { selector: rawSelector, timeout, nthMatch, textHint, returnSnapshot, compact } = req.body;
20650
21655
  if (!rawSelector) return res.status(400).json({ error: 'selector required' });
20651
21656
  const selector = normalizeSelector(rawSelector);
20652
21657
  try {
20653
21658
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20654
21659
  if (resolved.error) return res.status(400).json(resolved.error);
20655
21660
  await resolved.target.click({ timeout: timeout || 5000 });
20656
- const state = await syncPageState(session, routed);
21661
+ const state = await getBrowserActionState(session, routed, compact);
20657
21662
  const body = { ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) };
20658
21663
  if (returnSnapshot) {
20659
21664
  const snap = await buildSnapshotResponse(routed.page, {
@@ -20675,14 +21680,14 @@ app.post('/api/browser/sessions/:id/fill', async (req, res) => {
20675
21680
  if (!session) return res.status(404).json({ error: 'Session not found' });
20676
21681
  const routed = getTargetPage(session, req);
20677
21682
  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;
21683
+ const { selector: rawFillSel, value, timeout, nthMatch, textHint, compact } = req.body;
20679
21684
  if (!rawFillSel) return res.status(400).json({ error: 'selector required' });
20680
21685
  const selector = normalizeSelector(rawFillSel);
20681
21686
  try {
20682
21687
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20683
21688
  if (resolved.error) return res.status(400).json(resolved.error);
20684
21689
  await resolved.target.fill(value ?? '', { timeout: timeout || 5000 });
20685
- const state = await syncPageState(session, routed);
21690
+ const state = await getBrowserActionState(session, routed, compact);
20686
21691
  res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
20687
21692
  } catch (err) {
20688
21693
  res.status(500).json({ error: err.message });
@@ -20694,7 +21699,7 @@ app.post('/api/browser/sessions/:id/type', async (req, res) => {
20694
21699
  if (!session) return res.status(404).json({ error: 'Session not found' });
20695
21700
  const routed = getTargetPage(session, req);
20696
21701
  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;
21702
+ const { selector: rawTypeSel, text, timeout, nthMatch, textHint, mode, compact } = req.body;
20698
21703
  try {
20699
21704
  let healed = false;
20700
21705
  if (rawTypeSel) {
@@ -20702,11 +21707,20 @@ app.post('/api/browser/sessions/:id/type', async (req, res) => {
20702
21707
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20703
21708
  if (resolved.error) return res.status(400).json(resolved.error);
20704
21709
  healed = resolved.healed;
20705
- await resolved.target.pressSequentially(text ?? '', { timeout: timeout || 5000 });
21710
+ if (mode === 'insert') {
21711
+ await resolved.target.focus({ timeout: timeout || 5000 });
21712
+ await routed.page.keyboard.insertText(text ?? '');
21713
+ } else {
21714
+ await resolved.target.pressSequentially(text ?? '', { timeout: timeout || 5000 });
21715
+ }
20706
21716
  } else {
20707
- await routed.page.keyboard.type(text ?? '');
21717
+ if (mode === 'insert') {
21718
+ await routed.page.keyboard.insertText(text ?? '');
21719
+ } else {
21720
+ await routed.page.keyboard.type(text ?? '');
21721
+ }
20708
21722
  }
20709
- const state = await syncPageState(session, routed);
21723
+ const state = await getBrowserActionState(session, routed, compact);
20710
21724
  res.json({ ok: true, url: state.url, title: state.title, ...(healed ? { healed: true } : {}) });
20711
21725
  } catch (err) {
20712
21726
  res.status(500).json({ error: err.message });
@@ -20718,14 +21732,14 @@ app.post('/api/browser/sessions/:id/hover', async (req, res) => {
20718
21732
  if (!session) return res.status(404).json({ error: 'Session not found' });
20719
21733
  const routed = getTargetPage(session, req);
20720
21734
  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;
21735
+ const { selector: rawHoverSel, timeout, nthMatch, textHint, compact } = req.body;
20722
21736
  if (!rawHoverSel) return res.status(400).json({ error: 'selector required' });
20723
21737
  const selector = normalizeSelector(rawHoverSel);
20724
21738
  try {
20725
21739
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20726
21740
  if (resolved.error) return res.status(400).json(resolved.error);
20727
21741
  await resolved.target.hover({ timeout: timeout || 5000 });
20728
- const state = await syncPageState(session, routed);
21742
+ const state = await getBrowserActionState(session, routed, compact);
20729
21743
  res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
20730
21744
  } catch (err) {
20731
21745
  res.status(500).json({ error: err.message });
@@ -20737,14 +21751,14 @@ app.post('/api/browser/sessions/:id/select', async (req, res) => {
20737
21751
  if (!session) return res.status(404).json({ error: 'Session not found' });
20738
21752
  const routed = getTargetPage(session, req);
20739
21753
  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;
21754
+ const { selector: rawSelSel, value, timeout, nthMatch, textHint, compact } = req.body;
20741
21755
  if (!rawSelSel) return res.status(400).json({ error: 'selector required' });
20742
21756
  const selector = normalizeSelector(rawSelSel);
20743
21757
  try {
20744
21758
  const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
20745
21759
  if (resolved.error) return res.status(400).json(resolved.error);
20746
21760
  await resolved.target.selectOption(value ?? '', { timeout: timeout || 5000 });
20747
- const state = await syncPageState(session, routed);
21761
+ const state = await getBrowserActionState(session, routed, compact);
20748
21762
  res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
20749
21763
  } catch (err) {
20750
21764
  res.status(500).json({ error: err.message });
@@ -20756,11 +21770,11 @@ app.post('/api/browser/sessions/:id/press', async (req, res) => {
20756
21770
  if (!session) return res.status(404).json({ error: 'Session not found' });
20757
21771
  const routed = getTargetPage(session, req);
20758
21772
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20759
- const { key } = req.body;
21773
+ const { key, compact } = req.body;
20760
21774
  if (!key) return res.status(400).json({ error: 'key required' });
20761
21775
  try {
20762
21776
  await routed.page.keyboard.press(key);
20763
- const state = await syncPageState(session, routed);
21777
+ const state = await getBrowserActionState(session, routed, compact);
20764
21778
  res.json({ ok: true, url: state.url, title: state.title });
20765
21779
  } catch (err) {
20766
21780
  res.status(500).json({ error: err.message });
@@ -20923,13 +21937,13 @@ app.post('/api/browser/sessions/:id/wait', async (req, res) => {
20923
21937
  if (!session) return res.status(404).json({ error: 'Session not found' });
20924
21938
  const routed = getTargetPage(session, req);
20925
21939
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
20926
- const { selector, state, loadState, timeout } = req.body;
21940
+ const { selector, state, loadState, timeout, compact } = req.body;
20927
21941
  try {
20928
21942
  if (loadState) {
20929
21943
  const valid = ['load', 'domcontentloaded', 'networkidle'];
20930
21944
  if (!valid.includes(loadState)) return res.status(400).json({ error: `Invalid loadState. Use: ${valid.join(', ')}` });
20931
21945
  await routed.page.waitForLoadState(loadState, { timeout: timeout || 15000 });
20932
- const ps = await syncPageState(session, routed);
21946
+ const ps = await getBrowserActionState(session, routed, compact);
20933
21947
  res.json({ ok: true, loadState, url: ps.url, title: ps.title });
20934
21948
  } else if (selector) {
20935
21949
  await routed.page.locator(selector).waitFor({
@@ -20939,7 +21953,7 @@ app.post('/api/browser/sessions/:id/wait', async (req, res) => {
20939
21953
  res.json({ ok: true, selector, state: state || 'visible' });
20940
21954
  } else {
20941
21955
  await routed.page.waitForTimeout(timeout || 1000);
20942
- const ps = await syncPageState(session, routed);
21956
+ const ps = await getBrowserActionState(session, routed, compact);
20943
21957
  res.json({ ok: true, url: ps.url, title: ps.title });
20944
21958
  }
20945
21959
  } catch (err) {
@@ -21096,7 +22110,7 @@ app.post('/api/browser/sessions/:id/scroll', async (req, res) => {
21096
22110
  if (!session) return res.status(404).json({ error: 'Session not found' });
21097
22111
  const routed = getTargetPage(session, req);
21098
22112
  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;
22113
+ const { direction = 'down', distance = 500, selector: rawSel, returnSnapshot, compact } = req.body;
21100
22114
  const deltaX = direction === 'right' ? distance : direction === 'left' ? -distance : 0;
21101
22115
  const deltaY = direction === 'down' ? distance : direction === 'up' ? -distance : 0;
21102
22116
  try {
@@ -21108,7 +22122,7 @@ app.post('/api/browser/sessions/:id/scroll', async (req, res) => {
21108
22122
  await routed.page.evaluate(({ dx, dy }) => window.scrollBy(dx, dy), { dx: deltaX, dy: deltaY });
21109
22123
  }
21110
22124
  await routed.page.waitForTimeout(300);
21111
- const state = await syncPageState(session, routed);
22125
+ const state = await getBrowserActionState(session, routed, compact);
21112
22126
  const body = { ok: true, url: state.url, title: state.title };
21113
22127
  if (returnSnapshot) {
21114
22128
  const snap = await buildSnapshotResponse(routed.page, {
@@ -21129,7 +22143,7 @@ app.post('/api/browser/sessions/:id/upload', async (req, res) => {
21129
22143
  if (!session) return res.status(404).json({ error: 'Session not found' });
21130
22144
  const routed = getTargetPage(session, req);
21131
22145
  if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
21132
- const { selector: rawSel, filePaths, nthMatch } = req.body;
22146
+ const { selector: rawSel, filePaths, nthMatch, compact } = req.body;
21133
22147
  if (!rawSel) return res.status(400).json({ error: 'selector required' });
21134
22148
  if (!Array.isArray(filePaths) || !filePaths.length) return res.status(400).json({ error: 'filePaths must be a non-empty array' });
21135
22149
  const selector = normalizeSelector(rawSel);
@@ -21146,7 +22160,7 @@ app.post('/api/browser/sessions/:id/upload', async (req, res) => {
21146
22160
  const target = (count > 1 && nthMatch !== undefined) ? loc.nth(nthMatch) : loc;
21147
22161
  await target.setInputFiles(filePaths);
21148
22162
  await routed.page.waitForTimeout(500);
21149
- const state = await syncPageState(session, routed);
22163
+ const state = await getBrowserActionState(session, routed, compact);
21150
22164
  res.json({ ok: true, url: state.url, title: state.title });
21151
22165
  } catch (err) {
21152
22166
  res.status(500).json({ error: err.message });
@@ -21288,6 +22302,15 @@ const httpServer = app.listen(PORT, async () => {
21288
22302
  setKvConfig('embedding_dims', String(EMBEDDING_DIMS));
21289
22303
  } catch {}
21290
22304
 
22305
+ // Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
22306
+ try {
22307
+ const repair = pluginInstaller.repairSynabunPlugins();
22308
+ if (repair?.repaired?.length) console.log(`[plugins] repaired legacy installs: ${repair.repaired.map(r => r.marketplaceName).join(', ')}`);
22309
+ if (repair?.failed?.length) console.warn(`[plugins] repair failed for: ${repair.failed.map(r => r.marketplaceName + ' (' + r.error + ')').join(', ')}`);
22310
+ } catch (err) {
22311
+ console.warn('[plugins] repair on startup failed:', err.message);
22312
+ }
22313
+
21291
22314
  // SQLite indexes are created in schema — no runtime index creation needed
21292
22315
 
21293
22316
  // Warm the session_cache + session_fts tables so the hybrid search has data
@@ -21469,10 +22492,15 @@ wss.on('connection', (ws, req) => {
21469
22492
  // Cancel grace timer — a client reconnected
21470
22493
  if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
21471
22494
 
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 }));
22495
+ // Restore reconnecting clients from the server-side terminal model instead
22496
+ // of replaying a truncated raw ANSI tail. Raw tail replay can start inside an
22497
+ // alternate-screen frame, OSC string, or partial cursor sequence, corrupting
22498
+ // xterm state for long-running TUIs.
22499
+ if (session.outputBufferBytes > 0) {
22500
+ const snapshot = terminalSnapshotToAnsi(session);
22501
+ if (snapshot) {
22502
+ ws.send(JSON.stringify({ type: 'snapshot', data: snapshot, plain: terminalSnapshotToText(session) }));
22503
+ }
21476
22504
  }
21477
22505
 
21478
22506
  ws.on('message', (raw) => {
@@ -21500,6 +22528,7 @@ wss.on('connection', (ws, req) => {
21500
22528
  session._appliedResize.cols === r.cols &&
21501
22529
  session._appliedResize.rows === r.rows) return;
21502
22530
  session._appliedResize = { cols: r.cols, rows: r.rows };
22531
+ try { session.vterm?.resize(r.cols, r.rows); } catch {}
21503
22532
  try { session.pty.resize(r.cols, r.rows); } catch {}
21504
22533
  };
21505
22534
  if (!last) {