synabun 2026.4.21 → 2026.4.25-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +175 -881
  2. package/README.md +1 -1
  3. package/hooks/claude-code/prompt-submit.mjs +39 -18
  4. package/hooks/claude-code/session-start.mjs +10 -3
  5. package/hooks/claude-code/shared.mjs +39 -2
  6. package/hooks/claude-code/stop.mjs +33 -6
  7. package/mcp-server/src/index.ts +9 -1
  8. package/mcp-server/src/services/neural-interface.ts +42 -14
  9. package/mcp-server/src/services/profiles.ts +8 -7
  10. package/mcp-server/src/tools/browser-advanced.ts +12 -10
  11. package/mcp-server/src/tools/browser-interact.ts +39 -38
  12. package/mcp-server/src/tools/browser-navigate.ts +12 -4
  13. package/mcp-server/src/tools/loop.ts +7 -3
  14. package/mcp-server/src/tools/profile.ts +19 -1
  15. package/neural-interface/lib/loop-logger.js +103 -0
  16. package/neural-interface/lib/mcp-installer.js +109 -0
  17. package/neural-interface/lib/plugin-installer.js +264 -0
  18. package/neural-interface/package.json +1 -1
  19. package/neural-interface/public/shared/api.js +66 -17
  20. package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
  21. package/neural-interface/public/shared/cdx/cdx-panel.js +117 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +55 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +394 -72
  25. package/neural-interface/public/shared/cli-status.js +157 -0
  26. package/neural-interface/public/shared/constants.js +3 -2
  27. package/neural-interface/public/shared/html-shell.js +32 -11
  28. package/neural-interface/public/shared/ocp/ocp-panel.js +127 -28
  29. package/neural-interface/public/shared/ocp/ocp-render.js +52 -44
  30. package/neural-interface/public/shared/ocp/ocp-styles.js +33 -0
  31. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  32. package/neural-interface/public/shared/styles.css +635 -0
  33. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  34. package/neural-interface/public/shared/ui-claude-panel.js +322 -15
  35. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  36. package/neural-interface/public/shared/ui-menubar.js +34 -10
  37. package/neural-interface/public/shared/ui-navbar.js +8 -6
  38. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  39. package/neural-interface/public/shared/ui-settings.js +274 -50
  40. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  41. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  42. package/neural-interface/public/shared/ui-sync.js +9 -5
  43. package/neural-interface/public/shared/ui-terminal.js +76 -27
  44. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  45. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  46. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  47. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  48. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  49. package/neural-interface/public/variant/2d/main.js +9 -7
  50. package/neural-interface/public/variant/3d/main.js +10 -8
  51. package/neural-interface/server.js +1292 -255
  52. package/package.json +1 -1
  53. package/skills/leonardo/SKILL.md +5 -1
  54. package/skills/leonardo/modules/image-prompter.md +5 -1
  55. package/skills/leonardo/modules/video-prompter.md +5 -1
  56. package/skills/synabun/SKILL.md +44 -26
  57. package/skills/synabun/modules/audit.md +40 -36
  58. package/skills/synabun/modules/changelog.md +22 -18
  59. package/skills/synabun/modules/memorize.md +19 -15
  60. package/skills/synabun/modules/schedule.md +285 -0
  61. package/CLAUDE.md +0 -217
package/README.md CHANGED
@@ -151,7 +151,7 @@ synabun
151
151
  ### Or clone from GitHub
152
152
 
153
153
  ```bash
154
- git clone https://github.com/danilokhury/Synabun.git
154
+ git clone --depth=1 https://github.com/danilokhury/Synabun.git
155
155
  cd Synabun
156
156
  npm start
157
157
  ```
@@ -20,7 +20,7 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, ren
20
20
  import { execSync } from 'node:child_process';
21
21
  import { dirname, join } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
- import { detectProject, DATA_DIR } from './shared.mjs';
23
+ import { detectProject, DATA_DIR, appendLoopLog } from './shared.mjs';
24
24
 
25
25
  // Cross-platform safety: catch uncaught errors and output valid hook JSON
26
26
  process.on('uncaughtException', () => { try { process.stdout.write('{}'); } catch {} process.exit(0); });
@@ -236,14 +236,15 @@ function buildBrowserNote(state) {
236
236
  '=== BROWSER ENFORCEMENT (MANDATORY) ===',
237
237
  'This automation REQUIRES the SynaBun internal browser. You MUST:',
238
238
  ];
