kritya 0.8.23-beta → 0.8.25-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/README.md +12 -6
- package/dist/agent/loop.js +11 -0
- package/dist/agent/toolExecutor.js +16 -2
- package/dist/audit/audit.js +2 -2
- package/dist/commands/registry.js +99 -30
- package/dist/config/config.js +22 -0
- package/dist/config/debug.js +33 -0
- package/dist/config/models.js +10 -0
- package/dist/engine.js +10 -5
- package/dist/headless.js +15 -8
- package/dist/index.js +49 -10
- package/dist/provider/client.js +19 -0
- package/dist/provider/switchyardClient.js +1 -0
- package/dist/session/store.js +4 -4
- package/dist/telemetry/metrics.js +7 -4
- package/dist/telemetry/otlp.js +4 -4
- package/dist/telemetry/tracer.js +2 -2
- package/dist/trust/bannerSeen.js +36 -0
- package/dist/ui/ApiKeySetupPrompt.js +22 -0
- package/dist/ui/App.js +59 -7
- package/dist/ui/Banner.js +4 -1
- package/dist/ui/StatusLine.js +16 -8
- package/dist/ui/TranscriptItem.js +1 -1
- package/dist/ui/useAgent.js +43 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,7 +50,8 @@ review → fix` for building something new end-to-end
|
|
|
50
50
|
state
|
|
51
51
|
- **Office docs & notebooks** — read/write Word, Excel, PowerPoint, PDF,
|
|
52
52
|
Jupyter
|
|
53
|
-
- **Headless/CI mode** — scriptable, no-TTY runs for automation
|
|
53
|
+
- **Headless/CI mode** — scriptable, no-TTY runs for automation
|
|
54
|
+
- **Privacy mode** — `--privacy`, `KRITYA_PRIVACY=1`, or `"privacyMode": true` disables transcript, audit, and telemetry persistence
|
|
54
55
|
|
|
55
56
|
## Setup
|
|
56
57
|
|
|
@@ -64,6 +65,9 @@ Requires Node.js >=22.
|
|
|
64
65
|
- Linux/macOS: `export NVIDIA_API_KEY=nvapi-...`
|
|
65
66
|
- Windows: `setx NVIDIA_API_KEY nvapi-...` (then open a new terminal)
|
|
66
67
|
|
|
68
|
+
Skip this and kritya will prompt for the key on first launch (interactive
|
|
69
|
+
mode only) and save it to `~/.kritya/.env` for you.
|
|
70
|
+
|
|
67
71
|
Optional: add `TAVILY_API_KEY` the same way to enable the agent's
|
|
68
72
|
web-search tool (`/web-search`) — get one at [tavily.com](https://tavily.com).
|
|
69
73
|
|
|
@@ -158,6 +162,7 @@ In-session commands (type `/` to see them with autocomplete; letters filter the
|
|
|
158
162
|
| `/cost` | token usage and estimated $ (see Pricing below) |
|
|
159
163
|
| `/audit` | show this session's permission decisions and verify the audit log's chain ([more](docs/CONFIGURATION.md#audit-log--telemetry)) |
|
|
160
164
|
| `/budget` | show session token budget; `/budget reset` or `/budget <number>` |
|
|
165
|
+
| `/status` | show context/budget/token/task detail left off the always-visible status line |
|
|
161
166
|
| `/kill` | emergency stop: `/kill [reason]` halts everything; `/kill off` releases |
|
|
162
167
|
| `/help` | command list |
|
|
163
168
|
| `/exit` | quit |
|
|
@@ -281,8 +286,8 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
|
281
286
|
message is queued and absorbed before its next step (no need to interrupt).
|
|
282
287
|
- **Auto-compaction → self-improving project memory** — when the conversation
|
|
283
288
|
nears the model's context window (80% of `contextWindow`, default 120k
|
|
284
|
-
tokens), older turns are summarized automatically;
|
|
285
|
-
|
|
289
|
+
tokens), older turns are summarized automatically; `/status` shows current
|
|
290
|
+
usage as `context used: N%`. Compaction (auto or manual `/compact`) also
|
|
286
291
|
distills durable, objective facts out of what's being summarized away —
|
|
287
292
|
build/test commands, package manager, conventions actually observed — and
|
|
288
293
|
merges any new ones into a `## Learned by kritya` section in `KRITYA.md`,
|
|
@@ -293,7 +298,8 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
|
293
298
|
- **Token budget** — a session-wide cap on combined prompt + completion
|
|
294
299
|
tokens across every turn and model (default 1,000,000; set `tokenBudget` in
|
|
295
300
|
config, or `/budget <number>` mid-session). The statusline shows `budget N%`
|
|
296
|
-
once usage
|
|
301
|
+
once usage crosses 80% (yellow, with a one-time warning) or the budget is
|
|
302
|
+
hit (red); `/status` shows it from the start. It then
|
|
297
303
|
stops further turns entirely at 100% until you run `/budget reset` (clears
|
|
298
304
|
the count) or `/budget <number>` (raises the cap). `/cost` also reports it.
|
|
299
305
|
- **Background processes** — the agent can start dev servers/watchers with
|
|
@@ -310,7 +316,7 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
|
310
316
|
every request.
|
|
311
317
|
- **Sub-task checklist → resumable plans** — for multi-step requests the agent
|
|
312
318
|
plans first and shows a live ☐/◐/☑ checklist as it works, with a compact
|
|
313
|
-
`tasks N/M` summary
|
|
319
|
+
`tasks N/M` summary available via `/status`. The checklist is saved alongside the
|
|
314
320
|
session, so `kritya -c` (and picking a session via `-r`) restores not just
|
|
315
321
|
the conversation but exactly which steps were done, in progress, or still
|
|
316
322
|
pending.
|
|
@@ -345,7 +351,7 @@ full tool output. `Ctrl+K` is the kill switch (see below). `Ctrl+C` exits.
|
|
|
345
351
|
- **Prompt-caching awareness** — the system prompt is ordered stable-first
|
|
346
352
|
(identity and rules → project memory → volatile git status/listing last) so
|
|
347
353
|
providers can reuse their cached prompt prefix across turns instead of
|
|
348
|
-
re-reading everything. `/cost` and
|
|
354
|
+
re-reading everything. `/cost` and `/status` show how many prompt
|
|
349
355
|
tokens were served from the provider's cache; add an optional
|
|
350
356
|
`"cachedInput"` rate to your `pricing` config to see the dollar savings.
|
|
351
357
|
- **LSP integration** — the agent gets go-to-definition, find-references,
|
package/dist/agent/loop.js
CHANGED
|
@@ -78,6 +78,14 @@ export class Agent {
|
|
|
78
78
|
audit;
|
|
79
79
|
/** OpenTelemetry-shaped tracer for the tool loop. No-op unless enabled. */
|
|
80
80
|
tracer = NOOP_TRACER;
|
|
81
|
+
/**
|
|
82
|
+
* Fired around compact() so the UI can show a live "compacting…" indicator
|
|
83
|
+
* for the summarization call, which otherwise runs silently until it
|
|
84
|
+
* either finishes or fails (see onToolEnd's "compact" record for the
|
|
85
|
+
* after-the-fact summary).
|
|
86
|
+
*/
|
|
87
|
+
onCompactStart;
|
|
88
|
+
onCompactEnd;
|
|
81
89
|
/** OTLP metrics recorder for tool/turn durations. No-op unless enabled. */
|
|
82
90
|
meter = NOOP_METER;
|
|
83
91
|
/**
|
|
@@ -266,6 +274,7 @@ export class Agent {
|
|
|
266
274
|
this.kill.assertLive();
|
|
267
275
|
const compactSpan = this.tracer.startSpan("agent.compact", { parent: this.currentTurnSpan });
|
|
268
276
|
const tokensBefore = this.lastPromptTokens;
|
|
277
|
+
this.onCompactStart?.();
|
|
269
278
|
try {
|
|
270
279
|
const note = await this.doCompact(signal, compactSpan);
|
|
271
280
|
compactSpan.setStatus("OK");
|
|
@@ -279,6 +288,7 @@ export class Agent {
|
|
|
279
288
|
compactSpan.setAttribute("kritya.prompt_tokens_before", tokensBefore);
|
|
280
289
|
compactSpan.setAttribute("kritya.prompt_tokens_after", this.lastPromptTokens);
|
|
281
290
|
compactSpan.end();
|
|
291
|
+
this.onCompactEnd?.();
|
|
282
292
|
}
|
|
283
293
|
}
|
|
284
294
|
async doCompact(signal, compactSpan) {
|
|
@@ -444,6 +454,7 @@ export class Agent {
|
|
|
444
454
|
onTextDelta: handlers.onTextDelta,
|
|
445
455
|
onReasoningDelta: handlers.onReasoningDelta,
|
|
446
456
|
onRetry: handlers.onRetry,
|
|
457
|
+
onFallback: handlers.onFallback,
|
|
447
458
|
}, signal, { tracer: this.tracer, parent: this.currentTurnSpan });
|
|
448
459
|
let result;
|
|
449
460
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { classifyDanger } from "../permissions/danger.js";
|
|
2
|
-
import { acknowledgeUnsandboxedFallback, sandboxFallbackWarning } from "../shell/sandbox.js";
|
|
2
|
+
import { acknowledgeUnsandboxedFallback, sandboxAvailable, sandboxFallbackWarning, } from "../shell/sandbox.js";
|
|
3
3
|
import { isPlanningDocWrite, loadProjectState } from "./workflow.js";
|
|
4
4
|
import { redactSecrets } from "../tools/secretScan.js";
|
|
5
5
|
/** How much tool output to hand the UI (it shows a preview and expands on toggle). */
|
|
@@ -251,7 +251,21 @@ export class ToolExecutor {
|
|
|
251
251
|
diff = undefined;
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
-
const
|
|
254
|
+
const permissionDetails = [
|
|
255
|
+
summary,
|
|
256
|
+
`workspace: ${host.ctx.workspace}`,
|
|
257
|
+
typeof args.path === "string" ? `affected path: ${args.path}` : undefined,
|
|
258
|
+
tool.name === "shell"
|
|
259
|
+
? `command: ${shellCommand}\nsandbox: ${host.ctx.sandboxMode === "off"
|
|
260
|
+
? "off"
|
|
261
|
+
: sandboxAvailable()
|
|
262
|
+
? `${host.ctx.sandboxMode ?? "auto"} (available)`
|
|
263
|
+
: `${host.ctx.sandboxMode ?? "auto"} (unavailable; unsandboxed)`}`
|
|
264
|
+
: undefined,
|
|
265
|
+
]
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
.join("\n");
|
|
268
|
+
const decision = await handlers.requestPermission(tool.name, permissionDetails, diff, danger ?? undefined);
|
|
255
269
|
// A forced (danger) prompt does not grant a lasting allowance.
|
|
256
270
|
if (danger === null)
|
|
257
271
|
host.permissions.record(tool.name, decision, args);
|
package/dist/audit/audit.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CONFIG_DIR } from "../config/config.js";
|
|
5
5
|
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
6
|
-
import { debugLog,
|
|
6
|
+
import { debugLog, warnPersistenceFailure } from "../config/debug.js";
|
|
7
7
|
const GENESIS = "0".repeat(64);
|
|
8
8
|
/**
|
|
9
9
|
* Where all audit logs live, across every workspace (not scoped per-project).
|
|
@@ -108,7 +108,7 @@ export class AuditLog {
|
|
|
108
108
|
}
|
|
109
109
|
catch (err) {
|
|
110
110
|
// best-effort
|
|
111
|
-
|
|
111
|
+
warnPersistenceFailure(`AuditLog.write(${this.file})`, err);
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
ensureDir() {
|
|
@@ -9,73 +9,138 @@ import { scanPluginMcpServers } from "../plugins/mcp.js";
|
|
|
9
9
|
import { listProviders } from "../config/config.js";
|
|
10
10
|
import { expandCommand } from "./custom.js";
|
|
11
11
|
import { artifactPath, clearProjectState, loadProjectState, parseIdea, parsePhase, phaseBlocker, phasePrompt, renameProject, staleArtifacts, PHASE_COMMAND, PHASE_ORDER, PHASE_SUMMARY, saveProjectState, slugify, } from "../agent/workflow.js";
|
|
12
|
+
const WORKFLOW = "Workflow";
|
|
13
|
+
const SESSION = "Session";
|
|
14
|
+
const SAFETY = "Safety & limits";
|
|
15
|
+
const INFO = "Info";
|
|
12
16
|
export const BUILTIN_COMMANDS = [
|
|
13
|
-
{ name: "/help", description: "show available commands" },
|
|
14
|
-
{ name: "/model", description: "pick a model, or /model <id> for any provider model ID" },
|
|
15
|
-
{
|
|
16
|
-
name: "/provider",
|
|
17
|
-
description: "list providers, or /provider <name> to switch mid-session (keeps history); add --default to persist",
|
|
18
|
-
},
|
|
19
17
|
{
|
|
20
18
|
name: "/flow-brainstorm",
|
|
21
19
|
description: "start a new-project workflow: /flow-brainstorm <idea> (brainstorm→spec→plan→build→review→fix)",
|
|
20
|
+
category: WORKFLOW,
|
|
22
21
|
},
|
|
23
22
|
{
|
|
24
23
|
name: "/flow-spec",
|
|
25
24
|
description: "project workflow: write the spec from the approved brainstorm",
|
|
25
|
+
category: WORKFLOW,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "/flow-plan",
|
|
29
|
+
description: "project workflow: plan from the spec (read-only)",
|
|
30
|
+
category: WORKFLOW,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "/flow-build",
|
|
34
|
+
description: "project workflow: implement the plan, with tests",
|
|
35
|
+
category: WORKFLOW,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "/flow-review",
|
|
39
|
+
description: "project workflow: spec-compliance and security review",
|
|
40
|
+
category: WORKFLOW,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "/flow-fix",
|
|
44
|
+
description: "project workflow: fix the review's findings, re-verified",
|
|
45
|
+
category: WORKFLOW,
|
|
26
46
|
},
|
|
27
|
-
{ name: "/plan", description: "toggle plan mode: /plan, /plan on, /plan off" },
|
|
28
|
-
{ name: "/flow-plan", description: "project workflow: plan from the spec (read-only)" },
|
|
29
|
-
{ name: "/flow-build", description: "project workflow: implement the plan, with tests" },
|
|
30
|
-
{ name: "/flow-review", description: "project workflow: spec-compliance and security review" },
|
|
31
|
-
{ name: "/flow-fix", description: "project workflow: fix the review's findings, re-verified" },
|
|
32
47
|
{
|
|
33
48
|
name: "/project",
|
|
34
49
|
description: "workflow status · /project goto <phase> · rename <name> · clear",
|
|
50
|
+
category: WORKFLOW,
|
|
35
51
|
},
|
|
36
|
-
{ name: "/diff", description: "show the cumulative git diff of this session's changes" },
|
|
37
|
-
{ name: "/redo", description: "reapply the change most recently undone" },
|
|
38
|
-
{ name: "/init", description: "scan the repo and generate a KRITYA.md project-memory file" },
|
|
39
|
-
{ name: "/web-search", description: "search the web: /web-search <query>" },
|
|
40
52
|
{
|
|
41
|
-
name: "/
|
|
42
|
-
description: "
|
|
53
|
+
name: "/model",
|
|
54
|
+
description: "pick a model, or /model <id> for any provider model ID",
|
|
55
|
+
category: SESSION,
|
|
43
56
|
},
|
|
44
57
|
{
|
|
45
|
-
name: "/
|
|
46
|
-
description: "list
|
|
58
|
+
name: "/provider",
|
|
59
|
+
description: "list providers, or /provider <name> to switch mid-session (keeps history); add --default to persist",
|
|
60
|
+
category: SESSION,
|
|
47
61
|
},
|
|
48
62
|
{
|
|
49
|
-
name: "/
|
|
50
|
-
description: "
|
|
63
|
+
name: "/diff",
|
|
64
|
+
description: "show the cumulative git diff of this session's changes",
|
|
65
|
+
category: SESSION,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "/init",
|
|
69
|
+
description: "scan the repo and generate a KRITYA.md project-memory file",
|
|
70
|
+
category: SESSION,
|
|
51
71
|
},
|
|
52
|
-
{ name: "/
|
|
72
|
+
{ name: "/web-search", description: "search the web: /web-search <query>", category: SESSION },
|
|
73
|
+
{
|
|
74
|
+
name: "/commit",
|
|
75
|
+
description: "have the agent stage and commit the current changes",
|
|
76
|
+
category: SESSION,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "/compact",
|
|
80
|
+
description: "summarize older conversation to free context space",
|
|
81
|
+
category: SESSION,
|
|
82
|
+
},
|
|
83
|
+
{ name: "/clear", description: "start a fresh conversation", category: SESSION },
|
|
84
|
+
{
|
|
85
|
+
name: "/undo",
|
|
86
|
+
description: "revert the file changes from the agent's last turn",
|
|
87
|
+
category: SESSION,
|
|
88
|
+
},
|
|
89
|
+
{ name: "/redo", description: "reapply the change most recently undone", category: SESSION },
|
|
53
90
|
{
|
|
54
91
|
name: "/checkpoint",
|
|
55
92
|
description: "save a named point: /checkpoint <name> (no name lists saved ones)",
|
|
93
|
+
category: SESSION,
|
|
56
94
|
},
|
|
57
95
|
{
|
|
58
96
|
name: "/rewind",
|
|
59
97
|
description: "rewind the conversation and files to a checkpoint: /rewind <name>",
|
|
98
|
+
category: SESSION,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "/plan",
|
|
102
|
+
description: "toggle plan mode (read-only): /plan, /plan on, /plan off",
|
|
103
|
+
category: SAFETY,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "/kill",
|
|
107
|
+
description: "emergency stop: /kill [reason] halts everything · /kill off releases (Ctrl+K)",
|
|
108
|
+
category: SAFETY,
|
|
60
109
|
},
|
|
61
|
-
{ name: "/commit", description: "have the agent stage and commit the current changes" },
|
|
62
|
-
{ name: "/compact", description: "summarize older conversation to free context space" },
|
|
63
|
-
{ name: "/clear", description: "start a fresh conversation" },
|
|
64
|
-
{ name: "/cost", description: "show token usage and estimated cost" },
|
|
65
110
|
{
|
|
66
111
|
name: "/audit",
|
|
67
112
|
description: "show this session's permission decisions and verify the audit log's chain",
|
|
113
|
+
category: SAFETY,
|
|
68
114
|
},
|
|
69
115
|
{
|
|
70
116
|
name: "/budget",
|
|
71
117
|
description: "show session token budget, /budget reset, or /budget <number> to set it",
|
|
118
|
+
category: SAFETY,
|
|
72
119
|
},
|
|
120
|
+
{ name: "/cost", description: "show token usage and estimated cost", category: SAFETY },
|
|
73
121
|
{
|
|
74
|
-
name: "/
|
|
75
|
-
description: "
|
|
122
|
+
name: "/status",
|
|
123
|
+
description: "show context/budget/token/task detail left off the status line",
|
|
124
|
+
category: SAFETY,
|
|
125
|
+
},
|
|
126
|
+
{ name: "/help", description: "show available commands", category: INFO },
|
|
127
|
+
{
|
|
128
|
+
name: "/skills",
|
|
129
|
+
description: "list discovered skills (project + user-global) and why any were skipped",
|
|
130
|
+
category: INFO,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "/plugins",
|
|
134
|
+
description: "list discovered Agent Plugins, what each contributes, and why any were skipped",
|
|
135
|
+
category: INFO,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "/mcp",
|
|
139
|
+
description: "MCP servers: status, /mcp add|remove <name>, /mcp login|logout <name>",
|
|
140
|
+
category: INFO,
|
|
76
141
|
},
|
|
77
|
-
{ name: "/exit", description: "leave" },
|
|
78
|
-
{ name: "/quit", description: "leave" },
|
|
142
|
+
{ name: "/exit", description: "leave", category: INFO },
|
|
143
|
+
{ name: "/quit", description: "leave", category: INFO },
|
|
79
144
|
];
|
|
80
145
|
export const HELP_TEXT = `Commands:
|
|
81
146
|
${BUILTIN_COMMANDS.map((c) => ` ${c.name.padEnd(14)} ${c.description}`).join("\n")}
|
|
@@ -379,6 +444,9 @@ const handlers = {
|
|
|
379
444
|
"/cost": (ctx) => {
|
|
380
445
|
ctx.addItem({ kind: "info", text: ctx.costReport() });
|
|
381
446
|
},
|
|
447
|
+
"/status": (ctx) => {
|
|
448
|
+
ctx.addItem({ kind: "info", text: ctx.statusReport() });
|
|
449
|
+
},
|
|
382
450
|
"/audit": (ctx) => {
|
|
383
451
|
const audit = ctx.agent.audit;
|
|
384
452
|
if (!audit) {
|
|
@@ -674,6 +742,7 @@ const ALLOWED_WHILE_KILLED = new Set([
|
|
|
674
742
|
"/quit",
|
|
675
743
|
"/audit",
|
|
676
744
|
"/cost",
|
|
745
|
+
"/status",
|
|
677
746
|
"/diff",
|
|
678
747
|
"/budget",
|
|
679
748
|
"/mcp",
|
package/dist/config/config.js
CHANGED
|
@@ -5,6 +5,13 @@ import { hardenWindowsDir } from "./winAcl.js";
|
|
|
5
5
|
import { debugLog, warnUser } from "./debug.js";
|
|
6
6
|
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
7
7
|
import { assertJsonWithinLimits } from "./jsonSafety.js";
|
|
8
|
+
/** KRITYA_PRIVACY wins over config.json; truthy values enable privacy mode. */
|
|
9
|
+
export function privacyModeFor(config) {
|
|
10
|
+
const value = process.env.KRITYA_PRIVACY;
|
|
11
|
+
if (value !== undefined)
|
|
12
|
+
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
13
|
+
return config.privacyMode === true;
|
|
14
|
+
}
|
|
8
15
|
/**
|
|
9
16
|
* Bounds on `mcpServers` entries (from config.json or a workspace's
|
|
10
17
|
* .mcp.json) — without these, an oversized or malicious server list could
|
|
@@ -158,6 +165,7 @@ export function resolveProvider(config, override) {
|
|
|
158
165
|
name,
|
|
159
166
|
baseUrl,
|
|
160
167
|
apiKey,
|
|
168
|
+
apiKeyEnv: merged.apiKeyEnv,
|
|
161
169
|
temperature: merged.temperature,
|
|
162
170
|
topP: merged.topP,
|
|
163
171
|
maxTokens: merged.maxTokens,
|
|
@@ -315,3 +323,17 @@ export function loadDotEnv(paths) {
|
|
|
315
323
|
}
|
|
316
324
|
}
|
|
317
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* Append one KEY=VALUE line to ~/.kritya/.env, creating the directory (and
|
|
328
|
+
* hardening its permissions) the same way saveConfig does for config.json.
|
|
329
|
+
* Used by the first-run API key setup prompt so a pasted key survives across
|
|
330
|
+
* restarts without the user having to edit a dotfile by hand.
|
|
331
|
+
*/
|
|
332
|
+
export function appendGlobalEnvVar(name, value) {
|
|
333
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
334
|
+
hardenWindowsDir(CONFIG_DIR);
|
|
335
|
+
const envFile = path.join(CONFIG_DIR, ".env");
|
|
336
|
+
const sanitized = value.replace(/[\r\n]/g, "");
|
|
337
|
+
fs.appendFileSync(envFile, `${name}=${sanitized}\n`, { mode: 0o600 });
|
|
338
|
+
fs.chmodSync(envFile, 0o600);
|
|
339
|
+
}
|
package/dist/config/debug.js
CHANGED
|
@@ -47,3 +47,36 @@ export function warnUser(context, err) {
|
|
|
47
47
|
}
|
|
48
48
|
debugLog(context, err);
|
|
49
49
|
}
|
|
50
|
+
/** Contexts currently warning via warnPersistenceFailure, in the order first seen. */
|
|
51
|
+
const persistenceWarnings = [];
|
|
52
|
+
const persistenceListeners = new Set();
|
|
53
|
+
function notifyPersistenceListeners() {
|
|
54
|
+
const snapshot = [...persistenceWarnings];
|
|
55
|
+
for (const listener of persistenceListeners)
|
|
56
|
+
listener(snapshot);
|
|
57
|
+
}
|
|
58
|
+
/** Current persistence-failure warnings (session/audit/telemetry writes that failed this process). */
|
|
59
|
+
export function activePersistenceWarnings() {
|
|
60
|
+
return [...persistenceWarnings];
|
|
61
|
+
}
|
|
62
|
+
/** Subscribe to persistence-warning changes. Returns an unsubscribe function. */
|
|
63
|
+
export function onPersistenceWarning(listener) {
|
|
64
|
+
persistenceListeners.add(listener);
|
|
65
|
+
return () => persistenceListeners.delete(listener);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Like warnUser, but for the specific best-effort writes whose failure means
|
|
69
|
+
* silent data loss (session transcripts, audit log, telemetry) — a stderr
|
|
70
|
+
* line alone is easy to miss since Ink's full-screen redraws can immediately
|
|
71
|
+
* paint over it. Recorded once per context, same dedup as warnUser, so the
|
|
72
|
+
* UI badge doesn't grow without bound from a write that fails every turn.
|
|
73
|
+
*/
|
|
74
|
+
export function warnPersistenceFailure(context, err) {
|
|
75
|
+
const isNewContext = !warnedContexts.has(context);
|
|
76
|
+
warnUser(context, err);
|
|
77
|
+
if (isNewContext) {
|
|
78
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
persistenceWarnings.push({ context, message });
|
|
80
|
+
notifyPersistenceListeners();
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/config/models.js
CHANGED
|
@@ -76,3 +76,13 @@ export function modelDisplaySlug(modelId) {
|
|
|
76
76
|
.replace(/^-+|-+$/g, "");
|
|
77
77
|
return `${prefix}${slug}`;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* "provider/model" for display, without the doubled prefix that comes from
|
|
81
|
+
* naively joining them: several providers' model IDs already carry their own
|
|
82
|
+
* namespace (NVIDIA's catalog is "nvidia/nemotron-...", so the "nvidia"
|
|
83
|
+
* provider plus that model id would otherwise print "nvidia/nvidia/...").
|
|
84
|
+
* Only skip the provider prefix when the model id already starts with it.
|
|
85
|
+
*/
|
|
86
|
+
export function displayModelId(providerName, modelId) {
|
|
87
|
+
return modelId.startsWith(`${providerName}/`) ? modelId : `${providerName}/${modelId}`;
|
|
88
|
+
}
|
package/dist/engine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Agent } from "./agent/loop.js";
|
|
3
|
-
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
3
|
+
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
|
|
4
4
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
5
5
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
6
6
|
import { loadRules } from "./permissions/rules.js";
|
|
@@ -35,6 +35,7 @@ export async function createEngineSession(dir, opts = {}) {
|
|
|
35
35
|
if (trustWorkspace)
|
|
36
36
|
loadDotEnv([path.join(workspace, ".env")]);
|
|
37
37
|
const config = loadConfig();
|
|
38
|
+
const privacyMode = privacyModeFor(config);
|
|
38
39
|
const provider = resolveProvider(config, opts.provider);
|
|
39
40
|
if (!provider.apiKey) {
|
|
40
41
|
throw new Error(`No API key found for provider "${provider.name}". Set it via env var, a .env file, or ~/.kritya/config.json.`);
|
|
@@ -49,9 +50,11 @@ export async function createEngineSession(dir, opts = {}) {
|
|
|
49
50
|
const client = provider.name === "switchyard"
|
|
50
51
|
? await createSwitchyardClient(provider.apiKey, sampling)
|
|
51
52
|
: new ProviderClient(provider.apiKey, provider.baseUrl, sampling);
|
|
52
|
-
const session = new SessionStore(workspace);
|
|
53
|
+
const session = new SessionStore(workspace, privacyMode);
|
|
53
54
|
session.start([]);
|
|
54
|
-
const sessionMeter =
|
|
55
|
+
const sessionMeter = privacyMode
|
|
56
|
+
? createMeter(session.id, "off")
|
|
57
|
+
: createMeter(session.id, config.otel);
|
|
55
58
|
// The crash path is fire-and-forget best-effort by Node's own constraints
|
|
56
59
|
// (a crash handler can't reliably await async work), so this uses the
|
|
57
60
|
// synchronous flush() rather than flushAndWait().
|
|
@@ -79,8 +82,10 @@ export async function createEngineSession(dir, opts = {}) {
|
|
|
79
82
|
if (trustWorkspace) {
|
|
80
83
|
agent.hooks = new HookRunner(loadHooks(workspace, trustWorkspace), workspace);
|
|
81
84
|
}
|
|
82
|
-
agent.audit = AuditLog.forSession(session.id, config.audit);
|
|
83
|
-
agent.tracer =
|
|
85
|
+
agent.audit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
|
|
86
|
+
agent.tracer = privacyMode
|
|
87
|
+
? createTracer(session.id, "off")
|
|
88
|
+
: createTracer(session.id, config.otel);
|
|
84
89
|
agent.meter = sessionMeter;
|
|
85
90
|
if (agent.hooks)
|
|
86
91
|
agent.hooks.tracer = agent.tracer;
|
package/dist/headless.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Agent } from "./agent/loop.js";
|
|
3
3
|
import { KillSwitchError } from "./agent/killSwitch.js";
|
|
4
|
-
import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
4
|
+
import { CONFIG_DIR, legacyGlobalModel, listProviders, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
|
|
5
5
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
6
6
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
7
7
|
import { loadRules } from "./permissions/rules.js";
|
|
8
|
-
import { ProviderClient, RetryExhaustedError } from "./provider/client.js";
|
|
8
|
+
import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from "./provider/client.js";
|
|
9
9
|
import { createSwitchyardClient } from "./provider/switchyardClient.js";
|
|
10
10
|
import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel, staleSwitchyardModelWarning, stopSwitchyardSidecar, } from "./provider/switchyardSidecar.js";
|
|
11
11
|
import { SessionStore } from "./session/store.js";
|
|
@@ -78,6 +78,7 @@ export async function runHeadless(args) {
|
|
|
78
78
|
if (trustWorkspace)
|
|
79
79
|
loadDotEnv([path.join(workspace, ".env")]);
|
|
80
80
|
const config = loadConfig();
|
|
81
|
+
const privacyMode = args.privacy === true || privacyModeFor(config);
|
|
81
82
|
const provider = resolveProvider(config, args.provider || undefined);
|
|
82
83
|
if (!provider.apiKey) {
|
|
83
84
|
return finish(args, startedAt, {
|
|
@@ -102,12 +103,16 @@ export async function runHeadless(args) {
|
|
|
102
103
|
const client = provider.name === "switchyard"
|
|
103
104
|
? await createSwitchyardClient(provider.apiKey, sampling)
|
|
104
105
|
: new ProviderClient(provider.apiKey, provider.baseUrl, sampling);
|
|
105
|
-
const session = new SessionStore(workspace);
|
|
106
|
-
const initialHistory = args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
|
|
106
|
+
const session = new SessionStore(workspace, privacyMode);
|
|
107
|
+
const initialHistory = !privacyMode && args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
|
|
107
108
|
session.start(initialHistory);
|
|
108
|
-
const sessionAudit = AuditLog.forSession(session.id, config.audit);
|
|
109
|
-
const sessionTracer =
|
|
110
|
-
|
|
109
|
+
const sessionAudit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
|
|
110
|
+
const sessionTracer = privacyMode
|
|
111
|
+
? createTracer(session.id, "off")
|
|
112
|
+
: createTracer(session.id, config.otel);
|
|
113
|
+
const sessionMeter = privacyMode
|
|
114
|
+
? createMeter(session.id, "off")
|
|
115
|
+
: createMeter(session.id, config.otel);
|
|
111
116
|
// A crash here orphans MCP children and background processes onto a CI
|
|
112
117
|
// runner, where nothing will ever reap them. No terminal to restore. The
|
|
113
118
|
// crash path is fire-and-forget best-effort by Node's own constraints (a
|
|
@@ -252,7 +257,9 @@ export async function runHeadless(args) {
|
|
|
252
257
|
errorMessage = `Stopped (${err.killReason ?? "kill switch engaged"})`;
|
|
253
258
|
}
|
|
254
259
|
else {
|
|
255
|
-
|
|
260
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
261
|
+
const friendlyHint = friendlyProviderErrorHint(err, provider.name);
|
|
262
|
+
errorMessage = friendlyHint ? `${friendlyHint} (${message})` : message;
|
|
256
263
|
}
|
|
257
264
|
}
|
|
258
265
|
finally {
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { render } from "ink";
|
|
6
6
|
import { Agent } from "./agent/loop.js";
|
|
7
|
-
import { CONFIG_DIR, legacyGlobalModel, loadConfig, loadDotEnv, resolveProvider, } from "./config/config.js";
|
|
7
|
+
import { CONFIG_DIR, appendGlobalEnvVar, legacyGlobalModel, loadConfig, loadDotEnv, privacyModeFor, resolveProvider, } from "./config/config.js";
|
|
8
8
|
import { DEFAULT_MODEL, contextWindowFor } from "./config/models.js";
|
|
9
9
|
import { PermissionManager } from "./permissions/permissions.js";
|
|
10
10
|
import { loadRules } from "./permissions/rules.js";
|
|
@@ -26,6 +26,7 @@ import { UndoStack } from "./undo/undo.js";
|
|
|
26
26
|
import { App } from "./ui/App.js";
|
|
27
27
|
import { TrustPrompt } from "./ui/TrustPrompt.js";
|
|
28
28
|
import { AiDisclosurePrompt } from "./ui/AiDisclosurePrompt.js";
|
|
29
|
+
import { ApiKeySetupPrompt } from "./ui/ApiKeySetupPrompt.js";
|
|
29
30
|
import { McpTrustPrompt } from "./ui/McpTrustPrompt.js";
|
|
30
31
|
import { loadHooks, HookRunner } from "./hooks/hooks.js";
|
|
31
32
|
import { loadMcpTools, shutdownMcp, } from "./mcp/client.js";
|
|
@@ -35,6 +36,7 @@ import { pluginsDir, scanPlugins, userPluginsDir } from "./plugins/discover.js";
|
|
|
35
36
|
import { loadPluginMcpServers } from "./plugins/mcp.js";
|
|
36
37
|
import { describeGatedContent, gatedContentHash, isTrusted, saveTrust } from "./trust/trust.js";
|
|
37
38
|
import { isAiDisclosureShown, markAiDisclosureShown } from "./trust/aiDisclosure.js";
|
|
39
|
+
import { isBannerSeen, markBannerSeen } from "./trust/bannerSeen.js";
|
|
38
40
|
import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
|
|
39
41
|
import { runHeadless } from "./headless.js";
|
|
40
42
|
import { installCrashHandlers } from "./crash.js";
|
|
@@ -65,6 +67,7 @@ Headless / CI mode (no terminal UI, exits with 0 on success / 1 on failure):
|
|
|
65
67
|
since CI often checks out untrusted branches/PRs)
|
|
66
68
|
--timeout <seconds> hard wall-clock cap for the whole run (default 1800)
|
|
67
69
|
--non-interactive accepted for compatibility; implied by --prompt
|
|
70
|
+
--privacy do not persist transcripts, audit logs, or telemetry
|
|
68
71
|
|
|
69
72
|
Inspect the local audit log:
|
|
70
73
|
kritya audit --list | --verify [file] | --show [file]
|
|
@@ -93,6 +96,7 @@ function parseArgs(argv) {
|
|
|
93
96
|
allowAll: false,
|
|
94
97
|
trust: false,
|
|
95
98
|
timeoutSeconds: 1800,
|
|
99
|
+
privacy: false,
|
|
96
100
|
};
|
|
97
101
|
for (let i = 0; i < argv.length; i++) {
|
|
98
102
|
const a = argv[i];
|
|
@@ -124,6 +128,8 @@ function parseArgs(argv) {
|
|
|
124
128
|
args.trust = true;
|
|
125
129
|
else if (a === "--timeout")
|
|
126
130
|
args.timeoutSeconds = Number(argv[++i]) || args.timeoutSeconds;
|
|
131
|
+
else if (a === "--privacy")
|
|
132
|
+
args.privacy = true;
|
|
127
133
|
else if (a === "--non-interactive") {
|
|
128
134
|
// implied by --prompt; accepted so scripts can pass it explicitly
|
|
129
135
|
}
|
|
@@ -176,6 +182,7 @@ if (args.prompt) {
|
|
|
176
182
|
allowAll: args.allowAll,
|
|
177
183
|
trust: args.trust,
|
|
178
184
|
timeoutSeconds: args.timeoutSeconds,
|
|
185
|
+
privacy: args.privacy,
|
|
179
186
|
}).then((code) => process.exit(code));
|
|
180
187
|
}
|
|
181
188
|
else {
|
|
@@ -259,8 +266,27 @@ async function showAiDisclosureNotice() {
|
|
|
259
266
|
} }));
|
|
260
267
|
});
|
|
261
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Interactive fallback when no API key resolved for the active provider.
|
|
271
|
+
* Lets the user paste one straight in, saving it to ~/.kritya/.env, rather
|
|
272
|
+
* than forcing them out to read env-var instructions and relaunch. Only
|
|
273
|
+
* called from the interactive TTY path (see main() below) — headless mode
|
|
274
|
+
* (src/headless.ts) never imports this and keeps failing with a plain error.
|
|
275
|
+
*/
|
|
276
|
+
async function promptApiKeySetup(providerName, envVarName) {
|
|
277
|
+
if (!envVarName)
|
|
278
|
+
return null;
|
|
279
|
+
return new Promise((resolve) => {
|
|
280
|
+
const instance = render(_jsx(ApiKeySetupPrompt, { providerName: providerName, envVarName: envVarName, onDecision: (result) => {
|
|
281
|
+
instance.unmount();
|
|
282
|
+
resolve(result.action === "save" ? result.key : null);
|
|
283
|
+
} }));
|
|
284
|
+
});
|
|
285
|
+
}
|
|
262
286
|
async function main() {
|
|
263
287
|
const config = loadConfig();
|
|
288
|
+
const privacyMode = args.privacy || privacyModeFor(config);
|
|
289
|
+
const firstLaunch = !isBannerSeen(workspace);
|
|
264
290
|
if (!isAiDisclosureShown(workspace)) {
|
|
265
291
|
await showAiDisclosureNotice();
|
|
266
292
|
markAiDisclosureShown(workspace);
|
|
@@ -273,7 +299,15 @@ async function main() {
|
|
|
273
299
|
loadDotEnv([path.join(workspace, ".env")]);
|
|
274
300
|
}
|
|
275
301
|
const provider = resolveProvider(config, args.provider || undefined);
|
|
276
|
-
|
|
302
|
+
let apiKey = provider.apiKey;
|
|
303
|
+
if (!apiKey) {
|
|
304
|
+
const entered = await promptApiKeySetup(provider.name, provider.apiKeyEnv);
|
|
305
|
+
if (entered) {
|
|
306
|
+
appendGlobalEnvVar(provider.apiKeyEnv, entered);
|
|
307
|
+
process.env[provider.apiKeyEnv] = entered;
|
|
308
|
+
apiKey = entered;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
277
311
|
if (!apiKey) {
|
|
278
312
|
const hint = provider.name === "nvidia"
|
|
279
313
|
? `Get one at https://build.nvidia.com, then one of:\n` +
|
|
@@ -312,18 +346,22 @@ async function main() {
|
|
|
312
346
|
let client = provider.name === "switchyard"
|
|
313
347
|
? await createSwitchyardClient(apiKey, sampling)
|
|
314
348
|
: new ProviderClient(apiKey, provider.baseUrl, sampling);
|
|
315
|
-
const session = new SessionStore(workspace);
|
|
349
|
+
const session = new SessionStore(workspace, privacyMode);
|
|
316
350
|
// Shared by the main agent and every subagent it spawns, so a write
|
|
317
351
|
// subagent's commits and a read-only subagent's tool calls land in the same
|
|
318
352
|
// audit trail and trace tree as the turn that spawned them — an agent that
|
|
319
353
|
// edits the repo should never do so off the record.
|
|
320
|
-
const sessionAudit = AuditLog.forSession(session.id, config.audit);
|
|
321
|
-
const sessionTracer =
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
354
|
+
const sessionAudit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
|
|
355
|
+
const sessionTracer = privacyMode
|
|
356
|
+
? createTracer(session.id, "off")
|
|
357
|
+
: createTracer(session.id, config.otel);
|
|
358
|
+
const sessionMeter = privacyMode
|
|
359
|
+
? createMeter(session.id, "off")
|
|
360
|
+
: createMeter(session.id, config.otel);
|
|
361
|
+
const initialHistory = !privacyMode && args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
|
|
362
|
+
const initialTasks = !privacyMode && args.continue ? SessionStore.loadLatestTasks(workspace) : [];
|
|
325
363
|
session.start(initialHistory);
|
|
326
|
-
const resumeSessions = args.resume ? SessionStore.listSessions(workspace) : [];
|
|
364
|
+
const resumeSessions = !privacyMode && args.resume ? SessionStore.listSessions(workspace) : [];
|
|
327
365
|
// Filled in once the app is mounted, below; the crash handler needs a way to
|
|
328
366
|
// tear the UI down and is installed before there is a UI to tear down.
|
|
329
367
|
const ui = {};
|
|
@@ -677,5 +715,6 @@ async function main() {
|
|
|
677
715
|
permissionRef.current = fn;
|
|
678
716
|
}, onRequestElicitationReady: (fn) => {
|
|
679
717
|
elicitationRef.current = fn;
|
|
680
|
-
} }));
|
|
718
|
+
}, privacyMode: privacyMode, firstLaunch: firstLaunch }));
|
|
719
|
+
markBannerSeen(workspace);
|
|
681
720
|
}
|
package/dist/provider/client.js
CHANGED
|
@@ -99,6 +99,25 @@ export function isRetryable(err) {
|
|
|
99
99
|
}
|
|
100
100
|
return false;
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* A short, actionable translation for the non-retryable failures a
|
|
104
|
+
* first-time user is most likely to hit — a rejected key or an unknown
|
|
105
|
+
* model id — instead of the raw `{"status":403,"title":"Forbidden",...}`
|
|
106
|
+
* body the provider sent. Returns undefined for anything else, so callers
|
|
107
|
+
* fall back to the raw message unchanged.
|
|
108
|
+
*/
|
|
109
|
+
export function friendlyProviderErrorHint(err, providerName) {
|
|
110
|
+
const status = err?.status;
|
|
111
|
+
if (status === 401 || status === 403) {
|
|
112
|
+
return (`Your API key for provider "${providerName}" was rejected. Check it's correct, ` +
|
|
113
|
+
`still active, and hasn't expired, then try again.`);
|
|
114
|
+
}
|
|
115
|
+
if (status === 404 && !isEmptyBodyNotFound(err)) {
|
|
116
|
+
return (`Provider "${providerName}" doesn't recognize this model. Check the model ID with ` +
|
|
117
|
+
`/model, or that your account has access to it.`);
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
102
121
|
/** Read one header case-insensitively from whatever shape the SDK attached. */
|
|
103
122
|
function headerValue(err, name) {
|
|
104
123
|
const headers = err?.headers;
|
|
@@ -29,6 +29,7 @@ export class SwitchyardProviderClient extends ProviderClient {
|
|
|
29
29
|
let lastErr = err;
|
|
30
30
|
for (const fb of this.fallbacks) {
|
|
31
31
|
try {
|
|
32
|
+
callbacks.onFallback?.("switchyard", fb.model);
|
|
32
33
|
return await fb.client.chat(fb.model, messages, tools, callbacks, signal, trace);
|
|
33
34
|
}
|
|
34
35
|
catch (fbErr) {
|
package/dist/session/store.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { writeFileAtomicSync } from "../atomicWrite.js";
|
|
5
5
|
import { CONFIG_DIR } from "../config/config.js";
|
|
6
6
|
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
7
|
-
import { debugLog,
|
|
7
|
+
import { debugLog, warnPersistenceFailure } from "../config/debug.js";
|
|
8
8
|
/**
|
|
9
9
|
* A session file loaded whole into memory has no upper bound otherwise —
|
|
10
10
|
* `--continue` on a pathologically large transcript (corruption, a runaway
|
|
@@ -127,7 +127,7 @@ export class SessionStore {
|
|
|
127
127
|
writeSessionFile(this.tasksFilePath(), JSON.stringify(tasks));
|
|
128
128
|
}
|
|
129
129
|
catch (err) {
|
|
130
|
-
|
|
130
|
+
warnPersistenceFailure(`SessionStore.saveTasks(${this.tasksFilePath()})`, err);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
/** Loads the task checklist saved alongside a given session file, if any. */
|
|
@@ -178,7 +178,7 @@ export class SessionStore {
|
|
|
178
178
|
}
|
|
179
179
|
catch (err) {
|
|
180
180
|
// Persistence is best-effort; never crash the session over it.
|
|
181
|
-
|
|
181
|
+
warnPersistenceFailure(`SessionStore.append(${this.file})`, err);
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
/** Start over with a fresh session file (used by /clear). */
|
|
@@ -201,7 +201,7 @@ export class SessionStore {
|
|
|
201
201
|
}
|
|
202
202
|
catch (err) {
|
|
203
203
|
// Persistence is best-effort; never crash the session over it.
|
|
204
|
-
|
|
204
|
+
warnPersistenceFailure(`SessionStore.overwrite(${this.file})`, err);
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VERSION } from "../version.js";
|
|
2
|
+
import { warnPersistenceFailure } from "../config/debug.js";
|
|
2
3
|
import { encodeMetricsSnapshot, postOtlp, postOtlpAndWait, } from "./otlp.js";
|
|
3
4
|
const NOOP_COUNTER = { add() { } };
|
|
4
5
|
const NOOP_HISTOGRAM = { record() { } };
|
|
@@ -113,8 +114,10 @@ class RealMeter {
|
|
|
113
114
|
return;
|
|
114
115
|
postOtlp(this.endpoint, "/v1/metrics", body, this.headers);
|
|
115
116
|
}
|
|
116
|
-
catch {
|
|
117
|
-
// best-effort: metrics must never crash the process
|
|
117
|
+
catch (err) {
|
|
118
|
+
// best-effort: metrics must never crash the process, but a dropped
|
|
119
|
+
// export should still be visible to the user.
|
|
120
|
+
warnPersistenceFailure("metrics.flush", err);
|
|
118
121
|
}
|
|
119
122
|
}
|
|
120
123
|
async flushAndWait() {
|
|
@@ -124,8 +127,8 @@ class RealMeter {
|
|
|
124
127
|
return;
|
|
125
128
|
await postOtlpAndWait(this.endpoint, "/v1/metrics", body, this.headers);
|
|
126
129
|
}
|
|
127
|
-
catch {
|
|
128
|
-
|
|
130
|
+
catch (err) {
|
|
131
|
+
warnPersistenceFailure("metrics.flushAndWait", err);
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
stop() {
|
package/dist/telemetry/otlp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { warnPersistenceFailure } from "../config/debug.js";
|
|
2
2
|
import { assertSafeUrl, pinnedDispatcherAllowLoopback, } from "../net/urlSafety.js";
|
|
3
3
|
function toAnyValue(value) {
|
|
4
4
|
if (typeof value === "string")
|
|
@@ -109,10 +109,10 @@ export function postOtlp(endpoint, path, body, headers) {
|
|
|
109
109
|
body: JSON.stringify(body),
|
|
110
110
|
dispatcher: pinnedDispatcherAllowLoopback,
|
|
111
111
|
};
|
|
112
|
-
fetch(url.href, init).catch((err) =>
|
|
112
|
+
fetch(url.href, init).catch((err) => warnPersistenceFailure(`postOtlp(${path})`, err));
|
|
113
113
|
}
|
|
114
114
|
catch (err) {
|
|
115
|
-
|
|
115
|
+
warnPersistenceFailure(`postOtlp(${path})`, err);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
@@ -134,6 +134,6 @@ export async function postOtlpAndWait(endpoint, path, body, headers) {
|
|
|
134
134
|
await fetch(url.href, init);
|
|
135
135
|
}
|
|
136
136
|
catch (err) {
|
|
137
|
-
|
|
137
|
+
warnPersistenceFailure(`postOtlpAndWait(${path})`, err);
|
|
138
138
|
}
|
|
139
139
|
}
|
package/dist/telemetry/tracer.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { CONFIG_DIR } from "../config/config.js";
|
|
5
5
|
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
6
|
-
import { debugLog,
|
|
6
|
+
import { debugLog, warnPersistenceFailure } from "../config/debug.js";
|
|
7
7
|
import { VERSION } from "../version.js";
|
|
8
8
|
import { encodeSpan, postOtlp } from "./otlp.js";
|
|
9
9
|
function nowUnixNano() {
|
|
@@ -158,7 +158,7 @@ function fileSink(file) {
|
|
|
158
158
|
}
|
|
159
159
|
catch (err) {
|
|
160
160
|
// best-effort: telemetry must never crash a turn
|
|
161
|
-
|
|
161
|
+
warnPersistenceFailure(`tracer.fileSink(${file})`, err);
|
|
162
162
|
}
|
|
163
163
|
};
|
|
164
164
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "../config/config.js";
|
|
4
|
+
import { hardenWindowsDir } from "../config/winAcl.js";
|
|
5
|
+
import { debugLog } from "../config/debug.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tracks which workspaces have already shown the full ASCII banner once, so
|
|
8
|
+
* repeat launches (which cost real vertical space in a terminal) can fall
|
|
9
|
+
* back to a compact one-line header instead. Same shape and persistence
|
|
10
|
+
* pattern as aiDisclosure.ts's ai-disclosure.json.
|
|
11
|
+
*/
|
|
12
|
+
const BANNER_FILE = path.join(CONFIG_DIR, "banner-seen.json");
|
|
13
|
+
function loadStore(storeFile) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(fs.readFileSync(storeFile, "utf8"));
|
|
16
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
// A missing file means "not shown anywhere yet" (normal); a malformed one
|
|
20
|
+
// means every workspace re-shows the full banner, worth being able to see.
|
|
21
|
+
debugLog(`loadStore(${storeFile})`, err);
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Whether the full ASCII banner has already been shown for this workspace. */
|
|
26
|
+
export function isBannerSeen(workspace, storeFile = BANNER_FILE) {
|
|
27
|
+
return typeof loadStore(storeFile)[path.resolve(workspace)] === "string";
|
|
28
|
+
}
|
|
29
|
+
/** Record that the full ASCII banner has been shown for this workspace, now. */
|
|
30
|
+
export function markBannerSeen(workspace, storeFile = BANNER_FILE) {
|
|
31
|
+
const store = loadStore(storeFile);
|
|
32
|
+
store[path.resolve(workspace)] = new Date().toISOString();
|
|
33
|
+
fs.mkdirSync(path.dirname(storeFile), { recursive: true, mode: 0o700 });
|
|
34
|
+
hardenWindowsDir(path.dirname(storeFile));
|
|
35
|
+
fs.writeFileSync(storeFile, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
/**
|
|
6
|
+
* Shown once, only when the active provider has no API key resolved, on the
|
|
7
|
+
* interactive TTY path (never from headless — see runHeadless in
|
|
8
|
+
* src/headless.ts, which never imports this component). Lets a first-time
|
|
9
|
+
* user paste a key instead of hunting through env-var/dotfile instructions.
|
|
10
|
+
*/
|
|
11
|
+
export function ApiKeySetupPrompt({ providerName, envVarName, onDecision, }) {
|
|
12
|
+
const [value, setValue] = useState("");
|
|
13
|
+
useInput((_input, key) => {
|
|
14
|
+
if (key.escape)
|
|
15
|
+
onDecision({ action: "skip" });
|
|
16
|
+
});
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["No API key found for provider \"", providerName, "\""] }), _jsxs(Text, { children: ["Paste your ", envVarName, " value below to save it to ~/.kritya/.env and continue \u2014 or press Esc to skip and set it up yourself."] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(TextInput, { value: value, onChange: setValue, mask: "*", onSubmit: (v) => {
|
|
18
|
+
const trimmed = v.trim();
|
|
19
|
+
if (trimmed)
|
|
20
|
+
onDecision({ action: "save", key: trimmed });
|
|
21
|
+
} })] })] }));
|
|
22
|
+
}
|
package/dist/ui/App.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
5
|
import fs from "node:fs";
|
|
@@ -30,7 +30,7 @@ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
|
|
30
30
|
// Erase screen + scrollback, then home the cursor (same sequence Ink itself
|
|
31
31
|
// uses for its own "content taller than the terminal" clear path).
|
|
32
32
|
const CLEAR_TERMINAL = "\x1b[2J\x1b[3J\x1b[H";
|
|
33
|
-
export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, }) {
|
|
33
|
+
export function App({ agent, workspace, modelRef, providerRef, config, resumedCount, initialTasks, undoStack, uiBridge, resumeSessions, customCommands = [], mcpToolCount = 0, onSwitchClient, onRequestPermissionReady, onRequestElicitationReady, privacyMode, firstLaunch, }) {
|
|
34
34
|
const { exit } = useApp();
|
|
35
35
|
const { stdout } = useStdout();
|
|
36
36
|
const [input, setInput] = useState("");
|
|
@@ -125,7 +125,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
125
125
|
.catch(() => { });
|
|
126
126
|
}, [workspace]);
|
|
127
127
|
useEffect(refreshFileList, [refreshFileList]);
|
|
128
|
-
const { items, addItem, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, autoApprovedCount, cycleMode, onAcceptEditsConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, } = useAgent({
|
|
128
|
+
const { items, addItem, phase, setPhase, stream, thinking, activity, setActivity, runningPhase, setRunningPhase, workflow, refreshWorkflow, permission, elicitation, inFlight, model, provider, servedModel, totalUsage, totalCost, tasks, setTasks, ctxPct, setCtxPct, tokenBudget, budgetPct, budgetUsed, budgetStopped, resetBudget, setBudgetLimit, branch, planMode, setPlanMode, dryRunMode, killed, killReason, engageKill, releaseKill, acceptEdits, setAcceptEdits, autoApprovedCount, cycleMode, onAcceptEditsConfirm, abortRef, setModelEverywhere, setProviderEverywhere, costReport, runAgent, runWebSearch, onPermissionDecision, onElicitationDecision, onResumeSelect, requestPermission, requestElicitation, persistenceWarningCount, } = useAgent({
|
|
129
129
|
agent,
|
|
130
130
|
workspace,
|
|
131
131
|
modelRef,
|
|
@@ -137,6 +137,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
137
137
|
resumeSessions,
|
|
138
138
|
refreshFileList,
|
|
139
139
|
onSwitchClient,
|
|
140
|
+
firstLaunch,
|
|
140
141
|
});
|
|
141
142
|
useEffect(() => {
|
|
142
143
|
onRequestPermissionReady?.(requestPermission);
|
|
@@ -154,11 +155,49 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
154
155
|
const t = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
|
|
155
156
|
return () => clearInterval(t);
|
|
156
157
|
}, [phase]);
|
|
158
|
+
// Detail moved out of the always-visible status line (see StatusLine.tsx) —
|
|
159
|
+
// surfaced instead via the /status command, on demand.
|
|
160
|
+
const statusReport = useCallback(() => {
|
|
161
|
+
const sandboxActive = (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable();
|
|
162
|
+
const cachedPct = (totalUsage.cachedPromptTokens ?? 0) > 0
|
|
163
|
+
? ` (${Math.round(((totalUsage.cachedPromptTokens ?? 0) / totalUsage.promptTokens) * 100)}% cached)`
|
|
164
|
+
: "";
|
|
165
|
+
const lines = [
|
|
166
|
+
`workspace: ${workspace}`,
|
|
167
|
+
`sandbox: ${sandboxActive ? "active" : "inactive"}`,
|
|
168
|
+
`context used: ${ctxPct}%`,
|
|
169
|
+
`token budget: ${budgetPct}% of ${tokenBudget.toLocaleString()} (${budgetUsed.toLocaleString()} used)${budgetStopped ? " — stopped" : ""}`,
|
|
170
|
+
`tokens: ${totalUsage.estimated ? "~" : ""}${totalUsage.promptTokens.toLocaleString()} in${cachedPct} / ${totalUsage.completionTokens.toLocaleString()} out`,
|
|
171
|
+
tasks.length > 0
|
|
172
|
+
? `tasks: ${tasks.filter((t) => t.status === "done").length}/${tasks.length} done`
|
|
173
|
+
: null,
|
|
174
|
+
phase === "working" && elapsed > 0 ? `elapsed: ${elapsed}s` : null,
|
|
175
|
+
verbose ? "verbose: on" : null,
|
|
176
|
+
].filter((l) => l !== null);
|
|
177
|
+
return lines.join("\n");
|
|
178
|
+
}, [
|
|
179
|
+
config,
|
|
180
|
+
workspace,
|
|
181
|
+
ctxPct,
|
|
182
|
+
budgetPct,
|
|
183
|
+
tokenBudget,
|
|
184
|
+
budgetUsed,
|
|
185
|
+
budgetStopped,
|
|
186
|
+
totalUsage,
|
|
187
|
+
tasks,
|
|
188
|
+
phase,
|
|
189
|
+
elapsed,
|
|
190
|
+
verbose,
|
|
191
|
+
]);
|
|
157
192
|
const allCommands = [
|
|
158
193
|
...BUILTIN_COMMANDS,
|
|
159
|
-
...customCommands.map((c) => ({
|
|
194
|
+
...customCommands.map((c) => ({
|
|
195
|
+
name: c.name,
|
|
196
|
+
description: c.description,
|
|
197
|
+
category: "Custom",
|
|
198
|
+
})),
|
|
160
199
|
// Servers can contribute slash commands too (MCP prompts).
|
|
161
|
-
...mcpPrompts().map((p) => ({ name: p.command, description: p.description })),
|
|
200
|
+
...mcpPrompts().map((p) => ({ name: p.command, description: p.description, category: "MCP" })),
|
|
162
201
|
];
|
|
163
202
|
// Command suggestions while typing a slash command (before any arguments).
|
|
164
203
|
const suggestions = phase === "input" && input.startsWith("/") && !input.includes(" ")
|
|
@@ -195,6 +234,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
195
234
|
return;
|
|
196
235
|
}
|
|
197
236
|
if (key.escape && phase === "working") {
|
|
237
|
+
setActivity("cancelling…");
|
|
198
238
|
abortRef.current?.abort();
|
|
199
239
|
}
|
|
200
240
|
// Ctrl+O toggles showing full tool output.
|
|
@@ -307,6 +347,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
307
347
|
runWebSearch,
|
|
308
348
|
expandMentions,
|
|
309
349
|
costReport,
|
|
350
|
+
statusReport,
|
|
310
351
|
gitDiffStat,
|
|
311
352
|
exit,
|
|
312
353
|
};
|
|
@@ -444,7 +485,18 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
|
|
|
444
485
|
setInput((prev) => (isCtrlEcho(prev, v) ? prev : v.replace(/\t/g, "")));
|
|
445
486
|
setCmdIndex(0);
|
|
446
487
|
setFileIndex(0);
|
|
447
|
-
}, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [
|
|
488
|
+
}, onSubmit: handleSubmit }, inputKey)] }), suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [(() => {
|
|
489
|
+
// Group headers only earn their keep once more than one
|
|
490
|
+
// category is actually on screen — once typing has filtered
|
|
491
|
+
// down to a single family, a lone header is just noise.
|
|
492
|
+
const showHeaders = new Set(suggestions.map((c) => c.category)).size > 1;
|
|
493
|
+
let lastCategory;
|
|
494
|
+
return suggestions.map((c, i) => {
|
|
495
|
+
const showHeader = showHeaders && c.category !== lastCategory;
|
|
496
|
+
lastCategory = c.category;
|
|
497
|
+
return (_jsxs(Fragment, { children: [showHeader && _jsx(Text, { dimColor: true, children: c.category ?? "Other" }), _jsxs(Text, { color: i === selectedCmd ? "cyan" : undefined, children: [i === selectedCmd ? "❯ " : " ", _jsx(Text, { bold: i === selectedCmd, children: c.name }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", c.description] })] })] }, c.name));
|
|
498
|
+
});
|
|
499
|
+
})(), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter select \u00B7 Enter again to run" })] })), fileSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [fileSuggestions.map((f, i) => (_jsxs(Text, { color: i === selectedFile ? "cyan" : undefined, children: [i === selectedFile ? "❯ " : " ", f] }, f))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 Tab/Enter attach file" })] }))] })), _jsx(Box, { children: _jsx(StatusLine, { killed: killed, killReason: killReason, dryRunMode: dryRunMode, planMode: planMode, acceptEdits: acceptEdits, autoApprovedCount: autoApprovedCount, provider: provider, model: servedModel && servedModel !== model
|
|
448
500
|
? `${model}-${modelDisplaySlug(servedModel)}`
|
|
449
|
-
: model, workflow: workflow, branch: branch,
|
|
501
|
+
: model, workflow: workflow, branch: branch, budgetPct: budgetPct, budgetStopped: budgetStopped, totalCost: totalCost, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
|
|
450
502
|
}
|
package/dist/ui/Banner.js
CHANGED
|
@@ -50,7 +50,10 @@ function rowColor(row) {
|
|
|
50
50
|
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
|
51
51
|
}
|
|
52
52
|
const BETA_LABEL = "beta";
|
|
53
|
-
export function Banner({ subtitle }) {
|
|
53
|
+
export function Banner({ subtitle, compact }) {
|
|
54
|
+
if (compact) {
|
|
55
|
+
return (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: subtitle }) }));
|
|
56
|
+
}
|
|
54
57
|
const columns = terminalColumns(process.stdout);
|
|
55
58
|
const wide = bannerLines("KRITYA", "░░");
|
|
56
59
|
const narrow = bannerLines("KRITYA", "░");
|
package/dist/ui/StatusLine.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Text } from "ink";
|
|
3
|
-
import
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import { displayModelId } from "../config/models.js";
|
|
4
|
+
/**
|
|
5
|
+
* The single dim status bar pinned to the bottom of the screen. Keeps only
|
|
6
|
+
* the fields worth glancing at on every render; everything else (context %,
|
|
7
|
+
* task checklist, elapsed time, token counts, workspace path, verbose flag)
|
|
8
|
+
* moved to the on-demand `/status` command — see CommandContext.statusReport
|
|
9
|
+
* in src/commands/registry.ts.
|
|
10
|
+
*/
|
|
11
|
+
export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, provider, model, workflow, branch, budgetPct, budgetStopped, totalCost, sandboxActive, persistenceWarningCount, privacyMode, }) {
|
|
12
|
+
return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), _jsxs(Text, { color: dryRunMode || planMode ? "cyan" : acceptEdits ? "green" : undefined, children: ["mode:", " ", dryRunMode
|
|
13
|
+
? "dry-run"
|
|
14
|
+
: planMode
|
|
15
|
+
? "plan"
|
|
16
|
+
: acceptEdits
|
|
17
|
+
? `accept-edits (${autoApprovedCount} auto)`
|
|
18
|
+
: "default", " · "] }), displayModelId(provider, model), _jsxs(Text, { color: sandboxActive ? "green" : "red", children: [" ", "\u00B7 ", sandboxActive ? "🔒 sandbox:active" : "🔓 sandbox:inactive"] }), workflow ? (_jsxs(Text, { color: "magenta", children: [" ", "\u00B7 \u2691 ", workflow.name, ":", workflow.phase] })) : (""), branch ? ` · ⎇ ${branch}` : "", budgetStopped || budgetPct >= 80 ? (_jsxs(Text, { color: budgetStopped ? "red" : "yellow", children: [" \u00B7 budget ", budgetPct, "%"] })) : (""), totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", persistenceWarningCount > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 \u26A0 persistence warnings: ", persistenceWarningCount] })) : (""), privacyMode ? _jsx(Text, { color: "cyan", children: " \u00B7 privacy:on" }) : "", " · /status for details"] }));
|
|
11
19
|
}
|
|
@@ -9,5 +9,5 @@ export function TranscriptItem({ item, verbose, stdout }) {
|
|
|
9
9
|
return (_jsxs(Box, { marginBottom: item.kind === "tool" ? 0 : 1, flexDirection: "column", children: [item.kind === "user" && (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: "green", children: ["\u276F", " "] }), item.text] })), item.kind === "assistant" && _jsx(Markdown, { text: item.text }), item.kind === "tool" && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [item.error ? _jsx(Text, { color: "red", children: "\u2717" }) : _jsx(Text, { color: "green", children: "\u2713" }), " ", item.summary, item.resultSummary && !verbose ? ` — ${item.resultSummary}` : ""] }), item.output && item.output.trim() && (item.resultSummary === undefined || verbose) && (_jsx(Text, { dimColor: true, children: toolOutputPreview(item.output, verbose, Math.max(20, terminalColumns(stdout) - 6), item.error)
|
|
10
10
|
.split("\n")
|
|
11
11
|
.map((l) => ` ${l}`)
|
|
12
|
-
.join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle })] }));
|
|
12
|
+
.join("\n") }))] })), item.kind === "info" && _jsx(Text, { dimColor: true, children: item.text }), item.kind === "banner" && _jsx(Banner, { subtitle: item.subtitle, compact: item.compact })] }));
|
|
13
13
|
}
|
package/dist/ui/useAgent.js
CHANGED
|
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { gitBranch } from "../git/git.js";
|
|
4
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";
|
|
5
|
+
import { DEFAULT_MODEL, contextWindowFor, displayModelId } from "../config/models.js";
|
|
6
|
+
import { ProviderClient, RetryExhaustedError, friendlyProviderErrorHint, } from "../provider/client.js";
|
|
7
7
|
import { createSwitchyardClient } from "../provider/switchyardClient.js";
|
|
8
8
|
import { SWITCHYARD_ROUTE_ID, resolveEffectiveModel } from "../provider/switchyardSidecar.js";
|
|
9
9
|
import { KillSwitchError } from "../agent/killSwitch.js";
|
|
@@ -12,12 +12,24 @@ import { tavilySearch } from "../tools/webSearch.js";
|
|
|
12
12
|
import { killActiveNotice, useKillSwitch } from "./useKillSwitch.js";
|
|
13
13
|
import { useUsageBudget } from "./useUsageBudget.js";
|
|
14
14
|
import { useSessionResume } from "./useSessionResume.js";
|
|
15
|
+
import { activePersistenceWarnings, onPersistenceWarning } from "../config/debug.js";
|
|
16
|
+
function initialToolStatus(name) {
|
|
17
|
+
if (name.startsWith("mcp_"))
|
|
18
|
+
return "contacting MCP server…";
|
|
19
|
+
if (name.startsWith("lsp_"))
|
|
20
|
+
return "querying language server…";
|
|
21
|
+
if (name.includes("document"))
|
|
22
|
+
return "processing document…";
|
|
23
|
+
if (name.includes("notebook"))
|
|
24
|
+
return "processing notebook…";
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
15
27
|
/**
|
|
16
28
|
* Owns everything tied to running agent turns: the transcript, streaming and
|
|
17
29
|
* permission state, usage/cost tracking, and session resume. Input handling
|
|
18
30
|
* (text box, slash-command suggestions, @mention autocomplete) stays in App.
|
|
19
31
|
*/
|
|
20
|
-
export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, }) {
|
|
32
|
+
export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBridge, resumedCount, initialTasks, resumeSessions, refreshFileList, onSwitchClient, firstLaunch, }) {
|
|
21
33
|
const nextId = useRef(0);
|
|
22
34
|
const [items, setItems] = useState(() => {
|
|
23
35
|
const initial = [
|
|
@@ -25,6 +37,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
25
37
|
id: nextId.current++,
|
|
26
38
|
kind: "banner",
|
|
27
39
|
subtitle: `${path.basename(workspace)} · ${modelRef.current} · type a request, or /help for commands`,
|
|
40
|
+
compact: !firstLaunch,
|
|
28
41
|
},
|
|
29
42
|
];
|
|
30
43
|
if (resumedCount > 0) {
|
|
@@ -44,6 +57,21 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
44
57
|
const [stream, setStream] = useState("");
|
|
45
58
|
const [thinking, setThinking] = useState(false);
|
|
46
59
|
const [activity, setActivity] = useState(null);
|
|
60
|
+
const [persistenceWarningCount, setPersistenceWarningCount] = useState(() => activePersistenceWarnings().length);
|
|
61
|
+
useEffect(() => onPersistenceWarning((warnings) => setPersistenceWarningCount(warnings.length)), []);
|
|
62
|
+
/**
|
|
63
|
+
* Compaction calls the model directly and can take a while, so without this
|
|
64
|
+
* the spinner would sit frozen on whatever it showed before compaction
|
|
65
|
+
* started until onToolEnd's after-the-fact "compact" record appears.
|
|
66
|
+
*/
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
agent.onCompactStart = () => setActivity("compacting context…");
|
|
69
|
+
agent.onCompactEnd = () => setActivity(null);
|
|
70
|
+
return () => {
|
|
71
|
+
agent.onCompactStart = undefined;
|
|
72
|
+
agent.onCompactEnd = undefined;
|
|
73
|
+
};
|
|
74
|
+
}, [agent]);
|
|
47
75
|
/**
|
|
48
76
|
* Which workflow phase the *current turn* is running. Turn-scoped and cleared
|
|
49
77
|
* when the turn ends, unlike `workflow` below — `activity` can't carry this,
|
|
@@ -355,6 +383,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
355
383
|
return;
|
|
356
384
|
}
|
|
357
385
|
setPhase("working");
|
|
386
|
+
setActivity(`Calling ${displayModelId(provider, model)}…`);
|
|
358
387
|
const ac = new AbortController();
|
|
359
388
|
abortRef.current = ac;
|
|
360
389
|
setInFlight([]);
|
|
@@ -372,7 +401,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
372
401
|
},
|
|
373
402
|
onToolStart: (id, name, summary) => {
|
|
374
403
|
setStream("");
|
|
375
|
-
setInFlight((prev) => [
|
|
404
|
+
setInFlight((prev) => [
|
|
405
|
+
...prev,
|
|
406
|
+
{ id, name, summary, status: initialToolStatus(name) },
|
|
407
|
+
]);
|
|
376
408
|
},
|
|
377
409
|
onToolEnd: (id, name, summary, preview, isError, resultSummary) => {
|
|
378
410
|
setInFlight((prev) => prev.filter((t) => t.id !== id));
|
|
@@ -397,6 +429,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
397
429
|
setStream("");
|
|
398
430
|
setActivity(`Provider error${status ? ` (${status})` : ""} — retrying (attempt ${attempt})…`);
|
|
399
431
|
},
|
|
432
|
+
onFallback: (from, to) => {
|
|
433
|
+
setStream("");
|
|
434
|
+
setActivity(`${from} unavailable — trying fallback model ${to}…`);
|
|
435
|
+
},
|
|
400
436
|
onUsage: (usage) => {
|
|
401
437
|
if (usage.servedModel)
|
|
402
438
|
setServedModel(usage.servedModel);
|
|
@@ -433,7 +469,8 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
433
469
|
text = `Error: ${message}${hint}`;
|
|
434
470
|
}
|
|
435
471
|
else {
|
|
436
|
-
|
|
472
|
+
const friendlyHint = friendlyProviderErrorHint(err, provider);
|
|
473
|
+
text = friendlyHint ? `${friendlyHint}\n\n(${message})` : `Error: ${message}`;
|
|
437
474
|
}
|
|
438
475
|
}
|
|
439
476
|
addItem({ kind: "info", text });
|
|
@@ -498,6 +535,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
|
|
|
498
535
|
totalCost,
|
|
499
536
|
tasks,
|
|
500
537
|
setTasks,
|
|
538
|
+
persistenceWarningCount,
|
|
501
539
|
ctxPct,
|
|
502
540
|
setCtxPct,
|
|
503
541
|
tokenBudget,
|
package/package.json
CHANGED