kritya 0.8.51-beta → 0.8.53-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.
@@ -101,6 +101,44 @@ export function sanitizeMcpServersRecord(servers, label = "mcpServers") {
101
101
  }
102
102
  return out;
103
103
  }
104
+ /**
105
+ * Top-level keys `~/.kritya/config.json` understands — keep in sync with
106
+ * CliConfig. Anything else in the file is silently ignored (the parsed
107
+ * object is only ever consumed through the typed interface), so a typo like
108
+ * `maxStep` or `contextwindow` looks like "my config isn't taking effect"
109
+ * with no clue why. loadConfig warns about those instead.
110
+ */
111
+ const KNOWN_CONFIG_KEYS = new Set([
112
+ "apiKey",
113
+ "model",
114
+ "baseUrl",
115
+ "provider",
116
+ "providers",
117
+ "customModels",
118
+ "pricing",
119
+ "contextWindow",
120
+ "maxSteps",
121
+ "tokenBudget",
122
+ "toolTimeoutSeconds",
123
+ "subagentConcurrency",
124
+ "subagentTimeoutSeconds",
125
+ "subagentMaxSteps",
126
+ "subagentWriteMaxSteps",
127
+ "mcpServers",
128
+ "sandboxExec",
129
+ "retentionDays",
130
+ "audit",
131
+ "otel",
132
+ "commitAttribution",
133
+ "privacyMode",
134
+ ]);
135
+ /**
136
+ * Keys in a parsed config object that CliConfig doesn't know — all ignored
137
+ * by the typed code that consumes it. Exported for tests.
138
+ */
139
+ export function unknownConfigKeys(parsed) {
140
+ return Object.keys(parsed).filter((key) => !KNOWN_CONFIG_KEYS.has(key));
141
+ }
104
142
  function sanitizeCliConfig(config) {
105
143
  if (!config.mcpServers)
106
144
  return config;
@@ -218,7 +256,15 @@ export function loadConfig() {
218
256
  }
219
257
  try {
220
258
  assertJsonWithinLimits(raw, "config.json");
221
- return sanitizeCliConfig(JSON.parse(raw));
259
+ const parsed = JSON.parse(raw);
260
+ const unknown = unknownConfigKeys(parsed);
261
+ if (unknown.length > 0) {
262
+ // warnUser dedupes per context, so this prints once per process even
263
+ // though loadConfig runs often.
264
+ warnUser(`loadConfig(${CONFIG_FILE})`, new Error(`unknown key(s) ${unknown.map((key) => `"${key}"`).join(", ")} ignored — ` +
265
+ `check the spelling against the documented config keys`));
266
+ }
267
+ return sanitizeCliConfig(parsed);
222
268
  }
223
269
  catch (err) {
224
270
  // Malformed or oversized — worth being able to see when someone reports
package/dist/index.js CHANGED
@@ -258,7 +258,16 @@ if (args.prompt) {
258
258
  trust: args.trust,
259
259
  timeoutSeconds: args.timeoutSeconds,
260
260
  privacy: args.privacy,
261
- }).then((code) => process.exit(code));
261
+ })
262
+ .then((code) => process.exit(code))
263
+ // runHeadless is documented to resolve with an exit code rather than
264
+ // throw, so reaching here means a bug in that path. Fail loudly with a
265
+ // non-zero code instead of letting Node print an unhandled-rejection
266
+ // trace — a CI job reading `$?` deserves a real exit status either way.
267
+ .catch((err) => {
268
+ console.error(err);
269
+ process.exit(1);
270
+ });
262
271
  }
