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
@@ -0,0 +1,274 @@
1
+ import path from "node:path";
2
+ import { Agent } from "./agent/loop.js";
3
+ import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
4
+ import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
5
+ import { PermissionManager } from "./permissions/permissions.js";
6
+ import { loadRules } from "./permissions/rules.js";
7
+ import { ProviderClient, RetryExhaustedError } from "./provider/client.js";
8
+ import { createSwitchyardClient } from "./provider/switchyardClient.js";
9
+ import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, stopSwitchyardSidecar, } from "./provider/switchyardSidecar.js";
10
+ import { SessionStore } from "./session/store.js";
11
+ import { AuditLog } from "./audit/audit.js";
12
+ import { createTracer, telemetryFileFor, cleanupOldTelemetry } from "./telemetry/tracer.js";
13
+ import { createMeter } from "./telemetry/metrics.js";
14
+ import { retentionDaysFor } from "./config/retention.js";
15
+ import { backgroundManager } from "./shell/background.js";
16
+ import { lspManager } from "./lsp/manager.js";
17
+ import { ALL_TOOLS } from "./tools/index.js";
18
+ import { loadMcpTools, shutdownMcp } from "./mcp/client.js";
19
+ import { loadProjectMcpServers, mergeMcpServers } from "./mcp/servers.js";
20
+ import { pluginsDir, scanPlugins, userPluginsDir } from "./plugins/discover.js";
21
+ import { loadPluginMcpServers } from "./plugins/mcp.js";
22
+ import { loadHooks, HookRunner } from "./hooks/hooks.js";
23
+ import { gatedContentHash, isTrusted } from "./trust/trust.js";
24
+ import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
25
+ import { installCrashHandlers } from "./crash.js";
26
+ import { defaultSandboxMode } from "./shell/sandbox.js";
27
+ function emit(args, r) {
28
+ if (args.output === "json") {
29
+ console.log(JSON.stringify(r));
30
+ return;
31
+ }
32
+ if (r.result)
33
+ console.log(r.result);
34
+ if (r.error)
35
+ console.error(`Error: ${r.error}`);
36
+ }
37
+ /**
38
+ * Headless has no terminal to prompt for new MCP server approvals, so a
39
+ * server only loads if it was already approved in a past interactive
40
+ * session, unless --trust opts in (same escape hatch as workspace trust).
41
+ * `source` names where these servers came from, for the skip message.
42
+ */
43
+ function approveHeadlessMcp(servers, trust, source) {
44
+ if (!servers)
45
+ return undefined;
46
+ const { trusted, pending } = partitionByTrust(servers);
47
+ if (Object.keys(pending).length === 0)
48
+ return trusted;
49
+ if (trust) {
50
+ for (const [name, cfg] of Object.entries(pending))
51
+ trustServer(name, serverFingerprint(cfg));
52
+ return { ...trusted, ...pending };
53
+ }
54
+ process.stderr.write(`kritya: skipping unapproved MCP server(s) from ${source}: ${Object.keys(pending).join(", ")} ` +
55
+ `(approve them once interactively, or pass --trust)\n`);
56
+ return trusted;
57
+ }
58
+ /**
59
+ * Runs a single prompt to completion with no terminal UI at all — for
60
+ * scripting, CI pipelines, and GitHub Actions (`kritya --prompt "..." --output json`).
61
+ * Returns the process exit code (0 success, 1 failure) rather than exiting
62
+ * itself, so the caller can flush stdio first.
63
+ */
64
+ export async function runHeadless(args) {
65
+ const startedAt = Date.now();
66
+ const workspace = path.resolve(args.dir);
67
+ // Only the user's own global .env is unconditionally trusted; see below.
68
+ loadDotEnv([path.join(CONFIG_DIR, ".env")]);
69
+ // There's no terminal to show the interactive trust prompt, so headless
70
+ // mode never blocks on it. The workspace's gated content (its own allow
71
+ // rules, hooks, .env, and custom commands) only takes effect if it was
72
+ // already trusted in a prior interactive session, or --trust opts in
73
+ // explicitly — never silently, since CI checking out arbitrary PR branches
74
+ // is exactly the scenario that trust gate exists to protect against.
75
+ const hash = gatedContentHash(workspace);
76
+ const trustWorkspace = args.trust || !hash || isTrusted(workspace, hash);
77
+ if (trustWorkspace)
78
+ loadDotEnv([path.join(workspace, ".env")]);
79
+ const config = loadConfig();
80
+ const provider = resolveProvider(config, args.provider || undefined);
81
+ if (!provider.apiKey) {
82
+ return finish(args, startedAt, {
83
+ success: false,
84
+ result: "",
85
+ error: `No API key found for provider "${provider.name}". Set it via env var, a .env file, or ~/.kritya/config.json.`,
86
+ toolCalls: [],
87
+ usage: { promptTokens: 0, completionTokens: 0, cachedPromptTokens: 0, estimated: false },
88
+ durationMs: 0,
89
+ });
90
+ }
91
+ const providerDefaultModel = config.providers?.[provider.name]?.model;
92
+ const model = resolveEffectiveModel(provider.name, [args.model, providerDefaultModel, legacyGlobalModel(config, provider.name)], provider.name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
93
+ const staleModelWarning = staleSwitchyardModelWarning(provider.name, model, args.model);
94
+ if (staleModelWarning)
95
+ process.stderr.write(`${staleModelWarning}\n`);
96
+ const sampling = {
97
+ temperature: provider.temperature,
98
+ topP: provider.topP,
99
+ maxTokens: provider.maxTokens,
100
+ };
101
+ const client = provider.name === "switchyard"
102
+ ? await createSwitchyardClient(provider.apiKey, sampling)
103
+ : new ProviderClient(provider.apiKey, provider.baseUrl, sampling);
104
+ const session = new SessionStore(workspace);
105
+ const initialHistory = args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
106
+ session.start(initialHistory);
107
+ const sessionAudit = AuditLog.forSession(session.id, config.audit);
108
+ const sessionTracer = createTracer(session.id, config.otel);
109
+ const sessionMeter = createMeter(session.id, config.otel);
110
+ // A crash here orphans MCP children and background processes onto a CI
111
+ // runner, where nothing will ever reap them. No terminal to restore. The
112
+ // crash path is fire-and-forget best-effort by Node's own constraints (a
113
+ // crash handler can't reliably await async work), so this uses the
114
+ // synchronous flush() rather than flushAndWait().
115
+ installCrashHandlers({
116
+ cleanup: () => {
117
+ backgroundManager.killAll();
118
+ lspManager.disposeAll();
119
+ shutdownMcp();
120
+ sessionMeter.flush();
121
+ sessionMeter.stop();
122
+ },
123
+ details: () => (session.path ? ["", `Transcript: ${session.path}`] : []),
124
+ });
125
+ // Same best-effort retention as the interactive path — headless/CI runs are
126
+ // often the only way kritya ever runs for a given user, so this can't skip
127
+ // pruning old transcripts/audit logs/telemetry just because there's no TTY.
128
+ const retentionDays = retentionDaysFor(config);
129
+ SessionStore.cleanupOldSessions(retentionDays);
130
+ AuditLog.cleanupOld(retentionDays);
131
+ cleanupOldTelemetry(retentionDays);
132
+ // Subagents (spawn_agent/spawn_write_agent) aren't wired up here — they
133
+ // need per-call worktree/concurrency plumbing that isn't worth duplicating
134
+ // for a single headless prompt. If the model tries to use one, the tool
135
+ // itself reports "not available in this session", same as any context
136
+ // that doesn't wire spawnAgents.
137
+ // .mcp.json is trust-gated for the same reason hooks and .env are: it runs
138
+ // processes / contacts endpoints on the user's behalf the moment we load it.
139
+ const projectMcp = trustWorkspace ? loadProjectMcpServers(workspace) : undefined;
140
+ // On top of that, each individual server needs its own prior approval (see
141
+ // trust/mcpTrust.ts) — approveHeadlessMcp below.
142
+ const approvedProjectMcp = approveHeadlessMcp(projectMcp, args.trust, ".mcp.json");
143
+ // Agent Plugins: the workspace's own plugins/ folder is part of the same
144
+ // trust gate as .mcp.json above; user-global plugins are always
145
+ // discovered, same as ~/.kritya/config.json. Each plugin-declared server
146
+ // still needs its own prior approval, same as a project server.
147
+ const plugins = scanPlugins(trustWorkspace ? [pluginsDir(workspace), userPluginsDir()] : [userPluginsDir()]);
148
+ const { servers: pluginMcp } = loadPluginMcpServers(plugins);
149
+ const approvedPluginMcp = approveHeadlessMcp(Object.keys(pluginMcp).length ? pluginMcp : undefined, args.trust, "a plugin's mcp.json");
150
+ // Headless has no UI to ask the user anything, so sampling always fails
151
+ // closed rather than silently granting a server free use of the model.
152
+ const onSampling = async () => ({
153
+ ok: false,
154
+ reason: "sampling requires interactive mode (kritya without --headless)",
155
+ });
156
+ const onElicitation = async () => ({ action: "cancel" });
157
+ const mcpTools = await loadMcpTools(mergeMcpServers(config.mcpServers, approvedProjectMcp, approvedPluginMcp), { tracer: sessionTracer, audit: sessionAudit, workspace, onSampling, onElicitation });
158
+ const tools = [...ALL_TOOLS, ...mcpTools];
159
+ const permissions = new PermissionManager(loadRules(workspace, trustWorkspace));
160
+ const agent = new Agent(client, () => model, tools,
161
+ // No requestElicitation here — no UI to ask through. Leaving it undefined
162
+ // (rather than an always-cancel stub) lets ask_user report itself as
163
+ // unavailable, same message a subagent gets, instead of "the user
164
+ // declined" for a question no one was actually asked.
165
+ { workspace, sandboxMode: config.sandboxExec ?? defaultSandboxMode(), trustWorkspace }, permissions, session, initialHistory);
166
+ agent.contextWindow = contextWindowFor(model, config);
167
+ if (config.maxSteps && config.maxSteps > 0)
168
+ agent.maxSteps = config.maxSteps;
169
+ if (config.toolTimeoutSeconds !== undefined) {
170
+ agent.toolTimeoutMs = config.toolTimeoutSeconds * 1000;
171
+ }
172
+ agent.hooks = new HookRunner(loadHooks(workspace, trustWorkspace), workspace);
173
+ agent.audit = sessionAudit;
174
+ agent.tracer = sessionTracer;
175
+ agent.meter = sessionMeter;
176
+ agent.hooks.tracer = sessionTracer;
177
+ const toolCalls = [];
178
+ let usage = { promptTokens: 0, completionTokens: 0, cachedPromptTokens: 0, estimated: false };
179
+ let finalText = "";
180
+ const controller = new AbortController();
181
+ const timer = setTimeout(() => controller.abort(), Math.max(1, args.timeoutSeconds) * 1000);
182
+ timer.unref();
183
+ const handlers = {
184
+ onTextDelta: () => { },
185
+ onReasoningDelta: () => { },
186
+ onAssistantText: (text) => {
187
+ finalText = text;
188
+ },
189
+ onToolStart: () => { },
190
+ onToolEnd: (_id, name, summary, _preview, isError) => {
191
+ toolCalls.push({ name, summary, error: isError });
192
+ },
193
+ // No terminal to ask. A destructive command (classifyDanger sets
194
+ // `warning`) is always denied, no matter what — there's no one to
195
+ // confirm it and letting it run unattended would be unsafe. Anything
196
+ // else follows --allow-all, or falls through to settings.json allow
197
+ // rules (which PermissionManager already applied before this is called).
198
+ requestPermission: async (_name, _summary, _diff, warning) => {
199
+ if (warning)
200
+ return "no";
201
+ return args.allowAll ? "yes" : "no";
202
+ },
203
+ onUsage: (u) => {
204
+ usage = {
205
+ promptTokens: usage.promptTokens + u.promptTokens,
206
+ completionTokens: usage.completionTokens + u.completionTokens,
207
+ cachedPromptTokens: usage.cachedPromptTokens + (u.cachedPromptTokens ?? 0),
208
+ estimated: usage.estimated || Boolean(u.estimated),
209
+ };
210
+ },
211
+ };
212
+ let success = true;
213
+ let errorMessage;
214
+ try {
215
+ await agent.runTurn(args.prompt, handlers, controller.signal);
216
+ }
217
+ catch (err) {
218
+ success = false;
219
+ const isTimeout = controller.signal.aborted;
220
+ if (isTimeout) {
221
+ errorMessage = `Timed out after ${args.timeoutSeconds}s (--timeout to raise it)`;
222
+ }
223
+ else if (err instanceof RetryExhaustedError) {
224
+ const alternatives = listProviders(config)
225
+ .filter((p) => p.hasKey && p.name !== provider.name)
226
+ .map((p) => p.name);
227
+ const hint = alternatives.length
228
+ ? ` Retry with --provider ${alternatives[0]}` +
229
+ (alternatives.length > 1
230
+ ? ` (also configured: ${alternatives.slice(1).join(", ")})`
231
+ : "") +
232
+ `.`
233
+ : " No other provider has an API key configured to fall back to.";
234
+ errorMessage = `${err.message}.${hint}`;
235
+ }
236
+ else {
237
+ errorMessage = err instanceof Error ? err.message : String(err);
238
+ }
239
+ }
240
+ finally {
241
+ clearTimeout(timer);
242
+ backgroundManager.killAll();
243
+ lspManager.disposeAll();
244
+ shutdownMcp();
245
+ if (provider.name === "switchyard")
246
+ stopSwitchyardSidecar();
247
+ }
248
+ // Normal (non-crash) shutdown: give the final metrics export a real chance
249
+ // to land, capped so a hung collector can't stall the exit code the caller
250
+ // is waiting on.
251
+ await Promise.race([
252
+ sessionMeter.flushAndWait(),
253
+ new Promise((resolve) => setTimeout(resolve, 2000).unref()),
254
+ ]);
255
+ sessionMeter.stop();
256
+ return finish(args, startedAt, {
257
+ success,
258
+ result: finalText,
259
+ error: errorMessage,
260
+ toolCalls,
261
+ usage,
262
+ durationMs: Date.now() - startedAt,
263
+ model,
264
+ sessionId: session.id,
265
+ traceId: agent.lastTraceId,
266
+ auditFile: agent.audit?.path,
267
+ telemetryFile: telemetryFileFor(session.id),
268
+ });
269
+ }
270
+ function finish(args, startedAt, r) {
271
+ const result = { ...r, durationMs: r.durationMs ?? Date.now() - startedAt };
272
+ emit(args, result);
273
+ return result.success ? 0 : 1;
274
+ }
@@ -0,0 +1,122 @@
1
+ import { exec } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { CONFIG_DIR, scrubbedShellEnv } from "../config/config.js";
5
+ import { safeCompileRegex } from "../tools/common.js";
6
+ import { NOOP_TRACER } from "../telemetry/tracer.js";
7
+ import { debugLog } from "../config/debug.js";
8
+ const HOOK_TIMEOUT_MS = 30_000;
9
+ export function loadHooks(workspace, trustWorkspace = true) {
10
+ const merged = {};
11
+ const files = [path.join(CONFIG_DIR, "settings.json")];
12
+ if (trustWorkspace)
13
+ files.push(path.join(workspace, ".kritya", "settings.json"));
14
+ for (const file of files) {
15
+ try {
16
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
17
+ if (!parsed.hooks)
18
+ continue;
19
+ for (const event of ["preToolUse", "postToolUse", "stop"]) {
20
+ const defs = parsed.hooks[event];
21
+ if (Array.isArray(defs))
22
+ merged[event] = [...(merged[event] ?? []), ...defs];
23
+ }
24
+ }
25
+ catch (err) {
26
+ // A missing settings.json is normal; a malformed one silently means
27
+ // "no hooks run", worth being able to see.
28
+ debugLog(`loadHooks(${file})`, err);
29
+ }
30
+ }
31
+ return merged;
32
+ }
33
+ /** Run one hook command without blocking the event loop (the Ink UI stays live). */
34
+ function execHook(command, cwd, env) {
35
+ return new Promise((resolve) => {
36
+ exec(command, { cwd, env, timeout: HOOK_TIMEOUT_MS, windowsHide: true, encoding: "utf8" }, (error, stdout, stderr) => {
37
+ if (error) {
38
+ const output = (stderr || stdout || error.message || "hook failed").toString().trim();
39
+ resolve({ ok: false, output });
40
+ }
41
+ else {
42
+ resolve({ ok: true, output: (stdout ?? "").trim() });
43
+ }
44
+ });
45
+ });
46
+ }
47
+ export class HookRunner {
48
+ hooks;
49
+ workspace;
50
+ /** OpenTelemetry-shaped tracer, one span per hook execution. No-op unless set by the caller. */
51
+ tracer = NOOP_TRACER;
52
+ constructor(hooks, workspace) {
53
+ this.hooks = hooks;
54
+ this.workspace = workspace;
55
+ }
56
+ has(event) {
57
+ return (this.hooks[event]?.length ?? 0) > 0;
58
+ }
59
+ /** Run tool-related hooks. For preToolUse, `blocked` is true if a blocking hook failed. */
60
+ async runToolHooks(event, toolName, args, parent) {
61
+ const defs = this.hooks[event] ?? [];
62
+ // scrubbedShellEnv: hooks are arbitrary commands and must not silently
63
+ // inherit provider API keys from kritya's own environment.
64
+ const env = {
65
+ ...scrubbedShellEnv(),
66
+ KRITYA_TOOL_NAME: toolName,
67
+ KRITYA_TOOL_PATH: String(args.path ?? ""),
68
+ KRITYA_TOOL_COMMAND: String(args.command ?? ""),
69
+ KRITYA_TOOL_ARGS: safeJson(args),
70
+ };
71
+ const outputs = [];
72
+ for (const def of defs) {
73
+ if (def.match) {
74
+ let matches;
75
+ try {
76
+ matches = safeCompileRegex(def.match).test(toolName);
77
+ }
78
+ catch {
79
+ continue; // malformed or unsafe pattern in settings.json — skip this hook
80
+ }
81
+ if (!matches)
82
+ continue;
83
+ }
84
+ const span = this.tracer.startSpan(`hook.${event}`, {
85
+ parent,
86
+ attributes: { "kritya.hook_command": def.command, "kritya.hook_tool": toolName },
87
+ });
88
+ const { ok, output } = await execHook(def.command, this.workspace, env);
89
+ if (output)
90
+ outputs.push(output);
91
+ if (!ok && event === "preToolUse" && def.blocking) {
92
+ span.setStatus("ERROR", "blocked").end();
93
+ return {
94
+ blocked: true,
95
+ blockedBy: def.command,
96
+ output: `A preToolUse hook (\`${def.command}\`) blocked this ${toolName} call:\n${output}`,
97
+ };
98
+ }
99
+ span.setStatus(ok ? "OK" : "ERROR", ok ? undefined : output.slice(0, 500)).end();
100
+ }
101
+ return { blocked: false, output: outputs.join("\n") };
102
+ }
103
+ async runStop(parent) {
104
+ for (const def of this.hooks.stop ?? []) {
105
+ const span = this.tracer.startSpan("hook.stop", {
106
+ parent,
107
+ attributes: { "kritya.hook_command": def.command },
108
+ });
109
+ // stop hooks are best-effort; failures are ignored beyond the span.
110
+ const { ok, output } = await execHook(def.command, this.workspace, scrubbedShellEnv());
111
+ span.setStatus(ok ? "OK" : "ERROR", ok ? undefined : output.slice(0, 500)).end();
112
+ }
113
+ }
114
+ }
115
+ function safeJson(v) {
116
+ try {
117
+ return JSON.stringify(v);
118
+ }
119
+ catch {
120
+ return "{}";
121
+ }
122
+ }