dual-brain 0.3.26 → 0.3.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/dual-brain.mjs +160 -30
  2. package/package.json +1 -1
@@ -56,7 +56,26 @@ function _codexResumeArgs(sessionId, cwd) {
56
56
  return ['--dangerously-bypass-approvals-and-sandbox', 'resume', sessionId];
57
57
  }
58
58
  const approvalMode = getEffectiveAutomode(loadProfile(workspace), workspace) ? 'never' : 'on-request';
59
- return ['--sandbox', 'workspace-write', '--ask-for-approval', approvalMode, 'resume', sessionId];
59
+ return [..._codexApprovalArgs(workspace, approvalMode), 'resume', sessionId];
60
+ }
61
+
62
+ function _isReplitWorkspace(cwd) {
63
+ const workspace = cwd || process.cwd();
64
+ return !!(
65
+ process.env.REPL_ID ||
66
+ process.env.REPL_SLUG ||
67
+ process.env.REPLIT_CLUSTER ||
68
+ existsSync(join(workspace, '.replit')) ||
69
+ existsSync(join(workspace, '.replit-tools'))
70
+ );
71
+ }
72
+
73
+ function _codexApprovalArgs(cwd, approvalMode = 'on-request') {
74
+ // Codex workspace-write depends on bubblewrap/user namespaces. Replit's
75
+ // container frequently blocks that, so use the Replit container boundary and
76
+ // Codex approvals instead of launching a HEAD that cannot run any command.
77
+ const sandboxMode = _isReplitWorkspace(cwd) ? 'danger-full-access' : 'workspace-write';
78
+ return ['--sandbox', sandboxMode, '--ask-for-approval', approvalMode];
60
79
  }
61
80
 
