@ekkos/cli 0.2.18 → 0.3.3

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/capture/eviction-client.d.ts +139 -0
  3. package/dist/capture/eviction-client.js +454 -0
  4. package/dist/capture/index.d.ts +2 -0
  5. package/dist/capture/index.js +2 -0
  6. package/dist/capture/jsonl-rewriter.d.ts +96 -0
  7. package/dist/capture/jsonl-rewriter.js +1369 -0
  8. package/dist/capture/transcript-repair.d.ts +50 -0
  9. package/dist/capture/transcript-repair.js +308 -0
  10. package/dist/commands/doctor.js +23 -1
  11. package/dist/commands/run.d.ts +2 -0
  12. package/dist/commands/run.js +1229 -293
  13. package/dist/commands/usage.d.ts +7 -0
  14. package/dist/commands/usage.js +214 -0
  15. package/dist/cron/index.d.ts +7 -0
  16. package/dist/cron/index.js +13 -0
  17. package/dist/cron/promoter.d.ts +70 -0
  18. package/dist/cron/promoter.js +403 -0
  19. package/dist/index.js +24 -3
  20. package/dist/lib/usage-monitor.d.ts +47 -0
  21. package/dist/lib/usage-monitor.js +124 -0
  22. package/dist/lib/usage-parser.d.ts +72 -0
  23. package/dist/lib/usage-parser.js +238 -0
  24. package/dist/restore/RestoreOrchestrator.d.ts +4 -0
  25. package/dist/restore/RestoreOrchestrator.js +118 -30
  26. package/package.json +12 -12
  27. package/templates/cursor-hooks/after-agent-response.sh +0 -0
  28. package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
  29. package/templates/cursor-hooks/stop.sh +0 -0
  30. package/templates/ekkos-manifest.json +2 -2
  31. package/templates/hooks/assistant-response.sh +0 -0
  32. package/templates/hooks/session-start.sh +0 -0
  33. package/templates/plan-template.md +0 -0
  34. package/templates/spec-template.md +0 -0
  35. package/templates/agents/README.md +0 -182
  36. package/templates/agents/code-reviewer.md +0 -166
  37. package/templates/agents/debug-detective.md +0 -169
  38. package/templates/agents/ekkOS_Vercel.md +0 -99
  39. package/templates/agents/extension-manager.md +0 -229
  40. package/templates/agents/git-companion.md +0 -185
  41. package/templates/agents/github-test-agent.md +0 -321
  42. package/templates/agents/railway-manager.md +0 -215
@@ -38,22 +38,148 @@ 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
+ const ccdnaPath = findCcdnaPath();
80
+ if (!ccdnaPath) {
81
+ if (verbose) {
82
+ console.log(chalk_1.default.gray(' ccDNA not found - skipping patches'));
83
+ }
84
+ return null;
85
+ }
86
+ // Read ccDNA version from package.json FIRST
87
+ let ccdnaVersion = 'unknown';
88
+ try {
89
+ const pkgPath = path.join(path.dirname(ccdnaPath), '..', 'package.json');
90
+ if (fs.existsSync(pkgPath)) {
91
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
92
+ ccdnaVersion = pkg.version || 'unknown';
93
+ }
94
+ }
95
+ catch {
96
+ // Ignore version detection errors
97
+ }
98
+ try {
99
+ // Set env var to tell ccDNA which Claude to patch
100
+ // eslint-disable-next-line no-restricted-syntax
101
+ const env = { ...process.env };
102
+ if (claudePath) {
103
+ // ccDNA checks CCDNA_CC_INSTALLATION_PATH to override default detection
104
+ env.CCDNA_CC_INSTALLATION_PATH = claudePath;
105
+ }
106
+ // Run ccDNA in apply mode (non-interactive)
107
+ (0, child_process_1.execSync)(`node "${ccdnaPath}" -a`, {
108
+ stdio: verbose ? 'inherit' : 'pipe',
109
+ timeout: 30000, // 30 second timeout
110
+ env,
111
+ });
112
+ if (verbose) {
113
+ console.log(chalk_1.default.green(` ✓ ccDNA v${ccdnaVersion} patches applied`));
114
+ }
115
+ return ccdnaVersion;
116
+ }
117
+ catch (err) {
118
+ if (verbose) {
119
+ console.log(chalk_1.default.yellow(` ⚠ ccDNA patch failed: ${err.message}`));
120
+ }
121
+ return null;
122
+ }
123
+ }
124
+ /**
125
+ * Restore original Claude Code (remove ccDNA patches) on exit
126
+ * This restores the ekkOS-managed installation (~/.ekkos/claude-code/) to its base state
127
+ *
128
+ * NOTE: We intentionally DON'T restore on exit anymore because:
129
+ * 1. ekkOS uses a SEPARATE installation (~/.ekkos/claude-code/) from homebrew
130
+ * 2. The homebrew `claude` command should always be vanilla (untouched)
131
+ * 3. The ekkOS installation can stay patched - it's only used by `ekkos run`
132
+ *
133
+ * This function is kept for manual/explicit restore scenarios.
134
+ */
135
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
136
+ function restoreCcdnaPatches(verbose, claudePath) {
137
+ const ccdnaPath = findCcdnaPath();
138
+ if (!ccdnaPath) {
139
+ return false;
140
+ }
141
+ try {
142
+ // Set env var to tell ccDNA which Claude to restore
143
+ // eslint-disable-next-line no-restricted-syntax
144
+ const env = { ...process.env };
145
+ if (claudePath) {
146
+ env.CCDNA_CC_INSTALLATION_PATH = claudePath;
147
+ }
148
+ // Run ccDNA in restore mode (non-interactive)
149
+ (0, child_process_1.execSync)(`node "${ccdnaPath}" -r`, {
150
+ stdio: verbose ? 'inherit' : 'pipe',
151
+ timeout: 30000, // 30 second timeout
152
+ env,
153
+ });
154
+ if (verbose) {
155
+ console.log(chalk_1.default.green(' ✓ ccDNA patches removed (vanilla restored)'));
156
+ }
157
+ return true;
158
+ }
159
+ catch (err) {
160
+ if (verbose) {
161
+ console.log(chalk_1.default.yellow(` ⚠ ccDNA restore failed: ${err.message}`));
162
+ }
163
+ return false;
164
+ }
165
+ }
45
166
  const state_1 = require("../utils/state");
46
167
  const doctor_1 = require("./doctor");
47
168
  const stream_tailer_1 = require("../capture/stream-tailer");
169
+ const jsonl_rewriter_1 = require("../capture/jsonl-rewriter");
170
+ const transcript_repair_1 = require("../capture/transcript-repair");
48
171
  // Try to load node-pty (may fail on Node 24+)
49
172
  let pty = null;
