@ekkos/cli 0.2.4 → 0.2.6

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.
@@ -0,0 +1,32 @@
1
+ type GateStatus = 'PASS' | 'FAIL' | 'WARN';
2
+ interface Check {
3
+ name: string;
4
+ passed: boolean;
5
+ detail?: string;
6
+ }
7
+ interface Gate {
8
+ id: string;
9
+ title: string;
10
+ status: GateStatus;
11
+ checks: Check[];
12
+ fix?: string;
13
+ }
14
+ interface DoctorReport {
15
+ platform: string;
16
+ gates: Gate[];
17
+ canProceed: boolean;
18
+ nodeVersion?: string;
19
+ claudeVersion?: string;
20
+ }
21
+ /**
22
+ * Run diagnostic checks and return report
23
+ */
24
+ export declare function runDiagnostics(): DoctorReport;
25
+ /**
26
+ * Main doctor command
27
+ */
28
+ export declare function doctor(options?: {
29
+ fix?: boolean;
30
+ json?: boolean;
31
+ }): Promise<void>;
32
+ export {};
@@ -0,0 +1,418 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runDiagnostics = runDiagnostics;
7
+ exports.doctor = doctor;
8
+ const os_1 = require("os");
9
+ const child_process_1 = require("child_process");
10
+ const chalk_1 = __importDefault(require("chalk"));
11
+ /**
12
+ * Check if a command exists in PATH
13
+ */
14
+ function commandExists(cmd) {
15
+ try {
16
+ const which = (0, os_1.platform)() === 'win32' ? 'where' : 'which';
17
+ (0, child_process_1.execSync)(`${which} ${cmd}`, { stdio: 'ignore' });
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /**
25
+ * Get command version safely
26
+ */
27
+ function getVersion(cmd, versionFlag = '--version') {
28
+ try {
29
+ const output = (0, child_process_1.execSync)(`${cmd} ${versionFlag}`, {
30
+ encoding: 'utf-8',
31
+ timeout: 10000,
32
+ stdio: ['pipe', 'pipe', 'pipe']
33
+ }).trim();
34
+ return output.split('\n')[0];
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /**
41
+ * Check if node-pty can be loaded
42
+ */
43
+ function checkPty() {
44
+ try {
45
+ // Try to require node-pty
46
+ require('node-pty');
47
+ return { available: true };
48
+ }
49
+ catch (error) {
50
+ // Check for prebuilt alternative
51
+ try {
52
+ require('node-pty-prebuilt-multiarch');
53
+ return { available: true };
54
+ }
55
+ catch {
56
+ return {
57
+ available: false,
58
+ error: error?.message || 'Cannot load node-pty'
59
+ };
60
+ }
61
+ }
62
+ }
63
+ /**
64
+ * Check MCP configuration via claude mcp list
65
+ */
66
+ function checkMcpConfig() {
67
+ try {
68
+ const output = (0, child_process_1.execSync)('claude mcp list', {
69
+ encoding: 'utf-8',
70
+ timeout: 10000,
71
+ stdio: ['pipe', 'pipe', 'pipe']
72
+ });
73
+ // Parse server names from output
74
+ const servers = [];
75
+ const lines = output.split('\n');
76
+ for (const line of lines) {
77
+ // Look for server names (varies by Claude version)
78
+ if (line.includes('ekkos') || line.includes('memory')) {
79
+ servers.push(line.trim());
80
+ }
81
+ }
82
+ const hasEkkos = output.toLowerCase().includes('ekkos') ||
83
+ output.toLowerCase().includes('memory');
84
+ return { configured: hasEkkos, servers };
85
+ }
86
+ catch (error) {
87
+ return {
88
+ configured: false,
89
+ servers: [],
90
+ error: error?.message || 'Failed to check MCP config'
91
+ };
92
+ }
93
+ }
94
+ /**
95
+ * Check if bash is available (for hooks on Windows)
96
+ */
97
+ function checkBash() {
98
+ if ((0, os_1.platform)() !== 'win32')
99
+ return true;
100
+ try {
101
+ (0, child_process_1.execSync)('bash --version', { stdio: 'ignore', timeout: 5000 });
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /**
109
+ * Run diagnostic checks and return report
110
+ */
111
+ function runDiagnostics() {
112
+ const isWindows = (0, os_1.platform)() === 'win32';
113
+ const gates = [];
114
+ // Get basic info
115
+ const nodeVersion = getVersion('node') || 'Not found';
116
+ const claudeVersion = getVersion('claude');
117
+ // ═══════════════════════════════════════════════════════════════════════════
118
+ // GATE 1: Interactive Claude Works
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+ const claudeChecks = [];
121
+ let claudeGatePass = true;
122
+ // Check 1.1: claude command exists
123
+ const claudeExists = commandExists('claude');
124
+ claudeChecks.push({
125
+ name: 'Claude CLI installed',
126
+ passed: claudeExists,
127
+ detail: claudeExists ? claudeVersion || 'Found' : 'Not in PATH'
128
+ });
129
+ if (!claudeExists)
130
+ claudeGatePass = false;
131
+ // Check 1.2: claude --version works
132
+ if (claudeExists) {
133
+ const versionWorks = claudeVersion !== null;
134
+ claudeChecks.push({
135
+ name: 'Claude responds to --version',
136
+ passed: versionWorks,
137
+ detail: versionWorks ? claudeVersion : 'No response'
138
+ });
139
+ if (!versionWorks)
140
+ claudeGatePass = false;
141
+ }
142
+ // Check 1.3: On Windows, PTY is required for interactive mode
143
+ // This is checked in Gate 2, but we note it here
144
+ if (isWindows) {
145
+ claudeChecks.push({
146
+ name: 'Interactive mode (requires PTY)',
147
+ passed: true, // Will be validated in Gate 2
148
+ detail: 'See PTY gate below'
149
+ });
150
+ }
151
+ gates.push({
152
+ id: 'interactive-claude',
153
+ title: 'Interactive Claude',
154
+ status: claudeGatePass ? 'PASS' : 'FAIL',
155
+ checks: claudeChecks,
156
+ fix: claudeGatePass ? undefined : 'npm install -g @anthropic-ai/claude-code'
157
+ });
158
+ // ═══════════════════════════════════════════════════════════════════════════
159
+ // GATE 2: PTY Works (Windows-critical)
160
+ // ═══════════════════════════════════════════════════════════════════════════
161
+ const ptyChecks = [];
162
+ let ptyGatePass = true;
163
+ // Check Node version (20.x or 22.x recommended)
164
+ const nodeMatch = nodeVersion.match(/v?(\d+)\./);
165
+ const nodeMajor = nodeMatch ? parseInt(nodeMatch[1], 10) : 0;
166
+ const nodeOk = nodeMajor >= 18 && nodeMajor <= 22;
167
+ ptyChecks.push({
168
+ name: 'Node.js version',
169
+ passed: nodeOk,
170
+ detail: nodeOk
171
+ ? `${nodeVersion} (OK)`
172
+ : `${nodeVersion} (Need 20.x or 22.x LTS for PTY support)`
173
+ });
174
+ if (!nodeOk && isWindows)
175
+ ptyGatePass = false;
176
+ // Check PTY availability
177
+ const ptyResult = checkPty();
178
+ ptyChecks.push({
179
+ name: 'node-pty loadable',
180
+ passed: ptyResult.available,
181
+ detail: ptyResult.available ? 'ConPTY available' : ptyResult.error
182
+ });
183
+ if (!ptyResult.available && isWindows)
184
+ ptyGatePass = false;
185
+ // On macOS/Linux, PTY is typically available, mark as PASS with note
186
+ if (!isWindows && !ptyResult.available) {
187
+ ptyChecks.push({
188
+ name: 'Fallback available',
189
+ passed: true,
190
+ detail: 'Unix script(1) fallback available'
191
+ });
192
+ ptyGatePass = true; // Unix has fallback
193
+ }
194
+ gates.push({
195
+ id: 'pty',
196
+ title: 'PTY (Terminal)',
197
+ status: ptyGatePass ? 'PASS' : (isWindows ? 'FAIL' : 'WARN'),
198
+ checks: ptyChecks,
199
+ fix: ptyGatePass ? undefined : isWindows
200
+ ? 'npm install node-pty-prebuilt-multiarch OR install VS Build Tools and run: npm rebuild node-pty'
201
+ : undefined
202
+ });
203
+ // ═══════════════════════════════════════════════════════════════════════════
204
+ // GATE 3: MCP Works
205
+ // ═══════════════════════════════════════════════════════════════════════════
206
+ const mcpChecks = [];
207
+ let mcpGatePass = true;
208
+ // Check MCP config
209
+ const mcpResult = checkMcpConfig();
210
+ if (mcpResult.error && mcpResult.error.includes('not found')) {
211
+ // Claude not installed, can't check MCP
212
+ mcpChecks.push({
213
+ name: 'MCP configuration',
214
+ passed: false,
215
+ detail: 'Claude CLI required to check MCP'
216
+ });
217
+ mcpGatePass = false;
218
+ }
219
+ else {
220
+ mcpChecks.push({
221
+ name: 'ekkOS MCP server configured',
222
+ passed: mcpResult.configured,
223
+ detail: mcpResult.configured
224
+ ? `Found: ${mcpResult.servers.join(', ') || 'ekkOS server'}`
225
+ : 'No ekkOS server in claude mcp list'
226
+ });
227
+ if (!mcpResult.configured)
228
+ mcpGatePass = false;
229
+ }
230
+ // Check bash on Windows (for hooks, WARN only)
231
+ if (isWindows) {
232
+ const bashOk = checkBash();
233
+ mcpChecks.push({
234
+ name: 'Bash available (for hooks)',
235
+ passed: bashOk,
236
+ detail: bashOk ? 'Git Bash/WSL detected' : 'PowerShell hooks will be used'
237
+ });
238
+ // Don't fail gate for missing bash, just note it
239
+ }
240
+ gates.push({
241
+ id: 'mcp',
242
+ title: 'MCP Configuration',
243
+ status: mcpGatePass ? 'PASS' : 'FAIL',
244
+ checks: mcpChecks,
245
+ fix: mcpGatePass ? undefined :
246
+ 'claude mcp add ekkos-memory npx -y @ekkos/cli@latest mcp && claude mcp start ekkos-memory'
247
+ });
248
+ // ═══════════════════════════════════════════════════════════════════════════
249
+ // Calculate overall status
250
+ // ═══════════════════════════════════════════════════════════════════════════
251
+ const allPass = gates.every(g => g.status === 'PASS');
252
+ const hasBlocker = gates.some(g => g.status === 'FAIL');
253
+ return {
254
+ platform: isWindows ? 'Windows' : (0, os_1.platform)() === 'darwin' ? 'macOS' : 'Linux',
255
+ gates,
256
+ canProceed: !hasBlocker,
257
+ nodeVersion,
258
+ claudeVersion: claudeVersion || undefined
259
+ };
260
+ }
261
+ /**
262
+ * Display the doctor report
263
+ */
264
+ function displayReport(report) {
265
+ console.log('');
266
+ console.log(chalk_1.default.cyan.bold('🩺 ekkOS Doctor'));
267
+ console.log(chalk_1.default.gray('─'.repeat(60)));
268
+ console.log('');
269
+ // Platform info
270
+ console.log(chalk_1.default.gray(`Platform: ${report.platform}`));
271
+ console.log(chalk_1.default.gray(`Node: ${report.nodeVersion}`));
272
+ if (report.claudeVersion) {
273
+ console.log(chalk_1.default.gray(`Claude: ${report.claudeVersion}`));
274
+ }
275
+ console.log('');
276
+ // Display each gate
277
+ for (const gate of report.gates) {
278
+ const icon = gate.status === 'PASS' ? chalk_1.default.green('✓')
279
+ : gate.status === 'WARN' ? chalk_1.default.yellow('○')
280
+ : chalk_1.default.red('✗');
281
+ const color = gate.status === 'PASS' ? chalk_1.default.green
282
+ : gate.status === 'WARN' ? chalk_1.default.yellow
283
+ : chalk_1.default.red;
284
+ console.log(`${icon} ${color.bold(gate.title)}`);
285
+ for (const check of gate.checks) {
286
+ const checkIcon = check.passed ? chalk_1.default.green(' ✓') : chalk_1.default.red(' ✗');
287
+ console.log(`${checkIcon} ${check.name}`);
288
+ if (check.detail) {
289
+ console.log(chalk_1.default.gray(` ${check.detail}`));
290
+ }
291
+ }
292
+ if (gate.fix && gate.status === 'FAIL') {
293
+ console.log(chalk_1.default.yellow(` → Fix: ${gate.fix}`));
294
+ }
295
+ console.log('');
296
+ }
297
+ // Summary
298
+ console.log(chalk_1.default.gray('─'.repeat(60)));
299
+ const blockers = report.gates.filter(g => g.status === 'FAIL').length;
300
+ const warnings = report.gates.filter(g => g.status === 'WARN').length;
301
+ if (blockers > 0) {
302
+ console.log(chalk_1.default.red.bold(`BLOCKERS: ${blockers}`));
303
+ console.log(chalk_1.default.yellow('Run the fix commands above to resolve issues.'));
304
+ }
305
+ else if (warnings > 0) {
306
+ console.log(chalk_1.default.yellow.bold(`WARNINGS: ${warnings}`));
307
+ console.log(chalk_1.default.green('ekkOS can run, but some features may be limited.'));
308
+ }
309
+ else {
310
+ console.log(chalk_1.default.green.bold('✓ All systems operational'));
311
+ console.log(chalk_1.default.green('ekkOS is ready to use.'));
312
+ }
313
+ console.log('');
314
+ }
315
+ /**
316
+ * Attempt safe auto-fixes
317
+ */
318
+ async function attemptAutoFixes(report) {
319
+ const fixed = [];
320
+ const manual = [];
321
+ for (const gate of report.gates) {
322
+ if (gate.status === 'PASS')
323
+ continue;
324
+ switch (gate.id) {
325
+ case 'interactive-claude':
326
+ // Try to install Claude Code (safe, non-admin)
327
+ console.log(chalk_1.default.yellow('\nAttempting to install Claude Code...'));
328
+ try {
329
+ (0, child_process_1.execSync)('npm install -g @anthropic-ai/claude-code', {
330
+ stdio: 'inherit',
331
+ timeout: 120000
332
+ });
333
+ fixed.push('Claude Code installed');
334
+ }
335
+ catch {
336
+ manual.push('npm install -g @anthropic-ai/claude-code');
337
+ }
338
+ break;
339
+ case 'pty':
340
+ // Try prebuilt PTY first (safe, non-admin)
341
+ console.log(chalk_1.default.yellow('\nAttempting to install prebuilt PTY...'));
342
+ try {
343
+ // Try homebridge prebuilt first
344
+ (0, child_process_1.execSync)('npm install -g @homebridge/node-pty-prebuilt-multiarch', {
345
+ stdio: 'inherit',
346
+ timeout: 60000
347
+ });
348
+ fixed.push('Prebuilt PTY installed');
349
+ }
350
+ catch {
351
+ manual.push('npm install -g @homebridge/node-pty-prebuilt-multiarch');
352
+ manual.push('If that fails: Install VS Build Tools, then: npm rebuild node-pty');
353
+ }
354
+ break;
355
+ case 'mcp':
356
+ // Try to configure MCP (safe)
357
+ console.log(chalk_1.default.yellow('\nAttempting to configure MCP...'));
358
+ try {
359
+ (0, child_process_1.execSync)('claude mcp add ekkos-memory npx -y @ekkos/cli@latest mcp', {
360
+ stdio: 'inherit',
361
+ timeout: 30000
362
+ });
363
+ (0, child_process_1.execSync)('claude mcp start ekkos-memory', {
364
+ stdio: 'inherit',
365
+ timeout: 10000
366
+ });
367
+ fixed.push('MCP configured');
368
+ }
369
+ catch {
370
+ manual.push('claude mcp add ekkos-memory npx -y @ekkos/cli@latest mcp');
371
+ manual.push('claude mcp start ekkos-memory');
372
+ }
373
+ break;
374
+ }
375
+ }
376
+ return { fixed, manual };
377
+ }
378
+ /**
379
+ * Main doctor command
380
+ */
381
+ async function doctor(options = {}) {
382
+ const report = runDiagnostics();
383
+ // JSON output mode
384
+ if (options.json) {
385
+ console.log(JSON.stringify(report, null, 2));
386
+ process.exit(report.canProceed ? 0 : 1);
387
+ }
388
+ displayReport(report);
389
+ // Fix mode - attempt safe auto-fixes
390
+ if (options.fix && !report.canProceed) {
391
+ console.log(chalk_1.default.cyan.bold('\n🔧 Attempting auto-fixes...\n'));
392
+ const { fixed, manual } = await attemptAutoFixes(report);
393
+ if (fixed.length > 0) {
394
+ console.log(chalk_1.default.green('\n✓ Auto-fixed:'));
395
+ for (const f of fixed) {
396
+ console.log(chalk_1.default.green(` • ${f}`));
397
+ }
398
+ }
399
+ if (manual.length > 0) {
400
+ console.log(chalk_1.default.yellow('\n⚠ Manual steps required:'));
401
+ for (const m of manual) {
402
+ console.log(chalk_1.default.white(` ${m}`));
403
+ }
404
+ }
405
+ // Re-run diagnostics to check if fixes worked
406
+ console.log(chalk_1.default.cyan('\n🔄 Re-checking...'));
407
+ const recheck = runDiagnostics();
408
+ displayReport(recheck);
409
+ if (recheck.canProceed) {
410
+ console.log(chalk_1.default.green.bold('✓ All issues resolved!'));
411
+ }
412
+ process.exit(recheck.canProceed ? 0 : 1);
413
+ }
414
+ // Exit with error code if blockers
415
+ if (!report.canProceed) {
416
+ process.exit(1);
417
+ }
418
+ }
@@ -2,6 +2,7 @@ interface RunOptions {
2
2
  session?: string;
3
3
  verbose?: boolean;
4
4
  bypass?: boolean;
5
+ doctor?: boolean;
5
6
  slashOpenDelayMs?: number;
6
7
  charDelayMs?: number;
7
8
  postEnterDelayMs?: number;
@@ -43,6 +43,7 @@ const path = __importStar(require("path"));
43
43
  const os = __importStar(require("os"));
44
44
  const child_process_1 = require("child_process");
45
45
  const state_1 = require("../utils/state");
46
+ const doctor_1 = require("./doctor");
46
47
  // Try to load node-pty (may fail on Node 24+)
47
48
  let pty = null;
48
49
  try {
@@ -362,6 +363,26 @@ async function emergencyCapture(transcriptPath, sessionId) {
362
363
  async function run(options) {
363
364
  const verbose = options.verbose || false;
364
365
  const bypass = options.bypass || false;
366
+ // ══════════════════════════════════════════════════════════════════════════
367
+ // PRE-FLIGHT DIAGNOSTICS (--doctor flag)
368
+ // ══════════════════════════════════════════════════════════════════════════
369
+ if (options.doctor) {
370
+ console.log(chalk_1.default.cyan('\n🩺 Running pre-flight diagnostics...\n'));
371
+ const report = (0, doctor_1.runDiagnostics)();
372
+ // Display summary
373
+ for (const gate of report.gates) {
374
+ const icon = gate.status === 'PASS' ? chalk_1.default.green('✓')
375
+ : gate.status === 'WARN' ? chalk_1.default.yellow('○')
376
+ : chalk_1.default.red('✗');
377
+ console.log(`${icon} ${gate.title}: ${gate.status}`);
378
+ }
379
+ console.log('');
380
+ if (!report.canProceed) {
381
+ console.log(chalk_1.default.red('Blockers detected. Run `ekkos doctor` for details.'));
382
+ process.exit(1);
383
+ }
384
+ console.log(chalk_1.default.green('✓ All checks passed. Starting Claude...\n'));
385
+ }
365
386
  // Get injection config (from options or env vars)
366
387
  const config = getConfig(options);
367
388
  setDebugLogPath(config.debugLogPath);
@@ -481,6 +502,9 @@ async function run(options) {
481
502
  let lastSeenSessionName = null;
482
503
  let lastSeenSessionAt = 0;
483
504
  const RECENCY_WINDOW_MS = 15000; // 15s - session name must be recent to trust
505
+ // Track if we've EVER observed a session in THIS process run
506
+ // This is the authoritative flag - if false, don't trust persisted state
507
+ let observedSessionThisRun = false;
484
508
  // Output buffer for pattern detection
485
509
  let outputBuffer = '';
486
510
  // Debounce tracking to prevent double triggers
@@ -513,6 +537,35 @@ async function run(options) {
513
537
  }
514
538
  // Determine which mode to use
515
539
  const usePty = pty !== null;
540
+ // ══════════════════════════════════════════════════════════════════════════
541
+ // WINDOWS: HARD FAIL WITHOUT PTY
542
+ // Without node-pty/ConPTY, auto-continue cannot work on Windows.
543
+ // Claude switches to --print mode without a real PTY, breaking the TUI.
544
+ // ══════════════════════════════════════════════════════════════════════════
545
+ if (isWindows && !usePty) {
546
+ console.log('');
547
+ console.log(chalk_1.default.red.bold('❌ ekkos run requires PTY support on Windows'));
548
+ console.log('');
549
+ console.log(chalk_1.default.yellow('Without node-pty (ConPTY), Claude Code runs in --print mode'));
550
+ console.log(chalk_1.default.yellow('which prevents auto-continue from working.'));
551
+ console.log('');
552
+ console.log(chalk_1.default.cyan('To fix:'));
553
+ console.log('');
554
+ console.log(chalk_1.default.white(' Option 1: Use Node 20 or 22 LTS (recommended)'));
555
+ console.log(chalk_1.default.gray(' winget install OpenJS.NodeJS.LTS'));
556
+ console.log(chalk_1.default.gray(' npm install -g @ekkos/cli'));
557
+ console.log('');
558
+ console.log(chalk_1.default.white(' Option 2: Install prebuilt PTY'));
559
+ console.log(chalk_1.default.gray(' npm install node-pty-prebuilt-multiarch'));
560
+ console.log('');
561
+ console.log(chalk_1.default.white(' Option 3: Build node-pty from source'));
562
+ console.log(chalk_1.default.gray(' 1. Install VS Build Tools (Desktop C++ workload)'));
563
+ console.log(chalk_1.default.gray(' 2. npm rebuild node-pty --build-from-source'));
564
+ console.log('');
565
+ console.log(chalk_1.default.gray('Run `ekkos doctor` for detailed diagnostics.'));
566
+ console.log('');
567
+ process.exit(1);
568
+ }
516
569
  if (verbose) {
517
570
  console.log(chalk_1.default.gray(` 💻 PTY mode: ${usePty ? 'node-pty' : 'spawn+script (fallback)'}`));
518
571
  console.log('');
@@ -589,23 +642,46 @@ async function run(options) {
589
642
  return;
590
643
  }
591
644
  // ════════════════════════════════════════════════════════════════════════
592
- // SESSION SELECTION: Prefer most recently SEEN session from TUI output
593
- // This fixes the "groovy-koala-saves" bug where stale persisted state was used
594
- // Priority: lastSeenSessionName (if recent) > currentSession > persisted state
645
+ // SESSION SELECTION: observedSessionThisRun pattern
646
+ //
647
+ // The key insight: ONLY trust session names we've actually observed in THIS
648
+ // process run. On Windows (without PTY), Claude may exit immediately without
649
+ // emitting any session info, leaving persisted state stale.
650
+ //
651
+ // Priority:
652
+ // 1. lastSeenSessionName (if recent AND observed this run)
653
+ // 2. currentSession (if observed this run)
654
+ // 3. Most recent session from local cache (if NOT observed this run)
655
+ // 4. Persisted state (last resort)
595
656
  // ════════════════════════════════════════════════════════════════════════
596
657
  let sessionToRestore = null;
597
- // PRIORITY 1: Most recently observed session name from TUI output
598
- // Only trust if seen within RECENCY_WINDOW_MS (15 seconds)
599
- if (lastSeenSessionName && (Date.now() - lastSeenSessionAt) < RECENCY_WINDOW_MS) {
600
- sessionToRestore = lastSeenSessionName;
601
- dlog(`Using lastSeenSessionName (${Date.now() - lastSeenSessionAt}ms ago): ${sessionToRestore}`);
602
- }
603
- // PRIORITY 2: currentSession (in-memory, may be stale)
604
- if (!sessionToRestore && currentSession) {
605
- sessionToRestore = currentSession;
606
- dlog(`Using currentSession (in-memory): ${sessionToRestore}`);
607
- }
608
- // PRIORITY 3: Persisted state (fallback)
658
+ if (observedSessionThisRun) {
659
+ // We've seen a session in THIS process - trust in-memory state
660
+ dlog(`observedSessionThisRun=true - trusting in-memory state`);
661
+ // PRIORITY 1: Most recently observed session name from TUI output
662
+ // Only trust if seen within RECENCY_WINDOW_MS (15 seconds)
663
+ if (lastSeenSessionName && (Date.now() - lastSeenSessionAt) < RECENCY_WINDOW_MS) {
664
+ sessionToRestore = lastSeenSessionName;
665
+ dlog(`Using lastSeenSessionName (${Date.now() - lastSeenSessionAt}ms ago): ${sessionToRestore}`);
666
+ }
667
+ // PRIORITY 2: currentSession (in-memory, confirmed this run)
668
+ if (!sessionToRestore && currentSession) {
669
+ sessionToRestore = currentSession;
670
+ dlog(`Using currentSession (in-memory, observed this run): ${sessionToRestore}`);
671
+ }
672
+ }
673
+ else {
674
+ // We've NOT observed any session this run - don't trust persisted state
675
+ // This prevents the "groovy-koala-saves" bug on Windows
676
+ dlog(`observedSessionThisRun=false - using cache fallback`);
677
+ // PRIORITY 3: Most recent session from local cache index
678
+ const recentSession = (0, state_1.getMostRecentSession)();
679
+ if (recentSession) {
680
+ sessionToRestore = recentSession.sessionName;
681
+ dlog(`Using most recent session from cache: ${sessionToRestore} (${recentSession.lastActive})`);
682
+ }
683
+ }
684
+ // PRIORITY 4: Persisted state (last resort)
609
685
  if (!sessionToRestore) {
610
686
  const state = (0, state_1.getState)();
611
687
  sessionToRestore = state?.sessionName || null;
@@ -615,7 +691,7 @@ async function run(options) {
615
691
  sessionToRestore = (0, state_1.uuidToWords)(sessionId);
616
692
  }
617
693
  if (sessionToRestore) {
618
- dlog(`Using persisted state (fallback): ${sessionToRestore}`);
694
+ dlog(`Using persisted state (last resort): ${sessionToRestore}`);
619
695
  }
620
696
  }
621
697
  const sessionDisplay = sessionToRestore || 'unknown-session';
@@ -729,8 +805,9 @@ async function run(options) {
729
805
  lastSeenSessionName = detectedSession;
730
806
  lastSeenSessionAt = Date.now();
731
807
  currentSession = lastSeenSessionName;
808
+ observedSessionThisRun = true; // Mark that we've seen a session in THIS process
732
809
  (0, state_1.updateState)({ sessionName: currentSession });
733
- dlog(`Session detected from status line: ${currentSession}`);
810
+ dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
734
811
  }
735
812
  else {
736
813
  // Same session, just update timestamp
@@ -746,8 +823,9 @@ async function run(options) {
746
823
  lastSeenSessionName = detectedSession;
747
824
  lastSeenSessionAt = Date.now();
748
825
  currentSession = lastSeenSessionName;
826
+ observedSessionThisRun = true; // Mark that we've seen a session in THIS process
749
827
  (0, state_1.updateState)({ sessionName: currentSession });
750
- dlog(`Session detected from generic match: ${currentSession}`);
828
+ dlog(`Session detected from generic match: ${currentSession} (observedSessionThisRun=true)`);
751
829
  }
752
830
  else {
753
831
  lastSeenSessionAt = Date.now();
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ const init_1 = require("./commands/init");
9
9
  const test_1 = require("./commands/test");
10
10
  const status_1 = require("./commands/status");
11
11
  const run_1 = require("./commands/run");
12
+ const doctor_1 = require("./commands/doctor");
12
13
  const chalk_1 = __importDefault(require("chalk"));
13
14
  commander_1.program
14
15
  .name('ekkos')
@@ -42,13 +43,24 @@ commander_1.program
42
43
  .option('-s, --session <name>', 'Session name to restore on clear')
43
44
  .option('-b, --bypass', 'Enable bypass permissions mode (dangerously skip all permission checks)')
44
45
  .option('-v, --verbose', 'Show debug output')
46
+ .option('-d, --doctor', 'Run diagnostics before starting')
45
47
  .action((options) => {
46
48
  (0, run_1.run)({
47
49
  session: options.session,
48
50
  bypass: options.bypass,
49
- verbose: options.verbose
51
+ verbose: options.verbose,
52
+ doctor: options.doctor
50
53
  });
51
54
  });
55
+ // Doctor command - check system prerequisites
56
+ commander_1.program
57
+ .command('doctor')
58
+ .description('Check system prerequisites for ekkOS (Node, PTY, Claude, MCP)')
59
+ .option('-f, --fix', 'Attempt safe auto-fixes and show commands for manual fixes')
60
+ .option('-j, --json', 'Output machine-readable JSON report')
61
+ .action((options) => {
62
+ (0, doctor_1.doctor)({ fix: options.fix, json: options.json });
63
+ });
52
64
  // Deprecated setup command (redirects to init)
53
65
  commander_1.program
54
66
  .command('setup')
@@ -55,3 +55,12 @@ export declare function parseAutoClearFlag(): {
55
55
  session: string;
56
56
  timestamp: number;
57
57
  } | null;
58
+ /**
59
+ * Get the most recent session from the local cache index
60
+ * Used when no session was observed in the current process run
61
+ */
62
+ export declare function getMostRecentSession(): {
63
+ sessionName: string;
64
+ sessionId: string;
65
+ lastActive: string;
66
+ } | null;
@@ -46,6 +46,7 @@ exports.getConfig = getConfig;
46
46
  exports.getAuthToken = getAuthToken;
47
47
  exports.clearAutoClearFlag = clearAutoClearFlag;
48
48
  exports.parseAutoClearFlag = parseAutoClearFlag;
49
+ exports.getMostRecentSession = getMostRecentSession;
49
50
  const fs = __importStar(require("fs"));
50
51
  const path = __importStar(require("path"));
51
52
  const os = __importStar(require("os"));
@@ -184,3 +185,38 @@ function parseAutoClearFlag() {
184
185
  }
185
186
  return null;
186
187
  }
188
+ /**
189
+ * Get the most recent session from the local cache index
190
+ * Used when no session was observed in the current process run
191
+ */
192
+ function getMostRecentSession() {
193
+ const indexPath = path.join(exports.EKKOS_DIR, 'cache', 'sessions', 'index.json');
194
+ try {
195
+ if (!fs.existsSync(indexPath)) {
196
+ return null;
197
+ }
198
+ const indexContent = fs.readFileSync(indexPath, 'utf-8');
199
+ const index = JSON.parse(indexContent);
200
+ // Find the most recently active session
201
+ let mostRecent = null;
202
+ let latestTime = 0;
203
+ for (const [sessionName, data] of Object.entries(index)) {
204
+ // Skip test sessions
205
+ if (sessionName.startsWith('test-'))
206
+ continue;
207
+ const activeTime = new Date(data.last_active_ts).getTime();
208
+ if (activeTime > latestTime) {
209
+ latestTime = activeTime;
210
+ mostRecent = {
211
+ sessionName,
212
+ sessionId: data.session_id,
213
+ lastActive: data.last_active_ts
214
+ };
215
+ }
216
+ }
217
+ return mostRecent;
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekkos/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Setup ekkOS memory for AI coding assistants (Claude Code, Cursor, Windsurf)",
5
5
  "main": "dist/index.js",
6
6
  "bin": {