@i14ks/ccv 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bundle/ccv.mjs ADDED
@@ -0,0 +1,4428 @@
1
+ #!/usr/bin/env node
2
+
3
+ // dist/ccv.js
4
+ import { spawn as spawn2 } from "node:child_process";
5
+ import { readFile as readFile10 } from "node:fs/promises";
6
+ import path14 from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseArgs } from "node:util";
9
+
10
+ // ../host/dist/claude-cli.js
11
+ import { execFile, spawn } from "node:child_process";
12
+ import { constants } from "node:fs";
13
+ import { access, readFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+ var SDK_SCRIPT_EXT = [".js", ".mjs", ".ts", ".tsx", ".jsx"];
17
+ var BARE = { file: "claude", prefix: [] };
18
+ var DIRECT_EXT = /* @__PURE__ */ new Set([".exe", ".com"]);
19
+ var SHIM_RELATIVE = /(?:%~?dp0%|\$basedir)[\\/]*([^"'\r\n]+?\.[cm]?js)/i;
20
+ var SHIM_ABSOLUTE = /(["'])([A-Za-z]:[\\/][^"'\r\n]+?\.[cm]?js)\1/;
21
+ var resolved = null;
22
+ var inFlight = null;
23
+ var searched = false;
24
+ async function claudeLauncher() {
25
+ if (resolved)
26
+ return resolved;
27
+ inFlight ??= locate();
28
+ const found = await inFlight;
29
+ inFlight = null;
30
+ searched = true;
31
+ if (found)
32
+ resolved = found;
33
+ return found ?? BARE;
34
+ }
35
+ async function warmClaudeLauncher() {
36
+ await claudeLauncher();
37
+ }
38
+ function claudeExecutable() {
39
+ if (process.env.CCV_BUNDLED_CLAUDE)
40
+ return { source: "forced" };
41
+ const override = process.env.CCV_CLAUDE_PATH?.trim();
42
+ if (override)
43
+ return { source: "env", ...runnable(override) };
44
+ if (!searched || !resolved)
45
+ return { source: "bundled" };
46
+ const { file, prefix } = resolved;
47
+ if (prefix.length === 0)
48
+ return { source: "found", path: file };
49
+ const script = prefix[0];
50
+ if (!script || !isSdkScript(script))
51
+ return { source: "bundled" };
52
+ return { source: "found", path: script, executable: "node" };
53
+ }
54
+ function runnable(file) {
55
+ return isSdkScript(file) ? { path: file, executable: "node" } : { path: file };
56
+ }
57
+ function isSdkScript(file) {
58
+ return SDK_SCRIPT_EXT.includes(path.extname(file).toLowerCase());
59
+ }
60
+ async function runClaude(args, options2) {
61
+ const { file, prefix } = await claudeLauncher();
62
+ return new Promise((resolve, reject) => {
63
+ execFile(file, [...prefix, ...args], {
64
+ timeout: options2.timeout,
65
+ maxBuffer: options2.maxBuffer ?? 1024 * 1024,
66
+ windowsHide: true
67
+ }, (error, stdout, stderr) => {
68
+ if (!error)
69
+ return resolve(stdout.trim());
70
+ if (error.code === "ENOENT") {
71
+ return reject(new Error(`\u041D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D claude \u0432 PATH \u2014 ${options2.missing}`));
72
+ }
73
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
74
+ reject(new Error(detail || error.message));
75
+ });
76
+ });
77
+ }
78
+ async function spawnClaude(args) {
79
+ const { file, prefix } = await claudeLauncher();
80
+ return spawn(file, [...prefix, ...args], { windowsHide: true });
81
+ }
82
+ async function locate() {
83
+ for (const dir of searchDirs()) {
84
+ for (const name of names()) {
85
+ const found = await inspect(path.join(dir, name));
86
+ if (found)
87
+ return found;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ function searchDirs() {
93
+ const fromPath = (process.env.PATH ?? "").split(path.delimiter).map((dir) => dir.replace(/^"|"$/g, "").trim()).filter(Boolean);
94
+ const home = homedir();
95
+ const known = process.platform === "win32" ? [path.join(home, ".local", "bin")] : [path.join(home, ".local", "bin"), "/usr/local/bin", "/opt/homebrew/bin"];
96
+ return [.../* @__PURE__ */ new Set([...fromPath, ...known])];
97
+ }
98
+ function names() {
99
+ if (process.platform !== "win32")
100
+ return ["claude"];
101
+ const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map((ext) => ext.trim().toLowerCase()).filter((ext) => ext.startsWith("."));
102
+ return [...new Set(exts)].map((ext) => `claude${ext}`);
103
+ }
104
+ async function inspect(candidate) {
105
+ const mode = process.platform === "win32" ? constants.F_OK : constants.X_OK;
106
+ if (!await reachable(candidate, mode))
107
+ return null;
108
+ if (process.platform !== "win32")
109
+ return { file: candidate, prefix: [] };
110
+ if (DIRECT_EXT.has(path.extname(candidate).toLowerCase())) {
111
+ return { file: candidate, prefix: [] };
112
+ }
113
+ const script = await shimScript(candidate);
114
+ return script ? { file: process.execPath, prefix: [script] } : null;
115
+ }
116
+ async function shimScript(shim) {
117
+ const text = await readFile(shim, "utf8").catch(() => "");
118
+ if (!text)
119
+ return null;
120
+ const absolute = SHIM_ABSOLUTE.exec(text)?.[2];
121
+ const relative = SHIM_RELATIVE.exec(text)?.[1];
122
+ const script = absolute ?? (relative ? path.resolve(path.dirname(shim), relative) : null);
123
+ if (!script)
124
+ return null;
125
+ return await reachable(script, constants.F_OK) ? script : null;
126
+ }
127
+ function reachable(file, mode) {
128
+ return access(file, mode).then(() => true, () => false);
129
+ }
130
+
131
+ // ../host/dist/config.js
132
+ import os from "node:os";
133
+ import path2 from "node:path";
134
+ function envDefaults() {
135
+ return {
136
+ port: Number(process.env.CCV_PORT ?? 8787),
137
+ // Только loopback. Наружу этот порт не выставляется — снаружи будет relay.
138
+ host: process.env.CCV_HOST ?? "127.0.0.1",
139
+ dataDir: process.env.CCV_DATA_DIR ?? path2.join(os.homedir(), ".claude-code-visual"),
140
+ // Куда сопрягаться за удалённым доступом. Переопределяется ради своего
141
+ // relay — и ради прогона всей связки на одной машине.
142
+ relayUrl: process.env.CCV_RELAY_URL ?? "https://claudevisual.com"
143
+ };
144
+ }
145
+
146
+ // ../host/dist/server.js
147
+ import { createServer } from "node:http";
148
+ import { createReadStream, existsSync as existsSync3, statSync as statSync3 } from "node:fs";
149
+ import path13 from "node:path";
150
+ import { homedir as homedir5 } from "node:os";
151
+ import { randomUUID as randomUUID2 } from "node:crypto";
152
+ import { WebSocketServer } from "ws";
153
+ import { renameSession as renameSession2 } from "@anthropic-ai/claude-agent-sdk";
154
+
155
+ // ../protocol/dist/index.js
156
+ var PROTOCOL_VERSION = 1;
157
+ function isClientFrame(value) {
158
+ return typeof value === "object" && value !== null && typeof value.type === "string";
159
+ }
160
+ function workspaceIdFromPath(absolutePath) {
161
+ return absolutePath.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").toLowerCase();
162
+ }
163
+
164
+ // ../host/dist/session.js
165
+ import { EventEmitter } from "node:events";
166
+ import { randomUUID } from "node:crypto";
167
+ import path5 from "node:path";
168
+ import { query, renameSession } from "@anthropic-ai/claude-agent-sdk";
169
+
170
+ // ../host/dist/async-queue.js
171
+ var AsyncQueue = class {
172
+ #items = [];
173
+ #resolvers = [];
174
+ #closed = false;
175
+ push(item) {
176
+ if (this.#closed)
177
+ return;
178
+ const resolve = this.#resolvers.shift();
179
+ if (resolve) {
180
+ resolve({ value: item, done: false });
181
+ } else {
182
+ this.#items.push(item);
183
+ }
184
+ }
185
+ close() {
186
+ if (this.#closed)
187
+ return;
188
+ this.#closed = true;
189
+ for (const resolve of this.#resolvers) {
190
+ resolve({ value: void 0, done: true });
191
+ }
192
+ this.#resolvers = [];
193
+ }
194
+ get closed() {
195
+ return this.#closed;
196
+ }
197
+ [Symbol.asyncIterator]() {
198
+ return {
199
+ next: () => {
200
+ const item = this.#items.shift();
201
+ if (item !== void 0) {
202
+ return Promise.resolve({ value: item, done: false });
203
+ }
204
+ if (this.#closed) {
205
+ return Promise.resolve({ value: void 0, done: true });
206
+ }
207
+ return new Promise((resolve) => this.#resolvers.push(resolve));
208
+ },
209
+ return: () => {
210
+ this.close();
211
+ return Promise.resolve({ value: void 0, done: true });
212
+ }
213
+ };
214
+ }
215
+ };
216
+
217
+ // ../host/dist/files.js
218
+ import { lstat, mkdir, readFile as readFile2, readdir, realpath, rename as renamePath, rm, stat, writeFile } from "node:fs/promises";
219
+ import path3 from "node:path";
220
+ var MAX_FILE = 4 * 1024 * 1024;
221
+ var SNIFF = 8192;
222
+ var HIDDEN = /* @__PURE__ */ new Set([".git"]);
223
+ var SEARCH_SKIP = /* @__PURE__ */ new Set([...HIDDEN, "node_modules"]);
224
+ var SEARCH_LIMIT = 200;
225
+ var GREP_LIMIT = 500;
226
+ var GREP_LINE_PREVIEW = 300;
227
+ var ProjectFiles = class {
228
+ cwd;
229
+ /** Корень, разрешённый от ссылок: с ним сравниваются все пути. */
230
+ #root = null;
231
+ constructor(cwd) {
232
+ this.cwd = cwd;
233
+ }
234
+ async list(relative) {
235
+ const dir = await this.#resolve(relative);
236
+ const names2 = await readdir(dir, { withFileTypes: true });
237
+ const entries = [];
238
+ for (const dirent of names2) {
239
+ if (HIDDEN.has(dirent.name))
240
+ continue;
241
+ const full = path3.join(dir, dirent.name);
242
+ const link = dirent.isSymbolicLink();
243
+ const info = await stat(full).catch(() => null);
244
+ if (!info)
245
+ continue;
246
+ entries.push({
247
+ name: dirent.name,
248
+ path: join(relative, dirent.name),
249
+ kind: info.isDirectory() ? "dir" : "file",
250
+ size: info.isDirectory() ? 0 : info.size,
251
+ mtime: Math.round(info.mtimeMs),
252
+ link,
253
+ // Кто игнорируется, знает git, а не файловая система: отметку ставит
254
+ // сервер поверх готового списка (см. `markIgnored`).
255
+ ignored: false
256
+ });
257
+ }
258
+ return entries.sort((a, b) => {
259
+ if (a.kind !== b.kind)
260
+ return a.kind === "dir" ? -1 : 1;
261
+ return a.name.localeCompare(b.name, void 0, { numeric: true, sensitivity: "base" });
262
+ });
263
+ }
264
+ /**
265
+ * Поиск по подстроке в имени — рекурсивно по всему дереву проекта.
266
+ *
267
+ * В отличие от `list`, здесь нет ленивой подгрузки: искать в раскрытых
268
+ * каталогах бессмысленно, пользователь ищет как раз то, чего не видит.
269
+ * Поэтому обход идёт по диску напрямую, минуя `#resolve` на каждый шаг —
270
+ * он один раз проверяет корень, а дальше обход и так не выходит за
271
+ * найденные внутри него каталоги.
272
+ */
273
+ async search(query2) {
274
+ const needle = query2.trim().toLowerCase();
275
+ if (!needle)
276
+ return { entries: [], truncated: false };
277
+ const root = await this.#resolve("");
278
+ const entries = [];
279
+ let truncated = false;
280
+ const walk = async (dir, relative) => {
281
+ if (truncated)
282
+ return;
283
+ const names2 = await readdir(dir, { withFileTypes: true }).catch(() => []);
284
+ for (const dirent of names2) {
285
+ if (truncated)
286
+ return;
287
+ if (SEARCH_SKIP.has(dirent.name))
288
+ continue;
289
+ const full = path3.join(dir, dirent.name);
290
+ const rel = join(relative, dirent.name);
291
+ const info = await stat(full).catch(() => null);
292
+ if (!info)
293
+ continue;
294
+ const isDir = info.isDirectory();
295
+ if (dirent.name.toLowerCase().includes(needle)) {
296
+ if (entries.length >= SEARCH_LIMIT) {
297
+ truncated = true;
298
+ return;
299
+ }
300
+ entries.push({
301
+ name: dirent.name,
302
+ path: rel,
303
+ kind: isDir ? "dir" : "file",
304
+ size: isDir ? 0 : info.size,
305
+ mtime: Math.round(info.mtimeMs),
306
+ link: dirent.isSymbolicLink(),
307
+ ignored: false
308
+ });
309
+ }
310
+ if (isDir && !dirent.isSymbolicLink())
311
+ await walk(full, rel);
312
+ }
313
+ };
314
+ await walk(root, "");
315
+ entries.sort((a, b) => a.path.localeCompare(b.path, void 0, { numeric: true, sensitivity: "base" }));
316
+ return { entries, truncated };
317
+ }
318
+ /**
319
+ * Полнотекстовый поиск — подстрока в содержимом файлов, по всему дереву проекта.
320
+ *
321
+ * Как и `search`, обходит диск напрямую, минуя ленивую подгрузку дерева, и
322
+ * пропускает те же каталоги (`SEARCH_SKIP`). Дополнительно пропускает то, что
323
+ * не текст для чтения: бинарные файлы (по тому же признаку, что и `read`) и
324
+ * файлы больше `MAX_FILE` — совпадение внутри них всё равно нельзя было бы
325
+ * открыть в редакторе. Симлинки не разворачиваются вовсе: их цель — уже файл
326
+ * где-то в дереве, и он будет прочитан по своему собственному пути.
327
+ */
328
+ async grep(query2) {
329
+ const needle = query2.trim().toLowerCase();
330
+ if (!needle)
331
+ return { matches: [], truncated: false };
332
+ const root = await this.#resolve("");
333
+ const matches2 = [];
334
+ let truncated = false;
335
+ const walk = async (dir, relative) => {
336
+ if (truncated)
337
+ return;
338
+ const names2 = await readdir(dir, { withFileTypes: true }).catch(() => []);
339
+ for (const dirent of names2) {
340
+ if (truncated)
341
+ return;
342
+ if (SEARCH_SKIP.has(dirent.name) || dirent.isSymbolicLink())
343
+ continue;
344
+ const full = path3.join(dir, dirent.name);
345
+ const rel = join(relative, dirent.name);
346
+ if (dirent.isDirectory()) {
347
+ await walk(full, rel);
348
+ continue;
349
+ }
350
+ if (!dirent.isFile())
351
+ continue;
352
+ const info = await stat(full).catch(() => null);
353
+ if (!info || info.size > MAX_FILE)
354
+ continue;
355
+ const buffer = await readFile2(full).catch(() => null);
356
+ if (!buffer || isBinary(buffer))
357
+ continue;
358
+ const lines = buffer.toString("utf8").split(/\r\n|\n/);
359
+ for (let i = 0; i < lines.length; i++) {
360
+ const line = lines[i];
361
+ const lower = line.toLowerCase();
362
+ let from = 0;
363
+ for (; ; ) {
364
+ const at = lower.indexOf(needle, from);
365
+ if (at === -1)
366
+ break;
367
+ if (matches2.length >= GREP_LIMIT) {
368
+ truncated = true;
369
+ break;
370
+ }
371
+ matches2.push({
372
+ path: rel,
373
+ line: i + 1,
374
+ column: at + 1,
375
+ lineText: line.length > GREP_LINE_PREVIEW ? line.slice(0, GREP_LINE_PREVIEW) : line,
376
+ matchLength: needle.length
377
+ });
378
+ from = at + needle.length;
379
+ }
380
+ if (truncated)
381
+ break;
382
+ }
383
+ }
384
+ };
385
+ await walk(root, "");
386
+ matches2.sort((a, b) => {
387
+ const byPath = a.path.localeCompare(b.path, void 0, { numeric: true, sensitivity: "base" });
388
+ if (byPath !== 0)
389
+ return byPath;
390
+ return a.line - b.line || a.column - b.column;
391
+ });
392
+ return { matches: matches2, truncated };
393
+ }
394
+ async read(relative) {
395
+ const file = await this.#resolve(relative);
396
+ const info = await stat(file);
397
+ if (info.isDirectory())
398
+ throw new Error("\u042D\u0442\u043E \u043A\u0430\u0442\u0430\u043B\u043E\u0433, \u0430 \u043D\u0435 \u0444\u0430\u0439\u043B");
399
+ const blank = {
400
+ path: normalize(relative),
401
+ content: "",
402
+ eol: "lf",
403
+ mtime: Math.round(info.mtimeMs),
404
+ size: info.size,
405
+ binary: false,
406
+ tooLarge: false
407
+ };
408
+ if (info.size > MAX_FILE)
409
+ return { ...blank, tooLarge: true };
410
+ const buffer = await readFile2(file);
411
+ if (isBinary(buffer))
412
+ return { ...blank, binary: true };
413
+ const text = buffer.toString("utf8");
414
+ const crlf = text.includes("\r\n");
415
+ return {
416
+ ...blank,
417
+ content: crlf ? text.replace(/\r\n/g, "\n") : text,
418
+ eol: crlf ? "crlf" : "lf"
419
+ };
420
+ }
421
+ /**
422
+ * Записать файл, если его на диске не подменили.
423
+ *
424
+ * `baseMtime` — версия, которую редактировали (0 для нового файла). Не
425
+ * совпало — значит, файл изменил кто-то ещё (почти наверняка агент), и
426
+ * молчаливая перезапись стёрла бы его работу. `force` снимает проверку —
427
+ * это сознательное решение пользователя, принятое после отказа.
428
+ */
429
+ async write(relative, content, eol, baseMtime, force = false) {
430
+ const file = await this.#resolve(relative);
431
+ const info = await stat(file).catch(() => null);
432
+ if (info?.isDirectory())
433
+ throw new Error("\u042D\u0442\u043E \u043A\u0430\u0442\u0430\u043B\u043E\u0433, \u0430 \u043D\u0435 \u0444\u0430\u0439\u043B");
434
+ if (!force) {
435
+ const onDisk = info ? Math.round(info.mtimeMs) : 0;
436
+ if (Math.abs(onDisk - baseMtime) > 1e3) {
437
+ throw new ConflictError(info ? "\u0424\u0430\u0439\u043B \u043D\u0430 \u0434\u0438\u0441\u043A\u0435 \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u0441\u044F \u043F\u043E\u0441\u043B\u0435 \u0442\u043E\u0433\u043E, \u043A\u0430\u043A \u0435\u0433\u043E \u043E\u0442\u043A\u0440\u044B\u043B\u0438 \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435" : "\u0424\u0430\u0439\u043B \u043D\u0430 \u0434\u0438\u0441\u043A\u0435 \u0438\u0441\u0447\u0435\u0437 \u043F\u043E\u0441\u043B\u0435 \u0442\u043E\u0433\u043E, \u043A\u0430\u043A \u0435\u0433\u043E \u043E\u0442\u043A\u0440\u044B\u043B\u0438 \u0432 \u0440\u0435\u0434\u0430\u043A\u0442\u043E\u0440\u0435");
438
+ }
439
+ }
440
+ await writeFile(file, eol === "crlf" ? content.replace(/\n/g, "\r\n") : content, "utf8");
441
+ const saved = await stat(file);
442
+ return { mtime: Math.round(saved.mtimeMs), size: saved.size };
443
+ }
444
+ async create(relative, kind) {
445
+ const target = await this.#resolve(relative);
446
+ if (await exists(target))
447
+ throw new Error("\u0422\u0430\u043A\u043E\u0439 \u0444\u0430\u0439\u043B \u0438\u043B\u0438 \u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u0443\u0436\u0435 \u0435\u0441\u0442\u044C");
448
+ if (kind === "dir") {
449
+ await mkdir(target, { recursive: true });
450
+ return;
451
+ }
452
+ await mkdir(path3.dirname(target), { recursive: true });
453
+ await writeFile(target, "", { encoding: "utf8", flag: "wx" });
454
+ }
455
+ async rename(relative, to) {
456
+ const from = await this.#resolve(relative);
457
+ const target = await this.#resolve(to);
458
+ if (from === target)
459
+ return;
460
+ if (await exists(target))
461
+ throw new Error("\u041F\u043E \u043D\u043E\u0432\u043E\u043C\u0443 \u043F\u0443\u0442\u0438 \u0443\u0436\u0435 \u0447\u0442\u043E-\u0442\u043E \u0435\u0441\u0442\u044C");
462
+ await mkdir(path3.dirname(target), { recursive: true });
463
+ await renamePath(from, target);
464
+ }
465
+ async remove(relative) {
466
+ const target = await this.#resolve(relative);
467
+ if (!normalize(relative))
468
+ throw new Error("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043A\u043E\u0440\u0435\u043D\u044C \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u043D\u0435\u043B\u044C\u0437\u044F");
469
+ await rm(target, { recursive: true, force: false });
470
+ }
471
+ /**
472
+ * Относительный путь -> абсолютный, внутри проекта и нигде больше.
473
+ *
474
+ * Одной склейки мало: `..` в пути и символическая ссылка наружу — разные
475
+ * способы выйти из корня, и второй не ловится сравнением строк. Поэтому
476
+ * корень и существующая часть цели разрешаются через `realpath`, а
477
+ * несуществующая (её создают) проверяется по разрешённому родителю.
478
+ */
479
+ async #resolve(relative) {
480
+ const clean = normalize(relative);
481
+ if (path3.isAbsolute(clean) || /^[A-Za-z]:/.test(clean)) {
482
+ throw new Error("\u041F\u0443\u0442\u044C \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u043E\u0442\u043D\u043E\u0441\u0438\u0442\u0435\u043B\u044C\u043D\u044B\u043C \u043E\u0442 \u043A\u043E\u0440\u043D\u044F \u043F\u0440\u043E\u0435\u043A\u0442\u0430");
483
+ }
484
+ if (clean.split("/").includes("..")) {
485
+ throw new Error("\u0412\u044B\u0445\u043E\u0434 \u0437\u0430 \u043F\u0440\u0435\u0434\u0435\u043B\u044B \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u0437\u0430\u043F\u0440\u0435\u0449\u0451\u043D");
486
+ }
487
+ const root = this.#root ??= await realpath(this.cwd);
488
+ const target = path3.resolve(root, clean);
489
+ const probe = await exists(target) ? target : path3.dirname(target);
490
+ const real = await realpath(probe).catch(() => probe);
491
+ if (!contains(root, real)) {
492
+ throw new Error("\u0412\u044B\u0445\u043E\u0434 \u0437\u0430 \u043F\u0440\u0435\u0434\u0435\u043B\u044B \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u0437\u0430\u043F\u0440\u0435\u0449\u0451\u043D");
493
+ }
494
+ return target;
495
+ }
496
+ };
497
+ var ConflictError = class extends Error {
498
+ conflict = true;
499
+ };
500
+ function normalize(relative) {
501
+ return relative.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
502
+ }
503
+ function join(dir, name) {
504
+ const base = normalize(dir);
505
+ return base ? `${base}/${name}` : name;
506
+ }
507
+ function contains(root, target) {
508
+ if (target === root)
509
+ return true;
510
+ const prefix = root.endsWith(path3.sep) ? root : root + path3.sep;
511
+ return process.platform === "win32" ? target.toLowerCase().startsWith(prefix.toLowerCase()) : target.startsWith(prefix);
512
+ }
513
+ async function exists(file) {
514
+ return lstat(file).then(() => true, () => false);
515
+ }
516
+ function isBinary(buffer) {
517
+ const head = buffer.subarray(0, SNIFF);
518
+ return head.includes(0);
519
+ }
520
+
521
+ // ../host/dist/journal.js
522
+ var Journal = class {
523
+ #entries = [];
524
+ #nextSeq = 1;
525
+ #maxEntries;
526
+ constructor(maxEntries = 5e3) {
527
+ this.#maxEntries = maxEntries;
528
+ }
529
+ get lastSeq() {
530
+ return this.#nextSeq - 1;
531
+ }
532
+ append(event) {
533
+ const entry = { seq: this.#nextSeq++, ts: Date.now(), event };
534
+ this.#entries.push(entry);
535
+ if (this.#entries.length > this.#maxEntries) {
536
+ this.#entries.splice(0, this.#entries.length - this.#maxEntries);
537
+ }
538
+ return entry;
539
+ }
540
+ /** Всё, что появилось после `sinceSeq`. Пустой массив = клиент уже в курсе. */
541
+ since(sinceSeq) {
542
+ if (sinceSeq >= this.lastSeq)
543
+ return [];
544
+ const idx = this.#entries.findIndex((e) => e.seq > sinceSeq);
545
+ return idx === -1 ? [] : this.#entries.slice(idx);
546
+ }
547
+ /**
548
+ * Самый ранний seq, который мы ещё можем отдать. Если клиент отстал сильнее,
549
+ * догон невозможен — ему нужно перечитать транскрипт целиком.
550
+ */
551
+ get oldestSeq() {
552
+ return this.#entries[0]?.seq ?? this.#nextSeq;
553
+ }
554
+ };
555
+
556
+ // ../host/dist/settings.js
557
+ import { readFile as readFile3 } from "node:fs/promises";
558
+ import { homedir as homedir2 } from "node:os";
559
+ import path4 from "node:path";
560
+ var USER_SETTINGS = path4.join(homedir2(), ".claude", "settings.json");
561
+ var PROJECT_SETTINGS = path4.join(".claude", "settings.json");
562
+ var LOCAL_SETTINGS = path4.join(".claude", "settings.local.json");
563
+ function managedSettingsPath() {
564
+ switch (process.platform) {
565
+ case "win32":
566
+ return path4.join(process.env.PROGRAMDATA ?? "C:\\ProgramData", "ClaudeCode", "managed-settings.json");
567
+ case "darwin":
568
+ return "/Library/Application Support/ClaudeCode/managed-settings.json";
569
+ default:
570
+ return "/etc/claude-code/managed-settings.json";
571
+ }
572
+ }
573
+ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
574
+ async function readConfiguredDefaults(cwd) {
575
+ const files = [
576
+ USER_SETTINGS,
577
+ path4.join(cwd, PROJECT_SETTINGS),
578
+ path4.join(cwd, LOCAL_SETTINGS),
579
+ managedSettingsPath()
580
+ ];
581
+ const result = {
582
+ model: null,
583
+ effort: null,
584
+ ultracode: false,
585
+ thinking: true,
586
+ switchModelsOnFlag: true
587
+ };
588
+ for (const file of files) {
589
+ const settings = await readSettings(file);
590
+ if (!settings)
591
+ continue;
592
+ const value = settings.model;
593
+ if (typeof value === "string" && value.trim())
594
+ result.model = value.trim();
595
+ const env = settings.env;
596
+ if (env && typeof env === "object") {
597
+ const fromEnv = env.ANTHROPIC_MODEL;
598
+ if (typeof fromEnv === "string" && fromEnv.trim())
599
+ result.model = fromEnv.trim();
600
+ }
601
+ const effort = settings.effortLevel;
602
+ if (typeof effort === "string" && EFFORT_LEVELS.includes(effort)) {
603
+ result.effort = effort;
604
+ }
605
+ if (typeof settings.ultracode === "boolean") {
606
+ result.ultracode = settings.ultracode;
607
+ if (settings.ultracode)
608
+ result.effort = "xhigh";
609
+ }
610
+ if (typeof settings.alwaysThinkingEnabled === "boolean") {
611
+ result.thinking = settings.alwaysThinkingEnabled;
612
+ }
613
+ if (typeof settings.switchModelsOnFlag === "boolean") {
614
+ result.switchModelsOnFlag = settings.switchModelsOnFlag;
615
+ }
616
+ }
617
+ const fromProcess = process.env.ANTHROPIC_MODEL?.trim();
618
+ if (fromProcess)
619
+ result.model = fromProcess;
620
+ return result;
621
+ }
622
+ async function readSettings(file) {
623
+ try {
624
+ const parsed = JSON.parse(await readFile3(file, "utf8"));
625
+ return parsed && typeof parsed === "object" ? parsed : null;
626
+ } catch {
627
+ return null;
628
+ }
629
+ }
630
+
631
+ // ../host/dist/session.js
632
+ function uiSystemPromptAppend(cwd) {
633
+ const markdown = "You are running inside Claude Code Visual, a GUI for Claude Code. When the user opens a .md file in this app, it is rendered as formatted, human-readable text (headings, lists, code blocks, tables) by default \u2014 not shown as raw markdown source. A toggle lets them switch to the raw source view if they want to edit it. So when producing a report, plan, summary, or any other document meant for the user to read, prefer writing it to a .md file rather than only in chat \u2014 the user will see it rendered, not as plain marked-up text.";
634
+ if (!cwd)
635
+ return markdown;
636
+ return markdown + `
637
+
638
+ The user has a project open in this app: ${cwd}. Its file explorer shows that directory and nothing else \u2014 a file written anywhere outside it is invisible to the user, no matter how the path was described to them. So every file you create or edit belongs inside the project directory unless the user explicitly named a location outside it. This takes precedence over the scratchpad instruction above: use the scratchpad only for throwaway intermediates the user will never open, never for a file the user asked for. When asked for a file without being told where, put it in the project directory.`;
639
+ }
640
+ var READ_ONLY_TOOLS = ["Read", "Grep", "Glob"];
641
+ var WRITE_TOOLS = "Write|Edit|NotebookEdit";
642
+ function writeTarget(input) {
643
+ if (typeof input !== "object" || input === null)
644
+ return null;
645
+ const record = input;
646
+ const target = record.file_path ?? record.notebook_path;
647
+ return typeof target === "string" && target ? target : null;
648
+ }
649
+ var USAGE_MIN_INTERVAL_MS = 2e4;
650
+ var ClaudeSession = class extends EventEmitter {
651
+ id;
652
+ workspaceId;
653
+ cwd;
654
+ journal = new Journal();
655
+ #query = null;
656
+ /** Чем подняли эту сессию — нужно, чтобы объяснить провал старта. */
657
+ #executable = { source: "bundled" };
658
+ #inbox = new AsyncQueue();
659
+ #state = "starting";
660
+ #capabilities = null;
661
+ #pendingPermissions = /* @__PURE__ */ new Map();
662
+ #model = null;
663
+ #permissionMode;
664
+ /**
665
+ * Усилие, размышления и подмена модели по флагу.
666
+ *
667
+ * Живут рядом с моделью и режимом прав, потому что меняются так же — на лету,
668
+ * через `applyFlagSettings`. Начальные значения читаются с диска (см.
669
+ * `#resolveDefaults`): спросить о них SDK, как и о текущей модели, нельзя.
670
+ */
671
+ #effort = null;
672
+ #ultracode = false;
673
+ #thinking = true;
674
+ #switchModelsOnFlag = true;
675
+ #title = null;
676
+ /**
677
+ * Имя сессии — всё ещё подставленное имя проекта, а не сказанное о деле.
678
+ *
679
+ * Первый промпт заменяет только такое имя: переименованную вручную сессию
680
+ * он трогать не должен, а у продолженной имя пришло с диска и тоже настоящее.
681
+ */
682
+ #titleIsDefault = false;
683
+ /** Настоящий id сессии Claude Code — известен только после system/init. */
684
+ #claudeSessionId = null;
685
+ /**
686
+ * Имя, которое пользователь задал, но записать на диск ещё не удалось.
687
+ *
688
+ * JSONL сессии появляется только после первого обмена, поэтому
689
+ * переименование пустой сессии писать некуда — повторяем после result.
690
+ */
691
+ #unpersistedTitle = null;
692
+ /**
693
+ * id текущего ассистентского сообщения (msg_…).
694
+ *
695
+ * Нужен как общий ключ для дельт: у каждого `stream_event` собственный
696
+ * uuid, поэтому склеивать поток по нему нельзя — получаются обрывки. А
697
+ * `message_start.message.id` совпадает с `id` финального assistant-блока,
698
+ * так что UI заменяет накопленный стрим целиком.
699
+ */
700
+ #streamMessageId = null;
701
+ /**
702
+ * Сколько блоков сообщения `msg_…` уже пришло финальными.
703
+ *
704
+ * SDK отдаёт assistant-сообщения по одному блоку за раз, и в каждом таком
705
+ * сообщении блок лежит в `content[0]`. Индекс же у дельт растёт по всем
706
+ * блокам сообщения подряд, поэтому позиция в массиве ключом быть не может:
707
+ * текст после `thinking` или `tool_use` получал бы финальный `:0` против
708
+ * стримового `:1` и рисовался бы в ленте дважды. Считаем блоки сами.
709
+ */
710
+ #blockCounts = /* @__PURE__ */ new Map();
711
+ /**
712
+ * MCP-серверы, добавленные в эту сессию на лету.
713
+ *
714
+ * Помним их, потому что `setMcpServers` принимает полный набор: отправив
715
+ * только что добавленный сервер, мы отключили бы все предыдущие.
716
+ */
717
+ #dynamicMcp = /* @__PURE__ */ new Map();
718
+ #totalCostUsd = 0;
719
+ /** Последний снимок расхода лимитов плана; null — ещё не спрашивали. */
720
+ #usage = null;
721
+ /** Когда была последняя попытка его получить — удачная или нет. */
722
+ #usageAt = 0;
723
+ #usageInFlight = false;
724
+ #createdAt = Date.now();
725
+ #updatedAt = Date.now();
726
+ #options;
727
+ constructor(options2) {
728
+ super();
729
+ this.#options = options2;
730
+ this.id = options2.resume && !options2.fork ? options2.resume : randomUUID();
731
+ this.workspaceId = options2.workspaceId;
732
+ this.cwd = options2.cwd;
733
+ this.#permissionMode = options2.permissionMode ?? "default";
734
+ this.#model = options2.model ?? null;
735
+ this.#title = options2.title ?? options2.defaultTitle ?? null;
736
+ this.#titleIsDefault = !options2.title && options2.defaultTitle !== void 0;
737
+ }
738
+ // -------------------------------------------------------------------------
739
+ // Публичное состояние
740
+ // -------------------------------------------------------------------------
741
+ get state() {
742
+ return this.#state;
743
+ }
744
+ get capabilities() {
745
+ return this.#capabilities;
746
+ }
747
+ get claudeSessionId() {
748
+ return this.#claudeSessionId;
749
+ }
750
+ /** Боковая сессия («вопрос на полях») — у неё есть родитель и нет прав на запись. */
751
+ get parentSessionId() {
752
+ return this.#options.parentSessionId ?? null;
753
+ }
754
+ /** Расход лимитов плана. null — ещё ни разу не спросили или спросить не вышло. */
755
+ get usage() {
756
+ return this.#usage;
757
+ }
758
+ summary() {
759
+ return {
760
+ id: this.id,
761
+ claudeSessionId: this.#claudeSessionId,
762
+ workspaceId: this.workspaceId,
763
+ cwd: this.cwd,
764
+ parentSessionId: this.parentSessionId,
765
+ title: this.#title,
766
+ state: this.#state,
767
+ model: this.#model,
768
+ permissionMode: this.#permissionMode,
769
+ effort: this.#effort,
770
+ ultracode: this.#ultracode,
771
+ thinking: this.#thinking,
772
+ switchModelsOnFlag: this.#switchModelsOnFlag,
773
+ createdAt: this.#createdAt,
774
+ updatedAt: this.#updatedAt,
775
+ lastSeq: this.journal.lastSeq,
776
+ totalCostUsd: this.#totalCostUsd
777
+ };
778
+ }
779
+ // -------------------------------------------------------------------------
780
+ // Жизненный цикл
781
+ // -------------------------------------------------------------------------
782
+ start() {
783
+ if (this.#query)
784
+ throw new Error("session already started");
785
+ const options2 = {
786
+ cwd: this.cwd,
787
+ // Без этого SDK не читает с диска ничего: ни CLAUDE.md, ни кастомные
788
+ // слэш-команды, ни сабагентов из .claude/agents, ни MCP из settings.json.
789
+ // Именно эта строка отвечает за «сохранить весь функционал Claude Code».
790
+ settingSources: ["user", "project", "local"],
791
+ systemPrompt: {
792
+ type: "preset",
793
+ preset: "claude_code",
794
+ // Каталог называем только когда это правда каталог проекта. У сессии
795
+ // поиска воркспейса нет, а `cwd` у неё — корень, где ищут: назвать его
796
+ // «открытым проектом» значило бы соврать.
797
+ append: uiSystemPromptAppend(this.workspaceId ? this.cwd : null)
798
+ },
799
+ permissionMode: this.#permissionMode,
800
+ // Не включает `bypassPermissions`, а лишь разрешает его выбрать. Без
801
+ // флага CLI отказывает в переходе на лету («session was not launched
802
+ // with --dangerously-skip-permissions»), и пункт в выпадающем списке
803
+ // шапки просто не работал: режим молча оставался прежним. Раз пункт
804
+ // предложен человеку, выбор должен исполняться — а решает он.
805
+ allowDangerouslySkipPermissions: true,
806
+ // Даёт посимвольный стрим ответа, а не «тишина, потом стена текста».
807
+ includePartialMessages: true,
808
+ canUseTool: this.#canUseTool,
809
+ hooks: {
810
+ PreToolUse: [
811
+ {
812
+ matcher: WRITE_TOOLS,
813
+ hooks: [(input) => this.#guardWrite(input)]
814
+ }
815
+ ]
816
+ },
817
+ stderr: (data) => this.emit("stderr", data)
818
+ };
819
+ this.#executable = claudeExecutable();
820
+ if (this.#executable.path) {
821
+ options2.pathToClaudeCodeExecutable = this.#executable.path;
822
+ if (this.#executable.executable)
823
+ options2.executable = this.#executable.executable;
824
+ }
825
+ if (this.#model)
826
+ options2.model = this.#model;
827
+ if (this.#options.resume)
828
+ options2.resume = this.#options.resume;
829
+ if (this.#options.fork)
830
+ options2.forkSession = true;
831
+ if (this.#options.outputFormat)
832
+ options2.outputFormat = this.#options.outputFormat;
833
+ if (this.#options.readOnly) {
834
+ options2.tools = READ_ONLY_TOOLS;
835
+ options2.allowedTools = READ_ONLY_TOOLS;
836
+ options2.strictMcpConfig = true;
837
+ }
838
+ this.#query = query({ prompt: this.#inbox, options: options2 });
839
+ void this.#consume(this.#query);
840
+ void this.#loadCatalog();
841
+ }
842
+ /**
843
+ * Что известно о сессии до system/init.
844
+ *
845
+ * Каталог и режим прав мы задали сами, поэтому UI может показать их сразу.
846
+ * Модель здесь — алиас из опций сессии или из settings.json (см.
847
+ * `#resolveModel`), а не канонический id: до init канонического взять
848
+ * неоткуда, а показывать пустоту вместо выбранной в консоли модели хуже.
849
+ */
850
+ #baseCapabilities() {
851
+ return {
852
+ cwd: this.cwd,
853
+ model: this.#model ?? "",
854
+ permissionMode: this.#permissionMode,
855
+ slashCommands: [],
856
+ commands: [],
857
+ models: [],
858
+ tools: [],
859
+ agents: [],
860
+ mcpServers: [],
861
+ flags: []
862
+ };
863
+ }
864
+ async close(reason = "closed by client") {
865
+ this.#inbox.close();
866
+ for (const [requestId, pending] of this.#pendingPermissions) {
867
+ pending.resolve({ behavior: "deny", message: reason });
868
+ this.#pendingPermissions.delete(requestId);
869
+ }
870
+ try {
871
+ this.#query?.close();
872
+ } catch {
873
+ }
874
+ this.#setState("closed");
875
+ }
876
+ // -------------------------------------------------------------------------
877
+ // Ввод от пользователя
878
+ // -------------------------------------------------------------------------
879
+ sendPrompt(text) {
880
+ if (this.#inbox.closed)
881
+ throw new Error("session is closed");
882
+ this.#record({ kind: "user.message", text });
883
+ if (!this.#title || this.#titleIsDefault) {
884
+ this.#title = text.slice(0, 80);
885
+ this.#titleIsDefault = false;
886
+ }
887
+ if (this.#state === "error") {
888
+ this.#record({
889
+ kind: "error",
890
+ message: "\u0421\u0435\u0441\u0441\u0438\u044F \u043D\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D\u0430, \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u044F\u0442\u044C \u043F\u0440\u043E\u043C\u043F\u0442 \u043D\u0435\u043A\u0443\u0434\u0430 \u2014 \u0441\u043C. \u043E\u0448\u0438\u0431\u043A\u0443 \u0432\u044B\u0448\u0435."
891
+ });
892
+ return;
893
+ }
894
+ this.#setState("running");
895
+ this.#inbox.push({
896
+ type: "user",
897
+ message: { role: "user", content: [{ type: "text", text }] },
898
+ parent_tool_use_id: null
899
+ });
900
+ }
901
+ respondPermission(requestId, decision, reason) {
902
+ const pending = this.#pendingPermissions.get(requestId);
903
+ if (!pending)
904
+ return;
905
+ this.#pendingPermissions.delete(requestId);
906
+ pending.resolve(decision === "allow" ? { behavior: "allow", updatedInput: pending.input } : { behavior: "deny", message: reason ?? "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u043E \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0435\u043C" });
907
+ this.#record({ kind: "permission.resolved", requestId, decision, reason });
908
+ if (this.#pendingPermissions.size === 0)
909
+ this.#setState("running");
910
+ }
911
+ async interrupt() {
912
+ await this.#query?.interrupt();
913
+ }
914
+ /**
915
+ * Смена режима прав на лету.
916
+ *
917
+ * Отказ CLI гасим в ленту сессии, а не наружу исключением: кадр `error`
918
+ * клиент пишет в консоль браузера, и несменившийся режим выглядел бы
919
+ * молчаливым отказом кнопки. Здесь же рассылаем summary с прежним режимом —
920
+ * выпадающий список должен показывать то, что есть, а не то, что выбрали.
921
+ */
922
+ async setPermissionMode(mode) {
923
+ try {
924
+ await this.#query?.setPermissionMode(mode);
925
+ } catch (error) {
926
+ this.#record({
927
+ kind: "error",
928
+ message: `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0435\u0440\u0435\u0439\u0442\u0438 \u0432 \u0440\u0435\u0436\u0438\u043C ${mode}: ${error instanceof Error ? error.message : String(error)}`
929
+ });
930
+ this.emit("summary", this.summary());
931
+ return;
932
+ }
933
+ this.#permissionMode = mode;
934
+ this.emit("summary", this.summary());
935
+ }
936
+ async setModel(model) {
937
+ await this.#query?.setModel(model);
938
+ this.#model = model;
939
+ this.emit("summary", this.summary());
940
+ }
941
+ /**
942
+ * Усилие, размышления и подмена модели по флагу — всё это слой «флаговых»
943
+ * настроек SDK.
944
+ *
945
+ * `applyFlagSettings` кладёт значение над пользовательскими и проектными
946
+ * настройками, но под корпоративной политикой — то же место, куда пишет
947
+ * консоль, когда меняют то же самое на лету. Поэтому выбор действует до
948
+ * конца сессии и не переписывает settings.json пользователя.
949
+ *
950
+ * Локальное поле обновляем только после успеха: иначе шапка показывала бы
951
+ * уровень, до CLI не доехавший.
952
+ */
953
+ async setEffort(effort) {
954
+ await this.#query?.applyFlagSettings(this.#ultracode ? { effortLevel: effort, ultracode: false } : { effortLevel: effort });
955
+ this.#effort = effort;
956
+ this.#ultracode = false;
957
+ this.emit("summary", this.summary());
958
+ }
959
+ /**
960
+ * Ultracode: `xhigh` плюс постоянная оркестрация динамическими воркфлоу.
961
+ *
962
+ * Уровень выставляем сами, потому что CLI о нём отдельно не отчитывается, а
963
+ * в шапке уровень виден рядом с моделью. Выключение уровень не возвращает:
964
+ * `xhigh` остаётся ровно тем, что и был выбран, — понижать его за
965
+ * пользователя мы не вправе.
966
+ */
967
+ async setUltracode(enabled) {
968
+ await this.#query?.applyFlagSettings(enabled ? { ultracode: true, effortLevel: "xhigh" } : { ultracode: false });
969
+ this.#ultracode = enabled;
970
+ if (enabled)
971
+ this.#effort = "xhigh";
972
+ this.emit("summary", this.summary());
973
+ }
974
+ async setThinking(enabled) {
975
+ await this.#query?.applyFlagSettings({ alwaysThinkingEnabled: enabled });
976
+ this.#thinking = enabled;
977
+ this.emit("summary", this.summary());
978
+ }
979
+ async setSwitchModelsOnFlag(enabled) {
980
+ await this.#query?.applyFlagSettings({ switchModelsOnFlag: enabled });
981
+ this.#switchModelsOnFlag = enabled;
982
+ this.emit("summary", this.summary());
983
+ }
984
+ // -------------------------------------------------------------------------
985
+ // Лимиты плана
986
+ // -------------------------------------------------------------------------
987
+ /**
988
+ * Перечитать расход лимитов подписки.
989
+ *
990
+ * `force` — по прямой просьбе клиента (открыли сессию, CLI сам сказал, что
991
+ * лимит сдвинулся): тогда пауза между запросами не действует.
992
+ *
993
+ * Неудача здесь ничего не ломает и в ленту не пишется. Метод SDK помечен как
994
+ * экспериментальный и на старом CLI может отсутствовать вовсе — сессия от
995
+ * этого работать не перестаёт, просто полосы в шапке не будет.
996
+ */
997
+ async refreshUsage(force = false) {
998
+ const q = this.#query;
999
+ if (!q || this.#usageInFlight)
1000
+ return;
1001
+ if (!force && Date.now() - this.#usageAt < USAGE_MIN_INTERVAL_MS)
1002
+ return;
1003
+ this.#usageInFlight = true;
1004
+ try {
1005
+ const raw = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET();
1006
+ this.#usage = toUsageState(raw);
1007
+ this.emit("usage", this.#usage);
1008
+ } catch {
1009
+ } finally {
1010
+ this.#usageAt = Date.now();
1011
+ this.#usageInFlight = false;
1012
+ }
1013
+ }
1014
+ // -------------------------------------------------------------------------
1015
+ // MCP и плагины
1016
+ // -------------------------------------------------------------------------
1017
+ /** Живые статусы серверов: подключён, упал, ждёт авторизации. */
1018
+ async mcpServerStatus() {
1019
+ return await this.#query?.mcpServerStatus() ?? [];
1020
+ }
1021
+ /**
1022
+ * Поднять сервер в уже работающей сессии.
1023
+ *
1024
+ * `setMcpServers` заменяет весь набор динамических серверов целиком, а не
1025
+ * добавляет один, — поэтому добавленное за сессию приходится помнить и
1026
+ * отправлять каждый раз списком. Серверы из файлов настроек этот вызов не
1027
+ * трогает, так что своё накопленное ими не затрёт.
1028
+ */
1029
+ async addMcpServer(name, config) {
1030
+ this.#dynamicMcp.set(name, config);
1031
+ await this.#query?.setMcpServers(Object.fromEntries(this.#dynamicMcp));
1032
+ }
1033
+ /** Убрать сервер из живой сессии. Возвращает false, если он не наш. */
1034
+ async removeMcpServer(name) {
1035
+ if (!this.#dynamicMcp.delete(name))
1036
+ return false;
1037
+ await this.#query?.setMcpServers(Object.fromEntries(this.#dynamicMcp));
1038
+ return true;
1039
+ }
1040
+ /** Перечитать плагины с диска — после установки через внешний CLI. */
1041
+ async reloadPlugins() {
1042
+ const result = await this.#query?.reloadPlugins();
1043
+ if (!result)
1044
+ return;
1045
+ this.#capabilities = {
1046
+ ...this.#capabilities ?? this.#baseCapabilities(),
1047
+ commands: result.commands.map(toSlashCommand),
1048
+ agents: result.agents.map((a) => a.name),
1049
+ mcpServers: result.mcpServers.map((s) => ({ name: s.name, status: s.status }))
1050
+ };
1051
+ this.emit("capabilities", this.#capabilities);
1052
+ }
1053
+ /**
1054
+ * Переименовать сессию.
1055
+ *
1056
+ * В памяти — сразу, чтобы UI не ждал диска. На диск — тем же способом, что
1057
+ * `/rename` в CLI: запись в JSONL, так что имя переживает перезапуск хоста и
1058
+ * видно из самого Claude Code.
1059
+ */
1060
+ setTitle(title) {
1061
+ this.#title = title;
1062
+ this.#titleIsDefault = false;
1063
+ this.#updatedAt = Date.now();
1064
+ this.emit("summary", this.summary());
1065
+ void this.#persistTitle();
1066
+ }
1067
+ async #persistTitle() {
1068
+ const title = this.#title;
1069
+ if (!title || !this.#claudeSessionId) {
1070
+ this.#unpersistedTitle = title;
1071
+ return;
1072
+ }
1073
+ try {
1074
+ await renameSession(this.#claudeSessionId, title, { dir: this.cwd });
1075
+ this.#unpersistedTitle = null;
1076
+ } catch (error) {
1077
+ this.#unpersistedTitle = title;
1078
+ this.emit("stderr", `rename \u043D\u0435 \u0437\u0430\u043F\u0438\u0441\u0430\u043D: ${describe(error)}
1079
+ `);
1080
+ }
1081
+ }
1082
+ // -------------------------------------------------------------------------
1083
+ // Права: мост между колбэком SDK и асинхронным ответом из UI
1084
+ // -------------------------------------------------------------------------
1085
+ /**
1086
+ * SDK ждёт промис — мы его паркуем и разрешаем, когда придёт кадр
1087
+ * `permission.respond`. Промис переживает разрыв соединения с UI: агент
1088
+ * просто стоит на паузе, пока клиент не вернётся и не ответит.
1089
+ */
1090
+ /**
1091
+ * Запись за пределы проекта — только с разрешения человека.
1092
+ *
1093
+ * Промптом это не чинится: `cwd` задаёт каталог процессу, но не выбирает
1094
+ * место за модель, а системный промпт CLI отдельно и настойчиво зовёт писать
1095
+ * «временное» в scratchpad внутри `%TEMP%`. Однажды туда уехал файл, который
1096
+ * просили создать в проекте, — и в UI его было не найти: проводник показывает
1097
+ * только каталог проекта. Назвать каталог в промпте (`uiSystemPromptAppend`)
1098
+ * помогает, но остаётся уговором, а этот хук — проверкой.
1099
+ *
1100
+ * Именно `PreToolUse`, а не `canUseTool`: до `canUseTool` дело не доходит в
1101
+ * `acceptEdits`, `dontAsk` и `bypassPermissions` — там запись разрешена
1102
+ * заранее. Решение хука эти режимы перебивает, поэтому правило работает во
1103
+ * всех.
1104
+ *
1105
+ * Решение — `ask`, а не `deny`: писать вне проекта иногда надо по делу
1106
+ * (настройки в `~/.claude`, временный скрипт). Запрет ломал бы это, а вопрос
1107
+ * лишь возвращает выбор человеку — нажатое «Разрешить» и есть то самое явное
1108
+ * согласие на «иное место».
1109
+ *
1110
+ * Где именно проходит граница, решает настройка «Доступ к папкам»
1111
+ * (`writableRoots`): её самый широкий вариант убирает вопрос совсем, самый
1112
+ * строгий оставляет ровно каталог проекта.
1113
+ */
1114
+ #guardWrite = async (input) => {
1115
+ const pass = { continue: true };
1116
+ if (!this.workspaceId)
1117
+ return pass;
1118
+ const roots = this.#options.writableRoots?.() ?? [this.cwd];
1119
+ if (roots === null)
1120
+ return pass;
1121
+ const target = writeTarget(input.tool_input);
1122
+ if (!target)
1123
+ return pass;
1124
+ const absolute = path5.resolve(this.cwd, target);
1125
+ if (roots.some((root) => contains(root, absolute)))
1126
+ return pass;
1127
+ return {
1128
+ continue: true,
1129
+ hookSpecificOutput: {
1130
+ hookEventName: "PreToolUse",
1131
+ permissionDecision: "ask",
1132
+ // Каталог проекта в `roots` есть всегда, поэтому сюда попадает только
1133
+ // то, что лежит вне его, — про проводник можно говорить без оговорок.
1134
+ permissionDecisionReason: `\u0424\u0430\u0439\u043B \u0432\u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0451\u043D\u043D\u044B\u0445 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u043E\u0432 (${roots.join(", ")}): ${absolute}. \u0412 \u043F\u0440\u043E\u0432\u043E\u0434\u043D\u0438\u043A\u0435 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F \u043E\u043D \u0432\u0438\u0434\u0435\u043D \u043D\u0435 \u0431\u0443\u0434\u0435\u0442. \u0413\u0440\u0430\u043D\u0438\u0446\u0443 \u0437\u0430\u0434\u0430\u0451\u0442 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0430 \xAB\u0414\u043E\u0441\u0442\u0443\u043F \u043A \u043F\u0430\u043F\u043A\u0430\u043C\xBB.`
1135
+ }
1136
+ };
1137
+ };
1138
+ #canUseTool = (toolName, input, { signal, decisionReason }) => {
1139
+ if (this.#options.readOnly) {
1140
+ this.#record({
1141
+ kind: "error",
1142
+ message: `\u0411\u043E\u043A\u043E\u0432\u043E\u0439 \u0432\u043E\u043F\u0440\u043E\u0441 \u043D\u0435 \u0432\u044B\u043F\u043E\u043B\u043D\u044F\u0435\u0442 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0439: ${toolName} \u043E\u0442\u043A\u043B\u043E\u043D\u0451\u043D`
1143
+ });
1144
+ return Promise.resolve({
1145
+ behavior: "deny",
1146
+ message: "\u0411\u043E\u043A\u043E\u0432\u0430\u044F \u0441\u0435\u0441\u0441\u0438\u044F \u0440\u0430\u0431\u043E\u0442\u0430\u0435\u0442 \u0442\u043E\u043B\u044C\u043A\u043E \u043D\u0430 \u0447\u0442\u0435\u043D\u0438\u0435"
1147
+ });
1148
+ }
1149
+ const requestId = randomUUID();
1150
+ return new Promise((resolve) => {
1151
+ this.#pendingPermissions.set(requestId, { resolve, toolName, input });
1152
+ this.#setState("waiting_permission");
1153
+ this.#record({
1154
+ kind: "permission.request",
1155
+ requestId,
1156
+ toolName,
1157
+ input,
1158
+ // Причину даёт не всякий запрос: обычный вопрос в режиме `default`
1159
+ // объясняется самим режимом, а вот запрос, поднятый хуком поверх
1160
+ // разрешающего режима, без неё выглядит сбоем.
1161
+ ...decisionReason ? { reason: decisionReason } : {}
1162
+ });
1163
+ signal.addEventListener("abort", () => {
1164
+ if (!this.#pendingPermissions.delete(requestId))
1165
+ return;
1166
+ resolve({ behavior: "deny", message: "\u041F\u0440\u0435\u0440\u0432\u0430\u043D\u043E" });
1167
+ }, { once: true });
1168
+ });
1169
+ };
1170
+ // -------------------------------------------------------------------------
1171
+ // Поток сообщений из SDK -> события журнала
1172
+ // -------------------------------------------------------------------------
1173
+ async #consume(q) {
1174
+ try {
1175
+ for await (const message of q) {
1176
+ this.#handleMessage(message);
1177
+ }
1178
+ this.#setState("closed");
1179
+ } catch (error) {
1180
+ const message = error instanceof Error ? error.message : String(error);
1181
+ this.#record({ kind: "error", message: message + this.#executableHint() });
1182
+ this.#setState("error");
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Приписка про CLI, которым поднимали сессию.
1187
+ *
1188
+ * Надёжной проверки совместимости версий нет: у внешнего CLI и у SDK свои
1189
+ * ряды версий, и сказать заранее, поймёт ли этот CLI то, что пошлёт этот SDK,
1190
+ * нечем — их согласие проверяется только запуском. Поэтому мы не выдумываем
1191
+ * проверку, а объясняем провал: если сессия умерла, не дойдя до `system/init`,
1192
+ * рукопожатия с CLI не было вовсе, и первый подозреваемый — он.
1193
+ *
1194
+ * После init приписки нет: тогда CLI заведомо понят, а ошибка — про дело.
1195
+ */
1196
+ #executableHint() {
1197
+ if (this.#capabilities || !this.#executable.path)
1198
+ return "";
1199
+ const whence = this.#executable.source === "env" ? "\u0443\u043A\u0430\u0437\u0430\u043D\u043D\u044B\u0439 \u0432 CCV_CLAUDE_PATH" : "\u043D\u0430\u0439\u0434\u0435\u043D\u043D\u044B\u0439 \u0432 \u0441\u0438\u0441\u0442\u0435\u043C\u0435";
1200
+ return `
1201
+
1202
+ \u0421\u0435\u0441\u0441\u0438\u044E \u043F\u043E\u0434\u043D\u0438\u043C\u0430\u043B Claude Code, ${whence}: ${this.#executable.path}. \u0415\u0441\u043B\u0438 \u0434\u0435\u043B\u043E \u0432 \u043D\u0451\u043C, CCV_CLAUDE_PATH \u0443\u043A\u0430\u0436\u0435\u0442 \u0434\u0440\u0443\u0433\u043E\u0439, \u0430 CCV_BUNDLED_CLAUDE=1 \u0432\u0435\u0440\u043D\u0451\u0442 ccv \u043D\u0430 \u0431\u0438\u043D\u0430\u0440\u043D\u0438\u043A \u0438\u0437 Agent SDK.`;
1203
+ }
1204
+ #handleMessage(message) {
1205
+ switch (message.type) {
1206
+ case "system":
1207
+ this.#handleSystem(message);
1208
+ break;
1209
+ case "assistant":
1210
+ for (const block of message.message.content) {
1211
+ const blockId = `${message.message.id}:${this.#nextBlockIndex(message.message.id)}`;
1212
+ if (block.type === "text") {
1213
+ this.#record({ kind: "assistant.text", text: block.text, blockId });
1214
+ } else if (block.type === "thinking") {
1215
+ this.#record({
1216
+ kind: "assistant.thinking",
1217
+ text: block.thinking,
1218
+ blockId
1219
+ });
1220
+ } else if (block.type === "tool_use") {
1221
+ this.#record({
1222
+ kind: "tool.use",
1223
+ toolUseId: block.id,
1224
+ name: block.name,
1225
+ input: block.input
1226
+ });
1227
+ }
1228
+ }
1229
+ break;
1230
+ case "user":
1231
+ this.#handleToolResults(message);
1232
+ break;
1233
+ case "stream_event":
1234
+ this.#handleStreamEvent(message);
1235
+ break;
1236
+ case "result": {
1237
+ const usage = message.usage ? {
1238
+ inputTokens: message.usage.input_tokens ?? 0,
1239
+ outputTokens: message.usage.output_tokens ?? 0,
1240
+ cacheReadTokens: message.usage.cache_read_input_tokens ?? 0,
1241
+ cacheCreationTokens: message.usage.cache_creation_input_tokens ?? 0
1242
+ } : null;
1243
+ if (typeof message.total_cost_usd === "number") {
1244
+ this.#totalCostUsd += message.total_cost_usd;
1245
+ }
1246
+ this.#record({
1247
+ kind: "result",
1248
+ subtype: message.subtype,
1249
+ isError: message.is_error,
1250
+ durationMs: message.duration_ms,
1251
+ costUsd: message.total_cost_usd ?? null,
1252
+ usage,
1253
+ text: "result" in message ? message.result : null
1254
+ });
1255
+ if (this.#options.outputFormat) {
1256
+ this.emit("structured", {
1257
+ data: "structured_output" in message ? message.structured_output : void 0,
1258
+ text: "result" in message ? message.result : null
1259
+ });
1260
+ }
1261
+ this.#setState("idle");
1262
+ if (this.#unpersistedTitle)
1263
+ void this.#persistTitle();
1264
+ void this.refreshUsage();
1265
+ break;
1266
+ }
1267
+ // CLI сам сообщил, что лимит сдвинулся (обычно — подошёл к порогу).
1268
+ // Проценты в этом сообщении относятся к одному окну, а в шапке их два,
1269
+ // поэтому берём не их, а свежий снимок целиком.
1270
+ case "rate_limit_event":
1271
+ void this.refreshUsage(true);
1272
+ break;
1273
+ default:
1274
+ break;
1275
+ }
1276
+ }
1277
+ #handleSystem(message) {
1278
+ if (message.subtype === "init") {
1279
+ this.#model = message.model;
1280
+ this.#permissionMode = message.permissionMode;
1281
+ this.#claudeSessionId = message.session_id;
1282
+ const catalog = this.#capabilities ?? this.#baseCapabilities();
1283
+ this.#capabilities = {
1284
+ ...catalog,
1285
+ cwd: message.cwd,
1286
+ model: message.model,
1287
+ permissionMode: message.permissionMode,
1288
+ slashCommands: message.slash_commands ?? [],
1289
+ tools: message.tools ?? [],
1290
+ agents: message.agents ?? [],
1291
+ mcpServers: message.mcp_servers ?? [],
1292
+ flags: message.capabilities ?? []
1293
+ };
1294
+ this.emit("capabilities", this.#capabilities);
1295
+ this.emit("summary", this.summary());
1296
+ if (catalog.commands.length === 0)
1297
+ void this.#loadCatalog();
1298
+ if (this.#unpersistedTitle)
1299
+ void this.#persistTitle();
1300
+ if (this.#state === "starting")
1301
+ this.#setState("idle");
1302
+ void this.refreshUsage(true);
1303
+ return;
1304
+ }
1305
+ const { type: _type, subtype, ...rest } = message;
1306
+ this.#record({ kind: "system", subtype, data: rest });
1307
+ }
1308
+ /**
1309
+ * Слэш-команды и модели — то, из чего UI строит меню «/» и выбор модели.
1310
+ *
1311
+ * Это control-запрос к CLI, которого на старой версии может не быть вовсе:
1312
+ * падение гасим молча. Без каталога UI останется без меню команд, но сессия
1313
+ * работать не перестанет — ради подсказок ломать её нельзя.
1314
+ */
1315
+ async #loadCatalog() {
1316
+ const q = this.#query;
1317
+ if (!q)
1318
+ return;
1319
+ await this.#resolveDefaults();
1320
+ const init = await q.initializationResult().catch(() => null);
1321
+ if (!init)
1322
+ return;
1323
+ this.#capabilities = {
1324
+ ...this.#capabilities ?? this.#baseCapabilities(),
1325
+ commands: init.commands.map(toSlashCommand),
1326
+ models: init.models.map((m) => ({
1327
+ value: m.value,
1328
+ resolvedModel: m.resolvedModel ?? null,
1329
+ displayName: m.displayName,
1330
+ description: m.description,
1331
+ supportsEffort: m.supportsEffort === true,
1332
+ effortLevels: m.supportedEffortLevels ?? [],
1333
+ supportsThinking: m.supportsAdaptiveThinking === true,
1334
+ supportsAutoMode: m.supportsAutoMode === true
1335
+ }))
1336
+ };
1337
+ this.emit("capabilities", this.#capabilities);
1338
+ }
1339
+ /**
1340
+ * Что выбрано в сессии — до того, как об этом скажет `system/init`.
1341
+ *
1342
+ * Спросить об этом SDK нельзя: списком моделей он делится, а тем, какая из
1343
+ * них текущая, — нет; про усилие и размышления не говорит и `init`. Поэтому
1344
+ * читаем те же settings.json, что и консоль ([`settings.ts`](./settings.ts)).
1345
+ * Иначе дропдаун до первого хода показывал бы «ничего не выбрано» на сессии,
1346
+ * которая уже работает на opus с высоким усилием.
1347
+ *
1348
+ * Ответ здесь — предположение о том, что прочитает CLI из тех же файлов, а
1349
+ * не факт: `system/init` затем приходит с канонической моделью и заменяет
1350
+ * его. Явно заданную при старте модель не трогаем — она сильнее файлов.
1351
+ */
1352
+ async #resolveDefaults() {
1353
+ const configured = await readConfiguredDefaults(this.cwd).catch(() => null);
1354
+ if (!configured)
1355
+ return;
1356
+ this.#effort = configured.effort;
1357
+ this.#ultracode = configured.ultracode;
1358
+ this.#thinking = configured.thinking;
1359
+ this.#switchModelsOnFlag = configured.switchModelsOnFlag;
1360
+ if (configured.model && !this.#model)
1361
+ this.#model = configured.model;
1362
+ this.emit("summary", this.summary());
1363
+ }
1364
+ #handleToolResults(message) {
1365
+ const content = message.message.content;
1366
+ if (typeof content === "string")
1367
+ return;
1368
+ for (const block of content) {
1369
+ if (block.type !== "tool_result")
1370
+ continue;
1371
+ this.#record({
1372
+ kind: "tool.result",
1373
+ toolUseId: block.tool_use_id,
1374
+ content: contentToText(block.content),
1375
+ isError: block.is_error === true
1376
+ });
1377
+ }
1378
+ }
1379
+ /** Индекс очередного блока сообщения — в той же нумерации, что у дельт. */
1380
+ #nextBlockIndex(messageId) {
1381
+ const index = this.#blockCounts.get(messageId) ?? 0;
1382
+ this.#blockCounts.set(messageId, index + 1);
1383
+ if (this.#blockCounts.size > 64) {
1384
+ const oldest = this.#blockCounts.keys().next().value;
1385
+ if (oldest !== void 0 && oldest !== messageId) {
1386
+ this.#blockCounts.delete(oldest);
1387
+ }
1388
+ }
1389
+ return index;
1390
+ }
1391
+ #handleStreamEvent(message) {
1392
+ const event = message.event;
1393
+ if (event.type === "message_start") {
1394
+ this.#streamMessageId = event.message.id;
1395
+ return;
1396
+ }
1397
+ if (event.type !== "content_block_delta")
1398
+ return;
1399
+ if (!this.#streamMessageId)
1400
+ return;
1401
+ const delta = event.delta;
1402
+ const blockId = `${this.#streamMessageId}:${event.index}`;
1403
+ if (delta.type === "text_delta") {
1404
+ this.#record({ kind: "assistant.delta", text: delta.text, blockId });
1405
+ } else if (delta.type === "thinking_delta") {
1406
+ this.#record({ kind: "thinking.delta", text: delta.thinking, blockId });
1407
+ }
1408
+ }
1409
+ // -------------------------------------------------------------------------
1410
+ #record(event) {
1411
+ this.#updatedAt = Date.now();
1412
+ const entry = this.journal.append(event);
1413
+ this.emit("event", entry);
1414
+ }
1415
+ #setState(state) {
1416
+ if (this.#state === state)
1417
+ return;
1418
+ this.#state = state;
1419
+ this.#record({ kind: "state", state });
1420
+ this.emit("summary", this.summary());
1421
+ }
1422
+ };
1423
+ function describe(error) {
1424
+ return error instanceof Error ? error.message : String(error);
1425
+ }
1426
+ function toSlashCommand(command) {
1427
+ return {
1428
+ name: command.name,
1429
+ description: command.description,
1430
+ argumentHint: command.argumentHint ?? "",
1431
+ aliases: command.aliases ?? []
1432
+ };
1433
+ }
1434
+ function toUsageState(raw) {
1435
+ const limits = raw.rate_limits ?? {};
1436
+ return {
1437
+ available: raw.rate_limits_available === true,
1438
+ plan: raw.subscription_type ?? "",
1439
+ fiveHour: toUsageWindow(limits.five_hour),
1440
+ sevenDay: toUsageWindow(limits.seven_day),
1441
+ sevenDayOpus: toUsageWindow(limits.seven_day_opus),
1442
+ fetchedAt: Date.now()
1443
+ };
1444
+ }
1445
+ function toUsageWindow(raw) {
1446
+ if (!raw || typeof raw.utilization !== "number")
1447
+ return null;
1448
+ return {
1449
+ // Доля окна и так приходит в 0–100, но полоса шире дорожки — это уже не
1450
+ // «почти всё израсходовано», а сломанная вёрстка.
1451
+ utilization: Math.min(100, Math.max(0, raw.utilization)),
1452
+ resetsAt: typeof raw.resets_at === "string" ? raw.resets_at : ""
1453
+ };
1454
+ }
1455
+ function contentToText(content) {
1456
+ if (typeof content === "string")
1457
+ return content;
1458
+ if (!Array.isArray(content))
1459
+ return "";
1460
+ return content.map((block) => {
1461
+ if (typeof block === "string")
1462
+ return block;
1463
+ if (block && typeof block === "object" && "text" in block) {
1464
+ return String(block.text);
1465
+ }
1466
+ if (block && typeof block === "object" && "type" in block) {
1467
+ return `[${String(block.type)}]`;
1468
+ }
1469
+ return "";
1470
+ }).join("");
1471
+ }
1472
+
1473
+ // ../host/dist/git.js
1474
+ import { execFile as execFile2 } from "node:child_process";
1475
+ var TIMEOUT_MS = 2e4;
1476
+ var MAX_BUFFER = 32 * 1024 * 1024;
1477
+ var LOG_LIMIT = 150;
1478
+ var UNPUSHED_LIMIT = 2e3;
1479
+ var MAX_PATCH = 4e5;
1480
+ var MAX_FILE_PATCH = 8e4;
1481
+ var UNIT = "";
1482
+ var RECORD = "";
1483
+ var SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._/@^~-]*$/;
1484
+ var GitRepo = class {
1485
+ cwd;
1486
+ constructor(cwd) {
1487
+ this.cwd = cwd;
1488
+ }
1489
+ /**
1490
+ * Снимок репозитория целиком: статус, ветки и лента коммитов.
1491
+ *
1492
+ * Собирается одним проходом, потому что панель показывает это вместе.
1493
+ * Отдельные части опрашиваются терпимо к ошибкам: в репозитории без единого
1494
+ * коммита `rev-parse HEAD` падает, и это не повод не показать вообще ничего.
1495
+ */
1496
+ async state() {
1497
+ const blank = {
1498
+ available: false,
1499
+ message: "",
1500
+ branch: "",
1501
+ head: "",
1502
+ detached: false,
1503
+ upstream: "",
1504
+ hasRemote: false,
1505
+ ahead: 0,
1506
+ behind: 0,
1507
+ changed: [],
1508
+ commits: [],
1509
+ branches: []
1510
+ };
1511
+ try {
1512
+ await this.#run(["rev-parse", "--is-inside-work-tree"]);
1513
+ } catch (error) {
1514
+ return { ...blank, message: describe2(error) };
1515
+ }
1516
+ const branchRaw = await this.#soft(["rev-parse", "--abbrev-ref", "HEAD"]);
1517
+ const detached = branchRaw === "HEAD" || branchRaw === "";
1518
+ const upstream = await this.#soft([
1519
+ "rev-parse",
1520
+ "--abbrev-ref",
1521
+ "--symbolic-full-name",
1522
+ "@{upstream}"
1523
+ ]);
1524
+ const counts = upstream ? await this.#soft(["rev-list", "--left-right", "--count", `${upstream}...HEAD`]) : "";
1525
+ const [behind = "0", ahead = "0"] = counts.split(/\s+/);
1526
+ const hasRemote = await this.#soft(["remote"]) !== "";
1527
+ return {
1528
+ available: true,
1529
+ message: "",
1530
+ branch: detached ? "" : branchRaw,
1531
+ head: await this.#soft(["rev-parse", "HEAD"]),
1532
+ detached,
1533
+ upstream,
1534
+ hasRemote,
1535
+ ahead: Number(ahead) || 0,
1536
+ behind: Number(behind) || 0,
1537
+ changed: parseStatus(await this.#soft(["status", "--porcelain=v1"])),
1538
+ commits: parseLog(await this.#soft([
1539
+ "log",
1540
+ "--all",
1541
+ "--topo-order",
1542
+ `--max-count=${LOG_LIMIT}`,
1543
+ `--format=%H${UNIT}%P${UNIT}%an${UNIT}%aI${UNIT}%D${UNIT}%s${RECORD}`
1544
+ ]), hasRemote ? await this.#unpushed() : /* @__PURE__ */ new Set()),
1545
+ branches: parseBranches(await this.#soft(["branch", "--format=%(objectname):%(refname:short)"]), detached ? "" : branchRaw)
1546
+ };
1547
+ }
1548
+ /**
1549
+ * Хеши коммитов, которых нет ни в одной удалённой ветке.
1550
+ *
1551
+ * Спрашиваем «чего нет на remote», а не «что на нём есть»: неотправленного
1552
+ * почти всегда горстка, а отправленного — вся история, и второй список был
1553
+ * бы во столько же раз длиннее. `--all --not --remotes` — ровно эта разница:
1554
+ * всё, что видно локально, минус всё, что достижимо из `refs/remotes`.
1555
+ *
1556
+ * Достижимость, а не совпадение с вершиной ветки: коммит в глубине уже
1557
+ * отправленной истории отправлен так же, как её последний коммит.
1558
+ */
1559
+ async #unpushed() {
1560
+ const raw = await this.#soft([
1561
+ "rev-list",
1562
+ "--all",
1563
+ "--not",
1564
+ "--remotes",
1565
+ `--max-count=${UNPUSHED_LIMIT}`
1566
+ ]);
1567
+ return new Set(raw.split("\n").filter(Boolean));
1568
+ }
1569
+ /**
1570
+ * Коммит целиком: заголовок, сообщение и патчи файлов.
1571
+ *
1572
+ * Пустой `ref` — незакоммиченные изменения рабочего дерева. Это тот же
1573
+ * запрос по смыслу («покажи, что здесь поменялось»), и разводить его в
1574
+ * отдельный кадр протокола значило бы дублировать разбор патча.
1575
+ */
1576
+ async show(ref) {
1577
+ if (ref === "")
1578
+ return this.#worktree();
1579
+ const hash = requireRef(ref);
1580
+ const raw = await this.#run([
1581
+ "show",
1582
+ "--no-color",
1583
+ "--no-patch",
1584
+ `--format=%H${UNIT}%h${UNIT}%P${UNIT}%an${UNIT}%ae${UNIT}%aI${UNIT}%s${UNIT}%b`,
1585
+ hash
1586
+ ]);
1587
+ const parts = raw.split(UNIT);
1588
+ const patch = await this.#run([
1589
+ "show",
1590
+ "--no-color",
1591
+ "--patch",
1592
+ "--find-renames",
1593
+ "--format=",
1594
+ hash
1595
+ ]);
1596
+ const { files, truncated } = parsePatch(patch);
1597
+ return {
1598
+ hash: parts[0] ?? hash,
1599
+ shortHash: parts[1] ?? hash.slice(0, 8),
1600
+ parents: (parts[2] ?? "").split(" ").filter(Boolean),
1601
+ author: parts[3] ?? "",
1602
+ email: parts[4] ?? "",
1603
+ date: parts[5] ?? "",
1604
+ subject: parts[6] ?? "",
1605
+ body: parts.slice(7).join(UNIT).trim(),
1606
+ files,
1607
+ truncated
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Незакоммиченные изменения.
1612
+ *
1613
+ * `git diff HEAD` показывает и индекс, и рабочее дерево, но не знает про
1614
+ * файлы, которых в репозитории ещё нет вовсе. Их дописываем из статуса
1615
+ * отдельными строками без содержимого: показать «новый файл» честнее, чем
1616
+ * промолчать о нём.
1617
+ */
1618
+ async #worktree() {
1619
+ const patch = await this.#soft(["diff", "HEAD", "--no-color", "--find-renames"]);
1620
+ const { files, truncated } = parsePatch(patch);
1621
+ const untracked = parseStatus(await this.#soft(["status", "--porcelain=v1"])).filter((change) => change.status === "??").map((change) => ({
1622
+ path: change.path,
1623
+ oldPath: "",
1624
+ status: "added",
1625
+ added: 0,
1626
+ removed: 0,
1627
+ binary: false,
1628
+ patch: ""
1629
+ }));
1630
+ return {
1631
+ hash: "",
1632
+ shortHash: "",
1633
+ parents: [],
1634
+ author: "",
1635
+ email: "",
1636
+ date: (/* @__PURE__ */ new Date()).toISOString(),
1637
+ subject: "\u041D\u0435\u0437\u0430\u043A\u043E\u043C\u043C\u0438\u0447\u0435\u043D\u043D\u044B\u0435 \u0438\u0437\u043C\u0435\u043D\u0435\u043D\u0438\u044F",
1638
+ body: "",
1639
+ files: [...files, ...untracked],
1640
+ truncated
1641
+ };
1642
+ }
1643
+ /**
1644
+ * Какие из путей игнорируются по `.gitignore` — одним вызовом на весь список.
1645
+ *
1646
+ * Спрашиваем сам git, а не разбираем `.gitignore` руками: правил там больше,
1647
+ * чем кажется (вложенные файлы в каждом каталоге, `.git/info/exclude`,
1648
+ * глобальный `core.excludesFile`, отрицания `!`), и своя реализация врала бы
1649
+ * ровно в тех случаях, ради которых на неё и смотрят.
1650
+ *
1651
+ * Пути уходят через stdin, а не аргументами: каталог с тысячей записей не
1652
+ * влез бы в командную строку Windows. Отслеживаемый файл под правилом
1653
+ * игнорирования git ответом не назовёт — индекс он учитывает сам.
1654
+ *
1655
+ * Провал здесь — обычное дело (каталог не репозиторий, git не установлен), и
1656
+ * значит он ровно «ничего не игнорируется»: дерево файлов работает и без git.
1657
+ */
1658
+ async checkIgnored(paths) {
1659
+ if (paths.length === 0)
1660
+ return /* @__PURE__ */ new Set();
1661
+ const raw = await this.#run(
1662
+ ["check-ignore", "--stdin", "-z"],
1663
+ // `-z` переводит на NUL и вход тоже — иначе путь с переводом строки в
1664
+ // имени разъехался бы на две записи.
1665
+ paths.join("\0")
1666
+ ).catch(() => "");
1667
+ return new Set(raw.split("\0").filter(Boolean));
1668
+ }
1669
+ /**
1670
+ * Завести репозиторий в каталоге — шаг создания нового проекта.
1671
+ *
1672
+ * Имя начальной ветки не задаём: его выбирает `init.defaultBranch` из
1673
+ * настроек пользователя, и навязывать здесь своё значило бы разойтись с тем,
1674
+ * что тот же человек получает от `git init` в терминале.
1675
+ */
1676
+ async init() {
1677
+ return await this.#run(["init"]);
1678
+ }
1679
+ /** Переключиться на ветку или коммит. Возвращает то, что сказал сам git. */
1680
+ async checkout(target) {
1681
+ const out = await this.#run(["checkout", requireRef(target), "--"]);
1682
+ return out || `\u041F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0435\u043D\u043E \u043D\u0430 ${target}`;
1683
+ }
1684
+ /** Команда, чей провал — обычное дело (нет upstream, нет коммитов, detached). */
1685
+ async #soft(args) {
1686
+ return this.#run(args).catch(() => "");
1687
+ }
1688
+ /** `input` — то, что команда читает со stdin; без него stdin сразу закрывается. */
1689
+ #run(args, input) {
1690
+ return new Promise((resolve, reject) => {
1691
+ const child = execFile2(
1692
+ "git",
1693
+ // core.quotepath=false — иначе кириллические пути приезжают в виде
1694
+ // «\320\277\321\200…» и в панели читаются как мусор.
1695
+ ["-c", "core.quotepath=false", ...args],
1696
+ {
1697
+ cwd: this.cwd,
1698
+ timeout: TIMEOUT_MS,
1699
+ maxBuffer: MAX_BUFFER,
1700
+ windowsHide: true,
1701
+ encoding: "utf8"
1702
+ },
1703
+ (error, stdout, stderr) => {
1704
+ if (!error)
1705
+ return resolve(stdout.trimEnd());
1706
+ if (error.code === "ENOENT") {
1707
+ return reject(new Error(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u044C git \u0432 ${this.cwd} \u2014 \u043D\u0435\u0442 git \u0432 PATH \u0438\u043B\u0438 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430`));
1708
+ }
1709
+ const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
1710
+ reject(new Error(detail || error.message));
1711
+ }
1712
+ );
1713
+ child.stdin?.on("error", () => {
1714
+ });
1715
+ child.stdin?.end(input ?? "");
1716
+ });
1717
+ }
1718
+ };
1719
+ function requireRef(value) {
1720
+ const trimmed = value.trim();
1721
+ if (!SAFE_REF.test(trimmed) || trimmed.includes("..")) {
1722
+ throw new Error(`\u041D\u0435\u0434\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u0430\u044F \u0441\u0441\u044B\u043B\u043A\u0430 git: \xAB${value}\xBB`);
1723
+ }
1724
+ return trimmed;
1725
+ }
1726
+ function describe2(error) {
1727
+ return error instanceof Error ? error.message : String(error);
1728
+ }
1729
+ function parseStatus(raw) {
1730
+ const out = [];
1731
+ for (const line of raw.split("\n")) {
1732
+ if (line.length < 4)
1733
+ continue;
1734
+ const status = line.slice(0, 2);
1735
+ const rest = line.slice(3);
1736
+ const arrow = rest.indexOf(" -> ");
1737
+ out.push({
1738
+ status,
1739
+ staged: status[0] !== " " && status[0] !== "?",
1740
+ path: arrow === -1 ? unquote(rest) : unquote(rest.slice(arrow + 4)),
1741
+ oldPath: arrow === -1 ? "" : unquote(rest.slice(0, arrow))
1742
+ });
1743
+ }
1744
+ return out;
1745
+ }
1746
+ function unquote(value) {
1747
+ if (!value.startsWith('"') || !value.endsWith('"'))
1748
+ return value;
1749
+ return value.slice(1, -1).replace(/\\(.)/g, "$1");
1750
+ }
1751
+ function parseLog(raw, unpushed) {
1752
+ const out = [];
1753
+ for (const record of raw.split(RECORD)) {
1754
+ const line = record.replace(/^\n/, "");
1755
+ if (line.trim() === "")
1756
+ continue;
1757
+ const [hash = "", parents = "", author = "", date = "", refs = "", subject = ""] = line.split(UNIT);
1758
+ out.push({
1759
+ hash,
1760
+ shortHash: hash.slice(0, 8),
1761
+ parents: parents.split(" ").filter(Boolean),
1762
+ author,
1763
+ date,
1764
+ subject,
1765
+ refs: refs.split(", ").map((ref) => ref.trim()).filter(Boolean),
1766
+ // Пустое множество приходит и от репозитория без remote — там pushed
1767
+ // читать нельзя, и UI его не читает.
1768
+ pushed: !unpushed.has(hash)
1769
+ });
1770
+ }
1771
+ return out;
1772
+ }
1773
+ function parseBranches(raw, current) {
1774
+ const out = [];
1775
+ for (const line of raw.split("\n")) {
1776
+ const at = line.indexOf(":");
1777
+ if (at === -1)
1778
+ continue;
1779
+ const name = line.slice(at + 1).trim();
1780
+ if (!name)
1781
+ continue;
1782
+ out.push({ name, hash: line.slice(0, at).trim(), current: name === current });
1783
+ }
1784
+ return out;
1785
+ }
1786
+ function parsePatch(raw) {
1787
+ const files = [];
1788
+ let truncated = false;
1789
+ let total = 0;
1790
+ for (const chunk of raw.split(/^diff --git /m).slice(1)) {
1791
+ const lines = chunk.split("\n");
1792
+ let oldPath = "";
1793
+ let newPath = "";
1794
+ let status = "modified";
1795
+ let binary = false;
1796
+ let bodyStart = -1;
1797
+ for (let i = 0; i < lines.length; i++) {
1798
+ const line = lines[i] ?? "";
1799
+ if (line.startsWith("@@")) {
1800
+ bodyStart = i;
1801
+ break;
1802
+ }
1803
+ if (line.startsWith("--- "))
1804
+ oldPath = stripPrefix(line.slice(4));
1805
+ else if (line.startsWith("+++ "))
1806
+ newPath = stripPrefix(line.slice(4));
1807
+ else if (line.startsWith("new file mode"))
1808
+ status = "added";
1809
+ else if (line.startsWith("deleted file mode"))
1810
+ status = "deleted";
1811
+ else if (line.startsWith("rename from ")) {
1812
+ status = "renamed";
1813
+ oldPath = line.slice(12).trim();
1814
+ } else if (line.startsWith("rename to "))
1815
+ newPath = line.slice(10).trim();
1816
+ else if (line.startsWith("Binary files") || line.startsWith("GIT binary patch")) {
1817
+ binary = true;
1818
+ }
1819
+ }
1820
+ if (!newPath && !oldPath) {
1821
+ const header = lines[0] ?? "";
1822
+ const half = Math.floor(header.length / 2);
1823
+ newPath = stripPrefix(header.slice(half).trim());
1824
+ oldPath = newPath;
1825
+ }
1826
+ let body = bodyStart === -1 ? "" : lines.slice(bodyStart).join("\n").trimEnd();
1827
+ if (body.length > MAX_FILE_PATCH) {
1828
+ body = body.slice(0, MAX_FILE_PATCH);
1829
+ truncated = true;
1830
+ }
1831
+ if (total + body.length > MAX_PATCH) {
1832
+ truncated = true;
1833
+ body = "";
1834
+ }
1835
+ total += body.length;
1836
+ const path15 = status === "deleted" ? oldPath : newPath || oldPath;
1837
+ files.push({
1838
+ path: path15,
1839
+ oldPath: status === "renamed" ? oldPath : "",
1840
+ status,
1841
+ added: count(body, "+"),
1842
+ removed: count(body, "-"),
1843
+ binary,
1844
+ patch: binary ? "" : body
1845
+ });
1846
+ }
1847
+ return { files, truncated };
1848
+ }
1849
+ function stripPrefix(value) {
1850
+ const path15 = unquote(value.trim());
1851
+ if (path15 === "/dev/null")
1852
+ return "";
1853
+ return path15.replace(/^[ab]\//, "");
1854
+ }
1855
+ function count(body, mark) {
1856
+ let n = 0;
1857
+ for (const line of body.split("\n")) {
1858
+ if (line.startsWith(mark))
1859
+ n++;
1860
+ }
1861
+ return n;
1862
+ }
1863
+
1864
+ // ../host/dist/finder.js
1865
+ import { existsSync, statSync } from "node:fs";
1866
+ import { readdir as readdir2 } from "node:fs/promises";
1867
+ import os2 from "node:os";
1868
+ import path6 from "node:path";
1869
+ import { listSessions } from "@anthropic-ai/claude-agent-sdk";
1870
+ var CACHE_TTL = 6e4;
1871
+ var FIND_LIMIT = 50;
1872
+ var ROOT_LIMIT = 5;
1873
+ var SKIP_NAMES = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "venv"]);
1874
+ var ProjectFinder = class {
1875
+ registry;
1876
+ #dirs = [];
1877
+ #loadedAt = 0;
1878
+ /** Обновление уже идёт: второй `listSessions()` параллельно первому не нужен. */
1879
+ #refreshing = null;
1880
+ constructor(registry) {
1881
+ this.registry = registry;
1882
+ }
1883
+ /**
1884
+ * Прогреть кэш при старте хоста.
1885
+ *
1886
+ * Иначе первый же поиск пришёлся бы на непрогретый кэш и ответил пустотой,
1887
+ * пока журнал читается, — ровно в тот момент, когда пользователь открыл
1888
+ * диалог и ждёт подсказку.
1889
+ */
1890
+ async warm() {
1891
+ await this.#refresh();
1892
+ }
1893
+ /**
1894
+ * Каталоги, подходящие под запрос.
1895
+ *
1896
+ * Работает синхронно по памяти; устаревший кэш обновляется в фоне и попадёт
1897
+ * в следующий ответ. Ждать его здесь нельзя: это две секунды на нажатие
1898
+ * клавиши.
1899
+ */
1900
+ find(query2) {
1901
+ if (Date.now() - this.#loadedAt > CACHE_TTL)
1902
+ void this.#refresh();
1903
+ const needles = tokens(query2);
1904
+ if (needles.length === 0)
1905
+ return { candidates: [], truncated: false };
1906
+ const open = new Map(this.registry.list().map((workspace) => [workspace.path.toLowerCase(), workspace]));
1907
+ const byName = this.#dirs.filter((dir) => matches(path6.basename(dir.path), needles));
1908
+ const matched = byName.length > 0 ? byName : this.#dirs.filter((dir) => matches(dir.path, needles));
1909
+ const sorted = [...matched].sort((a, b) => {
1910
+ const aOpen = open.has(a.path.toLowerCase());
1911
+ const bOpen = open.has(b.path.toLowerCase());
1912
+ if (aOpen !== bOpen)
1913
+ return aOpen ? -1 : 1;
1914
+ if (a.sessions > 0 !== b.sessions > 0)
1915
+ return a.sessions > 0 ? -1 : 1;
1916
+ if (a.lastUsedAt !== b.lastUsedAt)
1917
+ return b.lastUsedAt - a.lastUsedAt;
1918
+ return path6.basename(a.path).localeCompare(path6.basename(b.path), void 0, {
1919
+ numeric: true,
1920
+ sensitivity: "base"
1921
+ });
1922
+ });
1923
+ return {
1924
+ candidates: sorted.slice(0, FIND_LIMIT).map((dir) => ({
1925
+ path: dir.path,
1926
+ name: path6.basename(dir.path) || dir.path,
1927
+ sessions: dir.sessions,
1928
+ lastUsedAt: dir.lastUsedAt || null,
1929
+ open: open.has(dir.path.toLowerCase()),
1930
+ why: null
1931
+ })),
1932
+ truncated: sorted.length > FIND_LIMIT
1933
+ };
1934
+ }
1935
+ /**
1936
+ * Превратить названные агентом пути в кандидатов.
1937
+ *
1938
+ * Проверяем каждый: модель может назвать каталог, которого нет, — открывать
1939
+ * его вслепую нельзя. Заодно дополняем тем, что знает журнал: сколько там
1940
+ * сессий и когда работали в последний раз. Агент этого не видит, а в списке
1941
+ * это самое полезное.
1942
+ */
1943
+ validate(named) {
1944
+ const known = new Map(this.#dirs.map((dir) => [dir.path.toLowerCase(), dir]));
1945
+ const open = new Map(this.registry.list().map((workspace) => [workspace.path.toLowerCase(), workspace]));
1946
+ const seen = /* @__PURE__ */ new Set();
1947
+ const candidates = [];
1948
+ for (const entry of named) {
1949
+ const absolute = entry.path.trim();
1950
+ if (!absolute || !path6.isAbsolute(absolute))
1951
+ continue;
1952
+ const key = absolute.toLowerCase();
1953
+ if (seen.has(key))
1954
+ continue;
1955
+ if (!isDirectory(absolute))
1956
+ continue;
1957
+ seen.add(key);
1958
+ const record = known.get(key);
1959
+ candidates.push({
1960
+ path: record?.path ?? absolute,
1961
+ name: path6.basename(absolute) || absolute,
1962
+ sessions: record?.sessions ?? 0,
1963
+ lastUsedAt: record?.lastUsedAt || null,
1964
+ open: open.has(key),
1965
+ why: entry.why
1966
+ });
1967
+ if (candidates.length >= FIND_LIMIT)
1968
+ break;
1969
+ }
1970
+ return candidates;
1971
+ }
1972
+ /**
1973
+ * Где искать агенту: родительские каталоги известных проектов.
1974
+ *
1975
+ * Первым идёт тот, под которым известных каталогов больше всего, — он же
1976
+ * станет `cwd` сессии поиска. Остальные достаются агенту списком в промпте:
1977
+ * `Glob` и `Grep` принимают абсолютный путь, так что одним `cwd` он не заперт.
1978
+ */
1979
+ roots() {
1980
+ return this.#parents(this.#dirs).slice(0, ROOT_LIMIT);
1981
+ }
1982
+ #refresh() {
1983
+ return this.#refreshing ??= this.#load().finally(() => {
1984
+ this.#refreshing = null;
1985
+ });
1986
+ }
1987
+ async #load() {
1988
+ const dirs = /* @__PURE__ */ new Map();
1989
+ for (const workspace of this.registry.list()) {
1990
+ dirs.set(workspace.path.toLowerCase(), {
1991
+ path: workspace.path,
1992
+ sessions: 0,
1993
+ lastUsedAt: 0
1994
+ });
1995
+ }
1996
+ for (const info of await allSessions()) {
1997
+ const cwd = info.cwd;
1998
+ if (!cwd || !usable(cwd))
1999
+ continue;
2000
+ const key = cwd.toLowerCase();
2001
+ const seen = dirs.get(key);
2002
+ if (seen) {
2003
+ seen.sessions += 1;
2004
+ if (info.lastModified > seen.lastUsedAt) {
2005
+ seen.lastUsedAt = info.lastModified;
2006
+ seen.path = cwd;
2007
+ }
2008
+ continue;
2009
+ }
2010
+ dirs.set(key, { path: cwd, sessions: 1, lastUsedAt: info.lastModified });
2011
+ }
2012
+ for (const parent of this.#parents(dirs.values())) {
2013
+ const names2 = await readdir2(parent, { withFileTypes: true }).catch(() => []);
2014
+ for (const dirent of names2) {
2015
+ if (!dirent.isDirectory() || SKIP_NAMES.has(dirent.name) || dirent.name.startsWith(".")) {
2016
+ continue;
2017
+ }
2018
+ const full = path6.join(parent, dirent.name);
2019
+ const key = full.toLowerCase();
2020
+ if (dirs.has(key))
2021
+ continue;
2022
+ dirs.set(key, { path: full, sessions: 0, lastUsedAt: 0 });
2023
+ }
2024
+ }
2025
+ this.#dirs = [...dirs.values()];
2026
+ this.#loadedAt = Date.now();
2027
+ }
2028
+ /**
2029
+ * Каталоги, в которых проекты действительно лежат.
2030
+ *
2031
+ * «Родитель известного каталога» само по себе не признак: у `C:\Users\corfi`
2032
+ * родитель `C:\Users`, а у проекта в корне диска — `C:\`. Признаком считаем
2033
+ * то, что в каталоге известных проектов больше одного: тогда это рабочая
2034
+ * папка, а не случайное место на пути.
2035
+ */
2036
+ #parents(dirs) {
2037
+ const counted = /* @__PURE__ */ new Map();
2038
+ for (const dir of dirs) {
2039
+ const parent = path6.dirname(dir.path);
2040
+ if (parent === dir.path || !usable(parent))
2041
+ continue;
2042
+ const key = parent.toLowerCase();
2043
+ const seen = counted.get(key);
2044
+ if (seen)
2045
+ seen.count += 1;
2046
+ else
2047
+ counted.set(key, { path: parent, count: 1 });
2048
+ }
2049
+ return [...counted.values()].filter((entry) => entry.count >= 2).sort((a, b) => b.count - a.count).map((entry) => entry.path);
2050
+ }
2051
+ };
2052
+ function usable(dir) {
2053
+ return !contains(os2.tmpdir(), dir) && existsSync(dir);
2054
+ }
2055
+ function tokens(query2) {
2056
+ return query2.toLowerCase().split(/[^a-z0-9а-яё]+/i).filter(Boolean);
2057
+ }
2058
+ function matches(value, needles) {
2059
+ const flat = value.toLowerCase().replace(/[^a-z0-9а-яё]+/gi, "");
2060
+ return needles.every((needle) => flat.includes(needle));
2061
+ }
2062
+ function isDirectory(dir) {
2063
+ try {
2064
+ return statSync(dir).isDirectory();
2065
+ } catch {
2066
+ return false;
2067
+ }
2068
+ }
2069
+ async function allSessions() {
2070
+ try {
2071
+ return await listSessions();
2072
+ } catch {
2073
+ return [];
2074
+ }
2075
+ }
2076
+ var FINDER_SCHEMA = {
2077
+ type: "object",
2078
+ properties: {
2079
+ candidates: {
2080
+ type: "array",
2081
+ items: {
2082
+ type: "object",
2083
+ properties: {
2084
+ path: { type: "string", description: "\u0410\u0431\u0441\u043E\u043B\u044E\u0442\u043D\u044B\u0439 \u043F\u0443\u0442\u044C \u043A \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0443 \u043F\u0440\u043E\u0435\u043A\u0442\u0430" },
2085
+ why: { type: "string", description: "\u041E\u0434\u043D\u0430 \u043A\u043E\u0440\u043E\u0442\u043A\u0430\u044F \u0444\u0440\u0430\u0437\u0430, \u043F\u043E\u0447\u0435\u043C\u0443 \u044D\u0442\u043E\u0442 \u043F\u0440\u043E\u0435\u043A\u0442 \u043F\u043E\u0434\u0445\u043E\u0434\u0438\u0442" }
2086
+ },
2087
+ required: ["path", "why"],
2088
+ additionalProperties: false
2089
+ }
2090
+ }
2091
+ },
2092
+ required: ["candidates"],
2093
+ additionalProperties: false
2094
+ };
2095
+ var AGENT_LIMIT = 5;
2096
+ function finderPrompt(query2, roots) {
2097
+ return [
2098
+ "\u041D\u0430\u0439\u0434\u0438 \u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u043D\u0430 \u044D\u0442\u043E\u043C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440\u0435 \u043F\u043E \u043E\u043F\u0438\u0441\u0430\u043D\u0438\u044E \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u044F.",
2099
+ "",
2100
+ `\u041E\u043F\u0438\u0441\u0430\u043D\u0438\u0435: ${query2}`,
2101
+ "",
2102
+ "\u0418\u0441\u043A\u0430\u0442\u044C \u0442\u043E\u043B\u044C\u043A\u043E \u0432\u043D\u0443\u0442\u0440\u0438 \u044D\u0442\u0438\u0445 \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u043E\u0432:",
2103
+ ...roots.map((root) => `- ${root}`),
2104
+ "",
2105
+ "\u041F\u0440\u043E\u0435\u043A\u0442 \u2014 \u044D\u0442\u043E \u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u0441 \u0438\u0441\u0445\u043E\u0434\u043D\u0438\u043A\u0430\u043C\u0438 (package.json, .git, pyproject.toml,",
2106
+ "Cargo.toml, *.csproj \u0438 \u043F\u043E\u0434\u043E\u0431\u043D\u043E\u0435), \u0430 \u043D\u0435 \u043E\u0442\u0434\u0435\u043B\u044C\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0438 \u043D\u0435 \u043F\u043E\u0434\u043A\u0430\u0442\u0430\u043B\u043E\u0433",
2107
+ "\u0432\u043D\u0443\u0442\u0440\u0438 \u043F\u0440\u043E\u0435\u043A\u0442\u0430. \u041D\u0435 \u0437\u0430\u0445\u043E\u0434\u0438 \u0432 node_modules, dist, build \u0438 .git.",
2108
+ "\u0418\u043C\u044F \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430, README \u0438 package.json \u043E\u0431\u044B\u0447\u043D\u043E \u043E\u0442\u0432\u0435\u0447\u0430\u044E\u0442 \u043D\u0430 \u0432\u043E\u043F\u0440\u043E\u0441 \u0431\u044B\u0441\u0442\u0440\u0435\u0435",
2109
+ "\u0432\u0441\u0435\u0433\u043E \u2014 \u043D\u0430\u0447\u0438\u043D\u0430\u0439 \u0441 \u043D\u0438\u0445, \u0430 \u043D\u0435 \u0441 \u043E\u0431\u0445\u043E\u0434\u0430 \u0432\u0441\u0435\u0433\u043E \u0434\u0435\u0440\u0435\u0432\u0430.",
2110
+ "",
2111
+ `\u0412\u0435\u0440\u043D\u0438 \u043D\u0435 \u0431\u043E\u043B\u044C\u0448\u0435 ${AGENT_LIMIT} \u043A\u0430\u043D\u0434\u0438\u0434\u0430\u0442\u043E\u0432, \u0441\u0430\u043C\u044B\u0439 \u043F\u043E\u0434\u0445\u043E\u0434\u044F\u0449\u0438\u0439 \u043F\u0435\u0440\u0432\u044B\u043C.`,
2112
+ "\u0415\u0441\u043B\u0438 \u043D\u0438\u0447\u0435\u0433\u043E \u043D\u0435 \u043F\u043E\u0434\u043E\u0448\u043B\u043E, \u0432\u0435\u0440\u043D\u0438 \u043F\u0443\u0441\u0442\u043E\u0439 \u0441\u043F\u0438\u0441\u043E\u043A \u2014 \u044D\u0442\u043E \u043D\u043E\u0440\u043C\u0430\u043B\u044C\u043D\u044B\u0439 \u043E\u0442\u0432\u0435\u0442."
2113
+ ].join("\n");
2114
+ }
2115
+ function readNamedPaths(data, text) {
2116
+ const structured = fromSchema(data);
2117
+ if (structured.length > 0)
2118
+ return structured;
2119
+ return fromText(text);
2120
+ }
2121
+ function fromSchema(data) {
2122
+ if (typeof data !== "object" || data === null)
2123
+ return [];
2124
+ const list = data.candidates;
2125
+ if (!Array.isArray(list))
2126
+ return [];
2127
+ const named = [];
2128
+ for (const item of list) {
2129
+ if (typeof item !== "object" || item === null)
2130
+ continue;
2131
+ const { path: value, why } = item;
2132
+ if (typeof value !== "string")
2133
+ continue;
2134
+ named.push({ path: value, why: typeof why === "string" ? why : null });
2135
+ }
2136
+ return named;
2137
+ }
2138
+ function fromText(text) {
2139
+ if (!text)
2140
+ return [];
2141
+ const matches2 = text.match(/(?:[A-Za-z]:[\\/]|\/)[^\s`'"*)>\]]+/g) ?? [];
2142
+ return matches2.map((value) => ({
2143
+ // Точка или скобка в конце — это пунктуация фразы, а не имя каталога.
2144
+ path: value.replace(/[.,;:]+$/, ""),
2145
+ why: null
2146
+ }));
2147
+ }
2148
+
2149
+ // ../host/dist/workspaces.js
2150
+ import { existsSync as existsSync2, statSync as statSync2 } from "node:fs";
2151
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
2152
+ import path7 from "node:path";
2153
+ import { listSessions as listSessions2 } from "@anthropic-ai/claude-agent-sdk";
2154
+ var WorkspaceRegistry = class {
2155
+ #paths = [];
2156
+ #storePath;
2157
+ constructor(dataDir) {
2158
+ this.#storePath = path7.join(dataDir, "workspaces.json");
2159
+ }
2160
+ async load() {
2161
+ try {
2162
+ const raw = await readFile4(this.#storePath, "utf8");
2163
+ const parsed = JSON.parse(raw);
2164
+ if (Array.isArray(parsed)) {
2165
+ this.#paths = parsed.filter((p) => typeof p === "string" && existsSync2(p));
2166
+ }
2167
+ } catch {
2168
+ this.#paths = [];
2169
+ }
2170
+ }
2171
+ async #persist() {
2172
+ await mkdir2(path7.dirname(this.#storePath), { recursive: true });
2173
+ await writeFile2(this.#storePath, JSON.stringify(this.#paths, null, 2), "utf8");
2174
+ }
2175
+ list() {
2176
+ return this.#paths.map(toInfo);
2177
+ }
2178
+ find(id) {
2179
+ return this.list().find((w) => w.id === id);
2180
+ }
2181
+ async open(rawPath) {
2182
+ const absolute = path7.resolve(rawPath);
2183
+ if (!existsSync2(absolute)) {
2184
+ throw new Error(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${absolute}`);
2185
+ }
2186
+ if (!this.#paths.includes(absolute)) {
2187
+ this.#paths.unshift(absolute);
2188
+ await this.#persist();
2189
+ }
2190
+ return toInfo(absolute);
2191
+ }
2192
+ /**
2193
+ * Убрать проекты из списка открытых.
2194
+ *
2195
+ * Только реестр: каталог, его файлы и транскрипты сессий остаются на месте —
2196
+ * закрытый проект открывается обратно тем же путём и с той же историей.
2197
+ * Пачкой, а не по одному, потому что список чистят разом и переписывать файл
2198
+ * на каждый проект незачем.
2199
+ */
2200
+ async close(ids) {
2201
+ const doomed = new Set(ids);
2202
+ const closed = this.list().filter((w) => doomed.has(w.id));
2203
+ if (closed.length === 0)
2204
+ return [];
2205
+ const gone = new Set(closed.map((w) => w.path));
2206
+ this.#paths = this.#paths.filter((p) => !gone.has(p));
2207
+ await this.#persist();
2208
+ return closed;
2209
+ }
2210
+ /**
2211
+ * Создать каталог проекта и открыть его.
2212
+ *
2213
+ * Единственное место в хосте, которое делает каталог, а не читает его. Порядок
2214
+ * шагов задан тем, что можно откатить: сам каталог обязателен — без него
2215
+ * проекта нет, и любая ошибка здесь отменяет всю операцию целиком. Заготовки
2216
+ * файлов и `git init` — уже нет: провал `git init` (нет git в PATH — обычное
2217
+ * дело, см. [`git.ts`](./git.ts)) не повод стирать только что созданный
2218
+ * каталог, поэтому такой шаг возвращается предупреждением, а проект всё равно
2219
+ * открывается.
2220
+ */
2221
+ async create(parentRaw, nameRaw, init) {
2222
+ const name = nameRaw.trim();
2223
+ requireName(name);
2224
+ const parent = path7.resolve(parentRaw.trim());
2225
+ if (!existsSync2(parent)) {
2226
+ throw new Error(`\u041A\u0430\u0442\u0430\u043B\u043E\u0433 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${parent}`);
2227
+ }
2228
+ if (!statSync2(parent).isDirectory()) {
2229
+ throw new Error(`\u042D\u0442\u043E \u043D\u0435 \u043A\u0430\u0442\u0430\u043B\u043E\u0433: ${parent}`);
2230
+ }
2231
+ const target = path7.join(parent, name);
2232
+ if (existsSync2(target)) {
2233
+ throw new Error(`\u0423\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442: ${target} \u2014 \u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0435\u0433\u043E \u043A\u0430\u043A \u043F\u0440\u043E\u0435\u043A\u0442`);
2234
+ }
2235
+ await mkdir2(target);
2236
+ const warnings = [];
2237
+ if (init.claudeMd) {
2238
+ await step(warnings, "CLAUDE.md", () => writeFile2(path7.join(target, "CLAUDE.md"), claudeMdStub(name), "utf8"));
2239
+ }
2240
+ if (init.readme) {
2241
+ await step(warnings, "README.md", () => writeFile2(path7.join(target, "README.md"), readmeStub(name), "utf8"));
2242
+ }
2243
+ if (init.git) {
2244
+ await step(warnings, "git init", () => new GitRepo(target).init());
2245
+ }
2246
+ return { workspace: await this.open(target), warnings };
2247
+ }
2248
+ /** Сессии Claude Code, уже лежащие на диске для этого каталога. */
2249
+ async sessionsOnDisk(workspaceId) {
2250
+ const workspace = this.find(workspaceId);
2251
+ if (!workspace)
2252
+ return [];
2253
+ try {
2254
+ return await listSessions2({ dir: workspace.path });
2255
+ } catch {
2256
+ return [];
2257
+ }
2258
+ }
2259
+ };
2260
+ var RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i;
2261
+ var FORBIDDEN = /[\\/:*?"<>|]/;
2262
+ function requireName(name) {
2263
+ if (name === "") {
2264
+ throw new Error("\u0418\u043C\u044F \u043F\u0440\u043E\u0435\u043A\u0442\u0430 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C");
2265
+ }
2266
+ if (FORBIDDEN.test(name)) {
2267
+ throw new Error('\u0412 \u0438\u043C\u0435\u043D\u0438 \u043D\u0435\u043B\u044C\u0437\u044F \u0438\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u044C \\ / : * ? " < > |');
2268
+ }
2269
+ if (name === "." || name === "..") {
2270
+ throw new Error(`\xAB${name}\xBB \u2014 \u044D\u0442\u043E \u043D\u0435 \u0438\u043C\u044F \u043A\u0430\u0442\u0430\u043B\u043E\u0433\u0430, \u0430 \u0441\u0441\u044B\u043B\u043A\u0430 \u043D\u0430 \u0434\u0440\u0443\u0433\u043E\u0439`);
2271
+ }
2272
+ if (name.startsWith(".")) {
2273
+ throw new Error("\u0418\u043C\u044F \u043D\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u043D\u0430\u0447\u0438\u043D\u0430\u0442\u044C\u0441\u044F \u0441 \u0442\u043E\u0447\u043A\u0438 \u2014 \u0442\u0430\u043A\u043E\u0439 \u043A\u0430\u0442\u0430\u043B\u043E\u0433 \u0441\u043A\u0440\u044B\u0442");
2274
+ }
2275
+ if (/[. ]$/.test(name)) {
2276
+ throw new Error("\u0418\u043C\u044F \u043D\u0435 \u0434\u043E\u043B\u0436\u043D\u043E \u0437\u0430\u043A\u0430\u043D\u0447\u0438\u0432\u0430\u0442\u044C\u0441\u044F \u0442\u043E\u0447\u043A\u043E\u0439 \u0438\u043B\u0438 \u043F\u0440\u043E\u0431\u0435\u043B\u043E\u043C");
2277
+ }
2278
+ if (RESERVED.test(name)) {
2279
+ throw new Error(`\xAB${name}\xBB \u2014 \u0437\u0430\u0440\u0435\u0437\u0435\u0440\u0432\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u043E\u0435 \u0438\u043C\u044F \u0443\u0441\u0442\u0440\u043E\u0439\u0441\u0442\u0432\u0430 Windows`);
2280
+ }
2281
+ }
2282
+ async function step(warnings, what, run2) {
2283
+ try {
2284
+ await run2();
2285
+ } catch (error) {
2286
+ warnings.push(`${what}: ${error instanceof Error ? error.message : String(error)}`);
2287
+ }
2288
+ }
2289
+ function claudeMdStub(name) {
2290
+ return [
2291
+ `# ${name}`,
2292
+ "",
2293
+ "\u0418\u043D\u0441\u0442\u0440\u0443\u043A\u0446\u0438\u0438 \u0434\u043B\u044F Claude Code \u0432 \u044D\u0442\u043E\u043C \u043F\u0440\u043E\u0435\u043A\u0442\u0435.",
2294
+ "",
2295
+ "## \u041E \u043F\u0440\u043E\u0435\u043A\u0442\u0435",
2296
+ "",
2297
+ "<!-- \u0427\u0435\u043C \u0437\u0430\u043D\u044F\u0442 \u043F\u0440\u043E\u0435\u043A\u0442 \u0438 \u0447\u0442\u043E \u0432 \u043D\u0451\u043C \u0433\u043B\u0430\u0432\u043D\u043E\u0435. -->",
2298
+ "",
2299
+ "## \u041A\u0430\u043A \u0437\u0434\u0435\u0441\u044C \u0440\u0430\u0431\u043E\u0442\u0430\u0442\u044C",
2300
+ "",
2301
+ "<!-- \u041A\u043E\u043C\u0430\u043D\u0434\u044B \u0441\u0431\u043E\u0440\u043A\u0438 \u0438 \u0442\u0435\u0441\u0442\u043E\u0432, \u0441\u043E\u0433\u043B\u0430\u0448\u0435\u043D\u0438\u044F \u043A\u043E\u0434\u0430, \u0447\u0435\u0433\u043E \u0434\u0435\u043B\u0430\u0442\u044C \u043D\u0435 \u043D\u0430\u0434\u043E. -->",
2302
+ ""
2303
+ ].join("\n");
2304
+ }
2305
+ function readmeStub(name) {
2306
+ return [`# ${name}`, "", "<!-- \u041E\u0434\u043D\u0430-\u0434\u0432\u0435 \u0444\u0440\u0430\u0437\u044B: \u0447\u0442\u043E \u044D\u0442\u043E \u0438 \u0437\u0430\u0447\u0435\u043C. -->", ""].join("\n");
2307
+ }
2308
+ function toInfo(absolute) {
2309
+ return {
2310
+ id: workspaceIdFromPath(absolute),
2311
+ path: absolute,
2312
+ name: path7.basename(absolute) || absolute,
2313
+ hasClaudeMd: existsSync2(path7.join(absolute, "CLAUDE.md"))
2314
+ };
2315
+ }
2316
+
2317
+ // ../host/dist/archive.js
2318
+ import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
2319
+ import path8 from "node:path";
2320
+ var SessionArchive = class {
2321
+ #archived = /* @__PURE__ */ new Map();
2322
+ #restored = /* @__PURE__ */ new Map();
2323
+ #storePath;
2324
+ constructor(dataDir) {
2325
+ this.#storePath = path8.join(dataDir, "session-archive.json");
2326
+ }
2327
+ async load() {
2328
+ try {
2329
+ const raw = await readFile5(this.#storePath, "utf8");
2330
+ const parsed = JSON.parse(raw);
2331
+ this.#archived = new Map(Object.entries(parsed.archived ?? {}).filter(([, r]) => isRecord(r)));
2332
+ this.#restored = new Map(Object.entries(parsed.restored ?? {}).filter((entry) => typeof entry[1] === "number"));
2333
+ } catch {
2334
+ this.#archived = /* @__PURE__ */ new Map();
2335
+ this.#restored = /* @__PURE__ */ new Map();
2336
+ }
2337
+ }
2338
+ find(sessionId) {
2339
+ return this.#archived.get(sessionId);
2340
+ }
2341
+ /** Убрать в архив. Возвращает `true`, если что-то изменилось. */
2342
+ async archive(workspaceId, sessionIds, auto = false) {
2343
+ let changed = false;
2344
+ const at = Date.now();
2345
+ for (const id of sessionIds) {
2346
+ if (this.#archived.has(id))
2347
+ continue;
2348
+ this.#archived.set(id, { workspaceId, at, auto });
2349
+ this.#restored.delete(id);
2350
+ changed = true;
2351
+ }
2352
+ if (changed)
2353
+ await this.#persist();
2354
+ return changed;
2355
+ }
2356
+ /**
2357
+ * Достать из архива.
2358
+ *
2359
+ * Заодно отмечает момент возврата: без него сессия, которая молчит дольше
2360
+ * срока, уехала бы обратно в архив тем же списком, которым её только что
2361
+ * вернули, — и достать её было бы нельзя вообще никак.
2362
+ */
2363
+ async unarchive(sessionIds) {
2364
+ let changed = false;
2365
+ const now = Date.now();
2366
+ for (const id of sessionIds) {
2367
+ if (!this.#archived.delete(id))
2368
+ continue;
2369
+ this.#restored.set(id, now);
2370
+ changed = true;
2371
+ }
2372
+ if (changed)
2373
+ await this.#persist();
2374
+ return changed;
2375
+ }
2376
+ /**
2377
+ * Отправить в архив всё, что молчит дольше срока, и забыть исчезнувшее.
2378
+ *
2379
+ * `candidates` — только те сессии, что сейчас не подняты: архивация живой
2380
+ * сессии её останавливает, и делать это молча, по таймеру, за спиной у
2381
+ * работающего агента нельзя. `known` — все сессии проекта, включая живые:
2382
+ * по ним вычищаются записи о транскриптах, которых больше нет на диске.
2383
+ *
2384
+ * `afterMs` приходит из настроек приложения, а не задан здесь константой:
2385
+ * срок молчания — вопрос того, как человек работает, и `0` («не архивировать
2386
+ * никогда») тоже законный ответ. Чистка исчезнувших записей от него не
2387
+ * зависит и идёт всегда: она не про архивацию, а про мусор в реестре.
2388
+ */
2389
+ async sweep(workspaceId, candidates, known, afterMs) {
2390
+ const now = Date.now();
2391
+ let changed = false;
2392
+ for (const session of afterMs > 0 ? candidates : []) {
2393
+ if (this.#archived.has(session.id))
2394
+ continue;
2395
+ const since = Math.max(session.updatedAt, this.#restored.get(session.id) ?? 0);
2396
+ if (now - since < afterMs)
2397
+ continue;
2398
+ this.#archived.set(session.id, { workspaceId, at: now, auto: true });
2399
+ this.#restored.delete(session.id);
2400
+ changed = true;
2401
+ }
2402
+ for (const [id, record] of this.#archived) {
2403
+ if (record.workspaceId !== workspaceId || known.has(id))
2404
+ continue;
2405
+ this.#archived.delete(id);
2406
+ this.#restored.delete(id);
2407
+ changed = true;
2408
+ }
2409
+ if (changed)
2410
+ await this.#persist();
2411
+ return changed;
2412
+ }
2413
+ async #persist() {
2414
+ const file = {
2415
+ archived: Object.fromEntries(this.#archived),
2416
+ restored: Object.fromEntries(this.#restored)
2417
+ };
2418
+ await mkdir3(path8.dirname(this.#storePath), { recursive: true });
2419
+ await writeFile3(this.#storePath, JSON.stringify(file, null, 2), "utf8");
2420
+ }
2421
+ };
2422
+ function isRecord(value) {
2423
+ return typeof value === "object" && value !== null && typeof value.workspaceId === "string" && typeof value.at === "number";
2424
+ }
2425
+
2426
+ // ../host/dist/app-settings.js
2427
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2428
+ import { homedir as homedir3 } from "node:os";
2429
+ import path9 from "node:path";
2430
+ var DEFAULT_AUTO_ARCHIVE_AFTER_MS = 2 * 24 * 60 * 60 * 1e3;
2431
+ var MIN_AUTO_ARCHIVE_AFTER_MS = 60 * 60 * 1e3;
2432
+ var FOLDER_ACCESS = ["all", "projects", "project"];
2433
+ var DEFAULT_APP_SETTINGS = {
2434
+ autoArchiveAfterMs: DEFAULT_AUTO_ARCHIVE_AFTER_MS,
2435
+ // Строгий режим по умолчанию: молча писать в чужой проект приложение начнёт
2436
+ // только после того, как об этом попросили явно.
2437
+ folderAccess: "project"
2438
+ };
2439
+ var AppSettingsStore = class {
2440
+ #settings = { ...DEFAULT_APP_SETTINGS };
2441
+ #storePath;
2442
+ constructor(dataDir) {
2443
+ this.#storePath = path9.join(dataDir, "app-settings.json");
2444
+ }
2445
+ async load() {
2446
+ try {
2447
+ const raw = await readFile6(this.#storePath, "utf8");
2448
+ this.#settings = sanitize(JSON.parse(raw), DEFAULT_APP_SETTINGS);
2449
+ } catch {
2450
+ this.#settings = { ...DEFAULT_APP_SETTINGS };
2451
+ }
2452
+ }
2453
+ get current() {
2454
+ return { ...this.#settings };
2455
+ }
2456
+ /**
2457
+ * Применить присланное окном.
2458
+ *
2459
+ * Частичным набором: клиент шлёт только то, что человек тронул, — иначе две
2460
+ * открытые вкладки затирали бы правки друг друга полем, которого не касались.
2461
+ * Непонятное значение не отвергается с ошибкой, а заменяется прежним: окно
2462
+ * всё равно получит в ответ то, что реально записано, и покажет именно это.
2463
+ */
2464
+ async update(patch) {
2465
+ const next = sanitize(patch, this.#settings);
2466
+ this.#settings = next;
2467
+ await mkdir4(path9.dirname(this.#storePath), { recursive: true });
2468
+ await writeFile4(this.#storePath, JSON.stringify(next, null, 2), "utf8");
2469
+ return this.current;
2470
+ }
2471
+ };
2472
+ function sanitize(value, fallback) {
2473
+ const result = { ...fallback };
2474
+ if (typeof value !== "object" || value === null)
2475
+ return result;
2476
+ const record = value;
2477
+ const after = record.autoArchiveAfterMs;
2478
+ if (typeof after === "number" && Number.isFinite(after)) {
2479
+ result.autoArchiveAfterMs = after <= 0 ? 0 : Math.max(Math.round(after), MIN_AUTO_ARCHIVE_AFTER_MS);
2480
+ }
2481
+ const access2 = record.folderAccess;
2482
+ if (typeof access2 === "string" && FOLDER_ACCESS.includes(access2)) {
2483
+ result.folderAccess = access2;
2484
+ }
2485
+ return result;
2486
+ }
2487
+ function claudeHome() {
2488
+ return path9.join(homedir3(), ".claude");
2489
+ }
2490
+ function writableRoots(mode, cwd, workspacePaths) {
2491
+ if (mode === "all")
2492
+ return null;
2493
+ const roots = /* @__PURE__ */ new Set([cwd, claudeHome()]);
2494
+ if (mode === "project")
2495
+ return [...roots];
2496
+ for (const workspace of [cwd, ...workspacePaths]) {
2497
+ roots.add(workspace);
2498
+ const parent = path9.dirname(workspace);
2499
+ if (parent !== workspace)
2500
+ roots.add(parent);
2501
+ }
2502
+ return [...roots];
2503
+ }
2504
+
2505
+ // ../host/dist/plugins.js
2506
+ import { readFile as readFile7 } from "node:fs/promises";
2507
+ import { homedir as homedir4 } from "node:os";
2508
+ import path10 from "node:path";
2509
+ var TIMEOUT_MS2 = 12e4;
2510
+ var SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
2511
+ var PluginCli = class {
2512
+ /** Установленные плагины, подключённые маркетплейсы и их каталог. */
2513
+ async list() {
2514
+ const [rawPlugins, rawMarkets] = await Promise.all([
2515
+ this.#json(["plugin", "list", "--json"]),
2516
+ this.#json(["plugin", "marketplace", "list", "--json"])
2517
+ ]);
2518
+ const plugins = Array.isArray(rawPlugins) ? rawPlugins.map(toPlugin) : [];
2519
+ const entries = Array.isArray(rawMarkets) ? rawMarkets.map(toMarketplace) : [];
2520
+ const installed = new Set(plugins.map((p) => p.id));
2521
+ return {
2522
+ plugins,
2523
+ marketplaces: entries.map(({ name, source }) => ({ name, source })),
2524
+ catalog: await readCatalog(entries, installed)
2525
+ };
2526
+ }
2527
+ async install(spec) {
2528
+ return this.#run(["plugin", "install", requireSafe(spec, "\u043F\u043B\u0430\u0433\u0438\u043D\u0430")]);
2529
+ }
2530
+ async uninstall(id) {
2531
+ return this.#run(["plugin", "uninstall", requireSafe(id, "\u043F\u043B\u0430\u0433\u0438\u043D\u0430"), "-y"]);
2532
+ }
2533
+ async setEnabled(id, enabled) {
2534
+ return this.#run(["plugin", enabled ? "enable" : "disable", requireSafe(id, "\u043F\u043B\u0430\u0433\u0438\u043D\u0430")]);
2535
+ }
2536
+ async addMarketplace(source) {
2537
+ if (source.trim() === "" || source.trim().startsWith("-")) {
2538
+ throw new Error("\u0418\u0441\u0442\u043E\u0447\u043D\u0438\u043A \u043C\u0430\u0440\u043A\u0435\u0442\u043F\u043B\u0435\u0439\u0441\u0430 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C \u0438\u043B\u0438 \u043D\u0430\u0447\u0438\u043D\u0430\u0442\u044C\u0441\u044F \u0441 \xAB-\xBB");
2539
+ }
2540
+ return this.#run(["plugin", "marketplace", "add", source.trim()]);
2541
+ }
2542
+ async removeMarketplace(name) {
2543
+ return this.#run(["plugin", "marketplace", "remove", requireSafe(name, "\u043C\u0430\u0440\u043A\u0435\u0442\u043F\u043B\u0435\u0439\u0441\u0430")]);
2544
+ }
2545
+ async #json(args) {
2546
+ const out = await this.#run(args);
2547
+ try {
2548
+ return JSON.parse(out);
2549
+ } catch {
2550
+ const start = out.indexOf("[");
2551
+ const end = out.lastIndexOf("]");
2552
+ if (start === -1 || end <= start)
2553
+ return null;
2554
+ try {
2555
+ return JSON.parse(out.slice(start, end + 1));
2556
+ } catch {
2557
+ return null;
2558
+ }
2559
+ }
2560
+ }
2561
+ #run(args) {
2562
+ return runClaude(args, {
2563
+ timeout: TIMEOUT_MS2,
2564
+ // install печатает лог клонирования репозитория — куску в мегабайт тесно.
2565
+ maxBuffer: 8 * 1024 * 1024,
2566
+ missing: "\u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u043F\u043B\u0430\u0433\u0438\u043D\u0430\u043C\u0438 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u043E"
2567
+ });
2568
+ }
2569
+ };
2570
+ function requireSafe(value, what) {
2571
+ const trimmed = value.trim();
2572
+ if (!SAFE_ARG.test(trimmed))
2573
+ throw new Error(`\u041D\u0435\u0434\u043E\u043F\u0443\u0441\u0442\u0438\u043C\u043E\u0435 \u0438\u043C\u044F ${what}: \xAB${value}\xBB`);
2574
+ return trimmed;
2575
+ }
2576
+ function toPlugin(raw) {
2577
+ const r = raw ?? {};
2578
+ const id = typeof r.id === "string" ? r.id : "";
2579
+ const at = id.indexOf("@");
2580
+ const servers = r.mcpServers;
2581
+ return {
2582
+ id,
2583
+ name: at === -1 ? id : id.slice(0, at),
2584
+ marketplace: at === -1 ? "" : id.slice(at + 1),
2585
+ version: typeof r.version === "string" ? r.version : "",
2586
+ scope: typeof r.scope === "string" ? r.scope : "",
2587
+ // Поле новое: на старом CLI его нет, и молча считать плагин выключенным
2588
+ // хуже, чем показать включённым — установленный плагин по умолчанию работает.
2589
+ enabled: r.enabled !== false,
2590
+ mcpServers: servers && typeof servers === "object" ? Object.keys(servers) : []
2591
+ };
2592
+ }
2593
+ function toMarketplace(raw) {
2594
+ const r = raw ?? {};
2595
+ return {
2596
+ name: typeof r.name === "string" ? r.name : "",
2597
+ // `repo` информативнее, чем 'github' в `source`, — показываем его, если есть.
2598
+ source: typeof r.repo === "string" ? r.repo : String(r.source ?? ""),
2599
+ installLocation: typeof r.installLocation === "string" ? r.installLocation : ""
2600
+ };
2601
+ }
2602
+ async function readCatalog(markets, installed) {
2603
+ const lists = await Promise.all(markets.map((market) => readMarketplace(market, installed)));
2604
+ return lists.flat().sort((a, b) => a.name.localeCompare(b.name));
2605
+ }
2606
+ async function readMarketplace(market, installed) {
2607
+ const root = market.installLocation || path10.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
2608
+ let manifest;
2609
+ try {
2610
+ const text = await readFile7(path10.join(root, ".claude-plugin", "marketplace.json"), "utf8");
2611
+ manifest = JSON.parse(text);
2612
+ } catch {
2613
+ return [];
2614
+ }
2615
+ const raw = manifest?.plugins;
2616
+ if (!Array.isArray(raw))
2617
+ return [];
2618
+ return raw.map((entry) => toCatalogPlugin(entry, market.name, installed)).filter((plugin) => plugin !== null);
2619
+ }
2620
+ function toCatalogPlugin(raw, marketplace, installed) {
2621
+ const r = raw ?? {};
2622
+ const name = typeof r.name === "string" ? r.name.trim() : "";
2623
+ if (!name)
2624
+ return null;
2625
+ const id = `${name}@${marketplace}`;
2626
+ return {
2627
+ id,
2628
+ name,
2629
+ displayName: typeof r.displayName === "string" ? r.displayName : "",
2630
+ marketplace,
2631
+ description: typeof r.description === "string" ? r.description : "",
2632
+ // `author` бывает строкой и объектом `{ name, email }` — в манифесте
2633
+ // допустимы оба вида, и второй встречается чаще.
2634
+ author: authorName(r.author),
2635
+ category: typeof r.category === "string" ? r.category : "",
2636
+ homepage: typeof r.homepage === "string" ? r.homepage : "",
2637
+ version: typeof r.version === "string" ? r.version : "",
2638
+ installed: installed.has(id)
2639
+ };
2640
+ }
2641
+ function authorName(raw) {
2642
+ if (typeof raw === "string")
2643
+ return raw;
2644
+ if (raw && typeof raw === "object") {
2645
+ const name = raw.name;
2646
+ if (typeof name === "string")
2647
+ return name;
2648
+ }
2649
+ return "";
2650
+ }
2651
+
2652
+ // ../host/dist/auth.js
2653
+ import { EventEmitter as EventEmitter2 } from "node:events";
2654
+ var QUICK_TIMEOUT_MS = 3e4;
2655
+ var LOGIN_TIMEOUT_MS = 10 * 6e4;
2656
+ var URL_RE = /https:\/\/\S*\/oauth\/authorize\S*/;
2657
+ var CODE_PROMPT_RE = /paste code|введите код|code here/i;
2658
+ var IDLE = {
2659
+ active: false,
2660
+ url: "",
2661
+ awaitingCode: false,
2662
+ output: "",
2663
+ error: null
2664
+ };
2665
+ var AuthCli = class extends EventEmitter2 {
2666
+ #child = null;
2667
+ #state = IDLE;
2668
+ #timer = null;
2669
+ constructor() {
2670
+ super();
2671
+ void warmClaudeLauncher();
2672
+ }
2673
+ get loginState() {
2674
+ return this.#state;
2675
+ }
2676
+ /** Кто вошёл. Незалогиненный CLI — не ошибка, а обычный ответ. */
2677
+ async status() {
2678
+ const raw = await run(["auth", "status", "--json"], QUICK_TIMEOUT_MS).catch(
2679
+ // Старый CLI мог не знать `auth status`. Показать «не вошёл» честнее,
2680
+ // чем уронить окно: сессии всё равно расскажут, работает ли ключ.
2681
+ () => ""
2682
+ );
2683
+ return parseStatus2(raw);
2684
+ }
2685
+ /**
2686
+ * Начать вход.
2687
+ *
2688
+ * `switchAccount` — это выход и вход подряд: отдельной команды «сменить
2689
+ * аккаунт» у CLI нет, а войти вторым, не выйдя первым, нельзя — новый токен
2690
+ * просто затрёт старый лишь после успеха, и до тех пор `auth status`
2691
+ * показывал бы предыдущего пользователя.
2692
+ */
2693
+ async login(mode, switchAccount = false) {
2694
+ if (this.#child)
2695
+ throw new Error("\u0412\u0445\u043E\u0434 \u0443\u0436\u0435 \u0438\u0434\u0451\u0442 \u2014 \u0434\u043E\u0436\u0434\u0438\u0442\u0435\u0441\u044C \u0438\u043B\u0438 \u043E\u0442\u043C\u0435\u043D\u0438\u0442\u0435 \u0435\u0433\u043E");
2696
+ if (switchAccount) {
2697
+ const out = await run(["auth", "logout"], QUICK_TIMEOUT_MS).catch(describe3);
2698
+ this.#push({ ...IDLE, active: true, output: `${out}
2699
+ ` });
2700
+ this.emit("changed");
2701
+ } else {
2702
+ this.#push({ ...IDLE, active: true });
2703
+ }
2704
+ const args = ["auth", "login", mode === "console" ? "--console" : "--claudeai"];
2705
+ const child = await spawnClaude(args);
2706
+ if (!this.#state.active) {
2707
+ child.kill();
2708
+ return;
2709
+ }
2710
+ this.#child = child;
2711
+ this.#timer = setTimeout(() => {
2712
+ this.#fail("\u0412\u0445\u043E\u0434 \u043D\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0451\u043D \u0437\u0430 10 \u043C\u0438\u043D\u0443\u0442 \u2014 \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0451 \u0440\u0430\u0437");
2713
+ }, LOGIN_TIMEOUT_MS);
2714
+ child.stdout?.on("data", (chunk) => this.#absorb(chunk.toString()));
2715
+ child.stderr?.on("data", (chunk) => this.#absorb(chunk.toString()));
2716
+ child.on("error", (error) => {
2717
+ this.#fail(error.code === "ENOENT" ? "\u041D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D claude \u0432 PATH \u2014 \u0432\u0445\u043E\u0434 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D" : describe3(error));
2718
+ });
2719
+ child.on("close", (code) => {
2720
+ if (this.#child !== child)
2721
+ return;
2722
+ this.#clear();
2723
+ this.#push({
2724
+ ...this.#state,
2725
+ active: false,
2726
+ awaitingCode: false,
2727
+ error: code === 0 ? null : this.#state.error ?? `claude auth login \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043B\u0441\u044F \u0441 \u043A\u043E\u0434\u043E\u043C ${code}`
2728
+ });
2729
+ this.emit("changed");
2730
+ });
2731
+ }
2732
+ /** Отдать процессу код, принесённый из браузера. */
2733
+ submitCode(code) {
2734
+ const child = this.#child;
2735
+ if (!child?.stdin?.writable)
2736
+ throw new Error("\u0412\u0445\u043E\u0434 \u043D\u0435 \u0438\u0434\u0451\u0442 \u2014 \u0432\u0432\u043E\u0434\u0438\u0442\u044C \u043A\u043E\u0434 \u043D\u0435\u043A\u0443\u0434\u0430");
2737
+ child.stdin.write(`${code.trim()}
2738
+ `);
2739
+ this.#push({ ...this.#state, awaitingCode: false });
2740
+ }
2741
+ /** Прервать вход. Учётные данные при этом остаются прежними. */
2742
+ cancel() {
2743
+ if (!this.#child && !this.#state.active)
2744
+ return;
2745
+ this.#child?.kill();
2746
+ this.#clear();
2747
+ this.#push({ ...this.#state, active: false, awaitingCode: false, error: "\u0412\u0445\u043E\u0434 \u043E\u0442\u043C\u0435\u043D\u0451\u043D" });
2748
+ }
2749
+ async logout() {
2750
+ this.cancel();
2751
+ const output = await run(["auth", "logout"], QUICK_TIMEOUT_MS);
2752
+ this.#push({ ...IDLE, output });
2753
+ this.emit("changed");
2754
+ }
2755
+ // -------------------------------------------------------------------------
2756
+ #absorb(text) {
2757
+ const output = this.#state.output + text;
2758
+ this.#push({
2759
+ ...this.#state,
2760
+ output,
2761
+ url: this.#state.url || URL_RE.exec(output)?.[0] || "",
2762
+ // Приглашение ищем в хвосте: раньше в выводе оно встретиться не может, а
2763
+ // так мы не примем за него упоминание кода в старом сообщении.
2764
+ awaitingCode: CODE_PROMPT_RE.test(output.slice(-200))
2765
+ });
2766
+ }
2767
+ #fail(message) {
2768
+ this.#child?.kill();
2769
+ this.#clear();
2770
+ this.#push({ ...this.#state, active: false, awaitingCode: false, error: message });
2771
+ }
2772
+ #clear() {
2773
+ this.#child = null;
2774
+ if (this.#timer)
2775
+ clearTimeout(this.#timer);
2776
+ this.#timer = null;
2777
+ }
2778
+ #push(state) {
2779
+ this.#state = state;
2780
+ this.emit("login", state);
2781
+ }
2782
+ };
2783
+ function run(args, timeout) {
2784
+ return runClaude(args, { timeout, missing: "\u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0430\u043A\u043A\u0430\u0443\u043D\u0442\u043E\u043C \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u043E" });
2785
+ }
2786
+ function parseStatus2(raw) {
2787
+ const empty = {
2788
+ loggedIn: false,
2789
+ method: "none",
2790
+ email: "",
2791
+ organization: "",
2792
+ plan: ""
2793
+ };
2794
+ const start = raw.indexOf("{");
2795
+ const end = raw.lastIndexOf("}");
2796
+ if (start === -1 || end <= start)
2797
+ return empty;
2798
+ let parsed;
2799
+ try {
2800
+ parsed = JSON.parse(raw.slice(start, end + 1));
2801
+ } catch {
2802
+ return empty;
2803
+ }
2804
+ return {
2805
+ loggedIn: parsed.loggedIn === true,
2806
+ method: str(parsed.authMethod) || "none",
2807
+ email: str(parsed.email),
2808
+ organization: str(parsed.orgName),
2809
+ plan: str(parsed.subscriptionType)
2810
+ };
2811
+ }
2812
+ function str(value) {
2813
+ return typeof value === "string" ? value : "";
2814
+ }
2815
+ function describe3(error) {
2816
+ return error instanceof Error ? error.message : String(error);
2817
+ }
2818
+
2819
+ // ../host/dist/relay-link.js
2820
+ import { EventEmitter as EventEmitter3 } from "node:events";
2821
+ import { readFile as readFile8, writeFile as writeFile5, rm as rm2, mkdir as mkdir5 } from "node:fs/promises";
2822
+ import path11 from "node:path";
2823
+ import { hostname } from "node:os";
2824
+ import WebSocket from "ws";
2825
+
2826
+ // ../protocol/dist/relay.js
2827
+ var RELAY_PROTOCOL_VERSION = 1;
2828
+ var RELAY_BROADCAST = "*";
2829
+
2830
+ // ../host/dist/relay-link.js
2831
+ var RETRY_MIN_MS = 1e3;
2832
+ var RETRY_MAX_MS = 6e4;
2833
+ function jitter(delay) {
2834
+ return Math.round(delay * (0.8 + Math.random() * 0.4));
2835
+ }
2836
+ var RelayLink = class extends EventEmitter3 {
2837
+ #credentials = null;
2838
+ #socket = null;
2839
+ #retry = RETRY_MIN_MS;
2840
+ #retryTimer = null;
2841
+ #clients = /* @__PURE__ */ new Set();
2842
+ #error = null;
2843
+ #closed = false;
2844
+ /** Куда сопрягаться, пока не сопряглись. */
2845
+ #defaultUrl;
2846
+ #file;
2847
+ #log;
2848
+ constructor(options2) {
2849
+ super();
2850
+ this.#file = path11.join(options2.dataDir, "relay.json");
2851
+ this.#defaultUrl = options2.defaultUrl.replace(/\/+$/, "");
2852
+ this.#log = options2.log ?? ((msg) => console.log(`[relay] ${msg}`));
2853
+ }
2854
+ // -------------------------------------------------------------------------
2855
+ // Состояние
2856
+ // -------------------------------------------------------------------------
2857
+ get state() {
2858
+ return {
2859
+ paired: this.#credentials !== null,
2860
+ connected: this.#socket?.readyState === WebSocket.OPEN,
2861
+ url: this.#credentials?.url ?? this.#defaultUrl,
2862
+ name: this.#credentials?.name ?? null,
2863
+ hostId: this.#credentials?.hostId ?? null,
2864
+ clients: this.#clients.size,
2865
+ error: this.#error
2866
+ };
2867
+ }
2868
+ #changed() {
2869
+ this.emit("state");
2870
+ }
2871
+ // -------------------------------------------------------------------------
2872
+ // Жизненный цикл
2873
+ // -------------------------------------------------------------------------
2874
+ /** Поднять связь, если хост уже сопрягали раньше. */
2875
+ async start() {
2876
+ this.#credentials = await this.#read();
2877
+ if (this.#credentials)
2878
+ this.#connect();
2879
+ this.#changed();
2880
+ }
2881
+ async #read() {
2882
+ try {
2883
+ const raw = await readFile8(this.#file, "utf8");
2884
+ const parsed = JSON.parse(raw);
2885
+ if (typeof parsed !== "object" || parsed === null)
2886
+ return null;
2887
+ const { url: url2, hostId, name, token } = parsed;
2888
+ if (!url2 || !hostId || !token)
2889
+ return null;
2890
+ return { url: url2, hostId, name: name ?? hostname(), token };
2891
+ } catch {
2892
+ return null;
2893
+ }
2894
+ }
2895
+ async #write(credentials) {
2896
+ await mkdir5(path11.dirname(this.#file), { recursive: true });
2897
+ await writeFile5(this.#file, `${JSON.stringify(credentials, null, 2)}
2898
+ `, {
2899
+ encoding: "utf8",
2900
+ mode: 384
2901
+ });
2902
+ }
2903
+ /**
2904
+ * Обменять код сопряжения на токен хоста.
2905
+ *
2906
+ * Единственное место, где хост сам ходит в relay по HTTP. Токен приходит в
2907
+ * ответе один раз и сразу ложится на диск: показывать его человеку незачем —
2908
+ * весь смысл кода в том, чтобы длинную строку никто не переносил руками.
2909
+ */
2910
+ async pair(code, name) {
2911
+ const url2 = this.#credentials?.url ?? this.#defaultUrl;
2912
+ const machine = (name ?? "").trim() || hostname();
2913
+ let response;
2914
+ try {
2915
+ response = await fetch(`${url2}/api/pair/claim`, {
2916
+ method: "POST",
2917
+ headers: { "content-type": "application/json" },
2918
+ body: JSON.stringify({ code, name: machine, platform: process.platform })
2919
+ });
2920
+ } catch (error) {
2921
+ throw new Error(`\u043D\u0435 \u0434\u043E\u0437\u0432\u043E\u043D\u0438\u043B\u0438\u0441\u044C \u0434\u043E ${url2}: ${describe4(error)}`);
2922
+ }
2923
+ const data = await response.json().catch(() => ({}));
2924
+ if (!response.ok || !data.token || !data.hostId) {
2925
+ throw new Error(data.error ?? `relay \u043E\u0442\u0432\u0435\u0442\u0438\u043B ${response.status}`);
2926
+ }
2927
+ this.#disconnect();
2928
+ const credentials = {
2929
+ url: url2,
2930
+ hostId: data.hostId,
2931
+ name: data.name ?? machine,
2932
+ token: data.token
2933
+ };
2934
+ await this.#write(credentials);
2935
+ this.#credentials = credentials;
2936
+ this.#error = null;
2937
+ this.#retry = RETRY_MIN_MS;
2938
+ this.#connect();
2939
+ this.#changed();
2940
+ this.#log(`\u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u043E: ${credentials.name} (${credentials.hostId}) \u043D\u0430 ${credentials.url}`);
2941
+ }
2942
+ /** Отвязать машину: связь рвём, токен стираем с диска. */
2943
+ async unpair() {
2944
+ this.#disconnect();
2945
+ this.#credentials = null;
2946
+ this.#error = null;
2947
+ this.#clients.clear();
2948
+ await rm2(this.#file, { force: true });
2949
+ this.#changed();
2950
+ this.#log("\u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435 \u0441\u043D\u044F\u0442\u043E");
2951
+ }
2952
+ async close() {
2953
+ this.#closed = true;
2954
+ this.#disconnect();
2955
+ }
2956
+ // -------------------------------------------------------------------------
2957
+ // Соединение
2958
+ // -------------------------------------------------------------------------
2959
+ #disconnect() {
2960
+ if (this.#retryTimer) {
2961
+ clearTimeout(this.#retryTimer);
2962
+ this.#retryTimer = null;
2963
+ }
2964
+ const socket = this.#socket;
2965
+ this.#socket = null;
2966
+ if (socket) {
2967
+ socket.removeAllListeners();
2968
+ socket.close();
2969
+ }
2970
+ for (const clientId of this.#clients)
2971
+ this.emit("detached", clientId);
2972
+ this.#clients.clear();
2973
+ }
2974
+ #connect() {
2975
+ const credentials = this.#credentials;
2976
+ if (!credentials || this.#closed || this.#socket)
2977
+ return;
2978
+ const wsUrl = `${credentials.url.replace(/^http/, "ws")}/relay/host`;
2979
+ const socket = new WebSocket(wsUrl);
2980
+ this.#socket = socket;
2981
+ socket.on("open", () => {
2982
+ const hello = {
2983
+ t: "host.hello",
2984
+ protocol: RELAY_PROTOCOL_VERSION,
2985
+ token: credentials.token,
2986
+ name: credentials.name,
2987
+ platform: process.platform
2988
+ };
2989
+ socket.send(JSON.stringify(hello));
2990
+ });
2991
+ socket.on("message", (raw) => this.#onMessage(socket, String(raw)));
2992
+ socket.on("error", (error) => {
2993
+ this.#error = describe4(error);
2994
+ });
2995
+ socket.on("close", (code, reason) => {
2996
+ if (this.#socket !== socket)
2997
+ return;
2998
+ this.#socket = null;
2999
+ for (const clientId of this.#clients)
3000
+ this.emit("detached", clientId);
3001
+ this.#clients.clear();
3002
+ const rejected = code === 4001;
3003
+ if (rejected) {
3004
+ this.#error = reason.toString() || "relay \u043D\u0435 \u043F\u0440\u0438\u0437\u043D\u0430\u043B \u0442\u043E\u043A\u0435\u043D \u0445\u043E\u0441\u0442\u0430";
3005
+ this.#log(`\u043E\u0442\u043A\u0430\u0437 relay: ${this.#error}`);
3006
+ }
3007
+ this.#changed();
3008
+ this.#scheduleRetry(rejected);
3009
+ });
3010
+ }
3011
+ #scheduleRetry(rejected) {
3012
+ if (this.#closed || !this.#credentials || this.#retryTimer)
3013
+ return;
3014
+ const delay = rejected ? RETRY_MAX_MS : this.#retry;
3015
+ this.#retry = Math.min(this.#retry * 2, RETRY_MAX_MS);
3016
+ this.#retryTimer = setTimeout(() => {
3017
+ this.#retryTimer = null;
3018
+ this.#connect();
3019
+ }, jitter(delay));
3020
+ this.#retryTimer.unref();
3021
+ }
3022
+ #onMessage(socket, raw) {
3023
+ let frame;
3024
+ try {
3025
+ frame = JSON.parse(raw);
3026
+ } catch {
3027
+ return;
3028
+ }
3029
+ switch (frame.t) {
3030
+ case "host.ok":
3031
+ this.#retry = RETRY_MIN_MS;
3032
+ this.#error = null;
3033
+ for (const clientId of frame.clients) {
3034
+ this.#clients.add(clientId);
3035
+ this.emit("attached", clientId);
3036
+ }
3037
+ this.#log(`\u043D\u0430 \u0441\u0432\u044F\u0437\u0438 \u043A\u0430\u043A ${frame.name} (${frame.hostId}), \u0436\u0434\u0443\u0442 ${frame.clients.length}`);
3038
+ this.#changed();
3039
+ return;
3040
+ case "client.attached":
3041
+ this.#clients.add(frame.clientId);
3042
+ this.emit("attached", frame.clientId);
3043
+ this.#changed();
3044
+ return;
3045
+ case "client.detached":
3046
+ this.#clients.delete(frame.clientId);
3047
+ this.emit("detached", frame.clientId);
3048
+ this.#changed();
3049
+ return;
3050
+ case "client.frame":
3051
+ this.emit("frame", frame.clientId, frame.body);
3052
+ return;
3053
+ case "ping":
3054
+ socket.send(JSON.stringify({ t: "pong", nonce: frame.nonce }));
3055
+ return;
3056
+ case "error":
3057
+ this.#error = frame.message;
3058
+ this.#log(`\u043E\u0448\u0438\u0431\u043A\u0430 relay (${frame.code}): ${frame.message}`);
3059
+ this.#changed();
3060
+ return;
3061
+ }
3062
+ }
3063
+ // -------------------------------------------------------------------------
3064
+ // Отправка
3065
+ // -------------------------------------------------------------------------
3066
+ /** Кадр одному удалённому клиенту. */
3067
+ send(clientId, frame) {
3068
+ this.#push({ t: "host.frame", to: clientId, body: frame });
3069
+ }
3070
+ /**
3071
+ * Кадр всем удалённым клиентам разом.
3072
+ *
3073
+ * Не то же самое, что рассылка по одному: broadcast попадает в кольцевой
3074
+ * буфер relay и переживёт кратковременный обрыв у клиента, а адресный кадр —
3075
+ * нет, потому что повторять ответ ушедшей вкладке некому.
3076
+ */
3077
+ broadcast(frame) {
3078
+ if (this.#clients.size === 0)
3079
+ return;
3080
+ this.#push({ t: "host.frame", to: RELAY_BROADCAST, body: frame });
3081
+ }
3082
+ #push(frame) {
3083
+ const socket = this.#socket;
3084
+ if (socket?.readyState !== WebSocket.OPEN)
3085
+ return;
3086
+ socket.send(JSON.stringify(frame));
3087
+ }
3088
+ };
3089
+ function describe4(error) {
3090
+ return error instanceof Error ? error.message : String(error);
3091
+ }
3092
+
3093
+ // ../host/dist/mcp-config.js
3094
+ import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir6 } from "node:fs/promises";
3095
+ import path12 from "node:path";
3096
+ var MCP_FILE = ".mcp.json";
3097
+ var LOCAL_SETTINGS2 = path12.join(".claude", "settings.local.json");
3098
+ var queues = /* @__PURE__ */ new Map();
3099
+ function serialize(cwd, work) {
3100
+ const previous = queues.get(cwd) ?? Promise.resolve();
3101
+ const next = previous.then(work, work);
3102
+ queues.set(cwd, next.catch(() => void 0));
3103
+ return next;
3104
+ }
3105
+ async function readProjectServers(cwd) {
3106
+ const file = await readJson(path12.join(cwd, MCP_FILE));
3107
+ const servers = file?.mcpServers;
3108
+ if (!servers || typeof servers !== "object")
3109
+ return {};
3110
+ return servers;
3111
+ }
3112
+ function addProjectServer(cwd, name, config) {
3113
+ return serialize(cwd, async () => {
3114
+ const file = await readJson(path12.join(cwd, MCP_FILE)) ?? {};
3115
+ const servers = file.mcpServers ?? {};
3116
+ servers[name] = config;
3117
+ file.mcpServers = servers;
3118
+ await writeJson(path12.join(cwd, MCP_FILE), file);
3119
+ await approve(cwd, name);
3120
+ });
3121
+ }
3122
+ function removeProjectServer(cwd, name) {
3123
+ return serialize(cwd, async () => {
3124
+ const file = await readJson(path12.join(cwd, MCP_FILE));
3125
+ const servers = file?.mcpServers;
3126
+ if (!servers || !(name in servers))
3127
+ return false;
3128
+ delete servers[name];
3129
+ await writeJson(path12.join(cwd, MCP_FILE), file);
3130
+ const settingsPath = path12.join(cwd, LOCAL_SETTINGS2);
3131
+ const settings = await readJson(settingsPath);
3132
+ const enabled = settings?.enabledMcpjsonServers;
3133
+ if (settings && Array.isArray(enabled) && enabled.includes(name)) {
3134
+ settings.enabledMcpjsonServers = enabled.filter((s) => s !== name);
3135
+ await writeJson(settingsPath, settings);
3136
+ }
3137
+ return true;
3138
+ });
3139
+ }
3140
+ async function approve(cwd, name) {
3141
+ const file = path12.join(cwd, LOCAL_SETTINGS2);
3142
+ const settings = await readJson(file) ?? {};
3143
+ const enabled = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
3144
+ if (!enabled.includes(name))
3145
+ enabled.push(name);
3146
+ settings.enabledMcpjsonServers = enabled;
3147
+ const disabled = settings.disabledMcpjsonServers;
3148
+ if (Array.isArray(disabled)) {
3149
+ settings.disabledMcpjsonServers = disabled.filter((s) => s !== name);
3150
+ }
3151
+ await writeJson(file, settings);
3152
+ }
3153
+ function draftToConfig(draft) {
3154
+ if (draft.transport === "stdio") {
3155
+ const config2 = { type: "stdio", command: draft.command };
3156
+ if (draft.args.length > 0)
3157
+ config2.args = draft.args;
3158
+ if (Object.keys(draft.env).length > 0)
3159
+ config2.env = draft.env;
3160
+ return config2;
3161
+ }
3162
+ const config = { type: draft.transport, url: draft.url };
3163
+ if (Object.keys(draft.headers).length > 0)
3164
+ config.headers = draft.headers;
3165
+ return config;
3166
+ }
3167
+ function describeConfig(config) {
3168
+ if (!config || typeof config !== "object")
3169
+ return { transport: "", target: "" };
3170
+ const c = config;
3171
+ const url2 = typeof c.url === "string" ? c.url : null;
3172
+ if (url2)
3173
+ return { transport: typeof c.type === "string" ? c.type : "http", target: url2 };
3174
+ const command = typeof c.command === "string" ? c.command : "";
3175
+ const args = Array.isArray(c.args) ? c.args.filter((a) => typeof a === "string") : [];
3176
+ return { transport: "stdio", target: [command, ...args].join(" ").trim() };
3177
+ }
3178
+ async function readJson(file) {
3179
+ try {
3180
+ const raw = await readFile9(file, "utf8");
3181
+ const parsed = JSON.parse(raw);
3182
+ return parsed && typeof parsed === "object" ? parsed : null;
3183
+ } catch (error) {
3184
+ if (error.code === "ENOENT")
3185
+ return null;
3186
+ if (error instanceof SyntaxError) {
3187
+ throw new Error(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C ${path12.basename(file)}: ${error.message}`);
3188
+ }
3189
+ throw error;
3190
+ }
3191
+ }
3192
+ async function writeJson(file, data) {
3193
+ await mkdir6(path12.dirname(file), { recursive: true });
3194
+ await writeFile6(file, `${JSON.stringify(data, null, 2)}
3195
+ `, "utf8");
3196
+ }
3197
+
3198
+ // ../host/dist/server.js
3199
+ var SIDE_TITLE = "\xB7 \u0432\u043E\u043F\u0440\u043E\u0441 \u043D\u0430 \u043F\u043E\u043B\u044F\u0445";
3200
+ var FINDER_TITLE = "\xB7 \u043F\u043E\u0438\u0441\u043A \u043F\u0440\u043E\u0435\u043A\u0442\u0430";
3201
+ var HostServer = class {
3202
+ #options;
3203
+ #workspaces;
3204
+ #archive;
3205
+ #settings;
3206
+ #finder;
3207
+ #sessions = /* @__PURE__ */ new Map();
3208
+ /** Боковые сессии («вопрос на полях») по id родителя — не больше одной на разговор. */
3209
+ #sides = /* @__PURE__ */ new Map();
3210
+ /**
3211
+ * Сессия поиска проекта — не больше одной на хост.
3212
+ *
3213
+ * Она ничья: у неё нет ни проекта, ни родительского разговора, поэтому и
3214
+ * ключа для неё нет — в отличие от боковой, которая живёт при своём.
3215
+ */
3216
+ #finderSession = null;
3217
+ /** Последний запрос к ней — им подписывается ответ, когда тот придёт. */
3218
+ #finderQuery = "";
3219
+ #clients = /* @__PURE__ */ new Set();
3220
+ /** Удалённые вкладки по их id в relay — чтобы адресовать ответ именно той. */
3221
+ #remotes = /* @__PURE__ */ new Map();
3222
+ #hostId = randomUUID2();
3223
+ #plugins = new PluginCli();
3224
+ #auth = new AuthCli();
3225
+ #relay;
3226
+ constructor(options2) {
3227
+ this.#options = options2;
3228
+ this.#workspaces = new WorkspaceRegistry(options2.dataDir);
3229
+ this.#archive = new SessionArchive(options2.dataDir);
3230
+ this.#settings = new AppSettingsStore(options2.dataDir);
3231
+ this.#finder = new ProjectFinder(this.#workspaces);
3232
+ this.#relay = new RelayLink({ dataDir: options2.dataDir, defaultUrl: options2.relayUrl });
3233
+ this.#wireRelay();
3234
+ this.#auth.on("login", (state) => {
3235
+ this.#broadcast({ type: "auth.login", state });
3236
+ });
3237
+ this.#auth.on("changed", () => {
3238
+ void this.#sendAuthStatus();
3239
+ });
3240
+ }
3241
+ async start() {
3242
+ await this.#workspaces.load();
3243
+ await this.#archive.load();
3244
+ await this.#settings.load();
3245
+ await warmClaudeLauncher();
3246
+ void this.#finder.warm();
3247
+ const httpServer = createServer((req, res) => this.#serveHttp(req, res));
3248
+ const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
3249
+ wss.on("connection", (socket) => this.#onConnection(socket));
3250
+ let listening = false;
3251
+ wss.on("error", (error) => {
3252
+ if (listening)
3253
+ console.error("[ws]", error instanceof Error ? error.message : String(error));
3254
+ });
3255
+ await new Promise((resolve, reject) => {
3256
+ httpServer.once("error", reject);
3257
+ httpServer.listen(this.#options.port, this.#options.host, () => {
3258
+ httpServer.off("error", reject);
3259
+ listening = true;
3260
+ resolve();
3261
+ });
3262
+ });
3263
+ await this.#relay.start().catch((error) => {
3264
+ console.error("[relay]", error instanceof Error ? error.message : String(error));
3265
+ });
3266
+ }
3267
+ async shutdown() {
3268
+ this.#auth.cancel();
3269
+ await this.#relay.close();
3270
+ await Promise.all([...this.#sessions.values()].map((s) => s.close("host shutdown")));
3271
+ this.#sessions.clear();
3272
+ }
3273
+ // -------------------------------------------------------------------------
3274
+ // WebSocket
3275
+ // -------------------------------------------------------------------------
3276
+ #onConnection(socket) {
3277
+ const client = {
3278
+ local: true,
3279
+ send: (frame) => {
3280
+ if (socket.readyState !== socket.OPEN)
3281
+ return;
3282
+ socket.send(JSON.stringify(frame));
3283
+ }
3284
+ };
3285
+ this.#clients.add(client);
3286
+ socket.on("close", () => this.#clients.delete(client));
3287
+ socket.on("message", (raw) => {
3288
+ let parsed;
3289
+ try {
3290
+ parsed = JSON.parse(raw.toString());
3291
+ } catch {
3292
+ return client.send({ type: "error", message: "\u041D\u0435\u043A\u043E\u0440\u0440\u0435\u043A\u0442\u043D\u044B\u0439 JSON \u0432\u043E \u0432\u0445\u043E\u0434\u044F\u0449\u0435\u043C \u043A\u0430\u0434\u0440\u0435" });
3293
+ }
3294
+ if (!isClientFrame(parsed)) {
3295
+ return client.send({ type: "error", message: "\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442 \u043A\u0430\u0434\u0440\u0430" });
3296
+ }
3297
+ this.#dispatch(client, parsed);
3298
+ });
3299
+ }
3300
+ // -------------------------------------------------------------------------
3301
+ // Relay: те же кадры, только пришедшие снаружи
3302
+ // -------------------------------------------------------------------------
3303
+ #wireRelay() {
3304
+ this.#relay.on("attached", (clientId) => {
3305
+ const client = {
3306
+ local: false,
3307
+ send: (frame) => this.#relay.send(clientId, frame)
3308
+ };
3309
+ this.#remotes.set(clientId, client);
3310
+ this.#clients.add(client);
3311
+ });
3312
+ this.#relay.on("detached", (clientId) => {
3313
+ const client = this.#remotes.get(clientId);
3314
+ if (!client)
3315
+ return;
3316
+ this.#remotes.delete(clientId);
3317
+ this.#clients.delete(client);
3318
+ });
3319
+ this.#relay.on("frame", (clientId, body) => {
3320
+ const client = this.#remotes.get(clientId);
3321
+ const channel = client ?? this.#adoptRemote(clientId);
3322
+ if (!isClientFrame(body)) {
3323
+ channel.send({ type: "error", message: "\u041D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u044B\u0439 \u0444\u043E\u0440\u043C\u0430\u0442 \u043A\u0430\u0434\u0440\u0430" });
3324
+ return;
3325
+ }
3326
+ this.#dispatch(channel, body);
3327
+ });
3328
+ this.#relay.on("state", () => {
3329
+ this.#broadcast({ type: "relay.state", state: this.#relay.state });
3330
+ });
3331
+ }
3332
+ #adoptRemote(clientId) {
3333
+ const client = {
3334
+ local: false,
3335
+ send: (frame) => this.#relay.send(clientId, frame)
3336
+ };
3337
+ this.#remotes.set(clientId, client);
3338
+ this.#clients.add(client);
3339
+ return client;
3340
+ }
3341
+ /** Общий вход для кадра любой вкладки: своё исключение — свой `error`. */
3342
+ #dispatch(client, frame) {
3343
+ void this.#handleFrame(client, frame).catch((error) => {
3344
+ client.send({
3345
+ type: "error",
3346
+ message: error instanceof Error ? error.message : String(error),
3347
+ context: frame.type
3348
+ });
3349
+ });
3350
+ }
3351
+ #send(client, frame) {
3352
+ client.send(frame);
3353
+ }
3354
+ #broadcast(frame) {
3355
+ this.#relay.broadcast(frame);
3356
+ for (const client of this.#clients) {
3357
+ if (client.local)
3358
+ client.send(frame);
3359
+ }
3360
+ }
3361
+ async #handleFrame(socket, frame) {
3362
+ switch (frame.type) {
3363
+ case "hello":
3364
+ this.#send(socket, {
3365
+ type: "hello.ok",
3366
+ protocol: PROTOCOL_VERSION,
3367
+ hostId: this.#hostId,
3368
+ hostVersion: this.#options.version,
3369
+ platform: process.platform
3370
+ });
3371
+ this.#send(socket, {
3372
+ type: "workspace.list",
3373
+ workspaces: this.#workspaces.list()
3374
+ });
3375
+ this.#auth.status().then((status) => this.#send(socket, { type: "auth.status", status })).catch(() => void 0);
3376
+ this.#send(socket, { type: "relay.state", state: this.#relay.state });
3377
+ return;
3378
+ case "ping":
3379
+ this.#send(socket, { type: "pong", nonce: frame.nonce });
3380
+ return;
3381
+ case "workspace.list":
3382
+ this.#send(socket, {
3383
+ type: "workspace.list",
3384
+ workspaces: this.#workspaces.list()
3385
+ });
3386
+ return;
3387
+ case "workspace.open":
3388
+ await this.#workspaces.open(frame.path);
3389
+ this.#broadcast({
3390
+ type: "workspace.list",
3391
+ workspaces: this.#workspaces.list()
3392
+ });
3393
+ await this.#closeFinder();
3394
+ return;
3395
+ case "workspace.close": {
3396
+ const closed = await this.#workspaces.close(frame.workspaceIds);
3397
+ if (closed.length === 0)
3398
+ return;
3399
+ const gone = new Set(closed.map((w) => w.id));
3400
+ for (const session of [...this.#sessions.values()]) {
3401
+ if (!gone.has(session.workspaceId))
3402
+ continue;
3403
+ await this.#dropSession(session.id, "workspace closed");
3404
+ }
3405
+ this.#broadcast({
3406
+ type: "workspace.list",
3407
+ workspaces: this.#workspaces.list()
3408
+ });
3409
+ return;
3410
+ }
3411
+ case "workspace.find": {
3412
+ const { candidates, truncated } = this.#finder.find(frame.query);
3413
+ this.#send(socket, {
3414
+ type: "workspace.candidates",
3415
+ query: frame.query,
3416
+ source: "host",
3417
+ candidates,
3418
+ truncated
3419
+ });
3420
+ return;
3421
+ }
3422
+ case "workspace.find.agent":
3423
+ this.#findWithAgent(frame.query);
3424
+ return;
3425
+ case "workspace.roots":
3426
+ this.#send(socket, { type: "workspace.roots", roots: this.#roots() });
3427
+ return;
3428
+ case "workspace.create": {
3429
+ const { workspace, warnings } = await this.#workspaces.create(frame.parent, frame.name, frame.init);
3430
+ this.#broadcast({
3431
+ type: "workspace.list",
3432
+ workspaces: this.#workspaces.list()
3433
+ });
3434
+ this.#send(socket, { type: "workspace.created", workspace, warnings });
3435
+ await this.#closeFinder();
3436
+ return;
3437
+ }
3438
+ case "session.list": {
3439
+ const { sessions, archived } = await this.#listSessions(frame.workspaceId);
3440
+ this.#send(socket, {
3441
+ type: "session.list",
3442
+ workspaceId: frame.workspaceId,
3443
+ sessions,
3444
+ archived
3445
+ });
3446
+ return;
3447
+ }
3448
+ case "session.start":
3449
+ await this.#startSession(socket, frame);
3450
+ return;
3451
+ case "session.close":
3452
+ await this.#dropSession(frame.sessionId, "closed by client");
3453
+ return;
3454
+ case "session.archive": {
3455
+ const ids = new Set(frame.sessionIds);
3456
+ for (const session of [...this.#sessions.values()]) {
3457
+ const diskId = session.claudeSessionId ?? session.id;
3458
+ if (!ids.has(session.id) && !ids.has(diskId))
3459
+ continue;
3460
+ ids.add(diskId);
3461
+ await this.#dropSession(session.id, "archived");
3462
+ }
3463
+ await this.#archive.archive(frame.workspaceId, [...ids]);
3464
+ await this.#sendSessionList(frame.workspaceId);
3465
+ return;
3466
+ }
3467
+ case "session.unarchive":
3468
+ await this.#archive.unarchive(frame.sessionIds);
3469
+ await this.#sendSessionList(frame.workspaceId);
3470
+ return;
3471
+ case "session.rename":
3472
+ await this.#renameSession(frame.sessionId, frame.title, frame.workspaceId);
3473
+ return;
3474
+ case "session.sync": {
3475
+ const session = this.#requireSession(frame.sessionId);
3476
+ const entries = session.journal.since(frame.sinceSeq);
3477
+ if (session.capabilities) {
3478
+ this.#send(socket, {
3479
+ type: "session.capabilities",
3480
+ sessionId: session.id,
3481
+ capabilities: session.capabilities
3482
+ });
3483
+ }
3484
+ this.#send(socket, { type: "session.summary", session: session.summary() });
3485
+ if (session.usage) {
3486
+ this.#send(socket, {
3487
+ type: "usage.state",
3488
+ sessionId: session.id,
3489
+ usage: session.usage
3490
+ });
3491
+ }
3492
+ if (entries.length > 0) {
3493
+ this.#send(socket, { type: "session.events", sessionId: session.id, entries });
3494
+ }
3495
+ return;
3496
+ }
3497
+ case "usage.get": {
3498
+ const session = this.#requireSession(frame.sessionId);
3499
+ if (session.usage) {
3500
+ this.#send(socket, {
3501
+ type: "usage.state",
3502
+ sessionId: session.id,
3503
+ usage: session.usage
3504
+ });
3505
+ }
3506
+ await session.refreshUsage(true);
3507
+ return;
3508
+ }
3509
+ case "prompt.send":
3510
+ this.#requireSession(frame.sessionId).sendPrompt(frame.text);
3511
+ return;
3512
+ case "permission.respond":
3513
+ this.#requireSession(frame.sessionId).respondPermission(frame.requestId, frame.decision, frame.reason);
3514
+ return;
3515
+ case "side.ask":
3516
+ this.#ensureSide(this.#requireSession(frame.sessionId)).sendPrompt(frame.text);
3517
+ return;
3518
+ // Поднять форк, ничего не спрашивая: клиенту нужен его id, чтобы
3519
+ // адресовать `control.*` — модель бокового вопроса выбирают до вопроса.
3520
+ case "side.open": {
3521
+ const side = this.#ensureSide(this.#requireSession(frame.sessionId));
3522
+ this.#send(socket, {
3523
+ type: "side.opened",
3524
+ parentSessionId: frame.sessionId,
3525
+ sessionId: side.id
3526
+ });
3527
+ return;
3528
+ }
3529
+ case "side.reset":
3530
+ await this.#closeSide(frame.sessionId);
3531
+ return;
3532
+ case "control.interrupt":
3533
+ await this.#requireSession(frame.sessionId).interrupt();
3534
+ return;
3535
+ case "control.set_mode":
3536
+ await this.#requireSession(frame.sessionId).setPermissionMode(frame.mode);
3537
+ return;
3538
+ case "control.set_model":
3539
+ await this.#requireSession(frame.sessionId).setModel(frame.model);
3540
+ return;
3541
+ case "control.set_effort":
3542
+ await this.#requireSession(frame.sessionId).setEffort(frame.effort);
3543
+ return;
3544
+ case "control.set_ultracode":
3545
+ await this.#requireSession(frame.sessionId).setUltracode(frame.enabled);
3546
+ return;
3547
+ case "control.set_thinking":
3548
+ await this.#requireSession(frame.sessionId).setThinking(frame.enabled);
3549
+ return;
3550
+ case "control.set_flag_switch":
3551
+ await this.#requireSession(frame.sessionId).setSwitchModelsOnFlag(frame.enabled);
3552
+ return;
3553
+ case "mcp.list":
3554
+ await this.#sendMcpList(frame.sessionId);
3555
+ return;
3556
+ case "mcp.add": {
3557
+ const session = this.#requireSession(frame.sessionId);
3558
+ const config = draftToConfig(frame.draft);
3559
+ const name = frame.name.trim();
3560
+ if (!name)
3561
+ throw new Error("\u0418\u043C\u044F \u0441\u0435\u0440\u0432\u0435\u0440\u0430 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C");
3562
+ await addProjectServer(session.cwd, name, config);
3563
+ await session.addMcpServer(name, config);
3564
+ this.#reportOperation(frame.sessionId, "mcp", true, `\u0421\u0435\u0440\u0432\u0435\u0440 \xAB${name}\xBB \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0451\u043D`);
3565
+ await this.#sendMcpList(frame.sessionId);
3566
+ return;
3567
+ }
3568
+ case "mcp.remove": {
3569
+ const session = this.#requireSession(frame.sessionId);
3570
+ const inFile = await removeProjectServer(session.cwd, frame.name);
3571
+ const live = await session.removeMcpServer(frame.name);
3572
+ this.#reportOperation(frame.sessionId, "mcp", inFile || live, inFile || live ? `\u0421\u0435\u0440\u0432\u0435\u0440 \xAB${frame.name}\xBB \u0443\u0434\u0430\u043B\u0451\u043D` : `\u0421\u0435\u0440\u0432\u0435\u0440 \xAB${frame.name}\xBB \u043E\u043F\u0438\u0441\u0430\u043D \u043D\u0435 \u0432 \u044D\u0442\u043E\u043C \u043F\u0440\u043E\u0435\u043A\u0442\u0435 \u2014 \u0443\u0434\u0430\u043B\u0438\u0442\u044C \u043E\u0442\u0441\u044E\u0434\u0430 \u043D\u0435\u043B\u044C\u0437\u044F`);
3573
+ await this.#sendMcpList(frame.sessionId);
3574
+ return;
3575
+ }
3576
+ case "mcp.action": {
3577
+ this.#requireSession(frame.sessionId).sendPrompt(`/mcp ${frame.action} ${frame.name}`);
3578
+ return;
3579
+ }
3580
+ case "plugin.list":
3581
+ await this.#sendPluginList(frame.sessionId);
3582
+ return;
3583
+ case "plugin.install":
3584
+ await this.#pluginOperation(frame.sessionId, () => this.#plugins.install(frame.spec));
3585
+ return;
3586
+ case "plugin.uninstall":
3587
+ await this.#pluginOperation(frame.sessionId, () => this.#plugins.uninstall(frame.id));
3588
+ return;
3589
+ case "plugin.toggle":
3590
+ await this.#pluginOperation(frame.sessionId, () => this.#plugins.setEnabled(frame.id, frame.enabled));
3591
+ return;
3592
+ case "marketplace.add":
3593
+ await this.#pluginOperation(frame.sessionId, () => this.#plugins.addMarketplace(frame.source));
3594
+ return;
3595
+ case "marketplace.remove":
3596
+ await this.#pluginOperation(frame.sessionId, () => this.#plugins.removeMarketplace(frame.name));
3597
+ return;
3598
+ case "git.state":
3599
+ this.#send(socket, {
3600
+ type: "git.state",
3601
+ workspaceId: frame.workspaceId,
3602
+ state: await this.#git(frame.workspaceId).state()
3603
+ });
3604
+ return;
3605
+ case "git.show":
3606
+ this.#send(socket, {
3607
+ type: "git.commit",
3608
+ workspaceId: frame.workspaceId,
3609
+ detail: await this.#git(frame.workspaceId).show(frame.ref)
3610
+ });
3611
+ return;
3612
+ case "git.checkout": {
3613
+ const repo = this.#git(frame.workspaceId);
3614
+ let ok = true;
3615
+ let message;
3616
+ try {
3617
+ message = await repo.checkout(frame.target);
3618
+ } catch (error) {
3619
+ ok = false;
3620
+ message = error instanceof Error ? error.message : String(error);
3621
+ }
3622
+ this.#broadcast({ type: "git.result", workspaceId: frame.workspaceId, ok, message });
3623
+ this.#broadcast({
3624
+ type: "git.state",
3625
+ workspaceId: frame.workspaceId,
3626
+ state: await repo.state()
3627
+ });
3628
+ return;
3629
+ }
3630
+ case "fs.list":
3631
+ this.#send(socket, {
3632
+ type: "fs.dir",
3633
+ workspaceId: frame.workspaceId,
3634
+ path: normalize(frame.path),
3635
+ entries: await this.#markIgnored(frame.workspaceId, await this.#files(frame.workspaceId).list(frame.path))
3636
+ });
3637
+ return;
3638
+ case "fs.search": {
3639
+ const { entries, truncated } = await this.#files(frame.workspaceId).search(frame.query);
3640
+ this.#send(socket, {
3641
+ type: "fs.search",
3642
+ workspaceId: frame.workspaceId,
3643
+ query: frame.query,
3644
+ scope: frame.scope ?? "tree",
3645
+ entries: await this.#markIgnored(frame.workspaceId, entries),
3646
+ truncated
3647
+ });
3648
+ return;
3649
+ }
3650
+ case "fs.grep": {
3651
+ const { matches: matches2, truncated } = await this.#files(frame.workspaceId).grep(frame.query);
3652
+ this.#send(socket, {
3653
+ type: "fs.grep",
3654
+ workspaceId: frame.workspaceId,
3655
+ query: frame.query,
3656
+ matches: matches2,
3657
+ truncated
3658
+ });
3659
+ return;
3660
+ }
3661
+ case "fs.read":
3662
+ this.#send(socket, {
3663
+ type: "fs.file",
3664
+ workspaceId: frame.workspaceId,
3665
+ file: await this.#files(frame.workspaceId).read(frame.path)
3666
+ });
3667
+ return;
3668
+ case "fs.write": {
3669
+ const files = this.#files(frame.workspaceId);
3670
+ let saved;
3671
+ try {
3672
+ saved = await files.write(frame.path, frame.content, frame.eol, frame.baseMtime, frame.force === true);
3673
+ } catch (error) {
3674
+ this.#fsFailed(frame.workspaceId, frame.path, error);
3675
+ return;
3676
+ }
3677
+ this.#broadcast({
3678
+ type: "fs.saved",
3679
+ workspaceId: frame.workspaceId,
3680
+ path: normalize(frame.path),
3681
+ mtime: saved.mtime,
3682
+ size: saved.size
3683
+ });
3684
+ await this.#sendDir(frame.workspaceId, parentOf(frame.path));
3685
+ return;
3686
+ }
3687
+ case "fs.create":
3688
+ case "fs.delete":
3689
+ case "fs.rename": {
3690
+ const files = this.#files(frame.workspaceId);
3691
+ const done = frame.type === "fs.create" ? `\u0421\u043E\u0437\u0434\u0430\u043D${frame.kind === "dir" ? " \u043A\u0430\u0442\u0430\u043B\u043E\u0433" : " \u0444\u0430\u0439\u043B"} ${normalize(frame.path)}` : frame.type === "fs.delete" ? `\u0423\u0434\u0430\u043B\u0435\u043D\u043E: ${normalize(frame.path)}` : `\u041F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u043D\u043E \u0432 ${normalize(frame.to)}`;
3692
+ try {
3693
+ if (frame.type === "fs.create")
3694
+ await files.create(frame.path, frame.kind);
3695
+ else if (frame.type === "fs.delete")
3696
+ await files.remove(frame.path);
3697
+ else
3698
+ await files.rename(frame.path, frame.to);
3699
+ } catch (error) {
3700
+ this.#fsFailed(frame.workspaceId, frame.path, error);
3701
+ return;
3702
+ }
3703
+ this.#broadcast({
3704
+ type: "fs.result",
3705
+ workspaceId: frame.workspaceId,
3706
+ ok: true,
3707
+ message: done,
3708
+ path: normalize(frame.type === "fs.rename" ? frame.to : frame.path)
3709
+ });
3710
+ await this.#sendDir(frame.workspaceId, parentOf(frame.path));
3711
+ if (frame.type === "fs.rename") {
3712
+ const to = parentOf(frame.to);
3713
+ if (to !== parentOf(frame.path))
3714
+ await this.#sendDir(frame.workspaceId, to);
3715
+ }
3716
+ return;
3717
+ }
3718
+ case "settings.get":
3719
+ this.#send(socket, { type: "settings.state", settings: this.#settings.current });
3720
+ return;
3721
+ case "settings.update": {
3722
+ const before = this.#settings.current;
3723
+ const settings = await this.#settings.update(frame.settings);
3724
+ this.#broadcast({ type: "settings.state", settings });
3725
+ if (settings.autoArchiveAfterMs !== before.autoArchiveAfterMs) {
3726
+ for (const workspace of this.#workspaces.list()) {
3727
+ await this.#sendSessionList(workspace.id);
3728
+ }
3729
+ }
3730
+ return;
3731
+ }
3732
+ case "auth.status":
3733
+ this.#send(socket, { type: "auth.status", status: await this.#auth.status() });
3734
+ this.#send(socket, { type: "auth.login", state: this.#auth.loginState });
3735
+ return;
3736
+ case "auth.login":
3737
+ await this.#auth.login(frame.mode, frame.switchAccount === true);
3738
+ return;
3739
+ case "auth.code":
3740
+ this.#auth.submitCode(frame.code);
3741
+ return;
3742
+ case "auth.cancel":
3743
+ this.#auth.cancel();
3744
+ return;
3745
+ case "auth.logout":
3746
+ await this.#auth.logout();
3747
+ return;
3748
+ case "relay.status":
3749
+ this.#send(socket, { type: "relay.state", state: this.#relay.state });
3750
+ return;
3751
+ case "relay.pair":
3752
+ this.#requireLocal(socket, "\u0421\u0432\u044F\u0437\u0430\u0442\u044C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440");
3753
+ await this.#relay.pair(frame.code, frame.name);
3754
+ return;
3755
+ case "relay.unpair":
3756
+ this.#requireLocal(socket, "\u041E\u0442\u0432\u044F\u0437\u0430\u0442\u044C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440");
3757
+ await this.#relay.unpair();
3758
+ return;
3759
+ }
3760
+ }
3761
+ /**
3762
+ * Настройки удалённого доступа меняются только с самой машины.
3763
+ *
3764
+ * Иначе достаточно один раз попасть внутрь через relay, чтобы перепривязать
3765
+ * хост к чужому аккаунту и остаться в нём навсегда. Смотреть на состояние
3766
+ * (`relay.status`) при этом можно откуда угодно — это не право, а справка.
3767
+ */
3768
+ #requireLocal(client, action) {
3769
+ if (client.local)
3770
+ return;
3771
+ throw new Error(`${action} \u043C\u043E\u0436\u043D\u043E \u0442\u043E\u043B\u044C\u043A\u043E \u043D\u0430 \u043D\u0451\u043C \u0441\u0430\u043C\u043E\u043C, \u043D\u0435 \u0443\u0434\u0430\u043B\u0451\u043D\u043D\u043E`);
3772
+ }
3773
+ async #sendAuthStatus() {
3774
+ this.#broadcast({ type: "auth.status", status: await this.#auth.status() });
3775
+ }
3776
+ // -------------------------------------------------------------------------
3777
+ // MCP и плагины
3778
+ // -------------------------------------------------------------------------
3779
+ /**
3780
+ * Список серверов для панели: живые статусы из SDK, дополненные конфигами
3781
+ * из `.mcp.json`.
3782
+ *
3783
+ * Одного источника мало. SDK знает только то, что уже поднято, и не скажет
3784
+ * про сервер, который записан в проект, но в этой сессии не стартовал.
3785
+ * Файл, наоборот, не знает ни статуса, ни ошибки подключения.
3786
+ */
3787
+ async #sendMcpList(sessionId) {
3788
+ const session = this.#requireSession(sessionId);
3789
+ const statuses = await session.mcpServerStatus();
3790
+ const fromFile = await readProjectServers(session.cwd);
3791
+ const servers = /* @__PURE__ */ new Map();
3792
+ for (const status of statuses) {
3793
+ const shape = describeConfig(status.config);
3794
+ servers.set(status.name, {
3795
+ name: status.name,
3796
+ status: status.status,
3797
+ transport: shape.transport,
3798
+ target: shape.target,
3799
+ scope: status.scope ?? "",
3800
+ error: status.error ?? null,
3801
+ tools: (status.tools ?? []).map((tool) => tool.name),
3802
+ editable: status.name in fromFile
3803
+ });
3804
+ }
3805
+ for (const [name, config] of Object.entries(fromFile)) {
3806
+ if (servers.has(name))
3807
+ continue;
3808
+ const shape = describeConfig(config);
3809
+ servers.set(name, {
3810
+ name,
3811
+ // Записан в проект, но SDK о нём не отчитался — значит, поднимется
3812
+ // только со следующей сессии.
3813
+ status: "pending",
3814
+ transport: shape.transport,
3815
+ target: shape.target,
3816
+ scope: "project",
3817
+ error: null,
3818
+ tools: [],
3819
+ editable: true
3820
+ });
3821
+ }
3822
+ this.#broadcast({
3823
+ type: "mcp.list",
3824
+ sessionId,
3825
+ servers: [...servers.values()].sort((a, b) => a.name.localeCompare(b.name))
3826
+ });
3827
+ }
3828
+ async #sendPluginList(sessionId) {
3829
+ const { plugins, marketplaces, catalog } = await this.#plugins.list();
3830
+ this.#broadcast({ type: "plugin.list", sessionId, plugins, marketplaces, catalog });
3831
+ }
3832
+ /**
3833
+ * Общий хвост для всех команд `claude plugin`.
3834
+ *
3835
+ * После любой из них сессии нужно перечитать плагины с диска, иначе меню «/»
3836
+ * и список сабагентов останутся со старым составом до перезапуска. Ошибку
3837
+ * CLI показываем как результат операции, а не роняем кадр: пользователь
3838
+ * должен увидеть текст «маркетплейс не найден», а не молчание.
3839
+ */
3840
+ async #pluginOperation(sessionId, run2) {
3841
+ let ok = true;
3842
+ let message;
3843
+ try {
3844
+ message = await run2() || "\u0413\u043E\u0442\u043E\u0432\u043E";
3845
+ } catch (error) {
3846
+ ok = false;
3847
+ message = error instanceof Error ? error.message : String(error);
3848
+ }
3849
+ if (ok) {
3850
+ await this.#sessions.get(sessionId)?.reloadPlugins().catch(() => void 0);
3851
+ }
3852
+ this.#reportOperation(sessionId, "plugin", ok, message);
3853
+ await this.#sendPluginList(sessionId);
3854
+ }
3855
+ #reportOperation(sessionId, scope, ok, message) {
3856
+ this.#broadcast({ type: "operation.result", sessionId, result: { scope, ok, message } });
3857
+ }
3858
+ // -------------------------------------------------------------------------
3859
+ // Сессии
3860
+ // -------------------------------------------------------------------------
3861
+ #requireSession(sessionId) {
3862
+ const session = this.#sessions.get(sessionId);
3863
+ if (!session)
3864
+ throw new Error(`\u0421\u0435\u0441\u0441\u0438\u044F \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D\u0430: ${sessionId}`);
3865
+ return session;
3866
+ }
3867
+ /**
3868
+ * Остановить живую сессию и сказать об этом всем окнам.
3869
+ *
3870
+ * Одно место на все причины остановки (закрыли сессию, закрыли проект, убрали
3871
+ * в архив): забыть про боковой вопрос или про рассылку в каждой из них по
3872
+ * отдельности — вопрос времени, а цена — висящий процесс.
3873
+ */
3874
+ async #dropSession(sessionId, reason) {
3875
+ const session = this.#sessions.get(sessionId);
3876
+ if (!session)
3877
+ return false;
3878
+ await this.#closeSide(sessionId);
3879
+ await session.close(reason);
3880
+ this.#sessions.delete(sessionId);
3881
+ this.#broadcast({ type: "session.closed", sessionId, reason });
3882
+ return true;
3883
+ }
3884
+ /**
3885
+ * Разослать список сессий проекта всем окнам.
3886
+ *
3887
+ * Broadcast, а не ответ спросившему: архив и список меняются от действий,
3888
+ * которые видны всем, — во втором окне открыт тот же проект.
3889
+ */
3890
+ async #sendSessionList(workspaceId) {
3891
+ const { sessions, archived } = await this.#listSessions(workspaceId);
3892
+ this.#broadcast({ type: "session.list", workspaceId, sessions, archived });
3893
+ }
3894
+ async #startSession(socket, frame) {
3895
+ const workspace = this.#workspaces.find(frame.workspaceId);
3896
+ if (!workspace)
3897
+ throw new Error(`\u0412\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${frame.workspaceId}`);
3898
+ const resumed = frame.resume && !frame.fork ? (await this.#workspaces.sessionsOnDisk(workspace.id)).find((info) => info.sessionId === frame.resume) : void 0;
3899
+ const resumedTitle = resumed ? diskTitle(resumed) : null;
3900
+ if (frame.resume)
3901
+ await this.#archive.unarchive([frame.resume]);
3902
+ const session = new ClaudeSession({
3903
+ workspaceId: workspace.id,
3904
+ cwd: workspace.path,
3905
+ resume: frame.resume,
3906
+ fork: frame.fork,
3907
+ permissionMode: frame.permissionMode,
3908
+ model: frame.model,
3909
+ ...resumedTitle ? { title: resumedTitle } : {},
3910
+ // Имя проекта вместо «Без названия»: свежую сессию в списке уже надо
3911
+ // как-то называть, а сказать о ней пока нечего, кроме того, где она.
3912
+ defaultTitle: workspace.name,
3913
+ writableRoots: () => this.#writableRoots(workspace.path)
3914
+ });
3915
+ this.#wire(session);
3916
+ this.#sessions.set(session.id, session);
3917
+ session.start();
3918
+ this.#send(socket, { type: "session.summary", session: session.summary() });
3919
+ }
3920
+ /**
3921
+ * Подписка на события сессии.
3922
+ *
3923
+ * Все клиенты видят одну и ту же сессию — это нужно уже сейчас (два окна
3924
+ * на одном ПК) и тем более потом, когда добавится телефон.
3925
+ */
3926
+ #wire(session) {
3927
+ session.on("event", (entry) => {
3928
+ this.#broadcast({ type: "session.events", sessionId: session.id, entries: [entry] });
3929
+ });
3930
+ session.on("summary", (summary) => {
3931
+ this.#broadcast({ type: "session.summary", session: summary });
3932
+ });
3933
+ session.on("capabilities", () => {
3934
+ if (!session.capabilities)
3935
+ return;
3936
+ this.#broadcast({
3937
+ type: "session.capabilities",
3938
+ sessionId: session.id,
3939
+ capabilities: session.capabilities
3940
+ });
3941
+ });
3942
+ session.on("usage", (usage) => {
3943
+ this.#broadcast({ type: "usage.state", sessionId: session.id, usage });
3944
+ });
3945
+ session.on("stderr", (data) => {
3946
+ process.stderr.write(`[claude ${session.id.slice(0, 8)}] ${data}`);
3947
+ });
3948
+ }
3949
+ // -------------------------------------------------------------------------
3950
+ // Боковой вопрос
3951
+ // -------------------------------------------------------------------------
3952
+ /**
3953
+ * Боковая сессия родителя, при надобности поднятая.
3954
+ *
3955
+ * Форк, а не новая сессия: смысл вопроса на полях в том, что спрашивают про
3956
+ * происходящее («зачем ты правишь этот файл?»), и без разговора ответить на
3957
+ * это нечем. Форк видит транскрипт родителя, но пишет в свой — основная
3958
+ * сессия о вопросе не узнает, и контекст ей вопрос не засоряет.
3959
+ *
3960
+ * Пока родитель не сделал ни одного обмена, транскрипта на диске ещё нет —
3961
+ * тогда поднимаем обычную сессию в том же каталоге: проект она видит, а
3962
+ * разговора пока и не было.
3963
+ */
3964
+ #ensureSide(parent) {
3965
+ const existing = this.#sides.get(parent.id);
3966
+ if (existing && existing.state !== "closed" && existing.state !== "error") {
3967
+ return existing;
3968
+ }
3969
+ const claudeId = parent.claudeSessionId;
3970
+ const side = new ClaudeSession({
3971
+ workspaceId: parent.workspaceId,
3972
+ cwd: parent.cwd,
3973
+ parentSessionId: parent.id,
3974
+ readOnly: true,
3975
+ ...claudeId ? { resume: claudeId, fork: true } : {}
3976
+ });
3977
+ this.#wire(side);
3978
+ this.#sessions.set(side.id, side);
3979
+ this.#sides.set(parent.id, side);
3980
+ side.start();
3981
+ side.setTitle(SIDE_TITLE);
3982
+ this.#broadcast({
3983
+ type: "side.opened",
3984
+ parentSessionId: parent.id,
3985
+ sessionId: side.id
3986
+ });
3987
+ return side;
3988
+ }
3989
+ async #closeSide(parentSessionId) {
3990
+ const side = this.#sides.get(parentSessionId);
3991
+ if (!side)
3992
+ return;
3993
+ this.#sides.delete(parentSessionId);
3994
+ this.#sessions.delete(side.id);
3995
+ await side.close("\u0431\u043E\u043A\u043E\u0432\u043E\u0439 \u0432\u043E\u043F\u0440\u043E\u0441 \u0437\u0430\u043A\u0440\u044B\u0442");
3996
+ this.#broadcast({ type: "side.closed", parentSessionId });
3997
+ }
3998
+ // -------------------------------------------------------------------------
3999
+ // Поиск проекта агентом
4000
+ // -------------------------------------------------------------------------
4001
+ /**
4002
+ * Спросить агента, где лежит проект.
4003
+ *
4004
+ * Сессия поднимается только на чтение и переиспользуется между запросами:
4005
+ * второй вопрос в том же диалоге — это почти всегда уточнение первого
4006
+ * («нет, не тот»), и разговор ему помогает. Закрывается она в
4007
+ * `workspace.open` — момент, когда проект нашли и диалог больше не нужен.
4008
+ */
4009
+ /**
4010
+ * Куда предлагать положить новый проект.
4011
+ *
4012
+ * То же, что знает агентный поиск, — каталоги, в которых проекты уже лежат.
4013
+ * Домашний каталог дописывается запасным вариантом, а не подмешивается всегда:
4014
+ * на обжитой машине он в этом списке лишний, а на свежей (проектов ещё нет, и
4015
+ * `roots()` пуст) — единственное осмысленное место, которое хост может
4016
+ * назвать, не спрашивая ОС о папке «Документы».
4017
+ */
4018
+ #roots() {
4019
+ const roots = this.#finder.roots();
4020
+ return roots.length > 0 ? roots : [homedir5()];
4021
+ }
4022
+ /**
4023
+ * Где сессии этого каталога разрешено писать без вопроса.
4024
+ *
4025
+ * Считается на каждый вызов хука, а не запоминается при старте сессии: и
4026
+ * настройку, и список открытых проектов меняют в работающем окне, а «дал
4027
+ * доступ ко всем папкам» обязано действовать сразу, а не со следующей сессии.
4028
+ */
4029
+ #writableRoots(cwd) {
4030
+ return writableRoots(this.#settings.current.folderAccess, cwd, this.#workspaces.list().map((workspace) => workspace.path));
4031
+ }
4032
+ #findWithAgent(query2) {
4033
+ const roots = this.#finder.roots();
4034
+ if (roots.length === 0) {
4035
+ this.#broadcast({
4036
+ type: "workspace.candidates",
4037
+ query: query2,
4038
+ source: "agent",
4039
+ candidates: [],
4040
+ truncated: false
4041
+ });
4042
+ return;
4043
+ }
4044
+ this.#finderQuery = query2;
4045
+ const session = this.#ensureFinder(roots);
4046
+ this.#broadcast({ type: "finder.opened", sessionId: session.id });
4047
+ session.sendPrompt(finderPrompt(query2, roots));
4048
+ }
4049
+ #ensureFinder(roots) {
4050
+ const existing = this.#finderSession;
4051
+ if (existing && existing.state !== "closed" && existing.state !== "error")
4052
+ return existing;
4053
+ const finder = new ClaudeSession({
4054
+ // Воркспейса у поиска нет: он ищет как раз то, что воркспейсом ещё не
4055
+ // стало. Пустой id никакому проекту не принадлежит — и в список сессий
4056
+ // ни одного из них такая сессия не попадёт.
4057
+ workspaceId: "",
4058
+ // `cwd` у сессии один, а корней несколько. Берём самый населённый, а
4059
+ // остальные перечисляем в промпте: `Glob` и `Grep` принимают абсолютный
4060
+ // путь, так что одним каталогом поиск не заперт.
4061
+ cwd: roots[0],
4062
+ readOnly: true,
4063
+ outputFormat: { type: "json_schema", schema: FINDER_SCHEMA }
4064
+ });
4065
+ this.#wire(finder);
4066
+ finder.on("structured", (payload) => {
4067
+ this.#broadcast({
4068
+ type: "workspace.candidates",
4069
+ query: this.#finderQuery,
4070
+ source: "agent",
4071
+ candidates: this.#finder.validate(readNamedPaths(payload.data, payload.text)),
4072
+ truncated: false
4073
+ });
4074
+ });
4075
+ this.#sessions.set(finder.id, finder);
4076
+ this.#finderSession = finder;
4077
+ finder.start();
4078
+ finder.setTitle(FINDER_TITLE);
4079
+ return finder;
4080
+ }
4081
+ async #closeFinder() {
4082
+ const finder = this.#finderSession;
4083
+ if (!finder)
4084
+ return;
4085
+ this.#finderSession = null;
4086
+ this.#sessions.delete(finder.id);
4087
+ await finder.close("\u043F\u0440\u043E\u0435\u043A\u0442 \u043D\u0430\u0439\u0434\u0435\u043D");
4088
+ }
4089
+ // -------------------------------------------------------------------------
4090
+ // Git
4091
+ // -------------------------------------------------------------------------
4092
+ #git(workspaceId) {
4093
+ const workspace = this.#workspaces.find(workspaceId);
4094
+ if (!workspace)
4095
+ throw new Error(`\u0412\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${workspaceId}`);
4096
+ return new GitRepo(workspace.path);
4097
+ }
4098
+ // -------------------------------------------------------------------------
4099
+ // Файлы проекта
4100
+ // -------------------------------------------------------------------------
4101
+ #files(workspaceId) {
4102
+ const workspace = this.#workspaces.find(workspaceId);
4103
+ if (!workspace)
4104
+ throw new Error(`\u0412\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${workspaceId}`);
4105
+ return new ProjectFiles(workspace.path);
4106
+ }
4107
+ /**
4108
+ * Разослать содержимое каталога после правки в нём.
4109
+ *
4110
+ * Broadcast, а не ответ спросившему: каталог проекта общий, и второе окно
4111
+ * должно увидеть созданный файл само. Ошибку чтения гасим — каталог мог
4112
+ * только что исчезнуть вместе с тем, что в нём удалили.
4113
+ */
4114
+ async #sendDir(workspaceId, dir) {
4115
+ const entries = await this.#files(workspaceId).list(dir).catch(() => null);
4116
+ if (!entries)
4117
+ return;
4118
+ this.#broadcast({
4119
+ type: "fs.dir",
4120
+ workspaceId,
4121
+ path: normalize(dir),
4122
+ entries: await this.#markIgnored(workspaceId, entries)
4123
+ });
4124
+ }
4125
+ /**
4126
+ * Проставить записям отметку «под .gitignore».
4127
+ *
4128
+ * Поверх готового списка, а не внутри `ProjectFiles`: файловая система про
4129
+ * git не знает ничего, а один `git check-ignore` на весь каталог дешевле,
4130
+ * чем вызов на запись. Каталог раскрывают по одному, поэтому и вызов уходит
4131
+ * по одному — на весь обход дерева его не бывает.
4132
+ */
4133
+ async #markIgnored(workspaceId, entries) {
4134
+ if (entries.length === 0)
4135
+ return entries;
4136
+ const ignored = await this.#git(workspaceId).checkIgnored(entries.map((e) => e.path));
4137
+ if (ignored.size === 0)
4138
+ return entries;
4139
+ return entries.map((entry) => ignored.has(entry.path) ? { ...entry, ignored: true } : entry);
4140
+ }
4141
+ #fsFailed(workspaceId, filePath, error) {
4142
+ const conflict = error instanceof Error && "conflict" in error;
4143
+ this.#broadcast({
4144
+ type: "fs.result",
4145
+ workspaceId,
4146
+ ok: false,
4147
+ message: error instanceof Error ? error.message : String(error),
4148
+ path: normalize(filePath),
4149
+ ...conflict ? { conflict: true } : {}
4150
+ });
4151
+ }
4152
+ /**
4153
+ * Переименование живой сессии и сессии с диска — разные пути.
4154
+ *
4155
+ * Живая сама разошлёт новое имя и запишет его в JSONL. Для дисковой поднимать
4156
+ * процесс ради имени незачем: пишем в JSONL напрямую и пересобираем список.
4157
+ */
4158
+ async #renameSession(sessionId, rawTitle, workspaceId) {
4159
+ const title = rawTitle.trim().slice(0, 200);
4160
+ if (!title)
4161
+ throw new Error("\u041D\u0430\u0437\u0432\u0430\u043D\u0438\u0435 \u0441\u0435\u0441\u0441\u0438\u0438 \u043D\u0435 \u043C\u043E\u0436\u0435\u0442 \u0431\u044B\u0442\u044C \u043F\u0443\u0441\u0442\u044B\u043C");
4162
+ const live = this.#sessions.get(sessionId);
4163
+ if (live) {
4164
+ live.setTitle(title);
4165
+ return;
4166
+ }
4167
+ const dir = workspaceId ? this.#workspaces.find(workspaceId)?.path : void 0;
4168
+ await renameSession2(sessionId, title, dir ? { dir } : void 0);
4169
+ if (!workspaceId)
4170
+ return;
4171
+ await this.#sendSessionList(workspaceId);
4172
+ }
4173
+ /**
4174
+ * Сессии проекта — двумя списками: обычные и убранные в архив.
4175
+ *
4176
+ * Здесь же срабатывает авто-архивация: список пересобирается на каждое
4177
+ * открытие проекта и на каждое изменение, и другого регулярного момента,
4178
+ * когда о сроке молчания вообще есть кому вспомнить, у хоста нет — таймер
4179
+ * ради этого был бы вторым источником тех же событий.
4180
+ */
4181
+ async #listSessions(workspaceId) {
4182
+ const live = [...this.#sessions.values()].filter((s) => s.workspaceId === workspaceId && !s.parentSessionId).map((s) => s.summary());
4183
+ const liveIds = new Set(live.flatMap((s) => s.claudeSessionId ? [s.id, s.claudeSessionId] : [s.id]));
4184
+ const onDisk = await this.#workspaces.sessionsOnDisk(workspaceId);
4185
+ const workspacePath = this.#workspaces.find(workspaceId)?.path ?? "";
4186
+ const historical = onDisk.filter((info) => !liveIds.has(info.sessionId) && info.customTitle !== SIDE_TITLE && info.customTitle !== FINDER_TITLE).map((info) => ({
4187
+ id: info.sessionId,
4188
+ claudeSessionId: info.sessionId,
4189
+ workspaceId,
4190
+ cwd: info.cwd ?? workspacePath,
4191
+ parentSessionId: null,
4192
+ title: diskTitle(info),
4193
+ state: "closed",
4194
+ model: null,
4195
+ permissionMode: "default",
4196
+ // Сессия с диска не поднята: чем она была настроена, знает только её
4197
+ // JSONL, а разбирать его ради выключенной строки в списке незачем.
4198
+ effort: null,
4199
+ ultracode: false,
4200
+ thinking: true,
4201
+ switchModelsOnFlag: true,
4202
+ createdAt: info.lastModified,
4203
+ updatedAt: info.lastModified,
4204
+ lastSeq: 0,
4205
+ totalCostUsd: 0
4206
+ }));
4207
+ await this.#archive.sweep(workspaceId, historical.map((s) => ({ id: s.id, updatedAt: s.updatedAt })), /* @__PURE__ */ new Set([...liveIds, ...historical.map((s) => s.id)]), this.#settings.current.autoArchiveAfterMs);
4208
+ const sessions = [];
4209
+ const archived = [];
4210
+ for (const summary of [...live, ...historical].sort((a, b) => b.updatedAt - a.updatedAt)) {
4211
+ const record = this.#archive.find(summary.claudeSessionId ?? summary.id);
4212
+ if (!record) {
4213
+ sessions.push(summary);
4214
+ continue;
4215
+ }
4216
+ archived.push({ ...summary, archivedAt: record.at, archivedAuto: record.auto });
4217
+ }
4218
+ archived.sort((a, b) => (b.archivedAt ?? 0) - (a.archivedAt ?? 0));
4219
+ return { sessions, archived };
4220
+ }
4221
+ // -------------------------------------------------------------------------
4222
+ // Статика (собранный веб-UI)
4223
+ // -------------------------------------------------------------------------
4224
+ /**
4225
+ * Один ответ, который не про статику: «здесь живёт ccv, версия такая-то».
4226
+ *
4227
+ * Нужен запуску из консоли. Занятый порт сам по себе не говорит ничего —
4228
+ * слушать 8787 может что угодно, и открывать в браузере чужой сервер, приняв
4229
+ * его за себя, хуже, чем честно сказать, что порт занят.
4230
+ */
4231
+ #serveHttp(req, res) {
4232
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4233
+ if (pathname === "/api/health") {
4234
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
4235
+ res.end(JSON.stringify({ app: "claude-code-visual", version: this.#options.version }));
4236
+ return;
4237
+ }
4238
+ this.#serveStatic(req, res);
4239
+ }
4240
+ #serveStatic(req, res) {
4241
+ const webRoot = this.#options.webRoot;
4242
+ if (!webRoot || !existsSync3(webRoot)) {
4243
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
4244
+ res.end("CCV host is running. UI is served by the Vite dev server.\n");
4245
+ return;
4246
+ }
4247
+ const url2 = new URL(req.url ?? "/", "http://localhost");
4248
+ const requested = path13.join(webRoot, path13.normalize(url2.pathname));
4249
+ const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path13.join(webRoot, "index.html");
4250
+ if (!existsSync3(target)) {
4251
+ res.writeHead(404).end("not found");
4252
+ return;
4253
+ }
4254
+ res.writeHead(200, { "content-type": contentType(target) });
4255
+ createReadStream(target).pipe(res);
4256
+ }
4257
+ };
4258
+ function diskTitle(info) {
4259
+ return info.customTitle ?? info.summary ?? info.firstPrompt ?? null;
4260
+ }
4261
+ function parentOf(relative) {
4262
+ const clean = normalize(relative);
4263
+ const cut = clean.lastIndexOf("/");
4264
+ return cut === -1 ? "" : clean.slice(0, cut);
4265
+ }
4266
+ function contentType(file) {
4267
+ const types = {
4268
+ ".html": "text/html; charset=utf-8",
4269
+ ".js": "text/javascript; charset=utf-8",
4270
+ ".css": "text/css; charset=utf-8",
4271
+ ".json": "application/json; charset=utf-8",
4272
+ ".svg": "image/svg+xml",
4273
+ ".woff2": "font/woff2"
4274
+ };
4275
+ return types[path13.extname(file)] ?? "application/octet-stream";
4276
+ }
4277
+
4278
+ // dist/ccv.js
4279
+ var HERE = path14.dirname(fileURLToPath(import.meta.url));
4280
+ var PACKAGE_ROOT = path14.resolve(HERE, "..");
4281
+ var USAGE = `ccv \u2014 Claude Code Visual: \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0445\u043E\u0441\u0442 Claude Code \u0441 \u0438\u043D\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u043E\u043C \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435.
4282
+
4283
+ \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435:
4284
+ ccv [\u043E\u043F\u0446\u0438\u0438]
4285
+
4286
+ \u041E\u043F\u0446\u0438\u0438:
4287
+ -p, --port <\u043F\u043E\u0440\u0442> \u043F\u043E\u0440\u0442 \u0445\u043E\u0441\u0442\u0430 (\u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E 8787)
4288
+ --host <\u0430\u0434\u0440\u0435\u0441> \u0430\u0434\u0440\u0435\u0441 \u043F\u0440\u043E\u0441\u043B\u0443\u0448\u0438\u0432\u0430\u043D\u0438\u044F (\u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E 127.0.0.1)
4289
+ --no-open \u043D\u0435 \u043E\u0442\u043A\u0440\u044B\u0432\u0430\u0442\u044C \u0431\u0440\u0430\u0443\u0437\u0435\u0440
4290
+ --claude \u0447\u0435\u043C \u0431\u0443\u0434\u0443\u0442 \u043F\u043E\u0434\u043D\u0438\u043C\u0430\u0442\u044C\u0441\u044F \u0441\u0435\u0441\u0441\u0438\u0438, \u0438 \u0432\u044B\u0439\u0442\u0438
4291
+ -v, --version \u0432\u0435\u0440\u0441\u0438\u044F
4292
+ -h, --help \u044D\u0442\u0430 \u0441\u043F\u0440\u0430\u0432\u043A\u0430
4293
+
4294
+ \u041E\u043A\u0440\u0443\u0436\u0435\u043D\u0438\u0435: CCV_PORT, CCV_HOST, CCV_DATA_DIR, CCV_WEB_ROOT, CCV_RELAY_URL,
4295
+ CCV_CLAUDE_PATH, CCV_BUNDLED_CLAUDE.
4296
+ \u0424\u043B\u0430\u0433 \u0432\u0430\u0436\u043D\u0435\u0435 \u043F\u0435\u0440\u0435\u043C\u0435\u043D\u043D\u043E\u0439 \u043E\u043A\u0440\u0443\u0436\u0435\u043D\u0438\u044F, \u043F\u0435\u0440\u0435\u043C\u0435\u043D\u043D\u0430\u044F \u0432\u0430\u0436\u043D\u0435\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u044F \u043F\u043E \u0443\u043C\u043E\u043B\u0447\u0430\u043D\u0438\u044E.`;
4297
+ var options = {
4298
+ port: { type: "string", short: "p" },
4299
+ host: { type: "string" },
4300
+ open: { type: "boolean", default: true },
4301
+ claude: { type: "boolean" },
4302
+ version: { type: "boolean", short: "v" },
4303
+ help: { type: "boolean", short: "h" }
4304
+ };
4305
+ var values;
4306
+ try {
4307
+ ({ values } = parseArgs({ options, allowPositionals: false, allowNegative: true }));
4308
+ } catch (error) {
4309
+ console.error(`[ccv] ${error instanceof Error ? error.message : String(error)}
4310
+ `);
4311
+ console.error(USAGE);
4312
+ process.exit(2);
4313
+ }
4314
+ var version = await packageVersion();
4315
+ if (values.help) {
4316
+ console.log(USAGE);
4317
+ process.exit(0);
4318
+ }
4319
+ if (values.version) {
4320
+ console.log(version);
4321
+ process.exit(0);
4322
+ }
4323
+ if (values.claude)
4324
+ process.exit(await reportClaude());
4325
+ var defaults = envDefaults();
4326
+ var port = values.port === void 0 ? defaults.port : Number(values.port);
4327
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
4328
+ console.error(`[ccv] \u043F\u043E\u0440\u0442 \u0434\u043E\u043B\u0436\u0435\u043D \u0431\u044B\u0442\u044C \u0447\u0438\u0441\u043B\u043E\u043C \u043E\u0442 1 \u0434\u043E 65535, \u0430 \u043D\u0435 \xAB${values.port}\xBB`);
4329
+ process.exit(2);
4330
+ }
4331
+ var host = values.host ?? defaults.host;
4332
+ var reachable2 = host === "0.0.0.0" || host === "::" || host === "" ? "127.0.0.1" : host;
4333
+ var url = `http://${authority(reachable2)}:${port}`;
4334
+ var running = await health(url);
4335
+ if (running) {
4336
+ console.log(`[ccv] \u0445\u043E\u0441\u0442 \u0443\u0436\u0435 \u0437\u0430\u043F\u0443\u0449\u0435\u043D: ${url} (\u0432\u0435\u0440\u0441\u0438\u044F ${running})`);
4337
+ if (values.open)
4338
+ openBrowser(url);
4339
+ process.exit(0);
4340
+ }
4341
+ var server = new HostServer({
4342
+ port,
4343
+ host,
4344
+ dataDir: defaults.dataDir,
4345
+ // UI лежит в пакете рядом с бандлом — это и есть разница между установленной
4346
+ // утилитой и запуском из репозитория, где сборка живёт в `apps/web/dist`.
4347
+ webRoot: process.env.CCV_WEB_ROOT ?? path14.join(PACKAGE_ROOT, "web"),
4348
+ version,
4349
+ relayUrl: defaults.relayUrl
4350
+ });
4351
+ try {
4352
+ await server.start();
4353
+ } catch (error) {
4354
+ const code = error.code;
4355
+ if (code === "EADDRINUSE") {
4356
+ console.error(`[ccv] \u043F\u043E\u0440\u0442 ${port} \u0437\u0430\u043D\u044F\u0442 \u0434\u0440\u0443\u0433\u043E\u0439 \u043F\u0440\u043E\u0433\u0440\u0430\u043C\u043C\u043E\u0439 \u2014 \u0432\u044B\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0440\u0443\u0433\u043E\u0439: ccv --port 8788`);
4357
+ } else if (code === "EACCES") {
4358
+ console.error(`[ccv] \u043D\u0435\u0442 \u043F\u0440\u0430\u0432 \u0441\u043B\u0443\u0448\u0430\u0442\u044C ${host}:${port}`);
4359
+ } else {
4360
+ console.error(`[ccv] \u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0437\u0430\u043F\u0443\u0441\u0442\u0438\u0442\u044C \u0445\u043E\u0441\u0442: ${error instanceof Error ? error.message : String(error)}`);
4361
+ }
4362
+ process.exit(1);
4363
+ }
4364
+ console.log(`[ccv] \u0445\u043E\u0441\u0442 \u0441\u043B\u0443\u0448\u0430\u0435\u0442 ${url} (ws: /ws)`);
4365
+ console.log(`[ccv] \u0434\u0430\u043D\u043D\u044B\u0435: ${defaults.dataDir}`);
4366
+ console.log("[ccv] \u043E\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u044C \u2014 Ctrl+C");
4367
+ if (values.open)
4368
+ openBrowser(url);
4369
+ for (const signal of ["SIGINT", "SIGTERM"]) {
4370
+ process.on(signal, () => {
4371
+ void server.shutdown().finally(() => process.exit(0));
4372
+ });
4373
+ }
4374
+ async function packageVersion() {
4375
+ try {
4376
+ const text = await readFile10(path14.join(PACKAGE_ROOT, "package.json"), "utf8");
4377
+ return JSON.parse(text).version ?? "0.0.0";
4378
+ } catch {
4379
+ return "0.0.0";
4380
+ }
4381
+ }
4382
+ async function reportClaude() {
4383
+ await warmClaudeLauncher();
4384
+ const found = claudeExecutable();
4385
+ if (found.source === "forced") {
4386
+ console.log("[ccv] CCV_BUNDLED_CLAUDE \u0437\u0430\u0434\u0430\u043D \u2014 \u0441\u0435\u0441\u0441\u0438\u0438 \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442 \u0431\u0438\u043D\u0430\u0440\u043D\u0438\u043A \u0438\u0437 Agent SDK");
4387
+ return 0;
4388
+ }
4389
+ if (found.source === "bundled") {
4390
+ console.log("[ccv] Claude Code \u0432 \u0441\u0438\u0441\u0442\u0435\u043C\u0435 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D \u2014 \u0441\u0435\u0441\u0441\u0438\u0438 \u043F\u043E\u0434\u043D\u0438\u043C\u0435\u0442 \u0431\u0438\u043D\u0430\u0440\u043D\u0438\u043A \u0438\u0437 Agent SDK");
4391
+ console.log("[ccv] \u0441\u0430\u043C Claude Code: https://claude.com/claude-code");
4392
+ return 1;
4393
+ }
4394
+ const via = found.executable ? `${found.executable} ${found.path}` : found.path;
4395
+ const how = found.source === "env" ? "\u0443\u043A\u0430\u0437\u0430\u043D \u0432 CCV_CLAUDE_PATH" : "\u043D\u0430\u0439\u0434\u0435\u043D \u0432 \u0441\u0438\u0441\u0442\u0435\u043C\u0435";
4396
+ console.log(`[ccv] \u0441\u0435\u0441\u0441\u0438\u0438 \u043F\u043E\u0434\u043D\u0438\u043C\u0430\u0435\u0442 Claude Code, ${how}: ${via}`);
4397
+ return 0;
4398
+ }
4399
+ function authority(address) {
4400
+ const bare = address.replace(/^\[|\]$/g, "");
4401
+ return bare.includes(":") ? `[${bare}]` : bare;
4402
+ }
4403
+ async function health(base) {
4404
+ try {
4405
+ const response = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(1500) });
4406
+ if (!response.ok)
4407
+ return null;
4408
+ const body = await response.json();
4409
+ return body.app === "claude-code-visual" ? body.version ?? "\u043D\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043D\u0430" : null;
4410
+ } catch {
4411
+ return null;
4412
+ }
4413
+ }
4414
+ function openBrowser(target) {
4415
+ if (!/^http:\/\/[A-Za-z0-9.\-[\]]+:\d+$/.test(target)) {
4416
+ console.log(`[ccv] \u043E\u0442\u043A\u0440\u043E\u0439\u0442\u0435 \u0432 \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435: ${target}`);
4417
+ return;
4418
+ }
4419
+ const command = process.platform === "win32" ? { file: "cmd", args: ["/c", "start", "", target] } : process.platform === "darwin" ? { file: "open", args: [target] } : { file: "xdg-open", args: [target] };
4420
+ const child = spawn2(command.file, command.args, {
4421
+ stdio: "ignore",
4422
+ detached: true,
4423
+ windowsHide: true
4424
+ });
4425
+ child.on("error", () => {
4426
+ });
4427
+ child.unref();
4428
+ }