klyro 1.0.5 → 1.0.6

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.
Files changed (47) hide show
  1. package/dist/agent/custom-agents.d.ts +3 -0
  2. package/dist/agent/custom-agents.js +96 -0
  3. package/dist/agent/orchestrator.d.ts +22 -2
  4. package/dist/agent/orchestrator.js +30 -4
  5. package/dist/agent/runtime.d.ts +5 -0
  6. package/dist/agent/runtime.js +164 -50
  7. package/dist/checkpoints/store.d.ts +9 -0
  8. package/dist/checkpoints/store.js +20 -0
  9. package/dist/cli/completion.js +2 -2
  10. package/dist/cli/config.d.ts +4 -4
  11. package/dist/cli/eval.d.ts +15 -1
  12. package/dist/cli/eval.js +34 -2
  13. package/dist/cli/hooks.d.ts +54 -5
  14. package/dist/cli/hooks.js +85 -6
  15. package/dist/cli/init.d.ts +6 -0
  16. package/dist/cli/init.js +60 -0
  17. package/dist/cli/repl.js +146 -25
  18. package/dist/cli/run.d.ts +7 -1
  19. package/dist/cli/run.js +92 -50
  20. package/dist/cli/slash/custom.d.ts +25 -0
  21. package/dist/cli/slash/custom.js +166 -0
  22. package/dist/cli/slash/parser.d.ts +9 -1
  23. package/dist/cli/slash/parser.js +31 -9
  24. package/dist/context/memory.js +18 -1
  25. package/dist/eval/harness.d.ts +21 -3
  26. package/dist/eval/harness.js +31 -3
  27. package/dist/eval/judge.d.ts +32 -0
  28. package/dist/eval/judge.js +63 -0
  29. package/dist/eval/tasks.js +134 -0
  30. package/dist/index.js +150 -127
  31. package/dist/mcp/client.d.ts +15 -0
  32. package/dist/mcp/client.js +42 -2
  33. package/dist/mcp/config.d.ts +3 -1
  34. package/dist/mcp/config.js +19 -1
  35. package/dist/mcp/registry.d.ts +13 -0
  36. package/dist/mcp/registry.js +41 -1
  37. package/dist/mcp/remote.d.ts +29 -0
  38. package/dist/mcp/remote.js +153 -0
  39. package/dist/policy/approval.d.ts +15 -1
  40. package/dist/policy/approval.js +8 -0
  41. package/dist/providers/endpoints.d.ts +43 -0
  42. package/dist/providers/endpoints.js +104 -0
  43. package/dist/providers.js +13 -10
  44. package/dist/tui/app.js +140 -13
  45. package/dist/tui/app.test.js +24 -0
  46. package/dist/tui/approval.js +53 -1
  47. package/package.json +1 -1
