kritya 0.8.22-beta → 0.8.24-beta

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.
@@ -9,6 +9,15 @@ import { validateBlocks } from "./types.js";
9
9
  // fileURLToPath yields backslash-separated paths — normalize to forward
10
10
  // slashes, which fs.readFile accepts on every platform.
11
11
  const STANDARD_FONT_DATA_URL = fileURLToPath(new URL("../../../node_modules/pdfjs-dist/standard_fonts/", import.meta.url)).replace(/\\/g, "/");
12
+ /**
13
+ * A hostile or corrupt PDF can declare an enormous page count, or pack a
14
+ * huge amount of text into a single page; without a cap, extracting text
15
+ * page-by-page has no upper bound on how long it runs or how much memory the
16
+ * accumulated text consumes. Both caps stop the extraction early rather than
17
+ * failing outright — the caller gets whatever was read so far, plus a note.
18
+ */
19
+ const MAX_PDF_PAGES = 5_000;
20
+ const MAX_PDF_TEXT_CHARS = 5_000_000;
12
21
  export async function readPdf(buf) {
13
22
  const loadingTask = getDocument({
14
23
  data: new Uint8Array(buf),
@@ -16,16 +25,27 @@ export async function readPdf(buf) {
16
25
  });
17
26
  const doc = await loadingTask.promise;
18
27
  const pageTexts = [];
19
- for (let i = 1; i <= doc.numPages; i++) {
28
+ let totalChars = 0;
29
+ let truncated = false;
30
+ const pageCount = Math.min(doc.numPages, MAX_PDF_PAGES);
31
+ for (let i = 1; i <= pageCount; i++) {
20
32
  const page = await doc.getPage(i);
21
33
  const content = await page.getTextContent();
22
34
  const text = content.items
23
35
  .map((item) => ("str" in item ? item.str : ""))
24
36
  .join(" ");
25
37
  pageTexts.push(text);
38
+ totalChars += text.length;
39
+ if (totalChars > MAX_PDF_TEXT_CHARS) {
40
+ truncated = true;
41
+ break;
42
+ }
26
43
  }
27
44
  await loadingTask.destroy();
28
- return pageTexts.join("\n\n");
45
+ if (pageCount < doc.numPages)
46
+ truncated = true;
47
+ return (pageTexts.join("\n\n") +
48
+ (truncated ? `\n\n... [truncated: read ${pageTexts.length} of ${doc.numPages} page(s)]` : ""));
29
49
  }
30
50
  const PAGE_WIDTH = 612; // US Letter, points
31
51
  const PAGE_HEIGHT = 792;
@@ -1,5 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
- import JSZip from "jszip";
2
+ import { loadSafeZip } from "./zipSafety.js";
3
3
  // pptxgenjs is CommonJS-only and its bundled types don't resolve cleanly
4
4
  // under NodeNext + esModuleInterop (the default import type-checks as the
5
5
  // module namespace, not the constructor). Load it via require and type the
@@ -9,7 +9,10 @@ const PptxGenJS = require("pptxgenjs");
9
9
  // Matches text runs inside slide XML, e.g. <a:t>Hello</a:t>.
10
10
  const TEXT_RUN_RE = /<a:t>([^<]*)<\/a:t>/g;
11
11
  export async function readPptx(buf) {
12
- const zip = await JSZip.loadAsync(buf);
12
+ // .pptx is a zip container; same decompression-bomb exposure as .xlsx/.docx
13
+ // (see zipSafety.ts) — validate declared uncompressed size before reading
14
+ // any entry's content.
15
+ const zip = await loadSafeZip(buf);
13
16
  const slideFiles = Object.keys(zip.files)
14
17
  .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
15
18
  .sort((a, b) => slideNumber(a) - slideNumber(b));
@@ -1,27 +1,12 @@
1
1
  import ExcelJS from "exceljs";
2
- import JSZip from "jszip";
2
+ import { loadSafeZip } from "./zipSafety.js";
3
3
  const CELL_REF_RE = /^[A-Za-z]{1,3}[1-9][0-9]*$/;
4
4
  // exceljs's xlsx loader decompresses every entry of the .xlsx zip with no
5
- // size limit (CVE-2026-78206, unpatched upstream as of this writing) — a
6
- // small file whose declared uncompressed size is huge can exhaust memory
7
- // before exceljs itself ever runs. JSZip.loadAsync only reads the central
8
- // directory (cheap, no inflation), so summing each entry's *declared*
9
- // uncompressed size here rejects a bomb before handing the buffer to exceljs.
10
- const MAX_XLSX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
5
+ // size limit (CVE-2026-78206, unpatched upstream as of this writing) — see
6
+ // zipSafety.ts for why checking declared sizes up front (via JSZip, which
7
+ // doesn't inflate) catches this before exceljs itself ever runs.
11
8
  async function assertSafeXlsxSize(buf) {
12
- const zip = await JSZip.loadAsync(buf);
13
- let total = 0;
14
- for (const entry of Object.values(zip.files)) {
15
- // `_data` is JSZip's internal CompressedObject; there is no public API
16
- // for a zip entry's declared (pre-inflation) uncompressed size.
17
- total +=
18
- entry._data?.uncompressedSize ?? 0;
19
- if (total > MAX_XLSX_UNCOMPRESSED_BYTES) {
20
- throw new Error(`This .xlsx file's declared uncompressed size exceeds ` +
21
- `${MAX_XLSX_UNCOMPRESSED_BYTES / (1024 * 1024)}MB and was refused ` +
22
- `as a likely decompression bomb rather than risk exhausting memory.`);
23
- }
24
- }
9
+ await loadSafeZip(buf);
25
10
  }
26
11
  export async function readXlsx(buf) {
27
12
  await assertSafeXlsxSize(buf);
@@ -0,0 +1,34 @@
1
+ import JSZip from "jszip";
2
+ /**
3
+ * .docx, .xlsx, and .pptx are all zip containers, and every library that
4
+ * reads them (exceljs, mammoth, and this codebase's own pptx reader via
5
+ * JSZip directly) decompresses each entry with no size limit — a small file
6
+ * whose declared uncompressed size is huge can exhaust memory before the
7
+ * library that actually parses the format ever runs (see CVE-2026-78206 for
8
+ * exceljs specifically; mammoth uses the same unbounded JSZip decompression
9
+ * path). Loading with JSZip first only reads the central directory (cheap,
10
+ * no inflation) so summing each entry's *declared* uncompressed size rejects
11
+ * a bomb before any real parsing starts.
12
+ */
13
+ const MAX_ZIP_UNCOMPRESSED_BYTES = 200 * 1024 * 1024;
14
+ /** Throws if `zip`'s entries declare more than `maxBytes` of uncompressed data combined. */
15
+ export function assertSafeZipEntries(zip, maxBytes = MAX_ZIP_UNCOMPRESSED_BYTES) {
16
+ let total = 0;
17
+ for (const entry of Object.values(zip.files)) {
18
+ // `_data` is JSZip's internal CompressedObject; there is no public API
19
+ // for a zip entry's declared (pre-inflation) uncompressed size.
20
+ total +=
21
+ entry._data?.uncompressedSize ?? 0;
22
+ if (total > maxBytes) {
23
+ throw new Error(`This file's declared uncompressed size exceeds ` +
24
+ `${maxBytes / (1024 * 1024)}MB and was refused as a likely decompression bomb ` +
25
+ `rather than risk exhausting memory.`);
26
+ }
27
+ }
28
+ }
29
+ /** Loads `buf` as a zip and validates its declared uncompressed size, returning the loaded zip for reuse. */
30
+ export async function loadSafeZip(buf, maxBytes = MAX_ZIP_UNCOMPRESSED_BYTES) {
31
+ const zip = await JSZip.loadAsync(buf);
32
+ assertSafeZipEntries(zip, maxBytes);
33
+ return zip;
34
+ }
@@ -7,6 +7,13 @@ import { readXlsx, writeXlsx, editXlsx } from "./document/xlsx.js";
7
7
  import { readPptx, writePptx } from "./document/pptx.js";
8
8
  import { readPdf, writePdf, editPdf } from "./document/pdf.js";
9
9
  const READABLE_EXTENSIONS = [".docx", ".xlsx", ".pptx", ".pdf"];
10
+ /**
11
+ * Bound the raw file read before it's dispatched to any format-specific
12
+ * reader. Each reader has its own internal limits (zip decompression caps,
13
+ * PDF page/text caps), but those only kick in once parsing has started —
14
+ * this stops an absurdly large file from being buffered into memory at all.
15
+ */
16
+ const MAX_DOCUMENT_FILE_BYTES = 200 * 1024 * 1024;
10
17
  const BLOCK_SCHEMA = {
11
18
  type: "array",
12
19
  description: "Flowed content blocks, in document order.",
@@ -39,6 +46,11 @@ export const readDocumentTool = {
39
46
  const relPath = String(args.path);
40
47
  const abs = resolveSafe(ctx.workspace, relPath);
41
48
  const ext = path.extname(abs).toLowerCase();
49
+ const { size } = await fs.stat(abs);
50
+ if (size > MAX_DOCUMENT_FILE_BYTES) {
51
+ throw new Error(`${relPath} is ${Math.round(size / (1024 * 1024))}MB, over the ` +
52
+ `${MAX_DOCUMENT_FILE_BYTES / (1024 * 1024)}MB limit for read_document.`);
53
+ }
42
54
  const buf = await fs.readFile(abs);
43
55
  switch (ext) {
44
56
  case ".docx":
@@ -1,7 +1,6 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
- import { Agent } from "undici";
3
2
  import { truncateResult } from "./common.js";
4
- import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
3
+ import { isPrivateOrLoopbackHost, pinnedDispatcher, } from "../net/urlSafety.js";
5
4
  /** Cap on how much text a single fetch returns, unless the caller asks for less. */
6
5
  const DEFAULT_MAX_CHARS = 20_000;
7
6
  /** Give up on a slow or hanging server rather than blocking the whole turn. */
@@ -67,41 +66,6 @@ export async function hostResolvesToPrivateAddress(hostname, lookupFn = dnsLooku
67
66
  }
68
67
  /** Refuse to keep chasing redirects forever. */
69
68
  const MAX_REDIRECTS = 5;
70
- /**
71
- * The actual SSRF boundary: a connect-time `lookup` on a dedicated undici
72
- * Agent, so the address that gets validated is the exact address the socket
73
- * connects to — one DNS answer, not two. Checking the hostname up front and
74
- * then letting `fetch` re-resolve it independently (the previous approach)
75
- * left a TOCTOU window: attacker-controlled or short-TTL DNS can answer
76
- * differently the second time (classic DNS rebinding). Pinning the lookup
77
- * closes that regardless of hop, hostname, or TTL.
78
- */
79
- const pinnedDispatcher = new Agent({
80
- connect: {
81
- lookup(hostname, options, callback) {
82
- dnsLookup(hostname, { all: true })
83
- .then((addresses) => {
84
- if (addresses.length === 0) {
85
- callback(new Error(`Could not resolve ${hostname}`), []);
86
- return;
87
- }
88
- const bad = addresses.find((a) => isPrivateOrLoopbackHost(a.address));
89
- if (bad) {
90
- callback(new Error(`Refusing to connect to ${hostname}: resolves to private/internal address ${bad.address}`), []);
91
- return;
92
- }
93
- if (options.all) {
94
- callback(null, addresses);
95
- }
96
- else {
97
- const chosen = addresses[0];
98
- callback(null, chosen.address, chosen.family);
99
- }
100
- })
101
- .catch((err) => callback(err, []));
102
- },
103
- },
104
- });
105
69
  /**
106
70
  * Fetch `url`, following redirects manually so every hop — not just the
107
71
  * original URL — is checked against assertPublicUrl before the (DNS-pinned)
@@ -56,7 +56,20 @@ function summarizeOutputs(outputs) {
56
56
  return (text.slice(0, MAX_OUTPUT_CHARS) +
57
57
  `\n... [output truncated, ${text.length - MAX_OUTPUT_CHARS} more characters]`);
58
58
  }
59
+ /**
60
+ * Cell *output* text is already capped post-parse (MAX_OUTPUT_CHARS above),
61
+ * but nothing bounded the input file itself — a pathologically large
62
+ * .ipynb (corrupt, or a runaway export) would otherwise be read and
63
+ * JSON.parse'd whole regardless of size. Real notebooks are nowhere near
64
+ * this large even with heavy output.
65
+ */
66
+ const MAX_NOTEBOOK_FILE_BYTES = 50 * 1024 * 1024;
59
67
  async function loadNotebook(abs) {
68
+ const { size } = await fs.stat(abs);
69
+ if (size > MAX_NOTEBOOK_FILE_BYTES) {
70
+ throw new Error(`Notebook file is ${Math.round(size / (1024 * 1024))}MB, over the ` +
71
+ `${MAX_NOTEBOOK_FILE_BYTES / (1024 * 1024)}MB limit for reading a .ipynb file.`);
72
+ }
60
73
  const text = await fs.readFile(abs, "utf8");
61
74
  let nb;
62
75
  try {
@@ -38,7 +38,12 @@ export const shellTool = {
38
38
  // background command returns immediately. A second, shorter cap from the
39
39
  // agent loop would cut off commands the user explicitly asked to run longer.
40
40
  timeoutMs: 0,
41
- summarize: (args) => `Run${args.background ? " in background" : ""}: ${args.command}`,
41
+ // The summary is what lands in the audit log and telemetry spans (see
42
+ // toolExecutor's `kritya.summary` attribute), so it needs the same
43
+ // redaction as the background-start echo below — otherwise a credential
44
+ // embedded in the command (e.g. a curl Authorization header) gets
45
+ // persisted in plain text on every ordinary foreground call.
46
+ summarize: (args) => `Run${args.background ? " in background" : ""}: ${redactSecrets(String(args.command ?? "")).redacted}`,
42
47
  // A command that printed nothing needs no preview to say so — but every
43
48
  // other command's output is the answer, so keep it (null = show the preview).
44
49
  resultSummary: (output) => (output.trim() === "(no output)" ? "no output" : null),
package/dist/ui/App.js CHANGED
@@ -30,7 +30,7 @@ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
30
30
  // Erase screen + scrollback, then home the cursor (same sequence Ink itself
31
31
  // uses for its own "content taller than the terminal" clear path).
32
32
  const CLEAR_TERMINAL = "\x1b[2J\x1b[3J\x1b[H";
33
- export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, }) {
33
+ export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, }) {
34
34
  const { exit } = useApp();
35
35
  const { stdout } = useStdout();
36
36
  const [input, setInput] = useState("");
@@ -125,7 +125,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
125
125
  .catch(() => { });
126
126
  }, [workspace]);
127
127
  useEffect(refreshFileList, [refreshFileList]);
128
- const { items, addItem, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, autoApprovedCount, cycleMode, onAcceptEditsConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, } = useAgent({
128
+ const { items, addItem, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, autoApprovedCount, cycleMode, onAcceptEditsConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
129
129
  agent,
130
130
  workspace,
131
131
  modelRef,
@@ -195,6 +195,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
195
195
  return;
196
196
  }
197
197
  if (key.escape && phase === "working") {
198
+ setActivity("cancelling…");
198
199
  abortRef.current?.abort();
199
200
  }
200
201
  // Ctrl+O toggles showing full tool output.
@@ -444,7 +445,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
444
445
  setInput((prev) => (isCtrlEcho(prev, v) ? prev : v.replace(/\t/g, "")));
445
446
  setCmdIndex(0);
446
447
  setFileIndex(0);
447
- }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestions.map((c, i) => (_jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] }, c.name))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, autoApprovedCount: autoApprovedCount, model: servedModel && servedModel !== model
448
+ }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestions.map((c, i) => (_jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] }, c.name))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
448
449
  ? `${model}-${modelDisplaySlug(servedModel)}`
449
- : model, workflow: workflow, branch: branch, tasks: tasks, ctxPct: ctxPct, budgetPct: budgetPct, budgetStopped: budgetStopped, phase: phase, elapsed: elapsed, totalUsage: totalUsage, totalCost: totalCost, verbose: verbose, workspace: workspace, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable() }) })] }));
450
+ : model, workflow: workflow, branch: branch, tasks: tasks, ctxPct: ctxPct, budgetPct: budgetPct, budgetStopped: budgetStopped, phase: phase, elapsed: elapsed, totalUsage: totalUsage, totalCost: totalCost, verbose: verbose, workspace: workspace, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
450
451
  }
