kumo-cli 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -31
- package/dist/auth/credentialsStore.js +41 -0
- package/dist/auth/credentialsStore.js.map +1 -0
- package/dist/claude/generateHookSettings.js +23 -0
- package/dist/claude/generateHookSettings.js.map +1 -0
- package/dist/claude/hookServer.js +33 -0
- package/dist/claude/hookServer.js.map +1 -0
- package/dist/claude/sessionScanner.js +250 -0
- package/dist/claude/sessionScanner.js.map +1 -0
- package/dist/config.js +40 -0
- package/dist/config.js.map +1 -0
- package/dist/core/config.js +48 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/loadDotEnv.js +46 -0
- package/dist/core/loadDotEnv.js.map +1 -0
- package/dist/core/logger.js +29 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/handlers/messageRouter.js +225 -0
- package/dist/handlers/messageRouter.js.map +1 -0
- package/dist/http/httpClient.js +41 -0
- package/dist/http/httpClient.js.map +1 -0
- package/dist/index.js +120 -1364
- package/dist/index.js.map +1 -1
- package/dist/logger.js +29 -0
- package/dist/logger.js.map +1 -0
- package/dist/pty/ansiCodes.js +14 -0
- package/dist/pty/ansiCodes.js.map +1 -0
- package/dist/pty/ptyMenuParser.js +357 -0
- package/dist/pty/ptyMenuParser.js.map +1 -0
- package/dist/pty/ptySnapshotRenderer.js +71 -0
- package/dist/pty/ptySnapshotRenderer.js.map +1 -0
- package/dist/pty/spawn.js +77 -0
- package/dist/pty/spawn.js.map +1 -0
- package/dist/pty/terminalManager.js +66 -0
- package/dist/pty/terminalManager.js.map +1 -0
- package/dist/services/claudeAdapterProxy.js +104 -0
- package/dist/services/claudeAdapterProxy.js.map +1 -0
- package/dist/services/claudeService.js +239 -0
- package/dist/services/claudeService.js.map +1 -0
- package/dist/services/claudeService.test.js +39 -0
- package/dist/services/claudeService.test.js.map +1 -0
- package/dist/services/effortService.js +89 -0
- package/dist/services/effortService.js.map +1 -0
- package/dist/services/fileService.js +129 -0
- package/dist/services/fileService.js.map +1 -0
- package/dist/services/modelService.js +115 -0
- package/dist/services/modelService.js.map +1 -0
- package/dist/services/modelService.test.js +76 -0
- package/dist/services/modelService.test.js.map +1 -0
- package/dist/services/pairingService.js +129 -0
- package/dist/services/pairingService.js.map +1 -0
- package/dist/services/sessionService.js +168 -0
- package/dist/services/sessionService.js.map +1 -0
- package/dist/services/tunnelService.js +47 -0
- package/dist/services/tunnelService.js.map +1 -0
- package/dist/sessionScanner.js +130 -4
- package/dist/sessionScanner.js.map +1 -1
- package/dist/snapshotScanner.js +3 -1
- package/dist/snapshotScanner.js.map +1 -1
- package/dist/transport/directWsServer.js +135 -0
- package/dist/transport/directWsServer.js.map +1 -0
- package/dist/transport/wsClient.js +87 -0
- package/dist/transport/wsClient.js.map +1 -0
- package/dist/utils/ignorePaths.js +54 -0
- package/dist/utils/ignorePaths.js.map +1 -0
- package/dist/utils/ptyBuffer.js +46 -0
- package/dist/utils/ptyBuffer.js.map +1 -0
- package/dist/utils/safePath.js +43 -0
- package/dist/utils/safePath.js.map +1 -0
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,88 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import WebSocket from 'ws';
|
|
3
|
-
import http from 'http';
|
|
4
|
-
import https from 'https';
|
|
5
2
|
import fs from 'node:fs';
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
function
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
3
|
+
import { startHookServer } from './claude/hookServer.js';
|
|
4
|
+
import { generateHookSettingsFile } from './claude/generateHookSettings.js';
|
|
5
|
+
import { SessionScanner } from './claude/sessionScanner.js';
|
|
6
|
+
import { TerminalManager } from './pty/terminalManager.js';
|
|
7
|
+
import { loadIgnoredNames } from './utils/ignorePaths.js';
|
|
8
|
+
import { loadCredentials, saveCredentials, clearCredentials, credentialsPath } from './auth/credentialsStore.js';
|
|
9
|
+
import { loadDotEnv } from './core/loadDotEnv.js';
|
|
10
|
+
import { log } from './core/logger.js';
|
|
11
|
+
import { SERVER_HTTP, FILE_WATCH_DEBOUNCE_MS } from './core/config.js';
|
|
12
|
+
import { ModelService } from './services/modelService.js';
|
|
13
|
+
import { EffortService } from './services/effortService.js';
|
|
14
|
+
import { ClaudeService } from './services/claudeService.js';
|
|
15
|
+
import * as sessionService from './services/sessionService.js';
|
|
16
|
+
import { pairFlow, checkBackendReachable, verifyCredentials } from './services/pairingService.js';
|
|
17
|
+
import { WsClient } from './transport/wsClient.js';
|
|
18
|
+
import { createMessageRouter } from './handlers/messageRouter.js';
|
|
19
|
+
function extractEnvFileArg(argv) {
|
|
20
|
+
const cliArgs = [];
|
|
21
|
+
let envFilePath = null;
|
|
22
|
+
for (let idx = 0; idx < argv.length; idx += 1) {
|
|
23
|
+
const arg = argv[idx];
|
|
24
|
+
if (arg === '--env-file') {
|
|
25
|
+
const nextArg = argv[idx + 1];
|
|
26
|
+
if (!nextArg) {
|
|
27
|
+
throw new Error('[kumo-cli] missing value for --env-file');
|
|
28
|
+
}
|
|
29
|
+
envFilePath = nextArg;
|
|
30
|
+
idx += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (arg.startsWith('--env-file=')) {
|
|
34
|
+
envFilePath = arg.slice('--env-file='.length);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
cliArgs.push(arg);
|
|
38
|
+
}
|
|
39
|
+
return { envFilePath, cliArgs };
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
u.protocol = 'ws:';
|
|
38
|
-
if (u.protocol === 'https:')
|
|
39
|
-
u.protocol = 'wss:';
|
|
40
|
-
if (u.pathname === '/' || u.pathname === '')
|
|
41
|
-
u.pathname = '/ws';
|
|
42
|
-
return u.toString().replace(/\/$/, '');
|
|
41
|
+
const { envFilePath, cliArgs: bootArgs } = extractEnvFileArg(process.argv.slice(2));
|
|
42
|
+
if (envFilePath) {
|
|
43
|
+
loadDotEnv(envFilePath);
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
const SERVER_HTTP = normalizeServerHttpUrl(process.env.KUMO_SERVER_HTTP_URL ?? serverUrl ?? 'https://kumocli.com');
|
|
46
|
-
const SERVER_WS = normalizeServerWsUrl(process.env.KUMO_SERVER_WS_URL ?? serverUrl ?? 'wss://kumocli.com/ws');
|
|
47
|
-
const SERVER_PORT = parseInt(process.env.KUMO_PORT ?? '443', 10);
|
|
48
|
-
let ws = null;
|
|
49
|
-
let claudeProcess = null;
|
|
50
|
-
let spawnCleanup = null;
|
|
51
|
-
let scanner = null;
|
|
52
|
-
let reconnectTimer = null;
|
|
53
|
-
let claudeSettingsPath = '';
|
|
54
|
-
let claudeArgs = [];
|
|
45
|
+
let activeCredentials = null;
|
|
55
46
|
let currentSessionId = null;
|
|
56
|
-
const SNAPSHOT_DIR = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..', 'claude-data-snapshot');
|
|
57
|
-
let snapshotScanner = null;
|
|
58
47
|
let pendingPrompt = null;
|
|
59
|
-
let
|
|
60
|
-
let
|
|
61
|
-
function safePath(requestedPath) {
|
|
62
|
-
const projectRoot = process.cwd();
|
|
63
|
-
const resolved = path.resolve(projectRoot, path.normalize(requestedPath || '.'));
|
|
64
|
-
if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
|
|
65
|
-
throw new Error('path traversal blocked');
|
|
66
|
-
}
|
|
67
|
-
return resolved;
|
|
68
|
-
}
|
|
69
|
-
function copyRecursive(src, dest) {
|
|
70
|
-
const stat = fs.statSync(src);
|
|
71
|
-
if (stat.isDirectory()) {
|
|
72
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
73
|
-
for (const child of fs.readdirSync(src)) {
|
|
74
|
-
copyRecursive(path.join(src, child), path.join(dest, child));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
fs.copyFileSync(src, dest);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
48
|
+
let startScannerAtEnd = false;
|
|
49
|
+
let scanner = null;
|
|
81
50
|
let fileWatcher = null;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
function startFileWatcher() {
|
|
51
|
+
let pairedResolved = false;
|
|
52
|
+
let pairedResolve = null;
|
|
53
|
+
function startFileWatcher(send) {
|
|
86
54
|
if (fileWatcher) {
|
|
87
55
|
try {
|
|
88
56
|
fileWatcher.close();
|
|
@@ -91,20 +59,22 @@ function startFileWatcher() {
|
|
|
91
59
|
fileWatcher = null;
|
|
92
60
|
}
|
|
93
61
|
const projectRoot = process.cwd();
|
|
62
|
+
const ignored = loadIgnoredNames(projectRoot);
|
|
63
|
+
const debounceMap = new Map();
|
|
94
64
|
try {
|
|
95
65
|
fileWatcher = fs.watch(projectRoot, { recursive: true }, (event, filename) => {
|
|
96
66
|
if (!filename)
|
|
97
67
|
return;
|
|
98
68
|
const normalized = filename.replace(/\\/g, '/');
|
|
99
69
|
const parts = normalized.split('/');
|
|
100
|
-
if (parts.some(p =>
|
|
70
|
+
if (parts.some((p) => ignored.has(p) || p.startsWith('.')))
|
|
101
71
|
return;
|
|
102
|
-
const prev =
|
|
72
|
+
const prev = debounceMap.get(normalized);
|
|
103
73
|
if (prev)
|
|
104
74
|
clearTimeout(prev);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
75
|
+
debounceMap.set(normalized, setTimeout(() => {
|
|
76
|
+
debounceMap.delete(normalized);
|
|
77
|
+
send({ type: 'file_changed', path: normalized, event });
|
|
108
78
|
log(`[watcher] ${event}: ${normalized}`);
|
|
109
79
|
}, FILE_WATCH_DEBOUNCE_MS));
|
|
110
80
|
});
|
|
@@ -114,1304 +84,54 @@ function startFileWatcher() {
|
|
|
114
84
|
log(`[watcher] failed to start: ${e}`);
|
|
115
85
|
}
|
|
116
86
|
}
|
|
117
|
-
let autoAcceptedApiKeyDialog = false;
|
|
118
|
-
let isRespawning = false;
|
|
119
|
-
let startScannerAtEnd = false;
|
|
120
|
-
let sessionSwitchCounter = 0;
|
|
121
|
-
const terminalManager = new TerminalManager((terminalId, data) => {
|
|
122
|
-
sendToServer({ type: 'terminal_output', terminalId, data });
|
|
123
|
-
}, (terminalId, exitCode) => {
|
|
124
|
-
log(`[terminal] ${terminalId} exited with code ${exitCode}`);
|
|
125
|
-
sendToServer({ type: 'terminal_closed', terminalId, exitCode });
|
|
126
|
-
});
|
|
127
|
-
let pairedResolve = null;
|
|
128
|
-
const pairedPromise = new Promise((resolve) => { pairedResolve = resolve; });
|
|
129
|
-
const PROJECT_TMP_DIR = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'tmp');
|
|
130
|
-
const LOGS_DIR = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'logs');
|
|
131
|
-
if (!fs.existsSync(PROJECT_TMP_DIR))
|
|
132
|
-
fs.mkdirSync(PROJECT_TMP_DIR, { recursive: true });
|
|
133
|
-
if (!fs.existsSync(LOGS_DIR))
|
|
134
|
-
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
135
|
-
const sessionStart = new Date().toISOString().replace(/[:.]/g, '-');
|
|
136
|
-
const debugLog = fs.createWriteStream(path.join(PROJECT_TMP_DIR, 'kumo-pty-debug.log'), { flags: 'w' });
|
|
137
|
-
const eventLog = fs.createWriteStream(path.join(LOGS_DIR, `cli-${sessionStart}.log`), { flags: 'w' });
|
|
138
|
-
function log(msg) {
|
|
139
|
-
const line = `[${new Date().toISOString()}] ${msg}`;
|
|
140
|
-
eventLog.write(line + '\n');
|
|
141
|
-
}
|
|
142
|
-
let ptyBuffer = '';
|
|
143
|
-
let ptyFlushTimer = null;
|
|
144
|
-
const PTY_FLUSH_MS = 120;
|
|
145
|
-
const PTY_MAX_CHUNK = 8000;
|
|
146
|
-
let ptySnapshotRenderer = new PtySnapshotRenderer(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
|
|
147
|
-
function sanitizePtyPreview(data) {
|
|
148
|
-
return data
|
|
149
|
-
.replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
|
|
150
|
-
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
|
|
151
|
-
.replace(/\s+/g, ' ')
|
|
152
|
-
.slice(0, 100);
|
|
153
|
-
}
|
|
154
|
-
function flushPtyUpdates() {
|
|
155
|
-
if (!ptyBuffer)
|
|
156
|
-
return;
|
|
157
|
-
ptyBuffer = '';
|
|
158
|
-
ptySnapshotRenderer.flushSnapshot();
|
|
159
|
-
}
|
|
160
|
-
function queuePtyData(chunk) {
|
|
161
|
-
ptyBuffer += chunk;
|
|
162
|
-
ptySnapshotRenderer.feed(chunk);
|
|
163
|
-
if (ptyBuffer.length >= PTY_MAX_CHUNK) {
|
|
164
|
-
if (ptyFlushTimer) {
|
|
165
|
-
clearTimeout(ptyFlushTimer);
|
|
166
|
-
ptyFlushTimer = null;
|
|
167
|
-
}
|
|
168
|
-
flushPtyUpdates();
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (!ptyFlushTimer) {
|
|
172
|
-
ptyFlushTimer = setTimeout(() => {
|
|
173
|
-
ptyFlushTimer = null;
|
|
174
|
-
flushPtyUpdates();
|
|
175
|
-
}, PTY_FLUSH_MS);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
let pingInterval = null;
|
|
179
|
-
let menuIdCounter = 0;
|
|
180
|
-
const menuParser = new PtyMenuParser((element) => {
|
|
181
|
-
switch (element.type) {
|
|
182
|
-
case 'selection':
|
|
183
|
-
if (element.options.length === 0) {
|
|
184
|
-
log(`[parser] ignoring empty menu`);
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
if (element.title.includes('Bypass Permissions')) {
|
|
188
|
-
const yesIndex = element.options.findIndex((o) => o.label.trim().toLowerCase().includes('yes'));
|
|
189
|
-
if (yesIndex >= 0 && claudeProcess) {
|
|
190
|
-
const delta = yesIndex - element.selectedIndex;
|
|
191
|
-
const key = delta < 0 ? '\x1b[A' : '\x1b[B';
|
|
192
|
-
for (let i = 0; i < Math.abs(delta); i++) {
|
|
193
|
-
claudeProcess.write(key);
|
|
194
|
-
}
|
|
195
|
-
claudeProcess.write('\r');
|
|
196
|
-
log(`[parser] auto-accepted bypass permissions dialog`);
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (!autoAcceptedApiKeyDialog && (element.title.includes('Detected a custom API key') || element.title.includes('Do you want to use this API key'))) {
|
|
201
|
-
const yesIndex = element.options.findIndex((o) => o.label.trim().toLowerCase() === 'yes');
|
|
202
|
-
if (yesIndex >= 0 && claudeProcess) {
|
|
203
|
-
autoAcceptedApiKeyDialog = true;
|
|
204
|
-
const delta = yesIndex - element.selectedIndex;
|
|
205
|
-
const key = delta < 0 ? '\x1b[A' : '\x1b[B';
|
|
206
|
-
for (let i = 0; i < Math.abs(delta); i++) {
|
|
207
|
-
claudeProcess.write(key);
|
|
208
|
-
}
|
|
209
|
-
claudeProcess.write('\r');
|
|
210
|
-
log(`[parser] auto-accepted custom api key dialog`);
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
menuIdCounter++;
|
|
215
|
-
const id = `menu-${menuIdCounter}`;
|
|
216
|
-
log(`[parser] menu detected: "${element.title}" (${element.options.length} opts, selected: ${element.selectedIndex})`);
|
|
217
|
-
log(`[parser] options: ${JSON.stringify(element.options)}`);
|
|
218
|
-
sendToServer({
|
|
219
|
-
type: 'prompt_options',
|
|
220
|
-
id,
|
|
221
|
-
title: element.title,
|
|
222
|
-
description: element.description,
|
|
223
|
-
options: element.options,
|
|
224
|
-
selectedIndex: element.selectedIndex,
|
|
225
|
-
hint: element.hint,
|
|
226
|
-
footer: element.footer,
|
|
227
|
-
});
|
|
228
|
-
break;
|
|
229
|
-
case 'input':
|
|
230
|
-
log(`[parser] input: "${element.label}" = "${element.value}"`);
|
|
231
|
-
sendToServer({
|
|
232
|
-
type: 'text_input',
|
|
233
|
-
label: element.label,
|
|
234
|
-
value: element.value,
|
|
235
|
-
placeholder: element.placeholder,
|
|
236
|
-
cursorPosition: element.cursorPosition,
|
|
237
|
-
});
|
|
238
|
-
break;
|
|
239
|
-
case 'notification':
|
|
240
|
-
log(`[parser] notification: [${element.level}] ${element.message}`);
|
|
241
|
-
sendToServer({
|
|
242
|
-
type: 'notification',
|
|
243
|
-
level: element.level,
|
|
244
|
-
message: element.message,
|
|
245
|
-
});
|
|
246
|
-
break;
|
|
247
|
-
case 'status':
|
|
248
|
-
log(`[parser] status: model=${element.model}, effort=${element.effort}, logged=${element.loggedIn}`);
|
|
249
|
-
sendToServer({
|
|
250
|
-
type: 'cli_status',
|
|
251
|
-
loggedIn: element.loggedIn,
|
|
252
|
-
model: element.model,
|
|
253
|
-
effort: element.effort,
|
|
254
|
-
workingDir: element.workingDir,
|
|
255
|
-
});
|
|
256
|
-
break;
|
|
257
|
-
case 'session':
|
|
258
|
-
log(`[parser] session: ${element.sessionId}`);
|
|
259
|
-
sendToServer({
|
|
260
|
-
type: 'session_info',
|
|
261
|
-
sessionId: element.sessionId,
|
|
262
|
-
resumeCommand: element.resumeCommand,
|
|
263
|
-
});
|
|
264
|
-
break;
|
|
265
|
-
case 'prompt':
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
function sendToServer(data) {
|
|
270
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
271
|
-
lastWsSendAt = Date.now();
|
|
272
|
-
ws.send(JSON.stringify(data));
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
function dispatchPrompt(text) {
|
|
276
|
-
if (!claudeProcess)
|
|
277
|
-
return;
|
|
278
|
-
if (text.trim() === '/login') {
|
|
279
|
-
menuParser.reset();
|
|
280
|
-
}
|
|
281
|
-
writeToProcess(claudeProcess, text);
|
|
282
|
-
}
|
|
283
|
-
function handleSelectOption(value) {
|
|
284
|
-
if (!claudeProcess) {
|
|
285
|
-
log(`[select_option] no claude process!`);
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
log(`[select_option] sending: "${value}" (without Enter)`);
|
|
289
|
-
menuParser.reset();
|
|
290
|
-
claudeProcess.write(value);
|
|
291
|
-
if (pendingSelectNudgeTimer)
|
|
292
|
-
clearTimeout(pendingSelectNudgeTimer);
|
|
293
|
-
const baseline = lastWsSendAt;
|
|
294
|
-
pendingSelectNudgeTimer = setTimeout(() => {
|
|
295
|
-
pendingSelectNudgeTimer = null;
|
|
296
|
-
if (!claudeProcess)
|
|
297
|
-
return;
|
|
298
|
-
if (lastWsSendAt === baseline)
|
|
299
|
-
nudgeResize();
|
|
300
|
-
}, 1000);
|
|
301
|
-
}
|
|
302
|
-
function nudgeResize() {
|
|
303
|
-
if (!claudeProcess)
|
|
304
|
-
return;
|
|
305
|
-
const cols = process.stdout.columns ?? 120;
|
|
306
|
-
const rows = process.stdout.rows ?? 30;
|
|
307
|
-
const deltaCols = 1;
|
|
308
|
-
const backDelayMs = 5;
|
|
309
|
-
try {
|
|
310
|
-
claudeProcess.resize(cols + deltaCols, rows);
|
|
311
|
-
setTimeout(() => {
|
|
312
|
-
try {
|
|
313
|
-
claudeProcess?.resize(cols, rows);
|
|
314
|
-
}
|
|
315
|
-
catch { }
|
|
316
|
-
}, backDelayMs);
|
|
317
|
-
}
|
|
318
|
-
catch { }
|
|
319
|
-
}
|
|
320
|
-
function removeResumeArgs(args) {
|
|
321
|
-
const result = [];
|
|
322
|
-
for (let i = 0; i < args.length; i++) {
|
|
323
|
-
if (args[i] === '--resume' || args[i] === '-r') {
|
|
324
|
-
i++;
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
result.push(args[i]);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return result;
|
|
331
|
-
}
|
|
332
|
-
// strip any existing --model / --model=... / -m flag pairs
|
|
333
|
-
function removeModelArgs(args) {
|
|
334
|
-
const result = [];
|
|
335
|
-
for (let i = 0; i < args.length; i++) {
|
|
336
|
-
const a = args[i];
|
|
337
|
-
if (a === '--model' || a === '-m') {
|
|
338
|
-
i++;
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
341
|
-
if (a.startsWith('--model='))
|
|
342
|
-
continue;
|
|
343
|
-
result.push(a);
|
|
344
|
-
}
|
|
345
|
-
return result;
|
|
346
|
-
}
|
|
347
|
-
let currentModel = null;
|
|
348
|
-
// apply an incoming model payload to claudeArgs and the env used at spawn time
|
|
349
|
-
function applyModel(payload) {
|
|
350
|
-
claudeArgs = removeModelArgs(claudeArgs);
|
|
351
|
-
if (payload && typeof payload === 'object') {
|
|
352
|
-
const p = payload;
|
|
353
|
-
const model = typeof p['model'] === 'string' ? p['model'] : '';
|
|
354
|
-
if (model) {
|
|
355
|
-
currentModel = {
|
|
356
|
-
model,
|
|
357
|
-
baseURL: typeof p['baseURL'] === 'string' ? p['baseURL'] : undefined,
|
|
358
|
-
apiKey: typeof p['apiKey'] === 'string' ? p['apiKey'] : undefined,
|
|
359
|
-
};
|
|
360
|
-
claudeArgs.push('--model', model);
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
currentModel = null;
|
|
365
|
-
}
|
|
366
|
-
// build env overrides for claude pty based on currentModel
|
|
367
|
-
function buildClaudeEnv() {
|
|
368
|
-
const env = {
|
|
369
|
-
ANTHROPIC_BASE_URL: undefined,
|
|
370
|
-
ANTHROPIC_API_KEY: undefined,
|
|
371
|
-
};
|
|
372
|
-
if (currentModel?.baseURL)
|
|
373
|
-
env.ANTHROPIC_BASE_URL = currentModel.baseURL;
|
|
374
|
-
if (currentModel?.apiKey)
|
|
375
|
-
env.ANTHROPIC_API_KEY = currentModel.apiKey;
|
|
376
|
-
return env;
|
|
377
|
-
}
|
|
378
|
-
// fetch the user's current default model from backend using deviceToken.
|
|
379
|
-
// returns null on any failure or when no default is set.
|
|
380
|
-
async function fetchDefaultModel() {
|
|
381
|
-
if (!activeCredentials?.deviceToken)
|
|
382
|
-
return null;
|
|
383
|
-
try {
|
|
384
|
-
const res = await httpGetWithAuth(`${SERVER_HTTP}/api/cli/default-model`, activeCredentials.deviceToken);
|
|
385
|
-
if (res.status !== 200 || !res.data || typeof res.data !== 'object')
|
|
386
|
-
return null;
|
|
387
|
-
const d = res.data;
|
|
388
|
-
const model = typeof d['model'] === 'string' ? d['model'] : '';
|
|
389
|
-
if (!model)
|
|
390
|
-
return null;
|
|
391
|
-
return {
|
|
392
|
-
model,
|
|
393
|
-
baseURL: typeof d['baseURL'] === 'string' ? d['baseURL'] : undefined,
|
|
394
|
-
apiKey: typeof d['apiKey'] === 'string' ? d['apiKey'] : undefined,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
catch {
|
|
398
|
-
return null;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
function httpGetWithAuth(url, token) {
|
|
402
|
-
return new Promise((resolve, reject) => {
|
|
403
|
-
const urlObj = new URL(url);
|
|
404
|
-
const isHttps = urlObj.protocol === 'https:';
|
|
405
|
-
const transport = isHttps ? https : http;
|
|
406
|
-
const options = {
|
|
407
|
-
hostname: urlObj.hostname,
|
|
408
|
-
port: urlObj.port || (isHttps ? 443 : 80),
|
|
409
|
-
path: urlObj.pathname + urlObj.search,
|
|
410
|
-
method: 'GET',
|
|
411
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
412
|
-
};
|
|
413
|
-
const req = transport.request(options, (res) => {
|
|
414
|
-
let raw = '';
|
|
415
|
-
res.on('data', (chunk) => { raw += chunk; });
|
|
416
|
-
res.on('end', () => {
|
|
417
|
-
try {
|
|
418
|
-
resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) });
|
|
419
|
-
}
|
|
420
|
-
catch {
|
|
421
|
-
resolve({ status: res.statusCode ?? 0, data: raw });
|
|
422
|
-
}
|
|
423
|
-
});
|
|
424
|
-
});
|
|
425
|
-
req.on('error', reject);
|
|
426
|
-
req.end();
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
// before each spawn, refresh the active model from backend so that
|
|
430
|
-
// changes to the user's default (or freshly-set defaults) take effect
|
|
431
|
-
// even without an explicit ws message.
|
|
432
|
-
async function ensureModelBeforeSpawn() {
|
|
433
|
-
if (currentModel)
|
|
434
|
-
return;
|
|
435
|
-
const def = await fetchDefaultModel();
|
|
436
|
-
if (def) {
|
|
437
|
-
applyModel({ model: def.model, baseURL: def.baseURL, apiKey: def.apiKey });
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
function spawnClaudeNow() {
|
|
441
|
-
log(`[spawn] spawning claude, args=${JSON.stringify(claudeArgs)}, cwd=${process.cwd()}`);
|
|
442
|
-
if (claudeProcess) {
|
|
443
|
-
isRespawning = true;
|
|
444
|
-
try {
|
|
445
|
-
claudeProcess.kill();
|
|
446
|
-
}
|
|
447
|
-
catch { }
|
|
448
|
-
claudeProcess = null;
|
|
449
|
-
}
|
|
450
|
-
spawnCleanup?.();
|
|
451
|
-
spawnCleanup = null;
|
|
452
|
-
scanner?.stop();
|
|
453
|
-
scanner = null;
|
|
454
|
-
snapshotScanner?.stop();
|
|
455
|
-
snapshotScanner = null;
|
|
456
|
-
autoAcceptedApiKeyDialog = false;
|
|
457
|
-
ptySnapshotRenderer.reset(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
|
|
458
|
-
const { pty: child, cleanup } = spawnClaude([...claudeArgs, '--settings', claudeSettingsPath], (active) => sendToServer({ type: 'thinking', active }), (data) => {
|
|
459
|
-
debugLog.write(data);
|
|
460
|
-
menuParser.feed(data);
|
|
461
|
-
queuePtyData(data);
|
|
462
|
-
}, (cols, rows) => menuParser.resize(cols, rows), (pty) => { claudeProcess = pty; }, undefined, buildClaudeEnv());
|
|
463
|
-
spawnCleanup = cleanup;
|
|
464
|
-
child.onExit(({ exitCode }) => {
|
|
465
|
-
log(`[spawn] claude exited, exitCode=${exitCode}, isRespawning=${isRespawning}`);
|
|
466
|
-
flushPtyUpdates();
|
|
467
|
-
scanner?.stop();
|
|
468
|
-
if (isRespawning) {
|
|
469
|
-
isRespawning = false;
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
if (reconnectTimer)
|
|
473
|
-
clearTimeout(reconnectTimer);
|
|
474
|
-
ws?.close();
|
|
475
|
-
process.exit(exitCode);
|
|
476
|
-
});
|
|
477
|
-
process.on('SIGINT', () => {
|
|
478
|
-
child.kill();
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
let activeCredentials = null;
|
|
482
|
-
function connectToServer(onPaired, onOpen) {
|
|
483
|
-
ws = new WebSocket(SERVER_WS);
|
|
484
|
-
ws.on('open', () => {
|
|
485
|
-
log(`[ws] connected to ${SERVER_WS}`);
|
|
486
|
-
sendToServer({
|
|
487
|
-
type: 'register',
|
|
488
|
-
clientType: 'cli',
|
|
489
|
-
deviceToken: activeCredentials?.deviceToken,
|
|
490
|
-
deviceUuid: activeCredentials?.deviceUuid,
|
|
491
|
-
cwd: process.cwd(),
|
|
492
|
-
});
|
|
493
|
-
menuParser.reset();
|
|
494
|
-
if (pingInterval)
|
|
495
|
-
clearInterval(pingInterval);
|
|
496
|
-
pingInterval = setInterval(() => {
|
|
497
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
498
|
-
ws.ping();
|
|
499
|
-
}
|
|
500
|
-
}, 20000);
|
|
501
|
-
if (onOpen) {
|
|
502
|
-
Promise.resolve(onOpen()).catch((e) => log(`[onOpen] error: ${e}`));
|
|
503
|
-
}
|
|
504
|
-
});
|
|
505
|
-
ws.on('ping', () => {
|
|
506
|
-
ws?.pong();
|
|
507
|
-
});
|
|
508
|
-
ws.on('message', (raw) => {
|
|
509
|
-
let msg;
|
|
510
|
-
try {
|
|
511
|
-
msg = JSON.parse(raw.toString());
|
|
512
|
-
}
|
|
513
|
-
catch {
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
if (msg['type'] === 'paired') {
|
|
517
|
-
log(`[pairing] paired with app`);
|
|
518
|
-
console.log('\n\x1b[32m✓ App paired! Launching Claude...\x1b[0m\n');
|
|
519
|
-
pairedResolve?.();
|
|
520
|
-
pairedResolve = null;
|
|
521
|
-
startFileWatcher();
|
|
522
|
-
onPaired?.();
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
if (msg['type'] === 'auth_error') {
|
|
526
|
-
const reason = msg['error'] ?? 'unknown';
|
|
527
|
-
log(`[ws] auth_error: ${reason}`);
|
|
528
|
-
console.error(`\n\x1b[31m✗ device authentication failed: ${reason}\x1b[0m`);
|
|
529
|
-
console.error('\x1b[33mthe device was likely removed from the Kumo app. clearing credentials...\x1b[0m');
|
|
530
|
-
clearCredentials();
|
|
531
|
-
if (reconnectTimer) {
|
|
532
|
-
clearTimeout(reconnectTimer);
|
|
533
|
-
reconnectTimer = null;
|
|
534
|
-
}
|
|
535
|
-
if (pingInterval) {
|
|
536
|
-
clearInterval(pingInterval);
|
|
537
|
-
pingInterval = null;
|
|
538
|
-
}
|
|
539
|
-
try {
|
|
540
|
-
ws?.close();
|
|
541
|
-
}
|
|
542
|
-
catch { }
|
|
543
|
-
ws = null;
|
|
544
|
-
console.error('\x1b[33mrun `kumo` again to pair with a new device.\x1b[0m');
|
|
545
|
-
process.exit(1);
|
|
546
|
-
}
|
|
547
|
-
if (msg['type'] === 'send_prompt') {
|
|
548
|
-
const text = msg['text'];
|
|
549
|
-
if (!text)
|
|
550
|
-
return;
|
|
551
|
-
if (claudeProcess) {
|
|
552
|
-
dispatchPrompt(text);
|
|
553
|
-
}
|
|
554
|
-
else {
|
|
555
|
-
pendingPrompt = text;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
if (msg['type'] === 'select_option') {
|
|
559
|
-
const value = msg['value'];
|
|
560
|
-
log(`[ws recv] select_option: value="${value}"`);
|
|
561
|
-
if (value)
|
|
562
|
-
handleSelectOption(value);
|
|
563
|
-
}
|
|
564
|
-
if (msg['type'] === 'submit_input') {
|
|
565
|
-
const value = msg['value'];
|
|
566
|
-
log(`[ws recv] submit_input: value="${value}"`);
|
|
567
|
-
if (claudeProcess) {
|
|
568
|
-
menuParser.reset();
|
|
569
|
-
claudeProcess.write('\x15');
|
|
570
|
-
claudeProcess.write(value);
|
|
571
|
-
claudeProcess.write('\r');
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
if (msg['type'] === 'send_key') {
|
|
575
|
-
const key = msg['key'];
|
|
576
|
-
log(`[ws recv] send_key: key="${key}"`);
|
|
577
|
-
if (claudeProcess && key === 'esc') {
|
|
578
|
-
menuParser.reset();
|
|
579
|
-
claudeProcess.write('\x1b');
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
if (msg['type'] === 'nudge_resize') {
|
|
583
|
-
nudgeResize();
|
|
584
|
-
}
|
|
585
|
-
if (msg['type'] === 'request_session_list') {
|
|
586
|
-
const cwdArg = typeof msg['cwd'] === 'string' ? msg['cwd'] : undefined;
|
|
587
|
-
sendSessionList(cwdArg).catch((e) => log(`[request_session_list] error: ${e}`));
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
if (msg['type'] === 'delete_session') {
|
|
591
|
-
const sessionId = typeof msg['sessionId'] === 'string' ? msg['sessionId'] : undefined;
|
|
592
|
-
const cwdArg = typeof msg['cwd'] === 'string' ? msg['cwd'] : undefined;
|
|
593
|
-
if (!sessionId)
|
|
594
|
-
return;
|
|
595
|
-
deleteSessionFile(sessionId, cwdArg)
|
|
596
|
-
.then(() => sendSessionList(cwdArg))
|
|
597
|
-
.catch((e) => log(`[delete_session] error: ${e}`));
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
if (msg['type'] === 'request_session_history') {
|
|
601
|
-
applyModel(msg['model']);
|
|
602
|
-
handleSessionHistoryRequest(msg['sessionId']).catch((e) => log(`[session_history] error: ${e}`));
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
if (msg['type'] === 'request_new_session') {
|
|
606
|
-
log(`[ws recv] request_new_session`);
|
|
607
|
-
applyModel(msg['model']);
|
|
608
|
-
claudeArgs = removeResumeArgs(claudeArgs);
|
|
609
|
-
startScannerAtEnd = false;
|
|
610
|
-
// if backend didn't include a model (e.g. app simply pressed "new"),
|
|
611
|
-
// fall back to user's default model resolved just-in-time.
|
|
612
|
-
ensureModelBeforeSpawn()
|
|
613
|
-
.catch(() => { })
|
|
614
|
-
.finally(() => spawnClaudeNow());
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
if (msg['type'] === 'terminal_create') {
|
|
618
|
-
const cols = msg['cols'] || 120;
|
|
619
|
-
const rows = msg['rows'] || 30;
|
|
620
|
-
const cwd = msg['cwd'];
|
|
621
|
-
const terminalId = terminalManager.create(cols, rows, cwd);
|
|
622
|
-
log(`[terminal] created: ${terminalId}`);
|
|
623
|
-
sendToServer({ type: 'terminal_created', terminalId });
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
if (msg['type'] === 'terminal_input') {
|
|
627
|
-
const terminalId = msg['terminalId'];
|
|
628
|
-
const data = msg['data'];
|
|
629
|
-
if (terminalId && data) {
|
|
630
|
-
terminalManager.write(terminalId, data);
|
|
631
|
-
}
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
if (msg['type'] === 'terminal_resize') {
|
|
635
|
-
const terminalId = msg['terminalId'];
|
|
636
|
-
const cols = msg['cols'];
|
|
637
|
-
const rows = msg['rows'];
|
|
638
|
-
if (terminalId && cols && rows) {
|
|
639
|
-
terminalManager.resize(terminalId, cols, rows);
|
|
640
|
-
log(`[terminal] resize ${terminalId} -> ${cols}x${rows}`);
|
|
641
|
-
}
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
if (msg['type'] === 'terminal_close') {
|
|
645
|
-
const terminalId = msg['terminalId'];
|
|
646
|
-
if (terminalId) {
|
|
647
|
-
terminalManager.close(terminalId);
|
|
648
|
-
log(`[terminal] closed: ${terminalId}`);
|
|
649
|
-
}
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if (msg['type'] === 'terminal_list') {
|
|
653
|
-
const terminals = terminalManager.list();
|
|
654
|
-
sendToServer({ type: 'terminal_list', terminals });
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
if (msg['type'] === 'file_search') {
|
|
658
|
-
// recursively search files/folders by name (case-insensitive substring)
|
|
659
|
-
const query = (msg['query'] || '').trim().toLowerCase();
|
|
660
|
-
const basePath = msg['path'] || '';
|
|
661
|
-
const limit = Math.min(Math.max(msg['limit'] || 200, 1), 1000);
|
|
662
|
-
try {
|
|
663
|
-
const projectRoot = process.cwd();
|
|
664
|
-
const baseResolved = safePath(basePath || '.');
|
|
665
|
-
const ignored = loadIgnoredNames(projectRoot);
|
|
666
|
-
const results = [];
|
|
667
|
-
if (query.length === 0) {
|
|
668
|
-
sendToServer({ type: 'file_search_result', query, entries: [] });
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
const stack = [baseResolved];
|
|
672
|
-
while (stack.length > 0 && results.length < limit) {
|
|
673
|
-
const dir = stack.pop();
|
|
674
|
-
let dirEntries;
|
|
675
|
-
try {
|
|
676
|
-
dirEntries = fs.readdirSync(dir, { withFileTypes: true });
|
|
677
|
-
}
|
|
678
|
-
catch {
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
681
|
-
for (const e of dirEntries) {
|
|
682
|
-
if (e.name.startsWith('.'))
|
|
683
|
-
continue;
|
|
684
|
-
if (ignored.has(e.name))
|
|
685
|
-
continue;
|
|
686
|
-
const full = path.resolve(dir, e.name);
|
|
687
|
-
if (e.name.toLowerCase().includes(query)) {
|
|
688
|
-
let size = 0;
|
|
689
|
-
if (!e.isDirectory()) {
|
|
690
|
-
try {
|
|
691
|
-
size = fs.statSync(full).size;
|
|
692
|
-
}
|
|
693
|
-
catch { }
|
|
694
|
-
}
|
|
695
|
-
results.push({
|
|
696
|
-
name: e.name,
|
|
697
|
-
path: path.relative(projectRoot, full).replace(/\\/g, '/'),
|
|
698
|
-
isDirectory: e.isDirectory(),
|
|
699
|
-
size,
|
|
700
|
-
});
|
|
701
|
-
if (results.length >= limit)
|
|
702
|
-
break;
|
|
703
|
-
}
|
|
704
|
-
if (e.isDirectory())
|
|
705
|
-
stack.push(full);
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
results.sort((a, b) => {
|
|
709
|
-
if (a.isDirectory !== b.isDirectory)
|
|
710
|
-
return a.isDirectory ? -1 : 1;
|
|
711
|
-
return a.name.localeCompare(b.name);
|
|
712
|
-
});
|
|
713
|
-
sendToServer({ type: 'file_search_result', query, entries: results });
|
|
714
|
-
}
|
|
715
|
-
catch (e) {
|
|
716
|
-
sendToServer({ type: 'file_search_result', query, entries: [], error: e instanceof Error ? e.message : String(e) });
|
|
717
|
-
}
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
if (msg['type'] === 'file_list') {
|
|
721
|
-
const requestedPath = msg['path'] || '';
|
|
722
|
-
try {
|
|
723
|
-
const projectRoot = process.cwd();
|
|
724
|
-
const resolved = safePath(requestedPath || '.');
|
|
725
|
-
if (!resolved.startsWith(projectRoot)) {
|
|
726
|
-
sendToServer({ type: 'file_list_result', path: requestedPath, entries: [], error: 'path traversal blocked' });
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
const entries = fs.readdirSync(resolved, { withFileTypes: true })
|
|
730
|
-
.filter(e => !e.name.startsWith('.'))
|
|
731
|
-
.map(e => ({
|
|
732
|
-
name: e.name,
|
|
733
|
-
path: path.relative(projectRoot, path.resolve(resolved, e.name)).replace(/\\/g, '/'),
|
|
734
|
-
isDirectory: e.isDirectory(),
|
|
735
|
-
size: e.isDirectory() ? 0 : fs.statSync(path.resolve(resolved, e.name)).size,
|
|
736
|
-
}))
|
|
737
|
-
.sort((a, b) => {
|
|
738
|
-
if (a.isDirectory !== b.isDirectory)
|
|
739
|
-
return a.isDirectory ? -1 : 1;
|
|
740
|
-
return a.name.localeCompare(b.name);
|
|
741
|
-
});
|
|
742
|
-
sendToServer({ type: 'file_list_result', path: requestedPath, entries, projectName: path.basename(process.cwd()) });
|
|
743
|
-
}
|
|
744
|
-
catch (e) {
|
|
745
|
-
sendToServer({ type: 'file_list_result', path: requestedPath, entries: [], error: e instanceof Error ? e.message : String(e) });
|
|
746
|
-
}
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
if (msg['type'] === 'file_read') {
|
|
750
|
-
const filePath = msg['path'];
|
|
751
|
-
try {
|
|
752
|
-
const projectRoot = process.cwd();
|
|
753
|
-
const resolved = path.resolve(projectRoot, path.normalize(filePath || '.'));
|
|
754
|
-
if (!resolved.startsWith(projectRoot)) {
|
|
755
|
-
sendToServer({ type: 'file_read_result', path: filePath, error: 'path traversal blocked' });
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
const content = fs.readFileSync(resolved, 'utf-8');
|
|
759
|
-
sendToServer({ type: 'file_read_result', path: filePath, content });
|
|
760
|
-
}
|
|
761
|
-
catch (e) {
|
|
762
|
-
sendToServer({ type: 'file_read_result', path: filePath, error: e instanceof Error ? e.message : String(e) });
|
|
763
|
-
}
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
if (msg['type'] === 'file_write') {
|
|
767
|
-
const filePath = msg['path'];
|
|
768
|
-
const content = msg['content'];
|
|
769
|
-
try {
|
|
770
|
-
const projectRoot = process.cwd();
|
|
771
|
-
const resolved = path.resolve(projectRoot, path.normalize(filePath || '.'));
|
|
772
|
-
if (!resolved.startsWith(projectRoot)) {
|
|
773
|
-
sendToServer({ type: 'file_write_result', path: filePath, ok: false, error: 'path traversal blocked' });
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
fs.writeFileSync(resolved, content, 'utf-8');
|
|
777
|
-
sendToServer({ type: 'file_write_result', path: filePath, ok: true });
|
|
778
|
-
}
|
|
779
|
-
catch (e) {
|
|
780
|
-
sendToServer({ type: 'file_write_result', path: filePath, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
781
|
-
}
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
if (msg['type'] === 'file_create') {
|
|
785
|
-
const targetPath = msg['path'];
|
|
786
|
-
const isDir = msg['isDirectory'] ?? false;
|
|
787
|
-
try {
|
|
788
|
-
const resolved = safePath(targetPath);
|
|
789
|
-
if (isDir) {
|
|
790
|
-
fs.mkdirSync(resolved, { recursive: true });
|
|
791
|
-
}
|
|
792
|
-
else {
|
|
793
|
-
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
794
|
-
fs.writeFileSync(resolved, '', 'utf-8');
|
|
795
|
-
}
|
|
796
|
-
sendToServer({ type: 'file_op_result', op: 'create', path: targetPath, ok: true });
|
|
797
|
-
}
|
|
798
|
-
catch (e) {
|
|
799
|
-
sendToServer({ type: 'file_op_result', op: 'create', path: targetPath, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
800
|
-
}
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
if (msg['type'] === 'file_delete') {
|
|
804
|
-
const targetPath = msg['path'];
|
|
805
|
-
try {
|
|
806
|
-
const resolved = safePath(targetPath);
|
|
807
|
-
fs.rmSync(resolved, { recursive: true, force: true });
|
|
808
|
-
sendToServer({ type: 'file_op_result', op: 'delete', path: targetPath, ok: true });
|
|
809
|
-
}
|
|
810
|
-
catch (e) {
|
|
811
|
-
sendToServer({ type: 'file_op_result', op: 'delete', path: targetPath, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
812
|
-
}
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
if (msg['type'] === 'file_rename') {
|
|
816
|
-
const oldPath = msg['oldPath'];
|
|
817
|
-
const newName = msg['newName'];
|
|
818
|
-
try {
|
|
819
|
-
const resolvedOld = safePath(oldPath);
|
|
820
|
-
const resolvedNew = path.join(path.dirname(resolvedOld), newName);
|
|
821
|
-
safePath(path.relative(process.cwd(), resolvedNew));
|
|
822
|
-
fs.renameSync(resolvedOld, resolvedNew);
|
|
823
|
-
sendToServer({ type: 'file_op_result', op: 'rename', path: oldPath, newPath: path.relative(process.cwd(), resolvedNew).replace(/\\/g, '/'), ok: true });
|
|
824
|
-
}
|
|
825
|
-
catch (e) {
|
|
826
|
-
sendToServer({ type: 'file_op_result', op: 'rename', path: oldPath, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
827
|
-
}
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
if (msg['type'] === 'file_copy') {
|
|
831
|
-
const srcPath = msg['src'];
|
|
832
|
-
const destPath = msg['dest'];
|
|
833
|
-
try {
|
|
834
|
-
const resolvedSrc = safePath(srcPath);
|
|
835
|
-
const resolvedDest = safePath(destPath);
|
|
836
|
-
copyRecursive(resolvedSrc, resolvedDest);
|
|
837
|
-
sendToServer({ type: 'file_op_result', op: 'copy', path: srcPath, dest: destPath, ok: true });
|
|
838
|
-
}
|
|
839
|
-
catch (e) {
|
|
840
|
-
sendToServer({ type: 'file_op_result', op: 'copy', path: srcPath, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
841
|
-
}
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
if (msg['type'] === 'file_move') {
|
|
845
|
-
const srcPath = msg['src'];
|
|
846
|
-
const destPath = msg['dest'];
|
|
847
|
-
try {
|
|
848
|
-
const resolvedSrc = safePath(srcPath);
|
|
849
|
-
const resolvedDest = safePath(destPath);
|
|
850
|
-
fs.renameSync(resolvedSrc, resolvedDest);
|
|
851
|
-
sendToServer({ type: 'file_op_result', op: 'move', path: srcPath, dest: destPath, ok: true });
|
|
852
|
-
}
|
|
853
|
-
catch (e) {
|
|
854
|
-
sendToServer({ type: 'file_op_result', op: 'move', path: srcPath, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
855
|
-
}
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
if (msg['type'] === 'tunnel_request') {
|
|
859
|
-
const { requestId, method, path: urlPath, headers: reqHeaders, body: reqBody, port, host: remoteHost } = msg;
|
|
860
|
-
const targetHost = remoteHost || 'localhost';
|
|
861
|
-
const url = `http://${targetHost}:${port}${urlPath}`;
|
|
862
|
-
const filteredHeaders = Object.fromEntries(Object.entries(reqHeaders).filter(([k]) => {
|
|
863
|
-
const lower = k.toLowerCase();
|
|
864
|
-
return !lower.startsWith('sec-') && !['host', 'connection', 'accept-encoding', 'upgrade-insecure-requests'].includes(lower);
|
|
865
|
-
}).map(([k, v]) => {
|
|
866
|
-
const lower = k.toLowerCase();
|
|
867
|
-
if (lower === 'origin' || lower === 'referer') {
|
|
868
|
-
return [k, v.replace(/http:\/\/localhost:\d+/g, `http://${targetHost}:${port}`)];
|
|
869
|
-
}
|
|
870
|
-
return [k, v];
|
|
871
|
-
}));
|
|
872
|
-
log(`[tunnel] ${method} ${url}`);
|
|
873
|
-
fetch(url, {
|
|
874
|
-
method,
|
|
875
|
-
headers: filteredHeaders,
|
|
876
|
-
body: reqBody && method !== 'GET' && method !== 'HEAD' ? Buffer.from(reqBody, 'base64') : undefined,
|
|
877
|
-
redirect: 'manual',
|
|
878
|
-
})
|
|
879
|
-
.then(async (res) => {
|
|
880
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
881
|
-
log(`[tunnel] response ${res.status} ${url} (${buf.length} bytes)`);
|
|
882
|
-
const respHeaders = Object.fromEntries([...res.headers.entries()].filter(([k]) => !['content-encoding', 'transfer-encoding', 'content-length'].includes(k.toLowerCase())));
|
|
883
|
-
const remoteOrigin = `http://${targetHost}:${port}`;
|
|
884
|
-
if (respHeaders['location']) {
|
|
885
|
-
respHeaders['location'] = respHeaders['location']
|
|
886
|
-
.replace(remoteOrigin, '');
|
|
887
|
-
}
|
|
888
|
-
if (respHeaders['set-cookie']) {
|
|
889
|
-
respHeaders['set-cookie'] = respHeaders['set-cookie']
|
|
890
|
-
.replace(/;\s*domain=[^;]*/gi, '')
|
|
891
|
-
.replace(/;\s*secure/gi, '');
|
|
892
|
-
}
|
|
893
|
-
sendToServer({
|
|
894
|
-
type: 'tunnel_response',
|
|
895
|
-
requestId,
|
|
896
|
-
status: res.status,
|
|
897
|
-
headers: respHeaders,
|
|
898
|
-
body: buf.toString('base64'),
|
|
899
|
-
});
|
|
900
|
-
})
|
|
901
|
-
.catch((err) => {
|
|
902
|
-
sendToServer({
|
|
903
|
-
type: 'tunnel_response',
|
|
904
|
-
requestId,
|
|
905
|
-
status: 502,
|
|
906
|
-
headers: {},
|
|
907
|
-
body: Buffer.from('Bad Gateway: ' + err.message).toString('base64'),
|
|
908
|
-
});
|
|
909
|
-
});
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
});
|
|
913
|
-
ws.on('close', () => {
|
|
914
|
-
log(`[ws] disconnected, reconnecting in 3s`);
|
|
915
|
-
if (pingInterval) {
|
|
916
|
-
clearInterval(pingInterval);
|
|
917
|
-
pingInterval = null;
|
|
918
|
-
}
|
|
919
|
-
if (fileWatcher) {
|
|
920
|
-
try {
|
|
921
|
-
fileWatcher.close();
|
|
922
|
-
}
|
|
923
|
-
catch { }
|
|
924
|
-
fileWatcher = null;
|
|
925
|
-
}
|
|
926
|
-
reconnectTimer = setTimeout(() => connectToServer(), 3000);
|
|
927
|
-
});
|
|
928
|
-
ws.on('error', () => { });
|
|
929
|
-
}
|
|
930
|
-
function generateCode() {
|
|
931
|
-
return String(Math.floor(100000 + Math.random() * 900000));
|
|
932
|
-
}
|
|
933
|
-
function httpPost(url, body) {
|
|
934
|
-
return new Promise((resolve, reject) => {
|
|
935
|
-
const payload = JSON.stringify(body);
|
|
936
|
-
const urlObj = new URL(url);
|
|
937
|
-
const isHttps = urlObj.protocol === 'https:';
|
|
938
|
-
const transport = isHttps ? https : http;
|
|
939
|
-
const options = {
|
|
940
|
-
hostname: urlObj.hostname,
|
|
941
|
-
port: urlObj.port || (isHttps ? 443 : 80),
|
|
942
|
-
path: urlObj.pathname,
|
|
943
|
-
method: 'POST',
|
|
944
|
-
headers: {
|
|
945
|
-
'Content-Type': 'application/json',
|
|
946
|
-
'Content-Length': Buffer.byteLength(payload),
|
|
947
|
-
},
|
|
948
|
-
};
|
|
949
|
-
const req = transport.request(options, (res) => {
|
|
950
|
-
let raw = '';
|
|
951
|
-
res.on('data', (chunk) => { raw += chunk; });
|
|
952
|
-
res.on('end', () => {
|
|
953
|
-
try {
|
|
954
|
-
resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) });
|
|
955
|
-
}
|
|
956
|
-
catch {
|
|
957
|
-
resolve({ status: res.statusCode ?? 0, data: raw });
|
|
958
|
-
}
|
|
959
|
-
});
|
|
960
|
-
});
|
|
961
|
-
req.on('error', reject);
|
|
962
|
-
req.write(payload);
|
|
963
|
-
req.end();
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
function httpGet(url) {
|
|
967
|
-
return new Promise((resolve, reject) => {
|
|
968
|
-
const urlObj = new URL(url);
|
|
969
|
-
const isHttps = urlObj.protocol === 'https:';
|
|
970
|
-
const transport = isHttps ? https : http;
|
|
971
|
-
const options = {
|
|
972
|
-
hostname: urlObj.hostname,
|
|
973
|
-
port: urlObj.port || (isHttps ? 443 : 80),
|
|
974
|
-
path: urlObj.pathname + urlObj.search,
|
|
975
|
-
method: 'GET',
|
|
976
|
-
};
|
|
977
|
-
const req = transport.request(options, (res) => {
|
|
978
|
-
let raw = '';
|
|
979
|
-
res.on('data', (chunk) => { raw += chunk; });
|
|
980
|
-
res.on('end', () => {
|
|
981
|
-
try {
|
|
982
|
-
resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) });
|
|
983
|
-
}
|
|
984
|
-
catch {
|
|
985
|
-
resolve({ status: res.statusCode ?? 0, data: raw });
|
|
986
|
-
}
|
|
987
|
-
});
|
|
988
|
-
});
|
|
989
|
-
req.on('error', reject);
|
|
990
|
-
req.end();
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
// poll pair status until approved/rejected or timeout (5 min)
|
|
994
|
-
async function pollPairStatus(requestId) {
|
|
995
|
-
const deadline = Date.now() + 5 * 60 * 1000;
|
|
996
|
-
while (Date.now() < deadline) {
|
|
997
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
998
|
-
try {
|
|
999
|
-
const res = await httpGet(`${SERVER_HTTP}/api/cli/pair/${requestId}`);
|
|
1000
|
-
if (res.status !== 200)
|
|
1001
|
-
continue;
|
|
1002
|
-
const data = res.data;
|
|
1003
|
-
if (data.status === 'approved' && data.deviceToken && data.device) {
|
|
1004
|
-
return data;
|
|
1005
|
-
}
|
|
1006
|
-
if (data.status === 'rejected') {
|
|
1007
|
-
console.warn('[kumo-cli] pairing rejected by app');
|
|
1008
|
-
return null;
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
catch {
|
|
1012
|
-
// transient, retry
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
console.warn('[kumo-cli] pairing timed out');
|
|
1016
|
-
return null;
|
|
1017
|
-
}
|
|
1018
|
-
function displayCode(code) {
|
|
1019
|
-
const line = '═'.repeat(40);
|
|
1020
|
-
console.log('\n\x1b[36m' + line + '\x1b[0m');
|
|
1021
|
-
console.log('\x1b[36m║\x1b[0m \x1b[1mKumo Pairing Code (valid 5 min)\x1b[0m');
|
|
1022
|
-
console.log('\x1b[36m║\x1b[0m');
|
|
1023
|
-
console.log(`\x1b[36m║\x1b[0m \x1b[1;33m${code.slice(0, 3)} ${code.slice(3)}\x1b[0m`);
|
|
1024
|
-
console.log('\x1b[36m║\x1b[0m');
|
|
1025
|
-
console.log('\x1b[36m║\x1b[0m Enter this code in the Kumo app');
|
|
1026
|
-
console.log('\x1b[36m' + line + '\x1b[0m\n');
|
|
1027
|
-
}
|
|
1028
|
-
async function registerCode(code) {
|
|
1029
|
-
try {
|
|
1030
|
-
const result = await httpPost(`${SERVER_HTTP}/api/pair/register`, {
|
|
1031
|
-
code,
|
|
1032
|
-
port: SERVER_PORT,
|
|
1033
|
-
});
|
|
1034
|
-
return result.status === 200;
|
|
1035
|
-
}
|
|
1036
|
-
catch {
|
|
1037
|
-
return false;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
async function checkCliStatus() {
|
|
1041
|
-
return new Promise((resolve) => {
|
|
1042
|
-
const urlObj = new URL(`${SERVER_HTTP}/api/cli/status`);
|
|
1043
|
-
const options = {
|
|
1044
|
-
hostname: urlObj.hostname,
|
|
1045
|
-
port: urlObj.port || 80,
|
|
1046
|
-
path: urlObj.pathname,
|
|
1047
|
-
method: 'GET',
|
|
1048
|
-
};
|
|
1049
|
-
const req = http.request(options, (res) => {
|
|
1050
|
-
let raw = '';
|
|
1051
|
-
res.on('data', (chunk) => { raw += chunk; });
|
|
1052
|
-
res.on('end', () => {
|
|
1053
|
-
try {
|
|
1054
|
-
resolve(JSON.parse(raw));
|
|
1055
|
-
}
|
|
1056
|
-
catch {
|
|
1057
|
-
resolve({ paired: false, alive: false });
|
|
1058
|
-
}
|
|
1059
|
-
});
|
|
1060
|
-
});
|
|
1061
|
-
req.on('error', () => resolve({ paired: false, alive: false }));
|
|
1062
|
-
req.end();
|
|
1063
|
-
});
|
|
1064
|
-
}
|
|
1065
|
-
function getCurrentProjectFolder() {
|
|
1066
|
-
return cwdToProjectFolder(process.cwd());
|
|
1067
|
-
}
|
|
1068
|
-
// claude-cli encodes a cwd into a folder name by replacing separators,
|
|
1069
|
-
// colons and underscores with dashes (observed on both unix and windows).
|
|
1070
|
-
function cwdToProjectFolder(cwd) {
|
|
1071
|
-
if (os.platform() === 'win32') {
|
|
1072
|
-
return cwd.replace(/:/g, '-').replace(/[\\/]/g, '-').replace(/_/g, '-');
|
|
1073
|
-
}
|
|
1074
|
-
return cwd.replace(/\//g, '-').replace(/_/g, '-');
|
|
1075
|
-
}
|
|
1076
|
-
async function getActiveSessionIds() {
|
|
1077
|
-
const sessionsDir = path.join(os.homedir(), '.claude', 'sessions');
|
|
1078
|
-
const active = new Set();
|
|
1079
|
-
try {
|
|
1080
|
-
const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
|
|
1081
|
-
for (const file of files) {
|
|
1082
|
-
try {
|
|
1083
|
-
const data = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf-8'));
|
|
1084
|
-
if (data.sessionId)
|
|
1085
|
-
active.add(data.sessionId);
|
|
1086
|
-
}
|
|
1087
|
-
catch { }
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
catch { }
|
|
1091
|
-
return active;
|
|
1092
|
-
}
|
|
1093
|
-
async function getSessionListForCwd(cwdOverride) {
|
|
1094
|
-
const cwd = cwdOverride && cwdOverride.length > 0 ? cwdOverride : process.cwd();
|
|
1095
|
-
const folderName = cwdToProjectFolder(cwd);
|
|
1096
|
-
const projectDir = path.join(os.homedir(), '.claude', 'projects', folderName);
|
|
1097
|
-
log(`[session_list] cwd=${cwd}, folder=${folderName}, dir=${projectDir}`);
|
|
1098
|
-
const sessions = [];
|
|
1099
|
-
try {
|
|
1100
|
-
if (!fs.existsSync(projectDir))
|
|
1101
|
-
return sessions;
|
|
1102
|
-
const files = fs.readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
|
|
1103
|
-
for (const file of files) {
|
|
1104
|
-
const sessionId = path.basename(file, '.jsonl');
|
|
1105
|
-
const filePath = path.join(projectDir, file);
|
|
1106
|
-
try {
|
|
1107
|
-
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
1108
|
-
const lines = raw.split('\n').filter((l) => l.trim());
|
|
1109
|
-
let firstMessage = '';
|
|
1110
|
-
let lastTimestamp = '';
|
|
1111
|
-
let hasMessages = false;
|
|
1112
|
-
for (const line of lines) {
|
|
1113
|
-
try {
|
|
1114
|
-
const entry = JSON.parse(line);
|
|
1115
|
-
if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
|
|
1116
|
-
continue;
|
|
1117
|
-
const msgObj = entry['message'];
|
|
1118
|
-
if (!msgObj || !msgObj['role'] || msgObj['role'] === 'system')
|
|
1119
|
-
continue;
|
|
1120
|
-
hasMessages = true;
|
|
1121
|
-
const ts = entry['timestamp'];
|
|
1122
|
-
if (ts)
|
|
1123
|
-
lastTimestamp = ts;
|
|
1124
|
-
if (!firstMessage && msgObj['role'] === 'user') {
|
|
1125
|
-
if (typeof msgObj['content'] === 'string') {
|
|
1126
|
-
firstMessage = msgObj['content'].slice(0, 200);
|
|
1127
|
-
}
|
|
1128
|
-
else if (Array.isArray(msgObj['content'])) {
|
|
1129
|
-
firstMessage = msgObj['content']
|
|
1130
|
-
.filter((b) => b['type'] === 'text' && b['text'])
|
|
1131
|
-
.map((b) => b['text'])
|
|
1132
|
-
.join('')
|
|
1133
|
-
.slice(0, 200);
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
catch { }
|
|
1138
|
-
}
|
|
1139
|
-
if (hasMessages) {
|
|
1140
|
-
sessions.push({ sessionId, firstMessage, lastTimestamp });
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
catch { }
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
catch { }
|
|
1147
|
-
sessions.sort((a, b) => (b.lastTimestamp || '').localeCompare(a.lastTimestamp || ''));
|
|
1148
|
-
return sessions;
|
|
1149
|
-
}
|
|
1150
|
-
async function deleteSessionFile(sessionId, cwdOverride) {
|
|
1151
|
-
const cwd = cwdOverride && cwdOverride.length > 0 ? cwdOverride : process.cwd();
|
|
1152
|
-
const folderName = cwdToProjectFolder(cwd);
|
|
1153
|
-
const projectDir = path.join(os.homedir(), '.claude', 'projects', folderName);
|
|
1154
|
-
const sessionFile = path.join(projectDir, `${sessionId}.jsonl`);
|
|
1155
|
-
try {
|
|
1156
|
-
if (fs.existsSync(sessionFile)) {
|
|
1157
|
-
fs.unlinkSync(sessionFile);
|
|
1158
|
-
log(`[delete_session] deleted ${sessionFile}`);
|
|
1159
|
-
}
|
|
1160
|
-
else {
|
|
1161
|
-
log(`[delete_session] file not found: ${sessionFile}`);
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
catch (err) {
|
|
1165
|
-
log(`[delete_session] error: ${err}`);
|
|
1166
|
-
throw err;
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
async function sendSessionList(cwdOverride) {
|
|
1170
|
-
try {
|
|
1171
|
-
const sessions = await getSessionListForCwd(cwdOverride);
|
|
1172
|
-
const activeIds = await getActiveSessionIds();
|
|
1173
|
-
const sessionsWithActive = sessions.map((s) => ({
|
|
1174
|
-
...s,
|
|
1175
|
-
isActive: activeIds.has(s.sessionId),
|
|
1176
|
-
}));
|
|
1177
|
-
sendToServer({ type: 'session_list', sessions: sessionsWithActive });
|
|
1178
|
-
log(`[session_list] sent ${sessionsWithActive.length} sessions, ${activeIds.size} active`);
|
|
1179
|
-
}
|
|
1180
|
-
catch (err) {
|
|
1181
|
-
log(`[session_list] error: ${err}`);
|
|
1182
|
-
sendToServer({ type: 'session_list', sessions: [] });
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
async function handleSessionHistoryRequest(sessionId) {
|
|
1186
|
-
if (!sessionId)
|
|
1187
|
-
return;
|
|
1188
|
-
log(`[ws recv] request_session_history: ${sessionId}`);
|
|
1189
|
-
const switchId = ++sessionSwitchCounter;
|
|
1190
|
-
log(`[session_history] switchId=${switchId}`);
|
|
1191
|
-
try {
|
|
1192
|
-
const folderName = getCurrentProjectFolder();
|
|
1193
|
-
const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
1194
|
-
const sessionFile = path.join(claudeProjectsDir, folderName, `${sessionId}.jsonl`);
|
|
1195
|
-
log(`[session_history] folder=${folderName}, file=${sessionFile}`);
|
|
1196
|
-
if (!fs.existsSync(sessionFile)) {
|
|
1197
|
-
log(`[session_history] file not found, returning empty`);
|
|
1198
|
-
sendToServer({ type: 'session_history', sessionId, messages: [] });
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
const raw = fs.readFileSync(sessionFile, 'utf-8');
|
|
1202
|
-
const messages = [];
|
|
1203
|
-
const seenUuids = new Set();
|
|
1204
|
-
for (const line of raw.split('\n')) {
|
|
1205
|
-
if (!line.trim())
|
|
1206
|
-
continue;
|
|
1207
|
-
try {
|
|
1208
|
-
const entry = JSON.parse(line);
|
|
1209
|
-
if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
|
|
1210
|
-
continue;
|
|
1211
|
-
const msgObj = entry['message'];
|
|
1212
|
-
if (!msgObj)
|
|
1213
|
-
continue;
|
|
1214
|
-
const role = msgObj['role'];
|
|
1215
|
-
if (!role || role === 'system')
|
|
1216
|
-
continue;
|
|
1217
|
-
if (entry['isMeta'] || entry['sourceToolUseID'])
|
|
1218
|
-
continue;
|
|
1219
|
-
const uuid = entry['uuid'];
|
|
1220
|
-
if (uuid && seenUuids.has(uuid))
|
|
1221
|
-
continue;
|
|
1222
|
-
if (uuid)
|
|
1223
|
-
seenUuids.add(uuid);
|
|
1224
|
-
const timestamp = entry['timestamp'];
|
|
1225
|
-
let content = '';
|
|
1226
|
-
const toolUses = [];
|
|
1227
|
-
if (typeof msgObj['content'] === 'string') {
|
|
1228
|
-
content = msgObj['content'];
|
|
1229
|
-
}
|
|
1230
|
-
else if (Array.isArray(msgObj['content'])) {
|
|
1231
|
-
content = msgObj['content']
|
|
1232
|
-
.filter((b) => b['type'] === 'text' && b['text'] && b['text'].trim() !== 'No response requested.')
|
|
1233
|
-
.map((b) => b['text'])
|
|
1234
|
-
.join('');
|
|
1235
|
-
for (const b of msgObj['content']) {
|
|
1236
|
-
if (b['type'] === 'tool_use' && b['name']) {
|
|
1237
|
-
toolUses.push({ toolName: b['name'], input: b['input'] ?? {} });
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
if (!content && toolUses.length === 0)
|
|
1242
|
-
continue;
|
|
1243
|
-
messages.push({ role, content, id: uuid, timestamp, toolUses: toolUses.length > 0 ? toolUses : undefined });
|
|
1244
|
-
}
|
|
1245
|
-
catch { }
|
|
1246
|
-
}
|
|
1247
|
-
sendToServer({ type: 'session_history', sessionId, messages });
|
|
1248
|
-
log(`[session_history] sent ${messages.length} messages for ${sessionId}`);
|
|
1249
|
-
if (switchId !== sessionSwitchCounter) {
|
|
1250
|
-
log(`[session_history] cancelled switch to ${sessionId} (superseded)`);
|
|
1251
|
-
return;
|
|
1252
|
-
}
|
|
1253
|
-
startScannerAtEnd = true;
|
|
1254
|
-
claudeArgs = [...removeResumeArgs(claudeArgs), '--resume', sessionId];
|
|
1255
|
-
await ensureModelBeforeSpawn();
|
|
1256
|
-
spawnClaudeNow();
|
|
1257
|
-
}
|
|
1258
|
-
catch (err) {
|
|
1259
|
-
log(`[request_session_history] error: ${err}`);
|
|
1260
|
-
sendToServer({ type: 'session_history', sessionId, messages: [] });
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
async function pairFlow() {
|
|
1264
|
-
// prompt user for the 9-digit pairing code shown in the kumo app
|
|
1265
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1266
|
-
const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a)));
|
|
1267
|
-
// ascii art of the kumo text
|
|
1268
|
-
const cloud = [
|
|
1269
|
-
" \x1b[1;36m_ __ _ _ __ __ ____ \x1b[0m",
|
|
1270
|
-
" \x1b[1;36m| |/ /| | | || \\/ | / __ \\\x1b[0m",
|
|
1271
|
-
" \x1b[1;36m| ' / | | | || \\ / || | | |\x1b[0m",
|
|
1272
|
-
" \x1b[1;36m| . \\ | |_| || |\\/| || |__| |\x1b[0m",
|
|
1273
|
-
" \x1b[1;36m|_|\\_\\ \\___/ |_| |_| \\____/\x1b[0m"
|
|
1274
|
-
];
|
|
1275
|
-
console.log('');
|
|
1276
|
-
for (const line of cloud)
|
|
1277
|
-
console.log(line);
|
|
1278
|
-
console.log('');
|
|
1279
|
-
console.log(' \x1b[2mwhere your code drifts between devices, softly.\x1b[0m');
|
|
1280
|
-
console.log('');
|
|
1281
|
-
console.log(' \x1b[36m›\x1b[0m open the \x1b[1mKumo app\x1b[0m, sign in, and tap \x1b[1m"generate"\x1b[0m on the pairing tab.');
|
|
1282
|
-
console.log(' \x1b[36m›\x1b[0m whisper the 9 digits back to me below — spaces are fine.');
|
|
1283
|
-
console.log('');
|
|
1284
|
-
try {
|
|
1285
|
-
// loop until paired successfully; any failure prompts re-entry
|
|
1286
|
-
while (true) {
|
|
1287
|
-
const raw = (await ask('\x1b[1;33m✦ pairing code ›\x1b[0m ')).trim();
|
|
1288
|
-
if (!raw) {
|
|
1289
|
-
console.warn('[kumo-cli] pairing code is required, please try again');
|
|
1290
|
-
continue;
|
|
1291
|
-
}
|
|
1292
|
-
const code = raw.replace(/\D+/g, '');
|
|
1293
|
-
if (!/^\d{9}$/.test(code)) {
|
|
1294
|
-
console.warn('[kumo-cli] pairing code must be exactly 9 digits, please try again');
|
|
1295
|
-
continue;
|
|
1296
|
-
}
|
|
1297
|
-
const deviceUuid = randomUUID();
|
|
1298
|
-
const deviceName = `${os.userInfo().username}@${os.hostname()}`;
|
|
1299
|
-
let result;
|
|
1300
|
-
try {
|
|
1301
|
-
result = await httpPost(`${SERVER_HTTP}/api/cli/pair`, {
|
|
1302
|
-
pairingCode: code,
|
|
1303
|
-
deviceUuid,
|
|
1304
|
-
deviceName,
|
|
1305
|
-
});
|
|
1306
|
-
}
|
|
1307
|
-
catch (err) {
|
|
1308
|
-
console.warn(`[kumo-cli] pair error: ${err.message}, please try again`);
|
|
1309
|
-
continue;
|
|
1310
|
-
}
|
|
1311
|
-
if (result.status !== 200 && result.status !== 201) {
|
|
1312
|
-
const data = result.data;
|
|
1313
|
-
const msg = data?.error ?? data?.message ?? `HTTP ${result.status}`;
|
|
1314
|
-
console.warn(`[kumo-cli] pair failed: ${msg}, please try again`);
|
|
1315
|
-
continue;
|
|
1316
|
-
}
|
|
1317
|
-
const initial = result.data;
|
|
1318
|
-
if (!initial.requestId) {
|
|
1319
|
-
console.warn('[kumo-cli] pair failed: missing requestId, please try again');
|
|
1320
|
-
continue;
|
|
1321
|
-
}
|
|
1322
|
-
console.log('[kumo-cli] waiting for approval in the Kumo app...');
|
|
1323
|
-
const approved = await pollPairStatus(initial.requestId);
|
|
1324
|
-
if (!approved) {
|
|
1325
|
-
console.warn('[kumo-cli] please try again with a fresh code');
|
|
1326
|
-
continue;
|
|
1327
|
-
}
|
|
1328
|
-
const creds = {
|
|
1329
|
-
deviceUuid: approved.device.deviceUuid,
|
|
1330
|
-
deviceToken: approved.deviceToken,
|
|
1331
|
-
serverUrl: SERVER_HTTP,
|
|
1332
|
-
user: approved.user ?? undefined,
|
|
1333
|
-
};
|
|
1334
|
-
saveCredentials(creds);
|
|
1335
|
-
console.log(`[kumo-cli] paired and saved to ${credentialsPath()}`);
|
|
1336
|
-
return creds;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
finally {
|
|
1340
|
-
rl.close();
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
// quick reachability check against backend health endpoint
|
|
1344
|
-
async function checkBackendReachable() {
|
|
1345
|
-
try {
|
|
1346
|
-
const res = await httpGet(`${SERVER_HTTP}/api/health`);
|
|
1347
|
-
return res.status === 200;
|
|
1348
|
-
}
|
|
1349
|
-
catch {
|
|
1350
|
-
return false;
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
// verify cached deviceToken against backend; returns refreshed user info or null when revoked/deleted
|
|
1354
|
-
async function verifyCredentials(creds) {
|
|
1355
|
-
try {
|
|
1356
|
-
const res = await httpPost(`${SERVER_HTTP}/api/cli/connect`, { deviceToken: creds.deviceToken });
|
|
1357
|
-
if (res.status !== 200)
|
|
1358
|
-
return null;
|
|
1359
|
-
const data = res.data;
|
|
1360
|
-
return {
|
|
1361
|
-
...creds,
|
|
1362
|
-
user: data.user ?? creds.user,
|
|
1363
|
-
deviceUuid: data.device?.deviceUuid ?? creds.deviceUuid,
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
catch {
|
|
1367
|
-
return null;
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
87
|
async function main() {
|
|
1371
|
-
claudeArgs =
|
|
88
|
+
let claudeArgs = [...bootArgs];
|
|
1372
89
|
if (claudeArgs[0] === 'reset') {
|
|
1373
90
|
const removed = clearCredentials();
|
|
1374
|
-
|
|
1375
|
-
console.log(`[kumo-cli] credentials cleared at ${credentialsPath()}`);
|
|
1376
|
-
else
|
|
1377
|
-
console.log(`[kumo-cli] no credentials to clear`);
|
|
91
|
+
console.log(removed ? `[kumo-cli] credentials cleared at ${credentialsPath()}` : `[kumo-cli] no credentials to clear`);
|
|
1378
92
|
return;
|
|
1379
93
|
}
|
|
1380
|
-
if (!claudeArgs.includes('--dangerously-skip-permissions'))
|
|
94
|
+
if (!claudeArgs.includes('--dangerously-skip-permissions'))
|
|
1381
95
|
claudeArgs.push('--dangerously-skip-permissions');
|
|
1382
|
-
|
|
1383
|
-
|
|
96
|
+
let ws;
|
|
97
|
+
const send = (data) => ws.send(data);
|
|
98
|
+
const model = new ModelService();
|
|
99
|
+
const effort = new EffortService();
|
|
100
|
+
effort.load();
|
|
101
|
+
const terminals = new TerminalManager((terminalId, data) => send({ type: 'terminal_output', terminalId, data }), (terminalId, exitCode) => {
|
|
102
|
+
log(`[terminal] ${terminalId} exited with code ${exitCode}`);
|
|
103
|
+
send({ type: 'terminal_closed', terminalId, exitCode });
|
|
104
|
+
});
|
|
105
|
+
const claude = new ClaudeService(send, model, effort, () => activeCredentials?.deviceToken);
|
|
106
|
+
claude.args = claudeArgs;
|
|
107
|
+
claude.onExit = (code) => { ws.close(); process.exit(code); };
|
|
1384
108
|
const hookServer = await startHookServer((sessionId) => {
|
|
1385
109
|
currentSessionId = sessionId;
|
|
1386
|
-
|
|
1387
|
-
|
|
110
|
+
claude.resetSnapshot();
|
|
111
|
+
send({ type: 'session_start', sessionId });
|
|
1388
112
|
scanner?.stop();
|
|
1389
113
|
scanner = new SessionScanner(sessionId, startScannerAtEnd);
|
|
1390
114
|
startScannerAtEnd = false;
|
|
1391
115
|
scanner.on('message', (msg) => {
|
|
1392
|
-
|
|
116
|
+
send({ type: 'transcript', message: msg });
|
|
1393
117
|
if (msg.role === 'assistant' && msg.stopReason === 'end_turn') {
|
|
1394
118
|
log(`[scanner] assistant_done detected, stopReason=${msg.stopReason}`);
|
|
1395
|
-
|
|
119
|
+
send({ type: 'assistant_done' });
|
|
1396
120
|
}
|
|
1397
121
|
});
|
|
1398
122
|
scanner.start();
|
|
1399
123
|
if (pendingPrompt) {
|
|
1400
124
|
const queued = pendingPrompt;
|
|
1401
125
|
pendingPrompt = null;
|
|
1402
|
-
dispatchPrompt(queued);
|
|
126
|
+
claude.dispatchPrompt(queued);
|
|
1403
127
|
}
|
|
1404
128
|
});
|
|
1405
|
-
process.stdout.on('resize', () =>
|
|
1406
|
-
|
|
1407
|
-
});
|
|
1408
|
-
claudeSettingsPath = generateHookSettingsFile(hookServer.port);
|
|
129
|
+
process.stdout.on('resize', () => claude.resizeSnapshot());
|
|
130
|
+
claude.settingsPath = generateHookSettingsFile(hookServer.port);
|
|
1409
131
|
activeCredentials = loadCredentials();
|
|
1410
132
|
if (activeCredentials) {
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
if (!reachable) {
|
|
1414
|
-
console.error(`[kumo-cli] cannot reach backend at ${SERVER_HTTP}. check KUMO_SERVER_URL and that the server is running.`);
|
|
133
|
+
if (!await checkBackendReachable()) {
|
|
134
|
+
console.error(`[kumo-cli] cannot reach backend at ${SERVER_HTTP()}. check KUMO_SERVER_URL and that the server is running.`);
|
|
1415
135
|
hookServer.close();
|
|
1416
136
|
process.exit(1);
|
|
1417
137
|
}
|
|
@@ -1427,10 +147,8 @@ async function main() {
|
|
|
1427
147
|
}
|
|
1428
148
|
}
|
|
1429
149
|
if (!activeCredentials) {
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
if (!reachable) {
|
|
1433
|
-
console.error(`[kumo-cli] cannot reach backend at ${SERVER_HTTP}. check KUMO_SERVER_URL and that the server is running.`);
|
|
150
|
+
if (!await checkBackendReachable()) {
|
|
151
|
+
console.error(`[kumo-cli] cannot reach backend at ${SERVER_HTTP()}. check KUMO_SERVER_URL and that the server is running.`);
|
|
1434
152
|
hookServer.close();
|
|
1435
153
|
process.exit(1);
|
|
1436
154
|
}
|
|
@@ -1442,10 +160,48 @@ async function main() {
|
|
|
1442
160
|
}
|
|
1443
161
|
}
|
|
1444
162
|
console.log(`\x1b[32m✓ Connected as ${activeCredentials.user?.email ?? activeCredentials.user?.id ?? 'device'}\x1b[0m\n`);
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
163
|
+
const router = createMessageRouter({
|
|
164
|
+
send,
|
|
165
|
+
claude, model, effort, terminals,
|
|
166
|
+
onPaired: () => {
|
|
167
|
+
if (pairedResolved)
|
|
168
|
+
return;
|
|
169
|
+
pairedResolved = true;
|
|
170
|
+
pairedResolve?.();
|
|
171
|
+
startFileWatcher(send);
|
|
172
|
+
},
|
|
173
|
+
onAuthError: () => { ws.close(); },
|
|
174
|
+
scheduleScannerAtEnd: () => { startScannerAtEnd = true; },
|
|
175
|
+
setCurrentSessionId: (id) => { currentSessionId = id; },
|
|
176
|
+
getCurrentSessionId: () => currentSessionId,
|
|
177
|
+
setPendingPrompt: (text) => { pendingPrompt = text; },
|
|
178
|
+
});
|
|
179
|
+
ws = new WsClient(async () => {
|
|
180
|
+
send({
|
|
181
|
+
type: 'register',
|
|
182
|
+
clientType: 'cli',
|
|
183
|
+
deviceToken: activeCredentials?.deviceToken,
|
|
184
|
+
deviceUuid: activeCredentials?.deviceUuid,
|
|
185
|
+
cwd: process.cwd(),
|
|
186
|
+
});
|
|
187
|
+
claude.resetMenu();
|
|
188
|
+
startFileWatcher(send);
|
|
189
|
+
const sessions = await sessionService.getSessionListForCwd();
|
|
190
|
+
const activeIds = await sessionService.getActiveSessionIds();
|
|
191
|
+
if (currentSessionId)
|
|
192
|
+
activeIds.add(currentSessionId);
|
|
193
|
+
send({ type: 'session_list', sessions: sessions.map((s) => ({ ...s, isActive: activeIds.has(s.sessionId) })) });
|
|
194
|
+
}, router, () => {
|
|
195
|
+
if (fileWatcher) {
|
|
196
|
+
try {
|
|
197
|
+
fileWatcher.close();
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
fileWatcher = null;
|
|
201
|
+
}
|
|
1448
202
|
});
|
|
203
|
+
ws.onSent(() => claude.notifySent());
|
|
204
|
+
ws.connect();
|
|
1449
205
|
}
|
|
1450
206
|
main().catch((err) => {
|
|
1451
207
|
console.error('[kumo-cli] fatal error:', err);
|