kritya 0.8.23-beta → 0.8.24-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
 
@@ -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() {
@@ -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
@@ -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/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,7 +1,7 @@
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";
@@ -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
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, 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";
@@ -65,6 +65,7 @@ Headless / CI mode (no terminal UI, exits with 0 on success / 1 on failure):
65
65
  since CI often checks out untrusted branches/PRs)
66
66
  --timeout <seconds> hard wall-clock cap for the whole run (default 1800)
67
67
  --non-interactive accepted for compatibility; implied by --prompt
68
+ --privacy do not persist transcripts, audit logs, or telemetry
68
69
 
69
70
  Inspect the local audit log:
70
71
  kritya audit --list | --verify [file] | --show [file]
@@ -93,6 +94,7 @@ function parseArgs(argv) {
93
94
  allowAll: false,
94
95
  trust: false,
95
96
  timeoutSeconds: 1800,
97
+ privacy: false,
96
98
  };
97
99
  for (let i = 0; i < argv.length; i++) {
98
100
  const a = argv[i];
@@ -124,6 +126,8 @@ function parseArgs(argv) {
124
126
  args.trust = true;
125
127
  else if (a === "--timeout")
126
128
  args.timeoutSeconds = Number(argv[++i]) || args.timeoutSeconds;
129
+ else if (a === "--privacy")
130
+ args.privacy = true;
127
131
  else if (a === "--non-interactive") {
128
132
  // implied by --prompt; accepted so scripts can pass it explicitly
129
133
  }
@@ -176,6 +180,7 @@ if (args.prompt) {
176
180
  allowAll: args.allowAll,
177
181
  trust: args.trust,
178
182
  timeoutSeconds: args.timeoutSeconds,
183
+ privacy: args.privacy,
179
184
  }).then((code) => process.exit(code));
180
185
  }
181
186
  else {
@@ -261,6 +266,7 @@ async function showAiDisclosureNotice() {
261
266
  }
262
267
  async function main() {
263
268
  const config = loadConfig();
269
+ const privacyMode = args.privacy || privacyModeFor(config);
264
270
  if (!isAiDisclosureShown(workspace)) {
265
271
  await showAiDisclosureNotice();
266
272
  markAiDisclosureShown(workspace);
@@ -312,18 +318,22 @@ async function main() {
312
318
  let client = provider.name === "switchyard"
313
319
  ? await createSwitchyardClient(apiKey, sampling)
314
320
  : new ProviderClient(apiKey, provider.baseUrl, sampling);
315
- const session = new SessionStore(workspace);
321
+ const session = new SessionStore(workspace, privacyMode);
316
322
  // Shared by the main agent and every subagent it spawns, so a write
317
323
  // subagent's commits and a read-only subagent's tool calls land in the same
318
324
  // audit trail and trace tree as the turn that spawned them — an agent that
319
325
  // 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) : [];
326
+ const sessionAudit = privacyMode ? undefined : AuditLog.forSession(session.id, config.audit);
327
+ const sessionTracer = privacyMode
328
+ ? createTracer(session.id, "off")
329
+ : createTracer(session.id, config.otel);
330
+ const sessionMeter = privacyMode
331
+ ? createMeter(session.id, "off")
332
+ : createMeter(session.id, config.otel);
333
+ const initialHistory = !privacyMode && args.continue ? (SessionStore.loadLatest(workspace) ?? []) : [];
334
+ const initialTasks = !privacyMode && args.continue ? SessionStore.loadLatestTasks(workspace) : [];
325
335
  session.start(initialHistory);
326
- const resumeSessions = args.resume ? SessionStore.listSessions(workspace) : [];
336
+ const resumeSessions = !privacyMode && args.resume ? SessionStore.listSessions(workspace) : [];
327
337
  // Filled in once the app is mounted, below; the crash handler needs a way to
328
338
  // tear the UI down and is installed before there is a UI to tear down.
329
339
  const ui = {};
@@ -677,5 +687,5 @@ async function main() {
677
687
  permissionRef.current = fn;
678
688
  }, onRequestElicitationReady: (fn) => {
679
689
  elicitationRef.current = fn;
680
- } }));
690
+ }, privacyMode: privacyMode }));
681
691
  }
@@ -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
  }
package/dist/ui/App.js CHANGED
@@ -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, }) {
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,
@@ -195,6 +195,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
195
195
  return;
196
196
  }
197
197
  if (key.escape && phase === "working") {
198
+ setActivity("cancelling…");
198
199
  abortRef.current?.abort();
199
200
  }
200
201
  // Ctrl+O toggles showing full tool output.
