@i14ks/ccv 0.1.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 (53) hide show
  1. package/LICENSE +173 -173
  2. package/README.md +56 -56
  3. package/THIRD-PARTY-NOTICES.md +91 -5
  4. package/bundle/ccv.mjs +1051 -68
  5. package/package.json +50 -49
  6. package/web/apple-touch-icon.png +0 -0
  7. package/web/assets/_commonjsHelpers-CqkleIqs.js +1 -0
  8. package/web/assets/addon-fit-CevVMrUA.js +1 -0
  9. package/web/assets/addon-web-links-CnB65OV_.js +1 -0
  10. package/web/assets/codemirror-DNwOY4Od.js +1 -0
  11. package/web/assets/index--X3c59oq.js +7 -0
  12. package/web/assets/index-1vcRYewd.js +1 -0
  13. package/web/assets/index-6JwHpeCk.js +1 -0
  14. package/web/assets/index-891wE-Rj.js +7 -0
  15. package/web/assets/index-9FU2NRHn.js +13 -0
  16. package/web/assets/index-9FstbMxt.js +1 -0
  17. package/web/assets/{index-C-HuAiCn.js → index-AptXdWK3.js} +1 -1
  18. package/web/assets/index-BFn7R550.js +1 -0
  19. package/web/assets/index-BFoyQq0x.js +1 -0
  20. package/web/assets/index-BHot-lnj.js +5 -0
  21. package/web/assets/index-Bh-gxu6Z.js +1 -0
  22. package/web/assets/index-Bj3bZRgw.js +2 -0
  23. package/web/assets/index-BvcLb1wh.js +1 -0
  24. package/web/assets/index-C9MQTTvT.js +1 -0
  25. package/web/assets/{index-DJN3PaCw.js → index-CKclwUJ-.js} +2 -2
  26. package/web/assets/index-Cfccn-Ob.js +1 -0
  27. package/web/assets/index-Cvz4rtnD.js +1 -0
  28. package/web/assets/{index-D0qAiokq.js → index-CzlH_Mh9.js} +3 -3
  29. package/web/assets/index-D9UhxKGI.js +126 -0
  30. package/web/assets/index-DB9ZVtJ8.js +1 -0
  31. package/web/assets/index-DmJcZf8t.css +1 -0
  32. package/web/assets/index-DtcfXsIX.js +3 -0
  33. package/web/assets/index-r40StboH.js +1 -0
  34. package/web/assets/xterm-D6Mng-DB.js +9 -0
  35. package/web/assets/xterm-DYP7pi_n.css +32 -0
  36. package/web/favicon.ico +0 -0
  37. package/web/favicon.png +0 -0
  38. package/web/favicon.svg +4 -0
  39. package/web/index.html +40 -30
  40. package/web/assets/index-4x6fBcbV.js +0 -1
  41. package/web/assets/index-B4RnRe_M.js +0 -133
  42. package/web/assets/index-BBL1LP-l.js +0 -1
  43. package/web/assets/index-BQbBSCN3.js +0 -1
  44. package/web/assets/index-BVHAVdqc.js +0 -3
  45. package/web/assets/index-CG3BWhrU.js +0 -7
  46. package/web/assets/index-CPBnfhi2.js +0 -1
  47. package/web/assets/index-CYyCCyf_.js +0 -1
  48. package/web/assets/index-CtQ2lLVm.js +0 -1
  49. package/web/assets/index-D8swzi0v.js +0 -13
  50. package/web/assets/index-DSNRlHPG.js +0 -1
  51. package/web/assets/index-Dw1tTDZW.js +0 -1
  52. package/web/assets/index-e0Ev4udb.js +0 -1
  53. package/web/assets/index-uKB-GMLe.css +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.";
@@ -646,6 +824,80 @@ function writeTarget(input) {
646
824
  const target = record.file_path ?? record.notebook_path;
647
825
  return typeof target === "string" && target ? target : null;
648
826
  }
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
+ }
851
+ function parseQuestions(input) {
852
+ if (typeof input !== "object" || input === null)
853
+ return null;
854
+ const raw = input.questions;
855
+ if (!Array.isArray(raw) || raw.length === 0)
856
+ return null;
857
+ const questions = [];
858
+ for (const item of raw) {
859
+ if (typeof item !== "object" || item === null)
860
+ return null;
861
+ const { question, header, options: options2, multiSelect } = item;
862
+ if (typeof question !== "string" || !question)
863
+ return null;
864
+ if (!Array.isArray(options2) || options2.length === 0)
865
+ return null;
866
+ const parsed = [];
867
+ for (const option of options2) {
868
+ if (typeof option !== "object" || option === null)
869
+ return null;
870
+ const { label, description, preview } = option;
871
+ if (typeof label !== "string" || !label)
872
+ return null;
873
+ parsed.push({
874
+ label,
875
+ description: typeof description === "string" ? description : "",
876
+ ...typeof preview === "string" && preview ? { preview } : {}
877
+ });
878
+ }
879
+ questions.push({
880
+ question,
881
+ header: typeof header === "string" ? header : "",
882
+ options: parsed,
883
+ multiSelect: multiSelect === true
884
+ });
885
+ }
886
+ return questions;
887
+ }
888
+ function previewAnnotations(questions, answers) {
889
+ const annotations = {};
890
+ for (const question of questions) {
891
+ const answer = answers[question.question];
892
+ if (!answer)
893
+ continue;
894
+ const chosen = question.options.filter((option) => answer.split(", ").includes(option.label));
895
+ if (chosen.length !== 1 || !chosen[0]?.preview)
896
+ continue;
897
+ annotations[question.question] = { preview: chosen[0].preview };
898
+ }
899
+ return annotations;
900
+ }
649
901
  var USAGE_MIN_INTERVAL_MS = 2e4;
