klyro 0.1.62 → 0.1.63

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.
@@ -8,14 +8,15 @@
8
8
  * 1. `system` is a top-level field, not a message with role=system.
9
9
  * 2. Tool definitions use `input_schema` not `parameters`, and have no
10
10
  * `type: 'function'` wrapper.
11
- * 3. `tool_use_id` becomes our `id`; the tool input is sent as a single
12
- * `input_json_delta` block.
11
+ * 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
12
+ * `input_json_delta` frames, assembled per content_block index (never
13
+ * assumed whole, never silently dropped).
13
14
  *
14
15
  * Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
15
16
  * Auth can be a Bearer token (for proxies) — the adapter accepts either.
16
17
  */
17
18
  import type { Message } from './message.js';
18
- import type { ProviderAdapter, ToolDefinition } from './provider-adapter.js';
19
+ import type { ProviderAdapter, StreamEvent, ToolDefinition } from './provider-adapter.js';
19
20
  export interface AnthropicAdapterOptions {
20
21
  baseURL?: string;
21
22
  apiKey: string;
@@ -50,17 +51,37 @@ interface AnthropicMessage {
50
51
  is_error?: boolean;
51
52
  }>;
52
53
  }
54
+ interface AnthropicSseEvent {
55
+ type: string;
56
+ [key: string]: unknown;
57
+ }
53
58
  export declare class AnthropicApiError extends Error {
54
59
  readonly status: number;
55
60
  readonly body: string;
56
61
  constructor(status: number, body: string);
57
62
  }
58
63
  export declare function anthropicAdapter(opts: AnthropicAdapterOptions): ProviderAdapter;
59
- /** Match an Anthropic content_block index to the tool_use id we emitted. */
60
- declare function findToolIdByIndex(index: number | undefined, buffers: Map<string, {
61
- name: string;
62
- argsJson: string;
63
- }>, indexToToolId?: Map<number, string>): string | undefined;
64
+ /**
65
+ * Mutable per-stream assembly state. Blocks are keyed by content_block
66
+ * index; the tool id is carried inside the block entry. There is no global
67
+ * "current tool" — concurrent or interleaved blocks stay correctly routed.
68
+ */
69
+ interface AnthropicStreamState {
70
+ blocks: Map<number, {
71
+ id: string;
72
+ name: string;
73
+ argsJson: string;
74
+ open: boolean;
75
+ }>;
76
+ /** Fragments for an index with no open block yet (flushed on block start). */
77
+ orphans: Map<number, string>;
78
+ thinkingIdx: number | null;
79
+ usage: {
80
+ input?: number;
81
+ output?: number;
82
+ };
83
+ }
84
+ declare function translateSse(event: string, parsed: AnthropicSseEvent, state: AnthropicStreamState): StreamEvent[];
64
85
  declare function toAnthropicMessages(messages: Message[]): AnthropicMessage[];
