@ekkos/cli 0.3.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +57 -0
  2. package/dist/agent/daemon.d.ts +27 -0
  3. package/dist/agent/daemon.js +254 -29
  4. package/dist/agent/health-check.d.ts +35 -0
  5. package/dist/agent/health-check.js +243 -0
  6. package/dist/agent/pty-runner.d.ts +1 -0
  7. package/dist/agent/pty-runner.js +6 -1
  8. package/dist/capture/transcript-repair.d.ts +1 -0
  9. package/dist/capture/transcript-repair.js +12 -1
  10. package/dist/commands/agent.d.ts +6 -0
  11. package/dist/commands/agent.js +244 -0
  12. package/dist/commands/dashboard.d.ts +25 -0
  13. package/dist/commands/dashboard.js +1175 -0
  14. package/dist/commands/run.d.ts +3 -0
  15. package/dist/commands/run.js +503 -350
  16. package/dist/commands/setup-remote.js +146 -37
  17. package/dist/commands/swarm-dashboard.d.ts +20 -0
  18. package/dist/commands/swarm-dashboard.js +735 -0
  19. package/dist/commands/swarm-setup.d.ts +10 -0
  20. package/dist/commands/swarm-setup.js +956 -0
  21. package/dist/commands/swarm.d.ts +46 -0
  22. package/dist/commands/swarm.js +441 -0
  23. package/dist/commands/test-claude.d.ts +16 -0
  24. package/dist/commands/test-claude.js +156 -0
  25. package/dist/commands/usage/blocks.d.ts +8 -0
  26. package/dist/commands/usage/blocks.js +60 -0
  27. package/dist/commands/usage/daily.d.ts +9 -0
  28. package/dist/commands/usage/daily.js +96 -0
  29. package/dist/commands/usage/dashboard.d.ts +8 -0
  30. package/dist/commands/usage/dashboard.js +104 -0
  31. package/dist/commands/usage/formatters.d.ts +41 -0
  32. package/dist/commands/usage/formatters.js +147 -0
  33. package/dist/commands/usage/index.d.ts +13 -0
  34. package/dist/commands/usage/index.js +87 -0
  35. package/dist/commands/usage/monthly.d.ts +8 -0
  36. package/dist/commands/usage/monthly.js +66 -0
  37. package/dist/commands/usage/session.d.ts +11 -0
  38. package/dist/commands/usage/session.js +193 -0
  39. package/dist/commands/usage/weekly.d.ts +9 -0
  40. package/dist/commands/usage/weekly.js +61 -0
  41. package/dist/deploy/instructions.d.ts +5 -2
  42. package/dist/deploy/instructions.js +11 -8
  43. package/dist/index.js +256 -20
  44. package/dist/lib/tmux-scrollbar.d.ts +14 -0
  45. package/dist/lib/tmux-scrollbar.js +296 -0
  46. package/dist/lib/usage-parser.d.ts +95 -5
  47. package/dist/lib/usage-parser.js +416 -71
  48. package/dist/utils/log-rotate.d.ts +18 -0
  49. package/dist/utils/log-rotate.js +74 -0
  50. package/dist/utils/platform.d.ts +2 -0
  51. package/dist/utils/platform.js +3 -1
  52. package/dist/utils/session-binding.d.ts +5 -0
  53. package/dist/utils/session-binding.js +46 -0
  54. package/dist/utils/state.js +4 -0
  55. package/dist/utils/verify-remote-terminal.d.ts +10 -0
  56. package/dist/utils/verify-remote-terminal.js +415 -0
  57. package/package.json +16 -11
  58. package/templates/CLAUDE.md +135 -23
  59. package/templates/cursor-hooks/after-agent-response.sh +0 -0
  60. package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
  61. package/templates/cursor-hooks/stop.sh +0 -0
  62. package/templates/ekkos-manifest.json +5 -5
  63. package/templates/hooks/assistant-response.sh +0 -0
  64. package/templates/hooks/lib/contract.sh +43 -31
  65. package/templates/hooks/lib/count-tokens.cjs +86 -0
  66. package/templates/hooks/lib/ekkos-reminders.sh +98 -0
  67. package/templates/hooks/lib/state.sh +53 -1
  68. package/templates/hooks/session-start.sh +0 -0
  69. package/templates/hooks/stop.sh +150 -388
  70. package/templates/hooks/user-prompt-submit.sh +353 -443
  71. package/templates/plan-template.md +0 -0
  72. package/templates/spec-template.md +0 -0
  73. package/templates/windsurf-hooks/README.md +212 -0
  74. package/templates/windsurf-hooks/hooks.json +9 -2
  75. package/templates/windsurf-hooks/install.sh +148 -0
  76. package/templates/windsurf-hooks/lib/contract.sh +2 -0
  77. package/templates/windsurf-hooks/post-cascade-response.sh +251 -0
  78. package/templates/windsurf-hooks/pre-user-prompt.sh +435 -0
  79. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  80. package/LICENSE +0 -21
  81. package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
