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 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; the statusline shows
285
- current usage as `ctx N%`. Compaction (auto or manual `/compact`) also
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 starts, turning yellow past 80% with a one-time warning, then
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 in the statusline. The checklist is saved alongside the
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 the statusline show how many prompt
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,
@@ -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 decision = await handlers.requestPermission(tool.name, summary, diff, danger ?? undefined);
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);
@@ -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, warnUser } from "../config/debug.js";
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
- warnUser(`AuditLog.write(${this.file})`, err);
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: "/mcp",
42
- description: "MCP servers: status, /mcp add|remove <name>, /mcp login|logout <name>",
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: "/skills",
46
- description: "list discovered skills (project + user-global) and why any were skipped",
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: "/plugins",
50
- description: "list discovered Agent Plugins, what each contributes, and why any were skipped",
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: "/undo", description: "revert the file changes from the agent's last turn" },
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: "/kill",
75
- description: "emergency stop: /kill [reason] halts everything · /kill off releases (Ctrl+K)",
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",
@@ -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
+ }
@@ -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
+ }
@@ -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 = createMeter(session.id, config.otel);
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 = createTracer(session.id, config.otel);
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 = createTracer(session.id, config.otel);
110
- const sessionMeter = createMeter(session.id, config.otel);
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
- errorMessage = err instanceof Error ? err.message : String(err);
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
- const apiKey = provider.apiKey;
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 = createTracer(session.id, config.otel);
322
- const sessionMeter = createMeter(session.id, config.otel);
323
- const initialHistory = args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
324
- const initialTasks = args.continue ? SessionStore.loadLatestTasks(workspace) : [];
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
  }
@@ -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) {
@@ -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, warnUser } from "../config/debug.js";
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
- warnUser(`SessionStore.saveTasks(${this.tasksFilePath()})`, err);
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
- warnUser(`SessionStore.append(${this.file})`, err);
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
- warnUser(`SessionStore.overwrite(${this.file})`, err);
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
- // best-effort: metrics must never crash the process
130
+ catch (err) {
131
+ warnPersistenceFailure("metrics.flushAndWait", err);
129
132
  }
130
133
  }
131
134
  stop() {
@@ -1,4 +1,4 @@
1
- import { warnUser } from "../config/debug.js";
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) => warnUser(`postOtlp(${path})`, err));
112
+ fetch(url.href, init).catch((err) => warnPersistenceFailure(`postOtlp(${path})`, err));
113
113
  }
114
114
  catch (err) {
115
- warnUser(`postOtlp(${path})`, err);
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
- warnUser(`postOtlpAndWait(${path})`, err);
137
+ warnPersistenceFailure(`postOtlpAndWait(${path})`, err);
138
138
  }
139
139
  }
@@ -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, warnUser } from "../config/debug.js";
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
- warnUser(`tracer.fileSink(${file})`, err);
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) => ({ name: c.name, description: c.description })),
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: [suggestions.map((c, i) => (_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))), _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, model: servedModel && servedModel !== model
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, tasks: tasks, ctxPct: ctxPct, budgetPct: budgetPct, budgetStopped: budgetStopped, phase: phase, elapsed: elapsed, totalUsage: totalUsage, totalCost: totalCost, verbose: verbose, workspace: workspace, sandboxActive: (config.sandboxExec ?? defaultSandboxMode()) !== "off" && sandboxAvailable() }) })] }));
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", "░");
@@ -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 path from "node:path";
4
- /** The single dim status bar pinned to the bottom of the screen. */
5
- export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, model, workflow, branch, tasks, ctxPct, budgetPct, budgetStopped, phase, elapsed, totalUsage, totalCost, verbose, workspace, sandboxActive, }) {
6
- return (_jsxs(Text, { dimColor: true, children: [killed ? (_jsxs(Text, { bold: true, color: "red", children: ["\u26D4 KILLED", killReason ? ` (${killReason})` : "", " \u00B7", " "] })) : (""), dryRunMode ? (_jsx(Text, { color: "cyan", children: "dry-run \u00B7 " })) : planMode ? (_jsx(Text, { color: "cyan", children: "plan \u00B7 " })) : acceptEdits ? (_jsxs(Text, { color: "green", children: ["accept edits (", autoApprovedCount, " auto-approved) \u00B7 "] })) : (""), 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}` : "", tasks.length > 0
7
- ? ` · tasks ${tasks.filter((t) => t.status === "done").length}/${tasks.length}`
8
- : "", ctxPct > 0 ? ` · ctx ${ctxPct}%` : "", budgetPct > 0 ? (_jsxs(Text, { color: budgetStopped ? "red" : budgetPct >= 80 ? "yellow" : undefined, children: [" ", "\u00B7 budget ", budgetPct, "%"] })) : (""), phase === "working" && elapsed > 0 ? ` · ${elapsed}s` : "", " \u00B7", " ", totalUsage.estimated ? "~" : "", totalUsage.promptTokens.toLocaleString(), " in", (totalUsage.cachedPromptTokens ?? 0) > 0
9
- ? ` (${Math.round(((totalUsage.cachedPromptTokens ?? 0) / totalUsage.promptTokens) * 100)}% cached)`
10
- : "", " ", "/ ", totalUsage.completionTokens.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)}` : "", verbose ? " · verbose" : "", " \u00B7 ", path.basename(workspace)] }));
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
  }
@@ -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) => [...prev, { id, name, summary }]);
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
- text = `Error: ${message}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.23-beta",
3
+ "version": "0.8.25-beta",
4
4
  "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
5
5
  "type": "module",
6
6
  "publishConfig": {