@i14ks/ccv 0.2.0 → 0.4.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.
Files changed (51) hide show
  1. package/THIRD-PARTY-NOTICES.md +91 -5
  2. package/bundle/ccv.mjs +1008 -60
  3. package/package.json +2 -1
  4. package/web/apple-touch-icon.png +0 -0
  5. package/web/assets/_commonjsHelpers-CqkleIqs.js +1 -0
  6. package/web/assets/addon-fit-CevVMrUA.js +1 -0
  7. package/web/assets/addon-web-links-CnB65OV_.js +1 -0
  8. package/web/assets/codemirror-nMcHCEIz.js +1 -0
  9. package/web/assets/index-0OKbY2Rb.js +7 -0
  10. package/web/assets/index-3-HwFAxb.js +3 -0
  11. package/web/assets/index-891wE-Rj.js +7 -0
  12. package/web/assets/index-B3rRxMhp.js +1 -0
  13. package/web/assets/index-BGGB4Isr.js +1 -0
  14. package/web/assets/index-BHot-lnj.js +5 -0
  15. package/web/assets/index-BJ3X5lFg.js +1 -0
  16. package/web/assets/index-BQl70IYm.js +1 -0
  17. package/web/assets/{index-C9vrRfhW.js → index-Bh6TpXPz.js} +3 -3
  18. package/web/assets/index-CHho5YQ1.js +1 -0
  19. package/web/assets/index-CJsOv-xc.js +128 -0
  20. package/web/assets/index-CQJq2xwX.css +1 -0
  21. package/web/assets/index-Cb6NO7uV.js +1 -0
  22. package/web/assets/{index-1iYnLw7q.js → index-Cg1cP-qO.js} +1 -1
  23. package/web/assets/index-Cs6uqj5d.js +1 -0
  24. package/web/assets/index-D1KJL4bg.js +1 -0
  25. package/web/assets/index-DOgeK81c.js +1 -0
  26. package/web/assets/index-Dj0LEd8w.js +13 -0
  27. package/web/assets/index-b5KaknlY.js +1 -0
  28. package/web/assets/index-cpz7Q_hZ.js +1 -0
  29. package/web/assets/index-lrIwyqs2.js +1 -0
  30. package/web/assets/{index-D-buiUaW.js → index-wD2t70jv.js} +2 -2
  31. package/web/assets/index-wu5Pm3nc.js +2 -0
  32. package/web/assets/xterm-D6Mng-DB.js +9 -0
  33. package/web/assets/xterm-DYP7pi_n.css +32 -0
  34. package/web/favicon.ico +0 -0
  35. package/web/favicon.png +0 -0
  36. package/web/favicon.svg +4 -0
  37. package/web/index.html +12 -2
  38. package/web/assets/index-7poo-d50.js +0 -1
  39. package/web/assets/index-BMMMWgeR.js +0 -13
  40. package/web/assets/index-BW8WtNtA.js +0 -1
  41. package/web/assets/index-C7CZdm1C.js +0 -3
  42. package/web/assets/index-CVP8I8tU.js +0 -1
  43. package/web/assets/index-CpwLxLdo.js +0 -1
  44. package/web/assets/index-DCtzpGvd.js +0 -133
  45. package/web/assets/index-DDnDtQl5.js +0 -1
  46. package/web/assets/index-DKwcT9vY.js +0 -1
  47. package/web/assets/index-DYr0npzc.css +0 -1
  48. package/web/assets/index-DcGWR_5B.js +0 -1
  49. package/web/assets/index-PCsjZn72.js +0 -1
  50. package/web/assets/index-XBmPtinY.js +0 -7
  51. package/web/assets/index-gDk09Gbz.js +0 -1
package/bundle/ccv.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // dist/ccv.js
4
4
  import { spawn as spawn2 } from "node:child_process";
5
- import { readFile as readFile10 } from "node:fs/promises";
6
- import path14 from "node:path";
5
+ import { readFile as readFile11 } from "node:fs/promises";
6
+ import path16 from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseArgs } from "node:util";
9
9
 
@@ -146,9 +146,9 @@ function envDefaults() {
146
146
  // ../host/dist/server.js
147
147
  import { createServer } from "node:http";
148
148
  import { createReadStream, existsSync as existsSync3, statSync as statSync3 } from "node:fs";
149
- import path13 from "node:path";
149
+ import path15 from "node:path";
150
150
  import { homedir as homedir5 } from "node:os";
151
- import { randomUUID as randomUUID2 } from "node:crypto";
151
+ import { randomUUID as randomUUID3 } from "node:crypto";
152
152
  import { WebSocketServer } from "ws";
153
153
  import { renameSession as renameSession2 } from "@anthropic-ai/claude-agent-sdk";
154
154
 
@@ -628,6 +628,184 @@ async function readSettings(file) {
628
628
  }
629
629
  }
630
630
 
