@i14ks/ccv 0.2.0 → 0.3.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 +936 -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-DNwOY4Od.js +1 -0
  9. package/web/assets/index--X3c59oq.js +7 -0
  10. package/web/assets/index-1vcRYewd.js +1 -0
  11. package/web/assets/index-6JwHpeCk.js +1 -0
  12. package/web/assets/index-891wE-Rj.js +7 -0
  13. package/web/assets/index-9FU2NRHn.js +13 -0
  14. package/web/assets/index-9FstbMxt.js +1 -0
  15. package/web/assets/{index-1iYnLw7q.js → index-AptXdWK3.js} +1 -1
  16. package/web/assets/index-BFn7R550.js +1 -0
  17. package/web/assets/index-BFoyQq0x.js +1 -0
  18. package/web/assets/index-BHot-lnj.js +5 -0
  19. package/web/assets/index-Bh-gxu6Z.js +1 -0
  20. package/web/assets/index-Bj3bZRgw.js +2 -0
  21. package/web/assets/index-BvcLb1wh.js +1 -0
  22. package/web/assets/index-C9MQTTvT.js +1 -0
  23. package/web/assets/{index-D-buiUaW.js → index-CKclwUJ-.js} +2 -2
  24. package/web/assets/index-Cfccn-Ob.js +1 -0
  25. package/web/assets/index-Cvz4rtnD.js +1 -0
  26. package/web/assets/{index-C9vrRfhW.js → index-CzlH_Mh9.js} +3 -3
  27. package/web/assets/index-D9UhxKGI.js +126 -0
  28. package/web/assets/index-DB9ZVtJ8.js +1 -0
  29. package/web/assets/index-DmJcZf8t.css +1 -0
  30. package/web/assets/index-DtcfXsIX.js +3 -0
  31. package/web/assets/index-r40StboH.js +1 -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;
