klyro 1.0.4 → 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 (80) hide show
  1. package/README.md +29 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +22 -2
  5. package/dist/agent/orchestrator.js +30 -4
  6. package/dist/agent/runtime.d.ts +5 -0
  7. package/dist/agent/runtime.js +174 -51
  8. package/dist/checkpoints/store.d.ts +9 -0
  9. package/dist/checkpoints/store.js +20 -0
  10. package/dist/cli/auth.js +11 -3
  11. package/dist/cli/completion.js +63 -10
  12. package/dist/cli/config.d.ts +4 -4
  13. package/dist/cli/doctor.js +13 -0
  14. package/dist/cli/eval.d.ts +15 -1
  15. package/dist/cli/eval.js +34 -2
  16. package/dist/cli/hooks.d.ts +54 -5
  17. package/dist/cli/hooks.js +85 -6
  18. package/dist/cli/init.d.ts +6 -0
  19. package/dist/cli/init.js +60 -0
  20. package/dist/cli/repl.js +261 -34
  21. package/dist/cli/run.d.ts +7 -1
  22. package/dist/cli/run.js +97 -41
  23. package/dist/cli/slash/custom.d.ts +25 -0
  24. package/dist/cli/slash/custom.js +166 -0
  25. package/dist/cli/slash/parser.d.ts +16 -2
  26. package/dist/cli/slash/parser.js +67 -18
  27. package/dist/cli/update.d.ts +8 -4
  28. package/dist/cli/update.js +50 -7
  29. package/dist/context/accounting.d.ts +8 -0
  30. package/dist/context/accounting.js +18 -1
  31. package/dist/context/compaction.d.ts +1 -0
  32. package/dist/context/compaction.js +2 -1
  33. package/dist/context/memory.js +18 -1
  34. package/dist/eval/harness.d.ts +21 -3
  35. package/dist/eval/harness.js +31 -3
  36. package/dist/eval/judge.d.ts +32 -0
  37. package/dist/eval/judge.js +63 -0
  38. package/dist/eval/tasks.js +134 -0
  39. package/dist/index.js +225 -132
  40. package/dist/mcp/client.d.ts +15 -0
  41. package/dist/mcp/client.js +42 -2
  42. package/dist/mcp/config.d.ts +10 -1
  43. package/dist/mcp/config.js +64 -1
  44. package/dist/mcp/registry.d.ts +13 -0
  45. package/dist/mcp/registry.js +47 -5
  46. package/dist/mcp/remote.d.ts +29 -0
  47. package/dist/mcp/remote.js +153 -0
  48. package/dist/mcp/serve.d.ts +23 -0
  49. package/dist/mcp/serve.js +111 -0
  50. package/dist/policy/approval.d.ts +15 -1
  51. package/dist/policy/approval.js +8 -0
  52. package/dist/policy/engine.d.ts +11 -1
  53. package/dist/policy/engine.js +14 -1
  54. package/dist/policy/secret-redactor.js +4 -1
  55. package/dist/providers/endpoints.d.ts +43 -0
  56. package/dist/providers/endpoints.js +104 -0
  57. package/dist/providers.js +13 -10
  58. package/dist/shared/error-map.d.ts +19 -0
  59. package/dist/shared/error-map.js +58 -0
  60. package/dist/tools/lsp/diagnostics.d.ts +35 -4
  61. package/dist/tools/lsp/diagnostics.js +88 -9
  62. package/dist/tools/normalize.d.ts +3 -0
  63. package/dist/tools/normalize.js +8 -5
  64. package/dist/tools/search/dependencies.d.ts +2 -2
  65. package/dist/tools/shell/background.d.ts +6 -0
  66. package/dist/tools/shell/background.js +17 -0
  67. package/dist/tools/symbols/find-symbol.d.ts +1 -1
  68. package/dist/tools/symbols/find-symbol.js +8 -6
  69. package/dist/tools/types.d.ts +8 -1
  70. package/dist/tui/app.d.ts +2 -0
  71. package/dist/tui/app.js +454 -53
  72. package/dist/tui/app.test.js +66 -3
  73. package/dist/tui/approval.js +53 -1
  74. package/dist/tui/markdown.js +9 -0
  75. package/dist/tui/mouse.d.ts +26 -1
  76. package/dist/tui/mouse.js +104 -6
  77. package/dist/tui/scroll-flow.test.js +3 -1
  78. package/dist/tui/tokens.d.ts +6 -6
  79. package/dist/tui/tokens.js +9 -6
  80. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,35 @@ node dist/index.js chat "Explain TypeScript in 2 sentences"
26
26
  node dist/index.js chat