@@ -0,0 +1,3 @@
1
+ import type { AgentDefinition } from './orchestrator.js';
2
+ /** Load custom agents: global first, project wins on id clash. Never throws. */
3
+ export declare function loadCustomAgents(cwd: string): AgentDefinition[];
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Custom subagents from markdown files:
3
+ * `<cwd>/.klyro/agents/*.md` (project) + `~/.klyro/agents/*.md` (global).
4
+ * Project wins on id clash (including overriding a builtin).
5
+ *
6
+ * Frontmatter fields: name (default: filename), description, tools
7
+ * (comma/list — omitted inherits parent tools), model, readonly,
8
+ * canSpawn, maxSteps, maxTokens, maxCost, maxTimeMs, allowedPaths.
9
+ * The markdown body becomes specialist instructions (`prompt`) prepended
10
+ * to the delegated task. Unknown tool names are NOT rejected here —
11
+ * `resolveCapabilities` drops them with reasons at spawn time.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import { parseFrontmatter, parseList, parseBool, parseInt_ } from '../cli/slash/custom.js';
17
+ const AGENT_ID_RE = /^[A-Za-z0-9_-]{1,32}$/;
18
+ function readAgentFile(file, source) {
19
+ let raw;
20
+ try {
21
+ raw = fs.readFileSync(file, 'utf-8');
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ const { data, body } = parseFrontmatter(raw);
27
+ const fallback = path.basename(file, path.extname(file));
28
+ const id = (data['name'] || fallback).toLowerCase();
29
+ if (!AGENT_ID_RE.test(id))
30
+ return null;
31
+ const description = data['description'] || `Custom agent ${id}`;
32
+ const def = { id, description };
33
+ const tools = parseList(data['tools']);
34
+ if (tools.length > 0)
35
+ def.allowedTools = tools;
36
+ if (data['model'])
37
+ def.model = data['model'];
38
+ if (data['readonly'] !== undefined && data['readonly'] !== '')
39
+ def.readonly = parseBool(data['readonly'], false);
40
+ if (data['canSpawn'] !== undefined && data['canSpawn'] !== '')
41
+ def.canSpawn = parseBool(data['canSpawn'], false);
42
+ const maxSteps = parseInt_(data['maxsteps']);
43
+ if (maxSteps !== undefined)
44
+ def.maxSteps = maxSteps;
45
+ const maxTokens = parseInt_(data['maxtokens']);
46
+ if (maxTokens !== undefined)
47
+ def.maxTokens = maxTokens;
48
+ const maxCost = data['maxcost'] !== undefined && data['maxcost'] !== '' ? Number(data['maxcost']) : undefined;
49
+ if (maxCost !== undefined && Number.isFinite(maxCost) && maxCost > 0)
50
+ def.maxCost = maxCost;
51
+ const maxTimeMs = parseInt_(data['maxtimems']);
52
+ if (maxTimeMs !== undefined)
53
+ def.maxTimeMs = maxTimeMs;
54
+ const paths = parseList(data['allowedpaths']);
55
+ if (paths.length > 0)
56
+ def.allowedPaths = paths;
57
+ const prompt = body.trim();
58
+ if (prompt)
59
+ def.prompt = prompt;
60
+ def.source = source;
61
+ return def;
62
+ }
63
+ function listAgentFiles(dir) {
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(dir, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ return entries
72
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
73
+ .map((e) => path.join(dir, e.name))
74
+ .sort();
75
+ }
76
+ /** Load custom agents: global first, project wins on id clash. Never throws. */
77
+ export function loadCustomAgents(cwd) {
78
+ const byId = new Map();
79
+ try {
80
+ const home = os.homedir() || process.cwd();
81
+ for (const f of listAgentFiles(path.join(home, '.klyro', 'agents'))) {
82
+ const d = readAgentFile(f, 'global');
83
+ if (d)
84
+ byId.set(d.id, d);
85
+ }
86
+ for (const f of listAgentFiles(path.join(cwd, '.klyro', 'agents'))) {
87
+ const d = readAgentFile(f, 'project');
88
+ if (d)
89
+ byId.set(d.id, d);
90
+ }
91
+ }
92
+ catch {
93
+ return [...byId.values()];
94
+ }
95
+ return [...byId.values()];
96
+ }
@@ -43,11 +43,17 @@ export interface AgentDefinition {
43
43
  * spawn time (`undefined` = no additional constraint).
44
44
  */
45
45
  allowedPaths?: string[];
46
+ /**
47
+ * Specialist instructions (from `.klyro/agents/*.md` body or programmatic
48
+ * defs). Prepended to the delegated task at spawn time.
49
+ */
50
+ prompt?: string;
51
+ /** Where the definition came from (builtins omit this = 'builtin'). */
52
+ source?: 'builtin' | 'project' | 'global';
46
53
  }
47
54
  /** Default agents a model can delegate to. */
48
55
  export declare const BUILTIN_AGENTS: readonly AgentDefinition[];
49
- /** Compact summary returned to the parent — the child's transcript stays separate. */
50
- export interface ChildSummary {
56
+ /** Compact summary returned to the parent — the child's transcript stays separate. */ export interface ChildSummary {
51
57
  taskId: string;
52
58
  agentName: string;
53
59
  status: TaskStatus;
@@ -158,7 +164,20 @@ export interface OrchestratorOpts {
158
164
  * always isolate. Defaults to false.
159
165
  */
160
166
  isTui?: boolean;
167
+ /**
168
+ * Working directory used to discover custom agents
169
+ * (`.klyro/agents/*.md`). Defaults to `process.cwd()`.
170
+ */
171
+ cwd?: string;
161
172
  }
173
+ /**
174
+ * All agents: builtins plus custom `.klyro/agents/*.md` definitions.
175
+ * Custom ids win on clash (including overriding a builtin) — the override
176
+ * is surfaced via `source`. No instance needed; used by CLI + spawn paths.
177
+ */
178
+ export declare function listAllAgents(cwd?: string): AgentDefinition[];
179
+ /** Find one agent by id across builtins + custom files. */
180
+ export declare function findAgent(id: string, cwd?: string): AgentDefinition | undefined;
162
181
  /**
163
182
  * Build a `subtask.progress` note for one finished tool call.
164
183
  * Pure — unit-tested directly (see agent-tools.test.ts).
@@ -182,6 +201,7 @@ export declare class AgentOrchestrator {
182
201
  readonly taskManager: TaskManager;
183
202
  readonly workerSpawner: WorkerSpawner;
184
203
  readonly isTui: boolean;
204
+ private readonly customCwd;
185
205
  /** Per-task spawn metadata: capability drops + worktree placement. */
186
206
  private readonly taskMeta;
187
207
  /** Finished summaries not yet drained via `drainCompletions`. */
@@ -19,6 +19,7 @@ import { TaskManager } from './task-manager.js';
19
19
  import { WorkerSpawner } from './worker-spawner.js';
20
20
  import { resolveCapabilities, DEFAULT_WRITE_TOOLS, DEFAULT_SPAWN_TOOLS, DEFAULT_DENIED_TOOLS, } from './capabilities.js';
21
21
  import { forkChild, workerEntryPath } from './child-worker.js';
22
+ import { loadCustomAgents } from './custom-agents.js';
22
23
  import { resolveAndFollowSymlinks } from '../policy/path-guard.js';
23
24
  import { ensureGitRepo, createWorktree, mergeWorktree, removeWorktree, deleteBranch, } from './worktree-manager.js';
24
25
  /** Concurrency budgets enforced in `spawnAgent` (CONCURRENCY_LIMIT on exceed). */
@@ -70,6 +71,26 @@ export const BUILTIN_AGENTS = [
70
71
  allowedTools: ['read_file', 'list_directory', 'glob', 'grep', 'search_files', 'repo_map', 'recent_files'],
71
72
  },
72
73
  ];
74
+ /**
75
+ * All agents: builtins plus custom `.klyro/agents/*.md` definitions.
76
+ * Custom ids win on clash (including overriding a builtin) — the override
77
+ * is surfaced via `source`. No instance needed; used by CLI + spawn paths.
78
+ */
79
+ export function listAllAgents(cwd) {
80
+ const byId = new Map();
81
+ for (const d of BUILTIN_AGENTS)
82
+ byId.set(d.id, { ...d, source: 'builtin' });
83
+ try {
84
+ for (const d of loadCustomAgents(cwd ?? process.cwd()))
85
+ byId.set(d.id, d);
86
+ }
87
+ catch { /* custom agents are best-effort */ }
88
+ return [...byId.values()];
89
+ }
90
+ /** Find one agent by id across builtins + custom files. */
91
+ export function findAgent(id, cwd) {
92
+ return listAllAgents(cwd).find((a) => a.id === id);
93
+ }
73
94
  /** Map a runtime `RunResult.status` to a task status. */
74
95
  function mapResultStatus(status) {
75
96
  switch (status) {
@@ -132,6 +153,7 @@ export class AgentOrchestrator {
132
153
  taskManager;
133
154
  workerSpawner;
134
155
  isTui;
156
+ customCwd;
135
157
  /** Per-task spawn metadata: capability drops + worktree placement. */
136
158
  taskMeta = new Map();
137
159
  /** Finished summaries not yet drained via `drainCompletions`. */
@@ -142,12 +164,13 @@ export class AgentOrchestrator {
142
164
  this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
143
165
  this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
144
166
  this.isTui = opts.isTui ?? false;
167
+ this.customCwd = opts.cwd;
145
168
  }
146
169
  listAgents() {
147
- return [...BUILTIN_AGENTS];
170
+ return listAllAgents(this.customCwd);
148
171
  }
149
172
  getAgent(id) {
150
- return BUILTIN_AGENTS.find((a) => a.id === id);
173
+ return listAllAgents(this.customCwd).find((a) => a.id === id);
151
174
  }
152
175
  /** Build the bridge the parent's runtime hands to tools. */
153
176
  bridgeFor(parent) {
@@ -357,8 +380,11 @@ export class AgentOrchestrator {
357
380
  ...(childModel !== undefined ? { model: childModel } : {}),
358
381
  ...(resolved.allowedPaths !== undefined ? { allowedPaths: resolved.allowedPaths } : {}),
359
382
  };
383
+ // Specialist instructions from `.klyro/agents/*.md` (or programmatic
384
+ // defs) ride with the delegated task on both paths below.
385
+ const childTask = def.prompt ? `${def.prompt}\n\n---\n\n${input.task}` : input.task;
360
386
  const childOptions = {
361
- task: input.task,
387
+ task: childTask,
362
388
  cwd: childCwd,
363
389
  model: childModel ?? 'inherit', // model override must reach the adapter (see runtime)
364
390
  maxSteps: def.maxSteps,
@@ -413,7 +439,7 @@ export class AgentOrchestrator {
413
439
  const systemPrompt = sysPrompt.suffix ? `${sysPrompt.system}\n${sysPrompt.suffix}` : sysPrompt.system;
414
440
  const payload = {
415
441
  cwd: childCwd,
416
- task: input.task,
442
+ task: childTask,
417
443
  // A concrete provider model must reach the child — 'inherit' only
418
444
  // exists to defer resolution inside the parent's run().
419
445
  model: (childModel ?? parent.model),
@@ -85,6 +85,11 @@ export interface RunOptions {
85
85
  temperature?: number;
86
86
  signal?: AbortSignal;
87
87
  nonInteractive: boolean;
88
+ /**
89
+ * Bare mode: skip all hooks (load + sessionStart/stop). The caller is
90
+ * responsible for skipping MCP/persistence/context (see runOnce `bare`).
91
+ */
92
+ bare?: boolean;
88
93
  /**
89
94
  * Optional pre-existing transcript to seed the conversation. When set,
90
95
  * the runtime skips the initial `[{role:'user', content:[text(task)]}]`
@@ -24,13 +24,14 @@ import { detectVerifyCommand } from '../verification/auto.js';
24
24
  import { ensureBaseline, getBaseline } from '../verification/baseline.js';
25
25
  import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
26
26
  import { capForModel } from '../context/accounting.js';
27
+ import { shouldRemind, reminderForTodos } from '../context/memory.js';
27
28
  import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
28
29
  import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
29
30
  import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
30
31
  import { globalBus } from '../events/bus.js';
31
32
  import { TraceWriter } from '../trace/writer.js';
32
33
  import { killAllJobs } from '../tools/shell/background.js';
33
- import { loadHooks, runHook } from '../cli/hooks.js';
34
+ import { loadHooks, runHook, hooksForEvent } from '../cli/hooks.js';
34
35
  /** Normalize either systemPrompt shape into {system, suffix}. */
35
36
  export function resolveSystemPrompt(fn, ctx) {
36
37
  const r = fn(ctx);
@@ -118,6 +119,7 @@ export async function run(opts, deps) {
118
119
  }
119
120
  };
120
121
  let steps = 0;
122
+ let lastRemindTurn = 0;
121
123
  let toolCallCount = 0;
122
124
  let finalText = '';
123
125
  let repairs = 0;
@@ -154,16 +156,21 @@ export async function run(opts, deps) {
154
156
  emit?.({ kind: 'model_override', requested: opts.model, effective: opts.parentContext.model });
155
157
  }
156
158
  // Hooks engine: loaded once per run. Zero-cost fast path — when no hooks
157
- // file exists, both lists are empty and every hook call site is skipped.
159
+ // file exists, the list is empty and every hook call site is skipped.
160
+ // --bare skips hooks entirely (deterministic runs).
161
+ // Tool-event hooks are matched per tool at the call sites below
162
+ // (hooksForEvent over runHooks); lifecycle events run at their own points.
158
163
  let runHooks = [];
159
- try {
160
- runHooks = loadHooks(opts.cwd);
161
- }
162
- catch {
163
- runHooks = [];
164
+ if (!opts.bare) {
165
+ try {
166
+ runHooks = loadHooks(opts.cwd);
167
+ }
168
+ catch {
169
+ runHooks = [];
170
+ }
164
171
  }
165
- const preHooks = runHooks.filter((h) => h.event === 'preToolUse');
166
- const postHooks = runHooks.filter((h) => h.event === 'postToolUse');
172
+ // Tool-event hooks are matched per tool at the call sites below
173
+ // (hooksForEvent over runHooks); lifecycle events run at their own points.
167
174
  // L15 failover chain: the active adapter starts as deps.adapter; each
168
175
  // terminal provider error consumes one fallback. Bounded — never loops.
169
176
  let activeAdapter = deps.adapter;
@@ -247,6 +254,26 @@ export async function run(opts, deps) {
247
254
  // Fire-and-forget initial checkpoint (don't await to block loop start)
248
255
  void checkpoint(transcript[transcript.length - 1]);
249
256
  }
257
+ // sessionStart: prerequisite gate. A non-zero exit aborts the run before
258
+ // step 1 with status 'blocked' (e.g. missing toolchain, dirty tree).
259
+ {
260
+ const starters = hooksForEvent(runHooks, 'sessionStart');
261
+ for (const hook of starters) {
262
+ let r = null;
263
+ try {
264
+ r = await runHook(hook, { toolName: '', input: {} }, { event: 'sessionStart', sessionId, cwd: opts.cwd, task: opts.task });
265
+ }
266
+ catch {
267
+ r = null;
268
+ }
269
+ if (r && !r.ok) {
270
+ const reason = (r.stderr || r.stdout || 'sessionStart hook failed').slice(0, 500);
271
+ emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'session_blocked', message: reason });
272
+ await closeTracer();
273
+ return { status: 'blocked', steps, toolCalls: toolCallCount, finalText: `Blocked by sessionStart hook ${hook.name}: ${reason}`, transcript, hasEdits, usage, repairs, phase: 'blocked' };
274
+ }
275
+ }
276
+ }
250
277
  // 5.2 — stuck detection state
251
278
  const callHistory = [];
252
279
  const fileEditCounts = new Map();
@@ -282,6 +309,33 @@ export async function run(opts, deps) {
282
309
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? withRepairTokens({ ok: false, attempts: verificationAttempts }) : undefined, phase: 'blocked' };
283
310
  }
284
311
  steps++;
312
+ // 8.4 — stale-todo reminder: every 20 turns, re-inject pending plan
313
+ // items from `.klyro/plans/todos.json` (written by todo_write) so a
314
+ // long run cannot silently drop its checklist. Best-effort + tiny.
315
+ if (shouldRemind(steps, lastRemindTurn)) {
316
+ lastRemindTurn = steps;
317
+ try {
318
+ const { readFileSync } = await import('node:fs');
319
+ const { join } = await import('node:path');
320
+ const rawTodos = JSON.parse(readFileSync(join(opts.cwd, '.klyro', 'plans', 'todos.json'), 'utf-8'));
321
+ if (Array.isArray(rawTodos)) {
322
+ const planSteps = rawTodos
323
+ .filter((t) => typeof t.title === 'string')
324
+ .map((t, i) => ({
325
+ id: `todo-${i}`,
326
+ title: t.title,
327
+ status: ['pending', 'in_progress', 'done', 'failed', 'skipped'].includes(t.status) ? t.status : 'pending',
328
+ }));
329
+ const reminder = reminderForTodos(planSteps);
330
+ if (reminder) {
331
+ const note = { role: 'user', content: [text(`[system note] ${reminder}`)] };
332
+ transcript.push(note);
333
+ await checkpoint(note);
334
+ }
335
+ }
336
+ }
337
+ catch { /* no todos file — nothing to remind */ }
338
+ }
285
339
  // 5.1 phase transitions (model-narrated)
286
340
  if (steps === 1)
287
341
  setPhase('understanding');
@@ -833,39 +887,48 @@ export async function run(opts, deps) {
833
887
  // results immediately — gate runs in call order so these stay ordered.
834
888
  // Returns true when the call is approved for execution.
835
889
  const gateCall = async (call) => {
836
- const decision = await deps.policy.evaluate({ name: call.name, input: call.input, permission: deps.registry.get(call.name)?.permission }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
837
- emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
838
- // Mirror to KlyroEvent bus
839
- if (decision.action === 'allow') {
840
- emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: 'allow' });
841
- }
842
- else {
843
- emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, reason: decision.reason });
844
- }
845
- if (decision.action === 'deny') {
846
- const denyMsg = {
847
- role: 'tool',
848
- content: [
849
- mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: decision.reason }, true),
850
- ],
851
- };
852
- transcript.push(denyMsg);
853
- await checkpoint(denyMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true });
854
- emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
855
- telemetry.recordToolError(call, 'policy_denied');
856
- emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
857
- return false;
858
- }
859
- if (decision.action === 'ask') {
890
+ // Edit-and-retry loop: an `e`dit choice re-validates + re-evaluates
891
+ // policy on the edited input (bounded to 3 rounds so a user can't be
892
+ // re-prompted forever). `call.input` is updated in place so the
893
+ // executed + checkpointed call reflects what was approved.
894
+ let effectiveInput = call.input;
895
+ for (let round = 0; round < 3; round++) {
896
+ const decision = await deps.policy.evaluate({ name: call.name, input: effectiveInput, permission: deps.registry.get(call.name)?.permission }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
897
+ emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
898
+ // Mirror to KlyroEvent bus
899
+ if (decision.action === 'allow') {
900
+ emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: 'allow' });
901
+ }
902
+ else {
903
+ emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, reason: decision.reason });
904
+ }
905
+ if (decision.action === 'deny') {
906
+ const denyMsg = {
907
+ role: 'tool',
908
+ content: [
909
+ mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: decision.reason }, true),
910
+ ],
911
+ };
912
+ transcript.push(denyMsg);
913
+ await checkpoint(denyMsg, { toolCallId: call.id, toolName: call.name, input: effectiveInput, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true });
914
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
915
+ telemetry.recordToolError(call, 'policy_denied');
916
+ emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
917
+ return false;
918
+ }
919
+ if (decision.action === 'allow') {
920
+ call.input = effectiveInput;
921
+ return true;
922
+ }
923
+ // decision.action === 'ask'
860
924
  emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
861
925
  const choice = await deps.approval.ask({
862
926
  toolName: call.name,
863
927
  reason: decision.reason,
864
- summary: summarizeToolCall(call),
865
- input: call.input,
866
- pattern: patternForCall(call.name, call.input),
928
+ summary: summarizeToolCall({ ...call, input: effectiveInput }),
929
+ input: effectiveInput,
930
+ pattern: patternForCall(call.name, effectiveInput),
867
931
  });
868
- // Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
869
932
  if (choice === 'deny') {
870
933
  const denyMsg2 = {
871
934
  role: 'tool',
@@ -874,15 +937,49 @@ export async function run(opts, deps) {
874
937
  ],
875
938
  };
876
939
  transcript.push(denyMsg2);
877
- await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
940
+ await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: effectiveInput, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
878
941
  telemetry.recordToolError(call, 'user_denied');
879
942
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
880
943
  return false;
881
944
  }
882
- // Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
945
+ if (typeof choice === 'object' && choice.kind === 'edit') {
946
+ // Re-validate the edited input against the tool schema before it
947
+ // goes anywhere — a malformed edit denies instead of executing.
948
+ const tool = deps.registry.get(call.name);
949
+ const parsed = tool?.inputSchema.safeParse(choice.editedInput);
950
+ if (!parsed || !parsed.success) {
951
+ const denyMsg3 = {
952
+ role: 'tool',
953
+ content: [
954
+ mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: 'edited input failed tool schema validation' }, true),
955
+ ],
956
+ };
957
+ transcript.push(denyMsg3);
958
+ await checkpoint(denyMsg3, { toolCallId: call.id, toolName: call.name, input: effectiveInput, output: { error: 'POLICY_DENIED', reason: 'edited input invalid' }, isError: true });
959
+ telemetry.recordToolError(call, 'edit_invalid');
960
+ emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'edited input invalid' }, isError: true, latencyMs: 0 });
961
+ return false;
962
+ }
963
+ effectiveInput = parsed.data;
964
+ continue; // re-evaluate policy on the edited input
965
+ }
966
+ // allow / always / always-persist — approved with (possibly edited) input.
883
967
  repairs++;
