@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript Repair - Handles orphan tool_result recovery
|
|
3
|
+
*
|
|
4
|
+
* When ccDNA's validate mode detects orphan tool_results (tool_result without
|
|
5
|
+
* matching tool_use), this module repairs the transcript:
|
|
6
|
+
*
|
|
7
|
+
* 1. ROLLBACK (preferred): Restore from backup if valid
|
|
8
|
+
* 2. SURGICAL REPAIR (fallback): Remove orphan tool_result lines
|
|
9
|
+
*
|
|
10
|
+
* This closes the loop: ccDNA detects → ekkos-cli repairs → /clear + /continue
|
|
11
|
+
*/
|
|
12
|
+
type RepairAction = 'none' | 'rollback' | 'surgical_repair' | 'failed';
|
|
13
|
+
export interface RepairResult {
|
|
14
|
+
action: RepairAction;
|
|
15
|
+
orphansFound: number;
|
|
16
|
+
removedLines?: number;
|
|
17
|
+
backupUsed?: string;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Minimal validator: tool_result.tool_use_id must reference a tool_use.id that
|
|
22
|
+
* appears earlier in the transcript (in-order).
|
|
23
|
+
*/
|
|
24
|
+
export declare function countOrphansInJsonl(jsonlPath: string): {
|
|
25
|
+
orphans: number;
|
|
26
|
+
orphanIds: string[];
|
|
27
|
+
lineCount: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Main entry point: repair or rollback a transcript with orphan tool_results.
|
|
31
|
+
*
|
|
32
|
+
* Strategy:
|
|
33
|
+
* 1. Check if there are orphans (exit early if none)
|
|
34
|
+
* 2. Try rollback to newest valid backup
|
|
35
|
+
* 3. If no valid backup, surgically remove orphan lines
|
|
36
|
+
*/
|
|
37
|
+
export declare function repairOrRollbackTranscript(jsonlPath: string): RepairResult;
|
|
38
|
+
/**
|
|
39
|
+
* Quick validation check - can be called before operations to verify health.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isTranscriptHealthy(jsonlPath: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Get detailed transcript health info.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getTranscriptHealth(jsonlPath: string): {
|
|
46
|
+
exists: boolean;
|
|
47
|
+
healthy: boolean;
|
|
48
|
+
orphans: number;
|
|
49
|
+
orphanIds: string[];
|
|
50
|
+
};
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Transcript Repair - Handles orphan tool_result recovery
|
|
4
|
+
*
|
|
5
|
+
* When ccDNA's validate mode detects orphan tool_results (tool_result without
|
|
6
|
+
* matching tool_use), this module repairs the transcript:
|
|
7
|
+
*
|
|
8
|
+
* 1. ROLLBACK (preferred): Restore from backup if valid
|
|
9
|
+
* 2. SURGICAL REPAIR (fallback): Remove orphan tool_result lines
|
|
10
|
+
*
|
|
11
|
+
* This closes the loop: ccDNA detects → ekkos-cli repairs → /clear + /continue
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.countOrphansInJsonl = countOrphansInJsonl;
|
|
48
|
+
exports.repairOrRollbackTranscript = repairOrRollbackTranscript;
|
|
49
|
+
exports.isTranscriptHealthy = isTranscriptHealthy;
|
|
50
|
+
exports.getTranscriptHealth = getTranscriptHealth;
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const os = __importStar(require("os"));
|
|
54
|
+
// Debug logging to the same file as eviction
|
|
55
|
+
function debugLog(category, msg, data) {
|
|
56
|
+
try {
|
|
57
|
+
const logDir = path.join(os.homedir(), '.ekkos', 'logs');
|
|
58
|
+
if (!fs.existsSync(logDir))
|
|
59
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
60
|
+
const logPath = path.join(logDir, 'eviction-debug.log');
|
|
61
|
+
const ts = new Date().toISOString();
|
|
62
|
+
const line = `[${ts}] [REPAIR:${category}] ${msg}${data ? '\n ' + JSON.stringify(data, null, 2).replace(/\n/g, '\n ') : ''}`;
|
|
63
|
+
fs.appendFileSync(logPath, line + '\n');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Silent fail
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Minimal validator: tool_result.tool_use_id must reference a tool_use.id that
|
|
71
|
+
* appears earlier in the transcript (in-order).
|
|
72
|
+
*/
|
|
73
|
+
function countOrphansInJsonl(jsonlPath) {
|
|
74
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
75
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
76
|
+
const seenToolUses = new Set();
|
|
77
|
+
const orphanIds = [];
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
let obj;
|
|
80
|
+
try {
|
|
81
|
+
obj = JSON.parse(line);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const blocks = obj?.message?.content;
|
|
87
|
+
if (!Array.isArray(blocks))
|
|
88
|
+
continue;
|
|
89
|
+
for (const b of blocks) {
|
|
90
|
+
const block = b;
|
|
91
|
+
if (block?.type === 'tool_use' && typeof block?.id === 'string') {
|
|
92
|
+
seenToolUses.add(block.id);
|
|
93
|
+
}
|
|
94
|
+
else if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
|
|
95
|
+
if (!seenToolUses.has(block.tool_use_id)) {
|
|
96
|
+
orphanIds.push(block.tool_use_id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { orphans: orphanIds.length, orphanIds, lineCount: lines.length };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Find plausible backups for a jsonl file.
|
|
105
|
+
* Our jsonl-rewriter uses: ${filePath}.backup (single backup)
|
|
106
|
+
*/
|
|
107
|
+
function findBackups(jsonlPath) {
|
|
108
|
+
const dir = path.dirname(jsonlPath);
|
|
109
|
+
const base = path.basename(jsonlPath);
|
|
110
|
+
if (!fs.existsSync(dir))
|
|
111
|
+
return [];
|
|
112
|
+
const candidates = [];
|
|
113
|
+
// Primary backup: exact .backup suffix (from jsonl-rewriter.ts)
|
|
114
|
+
const primaryBackup = `${jsonlPath}.backup`;
|
|
115
|
+
if (fs.existsSync(primaryBackup)) {
|
|
116
|
+
candidates.push(primaryBackup);
|
|
117
|
+
}
|
|
118
|
+
// Also check for other backup patterns in case user has manual backups
|
|
119
|
+
const files = fs.readdirSync(dir);
|
|
120
|
+
const additionalBackups = files
|
|
121
|
+
.filter(f => f.startsWith(base) &&
|
|
122
|
+
f !== base &&
|
|
123
|
+
(f.includes('.bak') || f.includes('.backup') || f.includes('.old') || f.includes('.prev')))
|
|
124
|
+
.map(f => path.join(dir, f))
|
|
125
|
+
.filter(f => !candidates.includes(f)) // Don't duplicate
|
|
126
|
+
.sort((a, b) => {
|
|
127
|
+
// Sort by mtime, newest first
|
|
128
|
+
const am = fs.statSync(a).mtimeMs;
|
|
129
|
+
const bm = fs.statSync(b).mtimeMs;
|
|
130
|
+
return bm - am;
|
|
131
|
+
});
|
|
132
|
+
return [...candidates, ...additionalBackups];
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Atomically replace target file with source file.
|
|
136
|
+
* Keeps the corrupted version for forensics.
|
|
137
|
+
*/
|
|
138
|
+
function atomicReplace(target, source) {
|
|
139
|
+
const dir = path.dirname(target);
|
|
140
|
+
const tmp = path.join(dir, `${path.basename(target)}.tmp-repair-${Date.now()}`);
|
|
141
|
+
fs.copyFileSync(source, tmp);
|
|
142
|
+
const corrupt = path.join(dir, `${path.basename(target)}.corrupt-${Date.now()}`);
|
|
143
|
+
fs.renameSync(target, corrupt);
|
|
144
|
+
fs.renameSync(tmp, target);
|
|
145
|
+
debugLog('ATOMIC_REPLACE', 'Replaced corrupt file', {
|
|
146
|
+
target,
|
|
147
|
+
source,
|
|
148
|
+
corruptBackup: corrupt,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Surgical repair: drop any line that contains a tool_result whose tool_use_id
|
|
153
|
+
* has not appeared earlier in the file. This unbricks at the cost of losing those results.
|
|
154
|
+
*/
|
|
155
|
+
function surgicalRepair(jsonlPath) {
|
|
156
|
+
const content = fs.readFileSync(jsonlPath, 'utf-8');
|
|
157
|
+
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
158
|
+
const seenToolUses = new Set();
|
|
159
|
+
const kept = [];
|
|
160
|
+
let removed = 0;
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
let obj;
|
|
163
|
+
try {
|
|
164
|
+
obj = JSON.parse(line);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Keep non-JSON lines (shouldn't happen, but safe)
|
|
168
|
+
kept.push(line);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const blocks = obj?.message?.content;
|
|
172
|
+
if (!Array.isArray(blocks)) {
|
|
173
|
+
kept.push(line);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// First pass: collect tool_use IDs from this line
|
|
177
|
+
const lineToolUseIds = [];
|
|
178
|
+
for (const b of blocks) {
|
|
179
|
+
const block = b;
|
|
180
|
+
if (block?.type === 'tool_use' && typeof block?.id === 'string') {
|
|
181
|
+
lineToolUseIds.push(block.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Second pass: check for orphan tool_results
|
|
185
|
+
let hasOrphanResult = false;
|
|
186
|
+
for (const b of blocks) {
|
|
187
|
+
const block = b;
|
|
188
|
+
if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
|
|
189
|
+
if (!seenToolUses.has(block.tool_use_id)) {
|
|
190
|
+
hasOrphanResult = true;
|
|
191
|
+
debugLog('SURGICAL_DROP', 'Dropping line with orphan tool_result', {
|
|
192
|
+
tool_use_id: block.tool_use_id,
|
|
193
|
+
linePreview: line.slice(0, 200),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (hasOrphanResult) {
|
|
199
|
+
removed++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// If kept, update seen tool uses AFTER keeping (order matters)
|
|
203
|
+
for (const id of lineToolUseIds) {
|
|
204
|
+
seenToolUses.add(id);
|
|
205
|
+
}
|
|
206
|
+
kept.push(line);
|
|
207
|
+
}
|
|
208
|
+
// Write atomically
|
|
209
|
+
const dir = path.dirname(jsonlPath);
|
|
210
|
+
const tmp = path.join(dir, `${path.basename(jsonlPath)}.tmp-surgical-${Date.now()}`);
|
|
211
|
+
fs.writeFileSync(tmp, kept.join('\n') + '\n', 'utf-8');
|
|
212
|
+
const corrupt = path.join(dir, `${path.basename(jsonlPath)}.corrupt-${Date.now()}`);
|
|
213
|
+
fs.renameSync(jsonlPath, corrupt);
|
|
214
|
+
fs.renameSync(tmp, jsonlPath);
|
|
215
|
+
const { orphans: orphansAfter } = countOrphansInJsonl(jsonlPath);
|
|
216
|
+
debugLog('SURGICAL_COMPLETE', 'Surgical repair finished', {
|
|
217
|
+
removed,
|
|
218
|
+
orphansAfter,
|
|
219
|
+
corruptBackup: corrupt,
|
|
220
|
+
});
|
|
221
|
+
return { removed, orphansAfter };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Main entry point: repair or rollback a transcript with orphan tool_results.
|
|
225
|
+
*
|
|
226
|
+
* Strategy:
|
|
227
|
+
* 1. Check if there are orphans (exit early if none)
|
|
228
|
+
* 2. Try rollback to newest valid backup
|
|
229
|
+
* 3. If no valid backup, surgically remove orphan lines
|
|
230
|
+
*/
|
|
231
|
+
function repairOrRollbackTranscript(jsonlPath) {
|
|
232
|
+
debugLog('REPAIR_START', '═══════════════════════════════════════════════════════════', {
|
|
233
|
+
action: 'TRANSCRIPT_REPAIR_INITIATED',
|
|
234
|
+
jsonlPath,
|
|
235
|
+
timestamp: new Date().toISOString(),
|
|
236
|
+
});
|
|
237
|
+
if (!fs.existsSync(jsonlPath)) {
|
|
238
|
+
debugLog('REPAIR_ABORT', 'JSONL file not found', { jsonlPath });
|
|
239
|
+
return { action: 'failed', orphansFound: 0, reason: 'jsonl_not_found' };
|
|
240
|
+
}
|
|
241
|
+
const initial = countOrphansInJsonl(jsonlPath);
|
|
242
|
+
debugLog('REPAIR_SCAN', 'Initial orphan count', {
|
|
243
|
+
orphans: initial.orphans,
|
|
244
|
+
orphanIds: initial.orphanIds,
|
|
245
|
+
});
|
|
246
|
+
if (initial.orphans === 0) {
|
|
247
|
+
debugLog('REPAIR_CLEAN', 'No orphans found - transcript is healthy');
|
|
248
|
+
return { action: 'none', orphansFound: 0 };
|
|
249
|
+
}
|
|
250
|
+
// 1) Try rollback to newest valid backup
|
|
251
|
+
const backups = findBackups(jsonlPath);
|
|
252
|
+
debugLog('REPAIR_BACKUPS', `Found ${backups.length} backup candidates`, { backups });
|
|
253
|
+
for (const backup of backups) {
|
|
254
|
+
try {
|
|
255
|
+
const v = countOrphansInJsonl(backup);
|
|
256
|
+
debugLog('REPAIR_CHECK_BACKUP', 'Validating backup', {
|
|
257
|
+
backup,
|
|
258
|
+
orphans: v.orphans,
|
|
259
|
+
});
|
|
260
|
+
if (v.orphans === 0) {
|
|
261
|
+
// SAFETY CHECK: Prevent session amnesia (rollback > 20 lines)
|
|
262
|
+
const lostLines = (initial.lineCount || 0) - (v.lineCount || 0);
|
|
263
|
+
if (lostLines > 20) {
|
|
264
|
+
debugLog('REPAIR_BACKUP_SKIPPED', 'Backup too old - skipping to prevent data loss', {
|
|
265
|
+
backup,
|
|
266
|
+
currentLines: initial.lineCount,
|
|
267
|
+
backupLines: v.lineCount,
|
|
268
|
+
lostLines
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
atomicReplace(jsonlPath, backup);
|
|
273
|
+
debugLog('REPAIR_ROLLBACK_SUCCESS', 'Rolled back to valid backup', { backup });
|
|
274
|
+
return { action: 'rollback', orphansFound: initial.orphans, backupUsed: backup };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
debugLog('REPAIR_BACKUP_ERROR', 'Failed to check backup', {
|
|
279
|
+
backup,
|
|
280
|
+
error: err.message,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// 2) Surgical repair fallback
|
|
285
|
+
debugLog('REPAIR_SURGICAL', 'No valid backup found - attempting surgical repair');
|
|
286
|
+
const { removed, orphansAfter } = surgicalRepair(jsonlPath);
|
|
287
|
+
if (orphansAfter === 0) {
|
|
288
|
+
return { action: 'surgical_repair', orphansFound: initial.orphans, removedLines: removed };
|
|
289
|
+
}
|
|
290
|
+
debugLog('REPAIR_FAILED', 'Surgical repair incomplete - orphans remain', {
|
|
291
|
+
orphansAfter,
|
|
292
|
+
removed,
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
action: 'failed',
|
|
296
|
+
orphansFound: initial.orphans,
|
|
297
|
+
removedLines: removed,
|
|
298
|
+
reason: 'orphans_remain_after_surgical',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Quick validation check - can be called before operations to verify health.
|
|
303
|
+
*/
|
|
304
|
+
function isTranscriptHealthy(jsonlPath) {
|
|
305
|
+
if (!fs.existsSync(jsonlPath))
|
|
306
|
+
return true; // Non-existent is not unhealthy
|
|
307
|
+
const { orphans } = countOrphansInJsonl(jsonlPath);
|
|
308
|
+
return orphans === 0;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Get detailed transcript health info.
|
|
312
|
+
*/
|
|
313
|
+
function getTranscriptHealth(jsonlPath) {
|
|
314
|
+
if (!fs.existsSync(jsonlPath)) {
|
|
315
|
+
return { exists: false, healthy: true, orphans: 0, orphanIds: [] };
|
|
316
|
+
}
|
|
317
|
+
const { orphans, orphanIds } = countOrphansInJsonl(jsonlPath);
|
|
318
|
+
return { exists: true, healthy: orphans === 0, orphans, orphanIds };
|
|
319
|
+
}
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -42,3 +42,9 @@ export declare function agentUninstall(options?: AgentOptions): Promise<void>;
|
|
|
42
42
|
export declare function agentLogs(options?: {
|
|
43
43
|
follow?: boolean;
|
|
44
44
|
}): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Check agent daemon health and diagnose connection issues
|
|
47
|
+
*/
|
|
48
|
+
export declare function agentHealth(options?: {
|
|
49
|
+
json?: boolean;
|
|
50
|
+
}): Promise<void>;
|
package/dist/commands/agent.js
CHANGED
|
@@ -54,6 +54,7 @@ exports.agentRestart = agentRestart;
|
|
|
54
54
|
exports.agentStatus = agentStatus;
|
|
55
55
|
exports.agentUninstall = agentUninstall;
|
|
56
56
|
exports.agentLogs = agentLogs;
|
|
57
|
+
exports.agentHealth = agentHealth;
|
|
57
58
|
const chalk_1 = __importDefault(require("chalk"));
|
|
58
59
|
const os = __importStar(require("os"));
|
|
59
60
|
const fs = __importStar(require("fs"));
|
|
@@ -298,3 +299,246 @@ async function agentLogs(options = {}) {
|
|
|
298
299
|
console.log(lines.join('\n'));
|
|
299
300
|
}
|
|
300
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Check agent daemon health and diagnose connection issues
|
|
304
|
+
*/
|
|
305
|
+
async function agentHealth(options = {}) {
|
|
306
|
+
const platform = os.platform();
|
|
307
|
+
const deviceFilePath = path.join(state_1.EKKOS_DIR, 'device.json');
|
|
308
|
+
const logPath = path.join(state_1.EKKOS_DIR, 'agent.out.log');
|
|
309
|
+
const pidFilePath = path.join(state_1.EKKOS_DIR, 'agent.pid');
|
|
310
|
+
const health = {
|
|
311
|
+
timestamp: new Date().toISOString(),
|
|
312
|
+
status: 'unknown',
|
|
313
|
+
checks: {
|
|
314
|
+
configured: false,
|
|
315
|
+
paired: false,
|
|
316
|
+
serviceRunning: false,
|
|
317
|
+
processRunning: false,
|
|
318
|
+
logsWriting: false,
|
|
319
|
+
cloudConnected: false,
|
|
320
|
+
networkHealthy: false,
|
|
321
|
+
},
|
|
322
|
+
issues: [],
|
|
323
|
+
suggestions: [],
|
|
324
|
+
};
|
|
325
|
+
// Check 1: Device configured
|
|
326
|
+
if (fs.existsSync(deviceFilePath)) {
|
|
327
|
+
const deviceData = JSON.parse(fs.readFileSync(deviceFilePath, 'utf-8'));
|
|
328
|
+
health.checks.configured = true;
|
|
329
|
+
health.device = {
|
|
330
|
+
name: deviceData.deviceName,
|
|
331
|
+
id: deviceData.deviceId.slice(0, 8) + '...',
|
|
332
|
+
platform: deviceData.platform,
|
|
333
|
+
arch: deviceData.arch,
|
|
334
|
+
};
|
|
335
|
+
// Check 2: Device paired
|
|
336
|
+
if (deviceData.deviceToken) {
|
|
337
|
+
health.checks.paired = true;
|
|
338
|
+
health.pairedAt = deviceData.pairedAt;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
health.checks.paired = false;
|
|
342
|
+
health.issues.push('Device not paired');
|
|
343
|
+
health.suggestions.push('Run `ekkos setup-remote` to pair device');
|
|
344
|
+
}
|
|
345
|
+
// Check 3: Service running
|
|
346
|
+
try {
|
|
347
|
+
if (platform === 'darwin') {
|
|
348
|
+
const output = (0, child_process_1.execSync)('launchctl list | grep dev.ekkos.agent || true', { encoding: 'utf-8' });
|
|
349
|
+
health.checks.serviceRunning = output.includes('dev.ekkos.agent');
|
|
350
|
+
}
|
|
351
|
+
else if (platform === 'win32') {
|
|
352
|
+
const output = (0, child_process_1.execSync)('schtasks /query /tn "ekkOS Agent" 2>nul || true', { encoding: 'utf-8' });
|
|
353
|
+
health.checks.serviceRunning = output.includes('Running');
|
|
354
|
+
}
|
|
355
|
+
else if (platform === 'linux') {
|
|
356
|
+
const output = (0, child_process_1.execSync)('systemctl --user is-active ekkos-agent 2>/dev/null || true', { encoding: 'utf-8' });
|
|
357
|
+
health.checks.serviceRunning = output.trim() === 'active';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
health.checks.serviceRunning = false;
|
|
362
|
+
}
|
|
363
|
+
if (!health.checks.serviceRunning) {
|
|
364
|
+
health.issues.push('Service not running');
|
|
365
|
+
health.suggestions.push('Run `ekkos agent start` to start the service');
|
|
366
|
+
}
|
|
367
|
+
// Check 4: Process running
|
|
368
|
+
if (fs.existsSync(pidFilePath)) {
|
|
369
|
+
const pid = parseInt(fs.readFileSync(pidFilePath, 'utf-8').trim());
|
|
370
|
+
try {
|
|
371
|
+
// Check if process exists
|
|
372
|
+
(0, child_process_1.execSync)(`kill -0 ${pid} 2>/dev/null`, { stdio: 'pipe' });
|
|
373
|
+
health.checks.processRunning = true;
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
health.checks.processRunning = false;
|
|
377
|
+
health.issues.push(`Agent process (PID ${pid}) not running`);
|
|
378
|
+
health.suggestions.push('Run `ekkos agent restart` to restart');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
health.checks.processRunning = false;
|
|
383
|
+
health.issues.push('No PID file found');
|
|
384
|
+
}
|
|
385
|
+
// Check 5: Logs writing
|
|
386
|
+
if (fs.existsSync(logPath)) {
|
|
387
|
+
const stats = fs.statSync(logPath);
|
|
388
|
+
const lastModified = Date.now() - stats.mtime.getTime();
|
|
389
|
+
const fifteenMinutes = 15 * 60 * 1000;
|
|
390
|
+
health.checks.logsWriting = lastModified < fifteenMinutes;
|
|
391
|
+
health.lastLogWrite = {
|
|
392
|
+
timestamp: stats.mtime.toISOString(),
|
|
393
|
+
minutesAgo: Math.round(lastModified / 1000 / 60),
|
|
394
|
+
};
|
|
395
|
+
if (!health.checks.logsWriting) {
|
|
396
|
+
health.issues.push(`No logs written for ${Math.round(lastModified / 1000 / 60)} minutes`);
|
|
397
|
+
health.suggestions.push('Check if daemon crashed - run `ekkos agent logs -f` to monitor');
|
|
398
|
+
}
|
|
399
|
+
// Parse recent errors from logs
|
|
400
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
401
|
+
const lines = content.split('\n').slice(-100);
|
|
402
|
+
const errors = lines.filter(l => l.includes('ERROR') || l.includes('error') || l.includes('Connection failed'));
|
|
403
|
+
if (errors.length > 0) {
|
|
404
|
+
health.recentErrors = errors.slice(-5);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
health.checks.logsWriting = false;
|
|
409
|
+
health.issues.push('Log file not found');
|
|
410
|
+
}
|
|
411
|
+
// Check 6: Cloud connectivity
|
|
412
|
+
if (health.checks.paired) {
|
|
413
|
+
try {
|
|
414
|
+
const authToken = (0, state_1.getAuthToken)();
|
|
415
|
+
const state = (0, state_1.getState)();
|
|
416
|
+
if (authToken && state?.userId) {
|
|
417
|
+
const MEMORY_API_URL = process.env.MEMORY_API_URL || 'https://api.ekkos.dev';
|
|
418
|
+
const controller = new AbortController();
|
|
419
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
420
|
+
try {
|
|
421
|
+
const response = await fetch(`${MEMORY_API_URL}/api/v1/relay/devices/${state.userId}`, {
|
|
422
|
+
headers: { 'Authorization': `Bearer ${authToken}` },
|
|
423
|
+
signal: controller.signal,
|
|
424
|
+
});
|
|
425
|
+
clearTimeout(timeout);
|
|
426
|
+
if (response.ok) {
|
|
427
|
+
const data = await response.json();
|
|
428
|
+
const device = data.devices?.find((d) => d.deviceId === deviceData.deviceId);
|
|
429
|
+
health.checks.cloudConnected = device?.online === true;
|
|
430
|
+
if (!health.checks.cloudConnected) {
|
|
431
|
+
health.issues.push('Device shows offline in cloud');
|
|
432
|
+
health.suggestions.push('Check network connection and agent logs');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
health.checks.cloudConnected = false;
|
|
437
|
+
health.issues.push(`Cloud API returned ${response.status}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
clearTimeout(timeout);
|
|
442
|
+
health.checks.cloudConnected = false;
|
|
443
|
+
if (err.name === 'AbortError') {
|
|
444
|
+
health.issues.push('Cloud connection timeout (>5s)');
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
health.issues.push(`Cloud connection error: ${err.message}`);
|
|
448
|
+
}
|
|
449
|
+
health.suggestions.push('Check internet connection');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
health.issues.push('Not logged in to ekkOS');
|
|
454
|
+
health.suggestions.push('Run `ekkos init` to set up authentication');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
health.checks.cloudConnected = false;
|
|
459
|
+
health.issues.push('Unable to check cloud connectivity');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// Check 7: Network health
|
|
463
|
+
if (health.checks.logsWriting && health.checks.cloudConnected) {
|
|
464
|
+
health.checks.networkHealthy = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
health.checks.configured = false;
|
|
469
|
+
health.issues.push('Device not configured');
|
|
470
|
+
health.suggestions.push('Run `ekkos setup-remote` to configure device');
|
|
471
|
+
}
|
|
472
|
+
// Determine overall status
|
|
473
|
+
if (health.issues.length === 0) {
|
|
474
|
+
health.status = 'healthy';
|
|
475
|
+
}
|
|
476
|
+
else if (health.checks.serviceRunning &&
|
|
477
|
+
health.checks.processRunning &&
|
|
478
|
+
health.checks.logsWriting) {
|
|
479
|
+
health.status = 'degraded';
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
health.status = 'unhealthy';
|
|
483
|
+
}
|
|
484
|
+
if (options.json) {
|
|
485
|
+
console.log(JSON.stringify(health, null, 2));
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
// Pretty print
|
|
489
|
+
console.log('');
|
|
490
|
+
console.log(chalk_1.default.cyan.bold(' ekkOS Agent Health Check'));
|
|
491
|
+
console.log('');
|
|
492
|
+
const statusColor = health.status === 'healthy' ? chalk_1.default.green :
|
|
493
|
+
health.status === 'degraded' ? chalk_1.default.yellow :
|
|
494
|
+
chalk_1.default.red;
|
|
495
|
+
console.log(` Status: ${statusColor.bold(health.status.toUpperCase())}`);
|
|
496
|
+
console.log('');
|
|
497
|
+
// Device info
|
|
498
|
+
if (health.device) {
|
|
499
|
+
console.log(chalk_1.default.gray(' Device:'));
|
|
500
|
+
console.log(` ${chalk_1.default.gray('Name:')} ${health.device.name}`);
|
|
501
|
+
console.log(` ${chalk_1.default.gray('ID:')} ${health.device.id}`);
|
|
502
|
+
console.log(` ${chalk_1.default.gray('Platform:')} ${health.device.platform}/${health.device.arch}`);
|
|
503
|
+
console.log('');
|
|
504
|
+
}
|
|
505
|
+
// Checks
|
|
506
|
+
console.log(chalk_1.default.gray(' Checks:'));
|
|
507
|
+
for (const [key, value] of Object.entries(health.checks)) {
|
|
508
|
+
const icon = value ? chalk_1.default.green('✓') : chalk_1.default.red('✗');
|
|
509
|
+
const label = key.replace(/([A-Z])/g, ' $1').trim();
|
|
510
|
+
console.log(` ${icon} ${label}`);
|
|
511
|
+
}
|
|
512
|
+
console.log('');
|
|
513
|
+
// Issues
|
|
514
|
+
if (health.issues.length > 0) {
|
|
515
|
+
console.log(chalk_1.default.yellow(' Issues:'));
|
|
516
|
+
for (const issue of health.issues) {
|
|
517
|
+
console.log(` ${chalk_1.default.yellow('⚠')} ${issue}`);
|
|
518
|
+
}
|
|
519
|
+
console.log('');
|
|
520
|
+
}
|
|
521
|
+
// Suggestions
|
|
522
|
+
if (health.suggestions.length > 0) {
|
|
523
|
+
console.log(chalk_1.default.cyan(' Suggestions:'));
|
|
524
|
+
for (const suggestion of health.suggestions) {
|
|
525
|
+
console.log(` ${chalk_1.default.cyan('→')} ${suggestion}`);
|
|
526
|
+
}
|
|
527
|
+
console.log('');
|
|
528
|
+
}
|
|
529
|
+
// Recent errors
|
|
530
|
+
if (health.recentErrors && health.recentErrors.length > 0) {
|
|
531
|
+
console.log(chalk_1.default.red(' Recent Errors:'));
|
|
532
|
+
for (const err of health.recentErrors.slice(0, 3)) {
|
|
533
|
+
const preview = err.substring(0, 100) + (err.length > 100 ? '...' : '');
|
|
534
|
+
console.log(` ${chalk_1.default.red('!')} ${preview}`);
|
|
535
|
+
}
|
|
536
|
+
console.log('');
|
|
537
|
+
}
|
|
538
|
+
// Logs info
|
|
539
|
+
if (health.lastLogWrite) {
|
|
540
|
+
console.log(chalk_1.default.gray(` Last activity: ${health.lastLogWrite.minutesAgo} minutes ago`));
|
|
541
|
+
console.log('');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ekkos dashboard [session-name]
|
|
3
|
+
*
|
|
4
|
+
* Live TUI dashboard for monitoring Claude Code session usage in real-time.
|
|
5
|
+
* Uses blessed-contrib for rich terminal widgets (gauges, charts, tables).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ekkos dashboard <session-name> Watch specific session
|
|
9
|
+
* ekkos dashboard --latest Auto-detect latest active session
|
|
10
|
+
* ekkos dashboard --wait-for-new Wait for a brand-new session to appear
|
|
11
|
+
* ekkos dashboard Interactive session picker
|
|
12
|
+
*
|
|
13
|
+
* Text Selection:
|
|
14
|
+
* To select text separately from Claude Code, run the dashboard in a different
|
|
15
|
+
* terminal window/pane. This prevents the blessed screen from interfering with
|
|
16
|
+
* Claude Code's text selection. Use iTerm2 split panes or tmux windows.
|
|
17
|
+
*
|
|
18
|
+
* Scrolling:
|
|
19
|
+
* Up/Down arrows or j/k Scroll one line
|
|
20
|
+
* PageUp/PageDown or u/d Scroll one page
|
|
21
|
+
* Home/End or g/G Jump to top/bottom
|
|
22
|
+
* Mouse wheel Scroll with mouse
|
|
23
|
+
*/
|
|
24
|
+
import { Command } from 'commander';
|
|
25
|
+
export declare const dashboardCommand: Command;
|