@kernelonpanic/kitcode 1.1.1 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,6 +9,14 @@ import { existsSync } from "fs";
9
9
 
10
10
  // src/config/schema.ts
11
11
  import { z } from "zod";
12
+ var reservedRecordKeys = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
13
+ var safeRecordKeySchema = z.string().min(1).max(256).refine((value) => !reservedRecordKeys.has(value) && !/[\0-\x1f\x7f]/.test(value), {
14
+ message: "contains a reserved or unsafe key"
15
+ });
16
+ var providerIdSchema = safeRecordKeySchema.regex(
17
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/,
18
+ "must start with a letter or digit and contain only letters, digits, dots, underscores, or dashes"
19
+ );
12
20
  function isAllowedEndpointUrl(value) {
13
21
  try {
14
22
  const url = new URL(value);
@@ -61,9 +69,6 @@ var diagnosticsSchema = z.object({
61
69
  autoRun: z.boolean().default(true),
62
70
  commands: z.array(z.string().trim().min(1).max(2e4)).max(8).default([])
63
71
  });
64
- var updatesSchema = z.object({
65
- checkOnStart: z.boolean().default(true)
66
- });
67
72
  var configSchema = z.object({
68
73
  version: z.literal(1).default(1),
69
74
  model: z.string().optional(),
@@ -78,12 +83,11 @@ var configSchema = z.object({
78
83
  maxSubagentsPerTurn: 3
79
84
  }),
80
85
  diagnostics: diagnosticsSchema.default({ autoRun: true, commands: [] }),
81
- updates: updatesSchema.default({ checkOnStart: false }),
82
- providers: z.record(z.string(), providerConfigSchema).default({}),
83
- permissions: z.record(z.string(), permissionModeSchema).default({}),
84
- mcp: z.record(z.string(), mcpServerSchema).default({})
86
+ providers: z.record(providerIdSchema, providerConfigSchema).default({}),
87
+ permissions: z.record(safeRecordKeySchema, permissionModeSchema).default({}),
88
+ mcp: z.record(safeRecordKeySchema, mcpServerSchema).default({})
85
89
  });
86
- var authSchema = z.record(z.string(), z.string());
90
+ var authSchema = z.record(providerIdSchema, z.string());
87
91
  function defaultConfig() {
88
92
  return configSchema.parse({});
89
93
  }
@@ -145,19 +149,19 @@ var CONNECTION_REASONS = {
145
149
  DEPTH_ZERO_SELF_SIGNED_CERT: "the TLS certificate is self-signed",
146
150
  UNABLE_TO_VERIFY_LEAF_SIGNATURE: "the TLS certificate chain cannot be verified"
147
151
  };
148
- function describeProviderFailure(error, providerId) {
152
+ function describeProviderFailure(error, providerId, knownSecrets = []) {
149
153
  const abort = abortKind(error);
150
154
  if (abort === "user") return `Request to "${providerId}" was cancelled.`;
151
155
  if (abort === "timeout") {
152
156
  return `Request to "${providerId}" ran out of time before the provider answered \u2014 it may be overloaded, try again.`;
153
157
  }
154
158
  const status = statusOf(error);
155
- if (status !== void 0) return describeStatus(status, providerId, error);
156
- const reason = connectionReason(error);
159
+ if (status !== void 0) return describeStatus(status, providerId, error, knownSecrets);
160
+ const reason = connectionReason(error, knownSecrets);
157
161
  if (reason) {
158
162
  return `Cannot reach "${providerId}": ${reason}. Check the base URL and your network.`;
159
163
  }
160
- return `Request to "${providerId}" failed \u2014 ${snippet(messageOf(error))}`;
164
+ return `Request to "${providerId}" failed \u2014 ${snippet(messageOf(error), 160, knownSecrets)}`;
161
165
  }
162
166
  function statusOf(error) {
163
167
  const value = prop(error, "status") ?? prop(error, "statusCode");
@@ -169,8 +173,8 @@ function isUserAbort(error, signal) {
169
173
  function isConnectionFailure(error) {
170
174
  return connectionReason(error) !== void 0;
171
175
  }
172
- function toProviderError(error, providerId) {
173
- return new ProviderError(describeProviderFailure(error, providerId), providerId, statusOf(error), {
176
+ function toProviderError(error, providerId, knownSecrets = []) {
177
+ return new ProviderError(describeProviderFailure(error, providerId, knownSecrets), providerId, statusOf(error), {
174
178
  cause: error
175
179
  });
176
180
  }
@@ -218,8 +222,8 @@ async function readHead(stream) {
218
222
  }
219
223
  return text;
220
224
  }
221
- async function invalidStreamError(providerId, capture, cause) {
222
- const excerpt = snippet(await capture.head(), EXCERPT_CHARS);
225
+ async function invalidStreamError(providerId, capture, cause, knownSecrets = []) {
226
+ const excerpt = snippet(await capture.head(), EXCERPT_CHARS, knownSecrets);
223
227
  const detail = excerpt === "" ? "the body was empty" : `the body began: ${excerpt}`;
224
228
  return new ProviderError(
225
229
  `"${providerId}" returned a response that is not a valid streaming chat completion \u2014 no stream events arrived and ${detail}. The endpoint may speak a different protocol than the one configured for it.`,
@@ -228,8 +232,8 @@ async function invalidStreamError(providerId, capture, cause) {
228
232
  cause === void 0 ? void 0 : { cause }
229
233
  );
230
234
  }
231
- function describeStatus(status, providerId, error) {
232
- const detail = detailOf(error, status);
235
+ function describeStatus(status, providerId, error, knownSecrets) {
236
+ const detail = detailOf(error, status, knownSecrets);
233
237
  const wait = retryAfterSeconds(error);
234
238
  const after = wait === void 0 ? void 0 : `retry after ${wait}s`;
235
239
  if (status === 401) {
@@ -264,7 +268,7 @@ function abortKind(error) {
264
268
  if (name === "AbortError") return "user";
265
269
  return void 0;
266
270
  }
267
- function connectionReason(error) {
271
+ function connectionReason(error, knownSecrets = []) {
268
272
  const links = chain(error);
269
273
  for (const link of links) {
270
274
  for (const candidate of [link, ...siblings(link)]) {
@@ -274,9 +278,11 @@ function connectionReason(error) {
274
278
  }
275
279
  }
276
280
  const deepest = links[links.length - 1];
277
- if (links.length > 1 && deepest instanceof Error) return snippet(deepest.message);
281
+ if (links.length > 1 && deepest instanceof Error) {
282
+ return snippet(deepest.message, 160, knownSecrets);
283
+ }
278
284
  const message = messageOf(error);
279
- return /fetch failed|connection error|socket|network/i.test(message) ? snippet(message) : void 0;
285
+ return /fetch failed|connection error|socket|network/i.test(message) ? snippet(message, 160, knownSecrets) : void 0;
280
286
  }
281
287
  function chain(error) {
282
288
  const links = [];
@@ -309,9 +315,9 @@ function header(error, name) {
309
315
  const value = headers[name];
310
316
  return typeof value === "string" ? value : void 0;
311
317
  }
312
- function detailOf(error, status) {
318
+ function detailOf(error, status, knownSecrets) {
313
319
  const raw = bodyMessage(error) ?? messageOf(error).replace(new RegExp(`^${status}\\s+`), "");
314
- const text = snippet(raw);
320
+ const text = snippet(raw, 160, knownSecrets);
315
321
  if (text === "" || text === String(status) || text === "status code (no body)") return "";
316
322
  return ` \u2014 ${text}`;
317
323
  }
@@ -328,8 +334,8 @@ function messageOf(error) {
328
334
  if (error === null || error === void 0) return "unknown error";
329
335
  return String(error);
330
336
  }
331
- function snippet(text, limit = 160) {
332
- return redactSecrets(oneLine(text)).slice(0, limit);
337
+ function snippet(text, limit = 160, knownSecrets = []) {
338
+ return redactSecrets(oneLine(text), knownSecrets).slice(0, limit);
333
339
  }
334
340
  function oneLine(text) {
335
341
  return text.replace(/\s+/g, " ").trim();
@@ -380,8 +386,9 @@ function perMillion(value) {
380
386
  }
381
387
  function positiveInt(value) {
382
388
  const parsed = toNumber(value);
383
- if (parsed === void 0 || parsed <= 0) return void 0;
384
- return Math.trunc(parsed);
389
+ if (parsed === void 0) return void 0;
390
+ const integer = Math.trunc(parsed);
391
+ return integer >= 1 ? integer : void 0;
385
392
  }
386
393
  function toNumber(value) {
387
394
  if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
@@ -401,7 +408,12 @@ async function detectProvider(rawUrl, apiKey2, opts = {}) {
401
408
  if (!isAllowedEndpointUrl(baseUrl)) {
402
409
  throw new Error("API URL must use https; plain http is only allowed for localhost or 127.0.0.1.");
403
410
  }
404
- const id = opts.name ?? providerIdFromUrl(baseUrl);
411
+ const candidateId = opts.name ?? providerIdFromUrl(baseUrl);
412
+ const parsedId = providerIdSchema.safeParse(candidateId);
413
+ if (!parsedId.success) {
414
+ throw new Error(`Invalid provider name "${candidateId}": ${parsedId.error.issues[0]?.message ?? "invalid name"}`);
415
+ }
416
+ const id = parsedId.data;
405
417
  if (hostname(baseUrl) === anthropicHost) {
406
418
  const probed = await probe(
407
419
  fetchImpl,
@@ -440,7 +452,7 @@ async function detectProvider(rawUrl, apiKey2, opts = {}) {
440
452
  }
441
453
  if (!attempt.ok && attempt.status !== 404 && attempt.status !== 0) break;
442
454
  }
443
- throw new Error(describeFailure(last));
455
+ throw new Error(describeFailure(last, apiKey2));
444
456
  }
445
457
  function normaliseBaseUrl(rawUrl) {
446
458
  return rawUrl.trim().replace(/\/+$/, "");
@@ -453,7 +465,8 @@ function providerIdFromUrl(url) {
453
465
  }
454
466
  const labels = host.split(".");
455
467
  const label = labels.find((part) => part !== "api" && part !== "") ?? "provider";
456
- return label.replace(/[^a-z0-9-]/g, "-");
468
+ const candidate = label.replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "provider";
469
+ return providerIdSchema.safeParse(candidate).success ? candidate : `provider-${candidate}`;
457
470
  }
458
471
  function isAddressLiteral(host) {
459
472
  return host === "localhost" || /^[0-9.]+$/.test(host) || host.includes(":");
@@ -576,9 +589,9 @@ function stringList(value) {
576
589
  if (!Array.isArray(value)) return [];
577
590
  return value.filter((item) => typeof item === "string");
578
591
  }
579
- function describeFailure(attempt) {
592
+ function describeFailure(attempt, apiKey2) {
580
593
  const status = attempt.status === 0 ? "request failed" : `HTTP ${attempt.status}`;
581
- const snippet2 = redactSecrets(attempt.text.trim()).slice(0, 200);
594
+ const snippet2 = redactSecrets(attempt.text.trim(), [apiKey2]).slice(0, 200);
582
595
  const detail = snippet2 === "" ? "" : `: ${snippet2}`;
583
596
  return `No OpenAI- or Anthropic-compatible API found at ${attempt.url} (${status})${detail}`;
584
597
  }
@@ -651,7 +664,7 @@ async function isFile(file) {
651
664
 
652
665
  // src/config/store.ts
653
666
  import { randomUUID as randomUUID2 } from "crypto";
654
- import { chmod as chmod2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
667
+ import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
655
668
  import path3 from "path";
656
669
 
657
670
  // src/config/trust.ts
@@ -671,23 +684,30 @@ async function isWorkspaceTrusted(cwd) {
671
684
  }
672
685
  async function trustWorkspace(cwd) {
673
686
  const workspace = await canonicalWorkspace(cwd);
674
- const trust = await loadTrust();
675
- if (!trust.workspaces.includes(workspace)) {
676
- trust.workspaces.push(workspace);
677
- trust.workspaces.sort();
678
- await saveTrust(trust);
679
- }
687
+ await withTrustLock(async () => {
688
+ const trust = await readTrust();
689
+ if (!trust.workspaces.includes(workspace)) {
690
+ trust.workspaces.push(workspace);
691
+ trust.workspaces.sort();
692
+ await writeTrust(trust);
693
+ }
694
+ });
680
695
  return workspace;
681
696
  }
682
697
  async function revokeWorkspaceTrust(cwd) {
683
698
  const workspace = await canonicalWorkspace(cwd);
684
- const trust = await loadTrust();
685
- const workspaces = trust.workspaces.filter((entry) => entry !== workspace);
686
- if (workspaces.length !== trust.workspaces.length) await saveTrust({ version: 1, workspaces });
699
+ await withTrustLock(async () => {
700
+ const trust = await readTrust();
701
+ const workspaces = trust.workspaces.filter((entry) => entry !== workspace);
702
+ if (workspaces.length !== trust.workspaces.length) await writeTrust({ version: 1, workspaces });
703
+ });
687
704
  return workspace;
688
705
  }
689
706
  async function loadTrust() {
690
707
  await trustLock;
708
+ return readTrust();
709
+ }
710
+ async function readTrust() {
691
711
  let parsed;
692
712
  try {
693
713
  parsed = JSON.parse(await readFile(trustPath, "utf8"));
@@ -703,26 +723,29 @@ async function loadTrust() {
703
723
  workspaces: value.workspaces.filter((entry) => typeof entry === "string")
704
724
  };
705
725
  }
706
- async function saveTrust(value) {
707
- const release = trustLock;
708
- let resolveLock;
726
+ async function writeTrust(value) {
727
+ await ensureDir(path2.dirname(trustPath));
728
+ const temp = `${trustPath}.${randomUUID()}.tmp`;
729
+ try {
730
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}
731
+ `, { encoding: "utf8", mode: 384 });
732
+ await rename(temp, trustPath);
733
+ } catch (error) {
734
+ await rm(temp, { force: true });
735
+ throw error;
736
+ }
737
+ }
738
+ async function withTrustLock(action) {
739
+ const previous = trustLock;
740
+ let release;
709
741
  trustLock = new Promise((resolve3) => {
710
- resolveLock = resolve3;
742
+ release = resolve3;
711
743
  });
712
- await release;
744
+ await previous;
713
745
  try {
714
- await ensureDir(path2.dirname(trustPath));
715
- const temp = `${trustPath}.${randomUUID()}.tmp`;
716
- try {
717
- await writeFile(temp, `${JSON.stringify(value, null, 2)}
718
- `, { encoding: "utf8", mode: 384 });
719
- await rename(temp, trustPath);
720
- } catch (error) {
721
- await rm(temp, { force: true });
722
- throw error;
723
- }
746
+ return await action();
724
747
  } finally {
725
- resolveLock();
748
+ release();
726
749
  }
727
750
  }
728
751
 
@@ -732,9 +755,16 @@ async function configLocation() {
732
755
  active ??= await resolveConfigLocation();
733
756
  return active;
734
757
  }
758
+ async function loadConfig(cwd) {
759
+ const location = await resolveConfigLocation(cwd);
760
+ return loadConfigAt(location);
761
+ }
735
762
  async function loadProjectConfig(dir) {
736
763
  return loadConfigAt({ path: projectConfigPath(dir), scope: "project" });
737
764
  }
765
+ async function loadGlobalConfig() {
766
+ return loadConfigAt({ path: configPath, scope: "global" });
767
+ }
738
768
  async function loadRuntimeConfig(cwd) {
739
769
  const location = await resolveConfigLocation(cwd);
740
770
  if (location.scope === "project" && !await isWorkspaceTrusted(cwd)) {
@@ -754,7 +784,7 @@ async function loadConfigAt(location) {
754
784
  }
755
785
  async function saveConfig(config) {
756
786
  const location = await configLocation();
757
- await writeJsonAtomic(location.path, config);
787
+ await writeJsonAtomic(location.path, config, location.scope === "project" ? void 0 : 384);
758
788
  }
759
789
  async function initProjectConfig(dir) {
760
790
  const location = { path: projectConfigPath(dir), scope: "project" };
@@ -817,17 +847,24 @@ async function readJson(file) {
817
847
  }
818
848
  }
819
849
  async function writeJsonAtomic(file, value, mode) {
820
- await ensureDir(path3.dirname(file));
850
+ const parent = path3.dirname(file);
851
+ if (isInside(homeDir, parent)) await ensureDir(parent);
852
+ else await mkdir2(parent, { recursive: true });
821
853
  const temp = `${file}.${randomUUID2()}.tmp`;
822
854
  try {
823
855
  await writeFile2(temp, `${JSON.stringify(value, null, 2)}
824
856
  `, { encoding: "utf8", mode });
825
857
  await rename2(temp, file);
858
+ if (mode !== void 0) await chmod2(file, mode);
826
859
  } catch (error) {
827
860
  await rm2(temp, { force: true });
828
861
  throw error;
829
862
  }
830
863
  }
864
+ function isInside(root, candidate) {
865
+ const relative2 = path3.relative(path3.resolve(root), path3.resolve(candidate));
866
+ return relative2 === "" || !path3.isAbsolute(relative2) && relative2.split(path3.sep)[0] !== "..";
867
+ }
831
868
  function formatIssues(file, error) {
832
869
  const lines = error.issues.map(
833
870
  (issue) => ` ${issue.path.join(".") || "(root)"}: ${issue.message}`
@@ -854,8 +891,10 @@ async function addProvider(url, key, options = {}) {
854
891
  await initProjectConfig(cwd);
855
892
  await trustWorkspace(cwd);
856
893
  }
857
- const config = options.local ? await loadProjectConfig(cwd) : (await loadRuntimeConfig(cwd)).config;
894
+ const config = options.local ? await loadProjectConfig(cwd) : process.env.KITCODE_CONFIG ? await loadConfig(cwd) : await loadGlobalConfig();
858
895
  const auth = await loadAuth();
896
+ const configBefore = structuredClone(config);
897
+ const authBefore = { ...auth };
859
898
  config.providers[detected.id] = detected.config;
860
899
  auth[detected.id] = key;
861
900
  const models = detected.models;
@@ -863,8 +902,16 @@ async function addProvider(url, key, options = {}) {
863
902
  const chosen = pickDefault(models);
864
903
  if (chosen) config.model = formatModelRef(detected.id, chosen);
865
904
  }
866
- await saveConfig(config);
867
- await saveAuth(auth);
905
+ try {
906
+ await saveConfig(config);
907
+ await saveAuth(auth);
908
+ } catch (error) {
909
+ Object.assign(config, configBefore);
910
+ for (const id of Object.keys(auth)) delete auth[id];
911
+ Object.assign(auth, authBefore);
912
+ await Promise.allSettled([saveConfig(config), saveAuth(auth)]);
913
+ throw error;
914
+ }
868
915
  const location = await configLocation();
869
916
  const protocol = detected.config.type === "anthropic" ? "Anthropic" : "OpenAI-compatible";
870
917
  const rows = [
@@ -971,7 +1018,7 @@ function skipControlString(text, from, bellTerminates) {
971
1018
 
972
1019
  // src/app/runtime.ts
973
1020
  import path13 from "path";
974
- import { mkdir as mkdir5 } from "fs/promises";
1021
+ import { mkdir as mkdir6 } from "fs/promises";
975
1022
 
976
1023
  // src/core/session.ts
977
1024
  import { randomBytes } from "crypto";
@@ -997,8 +1044,13 @@ async function saveSession(state) {
997
1044
  await ensureDir(sessionsDir);
998
1045
  const file = path4.join(sessionsDir, `${state.id}.json`);
999
1046
  const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
1000
- await writeFile3(temp, JSON.stringify(state), { encoding: "utf8", mode: 384 });
1001
- await atomicRename(temp, file);
1047
+ try {
1048
+ await writeFile3(temp, JSON.stringify(state), { encoding: "utf8", mode: 384 });
1049
+ await atomicRename(temp, file);
1050
+ } catch (error) {
1051
+ await unlink(temp).catch(() => void 0);
1052
+ throw error;
1053
+ }
1002
1054
  }
1003
1055
  async function atomicRename(from, to, retries = 5) {
1004
1056
  for (let attempt = 0; attempt < retries; attempt++) {
@@ -1172,7 +1224,9 @@ function readState(raw) {
1172
1224
  }
1173
1225
  if (typeof parsed !== "object" || parsed === null) return null;
1174
1226
  const state = parsed;
1175
- if (typeof state.id !== "string" || !Array.isArray(state.messages)) return null;
1227
+ if (typeof state.id !== "string" || !validSessionId(state.id)) return null;
1228
+ const messages = readMessages(state.messages);
1229
+ if (!messages) return null;
1176
1230
  return {
1177
1231
  id: state.id,
1178
1232
  title: typeof state.title === "string" ? state.title.slice(0, 120) : void 0,
@@ -1180,11 +1234,59 @@ function readState(raw) {
1180
1234
  createdAt: typeof state.createdAt === "string" ? state.createdAt : "",
1181
1235
  updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : "",
1182
1236
  model: typeof state.model === "string" ? state.model : "",
1183
- messages: state.messages,
1184
- usage: Array.isArray(state.usage) ? state.usage : [],
1237
+ messages,
1238
+ usage: readUsageEntries(state.usage),
1185
1239
  context: readContextUsage(state.context)
1186
1240
  };
1187
1241
  }
1242
+ function readMessages(value) {
1243
+ if (!Array.isArray(value)) return null;
1244
+ const messages = [];
1245
+ for (const item of value) {
1246
+ if (typeof item !== "object" || item === null) return null;
1247
+ const candidate = item;
1248
+ if (candidate.role !== "user" && candidate.role !== "assistant") return null;
1249
+ if (!Array.isArray(candidate.content)) return null;
1250
+ const content = [];
1251
+ for (const block of candidate.content) {
1252
+ if (!validContentBlock(block)) return null;
1253
+ content.push(block);
1254
+ }
1255
+ messages.push({ role: candidate.role, content });
1256
+ }
1257
+ return messages;
1258
+ }
1259
+ function validContentBlock(value) {
1260
+ if (typeof value !== "object" || value === null) return false;
1261
+ const block = value;
1262
+ switch (block.type) {
1263
+ case "text":
1264
+ case "thinking":
1265
+ return typeof block.text === "string";
1266
+ case "tool_use":
1267
+ return typeof block.id === "string" && typeof block.name === "string";
1268
+ case "tool_result":
1269
+ return typeof block.toolUseId === "string" && typeof block.content === "string" && (block.isError === void 0 || typeof block.isError === "boolean");
1270
+ case "image":
1271
+ return typeof block.name === "string" && typeof block.data === "string" && block.data.length <= MAX_SESSION_BYTES * 2 && ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(String(block.mediaType));
1272
+ case "file":
1273
+ return typeof block.name === "string" && typeof block.mediaType === "string" && typeof block.text === "string";
1274
+ default:
1275
+ return false;
1276
+ }
1277
+ }
1278
+ function readUsageEntries(value) {
1279
+ if (!Array.isArray(value)) return [];
1280
+ return value.flatMap((item) => {
1281
+ if (typeof item !== "object" || item === null) return [];
1282
+ const candidate = item;
1283
+ const usage = readUsage(candidate.usage);
1284
+ if (typeof candidate.model !== "string" || !usage || !Number.isInteger(candidate.requests) || candidate.requests < 0) {
1285
+ return [];
1286
+ }
1287
+ return [{ model: candidate.model, usage, requests: candidate.requests, costUsd: null }];
1288
+ });
1289
+ }
1188
1290
  async function resolveExportTarget(destination, state) {
1189
1291
  const resolved = path4.resolve(destination);
1190
1292
  const info = await stat2(resolved).catch(() => null);
@@ -1202,12 +1304,12 @@ function exportFileName(state) {
1202
1304
  }
1203
1305
  function renderSessionMarkdown(state) {
1204
1306
  const lines = [
1205
- `# ${state.title || `KitCode session ${shortSessionId(state.id)}`}`,
1307
+ `# ${escapeMarkdownInline(state.title || `KitCode session ${shortSessionId(state.id)}`)}`,
1206
1308
  "",
1207
- `- Session: ${state.id}`,
1208
- `- Workspace: ${state.cwd}`,
1209
- `- Model: ${state.model || "unknown"}`,
1210
- `- Updated: ${state.updatedAt}`,
1309
+ `- Session: ${escapeMarkdownInline(state.id)}`,
1310
+ `- Workspace: ${escapeMarkdownInline(state.cwd)}`,
1311
+ `- Model: ${escapeMarkdownInline(state.model || "unknown")}`,
1312
+ `- Updated: ${escapeMarkdownInline(state.updatedAt)}`,
1211
1313
  ""
1212
1314
  ];
1213
1315
  for (const message of state.messages) {
@@ -1222,18 +1324,41 @@ function renderMessage(content) {
1222
1324
  const sections = [];
1223
1325
  for (const block of content) {
1224
1326
  if (block.type === "text") sections.push(block.text);
1225
- else if (block.type === "image") sections.push(`[Image attached: ${block.name}]`);
1226
- else if (block.type === "file") sections.push(`[File attached: ${block.name}]
1327
+ else if (block.type === "image") {
1328
+ sections.push(`[Image attached: ${escapeMarkdownInline(block.name)}]`);
1329
+ } else if (block.type === "file") {
1330
+ sections.push(
1331
+ `[File attached: ${escapeMarkdownInline(block.name)}]
1227
1332
 
1228
- ${block.text}`);
1229
- else if (block.type === "tool_use") sections.push(`> Tool: ${block.name}`);
1230
- else if (block.type === "tool_result") {
1333
+ ${markdownCodeFence(block.text)}`
1334
+ );
1335
+ } else if (block.type === "tool_use") {
1336
+ sections.push(`> Tool: ${escapeMarkdownInline(block.name)}`);
1337
+ } else if (block.type === "tool_result") {
1338
+ const result = block.content.replace(/\r\n?/g, "\n").split("\n").map((line) => `> ${line}`).join("\n");
1231
1339
  sections.push(`> Tool result${block.isError ? " (error)" : ""}:
1232
- > ${block.content.replace(/\n/g, "\n> ")}`);
1340
+ ${result}`);
1233
1341
  }
1234
1342
  }
1235
1343
  return sections.join("\n\n").trim();
1236
1344
  }
1345
+ function escapeMarkdownInline(value) {
1346
+ return value.replace(/[\r\n\t]+/g, " ").replace(/([\\`*_[\]<>])/g, "\\$1");
1347
+ }
1348
+ function markdownCodeFence(value) {
1349
+ const normalized = value.replace(/\r\n?/g, "\n");
1350
+ const backtickLength = longestMarkerRun(normalized, /`+/g) + 1;
1351
+ const tildeLength = longestMarkerRun(normalized, /~+/g) + 1;
1352
+ const marker = backtickLength <= tildeLength ? "`" : "~";
1353
+ const fence = marker.repeat(Math.max(3, Math.min(backtickLength, tildeLength)));
1354
+ return `${fence}
1355
+ ${normalized}${normalized.endsWith("\n") ? "" : "\n"}${fence}`;
1356
+ }
1357
+ function longestMarkerRun(value, pattern) {
1358
+ let longest = 0;
1359
+ for (const match of value.matchAll(pattern)) longest = Math.max(longest, match[0].length);
1360
+ return longest;
1361
+ }
1237
1362
  function readContextUsage(value) {
1238
1363
  if (typeof value !== "object" || value === null) return void 0;
1239
1364
  const candidate = value;
@@ -1245,7 +1370,9 @@ function readUsage(value) {
1245
1370
  if (typeof value !== "object" || value === null) return void 0;
1246
1371
  const usage = value;
1247
1372
  const fields = ["input", "output", "cacheWrite", "cacheRead"];
1248
- if (fields.some((field2) => typeof usage[field2] !== "number" || !Number.isFinite(usage[field2]))) {
1373
+ if (fields.some(
1374
+ (field2) => typeof usage[field2] !== "number" || !Number.isFinite(usage[field2]) || usage[field2] < 0
1375
+ )) {
1249
1376
  return void 0;
1250
1377
  }
1251
1378
  return {
@@ -1276,10 +1403,13 @@ async function resolveSessionId(query) {
1276
1403
  );
1277
1404
  }
1278
1405
  function assertSessionId(id) {
1279
- if (!/^[A-Za-z0-9_-]{1,240}$/.test(id)) {
1406
+ if (!validSessionId(id)) {
1280
1407
  throw new Error(`Invalid session id: ${id}`);
1281
1408
  }
1282
1409
  }
1410
+ function validSessionId(id) {
1411
+ return /^[A-Za-z0-9_-]{1,240}$/.test(id);
1412
+ }
1283
1413
 
1284
1414
  // src/core/agent.ts
1285
1415
  var MAX_PAUSE_RESUMES = 5;
@@ -1455,11 +1585,20 @@ function retryAfterFromError(error) {
1455
1585
  }
1456
1586
  function sleep(ms, signal) {
1457
1587
  return new Promise((resolve3, reject) => {
1458
- const id = setTimeout(resolve3, ms);
1459
- signal.addEventListener("abort", () => {
1588
+ if (signal.aborted) {
1589
+ reject(signal.reason ?? new Error("Aborted"));
1590
+ return;
1591
+ }
1592
+ const finish = () => {
1593
+ signal.removeEventListener("abort", onAbort);
1594
+ resolve3();
1595
+ };
1596
+ const onAbort = () => {
1460
1597
  clearTimeout(id);
1461
1598
  reject(signal.reason ?? new Error("Aborted"));
1462
- }, { once: true });
1599
+ };
1600
+ const id = setTimeout(finish, ms);
1601
+ signal.addEventListener("abort", onAbort, { once: true });
1463
1602
  });
1464
1603
  }
1465
1604
  function estimateRequestTokens(cfg, messages) {
@@ -1478,9 +1617,7 @@ function estimateRequestTokens(cfg, messages) {
1478
1617
  async function runToolCalls(cfg, content, hooks, signal) {
1479
1618
  const calls = content.filter((block) => block.type === "tool_use");
1480
1619
  const results = [];
1481
- for (const call of calls) {
1482
- results.push(await runOneCall(cfg, call, hooks, signal));
1483
- }
1620
+ for (const call of calls) results.push(await runOneCall(cfg, call, hooks, signal));
1484
1621
  return results;
1485
1622
  }
1486
1623
  async function runOneCall(cfg, call, hooks, signal) {
@@ -1608,6 +1745,7 @@ function costOf(usage, pricing) {
1608
1745
  }
1609
1746
 
1610
1747
  // src/core/budget.ts
1748
+ var UNKNOWN_MODEL_PRICING = { input: 3, output: 15 };
1611
1749
  function createTurnBudget(limits, resolvePricing = pricingFor) {
1612
1750
  let usage = emptyUsage();
1613
1751
  let costUsd = 0;
@@ -1645,7 +1783,7 @@ function createTurnBudget(limits, resolvePricing = pricingFor) {
1645
1783
  const pricing = resolvePricing(request.modelRef);
1646
1784
  if (current.costUsd !== null) {
1647
1785
  const remainingUsd = limits.maxCostUsdPerTurn - current.costUsd;
1648
- const effectivePricing = pricing ?? { input: 3e-3, output: 0.015 };
1786
+ const effectivePricing = pricing ?? UNKNOWN_MODEL_PRICING;
1649
1787
  const inputUsd = estimatedInput * effectivePricing.input / 1e6;
1650
1788
  const affordableOutput = Math.floor(
1651
1789
  (remainingUsd - inputUsd) * 1e6 / effectivePricing.output
@@ -1661,9 +1799,10 @@ function createTurnBudget(limits, resolvePricing = pricingFor) {
1661
1799
  return { allowed: true, maxOutputTokens: Math.floor(outputLimit) };
1662
1800
  },
1663
1801
  record(modelRef, next) {
1664
- usage = addUsage(usage, normalizeUsage(next));
1665
- const priced = costOf(usage, resolvePricing(modelRef));
1666
- costUsd = priced;
1802
+ const normalized = normalizeUsage(next);
1803
+ usage = addUsage(usage, normalized);
1804
+ const priced = costOf(normalized, resolvePricing(modelRef) ?? UNKNOWN_MODEL_PRICING);
1805
+ costUsd = (costUsd ?? 0) + (priced ?? 0);
1667
1806
  },
1668
1807
  snapshot
1669
1808
  };
@@ -1686,7 +1825,7 @@ import { createReadStream } from "fs";
1686
1825
  import {
1687
1826
  chmod as chmod3,
1688
1827
  lstat,
1689
- mkdir as mkdir2,
1828
+ mkdir as mkdir3,
1690
1829
  readFile as readFile4,
1691
1830
  readdir as readdir2,
1692
1831
  rename as rename4,
@@ -1928,7 +2067,7 @@ async function restoreFile(root, relativePath, snapshot, expected) {
1928
2067
  const safe = resolveInside(root, relativePath);
1929
2068
  if (!safe.ok || safe.relative !== relativePath) throw new Error("The restore path changed.");
1930
2069
  const data = decodeSnapshot(snapshot);
1931
- await mkdir2(path5.dirname(safe.path), { recursive: true });
2070
+ await mkdir3(path5.dirname(safe.path), { recursive: true });
1932
2071
  const rechecked = resolveInside(root, relativePath);
1933
2072
  if (!rechecked.ok || rechecked.path !== safe.path || rechecked.relative !== relativePath) {
1934
2073
  throw new Error("The restore path changed while preparing its parent directory.");
@@ -2372,17 +2511,15 @@ async function loadAttachment(cwd, requestedPath) {
2372
2511
  }
2373
2512
  async function loadAutomaticAttachment(cwd, requestedPath) {
2374
2513
  if (!looksLikeAttachmentPath(requestedPath)) return null;
2375
- const resolved = resolveAttachmentPath(cwd, requestedPath);
2514
+ const requested = resolveAttachmentPath(cwd, requestedPath);
2515
+ const safe = resolveInside(cwd, requested);
2516
+ if (!safe.ok || safe.relative === "") return null;
2517
+ const resolved = safe.path;
2376
2518
  if (isSensitiveAutomaticPath(resolved)) {
2377
2519
  throw new Error(
2378
2520
  `For safety, sensitive-looking files must be attached explicitly with /attach: ${path7.basename(resolved)}`
2379
2521
  );
2380
2522
  }
2381
- if (path7.isAbsolute(resolved)) {
2382
- const normalized = resolved.toLowerCase();
2383
- const isProjectFile = normalized.includes(path7.sep + "src" + path7.sep) || normalized.includes(path7.sep + "lib" + path7.sep) || normalized.includes(path7.sep + "app" + path7.sep) || normalized.includes(path7.sep + "test" + path7.sep) || normalized.includes(path7.sep + "tests" + path7.sep) || normalized.includes(path7.sep + "public" + path7.sep) || normalized.includes(path7.sep + "assets" + path7.sep) || normalized.includes(path7.sep + "static" + path7.sep) || normalized.endsWith(".ts") || normalized.endsWith(".js") || normalized.endsWith(".tsx") || normalized.endsWith(".jsx") || normalized.endsWith(".py") || normalized.endsWith(".go") || normalized.endsWith(".rs") || normalized.endsWith(".java") || normalized.endsWith(".c") || normalized.endsWith(".cpp") || normalized.endsWith(".h") || normalized.endsWith(".hpp") || normalized.endsWith(".css") || normalized.endsWith(".html") || normalized.endsWith(".json") || normalized.endsWith(".yaml") || normalized.endsWith(".yml") || normalized.endsWith(".md") || normalized.endsWith(".txt") || normalized.endsWith(".toml") || normalized.endsWith(".ini") || normalized.endsWith(".cfg") || normalized.endsWith(".sh") || normalized.endsWith(".bat") || normalized.endsWith(".cmd") || normalized.endsWith(".ps1") || normalized.endsWith(".dockerfile") || normalized.endsWith(".makefile");
2384
- if (!isProjectFile) return null;
2385
- }
2386
2523
  const linkInfo = await lstat2(resolved).catch(() => null);
2387
2524
  if (linkInfo?.isSymbolicLink()) return null;
2388
2525
  const info = await stat5(resolved).catch((error) => {
@@ -2400,8 +2537,8 @@ function looksLikeAttachmentPath(value) {
2400
2537
  if (path7.isAbsolute(candidate)) return true;
2401
2538
  if (/^(?:~|\.{1,2})[\\/]/.test(candidate)) return true;
2402
2539
  if (candidate.includes("/") || candidate.includes("\\")) return true;
2403
- const basename2 = path7.basename(candidate).toLowerCase();
2404
- return path7.extname(basename2) !== "" || AUTO_PATH_NAMES.has(basename2);
2540
+ const basename3 = path7.basename(candidate).toLowerCase();
2541
+ return path7.extname(basename3) !== "" || AUTO_PATH_NAMES.has(basename3);
2405
2542
  }
2406
2543
  async function loadClipboardImage(platform = process.platform, runner = runClipboardCommand) {
2407
2544
  const commands = clipboardCommands(platform);
@@ -2477,8 +2614,8 @@ function resolveAttachmentPath(cwd, requestedPath) {
2477
2614
  return path7.isAbsolute(expanded) ? path7.normalize(expanded) : path7.resolve(cwd, expanded);
2478
2615
  }
2479
2616
  function isSensitiveAutomaticPath(file) {
2480
- const basename2 = path7.basename(file).toLowerCase();
2481
- return basename2.startsWith(".env.") || SENSITIVE_AUTO_NAMES.has(basename2) || SENSITIVE_AUTO_EXTENSIONS.has(path7.extname(basename2));
2617
+ const basename3 = path7.basename(file).toLowerCase();
2618
+ return basename3.startsWith(".env.") || SENSITIVE_AUTO_NAMES.has(basename3) || SENSITIVE_AUTO_EXTENSIONS.has(path7.extname(basename3));
2482
2619
  }
2483
2620
  function attachmentLabel(block) {
2484
2621
  if (block.type === "image") return `image: ${block.name}`;
@@ -2661,47 +2798,90 @@ function formatBytes(value) {
2661
2798
 
2662
2799
  // src/core/compact.ts
2663
2800
  var KEEP_USER_TURNS = 2;
2664
- var MAX_SOURCE_CHARS = 5e5;
2665
- var MAX_TOOL_RESULT_CHARS = 8e3;
2666
- var MAX_TEXT_BLOCK_CHARS = 5e4;
2801
+ var SINGLE_REQUEST_MAX_CHARS = 8e4;
2802
+ var CHUNK_TARGET_CHARS = 35e3;
2803
+ var CHUNK_OVERLAP_MESSAGES = 2;
2804
+ var CHUNK_OVERLAP_MAX_CHARS = 4e3;
2805
+ var MERGE_TARGET_CHARS = 6e4;
2806
+ var MAX_USER_TEXT_CHARS = 12e3;
2807
+ var MAX_ASSISTANT_TEXT_CHARS = 6e3;
2808
+ var MAX_FILE_BLOCK_CHARS = 2e3;
2809
+ var MAX_TOOL_RESULT_CHARS = 1200;
2810
+ var MAX_TOOL_ERROR_CHARS = 2e3;
2811
+ var MAX_TOOL_INPUT_CHARS = 800;
2812
+ var SINGLE_MAX_TOKENS = 768;
2813
+ var CHUNK_MAX_TOKENS = 512;
2814
+ var MERGE_MAX_TOKENS = 1024;
2815
+ var CHUNK_CONCURRENCY = 3;
2816
+ var SYSTEM_SUMMARIZE = "You are summarizing a coding conversation so another agent can continue the work. Extract and preserve ONLY: concrete requirements, user preferences, files changed, commands run and their results, errors encountered, bugs found, architectural decisions, and unfinished work. Be terse and factual. Do NOT include greetings, acknowledgments, chain-of-thought, or step-by-step narration. Return only the summary.";
2817
+ var SYSTEM_MERGE = "Merge these partial summaries of a coding conversation into one coherent summary. Remove duplicates. Preserve: requirements, files changed, commands run, errors, decisions, and unfinished work. Be terse. Return only the merged summary.";
2667
2818
  async function compactHistory(options) {
2668
2819
  const cut = compactCutIndex(options.history);
2669
2820
  if (cut <= 0) {
2670
2821
  return { history: options.history, compacted: false, removedMessages: 0 };
2671
2822
  }
2672
- const source = renderHistory(options.history.slice(0, cut));
2673
- if (!source.trim()) {
2823
+ const oldMessages = options.history.slice(0, cut);
2824
+ const renderedMessages = renderMessages(oldMessages);
2825
+ const rendered = renderedMessages.join("\n\n");
2826
+ if (!rendered.trim()) {
2674
2827
  return { history: options.history, compacted: false, removedMessages: 0 };
2675
2828
  }
2676
- let summary = "";
2677
- let finalText2 = "";
2829
+ let summary;
2678
2830
  let usage;
2679
2831
  let rateLimits;
2680
- const stream = options.provider.stream({
2681
- model: options.model,
2682
- system: "Summarize the earlier coding conversation for another agent. Preserve concrete requirements, user preferences, architectural decisions, commands and files changed, test results, bugs, errors, and unfinished work. Be concise but lossless. Do not include private chain-of-thought or invent details. Return only the durable summary.",
2683
- messages: [
2684
- {
2685
- role: "user",
2686
- content: [{ type: "text", text: `Earlier conversation to compact:
2687
-
2688
- ${source}` }]
2689
- }
2690
- ],
2691
- tools: [],
2692
- maxTokens: Math.max(512, Math.min(4096, options.maxTokens)),
2693
- thinking: false,
2694
- signal: options.signal
2695
- });
2696
- for await (const event of stream) {
2697
- if (event.type === "text_delta") summary += event.text;
2698
- else if (event.type === "usage") usage = event.usage;
2699
- else if (event.type === "rate_limits") rateLimits = event.limits;
2700
- else if (event.type === "done") {
2701
- finalText2 = event.content.filter((block) => block.type === "text").map((block) => block.type === "text" ? block.text : "").join("");
2832
+ if (rendered.length <= SINGLE_REQUEST_MAX_CHARS) {
2833
+ const requestMax = singleOutputLimit(options.maxTokens, options.maxTotalOutputTokens);
2834
+ const result = await summarizeText(
2835
+ options.provider,
2836
+ options.model,
2837
+ options.maxTokens,
2838
+ options.signal,
2839
+ rendered,
2840
+ SYSTEM_SUMMARIZE,
2841
+ requestMax
2842
+ );
2843
+ summary = result.text;
2844
+ usage = result.usage;
2845
+ rateLimits = result.rateLimits;
2846
+ } else {
2847
+ const chunks = chunkRenderedMessages(
2848
+ renderedMessages,
2849
+ CHUNK_TARGET_CHARS,
2850
+ CHUNK_OVERLAP_MESSAGES,
2851
+ CHUNK_OVERLAP_MAX_CHARS
2852
+ );
2853
+ const limits = chunkOutputLimits(
2854
+ chunks.length,
2855
+ options.maxTokens,
2856
+ options.maxTotalOutputTokens
2857
+ );
2858
+ const chunkResults = await summarizeSourcesParallel(
2859
+ options.provider,
2860
+ options.model,
2861
+ options.maxTokens,
2862
+ options.signal,
2863
+ chunks,
2864
+ SYSTEM_SUMMARIZE,
2865
+ limits.chunk
2866
+ );
2867
+ const chunkUsages = chunkResults.map((result) => result.usage).filter(Boolean);
2868
+ usage = chunkUsages.length > 0 ? mergeUsage(chunkUsages) : void 0;
2869
+ rateLimits = [...chunkResults].reverse().find((r) => r.rateLimits)?.rateLimits;
2870
+ const mergeResult = await mergePartialSummaries(
2871
+ options.provider,
2872
+ options.model,
2873
+ options.maxTokens,
2874
+ options.signal,
2875
+ chunkResults.map((result) => result.text),
2876
+ limits.merge
2877
+ );
2878
+ summary = mergeResult.text;
2879
+ if (mergeResult.usage) {
2880
+ usage = usage ? mergeUsage([usage, mergeResult.usage]) : mergeResult.usage;
2702
2881
  }
2882
+ if (mergeResult.rateLimits) rateLimits = mergeResult.rateLimits;
2703
2883
  }
2704
- summary = (finalText2 || summary).trim();
2884
+ summary = summary.trim();
2705
2885
  if (!summary) throw new Error("The provider returned an empty context summary.");
2706
2886
  const prefix = [
2707
2887
  {
@@ -2733,11 +2913,34 @@ function compactCutIndex(history) {
2733
2913
  if (userTurns.length <= KEEP_USER_TURNS) return 0;
2734
2914
  return userTurns[userTurns.length - KEEP_USER_TURNS] ?? 0;
2735
2915
  }
2736
- function estimateCompactTokens(history) {
2916
+ function estimateCompactBudget(history, maxTokens = MERGE_MAX_TOKENS) {
2737
2917
  const cut = compactCutIndex(history);
2738
- if (cut <= 0) return 1;
2739
- const rendered = renderHistory(history.slice(0, cut));
2740
- return Math.max(1, Math.min(rendered.length, MAX_SOURCE_CHARS) + 1e3);
2918
+ if (cut <= 0) return { inputTokens: 1, maxOutputTokens: 0 };
2919
+ const renderedMessages = renderMessages(history.slice(0, cut));
2920
+ const rendered = renderedMessages.join("\n\n");
2921
+ if (rendered.length <= SINGLE_REQUEST_MAX_CHARS) {
2922
+ return {
2923
+ inputTokens: Math.max(1, rendered.length + 1e3),
2924
+ maxOutputTokens: Math.max(1, Math.min(SINGLE_MAX_TOKENS, Math.floor(maxTokens)))
2925
+ };
2926
+ }
2927
+ const chunks = chunkRenderedMessages(
2928
+ renderedMessages,
2929
+ CHUNK_TARGET_CHARS,
2930
+ CHUNK_OVERLAP_MESSAGES,
2931
+ CHUNK_OVERLAP_MAX_CHARS
2932
+ );
2933
+ const chunkInput = chunks.reduce((total, chunk) => total + chunk.length, 0);
2934
+ const chunkOutput = Math.max(1, Math.min(CHUNK_MAX_TOKENS, Math.floor(maxTokens)));
2935
+ const mergeOutput = Math.max(1, Math.min(MERGE_MAX_TOKENS, Math.floor(maxTokens)));
2936
+ const mergeRequests = Math.max(0, chunks.length - 1);
2937
+ const totalOutput = chunks.length * chunkOutput + mergeRequests * mergeOutput;
2938
+ const intermediateOutput = chunks.length * chunkOutput + Math.max(0, mergeRequests - 1) * mergeOutput;
2939
+ const requestCount = chunks.length + mergeRequests;
2940
+ return {
2941
+ inputTokens: Math.max(1, chunkInput + intermediateOutput * 8 + requestCount * 1e3),
2942
+ maxOutputTokens: totalOutput
2943
+ };
2741
2944
  }
2742
2945
  function isConversationUser(message) {
2743
2946
  if (message.role !== "user") return false;
@@ -2745,50 +2948,249 @@ function isConversationUser(message) {
2745
2948
  (block) => block.type === "image" || block.type === "file" || block.type === "text" && !block.text.startsWith("[Earlier conversation summary]")
2746
2949
  );
2747
2950
  }
2748
- function renderHistory(history) {
2749
- let result = "";
2750
- for (const message of history) {
2751
- const blocks = message.content.map(renderBlock).filter(Boolean);
2752
- if (blocks.length === 0) continue;
2753
- const part = `${message.role.toUpperCase()}:
2754
- ${blocks.join("\n\n")}`;
2755
- if (result.length + part.length > MAX_SOURCE_CHARS) {
2756
- result += "\n\n[...older context truncated for compaction...]";
2757
- break;
2951
+ function chunkRenderedMessages(messages, targetChars, overlapMessages, overlapMaxChars) {
2952
+ const units = messages.flatMap((message) => splitLongMessage(message, targetChars));
2953
+ const chunks = [];
2954
+ let current = [];
2955
+ const sizeOf = (parts) => parts.reduce((total, part) => total + part.length, 0) + Math.max(0, parts.length - 1) * 2;
2956
+ for (const unit of units) {
2957
+ if (current.length > 0 && sizeOf([...current, unit]) > targetChars) {
2958
+ chunks.push(current.join("\n\n"));
2959
+ const overlap = [];
2960
+ let overlapChars = 0;
2961
+ for (let index = current.length - 1; index >= 0 && overlap.length < overlapMessages; index -= 1) {
2962
+ const candidate = current[index];
2963
+ if (overlapChars + candidate.length > overlapMaxChars) break;
2964
+ overlap.unshift(candidate);
2965
+ overlapChars += candidate.length;
2966
+ }
2967
+ current = overlap;
2968
+ while (current.length > 0 && sizeOf([...current, unit]) > targetChars) current.shift();
2758
2969
  }
2759
- result += (result ? "\n\n" : "") + part;
2970
+ current.push(unit);
2760
2971
  }
2761
- return result;
2972
+ if (current.length > 0) chunks.push(current.join("\n\n"));
2973
+ return chunks;
2974
+ }
2975
+ function splitLongMessage(message, targetChars) {
2976
+ if (message.length <= targetChars) return [message];
2977
+ const parts = [];
2978
+ let remaining = message;
2979
+ const payloadLimit = Math.max(1, targetChars - 32);
2980
+ while (remaining.length > payloadLimit) {
2981
+ let cut = remaining.lastIndexOf("\n", payloadLimit);
2982
+ if (cut < payloadLimit * 0.6) cut = payloadLimit;
2983
+ parts.push(`${remaining.slice(0, cut)}
2984
+ [message continues]`);
2985
+ remaining = `[continued message]
2986
+ ${remaining.slice(cut).replace(/^\n/, "")}`;
2987
+ }
2988
+ if (remaining) parts.push(remaining);
2989
+ return parts;
2990
+ }
2991
+ async function summarizeSourcesParallel(provider, model, maxTokens, signal, sources, systemPrompt, maxOutputTokens) {
2992
+ const results = new Array(sources.length);
2993
+ const controller = new AbortController();
2994
+ const abort = () => controller.abort(signal.reason);
2995
+ if (signal.aborted) abort();
2996
+ else signal.addEventListener("abort", abort, { once: true });
2997
+ let next = 0;
2998
+ const worker = async () => {
2999
+ for (; ; ) {
3000
+ const index = next++;
3001
+ if (index >= sources.length) return;
3002
+ try {
3003
+ results[index] = await summarizeText(
3004
+ provider,
3005
+ model,
3006
+ maxTokens,
3007
+ controller.signal,
3008
+ sources[index],
3009
+ systemPrompt,
3010
+ maxOutputTokens
3011
+ );
3012
+ } catch (error) {
3013
+ controller.abort(error);
3014
+ throw error;
3015
+ }
3016
+ }
3017
+ };
3018
+ try {
3019
+ await Promise.all(
3020
+ Array.from({ length: Math.min(CHUNK_CONCURRENCY, sources.length) }, () => worker())
3021
+ );
3022
+ return results;
3023
+ } finally {
3024
+ signal.removeEventListener("abort", abort);
3025
+ }
3026
+ }
3027
+ async function mergePartialSummaries(provider, model, maxTokens, signal, summaries, mergeMaxTokens) {
3028
+ let current = summaries;
3029
+ const usages = [];
3030
+ let rateLimits;
3031
+ while (current.length > 1) {
3032
+ const groups = groupSummaries(current, MERGE_TARGET_CHARS);
3033
+ const sources = groups.map(
3034
+ (group) => group.map((summary, index) => `## Part ${index + 1}
3035
+ ${summary}`).join("\n\n")
3036
+ );
3037
+ const merged = await summarizeSourcesParallel(
3038
+ provider,
3039
+ model,
3040
+ maxTokens,
3041
+ signal,
3042
+ sources,
3043
+ SYSTEM_MERGE,
3044
+ mergeMaxTokens
3045
+ );
3046
+ for (const result of merged) {
3047
+ if (!result.text) throw new Error("The provider returned an empty partial context summary.");
3048
+ if (result.usage) usages.push(result.usage);
3049
+ if (result.rateLimits) rateLimits = result.rateLimits;
3050
+ }
3051
+ current = merged.map((result) => result.text);
3052
+ }
3053
+ return {
3054
+ text: current[0] ?? "",
3055
+ usage: usages.length > 0 ? mergeUsage(usages) : void 0,
3056
+ rateLimits
3057
+ };
3058
+ }
3059
+ function singleOutputLimit(maxTokens, totalBudget) {
3060
+ const requested = Math.max(1, Math.min(SINGLE_MAX_TOKENS, Math.floor(maxTokens)));
3061
+ if (totalBudget === void 0) return requested;
3062
+ const allowed = Math.floor(totalBudget);
3063
+ if (allowed < 1) throw new Error("The remaining turn budget is too small to compact context.");
3064
+ return Math.min(requested, allowed);
3065
+ }
3066
+ function chunkOutputLimits(chunkCount, maxTokens, totalBudget) {
3067
+ let chunk = Math.max(1, Math.min(CHUNK_MAX_TOKENS, Math.floor(maxTokens)));
3068
+ let merge = Math.max(1, Math.min(MERGE_MAX_TOKENS, Math.floor(maxTokens)));
3069
+ if (totalBudget === void 0) return { chunk, merge };
3070
+ const mergeRequests = Math.max(0, chunkCount - 1);
3071
+ const requestCount = chunkCount + mergeRequests;
3072
+ const allowed = Math.floor(totalBudget);
3073
+ if (allowed < requestCount) {
3074
+ throw new Error("The remaining turn budget is too small for all context-compaction requests.");
3075
+ }
3076
+ const requested = chunkCount * chunk + mergeRequests * merge;
3077
+ if (allowed >= requested) return { chunk, merge };
3078
+ const scale = allowed / requested;
3079
+ chunk = Math.max(1, Math.floor(chunk * scale));
3080
+ merge = Math.max(1, Math.floor(merge * scale));
3081
+ const total = () => chunkCount * chunk + mergeRequests * merge;
3082
+ while (total() > allowed) {
3083
+ if (mergeRequests > 0 && merge > 1 && merge >= chunk) merge -= 1;
3084
+ else if (chunk > 1) chunk -= 1;
3085
+ else if (merge > 1) merge -= 1;
3086
+ else throw new Error("Could not fit context compaction into the remaining turn budget.");
3087
+ }
3088
+ return { chunk, merge };
3089
+ }
3090
+ function groupSummaries(summaries, targetChars) {
3091
+ const groups = [];
3092
+ let current = [];
3093
+ let size = 0;
3094
+ for (const summary of summaries) {
3095
+ if (current.length >= 2 && size + 12 + summary.length > targetChars) {
3096
+ groups.push(current);
3097
+ current = [];
3098
+ size = 0;
3099
+ }
3100
+ const separator = current.length > 0 ? 12 : 0;
3101
+ current.push(summary);
3102
+ size += separator + summary.length;
3103
+ }
3104
+ if (current.length > 0) groups.push(current);
3105
+ return groups;
3106
+ }
3107
+ async function summarizeText(provider, model, maxTokens, signal, source, systemPrompt, maxOutputTokens) {
3108
+ if (signal.aborted) throw signal.reason ?? new Error("Context compaction was cancelled.");
3109
+ let summary = "";
3110
+ let finalText2 = "";
3111
+ let usage;
3112
+ let rateLimits;
3113
+ const stream = provider.stream({
3114
+ model,
3115
+ system: systemPrompt,
3116
+ messages: [{ role: "user", content: [{ type: "text", text: source }] }],
3117
+ tools: [],
3118
+ maxTokens: Math.max(1, Math.floor(Math.min(maxOutputTokens, maxTokens))),
3119
+ thinking: false,
3120
+ signal
3121
+ });
3122
+ for await (const event of stream) {
3123
+ if (event.type === "text_delta") summary += event.text;
3124
+ else if (event.type === "usage") usage = event.usage;
3125
+ else if (event.type === "rate_limits") rateLimits = event.limits;
3126
+ else if (event.type === "done") {
3127
+ finalText2 = event.content.filter((block) => block.type === "text").map((block) => block.type === "text" ? block.text : "").join("");
3128
+ }
3129
+ }
3130
+ if (signal.aborted) throw signal.reason ?? new Error("Context compaction was cancelled.");
3131
+ return { text: (finalText2 || summary).trim(), usage, rateLimits };
3132
+ }
3133
+ function mergeUsage(usages) {
3134
+ return usages.reduce(
3135
+ (acc, u) => ({
3136
+ input: acc.input + u.input,
3137
+ output: acc.output + u.output,
3138
+ cacheWrite: acc.cacheWrite + u.cacheWrite,
3139
+ cacheRead: acc.cacheRead + u.cacheRead
3140
+ }),
3141
+ { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 }
3142
+ );
3143
+ }
3144
+ function renderMessages(history) {
3145
+ return history.flatMap((message) => {
3146
+ const blocks = message.content.map((block) => renderBlock(block, message.role)).filter(Boolean);
3147
+ return blocks.length > 0 ? [`${message.role.toUpperCase()}:
3148
+ ${blocks.join("\n")}`] : [];
3149
+ });
2762
3150
  }
2763
- function renderBlock(block) {
3151
+ function renderBlock(block, role) {
2764
3152
  switch (block.type) {
2765
3153
  case "text":
2766
- return truncate(block.text, MAX_TEXT_BLOCK_CHARS);
3154
+ return truncate(
3155
+ block.text,
3156
+ role === "user" ? MAX_USER_TEXT_CHARS : MAX_ASSISTANT_TEXT_CHARS
3157
+ );
2767
3158
  case "thinking":
2768
3159
  return "";
2769
3160
  case "image":
2770
- return `[Image attached: ${block.name}]`;
3161
+ return `[image: ${block.name}]`;
2771
3162
  case "file":
2772
- return `[File attached: ${block.name}]
2773
- ${truncate(block.text, MAX_TOOL_RESULT_CHARS)}`;
3163
+ return `[file: ${block.name}]
3164
+ ${truncate(block.text, MAX_FILE_BLOCK_CHARS)}`;
2774
3165
  case "tool_use":
2775
- return `[Tool call: ${block.name}]
2776
- ${safeJson(block.input)}`;
3166
+ return `[tool: ${block.name}(${truncate(safeJson(block.input), MAX_TOOL_INPUT_CHARS)})]`;
2777
3167
  case "tool_result":
2778
- return `[Tool result${block.isError ? " (error)" : ""}]
2779
- ${truncate(block.content, MAX_TOOL_RESULT_CHARS)}`;
3168
+ if (block.isError) {
3169
+ return `[tool result error: ${truncate(block.content, MAX_TOOL_ERROR_CHARS)}]`;
3170
+ }
3171
+ if (isTrivialSuccess(block.content)) return "";
3172
+ return truncate(block.content, MAX_TOOL_RESULT_CHARS);
2780
3173
  }
2781
3174
  }
3175
+ function isTrivialSuccess(content) {
3176
+ const t = content.trim().toLowerCase();
3177
+ if (!t || t === "ok" || t === "done" || t === "success") return true;
3178
+ if (/^no matches found/.test(t)) return true;
3179
+ if (t === "(no output)") return true;
3180
+ return false;
3181
+ }
2782
3182
  function safeJson(value) {
2783
3183
  try {
2784
- return truncate(JSON.stringify(value, null, 2), MAX_TOOL_RESULT_CHARS);
3184
+ return JSON.stringify(value);
2785
3185
  } catch {
2786
- return "[unserializable input]";
3186
+ return "[unserializable]";
2787
3187
  }
2788
3188
  }
2789
3189
  function truncate(value, max) {
2790
- return value.length <= max ? value : `${value.slice(0, max)}
2791
- [...truncated...]`;
3190
+ if (value.length <= max) return value;
3191
+ const keep = Math.floor(max * 0.4);
3192
+ return `${value.slice(0, max - keep)}
3193
+ ...${value.slice(-keep)}`;
2792
3194
  }
2793
3195
 
2794
3196
  // src/core/prompt.ts
@@ -3050,7 +3452,7 @@ function clip(value) {
3050
3452
  // package.json
3051
3453
  var package_default = {
3052
3454
  name: "@kernelonpanic/kitcode",
3053
- version: "1.1.1",
3455
+ version: "1.2.4",
3054
3456
  description: "Terminal coding agent with a config you never have to write by hand",
3055
3457
  type: "module",
3056
3458
  license: "MIT",
@@ -3091,6 +3493,7 @@ var package_default = {
3091
3493
  "ink-text-input": "^6.0.0",
3092
3494
  openai: "^7.3.0",
3093
3495
  react: "^19.2.8",
3496
+ "string-width": "^8.2.2",
3094
3497
  zod: "^4.4.3"
3095
3498
  },
3096
3499
  devDependencies: {
@@ -3117,8 +3520,7 @@ var package_default = {
3117
3520
 
3118
3521
  // src/version.ts
3119
3522
  var KITCODE_VERSION = package_default.version;
3120
- var KITCODE_REPOSITORY = "KernelEditor/KitCode";
3121
- var KITCODE_COMMIT = true ? "a57e54102a5e1ca1e60cec4ee9c629c890d27057" : "development";
3523
+ var KITCODE_COMMIT = true ? "84441dfba8c67477cf72b76552e72ffedc8d41a8" : "development";
3122
3524
 
3123
3525
  // src/mcp/client.ts
3124
3526
  var clientInfo = { name: "kitcode", version: KITCODE_VERSION };
@@ -3140,7 +3542,11 @@ var inheritedEnvKeys = /* @__PURE__ */ new Set([
3140
3542
  "COMSPEC",
3141
3543
  "PATHEXT",
3142
3544
  "APPDATA",
3143
- "LOCALAPPDATA"
3545
+ "LOCALAPPDATA",
3546
+ "USERPROFILE",
3547
+ "HOMEDRIVE",
3548
+ "HOMEPATH",
3549
+ "USERNAME"
3144
3550
  ]);
3145
3551
  var blockedEnvKeys = /* @__PURE__ */ new Set([
3146
3552
  "ANTHROPIC_API_KEY",
@@ -3255,9 +3661,9 @@ function createMcpManager(servers) {
3255
3661
  serverStates.delete(name);
3256
3662
  },
3257
3663
  async close() {
3258
- const open2 = [...sessions.values()];
3664
+ const open3 = [...sessions.values()];
3259
3665
  sessions.clear();
3260
- await Promise.all(open2.map((session) => session.client.close().catch(() => {
3666
+ await Promise.all(open3.map((session) => session.client.close().catch(() => {
3261
3667
  })));
3262
3668
  },
3263
3669
  states() {
@@ -3317,8 +3723,11 @@ function configuredSecrets(config) {
3317
3723
  }
3318
3724
  function inheritedEnv(source = process.env) {
3319
3725
  const env = {};
3726
+ const allowed = new Set([...inheritedEnvKeys].map((key) => key.toUpperCase()));
3727
+ const blocked = new Set([...blockedEnvKeys].map((key) => key.toUpperCase()));
3320
3728
  for (const [key, value] of Object.entries(source)) {
3321
- if (value !== void 0 && inheritedEnvKeys.has(key) && !blockedEnvKeys.has(key)) {
3729
+ const normalized = key.toUpperCase();
3730
+ if (value !== void 0 && allowed.has(normalized) && !blocked.has(normalized)) {
3322
3731
  env[key] = value;
3323
3732
  }
3324
3733
  }
@@ -3352,7 +3761,10 @@ async function fetchProviderBalance(config, apiKey2, options = {}) {
3352
3761
  },
3353
3762
  signal: controller.signal
3354
3763
  });
3355
- if (!response.ok) continue;
3764
+ if (!response.ok) {
3765
+ await response.body?.cancel().catch(() => void 0);
3766
+ continue;
3767
+ }
3356
3768
  const text = await limitedResponseText(response, MAX_RESPONSE_BYTES);
3357
3769
  if (text === null) continue;
3358
3770
  const balance = endpoint2.parse(JSON.parse(text));
@@ -3467,7 +3879,10 @@ function finiteNumber(value) {
3467
3879
  }
3468
3880
  async function limitedResponseText(response, limit) {
3469
3881
  const declared = Number(response.headers.get("content-length"));
3470
- if (Number.isFinite(declared) && declared > limit) return null;
3882
+ if (Number.isFinite(declared) && declared > limit) {
3883
+ await response.body?.cancel().catch(() => void 0);
3884
+ return null;
3885
+ }
3471
3886
  if (!response.body) return "";
3472
3887
  const reader = response.body.getReader();
3473
3888
  const decoder = new TextDecoder();
@@ -3517,11 +3932,34 @@ function cacheFile(providerId) {
3517
3932
  }
3518
3933
  async function readCache(file) {
3519
3934
  try {
3520
- return JSON.parse(await readFile7(file, "utf8"));
3935
+ const parsed = JSON.parse(await readFile7(file, "utf8"));
3936
+ if (typeof parsed !== "object" || parsed === null) return void 0;
3937
+ const candidate = parsed;
3938
+ if (typeof candidate.fetchedAt !== "number" || !Number.isFinite(candidate.fetchedAt) || !Array.isArray(candidate.models) || candidate.models.length > 1e4 || !candidate.models.every(validModel)) {
3939
+ return void 0;
3940
+ }
3941
+ return candidate;
3521
3942
  } catch {
3522
3943
  return void 0;
3523
3944
  }
3524
3945
  }
3946
+ function validModel(value) {
3947
+ if (typeof value !== "object" || value === null) return false;
3948
+ const model = value;
3949
+ if (typeof model.id !== "string" || model.id === "") return false;
3950
+ if (model.name !== void 0 && typeof model.name !== "string") return false;
3951
+ if (model.contextWindow !== void 0 && (!Number.isInteger(model.contextWindow) || model.contextWindow < 1)) {
3952
+ return false;
3953
+ }
3954
+ if (model.pricing === void 0) return true;
3955
+ const prices = [
3956
+ model.pricing.input,
3957
+ model.pricing.output,
3958
+ model.pricing.cacheWrite,
3959
+ model.pricing.cacheRead
3960
+ ].filter((price2) => price2 !== void 0);
3961
+ return prices.length >= 2 && prices.every((price2) => Number.isFinite(price2) && price2 >= 0);
3962
+ }
3525
3963
  async function writeCache(file, models) {
3526
3964
  const payload = { version: CACHE_VERSION, fetchedAt: Date.now(), models };
3527
3965
  try {
@@ -3661,12 +4099,12 @@ function createAnthropicProvider(args) {
3661
4099
  return {
3662
4100
  id: args.id,
3663
4101
  kind: "anthropic",
3664
- stream: (req) => streamTurn(client, args.id, req),
3665
- listModels: () => listModels(client, args.id),
4102
+ stream: (req) => streamTurn(client, args.id, args.apiKey, req),
4103
+ listModels: () => listModels(client, args.id, args.apiKey),
3666
4104
  knownModels: () => KNOWN_MODELS
3667
4105
  };
3668
4106
  }
3669
- async function* streamTurn(client, providerId, req) {
4107
+ async function* streamTurn(client, providerId, apiKey2, req) {
3670
4108
  const params = {
3671
4109
  model: req.model,
3672
4110
  max_tokens: req.maxTokens,
@@ -3707,9 +4145,9 @@ async function* streamTurn(client, providerId, req) {
3707
4145
  const limits2 = parseRateLimits(capture.headers());
3708
4146
  if (limits2) yield { type: "rate_limits", limits: limits2 };
3709
4147
  if (events === 0 && capture.succeeded() && !isConnectionFailure(error)) {
3710
- throw await invalidStreamError(providerId, capture, error);
4148
+ throw await invalidStreamError(providerId, capture, error, [apiKey2]);
3711
4149
  }
3712
- throw toProviderError(error, providerId);
4150
+ throw toProviderError(error, providerId, [apiKey2]);
3713
4151
  }
3714
4152
  const stopReason = STOP_REASONS[final.stop_reason ?? ""] ?? "end_turn";
3715
4153
  const content = toContentBlocks(final.content);
@@ -3728,7 +4166,7 @@ async function* streamTurn(client, providerId, req) {
3728
4166
  ...stopReason === "refusal" ? { refusal: toRefusal(final.stop_details) } : {}
3729
4167
  };
3730
4168
  }
3731
- async function listModels(client, providerId) {
4169
+ async function listModels(client, providerId, apiKey2) {
3732
4170
  try {
3733
4171
  const models = [];
3734
4172
  for await (const model of client.models.list({ limit: 100 })) {
@@ -3742,7 +4180,7 @@ async function listModels(client, providerId) {
3742
4180
  }
3743
4181
  return models;
3744
4182
  } catch (error) {
3745
- throw toProviderError(error, providerId);
4183
+ throw toProviderError(error, providerId, [apiKey2]);
3746
4184
  }
3747
4185
  }
3748
4186
  function toMessageParams(messages) {
@@ -3846,12 +4284,12 @@ function createOpenAiProvider(args) {
3846
4284
  return {
3847
4285
  id: args.id,
3848
4286
  kind: "openai",
3849
- stream: (req) => streamTurn2(client, args.id, req),
3850
- listModels: () => listModels2(client, args.id),
4287
+ stream: (req) => streamTurn2(client, args.id, args.apiKey, req),
4288
+ listModels: () => listModels2(client, args.id, args.apiKey),
3851
4289
  knownModels: () => []
3852
4290
  };
3853
4291
  }
3854
- async function* streamTurn2(client, providerId, req) {
4292
+ async function* streamTurn2(client, providerId, apiKey2, req) {
3855
4293
  let text = "";
3856
4294
  let thinking = "";
3857
4295
  let finishReason = null;
@@ -3899,19 +4337,20 @@ async function* streamTurn2(client, providerId, req) {
3899
4337
  if (!isUserAbort(error, req.signal)) {
3900
4338
  const limits2 = parseRateLimits(capture.headers());
3901
4339
  if (limits2) yield { type: "rate_limits", limits: limits2 };
3902
- throw toProviderError(error, providerId);
4340
+ throw toProviderError(error, providerId, [apiKey2]);
3903
4341
  }
3904
4342
  aborted = true;
3905
4343
  }
3906
- const content = toContentBlocks2(thinking, text, calls);
3907
4344
  if (aborted || req.signal?.aborted) {
4345
+ const content2 = toTextContentBlocks(thinking, text);
3908
4346
  if (sawUsage) yield { type: "usage", usage };
3909
4347
  const limits2 = parseRateLimits(capture.headers());
3910
4348
  if (limits2) yield { type: "rate_limits", limits: limits2 };
3911
- yield { type: "done", stopReason: "aborted", content };
4349
+ yield { type: "done", stopReason: "aborted", content: content2 };
3912
4350
  return;
3913
4351
  }
3914
- if (recognised === 0) throw await invalidStreamError(providerId, capture);
4352
+ if (recognised === 0) throw await invalidStreamError(providerId, capture, void 0, [apiKey2]);
4353
+ const content = toContentBlocks2(providerId, thinking, text, calls);
3915
4354
  for (const block of content) {
3916
4355
  if (block.type === "tool_use") {
3917
4356
  yield { type: "tool_call", id: block.id, name: block.name, input: block.input };
@@ -3928,7 +4367,7 @@ function resolveStop(finishReason, hasCalls) {
3928
4367
  if (hasCalls) return "tool_use";
3929
4368
  return mapped ?? "end_turn";
3930
4369
  }
3931
- async function listModels2(client, providerId) {
4370
+ async function listModels2(client, providerId, apiKey2) {
3932
4371
  try {
3933
4372
  const models = [];
3934
4373
  for await (const model of client.models.list()) {
@@ -3937,7 +4376,7 @@ async function listModels2(client, providerId) {
3937
4376
  }
3938
4377
  return models;
3939
4378
  } catch (error) {
3940
- throw toProviderError(error, providerId);
4379
+ throw toProviderError(error, providerId, [apiKey2]);
3941
4380
  }
3942
4381
  }
3943
4382
  function accumulate(calls, delta) {
@@ -3947,10 +4386,8 @@ function accumulate(calls, delta) {
3947
4386
  if (delta.function?.arguments) call.args += delta.function.arguments;
3948
4387
  calls.set(delta.index, call);
3949
4388
  }
3950
- function toContentBlocks2(thinking, text, calls) {
3951
- const content = [];
3952
- if (thinking) content.push({ type: "thinking", text: thinking });
3953
- if (text) content.push({ type: "text", text });
4389
+ function toContentBlocks2(providerId, thinking, text, calls) {
4390
+ const content = toTextContentBlocks(thinking, text);
3954
4391
  const sorted = [...calls.entries()].sort((a, b) => a[0] - b[0]);
3955
4392
  const tmpId = (position) => `call_${position}`;
3956
4393
  for (const [position, [, call]] of sorted.entries()) {
@@ -3958,18 +4395,34 @@ function toContentBlocks2(thinking, text, calls) {
3958
4395
  type: "tool_use",
3959
4396
  id: call.id || tmpId(position),
3960
4397
  name: call.name,
3961
- input: parseArguments(call.args)
4398
+ input: parseArguments(providerId, call.name, call.args)
3962
4399
  });
3963
4400
  }
3964
4401
  return content;
3965
4402
  }
3966
- function parseArguments(args) {
3967
- if (!args) return {};
4403
+ function toTextContentBlocks(thinking, text) {
4404
+ const content = [];
4405
+ if (thinking) content.push({ type: "thinking", text: thinking });
4406
+ if (text) content.push({ type: "text", text });
4407
+ return content;
4408
+ }
4409
+ function parseArguments(providerId, toolName, args) {
4410
+ let parsed;
3968
4411
  try {
3969
- return JSON.parse(args);
4412
+ parsed = JSON.parse(args);
3970
4413
  } catch {
3971
- return {};
4414
+ throw new ProviderError(
4415
+ `Tool "${toolName || "unknown"}" returned malformed JSON arguments; the tool was not run.`,
4416
+ providerId
4417
+ );
3972
4418
  }
4419
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
4420
+ throw new ProviderError(
4421
+ `Tool "${toolName || "unknown"}" arguments must be a JSON object; the tool was not run.`,
4422
+ providerId
4423
+ );
4424
+ }
4425
+ return parsed;
3973
4426
  }
3974
4427
  function toChatMessages(system, messages) {
3975
4428
  const out = [{ role: "system", content: system }];
@@ -4102,7 +4555,7 @@ function createPermissionEngine(configPermissions) {
4102
4555
  decide(tool, requested) {
4103
4556
  const configured = resolve3(tool, requested);
4104
4557
  if (configured === "deny") return "deny";
4105
- if (current === "plan" && configured !== "allow") return "deny";
4558
+ if (current === "plan" && tool.readOnly !== true) return "deny";
4106
4559
  if (configured === "allow") return "allow";
4107
4560
  if (bypassEnabled) return "allow";
4108
4561
  if (current === "accept" && isFileEdit(tool)) return "allow";
@@ -4114,8 +4567,9 @@ function createPermissionEngine(configPermissions) {
4114
4567
  },
4115
4568
  denyReason(tool, requested) {
4116
4569
  const configured = resolve3(tool, requested);
4570
+ if (current === "plan" && tool.readOnly !== true) return PLAN_REFUSAL;
4117
4571
  if (configured === "deny" || configured === "allow") return void 0;
4118
- return current === "plan" ? PLAN_REFUSAL : void 0;
4572
+ return void 0;
4119
4573
  },
4120
4574
  bypass: {
4121
4575
  enable() {
@@ -4145,7 +4599,133 @@ function isFileEdit(tool) {
4145
4599
  }
4146
4600
 
4147
4601
  // src/tools/edit.ts
4148
- import { lstat as lstat3, readFile as readFile8, stat as stat6, writeFile as writeFile6 } from "fs/promises";
4602
+ import { readFile as readFile8, stat as stat6 } from "fs/promises";
4603
+
4604
+ // src/tools/safe-write.ts
4605
+ import { randomUUID as randomUUID3 } from "crypto";
4606
+ import { chmod as chmod5, lstat as lstat3, open, rename as rename5, rm as rm3 } from "fs/promises";
4607
+ import { basename as basename2, dirname as dirname2, join } from "path";
4608
+ var UnsafeFileChangeError = class extends Error {
4609
+ constructor(message) {
4610
+ super(message);
4611
+ this.name = "UnsafeFileChangeError";
4612
+ }
4613
+ };
4614
+ async function readSafeFileSnapshot(file, maxBytes) {
4615
+ const parent = await lstat3(dirname2(file));
4616
+ if (parent.isSymbolicLink() || !parent.isDirectory()) {
4617
+ throw new UnsafeFileChangeError("the parent path is not a real directory");
4618
+ }
4619
+ const parentIdentity = directoryIdentity(parent);
4620
+ const pathInfo = await lstatMaybe(file);
4621
+ if (!pathInfo) return { exists: false, data: null, mode: 438, parent: parentIdentity };
4622
+ if (pathInfo.isSymbolicLink()) {
4623
+ throw new UnsafeFileChangeError("the path is a symbolic link");
4624
+ }
4625
+ if (!pathInfo.isFile()) throw new UnsafeFileChangeError("the path is not a regular file");
4626
+ if (pathInfo.size > maxBytes) throw new UnsafeFileChangeError("the file is too large");
4627
+ const handle = await open(file, "r");
4628
+ try {
4629
+ const opened = await handle.stat();
4630
+ if (!opened.isFile() || !sameObject(pathInfo, opened)) {
4631
+ throw new UnsafeFileChangeError("the file changed while it was being opened");
4632
+ }
4633
+ const data = await readBounded(handle, maxBytes);
4634
+ const afterRead = await handle.stat();
4635
+ if (!sameVersion(opened, afterRead)) {
4636
+ throw new UnsafeFileChangeError("the file changed while it was being read");
4637
+ }
4638
+ return {
4639
+ exists: true,
4640
+ data,
4641
+ mode: opened.mode & 511,
4642
+ identity: fileIdentity(opened),
4643
+ parent: parentIdentity
4644
+ };
4645
+ } finally {
4646
+ await handle.close();
4647
+ }
4648
+ }
4649
+ async function atomicWriteSafeFile(file, data, snapshot) {
4650
+ const parent = dirname2(file);
4651
+ await assertSameParent(parent, snapshot.parent);
4652
+ const temp = join(parent, `.${basename2(file)}.${process.pid}.${randomUUID3()}.tmp`);
4653
+ let handle;
4654
+ try {
4655
+ handle = await open(temp, "wx", snapshot.mode);
4656
+ await handle.writeFile(data, typeof data === "string" ? { encoding: "utf8" } : void 0);
4657
+ await handle.sync();
4658
+ await handle.close();
4659
+ handle = void 0;
4660
+ await chmod5(temp, snapshot.mode);
4661
+ await assertSameParent(parent, snapshot.parent);
4662
+ const current = await lstatMaybe(file);
4663
+ if (!matchesSnapshot2(current, snapshot)) {
4664
+ throw new UnsafeFileChangeError("the destination changed before it could be replaced");
4665
+ }
4666
+ await rename5(temp, file);
4667
+ } catch (error) {
4668
+ await handle?.close().catch(() => void 0);
4669
+ await rm3(temp, { force: true }).catch(() => void 0);
4670
+ throw error;
4671
+ }
4672
+ }
4673
+ async function readBounded(handle, maxBytes) {
4674
+ const chunks = [];
4675
+ let total = 0;
4676
+ for (; ; ) {
4677
+ const remaining = maxBytes + 1 - total;
4678
+ if (remaining <= 0) throw new UnsafeFileChangeError("the file is too large");
4679
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
4680
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
4681
+ if (bytesRead === 0) return Buffer.concat(chunks, total);
4682
+ chunks.push(chunk.subarray(0, bytesRead));
4683
+ total += bytesRead;
4684
+ }
4685
+ }
4686
+ async function assertSameParent(parent, expected) {
4687
+ const current = await lstat3(parent);
4688
+ if (current.isSymbolicLink() || !current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
4689
+ throw new UnsafeFileChangeError("the parent directory changed during the write");
4690
+ }
4691
+ }
4692
+ function matchesSnapshot2(current, snapshot) {
4693
+ if (!snapshot.exists) return current === null;
4694
+ return Boolean(
4695
+ current && !current.isSymbolicLink() && current.isFile() && sameIdentity(current, snapshot.identity)
4696
+ );
4697
+ }
4698
+ function sameObject(left, right) {
4699
+ return left.dev === right.dev && left.ino === right.ino;
4700
+ }
4701
+ function sameVersion(left, right) {
4702
+ return sameObject(left, right) && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
4703
+ }
4704
+ function sameIdentity(info, identity2) {
4705
+ return info.dev === identity2.dev && info.ino === identity2.ino && info.size === identity2.size && info.mtimeMs === identity2.mtimeMs && info.ctimeMs === identity2.ctimeMs;
4706
+ }
4707
+ function fileIdentity(info) {
4708
+ return {
4709
+ dev: info.dev,
4710
+ ino: info.ino,
4711
+ size: info.size,
4712
+ mtimeMs: info.mtimeMs,
4713
+ ctimeMs: info.ctimeMs
4714
+ };
4715
+ }
4716
+ function directoryIdentity(info) {
4717
+ return { dev: info.dev, ino: info.ino };
4718
+ }
4719
+ async function lstatMaybe(file) {
4720
+ try {
4721
+ return await lstat3(file);
4722
+ } catch (error) {
4723
+ if (error.code === "ENOENT") return null;
4724
+ throw error;
4725
+ }
4726
+ }
4727
+
4728
+ // src/tools/edit.ts
4149
4729
  var MAX_FILE_BYTES2 = 5e6;
4150
4730
  function countOccurrences(haystack, needle) {
4151
4731
  if (needle === "") return 0;
@@ -4201,19 +4781,14 @@ var editTool = {
4201
4781
  const { path: path14, oldString, newString, replaceAll = false } = input;
4202
4782
  const safe = resolveInside(ctx.cwd, path14);
4203
4783
  if (!safe.ok) return { content: safe.reason, isError: true };
4204
- const linkInfo = await lstat3(safe.path).catch(() => null);
4205
- if (linkInfo?.isSymbolicLink()) {
4206
- return { content: `Cannot edit ${path14}: the path is a symbolic link. Remove the symlink first.`, isError: true };
4207
- }
4208
- const info = await stat6(safe.path).catch(() => null);
4209
- if (info && info.size > MAX_FILE_BYTES2) {
4210
- return {
4211
- content: `Cannot edit ${path14}: the file exceeds the ${MAX_FILE_BYTES2 / 1e6} MB limit.`,
4212
- isError: true
4213
- };
4784
+ let snapshot;
4785
+ try {
4786
+ snapshot = await readSafeFileSnapshot(safe.path, MAX_FILE_BYTES2);
4787
+ } catch (error) {
4788
+ return { content: `Cannot edit ${path14}: ${error.message}.`, isError: true };
4214
4789
  }
4215
- const beforeBuffer = await readFile8(safe.path).catch(() => null);
4216
- if (beforeBuffer === null) return { content: `Cannot read ${path14}.`, isError: true };
4790
+ if (!snapshot.exists) return { content: `Cannot read ${path14}.`, isError: true };
4791
+ const beforeBuffer = snapshot.data;
4217
4792
  if (beforeBuffer.subarray(0, 8192).includes(0)) {
4218
4793
  return { content: `Cannot edit ${path14}: it is a binary file, not text.`, isError: true };
4219
4794
  }
@@ -4245,7 +4820,7 @@ var editTool = {
4245
4820
  return { content: `Checkpoint capture failed: ${error.message}`, isError: true };
4246
4821
  }
4247
4822
  try {
4248
- await writeFile6(safe.path, after, "utf8");
4823
+ await atomicWriteSafeFile(safe.path, after, snapshot);
4249
4824
  ctx.checkpoint?.markChanged(safe.path);
4250
4825
  } catch (error) {
4251
4826
  return { content: `Failed to write ${path14}: ${error.message}`, isError: true };
@@ -4265,7 +4840,7 @@ function replacement(before, oldString, newString, replaceAll) {
4265
4840
  }
4266
4841
 
4267
4842
  // src/tools/glob.ts
4268
- import { join } from "path";
4843
+ import { join as join2 } from "path";
4269
4844
  import fg from "fast-glob";
4270
4845
  var MAX_RESULTS = 500;
4271
4846
  var IGNORED_DIRECTORIES = ["**/node_modules/**", "**/.git/**", "**/dist/**"];
@@ -4282,6 +4857,7 @@ var globTool = {
4282
4857
  additionalProperties: false
4283
4858
  },
4284
4859
  defaultPermission: "allow",
4860
+ readOnly: true,
4285
4861
  summarize(input) {
4286
4862
  return `glob(${brief(input.pattern)})`;
4287
4863
  },
@@ -4303,7 +4879,7 @@ var globTool = {
4303
4879
  });
4304
4880
  if (entries.length === 0) return { content: `No files matched ${pattern}` };
4305
4881
  entries.sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0));
4306
- const paths = entries.slice(0, MAX_RESULTS).map((entry) => join(safe.relative, entry.path));
4882
+ const paths = entries.slice(0, MAX_RESULTS).map((entry) => join2(safe.relative, entry.path));
4307
4883
  if (entries.length > MAX_RESULTS) {
4308
4884
  paths.push(`... truncated: ${entries.length - MAX_RESULTS} more files matched.`);
4309
4885
  }
@@ -4313,7 +4889,7 @@ var globTool = {
4313
4889
 
4314
4890
  // src/tools/grep.ts
4315
4891
  import { readFile as readFile9, stat as stat7 } from "fs/promises";
4316
- import { join as join2 } from "path";
4892
+ import { join as join3 } from "path";
4317
4893
  import fg2 from "fast-glob";
4318
4894
 
4319
4895
  // src/tools/sensitive.ts
@@ -4462,6 +5038,7 @@ var grepTool = {
4462
5038
  additionalProperties: false
4463
5039
  },
4464
5040
  defaultPermission: "allow",
5041
+ readOnly: true,
4465
5042
  permission(input, ctx) {
4466
5043
  const { path: path14, glob } = input ?? {};
4467
5044
  if (isSensitivePath(path14) || mentionsSensitivePattern(glob)) return "ask";
@@ -4500,8 +5077,8 @@ var grepTool = {
4500
5077
  ignore: IGNORED_DIRECTORIES,
4501
5078
  stats: true
4502
5079
  })).sort((a, b) => (b.stats?.mtimeMs ?? 0) - (a.stats?.mtimeMs ?? 0)).map((entry) => ({
4503
- absolute: join2(safe.path, entry.path),
4504
- relative: join2(safe.relative, entry.path),
5080
+ absolute: join3(safe.path, entry.path),
5081
+ relative: join3(safe.relative, entry.path),
4505
5082
  size: entry.stats?.size ?? 0
4506
5083
  })) : [{ absolute: safe.path, relative: safe.relative, size: target.size }];
4507
5084
  const files = discovered.slice(0, MAX_FILES);
@@ -4569,6 +5146,7 @@ var readTool = {
4569
5146
  additionalProperties: false
4570
5147
  },
4571
5148
  defaultPermission: "allow",
5149
+ readOnly: true,
4572
5150
  permission(input, ctx) {
4573
5151
  const target = input?.path;
4574
5152
  if (isSensitivePath(target)) return "ask";
@@ -4629,8 +5207,8 @@ function toToolSchema(tool) {
4629
5207
  }
4630
5208
 
4631
5209
  // src/tools/write.ts
4632
- import { lstat as lstat4, mkdir as mkdir3, readFile as readFile11, stat as stat9, writeFile as writeFile7 } from "fs/promises";
4633
- import { dirname as dirname2 } from "path";
5210
+ import { mkdir as mkdir4, readFile as readFile11, stat as stat9 } from "fs/promises";
5211
+ import { dirname as dirname3 } from "path";
4634
5212
  var MAX_FILE_BYTES5 = 5e6;
4635
5213
  var writeTool = {
4636
5214
  name: "write",
@@ -4680,31 +5258,29 @@ var writeTool = {
4680
5258
  }
4681
5259
  const safe = resolveInside(ctx.cwd, path14);
4682
5260
  if (!safe.ok) return { content: safe.reason, isError: true };
4683
- const linkInfo = await lstat4(safe.path).catch(() => null);
4684
- if (linkInfo?.isSymbolicLink()) {
4685
- return { content: `Cannot write ${path14}: the path is a symbolic link. Remove the symlink first.`, isError: true };
4686
- }
4687
- const beforeInfo = await stat9(safe.path).catch(() => null);
4688
- if (beforeInfo && beforeInfo.size > MAX_FILE_BYTES5) {
4689
- return {
4690
- content: `Cannot replace ${path14}: the existing file exceeds the ${MAX_FILE_BYTES5 / 1e6} MB limit.`,
4691
- isError: true
4692
- };
4693
- }
4694
- const beforeBuffer = await readFile11(safe.path).catch(() => null);
4695
- if (beforeBuffer instanceof Buffer && beforeBuffer.subarray(0, 8192).includes(0)) {
4696
- return { content: `Cannot write ${path14}: the existing file is binary, not text.`, isError: true };
4697
- }
4698
- let before = "";
4699
- if (beforeBuffer) before = beforeBuffer.toString("utf8");
5261
+ let target = safe.path;
5262
+ let snapshot;
4700
5263
  try {
4701
- await mkdir3(dirname2(safe.path), { recursive: true });
4702
- await ctx.checkpoint?.capture(safe.path);
4703
- await writeFile7(safe.path, content, "utf8");
4704
- ctx.checkpoint?.markChanged(safe.path);
5264
+ await mkdir4(dirname3(target), { recursive: true });
5265
+ const rechecked = resolveInside(ctx.cwd, path14);
5266
+ if (!rechecked.ok || rechecked.path !== target) {
5267
+ return {
5268
+ content: `Cannot write ${path14}: the path changed while its parent was created.`,
5269
+ isError: true
5270
+ };
5271
+ }
5272
+ target = rechecked.path;
5273
+ snapshot = await readSafeFileSnapshot(target, MAX_FILE_BYTES5);
5274
+ if (snapshot.exists && snapshot.data.subarray(0, 8192).includes(0)) {
5275
+ return { content: `Cannot write ${path14}: the existing file is binary, not text.`, isError: true };
5276
+ }
5277
+ await ctx.checkpoint?.capture(target);
5278
+ await atomicWriteSafeFile(target, content, snapshot);
5279
+ ctx.checkpoint?.markChanged(target);
4705
5280
  } catch (error) {
4706
5281
  return { content: `Failed to write ${path14}: ${error.message}`, isError: true };
4707
5282
  }
5283
+ const before = snapshot.exists ? snapshot.data.toString("utf8") : "";
4708
5284
  const lines = content === "" ? 0 : content.replace(/\n$/, "").split("\n").length;
4709
5285
  return {
4710
5286
  content: `${before === "" ? "Created" : "Updated"} ${path14} (${lines} ${lines === 1 ? "line" : "lines"})`,
@@ -4737,7 +5313,7 @@ function createToolRegistry(tools) {
4737
5313
  }
4738
5314
 
4739
5315
  // src/prompts/library.ts
4740
- import { chmod as chmod5, readFile as readFile12, readdir as readdir3, unlink as unlink3, writeFile as writeFile8 } from "fs/promises";
5316
+ import { chmod as chmod6, readFile as readFile12, readdir as readdir3, unlink as unlink3, writeFile as writeFile6 } from "fs/promises";
4741
5317
  import path10 from "path";
4742
5318
  async function savePrompt(input) {
4743
5319
  const slug = slugify(input.name);
@@ -4751,11 +5327,11 @@ async function savePrompt(input) {
4751
5327
  };
4752
5328
  await ensureDir(promptsDir);
4753
5329
  const file = path10.join(promptsDir, `${slug}.md`);
4754
- await writeFile8(file, serialize(prompt), {
5330
+ await writeFile6(file, serialize(prompt), {
4755
5331
  encoding: "utf8",
4756
5332
  mode: 384
4757
5333
  });
4758
- await chmod5(file, 384);
5334
+ await chmod6(file, 384);
4759
5335
  return prompt;
4760
5336
  }
4761
5337
  async function getPrompt(slug) {
@@ -4842,18 +5418,21 @@ function parse(slug, text) {
4842
5418
  }
4843
5419
 
4844
5420
  // src/skills/library.ts
4845
- import { open, readFile as readFile13, readdir as readdir4, stat as stat10 } from "fs/promises";
5421
+ import { lstat as lstat4, open as open2, readdir as readdir4 } from "fs/promises";
4846
5422
  import path11 from "path";
4847
5423
  var SKILL_FILE = "SKILL.md";
4848
5424
  var FRONTMATTER_BYTES = 8192;
4849
5425
  var MAX_SKILL_BYTES = 5e6;
4850
5426
  var MAX_SKILLS_PER_ROOT = 500;
5427
+ var guards = /* @__PURE__ */ new WeakMap();
4851
5428
  async function discoverSkills(dirs) {
4852
5429
  const byName = /* @__PURE__ */ new Map();
4853
5430
  for (const root of dirs) {
4854
- const entries = await readdir4(root).catch(() => []);
5431
+ const rootInfo = await lstat4(root).catch(() => null);
5432
+ if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) continue;
5433
+ const entries = await readdir4(root, { withFileTypes: true }).catch(() => []);
4855
5434
  const found = await Promise.all(
4856
- entries.slice(0, MAX_SKILLS_PER_ROOT).map((entry) => readMeta(path11.join(root, entry)))
5435
+ entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()).slice(0, MAX_SKILLS_PER_ROOT).map((entry) => readMeta(root, rootInfo, path11.join(root, entry.name)))
4857
5436
  );
4858
5437
  for (const meta of found) {
4859
5438
  if (meta && !byName.has(meta.name)) byName.set(meta.name, meta);
@@ -4862,11 +5441,25 @@ async function discoverSkills(dirs) {
4862
5441
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
4863
5442
  }
4864
5443
  async function loadSkill(meta) {
4865
- const info = await stat10(meta.file);
4866
- if (info.size > MAX_SKILL_BYTES) {
5444
+ const guard = guards.get(meta);
5445
+ if (!guard) throw new Error(`Skill was not discovered through a protected skill root: ${meta.file}`);
5446
+ await assertIdentity(guard.root, guard.rootIdentity, "skill root");
5447
+ await assertIdentity(meta.dir, guard.dirIdentity, "skill directory");
5448
+ const opened = await openVerified(meta.file, guard.fileIdentity);
5449
+ if (opened.info.size > MAX_SKILL_BYTES) {
5450
+ await opened.handle.close();
4867
5451
  throw new Error(`Skill file exceeds the ${MAX_SKILL_BYTES / 1e6} MB limit: ${meta.file}`);
4868
5452
  }
4869
- const { body } = parseFrontmatter(await readFile13(meta.file, "utf8"));
5453
+ let text;
5454
+ try {
5455
+ text = (await readBounded2(opened.handle, MAX_SKILL_BYTES)).toString("utf8");
5456
+ } finally {
5457
+ await opened.handle.close();
5458
+ }
5459
+ await assertIdentity(guard.root, guard.rootIdentity, "skill root");
5460
+ await assertIdentity(meta.dir, guard.dirIdentity, "skill directory");
5461
+ await assertIdentity(meta.file, guard.fileIdentity, "skill file");
5462
+ const { body } = parseFrontmatter(text);
4870
5463
  return { ...meta, body };
4871
5464
  }
4872
5465
  function formatSkillCatalogue(skills) {
@@ -4876,20 +5469,33 @@ function formatSkillCatalogue(skills) {
4876
5469
  "\n"
4877
5470
  );
4878
5471
  }
4879
- async function readMeta(dir) {
5472
+ async function readMeta(root, rootInfo, dir) {
5473
+ const dirInfo = await lstat4(dir).catch(() => null);
5474
+ if (!dirInfo?.isDirectory() || dirInfo.isSymbolicLink()) return null;
4880
5475
  const file = path11.join(dir, SKILL_FILE);
4881
5476
  const head = await readFrontmatterBytes(file);
4882
- if (head === null) return null;
4883
- const { fields } = parseFrontmatter(head);
4884
- return { name: fields.name || path11.basename(dir), description: fields.description ?? "", dir, file };
5477
+ if (!head) return null;
5478
+ const { fields } = parseFrontmatter(head.text);
5479
+ const meta = { name: fields.name || path11.basename(dir), description: fields.description ?? "", dir, file };
5480
+ guards.set(meta, {
5481
+ root,
5482
+ rootIdentity: identity(rootInfo),
5483
+ dirIdentity: identity(dirInfo),
5484
+ fileIdentity: head.identity
5485
+ });
5486
+ return meta;
4885
5487
  }
4886
5488
  async function readFrontmatterBytes(file) {
4887
- const handle = await open(file, "r").catch(() => null);
5489
+ const before = await lstat4(file).catch(() => null);
5490
+ if (!before?.isFile() || before.isSymbolicLink()) return null;
5491
+ const handle = await open2(file, "r").catch(() => null);
4888
5492
  if (!handle) return null;
4889
5493
  try {
5494
+ const opened = await handle.stat();
5495
+ if (!opened.isFile() || !sameIdentity2(opened, identity(before))) return null;
4890
5496
  const buffer = Buffer.alloc(FRONTMATTER_BYTES);
4891
5497
  const { bytesRead } = await handle.read(buffer, 0, FRONTMATTER_BYTES, 0);
4892
- return buffer.subarray(0, bytesRead).toString("utf8");
5498
+ return { text: buffer.subarray(0, bytesRead).toString("utf8"), identity: identity(opened) };
4893
5499
  } catch {
4894
5500
  return null;
4895
5501
  } finally {
@@ -4897,11 +5503,49 @@ async function readFrontmatterBytes(file) {
4897
5503
  });
4898
5504
  }
4899
5505
  }
4900
- function parseFrontmatter(text) {
4901
- const lines = text.split("\n");
4902
- const fields = {};
4903
- let bodyStart = 0;
4904
- if (lines[0]?.trim() === "---") {
5506
+ async function openVerified(file, expected) {
5507
+ const before = await lstat4(file);
5508
+ if (!before.isFile() || before.isSymbolicLink() || !sameIdentity2(before, expected)) {
5509
+ throw new Error(`Refusing changed or symlinked skill file: ${file}`);
5510
+ }
5511
+ const handle = await open2(file, "r");
5512
+ const info = await handle.stat();
5513
+ if (!info.isFile() || !sameIdentity2(info, expected)) {
5514
+ await handle.close();
5515
+ throw new Error(`Refusing skill file changed while opening: ${file}`);
5516
+ }
5517
+ return { handle, info };
5518
+ }
5519
+ async function assertIdentity(file, expected, label) {
5520
+ const info = await lstat4(file);
5521
+ if (info.isSymbolicLink() || !sameIdentity2(info, expected)) {
5522
+ throw new Error(`Refusing changed or symlinked ${label}: ${file}`);
5523
+ }
5524
+ }
5525
+ async function readBounded2(handle, maxBytes) {
5526
+ const chunks = [];
5527
+ let total = 0;
5528
+ for (; ; ) {
5529
+ const remaining = maxBytes + 1 - total;
5530
+ if (remaining <= 0) throw new Error(`Skill file exceeds the ${maxBytes / 1e6} MB limit`);
5531
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
5532
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
5533
+ if (bytesRead === 0) return Buffer.concat(chunks, total);
5534
+ chunks.push(chunk.subarray(0, bytesRead));
5535
+ total += bytesRead;
5536
+ }
5537
+ }
5538
+ function identity(info) {
5539
+ return { dev: info.dev, ino: info.ino };
5540
+ }
5541
+ function sameIdentity2(info, expected) {
5542
+ return info.dev === expected.dev && info.ino === expected.ino;
5543
+ }
5544
+ function parseFrontmatter(text) {
5545
+ const lines = text.split("\n");
5546
+ const fields = {};
5547
+ let bodyStart = 0;
5548
+ if (lines[0]?.trim() === "---") {
4905
5549
  let i = 1;
4906
5550
  for (; i < lines.length && lines[i].trim() !== "---"; i++) {
4907
5551
  const colon = lines[i].indexOf(":");
@@ -4915,11 +5559,22 @@ function parseFrontmatter(text) {
4915
5559
  }
4916
5560
 
4917
5561
  // src/skills/install.ts
5562
+ import { randomUUID as randomUUID4 } from "crypto";
4918
5563
  import { execFileSync } from "child_process";
4919
- import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
4920
- import { chmod as chmod6, mkdir as mkdir4 } from "fs/promises";
5564
+ import {
5565
+ existsSync as existsSync2,
5566
+ lstatSync,
5567
+ mkdirSync,
5568
+ readFileSync,
5569
+ readdirSync,
5570
+ realpathSync as realpathSync2,
5571
+ rmSync,
5572
+ writeFileSync
5573
+ } from "fs";
5574
+ import { chmod as chmod7, mkdir as mkdir5 } from "fs/promises";
4921
5575
  import path12 from "path";
4922
5576
  var TMP_DIR = path12.join(skillsDir, ".tmp");
5577
+ var MAX_SKILL_BYTES2 = 5e6;
4923
5578
  async function installSkill(source) {
4924
5579
  await ensureDir(skillsDir);
4925
5580
  if (isGitHubUrl(source)) {
@@ -4931,30 +5586,31 @@ async function installSkill(source) {
4931
5586
  return installFromLocal(source);
4932
5587
  }
4933
5588
  function isGitHubUrl(input) {
4934
- return /^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+/.test(input);
5589
+ try {
5590
+ const url = new URL(input);
5591
+ return url.protocol === "https:" && (url.hostname === "github.com" || url.hostname === "www.github.com");
5592
+ } catch {
5593
+ return false;
5594
+ }
4935
5595
  }
4936
5596
  function isNpmPackage(input) {
4937
- if (input.includes("/") || input.includes("\\") || input.startsWith(".")) return false;
4938
- return /^(@[\w.-]+\/)?[\w.-]+(@[\w.*+-]+)?$/.test(input);
5597
+ return npmSkillName(input) !== null;
4939
5598
  }
4940
5599
  async function installFromGitHub(url) {
4941
5600
  const { owner, repo, subdir, branch } = parseGitHubUrl(url);
4942
- const name = subdir || repo;
4943
- const skillDir = path12.join(skillsDir, name);
4944
- const tmpDir = path12.join(TMP_DIR, `${name}-${Date.now()}`);
5601
+ const name = safeSkillName(subdir ? path12.posix.basename(subdir) : repo);
5602
+ const skillDir = safeChildPath(skillsDir, name);
5603
+ const tmpDir = safeChildPath(TMP_DIR, `${name}-${randomUUID4()}`);
4945
5604
  try {
4946
- rmSync(skillDir, { recursive: true, force: true });
4947
5605
  mkdirSync(tmpDir, { recursive: true });
4948
5606
  const cloneUrl = `https://github.com/${owner}/${repo}.git`;
4949
5607
  const gitArgs = ["clone", "--depth", "1"];
4950
5608
  if (branch) gitArgs.push("--branch", branch);
4951
5609
  gitArgs.push(cloneUrl, tmpDir);
4952
5610
  execFileSync("git", gitArgs, { stdio: "ignore" });
4953
- const skillFile = subdir ? path12.join(tmpDir, subdir, "SKILL.md") : path12.join(tmpDir, "SKILL.md");
4954
- if (!existsSync2(skillFile)) {
4955
- throw new Error(`No SKILL.md found in "${subdir || repo}"`);
4956
- }
4957
- const body = readFileSync(skillFile, "utf8");
5611
+ const skillFile = subdir ? safeChildPath(tmpDir, ...subdir.split("/"), "SKILL.md") : safeChildPath(tmpDir, "SKILL.md");
5612
+ const body = readSkillFile(skillFile, tmpDir, subdir || repo);
5613
+ removeExistingSkillDir(skillDir);
4958
5614
  await writeSkillFile(skillDir, body);
4959
5615
  return { name, dir: skillDir, source: url };
4960
5616
  } finally {
@@ -4962,9 +5618,11 @@ async function installFromGitHub(url) {
4962
5618
  }
4963
5619
  }
4964
5620
  async function installFromNpm(packageName) {
4965
- const name = packageName.replace(/^@/, "").replace(/\/[^/]*$/, "").replace(/@[^@]*$/, "");
4966
- const skillDir = path12.join(skillsDir, name);
4967
- const tmpDir = path12.join(TMP_DIR, `${name}-${Date.now()}`);
5621
+ const parsedName = npmSkillName(packageName);
5622
+ if (!parsedName) throw new Error(`Invalid npm package name: ${packageName}`);
5623
+ const name = safeSkillName(parsedName);
5624
+ const skillDir = safeChildPath(skillsDir, name);
5625
+ const tmpDir = safeChildPath(TMP_DIR, `${name}-${randomUUID4()}`);
4968
5626
  try {
4969
5627
  mkdirSync(tmpDir, { recursive: true });
4970
5628
  execFileSync("npm", ["pack", packageName, "--prefix", tmpDir], {
@@ -4976,6 +5634,7 @@ async function installFromNpm(packageName) {
4976
5634
  throw new Error(`Could not download npm package "${packageName}"`);
4977
5635
  }
4978
5636
  const tarballPath = path12.join(tmpDir, tarball);
5637
+ validateTarball(tarballPath);
4979
5638
  const extractDir = path12.join(tmpDir, "extracted");
4980
5639
  mkdirSync(extractDir, { recursive: true });
4981
5640
  try {
@@ -4985,7 +5644,7 @@ async function installFromNpm(packageName) {
4985
5644
  if (existsSync2(packageDir)) {
4986
5645
  const skillFile2 = path12.join(packageDir, "SKILL.md");
4987
5646
  if (existsSync2(skillFile2)) {
4988
- const body2 = readFileSync(skillFile2, "utf8");
5647
+ const body2 = readSkillFile(skillFile2, packageDir, packageName);
4989
5648
  await writeSkillFile(skillDir, body2);
4990
5649
  return { name, dir: skillDir, source: packageName };
4991
5650
  }
@@ -4999,13 +5658,41 @@ async function installFromNpm(packageName) {
4999
5658
  if (!existsSync2(skillFile)) {
5000
5659
  throw new Error(`No SKILL.md found in npm package "${packageName}"`);
5001
5660
  }
5002
- const body = readFileSync(skillFile, "utf8");
5661
+ const body = readSkillFile(skillFile, extractDir, packageName);
5003
5662
  await writeSkillFile(skillDir, body);
5004
5663
  return { name, dir: skillDir, source: packageName };
5005
5664
  } finally {
5006
5665
  rmSync(tmpDir, { recursive: true, force: true });
5007
5666
  }
5008
5667
  }
5668
+ function npmSkillName(input) {
5669
+ if (input.includes("\\") || input.startsWith(".")) return null;
5670
+ const scoped = input.match(/^@[\w.-]+\/([\w.-]+)(?:@[\w.*+-]+)?$/);
5671
+ if (scoped) return scoped[1] ?? null;
5672
+ const unscoped = input.match(/^([\w.-]+)(?:@[\w.*+-]+)?$/);
5673
+ return unscoped?.[1] ?? null;
5674
+ }
5675
+ function validateTarball(file) {
5676
+ const info = lstatSync(file, { throwIfNoEntry: false });
5677
+ if (!info?.isFile() || info.size > 2e7) {
5678
+ throw new Error("The npm skill archive is missing or exceeds the 20 MB safety limit.");
5679
+ }
5680
+ const listing = execFileSync("tar", ["-tzf", file], {
5681
+ encoding: "utf8",
5682
+ maxBuffer: 2e6,
5683
+ stdio: ["ignore", "pipe", "ignore"]
5684
+ });
5685
+ const entries = listing.split(/\r?\n/).filter(Boolean);
5686
+ if (entries.length === 0 || entries.length > 1e4) {
5687
+ throw new Error("The npm skill archive has an invalid number of entries.");
5688
+ }
5689
+ for (const entry of entries) {
5690
+ const normalized = entry.replace(/^\.\//, "");
5691
+ if (normalized.includes("\\") || path12.posix.isAbsolute(normalized) || normalized.split("/").some((segment) => segment === "..")) {
5692
+ throw new Error(`Unsafe path in npm skill archive: ${entry}`);
5693
+ }
5694
+ }
5695
+ }
5009
5696
  async function installFromLocal(srcPath) {
5010
5697
  const resolved = path12.resolve(srcPath);
5011
5698
  let skillDir;
@@ -5021,29 +5708,102 @@ async function installFromLocal(srcPath) {
5021
5708
  throw new Error(`No SKILL.md found at "${srcPath}"`);
5022
5709
  }
5023
5710
  const name = path12.basename(skillDir);
5024
- const destDir = path12.join(skillsDir, name);
5025
- const body = readFileSync(skillFile, "utf8");
5711
+ const destDir = safeChildPath(skillsDir, safeSkillName(name));
5712
+ const body = readSkillFile(skillFile, skillDir, srcPath);
5026
5713
  await writeSkillFile(destDir, body);
5027
5714
  return { name, dir: destDir, source: resolved };
5028
5715
  }
5029
5716
  async function writeSkillFile(dir, body) {
5030
- await mkdir4(dir, { recursive: true, mode: 448 });
5717
+ assertInside(skillsDir, dir);
5718
+ const existingDir = lstatSync(dir, { throwIfNoEntry: false });
5719
+ if (existingDir?.isSymbolicLink()) {
5720
+ throw new Error(`Refusing to replace symlinked skill directory: ${dir}`);
5721
+ }
5722
+ await mkdir5(dir, { recursive: true, mode: 448 });
5031
5723
  const file = path12.join(dir, "SKILL.md");
5032
- writeFileSync(file, body);
5033
- await chmod6(file, 384);
5724
+ const existingFile = lstatSync(file, { throwIfNoEntry: false });
5725
+ if (existingFile?.isSymbolicLink()) {
5726
+ throw new Error(`Refusing to replace symlinked skill file: ${file}`);
5727
+ }
5728
+ writeFileSync(file, body, { encoding: "utf8", mode: 384 });
5729
+ await chmod7(file, 384);
5034
5730
  }
5035
5731
  function parseGitHubUrl(url) {
5036
- const match = url.match(
5037
- /^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+)(?:\/(.+)))?/
5038
- );
5039
- if (!match) {
5040
- const simple = url.match(/^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)/);
5041
- if (simple) {
5042
- return { owner: simple[1], repo: simple[2] };
5043
- }
5732
+ let parsed;
5733
+ try {
5734
+ parsed = new URL(url);
5735
+ } catch {
5736
+ throw new Error(`Cannot parse GitHub URL: ${url}`);
5737
+ }
5738
+ if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" && parsed.hostname !== "www.github.com" || parsed.username || parsed.password) {
5739
+ throw new Error("GitHub skills must use an https://github.com URL without credentials.");
5740
+ }
5741
+ let segments;
5742
+ try {
5743
+ segments = parsed.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment));
5744
+ } catch {
5044
5745
  throw new Error(`Cannot parse GitHub URL: ${url}`);
5045
5746
  }
5046
- return { owner: match[1], repo: match[2], branch: match[3], subdir: match[4] };
5747
+ if (segments.length < 2) throw new Error(`Cannot parse GitHub URL: ${url}`);
5748
+ const owner = safeGitHubSegment(segments[0], "owner");
5749
+ const repo = safeSkillName(segments[1].replace(/\.git$/i, ""));
5750
+ if (segments.length === 2) return { owner, repo };
5751
+ if (segments[2] !== "tree" || segments.length < 4) {
5752
+ throw new Error(`Unsupported GitHub skill URL: ${url}`);
5753
+ }
5754
+ const branch = safeGitHubSegment(segments[3], "branch");
5755
+ const subdirSegments = segments.slice(4).map((segment) => safeGitHubSegment(segment, "path"));
5756
+ return {
5757
+ owner,
5758
+ repo,
5759
+ branch,
5760
+ subdir: subdirSegments.length > 0 ? subdirSegments.join("/") : void 0
5761
+ };
5762
+ }
5763
+ function safeGitHubSegment(value, kind) {
5764
+ if (value === "" || value === "." || value === ".." || value.length > 255 || /[\0-\x1f\x7f/\\:*?"<>|#%]/.test(value)) {
5765
+ throw new Error(`Unsafe GitHub ${kind}: ${value || "(empty)"}`);
5766
+ }
5767
+ return value;
5768
+ }
5769
+ function safeSkillName(value) {
5770
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) || value === "." || value === "..") {
5771
+ throw new Error(`Unsafe skill name: ${value || "(empty)"}`);
5772
+ }
5773
+ return value;
5774
+ }
5775
+ function safeChildPath(root, ...segments) {
5776
+ const target = path12.resolve(root, ...segments);
5777
+ assertInside(root, target);
5778
+ return target;
5779
+ }
5780
+ function assertInside(root, target) {
5781
+ const relative2 = path12.relative(path12.resolve(root), path12.resolve(target));
5782
+ if (relative2 === "" || !path12.isAbsolute(relative2) && relative2 !== ".." && !relative2.startsWith(`..${path12.sep}`)) {
5783
+ return;
5784
+ }
5785
+ throw new Error(`Path escapes the skills directory: ${target}`);
5786
+ }
5787
+ function readSkillFile(file, allowedRoot, source) {
5788
+ const info = lstatSync(file, { throwIfNoEntry: false });
5789
+ if (!info?.isFile() || info.isSymbolicLink()) {
5790
+ throw new Error(`No regular SKILL.md found in "${source}"`);
5791
+ }
5792
+ if (info.size > MAX_SKILL_BYTES2) {
5793
+ throw new Error(`SKILL.md in "${source}" exceeds the ${MAX_SKILL_BYTES2 / 1e6} MB limit`);
5794
+ }
5795
+ const root = realpathSync2(allowedRoot);
5796
+ const resolved = realpathSync2(file);
5797
+ assertInside(root, resolved);
5798
+ return readFileSync(resolved, "utf8");
5799
+ }
5800
+ function removeExistingSkillDir(dir) {
5801
+ const info = lstatSync(dir, { throwIfNoEntry: false });
5802
+ if (!info) return;
5803
+ if (info.isSymbolicLink()) {
5804
+ throw new Error(`Refusing to remove symlinked skill directory: ${dir}`);
5805
+ }
5806
+ rmSync(dir, { recursive: true, force: true });
5047
5807
  }
5048
5808
 
5049
5809
  // src/tools/skill.ts
@@ -5060,6 +5820,7 @@ function createSkillTool(skills) {
5060
5820
  additionalProperties: false
5061
5821
  },
5062
5822
  defaultPermission: "allow",
5823
+ readOnly: true,
5063
5824
  summarize(input) {
5064
5825
  return `skill(${brief(input.name)})`;
5065
5826
  },
@@ -5165,52 +5926,100 @@ function field(input, key) {
5165
5926
  }
5166
5927
 
5167
5928
  // src/core/update.ts
5168
- var UPDATE_URL = `https://api.github.com/repos/${KITCODE_REPOSITORY}/commits/main`;
5929
+ var UPDATE_URL = "https://registry.npmjs.org/%40kernelonpanic%2Fkitcode/latest";
5930
+ var PACKAGE_URL = "https://www.npmjs.com/package/@kernelonpanic/kitcode";
5169
5931
  var TIMEOUT_MS = 4e3;
5170
5932
  var MAX_RESPONSE_BYTES2 = 128e3;
5171
- async function checkForUpdates(fetcher = fetch, currentCommit = KITCODE_COMMIT) {
5172
- if (currentCommit === "development") {
5173
- return { status: "unknown", reason: "development build has no embedded commit" };
5933
+ async function checkForUpdates(fetcher = fetch, currentVersion = KITCODE_VERSION) {
5934
+ if (!parseSemver(currentVersion)) {
5935
+ return { status: "unknown", reason: "installed KitCode version is not valid semver" };
5174
5936
  }
5175
5937
  const controller = new AbortController();
5176
5938
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
5177
5939
  try {
5178
5940
  const response = await fetcher(UPDATE_URL, {
5179
5941
  headers: {
5180
- accept: "application/vnd.github+json",
5181
- "user-agent": `KitCode/${KITCODE_VERSION}`,
5182
- "x-github-api-version": "2022-11-28"
5942
+ accept: "application/json",
5943
+ "user-agent": `KitCode/${currentVersion}`
5183
5944
  },
5184
5945
  redirect: "error",
5185
5946
  signal: controller.signal
5186
5947
  });
5187
5948
  if (!response.ok) {
5188
- return { status: "unknown", reason: `GitHub returned ${response.status}` };
5949
+ await response.body?.cancel().catch(() => void 0);
5950
+ return { status: "unknown", reason: `npm registry returned ${response.status}` };
5189
5951
  }
5190
5952
  const payload = await readJsonBounded(response);
5191
- if (!payload) return { status: "unknown", reason: "GitHub response was too large or invalid" };
5192
- if (typeof payload.sha !== "string" || !/^[0-9a-f]{40}$/i.test(payload.sha)) {
5193
- return { status: "unknown", reason: "GitHub response did not contain a commit" };
5953
+ if (!payload) return { status: "unknown", reason: "npm response was too large or invalid" };
5954
+ if (typeof payload.version !== "string" || !parseSemver(payload.version)) {
5955
+ return { status: "unknown", reason: "npm response did not contain a valid version" };
5194
5956
  }
5195
- const current = currentCommit.toLowerCase();
5196
- const latest = payload.sha.toLowerCase();
5197
- if (latest.startsWith(current) || current.startsWith(latest)) {
5198
- return { status: "current", current, latest };
5957
+ const latest = payload.version;
5958
+ if (compareSemver(latest, currentVersion) <= 0) {
5959
+ return { status: "current", current: currentVersion, latest };
5199
5960
  }
5200
- const url = typeof payload.html_url === "string" && payload.html_url.startsWith("https://github.com/") ? payload.html_url : `https://github.com/${KITCODE_REPOSITORY}/commits/main`;
5201
- return { status: "available", current, latest, url };
5961
+ return {
5962
+ status: "available",
5963
+ current: currentVersion,
5964
+ latest,
5965
+ url: `${PACKAGE_URL}/v/${encodeURIComponent(latest)}`
5966
+ };
5202
5967
  } catch (error) {
5203
5968
  return {
5204
5969
  status: "unknown",
5205
- reason: error instanceof Error && error.name === "AbortError" ? "GitHub check timed out" : "GitHub check failed"
5970
+ reason: error instanceof Error && error.name === "AbortError" ? "npm update check timed out" : "npm update check failed"
5206
5971
  };
5207
5972
  } finally {
5208
5973
  clearTimeout(timer);
5209
5974
  }
5210
5975
  }
5976
+ function compareSemver(left, right) {
5977
+ const a = parseSemver(left);
5978
+ const b = parseSemver(right);
5979
+ if (!a || !b) throw new Error("Cannot compare invalid semantic versions");
5980
+ for (let index = 0; index < 3; index += 1) {
5981
+ const x = a.core[index] ?? 0n;
5982
+ const y = b.core[index] ?? 0n;
5983
+ if (x !== y) return x > y ? 1 : -1;
5984
+ }
5985
+ if (a.prerelease.length === 0 || b.prerelease.length === 0) {
5986
+ if (a.prerelease.length === b.prerelease.length) return 0;
5987
+ return a.prerelease.length === 0 ? 1 : -1;
5988
+ }
5989
+ const length = Math.max(a.prerelease.length, b.prerelease.length);
5990
+ for (let index = 0; index < length; index += 1) {
5991
+ const x = a.prerelease[index];
5992
+ const y = b.prerelease[index];
5993
+ if (x === void 0 || y === void 0) return x === void 0 ? -1 : 1;
5994
+ if (x === y) continue;
5995
+ const xNumeric = /^\d+$/.test(x);
5996
+ const yNumeric = /^\d+$/.test(y);
5997
+ if (xNumeric && yNumeric) return BigInt(x) > BigInt(y) ? 1 : -1;
5998
+ if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
5999
+ return x > y ? 1 : -1;
6000
+ }
6001
+ return 0;
6002
+ }
6003
+ function parseSemver(value) {
6004
+ const match = value.match(
6005
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
6006
+ );
6007
+ if (!match) return null;
6008
+ const prerelease = match[4]?.split(".") ?? [];
6009
+ if (prerelease.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) {
6010
+ return null;
6011
+ }
6012
+ return {
6013
+ core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
6014
+ prerelease
6015
+ };
6016
+ }
5211
6017
  async function readJsonBounded(response) {
5212
6018
  const declared = Number(response.headers.get("content-length"));
5213
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES2) return null;
6019
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES2) {
6020
+ await response.body?.cancel().catch(() => void 0);
6021
+ return null;
6022
+ }
5214
6023
  if (!response.body) return null;
5215
6024
  const reader = response.body.getReader();
5216
6025
  const chunks = [];
@@ -5252,7 +6061,7 @@ async function boot(options) {
5252
6061
  const warnings = [];
5253
6062
  const workspaceRoot2 = await canonicalWorkspace(options.cwd);
5254
6063
  const workspaceTrusted = await isWorkspaceTrusted(options.cwd);
5255
- const startupUpdate = config.updates.checkOnStart ? checkForUpdates() : null;
6064
+ let startupUpdate;
5256
6065
  if (loadedConfig.ignoredProject) {
5257
6066
  warnings.push(
5258
6067
  `Project config ignored until this workspace is trusted: ${loadedConfig.ignoredProject.path}. Review it, then run: kitcode trust`
@@ -5295,7 +6104,7 @@ async function boot(options) {
5295
6104
  session = (options.continueSession ? await latestSessionFor(options.cwd) : null) ?? createSession(options.cwd, config.model ?? "");
5296
6105
  }
5297
6106
  const usage = createUsageTracker(session.usage, resolvePricing);
5298
- const skillCatalogue = formatSkillCatalogue(skills);
6107
+ let skillCatalogue = formatSkillCatalogue(skills);
5299
6108
  const mainSystemPrompt = () => buildSystemPrompt({
5300
6109
  cwd: options.cwd,
5301
6110
  toolNames: tools.list().map((tool) => tool.name),
@@ -5402,26 +6211,39 @@ async function boot(options) {
5402
6211
  createTaskTool(wrappedRunner, config.budget.maxSubagentsPerTurn)
5403
6212
  ]);
5404
6213
  let persistQueue = Promise.resolve();
5405
- const persistConfig = async (mutate) => {
5406
- mutate(config);
5407
- await saveConfig(config);
6214
+ let configQueue = Promise.resolve();
6215
+ const persistConfig = (mutate) => {
6216
+ const save = configQueue.catch(() => void 0).then(async () => {
6217
+ const before = structuredClone(config);
6218
+ mutate(config);
6219
+ try {
6220
+ await saveConfig(config);
6221
+ } catch (error) {
6222
+ Object.assign(config, before);
6223
+ throw error;
6224
+ }
6225
+ });
6226
+ configQueue = save;
6227
+ return save;
5408
6228
  };
5409
6229
  const compactWithBudget = async (history, signal, budget) => {
5410
6230
  if (compactCutIndex(history) <= 0) {
5411
6231
  return { history, compacted: false, removedMessages: 0 };
5412
6232
  }
5413
6233
  const resolved = registry.resolve(modelRef);
6234
+ const estimate = estimateCompactBudget(history, config.maxTokens);
5414
6235
  const budgetDecision = budget.beforeRequest({
5415
6236
  modelRef,
5416
- maxOutputTokens: Math.min(4096, config.maxTokens),
5417
- estimatedInputTokens: estimateCompactTokens(history)
6237
+ maxOutputTokens: estimate.maxOutputTokens,
6238
+ estimatedInputTokens: estimate.inputTokens
5418
6239
  });
5419
6240
  if (!budgetDecision.allowed) throw new Error(budgetDecision.reason);
5420
6241
  const result = await compactHistory({
5421
6242
  provider: resolved.provider,
5422
6243
  model: resolved.modelId,
5423
6244
  history,
5424
- maxTokens: budgetDecision.maxOutputTokens,
6245
+ maxTokens: config.maxTokens,
6246
+ maxTotalOutputTokens: budgetDecision.maxOutputTokens,
5425
6247
  signal
5426
6248
  });
5427
6249
  if (result.usage) {
@@ -5442,12 +6264,22 @@ async function boot(options) {
5442
6264
  async addProvider(url, key) {
5443
6265
  const detected = await detectProvider(url, key);
5444
6266
  rememberModels(detected.id, detected.models);
6267
+ const configBefore = structuredClone(config);
6268
+ const authBefore = { ...auth };
5445
6269
  config.providers[detected.id] = detected.config;
5446
6270
  auth[detected.id] = key;
5447
6271
  const chosen = preferredModel(detected.models);
5448
6272
  if (chosen) config.model = formatModelRef(detected.id, chosen);
5449
- await saveConfig(config);
5450
- await saveAuth(auth);
6273
+ try {
6274
+ await saveConfig(config);
6275
+ await saveAuth(auth);
6276
+ } catch (error) {
6277
+ Object.assign(config, configBefore);
6278
+ for (const id of Object.keys(auth)) delete auth[id];
6279
+ Object.assign(auth, authBefore);
6280
+ await Promise.allSettled([saveConfig(config), saveAuth(auth)]);
6281
+ throw error;
6282
+ }
5451
6283
  registry = createRegistry(config, auth);
5452
6284
  const nextRef = config.model ?? "";
5453
6285
  const discovered = detected.models.find((model) => model.id === chosen)?.contextWindow;
@@ -5537,17 +6369,26 @@ async function boot(options) {
5537
6369
  const provider = config.providers[providerId];
5538
6370
  if (!provider) throw new Error(`Provider "${providerId}" is not configured.`);
5539
6371
  const previousKey = auth[providerId];
6372
+ const restoreKey = () => {
6373
+ if (previousKey === void 0) delete auth[providerId];
6374
+ else auth[providerId] = previousKey;
6375
+ };
5540
6376
  auth[providerId] = newKey;
5541
6377
  try {
5542
6378
  const testRegistry = createRegistry(config, auth);
5543
- await loadModels(testRegistry.get(providerId));
6379
+ await testRegistry.get(providerId).listModels();
5544
6380
  } catch (error) {
5545
- auth[providerId] = previousKey;
6381
+ restoreKey();
5546
6382
  throw new Error(
5547
6383
  `Key verification failed for "${providerId}": ${error instanceof Error ? error.message : String(error)}`
5548
6384
  );
5549
6385
  }
5550
- await saveAuth(auth);
6386
+ try {
6387
+ await saveAuth(auth);
6388
+ } catch (error) {
6389
+ restoreKey();
6390
+ throw error;
6391
+ }
5551
6392
  registry = createRegistry(config, auth);
5552
6393
  if (parseModelRef(modelRef)?.provider === providerId) {
5553
6394
  const models = await loadModels(registry.get(providerId));
@@ -5614,7 +6455,7 @@ async function boot(options) {
5614
6455
  async exportSession(id, destination) {
5615
6456
  await persistQueue.catch(() => void 0);
5616
6457
  const target = destination ? path13.resolve(options.cwd, destination) : path13.join(options.cwd, ".kitcode-exports");
5617
- if (!destination) await mkdir5(target, { recursive: true, mode: 448 });
6458
+ if (!destination) await mkdir6(target, { recursive: true, mode: 448 });
5618
6459
  return (await exportSession(id, target)).path;
5619
6460
  },
5620
6461
  configPath: () => location.path,
@@ -5810,6 +6651,9 @@ ${lines.join("\n")}` : null;
5810
6651
  async installSkill(source) {
5811
6652
  const result = await installSkill(source);
5812
6653
  skills = await discoverSkills(skillRoots);
6654
+ skillCatalogue = formatSkillCatalogue(skills);
6655
+ tools.unregister(["skill"]);
6656
+ if (skills.length > 0) tools.register([createSkillTool(skills)]);
5813
6657
  return result;
5814
6658
  },
5815
6659
  async loadAttachment(requestedPath) {
@@ -5830,7 +6674,7 @@ ${lines.join("\n")}` : null;
5830
6674
  const states = mcp.states();
5831
6675
  const lines = [
5832
6676
  `KitCode ${KITCODE_VERSION} \xB7 ${KITCODE_COMMIT.slice(0, 12)}`,
5833
- `updates: ${config.updates.checkOnStart ? "enabled" : "disabled"}`,
6677
+ "updates: npm check runs on every app start",
5834
6678
  `runtime: Node ${process.versions.node} \xB7 ${process.platform}/${process.arch}`,
5835
6679
  `workspace: ${options.cwd}`,
5836
6680
  `config: ${location.path}`,
@@ -5858,7 +6702,8 @@ ${lines.join("\n")}` : null;
5858
6702
  );
5859
6703
  return lines.join("\n");
5860
6704
  },
5861
- startupUpdateCheck: () => startupUpdate,
6705
+ startupUpdateCheck: () => startupUpdate ??= checkForUpdates(),
6706
+ checkForUpdates: () => checkForUpdates(),
5862
6707
  async run(history, hooks, signal) {
5863
6708
  syncMcpTools();
5864
6709
  const runProviderId = parseModelRef(modelRef)?.provider;
@@ -5972,7 +6817,7 @@ ${lines.join("\n")}` : null;
5972
6817
  warnings,
5973
6818
  async shutdown() {
5974
6819
  try {
5975
- await persistQueue;
6820
+ await Promise.all([persistQueue, configQueue]);
5976
6821
  } finally {
5977
6822
  await mcp.close();
5978
6823
  }
@@ -5996,6 +6841,13 @@ async function ask(text, options = {}) {
5996
6841
  mode: options.mode
5997
6842
  });
5998
6843
  for (const warning of warnings) console.error(sanitizeTerminalText(warning));
6844
+ const update = await runtime.startupUpdateCheck();
6845
+ if (update.status === "available") {
6846
+ console.error(
6847
+ `A newer KitCode version is available (${update.latest}). Update with: npm install -g @kernelonpanic/kitcode@latest
6848
+ ${update.url}`
6849
+ );
6850
+ }
5999
6851
  const controller = new AbortController();
6000
6852
  const onInterrupt = () => controller.abort();
6001
6853
  process.on("SIGINT", onInterrupt);
@@ -6165,8 +7017,7 @@ function validateKey(value) {
6165
7017
  import { render } from "ink";
6166
7018
 
6167
7019
  // src/ui/App.tsx
6168
- import { Box as Box12, Text as Text12, useApp, useInput as useInput2 } from "ink";
6169
- import { execFile as execFile2, execFileSync as execFileSync2 } from "child_process";
7020
+ import { Box as Box13, Text as Text12, useApp, useInput as useInput2, useWindowSize as useWindowSize4 } from "ink";
6170
7021
  import { useCallback, useEffect as useEffect2, useMemo as useMemo4, useRef as useRef4, useState as useState5 } from "react";
6171
7022
 
6172
7023
  // src/mcp/add.ts
@@ -6220,6 +7071,7 @@ var COMMANDS = [
6220
7071
  { name: "mcp", args: "[add|list|delete|enable|disable]" },
6221
7072
  { name: "attach", args: "<path|clipboard|clear>" },
6222
7073
  { name: "compact" },
7074
+ { name: "update" },
6223
7075
  { name: "checker" },
6224
7076
  { name: "sessions", args: "[list|rename|delete [all]|export]" },
6225
7077
  { name: "config" },
@@ -6462,7 +7314,11 @@ var en = {
6462
7314
  sessionActionRename: "rename",
6463
7315
  sessionActionDelete: "delete",
6464
7316
  sessionActionExport: "export Markdown",
6465
- updateAvailable: (version, url) => `A newer KitCode build is available (${version}): ${url}`,
7317
+ updateAvailable: (version, url) => `A newer KitCode version is available (${version}). Update with:
7318
+ npm install -g @kernelonpanic/kitcode@latest
7319
+ ${url}`,
7320
+ updateCurrent: (version) => `KitCode ${version} is up to date.`,
7321
+ updateFailed: (reason) => `Could not check for updates: ${reason}.`,
6466
7322
  accentSet: (name, hex) => `Accent: ${name} (${hex})`,
6467
7323
  reasoning: (on) => `Reasoning ${on ? "on" : "off"}`,
6468
7324
  effortSet: (value) => `Effort: ${value}`,
@@ -6537,6 +7393,7 @@ var en = {
6537
7393
  "mcp disable": "disconnect without removing an MCP server",
6538
7394
  attach: "attach an image or text file to the next message",
6539
7395
  compact: "summarize older context and keep recent turns",
7396
+ update: "check npm for a newer KitCode version",
6540
7397
  checker: "check local setup without spending model tokens",
6541
7398
  sessions: "search and manage saved sessions",
6542
7399
  "sessions list": "list saved sessions",
@@ -6645,7 +7502,11 @@ var ru = {
6645
7502
  sessionActionRename: "\u043F\u0435\u0440\u0435\u0438\u043C\u0435\u043D\u043E\u0432\u0430\u0442\u044C",
6646
7503
  sessionActionDelete: "\u0443\u0434\u0430\u043B\u0438\u0442\u044C",
6647
7504
  sessionActionExport: "\u044D\u043A\u0441\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C Markdown",
6648
- updateAvailable: (version, url) => `\u0414\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u043D\u043E\u0432\u0430\u044F \u0432\u0435\u0440\u0441\u0438\u044F KitCode (${version}): ${url}`,
7505
+ updateAvailable: (version, url) => `\u0414\u043E\u0441\u0442\u0443\u043F\u043D\u0430 \u043D\u043E\u0432\u0430\u044F \u0432\u0435\u0440\u0441\u0438\u044F KitCode (${version}). \u041E\u0431\u043D\u043E\u0432\u0438\u0442\u044C:
7506
+ npm install -g @kernelonpanic/kitcode@latest
7507
+ ${url}`,
7508
+ updateCurrent: (version) => `\u0423\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u0430 \u0430\u043A\u0442\u0443\u0430\u043B\u044C\u043D\u0430\u044F \u0432\u0435\u0440\u0441\u0438\u044F KitCode ${version}.`,
7509
+ updateFailed: (reason) => `\u041D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u043E\u0431\u043D\u043E\u0432\u043B\u0435\u043D\u0438\u044F: ${reason}.`,
6649
7510
  accentSet: (name, hex) => `\u0426\u0432\u0435\u0442: ${name} (${hex})`,
6650
7511
  reasoning: (on) => `\u0420\u0430\u0437\u043C\u044B\u0448\u043B\u0435\u043D\u0438\u044F ${on ? "\u0432\u043A\u043B\u044E\u0447\u0435\u043D\u044B" : "\u0432\u044B\u043A\u043B\u044E\u0447\u0435\u043D\u044B"}`,
6651
7512
  effortSet: (value) => `\u0413\u043B\u0443\u0431\u0438\u043D\u0430: ${value}`,
@@ -6718,6 +7579,7 @@ var ru = {
6718
7579
  "mcp disable": "\u043E\u0442\u043A\u043B\u044E\u0447\u0438\u0442\u044C MCP \u0431\u0435\u0437 \u0443\u0434\u0430\u043B\u0435\u043D\u0438\u044F",
6719
7580
  attach: "\u043F\u0440\u0438\u043A\u0440\u0435\u043F\u0438\u0442\u044C \u043A\u0430\u0440\u0442\u0438\u043D\u043A\u0443 \u0438\u043B\u0438 \u0442\u0435\u043A\u0441\u0442\u043E\u0432\u044B\u0439 \u0444\u0430\u0439\u043B \u043A \u0441\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044E",
6720
7581
  compact: "\u0441\u0436\u0430\u0442\u044C \u0441\u0442\u0430\u0440\u044B\u0439 \u043A\u043E\u043D\u0442\u0435\u043A\u0441\u0442, \u0441\u043E\u0445\u0440\u0430\u043D\u0438\u0432 \u043F\u043E\u0441\u043B\u0435\u0434\u043D\u0438\u0435 \u0445\u043E\u0434\u044B",
7582
+ update: "\u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u043D\u043E\u0432\u0443\u044E \u0432\u0435\u0440\u0441\u0438\u044E KitCode \u0432 npm",
6721
7583
  checker: "\u043F\u0440\u043E\u0432\u0435\u0440\u0438\u0442\u044C \u043D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0443 \u0431\u0435\u0437 \u0442\u0440\u0430\u0442\u044B \u0442\u043E\u043A\u0435\u043D\u043E\u0432 \u043C\u043E\u0434\u0435\u043B\u0438",
6722
7584
  sessions: "\u043F\u043E\u0438\u0441\u043A \u0438 \u0443\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D\u043D\u044B\u043C\u0438 \u0441\u0435\u0441\u0441\u0438\u044F\u043C\u0438",
6723
7585
  "sessions list": "\u0441\u043F\u0438\u0441\u043E\u043A \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D\u043D\u044B\u0445 \u0441\u0435\u0441\u0441\u0438\u0439",
@@ -6834,7 +7696,13 @@ function Logo({ subtitle, workspace }) {
6834
7696
  return /* @__PURE__ */ jsxs2(Box2, { width: "100%", marginBottom: 1, children: [
6835
7697
  /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", flexShrink: 0, children: CAT.map((line) => /* @__PURE__ */ jsx2(Text2, { color: theme.accent, children: line }, line)) }),
6836
7698
  /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginLeft: 2, flexGrow: 1, flexShrink: 1, minWidth: 0, children: [
6837
- /* @__PURE__ */ jsx2(Text2, { color: theme.accent, bold: true, children: "kitcode" }),
7699
+ /* @__PURE__ */ jsxs2(Box2, { children: [
7700
+ /* @__PURE__ */ jsx2(Text2, { color: theme.accent, bold: true, children: "kitcode" }),
7701
+ /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
7702
+ " v",
7703
+ KITCODE_VERSION
7704
+ ] })
7705
+ ] }),
6838
7706
  /* @__PURE__ */ jsx2(Text2, { dimColor: true, wrap: "truncate-end", children: subtitle ?? strings.hint }),
6839
7707
  location && /* @__PURE__ */ jsxs2(Box2, { width: "100%", children: [
6840
7708
  /* @__PURE__ */ jsx2(Text2, { color: theme.accent, children: "\u2302 " }),
@@ -7194,7 +8062,7 @@ var PromptInput = memo(function PromptInput2({
7194
8062
  valueRef.current = safeValue;
7195
8063
  cursorRef.current = inputCursor;
7196
8064
  const suggestions = matchCommands(safeValue);
7197
- const open2 = suggestions.length > 0;
8065
+ const open3 = suggestions.length > 0;
7198
8066
  const active2 = Math.min(selectionCursor, Math.max(0, suggestions.length - 1));
7199
8067
  useEffect(() => {
7200
8068
  setSelectionCursor(0);
@@ -7247,25 +8115,25 @@ var PromptInput = memo(function PromptInput2({
7247
8115
  onPasteImage();
7248
8116
  return;
7249
8117
  }
7250
- if (open2 && key.upArrow) {
8118
+ if (open3 && key.upArrow) {
7251
8119
  setSelectionCursor(Math.max(0, active2 - 1));
7252
8120
  return;
7253
8121
  }
7254
- if (open2 && key.downArrow) {
8122
+ if (open3 && key.downArrow) {
7255
8123
  setSelectionCursor(Math.min(suggestions.length - 1, active2 + 1));
7256
8124
  return;
7257
8125
  }
7258
- if (open2 && key.tab && !key.shift) {
8126
+ if (open3 && key.tab && !key.shift) {
7259
8127
  const chosen = suggestions[active2];
7260
8128
  if (chosen) change(`/${chosen.name} `);
7261
8129
  return;
7262
8130
  }
7263
- if (open2 && key.return) {
8131
+ if (open3 && key.return) {
7264
8132
  const chosen = suggestions[active2];
7265
8133
  if (chosen) submit(`/${chosen.name}`);
7266
8134
  return;
7267
8135
  }
7268
- if (!open2 && (key.upArrow || key.downArrow)) {
8136
+ if (!open3 && (key.upArrow || key.downArrow)) {
7269
8137
  const moved = moveInputHistory(
7270
8138
  history,
7271
8139
  historyIndex,
@@ -7317,7 +8185,7 @@ var PromptInput = memo(function PromptInput2({
7317
8185
  });
7318
8186
  const start = Math.max(0, Math.min(active2 - WINDOW2 + 2, suggestions.length - WINDOW2));
7319
8187
  const visible = suggestions.slice(start, start + WINDOW2);
7320
- return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginTop: 1, children: [
8188
+ return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginTop: 1, flexShrink: 0, children: [
7321
8189
  /* @__PURE__ */ jsxs8(
7322
8190
  Box8,
7323
8191
  {
@@ -7345,7 +8213,7 @@ var PromptInput = memo(function PromptInput2({
7345
8213
  " ",
7346
8214
  sanitizeTerminalText(hint)
7347
8215
  ] }),
7348
- open2 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 2, children: [
8216
+ open3 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 2, children: [
7349
8217
  visible.map((command, index) => {
7350
8218
  const selected = start + index === active2;
7351
8219
  return /* @__PURE__ */ jsxs8(Text8, { color: selected ? theme.accent : void 0, dimColor: !selected, children: [
@@ -7481,6 +8349,7 @@ function StatusBar({ status }) {
7481
8349
  Box9,
7482
8350
  {
7483
8351
  width: "100%",
8352
+ flexShrink: 0,
7484
8353
  marginTop: 1,
7485
8354
  paddingX: 1,
7486
8355
  borderStyle: "single",
@@ -7608,141 +8477,191 @@ function ContextMeter({
7608
8477
  ] });
7609
8478
  }
7610
8479
 
8480
+ // src/ui/components/TerminalViewport.tsx
8481
+ import { Box as Box10 } from "ink";
8482
+ import { jsx as jsx10 } from "react/jsx-runtime";
8483
+ function interactiveViewportRows(rows) {
8484
+ if (!Number.isFinite(rows)) return 22;
8485
+ return Math.max(1, Math.floor(rows) - 2);
8486
+ }
8487
+ var INTERACTIVE_CHROME_ROWS = 14;
8488
+ function liveTranscriptRows(rows) {
8489
+ return Math.max(1, interactiveViewportRows(rows) - INTERACTIVE_CHROME_ROWS);
8490
+ }
8491
+ function TerminalViewport({ children, rows }) {
8492
+ return /* @__PURE__ */ jsx10(
8493
+ Box10,
8494
+ {
8495
+ flexDirection: "column",
8496
+ maxHeight: interactiveViewportRows(rows),
8497
+ overflowY: "hidden",
8498
+ children
8499
+ }
8500
+ );
8501
+ }
8502
+
7611
8503
  // src/ui/components/Transcript.tsx
7612
- import { Box as Box11, Static, Text as Text11 } from "ink";
8504
+ import { Box as Box12, Static, Text as Text11, useWindowSize as useWindowSize3 } from "ink";
7613
8505
  import { memo as memo2, useMemo as useMemo3, useRef as useRef3 } from "react";
7614
8506
  import Spinner2 from "ink-spinner";
8507
+ import stringWidth2 from "string-width";
7615
8508
 
7616
8509
  // src/ui/markdown.tsx
7617
- import { Box as Box10, Text as Text10 } from "ink";
7618
- import { useMemo as useMemo2 } from "react";
7619
- import { Fragment as Fragment4, jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
8510
+ import { Box as Box11, Text as Text10, useWindowSize as useWindowSize2 } from "ink";
8511
+ import { Fragment as Fragment4, useMemo as useMemo2 } from "react";
8512
+ import stringWidth from "string-width";
8513
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
7620
8514
  function Markdown({ children }) {
7621
8515
  const blocks = useMemo2(() => extractBlocks(children), [children]);
7622
- return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: blocks.map((block, i) => /* @__PURE__ */ jsx10(BlockView, { block }, i)) });
8516
+ return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: blocks.map((block, i) => /* @__PURE__ */ jsx11(BlockView, { block }, i)) });
7623
8517
  }
7624
8518
  function BlockView({ block }) {
7625
8519
  switch (block.type) {
7626
8520
  case "heading": {
7627
- const sizes = [22, 20, 18, 16, 14, 13];
7628
- const size = sizes[(block.level ?? 1) - 1] ?? 14;
7629
- return /* @__PURE__ */ jsx10(Box10, { marginTop: block.level === 1 ? 1 : 0, children: /* @__PURE__ */ jsx10(Text10, { bold: true, children: truncateBySize(block.text ?? "", size) }) });
8521
+ return /* @__PURE__ */ jsx11(Box11, { marginTop: block.level === 1 ? 1 : 0, children: /* @__PURE__ */ jsx11(Text10, { bold: true, children: inline(block.text ?? "") }) });
7630
8522
  }
7631
8523
  case "blockquote":
7632
- return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", marginLeft: 2, children: (block.text ?? "").split("\n").map((line, i) => /* @__PURE__ */ jsxs10(Box10, { children: [
7633
- /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "\u2502 " }),
7634
- /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: line })
7635
- ] }, i)) });
8524
+ return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: (block.text ?? "").split("\n").map((line, i) => /* @__PURE__ */ jsx11(Text10, { dimColor: true, italic: true, children: inline(line) }, i)) });
7636
8525
  case "ul":
7637
- return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: (block.items ?? []).map((item, i) => /* @__PURE__ */ jsxs10(Box10, { children: [
7638
- /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "\u2022 " }),
7639
- /* @__PURE__ */ jsx10(Box10, { marginLeft: 2, children: /* @__PURE__ */ jsx10(BlockView, { block: item }) })
8526
+ return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: (block.items ?? []).map((item, i) => /* @__PURE__ */ jsxs10(Box11, { children: [
8527
+ /* @__PURE__ */ jsx11(Text10, { dimColor: true, children: item.text?.match(/^[☑☐] /) ? "" : "\u2022 " }),
8528
+ /* @__PURE__ */ jsx11(Box11, { flexGrow: 1, flexShrink: 1, minWidth: 0, children: /* @__PURE__ */ jsx11(BlockView, { block: item }) })
7640
8529
  ] }, i)) });
7641
8530
  case "ol":
7642
- return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: (block.items ?? []).map((item, i) => /* @__PURE__ */ jsxs10(Box10, { children: [
7643
- /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: `${i + 1}. ` }),
7644
- /* @__PURE__ */ jsx10(Box10, { marginLeft: 2, children: /* @__PURE__ */ jsx10(BlockView, { block: item }) })
8531
+ return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: (block.items ?? []).map((item, i) => /* @__PURE__ */ jsxs10(Box11, { children: [
8532
+ /* @__PURE__ */ jsx11(Text10, { dimColor: true, children: `${(block.start ?? 1) + i}. ` }),
8533
+ /* @__PURE__ */ jsx11(Box11, { flexGrow: 1, flexShrink: 1, minWidth: 0, children: /* @__PURE__ */ jsx11(BlockView, { block: item }) })
7645
8534
  ] }, i)) });
7646
8535
  case "paragraph":
7647
- default:
7648
- return /* @__PURE__ */ jsx10(Text10, { children: inline(block.text ?? "") });
8536
+ default: {
8537
+ const content = /* @__PURE__ */ jsx11(Text10, { children: inline(block.text ?? "") });
8538
+ if (!block.children?.length) return content;
8539
+ return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", children: [
8540
+ content,
8541
+ /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: block.children.map((child, index) => /* @__PURE__ */ jsx11(BlockView, { block: child }, index)) })
8542
+ ] });
8543
+ }
7649
8544
  case "table":
7650
- return /* @__PURE__ */ jsx10(TableView, { block });
8545
+ return /* @__PURE__ */ jsx11(TableView, { block });
7651
8546
  case "code":
7652
- return /* @__PURE__ */ jsx10(CodeBlock, { lang: block.lang, code: block.text ?? "" });
8547
+ return /* @__PURE__ */ jsx11(CodeBlock, { code: block.text ?? "" });
7653
8548
  case "hr":
7654
- return /* @__PURE__ */ jsx10(Box10, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "\u2500".repeat(60) }) });
8549
+ return /* @__PURE__ */ jsx11(Box11, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx11(Text10, { dimColor: true, wrap: "truncate-end", children: "\u2500".repeat(60) }) });
7655
8550
  }
7656
8551
  }
7657
- function CodeBlock({ lang, code }) {
7658
- const theme = useTheme();
8552
+ function CodeBlock({ code }) {
7659
8553
  const lines = code.split("\n");
7660
- return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", marginTop: 1, children: [
7661
- lang && /* @__PURE__ */ jsx10(Text10, { dimColor: true, color: theme.accent, children: lang }),
7662
- /* @__PURE__ */ jsx10(Box10, { borderColor: "gray", borderStyle: "round", paddingX: 1, children: /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: line }, i)) }) })
7663
- ] });
8554
+ return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", marginTop: 1, children: lines.map((line, i) => /* @__PURE__ */ jsx11(Text10, { color: "cyan", children: line }, i)) });
7664
8555
  }
7665
8556
  function TableView({ block }) {
8557
+ const { columns } = useWindowSize2();
7666
8558
  const headers = block.headers ?? [];
7667
8559
  const rows = block.rows ?? [];
7668
8560
  if (headers.length === 0 && rows.length === 0) {
7669
- return /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "(empty table)" });
8561
+ return /* @__PURE__ */ jsx11(Text10, { dimColor: true, children: "(empty table)" });
7670
8562
  }
7671
8563
  const colCount = Math.max(headers.length, ...rows.map((r) => r?.length ?? 0));
7672
8564
  if (colCount === 0) {
7673
- return /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "(empty table)" });
8565
+ return /* @__PURE__ */ jsx11(Text10, { dimColor: true, children: "(empty table)" });
7674
8566
  }
7675
- const colWidths = new Array(colCount).fill(0);
7676
- const allRows = [headers, ...rows];
8567
+ const allRows = [headers, ...rows].map(
8568
+ (row) => Array.from({ length: colCount }, (_, index) => inlineDisplayText(row[index] ?? ""))
8569
+ );
8570
+ const naturalWidths = new Array(colCount).fill(1);
7677
8571
  for (const row of allRows) {
7678
8572
  for (let i = 0; i < colCount; i++) {
7679
8573
  const cell = row[i] ?? "";
7680
- colWidths[i] = Math.max(colWidths[i], [...cell].length);
8574
+ naturalWidths[i] = Math.max(naturalWidths[i] ?? 1, stringWidth(cell));
7681
8575
  }
7682
8576
  }
7683
- const separator = colWidths.map((w) => "\u2500".repeat(w)).join("\u253C");
8577
+ const colWidths = fitColumnWidths(naturalWidths, Math.max(1, columns));
8578
+ const separator = colWidths.map((width, index) => {
8579
+ const edge = index === 0 || index === colWidths.length - 1;
8580
+ return "\u2500".repeat(width + (edge ? 1 : 2));
8581
+ }).join("\u253C");
7684
8582
  const lines = [];
7685
8583
  allRows.forEach((row, rowIdx) => {
7686
8584
  const cells = [];
7687
8585
  for (let i = 0; i < colCount; i++) {
7688
8586
  const cell = row[i] ?? "";
7689
8587
  const align = block.colAligns?.[i] ?? "left";
7690
- const visualWidth = [...cell].length;
7691
- const padWidth = colWidths[i] + (cell.length - visualWidth);
8588
+ const width = colWidths[i] ?? 1;
8589
+ const fitted = truncateToWidth(cell, width);
8590
+ const visualWidth = stringWidth(fitted);
8591
+ const totalPad = Math.max(0, width - visualWidth);
7692
8592
  let padded;
7693
8593
  if (align === "right") {
7694
- padded = cell.padStart(padWidth);
8594
+ padded = `${" ".repeat(totalPad)}${fitted}`;
7695
8595
  } else if (align === "center") {
7696
- const totalPad = padWidth - visualWidth;
7697
8596
  const left = Math.floor(totalPad / 2);
7698
- padded = " ".repeat(left) + cell + " ".repeat(totalPad - left);
8597
+ padded = `${" ".repeat(left)}${fitted}${" ".repeat(totalPad - left)}`;
7699
8598
  } else {
7700
- padded = cell.padEnd(padWidth);
8599
+ padded = `${fitted}${" ".repeat(totalPad)}`;
7701
8600
  }
7702
8601
  cells.push(padded);
7703
8602
  }
7704
8603
  lines.push(
7705
- /* @__PURE__ */ jsx10(Text10, { bold: rowIdx === 0, children: cells.join(" \u2502 ") }, `row-${rowIdx}`)
8604
+ /* @__PURE__ */ jsx11(Text10, { bold: rowIdx === 0, wrap: "truncate-end", children: cells.join(" \u2502 ") }, `row-${rowIdx}`)
7706
8605
  );
7707
8606
  if (rowIdx === 0) {
7708
- lines.push(/* @__PURE__ */ jsx10(Text10, { dimColor: true, children: separator }, `sep-${rowIdx}`));
8607
+ lines.push(/* @__PURE__ */ jsx11(Text10, { dimColor: true, wrap: "truncate-end", children: separator }, `sep-${rowIdx}`));
7709
8608
  }
7710
8609
  });
7711
- return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: lines });
8610
+ return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: lines });
8611
+ }
8612
+ function fitColumnWidths(natural, maxLineWidth) {
8613
+ const widths = natural.map((width) => Math.max(1, width));
8614
+ const separatorWidth = Math.max(0, widths.length - 1) * 3;
8615
+ const available = Math.max(widths.length, maxLineWidth - separatorWidth);
8616
+ let excess = widths.reduce((sum, width) => sum + width, 0) - available;
8617
+ while (excess > 0) {
8618
+ const shrinkable = widths.map((width, index) => ({ width, index })).filter(({ width }) => width > 1);
8619
+ if (shrinkable.length === 0) break;
8620
+ const share = Math.max(1, Math.ceil(excess / shrinkable.length));
8621
+ for (const { index } of shrinkable) {
8622
+ const current = widths[index] ?? 1;
8623
+ const amount = Math.min(current - 1, share, excess);
8624
+ widths[index] = current - amount;
8625
+ excess -= amount;
8626
+ if (excess === 0) break;
8627
+ }
8628
+ }
8629
+ return widths;
8630
+ }
8631
+ var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
8632
+ function truncateToWidth(text, maxWidth) {
8633
+ if (stringWidth(text) <= maxWidth) return text;
8634
+ if (maxWidth <= 1) return "\u2026";
8635
+ let result = "";
8636
+ for (const { segment } of graphemeSegmenter.segment(text)) {
8637
+ if (stringWidth(result + segment) > maxWidth - 1) break;
8638
+ result += segment;
8639
+ }
8640
+ return `${result}\u2026`;
7712
8641
  }
7713
- var CHARS_PER_FONT_LEVEL = 3;
7714
- function truncateBySize(text, size) {
7715
- const maxLen = size * CHARS_PER_FONT_LEVEL;
7716
- return text.length > maxLen ? text.slice(0, maxLen) + "\u2026" : text;
8642
+ function inlineDisplayText(text) {
8643
+ return text.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, "[$1]").replace(/\[([^\]]*)\]\(([^)]*)\)/g, "$1($2)").replace(/(`+)(.*?)\1/g, "$2").replace(/(\*\*|__|~~)(?=\S)(.*?\S)\1/g, "$2").replace(/(?<!\\)(\*|_)(?=\S)(.*?\S)\1/g, "$2").replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1");
7717
8644
  }
7718
- var HEADING_RE = /^(#{1,6})\s+(.*)$/;
8645
+ var HEADING_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$/;
7719
8646
  var UL_RE = /^([-*+])\s+(.*)$/;
7720
- var OL_RE = /^(\d+)\.\s+(.*)$/;
8647
+ var OL_RE = /^(\d{1,9})[.)]\s+(.*)$/;
7721
8648
  var QUOTE_RE = /^>\s?(.*)$/;
7722
- var TASK_RE = /^[-*+]\s+\[([ xX])\]\s+(.*)$/;
7723
- var INDENT_RE = /^(\s*)(.*)$/;
7724
- var HR_RE = /^([-*_])\1{2,}$/;
7725
- var FENCE_RE = /^(`{3,}|~{3,})(\w*)\s*$/;
8649
+ var TASK_CONTENT_RE = /^\[([ xX])\]\s+(.*)$/;
8650
+ var SETEXT_RE = /^ {0,3}(=+|-+)[ \t]*$/;
8651
+ var MAX_TABLE_COLUMNS = 20;
8652
+ var MAX_LIST_DEPTH = 20;
7726
8653
  function extractBlocks(src) {
7727
- const lines = src.replace(/\r\n/g, "\n").split("\n");
8654
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
7728
8655
  const blocks = [];
7729
8656
  let paragraph = [];
7730
- let list = null;
7731
8657
  let quote = [];
7732
8658
  let code = null;
7733
- let table = null;
7734
8659
  const flushParagraph = () => {
7735
8660
  if (paragraph.length) {
7736
- blocks.push({ type: "paragraph", text: paragraph.join(" ").trim() });
8661
+ blocks.push({ type: "paragraph", text: joinParagraphLines(paragraph) });
7737
8662
  paragraph = [];
7738
8663
  }
7739
8664
  };
7740
- const flushList = () => {
7741
- if (list && list.items.length) {
7742
- blocks.push({ type: list.ordered ? "ol" : "ul", items: list.items });
7743
- list = null;
7744
- }
7745
- };
7746
8665
  const flushQuote = () => {
7747
8666
  if (quote.length) {
7748
8667
  blocks.push({ type: "blockquote", text: quote.join("\n") });
@@ -7757,239 +8676,361 @@ function extractBlocks(src) {
7757
8676
  };
7758
8677
  const flushAll = () => {
7759
8678
  flushParagraph();
7760
- flushList();
7761
8679
  flushQuote();
7762
8680
  flushCode();
7763
8681
  };
7764
- const flushTable = () => {
7765
- if (table && table.headers.length > 0) {
7766
- blocks.push({
7767
- type: "table",
7768
- headers: table.headers,
7769
- rows: table.rows,
7770
- colAligns: table.colAligns
7771
- });
7772
- }
7773
- table = null;
7774
- };
7775
- const parseListItem = (text, indent) => {
7776
- const task = text.match(TASK_RE);
7777
- if (task) {
7778
- const done = task[1].toLowerCase() === "x";
7779
- return {
7780
- type: "paragraph",
7781
- text: `${done ? "\u2611" : "\u2610"} ${task[2]}`
7782
- };
7783
- }
7784
- return { type: "paragraph", text };
7785
- };
7786
8682
  for (let i = 0; i < lines.length; i++) {
7787
- const line = lines[i];
8683
+ const line = lines[i] ?? "";
7788
8684
  const trimmed = line.trim();
7789
8685
  if (code) {
7790
- const endMatch = trimmed.match(FENCE_RE);
7791
- if (endMatch && endMatch[1][0] === code.fence[0] && endMatch[1].length >= code.fence.length) {
8686
+ if (isClosingFence(line, code.fence)) {
7792
8687
  flushCode();
7793
8688
  continue;
7794
8689
  }
7795
8690
  code.lines.push(line);
7796
8691
  continue;
7797
8692
  }
7798
- const fenceMatch = trimmed.match(FENCE_RE);
7799
- if (fenceMatch) {
8693
+ const fence = parseOpeningFence(line);
8694
+ if (fence) {
7800
8695
  flushAll();
7801
- flushTable();
7802
- code = { fence: fenceMatch[1], lang: fenceMatch[2], lines: [] };
8696
+ code = { fence: fence.fence, lang: fence.lang, lines: [] };
7803
8697
  continue;
7804
8698
  }
7805
8699
  if (trimmed === "") {
7806
- flushTable();
7807
8700
  flushAll();
7808
8701
  continue;
7809
8702
  }
7810
- const hrMatch = trimmed.match(HR_RE);
7811
- if (hrMatch) {
7812
- flushTable();
8703
+ const heading = line.match(HEADING_RE);
8704
+ if (heading) {
7813
8705
  flushAll();
7814
- blocks.push({ type: "hr" });
8706
+ const text = (heading[2] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim();
8707
+ blocks.push({ type: "heading", level: heading[1].length, text });
7815
8708
  continue;
7816
8709
  }
7817
- const heading = trimmed.match(HEADING_RE);
7818
- if (heading) {
7819
- flushTable();
8710
+ const setext = lines[i + 1]?.match(SETEXT_RE);
8711
+ if (setext && isSetextHeadingText(line)) {
7820
8712
  flushAll();
7821
- blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
8713
+ blocks.push({ type: "heading", level: setext[1][0] === "=" ? 1 : 2, text: trimmed });
8714
+ i += 1;
8715
+ continue;
8716
+ }
8717
+ if (isHorizontalRule(trimmed)) {
8718
+ flushAll();
8719
+ blocks.push({ type: "hr" });
7822
8720
  continue;
7823
8721
  }
7824
8722
  const quoteMatch = trimmed.match(QUOTE_RE);
7825
8723
  if (quoteMatch) {
7826
- flushTable();
7827
8724
  flushParagraph();
7828
- flushList();
7829
8725
  flushCode();
7830
8726
  quote.push(quoteMatch[1]);
7831
8727
  continue;
7832
8728
  }
7833
- const indentMatch = line.match(INDENT_RE);
7834
- const indent = indentMatch?.[1].length ?? 0;
7835
- const taskMatch = trimmed.match(TASK_RE);
7836
- if (taskMatch) {
7837
- flushTable();
8729
+ const parsedList = parseListBlock(lines, i);
8730
+ if (parsedList) {
7838
8731
  flushParagraph();
7839
8732
  flushQuote();
7840
8733
  flushCode();
7841
- if (!list || list.ordered || indent !== list.indent) {
7842
- flushList();
7843
- list = { ordered: false, items: [], indent };
7844
- }
7845
- list.items.push(parseListItem(trimmed, indent));
8734
+ blocks.push(parsedList.block);
8735
+ i = parsedList.nextIndex - 1;
7846
8736
  continue;
7847
8737
  }
7848
- const ulMatch = trimmed.match(UL_RE);
7849
- if (ulMatch) {
7850
- flushTable();
7851
- flushParagraph();
7852
- flushQuote();
7853
- flushCode();
7854
- if (!list || list.ordered || indent !== list.indent) {
7855
- flushList();
7856
- list = { ordered: false, items: [], indent };
7857
- }
7858
- list.items.push(parseListItem(ulMatch[2], indent));
8738
+ const markdownTable = parseMarkdownTable(lines, i);
8739
+ if (markdownTable) {
8740
+ flushAll();
8741
+ blocks.push(markdownTable.block);
8742
+ i = markdownTable.nextIndex - 1;
7859
8743
  continue;
7860
8744
  }
7861
- const olMatch = trimmed.match(OL_RE);
7862
- if (olMatch) {
7863
- flushTable();
7864
- flushParagraph();
7865
- flushQuote();
7866
- flushCode();
7867
- if (!list || !list.ordered || indent !== list.indent) {
7868
- flushList();
7869
- list = { ordered: true, items: [], indent };
7870
- }
7871
- list.items.push(parseListItem(olMatch[2], indent));
8745
+ const asciiTable = parseAsciiTable(lines, i);
8746
+ if (asciiTable) {
8747
+ flushAll();
8748
+ blocks.push(asciiTable.block);
8749
+ i = asciiTable.nextIndex - 1;
7872
8750
  continue;
7873
8751
  }
7874
- const sepMatch = trimmed.match(/^\|?\s*(\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/);
7875
- if (sepMatch) {
7876
- const cells = trimmed.split("|").map((c) => c.trim()).filter((c) => c.length > 0);
7877
- if (cells.length >= 2 && cells.every((c) => /^:?-+:?$/.test(c))) {
7878
- if (table) {
7879
- table.colAligns = cells.map((c) => {
7880
- if (c.startsWith(":") && c.endsWith(":")) return "center";
7881
- if (c.endsWith(":")) return "right";
7882
- return "left";
7883
- });
7884
- continue;
7885
- }
7886
- }
8752
+ if (quote.length > 0) {
8753
+ quote.push(trimmed);
8754
+ continue;
7887
8755
  }
7888
- const rowMatch = trimmed.match(/^\|?.+\|.+\|?$/);
7889
- if (rowMatch) {
7890
- const cells = trimmed.split("|").map((c) => c.trim()).filter((c, idx, arr) => {
7891
- if (idx === 0 && c === "") return false;
7892
- if (idx === arr.length - 1 && c === "") return false;
7893
- return true;
7894
- });
7895
- const isWindowsPath = cells.length === 2 && (/^[A-Za-z]:\\/.test(cells[0]) || /^\\\\/.test(cells[0]));
7896
- if (cells.length >= 2 && cells.every((c) => c.length > 0) && !isWindowsPath) {
7897
- if (table) {
7898
- table.rows.push(cells);
7899
- } else {
7900
- flushAll();
7901
- table = { headers: cells, rows: [], colAligns: [] };
8756
+ flushQuote();
8757
+ flushCode();
8758
+ paragraph.push(paragraphLine(line));
8759
+ }
8760
+ flushAll();
8761
+ return blocks;
8762
+ }
8763
+ function paragraphLine(line) {
8764
+ const hardBreak = /(?: {2,}|\\)$/.test(line);
8765
+ const text = line.trim().replace(/\\$/, "");
8766
+ return hardBreak ? `${text}
8767
+ ` : text;
8768
+ }
8769
+ function joinParagraphLines(lines) {
8770
+ let result = "";
8771
+ for (const line of lines) {
8772
+ if (result && !result.endsWith("\n")) result += " ";
8773
+ result += line;
8774
+ }
8775
+ return result.trim();
8776
+ }
8777
+ function parseListMarker(line) {
8778
+ const match = line.match(/^(\s*)(?:(\d{1,9})[.)]|([-+*]))\s+(.*)$/);
8779
+ if (!match) return null;
8780
+ return {
8781
+ indent: match[1].replace(/\t/g, " ").length,
8782
+ ordered: Boolean(match[2]),
8783
+ start: match[2] ? Number.parseInt(match[2], 10) : 1,
8784
+ text: match[4]
8785
+ };
8786
+ }
8787
+ function parseListItem(text) {
8788
+ const task = text.match(TASK_CONTENT_RE);
8789
+ return {
8790
+ type: "paragraph",
8791
+ text: task ? `${task[1].toLowerCase() === "x" ? "\u2611" : "\u2610"} ${task[2]}` : text
8792
+ };
8793
+ }
8794
+ function parseListBlock(lines, start, depth = 0) {
8795
+ const first2 = parseListMarker(lines[start] ?? "");
8796
+ if (!first2) return null;
8797
+ const items = [];
8798
+ let nextIndex = start;
8799
+ while (nextIndex < lines.length) {
8800
+ const marker = parseListMarker(lines[nextIndex] ?? "");
8801
+ if (!marker || marker.indent !== first2.indent || marker.ordered !== first2.ordered) break;
8802
+ const item = parseListItem(marker.text);
8803
+ nextIndex += 1;
8804
+ while (nextIndex < lines.length) {
8805
+ if ((lines[nextIndex] ?? "").trim() === "") {
8806
+ let afterBlank = nextIndex;
8807
+ while (afterBlank < lines.length && (lines[afterBlank] ?? "").trim() === "") {
8808
+ afterBlank += 1;
7902
8809
  }
7903
- continue;
7904
- }
7905
- }
7906
- const asciiSepMatch = trimmed.match(/^[\s\-─┄┈━┅]{3,}$/);
7907
- if (asciiSepMatch) {
7908
- if (table && table.headers.length > 0 && table.colAligns.length === 0) {
7909
- table.colAligns = table.headers.map(() => "left");
7910
- continue;
7911
- }
7912
- if (paragraph.length === 1) {
7913
- const prevLine = paragraph[0].trim();
7914
- const tokens3 = prevLine.split(/\s{2,}/).map((c) => c.trim()).filter((c) => c.length > 0);
7915
- if (tokens3.length >= 2 && tokens3.length <= 10) {
7916
- paragraph = [];
7917
- flushList();
7918
- flushQuote();
7919
- flushCode();
7920
- table = { headers: tokens3, rows: [], colAligns: tokens3.map(() => "left") };
7921
- continue;
8810
+ const followingMarker2 = parseListMarker(lines[afterBlank] ?? "");
8811
+ if (!followingMarker2 || followingMarker2.indent < first2.indent) {
8812
+ nextIndex = afterBlank;
8813
+ break;
7922
8814
  }
8815
+ nextIndex = afterBlank;
8816
+ if (followingMarker2.indent === first2.indent) break;
7923
8817
  }
7924
- }
7925
- if (table && table.headers.length > 0 && !trimmed.includes("|")) {
7926
- const tokens3 = trimmed.split(/\s{2,}/).map((c) => c.trim()).filter((c) => c.length > 0);
7927
- if (tokens3.length >= 2 && tokens3.length <= table.headers.length + 2) {
7928
- const looksLikeCode = tokens3.some((t) => t.startsWith("//") || t.startsWith("#") || t.startsWith("/*"));
7929
- const looksLikePath = tokens3.length === 2 && (/^[A-Za-z]:\\/.test(tokens3[0]) || /^\\\\/.test(tokens3[0]));
7930
- if (!looksLikeCode && !looksLikePath) {
7931
- table.rows.push(tokens3);
8818
+ const followingMarker = parseListMarker(lines[nextIndex] ?? "");
8819
+ if (followingMarker) {
8820
+ if (followingMarker.indent <= first2.indent) break;
8821
+ if (depth >= MAX_LIST_DEPTH) {
8822
+ item.text = `${item.text ?? ""} ${followingMarker.text}`.trim();
8823
+ nextIndex += 1;
7932
8824
  continue;
7933
8825
  }
8826
+ const nested = parseListBlock(lines, nextIndex, depth + 1);
8827
+ if (!nested) break;
8828
+ item.children ??= [];
8829
+ item.children.push(nested.block);
8830
+ nextIndex = nested.nextIndex;
8831
+ continue;
7934
8832
  }
7935
- }
7936
- flushTable();
7937
- flushList();
7938
- flushQuote();
7939
- flushCode();
7940
- paragraph.push(trimmed);
8833
+ if (interruptsList(lines, nextIndex)) break;
8834
+ const continuation = (lines[nextIndex] ?? "").trim();
8835
+ item.text = `${item.text ?? ""} ${continuation}`.trim();
8836
+ nextIndex += 1;
8837
+ }
8838
+ items.push(item);
8839
+ }
8840
+ const block = { type: first2.ordered ? "ol" : "ul", items };
8841
+ if (first2.ordered && first2.start !== 1) block.start = first2.start;
8842
+ return { block, nextIndex };
8843
+ }
8844
+ function interruptsList(lines, index) {
8845
+ const line = lines[index] ?? "";
8846
+ const trimmed = line.trim();
8847
+ return Boolean(
8848
+ line.match(HEADING_RE) || trimmed.match(QUOTE_RE) || parseOpeningFence(line) || isHorizontalRule(trimmed) || parseMarkdownTable(lines, index) || parseAsciiTable(lines, index)
8849
+ );
8850
+ }
8851
+ function parseOpeningFence(line) {
8852
+ const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
8853
+ if (!match) return null;
8854
+ const info = match[2].trim();
8855
+ if (match[1][0] === "`" && info.includes("`")) return null;
8856
+ return { fence: match[1], lang: info.split(/\s+/, 1)[0] ?? "" };
8857
+ }
8858
+ function isClosingFence(line, opening) {
8859
+ const match = line.match(/^ {0,3}(`+|~+)[ \t]*$/);
8860
+ return Boolean(
8861
+ match && match[1][0] === opening[0] && match[1].length >= opening.length
8862
+ );
8863
+ }
8864
+ function isHorizontalRule(line) {
8865
+ return /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(line);
8866
+ }
8867
+ function isSetextHeadingText(line) {
8868
+ const trimmed = line.trim();
8869
+ return trimmed !== "" && !HEADING_RE.test(line) && !QUOTE_RE.test(trimmed) && !UL_RE.test(trimmed) && !OL_RE.test(trimmed) && !parseOpeningFence(line);
8870
+ }
8871
+ function parseMarkdownTable(lines, start) {
8872
+ const headers = splitTableRow(lines[start] ?? "");
8873
+ const delimiter = splitTableRow(lines[start + 1] ?? "");
8874
+ if (!headers || !delimiter || headers.length < 2 || headers.length > MAX_TABLE_COLUMNS || delimiter.length !== headers.length || !delimiter.every((cell) => /^:?-{3,}:?$/.test(cell))) {
8875
+ return null;
7941
8876
  }
7942
- flushTable();
7943
- flushAll();
7944
- return blocks;
8877
+ const colAligns = delimiter.map((cell) => {
8878
+ if (cell.startsWith(":") && cell.endsWith(":")) return "center";
8879
+ if (cell.endsWith(":")) return "right";
8880
+ return "left";
8881
+ });
8882
+ const rows = [];
8883
+ let nextIndex = start + 2;
8884
+ while (nextIndex < lines.length) {
8885
+ const cells = splitTableRow(lines[nextIndex] ?? "");
8886
+ if (!cells) break;
8887
+ rows.push(headers.map((_, index) => cells[index] ?? ""));
8888
+ nextIndex += 1;
8889
+ }
8890
+ return {
8891
+ block: { type: "table", headers, rows, colAligns },
8892
+ nextIndex
8893
+ };
8894
+ }
8895
+ function splitTableRow(line) {
8896
+ const source = line.trim();
8897
+ if (!source.includes("|")) return null;
8898
+ const cells = [];
8899
+ let cell = "";
8900
+ let codeFenceLength = 0;
8901
+ let foundSeparator = false;
8902
+ for (let index = 0; index < source.length; index += 1) {
8903
+ const character = source[index] ?? "";
8904
+ if (character === "\\" && source[index + 1] === "|") {
8905
+ cell += "|";
8906
+ index += 1;
8907
+ continue;
8908
+ }
8909
+ if (character === "`") {
8910
+ let end = index + 1;
8911
+ while (source[end] === "`") end += 1;
8912
+ const runLength = end - index;
8913
+ if (codeFenceLength === 0) codeFenceLength = runLength;
8914
+ else if (codeFenceLength === runLength) codeFenceLength = 0;
8915
+ cell += source.slice(index, end);
8916
+ index = end - 1;
8917
+ continue;
8918
+ }
8919
+ if (character === "|" && codeFenceLength === 0) {
8920
+ foundSeparator = true;
8921
+ cells.push(cell.trim());
8922
+ cell = "";
8923
+ continue;
8924
+ }
8925
+ cell += character;
8926
+ }
8927
+ cells.push(cell.trim());
8928
+ if (!foundSeparator) return null;
8929
+ if (cells[0] === "") cells.shift();
8930
+ if (cells[cells.length - 1] === "") cells.pop();
8931
+ return cells.length >= 2 ? cells : null;
8932
+ }
8933
+ function parseAsciiTable(lines, start) {
8934
+ const headers = splitAsciiCells(lines[start] ?? "");
8935
+ if (headers.length < 2 || headers.length > MAX_TABLE_COLUMNS) return null;
8936
+ const separator = (lines[start + 1] ?? "").trim();
8937
+ const separatorCells = splitAsciiCells(separator);
8938
+ const validSeparator = /^[─┄┈━┅-]{3,}$/.test(separator) || separatorCells.length === headers.length && separatorCells.every((cell) => /^[─┄┈━┅-]{3,}$/.test(cell));
8939
+ if (!validSeparator) return null;
8940
+ const rows = [];
8941
+ let nextIndex = start + 2;
8942
+ while (nextIndex < lines.length) {
8943
+ const cells = splitAsciiCells(lines[nextIndex] ?? "");
8944
+ if (cells.length !== headers.length) break;
8945
+ rows.push(cells);
8946
+ nextIndex += 1;
8947
+ }
8948
+ if (rows.length === 0) return null;
8949
+ return {
8950
+ block: {
8951
+ type: "table",
8952
+ headers,
8953
+ rows,
8954
+ colAligns: headers.map(() => "left")
8955
+ },
8956
+ nextIndex
8957
+ };
8958
+ }
8959
+ function splitAsciiCells(line) {
8960
+ return line.trim().split(/\s{2,}/).map((cell) => cell.trim()).filter(Boolean);
7945
8961
  }
7946
8962
  var MAX_INLINE_DEPTH = 10;
8963
+ var INLINE_ESCAPE_BASE = 57344;
8964
+ var ESCAPABLE_MARKDOWN = ["\\", "`", "*", "_", "[", "]", "{", "}", "(", ")", "#", "+", "-", ".", "!", "|", ">"];
8965
+ function protectInlineEscapes(text) {
8966
+ return text.replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, (_match, character) => {
8967
+ const index = ESCAPABLE_MARKDOWN.indexOf(character);
8968
+ return String.fromCodePoint(INLINE_ESCAPE_BASE + index);
8969
+ });
8970
+ }
8971
+ function restoreInlineEscapes(text) {
8972
+ return [...text].map((character) => {
8973
+ const index = character.codePointAt(0) - INLINE_ESCAPE_BASE;
8974
+ return index >= 0 && index < ESCAPABLE_MARKDOWN.length ? ESCAPABLE_MARKDOWN[index] : character;
8975
+ }).join("");
8976
+ }
7947
8977
  function inline(text, depth = 0) {
7948
8978
  if (depth > MAX_INLINE_DEPTH) {
7949
- return text;
8979
+ return restoreInlineEscapes(text);
7950
8980
  }
7951
8981
  const nodes = [];
7952
- let rest = text;
8982
+ let rest = protectInlineEscapes(text);
7953
8983
  let key = 0;
7954
8984
  const matchers = [
7955
8985
  {
7956
- re: /(!\[)([^\]]*)\]\(([^)]+)\)/,
8986
+ re: /(!\[)([^\]]*)\]\(((?:[^()\\]|\\.|\([^()]*\))+?)\)/,
7957
8987
  handler: (m) => /* @__PURE__ */ jsxs10(Text10, { color: "cyan", bold: true, children: [
7958
8988
  "[img: ",
7959
- m[2],
8989
+ restoreInlineEscapes(m[2]),
7960
8990
  "]"
7961
8991
  ] }, key++)
7962
8992
  },
7963
8993
  {
7964
- re: /(\[)([^\]]*)\]\(([^)]+)\)/,
7965
- handler: (m) => /* @__PURE__ */ jsxs10(Fragment4, { children: [
7966
- /* @__PURE__ */ jsx10(Text10, { color: "cyan", underline: true, children: inline(m[2], depth + 1) }, key++),
7967
- /* @__PURE__ */ jsxs10(Text10, { dimColor: true, color: "gray", children: [
7968
- "(",
7969
- m[3],
7970
- ")"
7971
- ] }, key++)
7972
- ] })
8994
+ re: /(\[)([^\]]*)\]\(((?:[^()\\]|\\.|\([^()]*\))+?)\)/,
8995
+ handler: (m) => {
8996
+ const fragmentKey = key++;
8997
+ return /* @__PURE__ */ jsxs10(Fragment4, { children: [
8998
+ /* @__PURE__ */ jsx11(Text10, { color: "cyan", underline: true, children: inline(m[2], depth + 1) }),
8999
+ /* @__PURE__ */ jsxs10(Text10, { dimColor: true, color: "gray", children: [
9000
+ "(",
9001
+ restoreInlineEscapes(m[3]),
9002
+ ")"
9003
+ ] })
9004
+ ] }, fragmentKey);
9005
+ }
7973
9006
  },
7974
9007
  {
7975
9008
  re: /(`+)([^`]+?)\1/,
7976
- handler: (m) => /* @__PURE__ */ jsx10(Text10, { bold: true, color: "cyan", children: m[2] }, key++)
9009
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { bold: true, color: "cyan", children: restoreInlineEscapes(m[2]) }, key++)
9010
+ },
9011
+ {
9012
+ re: /(\*\*\*|___)(?=\S)(.*?\S)\1/,
9013
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { bold: true, italic: true, children: inline(m[2], depth + 1) }, key++)
9014
+ },
9015
+ {
9016
+ re: /(\*\*)(?=\S)(.*?\S)\1/,
9017
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { bold: true, children: inline(m[2], depth + 1) }, key++)
7977
9018
  },
7978
9019
  {
7979
- re: /(\*\*)(.+?)\1/,
7980
- handler: (m) => /* @__PURE__ */ jsx10(Text10, { bold: true, children: inline(m[2], depth + 1) }, key++)
9020
+ re: /(__)(?=\S)(.*?\S)\1/,
9021
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { bold: true, children: inline(m[2], depth + 1) }, key++)
7981
9022
  },
7982
9023
  {
7983
- re: /(__)(.+?)\1/,
7984
- handler: (m) => /* @__PURE__ */ jsx10(Text10, { bold: true, children: inline(m[2], depth + 1) }, key++)
9024
+ re: /(~~)(?=\S)(.*?\S)\1/,
9025
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { strikethrough: true, children: inline(m[2], depth + 1) }, key++)
7985
9026
  },
7986
9027
  {
7987
- re: /(~~)(.+?)\1/,
7988
- handler: (m) => /* @__PURE__ */ jsx10(Text10, { strikethrough: true, children: m[2] }, key++)
9028
+ re: /(\*)(?=\S)(.*?\S)\1/,
9029
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { italic: true, children: inline(m[2], depth + 1) }, key++)
7989
9030
  },
7990
9031
  {
7991
- re: /(\*)(.+?)\1/,
7992
- handler: (m) => /* @__PURE__ */ jsx10(Text10, { italic: true, children: inline(m[2], depth + 1) }, key++)
9032
+ re: /(?<!\w)(_)(?=\S)(.*?\S)\1(?!\w)/,
9033
+ handler: (m) => /* @__PURE__ */ jsx11(Text10, { italic: true, children: inline(m[2], depth + 1) }, key++)
7993
9034
  }
7994
9035
  ];
7995
9036
  while (rest.length > 0) {
@@ -8001,11 +9042,11 @@ function inline(text, depth = 0) {
8001
9042
  }
8002
9043
  }
8003
9044
  if (!best) {
8004
- nodes.push(rest);
9045
+ nodes.push(restoreInlineEscapes(rest));
8005
9046
  break;
8006
9047
  }
8007
9048
  const idx = best.match.index ?? 0;
8008
- if (idx > 0) nodes.push(rest.slice(0, idx));
9049
+ if (idx > 0) nodes.push(restoreInlineEscapes(rest.slice(0, idx)));
8009
9050
  nodes.push(best.handler(best.match));
8010
9051
  rest = rest.slice(idx + best.match[0].length);
8011
9052
  }
@@ -8013,12 +9054,22 @@ function inline(text, depth = 0) {
8013
9054
  }
8014
9055
 
8015
9056
  // src/ui/components/Transcript.tsx
8016
- import { Fragment as Fragment5, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
9057
+ import { Fragment as Fragment5, jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
8017
9058
  var HEADER = { kind: "header" };
8018
- var Transcript = memo2(function Transcript2({ bubbles, workspace }) {
8019
- const liveAt = bubbles.findLastIndex(
8020
- (bubble) => bubble.kind === "assistant" && bubble.streaming || bubble.kind === "tool" && bubble.state === "running"
9059
+ function assistantThinkingForFrame(bubble, frozenThinking) {
9060
+ return bubble.streaming && bubble.text !== "" ? frozenThinking ?? bubble.thinking : bubble.thinking;
9061
+ }
9062
+ function firstMutableBubbleIndex(bubbles) {
9063
+ return bubbles.findIndex(
9064
+ (bubble) => bubble.kind === "assistant" && bubble.streaming || bubble.kind === "tool" && bubble.state === "running" || bubble.kind === "subagent" && bubble.state === "running"
8021
9065
  );
9066
+ }
9067
+ var Transcript = memo2(function Transcript2({
9068
+ bubbles,
9069
+ workspace,
9070
+ maxLiveRows = 12
9071
+ }) {
9072
+ const liveAt = firstMutableBubbleIndex(bubbles);
8022
9073
  const stableCount = liveAt === -1 ? bubbles.length : liveAt;
8023
9074
  const stableRef = useRef3([]);
8024
9075
  if (stableRef.current.length < stableCount) {
@@ -8029,41 +9080,106 @@ var Transcript = memo2(function Transcript2({ bubbles, workspace }) {
8029
9080
  } else if (stableRef.current.length > stableCount) {
8030
9081
  stableRef.current = bubbles.slice(0, stableCount);
8031
9082
  }
8032
- const stable = stableRef.current;
8033
9083
  const live = liveAt === -1 ? [] : bubbles.slice(liveAt);
8034
- const staticItems = [HEADER, ...stable];
8035
- return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", marginBottom: 1, children: [
8036
- /* @__PURE__ */ jsx11(Static, { items: staticItems, children: (item) => item.kind === "header" ? /* @__PURE__ */ jsx11(Logo, { workspace }, "kitcode-header") : /* @__PURE__ */ jsx11(BubbleView, { bubble: item }, item.id) }),
8037
- live.map((bubble) => /* @__PURE__ */ jsx11(BubbleView, { bubble }, bubble.id))
8038
- ] });
9084
+ const staticItems = [HEADER, ...stableRef.current];
9085
+ return /* @__PURE__ */ jsxs11(
9086
+ Box12,
9087
+ {
9088
+ flexDirection: "column",
9089
+ marginBottom: 1,
9090
+ flexShrink: 1,
9091
+ minHeight: 0,
9092
+ overflowY: "hidden",
9093
+ children: [
9094
+ /* @__PURE__ */ jsx12(Static, { items: staticItems, children: (item) => item.kind === "header" ? /* @__PURE__ */ jsx12(Logo, { workspace }, "kitcode-header") : /* @__PURE__ */ jsx12(BubbleView, { bubble: item }, item.id) }),
9095
+ /* @__PURE__ */ jsx12(
9096
+ Box12,
9097
+ {
9098
+ flexDirection: "column",
9099
+ flexShrink: 1,
9100
+ minHeight: 0,
9101
+ maxHeight: maxLiveRows,
9102
+ overflowY: "hidden",
9103
+ justifyContent: "flex-end",
9104
+ children: live.map((bubble) => /* @__PURE__ */ jsx12(BubbleView, { bubble, maxRows: maxLiveRows }, bubble.id))
9105
+ }
9106
+ )
9107
+ ]
9108
+ }
9109
+ );
8039
9110
  });
8040
- var BubbleView = memo2(function BubbleView2({ bubble }) {
9111
+ var BubbleView = memo2(function BubbleView2({
9112
+ bubble,
9113
+ maxRows
9114
+ }) {
8041
9115
  const theme = useTheme();
8042
9116
  if (bubble.kind === "user") {
8043
- return /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsxs11(Text11, { color: theme.accent, bold: true, children: [
9117
+ return /* @__PURE__ */ jsx12(Box12, { marginTop: 1, children: /* @__PURE__ */ jsxs11(Text11, { color: theme.accent, bold: true, children: [
8044
9118
  "\u203A ",
8045
9119
  bubble.text
8046
9120
  ] }) });
8047
9121
  }
8048
9122
  if (bubble.kind === "notice") {
8049
9123
  const color = bubble.level === "error" ? theme.error : bubble.level === "warn" ? theme.warn : theme.accent;
8050
- return /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text11, { color, children: bubble.text }) });
9124
+ return /* @__PURE__ */ jsx12(Box12, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text11, { color, children: bubble.text }) });
8051
9125
  }
8052
9126
  if (bubble.kind === "assistant") {
8053
- return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", marginTop: 1, children: [
8054
- bubble.thinking.trim() !== "" && /* @__PURE__ */ jsx11(Text11, { dimColor: true, italic: true, children: bubble.thinking.trim() }),
8055
- bubble.streaming ? /* @__PURE__ */ jsx11(Text11, { children: bubble.text }) : /* @__PURE__ */ jsx11(Markdown, { children: bubble.text }),
8056
- bubble.streaming && bubble.text === "" && /* @__PURE__ */ jsxs11(Text11, { dimColor: true, children: [
8057
- /* @__PURE__ */ jsx11(Spinner2, { type: "dots" }),
8058
- " thinking"
8059
- ] })
8060
- ] });
9127
+ return /* @__PURE__ */ jsx12(AssistantView, { bubble, maxRows });
8061
9128
  }
8062
9129
  if (bubble.kind === "subagent") {
8063
- return /* @__PURE__ */ jsx11(SubagentView, { bubble });
9130
+ return /* @__PURE__ */ jsx12(SubagentView, { bubble });
8064
9131
  }
8065
- return /* @__PURE__ */ jsx11(ToolView, { bubble });
9132
+ return /* @__PURE__ */ jsx12(ToolView, { bubble });
8066
9133
  });
9134
+ function AssistantView({ bubble, maxRows }) {
9135
+ const { columns } = useWindowSize3();
9136
+ const frozenThinking = useRef3(void 0);
9137
+ const answering = bubble.streaming && bubble.text !== "";
9138
+ if (!answering) frozenThinking.current = void 0;
9139
+ if (answering && frozenThinking.current === void 0) {
9140
+ frozenThinking.current = bubble.thinking;
9141
+ }
9142
+ const visibleThinking = assistantThinkingForFrame(bubble, frozenThinking.current);
9143
+ const liveBudget = bubble.streaming && maxRows !== void 0 ? Math.max(1, maxRows - 1) : void 0;
9144
+ const thinkingBudget = liveBudget === void 0 ? void 0 : bubble.text !== "" ? Math.min(3, Math.max(0, liveBudget - 1)) : Math.max(0, liveBudget - 1);
9145
+ const frameThinking = thinkingBudget === void 0 ? visibleThinking.trim() : clipTextToRows(visibleThinking.trim(), thinkingBudget, columns);
9146
+ const usedThinkingRows = frameThinking === "" ? 0 : frameThinking.split("\n").length;
9147
+ const frameText = liveBudget === void 0 ? bubble.text : clipTextToRows(bubble.text, Math.max(1, liveBudget - usedThinkingRows), columns);
9148
+ return /* @__PURE__ */ jsxs11(Box12, { flexDirection: "column", marginTop: 1, children: [
9149
+ frameThinking !== "" && /* @__PURE__ */ jsx12(Text11, { dimColor: true, italic: true, children: frameThinking }),
9150
+ bubble.streaming ? /* @__PURE__ */ jsx12(Text11, { children: frameText }) : /* @__PURE__ */ jsx12(Markdown, { children: bubble.text }),
9151
+ bubble.streaming && bubble.text === "" && /* @__PURE__ */ jsxs11(Text11, { dimColor: true, children: [
9152
+ /* @__PURE__ */ jsx12(Spinner2, { type: "dots" }),
9153
+ " thinking"
9154
+ ] })
9155
+ ] });
9156
+ }
9157
+ function clipTextToRows(text, maxRows, columns) {
9158
+ if (text === "" || maxRows <= 0) return "";
9159
+ const width = Number.isFinite(columns) ? Math.max(1, Math.floor(columns)) : 80;
9160
+ const rows = text.replace(/\r\n?/g, "\n").split("\n").flatMap((line) => wrapVisualLine(line, width));
9161
+ if (rows.length <= maxRows) return rows.join("\n");
9162
+ const tail2 = rows.slice(-Math.max(1, maxRows - 1));
9163
+ return maxRows === 1 ? tail2.at(-1) ?? "" : ["\u2026", ...tail2].join("\n");
9164
+ }
9165
+ function wrapVisualLine(line, columns) {
9166
+ if (line === "") return [""];
9167
+ const rows = [];
9168
+ let current = "";
9169
+ let currentWidth = 0;
9170
+ for (const character of line) {
9171
+ const width = Math.max(1, stringWidth2(character));
9172
+ if (current !== "" && currentWidth + width > columns) {
9173
+ rows.push(current);
9174
+ current = "";
9175
+ currentWidth = 0;
9176
+ }
9177
+ current += character;
9178
+ currentWidth += width;
9179
+ }
9180
+ if (current !== "" || rows.length === 0) rows.push(current);
9181
+ return rows;
9182
+ }
8067
9183
  function ToolView({ bubble }) {
8068
9184
  const theme = useTheme();
8069
9185
  const mark = bubble.state === "running" ? "\u25CC" : bubble.state === "ok" ? "\u25CF" : "\u2717";
@@ -8073,19 +9189,19 @@ function ToolView({ bubble }) {
8073
9189
  () => display?.kind === "diff" ? diffLines(display.before, display.after) : null,
8074
9190
  [display]
8075
9191
  );
8076
- if (!diff) return /* @__PURE__ */ jsx11(NonDiffToolView, { bubble, mark, color });
9192
+ if (!diff) return /* @__PURE__ */ jsx12(NonDiffToolView, { bubble, mark, color });
8077
9193
  const hasChanges = diff.added > 0 || diff.removed > 0;
8078
- return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", marginTop: 1, children: [
9194
+ return /* @__PURE__ */ jsxs11(Box12, { flexDirection: "column", marginTop: 1, children: [
8079
9195
  /* @__PURE__ */ jsxs11(Text11, { color, children: [
8080
9196
  mark,
8081
9197
  " ",
8082
9198
  bubble.summary,
8083
9199
  hasChanges && /* @__PURE__ */ jsxs11(Fragment5, { children: [
8084
- diff.added > 0 && /* @__PURE__ */ jsx11(Text11, { color: theme.ok, bold: true, children: ` +${diff.added}` }),
8085
- diff.removed > 0 && /* @__PURE__ */ jsx11(Text11, { color: theme.error, bold: true, children: ` -${diff.removed}` })
9200
+ diff.added > 0 && /* @__PURE__ */ jsx12(Text11, { color: theme.ok, bold: true, children: ` +${diff.added}` }),
9201
+ diff.removed > 0 && /* @__PURE__ */ jsx12(Text11, { color: theme.error, bold: true, children: ` -${diff.removed}` })
8086
9202
  ] })
8087
9203
  ] }),
8088
- /* @__PURE__ */ jsx11(Box11, { marginLeft: 2, children: /* @__PURE__ */ jsx11(DiffHunk, { lines: diff.hunk, hidden: diff.hidden }) })
9204
+ /* @__PURE__ */ jsx12(Box12, { marginLeft: 2, children: /* @__PURE__ */ jsx12(DiffHunk, { lines: diff.hunk, hidden: diff.hidden }) })
8089
9205
  ] });
8090
9206
  }
8091
9207
  function NonDiffToolView({
@@ -8093,13 +9209,13 @@ function NonDiffToolView({
8093
9209
  mark,
8094
9210
  color
8095
9211
  }) {
8096
- return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", marginTop: 1, children: [
9212
+ return /* @__PURE__ */ jsxs11(Box12, { flexDirection: "column", marginTop: 1, children: [
8097
9213
  /* @__PURE__ */ jsxs11(Text11, { color, children: [
8098
9214
  mark,
8099
9215
  " ",
8100
9216
  bubble.summary
8101
9217
  ] }),
8102
- bubble.state !== "running" && bubble.content.trim() !== "" && /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", marginLeft: 2, children: previewLines(bubble.content).map((line, index) => /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: truncate2(line, 110) }, index)) })
9218
+ bubble.state !== "running" && bubble.content.trim() !== "" && /* @__PURE__ */ jsx12(Box12, { flexDirection: "column", marginLeft: 2, children: previewLines(bubble.content).map((line, index) => /* @__PURE__ */ jsx12(Text11, { dimColor: true, children: truncate2(line, 110) }, index)) })
8103
9219
  ] });
8104
9220
  }
8105
9221
  function previewLines(content) {
@@ -8111,15 +9227,15 @@ function SubagentView({ bubble }) {
8111
9227
  const theme = useTheme();
8112
9228
  const mark = bubble.state === "running" ? "\u25CC" : "\u25CF";
8113
9229
  const color = bubble.state === "running" ? theme.warn : theme.ok;
8114
- return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", marginTop: 1, children: [
9230
+ return /* @__PURE__ */ jsxs11(Box12, { flexDirection: "column", marginTop: 1, children: [
8115
9231
  /* @__PURE__ */ jsxs11(Text11, { color, bold: true, children: [
8116
9232
  mark,
8117
9233
  " subagent: ",
8118
9234
  bubble.description
8119
9235
  ] }),
8120
- /* @__PURE__ */ jsxs11(Box11, { marginLeft: 2, flexDirection: "column", children: [
8121
- bubble.bubbles.map((inner, index) => /* @__PURE__ */ jsx11(BubbleView, { bubble: inner }, index)),
8122
- bubble.state === "done" && bubble.result && /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "\u2500\u2500 result \u2500\u2500" }) })
9236
+ /* @__PURE__ */ jsxs11(Box12, { marginLeft: 2, flexDirection: "column", children: [
9237
+ bubble.bubbles.map((inner, index) => /* @__PURE__ */ jsx12(BubbleView, { bubble: inner }, index)),
9238
+ bubble.state === "done" && bubble.result && /* @__PURE__ */ jsx12(Box12, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text11, { dimColor: true, children: "\u2500\u2500 result \u2500\u2500" }) })
8123
9239
  ] })
8124
9240
  ] });
8125
9241
  }
@@ -8361,26 +9477,17 @@ function sanitizeDisplay(display) {
8361
9477
  }
8362
9478
 
8363
9479
  // src/ui/App.tsx
8364
- import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
9480
+ import { jsx as jsx13, jsxs as jsxs12 } from "react/jsx-runtime";
8365
9481
  var EFFORTS = ["low", "medium", "high", "xhigh", "max"];
8366
9482
  var STREAM_FRAME_MS = 50;
8367
9483
  var MAX_ATTACHMENTS = 8;
8368
- var NEEDS_IDLE = /* @__PURE__ */ new Set([
8369
- "clear",
8370
- "resume",
8371
- "sessions",
8372
- "logout",
8373
- "bypass",
8374
- "undo",
8375
- "compact",
8376
- "checker"
8377
- ]);
8378
9484
  function App({
8379
9485
  runtime,
8380
9486
  initialHistory,
8381
9487
  warnings = []
8382
9488
  }) {
8383
9489
  const { exit } = useApp();
9490
+ const { rows } = useWindowSize4();
8384
9491
  const [transcript, setTranscript] = useState5(
8385
9492
  () => warnings.reduce((state, text) => pushNotice(state, "warn", text), fromHistory(initialHistory))
8386
9493
  );
@@ -8390,8 +9497,12 @@ function App({
8390
9497
  () => inputHistoryFromMessages(initialHistory)
8391
9498
  );
8392
9499
  const [busy, setBusy] = useState5(false);
9500
+ const busyRef = useRef4(false);
8393
9501
  const [pendingCount, setPendingCount] = useState5(0);
8394
9502
  const queueRef = useRef4([]);
9503
+ const slashQueueRef = useRef4(Promise.resolve());
9504
+ const slashPendingRef = useRef4(0);
9505
+ const drainQueueRef = useRef4(() => void 0);
8395
9506
  const [attachments, setAttachments] = useState5([]);
8396
9507
  const attachmentsRef = useRef4([]);
8397
9508
  const automaticAttachmentTask = useRef4(null);
@@ -8458,43 +9569,6 @@ function App({
8458
9569
  const timer = setInterval(() => tick((n) => n + 1), 1e3);
8459
9570
  return () => clearInterval(timer);
8460
9571
  }, [turnStart]);
8461
- useEffect2(() => {
8462
- if (process.platform === "darwin" || process.platform === "win32") return;
8463
- let last = "";
8464
- let cancelling = false;
8465
- let giveUp = false;
8466
- let polling = false;
8467
- let interval = null;
8468
- const poll = () => {
8469
- if (cancelling || giveUp || polling) return;
8470
- polling = true;
8471
- execFile2(
8472
- "xclip",
8473
- ["-o", "-selection", "primary"],
8474
- { encoding: "utf8" },
8475
- (error, stdout) => {
8476
- polling = false;
8477
- if (cancelling) return;
8478
- if (error) {
8479
- giveUp = true;
8480
- if (interval) clearInterval(interval);
8481
- return;
8482
- }
8483
- const sel = stdout.trim();
8484
- if (sel && sel !== last) {
8485
- last = sel;
8486
- copyToClipboard(sel);
8487
- }
8488
- }
8489
- );
8490
- };
8491
- poll();
8492
- interval = setInterval(poll, 750);
8493
- return () => {
8494
- cancelling = true;
8495
- if (interval) clearInterval(interval);
8496
- };
8497
- }, []);
8498
9572
  const theme = useMemo4(() => makeTheme(accent), [accent]);
8499
9573
  const strings = useMemo4(() => stringsFor(lang), [lang]);
8500
9574
  const notice = useCallback(
@@ -8506,11 +9580,10 @@ function App({
8506
9580
  );
8507
9581
  useEffect2(() => {
8508
9582
  const check = runtime.startupUpdateCheck();
8509
- if (!check) return;
8510
9583
  let active2 = true;
8511
9584
  void check.then((result) => {
8512
9585
  if (active2 && result.status === "available") {
8513
- notice("info", strings.updateAvailable(result.latest.slice(0, 12), result.url));
9586
+ notice("info", strings.updateAvailable(result.latest, result.url));
8514
9587
  }
8515
9588
  });
8516
9589
  return () => {
@@ -8540,8 +9613,10 @@ function App({
8540
9613
  []
8541
9614
  );
8542
9615
  const runAgent = useCallback(async () => {
9616
+ if (busyRef.current) return;
8543
9617
  const controller = new AbortController();
8544
9618
  abort.current = controller;
9619
+ busyRef.current = true;
8545
9620
  setBusy(true);
8546
9621
  setTurnStart(Date.now());
8547
9622
  turns.current += 1;
@@ -8577,17 +9652,10 @@ function App({
8577
9652
  } finally {
8578
9653
  flushTranscriptEvents();
8579
9654
  abort.current = null;
9655
+ busyRef.current = false;
8580
9656
  setBusy(false);
8581
9657
  setTurnStart(null);
8582
- const queue = queueRef.current;
8583
- if (queue.length > 0) {
8584
- const [next, ...rest] = queue;
8585
- queueRef.current = rest;
8586
- setPendingCount(rest.length);
8587
- queueMicrotask(() => enqueueNext(next));
8588
- } else {
8589
- setPendingCount(0);
8590
- }
9658
+ drainQueueRef.current();
8591
9659
  }
8592
9660
  }, [flushTranscriptEvents, notice, queueTranscriptEvent, runtime]);
8593
9661
  const applyAccent = useCallback(
@@ -8811,6 +9879,17 @@ function App({
8811
9879
  case "config":
8812
9880
  notice("info", strings.configAt(runtime.configPath()));
8813
9881
  return;
9882
+ case "update": {
9883
+ const result = await runtime.checkForUpdates();
9884
+ if (result.status === "available") {
9885
+ notice("info", strings.updateAvailable(result.latest, result.url));
9886
+ } else if (result.status === "current") {
9887
+ notice("info", strings.updateCurrent(result.current));
9888
+ } else {
9889
+ notice("warn", strings.updateFailed(result.reason));
9890
+ }
9891
+ return;
9892
+ }
8814
9893
  case "login":
8815
9894
  setSetup(true);
8816
9895
  return;
@@ -9161,6 +10240,7 @@ ${strings.mcpAddUsage}`
9161
10240
  case "compact": {
9162
10241
  const controller = new AbortController();
9163
10242
  abort.current = controller;
10243
+ busyRef.current = true;
9164
10244
  setBusy(true);
9165
10245
  setTurnStart(Date.now());
9166
10246
  notice("info", strings.compactStarting);
@@ -9183,17 +10263,9 @@ ${strings.mcpAddUsage}`
9183
10263
  }
9184
10264
  } finally {
9185
10265
  abort.current = null;
10266
+ busyRef.current = false;
9186
10267
  setBusy(false);
9187
10268
  setTurnStart(null);
9188
- const queue = queueRef.current;
9189
- if (queue.length > 0) {
9190
- const [next, ...remaining] = queue;
9191
- queueRef.current = remaining;
9192
- setPendingCount(remaining.length);
9193
- if (next) queueMicrotask(() => enqueueNext(next));
9194
- } else {
9195
- setPendingCount(0);
9196
- }
9197
10269
  }
9198
10270
  forceRender((n) => n + 1);
9199
10271
  return;
@@ -9396,18 +10468,46 @@ Rename the file if you want a different name.`);
9396
10468
  },
9397
10469
  [applyAccent, appendAttachment, ask2, exit, notice, pick, replaceAttachments, runtime, strings]
9398
10470
  );
10471
+ const runSlash = useCallback(
10472
+ (line) => {
10473
+ slashPendingRef.current += 1;
10474
+ const task = slashQueueRef.current.catch(() => void 0).then(() => handleSlash(line));
10475
+ slashQueueRef.current = task;
10476
+ void task.catch(
10477
+ (error) => notice("error", error instanceof Error ? error.message : String(error))
10478
+ );
10479
+ const finished = () => {
10480
+ slashPendingRef.current = Math.max(0, slashPendingRef.current - 1);
10481
+ drainQueueRef.current();
10482
+ };
10483
+ void task.then(finished, finished);
10484
+ },
10485
+ [handleSlash, notice]
10486
+ );
9399
10487
  const enqueueNext = useCallback(
9400
10488
  (item) => {
9401
10489
  if (item.kind === "command") {
9402
- void handleSlash(item.line);
10490
+ runSlash(item.line);
9403
10491
  return;
9404
10492
  }
9405
10493
  setTranscript((state) => pushUser(state, userDisplay(item.text, item.content)));
9406
10494
  history.current = [...history.current, { role: "user", content: item.content }];
9407
10495
  void runAgent();
9408
10496
  },
9409
- [handleSlash, runAgent]
10497
+ [runAgent, runSlash]
9410
10498
  );
10499
+ const drainQueue = useCallback(() => {
10500
+ if (busyRef.current || slashPendingRef.current > 0) return;
10501
+ const [next, ...remaining] = queueRef.current;
10502
+ if (!next) {
10503
+ setPendingCount(0);
10504
+ return;
10505
+ }
10506
+ queueRef.current = remaining;
10507
+ setPendingCount(remaining.length);
10508
+ queueMicrotask(() => enqueueNext(next));
10509
+ }, [enqueueNext]);
10510
+ drainQueueRef.current = drainQueue;
9411
10511
  const tryQueueAutomaticAttachment = useCallback(
9412
10512
  (requestedPath) => {
9413
10513
  if (!looksLikeAttachmentPath(requestedPath)) return Promise.resolve(false);
@@ -9469,12 +10569,12 @@ Rename the file if you want a different name.`);
9469
10569
  const text = raw.trim();
9470
10570
  const submitSlash = () => {
9471
10571
  if (!detachedInput) setInput("");
9472
- if (busy && slashNeedsIdle(text)) {
10572
+ if (busyRef.current || slashPendingRef.current > 0) {
9473
10573
  queueRef.current = [...queueRef.current, { kind: "command", line: text }];
9474
10574
  setPendingCount(queueRef.current.length);
9475
10575
  return;
9476
10576
  }
9477
- void handleSlash(text);
10577
+ runSlash(text);
9478
10578
  };
9479
10579
  if (isKnownSlashCommand(text)) {
9480
10580
  submitSlash();
@@ -9498,7 +10598,7 @@ Rename the file if you want a different name.`);
9498
10598
  ];
9499
10599
  replaceAttachments([]);
9500
10600
  const item = { kind: "message", text, content };
9501
- if (busy) {
10601
+ if (busyRef.current || slashPendingRef.current > 0) {
9502
10602
  queueRef.current = [...queueRef.current, item];
9503
10603
  setPendingCount(queueRef.current.length);
9504
10604
  return;
@@ -9507,7 +10607,7 @@ Rename the file if you want a different name.`);
9507
10607
  history.current = [...history.current, { role: "user", content }];
9508
10608
  void runAgent();
9509
10609
  },
9510
- [busy, handleSlash, replaceAttachments, runAgent, tryQueueAutomaticAttachment]
10610
+ [replaceAttachments, runAgent, runSlash, tryQueueAutomaticAttachment]
9511
10611
  );
9512
10612
  useTerminalInput((_char, key) => {
9513
10613
  if (key.tab && key.shift) {
@@ -9517,6 +10617,10 @@ Rename the file if you want a different name.`);
9517
10617
  return;
9518
10618
  }
9519
10619
  if (!key.escape) return;
10620
+ if (input !== "") {
10621
+ setInput("");
10622
+ return;
10623
+ }
9520
10624
  if (busy && abort.current) {
9521
10625
  abort.current.abort();
9522
10626
  queueRef.current = [];
@@ -9541,10 +10645,10 @@ Rename the file if you want a different name.`);
9541
10645
  }
9542
10646
  setInput("");
9543
10647
  });
9544
- const shell = (children) => /* @__PURE__ */ jsx12(ThemeContext.Provider, { value: theme, children: /* @__PURE__ */ jsx12(StringsContext.Provider, { value: strings, children }) });
10648
+ const shell = (children) => /* @__PURE__ */ jsx13(ThemeContext.Provider, { value: theme, children: /* @__PURE__ */ jsx13(StringsContext.Provider, { value: strings, children }) });
9545
10649
  if (lang === void 0) {
9546
10650
  return shell(
9547
- /* @__PURE__ */ jsx12(
10651
+ /* @__PURE__ */ jsx13(
9548
10652
  LanguagePicker,
9549
10653
  {
9550
10654
  onPick: (chosen) => {
@@ -9557,7 +10661,7 @@ Rename the file if you want a different name.`);
9557
10661
  }
9558
10662
  if (setup) {
9559
10663
  return shell(
9560
- /* @__PURE__ */ jsx12(
10664
+ /* @__PURE__ */ jsx13(
9561
10665
  Onboarding,
9562
10666
  {
9563
10667
  onSubmit: async (url, key) => {
@@ -9572,16 +10676,17 @@ ${strings.configAt(runtime.configPath())}`);
9572
10676
  );
9573
10677
  }
9574
10678
  return shell(
9575
- /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
9576
- /* @__PURE__ */ jsx12(
10679
+ /* @__PURE__ */ jsxs12(TerminalViewport, { rows, children: [
10680
+ /* @__PURE__ */ jsx13(
9577
10681
  Transcript,
9578
10682
  {
9579
10683
  bubbles: transcript.bubbles,
9580
- workspace: runtime.cwd
10684
+ workspace: runtime.cwd,
10685
+ maxLiveRows: liveTranscriptRows(rows)
9581
10686
  },
9582
10687
  transcriptRevision
9583
10688
  ),
9584
- overlay.kind === "permission" && /* @__PURE__ */ jsx12(
10689
+ overlay.kind === "permission" && /* @__PURE__ */ jsx13(
9585
10690
  PermissionPrompt,
9586
10691
  {
9587
10692
  request: overlay.request,
@@ -9591,7 +10696,7 @@ ${strings.configAt(runtime.configPath())}`);
9591
10696
  }
9592
10697
  }
9593
10698
  ),
9594
- overlay.kind === "picker" && /* @__PURE__ */ jsx12(
10699
+ overlay.kind === "picker" && /* @__PURE__ */ jsx13(
9595
10700
  Picker,
9596
10701
  {
9597
10702
  title: overlay.title,
@@ -9606,7 +10711,7 @@ ${strings.configAt(runtime.configPath())}`);
9606
10711
  }
9607
10712
  }
9608
10713
  ),
9609
- overlay.kind === "confirm" && /* @__PURE__ */ jsx12(
10714
+ overlay.kind === "confirm" && /* @__PURE__ */ jsx13(
9610
10715
  Confirm,
9611
10716
  {
9612
10717
  title: overlay.title,
@@ -9618,7 +10723,7 @@ ${strings.configAt(runtime.configPath())}`);
9618
10723
  }
9619
10724
  }
9620
10725
  ),
9621
- overlay.kind === "textinput" && /* @__PURE__ */ jsx12(
10726
+ overlay.kind === "textinput" && /* @__PURE__ */ jsx13(
9622
10727
  TextInputOverlay,
9623
10728
  {
9624
10729
  title: overlay.title,
@@ -9633,7 +10738,7 @@ ${strings.configAt(runtime.configPath())}`);
9633
10738
  }
9634
10739
  }
9635
10740
  ),
9636
- overlay.kind === "none" && /* @__PURE__ */ jsx12(
10741
+ overlay.kind === "none" && /* @__PURE__ */ jsx13(
9637
10742
  PromptInput,
9638
10743
  {
9639
10744
  value: input,
@@ -9651,7 +10756,7 @@ ${strings.configAt(runtime.configPath())}`);
9651
10756
  })
9652
10757
  }
9653
10758
  ),
9654
- /* @__PURE__ */ jsx12(
10759
+ /* @__PURE__ */ jsx13(
9655
10760
  StatusBar,
9656
10761
  {
9657
10762
  status: {
@@ -9687,9 +10792,9 @@ function TextInputOverlay({
9687
10792
  useInput2((_input, key) => {
9688
10793
  if (key.escape) onCancel();
9689
10794
  });
9690
- return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", children: [
9691
- /* @__PURE__ */ jsx12(Text12, { dimColor: true, children: title }),
9692
- /* @__PURE__ */ jsx12(
10795
+ return /* @__PURE__ */ jsxs12(Box13, { flexDirection: "column", children: [
10796
+ /* @__PURE__ */ jsx13(Text12, { dimColor: true, children: title }),
10797
+ /* @__PURE__ */ jsx13(
9693
10798
  TextInput2,
9694
10799
  {
9695
10800
  value,
@@ -9698,13 +10803,9 @@ function TextInputOverlay({
9698
10803
  mask: mask ? "\u2022" : void 0
9699
10804
  }
9700
10805
  ),
9701
- /* @__PURE__ */ jsx12(Box12, { marginTop: 1, children: /* @__PURE__ */ jsx12(Text12, { dimColor: true, color: theme.accent, children: "enter submit \xB7 esc cancel" }) })
10806
+ /* @__PURE__ */ jsx13(Box13, { marginTop: 1, children: /* @__PURE__ */ jsx13(Text12, { dimColor: true, color: theme.accent, children: "enter submit \xB7 esc cancel" }) })
9702
10807
  ] });
9703
10808
  }
9704
- function slashNeedsIdle(line) {
9705
- const [command = "", subcommand = ""] = line.slice(1).trim().toLowerCase().split(/\s+/);
9706
- return NEEDS_IDLE.has(command) || command === "mcp" && (subcommand === "add" || subcommand === "delete" || subcommand === "enable" || subcommand === "disable");
9707
- }
9708
10809
  function isKnownSlashCommand(line) {
9709
10810
  if (!line.startsWith("/")) return false;
9710
10811
  const [name = ""] = line.slice(1).trim().toLowerCase().split(/\s+/);
@@ -9773,41 +10874,18 @@ function unquoteArg(value) {
9773
10874
  const last = value.at(-1);
9774
10875
  return first2 === '"' && last === '"' || first2 === "'" && last === "'" ? value.slice(1, -1) : value;
9775
10876
  }
9776
- function copyToClipboard(text) {
9777
- const run = (cmd, args) => execFileSync2(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
9778
- if (process.platform === "darwin") {
9779
- try {
9780
- run("pbcopy", []);
9781
- } catch {
9782
- }
9783
- return;
9784
- }
9785
- if (process.platform === "win32") {
9786
- try {
9787
- run("clip", []);
9788
- } catch {
9789
- }
9790
- return;
9791
- }
9792
- const candidates = [
9793
- ["wl-copy", []],
9794
- ["xclip", ["-selection", "clipboard"]],
9795
- ["xsel", ["--clipboard", "--input"]]
9796
- ];
9797
- for (const [cmd, args] of candidates) {
9798
- try {
9799
- run(cmd, args);
9800
- return;
9801
- } catch {
9802
- }
9803
- }
9804
- }
9805
10877
 
9806
10878
  // src/app/tui.tsx
9807
- import { jsx as jsx13 } from "react/jsx-runtime";
10879
+ import { jsx as jsx14 } from "react/jsx-runtime";
9808
10880
  function clearTerminal() {
9809
10881
  if (process.stdout.isTTY) process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
9810
10882
  }
10883
+ var TUI_RENDER_OPTIONS = {
10884
+ // Keep unchanged streamed lines in place instead of clearing and redrawing
10885
+ // the whole live region on every frame.
10886
+ incrementalRendering: true,
10887
+ maxFps: 30
10888
+ };
9811
10889
  async function startTui(options) {
9812
10890
  const { runtime, history, warnings, shutdown } = await boot({
9813
10891
  cwd: options.cwd ?? process.cwd(),
@@ -9817,10 +10895,10 @@ async function startTui(options) {
9817
10895
  mode: options.mode
9818
10896
  });
9819
10897
  clearTerminal();
9820
- const instance = render(/* @__PURE__ */ jsx13(App, { runtime, initialHistory: history, warnings }), {
9821
- incrementalRendering: true,
9822
- maxFps: 30
9823
- });
10898
+ const instance = render(
10899
+ /* @__PURE__ */ jsx14(App, { runtime, initialHistory: history, warnings }),
10900
+ TUI_RENDER_OPTIONS
10901
+ );
9824
10902
  try {
9825
10903
  await instance.waitUntilExit();
9826
10904
  } finally {