650
902
  var ClaudeSession = class extends EventEmitter {
651
903
  id;
@@ -653,10 +905,47 @@ var ClaudeSession = class extends EventEmitter {
653
905
  cwd;
654
906
  journal = new Journal();
655
907
  #query = null;
908
+ /**
909
+ * Разговор, который был до нас, — прочитанный транскрипт продолженной сессии.
910
+ *
911
+ * Читается один раз, на старте, и больше не меняется: всё, что случится
912
+ * дальше, попадает в журнал. Промис, а не массив, потому что чтение
913
+ * асинхронное, а спросить историю могут раньше, чем оно закончится, — тогда
914
+ * запрос дождётся того же чтения вместо второго такого же. `null` — сессия
915
+ * начата с нуля, и продолжать нечего.
916
+ */
917
+ #history = null;
656
918
  /** Чем подняли эту сессию — нужно, чтобы объяснить провал старта. */
657
919
  #executable = { source: "bundled" };
658
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;
659
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;
660
949
  #capabilities = null;
661
950
  #pendingPermissions = /* @__PURE__ */ new Map();
662
951
  #model = null;
@@ -755,6 +1044,16 @@ var ClaudeSession = class extends EventEmitter {
755
1044
  get usage() {
756
1045
  return this.#usage;
757
1046
  }
1047
+ /**
1048
+ * Разговор, который был до старта этой сессии, — целиком и по порядку.
1049
+ *
1050
+ * Пустой массив у сессии, начатой с нуля. Ждёт чтения транскрипта, если оно
1051
+ * ещё идёт: клиент спрашивает историю сразу после открытия сессии, и ответ
1052
+ * «пока пусто» он принял бы за «истории нет».
1053
+ */
1054
+ async history() {
1055
+ return await this.#history ?? [];
1056
+ }
758
1057
  summary() {
759
1058
  return {
760
1059
  id: this.id,
@@ -764,6 +1063,7 @@ var ClaudeSession = class extends EventEmitter {
764
1063
  parentSessionId: this.parentSessionId,
765
1064
  title: this.#title,
766
1065
  state: this.#state,
1066
+ lastTurn: this.#lastTurn,
767
1067
  model: this.#model,
768
1068
  permissionMode: this.#permissionMode,
769
1069
  effort: this.#effort,
@@ -782,6 +1082,10 @@ var ClaudeSession = class extends EventEmitter {
782
1082
  start() {
783
1083
  if (this.#query)
784
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
+ }
785
1089
  const options2 = {
786
1090
  cwd: this.cwd,
787
1091
  // Без этого SDK не читает с диска ничего: ни CLAUDE.md, ни кастомные
@@ -891,6 +1195,8 @@ var ClaudeSession = class extends EventEmitter {
891
1195
  });
892
1196
  return;
893
1197
  }
1198
+ this.#lastTurn = null;
1199
+ this.#interruptRequested = false;
894
1200
  this.#setState("running");
895
1201
  this.#inbox.push({
896
1202
  type: "user",
@@ -898,17 +1204,46 @@ var ClaudeSession = class extends EventEmitter {
898
1204
  parent_tool_use_id: null
899
1205
  });
900
1206
  }
901
- respondPermission(requestId, decision, reason) {
1207
+ /**
1208
+ * Ответ человека на запрос прав — он же ответ на вопрос агента.
1209
+ *
1210
+ * `answers` осмысленны только для `AskUserQuestion`: там разрешение и есть
1211
+ * ответ, и выбранное надо вернуть инструменту в его же аргументах. Схема
1212
+ * аргументов у него строгая (`strictObject`), поэтому дописываем ровно те
1213
+ * поля, которые она знает, — лишнее уронило бы вызов ZodError'ом там, где
1214
+ * человек только что нажал кнопку.
1215
+ */
1216
+ respondPermission(requestId, decision, reason, answers, annotations) {
902
1217
  const pending = this.#pendingPermissions.get(requestId);
903
1218
  if (!pending)
904
1219
  return;
905
1220
  this.#pendingPermissions.delete(requestId);
906
- pending.resolve(decision === "allow" ? { behavior: "allow", updatedInput: pending.input } : { behavior: "deny", message: reason ?? "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u043E \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0435\u043C" });
907
- this.#record({ kind: "permission.resolved", requestId, decision, reason });
1221
+ const answered = pending.questions && answers ? answers : null;
1222
+ let input = pending.input;
1223
+ if (answered) {
1224
+ const withPreviews = {
1225
+ ...previewAnnotations(pending.questions ?? [], answered),
1226
+ ...annotations
1227
+ };
1228
+ input = {
1229
+ ...pending.input,
1230
+ answers: answered,
1231
+ ...Object.keys(withPreviews).length > 0 ? { annotations: withPreviews } : {}
1232
+ };
1233
+ }
1234
+ pending.resolve(decision === "allow" ? { behavior: "allow", updatedInput: input } : { behavior: "deny", message: reason ?? "\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u043E \u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u0442\u0435\u043B\u0435\u043C" });
1235
+ this.#record({
1236
+ kind: "permission.resolved",
1237
+ requestId,
1238
+ decision,
1239
+ reason,
1240
+ ...answered ? { answers: answered } : {}
1241
+ });
908
1242
  if (this.#pendingPermissions.size === 0)
909
1243
  this.#setState("running");
910
1244
  }
911
1245
  async interrupt() {
1246
+ this.#interruptRequested = true;
912
1247
  await this.#query?.interrupt();
913
1248
  }
914
1249
  /**
@@ -1115,7 +1450,7 @@ var ClaudeSession = class extends EventEmitter {
1115
1450
  const pass = { continue: true };
1116
1451
  if (!this.workspaceId)
1117
1452
  return pass;
1118
- const roots = this.#options.writableRoots?.() ?? [this.cwd];
1453
+ const roots = this.#options.writableRoots ? this.#options.writableRoots() : [this.cwd];
1119
1454
  if (roots === null)
1120
1455
  return pass;
1121
1456
  const target = writeTarget(input.tool_input);
@@ -1135,7 +1470,23 @@ var ClaudeSession = class extends EventEmitter {
1135
1470
  }
1136
1471
  };
1137
1472
  };
1138
- #canUseTool = (toolName, input, { signal, decisionReason }) => {
1473
+ /**
1474
+ * Мост между `canUseTool` из SDK и решением человека в UI.
1475
+ *
1476
+ * Сюда же приходит и `AskUserQuestion` — вопрос агента с вариантами ответа.
1477
+ * Отдельного канала для него в SDK нет и не нужно: CLI объявляет этот
1478
+ * инструмент как `requiresUserInteraction`, а такой всегда попадает в
1479
+ * `canUseTool`, в любом режиме прав, — даже в `bypassPermissions`, где всё
1480
+ * остальное проходит молча. Проверка стоит раньше разбора режима, так что
1481
+ * вопрос не потеряется ни в одном из них.
1482
+ *
1483
+ * Разрешение для него значит не «выполняй», а «вот ответ»: выбранное
1484
+ * возвращается в `updatedInput.answers`, и уже с ним инструмент выполняется
1485
+ * и складывает ответ в результат вызова. Пустой `answers` (то есть простое
1486
+ * «Разрешить») инструмент понимает как «человек не стал отвечать» — ровно
1487
+ * это и происходило, пока вопросы показывались обычной карточкой прав.
1488
+ */
1489
+ #canUseTool = (toolName, input, { signal, decisionReason, toolUseID }) => {
1139
1490
  if (this.#options.readOnly) {
1140
1491
  this.#record({
1141
1492
  kind: "error",
@@ -1147,8 +1498,9 @@ var ClaudeSession = class extends EventEmitter {
1147
1498
  });
1148
1499
  }
1149
1500
  const requestId = randomUUID();
1501
+ const questions = toolName === ASK_QUESTION_TOOL ? parseQuestions(input) : null;
1150
1502
  return new Promise((resolve) => {
1151
- this.#pendingPermissions.set(requestId, { resolve, toolName, input });
1503
+ this.#pendingPermissions.set(requestId, { resolve, toolName, input, questions });
1152
1504
  this.#setState("waiting_permission");
1153
1505
  this.#record({
1154
1506
  kind: "permission.request",
@@ -1157,8 +1509,11 @@ var ClaudeSession = class extends EventEmitter {
1157
1509
  input,
1158
1510
  // Причину даёт не всякий запрос: обычный вопрос в режиме `default`
1159
1511
  // объясняется самим режимом, а вот запрос, поднятый хуком поверх
1160
- // разрешающего режима, без неё выглядит сбоем.
1161
- ...decisionReason ? { reason: decisionReason } : {}
1512
+ // разрешающего режима, без неё выглядит сбоем. Вопросу агента причина
1513
+ // не нужна вовсе: CLI объясняет его технически («requiresUserInteraction»),
1514
+ // а спрашивают тут не «почему», а «что выбрать».
1515
+ ...decisionReason && !questions ? { reason: decisionReason } : {},
1516
+ ...questions ? { questions, toolUseId: toolUseID } : {}
1162
1517
  });
1163
1518
  signal.addEventListener("abort", () => {
1164
1519
  if (!this.#pendingPermissions.delete(requestId))
@@ -1207,6 +1562,10 @@ var ClaudeSession = class extends EventEmitter {
1207
1562
  this.#handleSystem(message);
1208
1563
  break;
1209
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
+ }
1210
1569
  for (const block of message.message.content) {
1211
1570
  const blockId = `${message.message.id}:${this.#nextBlockIndex(message.message.id)}`;
1212
1571
  if (block.type === "text") {
@@ -1250,6 +1609,7 @@ var ClaudeSession = class extends EventEmitter {
1250
1609
  durationMs: message.duration_ms,
1251
1610
  costUsd: message.total_cost_usd ?? null,
1252
1611
  usage,
1612
+ contextTokens: this.#contextTokens,
1253
1613
  text: "result" in message ? message.result : null
1254
1614
  });
1255
1615
  if (this.#options.outputFormat) {
@@ -1258,7 +1618,12 @@ var ClaudeSession = class extends EventEmitter {
1258
1618
  text: "result" in message ? message.result : null
1259
1619
  });
1260
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";
1261
1624
  this.#setState("idle");
1625
+ if (alreadyIdle)
1626
+ this.emit("summary", this.summary());
1262
1627
  if (this.#unpersistedTitle)
1263
1628
  void this.#persistTitle();
1264
1629
  void this.refreshUsage();
@@ -1371,7 +1736,7 @@ var ClaudeSession = class extends EventEmitter {
1371
1736
  this.#record({
1372
1737
  kind: "tool.result",
1373
1738
  toolUseId: block.tool_use_id,
1374
- content: contentToText(block.content),
1739
+ content: contentToText2(block.content),
1375
1740
  isError: block.is_error === true
1376
1741
  });
1377
1742
  }
@@ -1452,7 +1817,7 @@ function toUsageWindow(raw) {
1452
1817
  resetsAt: typeof raw.resets_at === "string" ? raw.resets_at : ""
1453
1818
  };
1454
1819
  }
1455
- function contentToText(content) {
1820
+ function contentToText2(content) {
1456
1821
  if (typeof content === "string")
1457
1822
  return content;
1458
1823
  if (!Array.isArray(content))
@@ -1833,9 +2198,9 @@ function parsePatch(raw) {
1833
2198
  body = "";
1834
2199
  }
1835
2200
  total += body.length;
1836
- const path15 = status === "deleted" ? oldPath : newPath || oldPath;
2201
+ const path17 = status === "deleted" ? oldPath : newPath || oldPath;
1837
2202
  files.push({
1838
- path: path15,
2203
+ path: path17,
1839
2204
  oldPath: status === "renamed" ? oldPath : "",
1840
2205
  status,
1841
2206
  added: count(body, "+"),
@@ -1847,10 +2212,10 @@ function parsePatch(raw) {
1847
2212
  return { files, truncated };
1848
2213
  }
1849
2214
  function stripPrefix(value) {
1850
- const path15 = unquote(value.trim());
1851
- if (path15 === "/dev/null")
2215
+ const path17 = unquote(value.trim());
2216
+ if (path17 === "/dev/null")
1852
2217
  return "";
1853
- return path15.replace(/^[ab]\//, "");
2218
+ return path17.replace(/^[ab]\//, "");
1854
2219
  }
1855
2220
  function count(body, mark) {
1856
2221
  let n = 0;
@@ -2423,28 +2788,166 @@ function isRecord(value) {
2423
2788
  return typeof value === "object" && value !== null && typeof value.workspaceId === "string" && typeof value.at === "number";
2424
2789
  }
2425
2790
 
2426
- // ../host/dist/app-settings.js
2791
+ // ../host/dist/pins.js
2427
2792
  import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
2428
- import { homedir as homedir3 } from "node:os";
2429
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";
2430
2912
  var DEFAULT_AUTO_ARCHIVE_AFTER_MS = 2 * 24 * 60 * 60 * 1e3;
2431
2913
  var MIN_AUTO_ARCHIVE_AFTER_MS = 60 * 60 * 1e3;
2914
+ var MIN_AUTO_SAVE_AFTER_PAUSE_MS = 500;
2432
2915
  var FOLDER_ACCESS = ["all", "projects", "project"];
2916
+ var PERMISSION_MODES = [
2917
+ "default",
2918
+ "acceptEdits",
2919
+ "plan",
2920
+ "dontAsk",
2921
+ "bypassPermissions",
2922
+ "auto"
2923
+ ];
2433
2924
  var DEFAULT_APP_SETTINGS = {
2434
2925
  autoArchiveAfterMs: DEFAULT_AUTO_ARCHIVE_AFTER_MS,
2435
2926
  // Строгий режим по умолчанию: молча писать в чужой проект приложение начнёт
2436
2927
  // только после того, как об этом попросили явно.
2437
- folderAccess: "project"
2928
+ folderAccess: "project",
2929
+ // Выключено по умолчанию: сохранение — явное действие, пока человек сам не
2930
+ // попросил иначе.
2931
+ autoSaveAfterPauseMs: 0,
2932
+ // Модель за пользователя не выбираем: пока он не сказал иначе, сессия
2933
+ // поднимается ровно с той, с которой её поднял бы консольный Claude Code.
2934
+ startModel: null,
2935
+ // Тот же режим, с которым сессия стартовала до появления этой настройки.
2936
+ startPermissionMode: "default",
2937
+ // Терминал по умолчанию закрыт — его включают, а не выключают: приложение,
2938
+ // открытое в браузере, не обязано с первого запуска уметь запускать на этой
2939
+ // машине что угодно.
2940
+ terminalEnabled: false
2438
2941
  };
2439
2942
  var AppSettingsStore = class {
2440
2943
  #settings = { ...DEFAULT_APP_SETTINGS };
2441
2944
  #storePath;
2442
2945
  constructor(dataDir) {
2443
- this.#storePath = path9.join(dataDir, "app-settings.json");
2946
+ this.#storePath = path10.join(dataDir, "app-settings.json");
2444
2947
  }
2445
2948
  async load() {
2446
2949
  try {
2447
- const raw = await readFile6(this.#storePath, "utf8");
2950
+ const raw = await readFile7(this.#storePath, "utf8");
2448
2951
  this.#settings = sanitize(JSON.parse(raw), DEFAULT_APP_SETTINGS);
2449
2952
  } catch {
2450
2953
  this.#settings = { ...DEFAULT_APP_SETTINGS };
@@ -2464,8 +2967,8 @@ var AppSettingsStore = class {
2464
2967
  async update(patch) {
2465
2968
  const next = sanitize(patch, this.#settings);
2466
2969
  this.#settings = next;
2467
- await mkdir4(path9.dirname(this.#storePath), { recursive: true });
2468
- 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");
2469
2972
  return this.current;
2470
2973
  }
2471
2974
  };
@@ -2478,14 +2981,32 @@ function sanitize(value, fallback) {
2478
2981
  if (typeof after === "number" && Number.isFinite(after)) {
2479
2982
  result.autoArchiveAfterMs = after <= 0 ? 0 : Math.max(Math.round(after), MIN_AUTO_ARCHIVE_AFTER_MS);
2480
2983
  }
2481
- const access2 = record.folderAccess;
2482
- if (typeof access2 === "string" && FOLDER_ACCESS.includes(access2)) {
2483
- result.folderAccess = access2;
2984
+ const access3 = record.folderAccess;
2985
+ if (typeof access3 === "string" && FOLDER_ACCESS.includes(access3)) {
2986
+ result.folderAccess = access3;
2987
+ }
2988
+ const autoSave = record.autoSaveAfterPauseMs;
2989
+ if (typeof autoSave === "number" && Number.isFinite(autoSave)) {
2990
+ result.autoSaveAfterPauseMs = autoSave <= 0 ? 0 : Math.max(Math.round(autoSave), MIN_AUTO_SAVE_AFTER_PAUSE_MS);
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;
2484
3005
  }
2485
3006
  return result;
2486
3007
  }
2487
3008
  function claudeHome() {
2488
- return path9.join(homedir3(), ".claude");
3009
+ return path10.join(homedir3(), ".claude");
2489
3010
  }
2490
3011
  function writableRoots(mode, cwd, workspacePaths) {
2491
3012
  if (mode === "all")
@@ -2495,7 +3016,7 @@ function writableRoots(mode, cwd, workspacePaths) {
2495
3016
  return [...roots];
2496
3017
  for (const workspace of [cwd, ...workspacePaths]) {
2497
3018
  roots.add(workspace);
2498
- const parent = path9.dirname(workspace);
3019
+ const parent = path10.dirname(workspace);
2499
3020
  if (parent !== workspace)
2500
3021
  roots.add(parent);
2501
3022
  }
@@ -2503,9 +3024,9 @@ function writableRoots(mode, cwd, workspacePaths) {
2503
3024
  }
2504
3025
 
2505
3026
  // ../host/dist/plugins.js
2506
- import { readFile as readFile7 } from "node:fs/promises";
3027
+ import { readFile as readFile8 } from "node:fs/promises";
2507
3028
  import { homedir as homedir4 } from "node:os";
2508
- import path10 from "node:path";
3029
+ import path11 from "node:path";
2509
3030
  var TIMEOUT_MS2 = 12e4;
2510
3031
  var SAFE_ARG = /^[A-Za-z0-9][A-Za-z0-9._@/-]*$/;
2511
3032
  var PluginCli = class {
@@ -2604,10 +3125,10 @@ async function readCatalog(markets, installed) {
2604
3125
  return lists.flat().sort((a, b) => a.name.localeCompare(b.name));
2605
3126
  }
2606
3127
  async function readMarketplace(market, installed) {
2607
- 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);
2608
3129
  let manifest;
2609
3130
  try {
2610
- 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");
2611
3132
  manifest = JSON.parse(text);
2612
3133
  } catch {
2613
3134
  return [];
@@ -2818,8 +3339,8 @@ function describe3(error) {
2818
3339
 
2819
3340
  // ../host/dist/relay-link.js
2820
3341
  import { EventEmitter as EventEmitter3 } from "node:events";
2821
- import { readFile as readFile8, writeFile as writeFile5, rm as rm2, mkdir as mkdir5 } from "node:fs/promises";
2822
- import path11 from "node:path";
3342
+ import { readFile as readFile9, writeFile as writeFile6, rm as rm2, mkdir as mkdir6 } from "node:fs/promises";
3343
+ import path12 from "node:path";
2823
3344
  import { hostname } from "node:os";
2824
3345
  import WebSocket from "ws";
2825
3346
 
@@ -2841,13 +3362,26 @@ var RelayLink = class extends EventEmitter3 {
2841
3362
  #clients = /* @__PURE__ */ new Set();
2842
3363
  #error = null;
2843
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 };
2844
3378
  /** Куда сопрягаться, пока не сопряглись. */
2845
3379
  #defaultUrl;
2846
3380
  #file;
2847
3381
  #log;
2848
3382
  constructor(options2) {
2849
3383
  super();
2850
- this.#file = path11.join(options2.dataDir, "relay.json");
3384
+ this.#file = path12.join(options2.dataDir, "relay.json");
2851
3385
  this.#defaultUrl = options2.defaultUrl.replace(/\/+$/, "");
2852
3386
  this.#log = options2.log ?? ((msg) => console.log(`[relay] ${msg}`));
2853
3387
  }
@@ -2862,9 +3396,57 @@ var RelayLink = class extends EventEmitter3 {
2862
3396
  name: this.#credentials?.name ?? null,
2863
3397
  hostId: this.#credentials?.hostId ?? null,
2864
3398
  clients: this.#clients.size,
3399
+ shell: this.#shell,
2865
3400
  error: this.#error
2866
3401
  };
2867
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
+ }
2868
3450
  #changed() {
2869
3451
  this.emit("state");
2870
3452
  }
@@ -2880,7 +3462,7 @@ var RelayLink = class extends EventEmitter3 {
2880
3462
  }
2881
3463
  async #read() {
2882
3464
  try {
2883
- const raw = await readFile8(this.#file, "utf8");
3465
+ const raw = await readFile9(this.#file, "utf8");
2884
3466
  const parsed = JSON.parse(raw);
2885
3467
  if (typeof parsed !== "object" || parsed === null)
2886
3468
  return null;
@@ -2893,8 +3475,8 @@ var RelayLink = class extends EventEmitter3 {
2893
3475
  }
2894
3476
  }
2895
3477
  async #write(credentials) {
2896
- await mkdir5(path11.dirname(this.#file), { recursive: true });
2897
- 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)}
2898
3480
  `, {
2899
3481
  encoding: "utf8",
2900
3482
  mode: 384
@@ -2945,6 +3527,7 @@ var RelayLink = class extends EventEmitter3 {
2945
3527
  this.#credentials = null;
2946
3528
  this.#error = null;
2947
3529
  this.#clients.clear();
3530
+ this.#shell = { enabled: false, pending: false, email: null, confirmedAt: null };
2948
3531
  await rm2(this.#file, { force: true });
2949
3532
  this.#changed();
2950
3533
  this.#log("\u0441\u043E\u043F\u0440\u044F\u0436\u0435\u043D\u0438\u0435 \u0441\u043D\u044F\u0442\u043E");
@@ -3050,6 +3633,16 @@ var RelayLink = class extends EventEmitter3 {
3050
3633
  case "client.frame":
3051
3634
  this.emit("frame", frame.clientId, frame.body);
3052
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;
3053
3646
  case "ping":
3054
3647
  socket.send(JSON.stringify({ t: "pong", nonce: frame.nonce }));
3055
3648
  return;
@@ -3090,11 +3683,196 @@ function describe4(error) {
3090
3683
  return error instanceof Error ? error.message : String(error);
3091
3684
  }
3092
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
+
3093
3871
  // ../host/dist/mcp-config.js
3094
- import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir6 } from "node:fs/promises";
3095
- import path12 from "node:path";
3872
+ import { readFile as readFile10, writeFile as writeFile7, mkdir as mkdir7 } from "node:fs/promises";
3873
+ import path14 from "node:path";
3096
3874
  var MCP_FILE = ".mcp.json";
3097
- var LOCAL_SETTINGS2 = path12.join(".claude", "settings.local.json");
3875
+ var LOCAL_SETTINGS2 = path14.join(".claude", "settings.local.json");
3098
3876
  var queues = /* @__PURE__ */ new Map();
3099
3877
  function serialize(cwd, work) {
3100
3878
  const previous = queues.get(cwd) ?? Promise.resolve();
@@ -3103,7 +3881,7 @@ function serialize(cwd, work) {
3103
3881
  return next;
3104
3882
  }
3105
3883
  async function readProjectServers(cwd) {
3106
- const file = await readJson(path12.join(cwd, MCP_FILE));
3884
+ const file = await readJson(path14.join(cwd, MCP_FILE));
3107
3885
  const servers = file?.mcpServers;
3108
3886
  if (!servers || typeof servers !== "object")
3109
3887
  return {};
@@ -3111,23 +3889,23 @@ async function readProjectServers(cwd) {
3111
3889
  }
3112
3890
  function addProjectServer(cwd, name, config) {
3113
3891
  return serialize(cwd, async () => {
3114
- const file = await readJson(path12.join(cwd, MCP_FILE)) ?? {};
3892
+ const file = await readJson(path14.join(cwd, MCP_FILE)) ?? {};
3115
3893
  const servers = file.mcpServers ?? {};
3116
3894
  servers[name] = config;
3117
3895
  file.mcpServers = servers;
3118
- await writeJson(path12.join(cwd, MCP_FILE), file);
3896
+ await writeJson(path14.join(cwd, MCP_FILE), file);
3119
3897
  await approve(cwd, name);
3120
3898
  });
3121
3899
  }
3122
3900
  function removeProjectServer(cwd, name) {
3123
3901
  return serialize(cwd, async () => {
3124
- const file = await readJson(path12.join(cwd, MCP_FILE));
3902
+ const file = await readJson(path14.join(cwd, MCP_FILE));
3125
3903
  const servers = file?.mcpServers;
3126
3904
  if (!servers || !(name in servers))
3127
3905
  return false;
3128
3906
  delete servers[name];
3129
- await writeJson(path12.join(cwd, MCP_FILE), file);
3130
- const settingsPath = path12.join(cwd, LOCAL_SETTINGS2);
3907
+ await writeJson(path14.join(cwd, MCP_FILE), file);
3908
+ const settingsPath = path14.join(cwd, LOCAL_SETTINGS2);
3131
3909
  const settings = await readJson(settingsPath);
3132
3910
  const enabled = settings?.enabledMcpjsonServers;
3133
3911
  if (settings && Array.isArray(enabled) && enabled.includes(name)) {
@@ -3138,7 +3916,7 @@ function removeProjectServer(cwd, name) {
3138
3916
  });
3139
3917
  }
3140
3918
  async function approve(cwd, name) {
3141
- const file = path12.join(cwd, LOCAL_SETTINGS2);
3919
+ const file = path14.join(cwd, LOCAL_SETTINGS2);
3142
3920
  const settings = await readJson(file) ?? {};
3143
3921
  const enabled = Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : [];
3144
3922
  if (!enabled.includes(name))
@@ -3177,31 +3955,34 @@ function describeConfig(config) {
3177
3955
  }
3178
3956
  async function readJson(file) {
3179
3957
  try {
3180
- const raw = await readFile9(file, "utf8");
3958
+ const raw = await readFile10(file, "utf8");
3181
3959
  const parsed = JSON.parse(raw);
3182
3960
  return parsed && typeof parsed === "object" ? parsed : null;
3183
3961
  } catch (error) {
3184
3962
  if (error.code === "ENOENT")
3185
3963
  return null;
3186
3964
  if (error instanceof SyntaxError) {
3187
- throw new Error(`\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0440\u0430\u0437\u043E\u0431\u0440\u0430\u0442\u044C ${path12.basename(file)}: ${error.message}`);
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}`);
3188
3966
  }
3189
3967
  throw error;
3190
3968
  }
3191
3969
  }
3192
3970
  async function writeJson(file, data) {
3193
- await mkdir6(path12.dirname(file), { recursive: true });
3194
- 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)}
3195
3973
  `, "utf8");
3196
3974
  }
3197
3975
 
3198
3976
  // ../host/dist/server.js
3199
3977
  var SIDE_TITLE = "\xB7 \u0432\u043E\u043F\u0440\u043E\u0441 \u043D\u0430 \u043F\u043E\u043B\u044F\u0445";
3200
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;
3201
3981
  var HostServer = class {
3202
3982
  #options;
3203
3983
  #workspaces;
3204
3984
  #archive;
3985
+ #pins;
3205
3986
  #settings;
3206
3987
  #finder;
3207
3988
  #sessions = /* @__PURE__ */ new Map();
@@ -3219,18 +4000,21 @@ var HostServer = class {
3219
4000
  #clients = /* @__PURE__ */ new Set();
3220
4001
  /** Удалённые вкладки по их id в relay — чтобы адресовать ответ именно той. */
3221
4002
  #remotes = /* @__PURE__ */ new Map();
3222
- #hostId = randomUUID2();
4003
+ #hostId = randomUUID3();
3223
4004
  #plugins = new PluginCli();
3224
4005
  #auth = new AuthCli();
3225
4006
  #relay;
4007
+ #terminals = new TerminalHub();
3226
4008
  constructor(options2) {
3227
4009
  this.#options = options2;
3228
4010
  this.#workspaces = new WorkspaceRegistry(options2.dataDir);
3229
4011
  this.#archive = new SessionArchive(options2.dataDir);
4012
+ this.#pins = new SessionPins(options2.dataDir);
3230
4013
  this.#settings = new AppSettingsStore(options2.dataDir);
3231
4014
  this.#finder = new ProjectFinder(this.#workspaces);
3232
4015
  this.#relay = new RelayLink({ dataDir: options2.dataDir, defaultUrl: options2.relayUrl });
3233
4016
  this.#wireRelay();
4017
+ this.#wireTerminals();
3234
4018
  this.#auth.on("login", (state) => {
3235
4019
  this.#broadcast({ type: "auth.login", state });
3236
4020
  });
@@ -3241,6 +4025,7 @@ var HostServer = class {
3241
4025
  async start() {
3242
4026
  await this.#workspaces.load();
3243
4027
  await this.#archive.load();
4028
+ await this.#pins.load();
3244
4029
  await this.#settings.load();
3245
4030
  await warmClaudeLauncher();
3246
4031
  void this.#finder.warm();
@@ -3266,6 +4051,7 @@ var HostServer = class {
3266
4051
  }
3267
4052
  async shutdown() {
3268
4053
  this.#auth.cancel();
4054
+ this.#terminals.closeAll();
3269
4055
  await this.#relay.close();
3270
4056
  await Promise.all([...this.#sessions.values()].map((s) => s.close("host shutdown")));
3271
4057
  this.#sessions.clear();
@@ -3329,6 +4115,36 @@ var HostServer = class {
3329
4115
  this.#broadcast({ type: "relay.state", state: this.#relay.state });
3330
4116
  });
3331
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
+ }
3332
4148
  #adoptRemote(clientId) {
3333
4149
  const client = {
3334
4150
  local: false,
@@ -3402,10 +4218,17 @@ var HostServer = class {
3402
4218
  continue;
3403
4219
  await this.#dropSession(session.id, "workspace closed");
3404
4220
  }
4221
+ let unpinned = false;
4222
+ for (const id of gone) {
4223
+ if (await this.#pins.forgetWorkspace(id))
4224
+ unpinned = true;
4225
+ }
3405
4226
  this.#broadcast({
3406
4227
  type: "workspace.list",
3407
4228
  workspaces: this.#workspaces.list()
3408
4229
  });
4230
+ if (unpinned)
4231
+ await this.#sendPinned();
3409
4232
  return;
3410
4233
  }
3411
4234
  case "workspace.find": {
@@ -3468,6 +4291,27 @@ var HostServer = class {
3468
4291
  await this.#archive.unarchive(frame.sessionIds);
3469
4292
  await this.#sendSessionList(frame.workspaceId);
3470
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;
3471
4315
  case "session.rename":
3472
4316
  await this.#renameSession(frame.sessionId, frame.title, frame.workspaceId);
3473
4317
  return;
@@ -3494,6 +4338,21 @@ var HostServer = class {
3494
4338
  }
3495
4339
  return;
3496
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
+ }
3497
4356
  case "usage.get": {
3498
4357
  const session = this.#requireSession(frame.sessionId);
3499
4358
  if (session.usage) {
@@ -3510,7 +4369,7 @@ var HostServer = class {
3510
4369
  this.#requireSession(frame.sessionId).sendPrompt(frame.text);
3511
4370
  return;
3512
4371
  case "permission.respond":
3513
- this.#requireSession(frame.sessionId).respondPermission(frame.requestId, frame.decision, frame.reason);
4372
+ this.#requireSession(frame.sessionId).respondPermission(frame.requestId, frame.decision, frame.reason, frame.answers, frame.annotations);
3514
4373
  return;
3515
4374
  case "side.ask":
3516
4375
  this.#ensureSide(this.#requireSession(frame.sessionId)).sendPrompt(frame.text);
@@ -3715,6 +4574,54 @@ var HostServer = class {
3715
4574
  }
3716
4575
  return;
3717
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;
3718
4625
  case "settings.get":
3719
4626
  this.#send(socket, { type: "settings.state", settings: this.#settings.current });
3720
4627
  return;
@@ -3727,6 +4634,12 @@ var HostServer = class {
3727
4634
  await this.#sendSessionList(workspace.id);
3728
4635
  }
3729
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
+ }
3730
4643
  return;
3731
4644
  }
3732
4645
  case "auth.status":
@@ -3756,7 +4669,40 @@ var HostServer = class {
3756
4669
  this.#requireLocal(socket, "\u041E\u0442\u0432\u044F\u0437\u0430\u0442\u044C \u043A\u043E\u043C\u043F\u044C\u044E\u0442\u0435\u0440");
3757
4670
  await this.#relay.unpair();
3758
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;
4684
+ }
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.");
3759
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.");
3760
4706
  }
3761
4707
  /**
3762
4708
  * Настройки удалённого доступа меняются только с самой машины.
@@ -3890,6 +4836,32 @@ var HostServer = class {
3890
4836
  async #sendSessionList(workspaceId) {
3891
4837
  const { sessions, archived } = await this.#listSessions(workspaceId);
3892
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 });
3893
4865
  }
3894
4866
  async #startSession(socket, frame) {
3895
4867
  const workspace = this.#workspaces.find(frame.workspaceId);
@@ -3899,13 +4871,14 @@ var HostServer = class {
3899
4871
  const resumedTitle = resumed ? diskTitle(resumed) : null;
3900
4872
  if (frame.resume)
3901
4873
  await this.#archive.unarchive([frame.resume]);
4874
+ const startDefaults = this.#settings.current;
3902
4875
  const session = new ClaudeSession({
3903
4876
  workspaceId: workspace.id,
3904
4877
  cwd: workspace.path,
3905
4878
  resume: frame.resume,
3906
4879
  fork: frame.fork,
3907
- permissionMode: frame.permissionMode,
3908
- model: frame.model,
4880
+ permissionMode: frame.permissionMode ?? startDefaults.startPermissionMode,
4881
+ model: frame.model ?? startDefaults.startModel ?? void 0,
3909
4882
  ...resumedTitle ? { title: resumedTitle } : {},
3910
4883
  // Имя проекта вместо «Без названия»: свежую сессию в списке уже надо
3911
4884
  // как-то называть, а сказать о ней пока нечего, кроме того, где она.
@@ -3929,6 +4902,9 @@ var HostServer = class {
3929
4902
  });
3930
4903
  session.on("summary", (summary) => {
3931
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);
3932
4908
  });
3933
4909
  session.on("capabilities", () => {
3934
4910
  if (!session.capabilities)
@@ -4191,6 +5167,9 @@ var HostServer = class {
4191
5167
  parentSessionId: null,
4192
5168
  title: diskTitle(info),
4193
5169
  state: "closed",
5170
+ // Хода в этой жизни у неё не было: сессия не поднята, и чем кончился
5171
+ // последний ход прошлой, знает только её JSONL.
5172
+ lastTurn: null,
4194
5173
  model: null,
4195
5174
  permissionMode: "default",
4196
5175
  // Сессия с диска не поднята: чем она была настроена, знает только её
@@ -4204,10 +5183,14 @@ var HostServer = class {
4204
5183
  lastSeq: 0,
4205
5184
  totalCostUsd: 0
4206
5185
  }));
4207
- await this.#archive.sweep(workspaceId, historical.map((s) => ({ id: s.id, updatedAt: s.updatedAt })), /* @__PURE__ */ new Set([...liveIds, ...historical.map((s) => s.id)]), this.#settings.current.autoArchiveAfterMs);
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);
4208
5189
  const sessions = [];
4209
5190
  const archived = [];
4210
- 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;
4211
5194
  const record = this.#archive.find(summary.claudeSessionId ?? summary.id);
4212
5195
  if (!record) {
4213
5196
  sessions.push(summary);
@@ -4245,8 +5228,8 @@ var HostServer = class {
4245
5228
  return;
4246
5229
  }
4247
5230
  const url2 = new URL(req.url ?? "/", "http://localhost");
4248
- const requested = path13.join(webRoot, path13.normalize(url2.pathname));
4249
- const target = requested.startsWith(webRoot) && existsSync3(requested) && statSync3(requested).isFile() ? requested : path13.join(webRoot, "index.html");
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");
4250
5233
  if (!existsSync3(target)) {
4251
5234
  res.writeHead(404).end("not found");
4252
5235
  return;
@@ -4272,12 +5255,12 @@ function contentType(file) {
4272
5255
  ".svg": "image/svg+xml",
4273
5256
  ".woff2": "font/woff2"
4274
5257
  };
4275
- return types[path13.extname(file)] ?? "application/octet-stream";
5258
+ return types[path15.extname(file)] ?? "application/octet-stream";
4276
5259
  }
4277
5260
 
4278
5261
  // dist/ccv.js
4279
- var HERE = path14.dirname(fileURLToPath(import.meta.url));
4280
- var PACKAGE_ROOT = path14.resolve(HERE, "..");
5262
+ var HERE = path16.dirname(fileURLToPath(import.meta.url));
5263
+ var PACKAGE_ROOT = path16.resolve(HERE, "..");
4281
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.
4282
5265
 
4283
5266
  \u0418\u0441\u043F\u043E\u043B\u044C\u0437\u043E\u0432\u0430\u043D\u0438\u0435:
@@ -4344,7 +5327,7 @@ var server = new HostServer({
4344
5327
  dataDir: defaults.dataDir,
4345
5328
  // UI лежит в пакете рядом с бандлом — это и есть разница между установленной
4346
5329
  // утилитой и запуском из репозитория, где сборка живёт в `apps/web/dist`.
4347
- webRoot: process.env.CCV_WEB_ROOT ?? path14.join(PACKAGE_ROOT, "web"),
5330
+ webRoot: process.env.CCV_WEB_ROOT ?? path16.join(PACKAGE_ROOT, "web"),
4348
5331
  version,
4349
5332
  relayUrl: defaults.relayUrl
4350
5333
  });
@@ -4373,7 +5356,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
4373
5356
  }
4374
5357
  async function packageVersion() {
4375
5358
  try {
4376
- const text = await readFile10(path14.join(PACKAGE_ROOT, "package.json"), "utf8");
5359
+ const text = await readFile11(path16.join(PACKAGE_ROOT, "package.json"), "utf8");
4377
5360
  return JSON.parse(text).version ?? "0.0.0";
4378
5361
  } catch {
4379
5362
  return "0.0.0";