65
86
  declare function toAnthropicTool(t: ToolDefinition): {
66
87
  name: string;
@@ -70,6 +91,6 @@ declare function toAnthropicTool(t: ToolDefinition): {
70
91
  export declare const _internal: {
71
92
  toAnthropicMessages: typeof toAnthropicMessages;
72
93
  toAnthropicTool: typeof toAnthropicTool;
73
- findToolIdByIndex: typeof findToolIdByIndex;
94
+ translateSse: typeof translateSse;
74
95
  };
75
96
  export {};
@@ -8,8 +8,9 @@
8
8
  * 1. `system` is a top-level field, not a message with role=system.
9
9
  * 2. Tool definitions use `input_schema` not `parameters`, and have no
10
10
  * `type: 'function'` wrapper.
11
- * 3. `tool_use_id` becomes our `id`; the tool input is sent as a single
12
- * `input_json_delta` block.
11
+ * 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
12
+ * `input_json_delta` frames, assembled per content_block index (never
13
+ * assumed whole, never silently dropped).
13
14
  *
14
15
  * Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
15
16
  * Auth can be a Bearer token (for proxies) — the adapter accepts either.
@@ -111,12 +112,14 @@ async function* streamAnthropic(req, opts) {
111
112
  const reader = resp.body.getReader();
112
113
  const decoder = new TextDecoder('utf-8');
113
114
  let buf = '';
114
- // Track in-progress tool calls so we can emit start/delta/end.
115
- const toolBuffers = new Map();
116
- // Map content_block index → tool_use id (persists after tool completes to handle late deltas)
117
- const indexToToolId = new Map();
118
- // Active thinking-block index (Anthropic reasoning channel).
119
- const thinkingState = { idx: null };
115
+ // Per-block assembly keyed by content_block index (tool input IS
116
+ // fragmented across input_json_delta frames — never assume otherwise).
117
+ const state = {
118
+ blocks: new Map(),
119
+ orphans: new Map(),
120
+ thinkingIdx: null,
121
+ usage: {},
122
+ };
120
123
  // message_stop already yields message_end — don't emit a second one at EOF.
121
124
  let sawMessageEnd = false;
122
125
  try {
@@ -155,7 +158,7 @@ async function* streamAnthropic(req, opts) {
155
158
  catch {
156
159
  continue;
157
160
  }
158
- const out = translateSse(e.event, parsed, toolBuffers, indexToToolId, thinkingState);
161
+ const out = translateSse(e.event, parsed, state);
159
162
  for (const ev of out) {
160
163
  if (ev.kind === 'message_end')
161
164
  sawMessageEnd = true;
@@ -172,23 +175,69 @@ async function* streamAnthropic(req, opts) {
172
175
  finally {
173
176
  reader.releaseLock();
174
177
  }
175
- if (!sawMessageEnd)
176
- yield { kind: 'message_end', finishReason: 'stop' };
178
+ // Truncated stream: close open blocks so the runtime finalizes them as
179
+ // (malformed) structured errors instead of hanging, then terminate.
180
+ for (const b of state.blocks.values()) {
181
+ if (b.open) {
182
+ b.open = false;
183
+ yield { kind: 'tool_call_end', id: b.id };
184
+ }
185
+ }
186
+ state.blocks.clear();
187
+ // Fragments that could never be attributed to a tool block are a stream
188
+ // integrity failure — surface loudly, never silently drop.
189
+ if (state.orphans.size > 0 && !sawMessageEnd) {
190
+ const count = [...state.orphans.values()].reduce((n, s) => n + s.length, 0);
191
+ state.orphans.clear();
192
+ yield {
193
+ kind: 'error',
194
+ code: 'ORPHAN_TOOL_DELTAS',
195
+ message: `stream ended with ${count} chars of tool input that match no content block`,
196
+ retryable: false,
197
+ };
198
+ return;
199
+ }
200
+ state.orphans.clear();
201
+ if (!sawMessageEnd) {
202
+ yield {
203
+ kind: 'message_end',
204
+ finishReason: 'stop',
205
+ ...(state.usage.input !== undefined || state.usage.output !== undefined
206
+ ? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
207
+ : {}),
208
+ };
209
+ }
177
210
  }
178
- function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
211
+ function translateSse(event, parsed, state) {
179
212
  const out = [];
180
213
  switch (event) {
214
+ case 'message_start': {
215
+ const usage = parsed.message?.usage;
216
+ if (typeof usage?.input_tokens === 'number')
217
+ state.usage.input = usage.input_tokens;
218
+ return out;
219
+ }
220
+ case 'message_delta': {
221
+ const usage = parsed.usage;
222
+ if (typeof usage?.output_tokens === 'number') {
223
+ state.usage.output = (state.usage.output ?? 0) + usage.output_tokens;
224
+ }
225
+ return out;
226
+ }
181
227
  case 'content_block_start': {
182
228
  const block = parsed.content_block;
183
229
  const idx = parsed.index;
184
- if (block?.type === 'tool_use' && block.id && block.name) {
185
- toolBuffers.set(block.id, { name: block.name, argsJson: '' });
186
- if (idx !== undefined)
187
- indexToToolId.set(idx, block.id);
230
+ if (block?.type === 'tool_use' && block.id && block.name && idx !== undefined) {
231
+ // Flush any fragments that arrived before the block start.
232
+ const stashed = state.orphans.get(idx);
233
+ state.orphans.delete(idx);
234
+ state.blocks.set(idx, { id: block.id, name: block.name, argsJson: stashed ?? '', open: true });
188
235
  out.push({ kind: 'tool_call_start', id: block.id, name: block.name });
236
+ if (stashed)
237
+ out.push({ kind: 'tool_call_delta', id: block.id, argsJson: stashed });
189
238
  }
190
- else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && thinking && idx !== undefined) {
191
- thinking.idx = idx;
239
+ else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && idx !== undefined) {
240
+ state.thinkingIdx = idx;
192
241
  }
193
242
  return out;
194
243
  }
@@ -202,31 +251,50 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
202
251
  out.push({ kind: 'thinking_delta', text: delta.thinking });
203
252
  }
204
253
  else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
205
- const id = findToolIdByIndex(index, toolBuffers, indexToToolId);
206
- if (id) {
207
- const buf = toolBuffers.get(id);
208
- if (buf) {
209
- buf.argsJson += delta.partial_json;
210
- out.push({ kind: 'tool_call_delta', id, argsJson: delta.partial_json });
254
+ const entry = index !== undefined ? state.blocks.get(index) : undefined;
255
+ if (entry && entry.open) {
256
+ entry.argsJson += delta.partial_json;
257
+ out.push({ kind: 'tool_call_delta', id: entry.id, argsJson: delta.partial_json });
258
+ }
259
+ else if (index !== undefined) {
260
+ const open = [...state.blocks.values()].filter((b) => b.open);
261
+ if (open.length === 1) {
262
+ // Single in-flight tool: attribute here (documented heuristic).
263
+ open[0].argsJson += delta.partial_json;
264
+ out.push({ kind: 'tool_call_delta', id: open[0].id, argsJson: delta.partial_json });
265
+ }
266
+ else {
267
+ // No safe attribution — stash for a later block start, or
268
+ // surface as ORPHAN_TOOL_DELTAS at stream end. Never drop.
269
+ state.orphans.set(index, (state.orphans.get(index) ?? '') + delta.partial_json);
211
270
  }
212
271
  }
272
+ else {
273
+ state.orphans.set(-1, (state.orphans.get(-1) ?? '') + delta.partial_json);
274
+ }
213
275
  }
214
276
  return out;
215
277
  }
216
278
  case 'content_block_stop': {
217
279
  const index = parsed.index;
218
- if (thinking && index !== undefined && index === thinking.idx)
219
- thinking.idx = null;
220
- const id = findToolIdByIndex(index, toolBuffers, indexToToolId);
221
- if (id) {
222
- toolBuffers.delete(id);
223
- // Keep index mapping for late deltas that may arrive after stop (rare)
224
- out.push({ kind: 'tool_call_end', id });
280
+ if (index !== undefined && index === state.thinkingIdx)
281
+ state.thinkingIdx = null;
282
+ const entry = index !== undefined ? state.blocks.get(index) : undefined;
283
+ if (entry && entry.open) {
284
+ entry.open = false;
285
+ state.blocks.delete(index);
286
+ out.push({ kind: 'tool_call_end', id: entry.id });
225
287
  }
226
288
  return out;
227
289
  }
228
290
  case 'message_stop': {
229
- out.push({ kind: 'message_end', finishReason: 'stop' });
291
+ out.push({
292
+ kind: 'message_end',
293
+ finishReason: 'stop',
294
+ ...(state.usage.input !== undefined || state.usage.output !== undefined
295
+ ? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
296
+ : {}),
297
+ });
230
298
  return out;
231
299
  }
232
300
  case 'error': {
@@ -243,21 +311,6 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
243
311
  return out;
244
312
  }
245
313
  }
246
- /** Match an Anthropic content_block index to the tool_use id we emitted. */
247
- function findToolIdByIndex(index, buffers, indexToToolId) {
248
- if (index === undefined)
249
- return undefined;
250
- if (indexToToolId) {
251
- const direct = indexToToolId.get(index);
252
- if (direct)
253
- return direct;
254
- }
255
- // Single-buffer fallback: if only one in-flight tool, any delta belongs to it
256
- if (buffers.size === 1)
257
- return buffers.keys().next().value;
258
- // No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
259
- return undefined;
260
- }
261
314
  function toAnthropicMessages(messages) {
262
315
  return messages.map((m) => {
263
316
  if (m.role === 'user') {
@@ -320,4 +373,4 @@ function toAnthropicTool(t) {
320
373
  };
321
374
  }
322
375
  // Re-export for testability.
323
- export const _internal = { toAnthropicMessages, toAnthropicTool, findToolIdByIndex };
376
+ export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse };
@@ -195,10 +195,41 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
195
195
  const reader = res.body.getReader();
196
196
  const decoder = new TextDecoder();
197
197
  let buf = '';
198
- // Track per-tool-call id by index.
199
- const toolIds = new Map();
200
- const toolNames = new Map();
198
+ const blocks = new Map();
201
199
  let pendingUsage;
200
+ // Canonical contract: exactly one terminal event per stream.
201
+ let terminalEmitted = false;
202
+ function* emitTerminal(finishReason, usage) {
203
+ if (terminalEmitted)
204
+ return;
205
+ terminalEmitted = true;
206
+ for (const b of blocks.values()) {
207
+ if (b.started && !b.ended && b.id) {
208
+ b.ended = true;
209
+ yield { kind: 'tool_call_end', id: b.id };
210
+ }
211
+ }
212
+ // Fragments that never gained an identity are surfaced as incomplete
213
+ // calls (the runtime turns them into structured errors) — never dropped.
214
+ let incomplete = 0;
215
+ for (const b of blocks.values()) {
216
+ if (!b.started && (b.argsJson || b.id || b.name)) {
217
+ const id = b.id ?? `incomplete_${incomplete++}`;
218
+ yield { kind: 'tool_call_start', id, name: b.name ?? 'unknown' };
219
+ if (b.argsJson)
220
+ yield { kind: 'tool_call_delta', id, argsJson: b.argsJson };
221
+ yield { kind: 'tool_call_end', id };
222
+ b.started = true;
223
+ b.ended = true;
224
+ }
225
+ }
226
+ const end = { kind: 'message_end' };
227
+ if (finishReason)
228
+ end.finishReason = finishReason;
229
+ if (usage)
230
+ end.usage = usage;
231
+ yield end;
232
+ }
202
233
  try {
203
234
  while (true) {
204
235
  const { value, done } = await reader.read();
@@ -215,7 +246,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
215
246
  continue;
216
247
  const data = line.slice(5).trim();
217
248
  if (data === '[DONE]') {
218
- yield { kind: 'message_end' };
249
+ yield* emitTerminal(undefined, pendingUsage);
219
250
  return;
220
251
  }
221
252
  let chunk;
@@ -248,17 +279,28 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
248
279
  yield { kind: 'thinking_delta', text: thinking };
249
280
  }
250
281
  for (const tc of choice.delta.tool_calls ?? []) {
251
- if (tc.id && tc.function?.name) {
252
- toolIds.set(tc.index, tc.id);
253
- toolNames.set(tc.index, tc.function.name);
254
- yield { kind: 'tool_call_start', id: tc.id, name: tc.function.name };
282
+ let b = blocks.get(tc.index);
283
+ if (!b) {
284
+ b = { argsJson: '', started: false, ended: false };
285
+ blocks.set(tc.index, b);
255
286
  }
256
- else if (tc.id) {
257
- toolIds.set(tc.index, tc.id);
287
+ if (tc.id)
288
+ b.id = tc.id;
289
+ if (tc.function?.name)
290
+ b.name = tc.function.name;
291
+ const newArgs = tc.function?.arguments;
292
+ if (newArgs)
293
+ b.argsJson += newArgs;
294
+ if (b.id && b.name && !b.started) {
295
+ // Identity arrived (possibly after earlier fragments). Emit
296
+ // start, then flush any accumulated args as one delta.
297
+ b.started = true;
298
+ yield { kind: 'tool_call_start', id: b.id, name: b.name };
299
+ if (b.argsJson)
300
+ yield { kind: 'tool_call_delta', id: b.id, argsJson: b.argsJson };
258
301
  }
259
- if (tc.function?.arguments) {
260
- const id = toolIds.get(tc.index) ?? `call_${tc.index}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
261
- yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
302
+ else if (b.started && b.id && newArgs) {
303
+ yield { kind: 'tool_call_delta', id: b.id, argsJson: newArgs };
262
304
  }
263
305
  }
264
306
  if (choice.finish_reason) {
@@ -266,38 +308,19 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
266
308
  ? { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens }
267
309
  : undefined);
268
310
  pendingUsage = undefined;
269
- // Clear tool tracking per message to avoid stale ids on next turn
270
- const ids = [...toolIds.values()];
271
- toolIds.clear();
272
- toolNames.clear();
273
- for (const id of ids)
274
- yield { kind: 'tool_call_end', id };
275
- yield { kind: 'message_end', finishReason: choice.finish_reason, usage };
311
+ yield* emitTerminal(choice.finish_reason, usage);
276
312
  }
277
313
  }
278
314
  }
279
315
  }
280
316
  }
281
- if (toolIds.size) {
282
- for (const id of toolIds.values())
283
- yield { kind: 'tool_call_end', id };
284
- if (pendingUsage) {
285
- yield { kind: 'message_end', usage: pendingUsage };
286
- }
287
- else {
288
- yield { kind: 'message_end' };
289
- }
290
- }
291
- else if (pendingUsage) {
292
- yield { kind: 'message_end', usage: pendingUsage };
293
- }
294
- else {
295
- yield { kind: 'message_end' };
296
- }
317
+ yield* emitTerminal(undefined, pendingUsage);
297
318
  }
298
319
  catch (err) {
299
- const msg = err instanceof Error ? err.message : String(err);
300
- yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
320
+ if (!terminalEmitted) {
321
+ const msg = err instanceof Error ? err.message : String(err);
322
+ yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
323
+ }
301
324
  }
302
325
  finally {
303
326
  clearTimeout(timer);
@@ -139,6 +139,7 @@ export type RuntimeEvent = {
139
139
  kind: 'usage';
140
140
  input: number;
141
141
  output: number;
142
+ estimated?: boolean;
142
143
  } | {
143
144
  kind: 'final_text';
144
145
  text: string;
@@ -171,7 +172,7 @@ export type RuntimeEvent = {
171
172
  sessionId: string;
172
173
  };
173
174
  export interface RunResult {
174
- status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
175
+ status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked' | 'stuck';
175
176
  steps: number;
176
177
  toolCalls: number;
177
178
  finalText: string;
@@ -181,6 +182,7 @@ export interface RunResult {
181
182
  usage: {
182
183
  input: number;
183
184
  output: number;
185
+ estimated?: boolean;
184
186
  };
185
187
  /** Number of policy-driven user prompts the user accepted. */
186
188
  repairs?: number;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { text, toolUse, toolResult as mkToolResult } from './message.js';
18
18
  import { redact } from '../policy/secret-redactor.js';
19
+ import { patternForCall } from '../policy/patterns.js';
19
20
  import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
20
21
  import * as path from 'node:path';
21
22
  import { verify, diagnosticForModel } from '../verification/engine.js';
@@ -173,6 +174,8 @@ export async function run(opts, deps) {
173
174
  // 5.2 — stuck detection state
174
175
  const callHistory = [];
175
176
  const fileEditCounts = new Map();
177
+ let stuckTriggers = 0;
178
+ let stuckAbort = false;
176
179
  outer: while (steps < maxSteps) {
177
180
  // 5.1 limits: max-cost, max-time
178
181
  if (maxCost !== undefined) {
@@ -273,6 +276,17 @@ export async function run(opts, deps) {
273
276
  telemetry.recordUsage(ev.usage.input, ev.usage.output);
274
277
  emit?.({ kind: 'usage', input: usage.input, output: usage.output });
275
278
  }
279
+ else {
280
+ // Providers that omit usage (Ollama, vLLM, proxies): estimate from
281
+ // the actual request + generated output so cost accounting never
282
+ // silently records zero. Marked estimated for the UI/debugging.
283
+ const est = estimateTurnUsage(reqSystem, reqMessages, textBuf, pendingToolCalls);
284
+ usage.input += est.input;
285
+ usage.output += est.output;
286
+ usage.estimated = true;
287
+ telemetry.recordUsage(est.input, est.output);
288
+ emit?.({ kind: 'usage', input: usage.input, output: usage.output, estimated: true });
289
+ }
276
290
  }
277
291
  else if (ev.kind === 'error') {
278
292
  telemetry.recordError(`stream_error: ${ev.code}`);
@@ -296,22 +310,47 @@ export async function run(opts, deps) {
296
310
  };
297
311
  }
298
312
  }
299
- // Build the assistant message.
313
+ // Build the assistant message. Tool calls are finalized here: JSON is
314
+ // parsed and schema-validated BEFORE policy/execution. Malformed calls
315
+ // become structured MALFORMED_TOOL_CALL results — garbage arguments must
316
+ // never reach a real tool.
300
317
  const assistantContent = [];
301
318
  if (textBuf)
302
319
  assistantContent.push(text(textBuf));
303
320
  const finalizedCalls = [];
321
+ let hadInvalidTool = false;
304
322
  for (const tc of pendingToolCalls.values()) {
305
- let input = {};
306
- try {
307
- input = JSON.parse(tc.argsJson || '{}');
308
- }
309
- catch {
310
- input = { _parse_error: true, raw: tc.argsJson };
323
+ const validated = validateFinalizedCall(deps.registry, tc.id, tc.name, tc.argsJson);
324
+ if (!validated.ok) {
325
+ hadInvalidTool = true;
326
+ // Record the model's tool_use (empty input — safe to replay to any
327
+ // provider) and answer it immediately with a structured error.
328
+ assistantContent.push(toolUse(tc.id, tc.name, {}));
329
+ emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: {} });
330
+ const errMsg = {
331
+ role: 'tool',
332
+ content: [mkToolResult(tc.id, tc.name, validated.output, true)],
333
+ };
334
+ transcript.push(errMsg);
335
+ await checkpoint(errMsg, {
336
+ toolCallId: tc.id, toolName: tc.name,
337
+ input: { raw: redact(tc.argsJson).slice(0, 500) }, output: validated.output, isError: true,
338
+ });
339
+ let attempted = {};
340
+ try {
341
+ attempted = JSON.parse(tc.argsJson);
342
+ }
343
+ catch {
344
+ attempted = {};
345
+ }
346
+ telemetry.recordToolError(toolUse(tc.id, tc.name, attempted), validated.code);
347
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
348
+ emit?.({ kind: 'tool_result', id: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
349
+ continue;
311
350
  }
312
- finalizedCalls.push(toolUse(tc.id, tc.name, input));
313
- assistantContent.push(toolUse(tc.id, tc.name, input));
314
- emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input });
351
+ finalizedCalls.push(toolUse(tc.id, tc.name, validated.input));
352
+ assistantContent.push(toolUse(tc.id, tc.name, validated.input));
353
+ emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: validated.input });
315
354
  }
316
355
  const assistantMsg = { role: 'assistant', content: assistantContent };
317
356
  transcript.push(assistantMsg);
@@ -332,6 +371,14 @@ export async function run(opts, deps) {
332
371
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
333
372
  }
334
373
  if (finalizedCalls.length === 0) {
374
+ if (hadInvalidTool) {
375
+ // The model attempted a tool call that failed validation; the error is
376
+ // already a tool_result in the transcript — loop so the model can
377
+ // repair next turn instead of treating an answerable error as a
378
+ // completion. Bounded by maxSteps and stuck detection (P0-3).
379
+ emit?.({ kind: 'step_end', step: steps });
380
+ continue;
381
+ }
335
382
  finalText = textBuf;
336
383
  // Level 8 — Verification + Autonomous Repair (gated on hasEdits below — pure analysis skips verify)
337
384
  const verifyEnabled = opts.verify?.enabled !== false;
@@ -528,8 +575,11 @@ export async function run(opts, deps) {
528
575
  sessionId,
529
576
  };
530
577
  const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
531
- const runOne = async (call) => {
532
- // Use per-call handling without continue (runOne is not a loop)
578
+ // Gate phase: policy decision + approval prompt for one call. Runs
579
+ // sequentially (approval UI is one-modal-at-a-time). Commits deny/user-deny
580
+ // results immediately — gate runs in call order so these stay ordered.
581
+ // Returns true when the call is approved for execution.
582
+ const gateCall = async (call) => {
533
583
  const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
534
584
  emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
535
585
  // Mirror to KlyroEvent bus
@@ -546,7 +596,7 @@ export async function run(opts, deps) {
546
596
  emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
547
597
  telemetry.recordToolError(call, 'policy_denied');
548
598
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
549
- return;
599
+ return false;
550
600
  }
551
601
  if (decision.action === 'ask') {
552
602
  emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
@@ -554,6 +604,8 @@ export async function run(opts, deps) {
554
604
  toolName: call.name,
555
605
  reason: decision.reason,
556
606
  summary: summarizeToolCall(call),
607
+ input: call.input,
608
+ pattern: patternForCall(call.name, call.input),
557
609
  });
558
610
  // Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
559
611
  if (choice === 'deny') {
@@ -567,15 +619,49 @@ export async function run(opts, deps) {
567
619
  await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
568
620
  telemetry.recordToolError(call, 'user_denied');
569
621
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
570
- return;
622
+ return false;
571
623
  }
572
624
  // Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
573
625
  repairs++;
574
626
  }
627
+ return true;
628
+ };
629
+ // Execute phase: run the tool with no transcript writes, so concurrent
630
+ // executions can't interleave. A throw here becomes a tool error (an
631
+ // executor crash must never kill the step).
632
+ const execTool = async (call) => {
575
633
  const t0 = Date.now();
576
634
  emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
577
- const obs = await deps.registry.execute(call.name, call.input, toolCtx);
635
+ let obs;
636
+ try {
637
+ obs = await deps.registry.execute(call.name, call.input, toolCtx);
638
+ }
639
+ catch (err) {
640
+ obs = { ok: false, error: { code: 'EXEC_CRASH', message: err instanceof Error ? err.message : String(err) } };
641
+ }
578
642
  const latencyMs = Date.now() - t0;
643
+ return { obs, latencyMs };
644
+ };
645
+ // 5.2 stuck termination (P0-3): the FIRST detection injects one
646
+ // "change approach" synthetic message for the next model turn; the SECOND
647
+ // detection aborts the automated loop with a `stuck` status so a repeated
648
+ // tool loop never burns unbounded tokens. Covers both signals: identical
649
+ // call ×3 and same-file edited >8×.
650
+ const markStuck = async (msg) => {
651
+ stuckTriggers++;
652
+ emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: msg });
653
+ if (stuckTriggers === 1) {
654
+ const note = { role: 'user', content: [text(`[system note] Stuck detected: ${msg}. Stop repeating the same action; change approach.`)] };
655
+ transcript.push(note);
656
+ await checkpoint(note);
657
+ }
658
+ else {
659
+ stuckAbort = true;
660
+ }
661
+ };
662
+ // Commit phase: fold one execution result into the transcript, in original
663
+ // call order. The only writer — call sequentially, never concurrently.
664
+ const commitResult = async (call, obs, latencyMs) => {
579
665
  const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
580
666
  const toolMsg = {
581
667
  role: 'tool',
@@ -620,7 +706,7 @@ export async function run(opts, deps) {
620
706
  const cnt = (fileEditCounts.get(fileChanged.path) ?? 0) + 1;
621
707
  fileEditCounts.set(fileChanged.path, cnt);
622
708
  if (cnt > 8) {
623
- emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: `same file edited >8×: ${fileChanged.path}` });
709
+ await markStuck(`same file edited >8×: ${fileChanged.path}`);
624
710
  }
625
711
  }
626
712
  }
@@ -631,23 +717,42 @@ export async function run(opts, deps) {
631
717
  callHistory.shift();
632
718
  const last3 = callHistory.slice(-3);
633
719
  if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
634
- emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: `identical call ×3: ${sig}` });
635
- // Inject system note for next turn
636
- const note = { role: 'user', content: [text(`[system note] Stuck detected: identical call ×3: ${sig}. Try a different approach.`)] };
637
- transcript.push(note);
638
- await checkpoint(note);
720
+ await markStuck(`identical call ×3: ${sig}`);
639
721
  }
640
722
  };
641
- // 3.5 — parallel if all concurrencySafe, sequential otherwise
642
- // BUG-002: preserve call order — run sequentially even when allSafe to avoid out-of-order transcript
643
- // Parallel execution previously pushed tool_results out of order via Promise.all
723
+ // Sequential path: gate → execute → commit per call, in order.
724
+ const runOne = async (call) => {
725
+ if (!(await gateCall(call)))
726
+ return;
727
+ const { obs, latencyMs } = await execTool(call);
728
+ await commitResult(call, obs, latencyMs);
729
+ };
730
+ // 3.5 — parallel when every call is concurrencySafe, sequential otherwise.
731
+ // Gate runs sequentially in both paths (approval UI is one-at-a-time).
732
+ // Parallel path executes concurrently but commits in original call order,
733
+ // so the transcript reads exactly as if the calls ran in order (this is
734
+ // what the old BUG-002 sequential fallback was protecting).
644
735
  if (allSafe) {
736
+ const approved = [];
645
737
  for (const call of finalizedCalls) {
646
738
  toolCallCount++;
647
- await runOne(call);
739
+ if (await gateCall(call))
740
+ approved.push(call);
648
741
  if (opts.signal?.aborted)
649
742
  break;
650
743
  }
744
+ if (approved.length > 0 && !opts.signal?.aborted) {
745
+ const settled = await Promise.allSettled(approved.map((c) => execTool(c)));
746
+ for (let i = 0; i < approved.length; i++) {
747
+ const s = settled[i];
748
+ if (s.status === 'fulfilled') {
749
+ await commitResult(approved[i], s.value.obs, s.value.latencyMs);
750
+ }
751
+ else {
752
+ await commitResult(approved[i], { ok: false, error: { code: 'EXEC_CRASH', message: String(s.reason) } }, 0);
753
+ }
754
+ }
755
+ }
651
756
  }
652
757
  else {
653
758
  for (const call of finalizedCalls) {
@@ -666,6 +771,9 @@ export async function run(opts, deps) {
666
771
  }
667
772
  catch { /* ignore */ }
668
773
  }
774
+ // 5.2 — terminate the automated loop once stuck recurs after the note
775
+ if (stuckAbort)
776
+ break;
669
777
  }
670
778
  if (opts.signal?.aborted) {
671
779
  emit?.({ kind: 'aborted' });
@@ -678,6 +786,18 @@ export async function run(opts, deps) {
678
786
  await closeTracer();
679
787
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
680
788
  }
789
+ // 5.2 — stuck termination (P0-3): bounded stop instead of unbounded token burn.
790
+ if (stuckAbort) {
791
+ emit?.({ kind: 'final_text', text: finalText });
792
+ if (store && sessionId) {
793
+ try {
794
+ await store.setStatus(sessionId, 'stuck', finalText);
795
+ }
796
+ catch { /* ignore */ }
797
+ }
798
+ await closeTracer();
799
+ return { status: 'stuck', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
800
+ }
681
801
  emit?.({ kind: 'final_text', text: finalText });
682
802
  if (store && sessionId) {
683
803
  try {
@@ -688,6 +808,88 @@ export async function run(opts, deps) {
688
808
  await closeTracer();
689
809
  return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
690
810
  }
811
+ /**
812
+ * Estimate usage for a turn whose provider omitted it (Ollama, vLLM,
813
+ * proxies). Input is measured from the actual request via the tokenizer;
814
+ * output is a chars/4 heuristic over generated text + tool arguments.
815
+ */
816
+ function estimateTurnUsage(system, messages, textOut, toolCalls) {
817
+ let argsChars = 0;
818
+ for (const tc of toolCalls.values())
819
+ argsChars += tc.argsJson.length;
820
+ return {
821
+ input: totalTokens(system, messages),
822
+ output: Math.max(1, Math.ceil((textOut.length + argsChars) / 4)),
823
+ };
824
+ }
825
+ /**
826
+ * Validate one assembled tool call before it reaches policy or execution.
827
+ * Returns the parsed+schema-validated input, or a structured error output
828
+ * (MALFORMED_TOOL_CALL / UNKNOWN_TOOL) that the runtime records as a tool
829
+ * result without executing anything.
830
+ */
831
+ function validateFinalizedCall(registry, id, name, argsJson) {
832
+ void id;
833
+ let parsed = {};
834
+ if (argsJson.trim()) {
835
+ try {
836
+ parsed = JSON.parse(argsJson);
837
+ }
838
+ catch {
839
+ return {
840
+ ok: false,
841
+ code: 'MALFORMED_TOOL_CALL',
842
+ output: {
843
+ code: 'MALFORMED_TOOL_CALL',
844
+ tool: name,
845
+ message: 'Tool arguments were incomplete or invalid JSON. Re-issue the call with complete arguments.',
846
+ retryable: true,
847
+ },
848
+ };
849
+ }
850
+ }
851
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
852
+ return {
853
+ ok: false,
854
+ code: 'MALFORMED_TOOL_CALL',
855
+ output: {
856
+ code: 'MALFORMED_TOOL_CALL',
857
+ tool: name,
858
+ message: 'Tool arguments must be a JSON object. Re-issue the call with complete arguments.',
859
+ retryable: true,
860
+ },
861
+ };
862
+ }
863
+ const tool = registry.get(name);
864
+ if (!tool) {
865
+ return {
866
+ ok: false,
867
+ code: 'UNKNOWN_TOOL',
868
+ output: {
869
+ code: 'MALFORMED_TOOL_CALL',
870
+ tool: name,
871
+ message: `Unknown tool "${name}". Use one of the available tools.`,
872
+ retryable: true,
873
+ },
874
+ };
875
+ }
876
+ const checked = tool.inputSchema.safeParse(parsed);
877
+ if (!checked.success) {
878
+ const first = checked.error.issues[0];
879
+ const detail = first ? ` (${first.path.join('.') || 'input'}: ${first.message})` : '';
880
+ return {
881
+ ok: false,
882
+ code: 'MALFORMED_TOOL_CALL',
883
+ output: {
884
+ code: 'MALFORMED_TOOL_CALL',
885
+ tool: name,
886
+ message: `Tool arguments failed validation${detail}. Re-issue the call with correct arguments.`,
887
+ retryable: true,
888
+ },
889
+ };
890
+ }
891
+ return { ok: true, input: parsed };
892
+ }
691
893
  function redactOutput(v) {
692
894
  if (typeof v === 'string')
693
895
  return redact(v);
@@ -34,6 +34,27 @@ declare function parseValue(raw: string): unknown;
34
34
  export declare function loadConfig(): Promise<Record<string, unknown>>;
35
35
  export declare function loadConfigSync(): Record<string, unknown>;
36
36
  export declare function loadMergedConfig(cwd?: string, flags?: Record<string, unknown>): Promise<Record<string, unknown>>;
37
+ export interface PermissionRules {
38
+ allow: string[];
39
+ deny: string[];
40
+ ask: string[];
41
+ }
42
+ /**
43
+ * Permission glob rules (`tool(glob)` grammar) from the merged config
44
+ * layers — home settings, project settings, project local. Fed into the
45
+ * policy engine at startup so persisted "always allow" patterns apply
46
+ * without re-prompting.
47
+ */
48
+ export declare function loadPermissionRules(cwd?: string): Promise<PermissionRules>;
49
+ /**
50
+ * Persist an "always allow" pattern to the home settings file
51
+ * (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
52
+ * added (false when already present) and the file written.
53
+ */
54
+ export declare function persistAllowRule(rule: string): Promise<{
55
+ added: boolean;
56
+ path: string;
57
+ }>;
37
58
  export declare function saveConfig(obj: Record<string, unknown>): Promise<void>;
38
59
  export declare function runConfig(args: string[]): Promise<number>;
39
60
  export declare const _helpers: {
@@ -320,6 +320,37 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
320
320
  }
321
321
  return merged;
322
322
  }
323
+ function asStringArray(v) {
324
+ return Array.isArray(v) ? v.filter((e) => typeof e === 'string') : [];
325
+ }
326
+ /**
327
+ * Permission glob rules (`tool(glob)` grammar) from the merged config
328
+ * layers — home settings, project settings, project local. Fed into the
329
+ * policy engine at startup so persisted "always allow" patterns apply
330
+ * without re-prompting.
331
+ */
332
+ export async function loadPermissionRules(cwd = process.cwd()) {
333
+ const merged = await loadMergedConfig(cwd, {});
334
+ return {
335
+ allow: asStringArray(merged.allow),
336
+ deny: asStringArray(merged.deny),
337
+ ask: asStringArray(merged.ask),
338
+ };
339
+ }
340
+ /**
341
+ * Persist an "always allow" pattern to the home settings file
342
+ * (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
343
+ * added (false when already present) and the file written.
344
+ */
345
+ export async function persistAllowRule(rule) {
346
+ const cfg = await loadConfig();
347
+ const allow = asStringArray(cfg.allow);
348
+ if (allow.includes(rule))
349
+ return { added: false, path: getConfigPath() };
350
+ cfg.allow = [...allow, rule];
351
+ await saveConfig(cfg);
352
+ return { added: true, path: getConfigPath() };
353
+ }
323
354
  export async function saveConfig(obj) {
324
355
  const p = getConfigPath();
325
356
  await fs.mkdir(path.dirname(p), { recursive: true });
package/dist/cli/repl.js CHANGED
@@ -16,7 +16,7 @@ import { run } from '../agent/runtime.js';
16
16
  import { builtinRegistry } from '../tools/registry.js';
17
17
  import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
18
18
  import { buildLevel6Context } from '../context/level6.js';
19
- import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
19
+ import { DenyAllApprovalPrompt, PatternApprovalCache, StdinApprovalPrompt } from '../policy/approval.js';
20
20
  import { TuiApprovalBridge } from '../tui/approval.js';
21
21
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
22
22
  import { parse } from './slash/parser.js';
@@ -72,6 +72,15 @@ export async function startRepl(opts = {}) {
72
72
  // Clone: /mode and /sandbox mutate this config — it must never leak into
73
73
  // the shared DEFAULT_POLICY_CONFIG across sessions.
74
74
  const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
75
+ // Persisted permission rules (home + project settings layers) — "always"
76
+ // choices from previous sessions apply without re-prompting.
77
+ try {
78
+ const { loadPermissionRules } = await import('./config.js');
79
+ policy.applyRules(await loadPermissionRules(cwd));
80
+ }
81
+ catch {
82
+ /* ignore — engine defaults stand */
83
+ }
75
84
  const providerKind = inferProviderFromBaseURL(baseUrl);
76
85
  // Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
77
86
  // via proxy — don't try anthropic adapter with empty key (would 401).
@@ -105,9 +114,29 @@ export async function startRepl(opts = {}) {
105
114
  // App and the runtime so the modal can resolve the runtime's ask().
106
115
  const tuiBridge = new TuiApprovalBridge();
107
116
  const useTui = opts.forceTty || process.stdin.isTTY;
108
- const approval = opts.nonInteractive
117
+ // Ask-once-per-pattern: session cache + optional persist. `a` records for
118
+ // the session, `A` additionally appends the pattern to settings and the
119
+ // live engine (matches the modal's [a] session / [A] always→settings).
120
+ const approvalBase = opts.nonInteractive
109
121
  ? new DenyAllApprovalPrompt()
110
122
  : (useTui ? tuiBridge : new StdinApprovalPrompt());
123
+ const approval = opts.nonInteractive
124
+ ? approvalBase
125
+ : new PatternApprovalCache(approvalBase, {
126
+ onPersist: async (pattern) => {
127
+ const { persistAllowRule } = await import('./config.js');
128
+ const res = await persistAllowRule(pattern);
129
+ policy.addAllow(pattern);
130
+ queuedAppend({
131
+ id: `allow-${Date.now()}`,
132
+ kind: 'text',
133
+ text: res.added
134
+ ? `allowed always: ${pattern} (saved to ${res.path} — revoke by deleting the line)`
135
+ : `allowed always: ${pattern} (already in ${res.path})`,
136
+ role: 'assistant',
137
+ });
138
+ },
139
+ });
111
140
  let inflight = null;
112
141
  let lastStatus = null;
113
142
  const pendingQueue = [];
package/dist/cli/run.js CHANGED
@@ -59,6 +59,14 @@ export async function runOnce(opts) {
59
59
  }
60
60
  const registry = builtinRegistry();
61
61
  const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
62
+ // Persisted "always allow" patterns apply to one-shot runs too.
63
+ try {
64
+ const { loadPermissionRules } = await import('./config.js');
65
+ policy.applyRules(await loadPermissionRules(opts.cwd));
66
+ }
67
+ catch {
68
+ /* ignore — engine defaults stand */
69
+ }
62
70
  const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
63
71
  // Level 9 — session setup (create or resume)
64
72
  const persistEnabled = opts.persist !== false;
@@ -193,6 +201,7 @@ export async function runOnce(opts) {
193
201
  aborted: 'aborted',
194
202
  no_final: 'aborted',
195
203
  verify_failed: 'verify_failed',
204
+ stuck: 'stuck',
196
205
  };
197
206
  try {
198
207
  await store.setStatus(sessionId, statusMap[result.status] ?? 'complete', result.finalText);
@@ -9,7 +9,7 @@
9
9
  * ~50-task eval suite. v1.0 can swap in SQLite behind the same
10
10
  * SessionStore interface.
11
11
  */
12
- export type SessionStatus = 'open' | 'complete' | 'verify_failed' | 'aborted' | 'max_steps';
12
+ export type SessionStatus = 'open' | 'complete' | 'verify_failed' | 'aborted' | 'max_steps' | 'stuck';
13
13
  export interface SessionConfig {
14
14
  model: string;
15
15
  maxSteps: number;
@@ -5,12 +5,22 @@
5
5
  * Non-TTY: returns deny. Callers should treat non-TTY as the default
6
6
  * (the user opted out of prompts with `--yes` or pipe input).
7
7
  */
8
- export type ApprovalChoice = 'allow' | 'deny' | 'always';
8
+ export type ApprovalChoice =
9
+ /** Yes, just this once. */
10
+ 'allow' | 'deny'
11
+ /** Yes, and auto-allow this pattern for the rest of the session. */
12
+ | 'always'
13
+ /** Yes, session-allow AND persist the pattern to settings (survives restarts). */
14
+ | 'always-persist';
9
15
  export interface ApprovalRequest {
10
16
  toolName: string;
11
17
  reason: string;
12
18
  /** Best-effort summary of the call (command or path). */
13
19
  summary: string;
20
+ /** Raw tool input — used to derive the approval pattern when `pattern` is absent. */
21
+ input?: Record<string, unknown>;
22
+ /** Pre-derived approval pattern (`tool(glob)` grammar). */
23
+ pattern?: string;
14
24
  }
15
25
  export interface ApprovalPrompt {
16
26
  ask(req: ApprovalRequest): Promise<ApprovalChoice>;
@@ -24,12 +34,23 @@ export declare class DenyAllApprovalPrompt implements ApprovalPrompt {
24
34
  ask(_req: ApprovalRequest): Promise<ApprovalChoice>;
25
35
  }
26
36
  /**
27
- * In-memory allowlist: tracks "always" choices by command prefix so a
28
- * single prompt per command covers repeat invocations in the same session.
37
+ * Pattern approval cache — ask-once-per-pattern (Claude-Code-like).
38
+ *
39
+ * Wraps any inner prompt (TUI modal, stdin). The first `ask` for a pattern
40
+ * delegates to the user; `always` records the pattern for the session,
41
+ * `always-persist` additionally calls `onPersist` (the CLI wires this to
42
+ * append the pattern to ~/.klyro/settings.json and the live engine).
43
+ * Re-prompts never happen for a recorded pattern within the session.
44
+ *
45
+ * The pattern comes from `req.pattern` (pre-derived by the runtime via
46
+ * `patternForCall`) or is derived from `req.input` here as a fallback.
29
47
  */
30
- export declare class InMemoryAllowlist implements ApprovalPrompt {
48
+ export declare class PatternApprovalCache implements ApprovalPrompt {
31
49
  private readonly inner;
32
- private readonly allow;
33
- constructor(inner?: ApprovalPrompt);
50
+ private readonly opts;
51
+ private readonly session;
52
+ constructor(inner?: ApprovalPrompt, opts?: {
53
+ onPersist?: (pattern: string) => void | Promise<void>;
54
+ });
34
55
  ask(req: ApprovalRequest): Promise<ApprovalChoice>;
35
56
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as readline from 'node:readline/promises';
9
9
  import { stdin as input, stdout as output } from 'node:process';
10
+ import { patternForCall } from './patterns.js';
10
11
  /** Default prompt backed by readline on stdin/stdout. */
11
12
  export class StdinApprovalPrompt {
12
13
  async ask(req) {
@@ -34,21 +35,48 @@ export class DenyAllApprovalPrompt {
34
35
  }
35
36
  }
36
37
  /**
37
- * In-memory allowlist: tracks "always" choices by command prefix so a
38
- * single prompt per command covers repeat invocations in the same session.
38
+ * Pattern approval cache — ask-once-per-pattern (Claude-Code-like).
39
+ *
40
+ * Wraps any inner prompt (TUI modal, stdin). The first `ask` for a pattern
41
+ * delegates to the user; `always` records the pattern for the session,
42
+ * `always-persist` additionally calls `onPersist` (the CLI wires this to
43
+ * append the pattern to ~/.klyro/settings.json and the live engine).
44
+ * Re-prompts never happen for a recorded pattern within the session.
45
+ *
46
+ * The pattern comes from `req.pattern` (pre-derived by the runtime via
47
+ * `patternForCall`) or is derived from `req.input` here as a fallback.
39
48
  */
40
- export class InMemoryAllowlist {
49
+ export class PatternApprovalCache {
41
50
  inner;
42
- allow = new Set();
43
- constructor(inner = new DenyAllApprovalPrompt()) {
51
+ opts;
52
+ session = new Set();
53
+ constructor(inner = new DenyAllApprovalPrompt(), opts = {}) {
44
54
  this.inner = inner;
55
+ this.opts = opts;
45
56
  }
46
57
  async ask(req) {
47
- if (this.allow.has(req.summary))
58
+ const key = req.pattern ?? patternFromRequest(req);
59
+ if (key && this.session.has(key))
48
60
  return 'allow';
49
61
  const choice = await this.inner.ask(req);
50
- if (choice === 'always')
51
- this.allow.add(req.summary);
62
+ if ((choice === 'always' || choice === 'always-persist') && key) {
63
+ this.session.add(key);
64
+ }
65
+ if (choice === 'always-persist' && key) {
66
+ // Best-effort: the current call is already approved via the session
67
+ // set above — a persist failure must not retro-deny it.
68
+ try {
69
+ await this.opts.onPersist?.(key);
70
+ }
71
+ catch {
72
+ /* ignore */
73
+ }
74
+ }
52
75
  return choice;
53
76
  }
54
77
  }
78
+ function patternFromRequest(req) {
79
+ if (!req.input)
80
+ return undefined;
81
+ return patternForCall(req.toolName, req.input);
82
+ }
@@ -67,6 +67,19 @@ export declare class PolicyEngine {
67
67
  nonInteractive: boolean;
68
68
  }): Promise<Decision>;
69
69
  private evaluateGlobRules;
70
+ /**
71
+ * Session rule management — appends a glob rule (`tool(glob)` grammar)
72
+ * if not already present. Used for persisted "always allow" patterns
73
+ * (loaded at startup) and live additions (approval `always→settings`).
74
+ */
75
+ addAllow(rule: string): void;
76
+ addDeny(rule: string): void;
77
+ addAsk(rule: string): void;
78
+ applyRules(rules: {
79
+ allow?: string[];
80
+ deny?: string[];
81
+ ask?: string[];
82
+ }): void;
70
83
  }
71
84
  /** Builtin set of rules. Order matters: first match wins. */
72
85
  export declare function builtinRules(): PolicyRule[];
@@ -116,6 +116,34 @@ export class PolicyEngine {
116
116
  // Precedence: deny → allow → ask
117
117
  return check(this.config.deny, 'deny') ?? check(this.config.allow, 'allow') ?? check(this.config.ask, 'ask') ?? null;
118
118
  }
119
+ /**
120
+ * Session rule management — appends a glob rule (`tool(glob)` grammar)
121
+ * if not already present. Used for persisted "always allow" patterns
122
+ * (loaded at startup) and live additions (approval `always→settings`).
123
+ */
124
+ addAllow(rule) {
125
+ const list = (this.config.allow ??= []);
126
+ if (!list.includes(rule))
127
+ list.push(rule);
128
+ }
129
+ addDeny(rule) {
130
+ const list = (this.config.deny ??= []);
131
+ if (!list.includes(rule))
132
+ list.push(rule);
133
+ }
134
+ addAsk(rule) {
135
+ const list = (this.config.ask ??= []);
136
+ if (!list.includes(rule))
137
+ list.push(rule);
138
+ }
139
+ applyRules(rules) {
140
+ for (const r of rules.allow ?? [])
141
+ this.addAllow(r);
142
+ for (const r of rules.deny ?? [])
143
+ this.addDeny(r);
144
+ for (const r of rules.ask ?? [])
145
+ this.addAsk(r);
146
+ }
119
147
  }
120
148
  /** Builtin set of rules. Order matters: first match wins. */
121
149
  export function builtinRules() {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Approval patterns — the unit of "always allow this pattern".
3
+ *
4
+ * A pattern uses the same `tool(glob)` grammar the policy engine matches
5
+ * (`matchesGlobRule` in engine.ts), e.g. `shell_exec(npm *)`,
6
+ * `write_file(src/index.ts)`. Derivation is deterministic: the same call
7
+ * shape always yields the same pattern, so session caching can key on exact
8
+ * pattern equality and persisted patterns re-match across sessions.
9
+ *
10
+ * Scoping (Claude-Code-like):
11
+ * - shell_exec / run_verify → first command word + ` *`
12
+ * (`npm test` and `npm run build` share `shell_exec(npm *)`)
13
+ * - file tools with a path → the exact path (safest: no wildcards)
14
+ * - everything else → the bare tool name (whole-tool session allow)
15
+ */
16
+ export declare function patternForCall(name: string, input: Record<string, unknown>): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Approval patterns — the unit of "always allow this pattern".
3
+ *
4
+ * A pattern uses the same `tool(glob)` grammar the policy engine matches
5
+ * (`matchesGlobRule` in engine.ts), e.g. `shell_exec(npm *)`,
6
+ * `write_file(src/index.ts)`. Derivation is deterministic: the same call
7
+ * shape always yields the same pattern, so session caching can key on exact
8
+ * pattern equality and persisted patterns re-match across sessions.
9
+ *
10
+ * Scoping (Claude-Code-like):
11
+ * - shell_exec / run_verify → first command word + ` *`
12
+ * (`npm test` and `npm run build` share `shell_exec(npm *)`)
13
+ * - file tools with a path → the exact path (safest: no wildcards)
14
+ * - everything else → the bare tool name (whole-tool session allow)
15
+ */
16
+ export function patternForCall(name, input) {
17
+ if ((name === 'shell_exec' || name === 'run_verify') && typeof input.command === 'string') {
18
+ const first = input.command.trim().split(/\s+/, 1)[0] ?? '';
19
+ if (first)
20
+ return `${name}(${first} *)`;
21
+ }
22
+ if (typeof input.path === 'string' && input.path.length > 0) {
23
+ return `${name}(${input.path})`;
24
+ }
25
+ return name;
26
+ }
@@ -13,7 +13,7 @@
13
13
  * a Promise.
14
14
  * 3. The App polls getPending() on every render to show a modal.
15
15
  * 4. The App's useInput handler sees a pending prompt and routes
16
- * y/n/a to the resolver.
16
+ * y/a/A/n to the resolver (A = always-persist to settings).
17
17
  *
18
18
  * Non-TTY mode (no Ink mounted) keeps using StdinApprovalPrompt; this
19
19
  * module is only used when the TUI is active.
@@ -14,7 +14,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
14
14
  * a Promise.
15
15
  * 3. The App polls getPending() on every render to show a modal.
16
16
  * 4. The App's useInput handler sees a pending prompt and routes
17
- * y/n/a to the resolver.
17
+ * y/a/A/n to the resolver (A = always-persist to settings).
18
18
  *
19
19
  * Non-TTY mode (no Ink mounted) keeps using StdinApprovalPrompt; this
20
20
  * module is only used when the TUI is active.
@@ -85,7 +85,7 @@ export function ApprovalModal({ bridge }) {
85
85
  return;
86
86
  }
87
87
  if (c === 'A') {
88
- bridge.resolve('always');
88
+ bridge.resolve('always-persist');
89
89
  return;
90
90
  }
91
91
  if (c === 'd' || c === 'n') {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.62",
4
- "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
3
+ "version": "0.1.63",
4
+ "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",