@@ -1,11 +1,16 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Text } from "ink";
3
- import path from "node:path";
4
3
  /** The single dim status bar pinned to the bottom of the screen. */
5
- export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, model, workflow, branch, tasks, ctxPct, budgetPct, budgetStopped, phase, elapsed, totalUsage, totalCost, verbose, workspace, sandboxActive, }) {
6
- return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), dryRunMode ? (_jsx(Text, { color: "cyan", children: "dry-run \u00B7 " })) : planMode ? (_jsx(Text, { color: "cyan", children: "plan \u00B7 " })) : acceptEdits ? (_jsxs(Text, { color: "green", children: ["accept edits (", autoApprovedCount, " auto-approved) \u00B7 "] })) : (""), model, _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", tasks.length > 0
4
+ export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, provider, model, workflow, branch, tasks, ctxPct, budgetPct, budgetStopped, phase, elapsed, totalUsage, totalCost, verbose, workspace, sandboxActive, persistenceWarningCount, privacyMode, }) {
5
+ return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), _jsxs(Text, { color: dryRunMode || planMode ? "cyan" : acceptEdits ? "green" : undefined, children: ["mode:", " ", dryRunMode
6
+ ? "dry-run"
7
+ : planMode
8
+ ? "plan"
9
+ : acceptEdits
10
+ ? `accept-edits (${autoApprovedCount} auto)`
11
+ : "default", " · "] }), provider, "/", model, _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", tasks.length > 0
7
12
  ? ` · tasks ${tasks.filter((t) => t.status === "done").length}/${tasks.length}`
