@ekkos/cli 0.3.3 → 1.0.1
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/transcript-repair.d.ts +1 -0
- package/dist/capture/transcript-repair.js +12 -1
- 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/run.d.ts +3 -0
- package/dist/commands/run.js +503 -350
- 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/deploy/instructions.d.ts +5 -2
- package/dist/deploy/instructions.js +11 -8
- package/dist/index.js +256 -20
- package/dist/lib/tmux-scrollbar.d.ts +14 -0
- package/dist/lib/tmux-scrollbar.js +296 -0
- package/dist/lib/usage-parser.d.ts +95 -5
- package/dist/lib/usage-parser.js +416 -71
- 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 +16 -11
- package/templates/CLAUDE.md +135 -23
- package/templates/cursor-hooks/after-agent-response.sh +0 -0
- package/templates/cursor-hooks/before-submit-prompt.sh +0 -0
- package/templates/cursor-hooks/stop.sh +0 -0
- package/templates/ekkos-manifest.json +5 -5
- package/templates/hooks/assistant-response.sh +0 -0
- 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/session-start.sh +0 -0
- package/templates/hooks/stop.sh +150 -388
- package/templates/hooks/user-prompt-submit.sh +353 -443
- package/templates/plan-template.md +0 -0
- package/templates/spec-template.md +0 -0
- 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/LICENSE +0 -21
- package/templates/windsurf-hooks/before-submit-prompt.sh +0 -238
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @ekkos/cli
|
|
2
|
+
|
|
3
|
+
Persistent cognitive memory for AI coding assistants. ekkOS gives Claude Code, Cursor, and Windsurf long-term memory — patterns, decisions, and operational intelligence that compound across every interaction.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install globally
|
|
9
|
+
npm install -g @ekkos/cli
|
|
10
|
+
|
|
11
|
+
# Authenticate and configure your IDE
|
|
12
|
+
ekkos init
|
|
13
|
+
|
|
14
|
+
# Launch Claude Code with memory
|
|
15
|
+
ekkos run
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
| Command | Description |
|
|
21
|
+
|---------|-------------|
|
|
22
|
+
| `ekkos init` | Authenticate and configure your IDE (Claude Code, Cursor, Windsurf) |
|
|
23
|
+
| `ekkos run` | Launch Claude Code with ekkOS memory connected |
|
|
24
|
+
| `ekkos run --dashboard` | Launch with live metrics dashboard (tmux split) |
|
|
25
|
+
| `ekkos status` | Check connection and memory health |
|
|
26
|
+
| `ekkos dashboard` | Open the live session dashboard |
|
|
27
|
+
| `ekkos export` | Export your patterns and memory as JSON |
|
|
28
|
+
| `ekkos --help` | Show all commands |
|
|
29
|
+
|
|
30
|
+
## What It Does
|
|
31
|
+
|
|
32
|
+
- **Remembers what works** — Patterns from successful solutions are recalled automatically
|
|
33
|
+
- **Learns from mistakes** — Anti-patterns prevent repeating the same errors
|
|
34
|
+
- **Crosses sessions** — Knowledge persists across conversations, projects, and tools
|
|
35
|
+
- **Gets smarter over time** — Success rates compound as patterns are verified
|
|
36
|
+
|
|
37
|
+
## How It Works
|
|
38
|
+
|
|
39
|
+
ekkOS installs hooks into your AI coding assistant that:
|
|
40
|
+
|
|
41
|
+
1. **Before each prompt** — Searches 11 memory layers for relevant patterns
|
|
42
|
+
2. **After each response** — Captures new solutions as reusable patterns
|
|
43
|
+
3. **Between sessions** — Preserves context through eviction and rehydration
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- Node.js >= 18
|
|
48
|
+
- An ekkOS account ([platform.ekkos.dev](https://platform.ekkos.dev/signup))
|
|
49
|
+
- One of: Claude Code, Cursor, Windsurf, or VS Code
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
[docs.ekkos.dev](https://docs.ekkos.dev)
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
Proprietary — see [ekkos.dev/terms](https://ekkos.dev/terms)
|
package/dist/agent/daemon.d.ts
CHANGED
|
@@ -18,9 +18,16 @@ export declare class AgentDaemon {
|
|
|
18
18
|
private ws;
|
|
19
19
|
private reconnectAttempt;
|
|
20
20
|
private heartbeatTimer;
|
|
21
|
+
private pongTimer;
|
|
22
|
+
private pongReceived;
|
|
21
23
|
private ptyRunner;
|
|
22
24
|
private currentSessionId;
|
|
25
|
+
private currentSessionCwd;
|
|
26
|
+
private sessionStartedAt;
|
|
27
|
+
private ptyRestartAttempts;
|
|
28
|
+
private readonly QUICK_EXIT_MS;
|
|
23
29
|
private running;
|
|
30
|
+
private logPath;
|
|
24
31
|
private outputBuffer;
|
|
25
32
|
private currentSessionName;
|
|
26
33
|
private isAutoClearInProgress;
|
|
@@ -63,6 +70,14 @@ export declare class AgentDaemon {
|
|
|
63
70
|
* Handle resize from browser
|
|
64
71
|
*/
|
|
65
72
|
private handleResize;
|
|
73
|
+
/**
|
|
74
|
+
* Handle directory listing request from relay for project browsing UI.
|
|
75
|
+
*/
|
|
76
|
+
private handleListDirectories;
|
|
77
|
+
private resolveBrowsePath;
|
|
78
|
+
private getParentPath;
|
|
79
|
+
private getWindowsRootEntries;
|
|
80
|
+
private resolveSessionCwd;
|
|
66
81
|
/**
|
|
67
82
|
* Handle PTY exit
|
|
68
83
|
*/
|
|
@@ -83,10 +98,22 @@ export declare class AgentDaemon {
|
|
|
83
98
|
* Sleep helper
|
|
84
99
|
*/
|
|
85
100
|
private sleep;
|
|
101
|
+
/**
|
|
102
|
+
* Start or restart PTY for the current session.
|
|
103
|
+
*/
|
|
104
|
+
private startSessionPty;
|
|
86
105
|
/**
|
|
87
106
|
* Handle WebSocket close
|
|
107
|
+
*
|
|
108
|
+
* CRITICAL: Do NOT kill the PTY here. The PTY must survive WebSocket
|
|
109
|
+
* disconnects so users can reconnect to their existing session.
|
|
110
|
+
* The PTY is only killed on explicit session_end or daemon stop.
|
|
88
111
|
*/
|
|
89
112
|
private handleClose;
|
|
113
|
+
/**
|
|
114
|
+
* Schedule reconnection with exponential backoff
|
|
115
|
+
*/
|
|
116
|
+
private scheduleReconnect;
|
|
90
117
|
/**
|
|
91
118
|
* Handle WebSocket error
|
|
92
119
|
*/
|
package/dist/agent/daemon.js
CHANGED
|
@@ -51,8 +51,10 @@ const os = __importStar(require("os"));
|
|
|
51
51
|
const fs = __importStar(require("fs"));
|
|
52
52
|
const path = __importStar(require("path"));
|
|
53
53
|
const pty_runner_1 = require("./pty-runner");
|
|
54
|
+
const log_rotate_1 = require("../utils/log-rotate");
|
|
54
55
|
const RELAY_URL = process.env.RELAY_WS_URL || 'wss://ekkos-relay-production.up.railway.app';
|
|
55
|
-
const HEARTBEAT_INTERVAL =
|
|
56
|
+
const HEARTBEAT_INTERVAL = 10000; // 10 seconds - must be well under Railway's 20-30s idle timeout
|
|
57
|
+
const PONG_TIMEOUT = 15000; // If no pong received in 15s, consider connection dead
|
|
56
58
|
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 32000, 60000]; // Exponential backoff
|
|
57
59
|
// Auto-continue: Context wall detection pattern
|
|
58
60
|
const CONTEXT_WALL_REGEX = /context limit reached.*\/(compact|clear)\b.*to continue/i;
|
|
@@ -65,8 +67,13 @@ class AgentDaemon {
|
|
|
65
67
|
this.ws = null;
|
|
66
68
|
this.reconnectAttempt = 0;
|
|
67
69
|
this.heartbeatTimer = null;
|
|
70
|
+
this.pongTimer = null;
|
|
71
|
+
this.pongReceived = true;
|
|
68
72
|
this.ptyRunner = null;
|
|
69
73
|
this.currentSessionId = null;
|
|
74
|
+
this.sessionStartedAt = 0;
|
|
75
|
+
this.ptyRestartAttempts = 0;
|
|
76
|
+
this.QUICK_EXIT_MS = 15000;
|
|
70
77
|
this.running = false;
|
|
71
78
|
// Auto-continue state
|
|
72
79
|
this.outputBuffer = '';
|
|
@@ -75,6 +82,7 @@ class AgentDaemon {
|
|
|
75
82
|
this.lastContextWallTime = 0;
|
|
76
83
|
this.CONTEXT_WALL_COOLDOWN = 30000; // 30 seconds between auto-clears
|
|
77
84
|
this.config = config;
|
|
85
|
+
this.logPath = path.join(os.homedir(), '.ekkos', 'agent.log');
|
|
78
86
|
}
|
|
79
87
|
/**
|
|
80
88
|
* Start the daemon
|
|
@@ -94,6 +102,11 @@ class AgentDaemon {
|
|
|
94
102
|
clearInterval(this.heartbeatTimer);
|
|
95
103
|
this.heartbeatTimer = null;
|
|
96
104
|
}
|
|
105
|
+
// Stop pong timer
|
|
106
|
+
if (this.pongTimer) {
|
|
107
|
+
clearTimeout(this.pongTimer);
|
|
108
|
+
this.pongTimer = null;
|
|
109
|
+
}
|
|
97
110
|
// Close PTY
|
|
98
111
|
if (this.ptyRunner) {
|
|
99
112
|
this.ptyRunner.kill();
|
|
@@ -121,6 +134,7 @@ class AgentDaemon {
|
|
|
121
134
|
});
|
|
122
135
|
this.ws.on('open', () => this.handleOpen());
|
|
123
136
|
this.ws.on('message', (data) => this.handleMessage(data));
|
|
137
|
+
this.ws.on('pong', () => { this.pongReceived = true; });
|
|
124
138
|
this.ws.on('close', (code, reason) => this.handleClose(code, reason.toString()));
|
|
125
139
|
this.ws.on('error', (err) => this.handleError(err));
|
|
126
140
|
}
|
|
@@ -130,8 +144,29 @@ class AgentDaemon {
|
|
|
130
144
|
handleOpen() {
|
|
131
145
|
this.log('Connected to relay server');
|
|
132
146
|
this.reconnectAttempt = 0;
|
|
133
|
-
|
|
147
|
+
this.pongReceived = true;
|
|
148
|
+
// If we have a surviving PTY session, tell relay we're ready to reattach
|
|
149
|
+
if (this.ptyRunner && this.currentSessionId) {
|
|
150
|
+
this.log(`Re-advertising surviving session ${this.currentSessionId}`);
|
|
151
|
+
this.sendMessage({
|
|
152
|
+
type: 'session_alive',
|
|
153
|
+
sessionId: this.currentSessionId,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Start heartbeat with dual ping: protocol-level + app-level
|
|
134
157
|
this.heartbeatTimer = setInterval(() => {
|
|
158
|
+
if (!this.pongReceived) {
|
|
159
|
+
// Previous ping never got a pong - connection is dead
|
|
160
|
+
this.log('No pong received, connection dead - forcing reconnect');
|
|
161
|
+
this.ws?.terminate(); // terminate, not close - skip graceful shutdown
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.pongReceived = false;
|
|
165
|
+
// Protocol-level ping (handled by ws library)
|
|
166
|
+
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
167
|
+
this.ws.ping();
|
|
168
|
+
}
|
|
169
|
+
// App-level heartbeat (for relay server to track device presence)
|
|
135
170
|
this.sendMessage({ type: 'heartbeat' });
|
|
136
171
|
}, HEARTBEAT_INTERVAL);
|
|
137
172
|
}
|
|
@@ -166,6 +201,9 @@ class AgentDaemon {
|
|
|
166
201
|
case 'error':
|
|
167
202
|
this.log('Server error:', message.error);
|
|
168
203
|
break;
|
|
204
|
+
case 'list_dirs':
|
|
205
|
+
void this.handleListDirectories(message.requestId, message.path);
|
|
206
|
+
break;
|
|
169
207
|
}
|
|
170
208
|
}
|
|
171
209
|
/**
|
|
@@ -173,27 +211,31 @@ class AgentDaemon {
|
|
|
173
211
|
*/
|
|
174
212
|
handleSessionStart(sessionId, cwd) {
|
|
175
213
|
this.log(`Session start request: ${sessionId}${cwd ? ` (cwd: ${cwd})` : ''}`);
|
|
176
|
-
//
|
|
214
|
+
// If reconnecting to the SAME session, reuse existing PTY
|
|
215
|
+
if (this.ptyRunner && this.currentSessionId === sessionId) {
|
|
216
|
+
this.log(`Reattaching to existing PTY for session ${sessionId}`);
|
|
217
|
+
this.sendMessage({ type: 'ready', sessionId });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// Different session - kill old PTY if any
|
|
177
221
|
if (this.ptyRunner) {
|
|
222
|
+
this.log(`Killing old PTY for session ${this.currentSessionId}`);
|
|
178
223
|
this.ptyRunner.kill();
|
|
179
224
|
this.ptyRunner = null;
|
|
180
225
|
}
|
|
181
226
|
this.currentSessionId = sessionId;
|
|
227
|
+
const resolved = this.resolveSessionCwd(cwd);
|
|
228
|
+
this.currentSessionCwd = resolved.cwd;
|
|
229
|
+
this.ptyRestartAttempts = 0;
|
|
182
230
|
// Reset auto-continue state
|
|
183
231
|
this.outputBuffer = '';
|
|
184
232
|
this.currentSessionName = null;
|
|
185
233
|
this.isAutoClearInProgress = false;
|
|
186
234
|
this.lastContextWallTime = 0;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
onData: (data) => this.sendOutput(data),
|
|
192
|
-
onExit: (code) => this.handlePTYExit(code),
|
|
193
|
-
cwd: cwd || process.env.HOME, // Use specified cwd or fall back to home
|
|
194
|
-
verbose: this.config.verbose,
|
|
195
|
-
});
|
|
196
|
-
this.ptyRunner.start();
|
|
235
|
+
this.startSessionPty(resolved.cwd);
|
|
236
|
+
if (resolved.warning) {
|
|
237
|
+
this.sendOutput(`\r\n[ekkOS] ${resolved.warning}\r\n`);
|
|
238
|
+
}
|
|
197
239
|
// Notify server that PTY is ready
|
|
198
240
|
this.sendMessage({
|
|
199
241
|
type: 'ready',
|
|
@@ -208,11 +250,13 @@ class AgentDaemon {
|
|
|
208
250
|
return; // Not our session
|
|
209
251
|
}
|
|
210
252
|
this.log('Session ended');
|
|
253
|
+
this.currentSessionId = null;
|
|
254
|
+
this.currentSessionCwd = undefined;
|
|
255
|
+
this.ptyRestartAttempts = 0;
|
|
211
256
|
if (this.ptyRunner) {
|
|
212
257
|
this.ptyRunner.kill();
|
|
213
258
|
this.ptyRunner = null;
|
|
214
259
|
}
|
|
215
|
-
this.currentSessionId = null;
|
|
216
260
|
}
|
|
217
261
|
/**
|
|
218
262
|
* Handle input from browser
|
|
@@ -230,17 +274,162 @@ class AgentDaemon {
|
|
|
230
274
|
this.ptyRunner.resize(cols, rows);
|
|
231
275
|
}
|
|
232
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Handle directory listing request from relay for project browsing UI.
|
|
279
|
+
*/
|
|
280
|
+
async handleListDirectories(requestId, requestedPath) {
|
|
281
|
+
if (!requestId)
|
|
282
|
+
return;
|
|
283
|
+
try {
|
|
284
|
+
const resolvedPath = this.resolveBrowsePath(requestedPath);
|
|
285
|
+
if (resolvedPath === '__WINDOWS_ROOT__') {
|
|
286
|
+
this.sendMessage({
|
|
287
|
+
type: 'list_dirs_result',
|
|
288
|
+
requestId,
|
|
289
|
+
path: '/',
|
|
290
|
+
parentPath: null,
|
|
291
|
+
entries: this.getWindowsRootEntries(),
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const dirents = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
|
296
|
+
const entries = dirents
|
|
297
|
+
.filter((entry) => entry.isDirectory())
|
|
298
|
+
.map((entry) => ({
|
|
299
|
+
name: entry.name,
|
|
300
|
+
path: path.join(resolvedPath, entry.name),
|
|
301
|
+
}))
|
|
302
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
303
|
+
.slice(0, 300);
|
|
304
|
+
this.sendMessage({
|
|
305
|
+
type: 'list_dirs_result',
|
|
306
|
+
requestId,
|
|
307
|
+
path: resolvedPath,
|
|
308
|
+
parentPath: this.getParentPath(resolvedPath),
|
|
309
|
+
entries,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
const message = error instanceof Error ? error.message : 'Failed to read directory';
|
|
314
|
+
this.sendMessage({
|
|
315
|
+
type: 'list_dirs_result',
|
|
316
|
+
requestId,
|
|
317
|
+
error: message,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
resolveBrowsePath(requestedPath) {
|
|
322
|
+
const homePath = os.homedir();
|
|
323
|
+
const raw = requestedPath?.trim() ?? '';
|
|
324
|
+
if (process.platform === 'win32' && (raw === '' || raw === '/' || raw === '\\')) {
|
|
325
|
+
return '__WINDOWS_ROOT__';
|
|
326
|
+
}
|
|
327
|
+
if (!raw) {
|
|
328
|
+
return homePath;
|
|
329
|
+
}
|
|
330
|
+
if (raw === '~') {
|
|
331
|
+
return homePath;
|
|
332
|
+
}
|
|
333
|
+
if (raw.startsWith('~/') || raw.startsWith('~\\')) {
|
|
334
|
+
return path.resolve(path.join(homePath, raw.slice(2)));
|
|
335
|
+
}
|
|
336
|
+
return path.resolve(raw);
|
|
337
|
+
}
|
|
338
|
+
getParentPath(currentPath) {
|
|
339
|
+
if (process.platform === 'win32' && /^[A-Za-z]:\\?$/.test(currentPath)) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const parsed = path.parse(currentPath);
|
|
343
|
+
if (currentPath === parsed.root) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
const parent = path.dirname(currentPath);
|
|
347
|
+
return parent === currentPath ? null : parent;
|
|
348
|
+
}
|
|
349
|
+
getWindowsRootEntries() {
|
|
350
|
+
const entries = [];
|
|
351
|
+
for (let code = 65; code <= 90; code++) {
|
|
352
|
+
const drive = String.fromCharCode(code);
|
|
353
|
+
const drivePath = `${drive}:\\`;
|
|
354
|
+
if (fs.existsSync(drivePath)) {
|
|
355
|
+
entries.push({
|
|
356
|
+
name: `${drive}:`,
|
|
357
|
+
path: drivePath,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return entries;
|
|
362
|
+
}
|
|
363
|
+
resolveSessionCwd(cwd) {
|
|
364
|
+
const homePath = os.homedir();
|
|
365
|
+
const raw = cwd?.trim();
|
|
366
|
+
if (!raw) {
|
|
367
|
+
return { cwd: homePath };
|
|
368
|
+
}
|
|
369
|
+
let expanded = raw;
|
|
370
|
+
if (raw === '~') {
|
|
371
|
+
expanded = homePath;
|
|
372
|
+
}
|
|
373
|
+
else if (raw.startsWith('~/') || raw.startsWith('~\\')) {
|
|
374
|
+
expanded = path.join(homePath, raw.slice(2));
|
|
375
|
+
}
|
|
376
|
+
const resolvedPath = path.resolve(expanded);
|
|
377
|
+
try {
|
|
378
|
+
const stat = fs.statSync(resolvedPath);
|
|
379
|
+
if (stat.isDirectory()) {
|
|
380
|
+
return { cwd: resolvedPath };
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
cwd: homePath,
|
|
384
|
+
warning: `Requested path is not a directory: ${raw}. Falling back to ${homePath}.`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return {
|
|
389
|
+
cwd: homePath,
|
|
390
|
+
warning: `Requested path not found: ${raw}. Falling back to ${homePath}.`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
233
394
|
/**
|
|
234
395
|
* Handle PTY exit
|
|
235
396
|
*/
|
|
236
397
|
handlePTYExit(code) {
|
|
237
398
|
this.log(`PTY exited with code ${code}`);
|
|
399
|
+
const elapsed = Date.now() - this.sessionStartedAt;
|
|
400
|
+
const hasActiveSession = this.running && this.currentSessionId !== null;
|
|
401
|
+
const quickExit = elapsed < this.QUICK_EXIT_MS;
|
|
402
|
+
if (hasActiveSession && code !== 0) {
|
|
403
|
+
this.ptyRestartAttempts = quickExit ? this.ptyRestartAttempts + 1 : 1;
|
|
404
|
+
const attempt = this.ptyRestartAttempts;
|
|
405
|
+
const sessionId = this.currentSessionId;
|
|
406
|
+
const restartDelay = quickExit
|
|
407
|
+
? Math.min(600 * attempt, 5000)
|
|
408
|
+
: 1000;
|
|
409
|
+
const reason = quickExit ? 'crashed' : 'exited unexpectedly';
|
|
410
|
+
this.log(`PTY ${reason} (${elapsed}ms, code ${code}). Restarting in ${restartDelay}ms (attempt ${attempt})...`);
|
|
411
|
+
this.ptyRunner = null;
|
|
412
|
+
this.sendOutput(`\r\n[ekkOS] Terminal ${reason} (code ${code}). Restarting in ${Math.max(1, Math.ceil(restartDelay / 1000))}s...\r\n`);
|
|
413
|
+
setTimeout(() => {
|
|
414
|
+
if (!this.running || !sessionId || this.currentSessionId !== sessionId || this.ptyRunner) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
this.startSessionPty(this.currentSessionCwd);
|
|
418
|
+
this.sendMessage({
|
|
419
|
+
type: 'ready',
|
|
420
|
+
sessionId,
|
|
421
|
+
});
|
|
422
|
+
}, restartDelay);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
238
425
|
this.sendMessage({
|
|
239
426
|
type: 'session_end',
|
|
240
427
|
sessionId: this.currentSessionId || undefined,
|
|
241
428
|
});
|
|
242
429
|
this.ptyRunner = null;
|
|
243
430
|
this.currentSessionId = null;
|
|
431
|
+
this.currentSessionCwd = undefined;
|
|
432
|
+
this.ptyRestartAttempts = 0;
|
|
244
433
|
}
|
|
245
434
|
/**
|
|
246
435
|
* Send PTY output to server (with auto-continue detection)
|
|
@@ -318,8 +507,39 @@ class AgentDaemon {
|
|
|
318
507
|
sleep(ms) {
|
|
319
508
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
320
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Start or restart PTY for the current session.
|
|
512
|
+
*/
|
|
513
|
+
startSessionPty(cwd) {
|
|
514
|
+
this.sessionStartedAt = Date.now();
|
|
515
|
+
// Run through the same Node runtime as the daemon to avoid PATH/shebang drift
|
|
516
|
+
// (e.g. Homebrew node upgrades breaking dylib links).
|
|
517
|
+
const fallbackEntrypoint = path.resolve(__dirname, '..', 'index.js');
|
|
518
|
+
const argvEntrypoint = process.argv[1];
|
|
519
|
+
const cliEntrypoint = argvEntrypoint && !argvEntrypoint.includes('/node_modules/.bin/')
|
|
520
|
+
? argvEntrypoint
|
|
521
|
+
: fallbackEntrypoint;
|
|
522
|
+
// Start PTY with `node <cli> run -b` (skip -d to avoid double spawn).
|
|
523
|
+
this.ptyRunner = new pty_runner_1.PTYRunner({
|
|
524
|
+
command: process.execPath,
|
|
525
|
+
args: [cliEntrypoint, 'run', '-b'],
|
|
526
|
+
onData: (data) => this.sendOutput(data),
|
|
527
|
+
onExit: (code) => this.handlePTYExit(code),
|
|
528
|
+
cwd: cwd || process.env.HOME, // Use specified cwd or fall back to home
|
|
529
|
+
env: {
|
|
530
|
+
EKKOS_REMOTE_SESSION: '1',
|
|
531
|
+
EKKOS_NO_SPLASH: '1',
|
|
532
|
+
},
|
|
533
|
+
verbose: this.config.verbose,
|
|
534
|
+
});
|
|
535
|
+
this.ptyRunner.start();
|
|
536
|
+
}
|
|
321
537
|
/**
|
|
322
538
|
* Handle WebSocket close
|
|
539
|
+
*
|
|
540
|
+
* CRITICAL: Do NOT kill the PTY here. The PTY must survive WebSocket
|
|
541
|
+
* disconnects so users can reconnect to their existing session.
|
|
542
|
+
* The PTY is only killed on explicit session_end or daemon stop.
|
|
323
543
|
*/
|
|
324
544
|
handleClose(code, reason) {
|
|
325
545
|
this.log(`Disconnected: ${code} ${reason}`);
|
|
@@ -328,20 +548,29 @@ class AgentDaemon {
|
|
|
328
548
|
clearInterval(this.heartbeatTimer);
|
|
329
549
|
this.heartbeatTimer = null;
|
|
330
550
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
this.
|
|
334
|
-
|
|
551
|
+
if (this.pongTimer) {
|
|
552
|
+
clearTimeout(this.pongTimer);
|
|
553
|
+
this.pongTimer = null;
|
|
554
|
+
}
|
|
555
|
+
// PTY stays alive - user can reconnect to existing session
|
|
556
|
+
// Only log if there's an active session being preserved
|
|
557
|
+
if (this.ptyRunner && this.currentSessionId) {
|
|
558
|
+
this.log(`Preserving PTY session ${this.currentSessionId} across disconnect`);
|
|
335
559
|
}
|
|
336
|
-
this.currentSessionId = null;
|
|
337
560
|
// Reconnect if still running
|
|
338
561
|
if (this.running) {
|
|
339
|
-
|
|
340
|
-
this.reconnectAttempt++;
|
|
341
|
-
this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})...`);
|
|
342
|
-
setTimeout(() => this.connect(), delay);
|
|
562
|
+
this.scheduleReconnect();
|
|
343
563
|
}
|
|
344
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* Schedule reconnection with exponential backoff
|
|
567
|
+
*/
|
|
568
|
+
scheduleReconnect() {
|
|
569
|
+
const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
|
570
|
+
this.reconnectAttempt++;
|
|
571
|
+
this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})...`);
|
|
572
|
+
setTimeout(() => this.connect(), delay);
|
|
573
|
+
}
|
|
345
574
|
/**
|
|
346
575
|
* Handle WebSocket error
|
|
347
576
|
*/
|
|
@@ -365,13 +594,9 @@ class AgentDaemon {
|
|
|
365
594
|
if (this.config.verbose) {
|
|
366
595
|
console.log(message);
|
|
367
596
|
}
|
|
368
|
-
//
|
|
597
|
+
// Log to file with rotation
|
|
369
598
|
try {
|
|
370
|
-
|
|
371
|
-
if (!fs.existsSync(logDir)) {
|
|
372
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
373
|
-
}
|
|
374
|
-
fs.appendFileSync(path.join(logDir, 'agent.log'), message + '\n');
|
|
599
|
+
(0, log_rotate_1.appendLog)(this.logPath, message);
|
|
375
600
|
}
|
|
376
601
|
catch {
|
|
377
602
|
// Ignore log errors
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health check for ekkOS agent daemon
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Service is installed and loaded
|
|
6
|
+
* - Process is running
|
|
7
|
+
* - Recent activity in logs
|
|
8
|
+
* - Network connectivity to relay server
|
|
9
|
+
*/
|
|
10
|
+
interface HealthStatus {
|
|
11
|
+
ok: boolean;
|
|
12
|
+
service: {
|
|
13
|
+
installed: boolean;
|
|
14
|
+
loaded: boolean;
|
|
15
|
+
running: boolean;
|
|
16
|
+
pid?: number;
|
|
17
|
+
};
|
|
18
|
+
logs: {
|
|
19
|
+
lastActivity?: Date;
|
|
20
|
+
recentErrors: string[];
|
|
21
|
+
};
|
|
22
|
+
relay: {
|
|
23
|
+
reachable: boolean;
|
|
24
|
+
lastError?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check agent daemon health
|
|
29
|
+
*/
|
|
30
|
+
export declare function checkDaemonHealth(): Promise<HealthStatus>;
|
|
31
|
+
/**
|
|
32
|
+
* Format health status for console output
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatHealthStatus(status: HealthStatus): string;
|
|
35
|
+
export {};
|