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.
- package/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +22 -2
- package/dist/agent/orchestrator.js +30 -4
- package/dist/agent/runtime.d.ts +5 -0
- package/dist/agent/runtime.js +164 -50
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +20 -0
- package/dist/cli/completion.js +2 -2
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +34 -2
- package/dist/cli/hooks.d.ts +54 -5
- package/dist/cli/hooks.js +85 -6
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/repl.js +146 -25
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +92 -50
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +9 -1
- package/dist/cli/slash/parser.js +31 -9
- package/dist/context/memory.js +18 -1
- package/dist/eval/harness.d.ts +21 -3
- package/dist/eval/harness.js +31 -3
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +150 -127
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +3 -1
- package/dist/mcp/config.js +19 -1
- package/dist/mcp/registry.d.ts +13 -0
- package/dist/mcp/registry.js +41 -1
- package/dist/mcp/remote.d.ts +29 -0
- package/dist/mcp/remote.js +153 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +13 -10
- package/dist/tui/app.js +140 -13
- package/dist/tui/app.test.js +24 -0
- package/dist/tui/approval.js +53 -1
- package/package.json +1 -1
|
@@ -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
|
|
170
|
+
return listAllAgents(this.customCwd);
|
|
148
171
|
}
|
|
149
172
|
getAgent(id) {
|
|
150
|
-
return
|
|
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:
|
|
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:
|
|
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),
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -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)]}]`
|
package/dist/agent/runtime.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
if (!opts.bare) {
|
|
165
|
+
try {
|
|
166
|
+
runHooks = loadHooks(opts.cwd);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
runHooks = [];
|
|
170
|
+
}
|
|
164
171
|
}
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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:
|
|
866
|
-
pattern: patternForCall(call.name,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
894
|
-
// denies the tool with POLICY_DENIED — the real tool never runs.
|
|
895
|
-
|
|
896
|
-
|
|
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
|
|
1007
|
-
// plus a bus event, and never fail the turn.
|
|
1008
|
-
|
|
1009
|
-
|
|
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];
|
package/dist/cli/completion.js
CHANGED
|
@@ -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'],
|