8
- : "", ctxPct > 0 ? ` · ctx ${ctxPct}%` : "", budgetPct > 0 ? (_jsxs(Text, { color: budgetStopped ? "red" : budgetPct >= 80 ? "yellow" : undefined, children: [" ", "\u00B7 budget ", budgetPct, "%"] })) : (""), phase === "working" && elapsed > 0 ? ` · ${elapsed}s` : "", " \u00B7", " ", totalUsage.estimated ? "~" : "", totalUsage.promptTokens.toLocaleString(), " in", (totalUsage.cachedPromptTokens ?? 0) > 0
13
+ : "", ctxPct > 0 ? ` · ctx ${ctxPct}%` : "", budgetPct > 0 ? (_jsxs(Text, { color: budgetStopped ? "red" : budgetPct >= 80 ? "yellow" : undefined, children: [" ", "\u00B7 budget ", budgetPct, "%"] })) : (""), phase === "working" && elapsed > 0 ? ` · ${elapsed}s` : "", " \u00B7", " ", totalUsage.estimated ? "~" : "", "tokens: ", totalUsage.promptTokens.toLocaleString(), " in", (totalUsage.cachedPromptTokens ?? 0) > 0
9
14
  ? ` (${Math.round(((totalUsage.cachedPromptTokens ?? 0) / totalUsage.promptTokens) * 100)}% cached)`
