@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.
- package/LICENSE +21 -0
- package/dist/capture/eviction-client.d.ts +139 -0
- package/dist/capture/eviction-client.js +454 -0
- package/dist/capture/index.d.ts +2 -0
- package/dist/capture/index.js +2 -0
- package/dist/capture/jsonl-rewriter.d.ts +96 -0
- package/dist/capture/jsonl-rewriter.js +1369 -0
- package/dist/capture/transcript-repair.d.ts +50 -0
- package/dist/capture/transcript-repair.js +308 -0
- package/dist/commands/doctor.js +23 -1
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +1229 -293
- package/dist/commands/usage.d.ts +7 -0
- package/dist/commands/usage.js +214 -0
- package/dist/cron/index.d.ts +7 -0
- package/dist/cron/index.js +13 -0
- package/dist/cron/promoter.d.ts +70 -0
- package/dist/cron/promoter.js +403 -0
- package/dist/index.js +24 -3
- package/dist/lib/usage-monitor.d.ts +47 -0
- package/dist/lib/usage-monitor.js +124 -0
- package/dist/lib/usage-parser.d.ts +72 -0
- package/dist/lib/usage-parser.js +238 -0
- package/dist/restore/RestoreOrchestrator.d.ts +4 -0
- package/dist/restore/RestoreOrchestrator.js +118 -30
- package/package.json +12 -12
- package/templates/cursor-hooks/after-agent-response.sh +0 -0
- package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
- package/templates/cursor-hooks/stop.sh +0 -0
- package/templates/ekkos-manifest.json +2 -2
- package/templates/hooks/assistant-response.sh +0 -0
- package/templates/hooks/session-start.sh +0 -0
- package/templates/plan-template.md +0 -0
- package/templates/spec-template.md +0 -0
- package/templates/agents/README.md +0 -182
- package/templates/agents/code-reviewer.md +0 -166
- package/templates/agents/debug-detective.md +0 -169
- package/templates/agents/ekkOS_Vercel.md +0 -99
- package/templates/agents/extension-manager.md +0 -229
- package/templates/agents/git-companion.md +0 -185
- package/templates/agents/github-test-agent.md +0 -321
- package/templates/agents/railway-manager.md +0 -215
package/dist/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
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
|
|
506
|
+
* Check if a Claude installation exists and get its version
|
|
507
|
+
* Returns version string if found, null otherwise
|
|
266
508
|
*/
|
|
267
|
-
function
|
|
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
|
-
//
|
|
271
|
-
const match = version.match(
|
|
272
|
-
if (match)
|
|
273
|
-
return match[1]
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
322
|
-
*
|
|
323
|
-
*
|
|
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) -
|
|
327
|
-
* 2.
|
|
328
|
-
* 3. npx with pinned version (fallback
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
873
|
-
|
|
1326
|
+
// JSONL eviction tracking - prevent rapid re-eviction
|
|
1327
|
+
let lastEvictionTime = 0;
|
|
874
1328
|
// ══════════════════════════════════════════════════════════════════════════
|
|
875
|
-
//
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
895
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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:
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
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, '-')
|
|
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, '-')
|
|
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(`
|
|
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
|
-
|
|
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
|
-
//
|
|
1269
|
-
|
|
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('
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
}
|