50
- try {
51
- pty = require('node-pty');
52
- }
53
- catch {
54
- // node-pty not available, will use spawn fallback
55
- }
173
+ (async () => {
174
+ try {
175
+ pty = await Promise.resolve().then(() => __importStar(require('node-pty')));
176
+ }
177
+ catch {
178
+ // node-pty not available, will use spawn fallback
179
+ }
180
+ })();
56
181
  function getConfig(options) {
182
+ /* eslint-disable no-restricted-syntax -- Config timing values, not API keys */
57
183
  return {
58
184
  slashOpenDelayMs: options.slashOpenDelayMs ??
59
185
  parseInt(process.env.EKKOS_SLASH_OPEN_DELAY_MS || '500', 10), // was 1000
@@ -70,6 +196,7 @@ function getConfig(options) {
70
196
  process.env.EKKOS_DEBUG_LOG_PATH ??
71
197
  path.join(os.homedir(), '.ekkos', 'auto-continue.debug.log')
72
198
  };
199
+ /* eslint-enable no-restricted-syntax */
73
200
  }
74
201
  // ═══════════════════════════════════════════════════════════════════════════
75
202
  // PATTERN MATCHING
@@ -108,6 +235,22 @@ const PALETTE_INDICATOR_REGEX = /\/(clear|continue|compact|help|bug|config)/i;
108
235
  const SESSION_NAME_IN_STATUS_REGEX = /·\s*([a-z]+-[a-z]+-[a-z]+)\s*·/i;
109
236
  // Weaker signal: any 3-word slug (word-word-word pattern)
110
237
  const SESSION_NAME_REGEX = /\b([a-z]+-[a-z]+-[a-z]+)\b/i;
238
+ // Orphan tool_result marker emitted by ccDNA validate mode
239
+ // Example: [ekkOS] ORPHAN_TOOL_RESULT {"idx":0,"tool_use_id":"toolu_01...","block_idx":0}
240
+ const ORPHAN_MARKER_REGEX = /\[ekkOS\]\s+ORPHAN_TOOL_RESULT\s+(\{.*?\})/gi;
241
+ // Cooldown to prevent thrashing if output repeats the marker
242
+ const ORPHAN_DETECTION_COOLDOWN_MS = 15000;
243
+ // ═══════════════════════════════════════════════════════════════════════════
244
+ // SILENT FAILURE DETECTION - Catch API errors even when ccDNA markers missing
245
+ // ═══════════════════════════════════════════════════════════════════════════
246
+ // Pattern 1: API returns 400 error (often due to orphan tool_results)
247
+ const API_400_REGEX = /(?:status[:\s]*400|"status":\s*400|HTTP\/\d\.\d\s+400|error.*400)/i;
248
+ // Pattern 2: Anthropic API specific error about tool_result without tool_use
249
+ const ORPHAN_API_ERROR_REGEX = /tool_result.*(?:no matching|without|missing).*tool_use|tool_use.*not found/i;
250
+ // Pattern 3: Generic "invalid" message structure error
251
+ const INVALID_MESSAGE_REGEX = /invalid.*message|message.*invalid|malformed.*request/i;
252
+ // Cooldown for silent failure detection (separate from orphan marker cooldown)
253
+ const SILENT_FAILURE_COOLDOWN_MS = 30000;
111
254
  // ═══════════════════════════════════════════════════════════════════════════
112
255
  // SESSION NAME VALIDATION (MUST use words from session-words.json)
113
256
  // This is the SOURCE OF TRUTH for valid session names
@@ -222,17 +365,29 @@ async function runSlashCommand(shell, command, config, getOutputBuffer, arg) {
222
365
  await typeSlowly(shell, '/', config.charDelayMs);
223
366
  dlog('Typed / to open palette');
224
367
  // STEP 3: Wait for palette to open
225
- await sleep(config.slashOpenDelayMs);
368
+ // Improved: Poll for palette indicator instead of hard sleep.
369
+ // This reduces latency on fast machines while ensuring safety on slow ones.
370
+ let paletteVisible = false;
371
+ const paletteStartTime = Date.now();
372
+ const maxWait = config.slashOpenDelayMs + config.paletteRetryMs;
373
+ while (Date.now() - paletteStartTime < maxWait) {
374
+ const currentBuffer = getOutputBuffer();
375
+ if (PALETTE_INDICATOR_REGEX.test(stripAnsi(currentBuffer))) {
376
+ paletteVisible = true;
377
+ dlog(`Palette detected after ${Date.now() - paletteStartTime}ms`);
378
+ break;
379
+ }
380
+ await sleep(50);
381
+ }
226
382
  // 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);
383
+ if (!paletteVisible) {
384
+ // Palette definitely didn't open - retry once
385
+ dlog('Palette indicator not detected after polling, retrying with force clear');
386
+ // Type / again in case first one was eaten or stuck in mid-render
387
+ shell.write('\x15'); // Ctrl+U
388
+ await sleep(100);
235
389
  await typeSlowly(shell, '/', config.charDelayMs);
390
+ // Brief wait for the second attempt
236
391
  await sleep(config.slashOpenDelayMs);
237
392
  }
238
393
  // STEP 5: Type the command
@@ -254,58 +409,191 @@ async function runSlashCommand(shell, command, config, getOutputBuffer, arg) {
254
409
  // Memory API URL
255
410
  const MEMORY_API_URL = 'https://mcp.ekkos.dev';
256
411
  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';
412
+ // Claude Code version for ekkos run
413
+ // 'latest' = use latest version, or specify like '2.1.33' for specific version
414
+ // Core ekkOS patches (eviction, context management) work with all recent versions
415
+ // Cosmetic patches may fail on newer versions but don't affect functionality
416
+ const PINNED_CLAUDE_VERSION = '2.1.33';
417
+ // Max output tokens for Claude responses
418
+ // Default: 16384 (safe for Sonnet 4.5)
419
+ // Opus 4.5 supports up to 64k - set EKKOS_MAX_OUTPUT_TOKENS=32768 or =65536 to use higher limits
420
+ // Configurable via environment variable
421
+ const EKKOS_MAX_OUTPUT_TOKENS = process.env.EKKOS_MAX_OUTPUT_TOKENS || '16384';
422
+ // Default proxy URL for context eviction
423
+ // eslint-disable-next-line no-restricted-syntax -- Config URL, not API key
424
+ const EKKOS_PROXY_URL = process.env.EKKOS_PROXY_URL || 'https://mcp.ekkos.dev';
425
+ // Track proxy mode for getEkkosEnv (set by run() based on options)
426
+ let proxyModeEnabled = true;
427
+ // ═══════════════════════════════════════════════════════════════════════════
428
+ // SESSION NAME GENERATION - Uses shared uuidToWords from state.ts
429
+ // ═══════════════════════════════════════════════════════════════════════════
430
+ /**
431
+ * Generate a unique session UUID and convert to human-readable name
432
+ * Each CLI invocation gets a NEW session (not tied to project path)
433
+ * Uses uuidToWords from state.ts for consistency with hooks
434
+ */
435
+ function generateCliSessionName() {
436
+ const sessionUuid = crypto.randomUUID();
437
+ return (0, state_1.uuidToWords)(sessionUuid);
438
+ }
439
+ // Track current CLI session name (set once at startup, stable for entire run)
440
+ let cliSessionName = null;
441
+ let cliSessionId = null;
442
+ /**
443
+ * Get environment with ekkOS enhancements
444
+ * - Sets CLAUDE_CODE_MAX_OUTPUT_TOKENS to 32k for longer responses
445
+ * - Routes API through ekkOS proxy for seamless context eviction (when enabled)
446
+ * - Sets EKKOS_PROXY_MODE to signal JSONL rewriter to disable eviction
447
+ * - Passes session headers for eviction/retrieval context tracking
448
+ */
449
+ function getEkkosEnv() {
450
+ /* eslint-disable no-restricted-syntax -- System env spreading, not API key access */
451
+ const env = {
452
+ ...process.env,
453
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: EKKOS_MAX_OUTPUT_TOKENS,
454
+ };
455
+ /* eslint-enable no-restricted-syntax */
456
+ // Check if proxy is disabled via env var or options
457
+ // eslint-disable-next-line no-restricted-syntax -- Feature flag, not API key
458
+ const proxyDisabled = process.env.EKKOS_DISABLE_PROXY === '1' || !proxyModeEnabled;
459
+ if (!proxyDisabled) {
460
+ env.EKKOS_PROXY_MODE = '1';
461
+ // Enable ultra-minimal mode by default (30%→20% eviction for constant-cost infinite context)
462
+ env.EKKOS_ULTRA_MINIMAL = '1';
463
+ // Use placeholder for session name - will be bound by hook with Claude's real session
464
+ // This fixes the mismatch where CLI generated one name but Claude Code used another
465
+ // The hook calls POST /proxy/session/bind with Claude's actual session name
466
+ if (!cliSessionName) {
467
+ cliSessionName = '_pending'; // Placeholder - hook will bind real name
468
+ cliSessionId = `pending-${Date.now()}`;
469
+ console.log(chalk_1.default.gray(` 📂 Session: pending (will bind to Claude session)`));
470
+ }
471
+ // Get full userId from config (NOT the truncated version from auth token)
472
+ // Config has full UUID like "d4532ba0-0a86-42ce-bab4-22aa62b55ce6"
473
+ // This matches the turns/ R2 structure: turns/{fullUserId}/{sessionName}/
474
+ const ekkosConfig = (0, state_1.getConfig)();
475
+ let userId = ekkosConfig?.userId || 'anonymous';
476
+ // Fallback to auth token extraction if config doesn't have userId
477
+ if (userId === 'anonymous') {
478
+ const authToken = (0, state_1.getAuthToken)();
479
+ if (authToken?.startsWith('ekk_')) {
480
+ const parts = authToken.split('_');
481
+ if (parts.length >= 2) {
482
+ userId = parts[1];
483
+ }
484
+ }
485
+ }
486
+ // CRITICAL: Embed user/session in URL path since ANTHROPIC_HEADERS doesn't work
487
+ // Claude Code SDK doesn't forward custom headers, but it DOES use ANTHROPIC_BASE_URL
488
+ // Format: https://mcp.ekkos.dev/proxy/{userId}/{sessionName}?project={base64(cwd)}
489
+ // Gateway extracts from URL: /proxy/{userId}/{sessionName}/v1/messages
490
+ // Project path is base64-encoded to handle special chars safely
491
+ const projectPath = process.cwd();
492
+ const projectPathEncoded = Buffer.from(projectPath).toString('base64url');
493
+ const proxyUrl = `${EKKOS_PROXY_URL}/proxy/${encodeURIComponent(userId)}/${encodeURIComponent(cliSessionName)}?project=${projectPathEncoded}`;
494
+ env.ANTHROPIC_BASE_URL = proxyUrl;
495
+ console.log(chalk_1.default.gray(` 📡 Proxy: ${proxyUrl.replace(userId, userId.slice(0, 8) + '...')}`));
496
+ }
497
+ else {
498
+ env.EKKOS_PROXY_MODE = '0';
499
+ }
500
+ return env;
501
+ }
261
502
  // ekkOS-managed Claude installation path
262
503
  const EKKOS_CLAUDE_DIR = path.join(os.homedir(), '.ekkos', 'claude-code');
263
504
  const EKKOS_CLAUDE_BIN = path.join(EKKOS_CLAUDE_DIR, 'node_modules', '.bin', 'claude');
264
505
  /**
265
- * Check if a Claude installation matches our required version
506
+ * Check if a Claude installation exists and get its version
507
+ * Returns version string if found, null otherwise
266
508
  */
267
- function checkClaudeVersion(claudePath) {
509
+ function getClaudeVersion(claudePath) {
268
510
  try {
269
511
  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;
512
+ // Look for pattern like "2.1.6 (Claude Code)" or just "2.1.6" anywhere in output
513
+ const match = version.match(/(\d+\.\d+\.\d+)\s*\(Claude Code\)/);
514
+ if (match)
515
+ return match[1];
516
+ const fallbackMatch = version.match(/(\d+\.\d+\.\d+)/);
517
+ if (fallbackMatch)
518
+ return fallbackMatch[1];
519
+ return null;
276
520
  }
277
521
  catch {
278
- return false;
522
+ return null;
279
523
  }
280
524
  }
525
+ /**
526
+ * Check if a Claude installation matches our required version
527
+ * When PINNED_CLAUDE_VERSION is 'latest', any version is acceptable
528
+ */
529
+ function checkClaudeVersion(claudePath) {
530
+ const version = getClaudeVersion(claudePath);
531
+ if (!version)
532
+ return false;
533
+ // 'latest' means any version is acceptable
534
+ if (PINNED_CLAUDE_VERSION === 'latest')
535
+ return true;
536
+ return version === PINNED_CLAUDE_VERSION;
537
+ }
281
538
  /**
282
539
  * Install Claude Code to ekkOS-managed directory
283
540
  * This gives us full control over the version without npx auto-update messages
284
541
  */
285
542
  function installEkkosClaudeVersion() {
286
- console.log(chalk_1.default.cyan(`\n📦 Installing Claude Code v${PINNED_CLAUDE_VERSION} to ~/.ekkos/claude-code...`));
543
+ const versionLabel = PINNED_CLAUDE_VERSION === 'latest' ? 'latest' : `v${PINNED_CLAUDE_VERSION}`;
544
+ console.log(chalk_1.default.cyan(`\n📦 Installing Claude Code ${versionLabel} to ~/.ekkos/claude-code...`));
287
545
  console.log(chalk_1.default.gray(' (This is a one-time setup for optimal context window behavior)\n'));
288
546
  try {
289
547
  // Create directory if needed
290
548
  if (!fs.existsSync(EKKOS_CLAUDE_DIR)) {
291
549
  fs.mkdirSync(EKKOS_CLAUDE_DIR, { recursive: true });
292
550
  }
293
- // Initialize package.json if needed
551
+ // Clean existing installation to ensure correct version is installed
552
+ // This prevents npm from reusing a cached/different version
553
+ const nodeModulesPath = path.join(EKKOS_CLAUDE_DIR, 'node_modules');
554
+ const packageLockPath = path.join(EKKOS_CLAUDE_DIR, 'package-lock.json');
555
+ if (fs.existsSync(nodeModulesPath)) {
556
+ fs.rmSync(nodeModulesPath, { recursive: true, force: true });
557
+ }
558
+ if (fs.existsSync(packageLockPath)) {
559
+ fs.unlinkSync(packageLockPath);
560
+ }
561
+ // Always write fresh package.json with exact version pinned
294
562
  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}`, {
563
+ fs.writeFileSync(packageJsonPath, JSON.stringify({
564
+ name: 'ekkos-claude-code',
565
+ version: '1.0.0',
566
+ private: true,
567
+ description: 'ekkOS-managed Claude Code installation',
568
+ dependencies: {
569
+ '@anthropic-ai/claude-code': PINNED_CLAUDE_VERSION
570
+ }
571
+ }, null, 2));
572
+ // Install with exact version pinning
573
+ (0, child_process_1.execSync)(`npm install --save-exact`, {
305
574
  cwd: EKKOS_CLAUDE_DIR,
306
575
  stdio: 'inherit'
307
576
  });
308
- console.log(chalk_1.default.green(`\n✓ Claude Code v${PINNED_CLAUDE_VERSION} installed successfully!`));
577
+ // Verify the installed version
578
+ const installedPkgPath = path.join(EKKOS_CLAUDE_DIR, 'node_modules', '@anthropic-ai', 'claude-code', 'package.json');
579
+ let installedVersion = 'unknown';
580
+ if (fs.existsSync(installedPkgPath)) {
581
+ const installedPkg = JSON.parse(fs.readFileSync(installedPkgPath, 'utf-8'));
582
+ installedVersion = installedPkg.version;
583
+ // Only check version match if not using 'latest'
584
+ if (PINNED_CLAUDE_VERSION !== 'latest' && installedPkg.version !== PINNED_CLAUDE_VERSION) {
585
+ console.error(chalk_1.default.red(`\n✗ Version mismatch: expected ${PINNED_CLAUDE_VERSION}, got ${installedPkg.version}`));
586
+ console.log(chalk_1.default.yellow(' Trying to force correct version...\n'));
587
+ // Force reinstall with exact version
588
+ fs.rmSync(nodeModulesPath, { recursive: true, force: true });
589
+ (0, child_process_1.execSync)(`npm install @anthropic-ai/claude-code@${PINNED_CLAUDE_VERSION} --save-exact`, {
590
+ cwd: EKKOS_CLAUDE_DIR,
591
+ stdio: 'inherit'
592
+ });
593
+ installedVersion = PINNED_CLAUDE_VERSION;
594
+ }
595
+ }
596
+ console.log(chalk_1.default.green(`\n✓ Claude Code v${installedVersion} installed successfully!`));
309
597
  return true;
310
598
  }
311
599
  catch (err) {
@@ -318,54 +606,45 @@ function installEkkosClaudeVersion() {
318
606
  * Resolve full path to claude executable
319
607
  * Returns direct path if found with correct version, otherwise 'npx:VERSION'
320
608
  *
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.
609
+ * IMPORTANT: We pin to a specific Claude Code version (currently 2.1.33) to ensure
610
+ * consistent behavior with ekkOS context management and eviction patches.
611
+ *
612
+ * CRITICAL: ekkos run ONLY uses the ekkOS-managed installation at ~/.ekkos/claude-code/
613
+ * This ensures complete separation from the user's existing Claude installation (Homebrew/npm).
614
+ * The user's `claude` command remains untouched and can be any version.
324
615
  *
325
616
  * 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)
617
+ * 1. ekkOS-managed installation (~/.ekkos/claude-code) - ONLY option for ekkos run
618
+ * 2. Auto-install if doesn't exist
619
+ * 3. npx with pinned version (fallback if install fails)
329
620
  */
330
621
  function resolveClaudePath() {
331
- // PRIORITY 1: ekkOS-managed installation (cleanest - no update messages)
622
+ // PRIORITY 1: ekkOS-managed installation
332
623
  if (fs.existsSync(EKKOS_CLAUDE_BIN) && checkClaudeVersion(EKKOS_CLAUDE_BIN)) {
333
624
  return EKKOS_CLAUDE_BIN;
334
625
  }
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
626
+ // PRIORITY 2: Auto-install to ekkOS-managed directory (user's Claude stays untouched)
352
627
  if (installEkkosClaudeVersion()) {
353
628
  if (fs.existsSync(EKKOS_CLAUDE_BIN)) {
354
629
  return EKKOS_CLAUDE_BIN;
355
630
  }
356
631
  }
357
- // PRIORITY 4: Fall back to npx with pinned version (shows update message)
632
+ // PRIORITY 3: Fall back to npx with pinned version (shows update message)
633
+ // This is rare - only happens if install failed
358
634
  return `npx:${PINNED_CLAUDE_VERSION}`;
359
635
  }
360
636
  /**
361
637
  * Original resolve function for fallback to global install
362
638
  */
639
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
363
640
  function resolveGlobalClaudePath() {
364
641
  // Windows global paths
365
642
  if (isWindows) {
643
+ /* eslint-disable no-restricted-syntax -- System paths, not API keys */
366
644
  const windowsPaths = [
367
645
  path.join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
368
646
  path.join(process.env.LOCALAPPDATA || '', 'npm', 'claude.cmd'),
647
+ /* eslint-enable no-restricted-syntax */
369
648
  path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'claude.cmd'),
370
649
  path.join(os.homedir(), '.npm-global', 'claude.cmd')
371
650
  ];
@@ -467,7 +746,7 @@ async function emergencyCapture(transcriptPath, sessionId) {
467
746
  dlog('Context captured to ekkOS');
468
747
  }
469
748
  }
470
- catch (err) {
749
+ catch {
471
750
  // Silent fail - don't block the clear process
472
751
  dlog('Warning: Could not capture context');
473
752
  }
@@ -508,8 +787,17 @@ async function run(options) {
508
787
  const verbose = options.verbose || false;
509
788
  const bypass = options.bypass || false;
510
789
  const noInject = options.noInject || false;
790
+ // Set proxy mode based on options (used by getEkkosEnv)
791
+ proxyModeEnabled = !(options.noProxy || false);
792
+ if (proxyModeEnabled) {
793
+ console.log(chalk_1.default.cyan(' 🧠 ekkOS_Continuum Loaded!'));
794
+ }
795
+ else if (verbose) {
796
+ console.log(chalk_1.default.yellow(' ⏭️ API proxy disabled (--no-proxy)'));
797
+ }
511
798
  // Generate instance ID for this run
512
799
  const instanceId = generateInstanceId();
800
+ // eslint-disable-next-line no-restricted-syntax -- Instance tracking, not API key
513
801
  process.env.EKKOS_INSTANCE_ID = instanceId;
514
802
  // ══════════════════════════════════════════════════════════════════════════
515
803
  // PRE-FLIGHT DIAGNOSTICS (--doctor flag)
@@ -540,8 +828,43 @@ async function run(options) {
540
828
  // ══════════════════════════════════════════════════════════════════════════
541
829
  (0, state_1.ensureEkkosDir)();
542
830
  (0, state_1.clearAutoClearFlag)();
831
+ // Resolve Claude path FIRST so ccDNA patches the RIGHT installation
543
832
  const rawClaudePath = resolveClaudePath();
544
833
  const isNpxMode = rawClaudePath.startsWith('npx:');
834
+ // Get the actual CLI path for ccDNA to patch
835
+ // CRITICAL: ONLY patch the ekkOS-managed installation, NEVER touch Homebrew/global!
836
+ let claudeCliPath;
837
+ // Always target the ekkOS-managed installation for patching
838
+ // Even if we're running from Homebrew, we only patch our own installation
839
+ if (fs.existsSync(EKKOS_CLAUDE_BIN)) {
840
+ try {
841
+ const realPath = fs.realpathSync(EKKOS_CLAUDE_BIN);
842
+ if (realPath.endsWith('.js') && fs.existsSync(realPath)) {
843
+ claudeCliPath = realPath;
844
+ }
845
+ }
846
+ catch {
847
+ // Ignore - will use default detection
848
+ }
849
+ }
850
+ // ══════════════════════════════════════════════════════════════════════════
851
+ // ccDNA AUTO-PATCH: Apply Claude Code customizations before spawn
852
+ // This patches the context warning, themes, and other ccDNA features
853
+ // Skip if --no-dna flag is set
854
+ // ══════════════════════════════════════════════════════════════════════════
855
+ const noDna = options.noDna || false;
856
+ let ccdnaVersion = null;
857
+ if (noDna) {
858
+ if (verbose) {
859
+ console.log(chalk_1.default.yellow(' ⏭️ Skipping ccDNA injection (--no-dna)'));
860
+ }
861
+ }
862
+ else {
863
+ if (verbose && claudeCliPath) {
864
+ console.log(chalk_1.default.gray(` 🔧 Patching: ${claudeCliPath}`));
865
+ }
866
+ ccdnaVersion = applyCcdnaPatches(verbose, claudeCliPath);
867
+ }
545
868
  const pinnedVersion = isNpxMode ? rawClaudePath.split(':')[1] : null;
546
869
  const claudePath = isNpxMode ? 'npx' : rawClaudePath;
547
870
  // Build args early
@@ -554,7 +877,6 @@ async function run(options) {
554
877
  }
555
878
  // Check PTY availability early
556
879
  const usePty = pty !== null;
557
- const monitorOnlyMode = noInject || (isWindows && !usePty);
558
880
  // ══════════════════════════════════════════════════════════════════════════
559
881
  // CONCURRENT STARTUP: Spawn Claude while animation runs
560
882
  // Buffer output until animation completes, then flush
@@ -574,7 +896,7 @@ async function run(options) {
574
896
  cols: process.stdout.columns || 80,
575
897
  rows: process.stdout.rows || 24,
576
898
  cwd: process.cwd(),
577
- env: process.env
899
+ env: getEkkosEnv()
578
900
  });
579
901
  // Buffer output until animation completes using delegating handler
580
902
  earlyDataHandler = (data) => {
@@ -657,14 +979,18 @@ async function run(options) {
657
979
  // Find positions with sparkle-able characters
658
980
  const sparklePositions = [];
659
981
  for (let i = 0; i < line.length; i++) {
660
- if (sparkleChars.includes(line[i])) {
982
+ const charItem = line[i];
983
+ if (typeof charItem === 'string' && sparkleChars.includes(charItem)) {
661
984
  sparklePositions.push(i);
662
985
  }
663
986
  }
664
987
  if (sparklePositions.length > 0) {
665
988
  const pos = sparklePositions[Math.floor(Math.random() * sparklePositions.length)];
989
+ const charAtPos = line[pos];
666
990
  // Mark this position for sparkle (we'll handle coloring below)
667
- frameLines[lineIdx][pos] = { char: line[pos], sparkle: true };
991
+ if (typeof charAtPos === 'string') {
992
+ frameLines[lineIdx][pos] = { char: charAtPos, sparkle: true };
993
+ }
668
994
  }
669
995
  }
670
996
  // Move cursor up and render frame
@@ -672,7 +998,7 @@ async function run(options) {
672
998
  for (const line of frameLines) {
673
999
  let output = '';
674
1000
  for (const char of line) {
675
- if (char && typeof char === 'object' && char.sparkle) {
1001
+ if (typeof char === 'object' && 'sparkle' in char && char.sparkle) {
676
1002
  const sparkleColor = sparkleColors[Math.floor(Math.random() * sparkleColors.length)];
677
1003
  output += sparkleColor(char.char);
678
1004
  }
@@ -772,6 +1098,9 @@ async function run(options) {
772
1098
  if (bypass) {
773
1099
  console.log(chalk_1.default.yellow(' ⚡ Bypass permissions mode enabled'));
774
1100
  }
1101
+ if (noDna) {
1102
+ console.log(chalk_1.default.yellow(' ⏭️ ccDNA injection skipped (--no-dna)'));
1103
+ }
775
1104
  if (verbose) {
776
1105
  console.log(chalk_1.default.gray(` 📁 Debug log: ${config.debugLogPath}`));
777
1106
  console.log(chalk_1.default.gray(` ⏱ Timing: clear=${config.clearWaitMs}ms, idleMax=${config.maxIdleWaitMs}ms (~${Math.round((config.clearWaitMs + config.maxIdleWaitMs * 2 + 1700) / 1000)}s total)`));
@@ -820,9 +1149,8 @@ async function run(options) {
820
1149
  let isAutoClearInProgress = false;
821
1150
  let transcriptPath = null;
822
1151
  let currentSessionId = null;
823
- // Stream tailer for mid-turn context capture
1152
+ // Stream tailer for mid-turn context capture (must be declared before polling code)
824
1153
  let streamTailer = null;
825
- // Instance-namespaced cache directory per spec v1.2
826
1154
  const streamCacheDir = path.join(os.homedir(), '.ekkos', 'cache', 'sessions', instanceId);
827
1155
  // Helper to start stream tailer when we have transcript path
828
1156
  function startStreamTailer(tPath, sId, sName) {
@@ -853,6 +1181,132 @@ async function run(options) {
853
1181
  dlog('Stream tailer stopped');
854
1182
  }
855
1183
  }
1184
+ // ════════════════════════════════════════════════════════════════════════════
1185
+ // FAST TRANSCRIPT DETECTION: Poll for new jsonl files immediately
1186
+ // Claude creates the transcript file BEFORE outputting the session name
1187
+ // So we watch for new files rather than parsing TUI output (which is slower)
1188
+ // ════════════════════════════════════════════════════════════════════════════
1189
+ const encodedCwd = process.cwd().replace(/\//g, '-');
1190
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedCwd);
1191
+ const launchTime = Date.now();
1192
+ // Track existing jsonl files at startup
1193
+ let existingJsonlFiles = new Set();
1194
+ try {
1195
+ const files = fs.readdirSync(projectDir);
1196
+ existingJsonlFiles = new Set(files.filter(f => f.endsWith('.jsonl')));
1197
+ dlog(`[TRANSCRIPT] Found ${existingJsonlFiles.size} existing jsonl files at startup`);
1198
+ }
1199
+ catch {
1200
+ dlog('[TRANSCRIPT] Project dir does not exist yet');
1201
+ }
1202
+ // Poll for new transcript file every 500ms for up to 30 seconds
1203
+ let transcriptPollInterval = null;
1204
+ function pollForNewTranscript() {
1205
+ if (transcriptPath) {
1206
+ // Already found - stop polling
1207
+ if (transcriptPollInterval) {
1208
+ clearInterval(transcriptPollInterval);
1209
+ transcriptPollInterval = null;
1210
+ }
1211
+ return;
1212
+ }
1213
+ // Stop after 30 seconds
1214
+ if (Date.now() - launchTime > 30000) {
1215
+ // FALLBACK FIX: If no transcript found yet, pick most recent jsonl as best guess
1216
+ // This handles /continue scenarios where the file already existed
1217
+ if (!transcriptPath) {
1218
+ try {
1219
+ const files = fs.readdirSync(projectDir);
1220
+ const jsonlFiles = files
1221
+ .filter(f => f.endsWith('.jsonl'))
1222
+ .map(f => ({
1223
+ name: f,
1224
+ path: path.join(projectDir, f),
1225
+ mtime: fs.statSync(path.join(projectDir, f)).mtimeMs
1226
+ }))
1227
+ .sort((a, b) => b.mtime - a.mtime);
1228
+ if (jsonlFiles.length > 0) {
1229
+ transcriptPath = jsonlFiles[0].path;
1230
+ currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
1231
+ dlog(`[TRANSCRIPT] TIMEOUT FALLBACK: Using most recent file ${transcriptPath}`);
1232
+ evictionDebugLog('TRANSCRIPT_SET', 'Polling timeout fallback - using most recent jsonl', {
1233
+ transcriptPath,
1234
+ currentSessionId,
1235
+ fileCount: jsonlFiles.length,
1236
+ });
1237
+ startStreamTailer(transcriptPath, currentSessionId);
1238
+ }
1239
+ else {
1240
+ dlog('[TRANSCRIPT] TIMEOUT FALLBACK: No jsonl files found in project dir');
1241
+ }
1242
+ }
1243
+ catch (err) {
1244
+ dlog(`[TRANSCRIPT] TIMEOUT FALLBACK ERROR: ${err.message}`);
1245
+ }
1246
+ }
1247
+ dlog('[TRANSCRIPT] Polling timeout - fallback complete');
1248
+ if (transcriptPollInterval) {
1249
+ clearInterval(transcriptPollInterval);
1250
+ transcriptPollInterval = null;
1251
+ }
1252
+ return;
1253
+ }
1254
+ try {
1255
+ const currentFiles = fs.readdirSync(projectDir);
1256
+ const jsonlFiles = currentFiles.filter(f => f.endsWith('.jsonl'));
1257
+ // Find NEW files (created after we started)
1258
+ for (const file of jsonlFiles) {
1259
+ if (!existingJsonlFiles.has(file)) {
1260
+ // New file! This is our transcript
1261
+ const fullPath = path.join(projectDir, file);
1262
+ const sessionId = file.replace('.jsonl', '');
1263
+ transcriptPath = fullPath;
1264
+ currentSessionId = sessionId;
1265
+ dlog(`[TRANSCRIPT] FAST DETECT: New transcript found! ${fullPath}`);
1266
+ evictionDebugLog('TRANSCRIPT_SET', 'Fast poll detected new file', {
1267
+ transcriptPath,
1268
+ currentSessionId,
1269
+ elapsedMs: Date.now() - launchTime
1270
+ });
1271
+ startStreamTailer(transcriptPath, currentSessionId);
1272
+ // Stop polling
1273
+ if (transcriptPollInterval) {
1274
+ clearInterval(transcriptPollInterval);
1275
+ transcriptPollInterval = null;
1276
+ }
1277
+ return;
1278
+ }
1279
+ }
1280
+ // Also check for recently modified files (in case we missed the creation)
1281
+ const recentFiles = jsonlFiles
1282
+ .map(f => ({ name: f, path: path.join(projectDir, f), mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
1283
+ .filter(f => f.mtime > launchTime - 2000) // Modified within 2s of launch
1284
+ .sort((a, b) => b.mtime - a.mtime);
1285
+ if (recentFiles.length > 0) {
1286
+ const newest = recentFiles[0];
1287
+ transcriptPath = newest.path;
1288
+ currentSessionId = newest.name.replace('.jsonl', '');
1289
+ dlog(`[TRANSCRIPT] FAST DETECT: Recent transcript found! ${transcriptPath}`);
1290
+ evictionDebugLog('TRANSCRIPT_SET', 'Fast poll found recent file', {
1291
+ transcriptPath,
1292
+ currentSessionId,
1293
+ elapsedMs: Date.now() - launchTime
1294
+ });
1295
+ startStreamTailer(transcriptPath, currentSessionId);
1296
+ if (transcriptPollInterval) {
1297
+ clearInterval(transcriptPollInterval);
1298
+ transcriptPollInterval = null;
1299
+ }
1300
+ }
1301
+ }
1302
+ catch {
1303
+ // Project dir doesn't exist yet, keep polling
1304
+ }
1305
+ }
1306
+ // Start polling immediately
1307
+ transcriptPollInterval = setInterval(pollForNewTranscript, 500);
1308
+ pollForNewTranscript(); // Also run once immediately
1309
+ dlog('[TRANSCRIPT] Fast polling started - looking for new jsonl files');
856
1310
  // ══════════════════════════════════════════════════════════════════════════
857
1311
  // SESSION NAME TRACKING (from live TUI output)
858
1312
  // Claude prints: "· Turn N · groovy-koala-saves · 📅"
@@ -869,42 +1323,203 @@ async function run(options) {
869
1323
  // Debounce tracking to prevent double triggers
870
1324
  let lastDetectionTime = 0;
871
1325
  const DETECTION_COOLDOWN = 30000; // 30 seconds cooldown
872
- // Use args from early setup
873
- const args = earlyArgs;
1326
+ // JSONL eviction tracking - prevent rapid re-eviction
1327
+ let lastEvictionTime = 0;
874
1328
  // ══════════════════════════════════════════════════════════════════════════
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.
1329
+ // ORPHAN TOOL_RESULT RECOVERY - React to ccDNA validate mode markers
878
1330
  // ══════════════════════════════════════════════════════════════════════════
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('');
1331
+ let lastOrphanDetectionTime = 0;
1332
+ let isOrphanRecoveryInProgress = false;
1333
+ // Deduplication: track orphan tool_use_ids we've already handled
1334
+ const handledOrphanIds = new Set();
1335
+ // Separate buffer for orphan detection (larger, to avoid truncation)
1336
+ let orphanDetectionBuffer = '';
1337
+ // Cursor for efficient scanning (avoids re-scanning already-processed text)
1338
+ let orphanScanCursor = 0;
1339
+ const ORPHAN_SCAN_TAIL_SLACK = 256; // Keep some overlap for chunk boundary tolerance
1340
+ // ══════════════════════════════════════════════════════════════════════════
1341
+ // SILENT FAILURE DETECTION - Catch API errors even without ccDNA markers
1342
+ // ══════════════════════════════════════════════════════════════════════════
1343
+ let lastSilentFailureTime = 0;
1344
+ let silentFailureCount = 0;
1345
+ const MAX_SILENT_FAILURES_BEFORE_ALERT = 2; // Alert user after 2 silent failures
1346
+ // ══════════════════════════════════════════════════════════════════════════
1347
+ // TURN-END EVICTION - Only clean up when Claude is idle (safe state)
1348
+ // ══════════════════════════════════════════════════════════════════════════
1349
+ let lastContextPercent = 0;
1350
+ let lastLoggedPercent = 0; // For throttling context % logs
1351
+ let turnEndTimeout = null;
1352
+ const TURN_END_STABLE_MS = 500; // Must see idle prompt for 500ms
1353
+ let pendingClearAfterEviction = false; // Flag to trigger /clear after eviction
1354
+ // Debug log to eviction-debug.log for 400 error diagnosis
1355
+ function evictionDebugLog(category, msg, data) {
1356
+ try {
1357
+ const logDir = path.join(os.homedir(), '.ekkos', 'logs');
1358
+ if (!fs.existsSync(logDir))
1359
+ fs.mkdirSync(logDir, { recursive: true });
1360
+ const logPath = path.join(logDir, 'eviction-debug.log');
1361
+ const ts = new Date().toISOString();
1362
+ const line = `[${ts}] [${category}] ${msg}${data ? '\n ' + JSON.stringify(data, null, 2).replace(/\n/g, '\n ') : ''}`;
1363
+ fs.appendFileSync(logPath, line + '\n');
1364
+ }
1365
+ catch { /* silent */ }
893
1366
  }
894
- else if (noInject) {
895
- console.log(chalk_1.default.yellow(' Monitor-only mode (--no-inject)'));
1367
+ // DEFENSIVE: Validate transcriptPath is a real file, not corrupted garbage
1368
+ function validateTranscriptPath(pathToCheck) {
1369
+ if (!pathToCheck)
1370
+ return false;
1371
+ // Check for ANSI escape codes (corruption signal)
1372
+ if (pathToCheck.includes('\u001b') || pathToCheck.includes('\x1b')) {
1373
+ evictionDebugLog('PATH_INVALID', 'Transcript path contains ANSI escape codes - clearing', {
1374
+ path: pathToCheck.slice(0, 100),
1375
+ });
1376
+ return false;
1377
+ }
1378
+ // Check it starts with / or ~ (absolute path)
1379
+ if (!pathToCheck.startsWith('/') && !pathToCheck.startsWith('~')) {
1380
+ evictionDebugLog('PATH_INVALID', 'Transcript path is not absolute - clearing', {
1381
+ path: pathToCheck.slice(0, 100),
1382
+ });
1383
+ return false;
1384
+ }
1385
+ // Check file exists
1386
+ if (!fs.existsSync(pathToCheck)) {
1387
+ evictionDebugLog('PATH_INVALID', 'Transcript path does not exist - clearing', {
1388
+ path: pathToCheck,
1389
+ });
1390
+ return false;
1391
+ }
1392
+ return true;
896
1393
  }
897
- if (verbose) {
898
- if (isNpxMode) {
899
- console.log(chalk_1.default.gray(` 🤖 Using claude-code@${pinnedVersion} via npx (pinned for better context)`));
1394
+ /**
1395
+ * Check if there are in-flight tool calls (tool_uses without matching tool_results)
1396
+ * CRITICAL: We must NOT evict while tools are in-flight or we'll orphan tool_results
1397
+ */
1398
+ function hasInFlightTools() {
1399
+ if (!transcriptPath)
1400
+ return false;
1401
+ try {
1402
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
1403
+ const lines = content.split('\n').filter(l => l.trim());
1404
+ // Extract all tool_use IDs and tool_result references
1405
+ const toolUseIds = new Set();
1406
+ const toolResultIds = new Set();
1407
+ for (const line of lines) {
1408
+ // Extract tool_use IDs using JSON parsing
1409
+ try {
1410
+ const obj = JSON.parse(line);
1411
+ const contentArr = obj?.message?.content;
1412
+ if (Array.isArray(contentArr)) {
1413
+ for (const block of contentArr) {
1414
+ if (block?.type === 'tool_use' && typeof block?.id === 'string') {
1415
+ toolUseIds.add(block.id);
1416
+ }
1417
+ if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
1418
+ toolResultIds.add(block.tool_use_id);
1419
+ }
1420
+ }
1421
+ }
1422
+ }
1423
+ catch {
1424
+ // Skip invalid JSON lines
1425
+ }
1426
+ }
1427
+ // In-flight = tool_uses that don't have matching results yet
1428
+ const inFlightCount = [...toolUseIds].filter(id => !toolResultIds.has(id)).length;
1429
+ if (inFlightCount > 0) {
1430
+ evictionDebugLog('IN_FLIGHT_CHECK', `Found ${inFlightCount} in-flight tools - blocking eviction`, {
1431
+ totalToolUses: toolUseIds.size,
1432
+ totalToolResults: toolResultIds.size,
1433
+ inFlightCount,
1434
+ });
1435
+ return true;
1436
+ }
1437
+ return false;
900
1438
  }
901
- else {
902
- console.log(chalk_1.default.gray(` 🤖 Using claude at: ${claudePath}`));
1439
+ catch (err) {
1440
+ evictionDebugLog('IN_FLIGHT_ERROR', `Failed to check in-flight tools: ${err.message}`);
1441
+ return true; // Assume in-flight on error (safer)
1442
+ }
1443
+ }
1444
+ async function handleTurnEnd() {
1445
+ if (!transcriptPath || isAutoClearInProgress)
1446
+ return;
1447
+ // DEFENSIVE: Validate path before using
1448
+ if (!validateTranscriptPath(transcriptPath)) {
1449
+ evictionDebugLog('TURN_END_ABORT', 'Invalid transcriptPath detected - resetting to null', {
1450
+ corruptedPath: transcriptPath?.slice(0, 100),
1451
+ });
1452
+ transcriptPath = null;
1453
+ return;
903
1454
  }
1455
+ // CRITICAL: Don't evict if tools are still in-flight
1456
+ // This prevents orphaning tool_results and causing 400 errors
1457
+ if (hasInFlightTools()) {
1458
+ evictionDebugLog('TURN_END_BLOCKED', 'Eviction blocked - in-flight tools detected');
1459
+ return;
1460
+ }
1461
+ const now = Date.now();
1462
+ if ((now - lastEvictionTime) < 10000)
1463
+ return; // 10s cooldown
1464
+ evictionDebugLog('TURN_END', 'Turn end detected - no in-flight tools', {
1465
+ transcriptPath,
1466
+ lastContextPercent,
1467
+ timeSinceLastEviction: now - lastEvictionTime,
1468
+ });
1469
+ // Run continuous clean first (always safe)
1470
+ const cleanResult = (0, jsonl_rewriter_1.continuousClean)(transcriptPath);
1471
+ if (cleanResult.cleaned > 0) {
1472
+ dlog(`🧹 Turn-end: Cleaned ${cleanResult.cleaned} junk lines`);
1473
+ evictionDebugLog('CONTINUOUS_CLEAN', `Cleaned ${cleanResult.cleaned} junk lines`);
1474
+ }
1475
+ // Then run eviction if needed (disabled when proxy handles it, or via EKKOS_DISABLE_EVICTION=1)
1476
+ // CRITICAL: When proxy mode is enabled, the proxy does seamless eviction - local JSONL eviction must be disabled
1477
+ // eslint-disable-next-line no-restricted-syntax -- Feature flag, not API key
1478
+ const evictionDisabled = process.env.EKKOS_DISABLE_EVICTION === '1' || proxyModeEnabled;
1479
+ if (evictionDisabled) {
1480
+ evictionDebugLog('EVICTION_DISABLED', proxyModeEnabled
1481
+ ? 'Eviction disabled - proxy handles context management'
1482
+ : 'Eviction disabled via EKKOS_DISABLE_EVICTION=1');
1483
+ }
1484
+ else if ((0, jsonl_rewriter_1.needsEviction)(lastContextPercent)) {
1485
+ dlog(`📉 Turn-end eviction at ${lastContextPercent}%`);
1486
+ evictionDebugLog('EVICTION_TRIGGER', `Eviction triggered at ${lastContextPercent}%`);
1487
+ lastEvictionTime = now;
1488
+ // Use HANDSHAKE EVICTION for data safety
1489
+ // This ensures R2 backup is confirmed BEFORE local deletion
1490
+ const result = await (0, jsonl_rewriter_1.evictToTargetAsync)(transcriptPath, lastContextPercent, currentSessionId || undefined, currentSession || undefined);
1491
+ if (result.success && (result.evicted > 0 || result.truncated > 0)) {
1492
+ dlog(` ✅ Evicted ${result.evicted}, truncated ${result.truncated} → ${result.newPercent}%`);
1493
+ evictionDebugLog('EVICTION_COMPLETE', 'Eviction completed', {
1494
+ evicted: result.evicted,
1495
+ truncated: result.truncated,
1496
+ newPercent: result.newPercent,
1497
+ handshakeUsed: result.handshakeUsed,
1498
+ });
1499
+ // SLIDING WINDOW: Trigger /clear to make Claude Code reload the slimmed transcript
1500
+ pendingClearAfterEviction = true;
1501
+ dlog(' 🔄 Pending /clear to reload evicted transcript');
1502
+ }
1503
+ else {
1504
+ evictionDebugLog('EVICTION_NOOP', 'No eviction performed', result);
1505
+ }
1506
+ }
1507
+ }
1508
+ // Use args from early setup
1509
+ const args = earlyArgs;
1510
+ if (noInject) {
1511
+ console.log(chalk_1.default.yellow(' Monitor-only mode (--no-inject)'));
1512
+ }
1513
+ if (verbose) {
1514
+ // Show Claude version with ccDNA version if patched
1515
+ const ccVersion = pinnedVersion || PINNED_CLAUDE_VERSION;
1516
+ const versionStr = ccdnaVersion
1517
+ ? `Claude Code v${ccVersion} + ekkOS_Continuum v${ccdnaVersion}`
1518
+ : `Claude Code v${ccVersion}`;
1519
+ console.log(chalk_1.default.gray(` 🤖 ${versionStr}`));
904
1520
  if (currentSession) {
905
1521
  console.log(chalk_1.default.green(` 📍 Session: ${currentSession}`));
906
1522
  }
907
- console.log(chalk_1.default.gray(` 💻 PTY mode: ${usePty ? 'node-pty' : 'spawn+script (fallback)'}`));
908
1523
  console.log('');
909
1524
  }
910
1525
  let shell;
@@ -947,7 +1562,7 @@ async function run(options) {
947
1562
  cols: process.stdout.columns || 80,
948
1563
  rows: process.stdout.rows || 24,
949
1564
  cwd: process.cwd(),
950
- env: process.env
1565
+ env: getEkkosEnv()
951
1566
  });
952
1567
  shell = {
953
1568
  write: (data) => ptyShell.write(data),
@@ -958,15 +1573,21 @@ async function run(options) {
958
1573
  };
959
1574
  }
960
1575
  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
1576
+ // PTY spawn failed - fall back to spawn pass-through
1577
+ dlog(`PTY spawn failed: ${err.message}, using spawn fallback`);
1578
+ const spawnedProcess = (0, child_process_1.spawn)(claudePath, args, {
1579
+ stdio: 'inherit',
1580
+ cwd: process.cwd(),
1581
+ env: getEkkosEnv()
1582
+ });
1583
+ spawnedProcess.on('exit', (code) => process.exit(code ?? 0));
1584
+ spawnedProcess.on('error', (e) => {
1585
+ console.error(chalk_1.default.red(`Failed to start Claude: ${e.message}`));
1586
+ process.exit(1);
969
1587
  });
1588
+ process.on('SIGINT', () => spawnedProcess.kill('SIGINT'));
1589
+ process.on('SIGTERM', () => spawnedProcess.kill('SIGTERM'));
1590
+ return;
970
1591
  }
971
1592
  }
972
1593
  // Handle terminal resize
@@ -975,14 +1596,26 @@ async function run(options) {
975
1596
  });
976
1597
  }
977
1598
  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
1599
+ // PTY not available - use spawn with stdio inherit (clean pass-through)
1600
+ // This mode doesn't support auto-continue but provides full Claude Code experience
1601
+ dlog('PTY not available, using spawn pass-through mode');
1602
+ const spawnedProcess = (0, child_process_1.spawn)(claudePath, args, {
1603
+ stdio: 'inherit',
1604
+ cwd: process.cwd(),
1605
+ env: getEkkosEnv()
1606
+ });
1607
+ spawnedProcess.on('exit', (code) => {
1608
+ process.exit(code ?? 0);
985
1609
  });
1610
+ spawnedProcess.on('error', (err) => {
1611
+ console.error(chalk_1.default.red(`Failed to start Claude: ${err.message}`));
1612
+ process.exit(1);
1613
+ });
1614
+ // Handle signals for clean shutdown
1615
+ process.on('SIGINT', () => spawnedProcess.kill('SIGINT'));
1616
+ process.on('SIGTERM', () => spawnedProcess.kill('SIGTERM'));
1617
+ // In spawn mode, we don't continue with the rest of the PTY-specific code
1618
+ return;
986
1619
  }
987
1620
  // Forward user input to PTY (named function so we can pause/resume)
988
1621
  const onStdinData = (data) => {
@@ -996,6 +1629,7 @@ async function run(options) {
996
1629
  // Helper to get current output buffer (for readiness checks)
997
1630
  const getOutputBuffer = () => outputBuffer;
998
1631
  // Handle context wall detection
1632
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
999
1633
  async function handleContextWall() {
1000
1634
  // Debounce check - prevent double triggers (BEFORE pausing stdin)
1001
1635
  const now = Date.now();
@@ -1142,6 +1776,285 @@ async function run(options) {
1142
1776
  dlog('Stdin resumed');
1143
1777
  }
1144
1778
  }
1779
+ // ══════════════════════════════════════════════════════════════════════════
1780
+ // ORPHAN TOOL_RESULT RECOVERY
1781
+ // When ccDNA validate mode detects orphan tool_results before an API call,
1782
+ // it emits [ekkOS] ORPHAN_TOOL_RESULT to the terminal. We detect this marker
1783
+ // and repair the transcript (rollback or surgical).
1784
+ //
1785
+ // NOTE: We do NOT run /clear + /continue anymore. That was leftover from when
1786
+ // ccDNA was in evict mode (filtering in-memory messages). With ccDNA in validate
1787
+ // mode and the JSONL rewriter as the single disk authority, orphans indicate a
1788
+ // BUG in the sliding window - not a state desync that needs rebuilding.
1789
+ // The in-memory state is fine; we just fix the disk and log the bug.
1790
+ // ══════════════════════════════════════════════════════════════════════════
1791
+ function handleOrphanToolResult(orphan) {
1792
+ const now = Date.now();
1793
+ if (isOrphanRecoveryInProgress) {
1794
+ dlog('Orphan recovery already in progress, ignoring');
1795
+ return;
1796
+ }
1797
+ if (now - lastOrphanDetectionTime < ORPHAN_DETECTION_COOLDOWN_MS) {
1798
+ dlog('Orphan recovery suppressed by cooldown');
1799
+ return;
1800
+ }
1801
+ lastOrphanDetectionTime = now;
1802
+ isOrphanRecoveryInProgress = true;
1803
+ // Cancel any pending turn-end eviction timer (don't evict while handling orphan)
1804
+ if (turnEndTimeout) {
1805
+ clearTimeout(turnEndTimeout);
1806
+ turnEndTimeout = null;
1807
+ }
1808
+ // Log the bug - this should NOT happen with a working sliding window
1809
+ evictionDebugLog('ORPHAN_BUG_DETECTED', '═══════════════════════════════════════════════════════════', {
1810
+ alert: '🚨 ORPHAN TOOL_RESULT DETECTED - SLIDING WINDOW BUG 🚨',
1811
+ orphan: {
1812
+ messageIndex: orphan.idx,
1813
+ toolUseId: orphan.tool_use_id,
1814
+ blockIndex: orphan.block_idx,
1815
+ },
1816
+ context: {
1817
+ transcriptPath,
1818
+ currentSessionId,
1819
+ currentSession,
1820
+ lastContextPercent,
1821
+ },
1822
+ diagnosis: 'JSONL rewriter evicted tool_use without its tool_result, or vice versa',
1823
+ action: 'Attempting disk repair via rollback or surgical removal',
1824
+ });
1825
+ console.log(`\n[ekkOS] 🚨 BUG: Orphan tool_result detected (${orphan.tool_use_id})`);
1826
+ console.log(`[ekkOS] 🔧 Repairing disk transcript...`);
1827
+ try {
1828
+ // Repair the disk transcript if we have one
1829
+ if (transcriptPath && validateTranscriptPath(transcriptPath)) {
1830
+ // Don't repair if tools are still in-flight
1831
+ if (hasInFlightTools()) {
1832
+ evictionDebugLog('ORPHAN_REPAIR_BLOCKED', 'In-flight tools present, deferring repair');
1833
+ isOrphanRecoveryInProgress = false;
1834
+ return;
1835
+ }
1836
+ dlog(`Repairing transcript: ${transcriptPath}`);
1837
+ const repair = (0, transcript_repair_1.repairOrRollbackTranscript)(transcriptPath);
1838
+ evictionDebugLog('ORPHAN_REPAIR_RESULT', '═══════════════════════════════════════════════════════════', {
1839
+ result: repair.action.toUpperCase(),
1840
+ orphansFound: repair.orphansFound,
1841
+ removedLines: repair.removedLines ?? 0,
1842
+ backupUsed: repair.backupUsed ?? 'none',
1843
+ reason: repair.reason ?? 'success',
1844
+ });
1845
+ dlog(`Orphan repair: ${repair.action} (orphans=${repair.orphansFound}, removed=${repair.removedLines ?? 0})`);
1846
+ if (repair.action === 'failed') {
1847
+ console.log(`[ekkOS] ❌ WARNING: Orphan repair failed - session may be unstable`);
1848
+ console.log(`[ekkOS] Reason: ${repair.reason}`);
1849
+ }
1850
+ else if (repair.action === 'rollback') {
1851
+ console.log(`[ekkOS] ✅ Disk repaired via ROLLBACK to backup`);
1852
+ }
1853
+ else if (repair.action === 'surgical_repair') {
1854
+ console.log(`[ekkOS] ✅ Disk repaired via SURGICAL removal (${repair.removedLines} lines removed)`);
1855
+ }
1856
+ else {
1857
+ console.log(`[ekkOS] ✅ No repair needed - transcript is healthy`);
1858
+ }
1859
+ // POST-REPAIR VALIDATION: Verify repair actually worked
1860
+ if (repair.action !== 'failed' && repair.action !== 'none') {
1861
+ try {
1862
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1863
+ const { countOrphansInJsonl } = require('../capture/transcript-repair');
1864
+ const { orphans: postRepairOrphans } = countOrphansInJsonl(transcriptPath);
1865
+ if (postRepairOrphans > 0) {
1866
+ evictionDebugLog('POST_REPAIR_VALIDATION_FAILED', '⚠️ Repair completed but orphans still exist!', {
1867
+ repair,
1868
+ postRepairOrphans,
1869
+ alert: 'REPAIR DID NOT FIX THE PROBLEM',
1870
+ });
1871
+ console.log(`[ekkOS] ⚠️ Post-repair check: ${postRepairOrphans} orphan(s) still present!`);
1872
+ console.log(`[ekkOS] Repair may have been incomplete - consider /clear + /continue`);
1873
+ }
1874
+ else {
1875
+ evictionDebugLog('POST_REPAIR_VALIDATION_SUCCESS', '✅ Repair verified - no orphans remaining', {
1876
+ repair,
1877
+ });
1878
+ dlog('Post-repair validation passed - transcript is clean');
1879
+ }
1880
+ }
1881
+ catch (validationErr) {
1882
+ dlog(`Post-repair validation failed: ${validationErr.message}`);
1883
+ }
1884
+ }
1885
+ }
1886
+ else {
1887
+ evictionDebugLog('ORPHAN_REPAIR_SKIPPED', 'No valid transcriptPath', { transcriptPath });
1888
+ dlog('No transcript to repair');
1889
+ }
1890
+ }
1891
+ catch (err) {
1892
+ evictionDebugLog('ORPHAN_REPAIR_ERROR', err.message, { stack: err.stack });
1893
+ }
1894
+ finally {
1895
+ // Release flag immediately - no stdin pause needed since we're not injecting commands
1896
+ isOrphanRecoveryInProgress = false;
1897
+ }
1898
+ }
1899
+ // ══════════════════════════════════════════════════════════════════════════
1900
+ // ORPHAN DETECTION FUNCTION - Can be called from shell.onData or tests
1901
+ // Uses cursor-based scanning to avoid re-scanning already-processed text
1902
+ // ══════════════════════════════════════════════════════════════════════════
1903
+ function runOrphanDetection() {
1904
+ if (isAutoClearInProgress || isOrphanRecoveryInProgress)
1905
+ return;
1906
+ // Only scan from cursor position forward (plus tail slack for boundary tolerance)
1907
+ const scanStart = Math.max(0, orphanScanCursor - ORPHAN_SCAN_TAIL_SLACK);
1908
+ const textToScan = orphanDetectionBuffer.slice(scanStart);
1909
+ // Reset regex lastIndex before matching
1910
+ ORPHAN_MARKER_REGEX.lastIndex = 0;
1911
+ let orphanMatch;
1912
+ while ((orphanMatch = ORPHAN_MARKER_REGEX.exec(textToScan)) !== null) {
1913
+ try {
1914
+ const orphanJson = orphanMatch[1];
1915
+ const orphan = JSON.parse(orphanJson);
1916
+ // Deduplication: skip if we've already handled this orphan
1917
+ if (handledOrphanIds.has(orphan.tool_use_id)) {
1918
+ dlog(`Skipping already-handled orphan: ${orphan.tool_use_id}`);
1919
+ continue;
1920
+ }
1921
+ dlog(`Detected ORPHAN_TOOL_RESULT: ${orphan.tool_use_id}`);
1922
+ evictionDebugLog('ORPHAN_MARKER_DETECTED', 'ccDNA reported orphan in PTY output', {
1923
+ orphan,
1924
+ bufferLen: orphanDetectionBuffer.length,
1925
+ scanCursor: orphanScanCursor,
1926
+ handledCount: handledOrphanIds.size,
1927
+ });
1928
+ // Mark as handled before firing (prevents re-trigger)
1929
+ handledOrphanIds.add(orphan.tool_use_id);
1930
+ // Fire and forget - the handler has its own cooldown/reentrancy guards
1931
+ void handleOrphanToolResult(orphan);
1932
+ }
1933
+ catch (e) {
1934
+ evictionDebugLog('ORPHAN_PARSE_ERROR', 'Failed to parse ORPHAN_TOOL_RESULT payload', {
1935
+ sample: orphanMatch?.[1]?.slice(0, 200),
1936
+ err: e.message,
1937
+ });
1938
+ }
1939
+ }
1940
+ // Advance cursor to end of buffer (next scan starts from here)
1941
+ orphanScanCursor = orphanDetectionBuffer.length;
1942
+ }
1943
+ // ══════════════════════════════════════════════════════════════════════════
1944
+ // TEST TRIGGER: Synthetic orphan marker injection
1945
+ // Set EKKOS_TEST_ORPHAN=1 to inject after transcriptPath is discovered (full E2E)
1946
+ // Set EKKOS_TEST_ORPHAN=2 to inject after 5s regardless (detection-only test)
1947
+ // ══════════════════════════════════════════════════════════════════════════
1948
+ // eslint-disable-next-line no-restricted-syntax -- Test flag, not API key
1949
+ const testOrphanMode = process.env.EKKOS_TEST_ORPHAN;
1950
+ if (testOrphanMode === '1' || testOrphanMode === '2') {
1951
+ console.log(`[ekkOS TEST] Test trigger starting (mode=${testOrphanMode})`);
1952
+ evictionDebugLog('TEST_TRIGGER_START', 'Test trigger initialized', { mode: testOrphanMode });
1953
+ // eslint-disable-next-line no-restricted-syntax -- Test flag, not API key
1954
+ const isQuickMode = process.env.EKKOS_TEST_ORPHAN === '2';
1955
+ const TEST_MAX_WAIT_MS = isQuickMode ? 5000 : 20000;
1956
+ const TEST_POLL_MS = 500;
1957
+ let testWaitedMs = 0;
1958
+ let testInjected = false;
1959
+ // ... (rest of the code remains the same)
1960
+ const injectMarker = (mode) => {
1961
+ if (testInjected)
1962
+ return;
1963
+ testInjected = true;
1964
+ const testOrphan = { idx: 0, tool_use_id: 'toolu_TEST_' + Date.now(), block_idx: 0 };
1965
+ const testMarker = `[ekkOS] ORPHAN_TOOL_RESULT ${JSON.stringify(testOrphan)}`;
1966
+ dlog(`TEST: Injecting synthetic orphan marker (${mode})`);
1967
+ evictionDebugLog('TEST_ORPHAN_INJECT', `Synthetic orphan marker injected (${mode})`, {
1968
+ testOrphan,
1969
+ testMarker,
1970
+ transcriptPath: transcriptPath || 'not_discovered',
1971
+ waitedMs: testWaitedMs,
1972
+ mode,
1973
+ });
1974
+ // Inject directly into detection buffer and run detection
1975
+ orphanDetectionBuffer += '\n' + testMarker + '\n';
1976
+ runOrphanDetection();
1977
+ };
1978
+ const testPollInterval = setInterval(() => {
1979
+ testWaitedMs += TEST_POLL_MS;
1980
+ // Check if transcriptPath is now available (full E2E test)
1981
+ if (transcriptPath && validateTranscriptPath(transcriptPath)) {
1982
+ clearInterval(testPollInterval);
1983
+ setTimeout(() => injectMarker('transcriptPath_ready'), 1000);
1984
+ }
1985
+ else if (testWaitedMs >= TEST_MAX_WAIT_MS) {
1986
+ clearInterval(testPollInterval);
1987
+ // In quick mode or after timeout, inject anyway (detection-only test)
1988
+ injectMarker(isQuickMode ? 'quick_mode' : 'timeout_fallback');
1989
+ }
1990
+ }, TEST_POLL_MS);
1991
+ }
1992
+ // ══════════════════════════════════════════════════════════════════════════
1993
+ // SILENT FAILURE DETECTION HANDLER
1994
+ // Catches API 400 errors and orphan-related messages even without ccDNA markers
1995
+ // This is a backup for when ccDNA validate mode isn't working or is disabled
1996
+ // ══════════════════════════════════════════════════════════════════════════
1997
+ function handleSilentFailure(matchType, matchedText) {
1998
+ const now = Date.now();
1999
+ // Cooldown check
2000
+ if (now - lastSilentFailureTime < SILENT_FAILURE_COOLDOWN_MS) {
2001
+ dlog(`Silent failure suppressed by cooldown (${matchType})`);
2002
+ return;
2003
+ }
2004
+ // Don't trigger during active recovery
2005
+ if (isOrphanRecoveryInProgress || isAutoClearInProgress) {
2006
+ dlog(`Silent failure ignored - recovery in progress (${matchType})`);
2007
+ return;
2008
+ }
2009
+ lastSilentFailureTime = now;
2010
+ silentFailureCount++;
2011
+ evictionDebugLog('SILENT_FAILURE_DETECTED', '════════════════════════════════════════════════════════', {
2012
+ alert: '⚠️ SILENT FAILURE - API error detected without ccDNA marker',
2013
+ matchType,
2014
+ matchedText: matchedText.slice(0, 200),
2015
+ silentFailureCount,
2016
+ transcriptPath,
2017
+ diagnosis: 'Possible orphan tool_result or ccDNA not in validate mode',
2018
+ });
2019
+ console.log(`\n[ekkOS] ⚠️ Silent failure detected: ${matchType}`);
2020
+ // After multiple failures, alert user and suggest action
2021
+ if (silentFailureCount >= MAX_SILENT_FAILURES_BEFORE_ALERT) {
2022
+ console.log(`[ekkOS] ⚠️ Multiple API errors detected (${silentFailureCount}x)`);
2023
+ console.log(`[ekkOS] This may indicate orphan tool_results in the transcript`);
2024
+ console.log(`[ekkOS] Try: /clear then /continue to rebuild message state`);
2025
+ evictionDebugLog('SILENT_FAILURE_ALERT', 'Multiple silent failures - user alerted', {
2026
+ count: silentFailureCount,
2027
+ suggestion: '/clear + /continue',
2028
+ });
2029
+ // Reset counter after alerting
2030
+ silentFailureCount = 0;
2031
+ }
2032
+ // Attempt proactive repair if we have a transcript
2033
+ if (transcriptPath && validateTranscriptPath(transcriptPath) && !hasInFlightTools()) {
2034
+ dlog('Attempting proactive repair due to silent failure');
2035
+ try {
2036
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
2037
+ const { countOrphansInJsonl } = require('../capture/transcript-repair');
2038
+ const { orphans: orphanCount, orphanIds } = countOrphansInJsonl(transcriptPath);
2039
+ if (orphanCount > 0) {
2040
+ evictionDebugLog('SILENT_FAILURE_ORPHANS_FOUND', `Proactive scan found ${orphanCount} orphans`, {
2041
+ transcriptPath,
2042
+ orphanCount,
2043
+ orphanIds: orphanIds.slice(0, 5), // Log first 5 IDs
2044
+ });
2045
+ console.log(`[ekkOS] 🔍 Found ${orphanCount} orphan(s) in transcript - triggering repair`);
2046
+ // Trigger orphan recovery (reuse existing handler)
2047
+ void handleOrphanToolResult({ idx: -1, tool_use_id: 'silent_failure_detected' });
2048
+ }
2049
+ else {
2050
+ dlog('Proactive scan found no orphans - API error may be unrelated');
2051
+ }
2052
+ }
2053
+ catch (err) {
2054
+ dlog(`Proactive repair scan failed: ${err.message}`);
2055
+ }
2056
+ }
2057
+ }
1145
2058
  // Monitor PTY output
1146
2059
  shell.onData((data) => {
1147
2060
  // Pass through to terminal
@@ -1152,18 +2065,81 @@ async function run(options) {
1152
2065
  if (outputBuffer.length > 5000) {
1153
2066
  outputBuffer = outputBuffer.slice(-2000);
1154
2067
  }
2068
+ // ══════════════════════════════════════════════════════════════════════════
2069
+ // ORPHAN TOOL_RESULT DETECTION
2070
+ // ccDNA validate mode emits [ekkOS] ORPHAN_TOOL_RESULT when it detects
2071
+ // tool_results without matching tool_uses. This triggers automatic repair.
2072
+ // Uses separate larger buffer to avoid truncation issues.
2073
+ // ══════════════════════════════════════════════════════════════════════════
2074
+ if (!isAutoClearInProgress && !isOrphanRecoveryInProgress) {
2075
+ // Append to orphan detection buffer (larger than main buffer to catch full markers)
2076
+ orphanDetectionBuffer += stripAnsi(data);
2077
+ if (orphanDetectionBuffer.length > 10000) {
2078
+ const trimAmount = orphanDetectionBuffer.length - 8000;
2079
+ orphanDetectionBuffer = orphanDetectionBuffer.slice(-8000);
2080
+ // Adjust cursor to account for trimmed portion
2081
+ orphanScanCursor = Math.max(0, orphanScanCursor - trimAmount);
2082
+ }
2083
+ // Run detection (extracted function for testability)
2084
+ runOrphanDetection();
2085
+ // ════════════════════════════════════════════════════════════════════════
2086
+ // SILENT FAILURE DETECTION - Catch API errors without ccDNA markers
2087
+ // ════════════════════════════════════════════════════════════════════════
2088
+ const normalizedForSilent = stripAnsi(data);
2089
+ // Check for API 400 errors
2090
+ if (API_400_REGEX.test(normalizedForSilent)) {
2091
+ handleSilentFailure('API_400', normalizedForSilent.match(API_400_REGEX)?.[0] || '400');
2092
+ }
2093
+ // Check for explicit orphan-related API error messages
2094
+ else if (ORPHAN_API_ERROR_REGEX.test(normalizedForSilent)) {
2095
+ handleSilentFailure('ORPHAN_API_ERROR', normalizedForSilent.match(ORPHAN_API_ERROR_REGEX)?.[0] || 'orphan');
2096
+ }
2097
+ // Check for generic invalid message errors
2098
+ else if (INVALID_MESSAGE_REGEX.test(normalizedForSilent)) {
2099
+ handleSilentFailure('INVALID_MESSAGE', normalizedForSilent.match(INVALID_MESSAGE_REGEX)?.[0] || 'invalid');
2100
+ }
2101
+ }
1155
2102
  // 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);
2103
+ // CRITICAL: Strip ANSI codes FIRST to prevent capturing terminal garbage
2104
+ const cleanData = stripAnsi(data);
2105
+ const transcriptMatch = cleanData.match(/transcript[_\s]?(?:path)?[:\s]+([^\s\n]+\.jsonl?)/i);
1157
2106
  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);
2107
+ const candidatePath = transcriptMatch[1];
2108
+ // Validate it's an actual path (not garbage from terminal output)
2109
+ if (candidatePath.startsWith('/') || candidatePath.startsWith('~')) {
2110
+ const resolvedPath = candidatePath.startsWith('~')
2111
+ ? path.join(os.homedir(), candidatePath.slice(1))
2112
+ : candidatePath;
2113
+ if (fs.existsSync(resolvedPath)) {
2114
+ // DEFENSIVE: Double-check no ANSI codes leaked through
2115
+ if (resolvedPath.includes('\u001b') || resolvedPath.includes('\x1b')) {
2116
+ evictionDebugLog('PATH_CORRUPTION', 'ANSI codes detected in resolved path!', {
2117
+ resolvedPath,
2118
+ candidatePath,
2119
+ cleanDataSample: cleanData.slice(0, 200),
2120
+ });
2121
+ }
2122
+ else {
2123
+ transcriptPath = resolvedPath;
2124
+ evictionDebugLog('TRANSCRIPT_SET', 'transcriptPath set from output', { transcriptPath });
2125
+ dlog(`Detected transcript from output: ${transcriptPath}`);
2126
+ }
2127
+ // Start tailer if we have session ID
2128
+ if (currentSessionId) {
2129
+ startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
2130
+ }
2131
+ }
2132
+ else {
2133
+ dlog(`Transcript path candidate doesn't exist: ${resolvedPath}`);
2134
+ }
2135
+ }
2136
+ else {
2137
+ dlog(`Transcript path candidate rejected (not absolute): ${candidatePath}`);
1163
2138
  }
1164
2139
  }
1165
2140
  // 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);
2141
+ // Use cleanData (already stripped of ANSI) to avoid terminal garbage
2142
+ const sessionMatch = cleanData.match(/session[_\s]?(?:id)?[:\s]+([a-f0-9-]{36})/i);
1167
2143
  if (sessionMatch) {
1168
2144
  currentSessionId = sessionMatch[1];
1169
2145
  currentSession = (0, state_1.uuidToWords)(currentSessionId);
@@ -1174,10 +2150,11 @@ async function run(options) {
1174
2150
  dlog(`Session detected from UUID: ${currentSession}`);
1175
2151
  // Try to find/construct transcript path from session ID
1176
2152
  if (!transcriptPath) {
1177
- const encodedCwd = process.cwd().replace(/\//g, '-').replace(/^-/, '');
2153
+ const encodedCwd = process.cwd().replace(/\//g, '-');
1178
2154
  const possibleTranscript = path.join(os.homedir(), '.claude', 'projects', encodedCwd, `${currentSessionId}.jsonl`);
1179
2155
  if (fs.existsSync(possibleTranscript)) {
1180
2156
  transcriptPath = possibleTranscript;
2157
+ evictionDebugLog('TRANSCRIPT_SET', 'Set from session ID', { transcriptPath, source: 'sessionId' });
1181
2158
  dlog(`Found transcript from session ID: ${transcriptPath}`);
1182
2159
  startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
1183
2160
  }
@@ -1209,11 +2186,14 @@ async function run(options) {
1209
2186
  (0, state_1.updateState)({ sessionName: currentSession });
1210
2187
  dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
1211
2188
  // Try to start stream tailer - scan for matching transcript file
2189
+ dlog(`[TRANSCRIPT_SCAN] streamTailer=${!!streamTailer}, transcriptPath=${transcriptPath || 'NULL'}`);
1212
2190
  if (!streamTailer) {
1213
- const encodedCwd = process.cwd().replace(/\//g, '-').replace(/^-/, '');
2191
+ const encodedCwd = process.cwd().replace(/\//g, '-');
1214
2192
  const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedCwd);
2193
+ dlog(`[TRANSCRIPT_SCAN] Scanning projectDir: ${projectDir}`);
1215
2194
  try {
1216
2195
  const files = fs.readdirSync(projectDir);
2196
+ dlog(`[TRANSCRIPT_SCAN] Found ${files.length} files in projectDir`);
1217
2197
  // Find most recent .jsonl file (likely current session)
1218
2198
  const jsonlFiles = files
1219
2199
  .filter(f => f.endsWith('.jsonl'))
@@ -1223,17 +2203,25 @@ async function run(options) {
1223
2203
  mtime: fs.statSync(path.join(projectDir, f)).mtimeMs
1224
2204
  }))
1225
2205
  .sort((a, b) => b.mtime - a.mtime);
2206
+ dlog(`[TRANSCRIPT_SCAN] Found ${jsonlFiles.length} .jsonl files`);
1226
2207
  if (jsonlFiles.length > 0) {
1227
2208
  transcriptPath = jsonlFiles[0].path;
1228
2209
  currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
1229
- dlog(`Found transcript from project scan: ${transcriptPath}`);
2210
+ dlog(`[TRANSCRIPT_SCAN] SUCCESS! transcriptPath=${transcriptPath}`);
2211
+ evictionDebugLog('TRANSCRIPT_SET', 'Set from session name detection', { transcriptPath, currentSessionId });
1230
2212
  startStreamTailer(transcriptPath, currentSessionId, currentSession);
1231
2213
  }
2214
+ else {
2215
+ dlog(`[TRANSCRIPT_SCAN] No jsonl files found!`);
2216
+ }
1232
2217
  }
1233
- catch {
1234
- // Project dir might not exist yet
2218
+ catch (err) {
2219
+ dlog(`[TRANSCRIPT_SCAN] ERROR: ${err.message}`);
1235
2220
  }
1236
2221
  }
2222
+ else {
2223
+ dlog(`[TRANSCRIPT_SCAN] Skipped - streamTailer already running`);
2224
+ }
1237
2225
  }
1238
2226
  else {
1239
2227
  // Same session, just update timestamp
@@ -1265,15 +2253,117 @@ async function run(options) {
1265
2253
  }
1266
2254
  }
1267
2255
  }
1268
- // Check for context wall patterns (ANSI-stripped + regex for robustness)
1269
- if (!isAutoClearInProgress) {
2256
+ // ══════════════════════════════════════════════════════════════════════════
2257
+ // TURN-END EVICTION - Track context % and run cleanup when Claude goes idle
2258
+ // This is MUCH safer than mid-stream eviction because:
2259
+ // 1. All tool calls have completed (no in-flight tools)
2260
+ // 2. JSONL is in a consistent state
2261
+ // 3. Claude Code is between operations
2262
+ // ══════════════════════════════════════════════════════════════════════════
2263
+ // ════════════════════════════════════════════════════════════════════════
2264
+ // CONTEXT % CALCULATION - Only when proxy mode is OFF (hook handles it otherwise)
2265
+ // ════════════════════════════════════════════════════════════════════════
2266
+ if (!proxyModeEnabled) {
2267
+ // Track context percentage - PRIMARY: calculate from JSONL file size
2268
+ // Claude Code has ~200K token limit. 1 token ≈ 4 chars = 4 bytes
2269
+ // 200K tokens ≈ 800KB. We estimate context % from file size.
2270
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
2271
+ try {
2272
+ const fileSize = fs.statSync(transcriptPath).size;
2273
+ const estimatedMaxSize = 800 * 1024; // 800KB ≈ 200K tokens
2274
+ lastContextPercent = Math.min(100, Math.round((fileSize / estimatedMaxSize) * 100));
2275
+ // Log periodically (every ~5%)
2276
+ if (Math.abs(lastContextPercent - (lastLoggedPercent || 0)) >= 5) {
2277
+ dlog(`[CONTEXT] Estimated ${lastContextPercent}% from file size (${Math.round(fileSize / 1024)}KB)`);
2278
+ lastLoggedPercent = lastContextPercent;
2279
+ }
2280
+ }
2281
+ catch {
2282
+ // Fall back to regex if file read fails
2283
+ const contextPercentMatch = outputBuffer.match(/(\d+)K?\s*\((\d+)%\)/);
2284
+ if (contextPercentMatch) {
2285
+ lastContextPercent = parseFloat(contextPercentMatch[2]);
2286
+ }
2287
+ }
2288
+ }
2289
+ }
2290
+ // ════════════════════════════════════════════════════════════════════════
2291
+ // CONTINUOUS CLEANUP - Runs ALWAYS (proxy handles big evictions, local handles junk)
2292
+ // handleTurnEnd() has internal check to skip threshold eviction when proxy is on
2293
+ // ════════════════════════════════════════════════════════════════════════
2294
+ // Detect idle prompt (turn end) and schedule cleanup
2295
+ const strippedOutput = stripAnsi(outputBuffer);
2296
+ const idlePromptDetected = IDLE_PROMPT_REGEX.test(strippedOutput);
2297
+ // DEFENSIVE LOGGING: Log when idle prompt detected but conditions fail
2298
+ if (idlePromptDetected && (!transcriptPath || isAutoClearInProgress)) {
2299
+ evictionDebugLog('TURN_CHECK_BLOCKED', 'Idle prompt detected but eviction blocked', {
2300
+ transcriptPath: transcriptPath || 'NULL',
2301
+ isAutoClearInProgress,
2302
+ lastContextPercent,
2303
+ outputBufferEnd: strippedOutput.slice(-100),
2304
+ });
2305
+ }
2306
+ if (idlePromptDetected && transcriptPath && !isAutoClearInProgress) {
2307
+ // Cancel any existing timer
2308
+ if (turnEndTimeout) {
2309
+ clearTimeout(turnEndTimeout);
2310
+ }
2311
+ // Start new debounce timer - fires when idle for TURN_END_STABLE_MS
2312
+ turnEndTimeout = setTimeout(() => {
2313
+ handleTurnEnd().catch(err => {
2314
+ evictionDebugLog('TURN_END_ERROR', `Async eviction error: ${err.message}`);
2315
+ });
2316
+ turnEndTimeout = null;
2317
+ }, TURN_END_STABLE_MS);
2318
+ }
2319
+ // SLIDING WINDOW: Inject /clear after eviction to force transcript reload
2320
+ if (idlePromptDetected && pendingClearAfterEviction && !isAutoClearInProgress) {
2321
+ pendingClearAfterEviction = false;
2322
+ isAutoClearInProgress = true;
2323
+ dlog('🔄 SLIDING WINDOW: Injecting /clear to reload evicted transcript');
2324
+ evictionDebugLog('SLIDING_WINDOW_CLEAR', 'Injecting /clear after eviction');
2325
+ // Pause stdin to prevent interference
2326
+ process.stdin.off('data', onStdinData);
2327
+ (async () => {
2328
+ try {
2329
+ // Clear current input line
2330
+ shell.write('\x15'); // Ctrl+U
2331
+ await sleep(60);
2332
+ // Type /clear
2333
+ for (const char of '/clear') {
2334
+ shell.write(char);
2335
+ await sleep(20);
2336
+ }
2337
+ await sleep(100);
2338
+ // Send Enter
2339
+ shell.write('\r');
2340
+ dlog('🔄 /clear injected - Claude Code will reload transcript');
2341
+ // Resume stdin after brief delay
2342
+ await sleep(500);
2343
+ process.stdin.on('data', onStdinData);
2344
+ isAutoClearInProgress = false;
2345
+ }
2346
+ catch (err) {
2347
+ dlog(`❌ Failed to inject /clear: ${err.message}`);
2348
+ process.stdin.on('data', onStdinData);
2349
+ isAutoClearInProgress = false;
2350
+ }
2351
+ })();
2352
+ }
2353
+ // BACKUP: Context wall detection - emergency evict
2354
+ if (!isAutoClearInProgress && transcriptPath) {
1270
2355
  const normalized = normalizeForMatch(outputBuffer);
1271
2356
  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
- });
2357
+ dlog('⚠️ CONTEXT WALL - emergency evict to 50%');
2358
+ // Cancel turn-end timer if pending
2359
+ if (turnEndTimeout) {
2360
+ clearTimeout(turnEndTimeout);
2361
+ turnEndTimeout = null;
2362
+ }
2363
+ const result = (0, jsonl_rewriter_1.emergencyEvict)(transcriptPath);
2364
+ if (result.success) {
2365
+ dlog(` ✅ Emergency evicted ${result.evicted} lines`);
2366
+ }
1277
2367
  }
1278
2368
  }
1279
2369
  });
@@ -1343,6 +2433,8 @@ Use Perplexity for deep research. Be thorough but efficient. Start now.`;
1343
2433
  stopStreamTailer(); // Stop stream capture
1344
2434
  (0, state_1.unregisterActiveSession)(); // Remove from active sessions registry
1345
2435
  cleanupInstanceFile(instanceId); // Clean up instance file
2436
+ // NOTE: No ccDNA restore needed - ekkOS uses separate installation from homebrew
2437
+ // ~/.ekkos/claude-code/ stays patched, homebrew `claude` is always vanilla
1346
2438
  // Restore terminal
1347
2439
  if (process.stdin.isTTY) {
1348
2440
  process.stdin.setRawMode(false);
@@ -1358,6 +2450,7 @@ Use Perplexity for deep research. Be thorough but efficient. Start now.`;
1358
2450
  stopStreamTailer(); // Stop stream capture
1359
2451
  (0, state_1.unregisterActiveSession)(); // Remove from active sessions registry
1360
2452
  cleanupInstanceFile(instanceId); // Clean up instance file
2453
+ // NOTE: No ccDNA restore needed - ekkOS uses separate installation from homebrew
1361
2454
  if (process.stdin.isTTY) {
1362
2455
  process.stdin.setRawMode(false);
1363
2456
  }
@@ -1368,160 +2461,3 @@ Use Perplexity for deep research. Be thorough but efficient. Start now.`;
1368
2461
  process.on('SIGINT', cleanup);
1369
2462
  process.on('SIGTERM', cleanup);
1370
2463
  }
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
- }