10
- : "", " ", "/ ", totalUsage.completionTokens.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", verbose ? " · verbose" : "", " \u00B7 ", path.basename(workspace)] }));
15
+ : "", " ", "/ ", totalUsage.completionTokens.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", verbose ? " · verbose" : "", " \u00B7 workspace: ", workspace] }));
11
16
  }
@@ -12,6 +12,18 @@ import { tavilySearch } from "../tools/webSearch.js";
12
12
  import { killActiveNotice, useKillSwitch } from "./useKillSwitch.js";
13
13
  import { useUsageBudget } from "./useUsageBudget.js";
14
14
  import { useSessionResume } from "./useSessionResume.js";
15
+ import { activePersistenceWarnings, onPersistenceWarning } from "../config/debug.js";
16
+ function initialToolStatus(name) {
17
+ if (name.startsWith("mcp_"))
18
+ return "contacting MCP server…";
19
+ if (name.startsWith("lsp_"))
20
+ return "querying language server…";
21
+ if (name.includes("document"))
22
+ return "processing document…";
23
+ if (name.includes("notebook"))
24
+ return "processing notebook…";
25
+ return undefined;
26
+ }
15
27
  /**
16
28
  * Owns everything tied to running agent turns: the transcript, streaming and
17
29
  * permission state, usage/cost tracking, and session resume. Input handling
@@ -44,6 +56,21 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
44
56
  const [stream, setStream] = useState("");
45
57
  const [thinking, setThinking] = useState(false);
46
58
  const [activity, setActivity] = useState(null);
59
+ const [persistenceWarningCount, setPersistenceWarningCount] = useState(() => activePersistenceWarnings().length);
60
+ useEffect(() => onPersistenceWarning((warnings) => setPersistenceWarningCount(warnings.length)), []);
61
+ /**
62
+ * Compaction calls the model directly and can take a while, so without this
63
+ * the spinner would sit frozen on whatever it showed before compaction
64
+ * started until onToolEnd's after-the-fact "compact" record appears.
65
+ */
66
+ useEffect(() => {
67
+ agent.onCompactStart = () => setActivity("compacting context…");
68
+ agent.onCompactEnd = () => setActivity(null);
69
+ return () => {
70
+ agent.onCompactStart = undefined;
71
+ agent.onCompactEnd = undefined;
72
+ };
73
+ }, [agent]);
47
74
  /**
48
75
  * Which workflow phase the *current turn* is running. Turn-scoped and cleared
49
76
  * when the turn ends, unlike `workflow` below — `activity` can't carry this,
@@ -355,6 +382,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
355
382
  return;
356
383
  }
357
384
  setPhase("working");
385
+ setActivity(`Calling ${provider}/${model}…`);
358
386
  const ac = new AbortController();
359
387
  abortRef.current = ac;
360
388
  setInFlight([]);
@@ -372,7 +400,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
372
400
  },
373
401
  onToolStart: (id, name, summary) => {
374
402
  setStream("");
375
- setInFlight((prev) => [...prev, { id, name, summary }]);
403
+ setInFlight((prev) => [
404
+ ...prev,
405
+ { id, name, summary, status: initialToolStatus(name) },
406
+ ]);
376
407
  },
377
408
  onToolEnd: (id, name, summary, preview, isError, resultSummary) => {
378
409
  setInFlight((prev) => prev.filter((t) => t.id !== id));
@@ -397,6 +428,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
397
428
  setStream("");
398
429
  setActivity(`Provider error${status ? ` (${status})` : ""} — retrying (attempt ${attempt})…`);
399
430
  },
431
+ onFallback: (from, to) => {
432
+ setStream("");
433
+ setActivity(`${from} unavailable — trying fallback model ${to}…`);
434
+ },
400
435
  onUsage: (usage) => {
401
436
  if (usage.servedModel)
402
437
  setServedModel(usage.servedModel);
@@ -498,6 +533,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
498
533
  totalCost,
499
534
  tasks,
500
535
  setTasks,
536
+ persistenceWarningCount,
501
537
  ctxPct,
502
538
  setCtxPct,
503
539
  tokenBudget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.22-beta",
3
+ "version": "0.8.24-beta",
4
4
  "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
5
5
  "type": "module",
6
6
  "publishConfig": {