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
|
@@ -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}` :
|
|
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
|
|
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
|
-
|
|
2978
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3715
|
-
|
|
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
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
child
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
_codexOrphanBuffer
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
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
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
8148
|
-
//
|
|
8149
|
-
//
|
|
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
|
-
|
|
8341
|
+
loopLog(terminalSessionId, 'launch:browser', 'strategy 0: pinned to requested browser', { browserSessionId });
|
|
8163
8342
|
} catch (err) {
|
|
8164
|
-
|
|
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
|
-
|
|
8347
|
+
loopLog(terminalSessionId, 'launch:browser', 'strategy 0 skipped: requested browser not in registry', { requestedBrowserSessionId });
|
|
8169
8348
|
}
|
|
8170
8349
|
}
|
|
8171
8350
|
|
|
8172
|
-
// Strategy 1: Reuse
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8202
|
-
//
|
|
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)
|
|
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
|
-
|
|
8216
|
-
|
|
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
|
-
|
|
8219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8314
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
8590
|
-
|
|
8591
|
-
|
|
8592
|
-
|
|
8593
|
-
if (bSession
|
|
8594
|
-
if (bSession
|
|
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
|
-
|
|
8599
|
-
} catch {
|
|
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
|
-
|
|
8887
|
+
// Profile-specific terminal + driver. Claude Code uses the interactive CLI
|
|
8888
|
+
// with hook-driven iteration. Codex/OpenCode/others use a shell with the
|
|
8889
|
+
// exec-mode driver (sentinel-echo based). Mirror regular /api/loop/launch.
|
|
8890
|
+
if (cliProfile === 'claude-code') {
|
|
8891
|
+
createTerminalSession(cliProfile, 120, 30, PACKAGE_ROOT, { model: cliModel, effort: cliEffort, extraEnv: schedExtraEnv, sessionId: terminalSessionId });
|
|
8892
|
+
attachLoopDriver(terminalSessionId);
|
|
8893
|
+
} else {
|
|
8894
|
+
createTerminalSession('shell', 120, 30, PACKAGE_ROOT, { extraEnv: schedExtraEnv, sessionId: terminalSessionId });
|
|
8895
|
+
attachExecLoopDriver(terminalSessionId);
|
|
8896
|
+
}
|
|
8630
8897
|
broadcastSync({ type: 'terminal:session-created', sessionId: terminalSessionId, profile: cliProfile });
|
|
8631
8898
|
|
|
8632
8899
|
// Update schedule metadata
|
|
@@ -8870,6 +9137,113 @@ app.post('/api/quick-timer/now', (req, res) => {
|
|
|
8870
9137
|
}
|
|
8871
9138
|
});
|
|
8872
9139
|
|
|
9140
|
+
// ── Schedule Group REST API ──
|
|
9141
|
+
|
|
9142
|
+
app.get('/api/schedule-groups', (_req, res) => {
|
|
9143
|
+
res.json(loadScheduleGroups());
|
|
9144
|
+
});
|
|
9145
|
+
|
|
9146
|
+
app.post('/api/schedule-groups', (req, res) => {
|
|
9147
|
+
try {
|
|
9148
|
+
const { name, color, icon, collapsed } = req.body || {};
|
|
9149
|
+
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
|
9150
|
+
|
|
9151
|
+
const groups = loadScheduleGroups();
|
|
9152
|
+
const group = {
|
|
9153
|
+
id: `sg_${randomBytes(6).toString('hex')}`,
|
|
9154
|
+
name: name.trim(),
|
|
9155
|
+
color: color || null,
|
|
9156
|
+
icon: icon || null,
|
|
9157
|
+
order: groups.length,
|
|
9158
|
+
collapsed: !!collapsed,
|
|
9159
|
+
createdAt: new Date().toISOString(),
|
|
9160
|
+
updatedAt: new Date().toISOString(),
|
|
9161
|
+
};
|
|
9162
|
+
groups.push(group);
|
|
9163
|
+
saveScheduleGroups(groups);
|
|
9164
|
+
broadcastSync({ type: 'schedule-group:created', group });
|
|
9165
|
+
res.json(group);
|
|
9166
|
+
} catch (err) {
|
|
9167
|
+
res.status(500).json({ error: err.message });
|
|
9168
|
+
}
|
|
9169
|
+
});
|
|
9170
|
+
|
|
9171
|
+
app.put('/api/schedule-groups/:id', (req, res) => {
|
|
9172
|
+
try {
|
|
9173
|
+
const groups = loadScheduleGroups();
|
|
9174
|
+
const idx = groups.findIndex(g => g.id === req.params.id);
|
|
9175
|
+
if (idx === -1) return res.status(404).json({ error: 'Group not found' });
|
|
9176
|
+
|
|
9177
|
+
const { name, color, icon, collapsed, order } = req.body || {};
|
|
9178
|
+
if (name !== undefined) {
|
|
9179
|
+
if (!name.trim()) return res.status(400).json({ error: 'name is required' });
|
|
9180
|
+
groups[idx].name = name.trim();
|
|
9181
|
+
}
|
|
9182
|
+
if (color !== undefined) groups[idx].color = color || null;
|
|
9183
|
+
if (icon !== undefined) groups[idx].icon = icon || null;
|
|
9184
|
+
if (collapsed !== undefined) groups[idx].collapsed = !!collapsed;
|
|
9185
|
+
if (order !== undefined && typeof order === 'number') groups[idx].order = order;
|
|
9186
|
+
groups[idx].updatedAt = new Date().toISOString();
|
|
9187
|
+
|
|
9188
|
+
saveScheduleGroups(groups);
|
|
9189
|
+
broadcastSync({ type: 'schedule-group:updated', group: groups[idx] });
|
|
9190
|
+
res.json(groups[idx]);
|
|
9191
|
+
} catch (err) {
|
|
9192
|
+
res.status(500).json({ error: err.message });
|
|
9193
|
+
}
|
|
9194
|
+
});
|
|
9195
|
+
|
|
9196
|
+
app.delete('/api/schedule-groups/:id', (req, res) => {
|
|
9197
|
+
try {
|
|
9198
|
+
const groups = loadScheduleGroups();
|
|
9199
|
+
const idx = groups.findIndex(g => g.id === req.params.id);
|
|
9200
|
+
if (idx === -1) return res.status(404).json({ error: 'Group not found' });
|
|
9201
|
+
groups.splice(idx, 1);
|
|
9202
|
+
|
|
9203
|
+
const schedules = loadSchedules();
|
|
9204
|
+
let unassigned = 0;
|
|
9205
|
+
for (const schedule of schedules) {
|
|
9206
|
+
if (schedule.groupId === req.params.id) {
|
|
9207
|
+
schedule.groupId = null;
|
|
9208
|
+
schedule.updatedAt = new Date().toISOString();
|
|
9209
|
+
unassigned++;
|
|
9210
|
+
}
|
|
9211
|
+
}
|
|
9212
|
+
|
|
9213
|
+
saveScheduleStore({ schedules, groups });
|
|
9214
|
+
broadcastSync({ type: 'schedule-group:deleted', groupId: req.params.id, schedulesUnassigned: unassigned });
|
|
9215
|
+
res.json({ ok: true, id: req.params.id, schedulesUnassigned: unassigned });
|
|
9216
|
+
} catch (err) {
|
|
9217
|
+
res.status(500).json({ error: err.message });
|
|
9218
|
+
}
|
|
9219
|
+
});
|
|
9220
|
+
|
|
9221
|
+
app.post('/api/schedule-groups/reorder', (req, res) => {
|
|
9222
|
+
try {
|
|
9223
|
+
const { orderedIds } = req.body || {};
|
|
9224
|
+
if (!Array.isArray(orderedIds)) return res.status(400).json({ error: 'orderedIds array required' });
|
|
9225
|
+
|
|
9226
|
+
const groups = loadScheduleGroups();
|
|
9227
|
+
const byId = new Map(groups.map(g => [g.id, g]));
|
|
9228
|
+
let order = 0;
|
|
9229
|
+
for (const id of orderedIds) {
|
|
9230
|
+
const group = byId.get(id);
|
|
9231
|
+
if (group) {
|
|
9232
|
+
group.order = order++;
|
|
9233
|
+
group.updatedAt = new Date().toISOString();
|
|
9234
|
+
}
|
|
9235
|
+
}
|
|
9236
|
+
for (const group of groups) {
|
|
9237
|
+
if (!orderedIds.includes(group.id)) group.order = order++;
|
|
9238
|
+
}
|
|
9239
|
+
saveScheduleGroups(groups);
|
|
9240
|
+
broadcastSync({ type: 'schedule-group:reordered', groups });
|
|
9241
|
+
res.json({ ok: true });
|
|
9242
|
+
} catch (err) {
|
|
9243
|
+
res.status(500).json({ error: err.message });
|
|
9244
|
+
}
|
|
9245
|
+
});
|
|
9246
|
+
|
|
8873
9247
|
// ── Schedule REST API ──
|
|
8874
9248
|
|
|
8875
9249
|
// GET /api/schedules — list all schedules
|
|
@@ -8889,7 +9263,7 @@ app.get('/api/schedules/timers', (_req, res) => {
|
|
|
8889
9263
|
// POST /api/schedules — create a new schedule
|
|
8890
9264
|
app.post('/api/schedules', (req, res) => {
|
|
8891
9265
|
try {
|
|
8892
|
-
const { name, templateId, cron, timezone, enabled, dayThemes, overrides } = req.body;
|
|
9266
|
+
const { name, templateId, cron, timezone, enabled, dayThemes, overrides, groupId, profile, model, effort, mcpProfile, usesBrowser } = req.body;
|
|
8893
9267
|
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
|
8894
9268
|
if (!templateId?.trim()) return res.status(400).json({ error: 'templateId is required' });
|
|
8895
9269
|
if (!cron?.trim()) return res.status(400).json({ error: 'cron is required' });
|
|
@@ -8897,6 +9271,8 @@ app.post('/api/schedules', (req, res) => {
|
|
|
8897
9271
|
|
|
8898
9272
|
const templates = readLoopTemplates();
|
|
8899
9273
|
if (!templates.find(t => t.id === templateId)) return res.status(400).json({ error: 'Template not found' });
|
|
9274
|
+
const normalizedGroupId = validateScheduleGroupId(groupId);
|
|
9275
|
+
if (normalizedGroupId === false) return res.status(400).json({ error: 'Group not found' });
|
|
8900
9276
|
|
|
8901
9277
|
const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
8902
9278
|
const schedule = {
|
|
@@ -8906,6 +9282,7 @@ app.post('/api/schedules', (req, res) => {
|
|
|
8906
9282
|
cron: cron.trim(),
|
|
8907
9283
|
timezone: tz,
|
|
8908
9284
|
enabled: enabled !== false,
|
|
9285
|
+
groupId: normalizedGroupId,
|
|
8909
9286
|
dayThemes: dayThemes || {},
|
|
8910
9287
|
overrides: overrides || {},
|
|
8911
9288
|
lastRun: null,
|
|
@@ -8915,6 +9292,7 @@ app.post('/api/schedules', (req, res) => {
|
|
|
8915
9292
|
createdAt: new Date().toISOString(),
|
|
8916
9293
|
updatedAt: new Date().toISOString(),
|
|
8917
9294
|
};
|
|
9295
|
+
applyScheduleLaunchOverrides(schedule, { profile, model, effort, mcpProfile, usesBrowser });
|
|
8918
9296
|
|
|
8919
9297
|
const schedules = loadSchedules();
|
|
8920
9298
|
schedules.push(schedule);
|
|
@@ -8942,7 +9320,7 @@ app.put('/api/schedules/:id', (req, res) => {
|
|
|
8942
9320
|
const idx = schedules.findIndex(s => s.id === req.params.id);
|
|
8943
9321
|
if (idx === -1) return res.status(404).json({ error: 'Schedule not found' });
|
|
8944
9322
|
|
|
8945
|
-
const { name, templateId, cron, timezone, enabled, dayThemes, overrides } = req.body;
|
|
9323
|
+
const { name, templateId, cron, timezone, enabled, dayThemes, overrides, groupId, profile, model, effort, mcpProfile, usesBrowser } = req.body;
|
|
8946
9324
|
if (name !== undefined) schedules[idx].name = name.trim();
|
|
8947
9325
|
if (templateId !== undefined) {
|
|
8948
9326
|
const templates = readLoopTemplates();
|
|
@@ -8957,6 +9335,12 @@ app.put('/api/schedules/:id', (req, res) => {
|
|
|
8957
9335
|
if (typeof enabled === 'boolean') schedules[idx].enabled = enabled;
|
|
8958
9336
|
if (dayThemes !== undefined) schedules[idx].dayThemes = dayThemes;
|
|
8959
9337
|
if (overrides !== undefined) schedules[idx].overrides = overrides;
|
|
9338
|
+
if (groupId !== undefined) {
|
|
9339
|
+
const normalizedGroupId = validateScheduleGroupId(groupId);
|
|
9340
|
+
if (normalizedGroupId === false) return res.status(400).json({ error: 'Group not found' });
|
|
9341
|
+
schedules[idx].groupId = normalizedGroupId;
|
|
9342
|
+
}
|
|
9343
|
+
applyScheduleLaunchOverrides(schedules[idx], { profile, model, effort, mcpProfile, usesBrowser });
|
|
8960
9344
|
|
|
8961
9345
|
schedules[idx].updatedAt = new Date().toISOString();
|
|
8962
9346
|
schedules[idx].nextRun = getNextCronRun(schedules[idx].cron, schedules[idx].timezone);
|
|
@@ -10070,7 +10454,8 @@ app.get('/api/system/backup', async (req, res) => {
|
|
|
10070
10454
|
'loop-templates.json', 'loop-schedules.json', 'loop-folders.json', 'auto-backup-config.json',
|
|
10071
10455
|
'cost-tracking.json', 'skin-config.json', 'image-favorites.json',
|
|
10072
10456
|
'browser-config.json', 'browser-storage.json', 'last-session.json',
|
|
10073
|
-
'invite-sessions.json'
|
|
10457
|
+
'invite-sessions.json', 'stored-plans.json', 'synabun-plugins.json',
|
|
10458
|
+
'mcp-registry.json', 'active-profile.json']) {
|
|
10074
10459
|
addFile(`data/${f}`, resolve(dataDir, f));
|
|
10075
10460
|
}
|
|
10076
10461
|
|
|
@@ -10094,6 +10479,9 @@ app.get('/api/system/backup', async (req, res) => {
|
|
|
10094
10479
|
// Screenshots, whiteboard captures, staged images
|
|
10095
10480
|
addDirRecursive('data/images', resolve(dataDir, 'images'));
|
|
10096
10481
|
|
|
10482
|
+
// Plan markdown history (data/plans/YYYY-MM-DD/*.md)
|
|
10483
|
+
addDirRecursive('data/plans', resolve(dataDir, 'plans'));
|
|
10484
|
+
|
|
10097
10485
|
// 3. mcp-server/data/
|
|
10098
10486
|
if (existsSync(CATEGORIES_DATA_DIR)) {
|
|
10099
10487
|
for (const f of readdirSync(CATEGORIES_DATA_DIR)) {
|
|
@@ -10229,7 +10617,7 @@ app.post('/api/system/restore',
|
|
|
10229
10617
|
// data/ files
|
|
10230
10618
|
const dataDir = resolve(DATA_HOME, 'data');
|
|
10231
10619
|
mkdirSync(dataDir, { recursive: true });
|
|
10232
|
-
for (const subdir of ['pending-remember', 'pending-compact', 'loop']) {
|
|
10620
|
+
for (const subdir of ['pending-remember', 'pending-compact', 'loop', 'plans']) {
|
|
10233
10621
|
mkdirSync(resolve(dataDir, subdir), { recursive: true });
|
|
10234
10622
|
}
|
|
10235
10623
|
|
|
@@ -10453,7 +10841,8 @@ function buildBackupZipToFile(destPath) {
|
|
|
10453
10841
|
'loop-templates.json', 'loop-schedules.json', 'loop-folders.json', 'auto-backup-config.json',
|
|
10454
10842
|
'cost-tracking.json', 'skin-config.json', 'image-favorites.json',
|
|
10455
10843
|
'browser-config.json', 'browser-storage.json', 'last-session.json',
|
|
10456
|
-
'invite-sessions.json'
|
|
10844
|
+
'invite-sessions.json', 'stored-plans.json', 'synabun-plugins.json',
|
|
10845
|
+
'mcp-registry.json', 'active-profile.json']) {
|
|
10457
10846
|
addFile(`data/${f}`, resolve(dataDir, f));
|
|
10458
10847
|
}
|
|
10459
10848
|
|
|
@@ -10475,6 +10864,9 @@ function buildBackupZipToFile(destPath) {
|
|
|
10475
10864
|
// Screenshots, whiteboard captures, staged images
|
|
10476
10865
|
addDirRecursive('data/images', resolve(dataDir, 'images'));
|
|
10477
10866
|
|
|
10867
|
+
// Plan markdown history (data/plans/YYYY-MM-DD/*.md)
|
|
10868
|
+
addDirRecursive('data/plans', resolve(dataDir, 'plans'));
|
|
10869
|
+
|
|
10478
10870
|
if (existsSync(CATEGORIES_DATA_DIR)) {
|
|
10479
10871
|
for (const f of readdirSync(CATEGORIES_DATA_DIR)) {
|
|
10480
10872
|
if (f.endsWith('.json')) addFile(`mcp-data/${f}`, resolve(CATEGORIES_DATA_DIR, f));
|
|
@@ -10802,10 +11194,25 @@ app.get('/api/latest-plan', (req, res) => {
|
|
|
10802
11194
|
}
|
|
10803
11195
|
});
|
|
10804
11196
|
|
|
11197
|
+
function cleanupMatchingRootPlan(cwd, content) {
|
|
11198
|
+
if (!cwd || typeof cwd !== 'string' || typeof content !== 'string') return false;
|
|
11199
|
+
try {
|
|
11200
|
+
const rootPlan = resolve(cwd, 'PLAN.md');
|
|
11201
|
+
if (!validateProjectPath(rootPlan) || !existsSync(rootPlan) || !statSync(rootPlan).isFile()) return false;
|
|
11202
|
+
const existing = readFileSync(rootPlan, 'utf-8').replace(/\r\n/g, '\n').trim();
|
|
11203
|
+
const next = content.replace(/\r\n/g, '\n').trim();
|
|
11204
|
+
if (!existing || existing !== next) return false;
|
|
11205
|
+
unlinkSync(rootPlan);
|
|
11206
|
+
return true;
|
|
11207
|
+
} catch {
|
|
11208
|
+
return false;
|
|
11209
|
+
}
|
|
11210
|
+
}
|
|
11211
|
+
|
|
10805
11212
|
// POST /api/create-plan — Materialize plan content into a file in data/plans/YYYY-MM-DD/slug.md
|
|
10806
11213
|
app.post('/api/create-plan', (req, res) => {
|
|
10807
11214
|
try {
|
|
10808
|
-
const { content } = req.body;
|
|
11215
|
+
const { content, cwd, projectPath } = req.body;
|
|
10809
11216
|
if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Missing content' });
|
|
10810
11217
|
|
|
10811
11218
|
// Date-organized folder
|
|
@@ -10835,7 +11242,8 @@ app.post('/api/create-plan', (req, res) => {
|
|
|
10835
11242
|
}
|
|
10836
11243
|
|
|
10837
11244
|
writeFileSync(fullPath, content, 'utf-8');
|
|
10838
|
-
|
|
11245
|
+
const cleanedRootPlan = cleanupMatchingRootPlan(projectPath || cwd || '', content);
|
|
11246
|
+
res.json({ ok: true, path: fullPath.replace(/\\/g, '/'), name, cleanedRootPlan });
|
|
10839
11247
|
} catch (err) {
|
|
10840
11248
|
res.status(500).json({ error: err.message });
|
|
10841
11249
|
}
|
|
@@ -12473,12 +12881,15 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
|
|
|
12473
12881
|
const text = typeof content === 'string' ? content
|
|
12474
12882
|
: Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n')
|
|
12475
12883
|
: '';
|
|
12476
|
-
if (text) { messages.push({ role: 'user', text: text.slice(0,
|
|
12884
|
+
if (text) { messages.push({ role: 'user', text: text.slice(0, 64000) }); turns++; }
|
|
12477
12885
|
} else if (obj.type === 'assistant') {
|
|
12478
12886
|
const content = obj.message?.content;
|
|
12479
12887
|
const textBlocks = Array.isArray(content)
|
|
12480
12888
|
? content.filter(b => b.type === 'text').map(b => b.text)
|
|
12481
12889
|
: [];
|
|
12890
|
+
const thinkingBlocks = Array.isArray(content)
|
|
12891
|
+
? content.filter(b => b.type === 'thinking').map(b => b.thinking || b.text || '').filter(Boolean)
|
|
12892
|
+
: [];
|
|
12482
12893
|
const toolUseBlocks = Array.isArray(content)
|
|
12483
12894
|
? content.filter(b => b.type === 'tool_use').map(b => ({
|
|
12484
12895
|
id: b.id,
|
|
@@ -12487,10 +12898,12 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
|
|
|
12487
12898
|
}))
|
|
12488
12899
|
: [];
|
|
12489
12900
|
const text = textBlocks.join('\n');
|
|
12490
|
-
|
|
12901
|
+
const thinking = thinkingBlocks.join('\n\n');
|
|
12902
|
+
if (text || toolUseBlocks.length || thinking) {
|
|
12491
12903
|
messages.push({
|
|
12492
12904
|
role: 'assistant',
|
|
12493
|
-
text: text.slice(0,
|
|
12905
|
+
text: text.slice(0, 64000) || undefined,
|
|
12906
|
+
thinking: thinking.slice(0, 32000) || undefined,
|
|
12494
12907
|
tools: toolUseBlocks.length ? toolUseBlocks : undefined,
|
|
12495
12908
|
});
|
|
12496
12909
|
}
|
|
@@ -12511,7 +12924,7 @@ app.get('/api/claude-code/sessions/:sessionId/messages', (req, res) => {
|
|
|
12511
12924
|
messages.push({
|
|
12512
12925
|
role: 'tool_result',
|
|
12513
12926
|
toolUseId,
|
|
12514
|
-
text: resultText.slice(0,
|
|
12927
|
+
text: resultText.slice(0, 32000) || undefined,
|
|
12515
12928
|
isError: obj.is_error || obj.message?.is_error || false,
|
|
12516
12929
|
});
|
|
12517
12930
|
}
|
|
@@ -13700,20 +14113,24 @@ function syncProfileToCodex(profile) {
|
|
|
13700
14113
|
const envMatch = section.match(envRe);
|
|
13701
14114
|
let newSection;
|
|
13702
14115
|
if (envMatch) {
|
|
13703
|
-
|
|
13704
|
-
const
|
|
13705
|
-
|
|
13706
|
-
|
|
13707
|
-
|
|
13708
|
-
}
|
|
13709
|
-
|
|
13710
|
-
|
|
13711
|
-
|
|
13712
|
-
|
|
14116
|
+
let body = envMatch[1].trim().replace(/,$/, '');
|
|
14117
|
+
const upserts = {
|
|
14118
|
+
SYNABUN_PROFILE: profile,
|
|
14119
|
+
SYNABUN_BROWSER_FAST: '1',
|
|
14120
|
+
SYNABUN_BROWSER_COMPACT: '1',
|
|
14121
|
+
};
|
|
14122
|
+
for (const [key, value] of Object.entries(upserts)) {
|
|
14123
|
+
const pair = `${key} = "${value}"`;
|
|
14124
|
+
const keyRe = new RegExp(`${key}\\s*=\\s*"[^"]*"`);
|
|
14125
|
+
if (keyRe.test(body)) {
|
|
14126
|
+
body = body.replace(keyRe, pair);
|
|
14127
|
+
} else {
|
|
14128
|
+
body = body ? `${body}, ${pair}` : pair;
|
|
14129
|
+
}
|
|
13713
14130
|
}
|
|
13714
|
-
newSection = section.replace(envRe, `env = {${
|
|
14131
|
+
newSection = section.replace(envRe, `env = { ${body} }`);
|
|
13715
14132
|
} else {
|
|
13716
|
-
newSection = section.trimEnd() + `\nenv = { SYNABUN_PROFILE = "${profile}" }\n`;
|
|
14133
|
+
newSection = section.trimEnd() + `\nenv = { SYNABUN_PROFILE = "${profile}", SYNABUN_BROWSER_FAST = "1", SYNABUN_BROWSER_COMPACT = "1" }\n`;
|
|
13717
14134
|
}
|
|
13718
14135
|
if (newSection === section) return false;
|
|
13719
14136
|
const updated = content.slice(0, range.start) + newSection + content.slice(range.end);
|
|
@@ -13783,6 +14200,28 @@ function getHomePath() {
|
|
|
13783
14200
|
return process.env.USERPROFILE || process.env.HOME || '';
|
|
13784
14201
|
}
|
|
13785
14202
|
|
|
14203
|
+
function getCodexMcpProfileForSetup() {
|
|
14204
|
+
const current = readActiveMcpProfile();
|
|
14205
|
+
return current && current !== 'full' ? current : 'codex-browser';
|
|
14206
|
+
}
|
|
14207
|
+
|
|
14208
|
+
function buildCodexMcpEnv(profile = getCodexMcpProfileForSetup()) {
|
|
14209
|
+
const { envPath } = getMcpPaths();
|
|
14210
|
+
return {
|
|
14211
|
+
DOTENV_PATH: envPath,
|
|
14212
|
+
SYNABUN_DATA_HOME: DATA_HOME,
|
|
14213
|
+
MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data'),
|
|
14214
|
+
SYNABUN_PROFILE: profile,
|
|
14215
|
+
SYNABUN_BROWSER_FAST: '1',
|
|
14216
|
+
SYNABUN_BROWSER_COMPACT: '1',
|
|
14217
|
+
};
|
|
14218
|
+
}
|
|
14219
|
+
|
|
14220
|
+
function buildCodexMcpToml(profile = getCodexMcpProfileForSetup()) {
|
|
14221
|
+
const { mcpIndexPath } = getMcpPaths();
|
|
14222
|
+
return `[mcp_servers.SynaBun]\ncommand = "node"\nargs = ["${mcpIndexPath}"]\nenv = ${tomlSerializeValue(buildCodexMcpEnv(profile))}\n`;
|
|
14223
|
+
}
|
|
14224
|
+
|
|
13786
14225
|
// ── Gemini MCP endpoints ──
|
|
13787
14226
|
|
|
13788
14227
|
app.get('/api/setup/gemini/mcp', (req, res) => {
|
|
@@ -13839,10 +14278,11 @@ app.delete('/api/setup/gemini/mcp', (req, res) => {
|
|
|
13839
14278
|
app.get('/api/setup/codex/mcp', (req, res) => {
|
|
13840
14279
|
try {
|
|
13841
14280
|
const configPath = join(getHomePath(), '.codex', 'config.toml');
|
|
13842
|
-
|
|
14281
|
+
const toml = buildCodexMcpToml();
|
|
14282
|
+
if (!existsSync(configPath)) return res.json({ ok: true, connected: false, toml });
|
|
13843
14283
|
const content = readFileSync(configPath, 'utf-8');
|
|
13844
14284
|
const connected = tomlHasSection(content, 'mcp_servers.SynaBun');
|
|
13845
|
-
res.json({ ok: true, connected });
|
|
14285
|
+
res.json({ ok: true, connected, toml });
|
|
13846
14286
|
} catch (err) {
|
|
13847
14287
|
res.status(500).json({ error: err.message });
|
|
13848
14288
|
}
|
|
@@ -13852,14 +14292,14 @@ app.post('/api/setup/codex/mcp', (req, res) => {
|
|
|
13852
14292
|
try {
|
|
13853
14293
|
const dir = join(getHomePath(), '.codex');
|
|
13854
14294
|
const configPath = join(dir, 'config.toml');
|
|
13855
|
-
const { mcpIndexPath
|
|
14295
|
+
const { mcpIndexPath } = getMcpPaths();
|
|
13856
14296
|
mkdirSync(dir, { recursive: true });
|
|
13857
14297
|
let content = '';
|
|
13858
14298
|
try { content = readFileSync(configPath, 'utf-8'); } catch {}
|
|
13859
14299
|
content = tomlUpsertSection(content, 'mcp_servers.SynaBun', {
|
|
13860
14300
|
command: 'node',
|
|
13861
14301
|
args: [mcpIndexPath],
|
|
13862
|
-
env:
|
|
14302
|
+
env: buildCodexMcpEnv(),
|
|
13863
14303
|
});
|
|
13864
14304
|
writeFileSync(configPath, content, 'utf-8');
|
|
13865
14305
|
res.json({ ok: true, message: 'SynaBun MCP registered in Codex CLI. Restart Codex to connect.' });
|
|
@@ -14216,6 +14656,54 @@ app.get('/api/claude-code/skills', (req, res) => {
|
|
|
14216
14656
|
}
|
|
14217
14657
|
});
|
|
14218
14658
|
|
|
14659
|
+
// GET /api/claude-code/plugin-commands — scan installed plugin cache dirs for slash commands
|
|
14660
|
+
app.get('/api/claude-code/plugin-commands', (req, res) => {
|
|
14661
|
+
try {
|
|
14662
|
+
const out = [];
|
|
14663
|
+
const cacheRoot = join(os.homedir(), '.claude', 'plugins', 'cache');
|
|
14664
|
+
if (!existsSync(cacheRoot)) return res.json({ ok: true, commands: [] });
|
|
14665
|
+
const readDesc = (filePath) => {
|
|
14666
|
+
try {
|
|
14667
|
+
const txt = readFileSync(filePath, 'utf-8');
|
|
14668
|
+
const toml = txt.match(/^\s*description\s*=\s*"((?:[^"\\]|\\.)*)"/m);
|
|
14669
|
+
if (toml) return toml[1].replace(/\\"/g, '"');
|
|
14670
|
+
const fm = txt.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
14671
|
+
if (fm) {
|
|
14672
|
+
const m = fm[1].match(/^\s*description\s*:\s*(.+?)\s*$/m);
|
|
14673
|
+
if (m) return m[1].replace(/^["']|["']$/g, '');
|
|
14674
|
+
}
|
|
14675
|
+
return '';
|
|
14676
|
+
} catch { return ''; }
|
|
14677
|
+
};
|
|
14678
|
+
const walkCommands = (pluginRoot, marketplace, plugin) => {
|
|
14679
|
+
const cmdDir = join(pluginRoot, 'commands');
|
|
14680
|
+
if (!existsSync(cmdDir)) return;
|
|
14681
|
+
let entries; try { entries = readdirSync(cmdDir); } catch { return; }
|
|
14682
|
+
for (const f of entries) {
|
|
14683
|
+
if (!/\.(toml|md)$/i.test(f)) continue;
|
|
14684
|
+
const name = f.replace(/\.(toml|md)$/i, '');
|
|
14685
|
+
const description = readDesc(join(cmdDir, f));
|
|
14686
|
+
out.push({ name, description, source: 'plugin', plugin, marketplace });
|
|
14687
|
+
}
|
|
14688
|
+
};
|
|
14689
|
+
let markets; try { markets = readdirSync(cacheRoot); } catch { markets = []; }
|
|
14690
|
+
for (const m of markets) {
|
|
14691
|
+
const mDir = join(cacheRoot, m);
|
|
14692
|
+
let plugins; try { plugins = readdirSync(mDir); } catch { continue; }
|
|
14693
|
+
for (const p of plugins) {
|
|
14694
|
+
const pDir = join(mDir, p);
|
|
14695
|
+
let versions; try { versions = readdirSync(pDir); } catch { continue; }
|
|
14696
|
+
for (const v of versions) {
|
|
14697
|
+
walkCommands(join(pDir, v), m, p);
|
|
14698
|
+
}
|
|
14699
|
+
}
|
|
14700
|
+
}
|
|
14701
|
+
res.json({ ok: true, commands: out });
|
|
14702
|
+
} catch (err) {
|
|
14703
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
14704
|
+
}
|
|
14705
|
+
});
|
|
14706
|
+
|
|
14219
14707
|
// POST /api/claude-code/skills — install a skill globally (copies entire directory)
|
|
14220
14708
|
app.post('/api/claude-code/skills', (req, res) => {
|
|
14221
14709
|
try {
|
|
@@ -15183,6 +15671,7 @@ function readMcpRegistry() {
|
|
|
15183
15671
|
profiles: {
|
|
15184
15672
|
core: { label: 'Core', groups: ['git', 'image'], servers: [] },
|
|
15185
15673
|
standard: { label: 'Standard', groups: ['git', 'image', 'whiteboard', 'card', 'tictactoe'], servers: [] },
|
|
15674
|
+
'codex-browser': { label: 'Codex Browser', groups: ['git', 'image', 'browser', 'browser_twitter'], servers: [] },
|
|
15186
15675
|
twitter: { label: 'Twitter/X', groups: ['git', 'image', 'browser', 'browser_twitter'], servers: [] },
|
|
15187
15676
|
facebook: { label: 'Facebook', groups: ['git', 'image', 'browser', 'browser_facebook'], servers: [] },
|
|
15188
15677
|
tiktok: { label: 'TikTok', groups: ['git', 'image', 'browser', 'browser_tiktok'], servers: [] },
|
|
@@ -15369,7 +15858,11 @@ app.get('/api/mcp/platforms', (req, res) => {
|
|
|
15369
15858
|
});
|
|
15370
15859
|
|
|
15371
15860
|
// ── GitHub install flow ──
|
|
15372
|
-
// POST /api/mcp/install/github { url, name?, runInstall? }
|
|
15861
|
+
// POST /api/mcp/install/github { url, name?, runInstall? }
|
|
15862
|
+
// Clones the repo, classifies it, then either:
|
|
15863
|
+
// - kind=mcp: detects + installs deps, returns detected config for MCP registry save
|
|
15864
|
+
// - kind=claude-plugin: symlinks into ~/.claude/plugins/ + edits CC's plugin JSON files
|
|
15865
|
+
// - kind=unknown: returns 422 with hints
|
|
15373
15866
|
app.post('/api/mcp/install/github', async (req, res) => {
|
|
15374
15867
|
try {
|
|
15375
15868
|
const { url, name: nameOverride, runInstall = true } = req.body || {};
|
|
@@ -15384,29 +15877,91 @@ app.post('/api/mcp/install/github', async (req, res) => {
|
|
|
15384
15877
|
const clone = await mcpInstaller.cloneRepo({ dataHome: DATA_HOME, name, cloneUrl: parsed.cloneUrl, onLine });
|
|
15385
15878
|
if (!clone.ok) return res.status(500).json({ ok: false, error: clone.error, stderr: clone.stderr, log: lines });
|
|
15386
15879
|
|
|
15387
|
-
const
|
|
15388
|
-
|
|
15880
|
+
const classification = mcpInstaller.classifyRepo(clone.path);
|
|
15881
|
+
|
|
15882
|
+
if (classification.kind === 'claude-plugin') {
|
|
15883
|
+
mcpInstaller.logInstall(DATA_HOME, name, `classified as claude-plugin (${classification.plugin?.manifest?.name || 'unknown'})`);
|
|
15884
|
+
const githubUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
|
|
15885
|
+
const install = pluginInstaller.installClaudePlugin({ repoPath: clone.path, githubUrl });
|
|
15886
|
+
if (!install.ok) {
|
|
15887
|
+
return res.status(500).json({ ok: false, kind: 'claude-plugin', error: install.error, name, repoPath: clone.path, log: lines });
|
|
15888
|
+
}
|
|
15889
|
+
return res.json({
|
|
15890
|
+
ok: true,
|
|
15891
|
+
kind: 'claude-plugin',
|
|
15892
|
+
name,
|
|
15893
|
+
repoPath: clone.path,
|
|
15894
|
+
cloneReused: clone.reused,
|
|
15895
|
+
plugin: {
|
|
15896
|
+
pluginName: install.pluginName,
|
|
15897
|
+
marketplaceName: install.marketplaceName,
|
|
15898
|
+
installPath: install.installPath,
|
|
15899
|
+
marketplacePath: install.marketplacePath,
|
|
15900
|
+
hooks: install.hooks,
|
|
15901
|
+
gitCommitSha: install.gitCommitSha,
|
|
15902
|
+
},
|
|
15903
|
+
hints: classification.hints,
|
|
15904
|
+
log: lines.slice(-50),
|
|
15905
|
+
});
|
|
15906
|
+
}
|
|
15907
|
+
|
|
15908
|
+
if (classification.kind !== 'mcp') {
|
|
15909
|
+
return res.status(422).json({
|
|
15910
|
+
ok: false,
|
|
15911
|
+
kind: 'unknown',
|
|
15912
|
+
error: classification.error || 'Could not identify repo type',
|
|
15913
|
+
name,
|
|
15914
|
+
repoPath: clone.path,
|
|
15915
|
+
hints: classification.hints || [],
|
|
15916
|
+
log: lines,
|
|
15917
|
+
});
|
|
15918
|
+
}
|
|
15389
15919
|
|
|
15920
|
+
// Skip local dep install when the detected command fetches at runtime
|
|
15921
|
+
// (uvx, npx, pipx, etc.) — local pip/npm install would be wasted work
|
|
15922
|
+
// AND its failure would mask an otherwise-working server.
|
|
15923
|
+
const selfFetches = mcpInstaller.commandSelfFetches(classification.detected);
|
|
15390
15924
|
let installResult = { ok: true, skipped: true };
|
|
15391
|
-
|
|
15925
|
+
let installWarning = null;
|
|
15926
|
+
if (runInstall && !selfFetches) {
|
|
15392
15927
|
installResult = await mcpInstaller.installDependencies({ dataHome: DATA_HOME, name, repoPath: clone.path, onLine });
|
|
15393
15928
|
if (!installResult.ok) {
|
|
15394
|
-
|
|
15929
|
+
// Soft-fail: keep going so the server is still registered.
|
|
15930
|
+
// The user can fix deps separately; if deps are truly required, the
|
|
15931
|
+
// server will fail at launch and they'll see the error there.
|
|
15932
|
+
installWarning = `Local dependency install failed: ${installResult.error}. The server is still registered — fix deps manually if it fails to launch.`;
|
|
15933
|
+
mcpInstaller.logInstall(DATA_HOME, name, `[warn] ${installWarning}`);
|
|
15934
|
+
}
|
|
15935
|
+
} else if (selfFetches) {
|
|
15936
|
+
installResult = { ok: true, skipped: true, reason: `command '${classification.detected.command}' self-fetches at runtime` };
|
|
15937
|
+
mcpInstaller.logInstall(DATA_HOME, name, `skipping local deps install — ${installResult.reason}`);
|
|
15938
|
+
// Preflight: verify the self-fetcher binary is actually on PATH.
|
|
15939
|
+
// If missing the server still registers, but it will fail at launch
|
|
15940
|
+
// until the user installs the runtime.
|
|
15941
|
+
const preflight = mcpInstaller.checkSelfFetcherAvailable(classification.detected);
|
|
15942
|
+
if (!preflight.available) {
|
|
15943
|
+
const hint = preflight.installHint ? ` Install: ${preflight.installHint}` : '';
|
|
15944
|
+
installWarning = `'${preflight.command}' not found on PATH.${hint}. Server registered anyway — it will fail at launch until the runtime is installed.`;
|
|
15945
|
+
mcpInstaller.logInstall(DATA_HOME, name, `[warn] ${installWarning}`);
|
|
15395
15946
|
}
|
|
15396
15947
|
}
|
|
15397
15948
|
|
|
15398
15949
|
// After install, re-detect in case build produced dist/ paths
|
|
15399
15950
|
const detect2 = mcpInstaller.detectServer(clone.path);
|
|
15400
|
-
const detected = detect2.ok ? detect2.detected :
|
|
15951
|
+
const detected = detect2.ok ? detect2.detected : classification.detected;
|
|
15401
15952
|
|
|
15402
15953
|
res.json({
|
|
15403
15954
|
ok: true,
|
|
15955
|
+
kind: 'mcp',
|
|
15404
15956
|
name,
|
|
15405
15957
|
repoPath: clone.path,
|
|
15406
15958
|
cloneReused: clone.reused,
|
|
15407
15959
|
detected,
|
|
15408
|
-
detectSource:
|
|
15960
|
+
detectSource: classification.detected?.source,
|
|
15409
15961
|
install: installResult,
|
|
15962
|
+
installWarning,
|
|
15963
|
+
hints: classification.hints,
|
|
15964
|
+
alsoClaudePlugin: classification.alsoClaudePlugin || null,
|
|
15410
15965
|
log: lines.slice(-50),
|
|
15411
15966
|
});
|
|
15412
15967
|
} catch (err) {
|
|
@@ -15414,6 +15969,161 @@ app.post('/api/mcp/install/github', async (req, res) => {
|
|
|
15414
15969
|
}
|
|
15415
15970
|
});
|
|
15416
15971
|
|
|
15972
|
+
// GET /api/mcp/installed-all — unified view of every externally-installed MCP
|
|
15973
|
+
// across Claude Code / OpenCode / Codex / Gemini + SynaBun's registry + Claude Code plugins.
|
|
15974
|
+
// Excludes SynaBun's built-in tool-group servers (they're baked in, not "installed").
|
|
15975
|
+
app.get('/api/mcp/installed-all', (req, res) => {
|
|
15976
|
+
try {
|
|
15977
|
+
const home = getHomePath();
|
|
15978
|
+
const byName = new Map();
|
|
15979
|
+
const touch = (name, cli, fields = {}) => {
|
|
15980
|
+
if (!byName.has(name)) {
|
|
15981
|
+
byName.set(name, { name, registeredWith: new Set(), transport: null, command: null, args: null, url: null });
|
|
15982
|
+
}
|
|
15983
|
+
const s = byName.get(name);
|
|
15984
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
15985
|
+
if (v !== undefined && (s[k] === null || s[k] === undefined)) s[k] = v;
|
|
15986
|
+
}
|
|
15987
|
+
s.registeredWith.add(cli);
|
|
15988
|
+
};
|
|
15989
|
+
|
|
15990
|
+
// SynaBun registry (external only)
|
|
15991
|
+
try {
|
|
15992
|
+
const reg = readMcpRegistry();
|
|
15993
|
+
for (const [name, info] of Object.entries(reg.servers || {})) {
|
|
15994
|
+
if (info.builtin) continue;
|
|
15995
|
+
touch(name, 'synabun', {
|
|
15996
|
+
transport: info.type || 'stdio',
|
|
15997
|
+
command: info.command || null,
|
|
15998
|
+
args: info.args || null,
|
|
15999
|
+
url: info.url || null,
|
|
16000
|
+
});
|
|
16001
|
+
}
|
|
16002
|
+
} catch {}
|
|
16003
|
+
|
|
16004
|
+
// Claude Code
|
|
16005
|
+
try {
|
|
16006
|
+
const p = join(home, '.claude.json');
|
|
16007
|
+
if (existsSync(p)) {
|
|
16008
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
16009
|
+
for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
|
|
16010
|
+
touch(name, 'claudeCode', {
|
|
16011
|
+
transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
|
|
16012
|
+
command: cfg.command || null,
|
|
16013
|
+
args: cfg.args || null,
|
|
16014
|
+
url: cfg.url || null,
|
|
16015
|
+
});
|
|
16016
|
+
}
|
|
16017
|
+
}
|
|
16018
|
+
} catch {}
|
|
16019
|
+
|
|
16020
|
+
// Gemini
|
|
16021
|
+
try {
|
|
16022
|
+
const p = join(home, '.gemini', 'settings.json');
|
|
16023
|
+
if (existsSync(p)) {
|
|
16024
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
16025
|
+
for (const [name, cfg] of Object.entries(data.mcpServers || {})) {
|
|
16026
|
+
touch(name, 'gemini', {
|
|
16027
|
+
transport: cfg.type || (cfg.url ? 'http' : 'stdio'),
|
|
16028
|
+
command: cfg.command || null,
|
|
16029
|
+
args: cfg.args || null,
|
|
16030
|
+
url: cfg.url || null,
|
|
16031
|
+
});
|
|
16032
|
+
}
|
|
16033
|
+
}
|
|
16034
|
+
} catch {}
|
|
16035
|
+
|
|
16036
|
+
// Codex TOML — only match top-level [mcp_servers.<name>], not [...tools.<name>]
|
|
16037
|
+
try {
|
|
16038
|
+
const p = join(home, '.codex', 'config.toml');
|
|
16039
|
+
if (existsSync(p)) {
|
|
16040
|
+
const txt = readFileSync(p, 'utf-8');
|
|
16041
|
+
const re = /^\[mcp_servers\.([^\].]+)\]\s*$/gm;
|
|
16042
|
+
let m;
|
|
16043
|
+
while ((m = re.exec(txt))) {
|
|
16044
|
+
touch(m[1], 'codex', { transport: 'stdio' });
|
|
16045
|
+
}
|
|
16046
|
+
}
|
|
16047
|
+
} catch {}
|
|
16048
|
+
|
|
16049
|
+
// OpenCode
|
|
16050
|
+
try {
|
|
16051
|
+
const ocPath = getOpencodeConfigPath();
|
|
16052
|
+
if (existsSync(ocPath)) {
|
|
16053
|
+
const oc = JSON.parse(readFileSync(ocPath, 'utf-8'));
|
|
16054
|
+
for (const [name, cfg] of Object.entries(oc?.mcp || {})) {
|
|
16055
|
+
const cmd = Array.isArray(cfg.command) ? cfg.command[0] : cfg.command;
|
|
16056
|
+
const args = Array.isArray(cfg.command) ? cfg.command.slice(1) : cfg.args;
|
|
16057
|
+
touch(name, 'opencode', {
|
|
16058
|
+
transport: cfg.type === 'local' ? 'stdio' : (cfg.type || 'stdio'),
|
|
16059
|
+
command: cmd || null,
|
|
16060
|
+
args: args || null,
|
|
16061
|
+
url: cfg.url || null,
|
|
16062
|
+
});
|
|
16063
|
+
}
|
|
16064
|
+
}
|
|
16065
|
+
} catch {}
|
|
16066
|
+
|
|
16067
|
+
const servers = Array.from(byName.values())
|
|
16068
|
+
.map(s => ({ ...s, registeredWith: [...s.registeredWith].sort() }))
|
|
16069
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
16070
|
+
|
|
16071
|
+
let plugins = [];
|
|
16072
|
+
try { plugins = pluginInstaller.listSynabunPlugins(); } catch {}
|
|
16073
|
+
|
|
16074
|
+
res.json({ ok: true, servers, plugins });
|
|
16075
|
+
} catch (err) {
|
|
16076
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
16077
|
+
}
|
|
16078
|
+
});
|
|
16079
|
+
|
|
16080
|
+
// ── Claude Code plugin management ──
|
|
16081
|
+
// GET /api/plugins/list — all SynaBun-managed Claude Code plugins
|
|
16082
|
+
app.get('/api/plugins/list', (req, res) => {
|
|
16083
|
+
try {
|
|
16084
|
+
const plugins = pluginInstaller.listSynabunPlugins();
|
|
16085
|
+
res.json({ ok: true, plugins });
|
|
16086
|
+
} catch (err) {
|
|
16087
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
16088
|
+
}
|
|
16089
|
+
});
|
|
16090
|
+
|
|
16091
|
+
// DELETE /api/plugins/:marketplace?pluginName=&purgeFiles=1
|
|
16092
|
+
app.delete('/api/plugins/:marketplace', (req, res) => {
|
|
16093
|
+
try {
|
|
16094
|
+
const { marketplace } = req.params;
|
|
16095
|
+
const { pluginName, purgeFiles } = req.query || {};
|
|
16096
|
+
const result = pluginInstaller.uninstallClaudePlugin({
|
|
16097
|
+
marketplaceName: marketplace,
|
|
16098
|
+
pluginName: pluginName ? String(pluginName) : undefined,
|
|
16099
|
+
});
|
|
16100
|
+
if (!result.ok) return res.status(400).json(result);
|
|
16101
|
+
|
|
16102
|
+
let purged = false;
|
|
16103
|
+
if (String(purgeFiles) === '1') {
|
|
16104
|
+
try {
|
|
16105
|
+
const dir = mcpInstaller.serverDir(DATA_HOME, marketplace);
|
|
16106
|
+
if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); purged = true; }
|
|
16107
|
+
} catch (err) {
|
|
16108
|
+
console.warn(`[plugins] purge files failed for ${marketplace}:`, err.message);
|
|
16109
|
+
}
|
|
16110
|
+
}
|
|
16111
|
+
res.json({ ok: true, ...result, purged });
|
|
16112
|
+
} catch (err) {
|
|
16113
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
16114
|
+
}
|
|
16115
|
+
});
|
|
16116
|
+
|
|
16117
|
+
// POST /api/plugins/repair — clean legacy synabun-symlink state and reinstall via CLI
|
|
16118
|
+
app.post('/api/plugins/repair', (req, res) => {
|
|
16119
|
+
try {
|
|
16120
|
+
const result = pluginInstaller.repairSynabunPlugins();
|
|
16121
|
+
res.json({ ok: true, ...result });
|
|
16122
|
+
} catch (err) {
|
|
16123
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
16124
|
+
}
|
|
16125
|
+
});
|
|
16126
|
+
|
|
15417
16127
|
// POST /api/mcp/install/detect { path } or { name } → re-run detection.
|
|
15418
16128
|
app.post('/api/mcp/install/detect', (req, res) => {
|
|
15419
16129
|
try {
|
|
@@ -16006,6 +16716,7 @@ const IS_WIN = process.platform === 'win32';
|
|
|
16006
16716
|
const DEFAULT_SHELL = IS_WIN ? (process.env.COMSPEC || 'cmd.exe') : (process.env.SHELL || '/bin/bash');
|
|
16007
16717
|
const TERMINAL_BUFFER_MAX_BYTES = 100 * 1024; // 100KB ring buffer per session
|
|
16008
16718
|
const TERMINAL_GRACE_PERIOD_MS = 30 * 60 * 1000; // 30 min before orphaned PTY is killed
|
|
16719
|
+
const TERMINAL_SNAPSHOT_SCROLLBACK_ROWS = 1000;
|
|
16009
16720
|
|
|
16010
16721
|
// ── CLI Config (custom CLI paths for terminal profiles) ──
|
|
16011
16722
|
|
|
@@ -16098,6 +16809,8 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
|
|
|
16098
16809
|
if (!pty) throw new Error('Terminal not available: node-pty failed to load. Run: cd neural-interface && npm run postinstall');
|
|
16099
16810
|
const sessionId = opts.sessionId || randomBytes(16).toString('hex');
|
|
16100
16811
|
const profileCfg = getTerminalProfile(profile, opts);
|
|
16812
|
+
const initialCols = cols || 120;
|
|
16813
|
+
const initialRows = rows || 30;
|
|
16101
16814
|
|
|
16102
16815
|
// Strip VSCode env vars so spawned CLIs (e.g. claude) don't think they're inside VSCode
|
|
16103
16816
|
const cleanEnv = Object.fromEntries(
|
|
@@ -16106,20 +16819,35 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
|
|
|
16106
16819
|
)
|
|
16107
16820
|
);
|
|
16108
16821
|
|
|
16822
|
+
const isLoopOwned = !!(opts.extraEnv && opts.extraEnv.SYNABUN_TERMINAL_SESSION === sessionId);
|
|
16823
|
+
if (isLoopOwned) {
|
|
16824
|
+
loopLog(sessionId, 'pty:resolve', 'profile resolved', {
|
|
16825
|
+
profile, shell: profileCfg.shell, args: profileCfg.args,
|
|
16826
|
+
envKeys: Object.keys(profileCfg.env || {}),
|
|
16827
|
+
extraEnvKeys: Object.keys(opts.extraEnv || {}),
|
|
16828
|
+
cwd, cols: initialCols, rows: initialRows,
|
|
16829
|
+
});
|
|
16830
|
+
}
|
|
16831
|
+
|
|
16109
16832
|
const ptyProcess = pty.spawn(profileCfg.shell, profileCfg.args, {
|
|
16110
16833
|
name: 'xterm-256color',
|
|
16111
|
-
cols:
|
|
16112
|
-
rows:
|
|
16834
|
+
cols: initialCols,
|
|
16835
|
+
rows: initialRows,
|
|
16113
16836
|
cwd: cwd || process.env.USERPROFILE || process.env.HOME || process.cwd(),
|
|
16114
16837
|
env: { ...cleanEnv, ...profileCfg.env, ...(opts.extraEnv || {}) },
|
|
16115
16838
|
useConpty: IS_WIN,
|
|
16116
16839
|
});
|
|
16117
16840
|
|
|
16841
|
+
if (isLoopOwned) {
|
|
16842
|
+
loopLog(sessionId, 'pty:spawned', 'pty spawned', { pid: ptyProcess.pid });
|
|
16843
|
+
}
|
|
16844
|
+
|
|
16118
16845
|
const session = {
|
|
16119
16846
|
pty: ptyProcess, clients: new Set(), profile, cwd,
|
|
16120
16847
|
createdAt: Date.now(),
|
|
16121
16848
|
outputBuffer: [], // ring buffer of output strings
|
|
16122
16849
|
outputBufferBytes: 0, // running byte count
|
|
16850
|
+
vterm: new VTermBuffer(initialCols, initialRows, TERMINAL_SNAPSHOT_SCROLLBACK_ROWS + initialRows),
|
|
16123
16851
|
graceTimer: null, // setTimeout handle for orphan cleanup
|
|
16124
16852
|
claudeSessionId: opts.resume || null, // known Claude session ID (from resume or detect)
|
|
16125
16853
|
};
|
|
@@ -16161,8 +16889,14 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
|
|
|
16161
16889
|
});
|
|
16162
16890
|
};
|
|
16163
16891
|
|
|
16892
|
+
// Loop boot signature: log first ~500 bytes of PTY output (only for loop-owned sessions)
|
|
16893
|
+
let _loopBootCaptured = false;
|
|
16894
|
+
let _loopBootBuf = '';
|
|
16895
|
+
|
|
16164
16896
|
const OUTPUT_BURST_CAP = 64 * 1024; // flush early when this much buffered in one tick
|
|
16165
16897
|
ptyProcess.onData((data) => {
|
|
16898
|
+
try { session.vterm?.write(data); } catch {}
|
|
16899
|
+
|
|
16166
16900
|
// Accumulate into coalesce buffer
|
|
16167
16901
|
_outputCoalesceBuf += data;
|
|
16168
16902
|
|
|
@@ -16179,6 +16913,19 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
|
|
|
16179
16913
|
}
|
|
16180
16914
|
}
|
|
16181
16915
|
|
|
16916
|
+
// Boot signature capture for loop-owned sessions (first 500 bytes only)
|
|
16917
|
+
if (isLoopOwned && !_loopBootCaptured) {
|
|
16918
|
+
_loopBootBuf += data;
|
|
16919
|
+
if (_loopBootBuf.length >= 500) {
|
|
16920
|
+
_loopBootCaptured = true;
|
|
16921
|
+
loopLog(sessionId, 'pty:boot-output', 'first 500 bytes of pty output captured', {
|
|
16922
|
+
uptimeMs: Date.now() - session.createdAt,
|
|
16923
|
+
sample: _loopBootBuf.slice(0, 500),
|
|
16924
|
+
});
|
|
16925
|
+
_loopBootBuf = '';
|
|
16926
|
+
}
|
|
16927
|
+
}
|
|
16928
|
+
|
|
16182
16929
|
// Burst cap: if a huge chunk arrives in one tick, flush synchronously so a
|
|
16183
16930
|
// multi-MB JSON doesn't pile up and block the client write loop (which
|
|
16184
16931
|
// stalls xterm rendering and causes drawn-glitch artifacts).
|
|
@@ -16198,6 +16945,39 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
|
|
|
16198
16945
|
// Clear grace timer if set
|
|
16199
16946
|
if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
|
|
16200
16947
|
|
|
16948
|
+
if (isLoopOwned) {
|
|
16949
|
+
// Flush any remaining boot buffer that didn't reach 500 bytes
|
|
16950
|
+
if (!_loopBootCaptured && _loopBootBuf.length > 0) {
|
|
16951
|
+
loopLog(sessionId, 'pty:boot-output', `pty exited before 500 bytes (got ${_loopBootBuf.length})`, {
|
|
16952
|
+
uptimeMs: Date.now() - session.createdAt,
|
|
16953
|
+
sample: _loopBootBuf,
|
|
16954
|
+
});
|
|
16955
|
+
}
|
|
16956
|
+
loopLog(sessionId, 'pty:exit', 'pty exited', {
|
|
16957
|
+
exitCode,
|
|
16958
|
+
uptimeMs: Date.now() - session.createdAt,
|
|
16959
|
+
bootCaptured: _loopBootCaptured,
|
|
16960
|
+
});
|
|
16961
|
+
// Mark loop file inactive if PTY died with loop still active
|
|
16962
|
+
try {
|
|
16963
|
+
const loopFile = findLoopFileForTerminal(sessionId);
|
|
16964
|
+
if (loopFile) {
|
|
16965
|
+
const loopPath = resolve(LOOP_DIR, loopFile);
|
|
16966
|
+
const ls = JSON.parse(readFileSync(loopPath, 'utf-8'));
|
|
16967
|
+
if (ls.active) {
|
|
16968
|
+
ls.active = false;
|
|
16969
|
+
ls.completedAt = new Date().toISOString();
|
|
16970
|
+
ls.stoppedReason = ls.stoppedReason || `pty-exit-${exitCode}`;
|
|
16971
|
+
ls.lastExitCode = exitCode;
|
|
16972
|
+
writeFileSync(loopPath, JSON.stringify(ls, null, 2));
|
|
16973
|
+
loopLog(sessionId, 'pty:exit', 'marked loop inactive', { stoppedReason: ls.stoppedReason, loopFile });
|
|
16974
|
+
}
|
|
16975
|
+
}
|
|
16976
|
+
} catch (err) {
|
|
16977
|
+
loopLog(sessionId, 'pty:exit', 'failed to mark loop inactive', { err: err.message });
|
|
16978
|
+
}
|
|
16979
|
+
}
|
|
16980
|
+
|
|
16201
16981
|
const msg = JSON.stringify({ type: 'exit', exitCode });
|
|
16202
16982
|
session.clients.forEach(ws => {
|
|
16203
16983
|
if (ws.readyState === 1) ws.send(msg);
|
|
@@ -16225,6 +17005,60 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
|
|
|
16225
17005
|
return sessionId;
|
|
16226
17006
|
}
|
|
16227
17007
|
|
|
17008
|
+
function terminalSnapshotToAnsi(session) {
|
|
17009
|
+
const vterm = session?.vterm;
|
|
17010
|
+
if (!vterm) return '';
|
|
17011
|
+
|
|
17012
|
+
const cols = Math.max(1, vterm.cols || session?.pty?.cols || 120);
|
|
17013
|
+
const rows = Math.max(1, vterm.rows || session?.pty?.rows || 30);
|
|
17014
|
+
const parts = ['\x1b[0m'];
|
|
17015
|
+
|
|
17016
|
+
if (vterm.useAltBuffer) {
|
|
17017
|
+
parts.push('\x1b[?1049h');
|
|
17018
|
+
} else {
|
|
17019
|
+
parts.push('\x1b[?1049l');
|
|
17020
|
+
const sbLen = vterm.scrollbackLength || 0;
|
|
17021
|
+
const start = Math.max(0, sbLen - TERMINAL_SNAPSHOT_SCROLLBACK_ROWS);
|
|
17022
|
+
for (let r = start; r < sbLen; r++) {
|
|
17023
|
+
const row = vterm.getScrollbackRow(r);
|
|
17024
|
+
if (!row) continue;
|
|
17025
|
+
let line = '';
|
|
17026
|
+
for (let c = 0; c < cols; c++) line += row[c]?.char || ' ';
|
|
17027
|
+
parts.push(line.replace(/\s+$/g, ''), '\r\n');
|
|
17028
|
+
}
|
|
17029
|
+
}
|
|
17030
|
+
|
|
17031
|
+
// Clear only the visible viewport. In normal-buffer mode this preserves the
|
|
17032
|
+
// scrollback rows emitted above while rebuilding the current screen from the
|
|
17033
|
+
// server-side terminal model instead of replaying an arbitrary ANSI tail.
|
|
17034
|
+
parts.push('\x1b[2J\x1b[H');
|
|
17035
|
+
for (let r = 0; r < rows; r++) {
|
|
17036
|
+
const row = vterm.getRow(r);
|
|
17037
|
+
if (!row) continue;
|
|
17038
|
+
let line = '';
|
|
17039
|
+
for (let c = 0; c < cols; c++) line += row[c]?.char || ' ';
|
|
17040
|
+
parts.push(`\x1b[${r + 1};1H`, line.replace(/\s+$/g, ''));
|
|
17041
|
+
}
|
|
17042
|
+
|
|
17043
|
+
parts.push(
|
|
17044
|
+
'\x1b[0m',
|
|
17045
|
+
vterm.cursorVisible ? '\x1b[?25h' : '\x1b[?25l',
|
|
17046
|
+
`\x1b[${Math.min(rows, Math.max(1, vterm.cursorY + 1))};${Math.min(cols, Math.max(1, vterm.cursorX + 1))}H`,
|
|
17047
|
+
);
|
|
17048
|
+
|
|
17049
|
+
return parts.join('');
|
|
17050
|
+
}
|
|
17051
|
+
|
|
17052
|
+
function terminalSnapshotToText(session) {
|
|
17053
|
+
const vterm = session?.vterm;
|
|
17054
|
+
if (!vterm) return '';
|
|
17055
|
+
const sb = vterm.scrollbackLength || 0;
|
|
17056
|
+
const totalRows = sb + (vterm.rows || 0);
|
|
17057
|
+
if (totalRows <= 0) return '';
|
|
17058
|
+
const startRow = vterm.useAltBuffer ? sb : Math.max(0, totalRows - TERMINAL_SNAPSHOT_SCROLLBACK_ROWS - (vterm.rows || 0));
|
|
17059
|
+
return vterm.getText(startRow, 0, totalRows - 1, Math.max(0, (vterm.cols || 1) - 1));
|
|
17060
|
+
}
|
|
17061
|
+
|
|
16228
17062
|
// ── Loop Driver — sends /clear + next iteration prompt between loop iterations ──
|
|
16229
17063
|
|
|
16230
17064
|
// Comprehensive ANSI escape stripping: CSI sequences, OSC sequences, charset selects, cursor keys, and \r
|
|
@@ -16238,15 +17072,31 @@ function writeToLoopPty(session, text, sendEnter = true) {
|
|
|
16238
17072
|
const full = sendEnter ? text + '\r' : text;
|
|
16239
17073
|
const CHUNK = 256;
|
|
16240
17074
|
const DELAY = 30;
|
|
17075
|
+
const chunkCount = Math.ceil(full.length / CHUNK);
|
|
17076
|
+
const totalSettleMs = chunkCount * DELAY;
|
|
17077
|
+
const tsid = session?._loopTerminalSessionId || null;
|
|
17078
|
+
let writeFailures = 0;
|
|
17079
|
+
loopLog(tsid, 'pty:write-begin', 'writeToLoopPty start', {
|
|
17080
|
+
bytes: full.length, chunkCount, totalSettleMs, sendEnter,
|
|
17081
|
+
preview: text.slice(0, 200),
|
|
17082
|
+
});
|
|
16241
17083
|
for (let i = 0; i < full.length; i += CHUNK) {
|
|
16242
17084
|
const chunk = full.slice(i, i + CHUNK);
|
|
16243
17085
|
const delay = (i / CHUNK) * DELAY;
|
|
16244
17086
|
setTimeout(() => {
|
|
16245
|
-
try { session.pty.write(chunk); } catch {
|
|
17087
|
+
try { session.pty.write(chunk); } catch (err) {
|
|
17088
|
+
writeFailures++;
|
|
17089
|
+
loopLog(tsid, 'pty:write-fail', 'pty.write threw', { offset: i, err: err.message });
|
|
17090
|
+
}
|
|
16246
17091
|
}, delay);
|
|
16247
17092
|
}
|
|
17093
|
+
setTimeout(() => {
|
|
17094
|
+
loopLog(tsid, 'pty:write-end', 'writeToLoopPty all chunks scheduled', {
|
|
17095
|
+
bytes: full.length, chunkCount, writeFailures,
|
|
17096
|
+
});
|
|
17097
|
+
}, totalSettleMs + 50);
|
|
16248
17098
|
// Return total time for all chunks to be sent
|
|
16249
|
-
return
|
|
17099
|
+
return totalSettleMs;
|
|
16250
17100
|
}
|
|
16251
17101
|
|
|
16252
17102
|
/**
|
|
@@ -16284,25 +17134,41 @@ function loopPromptReady(buffer) {
|
|
|
16284
17134
|
*/
|
|
16285
17135
|
function attachLoopDriver(terminalSessionId) {
|
|
16286
17136
|
const session = terminalSessions.get(terminalSessionId);
|
|
16287
|
-
if (!session)
|
|
17137
|
+
if (!session) {
|
|
17138
|
+
loopLog(terminalSessionId, 'driver:attach', 'ABORT: terminal session not found in registry');
|
|
17139
|
+
return;
|
|
17140
|
+
}
|
|
16288
17141
|
|
|
16289
17142
|
// Initialize capture buffer (used only for initial boot prompt detection)
|
|
16290
17143
|
session._loopDriverBuffer = '';
|
|
16291
17144
|
session._loopDriverStartedAt = Date.now();
|
|
17145
|
+
session._loopTerminalSessionId = terminalSessionId;
|
|
16292
17146
|
session._pendingClaimed = false;
|
|
16293
17147
|
session._pendingClaimedAt = null;
|
|
16294
17148
|
let driving = false; // prevent re-entrance
|
|
16295
17149
|
|
|
16296
17150
|
let consecutiveFailures = 0;
|
|
16297
17151
|
const MAX_DRIVE_FAILURES = 5; // with deterministic delays, failures are real problems
|
|
17152
|
+
let tickCount = 0;
|
|
17153
|
+
|
|
17154
|
+
loopLog(terminalSessionId, 'driver:attach', 'hook-driven loop driver attached (claude-code)', { intervalMs: 2000 });
|
|
16298
17155
|
|
|
16299
17156
|
const interval = setInterval(async () => {
|
|
17157
|
+
tickCount++;
|
|
16300
17158
|
if (driving) return;
|
|
16301
17159
|
if (!terminalSessions.has(terminalSessionId)) {
|
|
16302
17160
|
clearInterval(interval);
|
|
16303
|
-
|
|
17161
|
+
loopLog(terminalSessionId, 'driver:tick', 'terminal session gone — clearing driver interval', { ticks: tickCount });
|
|
16304
17162
|
return;
|
|
16305
17163
|
}
|
|
17164
|
+
// Throttled tick log: every 10th tick (20s)
|
|
17165
|
+
if (tickCount % 10 === 0) {
|
|
17166
|
+
loopLog(terminalSessionId, 'driver:tick', `tick #${tickCount}`, {
|
|
17167
|
+
pendingClaimed: !!session._pendingClaimed,
|
|
17168
|
+
bootElapsedMs: Date.now() - (session._loopDriverStartedAt || 0),
|
|
17169
|
+
bufferBytes: (session._loopDriverBuffer || '').length,
|
|
17170
|
+
});
|
|
17171
|
+
}
|
|
16306
17172
|
|
|
16307
17173
|
// Cooldown after pending claim: don't scan for awaitingNext while the first
|
|
16308
17174
|
// iteration is still running. Once the pending file is gone (renamed by hook)
|
|
@@ -16376,10 +17242,17 @@ function attachLoopDriver(terminalSessionId) {
|
|
|
16376
17242
|
} else {
|
|
16377
17243
|
const pendingFiles = readdirSync(LOOP_DIR)
|
|
16378
17244
|
.filter(f => f.startsWith('pending-') && f.endsWith('.json'));
|
|
17245
|
+
if (tickCount % 5 === 0) {
|
|
17246
|
+
loopLog(terminalSessionId, 'driver:scan', 'scanning for pending file', {
|
|
17247
|
+
pendingCount: pendingFiles.length, bootElapsedMs: bootElapsed,
|
|
17248
|
+
});
|
|
17249
|
+
}
|
|
17250
|
+
let foundOwnPending = false;
|
|
16379
17251
|
for (const pf of pendingFiles) {
|
|
16380
17252
|
let pendingState;
|
|
16381
17253
|
try { pendingState = JSON.parse(readFileSync(resolve(LOOP_DIR, pf), 'utf-8')); } catch { continue; }
|
|
16382
17254
|
if (pendingState.terminalSessionId !== terminalSessionId) continue;
|
|
17255
|
+
foundOwnPending = true;
|
|
16383
17256
|
if (!pendingState.active && !pendingState.pending) continue;
|
|
16384
17257
|
|
|
16385
17258
|
// Check for prompt OR use fallback after 30s of waiting
|
|
@@ -16388,7 +17261,11 @@ function attachLoopDriver(terminalSessionId) {
|
|
|
16388
17261
|
const fallbackFire = waitedForPrompt > 30000;
|
|
16389
17262
|
|
|
16390
17263
|
if (promptDetected || fallbackFire) {
|
|
16391
|
-
|
|
17264
|
+
loopLog(terminalSessionId, 'driver:claim', `claiming pending loop`, {
|
|
17265
|
+
file: pf, promptDetected, fallbackFire,
|
|
17266
|
+
waitedForPromptMs: waitedForPrompt, bootElapsedMs: bootElapsed,
|
|
17267
|
+
bufferTail: (session._loopDriverBuffer || '').slice(-300),
|
|
17268
|
+
});
|
|
16392
17269
|
session._pendingClaimed = true;
|
|
16393
17270
|
session._pendingClaimedAt = Date.now();
|
|
16394
17271
|
session._loopDriverBuffer = '';
|
|
@@ -16400,12 +17277,23 @@ function attachLoopDriver(terminalSessionId) {
|
|
|
16400
17277
|
setTimeout(() => {
|
|
16401
17278
|
try {
|
|
16402
17279
|
session.pty.write('\r');
|
|
16403
|
-
|
|
16404
|
-
} catch {
|
|
17280
|
+
loopLog(terminalSessionId, 'driver:claim', 'auto-confirm Enter sent for pending claim');
|
|
17281
|
+
} catch (err) {
|
|
17282
|
+
loopLog(terminalSessionId, 'driver:claim', 'auto-confirm Enter FAILED', { err: err.message });
|
|
17283
|
+
}
|
|
16405
17284
|
}, chunkTime + 4000);
|
|
17285
|
+
} else if (tickCount % 5 === 0) {
|
|
17286
|
+
loopLog(terminalSessionId, 'driver:claim', 'pending file matched but not claiming yet', {
|
|
17287
|
+
promptDetected, waitedForPromptMs: waitedForPrompt, bufferBytes: (session._loopDriverBuffer || '').length,
|
|
17288
|
+
});
|
|
16406
17289
|
}
|
|
16407
17290
|
break;
|
|
16408
17291
|
}
|
|
17292
|
+
if (!foundOwnPending && tickCount % 5 === 0 && pendingFiles.length > 0) {
|
|
17293
|
+
loopLog(terminalSessionId, 'driver:scan', 'no pending file matches this terminal session', {
|
|
17294
|
+
pendingFiles, ourTerminalSessionId: terminalSessionId,
|
|
17295
|
+
});
|
|
17296
|
+
}
|
|
16409
17297
|
}
|
|
16410
17298
|
}
|
|
16411
17299
|
} catch { /* ok */ }
|
|
@@ -16423,16 +17311,23 @@ function attachLoopDriver(terminalSessionId) {
|
|
|
16423
17311
|
async function driveNextIteration(session, loopState, filename) {
|
|
16424
17312
|
const loopPath = resolve(LOOP_DIR, filename);
|
|
16425
17313
|
const iter = loopState.currentIteration;
|
|
17314
|
+
const tsid = loopState.terminalSessionId || session?._loopTerminalSessionId || null;
|
|
16426
17315
|
|
|
16427
|
-
|
|
17316
|
+
loopLog(tsid, 'driver:iter-begin', `driving iteration ${iter}/${loopState.totalIterations}`, {
|
|
17317
|
+
file: filename, awaitingNext: !!loopState.awaitingNext,
|
|
17318
|
+
});
|
|
16428
17319
|
|
|
16429
17320
|
// Step 1: Grace delay — let PTY finish rendering after Claude stopped
|
|
16430
17321
|
await new Promise(r => setTimeout(r, 2000));
|
|
16431
17322
|
|
|
16432
17323
|
// Step 2: Send /clear to reset conversation context
|
|
16433
17324
|
session._loopDriverBuffer = '';
|
|
16434
|
-
|
|
16435
|
-
|
|
17325
|
+
try {
|
|
17326
|
+
session.pty.write('/clear\r');
|
|
17327
|
+
loopLog(tsid, 'driver:iter', '/clear sent');
|
|
17328
|
+
} catch (err) {
|
|
17329
|
+
loopLog(tsid, 'driver:iter', '/clear write FAILED', { err: err.message });
|
|
17330
|
+
}
|
|
16436
17331
|
|
|
16437
17332
|
// Step 3: Wait for /clear to process (CLI clears conversation, renders fresh prompt)
|
|
16438
17333
|
await new Promise(r => setTimeout(r, 5000));
|
|
@@ -16441,12 +17336,16 @@ async function driveNextIteration(session, loopState, filename) {
|
|
|
16441
17336
|
session._loopDriverBuffer = '';
|
|
16442
17337
|
const iterMsg = `[SynaBun Loop] Iteration ${iter}. Begin task.`;
|
|
16443
17338
|
const chunkTime = writeToLoopPty(session, iterMsg, true);
|
|
16444
|
-
|
|
17339
|
+
loopLog(tsid, 'driver:iter', 'iteration message scheduled', { iter, msg: iterMsg, chunkTime });
|
|
16445
17340
|
|
|
16446
17341
|
// Step 5: Auto-confirm (Enter after all chunks + settle time)
|
|
16447
17342
|
setTimeout(() => {
|
|
16448
|
-
try {
|
|
16449
|
-
|
|
17343
|
+
try {
|
|
17344
|
+
session.pty.write('\r');
|
|
17345
|
+
loopLog(tsid, 'driver:iter', 'auto-confirm Enter sent', { iter });
|
|
17346
|
+
} catch (err) {
|
|
17347
|
+
loopLog(tsid, 'driver:iter', 'auto-confirm Enter FAILED', { iter, err: err.message });
|
|
17348
|
+
}
|
|
16450
17349
|
}, chunkTime + 3000);
|
|
16451
17350
|
|
|
16452
17351
|
// Step 6: Clear awaitingNext flag
|
|
@@ -16455,8 +17354,9 @@ async function driveNextIteration(session, loopState, filename) {
|
|
|
16455
17354
|
freshState.awaitingNext = false;
|
|
16456
17355
|
delete freshState._driveRetries; // no longer needed
|
|
16457
17356
|
writeFileSync(loopPath, JSON.stringify(freshState, null, 2));
|
|
17357
|
+
loopLog(tsid, 'driver:iter-end', `iteration ${iter} dispatched, awaitingNext cleared`);
|
|
16458
17358
|
} catch (err) {
|
|
16459
|
-
|
|
17359
|
+
loopLog(tsid, 'driver:iter-end', 'clear awaitingNext FAILED', { iter, err: err.message });
|
|
16460
17360
|
}
|
|
16461
17361
|
|
|
16462
17362
|
return true;
|
|
@@ -16480,7 +17380,21 @@ const EXEC_BOOT_SENTINEL = 'SYNABUN_EXEC_BOOT_READY';
|
|
|
16480
17380
|
*/
|
|
16481
17381
|
function prepareExecTaskFile(terminalSessionId, loopState) {
|
|
16482
17382
|
const taskFile = resolve('/tmp', `synabun-loop-${terminalSessionId}.txt`);
|
|
16483
|
-
const
|
|
17383
|
+
const browserBlock = loopState.usesBrowser ? [
|
|
17384
|
+
'',
|
|
17385
|
+
'=== BROWSER ENFORCEMENT (MANDATORY) ===',
|
|
17386
|
+
'This automation REQUIRES the SynaBun internal browser.',
|
|
17387
|
+
loopState.browserSessionId ? `YOUR BROWSER SESSION: ${loopState.browserSessionId}` : '',
|
|
17388
|
+
loopState.browserTabId ? `YOUR BROWSER TAB: ${loopState.browserTabId}` : '',
|
|
17389
|
+
loopState.browserSessionId
|
|
17390
|
+
? `Pass sessionId: "${loopState.browserSessionId}"${loopState.browserTabId ? ` and tabId: "${loopState.browserTabId}"` : ''} to EVERY browser tool call. Omitting them can hijack another loop's browser.`
|
|
17391
|
+
: 'Use the SynaBun browser tools only. Do not use external browser/search tools for visual browsing.',
|
|
17392
|
+
'Use ONLY SynaBun MCP browser tools such as browser_navigate, browser_click, browser_snapshot, browser_content, browser_evaluate, browser_wait, and the browser_extract_* tools.',
|
|
17393
|
+
'If the browser shows a login page, CAPTCHA, 2FA, or any wall requiring human action, stop and report the blocker. Do not bypass it or fall back to web search.',
|
|
17394
|
+
'=== END BROWSER ENFORCEMENT ===',
|
|
17395
|
+
'',
|
|
17396
|
+
].filter(Boolean).join('\n') : '';
|
|
17397
|
+
const wrappedTask = `AUTHORIZATION: This task is running inside SynaBun Automation Studio, a user-supervised automation platform. The user has explicitly configured and launched this automation. All browser tools are first-party MCP tools under user control. Execute the task as instructed.${browserBlock}\n\nTASK:\n${loopState.task}`;
|
|
16484
17398
|
const fullTask = loopState.context ? `${wrappedTask}\n\nCONTEXT:\n${loopState.context}` : wrappedTask;
|
|
16485
17399
|
writeFileSync(taskFile, fullTask, 'utf-8');
|
|
16486
17400
|
return taskFile;
|
|
@@ -16559,23 +17473,41 @@ function findLoopFileForTerminal(terminalSessionId) {
|
|
|
16559
17473
|
*/
|
|
16560
17474
|
function attachExecLoopDriver(terminalSessionId) {
|
|
16561
17475
|
const session = terminalSessions.get(terminalSessionId);
|
|
16562
|
-
if (!session)
|
|
17476
|
+
if (!session) {
|
|
17477
|
+
loopLog(terminalSessionId, 'exec-driver:attach', 'ABORT: terminal session not found in registry');
|
|
17478
|
+
return;
|
|
17479
|
+
}
|
|
16563
17480
|
|
|
16564
17481
|
session._loopDriverBuffer = '';
|
|
16565
17482
|
session._loopDriverStartedAt = Date.now();
|
|
17483
|
+
session._loopTerminalSessionId = terminalSessionId;
|
|
16566
17484
|
session._execDriverState = 'booting';
|
|
16567
17485
|
session._execBootSent = false;
|
|
16568
17486
|
session._execCurrentIter = 0;
|
|
16569
17487
|
session._execTaskFile = null; // prepared on first ready
|
|
17488
|
+
session._execIterStartedAt = null;
|
|
16570
17489
|
let driving = false;
|
|
17490
|
+
let tickCount = 0;
|
|
17491
|
+
let consecutiveFastFails = 0;
|
|
17492
|
+
let consecutiveNonZero = 0;
|
|
17493
|
+
const FAST_FAIL_THRESHOLD_MS = 30_000;
|
|
17494
|
+
const FAIL_LIMIT = 3;
|
|
17495
|
+
loopLog(terminalSessionId, 'exec-driver:attach', 'exec loop driver attached', { intervalMs: 1500 });
|
|
16571
17496
|
|
|
16572
17497
|
const interval = setInterval(async () => {
|
|
17498
|
+
tickCount++;
|
|
16573
17499
|
if (driving) return;
|
|
16574
17500
|
if (!terminalSessions.has(terminalSessionId)) {
|
|
16575
17501
|
clearInterval(interval);
|
|
16576
|
-
|
|
17502
|
+
loopLog(terminalSessionId, 'exec-driver:tick', 'terminal session gone — clearing driver interval', { ticks: tickCount });
|
|
16577
17503
|
return;
|
|
16578
17504
|
}
|
|
17505
|
+
if (tickCount % 10 === 0) {
|
|
17506
|
+
loopLog(terminalSessionId, 'exec-driver:tick', `tick #${tickCount}`, {
|
|
17507
|
+
state: session._execDriverState, currentIter: session._execCurrentIter,
|
|
17508
|
+
bootSent: session._execBootSent,
|
|
17509
|
+
});
|
|
17510
|
+
}
|
|
16579
17511
|
|
|
16580
17512
|
// Load loop state file for this terminal
|
|
16581
17513
|
const loopFile = findLoopFileForTerminal(terminalSessionId);
|
|
@@ -16608,14 +17540,19 @@ function attachExecLoopDriver(terminalSessionId) {
|
|
|
16608
17540
|
session._loopDriverBuffer = '';
|
|
16609
17541
|
writeToLoopPty(session, `echo ${EXEC_BOOT_SENTINEL}`, true);
|
|
16610
17542
|
session._execBootSent = true;
|
|
16611
|
-
|
|
17543
|
+
loopLog(terminalSessionId, 'exec-driver:boot', 'boot sentinel echo sent', { sentinel: EXEC_BOOT_SENTINEL, bootElapsedMs: bootElapsed });
|
|
16612
17544
|
}
|
|
16613
17545
|
|
|
16614
17546
|
// Detect boot sentinel OR use fallback timeout
|
|
16615
|
-
|
|
17547
|
+
const sentinelDetected = session._execBootSent && stripped.includes(EXEC_BOOT_SENTINEL);
|
|
17548
|
+
const fallbackFire = bootElapsed > 20000;
|
|
17549
|
+
if (sentinelDetected || fallbackFire) {
|
|
16616
17550
|
session._execDriverState = 'ready';
|
|
16617
17551
|
session._loopDriverBuffer = '';
|
|
16618
|
-
|
|
17552
|
+
loopLog(terminalSessionId, 'exec-driver:boot', 'shell ready', {
|
|
17553
|
+
sentinelDetected, fallbackFire, bootElapsedMs: bootElapsed,
|
|
17554
|
+
bufferTail: stripped.slice(-200),
|
|
17555
|
+
});
|
|
16619
17556
|
}
|
|
16620
17557
|
}
|
|
16621
17558
|
|
|
@@ -16650,15 +17587,19 @@ function attachExecLoopDriver(terminalSessionId) {
|
|
|
16650
17587
|
// Prepare task file on first iteration (reused across all iterations)
|
|
16651
17588
|
if (!session._execTaskFile) {
|
|
16652
17589
|
session._execTaskFile = prepareExecTaskFile(terminalSessionId, loopState);
|
|
16653
|
-
|
|
17590
|
+
loopLog(terminalSessionId, 'exec-driver:taskfile', 'task file prepared', { path: session._execTaskFile });
|
|
16654
17591
|
}
|
|
16655
17592
|
|
|
16656
|
-
// Fire next iteration with sentinel echo appended
|
|
17593
|
+
// Fire next iteration with sentinel echo appended (captures exit code)
|
|
16657
17594
|
const cmd = buildExecCommand(loopState.profile, loopState, session._execTaskFile);
|
|
16658
17595
|
const nextIter = iter + 1;
|
|
16659
17596
|
const sentinel = `${EXEC_SENTINEL_PREFIX}${nextIter}`;
|
|
16660
|
-
const fullCmd = `${cmd}; echo ${sentinel}`;
|
|
16661
|
-
|
|
17597
|
+
const fullCmd = `${cmd}; rc=$?; echo ${sentinel} rc=$rc`;
|
|
17598
|
+
loopLog(terminalSessionId, 'exec-driver:iter-begin', `starting iteration ${nextIter}/${loopState.totalIterations}`, {
|
|
17599
|
+
profile: loopState.profile, sentinel,
|
|
17600
|
+
cmdPreview: fullCmd.slice(0, 300),
|
|
17601
|
+
cmdBytes: fullCmd.length,
|
|
17602
|
+
});
|
|
16662
17603
|
|
|
16663
17604
|
// Grace delay before sending command
|
|
16664
17605
|
await new Promise(r => setTimeout(r, 2000));
|
|
@@ -16666,6 +17607,7 @@ function attachExecLoopDriver(terminalSessionId) {
|
|
|
16666
17607
|
session._loopDriverBuffer = '';
|
|
16667
17608
|
session._execDriverState = 'running';
|
|
16668
17609
|
session._execCurrentIter = nextIter;
|
|
17610
|
+
session._execIterStartedAt = Date.now();
|
|
16669
17611
|
writeToLoopPty(session, fullCmd, true);
|
|
16670
17612
|
|
|
16671
17613
|
// Update loop state
|
|
@@ -16676,10 +17618,41 @@ function attachExecLoopDriver(terminalSessionId) {
|
|
|
16676
17618
|
}
|
|
16677
17619
|
|
|
16678
17620
|
else if (state === 'running') {
|
|
16679
|
-
// Detect iteration completion via sentinel echo
|
|
16680
|
-
const
|
|
16681
|
-
|
|
16682
|
-
|
|
17621
|
+
// Detect iteration completion via sentinel echo + capture rc
|
|
17622
|
+
const sentinelRe = new RegExp(`${EXEC_SENTINEL_PREFIX}${session._execCurrentIter} rc=(-?\\d+)`);
|
|
17623
|
+
const m = stripped.match(sentinelRe);
|
|
17624
|
+
if (m) {
|
|
17625
|
+
const rc = parseInt(m[1], 10);
|
|
17626
|
+
const iterDurationMs = Date.now() - (session._execIterStartedAt || Date.now());
|
|
17627
|
+
const isFastFail = iterDurationMs < FAST_FAIL_THRESHOLD_MS;
|
|
17628
|
+
const isNonZero = rc !== 0;
|
|
17629
|
+
|
|
17630
|
+
if (isNonZero) consecutiveNonZero++; else consecutiveNonZero = 0;
|
|
17631
|
+
if (isFastFail) consecutiveFastFails++; else consecutiveFastFails = 0;
|
|
17632
|
+
|
|
17633
|
+
loopLog(terminalSessionId, 'exec-driver:iter-end', `iteration ${session._execCurrentIter} complete`, {
|
|
17634
|
+
rc, iterDurationMs, isFastFail, isNonZero,
|
|
17635
|
+
consecutiveNonZero, consecutiveFastFails,
|
|
17636
|
+
});
|
|
17637
|
+
|
|
17638
|
+
// Halt on failure streak (3 consecutive non-zero OR 3 consecutive fast)
|
|
17639
|
+
if (consecutiveNonZero >= FAIL_LIMIT || consecutiveFastFails >= FAIL_LIMIT) {
|
|
17640
|
+
const reason = consecutiveNonZero >= FAIL_LIMIT ? 'fast-fail-rc' : 'fast-fail-duration';
|
|
17641
|
+
loopState.active = false;
|
|
17642
|
+
loopState.completedAt = new Date().toISOString();
|
|
17643
|
+
loopState.stoppedReason = reason;
|
|
17644
|
+
loopState.lastExitCode = rc;
|
|
17645
|
+
try { writeFileSync(loopPath, JSON.stringify(loopState, null, 2)); } catch { /* ok */ }
|
|
17646
|
+
loopLog(terminalSessionId, 'exec-driver:halt', `halting loop: ${reason}`, {
|
|
17647
|
+
rc, iterDurationMs, consecutiveNonZero, consecutiveFastFails,
|
|
17648
|
+
});
|
|
17649
|
+
clearInterval(interval);
|
|
17650
|
+
if (session._execTaskFile) try { unlinkSync(session._execTaskFile); } catch { /* ok */ }
|
|
17651
|
+
try { session.pty?.kill?.(); } catch { /* ok */ }
|
|
17652
|
+
driving = false;
|
|
17653
|
+
return;
|
|
17654
|
+
}
|
|
17655
|
+
|
|
16683
17656
|
session._loopDriverBuffer = '';
|
|
16684
17657
|
session._execDriverState = 'ready';
|
|
16685
17658
|
// Cooldown before next iteration
|
|
@@ -19525,7 +20498,7 @@ async function createBrowserSession(options = {}) {
|
|
|
19525
20498
|
const extra = savedCfg.extraArgs.split(/\s+/).filter(Boolean);
|
|
19526
20499
|
stealthArgs.push(...extra);
|
|
19527
20500
|
}
|
|
19528
|
-
const launchOpts = { headless: headlessVal, args: stealthArgs };
|
|
20501
|
+
const launchOpts = { headless: headlessVal, args: stealthArgs, timeout: 45000 };
|
|
19529
20502
|
// Priority: user-configured executablePath > channel > auto-detected executablePath.
|
|
19530
20503
|
// User explicitly set executablePath = intentional override, always respect it.
|
|
19531
20504
|
// Channel = let Playwright resolve the binary for that browser brand.
|
|
@@ -19649,6 +20622,46 @@ async function createBrowserSession(options = {}) {
|
|
|
19649
20622
|
const launchPersistentProfile = async (launchRoot, launchProfileDir, launchMode) => {
|
|
19650
20623
|
const launchArgs = [...stealthArgs];
|
|
19651
20624
|
|
|
20625
|
+
// Kill orphan Chrome processes holding this user-data-dir BEFORE removing locks.
|
|
20626
|
+
// Otherwise launchPersistentContext hangs indefinitely waiting for the singleton.
|
|
20627
|
+
try {
|
|
20628
|
+
const { execSync } = await import('node:child_process');
|
|
20629
|
+
let pids = [];
|
|
20630
|
+
if (IS_WIN) {
|
|
20631
|
+
// Windows: PowerShell Get-CimInstance filters by CommandLine substring.
|
|
20632
|
+
// Pass launchRoot via env var to avoid quoting hell.
|
|
20633
|
+
const psScript = `$root = $env:SYNABUN_KILL_ROOT; Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='msedge.exe' OR Name='chromium.exe'" | Where-Object { $_.CommandLine -like "*user-data-dir=$root*" } | ForEach-Object { $_.ProcessId }`;
|
|
20634
|
+
const out = execSync(`powershell -NoProfile -NonInteractive -Command -`, {
|
|
20635
|
+
input: psScript,
|
|
20636
|
+
encoding: 'utf-8',
|
|
20637
|
+
timeout: 10000,
|
|
20638
|
+
env: { ...process.env, SYNABUN_KILL_ROOT: launchRoot },
|
|
20639
|
+
windowsHide: true,
|
|
20640
|
+
}).trim();
|
|
20641
|
+
pids = out.split(/\s+/).filter(Boolean).map(p => parseInt(p, 10)).filter(p => p > 0 && p !== process.pid);
|
|
20642
|
+
} else {
|
|
20643
|
+
const needle = `user-data-dir=${launchRoot}`;
|
|
20644
|
+
const out = execSync(`pgrep -f ${JSON.stringify(needle)} 2>/dev/null || true`, { encoding: 'utf-8' }).trim();
|
|
20645
|
+
pids = out.split(/\s+/).filter(Boolean).map(p => parseInt(p, 10)).filter(p => p > 0 && p !== process.pid);
|
|
20646
|
+
}
|
|
20647
|
+
if (pids.length) {
|
|
20648
|
+
console.log(`[browser] Killing ${pids.length} orphan Chrome process(es) holding ${launchMode} root: ${pids.join(',')}`);
|
|
20649
|
+
for (const pid of pids) {
|
|
20650
|
+
try {
|
|
20651
|
+
if (IS_WIN) {
|
|
20652
|
+
// process.kill on Windows only does SIGTERM-like; taskkill /F is reliable.
|
|
20653
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
|
20654
|
+
} else {
|
|
20655
|
+
process.kill(pid, 'SIGKILL');
|
|
20656
|
+
}
|
|
20657
|
+
} catch {}
|
|
20658
|
+
}
|
|
20659
|
+
await new Promise(r => setTimeout(r, 300));
|
|
20660
|
+
}
|
|
20661
|
+
} catch (err) {
|
|
20662
|
+
console.warn(`[browser] Orphan kill scan failed: ${err.message}`);
|
|
20663
|
+
}
|
|
20664
|
+
|
|
19652
20665
|
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'DevToolsActivePort']) {
|
|
19653
20666
|
const lf = resolve(launchRoot, lockFile);
|
|
19654
20667
|
try {
|
|
@@ -19724,34 +20737,24 @@ async function createBrowserSession(options = {}) {
|
|
|
19724
20737
|
if (_selectedProfileKind === 'system') {
|
|
19725
20738
|
const requestedProfileDir = _profileDirectory || 'Default';
|
|
19726
20739
|
|
|
19727
|
-
//
|
|
19728
|
-
//
|
|
19729
|
-
//
|
|
20740
|
+
// System browser profiles cannot be launched directly from Chrome's real
|
|
20741
|
+
// User Data dir while the user's browser may be running. Launch an
|
|
20742
|
+
// isolated mirror instead, using Playwright's persistent-context path.
|
|
19730
20743
|
const { mirrorRoot } = prepareMirroredProfile(userDataDir, requestedProfileDir, _sourceBrowser || 'chrome');
|
|
19731
20744
|
|
|
19732
20745
|
try {
|
|
19733
|
-
const
|
|
19734
|
-
|
|
19735
|
-
|
|
19736
|
-
|
|
19737
|
-
|
|
19738
|
-
viewport: { width: vpW, height: vpH },
|
|
19739
|
-
});
|
|
19740
|
-
|
|
19741
|
-
browser = await chromium.connectOverCDP(cdpResult.endpoint);
|
|
19742
|
-
context = browser.contexts()[0];
|
|
19743
|
-
if (!context) throw new Error('CDP connected but no browser context available');
|
|
19744
|
-
|
|
19745
|
-
page = await context.newPage();
|
|
19746
|
-
_isPersistent = false;
|
|
19747
|
-
_profileMode = 'cdp';
|
|
20746
|
+
const launched = await launchPersistentProfile(mirrorRoot, requestedProfileDir, 'mirror');
|
|
20747
|
+
browser = launched.launchBrowser;
|
|
20748
|
+
context = launched.launchContext;
|
|
20749
|
+
page = launched.launchPage;
|
|
20750
|
+
_profileMode = 'mirror';
|
|
19748
20751
|
_launchUserDataDir = mirrorRoot;
|
|
19749
20752
|
_launchProfileDirectory = requestedProfileDir;
|
|
19750
20753
|
|
|
19751
|
-
console.log(`[browser]
|
|
19752
|
-
} catch (
|
|
20754
|
+
console.log(`[browser] Launched mirrored ${_profileSourceName || requestedProfileDir} profile`);
|
|
20755
|
+
} catch (mirrorErr) {
|
|
19753
20756
|
throw new Error(
|
|
19754
|
-
`Could not
|
|
20757
|
+
`Could not launch mirrored ${_browserLabel} profile: ${mirrorErr.message}`
|
|
19755
20758
|
);
|
|
19756
20759
|
}
|
|
19757
20760
|
} else {
|
|
@@ -20227,6 +21230,16 @@ async function syncPageState(session, routed) {
|
|
|
20227
21230
|
return { url, title };
|
|
20228
21231
|
}
|
|
20229
21232
|
|
|
21233
|
+
function syncPageStateCompact(session, routed) {
|
|
21234
|
+
const url = routed.page.url();
|
|
21235
|
+
if (routed.tabId === session.activeTabId) session.currentUrl = url;
|
|
21236
|
+
return { url, title: routed.tabId === session.activeTabId ? (session.title || '') : '' };
|
|
21237
|
+
}
|
|
21238
|
+
|
|
21239
|
+
async function getBrowserActionState(session, routed, compact) {
|
|
21240
|
+
return compact ? syncPageStateCompact(session, routed) : syncPageState(session, routed);
|
|
21241
|
+
}
|
|
21242
|
+
|
|
20230
21243
|
// ── Browser REST endpoints ──
|
|
20231
21244
|
|
|
20232
21245
|
app.get('/api/browser/sessions', (req, res) => {
|
|
@@ -20334,10 +21347,10 @@ app.post('/api/browser/sessions/:id/navigate', async (req, res) => {
|
|
|
20334
21347
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20335
21348
|
const routed = getTargetPage(session, req);
|
|
20336
21349
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20337
|
-
const { url, returnSnapshot } = req.body;
|
|
21350
|
+
const { url, returnSnapshot, compact } = req.body;
|
|
20338
21351
|
try {
|
|
20339
21352
|
await routed.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
20340
|
-
const state = await
|
|
21353
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20341
21354
|
const body = { ok: true, url: state.url, title: state.title };
|
|
20342
21355
|
if (returnSnapshot) {
|
|
20343
21356
|
const snap = await buildSnapshotResponse(routed.page, {
|
|
@@ -20360,7 +21373,7 @@ app.post('/api/browser/sessions/:id/back', async (req, res) => {
|
|
|
20360
21373
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20361
21374
|
try {
|
|
20362
21375
|
await routed.page.goBack({ timeout: 10000 });
|
|
20363
|
-
const state = await
|
|
21376
|
+
const state = await getBrowserActionState(session, routed, req.body?.compact);
|
|
20364
21377
|
res.json({ ok: true, url: state.url, title: state.title });
|
|
20365
21378
|
} catch (err) {
|
|
20366
21379
|
res.json({ ok: false, error: err.message });
|
|
@@ -20374,7 +21387,7 @@ app.post('/api/browser/sessions/:id/forward', async (req, res) => {
|
|
|
20374
21387
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20375
21388
|
try {
|
|
20376
21389
|
await routed.page.goForward({ timeout: 10000 });
|
|
20377
|
-
const state = await
|
|
21390
|
+
const state = await getBrowserActionState(session, routed, req.body?.compact);
|
|
20378
21391
|
res.json({ ok: true, url: state.url, title: state.title });
|
|
20379
21392
|
} catch (err) {
|
|
20380
21393
|
res.json({ ok: false, error: err.message });
|
|
@@ -20388,7 +21401,7 @@ app.post('/api/browser/sessions/:id/reload', async (req, res) => {
|
|
|
20388
21401
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20389
21402
|
try {
|
|
20390
21403
|
await routed.page.reload({ timeout: 15000 });
|
|
20391
|
-
const state = await
|
|
21404
|
+
const state = await getBrowserActionState(session, routed, req.body?.compact);
|
|
20392
21405
|
res.json({ ok: true, url: state.url, title: state.title });
|
|
20393
21406
|
} catch (err) {
|
|
20394
21407
|
res.json({ ok: false, error: err.message });
|
|
@@ -20646,14 +21659,14 @@ app.post('/api/browser/sessions/:id/click', async (req, res) => {
|
|
|
20646
21659
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20647
21660
|
const routed = getTargetPage(session, req);
|
|
20648
21661
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20649
|
-
const { selector: rawSelector, timeout, nthMatch, textHint, returnSnapshot } = req.body;
|
|
21662
|
+
const { selector: rawSelector, timeout, nthMatch, textHint, returnSnapshot, compact } = req.body;
|
|
20650
21663
|
if (!rawSelector) return res.status(400).json({ error: 'selector required' });
|
|
20651
21664
|
const selector = normalizeSelector(rawSelector);
|
|
20652
21665
|
try {
|
|
20653
21666
|
const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
|
|
20654
21667
|
if (resolved.error) return res.status(400).json(resolved.error);
|
|
20655
21668
|
await resolved.target.click({ timeout: timeout || 5000 });
|
|
20656
|
-
const state = await
|
|
21669
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20657
21670
|
const body = { ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) };
|
|
20658
21671
|
if (returnSnapshot) {
|
|
20659
21672
|
const snap = await buildSnapshotResponse(routed.page, {
|
|
@@ -20675,14 +21688,14 @@ app.post('/api/browser/sessions/:id/fill', async (req, res) => {
|
|
|
20675
21688
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20676
21689
|
const routed = getTargetPage(session, req);
|
|
20677
21690
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20678
|
-
const { selector: rawFillSel, value, timeout, nthMatch, textHint } = req.body;
|
|
21691
|
+
const { selector: rawFillSel, value, timeout, nthMatch, textHint, compact } = req.body;
|
|
20679
21692
|
if (!rawFillSel) return res.status(400).json({ error: 'selector required' });
|
|
20680
21693
|
const selector = normalizeSelector(rawFillSel);
|
|
20681
21694
|
try {
|
|
20682
21695
|
const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
|
|
20683
21696
|
if (resolved.error) return res.status(400).json(resolved.error);
|
|
20684
21697
|
await resolved.target.fill(value ?? '', { timeout: timeout || 5000 });
|
|
20685
|
-
const state = await
|
|
21698
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20686
21699
|
res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
|
|
20687
21700
|
} catch (err) {
|
|
20688
21701
|
res.status(500).json({ error: err.message });
|
|
@@ -20694,7 +21707,7 @@ app.post('/api/browser/sessions/:id/type', async (req, res) => {
|
|
|
20694
21707
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20695
21708
|
const routed = getTargetPage(session, req);
|
|
20696
21709
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20697
|
-
const { selector: rawTypeSel, text, timeout, nthMatch, textHint } = req.body;
|
|
21710
|
+
const { selector: rawTypeSel, text, timeout, nthMatch, textHint, mode, compact } = req.body;
|
|
20698
21711
|
try {
|
|
20699
21712
|
let healed = false;
|
|
20700
21713
|
if (rawTypeSel) {
|
|
@@ -20702,11 +21715,20 @@ app.post('/api/browser/sessions/:id/type', async (req, res) => {
|
|
|
20702
21715
|
const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
|
|
20703
21716
|
if (resolved.error) return res.status(400).json(resolved.error);
|
|
20704
21717
|
healed = resolved.healed;
|
|
20705
|
-
|
|
21718
|
+
if (mode === 'insert') {
|
|
21719
|
+
await resolved.target.focus({ timeout: timeout || 5000 });
|
|
21720
|
+
await routed.page.keyboard.insertText(text ?? '');
|
|
21721
|
+
} else {
|
|
21722
|
+
await resolved.target.pressSequentially(text ?? '', { timeout: timeout || 5000 });
|
|
21723
|
+
}
|
|
20706
21724
|
} else {
|
|
20707
|
-
|
|
21725
|
+
if (mode === 'insert') {
|
|
21726
|
+
await routed.page.keyboard.insertText(text ?? '');
|
|
21727
|
+
} else {
|
|
21728
|
+
await routed.page.keyboard.type(text ?? '');
|
|
21729
|
+
}
|
|
20708
21730
|
}
|
|
20709
|
-
const state = await
|
|
21731
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20710
21732
|
res.json({ ok: true, url: state.url, title: state.title, ...(healed ? { healed: true } : {}) });
|
|
20711
21733
|
} catch (err) {
|
|
20712
21734
|
res.status(500).json({ error: err.message });
|
|
@@ -20718,14 +21740,14 @@ app.post('/api/browser/sessions/:id/hover', async (req, res) => {
|
|
|
20718
21740
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20719
21741
|
const routed = getTargetPage(session, req);
|
|
20720
21742
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20721
|
-
const { selector: rawHoverSel, timeout, nthMatch, textHint } = req.body;
|
|
21743
|
+
const { selector: rawHoverSel, timeout, nthMatch, textHint, compact } = req.body;
|
|
20722
21744
|
if (!rawHoverSel) return res.status(400).json({ error: 'selector required' });
|
|
20723
21745
|
const selector = normalizeSelector(rawHoverSel);
|
|
20724
21746
|
try {
|
|
20725
21747
|
const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
|
|
20726
21748
|
if (resolved.error) return res.status(400).json(resolved.error);
|
|
20727
21749
|
await resolved.target.hover({ timeout: timeout || 5000 });
|
|
20728
|
-
const state = await
|
|
21750
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20729
21751
|
res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
|
|
20730
21752
|
} catch (err) {
|
|
20731
21753
|
res.status(500).json({ error: err.message });
|
|
@@ -20737,14 +21759,14 @@ app.post('/api/browser/sessions/:id/select', async (req, res) => {
|
|
|
20737
21759
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20738
21760
|
const routed = getTargetPage(session, req);
|
|
20739
21761
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20740
|
-
const { selector: rawSelSel, value, timeout, nthMatch, textHint } = req.body;
|
|
21762
|
+
const { selector: rawSelSel, value, timeout, nthMatch, textHint, compact } = req.body;
|
|
20741
21763
|
if (!rawSelSel) return res.status(400).json({ error: 'selector required' });
|
|
20742
21764
|
const selector = normalizeSelector(rawSelSel);
|
|
20743
21765
|
try {
|
|
20744
21766
|
const resolved = await resolveInteractLocator(routed.page, selector, nthMatch, textHint);
|
|
20745
21767
|
if (resolved.error) return res.status(400).json(resolved.error);
|
|
20746
21768
|
await resolved.target.selectOption(value ?? '', { timeout: timeout || 5000 });
|
|
20747
|
-
const state = await
|
|
21769
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20748
21770
|
res.json({ ok: true, url: state.url, title: state.title, ...(resolved.healed ? { healed: true } : {}) });
|
|
20749
21771
|
} catch (err) {
|
|
20750
21772
|
res.status(500).json({ error: err.message });
|
|
@@ -20756,11 +21778,11 @@ app.post('/api/browser/sessions/:id/press', async (req, res) => {
|
|
|
20756
21778
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20757
21779
|
const routed = getTargetPage(session, req);
|
|
20758
21780
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20759
|
-
const { key } = req.body;
|
|
21781
|
+
const { key, compact } = req.body;
|
|
20760
21782
|
if (!key) return res.status(400).json({ error: 'key required' });
|
|
20761
21783
|
try {
|
|
20762
21784
|
await routed.page.keyboard.press(key);
|
|
20763
|
-
const state = await
|
|
21785
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
20764
21786
|
res.json({ ok: true, url: state.url, title: state.title });
|
|
20765
21787
|
} catch (err) {
|
|
20766
21788
|
res.status(500).json({ error: err.message });
|
|
@@ -20923,13 +21945,13 @@ app.post('/api/browser/sessions/:id/wait', async (req, res) => {
|
|
|
20923
21945
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
20924
21946
|
const routed = getTargetPage(session, req);
|
|
20925
21947
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
20926
|
-
const { selector, state, loadState, timeout } = req.body;
|
|
21948
|
+
const { selector, state, loadState, timeout, compact } = req.body;
|
|
20927
21949
|
try {
|
|
20928
21950
|
if (loadState) {
|
|
20929
21951
|
const valid = ['load', 'domcontentloaded', 'networkidle'];
|
|
20930
21952
|
if (!valid.includes(loadState)) return res.status(400).json({ error: `Invalid loadState. Use: ${valid.join(', ')}` });
|
|
20931
21953
|
await routed.page.waitForLoadState(loadState, { timeout: timeout || 15000 });
|
|
20932
|
-
const ps = await
|
|
21954
|
+
const ps = await getBrowserActionState(session, routed, compact);
|
|
20933
21955
|
res.json({ ok: true, loadState, url: ps.url, title: ps.title });
|
|
20934
21956
|
} else if (selector) {
|
|
20935
21957
|
await routed.page.locator(selector).waitFor({
|
|
@@ -20939,7 +21961,7 @@ app.post('/api/browser/sessions/:id/wait', async (req, res) => {
|
|
|
20939
21961
|
res.json({ ok: true, selector, state: state || 'visible' });
|
|
20940
21962
|
} else {
|
|
20941
21963
|
await routed.page.waitForTimeout(timeout || 1000);
|
|
20942
|
-
const ps = await
|
|
21964
|
+
const ps = await getBrowserActionState(session, routed, compact);
|
|
20943
21965
|
res.json({ ok: true, url: ps.url, title: ps.title });
|
|
20944
21966
|
}
|
|
20945
21967
|
} catch (err) {
|
|
@@ -21096,7 +22118,7 @@ app.post('/api/browser/sessions/:id/scroll', async (req, res) => {
|
|
|
21096
22118
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
21097
22119
|
const routed = getTargetPage(session, req);
|
|
21098
22120
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
21099
|
-
const { direction = 'down', distance = 500, selector: rawSel, returnSnapshot } = req.body;
|
|
22121
|
+
const { direction = 'down', distance = 500, selector: rawSel, returnSnapshot, compact } = req.body;
|
|
21100
22122
|
const deltaX = direction === 'right' ? distance : direction === 'left' ? -distance : 0;
|
|
21101
22123
|
const deltaY = direction === 'down' ? distance : direction === 'up' ? -distance : 0;
|
|
21102
22124
|
try {
|
|
@@ -21108,7 +22130,7 @@ app.post('/api/browser/sessions/:id/scroll', async (req, res) => {
|
|
|
21108
22130
|
await routed.page.evaluate(({ dx, dy }) => window.scrollBy(dx, dy), { dx: deltaX, dy: deltaY });
|
|
21109
22131
|
}
|
|
21110
22132
|
await routed.page.waitForTimeout(300);
|
|
21111
|
-
const state = await
|
|
22133
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
21112
22134
|
const body = { ok: true, url: state.url, title: state.title };
|
|
21113
22135
|
if (returnSnapshot) {
|
|
21114
22136
|
const snap = await buildSnapshotResponse(routed.page, {
|
|
@@ -21129,7 +22151,7 @@ app.post('/api/browser/sessions/:id/upload', async (req, res) => {
|
|
|
21129
22151
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
21130
22152
|
const routed = getTargetPage(session, req);
|
|
21131
22153
|
if (!routed) return res.status(404).json({ error: `Tab ${req.body?.tabId} not found in session` });
|
|
21132
|
-
const { selector: rawSel, filePaths, nthMatch } = req.body;
|
|
22154
|
+
const { selector: rawSel, filePaths, nthMatch, compact } = req.body;
|
|
21133
22155
|
if (!rawSel) return res.status(400).json({ error: 'selector required' });
|
|
21134
22156
|
if (!Array.isArray(filePaths) || !filePaths.length) return res.status(400).json({ error: 'filePaths must be a non-empty array' });
|
|
21135
22157
|
const selector = normalizeSelector(rawSel);
|
|
@@ -21146,7 +22168,7 @@ app.post('/api/browser/sessions/:id/upload', async (req, res) => {
|
|
|
21146
22168
|
const target = (count > 1 && nthMatch !== undefined) ? loc.nth(nthMatch) : loc;
|
|
21147
22169
|
await target.setInputFiles(filePaths);
|
|
21148
22170
|
await routed.page.waitForTimeout(500);
|
|
21149
|
-
const state = await
|
|
22171
|
+
const state = await getBrowserActionState(session, routed, compact);
|
|
21150
22172
|
res.json({ ok: true, url: state.url, title: state.title });
|
|
21151
22173
|
} catch (err) {
|
|
21152
22174
|
res.status(500).json({ error: err.message });
|
|
@@ -21288,6 +22310,15 @@ const httpServer = app.listen(PORT, async () => {
|
|
|
21288
22310
|
setKvConfig('embedding_dims', String(EMBEDDING_DIMS));
|
|
21289
22311
|
} catch {}
|
|
21290
22312
|
|
|
22313
|
+
// Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
|
|
22314
|
+
try {
|
|
22315
|
+
const repair = pluginInstaller.repairSynabunPlugins();
|
|
22316
|
+
if (repair?.repaired?.length) console.log(`[plugins] repaired legacy installs: ${repair.repaired.map(r => r.marketplaceName).join(', ')}`);
|
|
22317
|
+
if (repair?.failed?.length) console.warn(`[plugins] repair failed for: ${repair.failed.map(r => r.marketplaceName + ' (' + r.error + ')').join(', ')}`);
|
|
22318
|
+
} catch (err) {
|
|
22319
|
+
console.warn('[plugins] repair on startup failed:', err.message);
|
|
22320
|
+
}
|
|
22321
|
+
|
|
21291
22322
|
// SQLite indexes are created in schema — no runtime index creation needed
|
|
21292
22323
|
|
|
21293
22324
|
// Warm the session_cache + session_fts tables so the hybrid search has data
|
|
@@ -21469,10 +22500,15 @@ wss.on('connection', (ws, req) => {
|
|
|
21469
22500
|
// Cancel grace timer — a client reconnected
|
|
21470
22501
|
if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
|
|
21471
22502
|
|
|
21472
|
-
//
|
|
21473
|
-
|
|
21474
|
-
|
|
21475
|
-
|
|
22503
|
+
// Restore reconnecting clients from the server-side terminal model instead
|
|
22504
|
+
// of replaying a truncated raw ANSI tail. Raw tail replay can start inside an
|
|
22505
|
+
// alternate-screen frame, OSC string, or partial cursor sequence, corrupting
|
|
22506
|
+
// xterm state for long-running TUIs.
|
|
22507
|
+
if (session.outputBufferBytes > 0) {
|
|
22508
|
+
const snapshot = terminalSnapshotToAnsi(session);
|
|
22509
|
+
if (snapshot) {
|
|
22510
|
+
ws.send(JSON.stringify({ type: 'snapshot', data: snapshot, plain: terminalSnapshotToText(session) }));
|
|
22511
|
+
}
|
|
21476
22512
|
}
|
|
21477
22513
|
|
|
21478
22514
|
ws.on('message', (raw) => {
|
|
@@ -21500,6 +22536,7 @@ wss.on('connection', (ws, req) => {
|
|
|
21500
22536
|
session._appliedResize.cols === r.cols &&
|
|
21501
22537
|
session._appliedResize.rows === r.rows) return;
|
|
21502
22538
|
session._appliedResize = { cols: r.cols, rows: r.rows };
|
|
22539
|
+
try { session.vterm?.resize(r.cols, r.rows); } catch {}
|
|
21503
22540
|
try { session.pty.resize(r.cols, r.rows); } catch {}
|
|
21504
22541
|
};
|
|
21505
22542
|
if (!last) {
|