@ekkos/cli 1.2.14 → 1.2.15

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.
File without changes
@@ -565,24 +565,21 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
565
565
  // 3. Claude Code pane remains fully independent
566
566
  // 4. Text selection works across panes without interference
567
567
  // ══════════════════════════════════════════════════════════════════════════
568
- // Open /dev/tty for blessed to use (gives it direct terminal access).
569
- // On macOS/Linux in tmux, this isolates the dashboard from stdout piping.
570
- // On Windows (no /dev/tty), stdin/stdout is correct — blessed handles the
571
- // alternate screen buffer natively for clean rendering.
568
+ // Open /dev/tty for blessed to use (gives it direct terminal access)
572
569
  const fs = require('fs');
573
570
  let ttyInput = process.stdin;
574
571
  let ttyOutput = process.stdout;
575
- if (inTmux) {
576
- try {
577
- const ttyFd = fs.openSync('/dev/tty', 'r+');
578
- ttyInput = new (require('tty').ReadStream)(ttyFd);
579
- ttyOutput = new (require('tty').WriteStream)(ttyFd);
580
- }
581
- catch (e) {
582
- // Fallback: if /dev/tty unavailable, use stdin/stdout
583
- ttyInput = process.stdin;
584
- ttyOutput = process.stdout;
585
- }
572
+ try {
573
+ // Create readable/writable streams from /dev/tty file descriptors
574
+ const ttyFd = fs.openSync('/dev/tty', 'r+');
575
+ ttyInput = new (require('tty').ReadStream)(ttyFd);
576
+ ttyOutput = new (require('tty').WriteStream)(ttyFd);
577
+ }
578
+ catch (e) {
579
+ // Fallback: if /dev/tty unavailable (some CI/remote environments),
580
+ // use stdin/stdout but disable all event handling
581
+ ttyInput = process.stdin;
582
+ ttyOutput = process.stdout;
586
583
  }
587
584
  const screen = blessed.screen({
588
585
  smartCSR: true,
@@ -599,17 +596,16 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
599
596
  resizeTimeout: 300, // Debounce resize events
600
597
  });
601
598
  // ══════════════════════════════════════════════════════════════════════════
602
- // DISABLE TERMINAL CONTROL SEQUENCE HIJACKING (TMUX ONLY)
603
- // When running in a tmux split pane alongside Claude Code, prevent blessed
604
- // from sending control sequences that would interfere with the other pane.
605
- // In standalone mode (including Windows Terminal split), blessed needs the
606
- // alternate buffer and raw mode for clean rendering without artifacts.
599
+ // DISABLE TERMINAL CONTROL SEQUENCE HIJACKING
600
+ // Prevent blessed from sending control sequences that might interfere with
601
+ // other panes in tmux. The /dev/tty approach already isolates this, but we
602
+ // also disable the raw mode entirely to be extra safe.
607
603
  // ══════════════════════════════════════════════════════════════════════════
608
- if (inTmux && screen.program) {
609
- // Override alternateBuffer to do nothing (tmux pane isolation)
604
+ if (screen.program) {
605
+ // Override alternateBuffer to do nothing
610
606
  screen.program.alternateBuffer = () => { };
611
607
  screen.program.normalBuffer = () => { };
612
- // Don't enter raw mode — let the OS handle it (tmux pane isolation)
608
+ // Don't enter raw mode — let the OS handle it
613
609
  if (screen.program.setRawMode) {
614
610
  screen.program.setRawMode = (enabled) => {
615
611
  // Silently ignore raw mode requests
@@ -1081,50 +1077,72 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
1081
1077
  let header = '';
1082
1078
  let separator = '';
1083
1079
  let rows = [];
1084
- // Always show all 7 columns: Turn, Model, Context, Cache Rd, Cache Wr, Output, Cost
1085
- // Shrink flex columns to fit narrow panes instead of dropping them.
1086
- const colNum = 4;
1087
- const colM = 7;
1088
- const colCtx = 7;
1089
- const colCost = 8;
1090
- const nDividers = 6;
1091
- const fixedW = colNum + colM + colCtx + colCost;
1092
- const flexTotal = Math.max(0, w - fixedW - nDividers);
1093
- let rdW = Math.max(5, Math.floor(flexTotal * 0.35));
1094
- let wrW = Math.max(5, Math.floor(flexTotal * 0.30));
1095
- let outW = Math.max(6, flexTotal - rdW - wrW);
1096
- // Trim flex columns if they overflow available width
1097
- let totalW = fixedW + nDividers + rdW + wrW + outW;
1098
- if (totalW > w) {
1099
- let overflow = totalW - w;
1100
- const trimOut = Math.min(overflow, Math.max(0, outW - 4));
1101
- outW -= trimOut;
1102
- overflow -= trimOut;
1103
- if (overflow > 0) {
1104
- const trimWr = Math.min(overflow, Math.max(0, wrW - 4));
1105
- wrW -= trimWr;
1106
- overflow -= trimWr;
1107
- }
1108
- if (overflow > 0) {
1109
- const trimRd = Math.min(overflow, Math.max(0, rdW - 4));
1110
- rdW -= trimRd;
1111
- }
1080
+ // Responsive table layouts keep headers visible even in very narrow panes.
1081
+ if (w >= 60) {
1082
+ // Full mode: Turn, Model, Context, Cache Rd, Cache Wr, Output, Cost
1083
+ const colNum = 4;
1084
+ const colM = 7;
1085
+ const colCtx = 7;
1086
+ const colCost = 8;
1087
+ const nDividers = 6;
1088
+ const fixedW = colNum + colM + colCtx + colCost;
1089
+ const flexTotal = w - fixedW - nDividers;
1090
+ const rdW = Math.max(10, Math.floor(flexTotal * 0.35));
1091
+ const wrW = Math.max(11, Math.floor(flexTotal * 0.30));
1092
+ const outW = Math.max(6, flexTotal - rdW - wrW);
1093
+ header = `${pad('Turn', colNum)}${div}${rpad('Model', colM)}${div}${rpad('Contex', colCtx)}${div}${rpad('Cache Read', rdW)}${div}${rpad('Cache Write', wrW)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}`;
1094
+ separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}`;
1095
+ rows = turns.map(t => {
1096
+ const mTag = modelTag(t.routedModel);
1097
+ const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
1098
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
1099
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
1100
+ return (pad(String(t.turn), colNum) + div +
1101
+ `{${mColor}-fg}${cpad(mTag, colM)}{/${mColor}-fg}` + div +
1102
+ rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
1103
+ `{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
1104
+ `{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
1105
+ `{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
1106
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
1107
+ });
1108
+ }
1109
+ else if (w >= 30) {
1110
+ // Compact mode: drop cache split columns to preserve readable headers.
1111
+ const colNum = 4;
1112
+ const colM = 6;
1113
+ const colCtx = 6;
1114
+ const colCost = 6;
1115
+ const nDividers = 4;
1116
+ const outW = Math.max(4, w - (colNum + colM + colCtx + colCost + nDividers));
1117
+ header = `${pad('Turn', colNum)}${div}${rpad('Model', colM)}${div}${rpad('Context', colCtx)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}`;
1118
+ separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}`;
1119
+ rows = turns.map(t => {
1120
+ const mTag = modelTag(t.routedModel);
1121
+ const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
1122
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
1123
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
1124
+ return (pad(String(t.turn), colNum) + div +
1125
+ `{${mColor}-fg}${cpad(mTag, colM)}{/${mColor}-fg}` + div +
1126
+ rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
1127
+ `{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
1128
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
1129
+ });
1130
+ }
1131
+ else {
1132
+ // Minimal mode: guaranteed no-wrap fallback.
1133
+ const colNum = 4;
1134
+ const colCtx = 6;
1135
+ const colCost = 6;
1136
+ header = `${pad('Turn', colNum)}${div}${rpad('Context', colCtx)}${div}${rpad('Cost', colCost)}`;
1137
+ separator = `${'─'.repeat(colNum)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(colCost)}`;
1138
+ rows = turns.map(t => {
1139
+ const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
1140
+ const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
1141
+ return (pad(String(t.turn), colNum) + div +
1142
+ rpad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
1143
+ costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
1144
+ });
1112
1145
  }
1113
- header = `{bold}${pad('Turn', colNum)}${div}${pad('Model', colM)}${div}${pad('Context', colCtx)}${div}${rpad('Cache Rd', rdW)}${div}${rpad('Cache Wr', wrW)}${div}${rpad('Output', outW)}${div}${rpad('Cost', colCost)}{/bold}`;
1114
- separator = `{gray-fg}${'─'.repeat(colNum)}┼${'─'.repeat(colM)}┼${'─'.repeat(colCtx)}┼${'─'.repeat(rdW)}┼${'─'.repeat(wrW)}┼${'─'.repeat(outW)}┼${'─'.repeat(colCost)}{/gray-fg}`;
1115
- rows = turns.map(t => {
1116
- const mTag = modelTag(t.routedModel);
1117
- const mColor = t.routedModel.includes('haiku') ? 'green' : t.routedModel.includes('sonnet') ? 'blue' : 'magenta';
1118
- const costFlag = t.cost > 1.0 ? '{red-fg}' : t.cost > 0.5 ? '{yellow-fg}' : '{white-fg}';
1119
- const costEnd = t.cost > 1.0 ? '{/red-fg}' : t.cost > 0.5 ? '{/yellow-fg}' : '{/white-fg}';
1120
- return (pad(String(t.turn), colNum) + div +
1121
- `{${mColor}-fg}${pad(mTag, colM)}{/${mColor}-fg}` + div +
1122
- pad(`${t.contextPct.toFixed(0)}%`, colCtx) + div +
1123
- `{green-fg}${rpad(fmtK(t.cacheRead), rdW)}{/green-fg}` + div +
1124
- `{yellow-fg}${rpad(fmtK(t.cacheCreate), wrW)}{/yellow-fg}` + div +
1125
- `{cyan-fg}${rpad(fmtK(t.output), outW)}{/cyan-fg}` + div +
1126
- costFlag + rpad(`$${t.cost.toFixed(2)}`, colCost) + costEnd);
1127
- });
1128
1146
  const lines = [header, separator, ...rows];
1129
1147
  turnBox.setContent(lines.join('\n'));
1130
1148
  // Restore scroll position AFTER content update
@@ -1386,13 +1404,11 @@ async function launchDashboard(initialSessionName, jsonlPath, refreshMs) {
1386
1404
  // Delay first ccusage call — let blessed render first, then load heavy data
1387
1405
  setTimeout(() => updateWindowBox(), 2000);
1388
1406
  const pollInterval = setInterval(updateDashboard, refreshMs);
1389
- // Animation interval — slower on Windows to reduce blessed redraw flicker
1390
- const animMs = process.platform === 'win32' ? 1000 : 500;
1391
1407
  const headerAnimInterval = setInterval(() => {
1392
1408
  // Keep advancing across the full session label; wrap at a large value.
1393
1409
  waveOffset = (waveOffset + 1) % 1000000;
1394
1410
  renderHeader();
1395
- }, animMs);
1411
+ }, 500);
1396
1412
  const fortuneInterval = setInterval(() => {
1397
1413
  if (activeFortunes.length === 0)
1398
1414
  return;
@@ -439,7 +439,7 @@ let proxyModeEnabled = true;
439
439
  /**
440
440
  * Generate a unique session UUID and convert to human-readable name
441
441
  * Each CLI invocation gets a NEW session (not tied to project path)
442
- * Uses uuidToWords from state.ts for consistency with hooks
442
+ * Uses uuidToWords from state.ts for consistency across ekkOS components
443
443
  */
444
444
  function generateCliSessionName() {
445
445
  const sessionUuid = crypto.randomUUID();
@@ -469,12 +469,12 @@ function getEkkosEnv() {
469
469
  env.EKKOS_PROXY_MODE = '1';
470
470
  // Enable ultra-minimal mode by default (30%→20% eviction for constant-cost infinite context)
471
471
  env.EKKOS_ULTRA_MINIMAL = '1';
472
- // Use placeholder for session name - will be bound by hook with Claude's real session
473
- // This fixes the mismatch where CLI generated one name but Claude Code used another
474
- // The hook calls POST /proxy/session/bind with Claude's actual session name
472
+ // Use a unique pending scope until Claude exposes its real session UUID.
473
+ // run.ts binds this scope to the real session immediately on transcript detect.
475
474
  if (!cliSessionName) {
476
- cliSessionName = '_pending'; // Placeholder - hook will bind real name
477
- cliSessionId = `pending-${Date.now()}`;
475
+ const pendingToken = crypto.randomBytes(8).toString('hex');
476
+ cliSessionName = `_pending-${pendingToken}`;
477
+ cliSessionId = `pending-${pendingToken}`;
478
478
  console.log(chalk_1.default.gray(` 📂 Session: pending (will bind to Claude session)`));
479
479
  }
480
480
  // Get full userId from config (NOT the truncated version from auth token)
@@ -912,11 +912,10 @@ function launchWithWindowsTerminal(options) {
912
912
  if (options.noProxy)
913
913
  runArgs.push('--skip-proxy');
914
914
  // Write a temp batch file to avoid all quoting issues
915
- // wt.exe doesn't resolve PATH for npm global bins — must use `cmd /c`
916
915
  const batPath = path.join(os.tmpdir(), `ekkos-wt-${launchTime}.cmd`);
917
916
  const batContent = [
918
917
  '@echo off',
919
- `wt --title "Claude Code" -d "${cwd}" cmd /c ekkos ${runArgs.join(' ')} ; split-pane -V -s 0.4 --title "ekkOS Dashboard" -d "${cwd}" cmd /c ekkos dashboard --wait-for-new --refresh 2000`,
918
+ `wt --title "Claude Code" -d "${cwd}" ekkos ${runArgs.join(' ')} ; split-pane -V -s 0.4 --title "ekkOS Dashboard" -d "${cwd}" ekkos dashboard --wait-for-new --refresh 2000`,
920
919
  ].join('\r\n');
921
920
  try {
922
921
  fs.writeFileSync(batPath, batContent);
@@ -1084,10 +1083,7 @@ async function run(options) {
1084
1083
  }
1085
1084
  }
1086
1085
  // Check PTY availability early (deterministic, no async race)
1087
- // Windows: SKIP node-pty entirely. ConPTY corrupts Ink's cursor management sequences
1088
- // (hide/show/move), causing ghost ▊ block cursor artifacts. Use stdio:'inherit' spawn
1089
- // instead so Ink gets direct terminal access. Hooks still handle session tracking.
1090
- const loadedPty = isWindows ? null : await loadPty();
1086
+ const loadedPty = await loadPty();
1091
1087
  const usePty = loadedPty !== null;
1092
1088
  // ══════════════════════════════════════════════════════════════════════════
1093
1089
  // CONCURRENT STARTUP: Spawn Claude while animation runs
@@ -1323,10 +1319,8 @@ async function run(options) {
1323
1319
  console.log('');
1324
1320
  }
1325
1321
  else {
1326
- // Static banner for Windows / remote / no-splash — no cursor manipulation
1327
- console.log('');
1328
- console.log(chalk_1.default.hex('#FF6B35').bold(' ekkOS_Pulse') + chalk_1.default.gray(' — Context is finite. Intelligence isn\'t.'));
1329
1322
  console.log('');
1323
+ console.log(chalk_1.default.cyan(' ekkOS remote session ready'));
1330
1324
  if (bypass) {
1331
1325
  console.log(chalk_1.default.yellow(' ⚡ Bypass permissions mode enabled'));
1332
1326
  }
@@ -1496,13 +1490,17 @@ async function run(options) {
1496
1490
  const sessionId = file.replace('.jsonl', '');
1497
1491
  transcriptPath = fullPath;
1498
1492
  currentSessionId = sessionId;
1493
+ currentSession = (0, state_1.uuidToWords)(sessionId);
1494
+ (0, state_1.updateCurrentProcessSession)(currentSessionId, currentSession);
1495
+ (0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
1496
+ bindRealSessionToProxy(currentSession, 'fast-transcript', currentSessionId);
1499
1497
  dlog(`[TRANSCRIPT] FAST DETECT: New transcript found! ${fullPath}`);
1500
1498
  evictionDebugLog('TRANSCRIPT_SET', 'Fast poll detected new file', {
1501
1499
  transcriptPath,
1502
1500
  currentSessionId,
1503
1501
  elapsedMs: Date.now() - launchTime
1504
1502
  });
1505
- startStreamTailer(transcriptPath, currentSessionId);
1503
+ startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
1506
1504
  // Stop polling
1507
1505
  if (transcriptPollInterval) {
1508
1506
  clearInterval(transcriptPollInterval);
@@ -1516,10 +1514,6 @@ async function run(options) {
1516
1514
  // Project dir doesn't exist yet, keep polling
1517
1515
  }
1518
1516
  }
1519
- // Start polling immediately
1520
- transcriptPollInterval = setInterval(pollForNewTranscript, 500);
1521
- pollForNewTranscript(); // Also run once immediately
1522
- dlog('[TRANSCRIPT] Fast polling started - looking for new jsonl files');
1523
1517
  // ══════════════════════════════════════════════════════════════════════════
1524
1518
  // SESSION NAME TRACKING (from live TUI output)
1525
1519
  // Claude prints: "· Turn N · groovy-koala-saves · 📅"
@@ -1561,6 +1555,10 @@ async function run(options) {
1561
1555
  let turnEndTimeout = null;
1562
1556
  const TURN_END_STABLE_MS = 500; // Must see idle prompt for 500ms
1563
1557
  let pendingClearAfterEviction = false; // Flag to trigger /clear after eviction
1558
+ // Start polling after session-tracking state is initialized.
1559
+ transcriptPollInterval = setInterval(pollForNewTranscript, 500);
1560
+ pollForNewTranscript(); // Also run once immediately
1561
+ dlog('[TRANSCRIPT] Fast polling started - looking for new jsonl files');
1564
1562
  // Debug log to eviction-debug.log for 400 error diagnosis
1565
1563
  function evictionDebugLog(category, msg, data) {
1566
1564
  try {
@@ -1615,22 +1613,30 @@ async function run(options) {
1615
1613
  dlog(`[TRANSCRIPT] Resolved by session ID (${source}): ${candidate}`);
1616
1614
  startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
1617
1615
  }
1618
- function bindRealSessionToProxy(sessionName, source) {
1616
+ function bindRealSessionToProxy(sessionName, source, sessionIdHint) {
1619
1617
  if (!proxyModeEnabled)
1620
1618
  return;
1621
1619
  if (!sessionName || sessionName === '_pending')
1622
1620
  return;
1623
1621
  if (boundProxySession === sessionName || bindingSessionInFlight === sessionName)
1624
1622
  return;
1623
+ const pendingSession = cliSessionName?.startsWith('_pending') ? cliSessionName : undefined;
1624
+ const bindSessionId = sessionIdHint || currentSessionId || undefined;
1625
1625
  bindingSessionInFlight = sessionName;
1626
1626
  void (async () => {
1627
1627
  const maxAttempts = 3;
1628
1628
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1629
- const success = await (0, session_binding_1.bindSession)(sessionName, process.cwd());
1629
+ const success = await (0, session_binding_1.bindSession)({
1630
+ realSession: sessionName,
1631
+ projectPath: process.cwd(),
1632
+ pendingSession,
1633
+ sessionId: bindSessionId,
1634
+ });
1630
1635
  if (success) {
1631
1636
  boundProxySession = sessionName;
1632
1637
  bindingSessionInFlight = null;
1633
1638
  cliSessionName = sessionName;
1639
+ cliSessionId = bindSessionId || cliSessionId;
1634
1640
  dlog(`[SESSION_BIND] Bound ${sessionName} from ${source} (attempt ${attempt}/${maxAttempts})`);
1635
1641
  return;
1636
1642
  }
@@ -2329,7 +2335,7 @@ async function run(options) {
2329
2335
  (0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
2330
2336
  dlog(`Session detected from UUID: ${currentSession}`);
2331
2337
  resolveTranscriptFromSessionId('session-id-from-output');
2332
- bindRealSessionToProxy(currentSession, 'session-id-from-output');
2338
+ bindRealSessionToProxy(currentSession, 'session-id-from-output', currentSessionId || undefined);
2333
2339
  }
2334
2340
  // ════════════════════════════════════════════════════════════════════════
2335
2341
  // SESSION NAME DETECTION (PRIMARY METHOD)
@@ -2363,7 +2369,7 @@ async function run(options) {
2363
2369
  // Also update global state for backwards compatibility
2364
2370
  (0, state_1.updateState)({ sessionName: currentSession });
2365
2371
  dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
2366
- bindRealSessionToProxy(currentSession, 'status-line');
2372
+ bindRealSessionToProxy(currentSession, 'status-line', currentSessionId || undefined);
2367
2373
  resolveTranscriptFromSessionId('status-line');
2368
2374
  }
2369
2375
  }
@@ -2371,7 +2377,7 @@ async function run(options) {
2371
2377
  // Same session, just update timestamp
2372
2378
  lastSeenSessionAt = Date.now();
2373
2379
  if (boundProxySession !== detectedSession) {
2374
- bindRealSessionToProxy(detectedSession, 'status-line-refresh');
2380
+ bindRealSessionToProxy(detectedSession, 'status-line-refresh', currentSessionId || undefined);
2375
2381
  }
2376
2382
  }
2377
2383
  }
File without changes
package/dist/index.js CHANGED
@@ -51,6 +51,9 @@ const agent_1 = require("./commands/agent");
51
51
  const state_1 = require("./utils/state");
52
52
  const index_1 = require("./commands/usage/index");
53
53
  const dashboard_1 = require("./commands/dashboard");
54
+ const swarm_1 = require("./commands/swarm");
55
+ const swarm_dashboard_1 = require("./commands/swarm-dashboard");
56
+ const swarm_setup_1 = require("./commands/swarm-setup");
54
57
  const chalk_1 = __importDefault(require("chalk"));
55
58
  const fs = __importStar(require("fs"));
56
59
  const path = __importStar(require("path"));
@@ -76,7 +79,7 @@ commander_1.program
76
79
  ` ${chalk_1.default.gray('$')} ${chalk_1.default.white('ekkos run -b')} ${chalk_1.default.gray('Launch with bypass permissions mode')}`,
77
80
  ` ${chalk_1.default.gray('$')} ${chalk_1.default.white('ekkos doctor --fix')} ${chalk_1.default.gray('Check and auto-fix system prerequisites')}`,
78
81
  ` ${chalk_1.default.gray('$')} ${chalk_1.default.white('ekkos usage daily')} ${chalk_1.default.gray("View today's token usage and costs")}`,
79
- ` ${chalk_1.default.gray('$')} ${chalk_1.default.white('ekkos sessions')} ${chalk_1.default.gray('List active Claude Code sessions')}`,
82
+ ` ${chalk_1.default.gray('$')} ${chalk_1.default.white('ekkos swarm launch -t "build X"')} ${chalk_1.default.gray('Launch parallel workers on a task')}`,
80
83
  '',
81
84
  chalk_1.default.gray(' Run ') + chalk_1.default.white('ekkos <command> --help') + chalk_1.default.gray(' for detailed options on any command.'),
82
85
  '',
@@ -154,7 +157,7 @@ commander_1.program
154
157
  title: 'Running',
155
158
  icon: '▸',
156
159
  commands: [
157
- { name: 'run', desc: 'Start Claude Code with ekkOS memory', note: 'default' },
160
+ { name: 'run', desc: 'Launch Claude Code with ekkOS memory + auto-continue', note: 'default' },
158
161
  { name: 'test-claude', desc: 'Launch Claude with proxy only (no ccDNA/PTY) for debugging' },
159
162
  { name: 'sessions', desc: 'List active Claude Code sessions' },
160
163
  ],
@@ -177,6 +180,13 @@ commander_1.program
177
180
  { name: 'agent', desc: 'Manage the remote terminal agent (start, stop, status, logs)' },
178
181
  ],
179
182
  },
183
+ {
184
+ title: 'Swarm (Multi-Agent)',
185
+ icon: '▸',
186
+ commands: [
187
+ { name: 'swarm', desc: 'Parallel workers, Q-learning routing, swarm dashboard' },
188
+ ],
189
+ },
180
190
  ];
181
191
  const padCmd = 18;
182
192
  let output = '';
@@ -229,7 +239,7 @@ commander_1.program
229
239
  // Run command - launches Claude with auto-continue wrapper
230
240
  commander_1.program
231
241
  .command('run')
232
- .description('Start Claude Code with ekkOS memory (default command)')
242
+ .description('Launch Claude Code with auto-continue (auto /clear + /continue when context is high)')
233
243
  .option('-s, --session <name>', 'Session name to restore on clear')
234
244
  .option('-b, --bypass', 'Enable bypass permissions mode (dangerously skip all permission checks)')
235
245
  .option('-v, --verbose', 'Show debug output')
@@ -342,10 +352,10 @@ hooksCmd
342
352
  (0, index_1.registerUsageCommand)(commander_1.program);
343
353
  // Dashboard command - live TUI for monitoring session usage
344
354
  commander_1.program.addCommand(dashboard_1.dashboardCommand);
345
- // Sessions command - list active Claude Code sessions
355
+ // Sessions command - list active Claude Code sessions (swarm support)
346
356
  commander_1.program
347
357
  .command('sessions')
348
- .description('List active Claude Code sessions')
358
+ .description('List active Claude Code sessions (for swarm/multi-session support)')
349
359
  .option('-j, --json', 'Output machine-readable JSON')
350
360
  .action((options) => {
351
361
  const sessions = (0, state_1.getActiveSessions)();
@@ -470,6 +480,59 @@ agentCmd
470
480
  .action((options) => {
471
481
  (0, agent_1.agentHealth)({ json: options.json });
472
482
  });
483
+ // Swarm command - manage Q-learning routing
484
+ const swarmCmd = commander_1.program
485
+ .command('swarm')
486
+ .description('Manage Swarm Q-learning model routing');
487
+ swarmCmd
488
+ .command('status')
489
+ .description('Show Q-table stats (states, visits, epsilon, top actions)')
490
+ .action(swarm_1.swarmStatus);
491
+ swarmCmd
492
+ .command('reset')
493
+ .description('Clear Q-table from Redis (routing reverts to static rules)')
494
+ .action(swarm_1.swarmReset);
495
+ swarmCmd
496
+ .command('export')
497
+ .description('Export Q-table to .swarm/q-learning-model.json')
498
+ .action(swarm_1.swarmExport);
499
+ swarmCmd
500
+ .command('import')
501
+ .description('Import Q-table from .swarm/q-learning-model.json into Redis')
502
+ .action(swarm_1.swarmImport);
503
+ swarmCmd
504
+ .command('launch')
505
+ .description('Launch parallel workers on a decomposed task (opens wizard if --task is omitted)')
506
+ .option('-w, --workers <count>', 'Number of parallel workers (2-8)', parseInt)
507
+ .option('-t, --task <task>', 'Task description to decompose and execute')
508
+ .option('--no-bypass', 'Disable bypass permissions mode')
509
+ .option('--no-decompose', 'Skip AI decomposition (send same task to all workers)')
510
+ .option('--no-queen', 'Skip launching the Python Queen coordinator')
511
+ .option('--queen-strategy <strategy>', 'Queen strategy (adaptive-default, hierarchical-cascade, mesh-consensus)')
512
+ .option('-v, --verbose', 'Show debug output')
513
+ .action((options) => {
514
+ // Auto-open wizard when --task is missing
515
+ if (!options.task) {
516
+ (0, swarm_setup_1.swarmSetup)();
517
+ return;
518
+ }
519
+ (0, swarm_1.swarmLaunch)({
520
+ workers: options.workers || 4,
521
+ task: options.task,
522
+ bypass: options.bypass !== false,
523
+ noDecompose: options.decompose === false,
524
+ noQueen: options.queen === false,
525
+ queenStrategy: options.queenStrategy,
526
+ verbose: options.verbose,
527
+ });
528
+ });
529
+ swarmCmd
530
+ .command('setup')
531
+ .description('Interactive TUI wizard for configuring and launching a swarm')
532
+ .action(() => {
533
+ (0, swarm_setup_1.swarmSetup)();
534
+ });
535
+ swarmCmd.addCommand(swarm_dashboard_1.swarmDashboardCommand);
473
536
  // Handle `-help` (single dash) — rewrite to `--help` for Commander compatibility
474
537
  const helpIdx = process.argv.indexOf('-help');
475
538
  if (helpIdx !== -1) {
@@ -1,5 +1,12 @@
1
+ interface BindSessionPayload {
2
+ realSession: string;
3
+ projectPath: string;
4
+ pendingSession?: string;
5
+ sessionId?: string;
6
+ }
1
7
  /**
2
8
  * Bind the real session name to the proxy
3
- * This replaces the '_pending' placeholder for this user, enabling proper eviction
9
+ * This replaces the pending placeholder for this user, enabling proper eviction.
4
10
  */
5
- export declare function bindSession(realSession: string, projectPath: string): Promise<boolean>;
11
+ export declare function bindSession({ realSession, projectPath, pendingSession, sessionId, }: BindSessionPayload): Promise<boolean>;
12
+ export {};
@@ -3,42 +3,67 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.bindSession = bindSession;
4
4
  const state_1 = require("./state");
5
5
  const MEMORY_API_URL = process.env.EKKOS_PROXY_URL || 'https://proxy.ekkos.dev';
6
+ function extractUserIdFromToken(token) {
7
+ const normalized = token.trim();
8
+ if (!normalized)
9
+ return undefined;
10
+ if (normalized.startsWith('ekk_')) {
11
+ const parts = normalized.split('_');
12
+ if (parts.length >= 2 && parts[1]) {
13
+ return parts[1];
14
+ }
15
+ }
16
+ const colonParts = normalized.split(':');
17
+ if (colonParts.length >= 2 && colonParts[0]) {
18
+ return colonParts[0];
19
+ }
20
+ return undefined;
21
+ }
6
22
  /**
7
23
  * Bind the real session name to the proxy
8
- * This replaces the '_pending' placeholder for this user, enabling proper eviction
24
+ * This replaces the pending placeholder for this user, enabling proper eviction.
9
25
  */
10
- async function bindSession(realSession, projectPath) {
26
+ async function bindSession({ realSession, projectPath, pendingSession, sessionId, }) {
11
27
  try {
12
28
  // Get userId same way as run.ts
13
29
  const config = (0, state_1.getConfig)();
30
+ const authToken = (0, state_1.getAuthToken)();
14
31
  let userId = config?.userId;
15
32
  if (!userId || userId === 'anonymous') {
16
- const token = (0, state_1.getAuthToken)();
17
- if (token?.startsWith('ekk_')) {
18
- const parts = token.split('_');
19
- if (parts.length >= 2) {
20
- userId = parts[1];
21
- }
33
+ if (authToken) {
34
+ userId = extractUserIdFromToken(authToken);
22
35
  }
23
36
  }
24
- if (!userId || userId === 'anonymous')
37
+ if (!userId || userId === 'anonymous' || !authToken)
25
38
  return false;
39
+ const bindAuthToken = authToken.startsWith('ekk_')
40
+ ? authToken
41
+ : `${userId}:${authToken}`;
42
+ const body = {
43
+ userId,
44
+ realSession,
45
+ projectPath,
46
+ };
47
+ if (pendingSession) {
48
+ body.pendingSession = pendingSession;
49
+ }
50
+ if (sessionId) {
51
+ body.sessionId = sessionId;
52
+ }
26
53
  // Use global fetch (Node 18+)
27
54
  const response = await fetch(`${MEMORY_API_URL}/proxy/session/bind`, {
28
55
  method: 'POST',
29
56
  signal: AbortSignal.timeout(2500),
30
57
  headers: {
31
- 'Content-Type': 'application/json'
58
+ 'Content-Type': 'application/json',
59
+ 'x-ekkos-auth-token': bindAuthToken,
60
+ Authorization: `Bearer ${bindAuthToken}`,
32
61
  },
33
- body: JSON.stringify({
34
- userId,
35
- realSession,
36
- projectPath
37
- })
62
+ body: JSON.stringify(body),
38
63
  });
39
64
  return response.ok;
40
65
  }
41
- catch (err) {
66
+ catch {
42
67
  // Fail silently - don't crash CLI
43
68
  return false;
44
69
  }
@@ -80,7 +80,7 @@ export declare function getMostRecentSession(): {
80
80
  */
81
81
  export declare function getActiveSessions(): ActiveSession[];
82
82
  /**
83
- * Register a new active session
83
+ * Register a new active session (for swarm tracking)
84
84
  */
85
85
  export declare function registerActiveSession(sessionId: string, sessionName: string, projectPath: string): ActiveSession;
86
86
  /**
@@ -244,7 +244,7 @@ function getMostRecentSession() {
244
244
  }
245
245
  }
246
246
  // ═══════════════════════════════════════════════════════════════════════════
247
- // MULTI-SESSION SUPPORT
247
+ // MULTI-SESSION/SWARM SUPPORT
248
248
  // Track multiple concurrent Claude Code sessions without state collision
249
249
  // ═══════════════════════════════════════════════════════════════════════════
250
250
  /**
@@ -271,7 +271,7 @@ function getActiveSessions() {
271
271
  }
272
272
  }
273
273
  /**
274
- * Register a new active session
274
+ * Register a new active session (for swarm tracking)
275
275
  */
276
276
  function registerActiveSession(sessionId, sessionName, projectPath) {
277
277
  ensureEkkosDir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekkos/cli",
3
- "version": "1.2.14",
3
+ "version": "1.2.15",
4
4
  "description": "Setup ekkOS memory for AI coding assistants (Claude Code, Cursor, Windsurf)",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -364,20 +364,21 @@ Call `ekkOS_Directive` when user says:
364
364
  **EVERY response MUST end with this footer:**
365
365
  ```
366
366
  ---
367
- {IDE} ({Model}) · 🧠 **ekkOS_™** · {SessionName} · 📅 {Timestamp}
367
+ {IDE} ({Model}) · 🧠 **ekkOS_™** · Turn {N} · {SessionName} · 📅 {Timestamp}
368
368
  ```
369
369
 
370
370
  **How to detect values:**
371
371
  - **IDE**: Claude Code, Cursor, Windsurf, etc. (from environment)
372
372
  - **Model**: Sonnet 4.5, Opus 4.5, etc. (from your model name)
373
+ - **Turn Number**: From hook header (e.g., "Turn 47") - starts at 0 for each new session
373
374
  - **Session Name**: From hook header (e.g., "sol-gem-dig") - human-readable session identifier
374
- - **Timestamp**: From hook header (accurate local time in EST, include time)
375
+ - **Timestamp**: From hook header (accurate local time in EST)
375
376
 
376
377
  **Examples:**
377
- - `Claude Code (Opus 4.6) · 🧠 **ekkOS_™** · cosmic-penguin-runs · 📅 2026-01-09 4:50 PM EST`
378
- - `Cursor (Claude Sonnet 4) · 🧠 **ekkOS_™** · bright-falcon-soars · 📅 2026-01-09 10:15 AM EST`
378
+ - `Claude Code (Sonnet 4.5) · 🧠 **ekkOS_™** · Turn 12 · cosmic-penguin-runs · 📅 2026-01-09 4:50 PM EST`
379
+ - `Cursor (Claude Sonnet 4) · 🧠 **ekkOS_™** · Turn 5 · bright-falcon-soars · 📅 2026-01-09 10:15 AM EST`
379
380
 
380
- **The hook header shows:** `🧠 ekkOS Memory | {SessionName} | {timestamp}`
381
+ **The hook header shows:** `🧠 ekkOS Memory | Turn {N} | {Context%} | {SessionName} | {timestamp}`
381
382
 
382
383
  ---
383
384
 
@@ -12,6 +12,14 @@
12
12
 
13
13
  $ErrorActionPreference = "SilentlyContinue"
14
14
 
15
+ # ═══════════════════════════════════════════════════════════════════════════
16
+ # PROXY MODE DETECTION
17
+ # ═══════════════════════════════════════════════════════════════════════════
18
+ $ProxyMode = $false
19
+ if ($env:ANTHROPIC_BASE_URL -and $env:ANTHROPIC_BASE_URL -like "*proxy.ekkos.dev*") {
20
+ $ProxyMode = $true
21
+ }
22
+
15
23
  # ═══════════════════════════════════════════════════════════════════════════
16
24
  # CONFIG PATHS
17
25
  # ═══════════════════════════════════════════════════════════════════════════
@@ -87,6 +95,14 @@ function Convert-UuidToWords {
87
95
  # ═══════════════════════════════════════════════════════════════════════════
88
96
  $inputJson = [Console]::In.ReadToEnd()
89
97
 
98
+ if ($ProxyMode) {
99
+ $projectState = Join-Path (Join-Path ((Get-Location).Path) ".claude\state") "hook-state.json"
100
+ $globalState = Join-Path $env:USERPROFILE ".claude\state\hook-state.json"
101
+ if (Test-Path $projectState) { Remove-Item $projectState -Force }
102
+ if (Test-Path $globalState) { Remove-Item $globalState -Force }
103
+ exit 0
104
+ }
105
+
90
106
  # Get session ID from state
91
107
  $stateFile = Join-Path $env:USERPROFILE ".claude\state\hook-state.json"
92
108
  $sessionFile = Join-Path $env:USERPROFILE ".claude\state\current-session.json"
@@ -110,39 +126,37 @@ if ($rawSessionId -eq "unknown" -and (Test-Path $sessionFile)) {
110
126
 
111
127
  $sessionName = Convert-UuidToWords $rawSessionId
112
128
 
129
+ function Get-PendingSessionFromProxyBaseUrl {
130
+ param([string]$baseUrl)
131
+ if (-not $baseUrl) { return $null }
132
+ try {
133
+ $uri = [System.Uri]$baseUrl
134
+ $segments = $uri.AbsolutePath.Trim('/') -split '/'
135
+ if (-not $segments -or $segments.Count -lt 3) { return $null }
136
+ $proxyIndex = [Array]::IndexOf($segments, 'proxy')
137
+ if ($proxyIndex -lt 0 -or $segments.Count -le ($proxyIndex + 2)) { return $null }
138
+ $candidate = [System.Uri]::UnescapeDataString($segments[$proxyIndex + 2])
139
+ if ($candidate -eq '_pending' -or $candidate -eq 'pending' -or $candidate.StartsWith('_pending-')) {
140
+ return $candidate
141
+ }
142
+ } catch {}
143
+ return $null
144
+ }
145
+ # Session binding removed — proxy now self-resolves _pending → word-based
146
+ # names inline via uuidToSessionName(). No external bind call needed.
147
+
113
148
  # ═══════════════════════════════════════════════════════════════════════════
114
- # SESSION BINDING: Bridge _pending → real session name for proxy eviction
115
- # Windows has no PTY so run.ts can't detect the session name. The stop hook
116
- # is the first place we have a confirmed session name, so we bind here.
117
- # Mac does this in stop.sh (lines 171-179). Logic is identical.
149
+ # AUTH LOADING (direct mode only)
118
150
  # ═══════════════════════════════════════════════════════════════════════════
151
+ $authToken = $null
152
+ $userId = $null
119
153
  $configFile = Join-Path $EkkosConfigDir "config.json"
120
- if ((Test-Path $configFile) -and $sessionName -ne "unknown-session") {
154
+ if (Test-Path $configFile) {
121
155
  try {
122
- $config = Get-Content $configFile -Raw | ConvertFrom-Json
123
- $userId = $config.userId
124
- $authToken = if ($config.hookApiKey) { $config.hookApiKey } else { $config.apiKey }
125
-
126
- if ($userId -and $authToken) {
127
- $projectPath = (Get-Location).Path
128
- $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
129
-
130
- $projectPath = $projectPath -replace '\\', '/'
131
- $bindBody = @{
132
- userId = $userId
133
- realSession = $sessionName
134
- projectPath = $projectPath
135
- pendingSession = $pendingSession
136
- } | ConvertTo-Json -Depth 10
137
-
138
- Start-Job -ScriptBlock {
139
- param($body, $token)
140
- Invoke-RestMethod -Uri "https://mcp.ekkos.dev/proxy/session/bind" `
141
- -Method POST `
142
- -Headers @{ "Content-Type" = "application/json" } `
143
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue | Out-Null
144
- } -ArgumentList $bindBody, $authToken | Out-Null
145
- }
156
+ $cfg = Get-Content $configFile -Raw | ConvertFrom-Json
157
+ $authToken = $cfg.hookApiKey
158
+ if (-not $authToken) { $authToken = $cfg.apiKey }
159
+ $userId = $cfg.userId
146
160
  } catch {}
147
161
  }
148
162
 
@@ -18,6 +18,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
  PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
19
19
  STATE_DIR="$PROJECT_ROOT/.claude/state"
20
20
 
21
+ # ═══════════════════════════════════════════════════════════════════════════
22
+ # PROXY MODE DETECTION
23
+ # ═══════════════════════════════════════════════════════════════════════════
24
+ PROXY_MODE=false
25
+ if [[ "$ANTHROPIC_BASE_URL" == *"proxy.ekkos.dev"* ]]; then
26
+ PROXY_MODE=true
27
+ fi
28
+
21
29
  mkdir -p "$STATE_DIR" 2>/dev/null
22
30
 
23
31
  # ═══════════════════════════════════════════════════════════════════════════
@@ -53,6 +61,14 @@ TRANSCRIPT_PATH=$(parse_json_value "$INPUT" '.transcript_path')
53
61
  MODEL_USED=$(parse_json_value "$INPUT" '.model')
54
62
  [ -z "$MODEL_USED" ] && MODEL_USED="claude-sonnet-4-5"
55
63
 
64
+ # ═══════════════════════════════════════════════════════════════════════════
65
+ # PROXY MODE: Slim hook path (local cleanup only)
66
+ # ═══════════════════════════════════════════════════════════════════════════
67
+ if [ "$PROXY_MODE" = "true" ]; then
68
+ rm -f "$STATE_DIR/hook-state.json" "$HOME/.claude/state/hook-state.json" 2>/dev/null || true
69
+ exit 0
70
+ fi
71
+
56
72
  # ═══════════════════════════════════════════════════════════════════════════
57
73
  # Session ID
58
74
  # ═══════════════════════════════════════════════════════════════════════════
@@ -163,20 +179,8 @@ fi
163
179
 
164
180
  MEMORY_API_URL="https://mcp.ekkos.dev"
165
181
 
166
- # ═══════════════════════════════════════════════════════════════════════════
167
- # SESSION BINDING: Bridge _pending real session name for proxy eviction
168
- # The CLI may be in spawn pass-through mode (no PTY = blind to TUI output),
169
- # so the stop hook (which IS sighted) must bind the session.
170
- # ═══════════════════════════════════════════════════════════════════════════
171
- if [ -n "$SESSION_NAME" ] && [ "$SESSION_NAME" != "unknown-session-starts" ] && [ -n "$USER_ID" ]; then
172
- PROJECT_PATH_FOR_BIND=$(pwd)
173
- PENDING_SESSION_FOR_BIND="${EKKOS_PENDING_SESSION:-_pending}"
174
- curl -s -X POST "$MEMORY_API_URL/proxy/session/bind" \
175
- -H "Content-Type: application/json" \
176
- -d "{\"userId\":\"$USER_ID\",\"realSession\":\"$SESSION_NAME\",\"projectPath\":\"$PROJECT_PATH_FOR_BIND\",\"pendingSession\":\"$PENDING_SESSION_FOR_BIND\"}" \
177
- --connect-timeout 1 \
178
- --max-time 2 >/dev/null 2>&1 &
179
- fi
182
+ # Session binding removed — proxy now self-resolves _pending → word-based
183
+ # names inline via uuidToSessionName(). No external bind call needed.
180
184
 
181
185
  # ═══════════════════════════════════════════════════════════════════════════
182
186
  # EVICTION: Handled by IPC (In-Place Progressive Compression) in the proxy.
@@ -12,6 +12,14 @@
12
12
 
13
13
  $ErrorActionPreference = "SilentlyContinue"
14
14
 
15
+ # ═══════════════════════════════════════════════════════════════════════════
16
+ # PROXY MODE DETECTION
17
+ # ═══════════════════════════════════════════════════════════════════════════
18
+ $ProxyMode = $false
19
+ if ($env:ANTHROPIC_BASE_URL -and $env:ANTHROPIC_BASE_URL -like "*proxy.ekkos.dev*") {
20
+ $ProxyMode = $true
21
+ }
22
+
15
23
  # ═══════════════════════════════════════════════════════════════════════════
16
24
  # CONFIG PATHS - No hardcoded word arrays per spec v1.2 Addendum
17
25
  # ═══════════════════════════════════════════════════════════════════════════
@@ -77,6 +85,100 @@ if ($rawSessionId -eq "unknown") {
77
85
  }
78
86
  }
79
87
 
88
+ # ═══════════════════════════════════════════════════════════════════════════
89
+ # PROXY MODE: Slim hook path (cosmetic output + local state only)
90
+ # ═══════════════════════════════════════════════════════════════════════════
91
+ if ($ProxyMode) {
92
+ function Get-ProxySessionName {
93
+ param([string]$sessionId)
94
+ if (-not $sessionId -or $sessionId -eq "unknown") { return "unknown-session" }
95
+ if ($sessionId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
96
+ return $sessionId
97
+ }
98
+
99
+ if (-not $script:SessionWords) { Load-SessionWords }
100
+ if (-not $script:SessionWords) { return $sessionId }
101
+
102
+ $adjectives = $script:SessionWords.adjectives
103
+ $nouns = $script:SessionWords.nouns
104
+ $verbs = $script:SessionWords.verbs
105
+ if (-not $adjectives -or -not $nouns -or -not $verbs) { return $sessionId }
106
+
107
+ $clean = $sessionId -replace "-", ""
108
+ if ($clean.Length -lt 12) { return $sessionId }
109
+
110
+ try {
111
+ $adj = [Convert]::ToInt32($clean.Substring(0, 4), 16) % $adjectives.Length
112
+ $noun = [Convert]::ToInt32($clean.Substring(4, 4), 16) % $nouns.Length
113
+ $verb = [Convert]::ToInt32($clean.Substring(8, 4), 16) % $verbs.Length
114
+ return "$($adjectives[$adj])-$($nouns[$noun])-$($verbs[$verb])"
115
+ } catch {
116
+ return $sessionId
117
+ }
118
+ }
119
+
120
+ $sessionName = Get-ProxySessionName $rawSessionId
121
+ $timestampUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
122
+ $displayTime = (Get-Date).ToString("yyyy-MM-dd hh:mm:ss tt zzz")
123
+ $projectPath = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
124
+
125
+ New-Item -ItemType Directory -Path $EkkosConfigDir -Force | Out-Null
126
+ New-Item -ItemType Directory -Path (Join-Path $env:USERPROFILE ".claude\state") -Force | Out-Null
127
+
128
+ $globalState = @{
129
+ session_id = $rawSessionId
130
+ session_name = $sessionName
131
+ project = $projectPath
132
+ timestamp = $timestampUtc
133
+ } | ConvertTo-Json -Depth 10 -Compress
134
+ Set-Content -Path (Join-Path $EkkosConfigDir "current-session.json") -Value $globalState -Force
135
+
136
+ $localState = @{
137
+ session_id = $rawSessionId
138
+ session_name = $sessionName
139
+ timestamp = $timestampUtc
140
+ } | ConvertTo-Json -Depth 10 -Compress
141
+ Set-Content -Path (Join-Path $env:USERPROFILE ".claude\state\current-session.json") -Value $localState -Force
142
+
143
+ $activeSessionsPath = Join-Path $EkkosConfigDir "active-sessions.json"
144
+ $sessions = @()
145
+ if (Test-Path $activeSessionsPath) {
146
+ try { $sessions = @(Get-Content $activeSessionsPath -Raw | ConvertFrom-Json) } catch { $sessions = @() }
147
+ }
148
+
149
+ $entry = @{
150
+ sessionId = $rawSessionId
151
+ sessionName = $sessionName
152
+ projectPath = $projectPath
153
+ startedAt = $timestampUtc
154
+ lastHeartbeat = $timestampUtc
155
+ pid = 0
156
+ }
157
+ $idx = -1
158
+ for ($i = 0; $i -lt $sessions.Count; $i++) {
159
+ if ($sessions[$i].sessionId -eq $rawSessionId) { $idx = $i; break }
160
+ }
161
+ if ($idx -ge 0) {
162
+ $entry.startedAt = if ($sessions[$idx].startedAt) { $sessions[$idx].startedAt } else { $timestampUtc }
163
+ $sessions[$idx] = $entry
164
+ } else {
165
+ $sessions += $entry
166
+ }
167
+ Set-Content -Path $activeSessionsPath -Value ($sessions | ConvertTo-Json -Depth 10) -Force
168
+
169
+ $hint = @{
170
+ sessionName = $sessionName
171
+ sessionId = $rawSessionId
172
+ projectPath = $projectPath
173
+ ts = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
174
+ } | ConvertTo-Json -Depth 10 -Compress
175
+ Set-Content -Path (Join-Path $EkkosConfigDir "hook-session-hint.json") -Value $hint -Force
176
+
177
+ $esc = [char]27
178
+ Write-Output "${esc}[0;36m${esc}[1m🧠 ekkOS Memory${esc}[0m ${esc}[2m| $sessionName | $displayTime${esc}[0m"
179
+ exit 0
180
+ }
181
+
80
182
  # ═══════════════════════════════════════════════════════════════════════════
81
183
  # INTELLIGENT TOOL ROUTER: Multi-trigger skill detection
82
184
  # ═══════════════════════════════════════════════════════════════════════════
@@ -139,39 +241,24 @@ function Convert-UuidToWords {
139
241
 
140
242
  $sessionName = Convert-UuidToWords $rawSessionId
141
243
 
142
- # ═══════════════════════════════════════════════════════════════════════════
143
- # PROXY SESSION BIND: _pending → real session name (fires every turn)
144
- # Mirrors bash user-prompt-submit.sh lines 319-338.
145
- # No PTY on Windows so run.ts can't detect session name — hook must bind it.
146
- # ═══════════════════════════════════════════════════════════════════════════
147
- if ($sessionName -ne "unknown-session" -and $rawSessionId -ne "unknown") {
148
- $configFile = Join-Path $EkkosConfigDir "config.json"
149
- if (Test-Path $configFile) {
150
- try {
151
- $config = Get-Content $configFile -Raw | ConvertFrom-Json
152
- $userId = $config.userId
153
- if ($userId) {
154
- $projectPath = if ($env:PWD) { $env:PWD } else { (Get-Location).Path }
155
- $pendingSession = if ($env:EKKOS_PENDING_SESSION) { $env:EKKOS_PENDING_SESSION } else { "_pending" }
156
- $projectPath = $projectPath -replace '\\', '/'
157
- $bindBody = @{
158
- userId = $userId
159
- realSession = $sessionName
160
- projectPath = $projectPath
161
- pendingSession = $pendingSession
162
- } | ConvertTo-Json -Depth 10 -Compress
163
-
164
- Start-Job -ScriptBlock {
165
- param($body)
166
- Invoke-RestMethod -Uri "https://mcp.ekkos.dev/proxy/session/bind" `
167
- -Method POST `
168
- -Headers @{ "Content-Type" = "application/json" } `
169
- -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) -ErrorAction SilentlyContinue | Out-Null
170
- } -ArgumentList $bindBody | Out-Null
171
- }
172
- } catch {}
173
- }
244
+ function Get-PendingSessionFromProxyBaseUrl {
245
+ param([string]$baseUrl)
246
+ if (-not $baseUrl) { return $null }
247
+ try {
248
+ $uri = [System.Uri]$baseUrl
249
+ $segments = $uri.AbsolutePath.Trim('/') -split '/'
250
+ if (-not $segments -or $segments.Count -lt 3) { return $null }
251
+ $proxyIndex = [Array]::IndexOf($segments, 'proxy')
252
+ if ($proxyIndex -lt 0 -or $segments.Count -le ($proxyIndex + 2)) { return $null }
253
+ $candidate = [System.Uri]::UnescapeDataString($segments[$proxyIndex + 2])
254
+ if ($candidate -eq '_pending' -or $candidate -eq 'pending' -or $candidate.StartsWith('_pending-')) {
255
+ return $candidate
256
+ }
257
+ } catch {}
258
+ return $null
174
259
  }
260
+ # Session binding removed — proxy now self-resolves _pending → word-based
261
+ # names inline via uuidToSessionName(). No external bind call needed.
175
262
 
176
263
  # ═══════════════════════════════════════════════════════════════════════════
177
264
  # SESSION CURRENT: Update Redis with current session name
@@ -325,7 +412,8 @@ if ($skillReminders.Count -gt 0) {
325
412
 
326
413
  $output += @"
327
414
 
328
- <footer-format>End responses with: Claude Code ({Model}) · 🧠 ekkOS_™ · $sessionName · 📅 $timestamp</footer-format>
415
+ <footer-format>End responses with: Claude Code ({Model}) · 🧠 ekkOS_™ · $sessionName · $timestamp</footer-format>
416
+ <footer-note>Do not include a turn counter in the footer.</footer-note>
329
417
  "@
330
418
 
331
419
  Write-Output $output
@@ -13,6 +13,14 @@ set +e
13
13
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
14
  PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
15
15
 
16
+ # ═══════════════════════════════════════════════════════════════════════════
17
+ # PROXY MODE DETECTION
18
+ # ═══════════════════════════════════════════════════════════════════════════
19
+ PROXY_MODE=false
20
+ if [[ "$ANTHROPIC_BASE_URL" == *"proxy.ekkos.dev"* ]]; then
21
+ PROXY_MODE=true
22
+ fi
23
+
16
24
  # ═══════════════════════════════════════════════════════════════════════════
17
25
  # CONFIG PATHS - No jq dependency (v1.2 spec)
18
26
  # Session words live in ~/.ekkos/ so they work in ANY project
@@ -64,6 +72,92 @@ if [ "$RAW_SESSION_ID" = "unknown" ] || [ "$RAW_SESSION_ID" = "null" ] || [ -z "
64
72
  fi
65
73
  fi
66
74
 
75
+ # ═══════════════════════════════════════════════════════════════════════════
76
+ # PROXY MODE: Slim hook path (cosmetic output + local state only)
77
+ # ═══════════════════════════════════════════════════════════════════════════
78
+ if [ "$PROXY_MODE" = "true" ]; then
79
+ SESSION_ID="$RAW_SESSION_ID"
80
+ UUID_REGEX='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
81
+ SESSION_NAME="$SESSION_ID"
82
+
83
+ if [[ "$SESSION_ID" =~ $UUID_REGEX ]] && [ -f "$JSON_PARSE_HELPER" ]; then
84
+ SESSION_NAME=$(node -e "
85
+ const fs = require('fs');
86
+ const sid = process.argv[1] || '';
87
+ const wordsFile = process.argv[2];
88
+ const fallbackFile = process.argv[3];
89
+ const helper = process.argv[4];
90
+ let wordsPath = wordsFile;
91
+ if (!fs.existsSync(wordsPath)) wordsPath = fallbackFile;
92
+ if (!fs.existsSync(wordsPath) || !fs.existsSync(helper)) {
93
+ console.log(sid || 'unknown-session');
94
+ process.exit(0);
95
+ }
96
+ const cp = require('child_process');
97
+ function readList(path) {
98
+ const out = cp.spawnSync('node', [helper, wordsPath, path], { encoding: 'utf8' });
99
+ if (out.status !== 0) return [];
100
+ return (out.stdout || '').split('\\n').map(s => s.trim()).filter(Boolean);
101
+ }
102
+ const adjectives = readList('.adjectives');
103
+ const nouns = readList('.nouns');
104
+ const verbs = readList('.verbs');
105
+ if (!adjectives.length || !nouns.length || !verbs.length) {
106
+ console.log(sid);
107
+ process.exit(0);
108
+ }
109
+ const hex = sid.replace(/-/g, '').slice(0, 12);
110
+ if (!/^[0-9a-fA-F]{12}$/.test(hex)) {
111
+ console.log(sid);
112
+ process.exit(0);
113
+ }
114
+ const adj = parseInt(hex.slice(0, 4), 16) % adjectives.length;
115
+ const noun = parseInt(hex.slice(4, 8), 16) % nouns.length;
116
+ const verb = parseInt(hex.slice(8, 12), 16) % verbs.length;
117
+ console.log(adjectives[adj] + '-' + nouns[noun] + '-' + verbs[verb]);
118
+ " "$SESSION_ID" "$SESSION_WORDS_JSON" "$SESSION_WORDS_DEFAULT" "$JSON_PARSE_HELPER" 2>/dev/null || echo "$SESSION_ID")
119
+ fi
120
+
121
+ TIMESTAMP_UTC=$(date -u +%Y-%m-%dT%H:%M:%SZ)
122
+ DISPLAY_TIME=$(date "+%Y-%m-%d %I:%M:%S %p %Z")
123
+ mkdir -p "$HOME/.ekkos" "$HOME/.claude/state" 2>/dev/null || true
124
+ echo "{\"session_id\":\"$SESSION_ID\",\"session_name\":\"$SESSION_NAME\",\"project\":\"$PROJECT_ROOT\",\"timestamp\":\"$TIMESTAMP_UTC\"}" > "$HOME/.ekkos/current-session.json" 2>/dev/null || true
125
+ echo "{\"session_id\":\"$SESSION_ID\",\"session_name\":\"$SESSION_NAME\",\"timestamp\":\"$TIMESTAMP_UTC\"}" > "$HOME/.claude/state/current-session.json" 2>/dev/null || true
126
+
127
+ ACTIVE_SESSIONS_FILE="$HOME/.ekkos/active-sessions.json"
128
+ node -e "
129
+ const fs = require('fs');
130
+ const file = process.argv[1];
131
+ const sid = process.argv[2];
132
+ const sname = process.argv[3];
133
+ const ts = process.argv[4];
134
+ const project = process.argv[5];
135
+ let sessions = [];
136
+ try {
137
+ if (fs.existsSync(file)) {
138
+ sessions = JSON.parse(fs.readFileSync(file, 'utf8') || '[]');
139
+ }
140
+ } catch {}
141
+ const index = sessions.findIndex(s => s.sessionId === sid);
142
+ if (index >= 0) {
143
+ sessions[index] = { ...sessions[index], sessionName: sname, projectPath: project, lastHeartbeat: ts };
144
+ } else {
145
+ sessions.push({ sessionId: sid, sessionName: sname, projectPath: project, startedAt: ts, lastHeartbeat: ts, pid: 0 });
146
+ }
147
+ fs.writeFileSync(file, JSON.stringify(sessions, null, 2));
148
+ " "$ACTIVE_SESSIONS_FILE" "$SESSION_ID" "$SESSION_NAME" "$TIMESTAMP_UTC" "$PROJECT_ROOT" 2>/dev/null || true
149
+
150
+ # Dashboard/local tooling hint file
151
+ echo "{\"sessionName\":\"$SESSION_NAME\",\"sessionId\":\"$SESSION_ID\",\"projectPath\":\"$PROJECT_ROOT\",\"ts\":$(date +%s)000}" > "$HOME/.ekkos/hook-session-hint.json" 2>/dev/null || true
152
+
153
+ CYAN='\033[0;36m'
154
+ DIM='\033[2m'
155
+ BOLD='\033[1m'
156
+ RESET='\033[0m'
157
+ echo -e "${CYAN}${BOLD}🧠 ekkOS Memory${RESET} ${DIM}| ${SESSION_NAME} | ${DISPLAY_TIME}${RESET}"
158
+ exit 0
159
+ fi
160
+
67
161
  # ═══════════════════════════════════════════════════════════════════════════
68
162
  # SKILL AUTO-FIRE: Detect keywords and inject skill reminders
69
163
  # ═══════════════════════════════════════════════════════════════════════════
@@ -138,7 +232,6 @@ echo "{\"session_id\": \"$SESSION_ID\", \"timestamp\": \"$(date -u +%Y-%m-%dT%H:
138
232
  PROJECT_SESSION_DIR="$STATE_DIR/sessions"
139
233
  mkdir -p "$PROJECT_SESSION_DIR" 2>/dev/null || true
140
234
  TURN_COUNTER_FILE="$PROJECT_SESSION_DIR/${SESSION_ID}.turn"
141
- CONTEXT_SIZE_FILE="$PROJECT_SESSION_DIR/${SESSION_ID}.context"
142
235
 
143
236
  # Count API round-trips from transcript to match TUI turn counter
144
237
  TURN_NUMBER=1
@@ -159,28 +252,7 @@ fi
159
252
 
160
253
  echo "$TURN_NUMBER" > "$TURN_COUNTER_FILE"
161
254
 
162
- # ═══════════════════════════════════════════════════════════════════════════
163
- # Context size tracking - Uses tokenizer script (single source)
164
- # ═══════════════════════════════════════════════════════════════════════════
165
- PREV_CONTEXT_PERCENT=0
166
- [ -f "$CONTEXT_SIZE_FILE" ] && PREV_CONTEXT_PERCENT=$(cat "$CONTEXT_SIZE_FILE" 2>/dev/null || echo "0")
167
-
168
- TOKEN_PERCENT=0
169
- MAX_TOKENS=200000
170
- TOKENIZER_SCRIPT="$SCRIPT_DIR/lib/count-tokens.cjs"
171
-
172
- if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ] && [ -f "$TOKENIZER_SCRIPT" ]; then
173
- TOKEN_COUNT=$(node "$TOKENIZER_SCRIPT" "$TRANSCRIPT_PATH" 2>/dev/null || echo "0")
174
- if [[ "$TOKEN_COUNT" =~ ^[0-9]+$ ]] && [ "$TOKEN_COUNT" -gt 0 ]; then
175
- TOKEN_PERCENT=$((TOKEN_COUNT * 100 / MAX_TOKENS))
176
- [ "$TOKEN_PERCENT" -gt 100 ] && TOKEN_PERCENT=100
177
- # In proxy mode, IPC compresses ~65-70% — show estimated post-compression %
178
- IPC_PERCENT=$((TOKEN_PERCENT * 30 / 100))
179
- [ "$IPC_PERCENT" -lt 1 ] && IPC_PERCENT=1
180
- fi
181
- fi
182
255
 
183
- echo "$TOKEN_PERCENT" > "$CONTEXT_SIZE_FILE"
184
256
 
185
257
  # ═══════════════════════════════════════════════════════════════════════════
186
258
  # COLORS
@@ -311,32 +383,8 @@ try {
311
383
  --max-time 3 >/dev/null 2>&1 &
312
384
  fi
313
385
 
314
- # 6. CRITICAL: Bind session name to proxy for R2 eviction paths - No jq
315
- EKKOS_USER_ID=""
316
- if [ -f "$HOME/.ekkos/config.json" ] && [ -f "$JSON_PARSE_HELPER" ]; then
317
- EKKOS_USER_ID=$(node "$JSON_PARSE_HELPER" "$HOME/.ekkos/config.json" '.userId' 2>/dev/null || echo "")
318
- fi
319
- if [ -n "$EKKOS_USER_ID" ] && [ -n "$SESSION_NAME" ]; then
320
- PENDING_SESSION="${EKKOS_PENDING_SESSION:-_pending}"
321
- node -e "
322
- const https = require('https');
323
- const body = JSON.stringify({
324
- userId: process.argv[1],
325
- realSession: process.argv[2],
326
- projectPath: process.argv[3],
327
- pendingSession: process.argv[4]
328
- });
329
- const req = https.request({
330
- hostname: 'mcp.ekkos.dev',
331
- path: '/proxy/session/bind',
332
- method: 'POST',
333
- headers: {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body)}
334
- }, () => {});
335
- req.on('error', () => {});
336
- req.write(body);
337
- req.end();
338
- " "$EKKOS_USER_ID" "$SESSION_NAME" "$PROJECT_ROOT" "$PENDING_SESSION" 2>/dev/null &
339
- fi
386
+ # Session binding removed proxy now self-resolves _pending word-based
387
+ # names inline via uuidToSessionName(). No external bind call needed.
340
388
  fi
341
389
 
342
390
  # ═══════════════════════════════════════════════════════════════════════════
@@ -410,49 +458,7 @@ turns.slice(0, -1).forEach(t => {
410
458
  fi
411
459
  fi
412
460
 
413
- # ═══════════════════════════════════════════════════════════════════════════
414
- # COMPACTION DETECTION: If context dropped dramatically, auto-restore
415
- # Was >50% last turn, now <15% = compaction happened
416
- # ═══════════════════════════════════════════════════════════════════════════
417
- if [ "$PREV_CONTEXT_PERCENT" -gt 50 ] && [ "$TOKEN_PERCENT" -lt 15 ] && [ -n "$AUTH_TOKEN" ]; then
418
- echo ""
419
- echo -e "${GREEN}${BOLD}🔄 CONTEXT RESTORED${RESET} ${DIM}| Compaction detected | Auto-loading recent turns...${RESET}"
420
-
421
- RESTORE_RESPONSE=$(curl -s -X POST "$MEMORY_API_URL/api/v1/turns/recall" \
422
- -H "Authorization: Bearer $AUTH_TOKEN" \
423
- -H "Content-Type: application/json" \
424
- -d "{\"session_id\": \"${SESSION_ID}\", \"last_n\": 10, \"format\": \"summary\"}" \
425
- --connect-timeout 3 \
426
- --max-time 5 2>/dev/null || echo '{"turns":[]}')
427
-
428
- RESTORED_COUNT=$(echo "$RESTORE_RESPONSE" | node -e "
429
- const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8') || '{}');
430
- console.log((d.turns || []).length);
431
- " 2>/dev/null || echo "0")
432
-
433
- if [ "$RESTORED_COUNT" -gt 0 ]; then
434
- echo -e "${GREEN} ✓${RESET} Restored ${RESTORED_COUNT} turns from Layer 2"
435
- echo ""
436
- echo -e "${MAGENTA}${BOLD}## Recent Context (auto-restored)${RESET}"
437
- echo ""
438
-
439
- echo "$RESTORE_RESPONSE" | node -e "
440
- const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8') || '{}');
441
- (d.turns || []).forEach(t => {
442
- const q = (t.user_query || '...').substring(0, 120);
443
- const a = (t.assistant_response || '...').substring(0, 250);
444
- console.log('**Turn ' + (t.turn_number || '?') + '**: ' + q + '...\n> ' + a + '...\n');
445
- });
446
- " 2>/dev/null || true
447
-
448
- echo ""
449
- echo -e "${DIM}Full history: \"turns 1-${TURN_NUMBER}\" or \"recall yesterday\"${RESET}"
450
- fi
451
-
452
- echo ""
453
- echo -e "${CYAN}${BOLD}🧠 ekkOS Memory${RESET} ${DIM}| ${SESSION_NAME} | ${CURRENT_TIME}${RESET}"
454
-
455
- elif [ "$POST_CLEAR_DETECTED" = true ] && [ -n "$AUTH_TOKEN" ]; then
461
+ if [ "$POST_CLEAR_DETECTED" = true ] && [ -n "$AUTH_TOKEN" ]; then
456
462
  # /clear detected - show visible restoration banner
457
463
  echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" >&2
458
464
  echo -e "${GREEN}${BOLD}🔄 SESSION CONTINUED${RESET} ${DIM}| ${TURN_NUMBER} turns preserved | Context restored${RESET}" >&2
@@ -497,9 +503,6 @@ const d = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8') || '{}');
497
503
  echo ""
498
504
  echo -e "${CYAN}${BOLD}🧠 ekkOS Memory${RESET} ${DIM}| ${SESSION_NAME} | ${CURRENT_TIME}${RESET}"
499
505
 
500
- elif [ "$TOKEN_PERCENT" -ge 50 ]; then
501
- echo -e "${CYAN}${BOLD}🧠 ekkOS Memory${RESET} ${DIM}| ~${IPC_PERCENT:-0}% IPC | ${SESSION_NAME} | ${CURRENT_TIME}${RESET}"
502
-
503
506
  else
504
507
  echo -e "${CYAN}${BOLD}🧠 ekkOS Memory${RESET} ${DIM}| ${SESSION_NAME} | ${CURRENT_TIME}${RESET}"
505
508
  fi