@@ -704,10 +905,47 @@ var ClaudeSession = class extends EventEmitter {
704
905
  cwd;
705
906
  journal = new Journal();
706
907
  #query = null;
908
+ /**
909
+ * Разговор, который был до нас, — прочитанный транскрипт продолженной сессии.
910
+ *
911
+ * Читается один раз, на старте, и больше не меняется: всё, что случится
912
+ * дальше, попадает в журнал. Промис, а не массив, потому что чтение
913
+ * асинхронное, а спросить историю могут раньше, чем оно закончится, — тогда
914
+ * запрос дождётся того же чтения вместо второго такого же. `null` — сессия
915
+ * начата с нуля, и продолжать нечего.
916
+ */
917
+ #history = null;
707
918
  /** Чем подняли эту сессию — нужно, чтобы объяснить провал старта. */
708
919
  #executable = { source: "bundled" };
709
920
  #inbox = new AsyncQueue();
921
+ /**
922
+ * Размер контекста на последнем запросе к API — то, что показывает полоса
923
+ * контекста в UI.
924
+ *
925
+ * Считается здесь, а не в UI из `result.usage`: SDK складывает в него все
926
+ * запросы хода разом, и разделить их обратно уже нечем. Usage
927
+ * `assistant`-сообщения, наоборот, всегда про один запрос — запоминаем
928
+ * последнее и отдаём его вместе с `result`.
929
+ */
930
+ #contextTokens = null;
710
931
  #state = "starting";
932
+ /**
933
+ * Чем кончился последний ход — см. `TurnOutcome` в протоколе.
934
+ *
935
+ * Обновляется на `result`, обнуляется новым промптом: свежий ход ещё ничем не
936
+ * кончился, и прошлый исход рядом с ним читался бы как исход этого.
937
+ */
938
+ #lastTurn = null;
939
+ /**
940
+ * Человек нажал «Стоп» — до того, как пришёл `result` этого хода.
941
+ *
942
+ * Запасной путь к «прервали»: обычно исход виден по `terminal_reason`
943
+ * (`aborted_streaming`/`aborted_tools`, проверено пробой), но поле
944
+ * необязательное, а версия CLI живёт отдельно от нашей. Тогда решает флаг:
945
+ * если мы сами только что просили прервать, оборвавшийся ход — прерванный, а
946
+ * не упавший.
947
+ */
948
+ #interruptRequested = false;
711
949
  #capabilities = null;
712
950
  #pendingPermissions = /* @__PURE__ */ new Map();
713
951
  #model = null;
@@ -806,6 +1044,16 @@ var ClaudeSession = class extends EventEmitter {
806
1044
  get usage() {
807
1045
  return this.#usage;
808
1046
  }
1047
+ /**
1048
+ * Разговор, который был до старта этой сессии, — целиком и по порядку.
1049
+ *
1050
+ * Пустой массив у сессии, начатой с нуля. Ждёт чтения транскрипта, если оно
1051
+ * ещё идёт: клиент спрашивает историю сразу после открытия сессии, и ответ
1052
+ * «пока пусто» он принял бы за «истории нет».
1053
+ */
1054
+ async history() {
1055
+ return await this.#history ?? [];
1056
+ }
809
1057
  summary() {
810
1058
  return {
811
1059
  id: this.id,
@@ -815,6 +1063,7 @@ var ClaudeSession = class extends EventEmitter {
815
1063
  parentSessionId: this.parentSessionId,
816
1064
  title: this.#title,
817
1065
  state: this.#state,
1066
+ lastTurn: this.#lastTurn,
818
1067
  model: this.#model,
819
1068
  permissionMode: this.#permissionMode,
820
1069
  effort: this.#effort,
@@ -833,6 +1082,10 @@ var ClaudeSession = class extends EventEmitter {
833
1082
  start() {
834
1083
  if (this.#query)
835
1084
  throw new Error("session already started");
1085
+ if (this.#options.resume && !this.#options.fork) {
1086
+ const until = Date.now();
1087
+ this.#history = readTranscript(this.#options.resume, { dir: this.cwd, until });
1088
+ }
836
1089
  const options2 = {
837
1090
  cwd: this.cwd,
838
1091
  // Без этого SDK не читает с диска ничего: ни CLAUDE.md, ни кастомные
@@ -942,6 +1195,8 @@ var ClaudeSession = class extends EventEmitter {
942
1195
  });
943
1196
  return;
944
1197
  }
1198
+ this.#lastTurn = null;
1199
+ this.#interruptRequested = false;
945
1200
  this.#setState("running");
946
1201
  this.#inbox.push({
947
1202
  type: "user",
@@ -988,6 +1243,7 @@ var ClaudeSession = class extends EventEmitter {
988
1243
  this.#setState("running");
989
1244
  }
990
1245
  async interrupt() {
1246
+ this.#interruptRequested = true;
991
1247
  await this.#query?.interrupt();
992
1248
  }
993
1249
  /**
@@ -1194,7 +1450,7 @@ var ClaudeSession = class extends EventEmitter {
1194
1450
  const pass = { continue: true };
1195
1451
  if (!this.workspaceId)
1196
1452
  return pass;
1197
- const roots = this.#options.writableRoots?.() ?? [this.cwd];
1453
+ const roots = this.#options.writableRoots ? this.#options.writableRoots() : [this.cwd];
1198
1454
  if (roots === null)
1199
1455
  return pass;
1200
1456
  const target = writeTarget(input.tool_input);
@@ -1306,6 +1562,10 @@ var ClaudeSession = class extends EventEmitter {
1306
1562
  this.#handleSystem(message);
1307
1563
  break;
1308
1564
  case "assistant":
1565
+ if (message.parent_tool_use_id === null && message.message.usage) {
1566
+ const usage = message.message.usage;
1567
+ this.#contextTokens = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
1568
+ }
1309
1569
  for (const block of message.message.content) {
1310
1570
  const blockId = `${message.message.id}:${this.#nextBlockIndex(message.message.id)}`;
1311
1571
  if (block.type === "text") {
@@ -1349,6 +1609,7 @@ var ClaudeSession = class extends EventEmitter {
1349
1609
  durationMs: message.duration_ms,
1350
1610
  costUsd: message.total_cost_usd ?? null,
1351
1611
  usage,
1612
+ contextTokens: this.#contextTokens,
1352
1613
  text: "result" in message ? message.result : null
1353
1614
  });
1354
1615
  if (this.#options.outputFormat) {
@@ -1357,7 +1618,12 @@ var ClaudeSession = class extends EventEmitter {
1357
1618
  text: "result" in message ? message.result : null
1358
1619
  });
1359
1620
  }
1621
+ this.#lastTurn = turnOutcome("terminal_reason" in message ? message.terminal_reason : void 0, message.subtype, this.#interruptRequested);
1622
+ this.#interruptRequested = false;
1623
+ const alreadyIdle = this.#state === "idle";
1360
1624
  this.#setState("idle");
1625
+ if (alreadyIdle)
1626
+ this.emit("summary", this.summary());
1361
1627
  if (this.#unpersistedTitle)
1362
1628
  void this.#persistTitle();
1363
1629
  void this.refreshUsage();
@@ -1470,7 +1736,7 @@ var ClaudeSession = class extends EventEmitter {
1470
1736
  this.#record({
1471
1737
  kind: "tool.result",
1472
1738
  toolUseId: block.tool_use_id,
1473
- content: contentToText(block.content),
1739
+ content: contentToText2(block.content),
1474
1740
  isError: block.is_error === true
1475
1741
  });
1476
1742
  }
@@ -1551,7 +1817,7 @@ function toUsageWindow(raw) {
1551
1817
  resetsAt: typeof raw.resets_at === "string" ? raw.resets_at : ""
1552
1818
  };
1553
1819
  }
1554
- function contentToText(content) {
1820
+ function contentToText2(content) {
1555
1821
  if (typeof content === "string")
1556
1822
  return content;
1557
1823
  if (!Array.isArray(content))
@@ -1932,9 +2198,9 @@ function parsePatch(raw) {
1932
2198
  body = "";
1933
2199
  }
1934
2200
  total += body.length;
1935
- const path15 = status === "deleted" ? oldPath : newPath || oldPath;
2201
+ const path17 = status === "deleted" ? oldPath : newPath || oldPath;
1936
2202
  files.push({
1937
- path: path15,
2203
+ path: path17,
1938
2204
  oldPath: status === "renamed" ? oldPath : "",
1939
2205
  status,
1940
2206
  added: count(body, "+"),
@@ -1946,10 +2212,10 @@ function parsePatch(raw) {
1946
2212
  return { files, truncated };
1947
2213
  }
1948
2214
  function stripPrefix(value) {
1949
- const path15 = unquote(value.trim());
1950
- if (path15 === "/dev/null")
2215
+ const path17 = unquote(value.trim());
2216
+ if (path17 === "/dev/null")
1951
2217
  return "";
1952
- return path15.replace(/^[ab]\//, "");
2218
+ return path17.replace(/^[ab]\//, "");
1953
2219
  }
1954
2220
  function count(body, mark) {
1955
2221
  let n = 0;
@@ -2522,14 +2788,139 @@ function isRecord(value) {
2522
2788
  return typeof value === "object" && value !== null && typeof value.workspaceId === "string" && typeof value.at === "number";
2523
2789
  }
2524
2790
 
2525
- // ../host/dist/app-settings.js
2791
+ // ../host/dist/pins.js
2526
2792
  import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2527
- import { homedir as homedir3 } from "node:os";
2528
2793
  import path9 from "node:path";
2794
+ var SessionPins = class {
2795
+ #pinned = /* @__PURE__ */ new Map();
2796
+ #storePath;
2797
+ constructor(dataDir) {
2798
+ this.#storePath = path9.join(dataDir, "session-pins.json");
2799
+ }
2800
+ async load() {
2801
+ try {
2802
+ const raw = await readFile6(this.#storePath, "utf8");
2803
+ const parsed = JSON.parse(raw);
2804
+ this.#pinned = new Map(Object.entries(parsed.pinned ?? {}).filter(([, r]) => isRecord2(r)));
2805
+ } catch {
2806
+ this.#pinned = /* @__PURE__ */ new Map();
2807
+ }
2808
+ }
2809
+ find(sessionId) {
2810
+ return this.#pinned.get(sessionId);
2811
+ }
2812
+ has(sessionId) {
2813
+ return this.#pinned.has(sessionId);
2814
+ }
2815
+ /** Проекты, в которых есть хоть что-то закреплённое, — по ним собирается панель. */
2816
+ workspaceIds() {
2817
+ return [...new Set([...this.#pinned.values()].map((r) => r.workspaceId))];
2818
+ }
2819
+ /** Закрепить или снять закрепление. Возвращает `true`, если что-то изменилось. */
2820
+ async set(workspaceId, sessionIds, pinned) {
2821
+ let changed = false;
2822
+ const at = Date.now();
2823
+ for (const id of sessionIds) {
2824
+ if (pinned) {
2825
+ if (this.#pinned.has(id))
2826
+ continue;
2827
+ this.#pinned.set(id, { workspaceId, at });
2828
+ } else if (!this.#pinned.delete(id)) {
2829
+ continue;
2830
+ }
2831
+ changed = true;
2832
+ }
2833
+ if (changed)
2834
+ await this.#persist();
2835
+ return changed;
2836
+ }
2837
+ /**
2838
+ * Перенести закрепление с локального uuid на настоящий id Claude Code.
2839
+ *
2840
+ * Свежую сессию закрепляют до первого обмена — тогда её дискового id ещё не
2841
+ * существует, и в реестр попадает временный uuid. Как только id появился,
2842
+ * запись обязана переехать на него: под uuid сессию не найдёт ни список
2843
+ * (`#listSessions` ищет закрепление по дисковому id), ни следующий запуск
2844
+ * хоста — там этого uuid нет вовсе, и подметание стёрло бы закрепление
2845
+ * молча.
2846
+ *
2847
+ * Время закрепления переносится как есть: порядок в панели задан им, и
2848
+ * строка не должна прыгать в конец списка от того, что сессия наконец
2849
+ * дошла до `system/init`.
2850
+ */
2851
+ async migrate(fromId, toId) {
2852
+ const record = this.#pinned.get(fromId);
2853
+ if (!record || fromId === toId)
2854
+ return false;
2855
+ this.#pinned.delete(fromId);
2856
+ if (!this.#pinned.has(toId))
2857
+ this.#pinned.set(toId, record);
2858
+ await this.#persist();
2859
+ return true;
2860
+ }
2861
+ /**
2862
+ * Забыть записи о сессиях, которых больше нет на диске.
2863
+ *
2864
+ * Зовётся оттуда же, откуда подметается архив, и делает то же самое: реестр
2865
+ * не должен копить ссылки на исчезнувшие транскрипты. `known` — все сессии
2866
+ * проекта, живые и с диска.
2867
+ */
2868
+ async sweep(workspaceId, known) {
2869
+ let changed = false;
2870
+ for (const [id, record] of this.#pinned) {
2871
+ if (record.workspaceId !== workspaceId || known.has(id))
2872
+ continue;
2873
+ this.#pinned.delete(id);
2874
+ changed = true;
2875
+ }
2876
+ if (changed)
2877
+ await this.#persist();
2878
+ return changed;
2879
+ }
2880
+ /**
2881
+ * Забыть всё закреплённое в проекте — при его закрытии.
2882
+ *
2883
+ * Закрытый проект не показывается нигде, и закреплённая сессия из него
2884
+ * висела бы в панели строкой, по клику на которую открывать нечего.
2885
+ */
2886
+ async forgetWorkspace(workspaceId) {
2887
+ let changed = false;
2888
+ for (const [id, record] of this.#pinned) {
2889
+ if (record.workspaceId !== workspaceId)
2890
+ continue;
2891
+ this.#pinned.delete(id);
2892
+ changed = true;
2893
+ }
2894
+ if (changed)
2895
+ await this.#persist();
2896
+ return changed;
2897
+ }
2898
+ async #persist() {
2899
+ const file = { pinned: Object.fromEntries(this.#pinned) };
2900
+ await mkdir4(path9.dirname(this.#storePath), { recursive: true });
2901
+ await writeFile4(this.#storePath, JSON.stringify(file, null, 2), "utf8");
2902
+ }
2903
+ };
2904
+ function isRecord2(value) {
2905
+ return typeof value === "object" && value !== null && typeof value.workspaceId === "string" && typeof value.at === "number";
2906
+ }
2907
+
2908
+ // ../host/dist/app-settings.js
2909
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
2910
+ import { homedir as homedir3 } from "node:os";
2911
+ import path10 from "node:path";
2529
2912
  var DEFAULT_AUTO_ARCHIVE_AFTER_MS = 2 * 24 * 60 * 60 * 1e3;
2530
2913
  var MIN_AUTO_ARCHIVE_AFTER_MS = 60 * 60 * 1e3;
2531
2914
  var MIN_AUTO_SAVE_AFTER_PAUSE_MS = 500;
2532
2915
  var FOLDER_ACCESS = ["all", "projects", "project"];
2916
+ var PERMISSION_MODES = [
2917
+ "default",
2918
+ "acceptEdits",
2919
+ "plan",
2920
+ "dontAsk",
2921
+ "bypassPermissions",
2922
+ "auto"
2923
+ ];
2533
2924
  var DEFAULT_APP_SETTINGS = {
2534
2925
  autoArchiveAfterMs: DEFAULT_AUTO_ARCHIVE_AFTER_MS,
2535
2926
  // Строгий режим по умолчанию: молча писать в чужой проект приложение начнёт
@@ -2537,17 +2928,26 @@ var DEFAULT_APP_SETTINGS = {
2537
2928
  folderAccess: "project",
2538
2929
  // Выключено по умолчанию: сохранение — явное действие, пока человек сам не
2539
2930
  // попросил иначе.
2540
- autoSaveAfterPauseMs: 0
2931
+ autoSaveAfterPauseMs: 0,
2932
+ // Модель за пользователя не выбираем: пока он не сказал иначе, сессия
2933
+ // поднимается ровно с той, с которой её поднял бы консольный Claude Code.
2934
+ startModel: null,
2935
+ // Тот же режим, с которым сессия стартовала до появления этой настройки.
2936
+ startPermissionMode: "default",
2937
+ // Терминал по умолчанию закрыт — его включают, а не выключают: приложение,
2938
+ // открытое в браузере, не обязано с первого запуска уметь запускать на этой
2939
+ // машине что угодно.
2940
+ terminalEnabled: false
2541
2941
  };
2542
2942
  var AppSettingsStore = class {
2543
2943
  #settings = { ...DEFAULT_APP_SETTINGS };
2544
2944
  #storePath;
2545
2945
  constructor(dataDir) {
2546
- this.#storePath = path9.join(dataDir, "app-settings.json");
2946
+ this.#storePath = path10.join(dataDir, "app-settings.json");
2547
2947
  }
2548
2948
  async load() {
2549
2949
  try {
2550
- const raw = await readFile6(this.#storePath, "utf8");
2950
+ const raw = await readFile7(this.#storePath, "utf8");
2551
2951
  this.#settings = sanitize(JSON.parse(raw), DEFAULT_APP_SETTINGS);
2552
2952
  } catch {
2553
2953
  this.#settings = { ...DEFAULT_APP_SETTINGS };
@@ -2567,8 +2967,8 @@ var AppSettingsStore = class {
2567
2967
  async update(patch) {
2568
2968
  const next = sanitize(patch, this.#settings);
2569
2969
  this.#settings = next;
2570
- await mkdir4(path9.dirname(this.#storePath), { recursive: true });
2571
- await writeFile4(this.#storePath, JSON.stringify(next, null, 2), "utf8");
2970
+ await mkdir5(path10.dirname(this.#storePath), { recursive: true });
2971
+ await writeFile5(this.#storePath, JSON.stringify(next, null, 2), "utf8");
2572
2972
  return this.current;
2573
2973
  }
2574
2974
  };
@@ -2581,18 +2981,32 @@ function sanitize(value, fallback) {
2581
2981
  if (typeof after === "number" && Number.isFinite(after)) {
2582
2982
  result.autoArchiveAfterMs = after <= 0 ? 0 : Math.max(Math.round(after), MIN_AUTO_ARCHIVE_AFTER_MS);
2583
2983
  }
2584
- const access2 = record.folderAccess;
2585
- if (typeof access2 === "string" && FOLDER_ACCESS.includes(access2)) {
2586
- result.folderAccess = access2;
2984
+ const access3 = record.folderAccess;
2985
+ if (typeof access3 === "string" && FOLDER_ACCESS.includes(access3)) {
2986
+ result.folderAccess = access3;
2587
2987
  }
2588
2988
  const autoSave = record.autoSaveAfterPauseMs;
2589
2989
  if (typeof autoSave === "number" && Number.isFinite(autoSave)) {
2590
2990
  result.autoSaveAfterPauseMs = autoSave <= 0 ? 0 : Math.max(Math.round(autoSave), MIN_AUTO_SAVE_AFTER_PAUSE_MS);
2591
2991
  }
2992
+ if ("startModel" in record) {
2993
+ const model = record.startModel;
2994
+ if (model === null)
2995
+ result.startModel = null;
2996
+ else if (typeof model === "string")
2997
+ result.startModel = model.trim() || null;
2998
+ }
2999
+ const mode = record.startPermissionMode;
3000
+ if (typeof mode === "string" && PERMISSION_MODES.includes(mode)) {
3001
+ result.startPermissionMode = mode;
3002
+ }
3003
+ if (typeof record.terminalEnabled === "boolean") {
3004
+ result.terminalEnabled = record.terminalEnabled;
3005
+ }
2592
3006
  return result;
2593
3007
  }
2594
3008
  function claudeHome() {
2595
- return path9.join(homedir3(), ".claude");
3009
+ return path10.join(homedir3(), ".claude");
2596
3010
  }
2597
3011
  function writableRoots(mode, cwd, workspacePaths) {
2598
3012
  if (mode === "all")
@@ -2602,7 +3016,7 @@ function writableRoots(mode, cwd, workspacePaths) {
2602
3016
  return [...roots];
2603
3017
  for (const workspace of [cwd, ...workspacePaths]) {
2604
3018
  roots.add(workspace);
2605
- const parent = path9.dirname(workspace);
3019
+ const parent = path10.dirname(workspace);
2606
3020
  if (parent !== workspace)
2607
3021
  roots.add(parent);
2608
3022
  }
@@ -2610,9 +3024,9 @@ function writableRoots(mode, cwd, workspacePaths) {
2610
3024
  }
2611
3025
 
2612
3026
  // ../host/dist/plugins.js
2613
- import { readFile as readFile7 } from "node:fs/promises";
3027
+ import { readFile as readFile8 } from "node:fs/promises";
2614
3028
  import { homedir as homedir4 } from "node:os";
2615
- import path10 from "node:path";
3029
+ import path11 from "node:path";
2616
3030
  var TIMEOUT_MS2 = 12e4;
2617
3031
  var SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
2618
3032
  var PluginCli = class {
@@ -2711,10 +3125,10 @@ async function readCatalog(markets, installed) {
2711
3125
  return lists.flat().sort((a, b) => a.name.localeCompare(b.name));
2712
3126
  }
2713
3127
  async function readMarketplace(market, installed) {
2714
- const root = market.installLocation || path10.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
3128
+ const root = market.installLocation || path11.join(homedir4(), ".claude", "plugins", "marketplaces", market.name);
2715
3129
  let manifest;
2716
3130
  try {
2717
- const text = await readFile7(path10.join(root, ".claude-plugin", "marketplace.json"), "utf8");
3131
+ const text = await readFile8(path11.join(root, ".claude-plugin", "marketplace.json"), "utf8");
2718
3132
  manifest = JSON.parse(text);
2719
3133
  } catch {
2720
3134
  return [];
@@ -2925,8 +3339,8 @@ function describe3(error) {
2925
3339
 
2926
3340
  // ../host/dist/relay-link.js
2927
3341
  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";
3342
+ import { readFile as readFile9, writeFile as writeFile6, rm as rm2, mkdir as mkdir6 } from "node:fs/promises";
3343
+ import path12 from "node:path";
2930
3344
  import { hostname } from "node:os";
2931
3345
  import WebSocket from "ws";
2932
3346
 
@@ -2948,13 +3362,26 @@ var RelayLink = class extends EventEmitter3 {
2948
3362
  #clients = /* @__PURE__ */ new Set();
2949
3363
  #error = null;
2950
3364
  #closed = false;
3365
+ /**
3366
+ * Право удалённого терминала — то, что нам сказал relay, и ничего больше.
3367
+ *
3368
+ * На диск не пишется намеренно, в отличие от токена сопряжения: правда о
3369
+ * праве живёт на relay, а кеш на диске означал бы, что хост, поднятый без
3370
+ * связи, сам себе разрешает то, что мог отозвать другой человек час назад.
3371
+ * Поэтому после каждого запуска право закрыто, пока relay не пришлёт
3372
+ * `shell.state`, — и это ничего не стоит: удалённых вкладок до связи с relay
3373
+ * всё равно нет. Обрыв связи состояние не гасит по обратной причине: гасить
3374
+ * нечего (вкладок снова нет), а мигание «выключено» в настройках от каждого
3375
+ * зевка сети означало бы звать человека чинить то, что чинится само.
3376
+ */
3377
+ #shell = { enabled: false, pending: false, email: null, confirmedAt: null };
2951
3378
  /** Куда сопрягаться, пока не сопряглись. */
2952
3379
  #defaultUrl;
2953
3380
  #file;
2954
3381
  #log;
2955
3382
  constructor(options2) {
2956
3383
  super();
2957
- this.#file = path11.join(options2.dataDir, "relay.json");
3384
+ this.#file = path12.join(options2.dataDir, "relay.json");
2958
3385
  this.#defaultUrl = options2.defaultUrl.replace(/\/+$/, "");
2959
3386
  this.#log = options2.log ?? ((msg) => console.log(`[relay] ${msg}`));
2960
3387
  }
@@ -2969,9 +3396,57 @@ var RelayLink = class extends EventEmitter3 {
2969
3396
  name: this.#credentials?.name ?? null,
2970
3397
  hostId: this.#credentials?.hostId ?? null,
2971
3398
  clients: this.#clients.size,
3399
+ shell: this.#shell,
2972
3400
  error: this.#error
2973
3401
  };
2974
3402
  }
3403
+ /** Право удалённого терминала — им же гейтится каждый кадр `term.*` от удалённой вкладки. */
3404
+ get shell() {
3405
+ return this.#shell;
3406
+ }
3407
+ /**
3408
+ * Включить удалённый терминал прямо сейчас — вызывается только для запроса,
3409
+ * пришедшего с локального сокета.
3410
+ *
3411
+ * Решение «локально или удалённо» принимает `HostServer`, а не этот класс:
3412
+ * здесь уже нечем отличить одно от другого, а там это ровно то поле
3413
+ * `ClientChannel.local`, ради которого канал и заведён.
3414
+ */
3415
+ async enableShell() {
3416
+ if (!this.#credentials)
3417
+ 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");
3418
+ if (this.#socket?.readyState !== WebSocket.OPEN) {
3419
+ 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");
3420
+ }
3421
+ this.#push({ t: "shell.enable" });
3422
+ }
3423
+ /**
3424
+ * Попросить relay выслать письмо, подтверждающее удалённый терминал.
3425
+ *
3426
+ * Само право этот вызов не даёт и дать не может: relay только отправляет
3427
+ * письмо, а включает доступ переход по ссылке из него. Поэтому здесь нет ни
3428
+ * ожидания ответа, ни оптимистичного `enabled = true` — состояние приедет
3429
+ * кадром `shell.state`, когда будет чему приезжать.
3430
+ */
3431
+ async requestShell() {
3432
+ if (!this.#credentials)
3433
+ 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");
3434
+ if (this.#socket?.readyState !== WebSocket.OPEN) {
3435
+ 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");
3436
+ }
3437
+ this.#push({ t: "shell.request" });
3438
+ }
3439
+ /** Снять право немедленно. Тоже через relay: там оно и хранится. */
3440
+ async disableShell() {
3441
+ if (!this.#credentials)
3442
+ return;
3443
+ if (this.#socket?.readyState !== WebSocket.OPEN) {
3444
+ this.#shell = { ...this.#shell, enabled: false, pending: false };
3445
+ this.#changed();
3446
+ return;
3447
+ }
3448
+ this.#push({ t: "shell.disable" });
3449
+ }
2975
3450
  #changed() {
2976
3451
  this.emit("state");
2977
3452
  }
@@ -2987,7 +3462,7 @@ var RelayLink = class extends EventEmitter3 {
2987
3462
  }
2988
3463
  async #read() {
2989
3464
  try {
2990
- const raw = await readFile8(this.#file, "utf8");
3465
+ const raw = await readFile9(this.#file, "utf8");
2991
3466
  const parsed = JSON.parse(raw);
2992
3467
  if (typeof parsed !== "object" || parsed === null)
2993
3468
  return null;
@@ -3000,8 +3475,8 @@ var RelayLink = class extends EventEmitter3 {
3000
3475
  }
3001
3476
  }
3002
3477
  async #write(credentials) {
3003
- await mkdir5(path11.dirname(this.#file), { recursive: true });
3004
- await writeFile5(this.#file, `${JSON.stringify(credentials, null, 2)}
3478
+ await mkdir6(path12.dirname(this.#file), { recursive: true });
3479
+ await writeFile6(this.#file, `${JSON.stringify(credentials, null, 2)}
3005
3480
  `, {
3006
3481
  encoding: "utf8",
3007
3482
  mode: 384
@@ -3052,6 +3527,7 @@ var RelayLink = class extends EventEmitter3 {
3052
3527
  this.#credentials = null;
3053
3528
  this.#error = null;
3054
3529
  this.#clients.clear();
3530
+ this.#shell = { enabled: false, pending: false, email: null, confirmedAt: null };
3055
3531
  await rm2(this.#file, { force: true });
3056
3532
  this.#changed();
3057
3533
  this.#log("\u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435 \u0441\u043D\u044F\u0442\u043E");
@@ -3157,6 +3633,16 @@ var RelayLink = class extends EventEmitter3 {
3157
3633
  case "client.frame":
3158
3634
  this.emit("frame", frame.clientId, frame.body);
3159
3635
  return;
3636
+ case "shell.state":
3637
+ this.#shell = {
3638
+ enabled: frame.enabled,
3639
+ pending: frame.pending,
3640
+ email: frame.email,
3641
+ confirmedAt: frame.confirmedAt
3642
+ };
3643
+ 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");
3644
+ this.#changed();
3645
+ return;
3160
3646
  case "ping":
3161
3647
  socket.send(JSON.stringify({ t: "pong", nonce: frame.nonce }));
3162
3648
  return;
@@ -3197,11 +3683,196 @@ function describe4(error) {
3197
3683
  return error instanceof Error ? error.message : String(error);
3198
3684
  }
3199
3685
 
3686
+ // ../host/dist/terminal.js
3687
+ import { EventEmitter as EventEmitter4 } from "node:events";
3688
+ import { access as access2, constants as constants2 } from "node:fs/promises";
3689
+ import path13 from "node:path";
3690
+ import { randomUUID as randomUUID2 } from "node:crypto";
3691
+ var SCROLLBACK_BYTES = 256 * 1024;
3692
+ var FLUSH_MS = 12;
3693
+ var MAX_TERMINALS = 24;
3694
+ var TerminalHub = class extends EventEmitter4 {
3695
+ #terminals = /* @__PURE__ */ new Map();
3696
+ #module = null;
3697
+ list(workspaceId) {
3698
+ const all = [...this.#terminals.values()].map((entry) => entry.info);
3699
+ return workspaceId ? all.filter((info) => info.workspaceId === workspaceId) : all;
3700
+ }
3701
+ has(id) {
3702
+ return this.#terminals.has(id);
3703
+ }
3704
+ /** Накопленный вывод — то, что увидит подключившийся позже. */
3705
+ buffer(id) {
3706
+ const entry = this.#terminals.get(id);
3707
+ if (!entry)
3708
+ return "";
3709
+ return entry.chunks.join("") + entry.pending;
3710
+ }
3711
+ async open(options2) {
3712
+ if (this.#terminals.size >= MAX_TERMINALS) {
3713
+ 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`);
3714
+ }
3715
+ const pty = await this.#pty();
3716
+ const shell = await defaultShell();
3717
+ const cols = clampSize(options2.cols, 20, 500, 80);
3718
+ const rows = clampSize(options2.rows, 5, 200, 24);
3719
+ const child = pty.spawn(shell.file, shell.args, {
3720
+ name: "xterm-256color",
3721
+ cols,
3722
+ rows,
3723
+ cwd: options2.cwd,
3724
+ env: environment()
3725
+ });
3726
+ const info = {
3727
+ id: randomUUID2(),
3728
+ workspaceId: options2.workspaceId,
3729
+ title: shell.title,
3730
+ shell: shell.file,
3731
+ cwd: options2.cwd,
3732
+ cols,
3733
+ rows,
3734
+ exitCode: null
3735
+ };
3736
+ const entry = { info, pty: child, chunks: [], chunksLength: 0, pending: "", flush: null };
3737
+ this.#terminals.set(info.id, entry);
3738
+ child.onData((data) => this.#onData(entry, data));
3739
+ child.onExit(({ exitCode, signal }) => {
3740
+ this.#flush(entry);
3741
+ entry.info.exitCode = exitCode;
3742
+ this.emit("exit", info.id, exitCode, signal ?? null);
3743
+ });
3744
+ return info;
3745
+ }
3746
+ /**
3747
+ * Ввод человека — как есть.
3748
+ *
3749
+ * Ни разбора, ни экранирования, ни проверки: перед нами не «команда», а поток
3750
+ * нажатий, в котором Ctrl+C, Tab и вставка из буфера обмена неотличимы от
3751
+ * обычных букв и должны такими остаться.
3752
+ */
3753
+ write(id, data) {
3754
+ const entry = this.#terminals.get(id);
3755
+ if (!entry)
3756
+ throw new Error("\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442");
3757
+ if (entry.info.exitCode !== null)
3758
+ return;
3759
+ entry.pty.write(data);
3760
+ }
3761
+ resize(id, cols, rows) {
3762
+ const entry = this.#terminals.get(id);
3763
+ if (!entry || entry.info.exitCode !== null)
3764
+ return;
3765
+ const next = { cols: clampSize(cols, 20, 500, 80), rows: clampSize(rows, 5, 200, 24) };
3766
+ if (next.cols === entry.info.cols && next.rows === entry.info.rows)
3767
+ return;
3768
+ entry.info.cols = next.cols;
3769
+ entry.info.rows = next.rows;
3770
+ try {
3771
+ entry.pty.resize(next.cols, next.rows);
3772
+ } catch {
3773
+ }
3774
+ }
3775
+ close(id) {
3776
+ const entry = this.#terminals.get(id);
3777
+ if (!entry)
3778
+ return;
3779
+ this.#terminals.delete(id);
3780
+ if (entry.flush)
3781
+ clearTimeout(entry.flush);
3782
+ try {
3783
+ entry.pty.kill();
3784
+ } catch {
3785
+ }
3786
+ }
3787
+ /** Погасить всё: незакрытые оболочки переживут хост и останутся висеть. */
3788
+ closeAll() {
3789
+ for (const id of [...this.#terminals.keys()])
3790
+ this.close(id);
3791
+ }
3792
+ #onData(entry, data) {
3793
+ entry.pending += data;
3794
+ if (entry.flush)
3795
+ return;
3796
+ entry.flush = setTimeout(() => this.#flush(entry), FLUSH_MS);
3797
+ entry.flush.unref?.();
3798
+ }
3799
+ #flush(entry) {
3800
+ if (entry.flush) {
3801
+ clearTimeout(entry.flush);
3802
+ entry.flush = null;
3803
+ }
3804
+ const data = entry.pending;
3805
+ if (!data)
3806
+ return;
3807
+ entry.pending = "";
3808
+ entry.chunks.push(data);
3809
+ entry.chunksLength += data.length;
3810
+ while (entry.chunksLength > SCROLLBACK_BYTES && entry.chunks.length > 1) {
3811
+ entry.chunksLength -= entry.chunks.shift().length;
3812
+ }
3813
+ this.emit("data", entry.info.id, data);
3814
+ }
3815
+ #pty() {
3816
+ this.#module ??= import("@lydell/node-pty").then((module) => module).catch((error) => {
3817
+ this.#module = null;
3818
+ const reason = error instanceof Error ? error.message : String(error);
3819
+ 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}`);
3820
+ });
3821
+ return this.#module;
3822
+ }
3823
+ };
3824
+ function clampSize(value, min, max, fallback) {
3825
+ if (!Number.isFinite(value))
3826
+ return fallback;
3827
+ return Math.min(max, Math.max(min, Math.round(value)));
3828
+ }
3829
+ function environment() {
3830
+ const env = {
3831
+ ...process.env,
3832
+ TERM: "xterm-256color",
3833
+ COLORTERM: "truecolor"
3834
+ };
3835
+ for (const key of Object.keys(env))
3836
+ if (key.startsWith("="))
3837
+ delete env[key];
3838
+ return env;
3839
+ }
3840
+ async function defaultShell() {
3841
+ if (process.platform === "win32") {
3842
+ const pwsh = await findOnPath("pwsh.exe");
3843
+ if (pwsh)
3844
+ return { file: pwsh, args: [], title: "pwsh" };
3845
+ const system = process.env.SystemRoot ?? "C:\\Windows";
3846
+ const powershell = path13.join(system, "System32/WindowsPowerShell/v1.0/powershell.exe");
3847
+ if (await exists2(powershell))
3848
+ return { file: powershell, args: [], title: "powershell" };
3849
+ return { file: process.env.COMSPEC ?? "cmd.exe", args: [], title: "cmd" };
3850
+ }
3851
+ const shell = process.env.SHELL;
3852
+ if (shell && await exists2(shell))
3853
+ return { file: shell, args: [], title: path13.basename(shell) };
3854
+ if (await exists2("/bin/bash"))
3855
+ return { file: "/bin/bash", args: [], title: "bash" };
3856
+ return { file: "/bin/sh", args: [], title: "sh" };
3857
+ }
3858
+ async function findOnPath(name) {
3859
+ const dirs = (process.env.PATH ?? "").split(path13.delimiter).filter(Boolean);
3860
+ for (const dir of dirs) {
3861
+ const candidate = path13.join(dir, name);
3862
+ if (await exists2(candidate))
3863
+ return candidate;
3864
+ }
3865
+ return null;
3866
+ }
3867
+ function exists2(file) {
3868
+ return access2(file, constants2.X_OK).then(() => true, () => false);
3869
+ }
3870
+
3200
3871
  // ../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";
3872
+ import { readFile as readFile10, writeFile as writeFile7, mkdir as mkdir7 } from "node:fs/promises";
3873
+ import path14 from "node:path";
3203
3874
  var MCP_FILE = ".mcp.json";
3204
- var LOCAL_SETTINGS2 = path12.join(".claude", "settings.local.json");
3875
+ var LOCAL_SETTINGS2 = path14.join(".claude", "settings.local.json");
3205
3876
  var queues = /* @__PURE__ */ new Map();
3206
3877
  function serialize(cwd, work) {
3207
3878
  const previous = queues.get(cwd) ?? Promise.resolve();
@@ -3210,7 +3881,7 @@ function serialize(cwd, work) {
3210
3881
  return next;
3211
3882
  }
3212
3883
  async function readProjectServers(cwd) {
3213
- const file = await readJson(path12.join(cwd, MCP_FILE));
3884
+ const file = await readJson(path14.join(cwd, MCP_FILE));
3214
3885
  const servers = file?.mcpServers;
3215
3886
  if (!servers || typeof servers !== "object")
3216
3887
  return {};
@@ -3218,23 +3889,23 @@ async function readProjectServers(cwd) {
3218
3889
  }
3219
3890
  function addProjectServer(cwd, name, config) {
3220
3891
  return serialize(cwd, async () => {
3221
- const file = await readJson(path12.join(cwd, MCP_FILE)) ?? {};
3892
+ const file = await readJson(path14.join(cwd, MCP_FILE)) ?? {};
3222
3893
  const servers = file.mcpServers ?? {};
3223
3894
  servers[name] = config;
3224
3895
  file.mcpServers = servers;
3225
- await writeJson(path12.join(cwd, MCP_FILE), file);
3896
+ await writeJson(path14.join(cwd, MCP_FILE), file);
3226
3897
  await approve(cwd, name);
3227
3898
  });
3228
3899
  }
3229
3900
  function removeProjectServer(cwd, name) {
3230
3901
  return serialize(cwd, async () => {
3231
- const file = await readJson(path12.join(cwd, MCP_FILE));
3902
+ const file = await readJson(path14.join(cwd, MCP_FILE));
3232
3903
  const servers = file?.mcpServers;
3233
3904
  if (!servers || !(name in servers))
3234
3905
  return false;
3235
3906
  delete servers[name];
3236
- await writeJson(path12.join(cwd, MCP_FILE), file);
3237
- const settingsPath = path12.join(cwd, LOCAL_SETTINGS2);
3907
+ await writeJson(path14.join(cwd, MCP_FILE), file);
3908
+ const settingsPath = path14.join(cwd, LOCAL_SETTINGS2);
3238
3909
  const settings = await readJson(settingsPath);
3239
3910
  const enabled = settings?.enabledMcpjsonServers;
3240
3911
  if (settings && Array.isArray(enabled) && enabled.includes(name)) {
@@ -3245,7 +3916,7 @@ function removeProjectServer(cwd, name) {
3245
3916
  });
3246
3917
  }
3247
3918
  async function approve(cwd, name) {
3248
- const file = path12.join(cwd, LOCAL_SETTINGS2);
3919
+ const file = path14.join(cwd, LOCAL_SETTINGS2);
3249
3920
  const settings = await readJson(file) ?? {};
3250
3921
  const enabled = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
3251
3922
  if (!enabled.includes(name))
@@ -3284,31 +3955,34 @@ function describeConfig(config) {
3284
3955
  }
3285
3956
  async function readJson(file) {
3286
3957
  try {
3287
- const raw = await readFile9(file, "utf8");
3958
+ const raw = await readFile10(file, "utf8");
3288
3959
  const parsed = JSON.parse(raw);
3289
3960
  return parsed && typeof parsed === "object" ? parsed : null;
3290
3961
  } catch (error) {
3291
3962
  if (error.code === "ENOENT")
3292
3963
  return null;
3293
3964
  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}`);
3965
+ 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
3966
  }
3296
3967
  throw error;
3297
3968
  }
3298
3969
  }
3299
3970
  async function writeJson(file, data) {
3300
- await mkdir6(path12.dirname(file), { recursive: true });
3301
- await writeFile6(file, `${JSON.stringify(data, null, 2)}
3971
+ await mkdir7(path14.dirname(file), { recursive: true });
3972
+ await writeFile7(file, `${JSON.stringify(data, null, 2)}
3302
3973
  `, "utf8");
3303
3974
  }
3304
3975
 
3305
3976
  // ../host/dist/server.js
3306
3977
  var SIDE_TITLE = "\xB7 \u0432\u043E\u043F\u0440\u043E\u0441 \u043D\u0430 \u043F\u043E\u043B\u044F\u0445";
3307
3978
  var FINDER_TITLE = "\xB7 \u043F\u043E\u0438\u0441\u043A \u043F\u0440\u043E\u0435\u043A\u0442\u0430";
3979
+ var HISTORY_PAGE = 250;
3980
+ var HISTORY_PAGE_MAX = 500;
3308
3981
  var HostServer = class {
3309
3982
  #options;
3310
3983
  #workspaces;
3311
3984
  #archive;
3985
+ #pins;
3312
3986
  #settings;
3313
3987
  #finder;
3314
3988
  #sessions = /* @__PURE__ */ new Map();
@@ -3326,18 +4000,21 @@ var HostServer = class {
3326
4000
  #clients = /* @__PURE__ */ new Set();
3327
4001
  /** Удалённые вкладки по их id в relay — чтобы адресовать ответ именно той. */
3328
4002
  #remotes = /* @__PURE__ */ new Map();
3329
- #hostId = randomUUID2();
4003
+ #hostId = randomUUID3();
3330
4004
  #plugins = new PluginCli();
3331
4005
  #auth = new AuthCli();
3332
4006
  #relay;
4007
+ #terminals = new TerminalHub();
3333
4008
  constructor(options2) {
3334
4009
  this.#options = options2;
3335
4010
  this.#workspaces = new WorkspaceRegistry(options2.dataDir);
3336
4011
  this.#archive = new SessionArchive(options2.dataDir);
4012
+ this.#pins = new SessionPins(options2.dataDir);
3337
4013
  this.#settings = new AppSettingsStore(options2.dataDir);
3338
4014
  this.#finder = new ProjectFinder(this.#workspaces);
3339
4015
  this.#relay = new RelayLink({ dataDir: options2.dataDir, defaultUrl: options2.relayUrl });
3340
4016
  this.#wireRelay();
4017
+ this.#wireTerminals();
3341
4018
  this.#auth.on("login", (state) => {
3342
4019
  this.#broadcast({ type: "auth.login", state });
3343
4020
  });
@@ -3348,6 +4025,7 @@ var HostServer = class {
3348
4025
  async start() {
3349
4026
  await this.#workspaces.load();
3350
4027
  await this.#archive.load();
4028
+ await this.#pins.load();
3351
4029
  await this.#settings.load();
3352
4030
  await warmClaudeLauncher();
3353
4031
  void this.#finder.warm();
@@ -3373,6 +4051,7 @@ var HostServer = class {
3373
4051
  }
3374
4052
  async shutdown() {
3375
4053
  this.#auth.cancel();
4054
+ this.#terminals.closeAll();
3376
4055
  await this.#relay.close();
3377
4056
  await Promise.all([...this.#sessions.values()].map((s) => s.close("host shutdown")));
3378
4057
  this.#sessions.clear();
@@ -3436,6 +4115,36 @@ var HostServer = class {
3436
4115
  this.#broadcast({ type: "relay.state", state: this.#relay.state });
3437
4116
  });
3438
4117
  }
4118
+ // -------------------------------------------------------------------------
4119
+ // Терминал
4120
+ // -------------------------------------------------------------------------
4121
+ #wireTerminals() {
4122
+ this.#terminals.on("data", (id, data) => {
4123
+ this.#broadcastTerminal({ type: "term.data", id, data });
4124
+ });
4125
+ this.#terminals.on("exit", (id, exitCode, signal) => {
4126
+ this.#broadcastTerminal({ type: "term.exit", id, exitCode, signal });
4127
+ });
4128
+ }
4129
+ /**
4130
+ * Вывод терминала — всем локальным вкладкам, удалённым только по праву.
4131
+ *
4132
+ * Отдельно от `#broadcast` именно поэтому: обычный broadcast уходит и в
4133
+ * relay, а вывод оболочки — то самое, что удалённой стороне видеть не
4134
+ * положено, пока доступ не подтверждён письмом. Ошибиться здесь дороже, чем
4135
+ * в проверке входящего кадра: вход можно не заметить и отклонить, а вывод,
4136
+ * ушедший наружу, уже ушёл.
4137
+ */
4138
+ #broadcastTerminal(frame) {
4139
+ if (this.#relay.shell.enabled) {
4140
+ this.#broadcast(frame);
4141
+ return;
4142
+ }
4143
+ for (const client of this.#clients) {
4144
+ if (client.local)
4145
+ client.send(frame);
4146
+ }
4147
+ }
3439
4148
  #adoptRemote(clientId) {
3440
4149
  const client = {
3441
4150
  local: false,
@@ -3509,10 +4218,17 @@ var HostServer = class {
3509
4218
  continue;
3510
4219
  await this.#dropSession(session.id, "workspace closed");
3511
4220
  }
4221
+ let unpinned = false;
4222
+ for (const id of gone) {
4223
+ if (await this.#pins.forgetWorkspace(id))
4224
+ unpinned = true;
4225
+ }
3512
4226
  this.#broadcast({
3513
4227
  type: "workspace.list",
3514
4228
  workspaces: this.#workspaces.list()
3515
4229
  });
4230
+ if (unpinned)
4231
+ await this.#sendPinned();
3516
4232
  return;
3517
4233
  }
3518
4234
  case "workspace.find": {
@@ -3575,6 +4291,27 @@ var HostServer = class {
3575
4291
  await this.#archive.unarchive(frame.sessionIds);
3576
4292
  await this.#sendSessionList(frame.workspaceId);
3577
4293
  return;
4294
+ case "session.pin": {
4295
+ const ids = new Set(frame.sessionIds);
4296
+ for (const session of [...this.#sessions.values()]) {
4297
+ const diskId = session.claudeSessionId;
4298
+ if (!diskId)
4299
+ continue;
4300
+ if (ids.has(session.id))
4301
+ ids.add(diskId);
4302
+ if (ids.has(diskId))
4303
+ ids.add(session.id);
4304
+ }
4305
+ await this.#pins.set(frame.workspaceId, [...ids], frame.pinned);
4306
+ const hadPins = this.#pins.workspaceIds().includes(frame.workspaceId);
4307
+ await this.#sendSessionList(frame.workspaceId);
4308
+ if (!hadPins)
4309
+ await this.#sendPinned();
4310
+ return;
4311
+ }
4312
+ case "session.pins":
4313
+ await this.#sendPinned();
4314
+ return;
3578
4315
  case "session.rename":
3579
4316
  await this.#renameSession(frame.sessionId, frame.title, frame.workspaceId);
3580
4317
  return;
@@ -3601,6 +4338,21 @@ var HostServer = class {
3601
4338
  }
3602
4339
  return;
3603
4340
  }
4341
+ case "session.history": {
4342
+ const session = this.#requireSession(frame.sessionId);
4343
+ const all = await session.history();
4344
+ const end = typeof frame.before === "number" ? Math.max(0, Math.min(all.length, frame.before - 1)) : all.length;
4345
+ const limit = Math.min(Math.max(frame.limit ?? HISTORY_PAGE, 1), HISTORY_PAGE_MAX);
4346
+ const start = Math.max(0, end - limit);
4347
+ this.#send(socket, {
4348
+ type: "session.history",
4349
+ sessionId: session.id,
4350
+ entries: all.slice(start, end),
4351
+ from: start + 1,
4352
+ total: all.length
4353
+ });
4354
+ return;
4355
+ }
3604
4356
  case "usage.get": {
3605
4357
  const session = this.#requireSession(frame.sessionId);
3606
4358
  if (session.usage) {
@@ -3822,6 +4574,54 @@ var HostServer = class {
3822
4574
  }
3823
4575
  return;
3824
4576
  }
4577
+ // Терминал. Каждая ручка начинается с одной и той же проверки права: их
4578
+ // пять, и «забыл в одной» здесь означает не потерянную кнопку, а чужую
4579
+ // оболочку в руках у того, кому её не давали.
4580
+ case "term.list":
4581
+ this.#requireShellAccess(socket);
4582
+ this.#send(socket, {
4583
+ type: "term.list",
4584
+ workspaceId: frame.workspaceId,
4585
+ terminals: this.#terminals.list(frame.workspaceId)
4586
+ });
4587
+ return;
4588
+ case "term.open": {
4589
+ this.#requireShellAccess(socket);
4590
+ const workspace = this.#workspaces.find(frame.workspaceId);
4591
+ if (!workspace)
4592
+ throw new Error(`\u0412\u043E\u0440\u043A\u0441\u043F\u0435\u0439\u0441 \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D: ${frame.workspaceId}`);
4593
+ const terminal = await this.#terminals.open({
4594
+ workspaceId: workspace.id,
4595
+ cwd: workspace.path,
4596
+ cols: frame.cols,
4597
+ rows: frame.rows
4598
+ });
4599
+ this.#broadcastTerminal({ type: "term.opened", terminal });
4600
+ return;
4601
+ }
4602
+ case "term.attach":
4603
+ this.#requireShellAccess(socket);
4604
+ if (!this.#terminals.has(frame.id))
4605
+ throw new Error("\u0422\u0435\u0440\u043C\u0438\u043D\u0430\u043B \u0443\u0436\u0435 \u0437\u0430\u043A\u0440\u044B\u0442");
4606
+ this.#send(socket, {
4607
+ type: "term.buffer",
4608
+ id: frame.id,
4609
+ data: this.#terminals.buffer(frame.id)
4610
+ });
4611
+ return;
4612
+ case "term.input":
4613
+ this.#requireShellAccess(socket);
4614
+ this.#terminals.write(frame.id, frame.data);
4615
+ return;
4616
+ case "term.resize":
4617
+ this.#requireShellAccess(socket);
4618
+ this.#terminals.resize(frame.id, frame.cols, frame.rows);
4619
+ return;
4620
+ case "term.close":
4621
+ this.#requireShellAccess(socket);
4622
+ this.#terminals.close(frame.id);
4623
+ this.#broadcastTerminal({ type: "term.closed", id: frame.id });
4624
+ return;
3825
4625
  case "settings.get":
3826
4626
  this.#send(socket, { type: "settings.state", settings: this.#settings.current });
3827
4627
  return;
@@ -3834,6 +4634,12 @@ var HostServer = class {
3834
4634
  await this.#sendSessionList(workspace.id);
3835
4635
  }
3836
4636
  }
4637
+ if (before.terminalEnabled && !settings.terminalEnabled) {
4638
+ for (const terminal of this.#terminals.list()) {
4639
+ this.#terminals.close(terminal.id);
4640
+ this.#broadcastTerminal({ type: "term.closed", id: terminal.id });
4641
+ }
4642
+ }
3837
4643
  return;
3838
4644
  }
3839
4645
  case "auth.status":
@@ -3863,8 +4669,41 @@ var HostServer = class {
3863
4669
  this.#requireLocal(socket, "\u041E\u0442\u0432\u044F\u0437\u0430\u0442\u044C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440");
3864
4670
  await this.#relay.unpair();
3865
4671
  return;
4672
+ case "relay.shell.enable":
4673
+ if (!this.#settings.current.terminalEnabled) {
4674
+ 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.");
4675
+ }
4676
+ if (socket.local)
4677
+ await this.#relay.enableShell();
4678
+ else
4679
+ await this.#relay.requestShell();
4680
+ return;
4681
+ case "relay.shell.disable":
4682
+ await this.#relay.disableShell();
4683
+ return;
3866
4684
  }
3867
4685
  }
4686
+ /**
4687
+ * Терминал — по двум условиям сразу: включён ли он вообще и, для удалённой
4688
+ * вкладки, подтверждено ли право.
4689
+ *
4690
+ * Первое — настройка самой машины (`AppSettings.terminalEnabled`), выключена
4691
+ * по умолчанию: пока её не подняли, терминала нет ни у кого. Второе, поверх
4692
+ * первого: локальному клиенту не отказываем никогда — он и так за этой
4693
+ * машиной, где терминал открывается и без нас. Удалённому — по состоянию,
4694
+ * которое ведёт
4695
+ * relay: право включают либо на самой машине (сразу, физический доступ и
4696
+ * есть подтверждение), либо из удалённой вкладки, и тогда его включает не
4697
+ * нажатие кнопки, а переход по ссылке из письма на почту аккаунта.
4698
+ */
4699
+ #requireShellAccess(client) {
4700
+ if (!this.#settings.current.terminalEnabled) {
4701
+ 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.");
4702
+ }
4703
+ if (client.local || this.#relay.shell.enabled)
4704
+ return;
4705
+ 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.");
4706
+ }
3868
4707
  /**
3869
4708
  * Настройки удалённого доступа меняются только с самой машины.
3870
4709
  *
@@ -3997,6 +4836,32 @@ var HostServer = class {
3997
4836
  async #sendSessionList(workspaceId) {
3998
4837
  const { sessions, archived } = await this.#listSessions(workspaceId);
3999
4838
  this.#broadcast({ type: "session.list", workspaceId, sessions, archived });
4839
+ if (this.#pins.workspaceIds().includes(workspaceId))
4840
+ await this.#sendPinned();
4841
+ }
4842
+ /**
4843
+ * Разослать закреплённые сессии — по всем проектам сразу.
4844
+ *
4845
+ * Собирается из тех же списков, что и панель проекта (`#listSessions`), а не
4846
+ * из одного реестра закреплений: в панели закреплённых у строки должны быть
4847
+ * имя, состояние и время последнего события, а реестр знает только id. Обход
4848
+ * идёт лишь по проектам, где вообще что-то закреплено, — их обычно единицы, и
4849
+ * читать диск по всем открытым проектам ради этого незачем.
4850
+ *
4851
+ * Архивные сюда тоже попадают: закреплённое в архив само не уезжает, но
4852
+ * убрать туда руками уже закреплённую сессию человеку никто не мешает, и
4853
+ * терять её из панели после этого было бы странно.
4854
+ */
4855
+ async #sendPinned() {
4856
+ const sessions = [];
4857
+ for (const workspaceId of this.#pins.workspaceIds()) {
4858
+ if (!this.#workspaces.find(workspaceId))
4859
+ continue;
4860
+ const { sessions: live, archived } = await this.#listSessions(workspaceId);
4861
+ sessions.push(...[...live, ...archived].filter((s) => s.pinned));
4862
+ }
4863
+ sessions.sort((a, b) => (a.pinnedAt ?? 0) - (b.pinnedAt ?? 0));
4864
+ this.#broadcast({ type: "session.pinned", sessions });
4000
4865
  }
4001
4866
  async #startSession(socket, frame) {
4002
4867
  const workspace = this.#workspaces.find(frame.workspaceId);
@@ -4006,13 +4871,14 @@ var HostServer = class {
4006
4871
  const resumedTitle = resumed ? diskTitle(resumed) : null;
4007
4872
  if (frame.resume)
4008
4873
  await this.#archive.unarchive([frame.resume]);
4874
+ const startDefaults = this.#settings.current;
4009
4875
  const session = new ClaudeSession({
4010
4876
  workspaceId: workspace.id,
4011
4877
  cwd: workspace.path,
4012
4878
  resume: frame.resume,
4013
4879
  fork: frame.fork,
4014
- permissionMode: frame.permissionMode,
4015
- model: frame.model,
4880
+ permissionMode: frame.permissionMode ?? startDefaults.startPermissionMode,
4881
+ model: frame.model ?? startDefaults.startModel ?? void 0,
4016
4882
  ...resumedTitle ? { title: resumedTitle } : {},
4017
4883
  // Имя проекта вместо «Без названия»: свежую сессию в списке уже надо
4018
4884
  // как-то называть, а сказать о ней пока нечего, кроме того, где она.
@@ -4036,6 +4902,9 @@ var HostServer = class {
4036
4902
  });
4037
4903
  session.on("summary", (summary) => {
4038
4904
  this.#broadcast({ type: "session.summary", session: summary });
4905
+ if (!summary.claudeSessionId || !this.#pins.has(summary.id))
4906
+ return;
4907
+ void this.#pins.migrate(summary.id, summary.claudeSessionId).then((moved) => moved ? this.#sendPinned() : void 0).catch(() => void 0);
4039
4908
  });
4040
4909
  session.on("capabilities", () => {
4041
4910
  if (!session.capabilities)
@@ -4298,6 +5167,9 @@ var HostServer = class {
4298
5167
  parentSessionId: null,
4299
5168
  title: diskTitle(info),
4300
5169
  state: "closed",
5170
+ // Хода в этой жизни у неё не было: сессия не поднята, и чем кончился
5171
+ // последний ход прошлой, знает только её JSONL.
5172
+ lastTurn: null,
4301
5173
  model: null,
4302
5174
  permissionMode: "default",
4303
5175
  // Сессия с диска не поднята: чем она была настроена, знает только её
@@ -4311,10 +5183,14 @@ var HostServer = class {
4311
5183
  lastSeq: 0,
4312
5184
  totalCostUsd: 0
4313
5185
  }));
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);
5186
+ const known = /* @__PURE__ */ new Set([...liveIds, ...historical.map((s) => s.id)]);
5187
+ 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);
5188
+ await this.#pins.sweep(workspaceId, known);
4315
5189
  const sessions = [];
4316
5190
  const archived = [];
4317
- for (const summary of [...live, ...historical].sort((a, b) => b.updatedAt - a.updatedAt)) {
5191
+ for (const base of [...live, ...historical].sort((a, b) => b.updatedAt - a.updatedAt)) {
5192
+ const pin = this.#pins.find(base.claudeSessionId ?? base.id);
5193
+ const summary = pin ? { ...base, pinned: true, pinnedAt: pin.at } : base;
4318
5194
  const record = this.#archive.find(summary.claudeSessionId ?? summary.id);
4319
5195
  if (!record) {
4320
5196
  sessions.push(summary);
@@ -4352,8 +5228,8 @@ var HostServer = class {
4352
5228
  return;
4353
5229
  }
4354
5230
  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");
5231
+ const requested = path15.join(webRoot, path15.normalize(url2.pathname));
5232
+ const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path15.join(webRoot, "index.html");
4357
5233
  if (!existsSync3(target)) {
4358
5234
  res.writeHead(404).end("not found");
4359
5235
  return;
@@ -4379,12 +5255,12 @@ function contentType(file) {
4379
5255
  ".svg": "image/svg+xml",
4380
5256
  ".woff2": "font/woff2"
4381
5257
  };
4382
- return types[path13.extname(file)] ?? "application/octet-stream";
5258
+ return types[path15.extname(file)] ?? "application/octet-stream";
4383
5259
  }
4384
5260
 
4385
5261
  // dist/ccv.js
4386
- var HERE = path14.dirname(fileURLToPath(import.meta.url));
4387
- var PACKAGE_ROOT = path14.resolve(HERE, "..");
5262
+ var HERE = path16.dirname(fileURLToPath(import.meta.url));
5263
+ var PACKAGE_ROOT = path16.resolve(HERE, "..");
4388
5264
  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
5265
 
4390
5266
  \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435:
@@ -4451,7 +5327,7 @@ var server = new HostServer({
4451
5327
  dataDir: defaults.dataDir,
4452
5328
  // UI лежит в пакете рядом с бандлом — это и есть разница между установленной
4453
5329
  // утилитой и запуском из репозитория, где сборка живёт в `apps/web/dist`.
4454
- webRoot: process.env.CCV_WEB_ROOT ?? path14.join(PACKAGE_ROOT, "web"),
5330
+ webRoot: process.env.CCV_WEB_ROOT ?? path16.join(PACKAGE_ROOT, "web"),
4455
5331
  version,
4456
5332
  relayUrl: defaults.relayUrl
4457
5333
  });
@@ -4480,7 +5356,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
4480
5356
  }
4481
5357
  async function packageVersion() {
4482
5358
  try {
4483
- const text = await readFile10(path14.join(PACKAGE_ROOT, "package.json"), "utf8");
5359
+ const text = await readFile11(path16.join(PACKAGE_ROOT, "package.json"), "utf8");
4484
5360
  return JSON.parse(text).version ?? "0.0.0";
4485
5361
  } catch {
4486
5362
  return "0.0.0";