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,534 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import path from "node:path";
3
+ import { gitBranch } from "../git/git.js";
4
+ import { listProviders, resolveProvider, saveConfig, saveProviderModel, } from "../config/config.js";
5
+ import { DEFAULT_MODEL, contextWindowFor } from "../config/models.js";
6
+ import { ProviderClient, RetryExhaustedError } from "../provider/client.js";
7
+ import { createSwitchyardClient } from "../provider/switchyardClient.js";
8
+ import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
9
+ import { KillSwitchError } from "../agent/killSwitch.js";
10
+ import { loadProjectState, nextPhase, PHASE_COMMAND, } from "../agent/workflow.js";
11
+ import { tavilySearch } from "../tools/webSearch.js";
12
+ import { killActiveNotice, useKillSwitch } from "./useKillSwitch.js";
13
+ import { useUsageBudget } from "./useUsageBudget.js";
14
+ import { useSessionResume } from "./useSessionResume.js";
15
+ /**
16
+ * Owns everything tied to running agent turns: the transcript, streaming and
17
+ * permission state, usage/cost tracking, and session resume. Input handling
18
+ * (text box, slash-command suggestions, @mention autocomplete) stays in App.
19
+ */
20
+ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, }) {
21
+ const nextId = useRef(0);
22
+ const [items, setItems] = useState(() => {
23
+ const initial = [
24
+ {
25
+ id: nextId.current++,
26
+ kind: "banner",
27
+ subtitle: `${path.basename(workspace)} · ${modelRef.current} · type a request, or /help for commands`,
28
+ },
29
+ ];
30
+ if (resumedCount > 0) {
31
+ const done = initialTasks?.filter((t) => t.status === "done").length ?? 0;
32
+ const taskNote = initialTasks?.length
33
+ ? ` — checklist restored (${done}/${initialTasks.length} done)`
34
+ : "";
35
+ initial.push({
36
+ id: nextId.current++,
37
+ kind: "info",
38
+ text: `Resumed previous session (${resumedCount} messages)${taskNote}.`,
39
+ });
40
+ }
41
+ return initial;
42
+ });
43
+ const [phase, setPhase] = useState(resumeSessions?.length ? "resume" : "input");
44
+ const [stream, setStream] = useState("");
45
+ const [thinking, setThinking] = useState(false);
46
+ const [activity, setActivity] = useState(null);
47
+ /**
48
+ * Which workflow phase the *current turn* is running. Turn-scoped and cleared
49
+ * when the turn ends, unlike `workflow` below — `activity` can't carry this,
50
+ * since retries and permission prompts overwrite it mid-turn. The ref is what
51
+ * the turn's teardown reads: a command sets this and calls runAgent in the
52
+ * same tick, so the state value inside runAgent's closure is still stale.
53
+ */
54
+ const runningPhaseRef = useRef(null);
55
+ const [runningPhase, setRunningPhaseState] = useState(null);
56
+ const setRunningPhase = useCallback((p) => {
57
+ runningPhaseRef.current = p;
58
+ setRunningPhaseState(p);
59
+ }, []);
60
+ /** The active project workflow, for the statusline. Persists between turns. */
61
+ const [workflow, setWorkflow] = useState(() => loadProjectState(workspace));
62
+ const [permission, setPermission] = useState(null);
63
+ const [elicitation, setElicitation] = useState(null);
64
+ const [model, setModel] = useState(modelRef.current);
65
+ const [provider, setProvider] = useState(providerRef.current);
66
+ /** The model that actually served the most recent turn — see Usage.servedModel.
67
+ * Only meaningful behind a router (switchyard), where it can differ from
68
+ * `model` (the route name); cleared on provider/model switch. */
69
+ const [servedModel, setServedModel] = useState(undefined);
70
+ const [tasks, setTasks] = useState(initialTasks ?? []);
71
+ const [branch, setBranch] = useState(() => gitBranch(workspace));
72
+ const [planMode, setPlanMode] = useState(false);
73
+ /** The manual Shift+Tab read-only toggle — independent of the project
74
+ * workflow's own `planMode`. */
75
+ const [dryRunMode, setDryRunMode] = useState(false);
76
+ const [acceptEdits, setAcceptEdits] = useState(false);
77
+ const [autoApprovedCount, setAutoApprovedCount] = useState(0);
78
+ const hasConfirmedAcceptEdits = useRef(false);
79
+ const abortRef = useRef(null);
80
+ /** Tool calls currently running, keyed by call id — more than one when a
81
+ * turn's read-only calls are dispatched in parallel. Rendered as live rows. */
82
+ const [inFlight, setInFlight] = useState([]);
83
+ /**
84
+ * The phase to restore once a permission prompt resolves. Tool-call
85
+ * permission requests always arrive mid-turn (phase "working"), but MCP
86
+ * sampling requests can arrive at any time — including while the user is
87
+ * simply sitting at the input prompt — so restoring to the phase captured
88
+ * when the prompt opened (rather than hardcoding "working") keeps the UI
89
+ * from getting stuck in a false "working" state afterward.
90
+ */
91
+ const phaseRef = useRef(phase);
92
+ useEffect(() => {
93
+ phaseRef.current = phase;
94
+ }, [phase]);
95
+ const previousPhaseRef = useRef("input");
96
+ /** Reusable permission prompt, shared by in-turn tool calls and out-of-turn
97
+ * callers (e.g. MCP sampling) — same UI, same three-way decision. */
98
+ const requestPermission = useCallback((toolName, summary, diff, warning) => new Promise((resolve) => {
99
+ previousPhaseRef.current = phaseRef.current;
100
+ setActivity(null);
101
+ process.stdout.write("\x07"); // ring the terminal bell when input is needed
102
+ setPermission({ toolName, summary, diff, warning, resolve });
103
+ setPhase("permission");
104
+ }), []);
105
+ /** Same out-of-turn reasoning as `requestPermission` — MCP elicitation
106
+ * requests can arrive at any time, not just mid-turn. */
107
+ const requestElicitation = useCallback((message, fields) => new Promise((resolve) => {
108
+ previousPhaseRef.current = phaseRef.current;
109
+ setActivity(null);
110
+ process.stdout.write("\x07");
111
+ setElicitation({ message, fields, resolve });
112
+ setPhase("elicitation");
113
+ }), []);
114
+ const addItem = useCallback((item) => {
115
+ setItems((prev) => {
116
+ // A model that calls the same tool with the same arguments several times
117
+ // in a row still gets a line each — hiding the calls would misreport what
118
+ // it did — but repeating the identical preview under every one buries the
119
+ // rest of the turn. Show the output once.
120
+ const last = prev[prev.length - 1];
121
+ const repeat = item.kind === "tool" &&
122
+ last?.kind === "tool" &&
123
+ last.summary === item.summary &&
124
+ last.output === item.output &&
125
+ !!item.output;
126
+ const next = repeat ? { ...item, output: "" } : item;
127
+ return [...prev, { ...next, id: nextId.current++ }];
128
+ });
129
+ }, []);
130
+ const { usageByModel, totalUsage, totalCost, costReport, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, recordUsage, totalTokensRef, } = useUsageBudget({ agent, config, modelRef, model, addItem, abortRef });
131
+ const { killed, killReason, engageKill, releaseKill } = useKillSwitch({
132
+ agent,
133
+ addItem,
134
+ abortRef,
135
+ permission,
136
+ setPermission,
137
+ setInFlight,
138
+ setActivity,
139
+ setStream,
140
+ setThinking,
141
+ setPhase,
142
+ });
143
+ useEffect(() => {
144
+ uiBridge.onTasksUpdate = setTasks;
145
+ uiBridge.onExternalEdit = (relPath) => {
146
+ addItem({
147
+ kind: "info",
148
+ text: `Detected an external edit to ${relPath} (made outside kritya) — checkpointed for /undo.`,
149
+ });
150
+ };
151
+ }, [uiBridge, addItem]);
152
+ useEffect(() => {
153
+ agent.onAutoApprove = () => setAutoApprovedCount((n) => n + 1);
154
+ }, [agent]);
155
+ const enterAcceptEdits = () => {
156
+ hasConfirmedAcceptEdits.current = true;
157
+ agent.acceptEdits = true;
158
+ setAcceptEdits(true);
159
+ setAutoApprovedCount(0);
160
+ addItem({
161
+ kind: "info",
162
+ text: "Accept-edits mode ON — file writes/edits auto-approve without asking. Destructive shell " +
163
+ "commands (rm -rf, force-push, etc.) still always ask. Shift+Tab again for dry-run mode, once " +
164
+ "more for normal.",
165
+ });
166
+ };
167
+ /** Cycles normal → accept-edits → dry-run → normal. First entry into accept-edits this session pauses on a confirmation instead of switching immediately. */
168
+ const cycleMode = () => {
169
+ if (dryRunMode) {
170
+ agent.dryRunMode = false;
171
+ setDryRunMode(false);
172
+ addItem({ kind: "info", text: "Dry-run mode OFF — the agent can make changes again." });
173
+ return;
174
+ }
175
+ if (acceptEdits) {
176
+ agent.acceptEdits = false;
177
+ setAcceptEdits(false);
178
+ agent.dryRunMode = true;
179
+ setDryRunMode(true);
180
+ addItem({
181
+ kind: "info",
182
+ text: "Dry-run mode ON — read-only. The agent will explore and propose a plan; edits and shell are blocked.",
183
+ });
184
+ return;
185
+ }
186
+ if (!hasConfirmedAcceptEdits.current) {
187
+ setPhase("confirmMode");
188
+ return;
189
+ }
190
+ enterAcceptEdits();
191
+ };
192
+ const onAcceptEditsConfirm = (confirmed) => {
193
+ setPhase("input");
194
+ if (confirmed)
195
+ enterAcceptEdits();
196
+ };
197
+ const setModelEverywhere = (id) => {
198
+ modelRef.current = id;
199
+ setModel(id);
200
+ setServedModel(undefined);
201
+ agent.contextWindow = contextWindowFor(id, config);
202
+ // Persists to providers.<name>.model — scoped to the active provider, so
203
+ // picking a model here never leaks into any other provider's resolution.
204
+ // On switchyard specifically, "model" is a routing directive, not a
205
+ // portable choice: the route id (SWITCHYARD_ROUTE_ID) means "let
206
+ // escalation routing decide," while any other id means "call this model
207
+ // directly, skip routing entirely." Persisting a raw id would silently
208
+ // disable routing on every future launch, so a bypass id is kept
209
+ // session-only there.
210
+ if (providerRef.current !== "switchyard" || id === SWITCHYARD_ROUTE_ID) {
211
+ saveProviderModel(providerRef.current, id);
212
+ }
213
+ addItem({ kind: "info", text: `Model set to ${id}` });
214
+ };
215
+ /**
216
+ * Switch the active provider mid-session — e.g. as a fallback when the
217
+ * current one keeps timing out or rate-limiting. Only the underlying HTTP
218
+ * client changes; `agent.history` (and the persisted session file) are
219
+ * untouched, so the conversation carries over.
220
+ *
221
+ * Session-only by default: closing and relaunching kritya reverts to
222
+ * config.provider. Pass `persist: true` (from `/provider <name> --default`)
223
+ * to make this the new default for future launches too.
224
+ */
225
+ const setProviderEverywhere = (name, persist = false) => {
226
+ const resolved = resolveProvider(config, name);
227
+ if (!resolved.apiKey) {
228
+ addItem({
229
+ kind: "info",
230
+ text: `No API key found for provider "${name}". Set its env var, a .env file, or ` +
231
+ `providers.${name}.apiKey in ~/.kritya/config.json, then try again.`,
232
+ });
233
+ return;
234
+ }
235
+ const sampling = {
236
+ temperature: resolved.temperature,
237
+ topP: resolved.topP,
238
+ maxTokens: resolved.maxTokens,
239
+ };
240
+ const finishSwitch = () => {
241
+ providerRef.current = name;
242
+ setProvider(name);
243
+ setServedModel(undefined);
244
+ if (persist)
245
+ saveConfig({ provider: name });
246
+ const providerDefaultModel = config.providers?.[name]?.model;
247
+ // Filters out SWITCHYARD_ROUTE_ID as a carry-over candidate when `name`
248
+ // isn't switchyard — it's a routing directive, not a real model, so it
249
+ // would 404 against any other provider (see the config.model === config.provider
250
+ // mismatch this was built to fix).
251
+ const targetModel = resolveEffectiveModel(name, [
252
+ providerDefaultModel,
253
+ name === "switchyard" ? SWITCHYARD_ROUTE_ID : undefined,
254
+ modelRef.current,
255
+ ], name === "switchyard" ? SWITCHYARD_ROUTE_ID : DEFAULT_MODEL);
256
+ let note = `Switched provider to ${name} — conversation history kept.`;
257
+ if (targetModel !== modelRef.current) {
258
+ setModelEverywhere(targetModel);
259
+ note = `Switched provider to ${name} (model: ${targetModel}) — conversation history kept.`;
260
+ }
261
+ else {
262
+ note += ` Model "${modelRef.current}" carried over — /model to change it if it isn't offered here.`;
263
+ }
264
+ note += persist
265
+ ? ` Saved as the default for future launches.`
266
+ : ` This session only — /provider ${name} --default to make it the default.`;
267
+ addItem({ kind: "info", text: note });
268
+ };
269
+ if (name === "switchyard") {
270
+ addItem({ kind: "info", text: "Starting local switchyard-server…" });
271
+ createSwitchyardClient(resolved.apiKey, sampling)
272
+ .then((newClient) => {
273
+ agent.setClient(newClient);
274
+ onSwitchClient(newClient);
275
+ finishSwitch();
276
+ })
277
+ .catch((err) => {
278
+ addItem({
279
+ kind: "info",
280
+ text: `Couldn't start switchyard: ${err instanceof Error ? err.message : String(err)}`,
281
+ });
282
+ });
283
+ return;
284
+ }
285
+ const newClient = new ProviderClient(resolved.apiKey, resolved.baseUrl, sampling);
286
+ agent.setClient(newClient);
287
+ onSwitchClient(newClient);
288
+ finishSwitch();
289
+ };
290
+ /** Re-read the workflow pointer from disk after anything that may have moved it. */
291
+ const refreshWorkflow = useCallback(() => {
292
+ setWorkflow(loadProjectState(workspace));
293
+ }, [workspace]);
294
+ /**
295
+ * Say what to run next, once a phase turn has finished.
296
+ *
297
+ * The phase prompts also ask the model to name the next command, but a model
298
+ * paraphrasing its instructions drops it often enough that the handoff can't
299
+ * depend on that — the user is left at a prompt with no idea what comes next.
300
+ * Printing it here makes it deterministic.
301
+ */
302
+ const announceNextPhase = useCallback(() => {
303
+ const ran = runningPhaseRef.current;
304
+ setRunningPhase(null);
305
+ if (!ran)
306
+ return;
307
+ // Prefer the phase on disk: an autonomous turn may have advanced it past
308
+ // whatever the command started.
309
+ const current = loadProjectState(workspace)?.phase ?? ran;
310
+ const next = nextPhase(current);
311
+ addItem({
312
+ kind: "info",
313
+ text: next
314
+ ? `✓ ${current} phase done — next: ${PHASE_COMMAND[next]} (or /project to review where you are)`
315
+ : `✓ ${current} phase done — the workflow is complete. /project clear ends it.`,
316
+ });
317
+ }, [workspace, addItem, setRunningPhase]);
318
+ const runWebSearch = async (query) => {
319
+ if (agent.kill.active) {
320
+ addItem({ kind: "info", text: killActiveNotice(agent.kill.reason) });
321
+ return;
322
+ }
323
+ setPhase("working");
324
+ setActivity(`Web search: ${query}`);
325
+ try {
326
+ const results = await tavilySearch(query, 5);
327
+ addItem({ kind: "info", text: `Web search: ${query}\n\n${results}` });
328
+ agent.addUserNote(`[Results of a web search I ran for "${query}"]:\n${results}`);
329
+ }
330
+ catch (err) {
331
+ addItem({
332
+ kind: "info",
333
+ text: `Web search failed: ${err instanceof Error ? err.message : String(err)}`,
334
+ });
335
+ }
336
+ finally {
337
+ setActivity(null);
338
+ setPhase("input");
339
+ }
340
+ };
341
+ const runAgent = async (text, images = []) => {
342
+ // The kill switch is checked first, ahead of every other stop condition:
343
+ // once it's on, the only thing the user can get back is this line.
344
+ if (agent.kill.active) {
345
+ addItem({ kind: "info", text: killActiveNotice(agent.kill.reason) });
346
+ return;
347
+ }
348
+ if (budgetStopped) {
349
+ addItem({
350
+ kind: "info",
351
+ text: `Token budget reached (${totalTokensRef.current.toLocaleString()} / ` +
352
+ `${tokenBudget.toLocaleString()} tokens) — run /budget reset to clear it, or ` +
353
+ `/budget <number> to raise the cap, before continuing.`,
354
+ });
355
+ return;
356
+ }
357
+ setPhase("working");
358
+ const ac = new AbortController();
359
+ abortRef.current = ac;
360
+ setInFlight([]);
361
+ try {
362
+ await agent.runTurn(text, {
363
+ onTextDelta: (delta) => {
364
+ setThinking(false);
365
+ setStream((prev) => prev + delta);
366
+ },
367
+ onReasoningDelta: () => setThinking(true),
368
+ onAssistantText: (full) => {
369
+ setStream("");
370
+ setThinking(false);
371
+ addItem({ kind: "assistant", text: full });
372
+ },
373
+ onToolStart: (id, name, summary) => {
374
+ setStream("");
375
+ setInFlight((prev) => [...prev, { id, name, summary }]);
376
+ },
377
+ onToolEnd: (id, name, summary, preview, isError, resultSummary) => {
378
+ setInFlight((prev) => prev.filter((t) => t.id !== id));
379
+ if (name !== "update_tasks")
380
+ addItem({
381
+ kind: "tool",
382
+ name,
383
+ summary,
384
+ error: isError,
385
+ output: preview,
386
+ resultSummary,
387
+ });
388
+ },
389
+ onToolProgress: (id, text) => {
390
+ setInFlight((prev) => prev.map((t) => (t.id === id ? { ...t, status: text } : t)));
391
+ },
392
+ requestPermission,
393
+ requestElicitation,
394
+ onRetry: (attempt, status) => {
395
+ // Drop the partial text the failed attempt streamed, or the
396
+ // retried answer would appear twice.
397
+ setStream("");
398
+ setActivity(`Provider error${status ? ` (${status})` : ""} — retrying (attempt ${attempt})…`);
399
+ },
400
+ onUsage: (usage) => {
401
+ if (usage.servedModel)
402
+ setServedModel(usage.servedModel);
403
+ recordUsage(usage);
404
+ },
405
+ }, ac.signal, images);
406
+ }
407
+ catch (err) {
408
+ const isAbort = (err instanceof Error && err.name === "AbortError") ||
409
+ (err instanceof Error && /abort/i.test(err.message));
410
+ let text;
411
+ if (err instanceof KillSwitchError || agent.kill.active) {
412
+ // engageKill already printed the banner; don't follow it with a
413
+ // stack-trace-flavored "Error:" line for the abort it caused.
414
+ text = "Turn stopped by the kill switch.";
415
+ }
416
+ else if (isAbort) {
417
+ text = "Interrupted.";
418
+ }
419
+ else {
420
+ const message = err instanceof Error ? err.message : String(err);
421
+ if (err instanceof RetryExhaustedError) {
422
+ const alternatives = listProviders(config)
423
+ .filter((p) => p.hasKey && p.name !== provider)
424
+ .map((p) => p.name);
425
+ const hint = alternatives.length
426
+ ? ` Provider "${provider}" isn't responding — try /provider ${alternatives[0]}` +
427
+ (alternatives.length > 1
428
+ ? ` (also available: ${alternatives.slice(1).join(", ")})`
429
+ : "") +
430
+ ` to switch without losing this conversation.`
431
+ : ` Provider "${provider}" isn't responding, and no other provider has an API key ` +
432
+ `configured to fall back to — see the "Providers" section of the README.`;
433
+ text = `Error: ${message}${hint}`;
434
+ }
435
+ else {
436
+ text = `Error: ${message}`;
437
+ }
438
+ }
439
+ addItem({ kind: "info", text });
440
+ }
441
+ finally {
442
+ abortRef.current = null;
443
+ setInFlight([]);
444
+ setStream("");
445
+ setThinking(false);
446
+ setActivity(null);
447
+ setPermission(null);
448
+ setPhase("input");
449
+ refreshFileList();
450
+ setBranch(gitBranch(workspace));
451
+ // A phase can advance itself by writing .kritya/project.json, so re-read
452
+ // it rather than trusting the command that started the turn.
453
+ refreshWorkflow();
454
+ announceNextPhase();
455
+ process.stdout.write("\x07"); // bell: the turn is done
456
+ }
457
+ };
458
+ const onPermissionDecision = (decision) => {
459
+ const pending = permission;
460
+ setPermission(null);
461
+ setPhase(previousPhaseRef.current);
462
+ pending?.resolve(decision);
463
+ };
464
+ const onElicitationDecision = (result) => {
465
+ const pending = elicitation;
466
+ setElicitation(null);
467
+ setPhase(previousPhaseRef.current);
468
+ pending?.resolve(result);
469
+ };
470
+ const { onResumeSelect } = useSessionResume({
471
+ agent,
472
+ resumeSessions,
473
+ addItem,
474
+ setPhase,
475
+ setTasks,
476
+ });
477
+ return {
478
+ items,
479
+ addItem,
480
+ phase,
481
+ setPhase,
482
+ stream,
483
+ thinking,
484
+ activity,
485
+ setActivity,
486
+ runningPhase,
487
+ setRunningPhase,
488
+ workflow,
489
+ refreshWorkflow,
490
+ permission,
491
+ elicitation,
492
+ inFlight,
493
+ model,
494
+ provider,
495
+ servedModel,
496
+ usageByModel,
497
+ totalUsage,
498
+ totalCost,
499
+ tasks,
500
+ setTasks,
501
+ ctxPct,
502
+ setCtxPct,
503
+ tokenBudget,
504
+ budgetPct,
505
+ budgetUsed,
506
+ budgetStopped,
507
+ resetBudget,
508
+ setBudgetLimit,
509
+ branch,
510
+ planMode,
511
+ setPlanMode,
512
+ dryRunMode,
513
+ killed,
514
+ killReason,
515
+ engageKill,
516
+ releaseKill,
517
+ acceptEdits,
518
+ setAcceptEdits,
519
+ autoApprovedCount,
520
+ cycleMode,
521
+ onAcceptEditsConfirm,
522
+ abortRef,
523
+ setModelEverywhere,
524
+ setProviderEverywhere,
525
+ costReport,
526
+ runAgent,
527
+ runWebSearch,
528
+ onPermissionDecision,
529
+ onElicitationDecision,
530
+ onResumeSelect,
531
+ requestPermission,
532
+ requestElicitation,
533
+ };
534
+ }
@@ -0,0 +1,65 @@
1
+ import { useState } from "react";
2
+ /** The line shown for anything the user tries while the switch is engaged. */
3
+ export function killActiveNotice(reason) {
4
+ return (`⛔ Kill switch ACTIVE${reason ? ` — ${reason}` : ""}. Nothing will run. ` +
5
+ `Release it with /kill off.`);
6
+ }
7
+ /** Owns the kill-switch UI state: engaging/releasing it and tearing down whatever turn was in flight. */
8
+ export function useKillSwitch({ agent, addItem, abortRef, permission, setPermission, setInFlight, setActivity, setStream, setThinking, setPhase, }) {
9
+ const [killed, setKilled] = useState(agent.kill.active);
10
+ const [killReason, setKillReason] = useState(agent.kill.reason);
11
+ /**
12
+ * Engage the kill switch: abort the in-flight turn, answer any open
13
+ * permission prompt with "no" (otherwise the tool call would hang forever
14
+ * waiting on a promise nobody will resolve), and drop back to the input
15
+ * line. The agent-side gates in agent/loop.ts are what actually enforce it;
16
+ * this is the UI half.
17
+ */
18
+ const engageKill = (reason) => {
19
+ if (!agent.kill.engage(reason))
20
+ return; // already engaged
21
+ setKilled(true);
22
+ setKillReason(agent.kill.reason);
23
+ abortRef.current?.abort();
24
+ permission?.resolve("no");
25
+ setPermission(null);
26
+ setInFlight([]);
27
+ setActivity(null);
28
+ setStream("");
29
+ setThinking(false);
30
+ setPhase("input");
31
+ agent.audit?.logTool({
32
+ tool: "kill-switch",
33
+ summary: `kill switch engaged${agent.kill.reason ? `: ${agent.kill.reason}` : ""}`,
34
+ outcome: "blocked",
35
+ });
36
+ agent.turnSpan?.addEvent("kill_switch.engaged", {
37
+ "kritya.kill_reason": agent.kill.reason ?? "",
38
+ });
39
+ process.stdout.write("\x07"); // bell: something just stopped hard
40
+ addItem({
41
+ kind: "info",
42
+ text: `⛔ KILL SWITCH ENGAGED${agent.kill.reason ? ` — ${agent.kill.reason}` : ""}.\n` +
43
+ `Everything in flight was aborted; no model calls, tools, or subagents will run.\n` +
44
+ `Release it with /kill off when you're ready to continue.`,
45
+ });
46
+ };
47
+ const releaseKill = () => {
48
+ if (!agent.kill.release()) {
49
+ addItem({ kind: "info", text: "The kill switch isn't engaged." });
50
+ return;
51
+ }
52
+ setKilled(false);
53
+ setKillReason(undefined);
54
+ agent.audit?.logTool({
55
+ tool: "kill-switch",
56
+ summary: "kill switch released",
57
+ outcome: "ok",
58
+ });
59
+ addItem({
60
+ kind: "info",
61
+ text: "Kill switch released — the agent can run again. The interrupted turn is not resumed; send a new message.",
62
+ });
63
+ };
64
+ return { killed, killReason, engageKill, releaseKill };
65
+ }
@@ -0,0 +1,39 @@
1
+ import { SessionStore } from "../session/store.js";
2
+ /** Loading a saved session file into the agent's history and replaying its last few turns to the transcript. */
3
+ export function useSessionResume({ agent, resumeSessions, addItem, setPhase, setTasks, }) {
4
+ const agentLoadSession = (file) => {
5
+ const messages = SessionStore.loadFile(file);
6
+ agent.loadHistory(messages);
7
+ const replay = messages.filter((m) => (m.role === "user" || m.role === "assistant") &&
8
+ typeof m.content === "string" &&
9
+ m.content.trim() &&
10
+ !(m.role === "user" && m.content.startsWith("[")));
11
+ for (const m of replay.slice(-6)) {
12
+ addItem({
13
+ kind: m.role === "user" ? "user" : "assistant",
14
+ text: String(m.content),
15
+ });
16
+ }
17
+ return messages.length;
18
+ };
19
+ const onResumeSelect = (file) => {
20
+ setPhase("input");
21
+ if (!file) {
22
+ addItem({ kind: "info", text: "Starting a fresh session." });
23
+ return;
24
+ }
25
+ const meta = resumeSessions?.find((s) => s.file === file);
26
+ const messages = agentLoadSession(file);
27
+ const restoredTasks = SessionStore.loadTasksForSession(file);
28
+ setTasks(restoredTasks);
29
+ const done = restoredTasks.filter((t) => t.status === "done").length;
30
+ const taskNote = restoredTasks.length
31
+ ? ` — checklist restored (${done}/${restoredTasks.length} done)`
32
+ : "";
33
+ addItem({
34
+ kind: "info",
35
+ text: `Resumed session from ${meta?.date ?? "?"} (${messages} messages)${taskNote}.`,
36
+ });
37
+ };
38
+ return { onResumeSelect };
39
+ }