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