kritya 0.8.26-beta → 0.8.28-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 CHANGED
@@ -55,7 +55,7 @@ review → fix` for building something new end-to-end
55
55
 
56
56
  ## Setup
57
57
 
58
- Requires Node.js >=22.
58
+ Requires Node.js >=22.19.0.
59
59
 
60
60
  1. Get an API key from your chosen provider (click any
61
61
  model → "Get API Key").
@@ -132,6 +132,25 @@ kritya [directory] [options]
132
132
  -v, --version version
133
133
  ```
134
134
 
135
+ Subcommands — these run instead of starting a session:
136
+
137
+ ```
138
+ kritya doctor [dir] [--json] [--offline]
139
+ check that this installation is set up correctly: the
140
+ Node version against the package's engines, the config
141
+ file, the active provider and whether its API key
142
+ resolves *and* the endpoint accepts it, workspace trust
143
+ and git state, MCP servers (including unset ${VAR}s),
144
+ sandbox availability, persistence settings, skills and
145
+ hooks, and whether a newer release exists
146
+ kritya audit ... inspect the local audit log (see docs/CONFIGURATION.md)
147
+ kritya skills ... list and validate discovered skills
148
+ ```
149
+
150
+ `kritya doctor` exits non-zero only when something is actually broken; the
151
+ warnings it prints are "this works, but not the way you may expect" — an
152
+ unavailable sandbox, an untrusted workspace, a lagging `latest` tag.
153
+
135
154
  In-session commands (type `/` to see them with autocomplete; letters filter the list):
136
155
 
137
156
  | Command | What it does |
