kumo-cli 1.0.1 → 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 (64) hide show
  1. package/README.md +20 -31
  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 +120 -1364
  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 +130 -4
  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/transport/directWsServer.js +135 -0
  55. package/dist/transport/directWsServer.js.map +1 -0
  56. package/dist/transport/wsClient.js +87 -0
  57. package/dist/transport/wsClient.js.map +1 -0
  58. package/dist/utils/ignorePaths.js +54 -0
  59. package/dist/utils/ignorePaths.js.map +1 -0
  60. package/dist/utils/ptyBuffer.js +46 -0
  61. package/dist/utils/ptyBuffer.js.map +1 -0
  62. package/dist/utils/safePath.js +43 -0
  63. package/dist/utils/safePath.js.map +1 -0
  64. package/package.json +2 -4
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 path from 'node:path';
7
- import os from 'node:os';
8
- import { fileURLToPath } from 'node:url';
9
- import { startHookServer } from './hookServer.js';
10
- import { generateHookSettingsFile } from './generateHookSettings.js';
11
- import { spawnClaude, writeToProcess } from './spawn.js';
12
- import { SessionScanner } from './sessionScanner.js';
13
- import { PtyMenuParser } from './ptyMenuParser.js';
14
- import { PtySnapshotRenderer } from './ptySnapshotRenderer.js';
15
- import { loadDotEnv } from './loadDotEnv.js';
16
- import { TerminalManager } from './terminalManager.js';
17
- import { loadIgnoredNames } from './ignorePaths.js';
18
- import { loadCredentials, saveCredentials, clearCredentials, credentialsPath } from './credentialsStore.js';
19
- import readline from 'node:readline';
20
- import { randomUUID } from 'node:crypto';
21
- loadDotEnv();
22
- function normalizeServerHttpUrl(input) {
23
- const u = new URL(input);
24
- if (u.protocol === 'ws:')
25
- u.protocol = 'http:';
26
- if (u.protocol === 'wss:')
27
- u.protocol = 'https:';
28
- if (u.pathname === '/ws')
29
- u.pathname = '/';
30
- if (u.pathname.endsWith('/ws'))
31
- u.pathname = u.pathname.slice(0, -3) || '/';
32
- 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 };
33
40
  }
34
- function normalizeServerWsUrl(input) {
35
- const u = new URL(input);
36
- if (u.protocol === 'http:')
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
- const serverUrl = process.env.KUMO_SERVER_URL;
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 lastWsSendAt = 0;
60
- let pendingSelectNudgeTimer = null;
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
- const IGNORED_PATHS = loadIgnoredNames(process.cwd());
83
- const fileWatchDebounceMap = new Map();
84
- const FILE_WATCH_DEBOUNCE_MS = 300;
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 => IGNORED_PATHS.has(p) || p.startsWith('.')))
70
+ if (parts.some((p) => ignored.has(p) || p.startsWith('.')))
101
71
  return;
102
- const prev = fileWatchDebounceMap.get(normalized);
72
+ const prev = debounceMap.get(normalized);
103
73
  if (prev)
104
74
  clearTimeout(prev);
105
- fileWatchDebounceMap.set(normalized, setTimeout(() => {
106
- fileWatchDebounceMap.delete(normalized);
107
- sendToServer({ type: 'file_changed', path: normalized, event });
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 = process.argv.slice(2);
88
+ let claudeArgs = [...bootArgs];
1372
89
  if (claudeArgs[0] === 'reset') {
1373
90
  const removed = clearCredentials();
1374
- if (removed)
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
- // model is resolved dynamically by backend via ws messages; no env fallback here
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
- ptySnapshotRenderer.reset(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
1387
- sendToServer({ type: 'session_start', sessionId });
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
- sendToServer({ type: 'transcript', message: msg });
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
- sendToServer({ type: 'assistant_done' });
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
- ptySnapshotRenderer.resize(process.stdout.columns ?? 120, process.stdout.rows ?? 30);
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
- // verify cached credentials against backend; if device was deleted/revoked, force re-pair
1412
- const reachable = await checkBackendReachable();
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
- // require backend to be reachable before asking the user to pair
1431
- const reachable = await checkBackendReachable();
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
- connectToServer(undefined, async () => {
1446
- startFileWatcher();
1447
- 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
+ }
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);