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
|
@@ -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
|
+
}
|