@@ -170,7 +189,8 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
170
189
  Custom `/commands` you define (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md#custom-slash-commands)) also appear here.
171
190
 
172
191
  `Esc` cancels a running request. `↑/↓` recalls input history. `Ctrl+O` toggles
173
- full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
192
+ full tool output. `Ctrl+E` opens the file the agent touched most recently in
193
+ `$EDITOR`. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
174
194
 
175
195
  ### More features
176
196
 
@@ -432,10 +452,20 @@ model config lives in `~/.kritya/config.json`.
432
452
  kritya collects **no telemetry** and phones home to nothing of its own by
433
453
  default. Network requests go only to the model provider you configure (and to
434
454
  Tavily if you use web search). Sessions and config stay on your machine under
435
- `~/.kritya/`. The one opt-in exception is `KRITYA_OTEL_ENDPOINT` (see
436
- [Audit log & telemetry](docs/CONFIGURATION.md#audit-log--telemetry)): if you explicitly set it, tracing/metrics spans are sent
437
- to the OpenTelemetry Collector you point it at — nothing is exported anywhere
438
- unless you configure that endpoint yourself.
455
+ `~/.kritya/`.
456
+
457
+ Two things leave the machine beyond the model provider, and both are opt-out:
458
+
459
+ - **The update check.** `kritya --version` and `kritya doctor` ask the public
460
+ npm registry for the latest published version, so they can tell you when
461
+ you're behind. The answer is cached in `~/.kritya/update-check.json` for 24
462
+ hours, so it is one request per day at most, and it only runs when stdout is
463
+ a TTY — a script or a pipe never triggers it and never sees the notice.
464
+ `KRITYA_NO_UPDATE_CHECK=1` turns it off entirely.
465
+ - **Tracing**, if you opt in via `KRITYA_OTEL_ENDPOINT` (see
466
+ [Audit log & telemetry](docs/CONFIGURATION.md#audit-log--telemetry)): spans
467
+ are sent to the OpenTelemetry Collector you point it at — nothing is
468
+ exported anywhere unless you configure that endpoint yourself.
439
469
 
440
470
  ## Development
441
471
 
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { writeStderr } from "../stderr.js";
4
5
  import { pluginsDir, pluginSkillsRoots, scanPlugins, userPluginsDir } from "../plugins/discover.js";
5
6
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
6
7
  const KEY_VALUE_RE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
@@ -73,7 +74,7 @@ function unquote(value) {
73
74
  return value;
74
75
  }
75
76
  let warnSink = (message) => {
76
- process.stderr.write(`kritya: ${message}\n`);
77
+ writeStderr(`kritya: ${message}\n`);
77
78
  };
78
79
  function warn(message) {
79
80
  warnSink(message);
@@ -0,0 +1,380 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { BUILTIN_PROVIDERS, CONFIG_DIR, CONFIG_FILE, legacyGlobalModel, loadConfig, privacyModeFor, resolveProvider, } from "../config/config.js";
6
+ import { DEFAULT_MODEL, contextWindowFor } from "../config/models.js";
7
+ import { retentionDaysFor } from "../config/retention.js";
8
+ import { assertJsonWithinLimits } from "../config/jsonSafety.js";
9
+ import { isGitRepo } from "../agent/worktree.js";
10
+ import { loadHooks } from "../hooks/hooks.js";
11
+ import { loadProjectMcpServers, missingVars } from "../mcp/servers.js";
12
+ import { scanSkillsDetailed, skillsDir, userSkillsDir } from "../agent/skills.js";
13
+ import { defaultSandboxMode, sandboxAvailable, sandboxUnavailableReason, } from "../shell/sandbox.js";
14
+ import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
15
+ import { gatedContentHash, isTrusted } from "../trust/trust.js";
16
+ import { checkForUpdate, updateCheckDisabled } from "../update/check.js";
17
+ import { VERSION } from "../version.js";
18
+ const require = createRequire(import.meta.url);
19
+ const pkg = require("../../package.json");
20
+ export const DOCTOR_USAGE = `kritya doctor — check that this installation is set up correctly
21
+
22
+ Usage:
23
+ kritya doctor [dir] diagnose the setup for [dir] (default: current directory)
24
+ kritya doctor [dir] --json machine-readable output
25
+ kritya doctor [dir] --offline skip the network probes (provider, update check)
26
+
27
+ Exits non-zero if any check failed. Warnings alone do not fail it — most are
28
+ "this works, but not the way you may expect" (an unavailable sandbox, an
29
+ untrusted workspace).
30
+
31
+ Checks: Node version against the package's engines, config file validity, the
32
+ active provider and whether its key resolves and the endpoint answers, workspace
33
+ trust and git state, MCP servers, sandbox availability, persistence/privacy
34
+ settings, discovered skills and hooks, and whether a newer release exists.`;
35
+ const MARKS = { ok: "✓", warn: "!", fail: "✗" };
36
+ function ok(label) {
37
+ return { level: "ok", label };
38
+ }
39
+ function warn(label) {
40
+ return { level: "warn", label };
41
+ }
42
+ function fail(label) {
43
+ return { level: "fail", label };
44
+ }
45
+ /** A path shortened to `~/…` when it lives under the home directory. */
46
+ function tilde(p) {
47
+ const home = os.homedir();
48
+ return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
49
+ }
50
+ function exists(p) {
51
+ try {
52
+ return fs.existsSync(p);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ function runtimeSection() {
59
+ const checks = [];
60
+ const required = pkg.engines?.node;
61
+ const actual = process.versions.node;
62
+ const [major = 0, minor = 0] = actual.split(".").map(Number);
63
+ // engines.node is `>=22.19.0`. Parsed by hand because the only operators
64
+ // this package ever uses are `>=` and a bare version.
65
+ const minimum = required?.replace(/^>=\s*/, "");
66
+ const [reqMajor = 0, reqMinor = 0] = (minimum ?? "").split(".").map(Number);
67
+ const tooOld = Boolean(minimum) && (major < reqMajor || (major === reqMajor && minor < reqMinor));
68
+ checks.push(tooOld
69
+ ? fail(`node ${actual} — this package requires >=${minimum}`)
70
+ : ok(`node ${actual}${minimum ? ` (requires >=${minimum})` : ""}`));
71
+ checks.push(ok(`${process.platform} ${process.arch}`));
72
+ checks.push(ok(`kritya ${VERSION}`));
73
+ return { title: "Runtime", checks };
74
+ }
75
+ async function updateSection(options) {
76
+ if (options.offline) {
77
+ return { title: "Releases", checks: [warn("update check skipped (--offline)")] };
78
+ }
79
+ if (updateCheckDisabled()) {
80
+ return {
81
+ title: "Releases",
82
+ checks: [warn(`update check disabled by ${"KRITYA_NO_UPDATE_CHECK"}`)],
83
+ };
84
+ }
85
+ const status = await checkForUpdate({ fetchImpl: options.fetchImpl, force: true });
86
+ const checks = [];
87
+ if (status.source === "unavailable") {
88
+ checks.push(warn(`could not reach the npm registry — ${status.reason}`));
89
+ return { title: "Releases", checks };
90
+ }
91
+ checks.push(ok(`installed ${status.current}`));
92
+ if (status.outdated) {
93
+ checks.push(warn(`newer version available: ${status.latest} — npm install -g kritya@${status.latest}`));
94
+ }
95
+ else {
96
+ checks.push(ok(`newest published: ${status.latest}`));
97
+ }
98
+ return { title: "Releases", checks };
99
+ }
100
+ function configSection(options) {
101
+ const configDir = options.configDir ?? CONFIG_DIR;
102
+ const configFile = options.configDir ? path.join(options.configDir, "config.json") : CONFIG_FILE;
103
+ const checks = [];
104
+ if (!exists(configFile)) {
105
+ checks.push(warn(`${tilde(configFile)} not found — built-in defaults are in use`));
106
+ }
107
+ else {
108
+ try {
109
+ const raw = fs.readFileSync(configFile, "utf8");
110
+ // The same two guards loadConfig applies. Reported separately because
111
+ // loadConfig swallows both and returns {} — which otherwise looks
112
+ // identical to "no config at all", the classic "my config isn't taking
113
+ // effect" report.
114
+ assertJsonWithinLimits(raw, "config.json");
115
+ JSON.parse(raw);
116
+ checks.push(ok(`${tilde(configFile)} parsed`));
117
+ }
118
+ catch (err) {
119
+ checks.push(fail(`${tilde(configFile)} is unusable: ${err instanceof Error ? err.message : String(err)}`));
120
+ }
121
+ }
122
+ checks.push(exists(configDir)
123
+ ? ok(`${tilde(configDir)} exists`)
124
+ : warn(`${tilde(configDir)} not created yet`));
125
+ return { title: "Configuration", checks };
126
+ }
127
+ function providerSection(config, provider) {
128
+ const checks = [];
129
+ checks.push(ok(`active provider: ${provider.name}`));
130
+ checks.push(ok(`base URL: ${provider.baseUrl}`));
131
+ if (provider.apiKey) {
132
+ const source = provider.apiKeyEnv
133
+ ? `${provider.apiKeyEnv} is set`
134
+ : "a literal key is configured";
135
+ checks.push(ok(`API key resolved (${source})`));
136
+ }
137
+ else if (provider.apiKeyEnv) {
138
+ checks.push(fail(`no API key — ${provider.apiKeyEnv} is unset and no literal key is configured. ` +
139
+ `Set it in the environment, in a .env file, or under providers.${provider.name}.apiKey ` +
140
+ `in ${tilde(CONFIG_FILE)}`));
141
+ }
142
+ else {
143
+ checks.push(fail(`no API key resolved for provider "${provider.name}"`));
144
+ }
145
+ const model = resolveEffectiveModel(provider.name, [config.providers?.[provider.name]?.model, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
146
+ checks.push(ok(`model: ${model} (context ${contextWindowFor(model, config).toLocaleString("en-US")})`));
147
+ // A pinned provider name with no matching entry is a silent misconfiguration:
148
+ // resolveProvider falls back to NVIDIA's URL, so requests go somewhere the
149
+ // user never intended. Built-ins count as known even with no config entry.
150
+ const known = Boolean(BUILTIN_PROVIDERS[provider.name] ?? config.providers?.[provider.name]);
151
+ if (!known) {
152
+ checks.push(warn(`provider "${provider.name}" is not a built-in and has no config.providers entry — ` +
153
+ `falling back to the default base URL`));
154
+ }
155
+ return { title: "Provider", checks };
156
+ }
157
+ /**
158
+ * Ask the provider's `/models` endpoint whether the key works. A 401/403 here
159
+ * is the single most useful thing doctor can report — it is the difference
160
+ * between "your setup is wrong" and "the model is having a bad day".
161
+ *
162
+ * Exported so each response class can be tested without a real provider.
163
+ */
164
+ export async function probeProvider(provider, fetchImpl, timeoutMs = 5_000) {
165
+ const url = `${provider.baseUrl.replace(/\/+$/, "")}/models`;
166
+ try {
167
+ const response = await (fetchImpl ?? fetch)(url, {
168
+ headers: provider.apiKey ? { authorization: `Bearer ${provider.apiKey}` } : {},
169
+ signal: AbortSignal.timeout(timeoutMs),
170
+ });
171
+ if (response.ok)
172
+ return ok(`endpoint reachable — GET ${url} returned ${response.status}`);
173
+ if (response.status === 401 || response.status === 403) {
174
+ return fail(`the endpoint rejected the API key — GET ${url} returned ${response.status}. ` +
175
+ `Check that the key is valid, not expired, and belongs to this provider.`);
176
+ }
177
+ if (response.status === 404) {
178
+ return warn(`endpoint reachable, but GET ${url} returned 404 — not every provider ` +
179
+ `implements /models, so this is not necessarily a problem`);
180
+ }
181
+ return warn(`GET ${url} returned ${response.status}`);
182
+ }
183
+ catch (err) {
184
+ return fail(`could not reach ${url} — ${err instanceof Error ? err.message : String(err)}. ` +
185
+ `Check your network, proxy, and the provider's baseUrl.`);
186
+ }
187
+ }
188
+ function workspaceSection(workspace, config) {
189
+ const checks = [];
190
+ const krityaDir = path.join(workspace, ".kritya");
191
+ checks.push(ok(tilde(workspace)));
192
+ checks.push(isGitRepo(workspace)
193
+ ? ok("git repository")
194
+ : warn("not a git repository — /commit and isolated write subagents are unavailable"));
195
+ // Trust gates the workspace's own settings.json allow rules, hooks, .env and
196
+ // custom commands. Worth stating explicitly: an untrusted workspace looks
197
+ // like "my hooks just don't run".
198
+ const hash = gatedContentHash(workspace);
199
+ if (!hash) {
200
+ checks.push(ok("no trust-gated content present"));
201
+ }
202
+ else if (isTrusted(workspace, hash)) {
203
+ checks.push(ok("workspace trust-gated content is trusted"));
204
+ }
205
+ else {
206
+ checks.push(warn("workspace is NOT trusted — .kritya/settings.json allow rules, hooks, .env and " +
207
+ ".kritya/commands/*.md will be ignored until you accept the prompt"));
208
+ }
209
+ for (const [rel, note] of [
210
+ ["settings.json", "permission rules"],
211
+ [".mcp.json", "project MCP servers"],
212
+ [".env", "workspace environment"],
213
+ ]) {
214
+ const file = rel === ".mcp.json" ? path.join(workspace, rel) : path.join(krityaDir, rel);
215
+ if (exists(file))
216
+ checks.push(ok(`${rel} present (${note})`));
217
+ }
218
+ const commandsDir = path.join(krityaDir, "commands");
219
+ if (exists(commandsDir)) {
220
+ const count = fs.readdirSync(commandsDir).filter((f) => f.endsWith(".md")).length;
221
+ checks.push(ok(`${count} custom command(s) in .kritya/commands`));
222
+ }
223
+ // Surfaced here rather than only on use: a configured MCP server whose env
224
+ // var is missing fails at load with a warning that is easy to miss.
225
+ const servers = {
226
+ ...loadProjectMcpServers(workspace),
227
+ ...config.mcpServers,
228
+ };
229
+ const names = Object.keys(servers);
230
+ if (names.length === 0) {
231
+ checks.push(ok("no MCP servers configured"));
232
+ }
233
+ else {
234
+ checks.push(ok(`${names.length} MCP server(s) configured: ${names.join(", ")}`));
235
+ for (const name of names) {
236
+ const missing = missingVars(servers[name]).filter((v) => process.env[v] === undefined);
237
+ if (missing.length > 0) {
238
+ checks.push(warn(`MCP server "${name}" references unset variable(s): ${missing.join(", ")}`));
239
+ }
240
+ }
241
+ }
242
+ return { title: "Workspace", checks };
243
+ }
244
+ function safetySection(config) {
245
+ const checks = [];
246
+ const mode = config.sandboxExec ?? defaultSandboxMode();
247
+ const available = sandboxAvailable();
248
+ if (mode === "off") {
249
+ checks.push(warn('sandboxExec is "off" — shell commands run unconfined'));
250
+ }
251
+ else if (available) {
252
+ checks.push(ok(`sandboxExec "${mode}" and a sandbox backend is available`));
253
+ }
254
+ else if (mode === "strict") {
255
+ // "strict" fails closed — the command is refused rather than run
256
+ // unprotected, so this is not a "falls back with a warning" situation.
257
+ checks.push(warn(`sandboxExec is "strict" but ${sandboxUnavailableReason()} — commands that would be ` +
258
+ `sandboxed are refused outright rather than run unprotected`));
259
+ }
260
+ else {
261
+ checks.push(warn(`sandboxExec is "${mode}" but ${sandboxUnavailableReason()} — sandboxed commands ` +
262
+ `fall back to unsandboxed execution with a warning`));
263
+ }
264
+ const privacy = privacyModeFor(config);
265
+ checks.push(privacy
266
+ ? warn("privacy mode is ON — transcripts, audit logs and telemetry are not written")
267
+ : ok("privacy mode is off"));
268
+ const audit = config.audit ?? "on";
269
+ const otel = config.otel ?? "off";
270
+ const retention = retentionDaysFor(config);
271
+ checks.push(ok(`audit ${audit}, telemetry ${otel}`));
272
+ checks.push(retention > 0
273
+ ? ok(`retention ${retention} day(s)`)
274
+ : warn(`retention disabled (${retention}) — transcripts and logs are kept forever`));
275
+ return { title: "Safety & persistence", checks };
276
+ }
277
+ function extensionsSection(workspace, config) {
278
+ const checks = [];
279
+ const hash = gatedContentHash(workspace);
280
+ const trusted = !hash || isTrusted(workspace, hash);
281
+ const { loaded, skipped } = scanSkillsDetailed([skillsDir(workspace), userSkillsDir()]);
282
+ checks.push(skipped.length === 0
283
+ ? ok(`${loaded.length} skill(s) discovered`)
284
+ : warn(`${loaded.length} skill(s) discovered, ${skipped.length} skipped — ` +
285
+ skipped.map((s) => `${s.name} (${s.reason})`).join("; ")));
286
+ const hooks = loadHooks(workspace, trusted);
287
+ // HooksConfig is keyed by event, each holding an array — sum across events
288
+ // rather than looking for a `hooks` key that does not exist.
289
+ const hookCount = Object.values(hooks).reduce((total, list) => total + (Array.isArray(list) ? list.length : 0), 0);
290
+ checks.push(hookCount > 0 ? ok(`${hookCount} hook(s) configured`) : ok("no hooks configured"));
291
+ checks.push(ok(`KRITYA.md ${exists(path.join(workspace, "KRITYA.md")) ? "present" : "not present"}`));
292
+ checks.push(ok(`config.mcpServers: ${Object.keys(config.mcpServers ?? {}).length}`));
293
+ return { title: "Skills & hooks", checks };
294
+ }
295
+ /** Every diagnostic, in display order. Exported so it can be tested directly. */
296
+ export async function collectDiagnostics(options = {}) {
297
+ const workspace = path.resolve(options.workspace ?? ".");
298
+ const config = options.config ?? loadConfig();
299
+ const configSec = configSection(options);
300
+ const sections = [runtimeSection(), await updateSection(options), configSec];
301
+ if (!exists(workspace) || !fs.statSync(workspace).isDirectory()) {
302
+ sections.push({
303
+ title: "Workspace",
304
+ checks: [fail(`${workspace} is not a directory`)],
305
+ });
306
+ return sections;
307
+ }
308
+ const resolved = resolveProvider(config);
309
+ const provider = providerSection(config, resolved);
310
+ if (options.offline) {
311
+ provider.checks.push(warn("provider endpoint not probed (--offline)"));
312
+ }
313
+ else if (resolved.name === "switchyard") {
314
+ // Its baseUrl is a placeholder; the real client is a local sidecar that
315
+ // only exists for the lifetime of a session, so there is nothing to probe.
316
+ provider.checks.push(warn("endpoint not probed — switchyard runs a local sidecar, started on launch"));
317
+ }
318
+ else if (resolved.apiKey) {
319
+ provider.checks.push(await probeProvider(resolved, options.fetchImpl));
320
+ }
321
+ sections.push(provider);
322
+ sections.push(workspaceSection(workspace, config));
323
+ sections.push(safetySection(config));
324
+ sections.push(extensionsSection(workspace, config));
325
+ return sections;
326
+ }
327
+ export function summarize(sections, workspace) {
328
+ const checks = sections.flatMap((s) => s.checks);
329
+ return {
330
+ version: VERSION,
331
+ workspace,
332
+ sections,
333
+ failures: checks.filter((c) => c.level === "fail").length,
334
+ warnings: checks.filter((c) => c.level === "warn").length,
335
+ };
336
+ }
337
+ /** Exported for tests: the human-readable rendering, given the collected checks. */
338
+ export function renderReport(report) {
339
+ const lines = [
340
+ `kritya doctor — ${report.version}`,
341
+ `workspace: ${tilde(report.workspace)}`,
342
+ "",
343
+ ];
344
+ for (const section of report.sections) {
345
+ lines.push(section.title);
346
+ for (const check of section.checks)
347
+ lines.push(` ${MARKS[check.level]} ${check.label}`);
348
+ lines.push("");
349
+ }
350
+ if (report.failures > 0) {
351
+ lines.push(`${report.failures} problem(s) found${report.warnings ? `, ${report.warnings} warning(s)` : ""}.`);
352
+ }
353
+ else if (report.warnings > 0) {
354
+ lines.push(`No problems found (${report.warnings} warning(s) — see ! above).`);
355
+ }
356
+ else {
357
+ lines.push("No problems found.");
358
+ }
359
+ return lines.join("\n");
360
+ }
361
+ /** Handles `kritya doctor ...`. Resolves to the process exit code. */
362
+ export async function runDoctorCli(argv, options = {}) {
363
+ if (argv.includes("--help") || argv.includes("-h")) {
364
+ console.log(DOCTOR_USAGE);
365
+ return 0;
366
+ }
367
+ const json = argv.includes("--json");
368
+ const offline = argv.includes("--offline");
369
+ const dirArg = argv.find((a) => !a.startsWith("-"));
370
+ const workspace = path.resolve(options.workspace ?? dirArg ?? ".");
371
+ const sections = await collectDiagnostics({ ...options, workspace, offline });
372
+ const report = summarize(sections, workspace);
373
+ if (json) {
374
+ console.log(JSON.stringify(report, null, 2));
375
+ }
376
+ else {
377
+ console.log(renderReport(report));
378
+ }
379
+ return report.failures > 0 ? 1 : 0;
380
+ }
@@ -161,6 +161,8 @@ export function resolveProvider(config, override) {
161
161
  apiKey = merged.apiKey;
162
162
  if (!apiKey && name === "nvidia")
163
163
  apiKey = process.env.NVIDIA_API_KEY || config.apiKey;
164
+ if (apiKey)
165
+ apiKey = apiKey.trim();
164
166
  return {
165
167
  name,
166
168
  baseUrl,
@@ -1,3 +1,4 @@
1
+ import { writeStderr } from "../stderr.js";
1
2
  /**
2
3
  * Most best-effort persistence (session/audit/telemetry writes, config and
3
4
  * trust-file reads, hook loading, …) deliberately swallows its own errors —
@@ -10,12 +11,7 @@ export function debugLog(context, err) {
10
11
  if ((process.env.KRITYA_DEBUG ?? "").toLowerCase() !== "1" && process.env.KRITYA_DEBUG !== "true")
11
12
  return;
12
13
  const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
13
- try {
14
- process.stderr.write(`[kritya debug] ${context}: ${message}\n`);
15
- }
16
- catch {
17
- // stderr itself failing isn't something debug logging can do anything about
18
- }
14
+ writeStderr(`[kritya debug] ${context}: ${message}\n`);
19
15
  }
20
16
  /**
21
17
  * For best-effort persistence that a user actually needs to know failed
@@ -39,12 +35,7 @@ export function warnUser(context, err) {
39
35
  }
40
36
  warnedContexts.add(context);
41
37
  const message = err instanceof Error ? err.message : String(err);
42
- try {
43
- process.stderr.write(`[kritya] warning: ${context} failed: ${message}\n`);
44
- }
45
- catch {
46
- // stderr itself failing isn't something this can do anything about
47
- }
38
+ writeStderr(`[kritya] warning: ${context} failed: ${message}\n`);
48
39
  debugLog(context, err);
49
40
  }
50
41
  /** Contexts currently warning via warnPersistenceFailure, in the order first seen. */
@@ -45,6 +45,24 @@ export const CURATED_MODELS = [
45
45
  note: "strong agentic tool use",
46
46
  contextWindow: 256_000,
47
47
  },
48
+ {
49
+ id: "z-ai/glm-5.3",
50
+ label: "GLM 5.3",
51
+ note: "strong agentic tool use",
52
+ contextWindow: 1_048_576,
53
+ },
54
+ {
55
+ id: "z-ai/glm-5.3-flash",
56
+ label: "GLM 5.3 Flash",
57
+ note: "fast + cheap",
58
+ contextWindow: 1_048_576,
59
+ },
60
+ {
61
+ id: "deepseek-ai/deepseek-v4.1-flash",
62
+ label: "DeepSeek V4.1 Flash",
63
+ note: "fast + cheap",
64
+ contextWindow: 1_048_576,
65
+ },
48
66
  ];
49
67
  export const DEFAULT_MODEL = CURATED_MODELS[0].id;
50
68
  /**
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { AuditLog } from "./audit/audit.js";
16
16
  import { runAuditCli } from "./audit/cli.js";
17
17
  import { runSkillsCli } from "./agent/skillsCli.js";
18
18
  import { createTracer, cleanupOldTelemetry } from "./telemetry/tracer.js";
19
- import { createMeter } from "./telemetry/metrics.js";
19
+ import { createMeter, RENDER_BOUNDS_MS } from "./telemetry/metrics.js";
20
20
  import { retentionDaysFor } from "./config/retention.js";
21
21
  import { backgroundManager } from "./shell/background.js";
22
22
  import { defaultSandboxMode, sandboxAvailable, sandboxUnavailableReason } from "./shell/sandbox.js";
@@ -42,6 +42,7 @@ import { runHeadless } from "./headless.js";
42
42
  import { installCrashHandlers } from "./crash.js";
43
43
  import { createWorktree, commitWorktree, worktreeDiffStat, removeWorktree, isGitRepo, } from "./agent/worktree.js";
44
44
  import { VERSION } from "./version.js";
45
+ import { updateNoticeForUser } from "./update/check.js";
45
46
  const USAGE = `kritya — a lean, provider-agnostic terminal coding agent
46
47
 
47
48
  Usage: kritya [directory] [options]
@@ -75,6 +76,11 @@ Inspect the local audit log:
75
76
  List and validate skills:
76
77
  kritya skills [dir] [--json] [--validate]
77
78
 
79
+ Check the installation:
80
+ kritya doctor [dir] [--json] [--offline]
81
+ Diagnoses the Node version, config, provider and its API key, workspace
82
+ trust, MCP servers, sandbox, and whether a newer release exists.
83
+
78
84
  Setup:
79
85
  1. Get an API key at https://build.nvidia.com (free credits available)
80
86
  2. export NVIDIA_API_KEY=nvapi-... (Linux/macOS)
@@ -153,14 +159,38 @@ if (process.argv[2] === "audit") {
153
159
  if (process.argv[2] === "skills") {
154
160
  process.exit(runSkillsCli(process.argv.slice(3)));
155
161
  }
162
+ // `kritya doctor` is the third standalone subcommand. It is async (it probes
163
+ // the provider and the registry), so unlike the two above it has to be awaited
164
+ // here — without that, top-level execution would fall through to parseArgs and
165
+ // start a session after the diagnosis had already run. Imported dynamically so
166
+ // `--version`, `--help` and a normal launch don't pay to load its module graph.
167
+ if (process.argv[2] === "doctor") {
168
+ const { runDoctorCli } = await import("./commands/doctor.js");
169
+ process.exit(await runDoctorCli(process.argv.slice(3)));
170
+ }
156
171
  const args = parseArgs(process.argv.slice(2));
157
172
  if (args.help) {
158
173
  console.log(USAGE);
159
174
  process.exit(0);
160
175
  }
161
176
  if (args.version) {
177
+ // The version itself goes to stdout and is printed before anything else, so
178
+ // `kritya --version` stays exactly one parseable line. The staleness notice
179
+ // is a courtesy, not part of that contract, so it is TTY-only (a script or a
180
+ // pipe never pays for it and never sees it), cached for a day, bounded, and
181
+ // written to stderr.
162
182
  console.log(VERSION);
163
- process.exit(0);
183
+ if (process.stdout.isTTY) {
184
+ void updateNoticeForUser()
185
+ .then((notice) => {
186
+ if (notice)
187
+ console.error(notice);
188
+ })
189
+ .finally(() => process.exit(0));
190
+ }
191
+ else {
192
+ process.exit(0);
193
+ }
164
194
  }
165
195
  const workspace = path.resolve(args.dir);
166
196
  if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
@@ -415,6 +445,17 @@ async function main() {
415
445
  sessionMeter.flushAndWait(),
416
446
  new Promise((resolve) => setTimeout(resolve, 2000).unref()),
417
447
  ]);
448
+ // Hand the terminal back before leaving. Ink hides the cursor for the
449
+ // whole time it is rendering and only shows it again from its own
450
+ // teardown — which the process.exit() below skips, leaving the shell's
451
+ // prompt invisible until the user runs `reset`. unmount() is that
452
+ // teardown; it also finishes drawing the frame we are standing on.
453
+ try {
454
+ ui.instance?.unmount();
455
+ }
456
+ catch {
457
+ // Whatever brought us here may have been the UI itself. Exit anyway.
458
+ }
418
459
  cleanup();
419
460
  process.exit(code);
420
461
  })();
@@ -709,12 +750,24 @@ async function main() {
709
750
  // compaction — subagents (read-only or write) never do, even though they
710
751
  // run the same Agent class and can also trigger auto-compaction.
711
752
  agent.autoMemory = true;
753
+ // How long Ink takes to turn the React tree into a frame, per frame. This is
754
+ // the part of drawing a screen that grows with the size of the transcript, so
755
+ // it is the number that says whether the UI is getting sluggish — the
756
+ // terminal write it excludes is bounded by the terminal, not by us.
757
+ // `renderTime` excludes Ink's own throttling, so it is the raw cost of the
758
+ // frame rather than the interval between frames.
759
+ const renderHistogram = sessionMeter.histogram("kritya.render.duration_ms", RENDER_BOUNDS_MS);
712
760
  ui.instance = render(_jsx(App, { agent: agent, workspace: workspace, modelRef: modelRef, providerRef: providerRef, config: config, resumedCount: initialHistory.length, initialTasks: initialTasks, undoStack: undoStack, uiBridge: uiBridge, resumeSessions: resumeSessions.length ? resumeSessions : undefined, customCommands: loadCustomCommands(workspace, trustWorkspace, plugins), mcpToolCount: mcpTools.length, onSwitchClient: (newClient) => {
713
761
  client = newClient;
714
762
  }, onRequestPermissionReady: (fn) => {
715
763
  permissionRef.current = fn;
716
764
  }, onRequestElicitationReady: (fn) => {
717
765
  elicitationRef.current = fn;
718
- }, privacyMode: privacyMode, firstLaunch: firstLaunch }));
766
+ }, privacyMode: privacyMode, firstLaunch: firstLaunch }), {
767
+ onRender: ({ renderTime }) => renderHistogram.record(renderTime),
768
+ // Repaint only the lines that changed instead of the whole frame:
769
+ // less flicker and less CPU while streaming. Ink 8 native.
770
+ incrementalRendering: true,
771
+ });
719
772
  markBannerSeen(workspace);
720
773
  }