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.
- package/README.md +29 -0
- 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 +174 -51
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +20 -0
- package/dist/cli/auth.js +11 -3
- package/dist/cli/completion.js +63 -10
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/doctor.js +13 -0
- 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 +261 -34
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +97 -41
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +16 -2
- package/dist/cli/slash/parser.js +67 -18
- package/dist/cli/update.d.ts +8 -4
- package/dist/cli/update.js +50 -7
- package/dist/context/accounting.d.ts +8 -0
- package/dist/context/accounting.js +18 -1
- package/dist/context/compaction.d.ts +1 -0
- package/dist/context/compaction.js +2 -1
- 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 +225 -132
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +10 -1
- package/dist/mcp/config.js +64 -1
- package/dist/mcp/registry.d.ts +13 -0
- package/dist/mcp/registry.js +47 -5
- package/dist/mcp/remote.d.ts +29 -0
- package/dist/mcp/remote.js +153 -0
- package/dist/mcp/serve.d.ts +23 -0
- package/dist/mcp/serve.js +111 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +14 -1
- package/dist/policy/secret-redactor.js +4 -1
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +13 -10
- package/dist/shared/error-map.d.ts +19 -0
- package/dist/shared/error-map.js +58 -0
- package/dist/tools/lsp/diagnostics.d.ts +35 -4
- package/dist/tools/lsp/diagnostics.js +88 -9
- package/dist/tools/normalize.d.ts +3 -0
- package/dist/tools/normalize.js +8 -5
- package/dist/tools/search/dependencies.d.ts +2 -2
- package/dist/tools/shell/background.d.ts +6 -0
- package/dist/tools/shell/background.js +17 -0
- package/dist/tools/symbols/find-symbol.d.ts +1 -1
- package/dist/tools/symbols/find-symbol.js +8 -6
- package/dist/tools/types.d.ts +8 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +454 -53
- package/dist/tui/app.test.js +66 -3
- package/dist/tui/approval.js +53 -1
- package/dist/tui/markdown.js +9 -0
- package/dist/tui/mouse.d.ts +26 -1
- package/dist/tui/mouse.js +104 -6
- package/dist/tui/scroll-flow.test.js +3 -1
- package/dist/tui/tokens.d.ts +6 -6
- package/dist/tui/tokens.js +9 -6
- 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,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
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
if (!opts.bare) {
|
|
165
|
+
try {
|
|
166
|
+
runHooks = loadHooks(opts.cwd);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
runHooks = [];
|
|
170
|
+
}
|
|
162
171
|
}
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
//
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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:
|
|
857
|
-
pattern: patternForCall(call.name,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
885
|
-
// denies the tool with POLICY_DENIED — the real tool never runs.
|
|
886
|
-
|
|
887
|
-
|
|
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
|
|
998
|
-
// plus a bus event, and never fail the turn.
|
|
999
|
-
|
|
1000
|
-
|
|
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>;
|