263
272
  else {
264
273
  runInteractive();
@@ -216,7 +216,14 @@ class McpConnection {
216
216
  * server can tell the difference between an unsupported client and a hung one.
217
217
  */
218
218
  onServerRequest(id, method, params) {
219
- const reply = (body) => this.transport.send({ jsonrpc: "2.0", id, ...body }, CONNECT_TIMEOUT_MS).catch(() => { });
219
+ // Typed void on purpose: every reply is best-effort, its own `.catch`
220
+ // already keeps a failed send from becoming an unhandled rejection, and no
221
+ // caller of `reply` has anything to do with the promise. The explicit
222
+ // return type is what tells that to callers — and to no-floating-promises,
223
+ // which cannot see through the `.catch` above.
224
+ const reply = (body) => {
225
+ void this.transport.send({ jsonrpc: "2.0", id, ...body }, CONNECT_TIMEOUT_MS).catch(() => { });
226
+ };
220
227
  if (method === "roots/list") {
221
228
  reply({
222
229
  result: {
@@ -5,6 +5,14 @@ import { isPathSafe, resolveSafe, safeCompileRegex, truncateResult, meaningfulLi
5
5
  import { loadIgnorePatterns } from "./ignore.js";
6
6
  const MAX_MATCHES = 200;
7
7
  const MAX_FILE_BYTES = 1024 * 1024;
8
+ /**
9
+ * Longest line regex-tested. A minified file is a single ~1MB line, and
10
+ * safeCompileRegex only blocks nested-quantifier shapes — any other
11
+ * ReDoS-shaped pattern tested against that one line spins the event loop,
12
+ * where the tool timeout can't fire. Skipping the line closes it; a real
13
+ * match on a line this long is unreadable in the result anyway.
14
+ */
15
+ const MAX_LINE_CHARS = 10_000;
8
16
  export const grepTool = {
9
17
  name: "grep",
10
18
  description: "Search file contents with a regular expression. Returns 'file:line: text' matches. " +
@@ -74,6 +82,8 @@ export const grepTool = {
74
82
  const rel = path.relative(ctx.workspace, abs);
75
83
  const lines = content.split("\n");
76
84
  for (let i = 0; i < lines.length && matches.length < MAX_MATCHES; i++) {
85
+ if (lines[i].length > MAX_LINE_CHARS)
86
+ continue;
77
87
  if (regex.test(lines[i])) {
78
88
  matches.push(`${rel}:${i + 1}: ${lines[i].slice(0, 250)}`);
79
89
  }
@@ -3,7 +3,7 @@ import { resolveSafe, truncateResult, countLines } from "./common.js";
3
3
  export const readFileTool = {
4
4
  name: "read_file",
5
5
  description: "Read a text file from the workspace. Returns the content with line numbers. " +
6
- "Use offset/limit for large files.",
6
+ "Use offset/limit for large files. Refuses binary files — use read_document for .pdf/.docx/.xlsx/.pptx.",
7
7
  parameters: {
8
8
  type: "object",
9
9
  properties: {
@@ -18,7 +18,15 @@ export const readFileTool = {
18
18
  resultSummary: (output) => countLines(output, "line", "lines"),
19
19
  async execute(args, ctx) {
20
20
  const abs = resolveSafe(ctx.workspace, String(args.path));
21
- const content = await fs.readFile(abs, "utf8");
21
+ const buffer = await fs.readFile(abs);
22
+ // Binary sniff, same as grep: a NUL byte in the first 8KB means this is
23
+ // not text. Decoding it as UTF-8 anyway would dump garbage into the
24
+ // context and burn tokens on bytes no one can read.
25
+ if (buffer.subarray(0, 8000).includes(0)) {
26
+ throw new Error(`"${args.path}" looks like a binary file — read_file only reads text. ` +
27
+ `For .pdf, .docx, .xlsx, or .pptx use read_document instead.`);
28
+ }
29
+ const content = buffer.toString("utf8");
22
30
  const lines = content.split("\n");
23
31
  const offset = Math.max(1, Number(args.offset) || 1);
24
32
  const limit = Number(args.limit) || 2000;
package/dist/ui/App.js CHANGED
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import { glob } from "tinyglobby";
6
6
  import stringWidth from "string-width";
7
7
  import { gitDiffStat } from "../git/git.js";
8
+ import { debugLog } from "../config/debug.js";
8
9
  import { modelDisplaySlug } from "../config/models.js";
9
10
  import { setStderrSink } from "../stderr.js";
10
11
  import { openInEditor } from "./openInEditor.js";
@@ -145,7 +146,12 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
145
146
  ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", ...loadIgnorePatterns(workspace)],
146
147
  })
147
148
  .then((files) => setFileList(files.sort().slice(0, 2000)))
148
- .catch(() => { });
149
+ .catch((err) => {
150
+ // A failed refresh must not leave the previous workspace's files
151
+ // behind for @-mentions: clear, and leave a trail in the debug log.
152
+ debugLog("refreshFileList", err);
153
+ setFileList([]);
154
+ });
149
155
  }, [workspace]);
150
156
  useEffect(refreshFileList, [refreshFileList]);
151
157
  const { items, addItem, clearItems, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, setChained, workflow, refreshWorkflow, prefill, setPrefill, 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, bypassMode, setBypassMode, autoApprovedCount, cycleMode, onAcceptEditsConfirm, onBypassModeConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
@@ -576,7 +582,11 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
576
582
  costReport,
577
583
  statusReport,
578
584
  gitDiffStat,
579
- exit: exitAfterFlush,
585
+ // exitAfterFlush is async because it waits for Ink to flush the final
586
+ // render, but `exit` is typed void: no caller can do anything with the
587
+ // promise, so the detach is marked here rather than by widening the
588
+ // interface to `void | Promise<void>` to silence the lint rule.
589
+ exit: () => void exitAfterFlush(),
580
590
  };
581
591
  void runCommand(cmd, ctx);
582
592
  };
@@ -739,7 +749,11 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
739
749
  setInput(v.replace(/\t/g, ""));
740
750
  setCmdIndex(0);
741
751
  setFileIndex(0);
742
- }, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestionWindow.visible.map((line, i) => {
752
+ },
753
+ // handleSubmit is async only because it awaits runAgent, which
754
+ // catches its own errors and renders them as transcript items —
755
+ // so there is no rejection left for this callback to surface.
756
+ onSubmit: (value) => void handleSubmit(value) }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [suggestionWindow.visible.map((line, i) => {
743
757
  if (line.kind === "header") {
744
758
  return (_jsx(Text, { dimColor: true, wrap: "truncate", children: line.label }, `header:${line.label}`));
745
759
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.51-beta",
3
+ "version": "0.8.53-beta",
4
4
  "description": "Kritya — Superintelligence at your command line",
5
5
  "type": "module",
6
6
  "publishConfig": {