@@ -76,50 +76,63 @@ function findCcdnaPath() {
76
76
  * @param claudePath - Path to Claude Code to patch (if different from default)
77
77
  */
78
78
  function applyCcdnaPatches(verbose, claudePath) {
79
+ // DISABLED: ccDNA patching is currently corrupting cli.js (JSON parse error at position 7945)
80
+ // See: https://github.com/anthropics/ekkos/issues/2856
81
+ // The patching process is injecting code that breaks the minified cli.js
82
+ // Temporarily disabled until ccDNA is fixed upstream
83
+ if (verbose) {
84
+ console.log(chalk_1.default.gray(' ccDNA patching disabled (see issue #2856)'));
85
+ }
86
+ return null;
87
+ // Original implementation (disabled):
88
+ /*
79
89
  const ccdnaPath = findCcdnaPath();
80
90
  if (!ccdnaPath) {
81
- if (verbose) {
82
- console.log(chalk_1.default.gray(' ccDNA not found - skipping patches'));
83
- }
84
- return null;
91
+ if (verbose) {
92
+ console.log(chalk.gray(' ccDNA not found - skipping patches'));
93
+ }
94
+ return null;
85
95
  }
96
+
86
97
  // Read ccDNA version from package.json FIRST
87
98
  let ccdnaVersion = 'unknown';
88
99
  try {
89
- const pkgPath = path.join(path.dirname(ccdnaPath), '..', 'package.json');
90
- if (fs.existsSync(pkgPath)) {
91
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
92
- ccdnaVersion = pkg.version || 'unknown';
93
- }
94
- }
95
- catch {
96
- // Ignore version detection errors
97
- }
100
+ const pkgPath = path.join(path.dirname(ccdnaPath), '..', 'package.json');
101
+ if (fs.existsSync(pkgPath)) {
102
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
103
+ ccdnaVersion = pkg.version || 'unknown';
104
+ }
105
+ } catch {
106
+ // Ignore version detection errors
107
+ }
108
+
98
109
  try {
99
- // Set env var to tell ccDNA which Claude to patch
100
- // eslint-disable-next-line no-restricted-syntax
101
- const env = { ...process.env };
102
- if (claudePath) {
103
- // ccDNA checks CCDNA_CC_INSTALLATION_PATH to override default detection
104
- env.CCDNA_CC_INSTALLATION_PATH = claudePath;
105
- }
106
- // Run ccDNA in apply mode (non-interactive)
107
- (0, child_process_1.execSync)(`node "${ccdnaPath}" -a`, {
108
- stdio: verbose ? 'inherit' : 'pipe',
109
- timeout: 30000, // 30 second timeout
110
- env,
111
- });
112
- if (verbose) {
113
- console.log(chalk_1.default.green(` ✓ ccDNA v${ccdnaVersion} patches applied`));
114
- }
115
- return ccdnaVersion;
116
- }
117
- catch (err) {
118
- if (verbose) {
119
- console.log(chalk_1.default.yellow(` ⚠ ccDNA patch failed: ${err.message}`));
120
- }
121
- return null;
122
- }
110
+ // Set env var to tell ccDNA which Claude to patch
111
+ // eslint-disable-next-line no-restricted-syntax
112
+ const env = { ...process.env };
113
+ if (claudePath) {
114
+ // ccDNA checks CCDNA_CC_INSTALLATION_PATH to override default detection
115
+ env.CCDNA_CC_INSTALLATION_PATH = claudePath;
116
+ }
117
+
118
+ // Run ccDNA in apply mode (non-interactive)
119
+ execSync(`node "${ccdnaPath}" -a`, {
120
+ stdio: verbose ? 'inherit' : 'pipe',
121
+ timeout: 30000, // 30 second timeout
122
+ env,
123
+ });
124
+
125
+ if (verbose) {
126
+ console.log(chalk.green(` ✓ ccDNA v${ccdnaVersion} patches applied`));
127
+ }
128
+ return ccdnaVersion;
129
+ } catch (err) {
130
+ if (verbose) {
131
+ console.log(chalk.yellow(` ⚠ ccDNA patch failed: ${(err as Error).message}`));
132
+ }
133
+ return null;
134
+ }
135
+ */
123
136
  }
124
137
  /**
125
138
  * Restore original Claude Code (remove ccDNA patches) on exit
@@ -164,20 +177,27 @@ function restoreCcdnaPatches(verbose, claudePath) {
164
177
  }
165
178
  }
166
179
  const state_1 = require("../utils/state");
180
+ const session_binding_1 = require("../utils/session-binding");
167
181
  const doctor_1 = require("./doctor");
168
182
  const stream_tailer_1 = require("../capture/stream-tailer");
169
183
  const jsonl_rewriter_1 = require("../capture/jsonl-rewriter");
170
184
  const transcript_repair_1 = require("../capture/transcript-repair");
171
185
  // Try to load node-pty (may fail on Node 24+)
186
+ // IMPORTANT: This must be awaited in run() to avoid racey false fallbacks.
172
187
  let pty = null;
173
- (async () => {
174
- try {
175
- pty = await Promise.resolve().then(() => __importStar(require('node-pty')));
176
- }
177
- catch {
178
- // node-pty not available, will use spawn fallback
179
- }
180
- })();
188
+ let ptyLoadPromise = null;
189
+ async function loadPty() {
190
+ if (pty)
191
+ return pty;
192
+ if (!ptyLoadPromise) {
193
+ ptyLoadPromise = Promise.resolve().then(() => __importStar(require('node-pty'))).then(mod => {
194
+ pty = mod;
195
+ return mod;
196
+ })
197
+ .catch(() => null);
198
+ }
199
+ return ptyLoadPromise;
200
+ }
181
201
  function getConfig(options) {
182
202
  /* eslint-disable no-restricted-syntax -- Config timing values, not API keys */
183
203
  return {
@@ -230,9 +250,9 @@ const PALETTE_INDICATOR_REGEX = /\/(clear|continue|compact|help|bug|config)/i;
230
250
  // SESSION NAME DETECTION (3-word slug: word-word-word)
231
251
  // Claude prints session name in footer: · Turn N · groovy-koala-saves · 📅
232
252
  // ═══════════════════════════════════════════════════════════════════════════
233
- // Strong signal: session name between dot separators in Claude status/footer line
234
- // Matches:groovy-koala-saves ·" or velvet-monk-skips ·"
235
- const SESSION_NAME_IN_STATUS_REGEX = /·\s*([a-z]+-[a-z]+-[a-z]+)\s*·/i;
253
+ // Strong signal: explicit turn footer emitted by Claude/ekkOS status line.
254
+ // Requires "Turn <n> · <session> ·" to avoid matching arbitrary slug text.
255
+ const SESSION_NAME_IN_STATUS_REGEX = /turn\s+\d+\s*·\s*([a-z]+-[a-z]+-[a-z]+)\s*·/i;
236
256
  // Weaker signal: any 3-word slug (word-word-word pattern)
237
257
  const SESSION_NAME_REGEX = /\b([a-z]+-[a-z]+-[a-z]+)\b/i;
238
258
  // Orphan tool_result marker emitted by ccDNA validate mode
@@ -413,7 +433,7 @@ const isWindows = os.platform() === 'win32';
413
433
  // 'latest' = use latest version, or specify like '2.1.33' for specific version
414
434
  // Core ekkOS patches (eviction, context management) work with all recent versions
415
435
  // Cosmetic patches may fail on newer versions but don't affect functionality
416
- const PINNED_CLAUDE_VERSION = '2.1.33';
436
+ const PINNED_CLAUDE_VERSION = '2.1.45';
417
437
  // Max output tokens for Claude responses
418
438
  // Default: 16384 (safe for Sonnet 4.5)
419
439
  // Opus 4.5 supports up to 64k - set EKKOS_MAX_OUTPUT_TOKENS=32768 or =65536 to use higher limits
@@ -421,7 +441,7 @@ const PINNED_CLAUDE_VERSION = '2.1.33';
421
441
  const EKKOS_MAX_OUTPUT_TOKENS = process.env.EKKOS_MAX_OUTPUT_TOKENS || '16384';
422
442
  // Default proxy URL for context eviction
423
443
  // eslint-disable-next-line no-restricted-syntax -- Config URL, not API key
424
- const EKKOS_PROXY_URL = process.env.EKKOS_PROXY_URL || 'https://mcp.ekkos.dev';
444
+ const EKKOS_PROXY_URL = process.env.EKKOS_PROXY_URL || 'https://proxy.ekkos.dev';
425
445
  // Track proxy mode for getEkkosEnv (set by run() based on options)
426
446
  let proxyModeEnabled = true;
427
447
  // ═══════════════════════════════════════════════════════════════════════════
@@ -450,7 +470,7 @@ function getEkkosEnv() {
450
470
  /* eslint-disable no-restricted-syntax -- System env spreading, not API key access */
451
471
  const env = {
452
472
  ...process.env,
453
- CLAUDE_CODE_MAX_OUTPUT_TOKENS: EKKOS_MAX_OUTPUT_TOKENS,
473
+ // Let Claude Code use its own default max_tokens (don't override)
454
474
  };
455
475
  /* eslint-enable no-restricted-syntax */
456
476
  // Check if proxy is disabled via env var or options
@@ -464,10 +484,14 @@ function getEkkosEnv() {
464
484
  // This fixes the mismatch where CLI generated one name but Claude Code used another
465
485
  // The hook calls POST /proxy/session/bind with Claude's actual session name
466
486
  if (!cliSessionName) {
467
- cliSessionName = '_pending'; // Placeholder - hook will bind real name
468
- cliSessionId = `pending-${Date.now()}`;
469
- console.log(chalk_1.default.gray(` 📂 Session: pending (will bind to Claude session)`));
470
- }
487
+ const pendingSeed = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
488
+ cliSessionName = `_pending-${pendingSeed}`; // Unique placeholder per CLI run
489
+ cliSessionId = `pending-${pendingSeed}`;
490
+ console.log(chalk_1.default.gray(` 📂 Session: ${cliSessionName} (will bind to Claude session)`));
491
+ }
492
+ env.EKKOS_PENDING_SESSION = cliSessionName;
493
+ if (cliSessionId)
494
+ env.EKKOS_PENDING_SESSION_ID = cliSessionId;
471
495
  // Get full userId from config (NOT the truncated version from auth token)
472
496
  // Config has full UUID like "d4532ba0-0a86-42ce-bab4-22aa62b55ce6"
473
497
  // This matches the turns/ R2 structure: turns/{fullUserId}/{sessionName}/
@@ -492,7 +516,7 @@ function getEkkosEnv() {
492
516
  const projectPathEncoded = Buffer.from(projectPath).toString('base64url');
493
517
  const proxyUrl = `${EKKOS_PROXY_URL}/proxy/${encodeURIComponent(userId)}/${encodeURIComponent(cliSessionName)}?project=${projectPathEncoded}`;
494
518
  env.ANTHROPIC_BASE_URL = proxyUrl;
495
- console.log(chalk_1.default.gray(` 📡 Proxy: ${proxyUrl.replace(userId, userId.slice(0, 8) + '...')}`));
519
+ // Proxy URL contains userId + project path — don't leak to terminal
496
520
  }
497
521
  else {
498
522
  env.EKKOS_PROXY_MODE = '0';
@@ -783,6 +807,85 @@ function cleanupInstanceFile(instanceId) {
783
807
  // Ignore cleanup errors
784
808
  }
785
809
  }
810
+ /**
811
+ * Launch ekkos run + dashboard in isolated tmux panes (60/40 split)
812
+ */
813
+ function launchWithDashboard(options) {
814
+ const tmuxSession = `ekkos-${Date.now().toString(36)}`;
815
+ const launchTime = Date.now();
816
+ // Build the ekkos run command WITHOUT --dashboard (prevent recursion)
817
+ const runArgs = ['run'];
818
+ if (options.session)
819
+ runArgs.push('-s', options.session);
820
+ if (options.bypass)
821
+ runArgs.push('-b');
822
+ if (options.verbose)
823
+ runArgs.push('-v');
824
+ if (options.doctor)
825
+ runArgs.push('-d');
826
+ if (options.research)
827
+ runArgs.push('-r');
828
+ if (options.noInject)
829
+ runArgs.push('--skip-inject');
830
+ if (options.noDna)
831
+ runArgs.push('--skip-dna');
832
+ if (options.noProxy)
833
+ runArgs.push('--skip-proxy');
834
+ runArgs.push('--kickstart'); // Auto-send "test" to create session immediately for dashboard
835
+ const ekkosCmd = process.argv[1]; // Path to ekkos CLI
836
+ const cwd = process.cwd();
837
+ const termCols = process.stdout.columns ?? 160;
838
+ const termRows = process.stdout.rows ?? 48;
839
+ // Write a marker file with launch timestamp + CWD so dashboard knows to wait for NEW session
840
+ const markerPath = path.join(state_1.EKKOS_DIR, '.dashboard-launch-ts');
841
+ try {
842
+ fs.writeFileSync(markerPath, `${launchTime}\n${cwd}`);
843
+ }
844
+ catch { }
845
+ const runCommand = `node "${ekkosCmd}" ${runArgs.join(' ')}`;
846
+ // Use --wait-for-new flag to wait for a session that started AFTER this launch
847
+ const dashCommand = `node "${ekkosCmd}" dashboard --wait-for-new --refresh 2000`;
848
+ try {
849
+ // Pane 0 (left): start with inert command (no interactive shell startup noise).
850
+ // Claude is launched AFTER split so Ink gets final pane geometry at startup.
851
+ (0, child_process_1.execSync)(`tmux new-session -d -s "${tmuxSession}" -x ${termCols} -y ${termRows} -n "claude" 'sleep 86400'`, { stdio: 'pipe' });
852
+ const applyTmuxOpt = (cmd) => {
853
+ try {
854
+ (0, child_process_1.execSync)(`tmux ${cmd}`, { stdio: 'pipe' });
855
+ }
856
+ catch (err) {
857
+ if (options.verbose) {
858
+ console.log(chalk_1.default.gray(` tmux option skipped: ${cmd} (${err.message})`));
859
+ }
860
+ }
861
+ };
862
+ // Session/window isolation and quality-of-life settings
863
+ applyTmuxOpt(`set-option -t "${tmuxSession}" mouse on`);
864
+ applyTmuxOpt(`set-window-option -t "${tmuxSession}" history-limit 100000`);
865
+ applyTmuxOpt(`set-window-option -t "${tmuxSession}" mode-keys vi`);
866
+ applyTmuxOpt(`set-window-option -t "${tmuxSession}:claude" synchronize-panes off`);
867
+ // Keep pane geometry synced to the active client so vertical window resizing
868
+ // immediately gives dashboard/table more rows.
869
+ applyTmuxOpt(`set-window-option -t "${tmuxSession}:claude" window-size latest`);
870
+ applyTmuxOpt(`set-window-option -t "${tmuxSession}:claude" aggressive-resize on`);
871
+ applyTmuxOpt(`set-option -t "${tmuxSession}" remain-on-exit off`);
872
+ applyTmuxOpt(`set-option -t "${tmuxSession}" escape-time 0`);
873
+ // Pane 1 (right): dashboard at 40% width
874
+ (0, child_process_1.execSync)(`tmux split-window -t "${tmuxSession}:claude" -h -p 40 -c "${cwd}" '${dashCommand}'`, { stdio: 'pipe' });
875
+ // Launch Claude in left pane AFTER split to avoid initial Ink width mis-detection
876
+ (0, child_process_1.execSync)(`tmux respawn-pane -k -t "${tmuxSession}:claude.0" '${runCommand}'`, { stdio: 'pipe' });
877
+ // Keep focus on left pane so Claude retains immediate keyboard/mouse interaction
878
+ (0, child_process_1.execSync)(`tmux select-pane -t "${tmuxSession}:claude.0"`, { stdio: 'pipe' });
879
+ console.log(chalk_1.default.cyan('\n Dashboard launched in right pane (40%)'));
880
+ console.log(chalk_1.default.gray(' Pane switch: Ctrl+B then o'));
881
+ // Attach to tmux session
882
+ (0, child_process_1.execSync)(`tmux attach -t "${tmuxSession}"`, { stdio: 'inherit' });
883
+ }
884
+ catch (err) {
885
+ console.log(chalk_1.default.red(`tmux error: ${err.message}`));
886
+ console.log(chalk_1.default.gray('Falling back to normal mode. Run "ekkos dashboard --latest" in another terminal.'));
887
+ }
888
+ }
786
889
  async function run(options) {
787
890
  const verbose = options.verbose || false;
788
891
  const bypass = options.bypass || false;
@@ -795,6 +898,23 @@ async function run(options) {
795
898
  else if (verbose) {
796
899
  console.log(chalk_1.default.yellow(' ⏭️ API proxy disabled (--no-proxy)'));
797
900
  }
901
+ // ══════════════════════════════════════════════════════════════════════════
902
+ // DASHBOARD MODE: Launch via tmux with isolated dashboard pane (60/40)
903
+ // ══════════════════════════════════════════════════════════════════════════
904
+ if (options.dashboard) {
905
+ try {
906
+ const tmuxPath = (0, child_process_1.execSync)('which tmux', { encoding: 'utf-8' }).trim();
907
+ if (tmuxPath) {
908
+ launchWithDashboard(options);
909
+ return;
910
+ }
911
+ }
912
+ catch {
913
+ console.log(chalk_1.default.yellow(' tmux not found. Install: brew install tmux'));
914
+ console.log(chalk_1.default.gray(' Alternative: run "ekkos dashboard --latest" in a separate terminal'));
915
+ console.log(chalk_1.default.gray(' Continuing without dashboard...\n'));
916
+ }
917
+ }
798
918
  // Generate instance ID for this run
799
919
  const instanceId = generateInstanceId();
800
920
  // eslint-disable-next-line no-restricted-syntax -- Instance tracking, not API key
@@ -875,8 +995,14 @@ async function run(options) {
875
995
  if (bypass) {
876
996
  earlyArgs.push('--dangerously-skip-permissions');
877
997
  }
878
- // Check PTY availability early
879
- const usePty = pty !== null;
998
+ if (options.addDirs && options.addDirs.length > 0) {
999
+ for (const dir of options.addDirs) {
1000
+ earlyArgs.push('--add-dir', dir);
1001
+ }
1002
+ }
1003
+ // Check PTY availability early (deterministic, no async race)
1004
+ const loadedPty = await loadPty();
1005
+ const usePty = loadedPty !== null;
880
1006
  // ══════════════════════════════════════════════════════════════════════════
881
1007
  // CONCURRENT STARTUP: Spawn Claude while animation runs
882
1008
  // Buffer output until animation completes, then flush
@@ -925,187 +1051,205 @@ async function run(options) {
925
1051
  // ══════════════════════════════════════════════════════════════════════════
926
1052
  // STARTUP BANNER WITH COLOR PULSE ANIMATION
927
1053
  // ══════════════════════════════════════════════════════════════════════════
928
- const logoLines = [
929
- ' ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄ ▄▄',
930
- ' ▄▄ ▄▄ ▄███████▄ █████▀▀▀ █ █ █',
931
- '▄█▀█▄ ██ ▄█▀ ██ ▄█▀ ███ ███ ▀████▄',
932
- '██▄█▀ ████ ████ ███▄▄▄███ ▀████',
933
- '▀█▄▄▄ ██ ▀█▄ ██ ▀█▄ ▀█████▀ ███████▀',
934
- ' ▄▄▄▄▄▄▄▄'
935
- ];
936
- // Color pulse sequence (magenta → cyan → blue → magenta cycle)
937
- const pulseColors = [
938
- chalk_1.default.magenta,
939
- chalk_1.default.hex('#FF69B4'), // Hot pink
940
- chalk_1.default.cyan,
941
- chalk_1.default.hex('#00CED1'), // Dark cyan
942
- chalk_1.default.blue,
943
- chalk_1.default.hex('#8A2BE2'), // Blue violet
944
- chalk_1.default.magenta
945
- ];
946
- // Print initial logo
947
- console.log('');
948
- logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
949
- // Animate: pulse through colors
950
- const PULSE_CYCLES = 2;
951
- const FRAME_DELAY_MS = 80;
952
- for (let cycle = 0; cycle < PULSE_CYCLES; cycle++) {
953
- for (const colorFn of pulseColors) {
954
- // Move cursor up to overwrite logo (6 lines)
955
- process.stdout.write('\x1B[6A');
956
- // Reprint with new color
957
- logoLines.forEach(line => console.log(colorFn(line)));
958
- await sleep(FRAME_DELAY_MS);
959
- }
960
- }
961
- // Final frame: settle on magenta
962
- process.stdout.write('\x1B[6A');
963
- logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
964
- // ══════════════════════════════════════════════════════════════════════════
965
- // SPARKLE EFFECT - Random characters flash white/cyan
966
- // ══════════════════════════════════════════════════════════════════════════
967
- const sparkleChars = ['▄', '█', '▀'];
968
- const sparkleColors = [chalk_1.default.white, chalk_1.default.whiteBright, chalk_1.default.cyanBright, chalk_1.default.yellowBright];
969
- const SPARKLE_FRAMES = 40; // ~3.2 seconds of sparkles
970
- const SPARKLE_DELAY_MS = 80;
971
- const SPARKLES_PER_FRAME = 3;
972
- for (let frame = 0; frame < SPARKLE_FRAMES; frame++) {
973
- // Create a copy of logo lines for this frame
974
- const frameLines = logoLines.map(line => [...line]);
975
- // Add random sparkles
976
- for (let s = 0; s < SPARKLES_PER_FRAME; s++) {
977
- const lineIdx = Math.floor(Math.random() * frameLines.length);
978
- const line = frameLines[lineIdx];
979
- // Find positions with sparkle-able characters
980
- const sparklePositions = [];
981
- for (let i = 0; i < line.length; i++) {
982
- const charItem = line[i];
983
- if (typeof charItem === 'string' && sparkleChars.includes(charItem)) {
984
- sparklePositions.push(i);
985
- }
986
- }
987
- if (sparklePositions.length > 0) {
988
- const pos = sparklePositions[Math.floor(Math.random() * sparklePositions.length)];
989
- const charAtPos = line[pos];
990
- // Mark this position for sparkle (we'll handle coloring below)
991
- if (typeof charAtPos === 'string') {
992
- frameLines[lineIdx][pos] = { char: charAtPos, sparkle: true };
993
- }
1054
+ const skipFancyIntro = process.env.EKKOS_REMOTE_SESSION === '1' || process.env.EKKOS_NO_SPLASH === '1';
1055
+ if (!skipFancyIntro) {
1056
+ const logoLines = [
1057
+ ' ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄ ▄▄',
1058
+ ' ▄▄ ▄▄ ▄███████▄ █████▀▀▀ █ █ ▀ █',
1059
+ '▄█▀█▄ ██ ▄█▀ ██ ▄█▀ ███ ███ ▀████▄',
1060
+ '██▄█▀ ████ ████ ███▄▄▄███ ▀████',
1061
+ '▀█▄▄▄ ██ ▀█▄ ██ ▀█▄ ▀█████▀ ███████▀',
1062
+ ' ▄▄▄▄▄▄▄▄'
1063
+ ];
1064
+ // Color pulse sequence (magenta → cyan → blue → magenta cycle)
1065
+ const pulseColors = [
1066
+ chalk_1.default.magenta,
1067
+ chalk_1.default.hex('#FF69B4'), // Hot pink
1068
+ chalk_1.default.cyan,
1069
+ chalk_1.default.hex('#00CED1'), // Dark cyan
1070
+ chalk_1.default.blue,
1071
+ chalk_1.default.hex('#8A2BE2'), // Blue violet
1072
+ chalk_1.default.magenta
1073
+ ];
1074
+ // Print initial logo
1075
+ console.log('');
1076
+ logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
1077
+ // Animate: pulse through colors
1078
+ const PULSE_CYCLES = 2;
1079
+ const FRAME_DELAY_MS = 80;
1080
+ for (let cycle = 0; cycle < PULSE_CYCLES; cycle++) {
1081
+ for (const colorFn of pulseColors) {
1082
+ // Move cursor up to overwrite logo (6 lines)
1083
+ process.stdout.write('\x1B[6A');
1084
+ // Reprint with new color
1085
+ logoLines.forEach(line => console.log(colorFn(line)));
1086
+ await sleep(FRAME_DELAY_MS);
994
1087
  }
995
1088
  }
996
- // Move cursor up and render frame
1089
+ // Final frame: settle on magenta
997
1090
  process.stdout.write('\x1B[6A');
998
- for (const line of frameLines) {
999
- let output = '';
1000
- for (const char of line) {
1001
- if (typeof char === 'object' && 'sparkle' in char && char.sparkle) {
1002
- const sparkleColor = sparkleColors[Math.floor(Math.random() * sparkleColors.length)];
1003
- output += sparkleColor(char.char);
1091
+ logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
1092
+ // ══════════════════════════════════════════════════════════════════════════
1093
+ // SPARKLE EFFECT - Random characters flash white/cyan
1094
+ // ══════════════════════════════════════════════════════════════════════════
1095
+ const sparkleChars = ['▄', '█', '▀'];
1096
+ const sparkleColors = [chalk_1.default.white, chalk_1.default.whiteBright, chalk_1.default.cyanBright, chalk_1.default.yellowBright];
1097
+ const SPARKLE_FRAMES = 40; // ~3.2 seconds of sparkles
1098
+ const SPARKLE_DELAY_MS = 80;
1099
+ const SPARKLES_PER_FRAME = 3;
1100
+ for (let frame = 0; frame < SPARKLE_FRAMES; frame++) {
1101
+ // Track sparkle positions for this frame (lineIdx -> Set<charIdx>)
1102
+ const sparkleMap = new Map();
1103
+ // Pick random sparkle positions
1104
+ for (let s = 0; s < SPARKLES_PER_FRAME; s++) {
1105
+ const lineIdx = Math.floor(Math.random() * logoLines.length);
1106
+ const line = logoLines[lineIdx];
1107
+ // Find sparkle-able character positions in this line
1108
+ const sparklePositions = [];
1109
+ for (let i = 0; i < line.length; i++) {
1110
+ if (sparkleChars.includes(line[i])) {
1111
+ sparklePositions.push(i);
1112
+ }
1004
1113
  }
1005
- else {
1006
- output += chalk_1.default.magenta(char);
1114
+ if (sparklePositions.length > 0) {
1115
+ const pos = sparklePositions[Math.floor(Math.random() * sparklePositions.length)];
1116
+ if (!sparkleMap.has(lineIdx)) {
1117
+ sparkleMap.set(lineIdx, new Set());
1118
+ }
1119
+ sparkleMap.get(lineIdx).add(pos);
1007
1120
  }
1008
1121
  }
1009
- console.log(output);
1010
- }
1011
- await sleep(SPARKLE_DELAY_MS);
1012
- }
1013
- // Final settle: clean magenta
1014
- process.stdout.write('\x1B[6A');
1015
- logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
1016
- console.log('');
1017
- // ══════════════════════════════════════════════════════════════════════════
1018
- // ANIMATED TITLE: "Cognitive Continuity Engine" with orange/white shine
1019
- // ══════════════════════════════════════════════════════════════════════════
1020
- const titleText = 'Cognitive Continuity Engine';
1021
- const taglineText = 'Context is finite. Intelligence isn\'t.';
1022
- // Color palette for shine effect
1023
- const whiteShine = chalk_1.default.whiteBright;
1024
- // Phase 1: Typewriter effect for title
1025
- process.stdout.write(' ');
1026
- for (let i = 0; i < titleText.length; i++) {
1027
- const char = titleText[i];
1028
- // Flash white then settle to orange
1029
- process.stdout.write(whiteShine(char));
1030
- await sleep(25);
1031
- process.stdout.write('\b' + chalk_1.default.hex('#FF6B35').bold(char));
1032
- }
1033
- console.log('');
1034
- // Phase 2: Shine sweep across title (3 passes)
1035
- const SHINE_PASSES = 3;
1036
- const SHINE_WIDTH = 4;
1037
- for (let pass = 0; pass < SHINE_PASSES; pass++) {
1038
- for (let shinePos = -SHINE_WIDTH; shinePos <= titleText.length + SHINE_WIDTH; shinePos++) {
1039
- process.stdout.write('\x1B[1A'); // Move up one line
1040
- process.stdout.write('\r '); // Return to start
1041
- let output = '';
1042
- for (let i = 0; i < titleText.length; i++) {
1043
- const distFromShine = Math.abs(i - shinePos);
1044
- if (distFromShine === 0) {
1045
- output += whiteShine.bold(titleText[i]);
1046
- }
1047
- else if (distFromShine === 1) {
1048
- output += chalk_1.default.hex('#FFFFFF')(titleText[i]);
1049
- }
1050
- else if (distFromShine === 2) {
1051
- output += chalk_1.default.hex('#FFD700')(titleText[i]);
1052
- }
1053
- else if (distFromShine === 3) {
1054
- output += chalk_1.default.hex('#FFA500')(titleText[i]);
1122
+ // Render frame with sparkles
1123
+ process.stdout.write('\x1B[6A'); // Move cursor up 6 lines
1124
+ for (let lineIdx = 0; lineIdx < logoLines.length; lineIdx++) {
1125
+ const line = logoLines[lineIdx];
1126
+ const sparkles = sparkleMap.get(lineIdx);
1127
+ let output = '';
1128
+ for (let charIdx = 0; charIdx < line.length; charIdx++) {
1129
+ const char = line[charIdx];
1130
+ if (sparkles && sparkles.has(charIdx)) {
1131
+ const sparkleColor = sparkleColors[Math.floor(Math.random() * sparkleColors.length)];
1132
+ output += sparkleColor(char);
1133
+ }
1134
+ else {
1135
+ output += chalk_1.default.magenta(char);
1136
+ }
1055
1137
  }
1056
- else {
1057
- output += chalk_1.default.hex('#FF6B35').bold(titleText[i]);
1138
+ console.log(output);
1139
+ }
1140
+ await sleep(SPARKLE_DELAY_MS);
1141
+ }
1142
+ // Final settle: clean magenta
1143
+ process.stdout.write('\x1B[6A');
1144
+ logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
1145
+ console.log('');
1146
+ // ══════════════════════════════════════════════════════════════════════════
1147
+ // ANIMATED TITLE: "Cognitive Continuity Engine" with orange/white shine
1148
+ // ══════════════════════════════════════════════════════════════════════════
1149
+ const titleText = 'Cognitive Continuity Engine';
1150
+ const taglineText = 'Context is finite. Intelligence isn\'t.';
1151
+ // Color palette for shine effect
1152
+ const whiteShine = chalk_1.default.whiteBright;
1153
+ // Phase 1: Typewriter effect for title
1154
+ process.stdout.write(' ');
1155
+ for (let i = 0; i < titleText.length; i++) {
1156
+ const char = titleText[i];
1157
+ // Flash white then settle to orange
1158
+ process.stdout.write(whiteShine(char));
1159
+ await sleep(25);
1160
+ process.stdout.write('\b' + chalk_1.default.hex('#FF6B35').bold(char));
1161
+ }
1162
+ console.log('');
1163
+ // Phase 2: Shine sweep across title (3 passes)
1164
+ const SHINE_PASSES = 3;
1165
+ const SHINE_WIDTH = 4;
1166
+ for (let pass = 0; pass < SHINE_PASSES; pass++) {
1167
+ for (let shinePos = -SHINE_WIDTH; shinePos <= titleText.length + SHINE_WIDTH; shinePos++) {
1168
+ process.stdout.write('\x1B[1A'); // Move up one line
1169
+ process.stdout.write('\r '); // Return to start
1170
+ let output = '';
1171
+ for (let i = 0; i < titleText.length; i++) {
1172
+ const distFromShine = Math.abs(i - shinePos);
1173
+ if (distFromShine === 0) {
1174
+ output += whiteShine.bold(titleText[i]);
1175
+ }
1176
+ else if (distFromShine === 1) {
1177
+ output += chalk_1.default.hex('#FFFFFF')(titleText[i]);
1178
+ }
1179
+ else if (distFromShine === 2) {
1180
+ output += chalk_1.default.hex('#FFD700')(titleText[i]);
1181
+ }
1182
+ else if (distFromShine === 3) {
1183
+ output += chalk_1.default.hex('#FFA500')(titleText[i]);
1184
+ }
1185
+ else {
1186
+ output += chalk_1.default.hex('#FF6B35').bold(titleText[i]);
1187
+ }
1058
1188
  }
1189
+ process.stdout.write(output + '\n'); // Write and move down for next frame
1190
+ await sleep(15);
1059
1191
  }
1060
- process.stdout.write(output + '\n'); // Write and move down for next frame
1061
- await sleep(15);
1062
- }
1063
- }
1064
- // Final title state
1065
- process.stdout.write('\x1B[1A\r');
1066
- console.log(' ' + chalk_1.default.hex('#FF6B35').bold(titleText));
1067
- // Phase 3: Tagline fade-in with shimmer
1068
- await sleep(100);
1069
- // Build up tagline with wave effect
1070
- const taglineColors = [
1071
- chalk_1.default.hex('#444444'),
1072
- chalk_1.default.hex('#666666'),
1073
- chalk_1.default.hex('#888888'),
1074
- chalk_1.default.hex('#AAAAAA'),
1075
- chalk_1.default.hex('#CCCCCC'),
1076
- chalk_1.default.hex('#EEEEEE'),
1077
- chalk_1.default.gray,
1078
- ];
1079
- for (let wave = 0; wave < taglineColors.length; wave++) {
1080
- process.stdout.write('\r ');
1081
- process.stdout.write(taglineColors[wave](taglineText));
1082
- await sleep(40);
1083
- }
1084
- console.log('');
1085
- // Phase 4: Quick orange accent pulse on tagline
1086
- for (let pulse = 0; pulse < 2; pulse++) {
1087
- await sleep(80);
1192
+ }
1193
+ // Final title state
1088
1194
  process.stdout.write('\x1B[1A\r');
1089
- console.log(' ' + chalk_1.default.hex('#FF8C00')(taglineText));
1090
- await sleep(80);
1195
+ console.log(' ' + chalk_1.default.hex('#FF6B35').bold(titleText));
1196
+ // Phase 3: Tagline fade-in with shimmer
1197
+ await sleep(100);
1198
+ // Build up tagline with wave effect
1199
+ const taglineColors = [
1200
+ chalk_1.default.hex('#444444'),
1201
+ chalk_1.default.hex('#666666'),
1202
+ chalk_1.default.hex('#888888'),
1203
+ chalk_1.default.hex('#AAAAAA'),
1204
+ chalk_1.default.hex('#CCCCCC'),
1205
+ chalk_1.default.hex('#EEEEEE'),
1206
+ chalk_1.default.gray,
1207
+ ];
1208
+ for (let wave = 0; wave < taglineColors.length; wave++) {
1209
+ process.stdout.write('\r ');
1210
+ process.stdout.write(taglineColors[wave](taglineText));
1211
+ await sleep(40);
1212
+ }
1213
+ console.log('');
1214
+ // Phase 4: Quick orange accent pulse on tagline
1215
+ for (let pulse = 0; pulse < 2; pulse++) {
1216
+ await sleep(80);
1217
+ process.stdout.write('\x1B[1A\r');
1218
+ console.log(' ' + chalk_1.default.hex('#FF8C00')(taglineText));
1219
+ await sleep(80);
1220
+ process.stdout.write('\x1B[1A\r');
1221
+ console.log(' ' + chalk_1.default.gray(taglineText));
1222
+ }
1223
+ // Final tagline state with subtle orange tint
1091
1224
  process.stdout.write('\x1B[1A\r');
1092
- console.log(' ' + chalk_1.default.gray(taglineText));
1093
- }
1094
- // Final tagline state with subtle orange tint
1095
- process.stdout.write('\x1B[1A\r');
1096
- console.log(' ' + chalk_1.default.hex('#B8860B')(taglineText));
1097
- console.log('');
1098
- if (bypass) {
1099
- console.log(chalk_1.default.yellow(' ⚡ Bypass permissions mode enabled'));
1100
- }
1101
- if (noDna) {
1102
- console.log(chalk_1.default.yellow(' ⏭️ ccDNA injection skipped (--no-dna)'));
1225
+ console.log(' ' + chalk_1.default.hex('#B8860B')(taglineText));
1226
+ console.log('');
1227
+ if (bypass) {
1228
+ console.log(chalk_1.default.yellow(' ⚡ Bypass permissions mode enabled'));
1229
+ }
1230
+ if (noDna) {
1231
+ console.log(chalk_1.default.yellow(' ⏭️ ccDNA injection skipped (--no-dna)'));
1232
+ }
1233
+ if (verbose) {
1234
+ console.log(chalk_1.default.gray(` 📁 Debug log: ${config.debugLogPath}`));
1235
+ console.log(chalk_1.default.gray(` Timing: clear=${config.clearWaitMs}ms, idleMax=${config.maxIdleWaitMs}ms (~${Math.round((config.clearWaitMs + config.maxIdleWaitMs * 2 + 1700) / 1000)}s total)`));
1236
+ }
1237
+ console.log('');
1103
1238
  }
1104
- if (verbose) {
1105
- console.log(chalk_1.default.gray(` 📁 Debug log: ${config.debugLogPath}`));
1106
- console.log(chalk_1.default.gray(` ⏱ Timing: clear=${config.clearWaitMs}ms, idleMax=${config.maxIdleWaitMs}ms (~${Math.round((config.clearWaitMs + config.maxIdleWaitMs * 2 + 1700) / 1000)}s total)`));
1239
+ else {
1240
+ console.log('');
1241
+ console.log(chalk_1.default.cyan(' ekkOS remote session ready'));
1242
+ if (bypass) {
1243
+ console.log(chalk_1.default.yellow(' ⚡ Bypass permissions mode enabled'));
1244
+ }
1245
+ if (noDna) {
1246
+ console.log(chalk_1.default.yellow(' ⏭️ ccDNA injection skipped (--no-dna)'));
1247
+ }
1248
+ if (verbose) {
1249
+ console.log(chalk_1.default.gray(` 📁 Debug log: ${config.debugLogPath}`));
1250
+ }
1251
+ console.log('');
1107
1252
  }
1108
- console.log('');
1109
1253
  // ══════════════════════════════════════════════════════════════════════════
1110
1254
  // ANIMATION COMPLETE: Mark ready and flush buffered Claude output
1111
1255
  // ══════════════════════════════════════════════════════════════════════════
@@ -1118,8 +1262,9 @@ async function run(options) {
1118
1262
  await sleep(100);
1119
1263
  process.stdout.write('\r' + ' '.repeat(30) + '\r'); // Clear the line
1120
1264
  }
1121
- // Track state
1122
- let currentSession = options.session || (0, state_1.getCurrentSessionName)();
1265
+ // Track state — only use explicit -s option; never inherit stale session from state.json
1266
+ // The real session name will be detected from Claude Code's output (line ~2552)
1267
+ let currentSession = options.session || null;
1123
1268
  // Write initial instance file
1124
1269
  const startedAt = new Date().toISOString();
1125
1270
  writeInstanceFile(instanceId, {
@@ -1199,7 +1344,8 @@ async function run(options) {
1199
1344
  catch {
1200
1345
  dlog('[TRANSCRIPT] Project dir does not exist yet');
1201
1346
  }
1202
- // Poll for new transcript file every 500ms for up to 30 seconds
1347
+ // Poll for new transcript file every 500ms for up to 30 seconds.
1348
+ // Safety rule: do NOT guess using "most recent" files; that can cross-bind sessions.
1203
1349
  let transcriptPollInterval = null;
1204
1350
  function pollForNewTranscript() {
1205
1351
  if (transcriptPath) {
@@ -1212,9 +1358,9 @@ async function run(options) {
1212
1358
  }
1213
1359
  // Stop after 30 seconds
1214
1360
  if (Date.now() - launchTime > 30000) {
1215
- // FALLBACK FIX: If no transcript found yet, pick most recent jsonl as best guess
1216
- // This handles /continue scenarios where the file already existed
1217
- if (!transcriptPath) {
1361
+ // Local mode fallback: transcript maintenance features need a file path.
1362
+ // In proxy mode this is intentionally disabled to avoid cross-session mixing.
1363
+ if (!proxyModeEnabled && !transcriptPath) {
1218
1364
  try {
1219
1365
  const files = fs.readdirSync(projectDir);
1220
1366
  const jsonlFiles = files
@@ -1228,23 +1374,23 @@ async function run(options) {
1228
1374
  if (jsonlFiles.length > 0) {
1229
1375
  transcriptPath = jsonlFiles[0].path;
1230
1376
  currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
1231
- dlog(`[TRANSCRIPT] TIMEOUT FALLBACK: Using most recent file ${transcriptPath}`);
1232
- evictionDebugLog('TRANSCRIPT_SET', 'Polling timeout fallback - using most recent jsonl', {
1233
- transcriptPath,
1234
- currentSessionId,
1235
- fileCount: jsonlFiles.length,
1236
- });
1377
+ dlog(`[TRANSCRIPT] Local-mode timeout fallback: ${transcriptPath}`);
1237
1378
  startStreamTailer(transcriptPath, currentSessionId);
1238
1379
  }
1239
- else {
1240
- dlog('[TRANSCRIPT] TIMEOUT FALLBACK: No jsonl files found in project dir');
1241
- }
1242
1380
  }
1243
- catch (err) {
1244
- dlog(`[TRANSCRIPT] TIMEOUT FALLBACK ERROR: ${err.message}`);
1381
+ catch {
1382
+ // Ignore local-mode timeout errors
1245
1383
  }
1246
1384
  }
1247
- dlog('[TRANSCRIPT] Polling timeout - fallback complete');
1385
+ if (proxyModeEnabled) {
1386
+ dlog('[TRANSCRIPT] Polling timeout - no safe transcript candidate found');
1387
+ }
1388
+ else if (transcriptPath) {
1389
+ dlog('[TRANSCRIPT] Polling timeout - local fallback transcript selected');
1390
+ }
1391
+ else {
1392
+ dlog('[TRANSCRIPT] Polling timeout - no transcript candidate found');
1393
+ }
1248
1394
  if (transcriptPollInterval) {
1249
1395
  clearInterval(transcriptPollInterval);
1250
1396
  transcriptPollInterval = null;
@@ -1277,27 +1423,6 @@ async function run(options) {
1277
1423
  return;
1278
1424
  }
1279
1425
  }
1280
- // Also check for recently modified files (in case we missed the creation)
1281
- const recentFiles = jsonlFiles
1282
- .map(f => ({ name: f, path: path.join(projectDir, f), mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
1283
- .filter(f => f.mtime > launchTime - 2000) // Modified within 2s of launch
1284
- .sort((a, b) => b.mtime - a.mtime);
1285
- if (recentFiles.length > 0) {
1286
- const newest = recentFiles[0];
1287
- transcriptPath = newest.path;
1288
- currentSessionId = newest.name.replace('.jsonl', '');
1289
- dlog(`[TRANSCRIPT] FAST DETECT: Recent transcript found! ${transcriptPath}`);
1290
- evictionDebugLog('TRANSCRIPT_SET', 'Fast poll found recent file', {
1291
- transcriptPath,
1292
- currentSessionId,
1293
- elapsedMs: Date.now() - launchTime
1294
- });
1295
- startStreamTailer(transcriptPath, currentSessionId);
1296
- if (transcriptPollInterval) {
1297
- clearInterval(transcriptPollInterval);
1298
- transcriptPollInterval = null;
1299
- }
1300
- }
1301
1426
  }
1302
1427
  catch {
1303
1428
  // Project dir doesn't exist yet, keep polling
@@ -1318,6 +1443,8 @@ async function run(options) {
1318
1443
  // Track if we've EVER observed a session in THIS process run
1319
1444
  // This is the authoritative flag - if false, don't trust persisted state
1320
1445
  let observedSessionThisRun = false;
1446
+ let boundProxySession = null;
1447
+ let bindingSessionInFlight = null;
1321
1448
  // Output buffer for pattern detection
1322
1449
  let outputBuffer = '';
1323
1450
  // Debounce tracking to prevent double triggers
@@ -1391,6 +1518,48 @@ async function run(options) {
1391
1518
  }
1392
1519
  return true;
1393
1520
  }
1521
+ function resolveTranscriptFromSessionId(source) {
1522
+ if (!currentSessionId || transcriptPath)
1523
+ return;
1524
+ const candidate = path.join(projectDir, `${currentSessionId}.jsonl`);
1525
+ if (!fs.existsSync(candidate))
1526
+ return;
1527
+ transcriptPath = candidate;
1528
+ evictionDebugLog('TRANSCRIPT_SET', `Set from session ID (${source})`, {
1529
+ transcriptPath,
1530
+ currentSessionId,
1531
+ });
1532
+ dlog(`[TRANSCRIPT] Resolved by session ID (${source}): ${candidate}`);
1533
+ startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
1534
+ }
1535
+ function bindRealSessionToProxy(sessionName, source) {
1536
+ if (!proxyModeEnabled)
1537
+ return;
1538
+ if (!sessionName || sessionName === '_pending' || sessionName === 'pending' || sessionName.startsWith('_pending-'))
1539
+ return;
1540
+ if (boundProxySession === sessionName || bindingSessionInFlight === sessionName)
1541
+ return;
1542
+ bindingSessionInFlight = sessionName;
1543
+ void (async () => {
1544
+ const maxAttempts = 3;
1545
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1546
+ const pendingSession = cliSessionName && cliSessionName.startsWith('_pending') ? cliSessionName : undefined;
1547
+ const success = await (0, session_binding_1.bindSession)(sessionName, process.cwd(), pendingSession);
1548
+ if (success) {
1549
+ boundProxySession = sessionName;
1550
+ bindingSessionInFlight = null;
1551
+ cliSessionName = sessionName;
1552
+ dlog(`[SESSION_BIND] Bound ${sessionName} from ${source} (attempt ${attempt}/${maxAttempts})`);
1553
+ return;
1554
+ }
1555
+ if (attempt < maxAttempts) {
1556
+ await sleep(200 * attempt);
1557
+ }
1558
+ }
1559
+ dlog(`[SESSION_BIND] Failed to bind ${sessionName} from ${source}`);
1560
+ bindingSessionInFlight = null;
1561
+ })();
1562
+ }
1394
1563
  /**
1395
1564
  * Check if there are in-flight tool calls (tool_uses without matching tool_results)
1396
1565
  * CRITICAL: We must NOT evict while tools are in-flight or we'll orphan tool_results
@@ -1444,6 +1613,12 @@ async function run(options) {
1444
1613
  async function handleTurnEnd() {
1445
1614
  if (!transcriptPath || isAutoClearInProgress)
1446
1615
  return;
1616
+ if (proxyModeEnabled) {
1617
+ // Proxy is the SOLE eviction authority. It strips old turns before forwarding to Anthropic,
1618
+ // keeping API payloads small and consistent for prompt cache hits.
1619
+ // CLI does NOT touch the local JSONL - Claude Code sends full history, proxy handles the rest.
1620
+ return;
1621
+ }
1447
1622
  // DEFENSIVE: Validate path before using
1448
1623
  if (!validateTranscriptPath(transcriptPath)) {
1449
1624
  evictionDebugLog('TURN_END_ABORT', 'Invalid transcriptPath detected - resetting to null', {
@@ -1472,13 +1647,12 @@ async function run(options) {
1472
1647
  dlog(`🧹 Turn-end: Cleaned ${cleanResult.cleaned} junk lines`);
1473
1648
  evictionDebugLog('CONTINUOUS_CLEAN', `Cleaned ${cleanResult.cleaned} junk lines`);
1474
1649
  }
1475
- // Then run eviction if needed (disabled when proxy handles it, or via EKKOS_DISABLE_EVICTION=1)
1476
- // CRITICAL: When proxy mode is enabled, the proxy does seamless eviction - local JSONL eviction must be disabled
1650
+ // Then run eviction if needed (local mode only - proxy handles eviction in proxy mode)
1477
1651
  // eslint-disable-next-line no-restricted-syntax -- Feature flag, not API key
1478
1652
  const evictionDisabled = process.env.EKKOS_DISABLE_EVICTION === '1' || proxyModeEnabled;
1479
1653
  if (evictionDisabled) {
1480
1654
  evictionDebugLog('EVICTION_DISABLED', proxyModeEnabled
1481
- ? 'Eviction disabled - proxy handles context management'
1655
+ ? 'Proxy is sole eviction authority - CLI does not touch local JSONL'
1482
1656
  : 'Eviction disabled via EKKOS_DISABLE_EVICTION=1');
1483
1657
  }
1484
1658
  else if ((0, jsonl_rewriter_1.needsEviction)(lastContextPercent)) {
@@ -2148,17 +2322,8 @@ async function run(options) {
2148
2322
  // Also update global state for backwards compatibility
2149
2323
  (0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
2150
2324
  dlog(`Session detected from UUID: ${currentSession}`);
2151
- // Try to find/construct transcript path from session ID
2152
- if (!transcriptPath) {
2153
- const encodedCwd = process.cwd().replace(/\//g, '-');
2154
- const possibleTranscript = path.join(os.homedir(), '.claude', 'projects', encodedCwd, `${currentSessionId}.jsonl`);
2155
- if (fs.existsSync(possibleTranscript)) {
2156
- transcriptPath = possibleTranscript;
2157
- evictionDebugLog('TRANSCRIPT_SET', 'Set from session ID', { transcriptPath, source: 'sessionId' });
2158
- dlog(`Found transcript from session ID: ${transcriptPath}`);
2159
- startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
2160
- }
2161
- }
2325
+ resolveTranscriptFromSessionId('session-id-from-output');
2326
+ bindRealSessionToProxy(currentSession, 'session-id-from-output');
2162
2327
  }
2163
2328
  // ════════════════════════════════════════════════════════════════════════
2164
2329
  // SESSION NAME DETECTION (PRIMARY METHOD)
@@ -2175,69 +2340,14 @@ async function run(options) {
2175
2340
  dlog(`Session rejected (invalid words): ${detectedSession}`);
2176
2341
  }
2177
2342
  else if (detectedSession !== lastSeenSessionName) {
2178
- // Only update if different (avoid log spam)
2179
- lastSeenSessionName = detectedSession;
2180
- lastSeenSessionAt = Date.now();
2181
- currentSession = lastSeenSessionName;
2182
- observedSessionThisRun = true; // Mark that we've seen a session in THIS process
2183
- // Update THIS process's session entry (not global state.json)
2184
- (0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
2185
- // Also update global state for backwards compatibility
2186
- (0, state_1.updateState)({ sessionName: currentSession });
2187
- dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
2188
- // Try to start stream tailer - scan for matching transcript file
2189
- dlog(`[TRANSCRIPT_SCAN] streamTailer=${!!streamTailer}, transcriptPath=${transcriptPath || 'NULL'}`);
2190
- if (!streamTailer) {
2191
- const encodedCwd = process.cwd().replace(/\//g, '-');
2192
- const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedCwd);
2193
- dlog(`[TRANSCRIPT_SCAN] Scanning projectDir: ${projectDir}`);
2194
- try {
2195
- const files = fs.readdirSync(projectDir);
2196
- dlog(`[TRANSCRIPT_SCAN] Found ${files.length} files in projectDir`);
2197
- // Find most recent .jsonl file (likely current session)
2198
- const jsonlFiles = files
2199
- .filter(f => f.endsWith('.jsonl'))
2200
- .map(f => ({
2201
- name: f,
2202
- path: path.join(projectDir, f),
2203
- mtime: fs.statSync(path.join(projectDir, f)).mtimeMs
2204
- }))
2205
- .sort((a, b) => b.mtime - a.mtime);
2206
- dlog(`[TRANSCRIPT_SCAN] Found ${jsonlFiles.length} .jsonl files`);
2207
- if (jsonlFiles.length > 0) {
2208
- transcriptPath = jsonlFiles[0].path;
2209
- currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
2210
- dlog(`[TRANSCRIPT_SCAN] SUCCESS! transcriptPath=${transcriptPath}`);
2211
- evictionDebugLog('TRANSCRIPT_SET', 'Set from session name detection', { transcriptPath, currentSessionId });
2212
- startStreamTailer(transcriptPath, currentSessionId, currentSession);
2213
- }
2214
- else {
2215
- dlog(`[TRANSCRIPT_SCAN] No jsonl files found!`);
2216
- }
2217
- }
2218
- catch (err) {
2219
- dlog(`[TRANSCRIPT_SCAN] ERROR: ${err.message}`);
2220
- }
2343
+ // Do not allow session switching from parsed output once a run has
2344
+ // already locked onto a session. This prevents rebinding from echoed
2345
+ // status/footer text in model responses (e.g. pasted transcripts).
2346
+ if (observedSessionThisRun && currentSession && detectedSession !== currentSession) {
2347
+ dlog(`Ignoring session switch from status line: ${currentSession} -> ${detectedSession}`);
2221
2348
  }
2222
2349
  else {
2223
- dlog(`[TRANSCRIPT_SCAN] Skipped - streamTailer already running`);
2224
- }
2225
- }
2226
- else {
2227
- // Same session, just update timestamp
2228
- lastSeenSessionAt = Date.now();
2229
- }
2230
- }
2231
- else {
2232
- // Weaker signal: any 3-word slug (only if no status match)
2233
- const anyMatch = plain.match(SESSION_NAME_REGEX);
2234
- if (anyMatch) {
2235
- const detectedSession = anyMatch[1].toLowerCase();
2236
- // Validate against word lists (SOURCE OF TRUTH)
2237
- if (!isValidSessionName(detectedSession)) {
2238
- dlog(`Session rejected (invalid words): ${detectedSession}`);
2239
- }
2240
- else if (detectedSession !== lastSeenSessionName) {
2350
+ // Only update if different (avoid log spam)
2241
2351
  lastSeenSessionName = detectedSession;
2242
2352
  lastSeenSessionAt = Date.now();
2243
2353
  currentSession = lastSeenSessionName;
@@ -2246,13 +2356,27 @@ async function run(options) {
2246
2356
  (0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
2247
2357
  // Also update global state for backwards compatibility
2248
2358
  (0, state_1.updateState)({ sessionName: currentSession });
2249
- dlog(`Session detected from generic match: ${currentSession} (observedSessionThisRun=true)`);
2359
+ dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
2360
+ bindRealSessionToProxy(currentSession, 'status-line');
2361
+ resolveTranscriptFromSessionId('status-line');
2250
2362
  }
2251
- else {
2252
- lastSeenSessionAt = Date.now();
2363
+ }
2364
+ else {
2365
+ // Same session, just update timestamp
2366
+ lastSeenSessionAt = Date.now();
2367
+ if (boundProxySession !== detectedSession) {
2368
+ bindRealSessionToProxy(detectedSession, 'status-line-refresh');
2253
2369
  }
2254
2370
  }
2255
2371
  }
2372
+ else {
2373
+ // Weak signal: any 3-word slug can appear in arbitrary output.
2374
+ // Ignore it to avoid accidental cross-session rebinding.
2375
+ const anyMatch = plain.match(SESSION_NAME_REGEX);
2376
+ if (anyMatch && !observedSessionThisRun) {
2377
+ dlog(`Ignoring weak session candidate (awaiting strong signal): ${anyMatch[1].toLowerCase()}`);
2378
+ }
2379
+ }
2256
2380
  // ══════════════════════════════════════════════════════════════════════════
2257
2381
  // TURN-END EVICTION - Track context % and run cleanup when Claude goes idle
2258
2382
  // This is MUCH safer than mid-stream eviction because:
@@ -2261,7 +2385,7 @@ async function run(options) {
2261
2385
  // 3. Claude Code is between operations
2262
2386
  // ══════════════════════════════════════════════════════════════════════════
2263
2387
  // ════════════════════════════════════════════════════════════════════════
2264
- // CONTEXT % CALCULATION - Only when proxy mode is OFF (hook handles it otherwise)
2388
+ // CONTEXT % CALCULATION - Local mode only (proxy handles its own token tracking)
2265
2389
  // ════════════════════════════════════════════════════════════════════════
2266
2390
  if (!proxyModeEnabled) {
2267
2391
  // Track context percentage - PRIMARY: calculate from JSONL file size
@@ -2288,8 +2412,7 @@ async function run(options) {
2288
2412
  }
2289
2413
  }
2290
2414
  // ════════════════════════════════════════════════════════════════════════
2291
- // CONTINUOUS CLEANUP - Runs ALWAYS (proxy handles big evictions, local handles junk)
2292
- // handleTurnEnd() has internal check to skip threshold eviction when proxy is on
2415
+ // TURN-END MAINTENANCE - Local mode only (proxy is sole eviction authority)
2293
2416
  // ════════════════════════════════════════════════════════════════════════
2294
2417
  // Detect idle prompt (turn end) and schedule cleanup
2295
2418
  const strippedOutput = stripAnsi(outputBuffer);
@@ -2303,7 +2426,7 @@ async function run(options) {
2303
2426
  outputBufferEnd: strippedOutput.slice(-100),
2304
2427
  });
2305
2428
  }
2306
- if (idlePromptDetected && transcriptPath && !isAutoClearInProgress) {
2429
+ if (!proxyModeEnabled && idlePromptDetected && transcriptPath && !isAutoClearInProgress) {
2307
2430
  // Cancel any existing timer
2308
2431
  if (turnEndTimeout) {
2309
2432
  clearTimeout(turnEndTimeout);
@@ -2317,7 +2440,7 @@ async function run(options) {
2317
2440
  }, TURN_END_STABLE_MS);
2318
2441
  }
2319
2442
  // SLIDING WINDOW: Inject /clear after eviction to force transcript reload
2320
- if (idlePromptDetected && pendingClearAfterEviction && !isAutoClearInProgress) {
2443
+ if (!proxyModeEnabled && idlePromptDetected && pendingClearAfterEviction && !isAutoClearInProgress) {
2321
2444
  pendingClearAfterEviction = false;
2322
2445
  isAutoClearInProgress = true;
2323
2446
  dlog('🔄 SLIDING WINDOW: Injecting /clear to reload evicted transcript');
@@ -2350,8 +2473,8 @@ async function run(options) {
2350
2473
  }
2351
2474
  })();
2352
2475
  }
2353
- // BACKUP: Context wall detection - emergency evict
2354
- if (!isAutoClearInProgress && transcriptPath) {
2476
+ // BACKUP: Context wall detection - emergency evict (all modes)
2477
+ if (!proxyModeEnabled && !isAutoClearInProgress && transcriptPath) {
2355
2478
  const normalized = normalizeForMatch(outputBuffer);
2356
2479
  if (CONTEXT_WALL_REGEX.test(normalized)) {
2357
2480
  dlog('⚠️ CONTEXT WALL - emergency evict to 50%');
@@ -2368,6 +2491,36 @@ async function run(options) {
2368
2491
  }
2369
2492
  });
2370
2493
  // ══════════════════════════════════════════════════════════════════════════
2494
+ // KICKSTART MODE: Auto-send "test" to create session immediately
2495
+ // Used by --dashboard to eliminate wait for first user message
2496
+ // ══════════════════════════════════════════════════════════════════════════
2497
+ if (options.kickstart) {
2498
+ dlog('Kickstart mode enabled - will auto-send "test" to create session');
2499
+ setTimeout(async () => {
2500
+ dlog('Starting kickstart injection...');
2501
+ const readiness = await waitForIdlePrompt(getOutputBuffer, config);
2502
+ if (!readiness.ready || readiness.interrupted) {
2503
+ dlog('Claude not ready for kickstart - aborting');
2504
+ return;
2505
+ }
2506
+ // PAUSE STDIN during injection
2507
+ process.stdin.off('data', onStdinData);
2508
+ dlog('Stdin paused during kickstart');
2509
+ try {
2510
+ shell.write('\x15'); // Ctrl+U - clear any existing input
2511
+ await sleep(60);
2512
+ await typeSlowly(shell, 'test', config.charDelayMs);
2513
+ await sleep(100);
2514
+ shell.write('\r'); // Enter
2515
+ dlog('Kickstart "test" sent - session should be created');
2516
+ }
2517
+ finally {
2518
+ process.stdin.on('data', onStdinData);
2519
+ dlog('Stdin resumed after kickstart');
2520
+ }
2521
+ }, 3000); // 3s for Claude to initialize
2522
+ }
2523
+ // ══════════════════════════════════════════════════════════════════════════
2371
2524
  // RESEARCH MODE: Auto-type research prompt after Claude is ready
2372
2525
  // Triggers: `ekkos run -r` or `ekkos run --research`
2373
2526
  // Works like /clear continue - waits for idle prompt, then injects text