dual-brain 0.3.32 → 0.3.34

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 +161 -31
  2. package/package.json +1 -1
@@ -1462,21 +1462,16 @@ async function cmdRuntimeSwitch(args = []) {
1462
1462
  const cwd = process.cwd();
1463
1463
  let sessions = [];
1464
1464
  try { sessions = enrichSessions(importReplitSessions(cwd), cwd); } catch {}
1465
- const active = readActiveConversation(cwd);
1466
- const sess = active
1467
- ? (sessions.find(s => s.id === active.sessionId) || {
1468
- id: active.sessionId,
1469
- tool: active.provider,
1470
- smartName: active.sessionName || active.conversationId || active.sessionId,
1471
- })
1472
- : sessions[0];
1465
+ const resolved = resolveCurrentConversationSession(cwd, sessions);
1466
+ const active = resolved.active;
1467
+ const sess = resolved.session;
1473
1468
  if (!sess) {
1474
- console.log('No resumable session found.');
1469
+ console.log('No current logical conversation found. Resume the intended chat through dual-brain first.');
1475
1470
  return;
1476
1471
  }
1477
1472
 
1478
1473
  const profile = loadProfile(cwd);
1479
- const settingsTerminalId = active?.terminalId || getTerminalId();
1474
+ const settingsTerminalId = resolved.terminalId || getTerminalId();
1480
1475
  const settings = loadSessionSettings(cwd, settingsTerminalId);
1481
1476
  let provider = _sessionTool(sess);
1482
1477
  let reason = 'head-runtime-switch';
@@ -1522,6 +1517,11 @@ async function cmdRuntimeSwitch(args = []) {
1522
1517
  settings.headModel = existingPending.model;
1523
1518
  if (!explicitEffort && existingPending.effort) settings.effort = existingPending.effort;
1524
1519
  }
1520
+ const runningHead = inferRunningHeadConfig(cwd, sess.id, provider);
1521
+ if (!explicitModel && !explicitLevel && _modelMatchesProvider(runningHead.model, provider)) {
1522
+ settings.headModel = runningHead.model;
1523
+ if (!explicitEffort && runningHead.effort) settings.effort = runningHead.effort;
1524
+ }
1525
1525
 
1526
1526
  const headPolicy = _headPolicyFor(provider, profile, settings);
1527
1527
  if (!settings.headModel || !_modelMatchesProvider(settings.headModel, provider)) {
@@ -1549,7 +1549,7 @@ async function cmdRuntimeSwitch(args = []) {
1549
1549
  });
1550
1550
 
1551
1551
  const launchArgs = provider === _sessionTool(sess)
1552
- ? _sessionLaunchArgs(sess, cwd)
1552
+ ? runtimeLaunchArgsForPending(sess, cwd, pending)
1553
1553
  : ['handoff', '--to', provider];
1554
1554
 
1555
1555
  console.log('');
@@ -1564,6 +1564,7 @@ async function cmdRuntimeSwitch(args = []) {
1564
1564
  const activeForSwitch = readActiveConversation(cwd);
1565
1565
  if (activeForSwitch?.sessionId === sess.id) {
1566
1566
  console.log('Active supervisor detected: HEAD will restart with these settings.');
1567
+ signalActiveConversation(cwd, sess.id);
1567
1568
  } else if (!args.includes('--apply')) {
1568
1569
  console.log('No active supervisor detected: saved for the next dual-brain resume/switch, not live yet.');
1569
1570
  }
@@ -1597,16 +1598,10 @@ async function cmdSwitchover(args = []) {
1597
1598
  const cwd = process.cwd();
1598
1599
  let sessions = [];
1599
1600
  try { sessions = enrichSessions(importReplitSessions(cwd), cwd); } catch {}
1600
- const active = readActiveConversation(cwd);
1601
- const sess = active
1602
- ? (sessions.find(s => s.id === active.sessionId) || {
1603
- id: active.sessionId,
1604
- tool: active.provider,
1605
- smartName: active.sessionName || active.conversationId || active.sessionId,
1606
- })
1607
- : sessions[0];
1601
+ const resolved = resolveCurrentConversationSession(cwd, sessions);
1602
+ const sess = resolved.session;
1608
1603
  if (!sess) {
1609
- console.log('No resumable session found.');
1604
+ console.log('No current logical conversation found. Resume the intended chat through dual-brain first.');
1610
1605
  return;
1611
1606
  }
1612
1607
 
@@ -2427,6 +2422,87 @@ function loadTerminalState(cwd, terminalId) {
2427
2422
  } catch { return null; }
2428
2423
  }
2429
2424
 
2425
+ function loadLatestTerminalState(cwd) {
2426
+ try {
2427
+ const dir = join(cwd, '.dualbrain');
2428
+ const files = readdirSync(dir)
2429
+ .filter(f => /^terminal-.*\.json$/.test(f))
2430
+ .map(f => {
2431
+ const full = join(dir, f);
2432
+ const state = JSON.parse(readFileSync(full, 'utf8'));
2433
+ return { state, mtime: statSync(full).mtimeMs };
2434
+ })
2435
+ .filter(x => x.state?.sessionId)
2436
+ .sort((a, b) => (b.state.timestamp || b.mtime || 0) - (a.state.timestamp || a.mtime || 0));
2437
+ return files[0]?.state || null;
2438
+ } catch { return null; }
2439
+ }
2440
+
2441
+ function sessionFromState(state, sessions = []) {
2442
+ if (!state?.sessionId) return null;
2443
+ return sessions.find(s => s.id === state.sessionId) || {
2444
+ id: state.sessionId,
2445
+ tool: state.tool || state.provider || 'claude',
2446
+ smartName: state.sessionName || state.conversationId || state.sessionId,
2447
+ };
2448
+ }
2449
+
2450
+ function resolveCurrentConversationSession(cwd, sessions = [], { allowLatestTerminal = true } = {}) {
2451
+ const terminalId = getTerminalId();
2452
+ const active = readActiveConversation(cwd);
2453
+ if (active?.terminalId === terminalId) {
2454
+ return {
2455
+ session: sessionFromState({
2456
+ sessionId: active.sessionId,
2457
+ tool: active.provider,
2458
+ sessionName: active.sessionName,
2459
+ conversationId: active.conversationId,
2460
+ }, sessions),
2461
+ terminalId,
2462
+ source: 'active-terminal',
2463
+ active,
2464
+ };
2465
+ }
2466
+
2467
+ const terminalState = loadTerminalState(cwd, terminalId);
2468
+ if (terminalState?.sessionId) {
2469
+ return {
2470
+ session: sessionFromState(terminalState, sessions),
2471
+ terminalId,
2472
+ source: 'terminal-state',
2473
+ active: null,
2474
+ };
2475
+ }
2476
+
2477
+ if (allowLatestTerminal) {
2478
+ const latestState = loadLatestTerminalState(cwd);
2479
+ if (latestState?.sessionId) {
2480
+ return {
2481
+ session: sessionFromState(latestState, sessions),
2482
+ terminalId: latestState.terminalId || null,
2483
+ source: 'latest-terminal-state',
2484
+ active: null,
2485
+ };
2486
+ }
2487
+ }
2488
+
2489
+ if (active?.sessionId) {
2490
+ return {
2491
+ session: sessionFromState({
2492
+ sessionId: active.sessionId,
2493
+ tool: active.provider,
2494
+ sessionName: active.sessionName,
2495
+ conversationId: active.conversationId,
2496
+ }, sessions),
2497
+ terminalId: active.terminalId || null,
2498
+ source: 'active-other-terminal',
2499
+ active,
2500
+ };
2501
+ }
2502
+
2503
+ return { session: null, terminalId, source: 'none', active: null };
2504
+ }
2505
+
2430
2506
  function sessionSettingsPath(cwd, terminalId = getTerminalId()) {
2431
2507
  return join(cwd, '.dualbrain', `session-settings-${terminalId}.json`);
2432
2508
  }
@@ -2596,15 +2672,7 @@ function parseProviderSwitchCommand(input) {
2596
2672
  }
2597
2673
 
2598
2674
  function getActionSession(cwd, recentSessions = []) {
2599
- const active = readActiveConversation(cwd);
2600
- if (active) {
2601
- return recentSessions.find(s => s.id === active.sessionId) || {
2602
- id: active.sessionId,
2603
- tool: active.provider,
2604
- smartName: active.sessionName || active.conversationId || active.sessionId,
2605
- };
2606
- }
2607
- return recentSessions[0] || null;
2675
+ return resolveCurrentConversationSession(cwd, recentSessions).session;
2608
2676
  }
2609
2677
 
2610
2678
  async function switchProviderNow(cwd, session, target = null) {
@@ -2780,13 +2848,46 @@ function readActiveConversation(cwd) {
2780
2848
  } catch { return null; }
2781
2849
  }
2782
2850
 
2851
+ function inferRunningHeadConfig(cwd, sessionId, provider) {
2852
+ const active = readActiveConversation(cwd);
2853
+ if (!active?.childPid || (sessionId && active.sessionId !== sessionId)) return {};
2854
+ const fallback = {
2855
+ provider: active.provider || provider,
2856
+ model: _modelMatchesProvider(active.model, provider) ? active.model : null,
2857
+ effort: active.effort || null,
2858
+ };
2859
+ try {
2860
+ const args = execSync(`ps -o args= -p ${Number(active.childPid)}`, {
2861
+ cwd,
2862
+ encoding: 'utf8',
2863
+ timeout: 1000,
2864
+ stdio: ['pipe', 'pipe', 'pipe'],
2865
+ }).trim();
2866
+ if (!args) return {};
2867
+ const parts = args.match(/(?:[^\s"]+|"[^"]*")+/g)?.map(s => s.replace(/^"|"$/g, '')) || [];
2868
+ const modelIdx = parts.indexOf('--model');
2869
+ const effortIdx = parts.indexOf('--effort');
2870
+ const model = modelIdx !== -1 ? parts[modelIdx + 1] : null;
2871
+ const effort = effortIdx !== -1 ? parts[effortIdx + 1] : null;
2872
+ return {
2873
+ provider: active.provider || provider,
2874
+ model: _modelMatchesProvider(model, provider) ? model : fallback.model,
2875
+ effort: effort || fallback.effort || null,
2876
+ };
2877
+ } catch { return fallback; }
2878
+ }
2879
+
2783
2880
  async function processPendingRuntimeSwitch(cwd) {
2784
2881
  const pending = readPendingRuntimeSwitch(cwd);
2785
2882
  if (!pending || pending.status !== 'confirmed' || pending.autoLaunch === false) return false;
2786
2883
 
2787
2884
  let sessions = [];
2788
2885
  try { sessions = enrichSessions(importReplitSessions(cwd), cwd); } catch {}
2789
- const sess = sessions.find(s => s.id === pending.sessionId) || sessions[0];
2886
+ const sess = sessions.find(s => s.id === pending.sessionId) || {
2887
+ id: pending.sessionId,
2888
+ tool: pending.fromProvider || pending.provider || 'claude',
2889
+ smartName: pending.sessionName || pending.sessionId,
2890
+ };
2790
2891
  if (!sess) {
2791
2892
  clearPendingRuntimeSwitch(cwd);
2792
2893
  return false;
@@ -2810,7 +2911,12 @@ async function processPendingRuntimeSwitch(cwd) {
2810
2911
 
2811
2912
  const launchArgs = runtimeLaunchArgsForPending(sess, cwd, pending);
2812
2913
  process.stdout.write(` Launching: ${targetTool} ${launchArgs.join(' ')}\n\n`);
2813
- writeActiveConversation(cwd, sess, targetTool);
2914
+ writeActiveConversation(cwd, sess, targetTool, {
2915
+ model: pending.model || null,
2916
+ effort: pending.effort || null,
2917
+ automode: pending.automode === true,
2918
+ bypassPermissions: pending.bypassPermissions === true,
2919
+ });
2814
2920
  try {
2815
2921
  await launchSupervisedHead(targetTool, launchArgs, cwd, sess);
2816
2922
  } finally {
@@ -2830,6 +2936,10 @@ function writeActiveConversation(cwd, session, tool, extra = {}) {
2830
2936
  terminalId,
2831
2937
  ownerPid: process.pid,
2832
2938
  childPid: extra.childPid || null,
2939
+ model: extra.model || null,
2940
+ effort: extra.effort || null,
2941
+ automode: extra.automode === true,
2942
+ bypassPermissions: extra.bypassPermissions === true,
2833
2943
  startedAt: new Date().toISOString(),
2834
2944
  lastHeartbeat: new Date().toISOString(),
2835
2945
  mode: 'active-head',
@@ -2851,6 +2961,18 @@ function updateActiveConversation(cwd, sessionId, updates = {}) {
2851
2961
  } catch {}
2852
2962
  }
2853
2963
 
2964
+ function signalActiveConversation(cwd, sessionId) {
2965
+ try {
2966
+ const lease = JSON.parse(readFileSync(activeConversationPath(cwd), 'utf8'));
2967
+ if (sessionId && lease.sessionId !== sessionId) return false;
2968
+ if (!pidAlive(lease.ownerPid)) return false;
2969
+ process.kill(lease.ownerPid, 'SIGUSR1');
2970
+ return true;
2971
+ } catch {
2972
+ return false;
2973
+ }
2974
+ }
2975
+
2854
2976
  function writeHandoffConversationLease(cwd, session, fromTool, targetTool, taskBrief = '') {
2855
2977
  const dir = join(cwd, '.dualbrain');
2856
2978
  const terminalId = getTerminalId();
@@ -2957,6 +3079,12 @@ async function launchSupervisedHead(tool, launchArgs, cwd, session) {
2957
3079
  }, 2500);
2958
3080
  };
2959
3081
 
3082
+ const onSignalSwitch = () => {
3083
+ const pending = readPendingRuntimeSwitch(cwd);
3084
+ if (pendingSwitchMatchesSession(pending, session)) stopChildForSwitch();
3085
+ };
3086
+ process.on('SIGUSR1', onSignalSwitch);
3087
+
2960
3088
  const watcher = setInterval(() => {
2961
3089
  const pending = readPendingRuntimeSwitch(cwd);
2962
3090
  if (pendingSwitchMatchesSession(pending, session)) stopChildForSwitch();
@@ -2964,6 +3092,7 @@ async function launchSupervisedHead(tool, launchArgs, cwd, session) {
2964
3092
 
2965
3093
  child.on('exit', async (code, signal) => {
2966
3094
  clearInterval(watcher);
3095
+ process.off('SIGUSR1', onSignalSwitch);
2967
3096
  if (forceTimer) clearTimeout(forceTimer);
2968
3097
  saveTerminalState(cwd, getTerminalId(), session?.id, session?.tool || tool);
2969
3098
 
@@ -2979,6 +3108,7 @@ async function launchSupervisedHead(tool, launchArgs, cwd, session) {
2979
3108
 
2980
3109
  child.on('error', (err) => {
2981
3110
  clearInterval(watcher);
3111
+ process.off('SIGUSR1', onSignalSwitch);
2982
3112
  if (forceTimer) clearTimeout(forceTimer);
2983
3113
  process.stderr.write(`\n Could not launch ${tool}: ${err.message}\n`);
2984
3114
  clearActiveConversation(cwd, session?.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.3.32",
3
+ "version": "0.3.34",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {