@ekkos/cli 0.2.18 → 1.0.0

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 (98) 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/eviction-client.d.ts +139 -0
  9. package/dist/capture/eviction-client.js +454 -0
  10. package/dist/capture/index.d.ts +2 -0
  11. package/dist/capture/index.js +2 -0
  12. package/dist/capture/jsonl-rewriter.d.ts +96 -0
  13. package/dist/capture/jsonl-rewriter.js +1369 -0
  14. package/dist/capture/transcript-repair.d.ts +51 -0
  15. package/dist/capture/transcript-repair.js +319 -0
  16. package/dist/commands/agent.d.ts +6 -0
  17. package/dist/commands/agent.js +244 -0
  18. package/dist/commands/dashboard.d.ts +25 -0
  19. package/dist/commands/dashboard.js +1175 -0
  20. package/dist/commands/doctor.js +23 -1
  21. package/dist/commands/run.d.ts +5 -0
  22. package/dist/commands/run.js +1605 -516
  23. package/dist/commands/setup-remote.js +146 -37
  24. package/dist/commands/swarm-dashboard.d.ts +20 -0
  25. package/dist/commands/swarm-dashboard.js +735 -0
  26. package/dist/commands/swarm-setup.d.ts +10 -0
  27. package/dist/commands/swarm-setup.js +956 -0
  28. package/dist/commands/swarm.d.ts +46 -0
  29. package/dist/commands/swarm.js +441 -0
  30. package/dist/commands/test-claude.d.ts +16 -0
  31. package/dist/commands/test-claude.js +156 -0
  32. package/dist/commands/usage/blocks.d.ts +8 -0
  33. package/dist/commands/usage/blocks.js +60 -0
  34. package/dist/commands/usage/daily.d.ts +9 -0
  35. package/dist/commands/usage/daily.js +96 -0
  36. package/dist/commands/usage/dashboard.d.ts +8 -0
  37. package/dist/commands/usage/dashboard.js +104 -0
  38. package/dist/commands/usage/formatters.d.ts +41 -0
  39. package/dist/commands/usage/formatters.js +147 -0
  40. package/dist/commands/usage/index.d.ts +13 -0
  41. package/dist/commands/usage/index.js +87 -0
  42. package/dist/commands/usage/monthly.d.ts +8 -0
  43. package/dist/commands/usage/monthly.js +66 -0
  44. package/dist/commands/usage/session.d.ts +11 -0
  45. package/dist/commands/usage/session.js +193 -0
  46. package/dist/commands/usage/weekly.d.ts +9 -0
  47. package/dist/commands/usage/weekly.js +61 -0
  48. package/dist/commands/usage.d.ts +7 -0
  49. package/dist/commands/usage.js +214 -0
  50. package/dist/cron/index.d.ts +7 -0
  51. package/dist/cron/index.js +13 -0
  52. package/dist/cron/promoter.d.ts +70 -0
  53. package/dist/cron/promoter.js +403 -0
  54. package/dist/deploy/instructions.d.ts +5 -2
  55. package/dist/deploy/instructions.js +11 -8
  56. package/dist/index.js +262 -5
  57. package/dist/lib/tmux-scrollbar.d.ts +14 -0
  58. package/dist/lib/tmux-scrollbar.js +296 -0
  59. package/dist/lib/usage-monitor.d.ts +47 -0
  60. package/dist/lib/usage-monitor.js +124 -0
  61. package/dist/lib/usage-parser.d.ts +162 -0
  62. package/dist/lib/usage-parser.js +583 -0
  63. package/dist/restore/RestoreOrchestrator.d.ts +4 -0
  64. package/dist/restore/RestoreOrchestrator.js +118 -30
  65. package/dist/utils/log-rotate.d.ts +18 -0
  66. package/dist/utils/log-rotate.js +74 -0
  67. package/dist/utils/platform.d.ts +2 -0
  68. package/dist/utils/platform.js +3 -1
  69. package/dist/utils/session-binding.d.ts +5 -0
  70. package/dist/utils/session-binding.js +46 -0
  71. package/dist/utils/state.js +4 -0
  72. package/dist/utils/verify-remote-terminal.d.ts +10 -0
  73. package/dist/utils/verify-remote-terminal.js +415 -0
  74. package/package.json +9 -2
  75. package/templates/CLAUDE.md +135 -23
  76. package/templates/ekkos-manifest.json +5 -5
  77. package/templates/hooks/lib/contract.sh +43 -31
  78. package/templates/hooks/lib/count-tokens.cjs +86 -0
  79. package/templates/hooks/lib/ekkos-reminders.sh +98 -0
  80. package/templates/hooks/lib/state.sh +53 -1
  81. package/templates/hooks/stop.sh +150 -388
  82. package/templates/hooks/user-prompt-submit.sh +353 -443
  83. package/templates/windsurf-hooks/README.md +212 -0
  84. package/templates/windsurf-hooks/hooks.json +9 -2
  85. package/templates/windsurf-hooks/install.sh +148 -0
  86. package/templates/windsurf-hooks/lib/contract.sh +2 -0
  87. package/templates/windsurf-hooks/post-cascade-response.sh +251 -0
  88. package/templates/windsurf-hooks/pre-user-prompt.sh +435 -0
  89. package/templates/windsurf-skills/ekkos-memory/SKILL.md +219 -0
  90. package/templates/agents/README.md +0 -182
  91. package/templates/agents/code-reviewer.md +0 -166
  92. package/templates/agents/debug-detective.md +0 -169
  93. package/templates/agents/ekkOS_Vercel.md +0 -99
  94. package/templates/agents/extension-manager.md +0 -229
  95. package/templates/agents/git-companion.md +0 -185
  96. package/templates/agents/github-test-agent.md +0 -321
  97. package/templates/agents/railway-manager.md +0 -215
  98. package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
@@ -38,22 +38,168 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.run = run;
40
40
  const chalk_1 = __importDefault(require("chalk"));
41
+ const crypto = __importStar(require("crypto"));
41
42
  const fs = __importStar(require("fs"));
42
43
  const path = __importStar(require("path"));
43
44
  const os = __importStar(require("os"));
44
45
  const child_process_1 = require("child_process");
46
+ // ═══════════════════════════════════════════════════════════════════════════
47
+ // ccDNA AUTO-LOAD: Apply Claude Code patches before spawning
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ const CCDNA_PATHS = [
50
+ // Development path (DEV sibling directory)
51
+ // From: EKKOS/packages/ekkos-cli/dist/commands/ → DEV/ekkos-ccdna/
52
+ path.join(__dirname, '..', '..', '..', '..', '..', 'ekkos-ccdna', 'dist', 'index.mjs'),
53
+ // User install path
54
+ path.join(os.homedir(), '.ekkos', 'ccdna', 'dist', 'index.mjs'),
55
+ // npm global (homebrew)
56
+ '/opt/homebrew/lib/node_modules/ekkos-ccdna/dist/index.mjs',
57
+ // npm global (standard)
58
+ path.join(os.homedir(), '.npm-global', 'lib', 'node_modules', 'ekkos-ccdna', 'dist', 'index.mjs'),
59
+ ];
60
+ /**
61
+ * Find ccDNA installation path
62
+ */
63
+ function findCcdnaPath() {
64
+ for (const p of CCDNA_PATHS) {
65
+ if (fs.existsSync(p)) {
66
+ return p;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ /**
72
+ * Apply ccDNA patches silently before Claude spawns
73
+ * Returns version string if patches were applied, null otherwise
74
+ *
75
+ * @param verbose - Show detailed output
76
+ * @param claudePath - Path to Claude Code to patch (if different from default)
77
+ */
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
+ /*
89
+ const ccdnaPath = findCcdnaPath();
90
+ if (!ccdnaPath) {
91
+ if (verbose) {
92
+ console.log(chalk.gray(' ccDNA not found - skipping patches'));
93
+ }
94
+ return null;
95
+ }
96
+
97
+ // Read ccDNA version from package.json FIRST
98
+ let ccdnaVersion = 'unknown';
99
+ try {
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
+
109
+ try {
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
+ */
136
+ }
137
+ /**
138
+ * Restore original Claude Code (remove ccDNA patches) on exit
139
+ * This restores the ekkOS-managed installation (~/.ekkos/claude-code/) to its base state
140
+ *
141
+ * NOTE: We intentionally DON'T restore on exit anymore because:
142
+ * 1. ekkOS uses a SEPARATE installation (~/.ekkos/claude-code/) from homebrew
143
+ * 2. The homebrew `claude` command should always be vanilla (untouched)
144
+ * 3. The ekkOS installation can stay patched - it's only used by `ekkos run`
145
+ *
146
+ * This function is kept for manual/explicit restore scenarios.
147
+ */
148
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
149
+ function restoreCcdnaPatches(verbose, claudePath) {
150
+ const ccdnaPath = findCcdnaPath();
151
+ if (!ccdnaPath) {
152
+ return false;
153
+ }
154
+ try {
155
+ // Set env var to tell ccDNA which Claude to restore
156
+ // eslint-disable-next-line no-restricted-syntax
157
+ const env = { ...process.env };
158
+ if (claudePath) {
159
+ env.CCDNA_CC_INSTALLATION_PATH = claudePath;
160
+ }
161
+ // Run ccDNA in restore mode (non-interactive)
162
+ (0, child_process_1.execSync)(`node "${ccdnaPath}" -r`, {
163
+ stdio: verbose ? 'inherit' : 'pipe',
164
+ timeout: 30000, // 30 second timeout
165
+ env,
166
+ });
167
+ if (verbose) {
168
+ console.log(chalk_1.default.green(' ✓ ccDNA patches removed (vanilla restored)'));
169
+ }
170
+ return true;
171
+ }
172
+ catch (err) {
173
+ if (verbose) {
174
+ console.log(chalk_1.default.yellow(` ⚠ ccDNA restore failed: ${err.message}`));
175
+ }
176
+ return false;
177
+ }
178
+ }
45
179
  const state_1 = require("../utils/state");
180
+ const session_binding_1 = require("../utils/session-binding");
46
181
  const doctor_1 = require("./doctor");
47
182
  const stream_tailer_1 = require("../capture/stream-tailer");
183
+ const jsonl_rewriter_1 = require("../capture/jsonl-rewriter");
184
+ const transcript_repair_1 = require("../capture/transcript-repair");
48
185
  // Try to load node-pty (may fail on Node 24+)
186
+ // IMPORTANT: This must be awaited in run() to avoid racey false fallbacks.
49
187
  let pty = null;
50
- try {
51
- pty = require('node-pty');
52
- }
53
- catch {
54
- // node-pty not available, will use spawn fallback
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;
55
200
  }
56
201
  function getConfig(options) {
202
+ /* eslint-disable no-restricted-syntax -- Config timing values, not API keys */
57
203
  return {
58
204
  slashOpenDelayMs: options.slashOpenDelayMs ??
59
205
  parseInt(process.env.EKKOS_SLASH_OPEN_DELAY_MS || '500', 10), // was 1000
@@ -70,6 +216,7 @@ function getConfig(options) {
70
216
  process.env.EKKOS_DEBUG_LOG_PATH ??
71
217
  path.join(os.homedir(), '.ekkos', 'auto-continue.debug.log')
72
218
  };
219
+ /* eslint-enable no-restricted-syntax */
73
220
  }
74
221
  // ═══════════════════════════════════════════════════════════════════════════
75
222
  // PATTERN MATCHING
@@ -103,11 +250,27 @@ const PALETTE_INDICATOR_REGEX = /\/(clear|continue|compact|help|bug|config)/i;
103
250
  // SESSION NAME DETECTION (3-word slug: word-word-word)
104
251
  // Claude prints session name in footer: · Turn N · groovy-koala-saves · 📅
105
252
  // ═══════════════════════════════════════════════════════════════════════════
106
- // Strong signal: session name between dot separators in Claude status/footer line
107
- // Matches:groovy-koala-saves ·" or velvet-monk-skips ·"
108
- 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;
109
256
  // Weaker signal: any 3-word slug (word-word-word pattern)
110
257
  const SESSION_NAME_REGEX = /\b([a-z]+-[a-z]+-[a-z]+)\b/i;
258
+ // Orphan tool_result marker emitted by ccDNA validate mode
259
+ // Example: [ekkOS] ORPHAN_TOOL_RESULT {"idx":0,"tool_use_id":"toolu_01...","block_idx":0}
260
+ const ORPHAN_MARKER_REGEX = /\[ekkOS\]\s+ORPHAN_TOOL_RESULT\s+(\{.*?\})/gi;
261
+ // Cooldown to prevent thrashing if output repeats the marker
262
+ const ORPHAN_DETECTION_COOLDOWN_MS = 15000;
263
+ // ═══════════════════════════════════════════════════════════════════════════
264
+ // SILENT FAILURE DETECTION - Catch API errors even when ccDNA markers missing
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+ // Pattern 1: API returns 400 error (often due to orphan tool_results)
267
+ const API_400_REGEX = /(?:status[:\s]*400|"status":\s*400|HTTP\/\d\.\d\s+400|error.*400)/i;
268
+ // Pattern 2: Anthropic API specific error about tool_result without tool_use
269
+ const ORPHAN_API_ERROR_REGEX = /tool_result.*(?:no matching|without|missing).*tool_use|tool_use.*not found/i;
270
+ // Pattern 3: Generic "invalid" message structure error
271
+ const INVALID_MESSAGE_REGEX = /invalid.*message|message.*invalid|malformed.*request/i;
272
+ // Cooldown for silent failure detection (separate from orphan marker cooldown)
273
+ const SILENT_FAILURE_COOLDOWN_MS = 30000;
111
274
  // ═══════════════════════════════════════════════════════════════════════════
112
275
  // SESSION NAME VALIDATION (MUST use words from session-words.json)
113
276
  // This is the SOURCE OF TRUTH for valid session names
@@ -222,17 +385,29 @@ async function runSlashCommand(shell, command, config, getOutputBuffer, arg) {
222
385
  await typeSlowly(shell, '/', config.charDelayMs);
223
386
  dlog('Typed / to open palette');
224
387
  // STEP 3: Wait for palette to open
225
- await sleep(config.slashOpenDelayMs);
388
+ // Improved: Poll for palette indicator instead of hard sleep.
389
+ // This reduces latency on fast machines while ensuring safety on slow ones.
390
+ let paletteVisible = false;
391
+ const paletteStartTime = Date.now();
392
+ const maxWait = config.slashOpenDelayMs + config.paletteRetryMs;
393
+ while (Date.now() - paletteStartTime < maxWait) {
394
+ const currentBuffer = getOutputBuffer();
395
+ if (PALETTE_INDICATOR_REGEX.test(stripAnsi(currentBuffer))) {
396
+ paletteVisible = true;
397
+ dlog(`Palette detected after ${Date.now() - paletteStartTime}ms`);
398
+ break;
399
+ }
400
+ await sleep(50);
401
+ }
226
402
  // STEP 4: Check if palette opened (look for command indicators in buffer)
227
- const bufferAfterSlash = getOutputBuffer();
228
- if (!PALETTE_INDICATOR_REGEX.test(stripAnsi(bufferAfterSlash))) {
229
- // Palette might not have opened - retry once
230
- dlog('Palette indicator not detected, retrying with extra wait');
231
- await sleep(config.paletteRetryMs);
232
- // Type / again in case first one was eaten
233
- shell.write('\x15'); // Clear line again
234
- await sleep(60);
403
+ if (!paletteVisible) {
404
+ // Palette definitely didn't open - retry once
405
+ dlog('Palette indicator not detected after polling, retrying with force clear');
406
+ // Type / again in case first one was eaten or stuck in mid-render
407
+ shell.write('\x15'); // Ctrl+U
408
+ await sleep(100);
235
409
  await typeSlowly(shell, '/', config.charDelayMs);
410
+ // Brief wait for the second attempt
236
411
  await sleep(config.slashOpenDelayMs);
237
412
  }
238
413
  // STEP 5: Type the command
@@ -254,58 +429,195 @@ async function runSlashCommand(shell, command, config, getOutputBuffer, arg) {
254
429
  // Memory API URL
255
430
  const MEMORY_API_URL = 'https://mcp.ekkos.dev';
256
431
  const isWindows = os.platform() === 'win32';
257
- // Pinned Claude Code version for ekkos run
258
- // 2.1.6 has the old context calculation (95% of full 200K, not effective window)
259
- // NOTE: Homebrew global installs may be broken, but npm installs work fine
260
- const PINNED_CLAUDE_VERSION = '2.1.6';
432
+ // Claude Code version for ekkos run
433
+ // 'latest' = use latest version, or specify like '2.1.33' for specific version
434
+ // Core ekkOS patches (eviction, context management) work with all recent versions
435
+ // Cosmetic patches may fail on newer versions but don't affect functionality
436
+ const PINNED_CLAUDE_VERSION = '2.1.45';
437
+ // Max output tokens for Claude responses
438
+ // Default: 16384 (safe for Sonnet 4.5)
439
+ // Opus 4.5 supports up to 64k - set EKKOS_MAX_OUTPUT_TOKENS=32768 or =65536 to use higher limits
440
+ // Configurable via environment variable
441
+ const EKKOS_MAX_OUTPUT_TOKENS = process.env.EKKOS_MAX_OUTPUT_TOKENS || '16384';
442
+ // Default proxy URL for context eviction
443
+ // eslint-disable-next-line no-restricted-syntax -- Config URL, not API key
444
+ const EKKOS_PROXY_URL = process.env.EKKOS_PROXY_URL || 'https://proxy.ekkos.dev';
445
+ // Track proxy mode for getEkkosEnv (set by run() based on options)
446
+ let proxyModeEnabled = true;
447
+ // ═══════════════════════════════════════════════════════════════════════════
448
+ // SESSION NAME GENERATION - Uses shared uuidToWords from state.ts
449
+ // ═══════════════════════════════════════════════════════════════════════════
450
+ /**
451
+ * Generate a unique session UUID and convert to human-readable name
452
+ * Each CLI invocation gets a NEW session (not tied to project path)
453
+ * Uses uuidToWords from state.ts for consistency with hooks
454
+ */
455
+ function generateCliSessionName() {
456
+ const sessionUuid = crypto.randomUUID();
457
+ return (0, state_1.uuidToWords)(sessionUuid);
458
+ }
459
+ // Track current CLI session name (set once at startup, stable for entire run)
460
+ let cliSessionName = null;
461
+ let cliSessionId = null;
462
+ /**
463
+ * Get environment with ekkOS enhancements
464
+ * - Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS to 32k for longer responses
465
+ * - Routes API through ekkOS proxy for seamless context eviction (when enabled)
466
+ * - Sets EKKOS_PROXY_MODE to signal JSONL rewriter to disable eviction
467
+ * - Passes session headers for eviction/retrieval context tracking
468
+ */
469
+ function getEkkosEnv() {
470
+ /* eslint-disable no-restricted-syntax -- System env spreading, not API key access */
471
+ const env = {
472
+ ...process.env,
473
+ // Let Claude Code use its own default max_tokens (don't override)
474
+ };
475
+ /* eslint-enable no-restricted-syntax */
476
+ // Check if proxy is disabled via env var or options
477
+ // eslint-disable-next-line no-restricted-syntax -- Feature flag, not API key
478
+ const proxyDisabled = process.env.EKKOS_DISABLE_PROXY === '1' || !proxyModeEnabled;
479
+ if (!proxyDisabled) {
480
+ env.EKKOS_PROXY_MODE = '1';
481
+ // Enable ultra-minimal mode by default (30%→20% eviction for constant-cost infinite context)
482
+ env.EKKOS_ULTRA_MINIMAL = '1';
483
+ // Use placeholder for session name - will be bound by hook with Claude's real session
484
+ // This fixes the mismatch where CLI generated one name but Claude Code used another
485
+ // The hook calls POST /proxy/session/bind with Claude's actual session name
486
+ if (!cliSessionName) {
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;
495
+ // Get full userId from config (NOT the truncated version from auth token)
496
+ // Config has full UUID like "d4532ba0-0a86-42ce-bab4-22aa62b55ce6"
497
+ // This matches the turns/ R2 structure: turns/{fullUserId}/{sessionName}/
498
+ const ekkosConfig = (0, state_1.getConfig)();
499
+ let userId = ekkosConfig?.userId || 'anonymous';
500
+ // Fallback to auth token extraction if config doesn't have userId
501
+ if (userId === 'anonymous') {
502
+ const authToken = (0, state_1.getAuthToken)();
503
+ if (authToken?.startsWith('ekk_')) {
504
+ const parts = authToken.split('_');
505
+ if (parts.length >= 2) {
506
+ userId = parts[1];
507
+ }
508
+ }
509
+ }
510
+ // CRITICAL: Embed user/session in URL path since ANTHROPIC_HEADERS doesn't work
511
+ // Claude Code SDK doesn't forward custom headers, but it DOES use ANTHROPIC_BASE_URL
512
+ // Format: https://mcp.ekkos.dev/proxy/{userId}/{sessionName}?project={base64(cwd)}
513
+ // Gateway extracts from URL: /proxy/{userId}/{sessionName}/v1/messages
514
+ // Project path is base64-encoded to handle special chars safely
515
+ const projectPath = process.cwd();
516
+ const projectPathEncoded = Buffer.from(projectPath).toString('base64url');
517
+ const proxyUrl = `${EKKOS_PROXY_URL}/proxy/${encodeURIComponent(userId)}/${encodeURIComponent(cliSessionName)}?project=${projectPathEncoded}`;
518
+ env.ANTHROPIC_BASE_URL = proxyUrl;
519
+ // Proxy URL contains userId + project path — don't leak to terminal
520
+ }
521
+ else {
522
+ env.EKKOS_PROXY_MODE = '0';
523
+ }
524
+ return env;
525
+ }
261
526
  // ekkOS-managed Claude installation path
262
527
  const EKKOS_CLAUDE_DIR = path.join(os.homedir(), '.ekkos', 'claude-code');
263
528
  const EKKOS_CLAUDE_BIN = path.join(EKKOS_CLAUDE_DIR, 'node_modules', '.bin', 'claude');
264
529
  /**
265
- * Check if a Claude installation matches our required version
530
+ * Check if a Claude installation exists and get its version
531
+ * Returns version string if found, null otherwise
266
532
  */
267
- function checkClaudeVersion(claudePath) {
533
+ function getClaudeVersion(claudePath) {
268
534
  try {
269
535
  const version = (0, child_process_1.execSync)(`"${claudePath}" --version 2>/dev/null`, { encoding: 'utf-8' }).trim();
270
- // Version output is like "2.1.6 (Claude Code)" - extract the version number
271
- const match = version.match(/^(\d+\.\d+\.\d+)/);
272
- if (match) {
273
- return match[1] === PINNED_CLAUDE_VERSION;
274
- }
275
- return false;
536
+ // Look for pattern like "2.1.6 (Claude Code)" or just "2.1.6" anywhere in output
537
+ const match = version.match(/(\d+\.\d+\.\d+)\s*\(Claude Code\)/);
538
+ if (match)
539
+ return match[1];
540
+ const fallbackMatch = version.match(/(\d+\.\d+\.\d+)/);
541
+ if (fallbackMatch)
542
+ return fallbackMatch[1];
543
+ return null;
276
544
  }
277
545
  catch {
278
- return false;
546
+ return null;
279
547
  }
280
548
  }
549
+ /**
550
+ * Check if a Claude installation matches our required version
551
+ * When PINNED_CLAUDE_VERSION is 'latest', any version is acceptable
552
+ */
553
+ function checkClaudeVersion(claudePath) {
554
+ const version = getClaudeVersion(claudePath);
555
+ if (!version)
556
+ return false;
557
+ // 'latest' means any version is acceptable
558
+ if (PINNED_CLAUDE_VERSION === 'latest')
559
+ return true;
560
+ return version === PINNED_CLAUDE_VERSION;
561
+ }
281
562
  /**
282
563
  * Install Claude Code to ekkOS-managed directory
283
564
  * This gives us full control over the version without npx auto-update messages
284
565
  */
285
566
  function installEkkosClaudeVersion() {
286
- console.log(chalk_1.default.cyan(`\n📦 Installing Claude Code v${PINNED_CLAUDE_VERSION} to ~/.ekkos/claude-code...`));
567
+ const versionLabel = PINNED_CLAUDE_VERSION === 'latest' ? 'latest' : `v${PINNED_CLAUDE_VERSION}`;
568
+ console.log(chalk_1.default.cyan(`\n📦 Installing Claude Code ${versionLabel} to ~/.ekkos/claude-code...`));
287
569
  console.log(chalk_1.default.gray(' (This is a one-time setup for optimal context window behavior)\n'));
288
570
  try {
289
571
  // Create directory if needed
290
572
  if (!fs.existsSync(EKKOS_CLAUDE_DIR)) {
291
573
  fs.mkdirSync(EKKOS_CLAUDE_DIR, { recursive: true });
292
574
  }
293
- // Initialize package.json if needed
575
+ // Clean existing installation to ensure correct version is installed
576
+ // This prevents npm from reusing a cached/different version
577
+ const nodeModulesPath = path.join(EKKOS_CLAUDE_DIR, 'node_modules');
578
+ const packageLockPath = path.join(EKKOS_CLAUDE_DIR, 'package-lock.json');
579
+ if (fs.existsSync(nodeModulesPath)) {
580
+ fs.rmSync(nodeModulesPath, { recursive: true, force: true });
581
+ }
582
+ if (fs.existsSync(packageLockPath)) {
583
+ fs.unlinkSync(packageLockPath);
584
+ }
585
+ // Always write fresh package.json with exact version pinned
294
586
  const packageJsonPath = path.join(EKKOS_CLAUDE_DIR, 'package.json');
295
- if (!fs.existsSync(packageJsonPath)) {
296
- fs.writeFileSync(packageJsonPath, JSON.stringify({
297
- name: 'ekkos-claude-code',
298
- version: '1.0.0',
299
- private: true,
300
- description: 'ekkOS-managed Claude Code installation'
301
- }, null, 2));
302
- }
303
- // Install specific version
304
- (0, child_process_1.execSync)(`npm install @anthropic-ai/claude-code@${PINNED_CLAUDE_VERSION}`, {
587
+ fs.writeFileSync(packageJsonPath, JSON.stringify({
588
+ name: 'ekkos-claude-code',
589
+ version: '1.0.0',
590
+ private: true,
591
+ description: 'ekkOS-managed Claude Code installation',
592
+ dependencies: {
593
+ '@anthropic-ai/claude-code': PINNED_CLAUDE_VERSION
594
+ }
595
+ }, null, 2));
596
+ // Install with exact version pinning
597
+ (0, child_process_1.execSync)(`npm install --save-exact`, {
305
598
  cwd: EKKOS_CLAUDE_DIR,
306
599
  stdio: 'inherit'
307
600
  });
308
- console.log(chalk_1.default.green(`\n✓ Claude Code v${PINNED_CLAUDE_VERSION} installed successfully!`));
601
+ // Verify the installed version
602
+ const installedPkgPath = path.join(EKKOS_CLAUDE_DIR, 'node_modules', '@anthropic-ai', 'claude-code', 'package.json');
603
+ let installedVersion = 'unknown';
604
+ if (fs.existsSync(installedPkgPath)) {
605
+ const installedPkg = JSON.parse(fs.readFileSync(installedPkgPath, 'utf-8'));
606
+ installedVersion = installedPkg.version;
607
+ // Only check version match if not using 'latest'
608
+ if (PINNED_CLAUDE_VERSION !== 'latest' && installedPkg.version !== PINNED_CLAUDE_VERSION) {
609
+ console.error(chalk_1.default.red(`\n✗ Version mismatch: expected ${PINNED_CLAUDE_VERSION}, got ${installedPkg.version}`));
610
+ console.log(chalk_1.default.yellow(' Trying to force correct version...\n'));
611
+ // Force reinstall with exact version
612
+ fs.rmSync(nodeModulesPath, { recursive: true, force: true });
613
+ (0, child_process_1.execSync)(`npm install @anthropic-ai/claude-code@${PINNED_CLAUDE_VERSION} --save-exact`, {
614
+ cwd: EKKOS_CLAUDE_DIR,
615
+ stdio: 'inherit'
616
+ });
617
+ installedVersion = PINNED_CLAUDE_VERSION;
618
+ }
619
+ }
620
+ console.log(chalk_1.default.green(`\n✓ Claude Code v${installedVersion} installed successfully!`));
309
621
  return true;
310
622
  }
311
623
  catch (err) {
@@ -318,54 +630,45 @@ function installEkkosClaudeVersion() {
318
630
  * Resolve full path to claude executable
319
631
  * Returns direct path if found with correct version, otherwise 'npx:VERSION'
320
632
  *
321
- * IMPORTANT: We MUST use version 2.1.6 specifically because Anthropic changed
322
- * context window calculation after this version. 2.1.6 uses 95% of full 200K,
323
- * newer versions use a different (more restrictive) effective window.
633
+ * IMPORTANT: We pin to a specific Claude Code version (currently 2.1.33) to ensure
634
+ * consistent behavior with ekkOS context management and eviction patches.
635
+ *
636
+ * CRITICAL: ekkos run ONLY uses the ekkOS-managed installation at ~/.ekkos/claude-code/
637
+ * This ensures complete separation from the user's existing Claude installation (Homebrew/npm).
638
+ * The user's `claude` command remains untouched and can be any version.
324
639
  *
325
640
  * Priority:
326
- * 1. ekkOS-managed installation (~/.ekkos/claude-code) - CLEANEST, auto-installed
327
- * 2. Homebrew/global install IF version matches 2.1.6
328
- * 3. npx with pinned version (fallback, shows update message)
641
+ * 1. ekkOS-managed installation (~/.ekkos/claude-code) - ONLY option for ekkos run
642
+ * 2. Auto-install if doesn't exist
643
+ * 3. npx with pinned version (fallback if install fails)
329
644
  */
330
645
  function resolveClaudePath() {
331
- // PRIORITY 1: ekkOS-managed installation (cleanest - no update messages)
646
+ // PRIORITY 1: ekkOS-managed installation
332
647
  if (fs.existsSync(EKKOS_CLAUDE_BIN) && checkClaudeVersion(EKKOS_CLAUDE_BIN)) {
333
648
  return EKKOS_CLAUDE_BIN;
334
649
  }
335
- // PRIORITY 2: Check Homebrew and global installations - only use if version matches
336
- const candidatePaths = [
337
- // Homebrew
338
- '/opt/homebrew/bin/claude', // macOS Apple Silicon
339
- '/usr/local/bin/claude', // macOS Intel
340
- '/home/linuxbrew/.linuxbrew/bin/claude', // Linux (system)
341
- path.join(os.homedir(), '.linuxbrew/bin/claude'), // Linux (user)
342
- // Global npm install
343
- path.join(os.homedir(), '.npm-global/bin/claude'),
344
- path.join(os.homedir(), '.local/bin/claude'),
345
- ];
346
- for (const p of candidatePaths) {
347
- if (fs.existsSync(p) && checkClaudeVersion(p)) {
348
- return p; // Direct path with correct version
349
- }
350
- }
351
- // PRIORITY 3: Auto-install to ekkOS-managed directory
650
+ // PRIORITY 2: Auto-install to ekkOS-managed directory (user's Claude stays untouched)
352
651
  if (installEkkosClaudeVersion()) {
353
652
  if (fs.existsSync(EKKOS_CLAUDE_BIN)) {
354
653
  return EKKOS_CLAUDE_BIN;
355
654
  }
356
655
  }
357
- // PRIORITY 4: Fall back to npx with pinned version (shows update message)
656
+ // PRIORITY 3: Fall back to npx with pinned version (shows update message)
657
+ // This is rare - only happens if install failed
358
658
  return `npx:${PINNED_CLAUDE_VERSION}`;
359
659
  }
360
660
  /**
361
661
  * Original resolve function for fallback to global install
362
662
  */
663
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
363
664
  function resolveGlobalClaudePath() {
364
665
  // Windows global paths
365
666
  if (isWindows) {
667
+ /* eslint-disable no-restricted-syntax -- System paths, not API keys */
366
668
  const windowsPaths = [
367
669
  path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
368
670
  path.join(process.env.LOCALAPPDATA || '', 'npm', 'claude.cmd'),
671
+ /* eslint-enable no-restricted-syntax */
369
672
  path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'claude.cmd'),
370
673
  path.join(os.homedir(), '.npm-global', 'claude.cmd')
371
674
  ];
@@ -467,7 +770,7 @@ async function emergencyCapture(transcriptPath, sessionId) {
467
770
  dlog('Context captured to ekkOS');
468
771
  }
469
772
  }
470
- catch (err) {
773
+ catch {
471
774
  // Silent fail - don't block the clear process
472
775
  dlog('Warning: Could not capture context');
473
776
  }
@@ -504,12 +807,117 @@ function cleanupInstanceFile(instanceId) {
504
807
  // Ignore cleanup errors
505
808
  }
506
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
+ }
507
889
  async function run(options) {
508
890
  const verbose = options.verbose || false;
509
891
  const bypass = options.bypass || false;
510
892
  const noInject = options.noInject || false;
893
+ // Set proxy mode based on options (used by getEkkosEnv)
894
+ proxyModeEnabled = !(options.noProxy || false);
895
+ if (proxyModeEnabled) {
896
+ console.log(chalk_1.default.cyan(' 🧠 ekkOS_Continuum Loaded!'));
897
+ }
898
+ else if (verbose) {
899
+ console.log(chalk_1.default.yellow(' ⏭️ API proxy disabled (--no-proxy)'));
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
+ }
511
918
  // Generate instance ID for this run
512
919
  const instanceId = generateInstanceId();
920
+ // eslint-disable-next-line no-restricted-syntax -- Instance tracking, not API key
513
921
  process.env.EKKOS_INSTANCE_ID = instanceId;
514
922
  // ══════════════════════════════════════════════════════════════════════════
515
923
  // PRE-FLIGHT DIAGNOSTICS (--doctor flag)
@@ -540,8 +948,43 @@ async function run(options) {
540
948
  // ══════════════════════════════════════════════════════════════════════════
541
949
  (0, state_1.ensureEkkosDir)();
542
950
  (0, state_1.clearAutoClearFlag)();
951
+ // Resolve Claude path FIRST so ccDNA patches the RIGHT installation
543
952
  const rawClaudePath = resolveClaudePath();
544
953
  const isNpxMode = rawClaudePath.startsWith('npx:');
954
+ // Get the actual CLI path for ccDNA to patch
955
+ // CRITICAL: ONLY patch the ekkOS-managed installation, NEVER touch Homebrew/global!
956
+ let claudeCliPath;
957
+ // Always target the ekkOS-managed installation for patching
958
+ // Even if we're running from Homebrew, we only patch our own installation
959
+ if (fs.existsSync(EKKOS_CLAUDE_BIN)) {
960
+ try {
961
+ const realPath = fs.realpathSync(EKKOS_CLAUDE_BIN);
962
+ if (realPath.endsWith('.js') && fs.existsSync(realPath)) {
963
+ claudeCliPath = realPath;
964
+ }
965
+ }
966
+ catch {
967
+ // Ignore - will use default detection
968
+ }
969
+ }
970
+ // ══════════════════════════════════════════════════════════════════════════
971
+ // ccDNA AUTO-PATCH: Apply Claude Code customizations before spawn
972
+ // This patches the context warning, themes, and other ccDNA features
973
+ // Skip if --no-dna flag is set
974
+ // ══════════════════════════════════════════════════════════════════════════
975
+ const noDna = options.noDna || false;
976
+ let ccdnaVersion = null;
977
+ if (noDna) {
978
+ if (verbose) {
979
+ console.log(chalk_1.default.yellow(' ⏭️ Skipping ccDNA injection (--no-dna)'));
980
+ }
981
+ }
982
+ else {
983
+ if (verbose && claudeCliPath) {
984
+ console.log(chalk_1.default.gray(` 🔧 Patching: ${claudeCliPath}`));
985
+ }
986
+ ccdnaVersion = applyCcdnaPatches(verbose, claudeCliPath);
987
+ }
545
988
  const pinnedVersion = isNpxMode ? rawClaudePath.split(':')[1] : null;
546
989
  const claudePath = isNpxMode ? 'npx' : rawClaudePath;
547
990
  // Build args early
@@ -552,9 +995,14 @@ async function run(options) {
552
995
  if (bypass) {
553
996
  earlyArgs.push('--dangerously-skip-permissions');
554
997
  }
555
- // Check PTY availability early
556
- const usePty = pty !== null;
557
- const monitorOnlyMode = noInject || (isWindows && !usePty);
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;
558
1006
  // ══════════════════════════════════════════════════════════════════════════
559
1007
  // CONCURRENT STARTUP: Spawn Claude while animation runs
560
1008
  // Buffer output until animation completes, then flush
@@ -574,7 +1022,7 @@ async function run(options) {
574
1022
  cols: process.stdout.columns || 80,
575
1023
  rows: process.stdout.rows || 24,
576
1024
  cwd: process.cwd(),
577
- env: process.env
1025
+ env: getEkkosEnv()
578
1026
  });
579
1027
  // Buffer output until animation completes using delegating handler
580
1028
  earlyDataHandler = (data) => {
@@ -603,180 +1051,205 @@ async function run(options) {
603
1051
  // ══════════════════════════════════════════════════════════════════════════
604
1052
  // STARTUP BANNER WITH COLOR PULSE ANIMATION
605
1053
  // ══════════════════════════════════════════════════════════════════════════
606
- const logoLines = [
607
- ' ▄▄▄▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄ ▄▄',
608
- ' ▄▄ ▄▄ ▄███████▄ █████▀▀▀ █ █ █',
609
- '▄█▀█▄ ██ ▄█▀ ██ ▄█▀ ███ ███ ▀████▄',
610
- '██▄█▀ ████ ████ ███▄▄▄███ ▀████',
611
- '▀█▄▄▄ ██ ▀█▄ ██ ▀█▄ ▀█████▀ ███████▀',
612
- ' ▄▄▄▄▄▄▄▄'
613
- ];
614
- // Color pulse sequence (magenta → cyan → blue → magenta cycle)
615
- const pulseColors = [
616
- chalk_1.default.magenta,
617
- chalk_1.default.hex('#FF69B4'), // Hot pink
618
- chalk_1.default.cyan,
619
- chalk_1.default.hex('#00CED1'), // Dark cyan
620
- chalk_1.default.blue,
621
- chalk_1.default.hex('#8A2BE2'), // Blue violet
622
- chalk_1.default.magenta
623
- ];
624
- // Print initial logo
625
- console.log('');
626
- logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
627
- // Animate: pulse through colors
628
- const PULSE_CYCLES = 2;
629
- const FRAME_DELAY_MS = 80;
630
- for (let cycle = 0; cycle < PULSE_CYCLES; cycle++) {
631
- for (const colorFn of pulseColors) {
632
- // Move cursor up to overwrite logo (6 lines)
633
- process.stdout.write('\x1B[6A');
634
- // Reprint with new color
635
- logoLines.forEach(line => console.log(colorFn(line)));
636
- await sleep(FRAME_DELAY_MS);
637
- }
638
- }
639
- // Final frame: settle on magenta
640
- process.stdout.write('\x1B[6A');
641
- logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
642
- // ══════════════════════════════════════════════════════════════════════════
643
- // SPARKLE EFFECT - Random characters flash white/cyan
644
- // ══════════════════════════════════════════════════════════════════════════
645
- const sparkleChars = ['▄', '█', '▀'];
646
- const sparkleColors = [chalk_1.default.white, chalk_1.default.whiteBright, chalk_1.default.cyanBright, chalk_1.default.yellowBright];
647
- const SPARKLE_FRAMES = 40; // ~3.2 seconds of sparkles
648
- const SPARKLE_DELAY_MS = 80;
649
- const SPARKLES_PER_FRAME = 3;
650
- for (let frame = 0; frame < SPARKLE_FRAMES; frame++) {
651
- // Create a copy of logo lines for this frame
652
- const frameLines = logoLines.map(line => [...line]);
653
- // Add random sparkles
654
- for (let s = 0; s < SPARKLES_PER_FRAME; s++) {
655
- const lineIdx = Math.floor(Math.random() * frameLines.length);
656
- const line = frameLines[lineIdx];
657
- // Find positions with sparkle-able characters
658
- const sparklePositions = [];
659
- for (let i = 0; i < line.length; i++) {
660
- if (sparkleChars.includes(line[i])) {
661
- sparklePositions.push(i);
662
- }
663
- }
664
- if (sparklePositions.length > 0) {
665
- const pos = sparklePositions[Math.floor(Math.random() * sparklePositions.length)];
666
- // Mark this position for sparkle (we'll handle coloring below)
667
- frameLines[lineIdx][pos] = { char: line[pos], sparkle: true };
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);
668
1087
  }
669
1088
  }
670
- // Move cursor up and render frame
1089
+ // Final frame: settle on magenta
671
1090
  process.stdout.write('\x1B[6A');
672
- for (const line of frameLines) {
673
- let output = '';
674
- for (const char of line) {
675
- if (char && typeof char === 'object' && char.sparkle) {
676
- const sparkleColor = sparkleColors[Math.floor(Math.random() * sparkleColors.length)];
677
- 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
+ }
678
1113
  }
679
- else {
680
- 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);
681
1120
  }
682
1121
  }
683
- console.log(output);
684
- }
685
- await sleep(SPARKLE_DELAY_MS);
686
- }
687
- // Final settle: clean magenta
688
- process.stdout.write('\x1B[6A');
689
- logoLines.forEach(line => console.log(chalk_1.default.magenta(line)));
690
- console.log('');
691
- // ══════════════════════════════════════════════════════════════════════════
692
- // ANIMATED TITLE: "Cognitive Continuity Engine" with orange/white shine
693
- // ══════════════════════════════════════════════════════════════════════════
694
- const titleText = 'Cognitive Continuity Engine';
695
- const taglineText = 'Context is finite. Intelligence isn\'t.';
696
- // Color palette for shine effect
697
- const whiteShine = chalk_1.default.whiteBright;
698
- // Phase 1: Typewriter effect for title
699
- process.stdout.write(' ');
700
- for (let i = 0; i < titleText.length; i++) {
701
- const char = titleText[i];
702
- // Flash white then settle to orange
703
- process.stdout.write(whiteShine(char));
704
- await sleep(25);
705
- process.stdout.write('\b' + chalk_1.default.hex('#FF6B35').bold(char));
706
- }
707
- console.log('');
708
- // Phase 2: Shine sweep across title (3 passes)
709
- const SHINE_PASSES = 3;
710
- const SHINE_WIDTH = 4;
711
- for (let pass = 0; pass < SHINE_PASSES; pass++) {
712
- for (let shinePos = -SHINE_WIDTH; shinePos <= titleText.length + SHINE_WIDTH; shinePos++) {
713
- process.stdout.write('\x1B[1A'); // Move up one line
714
- process.stdout.write('\r '); // Return to start
715
- let output = '';
716
- for (let i = 0; i < titleText.length; i++) {
717
- const distFromShine = Math.abs(i - shinePos);
718
- if (distFromShine === 0) {
719
- output += whiteShine.bold(titleText[i]);
720
- }
721
- else if (distFromShine === 1) {
722
- output += chalk_1.default.hex('#FFFFFF')(titleText[i]);
723
- }
724
- else if (distFromShine === 2) {
725
- output += chalk_1.default.hex('#FFD700')(titleText[i]);
726
- }
727
- else if (distFromShine === 3) {
728
- 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
+ }
729
1137
  }
730
- else {
731
- 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
+ }
732
1188
  }
1189
+ process.stdout.write(output + '\n'); // Write and move down for next frame
1190
+ await sleep(15);
733
1191
  }
734
- process.stdout.write(output + '\n'); // Write and move down for next frame
735
- await sleep(15);
736
- }
737
- }
738
- // Final title state
739
- process.stdout.write('\x1B[1A\r');
740
- console.log(' ' + chalk_1.default.hex('#FF6B35').bold(titleText));
741
- // Phase 3: Tagline fade-in with shimmer
742
- await sleep(100);
743
- // Build up tagline with wave effect
744
- const taglineColors = [
745
- chalk_1.default.hex('#444444'),
746
- chalk_1.default.hex('#666666'),
747
- chalk_1.default.hex('#888888'),
748
- chalk_1.default.hex('#AAAAAA'),
749
- chalk_1.default.hex('#CCCCCC'),
750
- chalk_1.default.hex('#EEEEEE'),
751
- chalk_1.default.gray,
752
- ];
753
- for (let wave = 0; wave < taglineColors.length; wave++) {
754
- process.stdout.write('\r ');
755
- process.stdout.write(taglineColors[wave](taglineText));
756
- await sleep(40);
757
- }
758
- console.log('');
759
- // Phase 4: Quick orange accent pulse on tagline
760
- for (let pulse = 0; pulse < 2; pulse++) {
761
- await sleep(80);
1192
+ }
1193
+ // Final title state
762
1194
  process.stdout.write('\x1B[1A\r');
763
- console.log(' ' + chalk_1.default.hex('#FF8C00')(taglineText));
764
- 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
765
1224
  process.stdout.write('\x1B[1A\r');
766
- console.log(' ' + chalk_1.default.gray(taglineText));
767
- }
768
- // Final tagline state with subtle orange tint
769
- process.stdout.write('\x1B[1A\r');
770
- console.log(' ' + chalk_1.default.hex('#B8860B')(taglineText));
771
- console.log('');
772
- if (bypass) {
773
- console.log(chalk_1.default.yellow(' ⚡ Bypass permissions mode enabled'));
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('');
774
1238
  }
775
- if (verbose) {
776
- console.log(chalk_1.default.gray(` 📁 Debug log: ${config.debugLogPath}`));
777
- 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('');
778
1252
  }
779
- console.log('');
780
1253
  // ══════════════════════════════════════════════════════════════════════════
781
1254
  // ANIMATION COMPLETE: Mark ready and flush buffered Claude output
782
1255
  // ══════════════════════════════════════════════════════════════════════════
@@ -789,8 +1262,9 @@ async function run(options) {
789
1262
  await sleep(100);
790
1263
  process.stdout.write('\r' + ' '.repeat(30) + '\r'); // Clear the line
791
1264
  }
792
- // Track state
793
- 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;
794
1268
  // Write initial instance file
795
1269
  const startedAt = new Date().toISOString();
796
1270
  writeInstanceFile(instanceId, {
@@ -820,9 +1294,8 @@ async function run(options) {
820
1294
  let isAutoClearInProgress = false;
821
1295
  let transcriptPath = null;
822
1296
  let currentSessionId = null;
823
- // Stream tailer for mid-turn context capture
1297
+ // Stream tailer for mid-turn context capture (must be declared before polling code)
824
1298
  let streamTailer = null;
825
- // Instance-namespaced cache directory per spec v1.2
826
1299
  const streamCacheDir = path.join(os.homedir(), '.ekkos', 'cache', 'sessions', instanceId);
827
1300
  // Helper to start stream tailer when we have transcript path
828
1301
  function startStreamTailer(tPath, sId, sName) {
@@ -853,6 +1326,112 @@ async function run(options) {
853
1326
  dlog('Stream tailer stopped');
854
1327
  }
855
1328
  }
1329
+ // ════════════════════════════════════════════════════════════════════════════
1330
+ // FAST TRANSCRIPT DETECTION: Poll for new jsonl files immediately
1331
+ // Claude creates the transcript file BEFORE outputting the session name
1332
+ // So we watch for new files rather than parsing TUI output (which is slower)
1333
+ // ════════════════════════════════════════════════════════════════════════════
1334
+ const encodedCwd = process.cwd().replace(/\//g, '-');
1335
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedCwd);
1336
+ const launchTime = Date.now();
1337
+ // Track existing jsonl files at startup
1338
+ let existingJsonlFiles = new Set();
1339
+ try {
1340
+ const files = fs.readdirSync(projectDir);
1341
+ existingJsonlFiles = new Set(files.filter(f => f.endsWith('.jsonl')));
1342
+ dlog(`[TRANSCRIPT] Found ${existingJsonlFiles.size} existing jsonl files at startup`);
1343
+ }
1344
+ catch {
1345
+ dlog('[TRANSCRIPT] Project dir does not exist yet');
1346
+ }
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.
1349
+ let transcriptPollInterval = null;
1350
+ function pollForNewTranscript() {
1351
+ if (transcriptPath) {
1352
+ // Already found - stop polling
1353
+ if (transcriptPollInterval) {
1354
+ clearInterval(transcriptPollInterval);
1355
+ transcriptPollInterval = null;
1356
+ }
1357
+ return;
1358
+ }
1359
+ // Stop after 30 seconds
1360
+ if (Date.now() - launchTime > 30000) {
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) {
1364
+ try {
1365
+ const files = fs.readdirSync(projectDir);
1366
+ const jsonlFiles = files
1367
+ .filter(f => f.endsWith('.jsonl'))
1368
+ .map(f => ({
1369
+ name: f,
1370
+ path: path.join(projectDir, f),
1371
+ mtime: fs.statSync(path.join(projectDir, f)).mtimeMs
1372
+ }))
1373
+ .sort((a, b) => b.mtime - a.mtime);
1374
+ if (jsonlFiles.length > 0) {
1375
+ transcriptPath = jsonlFiles[0].path;
1376
+ currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
1377
+ dlog(`[TRANSCRIPT] Local-mode timeout fallback: ${transcriptPath}`);
1378
+ startStreamTailer(transcriptPath, currentSessionId);
1379
+ }
1380
+ }
1381
+ catch {
1382
+ // Ignore local-mode timeout errors
1383
+ }
1384
+ }
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
+ }
1394
+ if (transcriptPollInterval) {
1395
+ clearInterval(transcriptPollInterval);
1396
+ transcriptPollInterval = null;
1397
+ }
1398
+ return;
1399
+ }
1400
+ try {
1401
+ const currentFiles = fs.readdirSync(projectDir);
1402
+ const jsonlFiles = currentFiles.filter(f => f.endsWith('.jsonl'));
1403
+ // Find NEW files (created after we started)
1404
+ for (const file of jsonlFiles) {
1405
+ if (!existingJsonlFiles.has(file)) {
1406
+ // New file! This is our transcript
1407
+ const fullPath = path.join(projectDir, file);
1408
+ const sessionId = file.replace('.jsonl', '');
1409
+ transcriptPath = fullPath;
1410
+ currentSessionId = sessionId;
1411
+ dlog(`[TRANSCRIPT] FAST DETECT: New transcript found! ${fullPath}`);
1412
+ evictionDebugLog('TRANSCRIPT_SET', 'Fast poll detected new file', {
1413
+ transcriptPath,
1414
+ currentSessionId,
1415
+ elapsedMs: Date.now() - launchTime
1416
+ });
1417
+ startStreamTailer(transcriptPath, currentSessionId);
1418
+ // Stop polling
1419
+ if (transcriptPollInterval) {
1420
+ clearInterval(transcriptPollInterval);
1421
+ transcriptPollInterval = null;
1422
+ }
1423
+ return;
1424
+ }
1425
+ }
1426
+ }
1427
+ catch {
1428
+ // Project dir doesn't exist yet, keep polling
1429
+ }
1430
+ }
1431
+ // Start polling immediately
1432
+ transcriptPollInterval = setInterval(pollForNewTranscript, 500);
1433
+ pollForNewTranscript(); // Also run once immediately
1434
+ dlog('[TRANSCRIPT] Fast polling started - looking for new jsonl files');
856
1435
  // ══════════════════════════════════════════════════════════════════════════
857
1436
  // SESSION NAME TRACKING (from live TUI output)
858
1437
  // Claude prints: "· Turn N · groovy-koala-saves · 📅"
@@ -864,47 +1443,257 @@ async function run(options) {
864
1443
  // Track if we've EVER observed a session in THIS process run
865
1444
  // This is the authoritative flag - if false, don't trust persisted state
866
1445
  let observedSessionThisRun = false;
1446
+ let boundProxySession = null;
1447
+ let bindingSessionInFlight = null;
867
1448
  // Output buffer for pattern detection
868
1449
  let outputBuffer = '';
869
1450
  // Debounce tracking to prevent double triggers
870
1451
  let lastDetectionTime = 0;
871
1452
  const DETECTION_COOLDOWN = 30000; // 30 seconds cooldown
872
- // Use args from early setup
873
- const args = earlyArgs;
1453
+ // JSONL eviction tracking - prevent rapid re-eviction
1454
+ let lastEvictionTime = 0;
874
1455
  // ══════════════════════════════════════════════════════════════════════════
875
- // WINDOWS: MONITOR-ONLY MODE WITHOUT PTY (Per Spec v1.2 Addendum)
876
- // Without node-pty/ConPTY, auto-continue cannot work on Windows.
877
- // Instead of hard-failing, we enter monitor-only mode.
1456
+ // ORPHAN TOOL_RESULT RECOVERY - React to ccDNA validate mode markers
878
1457
  // ══════════════════════════════════════════════════════════════════════════
879
- if (isWindows && !usePty) {
880
- console.log('');
881
- console.log(chalk_1.default.yellow.bold('⚠️ Monitor-only mode (PTY not available)'));
882
- console.log('');
883
- console.log(chalk_1.default.gray('Without node-pty (ConPTY), auto-continue cannot inject commands.'));
884
- console.log(chalk_1.default.gray('ekkOS will monitor context usage and provide instructions when needed.'));
885
- console.log('');
886
- console.log(chalk_1.default.cyan('To enable auto-continue:'));
887
- console.log(chalk_1.default.white(' Option 1: Use Node 20 or 22 LTS'));
888
- console.log(chalk_1.default.gray(' winget install OpenJS.NodeJS.LTS'));
889
- console.log(chalk_1.default.white(' Option 2: npm install node-pty-prebuilt-multiarch'));
890
- console.log('');
891
- console.log(chalk_1.default.gray('Run `ekkos doctor` for detailed diagnostics.'));
892
- console.log('');
1458
+ let lastOrphanDetectionTime = 0;
1459
+ let isOrphanRecoveryInProgress = false;
1460
+ // Deduplication: track orphan tool_use_ids we've already handled
1461
+ const handledOrphanIds = new Set();
1462
+ // Separate buffer for orphan detection (larger, to avoid truncation)
1463
+ let orphanDetectionBuffer = '';
1464
+ // Cursor for efficient scanning (avoids re-scanning already-processed text)
1465
+ let orphanScanCursor = 0;
1466
+ const ORPHAN_SCAN_TAIL_SLACK = 256; // Keep some overlap for chunk boundary tolerance
1467
+ // ══════════════════════════════════════════════════════════════════════════
1468
+ // SILENT FAILURE DETECTION - Catch API errors even without ccDNA markers
1469
+ // ══════════════════════════════════════════════════════════════════════════
1470
+ let lastSilentFailureTime = 0;
1471
+ let silentFailureCount = 0;
1472
+ const MAX_SILENT_FAILURES_BEFORE_ALERT = 2; // Alert user after 2 silent failures
1473
+ // ══════════════════════════════════════════════════════════════════════════
1474
+ // TURN-END EVICTION - Only clean up when Claude is idle (safe state)
1475
+ // ══════════════════════════════════════════════════════════════════════════
1476
+ let lastContextPercent = 0;
1477
+ let lastLoggedPercent = 0; // For throttling context % logs
1478
+ let turnEndTimeout = null;
1479
+ const TURN_END_STABLE_MS = 500; // Must see idle prompt for 500ms
1480
+ let pendingClearAfterEviction = false; // Flag to trigger /clear after eviction
1481
+ // Debug log to eviction-debug.log for 400 error diagnosis
1482
+ function evictionDebugLog(category, msg, data) {
1483
+ try {
1484
+ const logDir = path.join(os.homedir(), '.ekkos', 'logs');
1485
+ if (!fs.existsSync(logDir))
1486
+ fs.mkdirSync(logDir, { recursive: true });
1487
+ const logPath = path.join(logDir, 'eviction-debug.log');
1488
+ const ts = new Date().toISOString();
1489
+ const line = `[${ts}] [${category}] ${msg}${data ? '\n ' + JSON.stringify(data, null, 2).replace(/\n/g, '\n ') : ''}`;
1490
+ fs.appendFileSync(logPath, line + '\n');
1491
+ }
1492
+ catch { /* silent */ }
893
1493
  }
894
- else if (noInject) {
895
- console.log(chalk_1.default.yellow(' Monitor-only mode (--no-inject)'));
1494
+ // DEFENSIVE: Validate transcriptPath is a real file, not corrupted garbage
1495
+ function validateTranscriptPath(pathToCheck) {
1496
+ if (!pathToCheck)
1497
+ return false;
1498
+ // Check for ANSI escape codes (corruption signal)
1499
+ if (pathToCheck.includes('\u001b') || pathToCheck.includes('\x1b')) {
1500
+ evictionDebugLog('PATH_INVALID', 'Transcript path contains ANSI escape codes - clearing', {
1501
+ path: pathToCheck.slice(0, 100),
1502
+ });
1503
+ return false;
1504
+ }
1505
+ // Check it starts with / or ~ (absolute path)
1506
+ if (!pathToCheck.startsWith('/') && !pathToCheck.startsWith('~')) {
1507
+ evictionDebugLog('PATH_INVALID', 'Transcript path is not absolute - clearing', {
1508
+ path: pathToCheck.slice(0, 100),
1509
+ });
1510
+ return false;
1511
+ }
1512
+ // Check file exists
1513
+ if (!fs.existsSync(pathToCheck)) {
1514
+ evictionDebugLog('PATH_INVALID', 'Transcript path does not exist - clearing', {
1515
+ path: pathToCheck,
1516
+ });
1517
+ return false;
1518
+ }
1519
+ return true;
896
1520
  }
897
- if (verbose) {
898
- if (isNpxMode) {
899
- console.log(chalk_1.default.gray(` 🤖 Using claude-code@${pinnedVersion} via npx (pinned for better context)`));
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
+ }
1563
+ /**
1564
+ * Check if there are in-flight tool calls (tool_uses without matching tool_results)
1565
+ * CRITICAL: We must NOT evict while tools are in-flight or we'll orphan tool_results
1566
+ */
1567
+ function hasInFlightTools() {
1568
+ if (!transcriptPath)
1569
+ return false;
1570
+ try {
1571
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
1572
+ const lines = content.split('\n').filter(l => l.trim());
1573
+ // Extract all tool_use IDs and tool_result references
1574
+ const toolUseIds = new Set();
1575
+ const toolResultIds = new Set();
1576
+ for (const line of lines) {
1577
+ // Extract tool_use IDs using JSON parsing
1578
+ try {
1579
+ const obj = JSON.parse(line);
1580
+ const contentArr = obj?.message?.content;
1581
+ if (Array.isArray(contentArr)) {
1582
+ for (const block of contentArr) {
1583
+ if (block?.type === 'tool_use' && typeof block?.id === 'string') {
1584
+ toolUseIds.add(block.id);
1585
+ }
1586
+ if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
1587
+ toolResultIds.add(block.tool_use_id);
1588
+ }
1589
+ }
1590
+ }
1591
+ }
1592
+ catch {
1593
+ // Skip invalid JSON lines
1594
+ }
1595
+ }
1596
+ // In-flight = tool_uses that don't have matching results yet
1597
+ const inFlightCount = [...toolUseIds].filter(id => !toolResultIds.has(id)).length;
1598
+ if (inFlightCount > 0) {
1599
+ evictionDebugLog('IN_FLIGHT_CHECK', `Found ${inFlightCount} in-flight tools - blocking eviction`, {
1600
+ totalToolUses: toolUseIds.size,
1601
+ totalToolResults: toolResultIds.size,
1602
+ inFlightCount,
1603
+ });
1604
+ return true;
1605
+ }
1606
+ return false;
900
1607
  }
901
- else {
902
- console.log(chalk_1.default.gray(` 🤖 Using claude at: ${claudePath}`));
1608
+ catch (err) {
1609
+ evictionDebugLog('IN_FLIGHT_ERROR', `Failed to check in-flight tools: ${err.message}`);
1610
+ return true; // Assume in-flight on error (safer)
1611
+ }
1612
+ }
1613
+ async function handleTurnEnd() {
1614
+ if (!transcriptPath || isAutoClearInProgress)
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;
903
1621
  }
1622
+ // DEFENSIVE: Validate path before using
1623
+ if (!validateTranscriptPath(transcriptPath)) {
1624
+ evictionDebugLog('TURN_END_ABORT', 'Invalid transcriptPath detected - resetting to null', {
1625
+ corruptedPath: transcriptPath?.slice(0, 100),
1626
+ });
1627
+ transcriptPath = null;
1628
+ return;
1629
+ }
1630
+ // CRITICAL: Don't evict if tools are still in-flight
1631
+ // This prevents orphaning tool_results and causing 400 errors
1632
+ if (hasInFlightTools()) {
1633
+ evictionDebugLog('TURN_END_BLOCKED', 'Eviction blocked - in-flight tools detected');
1634
+ return;
1635
+ }
1636
+ const now = Date.now();
1637
+ if ((now - lastEvictionTime) < 10000)
1638
+ return; // 10s cooldown
1639
+ evictionDebugLog('TURN_END', 'Turn end detected - no in-flight tools', {
1640
+ transcriptPath,
1641
+ lastContextPercent,
1642
+ timeSinceLastEviction: now - lastEvictionTime,
1643
+ });
1644
+ // Run continuous clean first (always safe)
1645
+ const cleanResult = (0, jsonl_rewriter_1.continuousClean)(transcriptPath);
1646
+ if (cleanResult.cleaned > 0) {
1647
+ dlog(`🧹 Turn-end: Cleaned ${cleanResult.cleaned} junk lines`);
1648
+ evictionDebugLog('CONTINUOUS_CLEAN', `Cleaned ${cleanResult.cleaned} junk lines`);
1649
+ }
1650
+ // Then run eviction if needed (local mode only - proxy handles eviction in proxy mode)
1651
+ // eslint-disable-next-line no-restricted-syntax -- Feature flag, not API key
1652
+ const evictionDisabled = process.env.EKKOS_DISABLE_EVICTION === '1' || proxyModeEnabled;
1653
+ if (evictionDisabled) {
1654
+ evictionDebugLog('EVICTION_DISABLED', proxyModeEnabled
1655
+ ? 'Proxy is sole eviction authority - CLI does not touch local JSONL'
1656
+ : 'Eviction disabled via EKKOS_DISABLE_EVICTION=1');
1657
+ }
1658
+ else if ((0, jsonl_rewriter_1.needsEviction)(lastContextPercent)) {
1659
+ dlog(`📉 Turn-end eviction at ${lastContextPercent}%`);
1660
+ evictionDebugLog('EVICTION_TRIGGER', `Eviction triggered at ${lastContextPercent}%`);
1661
+ lastEvictionTime = now;
1662
+ // Use HANDSHAKE EVICTION for data safety
1663
+ // This ensures R2 backup is confirmed BEFORE local deletion
1664
+ const result = await (0, jsonl_rewriter_1.evictToTargetAsync)(transcriptPath, lastContextPercent, currentSessionId || undefined, currentSession || undefined);
1665
+ if (result.success && (result.evicted > 0 || result.truncated > 0)) {
1666
+ dlog(` ✅ Evicted ${result.evicted}, truncated ${result.truncated} → ${result.newPercent}%`);
1667
+ evictionDebugLog('EVICTION_COMPLETE', 'Eviction completed', {
1668
+ evicted: result.evicted,
1669
+ truncated: result.truncated,
1670
+ newPercent: result.newPercent,
1671
+ handshakeUsed: result.handshakeUsed,
1672
+ });
1673
+ // SLIDING WINDOW: Trigger /clear to make Claude Code reload the slimmed transcript
1674
+ pendingClearAfterEviction = true;
1675
+ dlog(' 🔄 Pending /clear to reload evicted transcript');
1676
+ }
1677
+ else {
1678
+ evictionDebugLog('EVICTION_NOOP', 'No eviction performed', result);
1679
+ }
1680
+ }
1681
+ }
1682
+ // Use args from early setup
1683
+ const args = earlyArgs;
1684
+ if (noInject) {
1685
+ console.log(chalk_1.default.yellow(' Monitor-only mode (--no-inject)'));
1686
+ }
1687
+ if (verbose) {
1688
+ // Show Claude version with ccDNA version if patched
1689
+ const ccVersion = pinnedVersion || PINNED_CLAUDE_VERSION;
1690
+ const versionStr = ccdnaVersion
1691
+ ? `Claude Code v${ccVersion} + ekkOS_Continuum v${ccdnaVersion}`
1692
+ : `Claude Code v${ccVersion}`;
1693
+ console.log(chalk_1.default.gray(` 🤖 ${versionStr}`));
904
1694
  if (currentSession) {
905
1695
  console.log(chalk_1.default.green(` 📍 Session: ${currentSession}`));
906
1696
  }
907
- console.log(chalk_1.default.gray(` 💻 PTY mode: ${usePty ? 'node-pty' : 'spawn+script (fallback)'}`));
908
1697
  console.log('');
909
1698
  }
910
1699
  let shell;
@@ -947,7 +1736,7 @@ async function run(options) {
947
1736
  cols: process.stdout.columns || 80,
948
1737
  rows: process.stdout.rows || 24,
949
1738
  cwd: process.cwd(),
950
- env: process.env
1739
+ env: getEkkosEnv()
951
1740
  });
952
1741
  shell = {
953
1742
  write: (data) => ptyShell.write(data),
@@ -958,15 +1747,21 @@ async function run(options) {
958
1747
  };
959
1748
  }
960
1749
  catch (err) {
961
- dlog(`node-pty spawn failed: ${err.message}`);
962
- // Fall through to spawn mode
963
- return runWithSpawn(claudePath, args, options, {
964
- currentSession,
965
- isAutoClearInProgress,
966
- transcriptPath,
967
- currentSessionId,
968
- outputBuffer
1750
+ // PTY spawn failed - fall back to spawn pass-through
1751
+ dlog(`PTY spawn failed: ${err.message}, using spawn fallback`);
1752
+ const spawnedProcess = (0, child_process_1.spawn)(claudePath, args, {
1753
+ stdio: 'inherit',
1754
+ cwd: process.cwd(),
1755
+ env: getEkkosEnv()
969
1756
  });
1757
+ spawnedProcess.on('exit', (code) => process.exit(code ?? 0));
1758
+ spawnedProcess.on('error', (e) => {
1759
+ console.error(chalk_1.default.red(`Failed to start Claude: ${e.message}`));
1760
+ process.exit(1);
1761
+ });
1762
+ process.on('SIGINT', () => spawnedProcess.kill('SIGINT'));
1763
+ process.on('SIGTERM', () => spawnedProcess.kill('SIGTERM'));
1764
+ return;
970
1765
  }
971
1766
  }
972
1767
  // Handle terminal resize
@@ -975,14 +1770,26 @@ async function run(options) {
975
1770
  });
976
1771
  }
977
1772
  else {
978
- // Fallback: use spawn+script for PTY emulation
979
- return runWithSpawn(claudePath, args, options, {
980
- currentSession,
981
- isAutoClearInProgress,
982
- transcriptPath,
983
- currentSessionId,
984
- outputBuffer
1773
+ // PTY not available - use spawn with stdio inherit (clean pass-through)
1774
+ // This mode doesn't support auto-continue but provides full Claude Code experience
1775
+ dlog('PTY not available, using spawn pass-through mode');
1776
+ const spawnedProcess = (0, child_process_1.spawn)(claudePath, args, {
1777
+ stdio: 'inherit',
1778
+ cwd: process.cwd(),
1779
+ env: getEkkosEnv()
1780
+ });
1781
+ spawnedProcess.on('exit', (code) => {
1782
+ process.exit(code ?? 0);
985
1783
  });
1784
+ spawnedProcess.on('error', (err) => {
1785
+ console.error(chalk_1.default.red(`Failed to start Claude: ${err.message}`));
1786
+ process.exit(1);
1787
+ });
1788
+ // Handle signals for clean shutdown
1789
+ process.on('SIGINT', () => spawnedProcess.kill('SIGINT'));
1790
+ process.on('SIGTERM', () => spawnedProcess.kill('SIGTERM'));
1791
+ // In spawn mode, we don't continue with the rest of the PTY-specific code
1792
+ return;
986
1793
  }
987
1794
  // Forward user input to PTY (named function so we can pause/resume)
988
1795
  const onStdinData = (data) => {
@@ -996,6 +1803,7 @@ async function run(options) {
996
1803
  // Helper to get current output buffer (for readiness checks)
997
1804
  const getOutputBuffer = () => outputBuffer;
998
1805
  // Handle context wall detection
1806
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
999
1807
  async function handleContextWall() {
1000
1808
  // Debounce check - prevent double triggers (BEFORE pausing stdin)
1001
1809
  const now = Date.now();
@@ -1142,6 +1950,285 @@ async function run(options) {
1142
1950
  dlog('Stdin resumed');
1143
1951
  }
1144
1952
  }
1953
+ // ══════════════════════════════════════════════════════════════════════════
1954
+ // ORPHAN TOOL_RESULT RECOVERY
1955
+ // When ccDNA validate mode detects orphan tool_results before an API call,
1956
+ // it emits [ekkOS] ORPHAN_TOOL_RESULT to the terminal. We detect this marker
1957
+ // and repair the transcript (rollback or surgical).
1958
+ //
1959
+ // NOTE: We do NOT run /clear + /continue anymore. That was leftover from when
1960
+ // ccDNA was in evict mode (filtering in-memory messages). With ccDNA in validate
1961
+ // mode and the JSONL rewriter as the single disk authority, orphans indicate a
1962
+ // BUG in the sliding window - not a state desync that needs rebuilding.
1963
+ // The in-memory state is fine; we just fix the disk and log the bug.
1964
+ // ══════════════════════════════════════════════════════════════════════════
1965
+ function handleOrphanToolResult(orphan) {
1966
+ const now = Date.now();
1967
+ if (isOrphanRecoveryInProgress) {
1968
+ dlog('Orphan recovery already in progress, ignoring');
1969
+ return;
1970
+ }
1971
+ if (now - lastOrphanDetectionTime < ORPHAN_DETECTION_COOLDOWN_MS) {
1972
+ dlog('Orphan recovery suppressed by cooldown');
1973
+ return;
1974
+ }
1975
+ lastOrphanDetectionTime = now;
1976
+ isOrphanRecoveryInProgress = true;
1977
+ // Cancel any pending turn-end eviction timer (don't evict while handling orphan)
1978
+ if (turnEndTimeout) {
1979
+ clearTimeout(turnEndTimeout);
1980
+ turnEndTimeout = null;
1981
+ }
1982
+ // Log the bug - this should NOT happen with a working sliding window
1983
+ evictionDebugLog('ORPHAN_BUG_DETECTED', '═══════════════════════════════════════════════════════════', {
1984
+ alert: '🚨 ORPHAN TOOL_RESULT DETECTED - SLIDING WINDOW BUG 🚨',
1985
+ orphan: {
1986
+ messageIndex: orphan.idx,
1987
+ toolUseId: orphan.tool_use_id,
1988
+ blockIndex: orphan.block_idx,
1989
+ },
1990
+ context: {
1991
+ transcriptPath,
1992
+ currentSessionId,
1993
+ currentSession,
1994
+ lastContextPercent,
1995
+ },
1996
+ diagnosis: 'JSONL rewriter evicted tool_use without its tool_result, or vice versa',
1997
+ action: 'Attempting disk repair via rollback or surgical removal',
1998
+ });
1999
+ console.log(`\n[ekkOS] 🚨 BUG: Orphan tool_result detected (${orphan.tool_use_id})`);
2000
+ console.log(`[ekkOS] 🔧 Repairing disk transcript...`);
2001
+ try {
2002
+ // Repair the disk transcript if we have one
2003
+ if (transcriptPath && validateTranscriptPath(transcriptPath)) {
2004
+ // Don't repair if tools are still in-flight
2005
+ if (hasInFlightTools()) {
2006
+ evictionDebugLog('ORPHAN_REPAIR_BLOCKED', 'In-flight tools present, deferring repair');
2007
+ isOrphanRecoveryInProgress = false;
2008
+ return;
2009
+ }
2010
+ dlog(`Repairing transcript: ${transcriptPath}`);
2011
+ const repair = (0, transcript_repair_1.repairOrRollbackTranscript)(transcriptPath);
2012
+ evictionDebugLog('ORPHAN_REPAIR_RESULT', '═══════════════════════════════════════════════════════════', {
2013
+ result: repair.action.toUpperCase(),
2014
+ orphansFound: repair.orphansFound,
2015
+ removedLines: repair.removedLines ?? 0,
2016
+ backupUsed: repair.backupUsed ?? 'none',
2017
+ reason: repair.reason ?? 'success',
2018
+ });
2019
+ dlog(`Orphan repair: ${repair.action} (orphans=${repair.orphansFound}, removed=${repair.removedLines ?? 0})`);
2020
+ if (repair.action === 'failed') {
2021
+ console.log(`[ekkOS] ❌ WARNING: Orphan repair failed - session may be unstable`);
2022
+ console.log(`[ekkOS] Reason: ${repair.reason}`);
2023
+ }
2024
+ else if (repair.action === 'rollback') {
2025
+ console.log(`[ekkOS] ✅ Disk repaired via ROLLBACK to backup`);
2026
+ }
2027
+ else if (repair.action === 'surgical_repair') {
2028
+ console.log(`[ekkOS] ✅ Disk repaired via SURGICAL removal (${repair.removedLines} lines removed)`);
2029
+ }
2030
+ else {
2031
+ console.log(`[ekkOS] ✅ No repair needed - transcript is healthy`);
2032
+ }
2033
+ // POST-REPAIR VALIDATION: Verify repair actually worked
2034
+ if (repair.action !== 'failed' && repair.action !== 'none') {
2035
+ try {
2036
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2037
+ const { countOrphansInJsonl } = require('../capture/transcript-repair');
2038
+ const { orphans: postRepairOrphans } = countOrphansInJsonl(transcriptPath);
2039
+ if (postRepairOrphans > 0) {
2040
+ evictionDebugLog('POST_REPAIR_VALIDATION_FAILED', '⚠️ Repair completed but orphans still exist!', {
2041
+ repair,
2042
+ postRepairOrphans,
2043
+ alert: 'REPAIR DID NOT FIX THE PROBLEM',
2044
+ });
2045
+ console.log(`[ekkOS] ⚠️ Post-repair check: ${postRepairOrphans} orphan(s) still present!`);
2046
+ console.log(`[ekkOS] Repair may have been incomplete - consider /clear + /continue`);
2047
+ }
2048
+ else {
2049
+ evictionDebugLog('POST_REPAIR_VALIDATION_SUCCESS', '✅ Repair verified - no orphans remaining', {
2050
+ repair,
2051
+ });
2052
+ dlog('Post-repair validation passed - transcript is clean');
2053
+ }
2054
+ }
2055
+ catch (validationErr) {
2056
+ dlog(`Post-repair validation failed: ${validationErr.message}`);
2057
+ }
2058
+ }
2059
+ }
2060
+ else {
2061
+ evictionDebugLog('ORPHAN_REPAIR_SKIPPED', 'No valid transcriptPath', { transcriptPath });
2062
+ dlog('No transcript to repair');
2063
+ }
2064
+ }
2065
+ catch (err) {
2066
+ evictionDebugLog('ORPHAN_REPAIR_ERROR', err.message, { stack: err.stack });
2067
+ }
2068
+ finally {
2069
+ // Release flag immediately - no stdin pause needed since we're not injecting commands
2070
+ isOrphanRecoveryInProgress = false;
2071
+ }
2072
+ }
2073
+ // ══════════════════════════════════════════════════════════════════════════
2074
+ // ORPHAN DETECTION FUNCTION - Can be called from shell.onData or tests
2075
+ // Uses cursor-based scanning to avoid re-scanning already-processed text
2076
+ // ══════════════════════════════════════════════════════════════════════════
2077
+ function runOrphanDetection() {
2078
+ if (isAutoClearInProgress || isOrphanRecoveryInProgress)
2079
+ return;
2080
+ // Only scan from cursor position forward (plus tail slack for boundary tolerance)
2081
+ const scanStart = Math.max(0, orphanScanCursor - ORPHAN_SCAN_TAIL_SLACK);
2082
+ const textToScan = orphanDetectionBuffer.slice(scanStart);
2083
+ // Reset regex lastIndex before matching
2084
+ ORPHAN_MARKER_REGEX.lastIndex = 0;
2085
+ let orphanMatch;
2086
+ while ((orphanMatch = ORPHAN_MARKER_REGEX.exec(textToScan)) !== null) {
2087
+ try {
2088
+ const orphanJson = orphanMatch[1];
2089
+ const orphan = JSON.parse(orphanJson);
2090
+ // Deduplication: skip if we've already handled this orphan
2091
+ if (handledOrphanIds.has(orphan.tool_use_id)) {
2092
+ dlog(`Skipping already-handled orphan: ${orphan.tool_use_id}`);
2093
+ continue;
2094
+ }
2095
+ dlog(`Detected ORPHAN_TOOL_RESULT: ${orphan.tool_use_id}`);
2096
+ evictionDebugLog('ORPHAN_MARKER_DETECTED', 'ccDNA reported orphan in PTY output', {
2097
+ orphan,
2098
+ bufferLen: orphanDetectionBuffer.length,
2099
+ scanCursor: orphanScanCursor,
2100
+ handledCount: handledOrphanIds.size,
2101
+ });
2102
+ // Mark as handled before firing (prevents re-trigger)
2103
+ handledOrphanIds.add(orphan.tool_use_id);
2104
+ // Fire and forget - the handler has its own cooldown/reentrancy guards
2105
+ void handleOrphanToolResult(orphan);
2106
+ }
2107
+ catch (e) {
2108
+ evictionDebugLog('ORPHAN_PARSE_ERROR', 'Failed to parse ORPHAN_TOOL_RESULT payload', {
2109
+ sample: orphanMatch?.[1]?.slice(0, 200),
2110
+ err: e.message,
2111
+ });
2112
+ }
2113
+ }
2114
+ // Advance cursor to end of buffer (next scan starts from here)
2115
+ orphanScanCursor = orphanDetectionBuffer.length;
2116
+ }
2117
+ // ══════════════════════════════════════════════════════════════════════════
2118
+ // TEST TRIGGER: Synthetic orphan marker injection
2119
+ // Set EKKOS_TEST_ORPHAN=1 to inject after transcriptPath is discovered (full E2E)
2120
+ // Set EKKOS_TEST_ORPHAN=2 to inject after 5s regardless (detection-only test)
2121
+ // ══════════════════════════════════════════════════════════════════════════
2122
+ // eslint-disable-next-line no-restricted-syntax -- Test flag, not API key
2123
+ const testOrphanMode = process.env.EKKOS_TEST_ORPHAN;
2124
+ if (testOrphanMode === '1' || testOrphanMode === '2') {
2125
+ console.log(`[ekkOS TEST] Test trigger starting (mode=${testOrphanMode})`);
2126
+ evictionDebugLog('TEST_TRIGGER_START', 'Test trigger initialized', { mode: testOrphanMode });
2127
+ // eslint-disable-next-line no-restricted-syntax -- Test flag, not API key
2128
+ const isQuickMode = process.env.EKKOS_TEST_ORPHAN === '2';
2129
+ const TEST_MAX_WAIT_MS = isQuickMode ? 5000 : 20000;
2130
+ const TEST_POLL_MS = 500;
2131
+ let testWaitedMs = 0;
2132
+ let testInjected = false;
2133
+ // ... (rest of the code remains the same)
2134
+ const injectMarker = (mode) => {
2135
+ if (testInjected)
2136
+ return;
2137
+ testInjected = true;
2138
+ const testOrphan = { idx: 0, tool_use_id: 'toolu_TEST_' + Date.now(), block_idx: 0 };
2139
+ const testMarker = `[ekkOS] ORPHAN_TOOL_RESULT ${JSON.stringify(testOrphan)}`;
2140
+ dlog(`TEST: Injecting synthetic orphan marker (${mode})`);
2141
+ evictionDebugLog('TEST_ORPHAN_INJECT', `Synthetic orphan marker injected (${mode})`, {
2142
+ testOrphan,
2143
+ testMarker,
2144
+ transcriptPath: transcriptPath || 'not_discovered',
2145
+ waitedMs: testWaitedMs,
2146
+ mode,
2147
+ });
2148
+ // Inject directly into detection buffer and run detection
2149
+ orphanDetectionBuffer += '\n' + testMarker + '\n';
2150
+ runOrphanDetection();
2151
+ };
2152
+ const testPollInterval = setInterval(() => {
2153
+ testWaitedMs += TEST_POLL_MS;
2154
+ // Check if transcriptPath is now available (full E2E test)
2155
+ if (transcriptPath && validateTranscriptPath(transcriptPath)) {
2156
+ clearInterval(testPollInterval);
2157
+ setTimeout(() => injectMarker('transcriptPath_ready'), 1000);
2158
+ }
2159
+ else if (testWaitedMs >= TEST_MAX_WAIT_MS) {
2160
+ clearInterval(testPollInterval);
2161
+ // In quick mode or after timeout, inject anyway (detection-only test)
2162
+ injectMarker(isQuickMode ? 'quick_mode' : 'timeout_fallback');
2163
+ }
2164
+ }, TEST_POLL_MS);
2165
+ }
2166
+ // ══════════════════════════════════════════════════════════════════════════
2167
+ // SILENT FAILURE DETECTION HANDLER
2168
+ // Catches API 400 errors and orphan-related messages even without ccDNA markers
2169
+ // This is a backup for when ccDNA validate mode isn't working or is disabled
2170
+ // ══════════════════════════════════════════════════════════════════════════
2171
+ function handleSilentFailure(matchType, matchedText) {
2172
+ const now = Date.now();
2173
+ // Cooldown check
2174
+ if (now - lastSilentFailureTime < SILENT_FAILURE_COOLDOWN_MS) {
2175
+ dlog(`Silent failure suppressed by cooldown (${matchType})`);
2176
+ return;
2177
+ }
2178
+ // Don't trigger during active recovery
2179
+ if (isOrphanRecoveryInProgress || isAutoClearInProgress) {
2180
+ dlog(`Silent failure ignored - recovery in progress (${matchType})`);
2181
+ return;
2182
+ }
2183
+ lastSilentFailureTime = now;
2184
+ silentFailureCount++;
2185
+ evictionDebugLog('SILENT_FAILURE_DETECTED', '════════════════════════════════════════════════════════', {
2186
+ alert: '⚠️ SILENT FAILURE - API error detected without ccDNA marker',
2187
+ matchType,
2188
+ matchedText: matchedText.slice(0, 200),
2189
+ silentFailureCount,
2190
+ transcriptPath,
2191
+ diagnosis: 'Possible orphan tool_result or ccDNA not in validate mode',
2192
+ });
2193
+ console.log(`\n[ekkOS] ⚠️ Silent failure detected: ${matchType}`);
2194
+ // After multiple failures, alert user and suggest action
2195
+ if (silentFailureCount >= MAX_SILENT_FAILURES_BEFORE_ALERT) {
2196
+ console.log(`[ekkOS] ⚠️ Multiple API errors detected (${silentFailureCount}x)`);
2197
+ console.log(`[ekkOS] This may indicate orphan tool_results in the transcript`);
2198
+ console.log(`[ekkOS] Try: /clear then /continue to rebuild message state`);
2199
+ evictionDebugLog('SILENT_FAILURE_ALERT', 'Multiple silent failures - user alerted', {
2200
+ count: silentFailureCount,
2201
+ suggestion: '/clear + /continue',
2202
+ });
2203
+ // Reset counter after alerting
2204
+ silentFailureCount = 0;
2205
+ }
2206
+ // Attempt proactive repair if we have a transcript
2207
+ if (transcriptPath && validateTranscriptPath(transcriptPath) && !hasInFlightTools()) {
2208
+ dlog('Attempting proactive repair due to silent failure');
2209
+ try {
2210
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2211
+ const { countOrphansInJsonl } = require('../capture/transcript-repair');
2212
+ const { orphans: orphanCount, orphanIds } = countOrphansInJsonl(transcriptPath);
2213
+ if (orphanCount > 0) {
2214
+ evictionDebugLog('SILENT_FAILURE_ORPHANS_FOUND', `Proactive scan found ${orphanCount} orphans`, {
2215
+ transcriptPath,
2216
+ orphanCount,
2217
+ orphanIds: orphanIds.slice(0, 5), // Log first 5 IDs
2218
+ });
2219
+ console.log(`[ekkOS] 🔍 Found ${orphanCount} orphan(s) in transcript - triggering repair`);
2220
+ // Trigger orphan recovery (reuse existing handler)
2221
+ void handleOrphanToolResult({ idx: -1, tool_use_id: 'silent_failure_detected' });
2222
+ }
2223
+ else {
2224
+ dlog('Proactive scan found no orphans - API error may be unrelated');
2225
+ }
2226
+ }
2227
+ catch (err) {
2228
+ dlog(`Proactive repair scan failed: ${err.message}`);
2229
+ }
2230
+ }
2231
+ }
1145
2232
  // Monitor PTY output
1146
2233
  shell.onData((data) => {
1147
2234
  // Pass through to terminal
@@ -1152,18 +2239,81 @@ async function run(options) {
1152
2239
  if (outputBuffer.length > 5000) {
1153
2240
  outputBuffer = outputBuffer.slice(-2000);
1154
2241
  }
2242
+ // ══════════════════════════════════════════════════════════════════════════
2243
+ // ORPHAN TOOL_RESULT DETECTION
2244
+ // ccDNA validate mode emits [ekkOS] ORPHAN_TOOL_RESULT when it detects
2245
+ // tool_results without matching tool_uses. This triggers automatic repair.
2246
+ // Uses separate larger buffer to avoid truncation issues.
2247
+ // ══════════════════════════════════════════════════════════════════════════
2248
+ if (!isAutoClearInProgress && !isOrphanRecoveryInProgress) {
2249
+ // Append to orphan detection buffer (larger than main buffer to catch full markers)
2250
+ orphanDetectionBuffer += stripAnsi(data);
2251
+ if (orphanDetectionBuffer.length > 10000) {
2252
+ const trimAmount = orphanDetectionBuffer.length - 8000;
2253
+ orphanDetectionBuffer = orphanDetectionBuffer.slice(-8000);
2254
+ // Adjust cursor to account for trimmed portion
2255
+ orphanScanCursor = Math.max(0, orphanScanCursor - trimAmount);
2256
+ }
2257
+ // Run detection (extracted function for testability)
2258
+ runOrphanDetection();
2259
+ // ════════════════════════════════════════════════════════════════════════
2260
+ // SILENT FAILURE DETECTION - Catch API errors without ccDNA markers
2261
+ // ════════════════════════════════════════════════════════════════════════
2262
+ const normalizedForSilent = stripAnsi(data);
2263
+ // Check for API 400 errors
2264
+ if (API_400_REGEX.test(normalizedForSilent)) {
2265
+ handleSilentFailure('API_400', normalizedForSilent.match(API_400_REGEX)?.[0] || '400');
2266
+ }
2267
+ // Check for explicit orphan-related API error messages
2268
+ else if (ORPHAN_API_ERROR_REGEX.test(normalizedForSilent)) {
2269
+ handleSilentFailure('ORPHAN_API_ERROR', normalizedForSilent.match(ORPHAN_API_ERROR_REGEX)?.[0] || 'orphan');
2270
+ }
2271
+ // Check for generic invalid message errors
2272
+ else if (INVALID_MESSAGE_REGEX.test(normalizedForSilent)) {
2273
+ handleSilentFailure('INVALID_MESSAGE', normalizedForSilent.match(INVALID_MESSAGE_REGEX)?.[0] || 'invalid');
2274
+ }
2275
+ }
1155
2276
  // Try to extract transcript path from output (Claude shows it on startup)
1156
- const transcriptMatch = data.match(/transcript[_\s]?(?:path)?[:\s]+([^\s\n]+\.jsonl?)/i);
2277
+ // CRITICAL: Strip ANSI codes FIRST to prevent capturing terminal garbage
2278
+ const cleanData = stripAnsi(data);
2279
+ const transcriptMatch = cleanData.match(/transcript[_\s]?(?:path)?[:\s]+([^\s\n]+\.jsonl?)/i);
1157
2280
  if (transcriptMatch) {
1158
- transcriptPath = transcriptMatch[1];
1159
- dlog(`Detected transcript: ${transcriptPath}`);
1160
- // Start tailer if we have session ID
1161
- if (currentSessionId && transcriptPath) {
1162
- startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
2281
+ const candidatePath = transcriptMatch[1];
2282
+ // Validate it's an actual path (not garbage from terminal output)
2283
+ if (candidatePath.startsWith('/') || candidatePath.startsWith('~')) {
2284
+ const resolvedPath = candidatePath.startsWith('~')
2285
+ ? path.join(os.homedir(), candidatePath.slice(1))
2286
+ : candidatePath;
2287
+ if (fs.existsSync(resolvedPath)) {
2288
+ // DEFENSIVE: Double-check no ANSI codes leaked through
2289
+ if (resolvedPath.includes('\u001b') || resolvedPath.includes('\x1b')) {
2290
+ evictionDebugLog('PATH_CORRUPTION', 'ANSI codes detected in resolved path!', {
2291
+ resolvedPath,
2292
+ candidatePath,
2293
+ cleanDataSample: cleanData.slice(0, 200),
2294
+ });
2295
+ }
2296
+ else {
2297
+ transcriptPath = resolvedPath;
2298
+ evictionDebugLog('TRANSCRIPT_SET', 'transcriptPath set from output', { transcriptPath });
2299
+ dlog(`Detected transcript from output: ${transcriptPath}`);
2300
+ }
2301
+ // Start tailer if we have session ID
2302
+ if (currentSessionId) {
2303
+ startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
2304
+ }
2305
+ }
2306
+ else {
2307
+ dlog(`Transcript path candidate doesn't exist: ${resolvedPath}`);
2308
+ }
2309
+ }
2310
+ else {
2311
+ dlog(`Transcript path candidate rejected (not absolute): ${candidatePath}`);
1163
2312
  }
1164
2313
  }
1165
2314
  // Try to extract session ID from output (fallback - Claude rarely prints this)
1166
- const sessionMatch = data.match(/session[_\s]?(?:id)?[:\s]+([a-f0-9-]{36})/i);
2315
+ // Use cleanData (already stripped of ANSI) to avoid terminal garbage
2316
+ const sessionMatch = cleanData.match(/session[_\s]?(?:id)?[:\s]+([a-f0-9-]{36})/i);
1167
2317
  if (sessionMatch) {
1168
2318
  currentSessionId = sessionMatch[1];
1169
2319
  currentSession = (0, state_1.uuidToWords)(currentSessionId);
@@ -1172,16 +2322,8 @@ async function run(options) {
1172
2322
  // Also update global state for backwards compatibility
1173
2323
  (0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
1174
2324
  dlog(`Session detected from UUID: ${currentSession}`);
1175
- // Try to find/construct transcript path from session ID
1176
- if (!transcriptPath) {
1177
- const encodedCwd = process.cwd().replace(/\//g, '-').replace(/^-/, '');
1178
- const possibleTranscript = path.join(os.homedir(), '.claude', 'projects', encodedCwd, `${currentSessionId}.jsonl`);
1179
- if (fs.existsSync(possibleTranscript)) {
1180
- transcriptPath = possibleTranscript;
1181
- dlog(`Found transcript from session ID: ${transcriptPath}`);
1182
- startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
1183
- }
1184
- }
2325
+ resolveTranscriptFromSessionId('session-id-from-output');
2326
+ bindRealSessionToProxy(currentSession, 'session-id-from-output');
1185
2327
  }
1186
2328
  // ════════════════════════════════════════════════════════════════════════
1187
2329
  // SESSION NAME DETECTION (PRIMARY METHOD)
@@ -1198,58 +2340,14 @@ async function run(options) {
1198
2340
  dlog(`Session rejected (invalid words): ${detectedSession}`);
1199
2341
  }
1200
2342
  else if (detectedSession !== lastSeenSessionName) {
1201
- // Only update if different (avoid log spam)
1202
- lastSeenSessionName = detectedSession;
1203
- lastSeenSessionAt = Date.now();
1204
- currentSession = lastSeenSessionName;
1205
- observedSessionThisRun = true; // Mark that we've seen a session in THIS process
1206
- // Update THIS process's session entry (not global state.json)
1207
- (0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
1208
- // Also update global state for backwards compatibility
1209
- (0, state_1.updateState)({ sessionName: currentSession });
1210
- dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
1211
- // Try to start stream tailer - scan for matching transcript file
1212
- if (!streamTailer) {
1213
- const encodedCwd = process.cwd().replace(/\//g, '-').replace(/^-/, '');
1214
- const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedCwd);
1215
- try {
1216
- const files = fs.readdirSync(projectDir);
1217
- // Find most recent .jsonl file (likely current session)
1218
- const jsonlFiles = files
1219
- .filter(f => f.endsWith('.jsonl'))
1220
- .map(f => ({
1221
- name: f,
1222
- path: path.join(projectDir, f),
1223
- mtime: fs.statSync(path.join(projectDir, f)).mtimeMs
1224
- }))
1225
- .sort((a, b) => b.mtime - a.mtime);
1226
- if (jsonlFiles.length > 0) {
1227
- transcriptPath = jsonlFiles[0].path;
1228
- currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
1229
- dlog(`Found transcript from project scan: ${transcriptPath}`);
1230
- startStreamTailer(transcriptPath, currentSessionId, currentSession);
1231
- }
1232
- }
1233
- catch {
1234
- // Project dir might not exist yet
1235
- }
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}`);
1236
2348
  }
1237
- }
1238
- else {
1239
- // Same session, just update timestamp
1240
- lastSeenSessionAt = Date.now();
1241
- }
1242
- }
1243
- else {
1244
- // Weaker signal: any 3-word slug (only if no status match)
1245
- const anyMatch = plain.match(SESSION_NAME_REGEX);
1246
- if (anyMatch) {
1247
- const detectedSession = anyMatch[1].toLowerCase();
1248
- // Validate against word lists (SOURCE OF TRUTH)
1249
- if (!isValidSessionName(detectedSession)) {
1250
- dlog(`Session rejected (invalid words): ${detectedSession}`);
1251
- }
1252
- else if (detectedSession !== lastSeenSessionName) {
2349
+ else {
2350
+ // Only update if different (avoid log spam)
1253
2351
  lastSeenSessionName = detectedSession;
1254
2352
  lastSeenSessionAt = Date.now();
1255
2353
  currentSession = lastSeenSessionName;
@@ -1258,26 +2356,171 @@ async function run(options) {
1258
2356
  (0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
1259
2357
  // Also update global state for backwards compatibility
1260
2358
  (0, state_1.updateState)({ sessionName: currentSession });
1261
- 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');
1262
2362
  }
1263
- else {
1264
- 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');
2369
+ }
2370
+ }
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
+ }
2380
+ // ══════════════════════════════════════════════════════════════════════════
2381
+ // TURN-END EVICTION - Track context % and run cleanup when Claude goes idle
2382
+ // This is MUCH safer than mid-stream eviction because:
2383
+ // 1. All tool calls have completed (no in-flight tools)
2384
+ // 2. JSONL is in a consistent state
2385
+ // 3. Claude Code is between operations
2386
+ // ══════════════════════════════════════════════════════════════════════════
2387
+ // ════════════════════════════════════════════════════════════════════════
2388
+ // CONTEXT % CALCULATION - Local mode only (proxy handles its own token tracking)
2389
+ // ════════════════════════════════════════════════════════════════════════
2390
+ if (!proxyModeEnabled) {
2391
+ // Track context percentage - PRIMARY: calculate from JSONL file size
2392
+ // Claude Code has ~200K token limit. 1 token ≈ 4 chars = 4 bytes
2393
+ // 200K tokens ≈ 800KB. We estimate context % from file size.
2394
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
2395
+ try {
2396
+ const fileSize = fs.statSync(transcriptPath).size;
2397
+ const estimatedMaxSize = 800 * 1024; // 800KB ≈ 200K tokens
2398
+ lastContextPercent = Math.min(100, Math.round((fileSize / estimatedMaxSize) * 100));
2399
+ // Log periodically (every ~5%)
2400
+ if (Math.abs(lastContextPercent - (lastLoggedPercent || 0)) >= 5) {
2401
+ dlog(`[CONTEXT] Estimated ${lastContextPercent}% from file size (${Math.round(fileSize / 1024)}KB)`);
2402
+ lastLoggedPercent = lastContextPercent;
2403
+ }
2404
+ }
2405
+ catch {
2406
+ // Fall back to regex if file read fails
2407
+ const contextPercentMatch = outputBuffer.match(/(\d+)K?\s*\((\d+)%\)/);
2408
+ if (contextPercentMatch) {
2409
+ lastContextPercent = parseFloat(contextPercentMatch[2]);
2410
+ }
1265
2411
  }
1266
2412
  }
1267
2413
  }
1268
- // Check for context wall patterns (ANSI-stripped + regex for robustness)
1269
- if (!isAutoClearInProgress) {
2414
+ // ════════════════════════════════════════════════════════════════════════
2415
+ // TURN-END MAINTENANCE - Local mode only (proxy is sole eviction authority)
2416
+ // ════════════════════════════════════════════════════════════════════════
2417
+ // Detect idle prompt (turn end) and schedule cleanup
2418
+ const strippedOutput = stripAnsi(outputBuffer);
2419
+ const idlePromptDetected = IDLE_PROMPT_REGEX.test(strippedOutput);
2420
+ // DEFENSIVE LOGGING: Log when idle prompt detected but conditions fail
2421
+ if (idlePromptDetected && (!transcriptPath || isAutoClearInProgress)) {
2422
+ evictionDebugLog('TURN_CHECK_BLOCKED', 'Idle prompt detected but eviction blocked', {
2423
+ transcriptPath: transcriptPath || 'NULL',
2424
+ isAutoClearInProgress,
2425
+ lastContextPercent,
2426
+ outputBufferEnd: strippedOutput.slice(-100),
2427
+ });
2428
+ }
2429
+ if (!proxyModeEnabled && idlePromptDetected && transcriptPath && !isAutoClearInProgress) {
2430
+ // Cancel any existing timer
2431
+ if (turnEndTimeout) {
2432
+ clearTimeout(turnEndTimeout);
2433
+ }
2434
+ // Start new debounce timer - fires when idle for TURN_END_STABLE_MS
2435
+ turnEndTimeout = setTimeout(() => {
2436
+ handleTurnEnd().catch(err => {
2437
+ evictionDebugLog('TURN_END_ERROR', `Async eviction error: ${err.message}`);
2438
+ });
2439
+ turnEndTimeout = null;
2440
+ }, TURN_END_STABLE_MS);
2441
+ }
2442
+ // SLIDING WINDOW: Inject /clear after eviction to force transcript reload
2443
+ if (!proxyModeEnabled && idlePromptDetected && pendingClearAfterEviction && !isAutoClearInProgress) {
2444
+ pendingClearAfterEviction = false;
2445
+ isAutoClearInProgress = true;
2446
+ dlog('🔄 SLIDING WINDOW: Injecting /clear to reload evicted transcript');
2447
+ evictionDebugLog('SLIDING_WINDOW_CLEAR', 'Injecting /clear after eviction');
2448
+ // Pause stdin to prevent interference
2449
+ process.stdin.off('data', onStdinData);
2450
+ (async () => {
2451
+ try {
2452
+ // Clear current input line
2453
+ shell.write('\x15'); // Ctrl+U
2454
+ await sleep(60);
2455
+ // Type /clear
2456
+ for (const char of '/clear') {
2457
+ shell.write(char);
2458
+ await sleep(20);
2459
+ }
2460
+ await sleep(100);
2461
+ // Send Enter
2462
+ shell.write('\r');
2463
+ dlog('🔄 /clear injected - Claude Code will reload transcript');
2464
+ // Resume stdin after brief delay
2465
+ await sleep(500);
2466
+ process.stdin.on('data', onStdinData);
2467
+ isAutoClearInProgress = false;
2468
+ }
2469
+ catch (err) {
2470
+ dlog(`❌ Failed to inject /clear: ${err.message}`);
2471
+ process.stdin.on('data', onStdinData);
2472
+ isAutoClearInProgress = false;
2473
+ }
2474
+ })();
2475
+ }
2476
+ // BACKUP: Context wall detection - emergency evict (all modes)
2477
+ if (!proxyModeEnabled && !isAutoClearInProgress && transcriptPath) {
1270
2478
  const normalized = normalizeForMatch(outputBuffer);
1271
2479
  if (CONTEXT_WALL_REGEX.test(normalized)) {
1272
- dlog('Context wall detected via regex');
1273
- handleContextWall().catch(err => {
1274
- dlog(`Error during auto-clear: ${err.message}`);
1275
- isAutoClearInProgress = false;
1276
- });
2480
+ dlog('⚠️ CONTEXT WALL - emergency evict to 50%');
2481
+ // Cancel turn-end timer if pending
2482
+ if (turnEndTimeout) {
2483
+ clearTimeout(turnEndTimeout);
2484
+ turnEndTimeout = null;
2485
+ }
2486
+ const result = (0, jsonl_rewriter_1.emergencyEvict)(transcriptPath);
2487
+ if (result.success) {
2488
+ dlog(` ✅ Emergency evicted ${result.evicted} lines`);
2489
+ }
1277
2490
  }
1278
2491
  }
1279
2492
  });
1280
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
+ // ══════════════════════════════════════════════════════════════════════════
1281
2524
  // RESEARCH MODE: Auto-type research prompt after Claude is ready
1282
2525
  // Triggers: `ekkos run -r` or `ekkos run --research`
1283
2526
  // Works like /clear continue - waits for idle prompt, then injects text
@@ -1343,6 +2586,8 @@ Use Perplexity for deep research. Be thorough but efficient. Start now.`;
1343
2586
  stopStreamTailer(); // Stop stream capture
1344
2587
  (0, state_1.unregisterActiveSession)(); // Remove from active sessions registry
1345
2588
  cleanupInstanceFile(instanceId); // Clean up instance file
2589
+ // NOTE: No ccDNA restore needed - ekkOS uses separate installation from homebrew
2590
+ // ~/.ekkos/claude-code/ stays patched, homebrew `claude` is always vanilla
1346
2591
  // Restore terminal
1347
2592
  if (process.stdin.isTTY) {
1348
2593
  process.stdin.setRawMode(false);
@@ -1358,6 +2603,7 @@ Use Perplexity for deep research. Be thorough but efficient. Start now.`;
1358
2603
  stopStreamTailer(); // Stop stream capture
1359
2604
  (0, state_1.unregisterActiveSession)(); // Remove from active sessions registry
1360
2605
  cleanupInstanceFile(instanceId); // Clean up instance file
2606
+ // NOTE: No ccDNA restore needed - ekkOS uses separate installation from homebrew
1361
2607
  if (process.stdin.isTTY) {
1362
2608
  process.stdin.setRawMode(false);
1363
2609
  }
@@ -1368,160 +2614,3 @@ Use Perplexity for deep research. Be thorough but efficient. Start now.`;
1368
2614
  process.on('SIGINT', cleanup);
1369
2615
  process.on('SIGTERM', cleanup);
1370
2616
  }
1371
- /**
1372
- * Fallback implementation using spawn+script for PTY emulation
1373
- * Used when node-pty is not available (e.g., Node 24+)
1374
- */
1375
- async function runWithSpawn(claudePath, args, options, state) {
1376
- const verbose = options.verbose || false;
1377
- let { currentSession, isAutoClearInProgress, transcriptPath, currentSessionId, outputBuffer } = state;
1378
- // Debounce tracking
1379
- let lastDetectionTime = 0;
1380
- const DETECTION_COOLDOWN = 30000;
1381
- console.log(chalk_1.default.gray('Using spawn fallback mode (node-pty unavailable)'));
1382
- console.log(chalk_1.default.yellow('⚠️ Auto-continue requires PTY - manual /clear + /continue only'));
1383
- console.log(chalk_1.default.gray(' To enable auto-continue: npm rebuild node-pty'));
1384
- console.log('');
1385
- let claude;
1386
- if (isWindows) {
1387
- // ══════════════════════════════════════════════════════════════════════════
1388
- // WINDOWS: Full TTY passthrough (no output monitoring, no auto-inject)
1389
- //
1390
- // Why: Without node-pty/ConPTY, we cannot simultaneously:
1391
- // 1. Keep Claude TUI interactive
1392
- // 2. Read output for context-wall detection
1393
- //
1394
- // Piping stdout triggers Claude's --print mode which breaks the TUI.
1395
- // Solution: stdio: 'inherit' for complete passthrough.
1396
- // ══════════════════════════════════════════════════════════════════════════
1397
- if (claudePath === 'npx') {
1398
- console.log('');
1399
- console.log(chalk_1.default.red('═══════════════════════════════════════════════════════════════════'));
1400
- console.log(chalk_1.default.red(' Windows requires Claude Code to be installed globally'));
1401
- console.log(chalk_1.default.red('═══════════════════════════════════════════════════════════════════'));
1402
- console.log('');
1403
- console.log(chalk_1.default.yellow(' Run this command first:'));
1404
- console.log(chalk_1.default.cyan(' npm install -g @anthropic-ai/claude-code'));
1405
- console.log('');
1406
- console.log(chalk_1.default.yellow(' Then try again:'));
1407
- console.log(chalk_1.default.cyan(' ekkos run -b'));
1408
- console.log('');
1409
- process.exit(1);
1410
- }
1411
- console.log(chalk_1.default.gray('Windows mode: full TTY passthrough (no auto-inject)'));
1412
- console.log(chalk_1.default.gray('Context wall → manual /clear + /continue <session>'));
1413
- console.log('');
1414
- // Build command for passthrough
1415
- const fullCmd = args.length > 0
1416
- ? `"${claudePath}" ${args.join(' ')}`
1417
- : `"${claudePath}"`;
1418
- // spawnSync with stdio: 'inherit' = full console passthrough
1419
- // This is the ONLY way to keep Claude TUI working on Windows without PTY
1420
- const { spawnSync } = require('child_process');
1421
- try {
1422
- const result = spawnSync('cmd.exe', ['/c', fullCmd], {
1423
- stdio: 'inherit', // CRITICAL: Full passthrough, no piping
1424
- cwd: process.cwd(),
1425
- env: process.env,
1426
- windowsHide: false,
1427
- shell: false
1428
- });
1429
- (0, state_1.clearAutoClearFlag)();
1430
- process.exit(result.status || 0);
1431
- }
1432
- catch (err) {
1433
- console.error(chalk_1.default.red('Failed to launch Claude:'), err.message);
1434
- console.log('');
1435
- console.log(chalk_1.default.yellow('Try running claude directly:'));
1436
- console.log(chalk_1.default.cyan(' claude'));
1437
- console.log('');
1438
- process.exit(1);
1439
- }
1440
- return; // Unreachable due to spawnSync + process.exit, but explicit
1441
- }
1442
- else {
1443
- // Use script command for PTY on Unix
1444
- const scriptCmd = process.platform === 'darwin'
1445
- ? ['script', '-q', '/dev/null', claudePath, ...args]
1446
- : ['script', '-q', '-c', `${claudePath} ${args.join(' ')}`, '/dev/null'];
1447
- claude = (0, child_process_1.spawn)(scriptCmd[0], scriptCmd.slice(1), {
1448
- stdio: ['inherit', 'pipe', 'inherit'],
1449
- cwd: process.cwd(),
1450
- env: process.env
1451
- });
1452
- }
1453
- // Ensure claude was spawned
1454
- if (!claude) {
1455
- console.error(chalk_1.default.red('Failed to spawn Claude'));
1456
- process.exit(1);
1457
- }
1458
- // For Windows direct spawn without piped stdout
1459
- if (isWindows && !claude.stdout) {
1460
- console.log(chalk_1.default.yellow('Running in basic mode - context detection disabled'));
1461
- claude.on('exit', (code) => {
1462
- (0, state_1.clearAutoClearFlag)();
1463
- process.exit(code || 0);
1464
- });
1465
- return;
1466
- }
1467
- // Monitor output
1468
- if (claude.stdout) {
1469
- claude.stdout.on('data', async (data) => {
1470
- const chunk = data.toString();
1471
- process.stdout.write(chunk);
1472
- outputBuffer += chunk;
1473
- if (outputBuffer.length > 5000) {
1474
- outputBuffer = outputBuffer.slice(-2000);
1475
- }
1476
- // Extract session info
1477
- const sessionMatch = chunk.match(/session[_\s]?(?:id)?[:\s]+([a-f0-9-]{36})/i);
1478
- if (sessionMatch) {
1479
- currentSessionId = sessionMatch[1];
1480
- currentSession = (0, state_1.uuidToWords)(currentSessionId);
1481
- (0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
1482
- }
1483
- // Detect context wall - show manual instructions (ANSI-stripped + regex)
1484
- const now = Date.now();
1485
- if (!isAutoClearInProgress && (now - lastDetectionTime >= DETECTION_COOLDOWN)) {
1486
- const normalized = normalizeForMatch(outputBuffer);
1487
- if (CONTEXT_WALL_REGEX.test(normalized)) {
1488
- // CRITICAL: Clear buffer immediately
1489
- outputBuffer = '';
1490
- lastDetectionTime = now;
1491
- isAutoClearInProgress = true;
1492
- if (!currentSession) {
1493
- const savedState = (0, state_1.getState)();
1494
- currentSession = savedState?.sessionName || 'unknown-session';
1495
- }
1496
- // Log to file only - don't corrupt TUI
1497
- dlog('════════════════════════════════════════════════════════════');
1498
- dlog('CONTEXT LIMIT REACHED (spawn fallback mode)');
1499
- dlog(`Session: ${currentSession}`);
1500
- dlog('Manual restore required: /clear then /continue ' + currentSession);
1501
- dlog('════════════════════════════════════════════════════════════');
1502
- setTimeout(() => {
1503
- isAutoClearInProgress = false;
1504
- // Buffer already cleared at detection time
1505
- }, DETECTION_COOLDOWN);
1506
- }
1507
- }
1508
- });
1509
- }
1510
- claude.on('exit', (code) => {
1511
- (0, state_1.clearAutoClearFlag)();
1512
- dlog(`Claude exited with code ${code}`);
1513
- process.exit(code || 0);
1514
- });
1515
- claude.on('error', (err) => {
1516
- dlog(`Error: ${err.message}`);
1517
- process.exit(1);
1518
- });
1519
- // Cleanup
1520
- const cleanup = () => {
1521
- (0, state_1.clearAutoClearFlag)();
1522
- claude.kill();
1523
- process.exit(0);
1524
- };
1525
- process.on('SIGINT', cleanup);
1526
- process.on('SIGTERM', cleanup);
1527
- }