239
- // Session and tab are auto-routed via SYNABUN_BROWSER_SESSION / SYNABUN_BROWSER_TAB env vars.
240
- // Display IDs for debugging visibility but no manual pass-through needed.
239
+ // HTTP MCP is shared across all loops — env-var pinning does NOT route tool calls.
240
+ // Claude must pass sessionId/tabId explicitly on every browser tool call, or a
241
+ // concurrent loop will hijack the tab.
241
242
  if (state.browserSessionId) {
242
243
  lines.push(`YOUR BROWSER SESSION: ${state.browserSessionId}`);
243
244
  if (state.browserTabId) {
244
245
  lines.push(`YOUR BROWSER TAB: ${state.browserTabId}`);
245
246
  }
246
- lines.push('Your session and tab are auto-configured via environment. All browser tool calls will target your dedicated tab automatically — no need to pass sessionId or tabId manually.');
247
+ lines.push(`CRITICAL: Pass sessionId: "${state.browserSessionId}"${state.browserTabId ? ` and tabId: "${state.browserTabId}"` : ''} to EVERY browser tool call (browser_navigate, browser_click, browser_snapshot, browser_extract_*, etc.). Omitting them will cause your tool calls to hijack another loop's tab. No exceptions.`);
247
248
  }
248
249
  lines.push(
249
250
  '1. Call `browser_navigate` with your target URL to create or reuse a browser session',
@@ -720,28 +721,39 @@ async function main() {
720
721
 
721
722
  // --- Loop marker detection (BEFORE greeting — loops must bypass greeting) ---
722
723
  const terminalSessionEnv = process.env.SYNABUN_TERMINAL_SESSION || '';
724
+ if (terminalSessionEnv) {
725
+ appendLoopLog(terminalSessionEnv, 'prompt-submit:enter', 'UserPromptSubmit fired inside loop terminal', {
726
+ sessionId, isLoopMarker: /^\[SynaBun Loop\]/i.test(trimmed), promptPreview: trimmed.slice(0, 200),
727
+ });
728
+ }
723
729
  if (sessionId && /^\[SynaBun Loop\]/i.test(trimmed)) {
724
730
  try {
725
731
  if (existsSync(LOOP_DIR)) {
726
732
  const pending = readdirSync(LOOP_DIR)
727
733
  .filter(f => f.startsWith('pending-') && f.endsWith('.json'));
728
- // Filter by terminalSessionId when available (multi-loop isolation).
729
- // Without the env var, fall back to first match (legacy behavior).
734
+ // STRICT multi-loop isolation: only claim a pending file whose
735
+ // terminalSessionId matches our SYNABUN_TERMINAL_SESSION env.
736
+ // Legacy "match first pending" fallback was removed — it silently
737
+ // stole loops across concurrent sessions (sidepanel plan ↔ scheduled
738
+ // CLI loop in same cwd). Loop spawners MUST set the env var.
739
+ // Legacy pending files without terminalSessionId are still claimable
740
+ // when this session also has no env (ancient manual-loop compat).
730
741
  let matchedPending = null;
731
- if (terminalSessionEnv) {
732
- for (const pf of pending) {
733
- try {
734
- const ps = JSON.parse(readFileSync(join(LOOP_DIR, pf), 'utf-8'));
742
+ for (const pf of pending) {
743
+ try {
744
+ const ps = JSON.parse(readFileSync(join(LOOP_DIR, pf), 'utf-8'));
745
+ if (terminalSessionEnv) {
735
746
  if (ps.terminalSessionId === terminalSessionEnv) { matchedPending = pf; break; }
736
- } catch { continue; }
737
- }
738
- } else if (pending.length > 0) {
739
- matchedPending = pending[0];
747
+ } else {
748
+ if (!ps.terminalSessionId) { matchedPending = pf; break; }
749
+ }
750
+ } catch { continue; }
740
751
  }
741
752
  if (matchedPending) {
742
753
  const pendingPath = join(LOOP_DIR, matchedPending);
743
754
  const targetPath = join(LOOP_DIR, `${sessionId}.json`);
744
755
  renameSync(pendingPath, targetPath);
756
+ appendLoopLog(terminalSessionEnv, 'prompt-submit:claim', 'pending loop renamed to active', { from: matchedPending, to: `${sessionId}.json` });
745
757
 
746
758
  const state = JSON.parse(readFileSync(targetPath, 'utf-8'));
747
759
  delete state.pending;
@@ -751,6 +763,7 @@ async function main() {
751
763
  state.currentIteration = 1;
752
764
  // Preserve terminalSessionId — loop driver needs it for session isolation
753
765
  writeFileSync(targetPath, JSON.stringify(state, null, 2));
766
+ appendLoopLog(terminalSessionEnv, 'prompt-submit:inject', 'iteration 1 context built — emitting additionalContext', { task: state.task?.slice(0, 200), totalIterations: state.totalIterations, usesBrowser: !!state.usesBrowser });
754
767
 
755
768
  const browserNote = buildBrowserNote(state);
756
769
  const blockerRule = state.usesBrowser
@@ -782,8 +795,11 @@ async function main() {
782
795
  try { state = JSON.parse(readFileSync(existingPath, 'utf-8')); } catch { /* skip */ }
783
796
  }
784
797
  // Fallback: scan loop files for an active loop (session ID may have changed after /clear).
785
- // When SYNABUN_TERMINAL_SESSION is set, only match loops owned by this terminal.
786
- // Without the env var, fall back to first active loop (legacy behavior).
798
+ // STRICT isolation: only match our terminal's loop.
799
+ // - env set → require exact terminalSessionId match
800
+ // - env unset → only match loops WITHOUT terminalSessionId (legacy manual loops)
801
+ // Previously the filter was disabled when env was empty, letting any
802
+ // Claude session grab any active loop in the same LOOP_DIR.
787
803
  if (!state?.active || !(state?.currentIteration > 0)) {
788
804
  const now = Date.now();
789
805
  const allLoopFiles = readdirSync(LOOP_DIR)
@@ -793,8 +809,12 @@ async function main() {
793
809
  const fullPath = join(LOOP_DIR, f);
794
810
  const candidate = JSON.parse(readFileSync(fullPath, 'utf-8'));
795
811
  if (candidate.active && candidate.currentIteration > 0) {
796
- // Multi-loop isolation: only match OUR terminal's loop
797
- if (terminalSessionEnv && candidate.terminalSessionId && candidate.terminalSessionId !== terminalSessionEnv) continue;
812
+ // Multi-loop isolation
813
+ if (terminalSessionEnv) {
814
+ if (candidate.terminalSessionId !== terminalSessionEnv) continue;
815
+ } else {
816
+ if (candidate.terminalSessionId) continue;
817
+ }
798
818
  // Validate the loop hasn't exceeded its own time cap + grace
799
819
  // Skip loops inactive for >45 minutes (stuck)
800
820
  const lastAct = new Date(candidate.lastIterationAt || candidate.startedAt || 0).getTime();
@@ -807,6 +827,7 @@ async function main() {
807
827
  }
808
828
  }
809
829
  if (state?.active && state?.currentIteration > 0) {
830
+ appendLoopLog(terminalSessionEnv, 'prompt-submit:inject', 'subsequent iteration context built', { iter: state.currentIteration, total: state.totalIterations, usesBrowser: !!state.usesBrowser });
810
831
  const formattingRules = extractFormattingRules(state.task);
811
832
  const browserNote = buildBrowserNote(state);
812
833
  const blockerRule2 = state.usesBrowser
@@ -171,9 +171,16 @@ async function main() {
171
171
  if (data.stopped === true || data.finishedAt) continue;
172
172
  // Active loop detected (pending or already running)
173
173
  if (data.active || data.pending) {
174
- // Multi-loop isolation: only match OUR terminal's loop
175
- // If the loop belongs to a specific terminal, only match if this session is that same terminal
176
- if (data.terminalSessionId && data.terminalSessionId !== terminalSessionEnv) continue;
174
+ // STRICT multi-loop isolation:
175
+ // - env set → require exact terminalSessionId match
176
+ // - env unset → only match loops WITHOUT terminalSessionId
177
+ // A non-loop CLI (env unset) must NOT latch onto a scheduled loop
178
+ // and suppress its greeting + recall.
179
+ if (terminalSessionEnv) {
180
+ if (data.terminalSessionId !== terminalSessionEnv) continue;
181
+ } else {
182
+ if (data.terminalSessionId) continue;
183
+ }
177
184
  isLoopSession = true;
178
185
  break;
179
186
  }
@@ -5,8 +5,8 @@
5
5
  * post-remember, pre-compact, and stop hooks.
6
6
  */
7
7
 
8
- import { readFileSync, existsSync, writeFileSync, readdirSync, unlinkSync } from 'node:fs';
9
- import { dirname, join, basename } from 'node:path';
8
+ import { readFileSync, existsSync, writeFileSync, readdirSync, unlinkSync, appendFileSync, mkdirSync } from 'node:fs';
9
+ import { dirname, join, basename, resolve as pathResolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { getDataHome } from '../../lib/paths.js';
12
12
 
@@ -17,6 +17,43 @@ export const MCP_DATA_DIR = join(DATA_HOME, 'mcp-data');
17
17
  export const ENV_PATH = join(DATA_HOME, '.env');
18
18
  export const HOOK_FEATURES_PATH = join(DATA_DIR, 'hook-features.json');
19
19
  export const PENDING_REMEMBER_DIR = join(DATA_DIR, 'pending-remember');
20
+ export const LOOP_LOG_DIR = join(DATA_DIR, 'logs');
21
+
22
+ // --- Loop logging (mirrors neural-interface/lib/loop-logger.js) ---
23
+ // Writes per-loop entries from inside hooks into the same logfile the server uses.
24
+
25
+ let _loopLogDirEnsured = false;
26
+ function ensureLoopLogDir() {
27
+ if (_loopLogDirEnsured) return;
28
+ try {
29
+ if (!existsSync(LOOP_LOG_DIR)) mkdirSync(LOOP_LOG_DIR, { recursive: true });
30
+ _loopLogDirEnsured = true;
31
+ } catch { /* ok */ }
32
+ }
33
+
34
+ function safeJsonInline(obj) {
35
+ try {
36
+ return JSON.stringify(obj, (_k, v) => {
37
+ if (typeof v === 'string' && v.length > 800) return v.slice(0, 800) + `…(+${v.length - 800})`;
38
+ return v;
39
+ });
40
+ } catch { return '<unserializable>'; }
41
+ }
42
+
43
+ /**
44
+ * Append one log entry to the per-loop logfile and stderr.
45
+ * tsid may be null/undefined; will fall back to SYNABUN_TERMINAL_SESSION env.
46
+ */
47
+ export function appendLoopLog(tsid, tag, msg, meta = undefined) {
48
+ const sid = tsid || process.env.SYNABUN_TERMINAL_SESSION || null;
49
+ const line = `${new Date().toISOString()} | hook:${tag} | ${msg}${meta ? ' | ' + safeJsonInline(meta) : ''}\n`;
50
+ try { process.stderr.write(`[loop:hook:${tag}] ${msg}${meta ? ' ' + safeJsonInline(meta) : ''}\n`); } catch { /* ok */ }
51
+ if (!sid) return;
52
+ ensureLoopLogDir();
53
+ try {
54
+ appendFileSync(pathResolve(LOOP_LOG_DIR, `loop-${sid}.log`), line);
55
+ } catch { /* ok */ }
56
+ }
20
57
 
21
58
  // --- Stdin ---
22
59
 
@@ -31,7 +31,7 @@
31
31
  import { readFileSync, writeFileSync, existsSync, unlinkSync, appendFileSync, readdirSync, renameSync, statSync } from 'node:fs';
32
32
  import { dirname, join } from 'node:path';
33
33
  import { fileURLToPath } from 'node:url';
34
- import { cleanupStaleLoops, detectProject, DATA_DIR } from './shared.mjs';
34
+ import { cleanupStaleLoops, detectProject, DATA_DIR, appendLoopLog } from './shared.mjs';
35
35
 
36
36
  // Cross-platform safety: catch uncaught errors and output valid hook JSON
37
37
  process.on('uncaughtException', () => { try { process.stdout.write('{}'); } catch {} process.exit(0); });
@@ -282,6 +282,7 @@ async function main() {
282
282
  // loops owned by THIS terminal — prevents other automations from suppressing
283
283
  // compaction in unrelated sessions.
284
284
  const terminalSessionEnv = process.env.SYNABUN_TERMINAL_SESSION || '';
285
+ appendLoopLog(terminalSessionEnv || null, 'stop:enter', 'Stop hook fired', { sessionId, hasEnv: !!terminalSessionEnv });
285
286
  let hasActiveLoop = false;
286
287
  try {
287
288
  if (existsSync(LOOP_DIR)) {
@@ -290,8 +291,16 @@ async function main() {
290
291
  try {
291
292
  const candidate = JSON.parse(readFileSync(join(LOOP_DIR, f), 'utf-8'));
292
293
  if (candidate.active && (candidate.currentIteration || 0) > 0) {
293
- // When running inside a loop terminal, only match OUR loop
294
- if (terminalSessionEnv && candidate.terminalSessionId && candidate.terminalSessionId !== terminalSessionEnv) continue;
294
+ // STRICT isolation. Before: `terminalSessionEnv &&` gated the
295
+ // mismatch check, so a session with no env would pick up ANY
296
+ // loop and wrongly suppress its own compaction. Now:
297
+ // - env set → require exact terminalSessionId match
298
+ // - env unset → only count loops WITHOUT terminalSessionId
299
+ if (terminalSessionEnv) {
300
+ if (candidate.terminalSessionId !== terminalSessionEnv) continue;
301
+ } else {
302
+ if (candidate.terminalSessionId) continue;
303
+ }
295
304
  hasActiveLoop = true;
296
305
  break;
297
306
  }
@@ -410,9 +419,17 @@ async function main() {
410
419
  loop = candidate;
411
420
  const newPath = join(LOOP_DIR, `${sessionId}.json`);
412
421
  if (fp !== newPath) {
413
- try { renameSync(fp, newPath); loopFlagPath = newPath; } catch { loopFlagPath = fp; }
422
+ try {
423
+ renameSync(fp, newPath);
424
+ loopFlagPath = newPath;
425
+ appendLoopLog(terminalSessionEnv, 'stop:claim', 'matched loop via Strategy A (env), renamed', { from: f, to: `${sessionId}.json`, currentIteration: candidate.currentIteration });
426
+ } catch (err) {
427
+ loopFlagPath = fp;
428
+ appendLoopLog(terminalSessionEnv, 'stop:claim', 'matched loop via Strategy A (env), rename FAILED', { from: f, err: err.message });
429
+ }
414
430
  } else {
415
431
  loopFlagPath = fp;
432
+ appendLoopLog(terminalSessionEnv, 'stop:claim', 'matched loop via Strategy A (env), already named correctly', { file: f, currentIteration: candidate.currentIteration });
416
433
  }
417
434
  break;
418
435
  }
@@ -505,7 +522,12 @@ async function main() {
505
522
 
506
523
  // Signal server loop driver to send /clear + next iteration prompt
507
524
  loop.awaitingNext = true;
508
- try { writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2)); } catch { /* ok */ }
525
+ try {
526
+ writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2));
527
+ appendLoopLog(loop.terminalSessionId || terminalSessionEnv, 'stop:awaitingNext', 'set awaitingNext=true', { iter: loop.currentIteration, total: loop.totalIterations, file: loopFlagPath });
528
+ } catch (err) {
529
+ appendLoopLog(loop.terminalSessionId || terminalSessionEnv, 'stop:awaitingNext', 'write FAILED', { err: err.message });
530
+ }
509
531
 
510
532
  // Reset edit tracking for fresh iteration context
511
533
  const rememberFlagForReset = join(PENDING_REMEMBER_DIR, `${sessionId}.json`);
@@ -530,7 +552,12 @@ async function main() {
530
552
  }
531
553
 
532
554
  loop.awaitingNext = true;
533
- try { writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2)); } catch { /* ok */ }
555
+ try {
556
+ writeFileSync(loopFlagPath, JSON.stringify(loop, null, 2));
557
+ appendLoopLog(loop.terminalSessionId || terminalSessionEnv, 'stop:awaitingNext', 'set awaitingNext=true', { iter: loop.currentIteration, total: loop.totalIterations, file: loopFlagPath });
558
+ } catch (err) {
559
+ appendLoopLog(loop.terminalSessionId || terminalSessionEnv, 'stop:awaitingNext', 'write FAILED', { err: err.message });
560
+ }
534
561
 
535
562
  const rememberFlagForReset = join(PENDING_REMEMBER_DIR, `${sessionId}.json`);
536
563
  if (existsSync(rememberFlagForReset)) {
@@ -86,12 +86,20 @@ Use "category" with action "list" to see valid category names before using remem
86
86
  instructions += `\n\nLeonardo tools are 100% browser-based — no API key needed. Use leonardo_browser_navigate to go to the right page, then use generic browser tools (browser_click, browser_fill, browser_snapshot) to configure settings (model, style, dimensions, motion controls), and leonardo_browser_generate to fill the prompt and click Generate. Use the /leonardo skill for the full guided creation experience.`;
87
87
  }
88
88
 
89
+ // Dedupe this log — buildServerInstructions runs on every HTTP MCP request
90
+ // (fresh server per request), which would otherwise spam this line forever.
89
91
  const profileName = getActiveProfileName();
90
- console.error(`[SynaBun] Profile: ${profileName} (${activeGroups.size} groups, instructions: ${instructions.length} chars)`);
92
+ const sig = `${profileName}|${activeGroups.size}|${instructions.length}`;
93
+ if (sig !== _lastInstructionsSig) {
94
+ _lastInstructionsSig = sig;
95
+ console.error(`[SynaBun] Profile: ${profileName} (${activeGroups.size} groups, instructions: ${instructions.length} chars)`);
96
+ }
91
97
 
92
98
  return instructions;
93
99
  }
94
100
 
101
+ let _lastInstructionsSig: string | null = null;
102
+
95
103
  // Register ALL tools on a given McpServer instance.
96
104
  // All groups are always registered; applyProfile() enables/disables them.
97
105
  export function registerTools(server: McpServer) {
@@ -10,6 +10,14 @@ const DEFAULT_TIMEOUT = 10_000;
10
10
  const LONG_TIMEOUT = 30_000;
11
11
  const SESSION_CREATE_TIMEOUT = 70_000;
12
12
 
13
+ export function isBrowserFastMode(): boolean {
14
+ return process.env.SYNABUN_BROWSER_FAST === '1';
15
+ }
16
+
17
+ export function isBrowserCompactMode(): boolean {
18
+ return process.env.SYNABUN_BROWSER_COMPACT === '1' || isBrowserFastMode();
19
+ }
20
+
13
21
  export interface BrowserSessionInfo {
14
22
  id: string;
15
23
  url: string;
@@ -82,8 +90,14 @@ export async function resolveSession(
82
90
  autoCreate?: { url?: string },
83
91
  tabId?: string
84
92
  ): Promise<{ sessionId: string; tabId?: string } | { error: string }> {
85
- // Resolve tab ID from explicit param or environment variable
86
- const resolvedTabId = tabId || process.env.SYNABUN_BROWSER_TAB || undefined;
93
+ // Resolve tab ID from explicit param or environment variable.
94
+ // When SYNABUN_BROWSER_TAB is pinned (loop/agent context), the env value wins
95
+ // even if caller passes a different tabId — prevents tab-leak across loops.
96
+ const pinnedTab = process.env.SYNABUN_BROWSER_TAB || undefined;
97
+ const resolvedTabId = pinnedTab || tabId || undefined;
98
+ if (pinnedTab && tabId && tabId !== pinnedTab) {
99
+ console.error(`[MCP] tabId override ignored: pinned=${pinnedTab} requested=${tabId}`);
100
+ }
87
101
 
88
102
  // Agent/loop-scoped browser session — set by the orchestrator to pin
89
103
  // this MCP instance to a specific browser session (multi-session isolation).
@@ -121,6 +135,17 @@ export async function resolveSession(
121
135
  return { sessionId, tabId: resolvedTabId };
122
136
  }
123
137
 
138
+ // Codex fast mode favors one direct create call on first navigate. This avoids
139
+ // the list-sessions round trip that dominates short browser flows.
140
+ if (autoCreate && isBrowserFastMode()) {
141
+ const created = await request('POST', '/api/browser/sessions', {
142
+ url: autoCreate.url || 'about:blank',
143
+ }, SESSION_CREATE_TIMEOUT);
144
+ if (created.error) return { error: `Failed to auto-create session: ${created.error}` };
145
+ _affinitySessionId = created.sessionId as string;
146
+ return { sessionId: _affinitySessionId, tabId: resolvedTabId };
147
+ }
148
+
124
149
  // List sessions (single GET used for both affinity check and auto-selection)
125
150
  const data = await request('GET', '/api/browser/sessions');
126
151
  if (data.error) return { error: data.error };
@@ -212,21 +237,22 @@ export async function navigate(
212
237
  ): Promise<NiResponse> {
213
238
  return request('POST', `/api/browser/sessions/${sessionId}/navigate`, {
214
239
  url,
240
+ ...(isBrowserCompactMode() && { compact: true }),
215
241
  ...(returnSnapshot && { returnSnapshot }),
216
242
  ...(tabId && { tabId }),
217
243
  }, LONG_TIMEOUT);
218
244
  }
219
245
 
220
246
  export async function goBack(sessionId: string, tabId?: string): Promise<NiResponse> {
221
- return request('POST', `/api/browser/sessions/${sessionId}/back`, { ...(tabId && { tabId }) });
247
+ return request('POST', `/api/browser/sessions/${sessionId}/back`, { ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
222
248
  }
223
249
 
224
250
  export async function goForward(sessionId: string, tabId?: string): Promise<NiResponse> {
225
- return request('POST', `/api/browser/sessions/${sessionId}/forward`, { ...(tabId && { tabId }) });
251
+ return request('POST', `/api/browser/sessions/${sessionId}/forward`, { ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
226
252
  }
227
253
 
228
254
  export async function reload(sessionId: string, tabId?: string): Promise<NiResponse> {
229
- return request('POST', `/api/browser/sessions/${sessionId}/reload`, { ...(tabId && { tabId }) }, LONG_TIMEOUT);
255
+ return request('POST', `/api/browser/sessions/${sessionId}/reload`, { ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) }, LONG_TIMEOUT);
230
256
  }
231
257
 
232
258
  // ── Interaction (selector-based) ──
@@ -243,29 +269,31 @@ export async function click(
243
269
  selector,
244
270
  ...(nthMatch !== undefined && { nthMatch }),
245
271
  ...(textHint && { textHint }),
272
+ ...(isBrowserCompactMode() && { compact: true }),
246
273
  ...(returnSnapshot && { returnSnapshot }),
247
274
  ...(tabId && { tabId }),
248
275
  });
249
276
  }
250
277
 
251
278
  export async function fill(sessionId: string, selector: string, value: string, nthMatch?: number, tabId?: string, textHint?: string): Promise<NiResponse> {
252
- return request('POST', `/api/browser/sessions/${sessionId}/fill`, { selector, value, ...(nthMatch !== undefined && { nthMatch }), ...(textHint && { textHint }), ...(tabId && { tabId }) });
279
+ return request('POST', `/api/browser/sessions/${sessionId}/fill`, { selector, value, ...(nthMatch !== undefined && { nthMatch }), ...(textHint && { textHint }), ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
253
280
  }
254
281
 
255
- export async function type(sessionId: string, selector: string | null, text: string, nthMatch?: number, tabId?: string, textHint?: string): Promise<NiResponse> {
256
- return request('POST', `/api/browser/sessions/${sessionId}/type`, { selector, text, ...(nthMatch !== undefined && { nthMatch }), ...(textHint && { textHint }), ...(tabId && { tabId }) });
282
+ export async function type(sessionId: string, selector: string | null, text: string, nthMatch?: number, tabId?: string, textHint?: string, mode?: 'sequential' | 'insert'): Promise<NiResponse> {
283
+ const resolvedMode = mode || (isBrowserFastMode() ? 'insert' : 'sequential');
284
+ return request('POST', `/api/browser/sessions/${sessionId}/type`, { selector, text, mode: resolvedMode, ...(nthMatch !== undefined && { nthMatch }), ...(textHint && { textHint }), ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
257
285
  }
258
286
 
259
287
  export async function hover(sessionId: string, selector: string, nthMatch?: number, tabId?: string, textHint?: string): Promise<NiResponse> {
260
- return request('POST', `/api/browser/sessions/${sessionId}/hover`, { selector, ...(nthMatch !== undefined && { nthMatch }), ...(textHint && { textHint }), ...(tabId && { tabId }) });
288
+ return request('POST', `/api/browser/sessions/${sessionId}/hover`, { selector, ...(nthMatch !== undefined && { nthMatch }), ...(textHint && { textHint }), ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
261
289
  }
262
290
 
263
291
  export async function selectOption(sessionId: string, selector: string, value: string, nthMatch?: number, tabId?: string): Promise<NiResponse> {
264
- return request('POST', `/api/browser/sessions/${sessionId}/select`, { selector, value, ...(nthMatch !== undefined && { nthMatch }), ...(tabId && { tabId }) });
292
+ return request('POST', `/api/browser/sessions/${sessionId}/select`, { selector, value, ...(nthMatch !== undefined && { nthMatch }), ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
265
293
  }
266
294
 
267
295
  export async function pressKey(sessionId: string, key: string, tabId?: string): Promise<NiResponse> {
268
- return request('POST', `/api/browser/sessions/${sessionId}/press`, { key, ...(tabId && { tabId }) });
296
+ return request('POST', `/api/browser/sessions/${sessionId}/press`, { key, ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) });
269
297
  }
270
298
 
271
299
  export async function scroll(
@@ -273,7 +301,7 @@ export async function scroll(
273
301
  opts: { direction: string; distance?: number; selector?: string; returnSnapshot?: { mode?: string; selector?: string; viewport?: boolean } },
274
302
  tabId?: string
275
303
  ): Promise<NiResponse> {
276
- return request('POST', `/api/browser/sessions/${sessionId}/scroll`, { ...opts, ...(tabId && { tabId }) } as Record<string, unknown>);
304
+ return request('POST', `/api/browser/sessions/${sessionId}/scroll`, { ...opts, ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) } as Record<string, unknown>);
277
305
  }
278
306
 
279
307
  export async function upload(
@@ -283,7 +311,7 @@ export async function upload(
283
311
  nthMatch?: number,
284
312
  tabId?: string
285
313
  ): Promise<NiResponse> {
286
- return request('POST', `/api/browser/sessions/${sessionId}/upload`, { selector, filePaths, ...(nthMatch !== undefined && { nthMatch }), ...(tabId && { tabId }) }, LONG_TIMEOUT);
314
+ return request('POST', `/api/browser/sessions/${sessionId}/upload`, { selector, filePaths, ...(nthMatch !== undefined && { nthMatch }), ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) }, LONG_TIMEOUT);
287
315
  }
288
316
 
289
317
  // ── Observation ──
@@ -338,7 +366,7 @@ export async function waitFor(
338
366
  opts: { selector?: string; state?: string; loadState?: string; timeout?: number },
339
367
  tabId?: string
340
368
  ): Promise<NiResponse> {
341
- return request('POST', `/api/browser/sessions/${sessionId}/wait`, { ...opts, ...(tabId && { tabId }) }, LONG_TIMEOUT);
369
+ return request('POST', `/api/browser/sessions/${sessionId}/wait`, { ...opts, ...(isBrowserCompactMode() && { compact: true }), ...(tabId && { tabId }) }, LONG_TIMEOUT);
342
370
  }
343
371
 
344
372
  // ── Whiteboard ──
@@ -14,6 +14,7 @@ const ALL_BROWSER_GROUPS = ['browser', 'browser_twitter', 'browser_facebook', 'b
14
14
  export const PROFILE_PRESETS: Record<string, string[]> = {
15
15
  core: ['git', 'image'],
16
16
  standard: ['git', 'image', 'whiteboard', 'card', 'tictactoe'],
17
+ 'codex-browser': ['git', 'image', 'browser', 'browser_twitter'],
17
18
  twitter: ['git', 'image', 'browser', 'browser_twitter'],
18
19
  facebook: ['git', 'image', 'browser', 'browser_facebook'],
19
20
  tiktok: ['git', 'image', 'browser', 'browser_tiktok'],
@@ -116,21 +117,21 @@ export function applyProfile(profileName: string): { profile: string; enabled: s
116
117
  if (!shouldEnable && _activeGroups.has(group)) disabled.push(group);
117
118
  }
118
119
 
120
+ const prevProfileName = _activeProfileName;
119
121
  _activeGroups = newGroups;
120
122
  _activeProfileName = profileName.toLowerCase().trim();
121
123
 
122
124
  // Count enabled tools (core + enabled groups)
123
- let totalTools = 10; // core memory tools + profile tool
124
- for (const [, tools] of _toolGroups) {
125
- if (_activeGroups.has([..._toolGroups].find(([, t]) => t === tools)?.[0] ?? '')) totalTools += tools.length;
126
- }
127
- // Simpler recount
128
- totalTools = 10;
125
+ let totalTools = 10;
129
126
  for (const [group, tools] of _toolGroups) {
130
127
  if (_activeGroups.has(group)) totalTools += tools.length;
131
128
  }
132
129
 
133
- console.error(`[SynaBun] Profile switched to "${_activeProfileName}" (${_activeGroups.size} groups, ${totalTools} tools, +${enabled.join(',') || 'none'}, -${disabled.join(',') || 'none'})`);
130
+ // Only log on an actual change — HTTP MCP creates a fresh server per request,
131
+ // which would otherwise spam this line twice per request forever.
132
+ if (prevProfileName !== _activeProfileName || enabled.length > 0 || disabled.length > 0) {
133
+ console.error(`[SynaBun] Profile switched to "${_activeProfileName}" (${_activeGroups.size} groups, ${totalTools} tools, +${enabled.join(',') || 'none'}, -${disabled.join(',') || 'none'})`);
134
+ }
134
135
  return { profile: _activeProfileName, enabled, disabled, totalTools };
135
136
  }
136
137
 
@@ -71,16 +71,18 @@ export async function handleBrowserWait(args: {
71
71
  loadState: args.loadState,
72
72
  timeout: args.timeout,
73
73
  }, resolved.tabId);
74
- if (result.error) return text(`Wait failed: ${result.error}`);
75
-
76
- if (args.loadState) {
77
- return text(`Page reached "${args.loadState}" — now at ${result.url} "${result.title}"`);
78
- }
79
- if (args.selector) {
80
- return text(`"${args.selector}" is now ${result.state}`);
81
- }
82
- return text(`Waited ${args.timeout || 1000}ms — now at ${result.url} "${result.title}"`);
83
- }
74
+ if (result.error) return text(`Wait failed: ${result.error}`);
75
+
76
+ if (args.loadState) {
77
+ if (ni.isBrowserCompactMode()) return text(`Page reached "${args.loadState}"`);
78
+ return text(`Page reached "${args.loadState}" — now at ${result.url} "${result.title}"`);
79
+ }
80
+ if (args.selector) {
81
+ return text(`"${args.selector}" is now ${result.state}`);
82
+ }
83
+ if (ni.isBrowserCompactMode()) return text(`Waited ${args.timeout || 1000}ms`);
84
+ return text(`Waited ${args.timeout || 1000}ms — now at ${result.url} "${result.title}"`);
85
+ }
84
86
 
85
87
  // ── browser_session ──
86
88