kumo-cli 1.0.0 → 1.0.2

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.
Files changed (66) hide show
  1. package/README.md +21 -32
  2. package/dist/auth/credentialsStore.js +41 -0
  3. package/dist/auth/credentialsStore.js.map +1 -0
  4. package/dist/claude/generateHookSettings.js +23 -0
  5. package/dist/claude/generateHookSettings.js.map +1 -0
  6. package/dist/claude/hookServer.js +33 -0
  7. package/dist/claude/hookServer.js.map +1 -0
  8. package/dist/claude/sessionScanner.js +250 -0
  9. package/dist/claude/sessionScanner.js.map +1 -0
  10. package/dist/config.js +40 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/core/config.js +48 -0
  13. package/dist/core/config.js.map +1 -0
  14. package/dist/core/loadDotEnv.js +46 -0
  15. package/dist/core/loadDotEnv.js.map +1 -0
  16. package/dist/core/logger.js +29 -0
  17. package/dist/core/logger.js.map +1 -0
  18. package/dist/handlers/messageRouter.js +223 -0
  19. package/dist/handlers/messageRouter.js.map +1 -0
  20. package/dist/http/httpClient.js +41 -0
  21. package/dist/http/httpClient.js.map +1 -0
  22. package/dist/index.js +141 -1090
  23. package/dist/index.js.map +1 -1
  24. package/dist/logger.js +29 -0
  25. package/dist/logger.js.map +1 -0
  26. package/dist/pty/ansiCodes.js +14 -0
  27. package/dist/pty/ansiCodes.js.map +1 -0
  28. package/dist/pty/ptyMenuParser.js +357 -0
  29. package/dist/pty/ptyMenuParser.js.map +1 -0
  30. package/dist/pty/ptySnapshotRenderer.js +71 -0
  31. package/dist/pty/ptySnapshotRenderer.js.map +1 -0
  32. package/dist/pty/spawn.js +77 -0
  33. package/dist/pty/spawn.js.map +1 -0
  34. package/dist/pty/terminalManager.js +66 -0
  35. package/dist/pty/terminalManager.js.map +1 -0
  36. package/dist/services/claudeService.js +218 -0
  37. package/dist/services/claudeService.js.map +1 -0
  38. package/dist/services/effortService.js +89 -0
  39. package/dist/services/effortService.js.map +1 -0
  40. package/dist/services/fileService.js +127 -0
  41. package/dist/services/fileService.js.map +1 -0
  42. package/dist/services/modelService.js +84 -0
  43. package/dist/services/modelService.js.map +1 -0
  44. package/dist/services/pairingService.js +129 -0
  45. package/dist/services/pairingService.js.map +1 -0
  46. package/dist/services/sessionService.js +168 -0
  47. package/dist/services/sessionService.js.map +1 -0
  48. package/dist/services/tunnelService.js +47 -0
  49. package/dist/services/tunnelService.js.map +1 -0
  50. package/dist/sessionScanner.js +131 -5
  51. package/dist/sessionScanner.js.map +1 -1
  52. package/dist/snapshotScanner.js +3 -1
  53. package/dist/snapshotScanner.js.map +1 -1
  54. package/dist/spawn.js +12 -2
  55. package/dist/spawn.js.map +1 -1
  56. package/dist/transport/directWsServer.js +135 -0
  57. package/dist/transport/directWsServer.js.map +1 -0
  58. package/dist/transport/wsClient.js +87 -0
  59. package/dist/transport/wsClient.js.map +1 -0
  60. package/dist/utils/ignorePaths.js +54 -0
  61. package/dist/utils/ignorePaths.js.map +1 -0
  62. package/dist/utils/ptyBuffer.js +46 -0
  63. package/dist/utils/ptyBuffer.js.map +1 -0
  64. package/dist/utils/safePath.js +43 -0
  65. package/dist/utils/safePath.js.map +1 -0
  66. package/package.json +2 -4
package/dist/index.js CHANGED
@@ -1,89 +1,56 @@
1
1
  #!/usr/bin/env node
2
- import WebSocket from 'ws';
3
- import http from 'http';
4
2
  import fs from 'node:fs';
5
- import path from 'node:path';
6
- import os from 'node:os';
7
- import { fileURLToPath } from 'node:url';
8
- import { startHookServer } from './hookServer.js';
9
- import { generateHookSettingsFile } from './generateHookSettings.js';
10
- import { spawnClaude, writeToProcess } from './spawn.js';
11
- import { SessionScanner } from './sessionScanner.js';
12
- import { PtyMenuParser } from './ptyMenuParser.js';
13
- import { PtySnapshotRenderer } from './ptySnapshotRenderer.js';
14
- import { loadDotEnv } from './loadDotEnv.js';
15
- import { TerminalManager } from './terminalManager.js';
16
- import { loadIgnoredNames } from './ignorePaths.js';
17
- import { loadCredentials, saveCredentials, clearCredentials, credentialsPath } from './credentialsStore.js';
18
- import readline from 'node:readline';
19
- import { randomUUID } from 'node:crypto';
20
- loadDotEnv();
21
- function normalizeServerHttpUrl(input) {
22
- const u = new URL(input);
23
- if (u.protocol === 'ws:')
24
- u.protocol = 'http:';
25
- if (u.protocol === 'wss:')
26
- u.protocol = 'https:';
27
- if (u.pathname === '/ws')
28
- u.pathname = '/';
29
- if (u.pathname.endsWith('/ws'))
30
- u.pathname = u.pathname.slice(0, -3) || '/';
31
- return u.toString().replace(/\/$/, '');
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 };
40
+ }
41
+ const { envFilePath, cliArgs: bootArgs } = extractEnvFileArg(process.argv.slice(2));
42
+ if (envFilePath) {
43
+ loadDotEnv(envFilePath);
32
44
  }
33
- function normalizeServerWsUrl(input) {
34
- const u = new URL(input);
35
- if (u.protocol === 'http:')
36
- u.protocol = 'ws:';
37
- if (u.protocol === 'https:')
38
- u.protocol = 'wss:';
39
- if (u.pathname === '/' || u.pathname === '')
40
- u.pathname = '/ws';
41
- return u.toString().replace(/\/$/, '');
42
- }
43
- const serverUrl = process.env.KUMO_SERVER_URL;
44
- const SERVER_HTTP = process.env.KUMO_SERVER_HTTP_URL
45
- ?? (serverUrl ? normalizeServerHttpUrl(serverUrl) : 'http://localhost:3579');
46
- const SERVER_WS = process.env.KUMO_SERVER_WS_URL
47
- ?? (serverUrl ? normalizeServerWsUrl(serverUrl) : 'ws://localhost:3579/ws');
48
- const SERVER_PORT = parseInt(process.env.KUMO_PORT ?? '3579', 10);
49
- let ws = null;
50
- let claudeProcess = null;
51
- let spawnCleanup = null;
52
- let scanner = null;
53
- let reconnectTimer = null;
54
- let claudeSettingsPath = '';
55
- let claudeArgs = [];
45
+ let activeCredentials = null;
56
46
  let currentSessionId = null;