968
+ call.input = effectiveInput;
969
+ return true;
884
970
  }
885
- return true;
971
+ // Edit rounds exhausted without approval — deny rather than loop forever.
972
+ const denyMsg4 = {
973
+ role: 'tool',
974
+ content: [
975
+ mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: 'approval rounds exhausted' }, true),
976
+ ],
977
+ };
978
+ transcript.push(denyMsg4);
979
+ await checkpoint(denyMsg4, { toolCallId: call.id, toolName: call.name, input: effectiveInput, output: { error: 'POLICY_DENIED', reason: 'approval rounds exhausted' }, isError: true });
980
+ telemetry.recordToolError(call, 'approval_exhausted');
981
+ emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'approval rounds exhausted' }, isError: true, latencyMs: 0 });
982
+ return false;
886
983
  };
887
984
  // Execute phase: run the tool with no transcript writes, so concurrent
888
985
  // executions can't interleave. A throw here becomes a tool error (an
@@ -890,14 +987,16 @@ export async function run(opts, deps) {
890
987
  const execTool = async (call) => {
891
988
  const t0 = Date.now();
892
989
  emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
893
- // Hooks: every preToolUse hook runs before execution. A non-zero exit
894
- // denies the tool with POLICY_DENIED — the real tool never runs.
895
- if (preHooks.length > 0) {
896
- for (const hook of preHooks) {
990
+ // Hooks: matching preToolUse hooks run before execution. A non-zero
991
+ // exit denies the tool with POLICY_DENIED — the real tool never runs.
992
+ // Matchers scope hooks per tool; stdin carries the structured payload.
993
+ const matchingPre = hooksForEvent(runHooks, 'preToolUse', call.name);
994
+ if (matchingPre.length > 0) {
995
+ for (const hook of matchingPre) {
897
996
  let exitCode = -1;
898
997
  let detail = '';
899
998
  try {
900
- const r = await runHook(hook, { toolName: call.name, input: call.input });
999
+ const r = await runHook(hook, { toolName: call.name, input: call.input }, { event: 'preToolUse', tool: call.name, input: call.input, sessionId, cwd: opts.cwd });
901
1000
  exitCode = r.exitCode;
902
1001
  detail = (r.stderr || r.stdout || '').slice(0, 300);
903
1002
  }
@@ -1003,12 +1102,13 @@ export async function run(opts, deps) {
1003
1102
  if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
1004
1103
  await markStuck(`identical call ×3: ${sig}`);
1005
1104
  }
1006
- // Hooks: postToolUse hooks are best-effort — failures warn on stderr
1007
- // plus a bus event, and never fail the turn.
1008
- if (postHooks.length > 0) {
1009
- for (const hook of postHooks) {
1105
+ // Hooks: matching postToolUse hooks are best-effort — failures warn
1106
+ // on stderr plus a bus event, and never fail the turn.
1107
+ const matchingPost = hooksForEvent(runHooks, 'postToolUse', call.name);
1108
+ if (matchingPost.length > 0) {
1109
+ for (const hook of matchingPost) {
1010
1110
  try {
1011
- const r = await runHook(hook, { toolName: call.name, input: call.input });
1111
+ const r = await runHook(hook, { toolName: call.name, input: call.input }, { event: 'postToolUse', tool: call.name, input: call.input, sessionId, cwd: opts.cwd });
1012
1112
  if (!r.ok || r.exitCode !== 0) {
1013
1113
  const msg = `klyro: hooks: postToolUse ${hook.name} failed (exit ${String(r.exitCode)}): ${(r.stderr || r.stdout || '').slice(0, 200)}\n`;
1014
1114
  try {
@@ -1093,6 +1193,20 @@ export async function run(opts, deps) {
1093
1193
  }
1094
1194
  }
1095
1195
  catch { /* ignore — completions are best-effort visibility */ }
1196
+ // stop hooks: run once per completed step (blocking, side effects only —
1197
+ // output is logged, never injected into the transcript).
1198
+ for (const hook of hooksForEvent(runHooks, 'stop')) {
1199
+ try {
1200
+ const r = await runHook(hook, { toolName: '', input: {} }, { event: 'stop', sessionId, cwd: opts.cwd, step: steps, status: 'open' });
1201
+ if (!r.ok) {
1202
+ try {
1203
+ process.stderr.write(`klyro: hooks: stop ${hook.name} failed (exit ${String(r.exitCode)})\n`);
1204
+ }
1205
+ catch { /* ignore */ }
1206
+ }
1207
+ }
1208
+ catch { /* ignore — stop hooks never fail the turn */ }
1209
+ }
1096
1210
  emit?.({ kind: 'step_end', step: steps });
1097
1211
  emitKlyro({ type: 'turn.end', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps });
1098
1212
  // Level 9 — checkpoint status after each step
@@ -12,6 +12,15 @@
12
12
  */
13
13
  export declare function snapshot(cwd: string, files: string[]): Promise<string>;
14
14
  export declare function listCheckpoints(cwd: string): Promise<string[]>;
15
+ export interface CheckpointInfo {
16
+ /** 1-based index from the latest (1 = newest, like `undo(n)`). */
17
+ index: number;
18
+ id: string;
19
+ ts: number;
20
+ files: number;
21
+ }
22
+ /** Numbered snapshot list for `/checkpoints` and the `/rewind` menu. */
23
+ export declare function listCheckpointInfo(cwd: string): Promise<CheckpointInfo[]>;
15
24
  export declare function diff(cwd: string, id?: string): Promise<string>;
16
25
  export declare function undo(cwd: string, n?: number): Promise<void>;
17
26
  export declare function rewind(cwd: string): Promise<void>;
@@ -154,6 +154,26 @@ export async function listCheckpoints(cwd) {
154
154
  return [];
155
155
  }
156
156
  }
157
+ /** Numbered snapshot list for `/checkpoints` and the `/rewind` menu. */
158
+ export async function listCheckpointInfo(cwd) {
159
+ const ids = await listCheckpoints(cwd);
160
+ const out = [];
161
+ for (let i = ids.length - 1; i >= 0; i--) {
162
+ const id = ids[i];
163
+ let ts = 0;
164
+ let files = 0;
165
+ try {
166
+ const meta = JSON.parse(await fs.readFile(path.join(ckptDir(cwd), id, '.meta.json'), 'utf-8'));
167
+ if (typeof meta.ts === 'number')
168
+ ts = meta.ts;
169
+ if (Array.isArray(meta.files))
170
+ files = meta.files.length;
171
+ }
172
+ catch { /* best-effort */ }
173
+ out.push({ index: ids.length - i, id, ts, files });
174
+ }
175
+ return out;
176
+ }
157
177
  export async function diff(cwd, id) {
158
178
  const ckpts = await listCheckpoints(cwd);
159
179
  const target = id ?? ckpts[ckpts.length - 1];
@@ -2,11 +2,11 @@
2
2
  * 1.2 — klyro completion
3
3
  * Generates shell completion scripts for bash/zsh/fish/powershell.
4
4
  */
5
- const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version', 'scan', 'project', 'mcp', 'hooks', 'agents', 'commit', 'audit', 'benchmark', 'sessions', 'login', 'logout'];
5
+ const COMMANDS = ['tui', 'run', 'chat', 'config', 'doctor', 'init', 'completion', 'update', 'eval', 'session', 'resume', 'help', 'version', 'scan', 'project', 'mcp', 'hooks', 'agents', 'commit', 'audit', 'benchmark', 'sessions', 'login', 'logout'];
6
6
  /** Second-level completion: global flags + per-command flags. */
7
7
  const GLOBAL_FLAGS = ['--cwd', '--config', '--debug', '--verbose', '--quiet', '--json', '--yes', '--no-color', '--print', '--output-format', '--no-stream', '--show-thinking', '--tui', '--chat', '--continue', '--resume', '--help', '--version'];
8
8
  const COMMAND_FLAGS = {
9
- run: ['-m', '--model', '--max-steps', '--max-tokens', '--temperature', '--timeout', '--base-url', '--api-key', '--output', '--provider', '--dry-run', '--resume', '--resume-session', '--verify', '--verify-command', '--verify-mode', '--max-repairs', '--persist', '--require-verify', '--agent', '--max-depth'],
9
+ run: ['-m', '--model', '--max-steps', '--max-tokens', '--temperature', '--timeout', '--base-url', '--api-key', '--output', '--provider', '--dry-run', '--resume', '--resume-session', '--verify', '--verify-command', '--verify-mode', '--max-repairs', '--persist', '--require-verify', '--agent', '--max-depth', '--bare'],
10
10
  chat: ['-s', '--system', '-m', '--model', '-t', '--timeout'],
11
11
  eval: ['--output', '--suite', '--filter', '--runs', '--parallel', '--model'],
12
12
  tui: ['-m', '--model', '--max-steps'],