@@ -444,7 +445,7 @@ export function App({ agent, workspace, modelRef, providerRef, config, resumedCo
444
445
  setInput((prev) => (isCtrlEcho(prev, v) ? prev : v.replace(/\t/g, "")));
445
446
  setCmdIndex(0);
446
447
  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
448
+ }, 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, provider: provider, model: servedModel && servedModel !== model
448
449
  ? `${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() }) })] }));
450
+ : 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(), persistenceWarningCount: persistenceWarningCount, privacyMode: privacyMode }) })] }));
450
451
  }
@@ -1,11 +1,16 @@
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
3
  /** 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
4
+ export function StatusLine({ killed, killReason, dryRunMode, planMode, acceptEdits, autoApprovedCount, provider, model, workflow, branch, tasks, ctxPct, budgetPct, budgetStopped, phase, elapsed, totalUsage, totalCost, verbose, workspace, sandboxActive, persistenceWarningCount, privacyMode, }) {
5
+ 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
6
+ ? "dry-run"
7
+ : planMode
8
+ ? "plan"
9
+ : acceptEdits
10
+ ? `accept-edits (${autoApprovedCount} auto)`
11
+ : "default", " · "] }), 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}` : "", tasks.length > 0
7
12
  ? ` · 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
13
+ : "", 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 ? "~" : "", "tokens: ", totalUsage.promptTokens.toLocaleString(), " in", (totalUsage.cachedPromptTokens ?? 0) > 0
9
14
  ? ` (${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)] }));
15
+ : "", " ", "/ ", totalUsage.completionTokens.toLocaleString(), " out", 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" }) : "", verbose ? " · verbose" : "", " \u00B7 workspace: ", workspace] }));
11
16
  }
@@ -12,6 +12,18 @@ 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
@@ -44,6 +56,21 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
44
56
  const [stream, setStream] = useState("");
45
57
  const [thinking, setThinking] = useState(false);
46
58
  const [activity, setActivity] = useState(null);
59
+ const [persistenceWarningCount, setPersistenceWarningCount] = useState(() => activePersistenceWarnings().length);
60
+ useEffect(() => onPersistenceWarning((warnings) => setPersistenceWarningCount(warnings.length)), []);
61
+ /**
62
+ * Compaction calls the model directly and can take a while, so without this
63
+ * the spinner would sit frozen on whatever it showed before compaction
64
+ * started until onToolEnd's after-the-fact "compact" record appears.
65
+ */
66
+ useEffect(() => {
67
+ agent.onCompactStart = () => setActivity("compacting context…");
68
+ agent.onCompactEnd = () => setActivity(null);
69
+ return () => {
70
+ agent.onCompactStart = undefined;
71
+ agent.onCompactEnd = undefined;
72
+ };
73
+ }, [agent]);
47
74
  /**
48
75
  * Which workflow phase the *current turn* is running. Turn-scoped and cleared
49
76
  * when the turn ends, unlike `workflow` below — `activity` can't carry this,
@@ -355,6 +382,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
355
382
  return;
356
383
  }
357
384
  setPhase("working");
385
+ setActivity(`Calling ${provider}/${model}…`);
358
386
  const ac = new AbortController();
359
387
  abortRef.current = ac;
360
388
  setInFlight([]);
@@ -372,7 +400,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
372
400
  },
373
401
  onToolStart: (id, name, summary) => {
374
402
  setStream("");
375
- setInFlight((prev) => [...prev, { id, name, summary }]);
403
+ setInFlight((prev) => [
404
+ ...prev,
405
+ { id, name, summary, status: initialToolStatus(name) },
406
+ ]);
376
407
  },
377
408
  onToolEnd: (id, name, summary, preview, isError, resultSummary) => {
378
409
  setInFlight((prev) => prev.filter((t) => t.id !== id));
@@ -397,6 +428,10 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
397
428
  setStream("");
398
429
  setActivity(`Provider error${status ? ` (${status})` : ""} — retrying (attempt ${attempt})…`);
399
430
  },
431
+ onFallback: (from, to) => {
432
+ setStream("");
433
+ setActivity(`${from} unavailable — trying fallback model ${to}…`);
434
+ },
400
435
  onUsage: (usage) => {
401
436
  if (usage.servedModel)
402
437
  setServedModel(usage.servedModel);
@@ -498,6 +533,7 @@ export function useAgent({ agent, workspace, modelRef, providerRef, config, uiBr
498
533
  totalCost,
499
534
  tasks,
500
535
  setTasks,
536
+ persistenceWarningCount,
501
537
  ctxPct,
502
538
  setCtxPct,
503
539
  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.24-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": {