57
- const SNAPSHOT_DIR = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..', 'claude-data-snapshot');
58
- let snapshotScanner = null;
59
47
  let pendingPrompt = null;
60
- let lastWsSendAt = 0;
61
- let pendingSelectNudgeTimer = null;
62
- function safePath(requestedPath) {
63
- const projectRoot = process.cwd();
64
- const resolved = path.resolve(projectRoot, path.normalize(requestedPath || '.'));
65
- if (!resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot) {
66
- throw new Error('path traversal blocked');
67
- }
68
- return resolved;
69
- }
70
- function copyRecursive(src, dest) {
71
- const stat = fs.statSync(src);
72
- if (stat.isDirectory()) {
73
- fs.mkdirSync(dest, { recursive: true });
74
- for (const child of fs.readdirSync(src)) {
75
- copyRecursive(path.join(src, child), path.join(dest, child));
76
- }
77
- }
78
- else {
79
- fs.copyFileSync(src, dest);
80
- }
81
- }
48
+ let startScannerAtEnd = false;
49
+ let scanner = null;
82
50
  let fileWatcher = null;
83
- const IGNORED_PATHS = loadIgnoredNames(process.cwd());
84
- const fileWatchDebounceMap = new Map();
85
- const FILE_WATCH_DEBOUNCE_MS = 300;
86
- function startFileWatcher() {
51
+ let pairedResolved = false;
52
+ let pairedResolve = null;
53
+ function startFileWatcher(send) {
87
54
  if (fileWatcher) {
88
55
  try {
89
56
  fileWatcher.close();
@@ -92,20 +59,22 @@ function startFileWatcher() {
92
59
  fileWatcher = null;
93
60
  }
94
61
  const projectRoot = process.cwd();
62
+ const ignored = loadIgnoredNames(projectRoot);
63
+ const debounceMap = new Map();
95
64
  try {
96
65
  fileWatcher = fs.watch(projectRoot, { recursive: true }, (event, filename) => {
97
66
  if (!filename)
98
67
  return;
99
68
  const normalized = filename.replace(/\\/g, '/');
100
69
  const parts = normalized.split('/');
101
- if (parts.some(p => IGNORED_PATHS.has(p) || p.startsWith('.')))
70
+ if (parts.some((p) => ignored.has(p) || p.startsWith('.')))
102
71
  return;
103
- const prev = fileWatchDebounceMap.get(normalized);
72
+ const prev = debounceMap.get(normalized);
104
73
  if (prev)
105
74
  clearTimeout(prev);
106
- fileWatchDebounceMap.set(normalized, setTimeout(() => {
107
- fileWatchDebounceMap.delete(normalized);
108
- sendToServer({ type: 'file_changed', path: normalized, event });
75
+ debounceMap.set(normalized, setTimeout(() => {
76
+ debounceMap.delete(normalized);
77
+ send({ type: 'file_changed', path: normalized, event });
109
78
  log(`[watcher] ${event}: ${normalized}`);
110
79
  }, FILE_WATCH_DEBOUNCE_MS));
111
80
  });
@@ -115,1042 +84,124 @@ function startFileWatcher() {
115
84
  log(`[watcher] failed to start: ${e}`);
116
85
  }
117
86
  }
118
- let autoAcceptedApiKeyDialog = false;
119
- let isRespawning = false;
120
- let startScannerAtEnd = false;
121
- let sessionSwitchCounter = 0;
122
- const terminalManager = new TerminalManager((terminalId, data) => {
123
- sendToServer({ type: 'terminal_output', terminalId, data });
124
- }, (terminalId, exitCode) => {
125
- log(`[terminal] ${terminalId} exited with code ${exitCode}`);
126
- sendToServer({ type: 'terminal_closed', terminalId, exitCode });
127
- });
128
- let pairedResolve = null;
129
- const pairedPromise = new Promise((resolve) => { pairedResolve = resolve; });
130
- const PROJECT_TMP_DIR = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'tmp');
131
- const LOGS_DIR = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'logs');
132
- if (!fs.existsSync(PROJECT_TMP_DIR))
133
- fs.mkdirSync(PROJECT_TMP_DIR, { recursive: true });
134
- if (!fs.existsSync(LOGS_DIR))
135
- fs.mkdirSync(LOGS_DIR, { recursive: true });
136
- const sessionStart = new Date().toISOString().replace(/[:.]/g, '-');
137
- const debugLog = fs.createWriteStream(path.join(PROJECT_TMP_DIR, 'kumo-pty-debug.log'), { flags: 'w' });
138
- const eventLog = fs.createWriteStream(path.join(LOGS_DIR, `cli-${sessionStart}.log`), { flags: 'w' });
139
- function log(msg) {
140
- const line = `[${new Date().toISOString()}] ${msg}`;
141
- eventLog.write(line + '\n');
142
- }
143
- let ptyBuffer = '';
144
- let ptyFlushTimer = null;
145
- const PTY_FLUSH_MS = 120;
146
- const PTY_MAX_CHUNK = 8000;
147
- let ptySnapshotRenderer = new PtySnapshotRenderer(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
148
- function sanitizePtyPreview(data) {
149
- return data
150
- .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
151
- .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
152
- .replace(/\s+/g, ' ')
153
- .slice(0, 100);
154
- }
155
- function flushPtyUpdates() {
156
- if (!ptyBuffer)
157
- return;
158
- ptyBuffer = '';
159
- ptySnapshotRenderer.flushSnapshot();
160
- }
161
- function queuePtyData(chunk) {
162
- ptyBuffer += chunk;
163
- ptySnapshotRenderer.feed(chunk);
164
- if (ptyBuffer.length >= PTY_MAX_CHUNK) {
165
- if (ptyFlushTimer) {
166
- clearTimeout(ptyFlushTimer);
167
- ptyFlushTimer = null;
168
- }
169
- flushPtyUpdates();
170
- return;
171
- }
172
- if (!ptyFlushTimer) {
173
- ptyFlushTimer = setTimeout(() => {
174
- ptyFlushTimer = null;
175
- flushPtyUpdates();
176
- }, PTY_FLUSH_MS);
177
- }
178
- }
179
- let pingInterval = null;
180
- let menuIdCounter = 0;
181
- const menuParser = new PtyMenuParser((element) => {
182
- switch (element.type) {
183
- case 'selection':
184
- if (element.options.length === 0) {
185
- log(`[parser] ignoring empty menu`);
186
- break;
187
- }
188
- if (element.title.includes('Bypass Permissions')) {
189
- const yesIndex = element.options.findIndex((o) => o.label.trim().toLowerCase().includes('yes'));
190
- if (yesIndex >= 0 && claudeProcess) {
191
- const delta = yesIndex - element.selectedIndex;
192
- const key = delta < 0 ? '\x1b[A' : '\x1b[B';
193
- for (let i = 0; i < Math.abs(delta); i++) {
194
- claudeProcess.write(key);
195
- }
196
- claudeProcess.write('\r');
197
- log(`[parser] auto-accepted bypass permissions dialog`);
198
- break;
199
- }
200
- }
201
- if (!autoAcceptedApiKeyDialog && (element.title.includes('Detected a custom API key') || element.title.includes('Do you want to use this API key'))) {
202
- const yesIndex = element.options.findIndex((o) => o.label.trim().toLowerCase() === 'yes');
203
- if (yesIndex >= 0 && claudeProcess) {
204
- autoAcceptedApiKeyDialog = true;
205
- const delta = yesIndex - element.selectedIndex;
206
- const key = delta < 0 ? '\x1b[A' : '\x1b[B';
207
- for (let i = 0; i < Math.abs(delta); i++) {
208
- claudeProcess.write(key);
209
- }
210
- claudeProcess.write('\r');
211
- log(`[parser] auto-accepted custom api key dialog`);
212
- break;
213
- }
214
- }
215
- menuIdCounter++;
216
- const id = `menu-${menuIdCounter}`;
217
- log(`[parser] menu detected: "${element.title}" (${element.options.length} opts, selected: ${element.selectedIndex})`);
218
- log(`[parser] options: ${JSON.stringify(element.options)}`);
219
- sendToServer({
220
- type: 'prompt_options',
221
- id,
222
- title: element.title,
223
- description: element.description,
224
- options: element.options,
225
- selectedIndex: element.selectedIndex,
226
- hint: element.hint,
227
- footer: element.footer,
228
- });
229
- break;
230
- case 'input':
231
- log(`[parser] input: "${element.label}" = "${element.value}"`);
232
- sendToServer({
233
- type: 'text_input',
234
- label: element.label,
235
- value: element.value,
236
- placeholder: element.placeholder,
237
- cursorPosition: element.cursorPosition,
238
- });
239
- break;
240
- case 'notification':
241
- log(`[parser] notification: [${element.level}] ${element.message}`);
242
- sendToServer({
243
- type: 'notification',
244
- level: element.level,
245
- message: element.message,
246
- });
247
- break;
248
- case 'status':
249
- log(`[parser] status: model=${element.model}, effort=${element.effort}, logged=${element.loggedIn}`);
250
- sendToServer({
251
- type: 'cli_status',
252
- loggedIn: element.loggedIn,
253
- model: element.model,
254
- effort: element.effort,
255
- workingDir: element.workingDir,
256
- });
257
- break;
258
- case 'session':
259
- log(`[parser] session: ${element.sessionId}`);
260
- sendToServer({
261
- type: 'session_info',
262
- sessionId: element.sessionId,
263
- resumeCommand: element.resumeCommand,
264
- });
265
- break;
266
- case 'prompt':
267
- break;
268
- }
269
- });
270
- function sendToServer(data) {
271
- if (ws && ws.readyState === WebSocket.OPEN) {
272
- lastWsSendAt = Date.now();
273
- ws.send(JSON.stringify(data));
274
- }
275
- }
276
- function dispatchPrompt(text) {
277
- if (!claudeProcess)
278
- return;
279
- if (text.trim() === '/login') {
280
- menuParser.reset();
281
- }
282
- writeToProcess(claudeProcess, text);
283
- }
284
- function handleSelectOption(value) {
285
- if (!claudeProcess) {
286
- log(`[select_option] no claude process!`);
287
- return;
288
- }
289
- log(`[select_option] sending: "${value}" (without Enter)`);
290
- menuParser.reset();
291
- claudeProcess.write(value);
292
- if (pendingSelectNudgeTimer)
293
- clearTimeout(pendingSelectNudgeTimer);
294
- const baseline = lastWsSendAt;
295
- pendingSelectNudgeTimer = setTimeout(() => {
296
- pendingSelectNudgeTimer = null;
297
- if (!claudeProcess)
298
- return;
299
- if (lastWsSendAt === baseline)
300
- nudgeResize();
301
- }, 1000);
302
- }
303
- function nudgeResize() {
304
- if (!claudeProcess)
305
- return;
306
- const cols = process.stdout.columns ?? 120;
307
- const rows = process.stdout.rows ?? 30;
308
- const deltaCols = 1;
309
- const backDelayMs = 5;
310
- try {
311
- claudeProcess.resize(cols + deltaCols, rows);
312
- setTimeout(() => {
313
- try {
314
- claudeProcess?.resize(cols, rows);
315
- }
316
- catch { }
317
- }, backDelayMs);
318
- }
319
- catch { }
320
- }
321
- function removeResumeArgs(args) {
322
- const result = [];
323
- for (let i = 0; i < args.length; i++) {
324
- if (args[i] === '--resume' || args[i] === '-r') {
325
- i++;
326
- }
327
- else {
328
- result.push(args[i]);
329
- }
330
- }
331
- return result;
332
- }
333
- function spawnClaudeNow() {
334
- log(`[spawn] spawning claude, args=${JSON.stringify(claudeArgs)}, cwd=${process.cwd()}`);
335
- if (claudeProcess) {
336
- isRespawning = true;
337
- try {
338
- claudeProcess.kill();
339
- }
340
- catch { }
341
- claudeProcess = null;
342
- }
343
- spawnCleanup?.();
344
- spawnCleanup = null;
345
- scanner?.stop();
346
- scanner = null;
347
- snapshotScanner?.stop();
348
- snapshotScanner = null;
349
- autoAcceptedApiKeyDialog = false;
350
- ptySnapshotRenderer.reset(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
351
- const { pty: child, cleanup } = spawnClaude([...claudeArgs, '--settings', claudeSettingsPath], (active) => sendToServer({ type: 'thinking', active }), (data) => {
352
- debugLog.write(data);
353
- menuParser.feed(data);
354
- queuePtyData(data);
355
- }, (cols, rows) => menuParser.resize(cols, rows), (pty) => { claudeProcess = pty; });
356
- spawnCleanup = cleanup;
357
- child.onExit(({ exitCode }) => {
358
- log(`[spawn] claude exited, exitCode=${exitCode}, isRespawning=${isRespawning}`);
359
- flushPtyUpdates();
360
- scanner?.stop();
361
- if (isRespawning) {
362
- isRespawning = false;
363
- return;
364
- }
365
- if (reconnectTimer)
366
- clearTimeout(reconnectTimer);
367
- ws?.close();
368
- process.exit(exitCode);
369
- });
370
- process.on('SIGINT', () => {
371
- child.kill();
372
- });
373
- }
374
- let activeCredentials = null;
375
- function connectToServer(onPaired, onOpen) {
376
- ws = new WebSocket(SERVER_WS);
377
- ws.on('open', () => {
378
- log(`[ws] connected to ${SERVER_WS}`);
379
- sendToServer({
380
- type: 'register',
381
- clientType: 'cli',
382
- deviceToken: activeCredentials?.deviceToken,
383
- deviceUuid: activeCredentials?.deviceUuid,
384
- });
385
- menuParser.reset();
386
- if (pingInterval)
387
- clearInterval(pingInterval);
388
- pingInterval = setInterval(() => {
389
- if (ws && ws.readyState === WebSocket.OPEN) {
390
- ws.ping();
391
- }
392
- }, 20000);
393
- if (onOpen) {
394
- Promise.resolve(onOpen()).catch((e) => log(`[onOpen] error: ${e}`));
395
- }
396
- });
397
- ws.on('ping', () => {
398
- ws?.pong();
399
- });
400
- ws.on('message', (raw) => {
401
- let msg;
402
- try {
403
- msg = JSON.parse(raw.toString());
404
- }
405
- catch {
406
- return;
407
- }
408
- if (msg['type'] === 'paired') {
409
- log(`[pairing] paired with app`);
410
- console.log('\n\x1b[32m✓ App paired! Launching Claude...\x1b[0m\n');
411
- pairedResolve?.();
412
- pairedResolve = null;
413
- startFileWatcher();
414
- onPaired?.();
415
- return;
416
- }
417
- if (msg['type'] === 'send_prompt') {
418
- const text = msg['text'];
419
- if (!text)
420
- return;
421
- if (claudeProcess) {
422
- dispatchPrompt(text);
423
- }
424
- else {
425
- pendingPrompt = text;
426
- }
427
- }
428
- if (msg['type'] === 'select_option') {
429
- const value = msg['value'];
430
- log(`[ws recv] select_option: value="${value}"`);
431
- if (value)
432
- handleSelectOption(value);
433
- }
434
- if (msg['type'] === 'submit_input') {
435
- const value = msg['value'];
436
- log(`[ws recv] submit_input: value="${value}"`);
437
- if (claudeProcess) {
438
- menuParser.reset();
439
- claudeProcess.write('\x15');
440
- claudeProcess.write(value);
441
- claudeProcess.write('\r');
442
- }
443
- }
444
- if (msg['type'] === 'send_key') {
445
- const key = msg['key'];
446
- log(`[ws recv] send_key: key="${key}"`);
447
- if (claudeProcess && key === 'esc') {
448
- menuParser.reset();
449
- claudeProcess.write('\x1b');
450
- }
451
- }
452
- if (msg['type'] === 'nudge_resize') {
453
- nudgeResize();
454
- }
455
- if (msg['type'] === 'request_session_list') {
456
- sendSessionList().catch((e) => log(`[request_session_list] error: ${e}`));
457
- return;
458
- }
459
- if (msg['type'] === 'request_session_history') {
460
- handleSessionHistoryRequest(msg['sessionId']).catch((e) => log(`[session_history] error: ${e}`));
461
- return;
462
- }
463
- if (msg['type'] === 'request_new_session') {
464
- log(`[ws recv] request_new_session`);
465
- claudeArgs = removeResumeArgs(claudeArgs);
466
- startScannerAtEnd = false;
467
- spawnClaudeNow();
468
- return;
469
- }
470
- if (msg['type'] === 'terminal_create') {
471
- const cols = msg['cols'] || 120;
472
- const rows = msg['rows'] || 30;
473
- const cwd = msg['cwd'];
474
- const terminalId = terminalManager.create(cols, rows, cwd);
475
- log(`[terminal] created: ${terminalId}`);
476
- sendToServer({ type: 'terminal_created', terminalId });
477
- return;
478
- }
479
- if (msg['type'] === 'terminal_input') {
480
- const terminalId = msg['terminalId'];
481
- const data = msg['data'];
482
- if (terminalId && data) {
483
- terminalManager.write(terminalId, data);
484
- }
485
- return;
486
- }
487
- if (msg['type'] === 'terminal_resize') {
488
- const terminalId = msg['terminalId'];
489
- const cols = msg['cols'];
490
- const rows = msg['rows'];
491
- if (terminalId && cols && rows) {
492
- terminalManager.resize(terminalId, cols, rows);
493
- log(`[terminal] resize ${terminalId} -> ${cols}x${rows}`);
494
- }
495
- return;
496
- }
497
- if (msg['type'] === 'terminal_close') {
498
- const terminalId = msg['terminalId'];
499
- if (terminalId) {
500
- terminalManager.close(terminalId);
501
- log(`[terminal] closed: ${terminalId}`);
502
- }
503
- return;
504
- }
505
- if (msg['type'] === 'terminal_list') {
506
- const terminals = terminalManager.list();
507
- sendToServer({ type: 'terminal_list', terminals });
508
- return;
509
- }
510
- if (msg['type'] === 'file_search') {
511
- // recursively search files/folders by name (case-insensitive substring)
512
- const query = (msg['query'] || '').trim().toLowerCase();
513
- const basePath = msg['path'] || '';
514
- const limit = Math.min(Math.max(msg['limit'] || 200, 1), 1000);
515
- try {
516
- const projectRoot = process.cwd();
517
- const baseResolved = safePath(basePath || '.');
518
- const ignored = loadIgnoredNames(projectRoot);
519
- const results = [];
520
- if (query.length === 0) {
521
- sendToServer({ type: 'file_search_result', query, entries: [] });
522
- return;
523
- }
524
- const stack = [baseResolved];
525
- while (stack.length > 0 && results.length < limit) {
526
- const dir = stack.pop();
527
- let dirEntries;
528
- try {
529
- dirEntries = fs.readdirSync(dir, { withFileTypes: true });
530
- }
531
- catch {
532
- continue;
533
- }
534
- for (const e of dirEntries) {
535
- if (e.name.startsWith('.'))
536
- continue;
537
- if (ignored.has(e.name))
538
- continue;
539
- const full = path.resolve(dir, e.name);
540
- if (e.name.toLowerCase().includes(query)) {
541
- let size = 0;
542
- if (!e.isDirectory()) {
543
- try {
544
- size = fs.statSync(full).size;
545
- }
546
- catch { }
547
- }
548
- results.push({
549
- name: e.name,
550
- path: path.relative(projectRoot, full).replace(/\\/g, '/'),
551
- isDirectory: e.isDirectory(),
552
- size,
553
- });
554
- if (results.length >= limit)
555
- break;
556
- }
557
- if (e.isDirectory())
558
- stack.push(full);
559
- }
560
- }
561
- results.sort((a, b) => {
562
- if (a.isDirectory !== b.isDirectory)
563
- return a.isDirectory ? -1 : 1;
564
- return a.name.localeCompare(b.name);
565
- });
566
- sendToServer({ type: 'file_search_result', query, entries: results });
567
- }
568
- catch (e) {
569
- sendToServer({ type: 'file_search_result', query, entries: [], error: e instanceof Error ? e.message : String(e) });
570
- }
571
- return;
572
- }
573
- if (msg['type'] === 'file_list') {
574
- const requestedPath = msg['path'] || '';
575
- try {
576
- const projectRoot = process.cwd();
577
- const resolved = safePath(requestedPath || '.');
578
- if (!resolved.startsWith(projectRoot)) {
579
- sendToServer({ type: 'file_list_result', path: requestedPath, entries: [], error: 'path traversal blocked' });
580
- return;
581
- }
582
- const entries = fs.readdirSync(resolved, { withFileTypes: true })
583
- .filter(e => !e.name.startsWith('.'))
584
- .map(e => ({
585
- name: e.name,
586
- path: path.relative(projectRoot, path.resolve(resolved, e.name)).replace(/\\/g, '/'),
587
- isDirectory: e.isDirectory(),
588
- size: e.isDirectory() ? 0 : fs.statSync(path.resolve(resolved, e.name)).size,
589
- }))
590
- .sort((a, b) => {
591
- if (a.isDirectory !== b.isDirectory)
592
- return a.isDirectory ? -1 : 1;
593
- return a.name.localeCompare(b.name);
594
- });
595
- sendToServer({ type: 'file_list_result', path: requestedPath, entries, projectName: path.basename(process.cwd()) });
596
- }
597
- catch (e) {
598
- sendToServer({ type: 'file_list_result', path: requestedPath, entries: [], error: e instanceof Error ? e.message : String(e) });
599
- }
600
- return;
601
- }
602
- if (msg['type'] === 'file_read') {
603
- const filePath = msg['path'];
604
- try {
605
- const projectRoot = process.cwd();
606
- const resolved = path.resolve(projectRoot, path.normalize(filePath || '.'));
607
- if (!resolved.startsWith(projectRoot)) {
608
- sendToServer({ type: 'file_read_result', path: filePath, error: 'path traversal blocked' });
609
- return;
610
- }
611
- const content = fs.readFileSync(resolved, 'utf-8');
612
- sendToServer({ type: 'file_read_result', path: filePath, content });
613
- }
614
- catch (e) {
615
- sendToServer({ type: 'file_read_result', path: filePath, error: e instanceof Error ? e.message : String(e) });
616
- }
617
- return;
618
- }
619
- if (msg['type'] === 'file_write') {
620
- const filePath = msg['path'];
621
- const content = msg['content'];
622
- try {
623
- const projectRoot = process.cwd();
624
- const resolved = path.resolve(projectRoot, path.normalize(filePath || '.'));
625
- if (!resolved.startsWith(projectRoot)) {
626
- sendToServer({ type: 'file_write_result', path: filePath, ok: false, error: 'path traversal blocked' });
627
- return;
628
- }
629
- fs.writeFileSync(resolved, content, 'utf-8');
630
- sendToServer({ type: 'file_write_result', path: filePath, ok: true });
631
- }
632
- catch (e) {
633
- sendToServer({ type: 'file_write_result', path: filePath, ok: false, error: e instanceof Error ? e.message : String(e) });
634
- }
635
- return;
636
- }
637
- if (msg['type'] === 'file_create') {
638
- const targetPath = msg['path'];
639
- const isDir = msg['isDirectory'] ?? false;
640
- try {
641
- const resolved = safePath(targetPath);
642
- if (isDir) {
643
- fs.mkdirSync(resolved, { recursive: true });
644
- }
645
- else {
646
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
647
- fs.writeFileSync(resolved, '', 'utf-8');
648
- }
649
- sendToServer({ type: 'file_op_result', op: 'create', path: targetPath, ok: true });
650
- }
651
- catch (e) {
652
- sendToServer({ type: 'file_op_result', op: 'create', path: targetPath, ok: false, error: e instanceof Error ? e.message : String(e) });
653
- }
654
- return;
655
- }
656
- if (msg['type'] === 'file_delete') {
657
- const targetPath = msg['path'];
658
- try {
659
- const resolved = safePath(targetPath);
660
- fs.rmSync(resolved, { recursive: true, force: true });
661
- sendToServer({ type: 'file_op_result', op: 'delete', path: targetPath, ok: true });
662
- }
663
- catch (e) {
664
- sendToServer({ type: 'file_op_result', op: 'delete', path: targetPath, ok: false, error: e instanceof Error ? e.message : String(e) });
665
- }
666
- return;
667
- }
668
- if (msg['type'] === 'file_rename') {
669
- const oldPath = msg['oldPath'];
670
- const newName = msg['newName'];
671
- try {
672
- const resolvedOld = safePath(oldPath);
673
- const resolvedNew = path.join(path.dirname(resolvedOld), newName);
674
- safePath(path.relative(process.cwd(), resolvedNew));
675
- fs.renameSync(resolvedOld, resolvedNew);
676
- sendToServer({ type: 'file_op_result', op: 'rename', path: oldPath, newPath: path.relative(process.cwd(), resolvedNew).replace(/\\/g, '/'), ok: true });
677
- }
678
- catch (e) {
679
- sendToServer({ type: 'file_op_result', op: 'rename', path: oldPath, ok: false, error: e instanceof Error ? e.message : String(e) });
680
- }
681
- return;
682
- }
683
- if (msg['type'] === 'file_copy') {
684
- const srcPath = msg['src'];
685
- const destPath = msg['dest'];
686
- try {
687
- const resolvedSrc = safePath(srcPath);
688
- const resolvedDest = safePath(destPath);
689
- copyRecursive(resolvedSrc, resolvedDest);
690
- sendToServer({ type: 'file_op_result', op: 'copy', path: srcPath, dest: destPath, ok: true });
691
- }
692
- catch (e) {
693
- sendToServer({ type: 'file_op_result', op: 'copy', path: srcPath, ok: false, error: e instanceof Error ? e.message : String(e) });
694
- }
695
- return;
696
- }
697
- if (msg['type'] === 'file_move') {
698
- const srcPath = msg['src'];
699
- const destPath = msg['dest'];
700
- try {
701
- const resolvedSrc = safePath(srcPath);
702
- const resolvedDest = safePath(destPath);
703
- fs.renameSync(resolvedSrc, resolvedDest);
704
- sendToServer({ type: 'file_op_result', op: 'move', path: srcPath, dest: destPath, ok: true });
705
- }
706
- catch (e) {
707
- sendToServer({ type: 'file_op_result', op: 'move', path: srcPath, ok: false, error: e instanceof Error ? e.message : String(e) });
708
- }
709
- return;
710
- }
711
- if (msg['type'] === 'tunnel_request') {
712
- const { requestId, method, path: urlPath, headers: reqHeaders, body: reqBody, port, host: remoteHost } = msg;
713
- const targetHost = remoteHost || 'localhost';
714
- const url = `http://${targetHost}:${port}${urlPath}`;
715
- const filteredHeaders = Object.fromEntries(Object.entries(reqHeaders).filter(([k]) => {
716
- const lower = k.toLowerCase();
717
- return !lower.startsWith('sec-') && !['host', 'connection', 'accept-encoding', 'upgrade-insecure-requests'].includes(lower);
718
- }).map(([k, v]) => {
719
- const lower = k.toLowerCase();
720
- if (lower === 'origin' || lower === 'referer') {
721
- return [k, v.replace(/http:\/\/localhost:\d+/g, `http://${targetHost}:${port}`)];
722
- }
723
- return [k, v];
724
- }));
725
- log(`[tunnel] ${method} ${url}`);
726
- fetch(url, {
727
- method,
728
- headers: filteredHeaders,
729
- body: reqBody && method !== 'GET' && method !== 'HEAD' ? Buffer.from(reqBody, 'base64') : undefined,
730
- redirect: 'manual',
731
- })
732
- .then(async (res) => {
733
- const buf = Buffer.from(await res.arrayBuffer());
734
- log(`[tunnel] response ${res.status} ${url} (${buf.length} bytes)`);
735
- const respHeaders = Object.fromEntries([...res.headers.entries()].filter(([k]) => !['content-encoding', 'transfer-encoding', 'content-length'].includes(k.toLowerCase())));
736
- const remoteOrigin = `http://${targetHost}:${port}`;
737
- if (respHeaders['location']) {
738
- respHeaders['location'] = respHeaders['location']
739
- .replace(remoteOrigin, '');
740
- }
741
- if (respHeaders['set-cookie']) {
742
- respHeaders['set-cookie'] = respHeaders['set-cookie']
743
- .replace(/;\s*domain=[^;]*/gi, '')
744
- .replace(/;\s*secure/gi, '');
745
- }
746
- sendToServer({
747
- type: 'tunnel_response',
748
- requestId,
749
- status: res.status,
750
- headers: respHeaders,
751
- body: buf.toString('base64'),
752
- });
753
- })
754
- .catch((err) => {
755
- sendToServer({
756
- type: 'tunnel_response',
757
- requestId,
758
- status: 502,
759
- headers: {},
760
- body: Buffer.from('Bad Gateway: ' + err.message).toString('base64'),
761
- });
762
- });
763
- return;
764
- }
765
- });
766
- ws.on('close', () => {
767
- log(`[ws] disconnected, reconnecting in 3s`);
768
- if (pingInterval) {
769
- clearInterval(pingInterval);
770
- pingInterval = null;
771
- }
772
- if (fileWatcher) {
773
- try {
774
- fileWatcher.close();
775
- }
776
- catch { }
777
- fileWatcher = null;
778
- }
779
- reconnectTimer = setTimeout(() => connectToServer(), 3000);
780
- });
781
- ws.on('error', () => { });
782
- }
783
- function generateCode() {
784
- return String(Math.floor(100000 + Math.random() * 900000));
785
- }
786
- function httpPost(url, body) {
787
- return new Promise((resolve, reject) => {
788
- const payload = JSON.stringify(body);
789
- const urlObj = new URL(url);
790
- const options = {
791
- hostname: urlObj.hostname,
792
- port: urlObj.port || 80,
793
- path: urlObj.pathname,
794
- method: 'POST',
795
- headers: {
796
- 'Content-Type': 'application/json',
797
- 'Content-Length': Buffer.byteLength(payload),
798
- },
799
- };
800
- const req = http.request(options, (res) => {
801
- let raw = '';
802
- res.on('data', (chunk) => { raw += chunk; });
803
- res.on('end', () => {
804
- try {
805
- resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) });
806
- }
807
- catch {
808
- resolve({ status: res.statusCode ?? 0, data: raw });
809
- }
810
- });
811
- });
812
- req.on('error', reject);
813
- req.write(payload);
814
- req.end();
815
- });
816
- }
817
- function displayCode(code) {
818
- const line = '═'.repeat(40);
819
- console.log('\n\x1b[36m' + line + '\x1b[0m');
820
- console.log('\x1b[36m║\x1b[0m \x1b[1mKumo Pairing Code (valid 5 min)\x1b[0m');
821
- console.log('\x1b[36m║\x1b[0m');
822
- console.log(`\x1b[36m║\x1b[0m \x1b[1;33m${code.slice(0, 3)} ${code.slice(3)}\x1b[0m`);
823
- console.log('\x1b[36m║\x1b[0m');
824
- console.log('\x1b[36m║\x1b[0m Enter this code in the Kumo app');
825
- console.log('\x1b[36m' + line + '\x1b[0m\n');
826
- }
827
- async function registerCode(code) {
828
- try {
829
- const result = await httpPost(`${SERVER_HTTP}/api/pair/register`, {
830
- code,
831
- port: SERVER_PORT,
832
- });
833
- return result.status === 200;
834
- }
835
- catch {
836
- return false;
837
- }
838
- }
839
- async function checkCliStatus() {
840
- return new Promise((resolve) => {
841
- const urlObj = new URL(`${SERVER_HTTP}/api/cli/status`);
842
- const options = {
843
- hostname: urlObj.hostname,
844
- port: urlObj.port || 80,
845
- path: urlObj.pathname,
846
- method: 'GET',
847
- };
848
- const req = http.request(options, (res) => {
849
- let raw = '';
850
- res.on('data', (chunk) => { raw += chunk; });
851
- res.on('end', () => {
852
- try {
853
- resolve(JSON.parse(raw));
854
- }
855
- catch {
856
- resolve({ paired: false, alive: false });
857
- }
858
- });
859
- });
860
- req.on('error', () => resolve({ paired: false, alive: false }));
861
- req.end();
862
- });
863
- }
864
- function getCurrentProjectFolder() {
865
- const cwd = process.cwd();
866
- if (os.platform() === 'win32') {
867
- return cwd.replace(/:/g, '-').replace(/[\\/]/g, '-');
868
- }
869
- return cwd.replace(/\//g, '-');
870
- }
871
- async function getActiveSessionIds() {
872
- const sessionsDir = path.join(os.homedir(), '.claude', 'sessions');
873
- const active = new Set();
874
- try {
875
- const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
876
- for (const file of files) {
877
- try {
878
- const data = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf-8'));
879
- if (data.sessionId)
880
- active.add(data.sessionId);
881
- }
882
- catch { }
883
- }
884
- }
885
- catch { }
886
- return active;
887
- }
888
- async function getSessionListForCwd() {
889
- const folderName = getCurrentProjectFolder();
890
- const projectDir = path.join(os.homedir(), '.claude', 'projects', folderName);
891
- log(`[session_list] cwd=${process.cwd()}, folder=${folderName}, dir=${projectDir}`);
892
- const sessions = [];
893
- try {
894
- if (!fs.existsSync(projectDir))
895
- return sessions;
896
- const files = fs.readdirSync(projectDir).filter((f) => f.endsWith('.jsonl'));
897
- for (const file of files) {
898
- const sessionId = path.basename(file, '.jsonl');
899
- const filePath = path.join(projectDir, file);
900
- try {
901
- const raw = fs.readFileSync(filePath, 'utf-8');
902
- const lines = raw.split('\n').filter((l) => l.trim());
903
- let firstMessage = '';
904
- let lastTimestamp = '';
905
- let hasMessages = false;
906
- for (const line of lines) {
907
- try {
908
- const entry = JSON.parse(line);
909
- if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
910
- continue;
911
- const msgObj = entry['message'];
912
- if (!msgObj || !msgObj['role'] || msgObj['role'] === 'system')
913
- continue;
914
- hasMessages = true;
915
- const ts = entry['timestamp'];
916
- if (ts)
917
- lastTimestamp = ts;
918
- if (!firstMessage && msgObj['role'] === 'user') {
919
- if (typeof msgObj['content'] === 'string') {
920
- firstMessage = msgObj['content'].slice(0, 200);
921
- }
922
- else if (Array.isArray(msgObj['content'])) {
923
- firstMessage = msgObj['content']
924
- .filter((b) => b['type'] === 'text' && b['text'])
925
- .map((b) => b['text'])
926
- .join('')
927
- .slice(0, 200);
928
- }
929
- }
930
- }
931
- catch { }
932
- }
933
- if (hasMessages) {
934
- sessions.push({ sessionId, firstMessage, lastTimestamp });
935
- }
936
- }
937
- catch { }
938
- }
939
- }
940
- catch { }
941
- sessions.sort((a, b) => (b.lastTimestamp || '').localeCompare(a.lastTimestamp || ''));
942
- return sessions;
943
- }
944
- async function sendSessionList() {
945
- try {
946
- const sessions = await getSessionListForCwd();
947
- const activeIds = await getActiveSessionIds();
948
- const sessionsWithActive = sessions.map((s) => ({
949
- ...s,
950
- isActive: activeIds.has(s.sessionId),
951
- }));
952
- sendToServer({ type: 'session_list', sessions: sessionsWithActive });
953
- log(`[session_list] sent ${sessionsWithActive.length} sessions, ${activeIds.size} active`);
954
- }
955
- catch (err) {
956
- log(`[session_list] error: ${err}`);
957
- sendToServer({ type: 'session_list', sessions: [] });
958
- }
959
- }
960
- async function handleSessionHistoryRequest(sessionId) {
961
- if (!sessionId)
962
- return;
963
- log(`[ws recv] request_session_history: ${sessionId}`);
964
- const switchId = ++sessionSwitchCounter;
965
- log(`[session_history] switchId=${switchId}`);
966
- try {
967
- const folderName = getCurrentProjectFolder();
968
- const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
969
- const sessionFile = path.join(claudeProjectsDir, folderName, `${sessionId}.jsonl`);
970
- log(`[session_history] folder=${folderName}, file=${sessionFile}`);
971
- if (!fs.existsSync(sessionFile)) {
972
- log(`[session_history] file not found, returning empty`);
973
- sendToServer({ type: 'session_history', sessionId, messages: [] });
974
- return;
975
- }
976
- const raw = fs.readFileSync(sessionFile, 'utf-8');
977
- const messages = [];
978
- const seenUuids = new Set();
979
- for (const line of raw.split('\n')) {
980
- if (!line.trim())
981
- continue;
982
- try {
983
- const entry = JSON.parse(line);
984
- if (entry['type'] !== 'user' && entry['type'] !== 'assistant')
985
- continue;
986
- const msgObj = entry['message'];
987
- if (!msgObj)
988
- continue;
989
- const role = msgObj['role'];
990
- if (!role || role === 'system')
991
- continue;
992
- if (entry['isMeta'] || entry['sourceToolUseID'])
993
- continue;
994
- const uuid = entry['uuid'];
995
- if (uuid && seenUuids.has(uuid))
996
- continue;
997
- if (uuid)
998
- seenUuids.add(uuid);
999
- const timestamp = entry['timestamp'];
1000
- let content = '';
1001
- const toolUses = [];
1002
- if (typeof msgObj['content'] === 'string') {
1003
- content = msgObj['content'];
1004
- }
1005
- else if (Array.isArray(msgObj['content'])) {
1006
- content = msgObj['content']
1007
- .filter((b) => b['type'] === 'text' && b['text'])
1008
- .map((b) => b['text'])
1009
- .join('');
1010
- for (const b of msgObj['content']) {
1011
- if (b['type'] === 'tool_use' && b['name']) {
1012
- toolUses.push({ toolName: b['name'], input: b['input'] ?? {} });
1013
- }
1014
- }
1015
- }
1016
- if (!content && toolUses.length === 0)
1017
- continue;
1018
- messages.push({ role, content, id: uuid, timestamp, toolUses: toolUses.length > 0 ? toolUses : undefined });
1019
- }
1020
- catch { }
1021
- }
1022
- sendToServer({ type: 'session_history', sessionId, messages });
1023
- log(`[session_history] sent ${messages.length} messages for ${sessionId}`);
1024
- if (switchId !== sessionSwitchCounter) {
1025
- log(`[session_history] cancelled switch to ${sessionId} (superseded)`);
1026
- return;
1027
- }
1028
- startScannerAtEnd = true;
1029
- claudeArgs = [...removeResumeArgs(claudeArgs), '--resume', sessionId];
1030
- spawnClaudeNow();
1031
- }
1032
- catch (err) {
1033
- log(`[request_session_history] error: ${err}`);
1034
- sendToServer({ type: 'session_history', sessionId, messages: [] });
1035
- }
1036
- }
1037
- async function pairFlow() {
1038
- // prompt user for the 9-digit pairing code shown in the kumo app
1039
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1040
- const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a)));
1041
- // ascii art of the kumo text
1042
- const cloud = [
1043
- " \x1b[1;36m_ __ _ _ __ __ ____ \x1b[0m",
1044
- " \x1b[1;36m| |/ /| | | || \\/ | / __ \\\x1b[0m",
1045
- " \x1b[1;36m| ' / | | | || \\ / || | | |\x1b[0m",
1046
- " \x1b[1;36m| . \\ | |_| || |\\/| || |__| |\x1b[0m",
1047
- " \x1b[1;36m|_|\\_\\ \\___/ |_| |_| \\____/\x1b[0m"
1048
- ];
1049
- console.log('');
1050
- for (const line of cloud)
1051
- console.log(line);
1052
- console.log('');
1053
- console.log(' \x1b[2mwhere your code drifts between devices, softly.\x1b[0m');
1054
- console.log('');
1055
- 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.');
1056
- console.log(' \x1b[36m›\x1b[0m whisper the 9 digits back to me below — spaces are fine.');
1057
- console.log('');
1058
- const raw = (await ask('\x1b[1;33m✦ pairing code ›\x1b[0m ')).trim();
1059
- rl.close();
1060
- if (!raw)
1061
- return null;
1062
- const code = raw.replace(/\D+/g, '');
1063
- if (!/^\d{9}$/.test(code)) {
1064
- console.warn('[kumo-cli] pairing code must be exactly 9 digits');
1065
- return null;
1066
- }
1067
- const deviceUuid = randomUUID();
1068
- const deviceName = `${os.userInfo().username}@${os.hostname()}`;
1069
- try {
1070
- const result = await httpPost(`${SERVER_HTTP}/api/cli/pair`, {
1071
- pairingCode: code,
1072
- deviceUuid,
1073
- deviceName,
1074
- });
1075
- if (result.status !== 201) {
1076
- const data = result.data;
1077
- console.warn(`[kumo-cli] pair failed: ${data?.error ?? result.status}`);
1078
- return null;
1079
- }
1080
- const data = result.data;
1081
- const creds = {
1082
- deviceUuid: data.device.deviceUuid,
1083
- deviceToken: data.deviceToken,
1084
- serverUrl: SERVER_HTTP,
1085
- user: data.user ?? undefined,
1086
- };
1087
- saveCredentials(creds);
1088
- console.log(`[kumo-cli] paired and saved to ${credentialsPath()}`);
1089
- return creds;
1090
- }
1091
- catch (err) {
1092
- console.warn(`[kumo-cli] pair error: ${err.message}`);
1093
- return null;
1094
- }
1095
- }
1096
87
  async function main() {
1097
- claudeArgs = process.argv.slice(2);
88
+ let claudeArgs = [...bootArgs];
1098
89
  if (claudeArgs[0] === 'reset') {
1099
90
  const removed = clearCredentials();
1100
- if (removed)
1101
- console.log(`[kumo-cli] credentials cleared at ${credentialsPath()}`);
1102
- else
1103
- console.log(`[kumo-cli] no credentials to clear`);
91
+ console.log(removed ? `[kumo-cli] credentials cleared at ${credentialsPath()}` : `[kumo-cli] no credentials to clear`);
1104
92
  return;
1105
93
  }
1106
- if (!claudeArgs.includes('--dangerously-skip-permissions')) {
94
+ if (!claudeArgs.includes('--dangerously-skip-permissions'))
1107
95
  claudeArgs.push('--dangerously-skip-permissions');
1108
- }
1109
- const envModel = process.env.KUMO_CLAUDE_MODEL ?? process.env.CLAUDE_MODEL;
1110
- const hasModelArg = claudeArgs.some((a, i) => a === '--model' || a.startsWith('--model=') || (a === '-m' && i + 1 < claudeArgs.length));
1111
- if (envModel && !hasModelArg) {
1112
- claudeArgs.push('--model', envModel);
1113
- }
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); };
1114
108
  const hookServer = await startHookServer((sessionId) => {
1115
109
  currentSessionId = sessionId;
1116
- ptySnapshotRenderer.reset(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
1117
- sendToServer({ type: 'session_start', sessionId });
110
+ claude.resetSnapshot();
111
+ send({ type: 'session_start', sessionId });
1118
112
  scanner?.stop();
1119
113
  scanner = new SessionScanner(sessionId, startScannerAtEnd);
1120
114
  startScannerAtEnd = false;
1121
115
  scanner.on('message', (msg) => {
1122
- sendToServer({ type: 'transcript', message: msg });
116
+ send({ type: 'transcript', message: msg });
1123
117
  if (msg.role === 'assistant' && msg.stopReason === 'end_turn') {
1124
118
  log(`[scanner] assistant_done detected, stopReason=${msg.stopReason}`);
1125
- sendToServer({ type: 'assistant_done' });
119
+ send({ type: 'assistant_done' });
1126
120
  }
1127
121
  });
1128
122
  scanner.start();
1129
123
  if (pendingPrompt) {
1130
124
  const queued = pendingPrompt;
1131
125
  pendingPrompt = null;
1132
- dispatchPrompt(queued);
126
+ claude.dispatchPrompt(queued);
1133
127
  }
1134
128
  });
1135
- process.stdout.on('resize', () => {
1136
- ptySnapshotRenderer.resize(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
1137
- });
1138
- claudeSettingsPath = generateHookSettingsFile(hookServer.port);
129
+ process.stdout.on('resize', () => claude.resizeSnapshot());
130
+ claude.settingsPath = generateHookSettingsFile(hookServer.port);
1139
131
  activeCredentials = loadCredentials();
132
+ if (activeCredentials) {
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.`);
135
+ hookServer.close();
136
+ process.exit(1);
137
+ }
138
+ const verified = await verifyCredentials(activeCredentials);
139
+ if (!verified) {
140
+ console.warn('[kumo-cli] cached device was revoked or deleted, clearing credentials and re-pairing...');
141
+ clearCredentials();
142
+ activeCredentials = null;
143
+ }
144
+ else {
145
+ activeCredentials = verified;
146
+ saveCredentials(verified);
147
+ }
148
+ }
1140
149
  if (!activeCredentials) {
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.`);
152
+ hookServer.close();
153
+ process.exit(1);
154
+ }
1141
155
  activeCredentials = await pairFlow();
1142
156
  if (!activeCredentials) {
1143
- console.warn('[kumo-cli] pairing skipped — launching Claude directly\n');
1144
- spawnClaudeNow();
157
+ console.error('[kumo-cli] pairing aborted');
1145
158
  hookServer.close();
1146
- return;
159
+ process.exit(1);
1147
160
  }
1148
161
  }
1149
162
  console.log(`\x1b[32m✓ Connected as ${activeCredentials.user?.email ?? activeCredentials.user?.id ?? 'device'}\x1b[0m\n`);
1150
- connectToServer(undefined, async () => {
1151
- startFileWatcher();
1152
- await sendSessionList();
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
+ }
1153
202
  });
203
+ ws.onSent(() => claude.notifySent());
204
+ ws.connect();
1154
205
  }
1155
206
  main().catch((err) => {
1156
207
  console.error('[kumo-cli] fatal error:', err);