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.
- package/README.md +2 -1
- package/dist/agent/loop.js +11 -0
- package/dist/agent/toolExecutor.js +49 -3
- package/dist/audit/audit.js +2 -2
- package/dist/config/config.js +116 -5
- package/dist/config/debug.js +63 -0
- package/dist/config/jsonSafety.js +69 -0
- package/dist/engine.js +10 -5
- package/dist/headless.js +11 -6
- package/dist/hooks/hooks.js +9 -2
- package/dist/index.js +26 -9
- package/dist/mcp/client.js +5 -1
- package/dist/mcp/eraDetect.js +17 -12
- package/dist/mcp/oauth.js +19 -8
- package/dist/mcp/servers.js +18 -3
- package/dist/mcp/transport.js +18 -8
- package/dist/mcp/transportModern.js +7 -2
- package/dist/net/urlSafety.js +56 -0
- package/dist/permissions/danger.js +41 -1
- package/dist/provider/switchyardClient.js +1 -0
- package/dist/session/store.js +60 -9
- package/dist/telemetry/metrics.js +7 -4
- package/dist/telemetry/otlp.js +18 -7
- package/dist/telemetry/tracer.js +2 -2
- package/dist/tools/document/docx.js +6 -0
- package/dist/tools/document/pdf.js +22 -2
- package/dist/tools/document/pptx.js +5 -2
- package/dist/tools/document/xlsx.js +5 -20
- package/dist/tools/document/zipSafety.js +34 -0
- package/dist/tools/document.js +12 -0
- package/dist/tools/fetchUrl.js +1 -37
- package/dist/tools/notebook.js +13 -0
- package/dist/tools/shell.js +6 -1
- package/dist/ui/App.js +5 -4
- package/dist/ui/StatusLine.js +10 -5
- package/dist/ui/useAgent.js +37 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) —
|
|
6
|
-
//
|
|
7
|
-
// before exceljs itself ever runs.
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/tools/document.js
CHANGED
|
@@ -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":
|
package/dist/tools/fetchUrl.js
CHANGED
|
@@ -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)
|
package/dist/tools/notebook.js
CHANGED
|
@@ -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 {
|
package/dist/tools/shell.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/ui/StatusLine.js
CHANGED
|
@@ -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", " "] })) : (""),
|
|
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 ",
|
|
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
|
}
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -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) => [
|
|
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