62
81
  function _sessionTool(session) {
@@ -2399,8 +2418,7 @@ function runtimeLaunchArgsForPending(session, cwd, pending) {
2399
2418
  if (tool === 'codex') {
2400
2419
  if (pending?.bypassPermissions) return ['--dangerously-bypass-approvals-and-sandbox', 'resume', session.id];
2401
2420
  return [
2402
- '--sandbox', 'workspace-write',
2403
- '--ask-for-approval', pending?.automode ? 'never' : 'on-request',
2421
+ ..._codexApprovalArgs(cwd, pending?.automode ? 'never' : 'on-request'),
2404
2422
  'resume', session.id,
2405
2423
  ];
2406
2424
  }
@@ -2446,29 +2464,25 @@ async function processPendingRuntimeSwitch(cwd) {
2446
2464
  clearPendingRuntimeSwitch(cwd);
2447
2465
 
2448
2466
  if (targetTool !== currentTool) {
2449
- writeHandoffConversationLease(cwd, sess, currentTool, targetTool, pending.handoffBrief || _sessionBrief(sess, targetTool));
2467
+ const brief = pending.handoffBrief || _sessionBrief(sess, targetTool);
2468
+ writeHandoffConversationLease(cwd, sess, currentTool, targetTool, brief);
2450
2469
  markSessionSuperseded(cwd, sess, targetTool, pending.reason || 'runtime-switch');
2451
- await cmdSwitch([targetTool, pending.handoffBrief || _sessionBrief(sess, targetTool)]);
2470
+ await launchSupervisedHandoff(sess, cwd, currentTool, targetTool, brief, pending);
2452
2471
  return true;
2453
2472
  }
2454
2473
 
2455
- const { spawnSync } = await import('node:child_process');
2456
2474
  const launchArgs = runtimeLaunchArgsForPending(sess, cwd, pending);
2457
2475
  process.stdout.write(` Launching: ${targetTool} ${launchArgs.join(' ')}\n\n`);
2458
2476
  writeActiveConversation(cwd, sess, targetTool);
2459
- const env = targetTool === 'codex' && (!process.env.TERM || process.env.TERM === 'dumb')
2460
- ? { ...process.env, TERM: 'xterm-256color' }
2461
- : process.env;
2462
2477
  try {
2463
- spawnSync(targetTool, launchArgs, { stdio: 'inherit', cwd, env });
2478
+ await launchSupervisedHead(targetTool, launchArgs, cwd, sess);
2464
2479
  } finally {
2465
- saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || targetTool);
2466
2480
  clearActiveConversation(cwd, sess.id);
2467
2481
  }
2468
2482
  return true;
2469
2483
  }
2470
2484
 
2471
- function writeActiveConversation(cwd, session, tool) {
2485
+ function writeActiveConversation(cwd, session, tool, extra = {}) {
2472
2486
  const dir = join(cwd, '.dualbrain');
2473
2487
  const terminalId = getTerminalId();
2474
2488
  const lease = {
@@ -2477,6 +2491,7 @@ function writeActiveConversation(cwd, session, tool) {
2477
2491
  provider: tool,
2478
2492
  terminalId,
2479
2493
  ownerPid: process.pid,
2494
+ childPid: extra.childPid || null,
2480
2495
  startedAt: new Date().toISOString(),
2481
2496
  lastHeartbeat: new Date().toISOString(),
2482
2497
  mode: 'active-head',
@@ -2486,6 +2501,18 @@ function writeActiveConversation(cwd, session, tool) {
2486
2501
  return lease;
2487
2502
  }
2488
2503
 
2504
+ function updateActiveConversation(cwd, sessionId, updates = {}) {
2505
+ try {
2506
+ const lease = JSON.parse(readFileSync(activeConversationPath(cwd), 'utf8'));
2507
+ if (sessionId && lease.sessionId !== sessionId) return;
2508
+ writeFileSync(activeConversationPath(cwd), JSON.stringify({
2509
+ ...lease,
2510
+ ...updates,
2511
+ lastHeartbeat: new Date().toISOString(),
2512
+ }, null, 2) + '\n');
2513
+ } catch {}
2514
+ }
2515
+
2489
2516
  function writeHandoffConversationLease(cwd, session, fromTool, targetTool, taskBrief = '') {
2490
2517
  const dir = join(cwd, '.dualbrain');
2491
2518
  const terminalId = getTerminalId();
@@ -2562,6 +2589,124 @@ function clearActiveConversation(cwd, sessionId) {
2562
2589
  } catch {}
2563
2590
  }
2564
2591
 
2592
+ function pendingSwitchMatchesSession(pending, session) {
2593
+ if (!pending || pending.status !== 'confirmed' || pending.autoLaunch === false) return false;
2594
+ if (!session?.id) return true;
2595
+ return pending.sessionId === session.id;
2596
+ }
2597
+
2598
+ async function launchSupervisedHead(tool, launchArgs, cwd, session) {
2599
+ const { spawn } = await import('node:child_process');
2600
+ const env = tool === 'codex' && (!process.env.TERM || process.env.TERM === 'dumb')
2601
+ ? { ...process.env, TERM: 'xterm-256color' }
2602
+ : process.env;
2603
+ let switching = false;
2604
+ let forceTimer = null;
2605
+
2606
+ return await new Promise((resolve) => {
2607
+ const child = spawn(tool, launchArgs, { stdio: 'inherit', cwd, env });
2608
+ updateActiveConversation(cwd, session?.id, { childPid: child.pid, provider: tool });
2609
+
2610
+ const stopChildForSwitch = () => {
2611
+ if (switching) return;
2612
+ switching = true;
2613
+ process.stdout.write('\n Switch confirmed — restarting HEAD with updated settings...\n');
2614
+ try { child.kill('SIGTERM'); } catch {}
2615
+ forceTimer = setTimeout(() => {
2616
+ try {
2617
+ if (!child.killed) child.kill('SIGKILL');
2618
+ } catch {}
2619
+ }, 2500);
2620
+ };
2621
+
2622
+ const watcher = setInterval(() => {
2623
+ const pending = readPendingRuntimeSwitch(cwd);
2624
+ if (pendingSwitchMatchesSession(pending, session)) stopChildForSwitch();
2625
+ }, 500);
2626
+
2627
+ child.on('exit', async (code, signal) => {
2628
+ clearInterval(watcher);
2629
+ if (forceTimer) clearTimeout(forceTimer);
2630
+ saveTerminalState(cwd, getTerminalId(), session?.id, session?.tool || tool);
2631
+
2632
+ if (switching) {
2633
+ const launched = await processPendingRuntimeSwitch(cwd);
2634
+ resolve({ switched: launched, code, signal });
2635
+ return;
2636
+ }
2637
+
2638
+ clearActiveConversation(cwd, session?.id);
2639
+ resolve({ switched: false, code, signal });
2640
+ });
2641
+
2642
+ child.on('error', (err) => {
2643
+ clearInterval(watcher);
2644
+ if (forceTimer) clearTimeout(forceTimer);
2645
+ process.stderr.write(`\n Could not launch ${tool}: ${err.message}\n`);
2646
+ clearActiveConversation(cwd, session?.id);
2647
+ resolve({ switched: false, error: err });
2648
+ });
2649
+ });
2650
+ }
2651
+
2652
+ async function launchSupervisedHandoff(session, cwd, currentTool, targetTool, brief, pending = {}) {
2653
+ let autoHandoff;
2654
+ try {
2655
+ autoHandoff = await import('../dist/src/auto-handoff.js');
2656
+ } catch (e) {
2657
+ process.stderr.write(`\n Could not load auto-handoff module: ${e.message}\n`);
2658
+ return { switched: false };
2659
+ }
2660
+
2661
+ const fromProvider = currentTool === 'codex' ? 'openai' : 'anthropic';
2662
+ const result = autoHandoff.executeHandoff({
2663
+ fromProvider,
2664
+ cwd,
2665
+ auto: !!pending.automode,
2666
+ force: true,
2667
+ taskBrief: brief,
2668
+ });
2669
+ if (!result.success || !result.contextFile) {
2670
+ process.stderr.write(`\n ${result.message || 'Handoff failed.'}\n`);
2671
+ return { switched: false };
2672
+ }
2673
+
2674
+ let prompt = '';
2675
+ try {
2676
+ const data = JSON.parse(readFileSync(result.contextFile, 'utf8'));
2677
+ prompt = data?.prompt || '';
2678
+ } catch {}
2679
+ if (!prompt) {
2680
+ process.stderr.write('\n Handoff context was created, but no prompt was available to launch the target provider.\n');
2681
+ return { switched: false };
2682
+ }
2683
+
2684
+ let launchArgs;
2685
+ if (targetTool === 'codex') {
2686
+ launchArgs = pending?.bypassPermissions
2687
+ ? ['--dangerously-bypass-approvals-and-sandbox', prompt.slice(0, 4000)]
2688
+ : [
2689
+ ..._codexApprovalArgs(cwd, pending?.automode ? 'never' : 'on-request'),
2690
+ prompt.slice(0, 4000),
2691
+ ];
2692
+ } else {
2693
+ const permissionArgs = pending?.bypassPermissions
2694
+ ? ['--dangerously-skip-permissions']
2695
+ : pending?.automode
2696
+ ? ['--permission-mode', 'auto']
2697
+ : [];
2698
+ launchArgs = [...permissionArgs, prompt.slice(0, 4000)];
2699
+ }
2700
+
2701
+ process.stdout.write(` Launching: ${targetTool} ${launchArgs.slice(0, -1).join(' ')} [handoff context]\n\n`);
2702
+ writeActiveConversation(cwd, session, targetTool);
2703
+ try {
2704
+ return await launchSupervisedHead(targetTool, launchArgs, cwd, { ...session, tool: targetTool });
2705
+ } finally {
2706
+ clearActiveConversation(cwd, session?.id);
2707
+ }
2708
+ }
2709
+
2565
2710
  async function confirmConversationTakeover(sess, cwd, ask) {
2566
2711
  const active = readActiveConversation(cwd);
2567
2712
  if (!active || active.sessionId !== sess.id || active.ownerPid === process.pid) return 'launch';
@@ -2602,9 +2747,8 @@ async function launchSessionWithLease(sess, cwd, ask = null) {
2602
2747
  writeActiveConversation(cwd, sess, tool);
2603
2748
  process.stdout.write(`\n Launching: ${tool} ${launchArgs.join(' ')}\n\n`);
2604
2749
  try {
2605
- spawnSync(tool, launchArgs, { stdio: 'inherit' });
2750
+ await launchSupervisedHead(tool, launchArgs, cwd, sess);
2606
2751
  } finally {
2607
- saveTerminalState(cwd, getTerminalId(), sess.id, sess.tool || tool);
2608
2752
  clearActiveConversation(cwd, sess.id);
2609
2753
  }
2610
2754
  return { next: 'main' };
@@ -6232,16 +6376,7 @@ async function sessionDetailScreen(rl, ask, ctx = {}) {
6232
6376
  const choice = (await ask(' Choice: ')).trim().toLowerCase();
6233
6377
 
6234
6378
  if (choice === 'c' || choice === 'r') {
6235
- const tool = _sessionTool(sess);
6236
- const launchArgs = _sessionLaunchArgs(sess, cwd);
6237
- console.log(`\n Launching: ${tool} ${launchArgs.join(' ')}\n`);
6238
- try {
6239
- const { spawnSync } = await import('node:child_process');
6240
- spawnSync(tool, launchArgs, { stdio: 'inherit' });
6241
- } catch {
6242
- console.log(` Could not launch ${tool} CLI.`);
6243
- }
6244
- return { next: 'dashboard' };
6379
+ return await launchSessionWithLease(sess, cwd, ask);
6245
6380
  }
6246
6381
 
6247
6382
  if (choice === 'g') {
@@ -6542,12 +6677,7 @@ async function sessionManageScreen(rl, ask, ctx = {}) {
6542
6677
  }
6543
6678
 
6544
6679
  if (choice === 'o') {
6545
- const { spawnSync } = await import('node:child_process');
6546
- const tool = _sessionTool(sess);
6547
- const launchArgs = _sessionLaunchArgs(sess, cwd);
6548
- console.log(`\n Launching: ${tool} ${launchArgs.join(' ')}\n`);
6549
- spawnSync(tool, launchArgs, { stdio: 'inherit' });
6550
- return { next: 'sessions' };
6680
+ return await launchSessionWithLease(sess, cwd, ask);
6551
6681
  }
6552
6682
 
6553
6683
  if (choice === 'g') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.3.26",
3
+ "version": "0.3.27",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {