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