kritya 0.8.2-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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +434 -0
  3. package/dist/agent/budget.js +25 -0
  4. package/dist/agent/compactor.js +78 -0
  5. package/dist/agent/contextWarning.js +6 -0
  6. package/dist/agent/killSwitch.js +111 -0
  7. package/dist/agent/loop.js +543 -0
  8. package/dist/agent/memory.js +145 -0
  9. package/dist/agent/plugins.js +408 -0
  10. package/dist/agent/skills.js +210 -0
  11. package/dist/agent/skillsCli.js +51 -0
  12. package/dist/agent/systemPrompt.js +122 -0
  13. package/dist/agent/tokens.js +55 -0
  14. package/dist/agent/toolExecutor.js +331 -0
  15. package/dist/agent/workflow.js +496 -0
  16. package/dist/agent/worktree.js +116 -0
  17. package/dist/atomicWrite.js +109 -0
  18. package/dist/audit/audit.js +230 -0
  19. package/dist/audit/cli.js +135 -0
  20. package/dist/commands/custom.js +69 -0
  21. package/dist/commands/mcpCommand.js +433 -0
  22. package/dist/commands/pluginsCommand.js +204 -0
  23. package/dist/commands/registry.js +739 -0
  24. package/dist/config/config.js +214 -0
  25. package/dist/config/debug.js +19 -0
  26. package/dist/config/models.js +82 -0
  27. package/dist/config/retention.js +24 -0
  28. package/dist/config/winAcl.js +38 -0
  29. package/dist/crash.js +102 -0
  30. package/dist/engine.js +113 -0
  31. package/dist/git/git.js +46 -0
  32. package/dist/headless.js +274 -0
  33. package/dist/hooks/hooks.js +122 -0
  34. package/dist/index.js +670 -0
  35. package/dist/lsp/client.js +413 -0
  36. package/dist/lsp/manager.js +0 -0
  37. package/dist/lsp/registry.js +62 -0
  38. package/dist/mcp/callback.js +141 -0
  39. package/dist/mcp/client.js +945 -0
  40. package/dist/mcp/login.js +117 -0
  41. package/dist/mcp/oauth.js +345 -0
  42. package/dist/mcp/servers.js +114 -0
  43. package/dist/mcp/spawnWin.js +86 -0
  44. package/dist/mcp/tokens.js +97 -0
  45. package/dist/mcp/transport.js +295 -0
  46. package/dist/net/urlSafety.js +158 -0
  47. package/dist/permissions/danger.js +96 -0
  48. package/dist/permissions/permissions.js +62 -0
  49. package/dist/permissions/rules.js +69 -0
  50. package/dist/plugins/discover.js +108 -0
  51. package/dist/plugins/mcp.js +80 -0
  52. package/dist/provider/client.js +460 -0
  53. package/dist/provider/switchyardClient.js +46 -0
  54. package/dist/provider/switchyardSidecar.js +245 -0
  55. package/dist/provider/textToolCalls.js +140 -0
  56. package/dist/repomap/repoMap.js +168 -0
  57. package/dist/repomap/symbols.js +190 -0
  58. package/dist/session/store.js +328 -0
  59. package/dist/shell/background.js +131 -0
  60. package/dist/shell/sandbox.js +396 -0
  61. package/dist/telemetry/metrics.js +172 -0
  62. package/dist/telemetry/otlp.js +128 -0
  63. package/dist/telemetry/tracer.js +240 -0
  64. package/dist/tools/askUser.js +64 -0
  65. package/dist/tools/bg.js +59 -0
  66. package/dist/tools/common.js +158 -0
  67. package/dist/tools/deepResearch.js +126 -0
  68. package/dist/tools/diff.js +70 -0
  69. package/dist/tools/document/docx.js +44 -0
  70. package/dist/tools/document/pdf.js +190 -0
  71. package/dist/tools/document/pptx.js +148 -0
  72. package/dist/tools/document/types.js +26 -0
  73. package/dist/tools/document/xlsx.js +87 -0
  74. package/dist/tools/document.js +389 -0
  75. package/dist/tools/edit.js +56 -0
  76. package/dist/tools/fetchUrl.js +218 -0
  77. package/dist/tools/fuzzyMatch.js +74 -0
  78. package/dist/tools/glob.js +34 -0
  79. package/dist/tools/grep.js +85 -0
  80. package/dist/tools/ignore.js +28 -0
  81. package/dist/tools/index.js +66 -0
  82. package/dist/tools/ls.js +27 -0
  83. package/dist/tools/lsp.js +239 -0
  84. package/dist/tools/notebook.js +215 -0
  85. package/dist/tools/read.js +32 -0
  86. package/dist/tools/repoMap.js +26 -0
  87. package/dist/tools/secretScan.js +143 -0
  88. package/dist/tools/shell.js +134 -0
  89. package/dist/tools/skills.js +69 -0
  90. package/dist/tools/subagent.js +57 -0
  91. package/dist/tools/tasks.js +43 -0
  92. package/dist/tools/webSearch.js +97 -0
  93. package/dist/tools/write.js +47 -0
  94. package/dist/tools/writeAgent.js +72 -0
  95. package/dist/trust/aiDisclosure.js +45 -0
  96. package/dist/trust/mcpTrust.js +111 -0
  97. package/dist/trust/trust.js +231 -0
  98. package/dist/types.js +1 -0
  99. package/dist/ui/AiDisclosurePrompt.js +23 -0
  100. package/dist/ui/App.js +449 -0
  101. package/dist/ui/Banner.js +71 -0
  102. package/dist/ui/ElicitationPrompt.js +46 -0
  103. package/dist/ui/Markdown.js +148 -0
  104. package/dist/ui/McpTrustPrompt.js +42 -0
  105. package/dist/ui/ModelPicker.js +19 -0
  106. package/dist/ui/PermissionPrompt.js +27 -0
  107. package/dist/ui/SelectList.js +24 -0
  108. package/dist/ui/Spinner.js +12 -0
  109. package/dist/ui/StatusLine.js +11 -0
  110. package/dist/ui/TranscriptItem.js +13 -0
  111. package/dist/ui/TrustPrompt.js +12 -0
  112. package/dist/ui/highlight.js +137 -0
  113. package/dist/ui/inline.js +241 -0
  114. package/dist/ui/mermaid.js +98 -0
  115. package/dist/ui/table.js +148 -0
  116. package/dist/ui/toolOutputPreview.js +34 -0
  117. package/dist/ui/useAgent.js +534 -0
  118. package/dist/ui/useKillSwitch.js +65 -0
  119. package/dist/ui/useSessionResume.js +39 -0
  120. package/dist/ui/useUsageBudget.js +149 -0
  121. package/dist/ui/viewport.js +71 -0
  122. package/dist/undo/undo.js +293 -0
  123. package/dist/version.js +4 -0
  124. package/package.json +122 -0