631
+ // ../host/dist/transcript.js
632
+ import { getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
633
+ var RESULT_LIMIT = 4e3;
634
+ var INPUT_LIMIT = 2e4;
635
+ var CUT = "\n\u2026 (\u043E\u0431\u0440\u0435\u0437\u0430\u043D\u043E)";
636
+ async function readTranscript(sessionId, options2) {
637
+ let messages;
638
+ try {
639
+ messages = await getSessionMessages(sessionId, {
640
+ dir: options2.dir,
641
+ includeSystemMessages: true
642
+ });
643
+ } catch {
644
+ return [];
645
+ }
646
+ const entries = [];
647
+ const blockCounts = /* @__PURE__ */ new Map();
648
+ let seq = 0;
649
+ const record = (event, ts) => {
650
+ entries.push({ seq: ++seq, ts, event });
651
+ };
652
+ for (const message of messages) {
653
+ if (message.parent_tool_use_id !== null || message.parent_agent_id !== null)
654
+ continue;
655
+ const ts = timestampOf(message);
656
+ if (ts >= options2.until)
657
+ continue;
658
+ if (message.type === "system") {
659
+ if (subtypeOf(message) === "compact_boundary") {
660
+ record({ kind: "system", subtype: "compact_boundary", data: {} }, ts);
661
+ }
662
+ continue;
663
+ }
664
+ const content = contentOf(message);
665
+ if (message.type === "user") {
666
+ if (typeof content === "string") {
667
+ const text = humanText(content);
668
+ if (text)
669
+ record({ kind: "user.message", text }, ts);
670
+ continue;
671
+ }
672
+ for (const block of content) {
673
+ if (block.type === "text" && typeof block.text === "string") {
674
+ const text = humanText(block.text);
675
+ if (text)
676
+ record({ kind: "user.message", text }, ts);
677
+ } else if (block.type === "tool_result") {
678
+ record({
679
+ kind: "tool.result",
680
+ toolUseId: String(block.tool_use_id ?? ""),
681
+ content: clip(contentToText(block.content), RESULT_LIMIT),
682
+ isError: block.is_error === true
683
+ }, ts);
684
+ }
685
+ }
686
+ continue;
687
+ }
688
+ const messageId = messageIdOf(message);
689
+ if (typeof content === "string") {
690
+ if (content)
691
+ record({ kind: "assistant.text", text: content, blockId: `${messageId}:0` }, ts);
692
+ continue;
693
+ }
694
+ for (const block of content) {
695
+ const blockId = `${messageId}:${nextBlockIndex(blockCounts, messageId)}`;
696
+ if (block.type === "text" && typeof block.text === "string") {
697
+ if (!block.text)
698
+ continue;
699
+ if (synthetic(message)) {
700
+ record({ kind: "error", message: block.text }, ts);
701
+ } else {
702
+ record({ kind: "assistant.text", text: block.text, blockId }, ts);
703
+ }
704
+ } else if (block.type === "thinking") {
705
+ const text = typeof block.thinking === "string" ? block.thinking : "";
706
+ if (text)
707
+ record({ kind: "assistant.thinking", text, blockId }, ts);
708
+ } else if (block.type === "tool_use") {
709
+ record({
710
+ kind: "tool.use",
711
+ toolUseId: String(block.id ?? ""),
712
+ name: String(block.name ?? ""),
713
+ input: clipInput(block.input)
714
+ }, ts);
715
+ }
716
+ }
717
+ }
718
+ return entries;
719
+ }
720
+ function asObject(value) {
721
+ return typeof value === "object" && value !== null ? value : null;
722
+ }
723
+ function contentOf(message) {
724
+ const content = asObject(message.message)?.content;
725
+ if (typeof content === "string")
726
+ return content;
727
+ if (!Array.isArray(content))
728
+ return [];
729
+ return content.filter((block) => typeof block === "object" && block !== null);
730
+ }
731
+ function messageIdOf(message) {
732
+ const id = asObject(message.message)?.id;
733
+ return typeof id === "string" ? id : message.uuid;
734
+ }
735
+ function synthetic(message) {
736
+ return asObject(message.message)?.model === "<synthetic>";
737
+ }
738
+ function subtypeOf(message) {
739
+ const subtype = message.subtype;
740
+ return typeof subtype === "string" ? subtype : "";
741
+ }
742
+ function timestampOf(message) {
743
+ const raw = message.timestamp;
744
+ if (typeof raw !== "string")
745
+ return 0;
746
+ const ts = Date.parse(raw);
747
+ return Number.isNaN(ts) ? 0 : ts;
748
+ }
749
+ function nextBlockIndex(counts, messageId) {
750
+ const index = counts.get(messageId) ?? 0;
751
+ counts.set(messageId, index + 1);
752
+ return index;
753
+ }
754
+ var ENVELOPE = /^\s*<(local-command-caveat|local-command-stdout|system-reminder|task-notification|command-message|command-args)\b/;
755
+ var SLASH_COMMAND = /^\s*<command-name>([^<]*)<\/command-name>/;
756
+ var SLASH_ARGS = /<command-args>([^<]*)<\/command-args>/;
757
+ function humanText(raw) {
758
+ const slash = SLASH_COMMAND.exec(raw);
759
+ if (slash) {
760
+ const name = (slash[1] ?? "").trim();
761
+ const args = (SLASH_ARGS.exec(raw)?.[1] ?? "").trim();
762
+ return args ? `${name} ${args}` : name;
763
+ }
764
+ if (ENVELOPE.test(raw))
765
+ return "";
766
+ const text = raw.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
767
+ if (/^\[Image: /.test(text))
768
+ return "";
769
+ return text;
770
+ }
771
+ function contentToText(content) {
772
+ if (typeof content === "string")
773
+ return content;
774
+ if (!Array.isArray(content))
775
+ return "";
776
+ return content.map((block) => {
777
+ if (typeof block === "string")
778
+ return block;
779
+ const item = asObject(block);
780
+ if (!item)
781
+ return "";
782
+ if (typeof item.text === "string")
783
+ return item.text;
784
+ if (typeof item.type === "string")
785
+ return `[${item.type}]`;
786
+ return "";
787
+ }).join("");
788
+ }
789
+ function clip(text, limit) {
790
+ return text.length > limit ? text.slice(0, limit) + CUT : text;
791
+ }
792
+ function clipInput(input) {
793
+ const object = asObject(input);
794
+ if (!object)
795
+ return input;
796
+ let changed = false;
797
+ const clipped = {};
798
+ for (const [key, value] of Object.entries(object)) {
799
+ if (typeof value === "string" && value.length > INPUT_LIMIT) {
800
+ clipped[key] = clip(value, INPUT_LIMIT);
801
+ changed = true;
802
+ } else {
803
+ clipped[key] = value;
804
+ }
805
+ }
806
+ return changed ? clipped : input;
807
+ }
808
+
631
809
  // ../host/dist/session.js
632
810
  function uiSystemPromptAppend(cwd) {
633
811
  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.";
@@ -647,6 +825,29 @@ function writeTarget(input) {
647
825
  return typeof target === "string" && target ? target : null;
648
826
  }
649
827
  var ASK_QUESTION_TOOL = "AskUserQuestion";
828
+ var ABORTED_REASONS = /* @__PURE__ */ new Set(["aborted_streaming", "aborted_tools"]);
829
+ var STOPPED_REASONS = /* @__PURE__ */ new Set([
830
+ "max_turns",
831
+ "budget_exhausted",
832
+ "stop_hook_prevented",
833
+ "hook_stopped",
834
+ "tool_deferred",
835
+ "tool_deferred_unavailable",
836
+ "background_requested"
837
+ ]);
838
+ function turnOutcome(terminalReason, subtype, interruptRequested) {
839
+ if (terminalReason && ABORTED_REASONS.has(terminalReason))
840
+ return "interrupted";
841
+ if (terminalReason && STOPPED_REASONS.has(terminalReason))
842
+ return "stopped";
843
+ if (subtype === "success")
844
+ return "completed";
845
+ if (interruptRequested)
846
+ return "interrupted";
847
+ if (subtype === "error_max_turns" || subtype === "error_max_budget_usd")
848
+ return "stopped";
849
+ return "failed";
850
+ }
650
851
  function parseQuestions(input) {
651
852
  if (typeof input !== "object" || input === null)
652
853
  return null;
@@ -698,16 +899,54 @@ function previewAnnotations(questions, answers) {
698
899
  return annotations;
699
900
  }
700
901
  var USAGE_MIN_INTERVAL_MS = 2e4;
902
+ var CONTEXT_MIN_INTERVAL_MS = 5e3;
701
903
  var ClaudeSession = class extends EventEmitter {
702
904
  id;
703
905
  workspaceId;
704
906
  cwd;
705
907
  journal = new Journal();
706
908
  #query = null;
909
+ /**
910
+ * Разговор, который был до нас, — прочитанный транскрипт продолженной сессии.
911
+ *
912
+ * Читается один раз, на старте, и больше не меняется: всё, что случится
913
+ * дальше, попадает в журнал. Промис, а не массив, потому что чтение
914
+ * асинхронное, а спросить историю могут раньше, чем оно закончится, — тогда
915
+ * запрос дождётся того же чтения вместо второго такого же. `null` — сессия
916
+ * начата с нуля, и продолжать нечего.
917
+ */
918
+ #history = null;
707
919
  /** Чем подняли эту сессию — нужно, чтобы объяснить провал старта. */
708
920
  #executable = { source: "bundled" };
709
921
  #inbox = new AsyncQueue();
922
+ /**
923
+ * Размер контекста на последнем запросе к API — то, что показывает полоса
924
+ * контекста в UI.
925
+ *
926
+ * Считается здесь, а не в UI из `result.usage`: SDK складывает в него все
927
+ * запросы хода разом, и разделить их обратно уже нечем. Usage
928
+ * `assistant`-сообщения, наоборот, всегда про один запрос — запоминаем
929
+ * последнее и отдаём его вместе с `result`.
930
+ */
931
+ #contextTokens = null;
710
932
  #state = "starting";
933
+ /**
934
+ * Чем кончился последний ход — см. `TurnOutcome` в протоколе.
935
+ *
936
+ * Обновляется на `result`, обнуляется новым промптом: свежий ход ещё ничем не
937
+ * кончился, и прошлый исход рядом с ним читался бы как исход этого.
938
+ */
939
+ #lastTurn = null;
940
+ /**
941
+ * Человек нажал «Стоп» — до того, как пришёл `result` этого хода.
942
+ *
943
+ * Запасной путь к «прервали»: обычно исход виден по `terminal_reason`
944
+ * (`aborted_streaming`/`aborted_tools`, проверено пробой), но поле
945
+ * необязательное, а версия CLI живёт отдельно от нашей. Тогда решает флаг:
946
+ * если мы сами только что просили прервать, оборвавшийся ход — прерванный, а
947
+ * не упавший.
948
+ */
949
+ #interruptRequested = false;
711
950
  #capabilities = null;
712
951
  #pendingPermissions = /* @__PURE__ */ new Map();
713
952
  #model = null;
@@ -772,6 +1011,11 @@ var ClaudeSession = class extends EventEmitter {
772
1011
  /** Когда была последняя попытка его получить — удачная или нет. */
773
1012
  #usageAt = 0;
774
1013
  #usageInFlight = false;
1014
+ /** Последний снимок заполненности окна контекста; null — ещё не спрашивали. */
1015
+ #context = null;
1016
+ /** Когда была последняя попытка его получить — удачная или нет. */
1017
+ #contextAt = 0;
1018
+ #contextInFlight = false;
775
1019
  #createdAt = Date.now();
776
1020
  #updatedAt = Date.now();
777
1021
  #options;
@@ -806,6 +1050,20 @@ var ClaudeSession = class extends EventEmitter {
806
1050
  get usage() {
807
1051
  return this.#usage;
808
1052
  }
1053
+ /** Заполненность окна контекста. null — ещё ни разу не спросили или спросить не вышло. */
1054
+ get context() {
1055
+ return this.#context;
1056
+ }
1057
+ /**
1058
+ * Разговор, который был до старта этой сессии, — целиком и по порядку.
1059
+ *
1060
+ * Пустой массив у сессии, начатой с нуля. Ждёт чтения транскрипта, если оно
1061
+ * ещё идёт: клиент спрашивает историю сразу после открытия сессии, и ответ
1062
+ * «пока пусто» он принял бы за «истории нет».
1063
+ */
1064
+ async history() {
1065
+ return await this.#history ?? [];
1066
+ }
809
1067
  summary() {
810
1068
  return {
811
1069
  id: this.id,
@@ -815,6 +1073,7 @@ var ClaudeSession = class extends EventEmitter {
815
1073
  parentSessionId: this.parentSessionId,
816
1074
  title: this.#title,
817
1075
  state: this.#state,
1076
+ lastTurn: this.#lastTurn,
818
1077
  model: this.#model,
819
1078
  permissionMode: this.#permissionMode,
820
1079
  effort: this.#effort,
@@ -833,6 +1092,10 @@ var ClaudeSession = class extends EventEmitter {
833
1092
  start() {
834
1093
  if (this.#query)
835
1094
  throw new Error("session already started");
1095
+ if (this.#options.resume && !this.#options.fork) {
1096
+ const until = Date.now();
1097
+ this.#history = readTranscript(this.#options.resume, { dir: this.cwd, until });
1098
+ }
836
1099
  const options2 = {
837
1100
  cwd: this.cwd,
838
1101
  // Без этого SDK не читает с диска ничего: ни CLAUDE.md, ни кастомные
@@ -942,6 +1205,8 @@ var ClaudeSession = class extends EventEmitter {
942
1205
  });
943
1206
  return;
944
1207
  }
1208
+ this.#lastTurn = null;
1209
+ this.#interruptRequested = false;
945
1210
  this.#setState("running");
946
1211
  this.#inbox.push({
947
1212
  type: "user",
@@ -988,6 +1253,7 @@ var ClaudeSession = class extends EventEmitter {
988
1253
  this.#setState("running");
989
1254
  }
990
1255
  async interrupt() {
1256
+ this.#interruptRequested = true;
991
1257
  await this.#query?.interrupt();
992
1258
  }
993
1259
  /**
@@ -1090,6 +1356,44 @@ var ClaudeSession = class extends EventEmitter {
1090
1356
  this.#usageInFlight = false;
1091
1357
  }
1092
1358
  }
1359
+ /**
1360
+ * Пересчитать заполненность окна контекста.
1361
+ *
1362
+ * Спрашиваем у CLI, а не считаем сами: `contextTokens` из `result` знает
1363
+ * только про разговор и только на конец хода, а в окне лежат ещё системный
1364
+ * промпт, инструменты, память и навыки. Здесь же приезжает и размер самого
1365
+ * окна — знаменатель полосы.
1366
+ *
1367
+ * `force` — по прямой просьбе клиента (открыли сессию, нажали «обновить»):
1368
+ * тогда пауза между запросами не действует.
1369
+ *
1370
+ * Неудача, как и у лимитов, ничего не ломает и в ленту не пишется: на CLI
1371
+ * постарше метода может не быть вовсе, и сессия от этого не перестаёт
1372
+ * работать — просто полоса останется на числе из последнего хода.
1373
+ */
1374
+ async refreshContext(force = false) {
1375
+ const q = this.#query;
1376
+ if (!q || this.#contextInFlight)
1377
+ return;
1378
+ if (!force && Date.now() - this.#contextAt < CONTEXT_MIN_INTERVAL_MS)
1379
+ return;
1380
+ this.#contextInFlight = true;
1381
+ try {
1382
+ const raw = await q.getContextUsage();
1383
+ if (typeof raw.totalTokens === "number" && raw.maxTokens > 0) {
1384
+ this.#context = {
1385
+ tokens: Math.max(0, raw.totalTokens),
1386
+ maxTokens: raw.maxTokens,
1387
+ fetchedAt: Date.now()
1388
+ };
1389
+ this.emit("context", this.#context);
1390
+ }
1391
+ } catch {
1392
+ } finally {
1393
+ this.#contextAt = Date.now();
1394
+ this.#contextInFlight = false;
1395
+ }
1396
+ }
1093
1397
  // -------------------------------------------------------------------------
1094
1398
  // MCP и плагины
1095
1399
  // -------------------------------------------------------------------------
@@ -1194,7 +1498,7 @@ var ClaudeSession = class extends EventEmitter {
1194
1498
  const pass = { continue: true };
1195
1499
  if (!this.workspaceId)
1196
1500
  return pass;
1197
- const roots = this.#options.writableRoots?.() ?? [this.cwd];
1501
+ const roots = this.#options.writableRoots ? this.#options.writableRoots() : [this.cwd];
1198
1502
  if (roots === null)
1199
1503
  return pass;
1200
1504
  const target = writeTarget(input.tool_input);
@@ -1306,6 +1610,10 @@ var ClaudeSession = class extends EventEmitter {
1306
1610
  this.#handleSystem(message);
1307
1611
  break;
1308
1612
  case "assistant":
1613
+ if (message.parent_tool_use_id === null && message.message.usage) {
1614
+ const usage = message.message.usage;
1615
+ this.#contextTokens = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
1616
+ }
1309
1617
  for (const block of message.message.content) {
1310
1618
  const blockId = `${message.message.id}:${this.#nextBlockIndex(message.message.id)}`;
1311
1619
  if (block.type === "text") {
@@ -1349,6 +1657,7 @@ var ClaudeSession = class extends EventEmitter {
1349
1657
  durationMs: message.duration_ms,
1350
1658
  costUsd: message.total_cost_usd ?? null,
1351
1659
  usage,
1660
+ contextTokens: this.#contextTokens,
1352
1661
  text: "result" in message ? message.result : null
1353
1662
  });
1354
1663
  if (this.#options.outputFormat) {
@@ -1357,10 +1666,16 @@ var ClaudeSession = class extends EventEmitter {
1357
1666
  text: "result" in message ? message.result : null
1358
1667
  });
1359
1668
  }
1669
+ this.#lastTurn = turnOutcome("terminal_reason" in message ? message.terminal_reason : void 0, message.subtype, this.#interruptRequested);
1670
+ this.#interruptRequested = false;
1671
+ const alreadyIdle = this.#state === "idle";
1360
1672
  this.#setState("idle");
1673
+ if (alreadyIdle)
1674
+ this.emit("summary", this.summary());
1361
1675
  if (this.#unpersistedTitle)
1362
1676
  void this.#persistTitle();
1363
1677
  void this.refreshUsage();
1678
+ void this.refreshContext(true);
1364
1679
  break;
1365
1680
  }
1366
1681
  // CLI сам сообщил, что лимит сдвинулся (обычно — подошёл к порогу).
@@ -1399,6 +1714,7 @@ var ClaudeSession = class extends EventEmitter {
1399
1714
  if (this.#state === "starting")
1400
1715
  this.#setState("idle");
1401
1716
  void this.refreshUsage(true);
1717
+ void this.refreshContext(true);
1402
1718
  return;
1403
1719
  }
1404
1720
  const { type: _type, subtype, ...rest } = message;
@@ -1470,7 +1786,7 @@ var ClaudeSession = class extends EventEmitter {
1470
1786
  this.#record({
1471
1787
  kind: "tool.result",
1472
1788
  toolUseId: block.tool_use_id,
1473
- content: contentToText(block.content),
1789
+ content: contentToText2(block.content),
1474
1790
  isError: block.is_error === true
1475
1791
  });
1476
1792
  }
@@ -1551,7 +1867,7 @@ function toUsageWindow(raw) {
1551
1867
  resetsAt: typeof raw.resets_at === "string" ? raw.resets_at : ""
1552
1868
  };
1553
1869
  }
1554
- function contentToText(content) {
1870
+ function contentToText2(content) {
1555
1871
  if (typeof content === "string")
1556
1872
  return content;
1557
1873
  if (!Array.isArray(content))
@@ -1932,9 +2248,9 @@ function parsePatch(raw) {
1932
2248
  body = "";
1933
2249
  }
1934
2250
  total += body.length;
1935
- const path15 = status === "deleted" ? oldPath : newPath || oldPath;
2251
+ const path17 = status === "deleted" ? oldPath : newPath || oldPath;
1936
2252
  files.push({
1937
- path: path15,
2253
+ path: path17,
1938
2254
  oldPath: status === "renamed" ? oldPath : "",
1939
2255
  status,
1940
2256
  added: count(body, "+"),
@@ -1946,10 +2262,10 @@ function parsePatch(raw) {
1946
2262
  return { files, truncated };
1947
2263
  }
1948
2264
  function stripPrefix(value) {
1949
- const path15 = unquote(value.trim());
1950
- if (path15 === "/dev/null")
2265
+ const path17 = unquote(value.trim());
2266
+ if (path17 === "/dev/null")
1951
2267
  return "";
1952
- return path15.replace(/^[ab]\//, "");
2268
+ return path17.replace(/^[ab]\//, "");
1953
2269
  }
1954
2270
  function count(body, mark) {
1955
2271
  let n = 0;
@@ -2522,14 +2838,139 @@ function isRecord(value) {
2522
2838
  return typeof value === "object" && value !== null && typeof value.workspaceId === "string" && typeof value.at === "number";
2523
2839
  }
2524
2840
 
2525
- // ../host/dist/app-settings.js
2841
+ // ../host/dist/pins.js
2526
2842
  import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2527
- import { homedir as homedir3 } from "node:os";
2528
2843
  import path9 from "node:path";
2844
+ var SessionPins = class {
2845
+ #pinned = /* @__PURE__ */ new Map();
2846
+ #storePath;
2847
+ constructor(dataDir) {
2848
+ this.#storePath = path9.join(dataDir, "session-pins.json");
2849
+ }
2850
+ async load() {
2851
+ try {
2852
+ const raw = await readFile6(this.#storePath, "utf8");
2853
+ const parsed = JSON.parse(raw);
2854
+ this.#pinned = new Map(Object.entries(parsed.pinned ?? {}).filter(([, r]) => isRecord2(r)));
2855
+ } catch {
2856
+ this.#pinned = /* @__PURE__ */ new Map();
2857
+ }
2858
+ }
2859
+ find(sessionId) {
2860
+ return this.#pinned.get(sessionId);
2861
+ }
2862
+ has(sessionId) {
2863
+ return this.#pinned.has(sessionId);
2864
+ }
2865
+ /** Проекты, в которых есть хоть что-то закреплённое, — по ним собирается панель. */
2866
+ workspaceIds() {
2867
+ return [...new Set([...this.#pinned.values()].map((r) => r.workspaceId))];
2868
+ }
2869
+ /** Закрепить или снять закрепление. Возвращает `true`, если что-то изменилось. */
2870
+ async set(workspaceId, sessionIds, pinned) {
2871
+ let changed = false;
2872
+ const at = Date.now();
2873
+ for (const id of sessionIds) {
2874
+ if (pinned) {
2875
+ if (this.#pinned.has(id))
2876
+ continue;
2877
+ this.#pinned.set(id, { workspaceId, at });
2878
+ } else if (!this.#pinned.delete(id)) {
2879
+ continue;
2880
+ }
2881
+ changed = true;
2882
+ }
2883
+ if (changed)
2884
+ await this.#persist();
2885
+ return changed;
2886
+ }
2887
+ /**
2888
+ * Перенести закрепление с локального uuid на настоящий id Claude Code.
2889
+ *
2890
+ * Свежую сессию закрепляют до первого обмена — тогда её дискового id ещё не
2891
+ * существует, и в реестр попадает временный uuid. Как только id появился,
2892
+ * запись обязана переехать на него: под uuid сессию не найдёт ни список
2893
+ * (`#listSessions` ищет закрепление по дисковому id), ни следующий запуск
2894
+ * хоста — там этого uuid нет вовсе, и подметание стёрло бы закрепление
2895
+ * молча.
2896
+ *
2897
+ * Время закрепления переносится как есть: порядок в панели задан им, и
2898
+ * строка не должна прыгать в конец списка от того, что сессия наконец
2899
+ * дошла до `system/init`.
2900
+ */
2901
+ async migrate(fromId, toId) {
2902
+ const record = this.#pinned.get(fromId);
2903
+ if (!record || fromId === toId)
2904
+ return false;
2905
+ this.#pinned.delete(fromId);
2906
+ if (!this.#pinned.has(toId))
2907
+ this.#pinned.set(toId, record);
2908
+ await this.#persist();
2909
+ return true;
2910
+ }
2911
+ /**
2912
+ * Забыть записи о сессиях, которых больше нет на диске.
2913
+ *
2914
+ * Зовётся оттуда же, откуда подметается архив, и делает то же самое: реестр
2915
+ * не должен копить ссылки на исчезнувшие транскрипты. `known` — все сессии
2916
+ * проекта, живые и с диска.
2917
+ */
2918
+ async sweep(workspaceId, known) {
2919
+ let changed = false;
2920
+ for (const [id, record] of this.#pinned) {
2921
+ if (record.workspaceId !== workspaceId || known.has(id))
2922
+ continue;
2923
+ this.#pinned.delete(id);
2924
+ changed = true;
2925
+ }
2926
+ if (changed)
2927
+ await this.#persist();
2928
+ return changed;
2929
+ }
2930
+ /**
2931
+ * Забыть всё закреплённое в проекте — при его закрытии.
2932
+ *
2933
+ * Закрытый проект не показывается нигде, и закреплённая сессия из него
2934
+ * висела бы в панели строкой, по клику на которую открывать нечего.
2935
+ */
2936
+ async forgetWorkspace(workspaceId) {
2937
+ let changed = false;
2938
+ for (const [id, record] of this.#pinned) {
2939
+ if (record.workspaceId !== workspaceId)
2940
+ continue;
2941
+ this.#pinned.delete(id);
2942
+ changed = true;
2943
+ }
2944
+ if (changed)
2945
+ await this.#persist();
2946
+ return changed;
2947
+ }
2948
+ async #persist() {
2949
+ const file = { pinned: Object.fromEntries(this.#pinned) };
2950
+ await mkdir4(path9.dirname(this.#storePath), { recursive: true });
2951
+ await writeFile4(this.#storePath, JSON.stringify(file, null, 2), "utf8");
2952
+ }
2953
+ };
2954
+ function isRecord2(value) {
2955
+ return typeof value === "object" && value !== null && typeof value.workspaceId === "string" && typeof value.at === "number";
2956
+ }
2957
+
2958
+ // ../host/dist/app-settings.js
2959
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
2960
+ import { homedir as homedir3 } from "node:os";
2961
+ import path10 from "node:path";
2529
2962
  var DEFAULT_AUTO_ARCHIVE_AFTER_MS = 2 * 24 * 60 * 60 * 1e3;
2530
2963
  var MIN_AUTO_ARCHIVE_AFTER_MS = 60 * 60 * 1e3;
2531
2964
  var MIN_AUTO_SAVE_AFTER_PAUSE_MS = 500;
2532
2965
  var FOLDER_ACCESS = ["all", "projects", "project"];
2966
+ var PERMISSION_MODES = [
2967
+ "default",
2968
+ "acceptEdits",
2969
+ "plan",
2970
+ "dontAsk",
2971
+ "bypassPermissions",
2972
+ "auto"
2973
+ ];
2533
2974
  var DEFAULT_APP_SETTINGS = {
2534
2975
  autoArchiveAfterMs: DEFAULT_AUTO_ARCHIVE_AFTER_MS,
2535
2976
  // Строгий режим по умолчанию: молча писать в чужой проект приложение начнёт
@@ -2537,17 +2978,26 @@ var DEFAULT_APP_SETTINGS = {
2537
2978
  folderAccess: "project",
2538
2979
  // Выключено по умолчанию: сохранение — явное действие, пока человек сам не
2539
2980
  // попросил иначе.
2540
- autoSaveAfterPauseMs: 0
2981
+ autoSaveAfterPauseMs: 0,
2982
+ // Модель за пользователя не выбираем: пока он не сказал иначе, сессия
2983
+ // поднимается ровно с той, с которой её поднял бы консольный Claude Code.
2984
+ startModel: null,
2985
+ // Тот же режим, с которым сессия стартовала до появления этой настройки.
2986
+ startPermissionMode: "default",
2987
+ // Терминал по умолчанию закрыт — его включают, а не выключают: приложение,
2988
+ // открытое в браузере, не обязано с первого запуска уметь запускать на этой
2989
+ // машине что угодно.
2990
+ terminalEnabled: false
2541
2991
  };
2542
2992
  var AppSettingsStore = class {
2543
2993
  #settings = { ...DEFAULT_APP_SETTINGS };
2544
2994
  #storePath;
2545
2995
  constructor(dataDir) {
2546
- this.#storePath = path9.join(dataDir, "app-settings.json");
2996
+ this.#storePath = path10.join(dataDir, "app-settings.json");
2547
2997
  }
2548
2998
  async load() {
2549
2999
  try {
2550
- const raw = await readFile6(this.#storePath, "utf8");
3000
+ const raw = await readFile7(this.#storePath, "utf8");
2551
3001
  this.#settings = sanitize(JSON.parse(raw), DEFAULT_APP_SETTINGS);
2552
3002
  } catch {
2553
3003
  this.#settings = { ...DEFAULT_APP_SETTINGS };
@@ -2567,8 +3017,8 @@ var AppSettingsStore = class {
2567
3017
  async update(patch) {
2568
3018
  const next = sanitize(patch, this.#settings);
2569
3019
  this.#settings = next;
2570
- await mkdir4(path9.dirname(this.#storePath), { recursive: true });
2571
- await writeFile4(this.#storePath, JSON.stringify(next, null, 2), "utf8");
3020
+ await mkdir5(path10.dirname(this.#storePath), { recursive: true });
3021
+ await writeFile5(this.#storePath, JSON.stringify(next, null, 2), "utf8");
2572
3022
  return this.current;
2573
3023
  }
2574
3024
  };
@@ -2581,18 +3031,32 @@ function sanitize(value, fallback) {
2581
3031
  if (typeof after === "number" && Number.isFinite(after)) {
2582
3032
  result.autoArchiveAfterMs = after <= 0 ? 0 : Math.max(Math.round(after), MIN_AUTO_ARCHIVE_AFTER_MS);
2583
3033
  }
2584
- const access2 = record.folderAccess;
2585
- if (typeof access2 === "string" && FOLDER_ACCESS.includes(access2)) {
2586
- result.folderAccess = access2;
3034
+ const access3 = record.folderAccess;
3035
+ if (typeof access3 === "string" && FOLDER_ACCESS.includes(access3)) {
3036
+ result.folderAccess = access3;
2587
3037
  }
2588
3038
  const autoSave = record.autoSaveAfterPauseMs;
2589
3039
  if (typeof autoSave === "number" && Number.isFinite(autoSave)) {
2590
3040
  result.autoSaveAfterPauseMs = autoSave <= 0 ? 0 : Math.max(Math.round(autoSave), MIN_AUTO_SAVE_AFTER_PAUSE_MS);
2591
3041
  }
3042
+ if ("startModel" in record) {
3043
+ const model = record.startModel;
3044
+ if (model === null)
3045
+ result.startModel = null;
3046
+ else if (typeof model === "string")
3047
+ result.startModel = model.trim() || null;
3048
+ }
3049
+ const mode = record.startPermissionMode;
3050
+ if (typeof mode === "string" && PERMISSION_MODES.includes(mode)) {
3051
+ result.startPermissionMode = mode;
3052
+ }
3053
+ if (typeof record.terminalEnabled === "boolean") {
3054
+ result.terminalEnabled = record.terminalEnabled;
3055
+ }
2592
3056
  return result;
2593
3057
  }
2594
3058
  function claudeHome() {
2595
- return path9.join(homedir3(), ".claude");
3059
+ return path10.join(homedir3(), ".claude");
2596
3060
  }
2597
3061
  function writableRoots(mode, cwd, workspacePaths) {
2598
3062
  if (mode === "all")
@@ -2602,7 +3066,7 @@ function writableRoots(mode, cwd, workspacePaths) {
2602
3066
  return [...roots];
2603
3067
  for (const workspace of [cwd, ...workspacePaths]) {
2604
3068
  roots.add(workspace);
2605
- const parent = path9.dirname(workspace);
3069
+ const parent = path10.dirname(workspace);
2606
3070
  if (parent !== workspace)
2607
3071
  roots.add(parent);
2608
3072
  }
@@ -2610,9 +3074,9 @@ function writableRoots(mode, cwd, workspacePaths) {
2610
3074
  }
2611
3075
 
2612
3076
  // ../host/dist/plugins.js
2613
- import { readFile as readFile7 } from "node:fs/promises";
3077
+ import { readFile as readFile8 } from "node:fs/promises";
2614
3078
  import { homedir as homedir4 } from "node:os";
2615
- import path10 from "node:path";
3079
+ import path11 from "node:path";
2616
3080
  var TIMEOUT_MS2 = 12e4;
2617
3081
  var SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
2618
3082
  var PluginCli = class {
@@ -2711,10 +3175,10 @@ async function readCatalog(markets, installed) {
2711
3175
  return lists.flat().sort((a, b) => a.name.localeCompare(b.name));
2712
3176
  }
2713
3177
  async function readMarketplace(market, installed) {
2714
- const root = market.installLocation || path10.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
3178
+ const root = market.installLocation || path11.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
2715
3179
  let manifest;
2716
3180
  try {
2717
- const text = await readFile7(path10.join(root, ".claude-plugin", "marketplace.json"), "utf8");
3181
+ const text = await readFile8(path11.join(root, ".claude-plugin", "marketplace.json"), "utf8");
2718
3182
  manifest = JSON.parse(text);
2719
3183
  } catch {
2720
3184
  return [];
@@ -2925,8 +3389,8 @@ function describe3(error) {
2925
3389
 
2926
3390
  // ../host/dist/relay-link.js
2927
3391
  import { EventEmitter as EventEmitter3 } from "node:events";
2928
- import { readFile as readFile8, writeFile as writeFile5, rm as rm2, mkdir as mkdir5 } from "node:fs/promises";
2929
- import path11 from "node:path";
3392
+ import { readFile as readFile9, writeFile as writeFile6, rm as rm2, mkdir as mkdir6 } from "node:fs/promises";
3393
+ import path12 from "node:path";
2930
3394
  import { hostname } from "node:os";
2931
3395
  import WebSocket from "ws";
2932
3396
 
@@ -2948,13 +3412,26 @@ var RelayLink = class extends EventEmitter3 {
2948
3412
  #clients = /* @__PURE__ */ new Set();
2949
3413
  #error = null;
2950
3414
  #closed = false;
3415
+ /**
3416
+ * Право удалённого терминала — то, что нам сказал relay, и ничего больше.
3417
+ *
3418
+ * На диск не пишется намеренно, в отличие от токена сопряжения: правда о
3419
+ * праве живёт на relay, а кеш на диске означал бы, что хост, поднятый без
3420
+ * связи, сам себе разрешает то, что мог отозвать другой человек час назад.
3421
+ * Поэтому после каждого запуска право закрыто, пока relay не пришлёт
3422
+ * `shell.state`, — и это ничего не стоит: удалённых вкладок до связи с relay
3423
+ * всё равно нет. Обрыв связи состояние не гасит по обратной причине: гасить
3424
+ * нечего (вкладок снова нет), а мигание «выключено» в настройках от каждого
3425
+ * зевка сети означало бы звать человека чинить то, что чинится само.
3426
+ */
3427
+ #shell = { enabled: false, pending: false, email: null, confirmedAt: null };
2951
3428
  /** Куда сопрягаться, пока не сопряглись. */
2952
3429
  #defaultUrl;
2953
3430
  #file;
2954
3431
  #log;
2955
3432
  constructor(options2) {
2956
3433
  super();
2957
- this.#file = path11.join(options2.dataDir, "relay.json");
3434
+ this.#file = path12.join(options2.dataDir, "relay.json");
2958
3435
  this.#defaultUrl = options2.defaultUrl.replace(/\/+$/, "");
2959
3436
  this.#log = options2.log ?? ((msg) => console.log(`[relay] ${msg}`));
2960
3437
  }
@@ -2969,9 +3446,57 @@ var RelayLink = class extends EventEmitter3 {
2969
3446
  name: this.#credentials?.name ?? null,
2970
3447
  hostId: this.#credentials?.hostId ?? null,
2971
3448
  clients: this.#clients.size,
3449
+ shell: this.#shell,
2972
3450
  error: this.#error
2973
3451
  };
2974
3452
  }
3453
+ /** Право удалённого терминала — им же гейтится каждый кадр `term.*` от удалённой вкладки. */
3454
+ get shell() {
3455
+ return this.#shell;
3456
+ }
3457
+ /**
3458
+ * Включить удалённый терминал прямо сейчас — вызывается только для запроса,
3459
+ * пришедшего с локального сокета.
3460
+ *
3461
+ * Решение «локально или удалённо» принимает `HostServer`, а не этот класс:
3462
+ * здесь уже нечем отличить одно от другого, а там это ровно то поле
3463
+ * `ClientChannel.local`, ради которого канал и заведён.
3464
+ */
3465
+ async enableShell() {
3466
+ if (!this.#credentials)
3467
+ throw new Error("\u041A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440 \u043D\u0435 \u0441\u0432\u044F\u0437\u0430\u043D \u0441 \u0430\u043A\u043A\u0430\u0443\u043D\u0442\u043E\u043C \u2014 \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435");
3468
+ if (this.#socket?.readyState !== WebSocket.OPEN) {
3469
+ throw new Error("\u041D\u0435\u0442 \u0441\u0432\u044F\u0437\u0438 \u0441 relay \u2014 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u044C \u0443\u0434\u0430\u043B\u0451\u043D\u043D\u044B\u0439 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0441\u0435\u0439\u0447\u0430\u0441 \u043D\u0435 \u0432\u044B\u0439\u0434\u0435\u0442");
3470
+ }
3471
+ this.#push({ t: "shell.enable" });
3472
+ }
3473
+ /**
3474
+ * Попросить relay выслать письмо, подтверждающее удалённый терминал.
3475
+ *
3476
+ * Само право этот вызов не даёт и дать не может: relay только отправляет
3477
+ * письмо, а включает доступ переход по ссылке из него. Поэтому здесь нет ни
3478
+ * ожидания ответа, ни оптимистичного `enabled = true` — состояние приедет
3479
+ * кадром `shell.state`, когда будет чему приезжать.
3480
+ */
3481
+ async requestShell() {
3482
+ if (!this.#credentials)
3483
+ throw new Error("\u041A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440 \u043D\u0435 \u0441\u0432\u044F\u0437\u0430\u043D \u0441 \u0430\u043A\u043A\u0430\u0443\u043D\u0442\u043E\u043C \u2014 \u0441\u043D\u0430\u0447\u0430\u043B\u0430 \u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435");
3484
+ if (this.#socket?.readyState !== WebSocket.OPEN) {
3485
+ throw new Error("\u041D\u0435\u0442 \u0441\u0432\u044F\u0437\u0438 \u0441 relay \u2014 \u043F\u0438\u0441\u044C\u043C\u043E \u043E\u0442\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043D\u0435\u043A\u043E\u043C\u0443, \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u043F\u043E\u0437\u0436\u0435");
3486
+ }
3487
+ this.#push({ t: "shell.request" });
3488
+ }
3489
+ /** Снять право немедленно. Тоже через relay: там оно и хранится. */
3490
+ async disableShell() {
3491
+ if (!this.#credentials)
3492
+ return;
3493
+ if (this.#socket?.readyState !== WebSocket.OPEN) {
3494
+ this.#shell = { ...this.#shell, enabled: false, pending: false };
3495
+ this.#changed();
3496
+ return;
3497
+ }
3498
+ this.#push({ t: "shell.disable" });
3499
+ }
2975
3500
  #changed() {
2976
3501
  this.emit("state");
2977
3502
  }
@@ -2987,7 +3512,7 @@ var RelayLink = class extends EventEmitter3 {
2987
3512
  }
2988
3513
  async #read() {
2989
3514
  try {
2990
- const raw = await readFile8(this.#file, "utf8");
3515
+ const raw = await readFile9(this.#file, "utf8");
2991
3516
  const parsed = JSON.parse(raw);
2992
3517
  if (typeof parsed !== "object" || parsed === null)
2993
3518
  return null;
@@ -3000,8 +3525,8 @@ var RelayLink = class extends EventEmitter3 {
3000
3525
  }
3001
3526
  }
3002
3527
  async #write(credentials) {
3003
- await mkdir5(path11.dirname(this.#file), { recursive: true });
3004
- await writeFile5(this.#file, `${JSON.stringify(credentials, null, 2)}
3528
+ await mkdir6(path12.dirname(this.#file), { recursive: true });
3529
+ await writeFile6(this.#file, `${JSON.stringify(credentials, null, 2)}
3005
3530
  `, {
3006
3531
  encoding: "utf8",
3007
3532
  mode: 384
@@ -3052,6 +3577,7 @@ var RelayLink = class extends EventEmitter3 {
3052
3577
  this.#credentials = null;
3053
3578
  this.#error = null;
3054
3579
  this.#clients.clear();
3580
+ this.#shell = { enabled: false, pending: false, email: null, confirmedAt: null };
3055
3581
  await rm2(this.#file, { force: true });
3056
3582
  this.#changed();
3057
3583
  this.#log("\u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435 \u0441\u043D\u044F\u0442\u043E");
@@ -3157,6 +3683,16 @@ var RelayLink = class extends EventEmitter3 {
3157
3683
  case "client.frame":
3158
3684
  this.emit("frame", frame.clientId, frame.body);
3159
3685
  return;
3686
+ case "shell.state":
3687
+ this.#shell = {
3688
+ enabled: frame.enabled,
3689
+ pending: frame.pending,
3690
+ email: frame.email,
3691
+ confirmedAt: frame.confirmedAt
3692
+ };
3693
+ this.#log(frame.enabled ? "\u0443\u0434\u0430\u043B\u0451\u043D\u043D\u044B\u0439 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0440\u0430\u0437\u0440\u0435\u0448\u0451\u043D" : frame.pending ? `\u0443\u0434\u0430\u043B\u0451\u043D\u043D\u044B\u0439 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B: \u043F\u0438\u0441\u044C\u043C\u043E \u043E\u0442\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u043E \u043D\u0430 ${frame.email}` : "\u0443\u0434\u0430\u043B\u0451\u043D\u043D\u044B\u0439 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0432\u044B\u043A\u043B\u044E\u0447\u0435\u043D");
3694
+ this.#changed();
3695
+ return;
3160
3696
  case "ping":
3161
3697
  socket.send(JSON.stringify({ t: "pong", nonce: frame.nonce }));
3162
3698
  return;
@@ -3197,11 +3733,196 @@ function describe4(error) {
3197
3733
  return error instanceof Error ? error.message : String(error);
3198
3734
  }
3199
3735
 
3736
+ // ../host/dist/terminal.js
3737
+ import { EventEmitter as EventEmitter4 } from "node:events";
3738
+ import { access as access2, constants as constants2 } from "node:fs/promises";
3739
+ import path13 from "node:path";
3740
+ import { randomUUID as randomUUID2 } from "node:crypto";
3741
+ var SCROLLBACK_BYTES = 256 * 1024;
3742
+ var FLUSH_MS = 12;
3743
+ var MAX_TERMINALS = 24;
3744
+ var TerminalHub = class extends EventEmitter4 {
3745
+ #terminals = /* @__PURE__ */ new Map();
3746
+ #module = null;
3747
+ list(workspaceId) {
3748
+ const all = [...this.#terminals.values()].map((entry) => entry.info);
3749
+ return workspaceId ? all.filter((info) => info.workspaceId === workspaceId) : all;
3750
+ }
3751
+ has(id) {
3752
+ return this.#terminals.has(id);
3753
+ }
3754
+ /** Накопленный вывод — то, что увидит подключившийся позже. */
3755
+ buffer(id) {
3756
+ const entry = this.#terminals.get(id);
3757
+ if (!entry)
3758
+ return "";
3759
+ return entry.chunks.join("") + entry.pending;
3760
+ }
3761
+ async open(options2) {
3762
+ if (this.#terminals.size >= MAX_TERMINALS) {
3763
+ throw new Error(`\u041E\u0442\u043A\u0440\u044B\u0442\u043E \u0443\u0436\u0435 ${MAX_TERMINALS} \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B\u043E\u0432 \u2014 \u0437\u0430\u043A\u0440\u043E\u0439\u0442\u0435 \u043B\u0438\u0448\u043D\u0438\u0435`);
3764
+ }
3765
+ const pty = await this.#pty();
3766
+ const shell = await defaultShell();
3767
+ const cols = clampSize(options2.cols, 20, 500, 80);
3768
+ const rows = clampSize(options2.rows, 5, 200, 24);
3769
+ const child = pty.spawn(shell.file, shell.args, {
3770
+ name: "xterm-256color",
3771
+ cols,
3772
+ rows,
3773
+ cwd: options2.cwd,
3774
+ env: environment()
3775
+ });
3776
+ const info = {
3777
+ id: randomUUID2(),
3778
+ workspaceId: options2.workspaceId,
3779
+ title: shell.title,
3780
+ shell: shell.file,
3781
+ cwd: options2.cwd,
3782
+ cols,
3783
+ rows,
3784
+ exitCode: null
3785
+ };
3786
+ const entry = { info, pty: child, chunks: [], chunksLength: 0, pending: "", flush: null };
3787
+ this.#terminals.set(info.id, entry);
3788
+ child.onData((data) => this.#onData(entry, data));
3789
+ child.onExit(({ exitCode, signal }) => {
3790
+ this.#flush(entry);
3791
+ entry.info.exitCode = exitCode;
3792
+ this.emit("exit", info.id, exitCode, signal ?? null);
3793
+ });
3794
+ return info;
3795
+ }
3796
+ /**
3797
+ * Ввод человека — как есть.
3798
+ *
3799
+ * Ни разбора, ни экранирования, ни проверки: перед нами не «команда», а поток
3800
+ * нажатий, в котором Ctrl+C, Tab и вставка из буфера обмена неотличимы от
3801
+ * обычных букв и должны такими остаться.
3802
+ */
3803
+ write(id, data) {
3804
+ const entry = this.#terminals.get(id);
3805
+ if (!entry)
3806
+ throw new Error("\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442");
3807
+ if (entry.info.exitCode !== null)
3808
+ return;
3809
+ entry.pty.write(data);
3810
+ }
3811
+ resize(id, cols, rows) {
3812
+ const entry = this.#terminals.get(id);
3813
+ if (!entry || entry.info.exitCode !== null)
3814
+ return;
3815
+ const next = { cols: clampSize(cols, 20, 500, 80), rows: clampSize(rows, 5, 200, 24) };
3816
+ if (next.cols === entry.info.cols && next.rows === entry.info.rows)
3817
+ return;
3818
+ entry.info.cols = next.cols;
3819
+ entry.info.rows = next.rows;
3820
+ try {
3821
+ entry.pty.resize(next.cols, next.rows);
3822
+ } catch {
3823
+ }
3824
+ }
3825
+ close(id) {
3826
+ const entry = this.#terminals.get(id);
3827
+ if (!entry)
3828
+ return;
3829
+ this.#terminals.delete(id);
3830
+ if (entry.flush)
3831
+ clearTimeout(entry.flush);
3832
+ try {
3833
+ entry.pty.kill();
3834
+ } catch {
3835
+ }
3836
+ }
3837
+ /** Погасить всё: незакрытые оболочки переживут хост и останутся висеть. */
3838
+ closeAll() {
3839
+ for (const id of [...this.#terminals.keys()])
3840
+ this.close(id);
3841
+ }
3842
+ #onData(entry, data) {
3843
+ entry.pending += data;
3844
+ if (entry.flush)
3845
+ return;
3846
+ entry.flush = setTimeout(() => this.#flush(entry), FLUSH_MS);
3847
+ entry.flush.unref?.();
3848
+ }
3849
+ #flush(entry) {
3850
+ if (entry.flush) {
3851
+ clearTimeout(entry.flush);
3852
+ entry.flush = null;
3853
+ }
3854
+ const data = entry.pending;
3855
+ if (!data)
3856
+ return;
3857
+ entry.pending = "";
3858
+ entry.chunks.push(data);
3859
+ entry.chunksLength += data.length;
3860
+ while (entry.chunksLength > SCROLLBACK_BYTES && entry.chunks.length > 1) {
3861
+ entry.chunksLength -= entry.chunks.shift().length;
3862
+ }
3863
+ this.emit("data", entry.info.id, data);
3864
+ }
3865
+ #pty() {
3866
+ this.#module ??= import("@lydell/node-pty").then((module) => module).catch((error) => {
3867
+ this.#module = null;
3868
+ const reason = error instanceof Error ? error.message : String(error);
3869
+ throw new Error(`\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u043D\u0430 \u044D\u0442\u043E\u0439 \u043C\u0430\u0448\u0438\u043D\u0435: ${reason}`);
3870
+ });
3871
+ return this.#module;
3872
+ }
3873
+ };
3874
+ function clampSize(value, min, max, fallback) {
3875
+ if (!Number.isFinite(value))
3876
+ return fallback;
3877
+ return Math.min(max, Math.max(min, Math.round(value)));
3878
+ }
3879
+ function environment() {
3880
+ const env = {
3881
+ ...process.env,
3882
+ TERM: "xterm-256color",
3883
+ COLORTERM: "truecolor"
3884
+ };
3885
+ for (const key of Object.keys(env))
3886
+ if (key.startsWith("="))
3887
+ delete env[key];
3888
+ return env;
3889
+ }
3890
+ async function defaultShell() {
3891
+ if (process.platform === "win32") {
3892
+ const pwsh = await findOnPath("pwsh.exe");
3893
+ if (pwsh)
3894
+ return { file: pwsh, args: [], title: "pwsh" };
3895
+ const system = process.env.SystemRoot ?? "C:\\Windows";
3896
+ const powershell = path13.join(system, "System32/WindowsPowerShell/v1.0/powershell.exe");
3897
+ if (await exists2(powershell))
3898
+ return { file: powershell, args: [], title: "powershell" };
3899
+ return { file: process.env.COMSPEC ?? "cmd.exe", args: [], title: "cmd" };
3900
+ }
3901
+ const shell = process.env.SHELL;
3902
+ if (shell && await exists2(shell))
3903
+ return { file: shell, args: [], title: path13.basename(shell) };
3904
+ if (await exists2("/bin/bash"))
3905
+ return { file: "/bin/bash", args: [], title: "bash" };
3906
+ return { file: "/bin/sh", args: [], title: "sh" };
3907
+ }
3908
+ async function findOnPath(name) {
3909
+ const dirs = (process.env.PATH ?? "").split(path13.delimiter).filter(Boolean);
3910
+ for (const dir of dirs) {
3911
+ const candidate = path13.join(dir, name);
3912
+ if (await exists2(candidate))
3913
+ return candidate;
3914
+ }
3915
+ return null;
3916
+ }
3917
+ function exists2(file) {
3918
+ return access2(file, constants2.X_OK).then(() => true, () => false);
3919
+ }
3920
+
3200
3921
  // ../host/dist/mcp-config.js
3201
- import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir6 } from "node:fs/promises";
3202
- import path12 from "node:path";
3922
+ import { readFile as readFile10, writeFile as writeFile7, mkdir as mkdir7 } from "node:fs/promises";
3923
+ import path14 from "node:path";
3203
3924
  var MCP_FILE = ".mcp.json";
3204
- var LOCAL_SETTINGS2 = path12.join(".claude", "settings.local.json");
3925
+ var LOCAL_SETTINGS2 = path14.join(".claude", "settings.local.json");
3205
3926
  var queues = /* @__PURE__ */ new Map();
3206
3927
  function serialize(cwd, work) {
3207
3928
  const previous = queues.get(cwd) ?? Promise.resolve();
@@ -3210,7 +3931,7 @@ function serialize(cwd, work) {
3210
3931
  return next;
3211
3932
  }
3212
3933
  async function readProjectServers(cwd) {
3213
- const file = await readJson(path12.join(cwd, MCP_FILE));
3934
+ const file = await readJson(path14.join(cwd, MCP_FILE));
3214
3935
  const servers = file?.mcpServers;
3215
3936
  if (!servers || typeof servers !== "object")
3216
3937
  return {};
@@ -3218,23 +3939,23 @@ async function readProjectServers(cwd) {
3218
3939
  }
3219
3940
  function addProjectServer(cwd, name, config) {
3220
3941
  return serialize(cwd, async () => {
3221
- const file = await readJson(path12.join(cwd, MCP_FILE)) ?? {};
3942
+ const file = await readJson(path14.join(cwd, MCP_FILE)) ?? {};
3222
3943
  const servers = file.mcpServers ?? {};
3223
3944
  servers[name] = config;
3224
3945
  file.mcpServers = servers;
3225
- await writeJson(path12.join(cwd, MCP_FILE), file);
3946
+ await writeJson(path14.join(cwd, MCP_FILE), file);
3226
3947
  await approve(cwd, name);
3227
3948
  });
3228
3949
  }
3229
3950
  function removeProjectServer(cwd, name) {
3230
3951
  return serialize(cwd, async () => {
3231
- const file = await readJson(path12.join(cwd, MCP_FILE));
3952
+ const file = await readJson(path14.join(cwd, MCP_FILE));
3232
3953
  const servers = file?.mcpServers;
3233
3954
  if (!servers || !(name in servers))
3234
3955
  return false;
3235
3956
  delete servers[name];
3236
- await writeJson(path12.join(cwd, MCP_FILE), file);
3237
- const settingsPath = path12.join(cwd, LOCAL_SETTINGS2);
3957
+ await writeJson(path14.join(cwd, MCP_FILE), file);
3958
+ const settingsPath = path14.join(cwd, LOCAL_SETTINGS2);
3238
3959
  const settings = await readJson(settingsPath);
3239
3960
  const enabled = settings?.enabledMcpjsonServers;
3240
3961
  if (settings && Array.isArray(enabled) && enabled.includes(name)) {
@@ -3245,7 +3966,7 @@ function removeProjectServer(cwd, name) {
3245
3966
  });
3246
3967
  }
3247
3968
  async function approve(cwd, name) {
3248
- const file = path12.join(cwd, LOCAL_SETTINGS2);
3969
+ const file = path14.join(cwd, LOCAL_SETTINGS2);
3249
3970
  const settings = await readJson(file) ?? {};
3250
3971
  const enabled = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
3251
3972
  if (!enabled.includes(name))
@@ -3284,31 +4005,34 @@ function describeConfig(config) {
3284
4005
  }
3285
4006
  async function readJson(file) {
3286
4007
  try {
3287
- const raw = await readFile9(file, "utf8");
4008
+ const raw = await readFile10(file, "utf8");
3288
4009
  const parsed = JSON.parse(raw);
3289
4010
  return parsed && typeof parsed === "object" ? parsed : null;
3290
4011
  } catch (error) {
3291
4012
  if (error.code === "ENOENT")
3292
4013
  return null;
3293
4014
  if (error instanceof SyntaxError) {
3294
- 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}`);
4015
+ throw new Error(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C ${path14.basename(file)}: ${error.message}`);
3295
4016
  }
3296
4017
  throw error;
3297
4018
  }
3298
4019
  }
3299
4020
  async function writeJson(file, data) {
3300
- await mkdir6(path12.dirname(file), { recursive: true });
3301
- await writeFile6(file, `${JSON.stringify(data, null, 2)}
4021
+ await mkdir7(path14.dirname(file), { recursive: true });
4022
+ await writeFile7(file, `${JSON.stringify(data, null, 2)}
3302
4023
  `, "utf8");
3303
4024
  }
3304
4025
 
3305
4026
  // ../host/dist/server.js
3306
4027
  var SIDE_TITLE = "\xB7 \u0432\u043E\u043F\u0440\u043E\u0441 \u043D\u0430 \u043F\u043E\u043B\u044F\u0445";
3307
4028
  var FINDER_TITLE = "\xB7 \u043F\u043E\u0438\u0441\u043A \u043F\u0440\u043E\u0435\u043A\u0442\u0430";
4029
+ var HISTORY_PAGE = 250;
4030
+ var HISTORY_PAGE_MAX = 500;
3308
4031
  var HostServer = class {
3309
4032
  #options;
3310
4033
  #workspaces;
3311
4034
  #archive;
4035
+ #pins;
3312
4036
  #settings;
3313
4037
  #finder;
3314
4038
  #sessions = /* @__PURE__ */ new Map();
@@ -3326,18 +4050,21 @@ var HostServer = class {
3326
4050
  #clients = /* @__PURE__ */ new Set();
3327
4051
  /** Удалённые вкладки по их id в relay — чтобы адресовать ответ именно той. */
3328
4052
  #remotes = /* @__PURE__ */ new Map();
3329
- #hostId = randomUUID2();
4053
+ #hostId = randomUUID3();
3330
4054
  #plugins = new PluginCli();
3331
4055
  #auth = new AuthCli();
3332
4056
  #relay;
4057
+ #terminals = new TerminalHub();
3333
4058
  constructor(options2) {
3334
4059
  this.#options = options2;
3335
4060
  this.#workspaces = new WorkspaceRegistry(options2.dataDir);
3336
4061
  this.#archive = new SessionArchive(options2.dataDir);
4062
+ this.#pins = new SessionPins(options2.dataDir);
3337
4063
  this.#settings = new AppSettingsStore(options2.dataDir);
3338
4064
  this.#finder = new ProjectFinder(this.#workspaces);
3339
4065
  this.#relay = new RelayLink({ dataDir: options2.dataDir, defaultUrl: options2.relayUrl });
3340
4066
  this.#wireRelay();
4067
+ this.#wireTerminals();
3341
4068
  this.#auth.on("login", (state) => {
3342
4069
  this.#broadcast({ type: "auth.login", state });
3343
4070
  });
@@ -3348,6 +4075,7 @@ var HostServer = class {
3348
4075
  async start() {
3349
4076
  await this.#workspaces.load();
3350
4077
  await this.#archive.load();
4078
+ await this.#pins.load();
3351
4079
  await this.#settings.load();
3352
4080
  await warmClaudeLauncher();
3353
4081
  void this.#finder.warm();
@@ -3373,6 +4101,7 @@ var HostServer = class {
3373
4101
  }
3374
4102
  async shutdown() {
3375
4103
  this.#auth.cancel();
4104
+ this.#terminals.closeAll();
3376
4105
  await this.#relay.close();
3377
4106
  await Promise.all([...this.#sessions.values()].map((s) => s.close("host shutdown")));
3378
4107
  this.#sessions.clear();
@@ -3436,6 +4165,36 @@ var HostServer = class {
3436
4165
  this.#broadcast({ type: "relay.state", state: this.#relay.state });
3437
4166
  });
3438
4167
  }
4168
+ // -------------------------------------------------------------------------
4169
+ // Терминал
4170
+ // -------------------------------------------------------------------------
4171
+ #wireTerminals() {
4172
+ this.#terminals.on("data", (id, data) => {
4173
+ this.#broadcastTerminal({ type: "term.data", id, data });
4174
+ });
4175
+ this.#terminals.on("exit", (id, exitCode, signal) => {
4176
+ this.#broadcastTerminal({ type: "term.exit", id, exitCode, signal });
4177
+ });
4178
+ }
4179
+ /**
4180
+ * Вывод терминала — всем локальным вкладкам, удалённым только по праву.
4181
+ *
4182
+ * Отдельно от `#broadcast` именно поэтому: обычный broadcast уходит и в
4183
+ * relay, а вывод оболочки — то самое, что удалённой стороне видеть не
4184
+ * положено, пока доступ не подтверждён письмом. Ошибиться здесь дороже, чем
4185
+ * в проверке входящего кадра: вход можно не заметить и отклонить, а вывод,
4186
+ * ушедший наружу, уже ушёл.
4187
+ */
4188
+ #broadcastTerminal(frame) {
4189
+ if (this.#relay.shell.enabled) {
4190
+ this.#broadcast(frame);
4191
+ return;
4192
+ }
4193
+ for (const client of this.#clients) {
4194
+ if (client.local)
4195
+ client.send(frame);
4196
+ }
4197
+ }
3439
4198
  #adoptRemote(clientId) {
3440
4199
  const client = {
3441
4200
  local: false,
@@ -3509,10 +4268,17 @@ var HostServer = class {
3509
4268
  continue;
3510
4269
  await this.#dropSession(session.id, "workspace closed");
3511
4270
  }
4271
+ let unpinned = false;
4272
+ for (const id of gone) {
4273
+ if (await this.#pins.forgetWorkspace(id))
4274
+ unpinned = true;
4275
+ }
3512
4276
  this.#broadcast({
3513
4277
  type: "workspace.list",
3514
4278
  workspaces: this.#workspaces.list()
3515
4279
  });
4280
+ if (unpinned)
4281
+ await this.#sendPinned();
3516
4282
  return;
3517
4283
  }
3518
4284
  case "workspace.find": {
@@ -3575,6 +4341,27 @@ var HostServer = class {
3575
4341
  await this.#archive.unarchive(frame.sessionIds);
3576
4342
  await this.#sendSessionList(frame.workspaceId);
3577
4343
  return;
4344
+ case "session.pin": {
4345
+ const ids = new Set(frame.sessionIds);
4346
+ for (const session of [...this.#sessions.values()]) {
4347
+ const diskId = session.claudeSessionId;
4348
+ if (!diskId)
4349
+ continue;
4350
+ if (ids.has(session.id))
4351
+ ids.add(diskId);
4352
+ if (ids.has(diskId))
4353
+ ids.add(session.id);
4354
+ }
4355
+ await this.#pins.set(frame.workspaceId, [...ids], frame.pinned);
4356
+ const hadPins = this.#pins.workspaceIds().includes(frame.workspaceId);
4357
+ await this.#sendSessionList(frame.workspaceId);
4358
+ if (!hadPins)
4359
+ await this.#sendPinned();
4360
+ return;
4361
+ }
4362
+ case "session.pins":
4363
+ await this.#sendPinned();
4364
+ return;
3578
4365
  case "session.rename":
3579
4366
  await this.#renameSession(frame.sessionId, frame.title, frame.workspaceId);
3580
4367
  return;
@@ -3596,11 +4383,33 @@ var HostServer = class {
3596
4383
  usage: session.usage
3597
4384
  });
3598
4385
  }
4386
+ if (session.context) {
4387
+ this.#send(socket, {
4388
+ type: "context.state",
4389
+ sessionId: session.id,
4390
+ context: session.context
4391
+ });
4392
+ }
3599
4393
  if (entries.length > 0) {
3600
4394
  this.#send(socket, { type: "session.events", sessionId: session.id, entries });
3601
4395
  }
3602
4396
  return;
3603
4397
  }
4398
+ case "session.history": {
4399
+ const session = this.#requireSession(frame.sessionId);
4400
+ const all = await session.history();
4401
+ const end = typeof frame.before === "number" ? Math.max(0, Math.min(all.length, frame.before - 1)) : all.length;
4402
+ const limit = Math.min(Math.max(frame.limit ?? HISTORY_PAGE, 1), HISTORY_PAGE_MAX);
4403
+ const start = Math.max(0, end - limit);
4404
+ this.#send(socket, {
4405
+ type: "session.history",
4406
+ sessionId: session.id,
4407
+ entries: all.slice(start, end),
4408
+ from: start + 1,
4409
+ total: all.length
4410
+ });
4411
+ return;
4412
+ }
3604
4413
  case "usage.get": {
3605
4414
  const session = this.#requireSession(frame.sessionId);
3606
4415
  if (session.usage) {
@@ -3613,6 +4422,18 @@ var HostServer = class {
3613
4422
  await session.refreshUsage(true);
3614
4423
  return;
3615
4424
  }
4425
+ case "context.get": {
4426
+ const session = this.#requireSession(frame.sessionId);
4427
+ if (session.context) {
4428
+ this.#send(socket, {
4429
+ type: "context.state",
4430
+ sessionId: session.id,
4431
+ context: session.context
4432
+ });
4433
+ }
4434
+ await session.refreshContext(true);
4435
+ return;
4436
+ }
3616
4437
  case "prompt.send":
3617
4438
  this.#requireSession(frame.sessionId).sendPrompt(frame.text);
3618
4439
  return;
@@ -3822,6 +4643,54 @@ var HostServer = class {
3822
4643
  }
3823
4644
  return;
3824
4645
  }
4646
+ // Терминал. Каждая ручка начинается с одной и той же проверки права: их
4647
+ // пять, и «забыл в одной» здесь означает не потерянную кнопку, а чужую
4648
+ // оболочку в руках у того, кому её не давали.
4649
+ case "term.list":
4650
+ this.#requireShellAccess(socket);
4651
+ this.#send(socket, {
4652
+ type: "term.list",
4653
+ workspaceId: frame.workspaceId,
4654
+ terminals: this.#terminals.list(frame.workspaceId)
4655
+ });
4656
+ return;
4657
+ case "term.open": {
4658
+ this.#requireShellAccess(socket);
4659
+ const workspace = this.#workspaces.find(frame.workspaceId);
4660
+ if (!workspace)
4661
+ throw new Error(`\u0412\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${frame.workspaceId}`);
4662
+ const terminal = await this.#terminals.open({
4663
+ workspaceId: workspace.id,
4664
+ cwd: workspace.path,
4665
+ cols: frame.cols,
4666
+ rows: frame.rows
4667
+ });
4668
+ this.#broadcastTerminal({ type: "term.opened", terminal });
4669
+ return;
4670
+ }
4671
+ case "term.attach":
4672
+ this.#requireShellAccess(socket);
4673
+ if (!this.#terminals.has(frame.id))
4674
+ throw new Error("\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442");
4675
+ this.#send(socket, {
4676
+ type: "term.buffer",
4677
+ id: frame.id,
4678
+ data: this.#terminals.buffer(frame.id)
4679
+ });
4680
+ return;
4681
+ case "term.input":
4682
+ this.#requireShellAccess(socket);
4683
+ this.#terminals.write(frame.id, frame.data);
4684
+ return;
4685
+ case "term.resize":
4686
+ this.#requireShellAccess(socket);
4687
+ this.#terminals.resize(frame.id, frame.cols, frame.rows);
4688
+ return;
4689
+ case "term.close":
4690
+ this.#requireShellAccess(socket);
4691
+ this.#terminals.close(frame.id);
4692
+ this.#broadcastTerminal({ type: "term.closed", id: frame.id });
4693
+ return;
3825
4694
  case "settings.get":
3826
4695
  this.#send(socket, { type: "settings.state", settings: this.#settings.current });
3827
4696
  return;
@@ -3834,6 +4703,12 @@ var HostServer = class {
3834
4703
  await this.#sendSessionList(workspace.id);
3835
4704
  }
3836
4705
  }
4706
+ if (before.terminalEnabled && !settings.terminalEnabled) {
4707
+ for (const terminal of this.#terminals.list()) {
4708
+ this.#terminals.close(terminal.id);
4709
+ this.#broadcastTerminal({ type: "term.closed", id: terminal.id });
4710
+ }
4711
+ }
3837
4712
  return;
3838
4713
  }
3839
4714
  case "auth.status":
@@ -3863,7 +4738,40 @@ var HostServer = class {
3863
4738
  this.#requireLocal(socket, "\u041E\u0442\u0432\u044F\u0437\u0430\u0442\u044C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440");
3864
4739
  await this.#relay.unpair();
3865
4740
  return;
4741
+ case "relay.shell.enable":
4742
+ if (!this.#settings.current.terminalEnabled) {
4743
+ throw new Error("\u0421\u043D\u0430\u0447\u0430\u043B\u0430 \u0432\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0442\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0432 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0430\u0445 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F \u2014 \u0443\u0434\u0430\u043B\u0451\u043D\u043D\u044B\u0439 \u0434\u043E\u0441\u0442\u0443\u043F \u0434\u0430\u0451\u0442\u0441\u044F \u043A \u043D\u0435\u043C\u0443, \u0430 \u0435\u0433\u043E \u0441\u0435\u0439\u0447\u0430\u0441 \u043D\u0435\u0442.");
4744
+ }
4745
+ if (socket.local)
4746
+ await this.#relay.enableShell();
4747
+ else
4748
+ await this.#relay.requestShell();
4749
+ return;
4750
+ case "relay.shell.disable":
4751
+ await this.#relay.disableShell();
4752
+ return;
4753
+ }
4754
+ }
4755
+ /**
4756
+ * Терминал — по двум условиям сразу: включён ли он вообще и, для удалённой
4757
+ * вкладки, подтверждено ли право.
4758
+ *
4759
+ * Первое — настройка самой машины (`AppSettings.terminalEnabled`), выключена
4760
+ * по умолчанию: пока её не подняли, терминала нет ни у кого. Второе, поверх
4761
+ * первого: локальному клиенту не отказываем никогда — он и так за этой
4762
+ * машиной, где терминал открывается и без нас. Удалённому — по состоянию,
4763
+ * которое ведёт
4764
+ * relay: право включают либо на самой машине (сразу, физический доступ и
4765
+ * есть подтверждение), либо из удалённой вкладки, и тогда его включает не
4766
+ * нажатие кнопки, а переход по ссылке из письма на почту аккаунта.
4767
+ */
4768
+ #requireShellAccess(client) {
4769
+ if (!this.#settings.current.terminalEnabled) {
4770
+ throw new Error("\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0432\u044B\u043A\u043B\u044E\u0447\u0435\u043D. \u0412\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0435\u0433\u043E \u0432 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0430\u0445 \u043F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F, \u0440\u0430\u0437\u0434\u0435\u043B \xAB\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B\xBB.");
3866
4771
  }
4772
+ if (client.local || this.#relay.shell.enabled)
4773
+ return;
4774
+ throw new Error("\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0441 \u0443\u0434\u0430\u043B\u0451\u043D\u043D\u044B\u0445 \u0443\u0441\u0442\u0440\u043E\u0439\u0441\u0442\u0432 \u0432\u044B\u043A\u043B\u044E\u0447\u0435\u043D. \u0412\u043A\u043B\u044E\u0447\u0438\u0442\u0435 \u0435\u0433\u043E \u0432 \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0430\u0445 \u0443\u0434\u0430\u043B\u0451\u043D\u043D\u043E\u0433\u043E \u0434\u043E\u0441\u0442\u0443\u043F\u0430 \u2014 \u043D\u0430 \u0441\u0430\u043C\u043E\u043C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440\u0435 \u043E\u043D \u0432\u043A\u043B\u044E\u0447\u0430\u0435\u0442\u0441\u044F \u0441\u0440\u0430\u0437\u0443, \u043E\u0442\u0441\u044E\u0434\u0430 \u043F\u043E\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044F \u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0438\u0435 \u0441\u0441\u044B\u043B\u043A\u043E\u0439 \u0438\u0437 \u043F\u0438\u0441\u044C\u043C\u0430.");
3867
4775
  }
3868
4776
  /**
3869
4777
  * Настройки удалённого доступа меняются только с самой машины.
@@ -3997,6 +4905,32 @@ var HostServer = class {
3997
4905
  async #sendSessionList(workspaceId) {
3998
4906
  const { sessions, archived } = await this.#listSessions(workspaceId);
3999
4907
  this.#broadcast({ type: "session.list", workspaceId, sessions, archived });
4908
+ if (this.#pins.workspaceIds().includes(workspaceId))
4909
+ await this.#sendPinned();
4910
+ }
4911
+ /**
4912
+ * Разослать закреплённые сессии — по всем проектам сразу.
4913
+ *
4914
+ * Собирается из тех же списков, что и панель проекта (`#listSessions`), а не
4915
+ * из одного реестра закреплений: в панели закреплённых у строки должны быть
4916
+ * имя, состояние и время последнего события, а реестр знает только id. Обход
4917
+ * идёт лишь по проектам, где вообще что-то закреплено, — их обычно единицы, и
4918
+ * читать диск по всем открытым проектам ради этого незачем.
4919
+ *
4920
+ * Архивные сюда тоже попадают: закреплённое в архив само не уезжает, но
4921
+ * убрать туда руками уже закреплённую сессию человеку никто не мешает, и
4922
+ * терять её из панели после этого было бы странно.
4923
+ */
4924
+ async #sendPinned() {
4925
+ const sessions = [];
4926
+ for (const workspaceId of this.#pins.workspaceIds()) {
4927
+ if (!this.#workspaces.find(workspaceId))
4928
+ continue;
4929
+ const { sessions: live, archived } = await this.#listSessions(workspaceId);
4930
+ sessions.push(...[...live, ...archived].filter((s) => s.pinned));
4931
+ }
4932
+ sessions.sort((a, b) => (a.pinnedAt ?? 0) - (b.pinnedAt ?? 0));
4933
+ this.#broadcast({ type: "session.pinned", sessions });
4000
4934
  }
4001
4935
  async #startSession(socket, frame) {
4002
4936
  const workspace = this.#workspaces.find(frame.workspaceId);
@@ -4006,13 +4940,14 @@ var HostServer = class {
4006
4940
  const resumedTitle = resumed ? diskTitle(resumed) : null;
4007
4941
  if (frame.resume)
4008
4942
  await this.#archive.unarchive([frame.resume]);
4943
+ const startDefaults = this.#settings.current;
4009
4944
  const session = new ClaudeSession({
4010
4945
  workspaceId: workspace.id,
4011
4946
  cwd: workspace.path,
4012
4947
  resume: frame.resume,
4013
4948
  fork: frame.fork,
4014
- permissionMode: frame.permissionMode,
4015
- model: frame.model,
4949
+ permissionMode: frame.permissionMode ?? startDefaults.startPermissionMode,
4950
+ model: frame.model ?? startDefaults.startModel ?? void 0,
4016
4951
  ...resumedTitle ? { title: resumedTitle } : {},
4017
4952
  // Имя проекта вместо «Без названия»: свежую сессию в списке уже надо
4018
4953
  // как-то называть, а сказать о ней пока нечего, кроме того, где она.
@@ -4036,6 +4971,9 @@ var HostServer = class {
4036
4971
  });
4037
4972
  session.on("summary", (summary) => {
4038
4973
  this.#broadcast({ type: "session.summary", session: summary });
4974
+ if (!summary.claudeSessionId || !this.#pins.has(summary.id))
4975
+ return;
4976
+ void this.#pins.migrate(summary.id, summary.claudeSessionId).then((moved) => moved ? this.#sendPinned() : void 0).catch(() => void 0);
4039
4977
  });
4040
4978
  session.on("capabilities", () => {
4041
4979
  if (!session.capabilities)
@@ -4049,6 +4987,9 @@ var HostServer = class {
4049
4987
  session.on("usage", (usage) => {
4050
4988
  this.#broadcast({ type: "usage.state", sessionId: session.id, usage });
4051
4989
  });
4990
+ session.on("context", (context) => {
4991
+ this.#broadcast({ type: "context.state", sessionId: session.id, context });
4992
+ });
4052
4993
  session.on("stderr", (data) => {
4053
4994
  process.stderr.write(`[claude ${session.id.slice(0, 8)}] ${data}`);
4054
4995
  });
@@ -4298,6 +5239,9 @@ var HostServer = class {
4298
5239
  parentSessionId: null,
4299
5240
  title: diskTitle(info),
4300
5241
  state: "closed",
5242
+ // Хода в этой жизни у неё не было: сессия не поднята, и чем кончился
5243
+ // последний ход прошлой, знает только её JSONL.
5244
+ lastTurn: null,
4301
5245
  model: null,
4302
5246
  permissionMode: "default",
4303
5247
  // Сессия с диска не поднята: чем она была настроена, знает только её
@@ -4311,10 +5255,14 @@ var HostServer = class {
4311
5255
  lastSeq: 0,
4312
5256
  totalCostUsd: 0
4313
5257
  }));
4314
- 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);
5258
+ const known = /* @__PURE__ */ new Set([...liveIds, ...historical.map((s) => s.id)]);
5259
+ await this.#archive.sweep(workspaceId, historical.filter((s) => !this.#pins.has(s.id)).map((s) => ({ id: s.id, updatedAt: s.updatedAt })), known, this.#settings.current.autoArchiveAfterMs);
5260
+ await this.#pins.sweep(workspaceId, known);
4315
5261
  const sessions = [];
4316
5262
  const archived = [];
4317
- for (const summary of [...live, ...historical].sort((a, b) => b.updatedAt - a.updatedAt)) {
5263
+ for (const base of [...live, ...historical].sort((a, b) => b.updatedAt - a.updatedAt)) {
5264
+ const pin = this.#pins.find(base.claudeSessionId ?? base.id);
5265
+ const summary = pin ? { ...base, pinned: true, pinnedAt: pin.at } : base;
4318
5266
  const record = this.#archive.find(summary.claudeSessionId ?? summary.id);
4319
5267
  if (!record) {
4320
5268
  sessions.push(summary);
@@ -4352,8 +5300,8 @@ var HostServer = class {
4352
5300
  return;
4353
5301
  }
4354
5302
  const url2 = new URL(req.url ?? "/", "http://localhost");
4355
- const requested = path13.join(webRoot, path13.normalize(url2.pathname));
4356
- const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path13.join(webRoot, "index.html");
5303
+ const requested = path15.join(webRoot, path15.normalize(url2.pathname));
5304
+ const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path15.join(webRoot, "index.html");
4357
5305
  if (!existsSync3(target)) {
4358
5306
  res.writeHead(404).end("not found");
4359
5307
  return;
@@ -4379,12 +5327,12 @@ function contentType(file) {
4379
5327
  ".svg": "image/svg+xml",
4380
5328
  ".woff2": "font/woff2"
4381
5329
  };
4382
- return types[path13.extname(file)] ?? "application/octet-stream";
5330
+ return types[path15.extname(file)] ?? "application/octet-stream";
4383
5331
  }
4384
5332
 
4385
5333
  // dist/ccv.js
4386
- var HERE = path14.dirname(fileURLToPath(import.meta.url));
4387
- var PACKAGE_ROOT = path14.resolve(HERE, "..");
5334
+ var HERE = path16.dirname(fileURLToPath(import.meta.url));
5335
+ var PACKAGE_ROOT = path16.resolve(HERE, "..");
4388
5336
  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.
4389
5337
 
4390
5338
  \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435:
@@ -4451,7 +5399,7 @@ var server = new HostServer({
4451
5399
  dataDir: defaults.dataDir,
4452
5400
  // UI лежит в пакете рядом с бандлом — это и есть разница между установленной
4453
5401
  // утилитой и запуском из репозитория, где сборка живёт в `apps/web/dist`.
4454
- webRoot: process.env.CCV_WEB_ROOT ?? path14.join(PACKAGE_ROOT, "web"),
5402
+ webRoot: process.env.CCV_WEB_ROOT ?? path16.join(PACKAGE_ROOT, "web"),
4455
5403
  version,
4456
5404
  relayUrl: defaults.relayUrl
4457
5405
  });
@@ -4480,7 +5428,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
4480
5428
  }
4481
5429
  async function packageVersion() {
4482
5430
  try {
4483
- const text = await readFile10(path14.join(PACKAGE_ROOT, "package.json"), "utf8");
5431
+ const text = await readFile11(path16.join(PACKAGE_ROOT, "package.json"), "utf8");
4484
5432
  return JSON.parse(text).version ?? "0.0.0";
4485
5433
  } catch {
4486
5434
  return "0.0.0";