27
27
  ```
28
28
 
29
+ ## Exit codes
30
+
31
+ | Code | Meaning |
32
+ |---|---|
33
+ | 0 | Success / complete |
34
+ | 1 | Unexpected failure (last-resort handler) |
35
+ | 2 | Usage / config error, policy refusal to commit, unknown command or option |
36
+ | 3 | Config invalid / not found |
37
+ | 4 | Provider error (auth, rate-limit, timeout) |
38
+ | 5 | No final answer from provider |
39
+ | 7 | Stopped: max steps, cost/time limit, or stuck |
40
+ | 8 | Verification failed (or `--require-verify` unsatisfied) |
41
+ | 130 | Aborted (Ctrl+C / Esc×2 / /cancel / SIGINT) |
42
+
43
+ ## Environment
44
+
45
+ | Variable | Scope |
46
+ |---|---|
47
+ | `KLYRO_BASE_URL`, `KLYRO_API_KEY`, `KLYRO_MODEL`, `KLYRO_PROVIDER` | Provider selection |
48
+ | `KLYRO_CONFIG` / `--config` | Config file override |
49
+ | `KLYRO_YES` / `--yes` | **Commit only** — auto-approves `klyro commit` prompts; nothing else reads it |
50
+ | `KLYRO_NO_UPDATE_CHECK=1` | Disables the 24h update check |
51
+ | `KLYRO_ALLOW_MAIN_PUSH=1` | Per-risk escape for protected-branch push |
52
+ | `KLYRO_CREDENTIALS_INSECURE_OK=1` | Warn (don't refuse) on group-readable credentials |
53
+ | `KLYRO_LSP=0` | Force language tools off |
54
+ | `KLYRO_SYMBOLS=0` | Force `find_symbol` off |
55
+ | `KLYRO_WORKER=0` | Disable subprocess isolation for subagents |
56
+ | `KLYRO_SESSIONS_DIR`, `KLYRO_UPDATE_CACHE`, `KLYRO_CREDENTIALS_FILE` | Relocatable state (tests + power users) |
57
+
29
58
  ## Documentation
30
59
 
31
60
  | Doc | Purpose |
@@ -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)]}]`
@@ -23,12 +23,15 @@ import { verify, diagnosticForModel } from '../verification/engine.js';
23
23
  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
+ import { capForModel } from '../context/accounting.js';
27
+ import { shouldRemind, reminderForTodos } from '../context/memory.js';
26
28
  import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
27
29
  import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
28
30
  import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
29
31
  import { globalBus } from '../events/bus.js';
30
32
  import { TraceWriter } from '../trace/writer.js';
31
- import { loadHooks, runHook } from '../cli/hooks.js';
33
+ import { killAllJobs } from '../tools/shell/background.js';
34
+ import { loadHooks, runHook, hooksForEvent } from '../cli/hooks.js';
32
35
  /** Normalize either systemPrompt shape into {system, suffix}. */
33
36
  export function resolveSystemPrompt(fn, ctx) {
34
37
  const r = fn(ctx);
@@ -116,6 +119,7 @@ export async function run(opts, deps) {
116
119
  }
117
120
  };
118
121
  let steps = 0;
122
+ let lastRemindTurn = 0;
119
123
  let toolCallCount = 0;
120
124
  let finalText = '';
121
125
  let repairs = 0;
@@ -152,16 +156,21 @@ export async function run(opts, deps) {
152
156
  emit?.({ kind: 'model_override', requested: opts.model, effective: opts.parentContext.model });
153
157
  }
154
158
  // Hooks engine: loaded once per run. Zero-cost fast path — when no hooks
155
- // 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.
156
163
  let runHooks = [];