package/dist/index.js ADDED
@@ -0,0 +1,670 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { render } from "ink";
6
+ import { Agent } from "./agent/loop.js";
7
+ import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
8
+ import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
9
+ import { PermissionManager } from "./permissions/permissions.js";
10
+ import { loadRules } from "./permissions/rules.js";
11
+ import { ProviderClient } from "./provider/client.js";
12
+ import { createSwitchyardClient } from "./provider/switchyardClient.js";
13
+ import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, } from "./provider/switchyardSidecar.js";
14
+ import { SessionStore } from "./session/store.js";
15
+ import { AuditLog } from "./audit/audit.js";
16
+ import { runAuditCli } from "./audit/cli.js";
17
+ import { runSkillsCli } from "./agent/skillsCli.js";
18
+ import { createTracer, cleanupOldTelemetry } from "./telemetry/tracer.js";
19
+ import { createMeter } from "./telemetry/metrics.js";
20
+ import { retentionDaysFor } from "./config/retention.js";
21
+ import { backgroundManager } from "./shell/background.js";
22
+ import { defaultSandboxMode, sandboxAvailable, sandboxUnavailableReason } from "./shell/sandbox.js";
23
+ import { lspManager } from "./lsp/manager.js";
24
+ import { ALL_TOOLS, READONLY_TOOLS } from "./tools/index.js";
25
+ import { UndoStack } from "./undo/undo.js";
26
+ import { App } from "./ui/App.js";
27
+ import { TrustPrompt } from "./ui/TrustPrompt.js";
28
+ import { AiDisclosurePrompt } from "./ui/AiDisclosurePrompt.js";
29
+ import { McpTrustPrompt } from "./ui/McpTrustPrompt.js";
30
+ import { loadHooks, HookRunner } from "./hooks/hooks.js";
31
+ import { loadMcpTools, shutdownMcp, } from "./mcp/client.js";
32
+ import { loadProjectMcpServers, mergeMcpServers } from "./mcp/servers.js";
33
+ import { loadCustomCommands } from "./commands/custom.js";
34
+ import { pluginsDir, scanPlugins, userPluginsDir } from "./plugins/discover.js";
35
+ import { loadPluginMcpServers } from "./plugins/mcp.js";
36
+ import { describeGatedContent, gatedContentHash, isTrusted, saveTrust } from "./trust/trust.js";
37
+ import { isAiDisclosureShown, markAiDisclosureShown } from "./trust/aiDisclosure.js";
38
+ import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
39
+ import { runHeadless } from "./headless.js";
40
+ import { installCrashHandlers } from "./crash.js";
41
+ import { createWorktree, commitWorktree, worktreeDiffStat, removeWorktree, isGitRepo, } from "./agent/worktree.js";
42
+ import { VERSION } from "./version.js";
43
+ const USAGE = `kritya — a lean, provider-agnostic terminal coding agent
44
+
45
+ Usage: kritya [directory] [options]
46
+
47
+ Options:
48
+ -c, --continue resume the most recent session for this directory
49
+ -r, --resume pick a past session for this directory from a list
50
+ -m, --model <id> model ID to use (any model your provider offers)
51
+ -p, --provider <n> provider: nvidia (default), openai, openrouter, groq,
52
+ deepseek, mistral, together, ollama, or a custom one
53
+ -h, --help show this help
54
+ -v, --version show version
55
+
56
+ Headless / CI mode (no terminal UI, exits with 0 on success / 1 on failure):
57
+ --prompt <text> run this one prompt to completion, then exit
58
+ --output <fmt> text (default) or json — a single JSON object on stdout
59
+ with {success, result, error, toolCalls, usage, durationMs, model}
60
+ --allow-all auto-approve tool calls that would otherwise prompt
61
+ (destructive commands are still always denied — there's
62
+ no terminal to confirm them)
63
+ --trust trust the workspace's own .kritya/settings.json allow
64
+ rules, hooks, .env, and custom commands (off by default,
65
+ since CI often checks out untrusted branches/PRs)
66
+ --timeout <seconds> hard wall-clock cap for the whole run (default 1800)
67
+ --non-interactive accepted for compatibility; implied by --prompt
68
+
69
+ Inspect the local audit log:
70
+ kritya audit --list | --verify [file] | --show [file]
71
+
72
+ List and validate skills:
73
+ kritya skills [dir] [--json] [--validate]
74
+
75
+ Setup:
76
+ 1. Get an API key at https://build.nvidia.com (free credits available)
77
+ 2. export NVIDIA_API_KEY=nvapi-... (Linux/macOS)
78
+ setx NVIDIA_API_KEY nvapi-... (Windows)
79
+ 3. cd your-project && kritya .
80
+
81
+ Config file: ~/.kritya/config.json { "apiKey", "model", "customModels": [{"id"}] }`;
82
+ function parseArgs(argv) {
83
+ const args = {
84
+ dir: ".",
85
+ continue: false,
86
+ resume: false,
87
+ model: "",
88
+ provider: "",
89
+ help: false,
90
+ version: false,
91
+ prompt: "",
92
+ output: "text",
93
+ allowAll: false,
94
+ trust: false,
95
+ timeoutSeconds: 1800,
96
+ };
97
+ for (let i = 0; i < argv.length; i++) {
98
+ const a = argv[i];
99
+ if (a === "-c" || a === "--continue")
100
+ args.continue = true;
101
+ else if (a === "-r" || a === "--resume")
102
+ args.resume = true;
103
+ else if (a === "-m" || a === "--model")
104
+ args.model = argv[++i] ?? "";
105
+ else if (a === "-p" || a === "--provider")
106
+ args.provider = argv[++i] ?? "";
107
+ else if (a === "-h" || a === "--help")
108
+ args.help = true;
109
+ else if (a === "-v" || a === "--version")
110
+ args.version = true;
111
+ else if (a === "--prompt")
112
+ args.prompt = argv[++i] ?? "";
113
+ else if (a === "--output") {
114
+ const v = argv[++i] ?? "";
115
+ if (v !== "text" && v !== "json") {
116
+ console.error(`--output must be "text" or "json", got "${v}"`);
117
+ process.exit(1);
118
+ }
119
+ args.output = v;
120
+ }
121
+ else if (a === "--allow-all")
122
+ args.allowAll = true;
123
+ else if (a === "--trust")
124
+ args.trust = true;
125
+ else if (a === "--timeout")
126
+ args.timeoutSeconds = Number(argv[++i]) || args.timeoutSeconds;
127
+ else if (a === "--non-interactive") {
128
+ // implied by --prompt; accepted so scripts can pass it explicitly
129
+ }
130
+ else if (!a.startsWith("-"))
131
+ args.dir = a;
132
+ else {
133
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
134
+ process.exit(1);
135
+ }
136
+ }
137
+ return args;
138
+ }
139
+ // `kritya audit ...` is a standalone inspection subcommand, not a session — it
140
+ // never touches a workspace directory, so it's dispatched before the normal
141
+ // directory-based argv parsing below even looks at it.
142
+ if (process.argv[2] === "audit") {
143
+ process.exit(runAuditCli(process.argv.slice(3)));
144
+ }
145
+ // `kritya skills` is likewise a standalone inspection subcommand, dispatched
146
+ // the same way and for the same reason.
147
+ if (process.argv[2] === "skills") {
148
+ process.exit(runSkillsCli(process.argv.slice(3)));
149
+ }
150
+ const args = parseArgs(process.argv.slice(2));
151
+ if (args.help) {
152
+ console.log(USAGE);
153
+ process.exit(0);
154
+ }
155
+ if (args.version) {
156
+ console.log(VERSION);
157
+ process.exit(0);
158
+ }
159
+ const workspace = path.resolve(args.dir);
160
+ if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) {
161
+ console.error(`Not a directory: ${workspace}`);
162
+ process.exit(1);
163
+ }
164
+ // Headless mode: run one prompt to completion with no terminal UI at all, and
165
+ // no TTY requirement — this is what makes `kritya --prompt "..."` usable from
166
+ // CI/scripts. Everything else in this file (the TTY check, the Ink app) is
167
+ // only for the interactive path, so it's skipped entirely here.
168
+ if (args.prompt) {
169
+ runHeadless({
170
+ dir: args.dir,
171
+ prompt: args.prompt,
172
+ provider: args.provider,
173
+ model: args.model,
174
+ continue: args.continue,
175
+ output: args.output,
176
+ allowAll: args.allowAll,
177
+ trust: args.trust,
178
+ timeoutSeconds: args.timeoutSeconds,
179
+ }).then((code) => process.exit(code));
180
+ }
181
+ else {
182
+ runInteractive();
183
+ }
184
+ function runInteractive() {
185
+ // Only the user's own global .env (~/.kritya/.env) is unconditionally
186
+ // trusted. The workspace's .env can be authored by whoever's repo this is,
187
+ // so loading it is gated behind the workspace trust prompt below (see
188
+ // resolveWorkspaceTrust).
189
+ loadDotEnv([path.join(CONFIG_DIR, ".env")]);
190
+ if (!process.stdin.isTTY) {
191
+ console.error("kritya is interactive and requires a TTY.");
192
+ process.exit(1);
193
+ }
194
+ void main();
195
+ }
196
+ /**
197
+ * Whether the workspace's .kritya/settings.json `allow` rules, `hooks`,
198
+ * `.env` file, and `.kritya/commands/*.md` may take effect. Prompts (via a
199
+ * standalone Ink screen) only when that content actually exists and hasn't
200
+ * already been trusted.
201
+ */
202
+ async function resolveWorkspaceTrust() {
203
+ const hash = gatedContentHash(workspace);
204
+ if (!hash)
205
+ return true;
206
+ if (isTrusted(workspace, hash))
207
+ return true;
208
+ const preview = describeGatedContent(workspace);
209
+ return new Promise((resolve) => {
210
+ const instance = render(_jsx(TrustPrompt, { workspace: workspace, preview: preview, onDecision: (trust) => {
211
+ if (trust)
212
+ saveTrust(workspace, hash);
213
+ instance.unmount();
214
+ resolve(trust);
215
+ } }));
216
+ });
217
+ }
218
+ /**
219
+ * Whether each server named in the workspace's .mcp.json may be loaded.
220
+ * Workspace trust above only gates the file as a whole; this adds a second,
221
+ * per-server gate so a later edit to .mcp.json (e.g. a new server added by a
222
+ * `git pull` or a malicious PR branch) doesn't silently inherit trust from
223
+ * servers the user already reviewed. Already-approved servers (by
224
+ * fingerprint, across all workspaces) pass straight through.
225
+ *
226
+ * Pending servers are decided one at a time: a batch approval makes refusing
227
+ * one server cost you the others, which is how people end up approving things
228
+ * they wouldn't have alone.
229
+ */
230
+ async function resolveMcpServerTrust(projectMcp) {
231
+ if (!projectMcp)
232
+ return undefined;
233
+ const { trusted, pending } = partitionByTrust(projectMcp);
234
+ if (Object.keys(pending).length === 0)
235
+ return trusted;
236
+ return new Promise((resolve) => {
237
+ const instance = render(_jsx(McpTrustPrompt, { servers: pending, onComplete: (approved) => {
238
+ const loaded = { ...trusted };
239
+ for (const name of approved) {
240
+ trustServer(name, serverFingerprint(pending[name]));
241
+ loaded[name] = pending[name];
242
+ }
243
+ instance.unmount();
244
+ resolve(loaded);
245
+ } }));
246
+ });
247
+ }
248
+ /**
249
+ * Shown once per workspace, on the interactive path only. Persisted in
250
+ * ~/.kritya/ai-disclosure.json, keyed by resolved workspace path — see
251
+ * isAiDisclosureShown/markAiDisclosureShown.
252
+ */
253
+ async function showAiDisclosureNotice() {
254
+ return new Promise((resolve) => {
255
+ const instance = render(_jsx(AiDisclosurePrompt, { onDismiss: () => {
256
+ instance.clear();
257
+ instance.unmount();
258
+ resolve();
259
+ } }));
260
+ });
261
+ }
262
+ async function main() {
263
+ const config = loadConfig();
264
+ if (!isAiDisclosureShown(workspace)) {
265
+ await showAiDisclosureNotice();
266
+ markAiDisclosureShown(workspace);
267
+ }
268
+ const trustWorkspace = await resolveWorkspaceTrust();
269
+ if (trustWorkspace) {
270
+ // Only the workspace's own .env — it's part of the trust-gated content.
271
+ // The launch directory's .env is deliberately NOT loaded: it can belong to
272
+ // an unrelated (possibly cloned) repo and is invisible to the trust hash.
273
+ loadDotEnv([path.join(workspace, ".env")]);
274
+ }
275
+ const provider = resolveProvider(config, args.provider || undefined);
276
+ const apiKey = provider.apiKey;
277
+ if (!apiKey) {
278
+ const hint = provider.name === "nvidia"
279
+ ? `Get one at https://build.nvidia.com, then one of:\n` +
280
+ ` put NVIDIA_API_KEY=nvapi-... in a .env file (workspace or ~/.kritya/.env)\n` +
281
+ ` export NVIDIA_API_KEY=nvapi-...\n` +
282
+ ` add "apiKey" to ~/.kritya/config.json`
283
+ : `Set the API key for provider "${provider.name}" via its env var or a .env file,\n` +
284
+ ` or add it under providers.${provider.name}.apiKey in ~/.kritya/config.json`;
285
+ console.error(`No API key found for provider "${provider.name}".\n\n${hint}`);
286
+ process.exit(1);
287
+ }
288
+ const sandboxMode = config.sandboxExec ?? defaultSandboxMode();
289
+ if (sandboxMode !== "off" && !sandboxAvailable()) {
290
+ console.error(`⚠ sandboxExec is "${sandboxMode}" but ${sandboxUnavailableReason()}. ` +
291
+ `Shell commands will run WITHOUT sandbox isolation this session — each one will ` +
292
+ `say so in its output.`);
293
+ }
294
+ const providerDefaultModel = config.providers?.[provider.name]?.model;
295
+ const modelRef = {
296
+ current: resolveEffectiveModel(provider.name, [args.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL),
297
+ };
298
+ const staleModelWarning = staleSwitchyardModelWarning(provider.name, modelRef.current, args.model);
299
+ if (staleModelWarning)
300
+ console.error(staleModelWarning);
301
+ const providerRef = { current: provider.name };
302
+ // Mutable so /provider can swap the active client mid-session (fallback
303
+ // when a provider keeps timing out or rate-limiting — see
304
+ // RetryExhaustedError) without losing the conversation. runReadOnlyAgent /
305
+ // runWriteAgent below read this variable at call time, so subagents spawned
306
+ // after a switch pick up the new provider too.
307
+ const sampling = {
308
+ temperature: provider.temperature,
309
+ topP: provider.topP,
310
+ maxTokens: provider.maxTokens,
311
+ };
312
+ let client = provider.name === "switchyard"
313
+ ? await createSwitchyardClient(apiKey, sampling)
314
+ : new ProviderClient(apiKey, provider.baseUrl, sampling);
315
+ const session = new SessionStore(workspace);
316
+ // Shared by the main agent and every subagent it spawns, so a write
317
+ // subagent's commits and a read-only subagent's tool calls land in the same
318
+ // audit trail and trace tree as the turn that spawned them — an agent that
319
+ // edits the repo should never do so off the record.
320
+ const sessionAudit = AuditLog.forSession(session.id, config.audit);
321
+ const sessionTracer = createTracer(session.id, config.otel);
322
+ const sessionMeter = createMeter(session.id, config.otel);
323
+ const initialHistory = args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
324
+ const initialTasks = args.continue ? SessionStore.loadLatestTasks(workspace) : [];
325
+ session.start(initialHistory);
326
+ const resumeSessions = args.resume ? SessionStore.listSessions(workspace) : [];
327
+ // Filled in once the app is mounted, below; the crash handler needs a way to
328
+ // tear the UI down and is installed before there is a UI to tear down.
329
+ const ui = {};
330
+ const cleanup = () => {
331
+ backgroundManager.killAll();
332
+ lspManager.disposeAll();
333
+ shutdownMcp();
334
+ undoStack.closeAll();
335
+ sessionMeter.flush();
336
+ sessionMeter.stop();
337
+ };
338
+ // Node ignores async work started inside an "exit" handler, so this last-
339
+ // resort fallback path stays on the synchronous, fire-and-forget flush()
340
+ // inside cleanup() — it's best-effort only, not guaranteed to land.
341
+ process.on("exit", cleanup);
342
+ // Same reasoning as the signal handlers below, for the other way the process
343
+ // can die without firing "exit": an error nothing caught. Also hands the
344
+ // terminal back — Ink leaves it in raw mode with the cursor hidden.
345
+ installCrashHandlers({
346
+ cleanup,
347
+ restoreTerminal: true,
348
+ unmountUi: () => ui.instance?.unmount(),
349
+ details: () => {
350
+ const file = session.path;
351
+ return file
352
+ ? ["", `The conversation was saved to ${file}`, `Resume it with: kritya -c ${workspace}`]
353
+ : [];
354
+ },
355
+ });
356
+ // Default signal handling terminates WITHOUT firing "exit", which would
357
+ // orphan background dev servers and MCP children (e.g. when the terminal
358
+ // window closes → SIGHUP). Clean up, then exit with the conventional code.
359
+ // Give the final metrics export a real chance to land first (capped so a
360
+ // hung collector can't stall shutdown) — cleanup()'s own sessionMeter.flush()
361
+ // is fire-and-forget and would otherwise usually be discarded by the
362
+ // process.exit() that follows it.
363
+ for (const [sig, code] of [
364
+ ["SIGTERM", 143],
365
+ ["SIGHUP", 129],
366
+ ]) {
367
+ process.on(sig, () => {
368
+ void (async () => {
369
+ await Promise.race([
370
+ sessionMeter.flushAndWait(),
371
+ new Promise((resolve) => setTimeout(resolve, 2000).unref()),
372
+ ]);
373
+ cleanup();
374
+ process.exit(code);
375
+ })();
376
+ });
377
+ }
378
+ // Best-effort retention: session transcripts, audit logs, and telemetry can
379
+ // all carry secrets that passed through tool output, so none of them
380
+ // accumulate forever by default. retentionDaysFor honors config.json's
381
+ // retentionDays / KRITYA_RETENTION_DAYS; 0 or negative disables this.
382
+ const retentionDays = retentionDaysFor(config);
383
+ SessionStore.cleanupOldSessions(retentionDays);
384
+ AuditLog.cleanupOld(retentionDays);
385
+ cleanupOldTelemetry(retentionDays);
386
+ const undoStack = new UndoStack();
387
+ const uiBridge = {
388
+ onTasksUpdate: (_tasks) => { },
389
+ onExternalEdit: (_relPath) => { },
390
+ };
391
+ undoStack.onExternalChange = (relPath) => uiBridge.onExternalEdit?.(relPath);
392
+ // MCP servers (if any) contribute extra tools; loading is resilient.
393
+ // Project-level .mcp.json is part of the workspace trust gate: it launches
394
+ // processes / contacts endpoints with the user's credentials, so it only
395
+ // takes effect once the workspace is trusted.
396
+ const projectMcp = trustWorkspace ? loadProjectMcpServers(workspace) : undefined;
397
+ const approvedProjectMcp = await resolveMcpServerTrust(projectMcp);
398
+ // Agent Plugins (.kritya/plugins/, ~/.kritya/plugins/). The workspace's own
399
+ // plugins/ folder is part of the same trust gate as .mcp.json above -- a
400
+ // plugin dropped into a cloned repo is just as capable of declaring a
401
+ // server that runs on the user's behalf. User-global plugins are always
402
+ // discovered, same as ~/.kritya/config.json.
403
+ const plugins = scanPlugins(trustWorkspace ? [pluginsDir(workspace), userPluginsDir()] : [userPluginsDir()]);
404
+ const { servers: pluginMcp } = loadPluginMcpServers(plugins);
405
+ const approvedPluginMcp = await resolveMcpServerTrust(Object.keys(pluginMcp).length ? pluginMcp : undefined);
406
+ // Filled in once <App> mounts (below) — sampling requests only ever arrive
407
+ // after that, so the callback can safely read it lazily at call time (same
408
+ // pattern as modelRef/providerRef above).
409
+ const permissionRef = {};
410
+ const samplingApprovedServers = new Set();
411
+ const onSampling = async (server, req) => {
412
+ if (!samplingApprovedServers.has(server)) {
413
+ if (!permissionRef.current)
414
+ return { ok: false, reason: "sampling approval unavailable" };
415
+ const decision = await permissionRef.current(`mcp:sampling:${server}`, `Server "${server}" wants to use your model to generate text.`);
416
+ if (decision === "always")
417
+ samplingApprovedServers.add(server);
418
+ else if (decision !== "yes")
419
+ return { ok: false, reason: "user declined" };
420
+ }
421
+ try {
422
+ const result = await client.complete(modelRef.current, [
423
+ ...(req.systemPrompt ? [{ role: "system", content: req.systemPrompt }] : []),
424
+ ...req.messages.map((m) => ({ role: m.role, content: m.content })),
425
+ ], req.maxTokens);
426
+ return {
427
+ ok: true,
428
+ role: "assistant",
429
+ content: result.text,
430
+ model: result.model,
431
+ stopReason: result.stopReason,
432
+ };
433
+ }
434
+ catch (err) {
435
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
436
+ }
437
+ };
438
+ const elicitationRef = {};
439
+ const onElicitation = async (server, message, fields) => {
440
+ if (!elicitationRef.current)
441
+ return { action: "cancel" };
442
+ return elicitationRef.current(`[MCP: ${server}] ${message}`, fields);
443
+ };
444
+ // Same prompt UI as MCP elicitation, but for the agent's own ask_user tool —
445
+ // no "[MCP: server]" prefix, since the question is the agent's, not a
446
+ // third-party server's.
447
+ const onAskUser = async (message, fields) => {
448
+ if (!elicitationRef.current)
449
+ return { action: "cancel" };
450
+ return elicitationRef.current(message, fields);
451
+ };
452
+ const mcpTools = await loadMcpTools(mergeMcpServers(config.mcpServers, approvedProjectMcp, approvedPluginMcp), { tracer: sessionTracer, audit: sessionAudit, workspace, onSampling, onElicitation });
453
+ const tools = [...ALL_TOOLS, ...mcpTools];
454
+ // Subagents never get spawn_agent/spawn_write_agent themselves — otherwise a
455
+ // subagent could spawn more subagents unboundedly (fork-bomb-style resource
456
+ // exhaustion) with no human in the loop to notice.
457
+ const nonRecursive = (list) => list.filter((t) => t.name !== "spawn_agent" && t.name !== "spawn_write_agent");
458
+ const readOnlySubTools = [...READONLY_TOOLS, ...mcpTools.filter((t) => !t.requiresPermission)];
459
+ const writeSubTools = [
460
+ ...nonRecursive(ALL_TOOLS),
461
+ ...mcpTools.filter((t) => !t.requiresPermission),
462
+ ];
463
+ // Hard caps so a runaway or hung subagent can't stall the session or burn
464
+ // unbounded API/compute: a wall-clock timeout per subagent, and a bound on
465
+ // how many run at once.
466
+ const SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000;
467
+ const SUBAGENT_CONCURRENCY = 3;
468
+ function silentHandlers(onFinalText, requestPermission) {
469
+ return {
470
+ onTextDelta: () => { },
471
+ onReasoningDelta: () => { },
472
+ onAssistantText: onFinalText,
473
+ onToolStart: () => { },
474
+ onToolEnd: () => { },
475
+ requestPermission,
476
+ onUsage: () => { },
477
+ };
478
+ }
479
+ async function runReadOnlyAgent(task, signal) {
480
+ const sub = new Agent(client, () => modelRef.current, readOnlySubTools, { workspace, sandboxMode, trustWorkspace }, new PermissionManager(), new SessionStore(workspace, true), []);
481
+ sub.maxSteps = 15;
482
+ sub.audit = sessionAudit;
483
+ sub.tracer = sessionTracer;
484
+ sub.meter = sessionMeter;
485
+ // Share the parent's kill switch: a subagent with its own would keep
486
+ // running after the user stopped the session.
487
+ sub.kill = agent.kill;
488
+ sub.spanParent = agent.turnSpan;
489
+ sub.spanAttributes = { "kritya.subagent": true, "kritya.subagent_task": task.slice(0, 120) };
490
+ let finalText = "";
491
+ await sub.runTurn(task,
492
+ // read-only tools never require permission, so this is never invoked
493
+ silentHandlers((t) => (finalText = t), async () => "no"), signal);
494
+ return { task, write: false, summary: finalText.trim() || "(subagent returned no findings)" };
495
+ }
496
+ async function runWriteAgent(task, signal) {
497
+ if (!isGitRepo(workspace)) {
498
+ return {
499
+ task,
500
+ write: true,
501
+ summary: "",
502
+ error: "the workspace is not a git repository, so an isolated worktree could not be created",
503
+ };
504
+ }
505
+ const wt = createWorktree(workspace);
506
+ if (!wt) {
507
+ sessionAudit?.logTool({
508
+ tool: "subagent_worktree",
509
+ summary: `worktree creation failed for task: ${task.slice(0, 72)}`,
510
+ outcome: "error",
511
+ });
512
+ return { task, write: true, summary: "", error: "failed to create an isolated git worktree" };
513
+ }
514
+ sessionAudit?.logTool({
515
+ tool: "subagent_worktree",
516
+ summary: `created branch "${wt.branch}" for task: ${task.slice(0, 72)}`,
517
+ outcome: "ok",
518
+ });
519
+ let finalText = "";
520
+ try {
521
+ // Auto-allow ordinary writes/edits/shell (no human is watching this run),
522
+ // but a destructive command still forces `warning` via classifyDanger in
523
+ // the agent loop regardless of the allowlist — fail-safe deny it there,
524
+ // since there's no one to confirm it and letting it run unattended would
525
+ // be unsafe even inside an isolated worktree (it still has real shell/
526
+ // network access).
527
+ const sub = new Agent(client, () => modelRef.current, writeSubTools, { workspace: wt.dir, sandboxMode, trustWorkspace }, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }), new SessionStore(wt.dir, true), []);
528
+ sub.maxSteps = 30;
529
+ sub.audit = sessionAudit;
530
+ sub.tracer = sessionTracer;
531
+ sub.meter = sessionMeter;
532
+ sub.kill = agent.kill; // see runReadOnlyAgent
533
+ sub.spanParent = agent.turnSpan;
534
+ sub.spanAttributes = {
535
+ "kritya.subagent": true,
536
+ "kritya.subagent_write": true,
537
+ "kritya.subagent_task": task.slice(0, 120),
538
+ "kritya.subagent_branch": wt.branch,
539
+ };
540
+ await sub.runTurn(task, silentHandlers((t) => (finalText = t), async (_name, _summary, _diff, warning) => (warning ? "no" : "yes")), signal);
541
+ }
542
+ catch (err) {
543
+ if (!finalText)
544
+ finalText = `(subagent stopped: ${err instanceof Error ? err.message : String(err)})`;
545
+ }
546
+ const commitState = commitWorktree(wt, `kritya subagent: ${task.slice(0, 72)}`);
547
+ sessionAudit?.logTool({
548
+ tool: "subagent_worktree",
549
+ summary: `branch "${wt.branch}": commit ${commitState}`,
550
+ outcome: commitState === "failed" ? "error" : "ok",
551
+ });
552
+ if (commitState === "clean") {
553
+ const cleaned = removeWorktree(workspace, wt, true);
554
+ const summary = finalText.trim() || "(no changes made)";
555
+ // Surface this rather than silently leave an empty orphaned branch: a
556
+ // failed `git branch -D` (e.g. a transient ref lock) shouldn't look
557
+ // identical to a subagent that genuinely made no changes.
558
+ return cleaned
559
+ ? { task, write: true, summary }
560
+ : {
561
+ task,
562
+ write: true,
563
+ summary,
564
+ error: `made no changes, but its empty scratch branch "${wt.branch}" could not be ` +
565
+ `auto-deleted (a transient git lock) — safe to remove manually with ` +
566
+ `\`git branch -D ${wt.branch}\``,
567
+ };
568
+ }
569
+ if (commitState === "failed") {
570
+ // Don't discard the worktree: the subagent's edits are real work, even
571
+ // if a commit hook rejected them. Leave it on disk for manual recovery.
572
+ return {
573
+ task,
574
+ write: true,
575
+ summary: finalText.trim(),
576
+ error: `changes could not be committed (a commit hook may have rejected them) — left uncommitted at ${wt.dir}`,
577
+ };
578
+ }
579
+ const diffstat = worktreeDiffStat(workspace, wt);
580
+ removeWorktree(workspace, wt, false);
581
+ return {
582
+ task,
583
+ write: true,
584
+ branch: wt.branch,
585
+ summary: `${finalText.trim() || "(no summary)"}${diffstat ? `\n\n${diffstat}` : ""}`,
586
+ };
587
+ }
588
+ async function runOneAgent(spec, parentSignal) {
589
+ const controller = new AbortController();
590
+ const onParentAbort = () => controller.abort();
591
+ parentSignal?.addEventListener("abort", onParentAbort);
592
+ const timer = setTimeout(() => controller.abort(), SUBAGENT_TIMEOUT_MS);
593
+ try {
594
+ return spec.write
595
+ ? await runWriteAgent(spec.task, controller.signal)
596
+ : await runReadOnlyAgent(spec.task, controller.signal);
597
+ }
598
+ catch (err) {
599
+ return {
600
+ task: spec.task,
601
+ write: Boolean(spec.write),
602
+ summary: "",
603
+ error: err instanceof Error ? err.message : String(err),
604
+ };
605
+ }
606
+ finally {
607
+ clearTimeout(timer);
608
+ parentSignal?.removeEventListener("abort", onParentAbort);
609
+ }
610
+ }
611
+ // Runs subagents concurrently, capped at SUBAGENT_CONCURRENCY at a time, so
612
+ // a burst of parallel tasks can't exhaust API rate limits or system resources.
613
+ const spawnAgents = async (specs, signal) => {
614
+ // Don't stand up worktrees and API calls for work that the shared kill
615
+ // switch would abort on its first step anyway.
616
+ if (agent.kill.active) {
617
+ return specs.map((s) => ({
618
+ task: s.task,
619
+ write: Boolean(s.write),
620
+ summary: "",
621
+ error: "not started — the kill switch is active",
622
+ }));
623
+ }
624
+ const results = new Array(specs.length);
625
+ let next = 0;
626
+ const workers = Array.from({ length: Math.min(SUBAGENT_CONCURRENCY, specs.length) }, async () => {
627
+ while (next < specs.length) {
628
+ const i = next++;
629
+ results[i] = await runOneAgent(specs[i], signal);
630
+ }
631
+ });
632
+ await Promise.all(workers);
633
+ return results;
634
+ };
635
+ const agent = new Agent(client, () => modelRef.current, tools, {
636
+ workspace,
637
+ sandboxMode,
638
+ trustWorkspace,
639
+ undo: undoStack,
640
+ onTasksUpdate: (t) => {
641
+ uiBridge.onTasksUpdate(t);
642
+ session.saveTasks(t);
643
+ },
644
+ requestElicitation: onAskUser,
645
+ spawnAgents,
646
+ }, new PermissionManager(loadRules(workspace, trustWorkspace)), session, initialHistory);
647
+ agent.contextWindow = contextWindowFor(modelRef.current, config);
648
+ if (config.maxSteps && config.maxSteps > 0)
649
+ agent.maxSteps = config.maxSteps;
650
+ // Not gated on > 0: 0 is a meaningful setting here ("no cap"), unlike maxSteps.
651
+ if (config.toolTimeoutSeconds !== undefined) {
652
+ agent.toolTimeoutMs = config.toolTimeoutSeconds * 1000;
653
+ }
654
+ agent.hooks = new HookRunner(loadHooks(workspace, trustWorkspace), workspace);
655
+ agent.audit = sessionAudit;
656
+ agent.tracer = sessionTracer;
657
+ agent.meter = sessionMeter;
658
+ agent.hooks.tracer = sessionTracer;
659
+ // Only the main interactive agent distills durable facts into KRITYA.md on
660
+ // compaction — subagents (read-only or write) never do, even though they
661
+ // run the same Agent class and can also trigger auto-compaction.
662
+ agent.autoMemory = true;
663
+ 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) => {
664
+ client = newClient;
665
+ }, onRequestPermissionReady: (fn) => {
666
+ permissionRef.current = fn;
667
+ }, onRequestElicitationReady: (fn) => {
668
+ elicitationRef.current = fn;
669
+ } }));
670
+ }