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.
- package/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/agent/budget.js +25 -0
- package/dist/agent/compactor.js +78 -0
- package/dist/agent/contextWarning.js +6 -0
- package/dist/agent/killSwitch.js +111 -0
- package/dist/agent/loop.js +543 -0
- package/dist/agent/memory.js +145 -0
- package/dist/agent/plugins.js +408 -0
- package/dist/agent/skills.js +210 -0
- package/dist/agent/skillsCli.js +51 -0
- package/dist/agent/systemPrompt.js +122 -0
- package/dist/agent/tokens.js +55 -0
- package/dist/agent/toolExecutor.js +331 -0
- package/dist/agent/workflow.js +496 -0
- package/dist/agent/worktree.js +116 -0
- package/dist/atomicWrite.js +109 -0
- package/dist/audit/audit.js +230 -0
- package/dist/audit/cli.js +135 -0
- package/dist/commands/custom.js +69 -0
- package/dist/commands/mcpCommand.js +433 -0
- package/dist/commands/pluginsCommand.js +204 -0
- package/dist/commands/registry.js +739 -0
- package/dist/config/config.js +214 -0
- package/dist/config/debug.js +19 -0
- package/dist/config/models.js +82 -0
- package/dist/config/retention.js +24 -0
- package/dist/config/winAcl.js +38 -0
- package/dist/crash.js +102 -0
- package/dist/engine.js +113 -0
- package/dist/git/git.js +46 -0
- package/dist/headless.js +274 -0
- package/dist/hooks/hooks.js +122 -0
- package/dist/index.js +670 -0
- package/dist/lsp/client.js +413 -0
- package/dist/lsp/manager.js +0 -0
- package/dist/lsp/registry.js +62 -0
- package/dist/mcp/callback.js +141 -0
- package/dist/mcp/client.js +945 -0
- package/dist/mcp/login.js +117 -0
- package/dist/mcp/oauth.js +345 -0
- package/dist/mcp/servers.js +114 -0
- package/dist/mcp/spawnWin.js +86 -0
- package/dist/mcp/tokens.js +97 -0
- package/dist/mcp/transport.js +295 -0
- package/dist/net/urlSafety.js +158 -0
- package/dist/permissions/danger.js +96 -0
- package/dist/permissions/permissions.js +62 -0
- package/dist/permissions/rules.js +69 -0
- package/dist/plugins/discover.js +108 -0
- package/dist/plugins/mcp.js +80 -0
- package/dist/provider/client.js +460 -0
- package/dist/provider/switchyardClient.js +46 -0
- package/dist/provider/switchyardSidecar.js +245 -0
- package/dist/provider/textToolCalls.js +140 -0
- package/dist/repomap/repoMap.js +168 -0
- package/dist/repomap/symbols.js +190 -0
- package/dist/session/store.js +328 -0
- package/dist/shell/background.js +131 -0
- package/dist/shell/sandbox.js +396 -0
- package/dist/telemetry/metrics.js +172 -0
- package/dist/telemetry/otlp.js +128 -0
- package/dist/telemetry/tracer.js +240 -0
- package/dist/tools/askUser.js +64 -0
- package/dist/tools/bg.js +59 -0
- package/dist/tools/common.js +158 -0
- package/dist/tools/deepResearch.js +126 -0
- package/dist/tools/diff.js +70 -0
- package/dist/tools/document/docx.js +44 -0
- package/dist/tools/document/pdf.js +190 -0
- package/dist/tools/document/pptx.js +148 -0
- package/dist/tools/document/types.js +26 -0
- package/dist/tools/document/xlsx.js +87 -0
- package/dist/tools/document.js +389 -0
- package/dist/tools/edit.js +56 -0
- package/dist/tools/fetchUrl.js +218 -0
- package/dist/tools/fuzzyMatch.js +74 -0
- package/dist/tools/glob.js +34 -0
- package/dist/tools/grep.js +85 -0
- package/dist/tools/ignore.js +28 -0
- package/dist/tools/index.js +66 -0
- package/dist/tools/ls.js +27 -0
- package/dist/tools/lsp.js +239 -0
- package/dist/tools/notebook.js +215 -0
- package/dist/tools/read.js +32 -0
- package/dist/tools/repoMap.js +26 -0
- package/dist/tools/secretScan.js +143 -0
- package/dist/tools/shell.js +134 -0
- package/dist/tools/skills.js +69 -0
- package/dist/tools/subagent.js +57 -0
- package/dist/tools/tasks.js +43 -0
- package/dist/tools/webSearch.js +97 -0
- package/dist/tools/write.js +47 -0
- package/dist/tools/writeAgent.js +72 -0
- package/dist/trust/aiDisclosure.js +45 -0
- package/dist/trust/mcpTrust.js +111 -0
- package/dist/trust/trust.js +231 -0
- package/dist/types.js +1 -0
- package/dist/ui/AiDisclosurePrompt.js +23 -0
- package/dist/ui/App.js +449 -0
- package/dist/ui/Banner.js +71 -0
- package/dist/ui/ElicitationPrompt.js +46 -0
- package/dist/ui/Markdown.js +148 -0
- package/dist/ui/McpTrustPrompt.js +42 -0
- package/dist/ui/ModelPicker.js +19 -0
- package/dist/ui/PermissionPrompt.js +27 -0
- package/dist/ui/SelectList.js +24 -0
- package/dist/ui/Spinner.js +12 -0
- package/dist/ui/StatusLine.js +11 -0
- package/dist/ui/TranscriptItem.js +13 -0
- package/dist/ui/TrustPrompt.js +12 -0
- package/dist/ui/highlight.js +137 -0
- package/dist/ui/inline.js +241 -0
- package/dist/ui/mermaid.js +98 -0
- package/dist/ui/table.js +148 -0
- package/dist/ui/toolOutputPreview.js +34 -0
- package/dist/ui/useAgent.js +534 -0
- package/dist/ui/useKillSwitch.js +65 -0
- package/dist/ui/useSessionResume.js +39 -0
- package/dist/ui/useUsageBudget.js +149 -0
- package/dist/ui/viewport.js +71 -0
- package/dist/undo/undo.js +293 -0
- package/dist/version.js +4 -0
- package/package.json +122 -0
package/dist/headless.js
ADDED
|
@@ -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
|
+
}
|