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.
- package/CHANGELOG.md +175 -881
- package/README.md +1 -1
- package/hooks/claude-code/prompt-submit.mjs +39 -18
- package/hooks/claude-code/session-start.mjs +10 -3
- package/hooks/claude-code/shared.mjs +39 -2
- package/hooks/claude-code/stop.mjs +33 -6
- package/mcp-server/src/index.ts +9 -1
- package/mcp-server/src/services/neural-interface.ts +42 -14
- package/mcp-server/src/services/profiles.ts +8 -7
- package/mcp-server/src/tools/browser-advanced.ts +12 -10
- package/mcp-server/src/tools/browser-interact.ts +39 -38
- package/mcp-server/src/tools/browser-navigate.ts +12 -4
- package/mcp-server/src/tools/loop.ts +7 -3
- package/mcp-server/src/tools/profile.ts +19 -1
- package/neural-interface/lib/loop-logger.js +103 -0
- package/neural-interface/lib/mcp-installer.js +109 -0
- package/neural-interface/lib/plugin-installer.js +264 -0
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/shared/api.js +66 -17
- package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
- package/neural-interface/public/shared/cdx/cdx-panel.js +117 -14
- package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
- package/neural-interface/public/shared/cdx/cdx-styles.js +55 -10
- package/neural-interface/public/shared/cdx/cdx-tabs.js +394 -72
- package/neural-interface/public/shared/cli-status.js +157 -0
- package/neural-interface/public/shared/constants.js +3 -2
- package/neural-interface/public/shared/html-shell.js +32 -11
- package/neural-interface/public/shared/ocp/ocp-panel.js +127 -28
- package/neural-interface/public/shared/ocp/ocp-render.js +52 -44
- package/neural-interface/public/shared/ocp/ocp-styles.js +33 -0
- package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
- package/neural-interface/public/shared/styles.css +635 -0
- package/neural-interface/public/shared/ui-automation-studio.js +38 -12
- package/neural-interface/public/shared/ui-claude-panel.js +322 -15
- package/neural-interface/public/shared/ui-file-explorer.js +38 -18
- package/neural-interface/public/shared/ui-menubar.js +34 -10
- package/neural-interface/public/shared/ui-navbar.js +8 -6
- package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
- package/neural-interface/public/shared/ui-settings.js +274 -50
- package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
- package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
- package/neural-interface/public/shared/ui-sync.js +9 -5
- package/neural-interface/public/shared/ui-terminal.js +76 -27
- package/neural-interface/public/shared/ui-tool-updates.js +49 -15
- package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
- package/neural-interface/public/shared/ui-tutorial.js +24 -18
- package/neural-interface/public/shared/ui-whiteboard.js +79 -31
- package/neural-interface/public/shared/vterm-buffer.js +9 -8
- package/neural-interface/public/variant/2d/main.js +9 -7
- package/neural-interface/public/variant/3d/main.js +10 -8
- package/neural-interface/server.js +1292 -255
- package/package.json +1 -1
- package/skills/leonardo/SKILL.md +5 -1
- package/skills/leonardo/modules/image-prompter.md +5 -1
- package/skills/leonardo/modules/video-prompter.md +5 -1
- package/skills/synabun/SKILL.md +44 -26
- package/skills/synabun/modules/audit.md +40 -36
- package/skills/synabun/modules/changelog.md +22 -18
- package/skills/synabun/modules/memorize.md +19 -15
- package/skills/synabun/modules/schedule.md +285 -0
- package/CLAUDE.md +0 -217
package/README.md
CHANGED
|
@@ -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
|
-
//
|
|
240
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
729
|
-
//
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
//
|
|
786
|
-
//
|
|
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
|
|
797
|
-
if (terminalSessionEnv
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
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
|
-
//
|
|
294
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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)) {
|
package/mcp-server/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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}"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|