dual-brain 0.2.18 → 0.2.20

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.
@@ -1151,7 +1151,10 @@ async function installGlobal() {
1151
1151
 
1152
1152
  // Resolve absolute path to hooks directory via import.meta.url
1153
1153
  const pkgRoot = join(__dirname, '..');
1154
- const hooksDir = join(pkgRoot, '.claude', 'hooks');
1154
+ // Hooks live at hooks/ in the published package, .claude/hooks/ in dev
1155
+ const hooksDir = existsSync(join(pkgRoot, 'hooks', 'head-guard.mjs'))
1156
+ ? join(pkgRoot, 'hooks')
1157
+ : join(pkgRoot, '.claude', 'hooks');
1155
1158
 
1156
1159
  // Warn if running from npx (ephemeral path)
1157
1160
  if (pkgRoot.includes('.npm/_npx') || pkgRoot.includes('npx-')) {
@@ -1178,9 +1181,9 @@ async function installGlobal() {
1178
1181
  })();
1179
1182
 
1180
1183
  if (hasProjectLocalHooks) {
1181
- console.log(' hooks already configured project-locally, skipping global hooks');
1182
- console.log(' (project .claude/settings.local.json already contains dual-brain hooks)');
1183
- } else {
1184
+ console.log(' project-local hooks detected (will take precedence in this workspace)');
1185
+ }
1186
+ {
1184
1187
  // Load existing settings (merge, never clobber)
1185
1188
  let existing = {};
1186
1189
  if (existsSync(globalSettingsPath)) {
@@ -4479,7 +4482,7 @@ async function askDefaultShell(cwd, rl, fx) {
4479
4482
  ` ${DIM}modifies${RST} ${YLW}.replit onBoot${RST}`,
4480
4483
  ` ${DIM}undo${RST} Settings → System → Startup`,
4481
4484
  '',
4482
- ` ${CYAN}[Y]${RST} Start on boot ${DIM}[n] Run manually${RST}`,
4485
+ ` ${CYAN}[Enter]${RST} Start on boot ${DIM}[n] Run manually${RST}`,
4483
4486
  ];
4484
4487
  process.stdout.write('\n' + panel('dual-brain setup', setupContent) + '\n');
4485
4488
 
@@ -31,16 +31,12 @@ try {
31
31
  const raw = readFileSync('/dev/stdin', 'utf8');
32
32
  input = JSON.parse(raw);
33
33
  } catch {
34
- // Can't parse input — fail closed to avoid guard bypass.
35
- const output = {
36
- hookSpecificOutput: {
37
- hookEventName: 'PreToolUse',
38
- permissionDecision: 'deny',
39
- permissionDecisionReason: '[dual-brain] head-guard could not parse hook input — blocking as a safety measure.',
40
- },
41
- };
42
- process.stdout.write(JSON.stringify(output));
43
- process.exit(2);
34
+ // Can't parse input — fail open. This hook's purpose is to block HEAD from
35
+ // implementing directly. If we can't parse stdin (e.g. subagent context where
36
+ // Claude Code doesn't pipe parseable JSON), blocking would incorrectly deny
37
+ // work agents. Allowing is safer: worst case HEAD slips through once, but
38
+ // work agents aren't blocked.
39
+ process.exit(0);
44
40
  }
45
41
 
46
42
  const toolName = input.tool_name || '';
package/install.mjs CHANGED
@@ -15,6 +15,8 @@ import { dirname, join, resolve } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { spawnSync } from 'child_process';
17
17
  import { createHash } from 'crypto';
18
+ import { spinner, success as fxSuccess, warn as fxWarn, error as fxError, info as fxInfo, banner, celebrate, colors, sleep, nl, getMode } from './src/fx.mjs';
19
+ import { panel, signalLine, headerBar } from './src/tui.mjs';
18
20
 
19
21
  // Skip hook installation during global npm install — hooks are installed
20
22
  // when the user runs 'dual-brain install' in their project directory.
@@ -103,14 +105,14 @@ if (subcommand && !SUBCOMMANDS.includes(subcommand)) {
103
105
  process.exit(1);
104
106
  }
105
107
 
106
- // ─── Box Drawing ────────────────────────────────────────────────────────────
108
+ // ─── Box Drawing (legacy compat — prefer panel() from tui.mjs) ─────────────
107
109
 
108
110
  const W = 54;
109
- const pad = (s, len = W - 2) => {
111
+ const pad_legacy = (s, len = W - 2) => {
110
112
  s = String(s);
111
113
  return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
112
114
  };
113
- const ln = (s) => `║ ${pad(s)} ║`;
115
+ const ln = (s) => `║ ${pad_legacy(s)} ║`;
114
116
  const br = (l, r) => l + '═'.repeat(W) + r;
115
117
  const sep = () => '╠' + '═'.repeat(W) + '╣';
116
118
 
@@ -421,10 +423,8 @@ async function authGuidance(env) {
421
423
  if (env.claude.authed && env.codex.authed) return env;
422
424
  if (!process.stdin.isTTY || !process.stdout.isTTY) return env;
423
425
 
424
- console.log('');
425
- console.log(' ┌────────────────────────────────────────────┐');
426
- console.log(' │ 🔑 Auth Setup │');
427
- console.log(' └────────────────────────────────────────────┘');
426
+ nl();
427
+ console.log(panel('Auth Setup', ['Checking provider authentication...'], { width: 50 }));
428
428
 
429
429
  if (!env.claude.authed) {
430
430
  console.log('');
@@ -654,27 +654,24 @@ function getAuthState() {
654
654
  }
655
655
 
656
656
  function printAuthStatusBox(state) {
657
- const c = state.claude;
657
+ const cl = state.claude;
658
658
  const x = state.codex;
659
- const cIcon = c.authed ? '✅' : c.installed ? '⚠️' : '❌';
660
- const xIcon = x.authed ? '✅' : x.installed ? '⚠️' : '❌';
661
659
 
662
- console.log('');
663
- console.log(` ${br('', '')}`);
664
- console.log(` ${ln(`🔑 Auth Status`)}`);
665
- console.log(` ${sep()}`);
666
- console.log(` ${ln(`🟠 Claude ${cIcon} ${c.authed ? 'authenticated' : c.installed ? 'not authenticated' : 'not installed'}`)}`);
667
- console.log(` ${ln(` Method: ${c.method}`)}`);
668
- console.log(` ${ln(` Expiry: ${c.expiryText}`)}`);
669
- console.log(` ${ln(` Storage: ${c.storageText}`)}`);
670
- console.log(` ${sep()}`);
671
- console.log(` ${ln(`🟢 Codex ${xIcon} ${x.authed ? 'authenticated' : x.installed ? 'not authenticated' : 'not installed'}`)}`);
672
- console.log(` ${ln(` Method: ${x.method}`)}`);
673
- console.log(` ${ln(` Expiry: ${x.expiryText}`)}`);
674
- console.log(` ${ln(` Storage: ${x.storageText}`)}`);
675
- if (x.lastRefresh) console.log(` ${ln(` Refreshed:${x.lastRefreshText}`)}`);
676
- console.log(` ${br('╚', '╝')}`);
677
- console.log('');
660
+ const lines = [];
661
+ lines.push(signalLine(cl.authed ? 'success' : 'warning', `Claude ${cl.authed ? 'authenticated' : cl.installed ? 'not authenticated' : 'not installed'}`));
662
+ lines.push(` Method: ${cl.method}`);
663
+ lines.push(` Expiry: ${cl.expiryText}`);
664
+ lines.push(` Storage: ${cl.storageText}`);
665
+ lines.push('');
666
+ lines.push(signalLine(x.authed ? 'success' : 'warning', `Codex ${x.authed ? 'authenticated' : x.installed ? 'not authenticated' : 'not installed'}`));
667
+ lines.push(` Method: ${x.method}`);
668
+ lines.push(` Expiry: ${x.expiryText}`);
669
+ lines.push(` Storage: ${x.storageText}`);
670
+ if (x.lastRefresh) lines.push(` Refreshed:${x.lastRefreshText}`);
671
+
672
+ nl();
673
+ console.log(panel('Auth Status', lines, { width: 60 }));
674
+ nl();
678
675
  }
679
676
 
680
677
  function runCodexDeviceAuth(codexPath) {
@@ -913,9 +910,9 @@ function install(workspace, env, mode) {
913
910
  'gpt-work-dispatcher.mjs', 'profiles.mjs',
914
911
  'summary-checkpoint.mjs', 'decision-ledger.mjs', 'control-panel.mjs',
915
912
  'risk-classifier.mjs', 'failure-detector.mjs',
916
- 'vibe-router.mjs', 'plan-generator.mjs', 'vibe-memory.mjs',
913
+ 'plan-generator.mjs', 'vibe-memory.mjs',
917
914
  'wave-orchestrator.mjs',
918
- 'task-classifier.mjs', 'model-registry.mjs',
915
+ 'model-registry.mjs',
919
916
  'auto-update-wrapper.mjs',
920
917
  'head-guard.mjs',
921
918
  ];
@@ -981,36 +978,38 @@ function install(workspace, env, mode) {
981
978
 
982
979
  // ─── Status Report ──────────────────────────────────────────────────────────
983
980
 
984
- function printReport(env, mode, actions, isDryRun) {
985
- const lines = [];
981
+ function printReport(env, mode, actions, isDryRun, { skipBanner = false } = {}) {
982
+ const m = getMode();
983
+ nl();
986
984
 
987
- lines.push(br('╔', '╗'));
988
- lines.push(ln(`🧠 Data Tools — Dual Brain v${VERSION}`));
989
- lines.push(sep());
985
+ // Gradient banner (skip if already shown in animated detection)
986
+ if (!skipBanner) banner(`v${VERSION}`);
990
987
 
991
- const cAuth = env.claude.authed ? '✅' : env.claude.installed ? '⚠️' : '❌';
992
- const xAuth = env.codex.authed ? '' : env.codex.installed ? '⚠️' : '';
993
- lines.push(ln(` 🟠 Claude ${cAuth} 🟢 Codex ${xAuth}`));
988
+ // Provider status
989
+ const cAuth = env.claude.authed ? 'authenticated' : env.claude.installed ? 'installed · not authed' : 'not installed';
990
+ const xAuth = env.codex.authed ? 'authenticated' : env.codex.installed ? 'installed · not authed' : 'not installed';
991
+ const cHealthy = env.claude.authed;
992
+ const xHealthy = env.codex.authed;
994
993
 
994
+ const statusLines = [];
995
+ statusLines.push(signalLine(cHealthy ? 'success' : 'warning', `Claude ${cAuth}`));
996
+ statusLines.push(signalLine(xHealthy ? 'success' : 'warning', `Codex ${xAuth}`));
995
997
  if (env.isReplit) {
996
- lines.push(ln(` 🌀 Replit${env.hasReplitTools ? ' + replit-tools' : ''}`));
998
+ statusLines.push(signalLine('info', `Replit${env.hasReplitTools ? ' + replit-tools' : ''}`));
997
999
  }
998
1000
 
999
1001
  if (actions) {
1000
- lines.push(sep());
1001
- for (const a of actions) lines.push(ln(` ${a}`));
1002
- lines.push(sep());
1003
- lines.push(ln(' Installed — launching session manager...'));
1002
+ statusLines.push('');
1003
+ for (const a of actions) statusLines.push(` ${a}`);
1004
+ statusLines.push('');
1005
+ statusLines.push(signalLine('success', 'Installed — launching session manager...'));
1004
1006
  } else if (isDryRun) {
1005
- lines.push(sep());
1006
- lines.push(ln('Dry run — no files written'));
1007
+ statusLines.push('');
1008
+ statusLines.push(signalLine('info', 'Dry run — no files written'));
1007
1009
  }
1008
1010
 
1009
- lines.push(br('', '╝'));
1010
-
1011
- console.log('');
1012
- for (const l of lines) console.log(` ${l}`);
1013
- console.log('');
1011
+ console.log(panel('dual-brain status', statusLines, { width: 64 }));
1012
+ nl();
1014
1013
  }
1015
1014
 
1016
1015
  // ─── Profile System ────────────────────────────────────────────────────────
@@ -1223,17 +1222,19 @@ function cmdMode() {
1223
1222
  const current = loadProfile(workspace);
1224
1223
  const PEMOJIS = { auto: '🤖', balanced: '⚖️ ', 'cost-saver': '🛡️', 'quality-first': '🚀' };
1225
1224
  const UI_NAMES = { auto: 'Auto (default)', balanced: 'Balanced', 'cost-saver': 'Conservative', 'quality-first': 'Aggressive' };
1226
- console.log('');
1227
- console.log(' 🎛️ Routing modes:');
1228
- console.log('');
1225
+ nl();
1226
+ console.log(` ${colors.bold}${colors.cyan}Routing modes:${colors.reset}`);
1227
+ nl();
1229
1228
  for (const [name, p] of Object.entries(PROFILES)) {
1230
- const active = name === current.name ? ' ✅ active' : '';
1229
+ const isActive = name === current.name;
1231
1230
  const label = UI_NAMES[name] || name;
1232
- console.log(` ${PEMOJIS[name] || ' '} ${label.padEnd(15)} ${p.description}${active}`);
1231
+ const activeMarker = isActive ? ` ${colors.green} active${colors.reset}` : '';
1232
+ const style = isActive ? `${colors.cyan}${colors.bold}` : colors.dim;
1233
+ console.log(` ${PEMOJIS[name] || ' '} ${style}${label.padEnd(15)}${colors.reset} ${p.description}${activeMarker}`);
1233
1234
  }
1234
- console.log('');
1235
+ nl();
1235
1236
  console.log(` Switch: ${cmd('npx dual-brain mode <name>')}`);
1236
- console.log('');
1237
+ nl();
1237
1238
  return;
1238
1239
  }
1239
1240
 
@@ -1434,7 +1435,85 @@ async function main() {
1434
1435
  if (subcommand === 'budget') { cmdBudget(); return; }
1435
1436
  if (subcommand === 'explain') { cmdExplain(); return; }
1436
1437
 
1437
- let env = detectEnvironment();
1438
+ const mode_fx = getMode();
1439
+ const animate = mode_fx === 'full' || mode_fx === 'subtle';
1440
+
1441
+ // Animated detection phase
1442
+ nl();
1443
+ banner(`v${VERSION}`);
1444
+
1445
+ let env;
1446
+ if (animate) {
1447
+ const sp1 = spinner('Detecting environment...').start();
1448
+ await sleep(300);
1449
+ env = detectEnvironment();
1450
+
1451
+ // Workspace detection
1452
+ if (env.isReplit) {
1453
+ sp1.succeed(`Replit workspace detected${env.hasReplitTools ? ' + replit-tools' : ''}`);
1454
+ } else {
1455
+ sp1.succeed('Local workspace detected');
1456
+ }
1457
+
1458
+ // Node version
1459
+ const sp2 = spinner('Checking Node.js...').start();
1460
+ await sleep(200);
1461
+ sp2.succeed(`Node ${process.version} found`);
1462
+
1463
+ // Git repo
1464
+ const sp3 = spinner('Checking git...').start();
1465
+ await sleep(200);
1466
+ const gitResult = run('git', ['rev-parse', '--show-toplevel']);
1467
+ const gitBranch = run('git', ['branch', '--show-current']);
1468
+ if (gitResult.status === 0) {
1469
+ const repoName = gitResult.stdout.trim().split('/').pop();
1470
+ const branch = gitBranch.stdout?.trim() || 'unknown';
1471
+ sp3.succeed(`Git repository: ${repoName} (${branch})`);
1472
+ } else {
1473
+ sp3.warn('Not a git repository');
1474
+ }
1475
+
1476
+ // Claude CLI
1477
+ const sp4 = spinner('Checking Claude CLI...').start();
1478
+ await sleep(300);
1479
+ if (env.claude.installed) {
1480
+ const authLabel = env.claude.authed ? 'CLI OAuth' : 'not authenticated';
1481
+ sp4.succeed(`Claude CLI found · ${authLabel}`);
1482
+ } else {
1483
+ sp4.warn('Claude CLI not found');
1484
+ }
1485
+
1486
+ // Codex CLI
1487
+ const sp5 = spinner('Checking Codex CLI...').start();
1488
+ await sleep(300);
1489
+ if (env.codex.installed) {
1490
+ const authLabel = env.codex.authed ? 'authenticated' : 'not authenticated';
1491
+ sp5.succeed(`OpenAI Codex CLI found · ${authLabel}`);
1492
+ } else {
1493
+ sp5.warn('Codex CLI not found');
1494
+ }
1495
+
1496
+ // Sessions
1497
+ const sp6 = spinner('Checking sessions...').start();
1498
+ await sleep(200);
1499
+ const sessionsDir = resolve(process.cwd(), '.replit-tools', '.claude-persistent');
1500
+ if (existsSync(sessionsDir)) {
1501
+ sp6.succeed('Session persistence via replit-tools');
1502
+ } else {
1503
+ sp6.succeed('Standard session management');
1504
+ }
1505
+
1506
+ nl();
1507
+ } else {
1508
+ // Non-animated fallback for CI/plain
1509
+ env = detectEnvironment();
1510
+ fxInfo(`Workspace: ${env.isReplit ? 'Replit' : 'local'}`);
1511
+ fxInfo(`Node ${process.version}`);
1512
+ fxInfo(`Claude: ${env.claude.installed ? (env.claude.authed ? 'authed' : 'installed') : 'missing'}`);
1513
+ fxInfo(`Codex: ${env.codex.installed ? (env.codex.authed ? 'authed' : 'installed') : 'missing'}`);
1514
+ nl();
1515
+ }
1516
+
1438
1517
  const startupUpdateInfo = (subcommand === 'update' || dryRun || jsonOut)
1439
1518
  ? null
1440
1519
  : checkForUpdate(env.workspace);
@@ -1445,9 +1524,9 @@ async function main() {
1445
1524
  process.stdout.isTTY &&
1446
1525
  !process.env.CI
1447
1526
  ) {
1448
- console.log('');
1527
+ nl();
1449
1528
  const shouldUpdate = await promptForUpdate(startupUpdateInfo);
1450
- console.log('');
1529
+ nl();
1451
1530
  if (shouldUpdate) {
1452
1531
  env = healClaudeAuth(env);
1453
1532
  env = healCodexAuth(env);
@@ -1477,29 +1556,38 @@ async function main() {
1477
1556
  if (jsonOut) {
1478
1557
  console.log(JSON.stringify({ version: VERSION, env, mode }, null, 2));
1479
1558
  } else {
1480
- printReport(env, mode, null, true);
1559
+ printReport(env, mode, null, true, { skipBanner: true });
1481
1560
  }
1482
1561
  process.exit(0);
1483
1562
  }
1484
1563
 
1485
1564
  if (subcommand === 'update') {
1486
1565
  const actions = performUpdate(env.workspace, env, mode);
1487
- printReport(env, mode, actions);
1566
+ printReport(env, mode, actions, false, { skipBanner: true });
1488
1567
  process.exit(0);
1489
1568
  }
1490
1569
 
1491
1570
  // Check for replit-tools on Replit
1492
1571
  if (env.isReplit && !env.hasReplitTools) {
1493
- console.log('');
1494
- console.log(' ⚠️ replit-tools not found — recommended for Replit environments.');
1495
- console.log(' Dual-brain works best alongside replit-tools for persistent auth,');
1496
- console.log(' session management, and shell integration.');
1497
- console.log('');
1498
- console.log(` Install: ${cmd('npx -y data-tools')}`);
1499
- console.log('');
1572
+ nl();
1573
+ fxWarn('replit-tools not found — recommended for Replit environments.');
1574
+ fxInfo('Dual-brain works best alongside replit-tools for persistent auth,');
1575
+ fxInfo('session management, and shell integration.');
1576
+ nl();
1577
+ fxInfo(`Install: ${cmd('npx -y data-tools')}`);
1578
+ nl();
1500
1579
  }
1501
1580
 
1502
- const actions = install(env.workspace, env, mode);
1581
+ // Install hooks and config
1582
+ let actions;
1583
+ if (animate) {
1584
+ const spInstall = spinner('Installing dual-brain hooks...').start();
1585
+ await sleep(400);
1586
+ actions = install(env.workspace, env, mode);
1587
+ spInstall.succeed(`Installed ${actions.length} components`);
1588
+ } else {
1589
+ actions = install(env.workspace, env, mode);
1590
+ }
1503
1591
 
1504
1592
  // Write a standalone shell-hook.sh so users can source it from .bashrc.
1505
1593
  // Non-interactive installs (npm postinstall) just print the hint; interactive
@@ -1530,7 +1618,10 @@ async function main() {
1530
1618
  }
1531
1619
  }
1532
1620
 
1533
- printReport(env, mode, actions);
1621
+ nl();
1622
+ await celebrate('Setup complete');
1623
+ nl();
1624
+ printReport(env, mode, actions, false, { skipBanner: true });
1534
1625
 
1535
1626
  // After install, launch the session manager (interactive TTY only)
1536
1627
  if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -197,6 +197,7 @@ export function enter(userMessage, context = {}) {
197
197
  plan,
198
198
  nextDispatch: prepared,
199
199
  suggestion: prepared.blockers[0],
200
+ mode,
200
201
  };
201
202
  }
202
203
 
@@ -209,6 +210,7 @@ export function enter(userMessage, context = {}) {
209
210
  plan,
210
211
  nextDispatch: prepared,
211
212
  estimatedCost: plan.estimatedCost,
213
+ mode,
212
214
  };
213
215
  }
214
216
 
@@ -46,6 +46,12 @@ export function acquire({ force = false } = {}) {
46
46
  return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
47
47
  }
48
48
 
49
+ // Same process (re-entry within same session) — always grant
50
+ if (existing.pid === process.pid) {
51
+ _sessionId = existing.sessionId;
52
+ return { acquired: true, sessionId: _sessionId, existingSession: null, mode: 'primary' };
53
+ }
54
+
49
55
  const age = Date.now() - existing.heartbeat;
50
56
 
51
57
  if (age > STALE_THRESHOLD_MS || force) {