157
- try {
158
- runHooks = loadHooks(opts.cwd);
159
- }
160
- catch {
161
- runHooks = [];
164
+ if (!opts.bare) {
165
+ try {
166
+ runHooks = loadHooks(opts.cwd);
167
+ }
168
+ catch {
169
+ runHooks = [];
170
+ }
162
171
  }
163
- const preHooks = runHooks.filter((h) => h.event === 'preToolUse');
164
- 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.
165
174
  // L15 failover chain: the active adapter starts as deps.adapter; each
166
175
  // terminal provider error consumes one fallback. Bounded — never loops.
167
176
  let activeAdapter = deps.adapter;
@@ -245,6 +254,26 @@ export async function run(opts, deps) {
245
254
  // Fire-and-forget initial checkpoint (don't await to block loop start)
246
255
  void checkpoint(transcript[transcript.length - 1]);
247
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
+ }
248
277
  // 5.2 — stuck detection state
249
278
  const callHistory = [];
250
279
  const fileEditCounts = new Map();
@@ -267,6 +296,9 @@ export async function run(opts, deps) {
267
296
  }
268
297
  if (opts.signal?.aborted) {
269
298
  emit?.({ kind: 'aborted' });
299
+ // Abort cascade (fix: background shells must not outlive the run).
300
+ const killed = killAllJobs();
301
+ emitKlyro({ type: 'abort', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', reason: killed.length > 0 ? `aborted by operator (${killed.length} background job(s) killed)` : 'aborted by operator' });
270
302
  if (store && sessionId) {
271
303
  try {
272
304
  await store.setStatus(sessionId, 'aborted', finalText);
@@ -277,6 +309,33 @@ export async function run(opts, deps) {
277
309
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? withRepairTokens({ ok: false, attempts: verificationAttempts }) : undefined, phase: 'blocked' };
278
310
  }
279
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
+ }
280
339
  // 5.1 phase transitions (model-narrated)
281
340
  if (steps === 1)
282
341
  setPhase('understanding');
@@ -297,7 +356,9 @@ export async function run(opts, deps) {
297
356
  // Budget accounting sees what the model sees (prefix + suffix); the
298
357
  // request itself keeps the halves split for cache-friendly adapters.
299
358
  const systemForBudget = telemetrySuffix ? `${stableSystem}\n\n${telemetrySuffix}` : stableSystem;
300
- const BUDGET = { total: 120_000, reservedOutput: 4000 };
359
+ // Window-aware ceiling (was a hardcoded 120k that overflowed 8k local
360
+ // models): size the input budget to the model's context window.
361
+ const BUDGET = { total: capForModel(opts.model, 4000), reservedOutput: 4000 };
301
362
  let reqMessages = transcript;
302
363
  let reqSystem = stableSystem;
303
364
  let reqSuffix = telemetrySuffix;
@@ -530,6 +591,8 @@ export async function run(opts, deps) {
530
591
  if (opts.signal?.aborted) {
531
592
  finalText = textBuf;
532
593
  emit?.({ kind: 'aborted' });
594
+ const killed = killAllJobs();
595
+ emitKlyro({ type: 'abort', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', reason: killed.length > 0 ? `aborted by operator (${killed.length} background job(s) killed)` : 'aborted by operator' });
533
596
  if (store && sessionId) {
534
597
  try {
535
598
  await store.setStatus(sessionId, 'aborted', finalText);
@@ -824,39 +887,48 @@ export async function run(opts, deps) {
824
887
  // results immediately — gate runs in call order so these stay ordered.
825
888
  // Returns true when the call is approved for execution.
826
889
  const gateCall = async (call) => {
827
- const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
828
- emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
829
- // Mirror to KlyroEvent bus
830
- if (decision.action === 'allow') {
831
- emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: 'allow' });
832
- }
833
- else {
834
- emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, reason: decision.reason });
835
- }
836
- if (decision.action === 'deny') {
837
- const denyMsg = {
838
- role: 'tool',
839
- content: [
840
- mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: decision.reason }, true),
841
- ],
842
- };
843
- transcript.push(denyMsg);
844
- await checkpoint(denyMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true });
845
- emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
846
- telemetry.recordToolError(call, 'policy_denied');
847
- emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
848
- return false;
849
- }
850
- 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'
851
924
  emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
852
925
  const choice = await deps.approval.ask({
853
926
  toolName: call.name,
854
927
  reason: decision.reason,
855
- summary: summarizeToolCall(call),
856
- input: call.input,
857
- pattern: patternForCall(call.name, call.input),
928
+ summary: summarizeToolCall({ ...call, input: effectiveInput }),
929
+ input: effectiveInput,
930
+ pattern: patternForCall(call.name, effectiveInput),
858
931
  });
859
- // Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
860
932
  if (choice === 'deny') {
861
933
  const denyMsg2 = {
862
934
  role: 'tool',
@@ -865,15 +937,49 @@ export async function run(opts, deps) {
865
937
  ],
866
938
  };
867
939
  transcript.push(denyMsg2);
868
- 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 });
869
941
  telemetry.recordToolError(call, 'user_denied');
870
942
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
871
943
  return false;
872
944
  }
873
- // 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.
874
967
  repairs++;
968
+ call.input = effectiveInput;
969
+ return true;
875
970
  }
876
- 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;
877
983
  };
878
984
  // Execute phase: run the tool with no transcript writes, so concurrent
879
985
  // executions can't interleave. A throw here becomes a tool error (an
@@ -881,14 +987,16 @@ export async function run(opts, deps) {
881
987
  const execTool = async (call) => {
882
988
  const t0 = Date.now();
883
989
  emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
884
- // Hooks: every preToolUse hook runs before execution. A non-zero exit
885
- // denies the tool with POLICY_DENIED — the real tool never runs.
886
- if (preHooks.length > 0) {
887
- 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) {
888
996
  let exitCode = -1;
889
997
  let detail = '';
890
998
  try {
891
- 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 });
892
1000
  exitCode = r.exitCode;
893
1001
  detail = (r.stderr || r.stdout || '').slice(0, 300);
894
1002
  }
@@ -994,12 +1102,13 @@ export async function run(opts, deps) {
994
1102
  if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
995
1103
  await markStuck(`identical call ×3: ${sig}`);
996
1104
  }
997
- // Hooks: postToolUse hooks are best-effort — failures warn on stderr
998
- // plus a bus event, and never fail the turn.
999
- if (postHooks.length > 0) {
1000
- 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) {
1001
1110
  try {
1002
- 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 });
1003
1112
  if (!r.ok || r.exitCode !== 0) {
1004
1113
  const msg = `klyro: hooks: postToolUse ${hook.name} failed (exit ${String(r.exitCode)}): ${(r.stderr || r.stdout || '').slice(0, 200)}\n`;
1005
1114
  try {
@@ -1084,6 +1193,20 @@ export async function run(opts, deps) {
1084
1193
  }
1085
1194
  }
1086
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
+ }
1087
1210
  emit?.({ kind: 'step_end', step: steps });
1088
1211
  emitKlyro({ type: 'turn.end', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps });
1089
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>;