klyro 1.0.1 → 1.0.2
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/anthropic-adapter.d.ts +13 -5
- package/dist/agent/anthropic-adapter.js +19 -2
- package/dist/agent/capabilities.js +7 -1
- package/dist/agent/orchestrator.d.ts +18 -1
- package/dist/agent/orchestrator.js +51 -4
- package/dist/agent/retry.js +52 -10
- package/dist/agent/runtime.d.ts +34 -0
- package/dist/agent/runtime.js +164 -14
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +26 -0
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +45 -0
- package/dist/cli/config.js +82 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +41 -1
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.js +76 -3
- package/dist/events/catalog.d.ts +9 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +89 -5
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/registry.d.ts +0 -18
- package/dist/mcp/registry.js +49 -2
- package/dist/policy/engine.d.ts +16 -0
- package/dist/policy/engine.js +74 -1
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/providers/model-info.d.ts +6 -0
- package/dist/providers/model-info.js +8 -0
- package/dist/tools/fs/apply-patch.js +6 -1
- package/dist/tools/fs/edit-file.js +4 -1
- package/dist/tools/fs/multi-edit.js +4 -1
- package/dist/tools/fs/write-file.js +16 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/shell/shell-exec.d.ts +28 -0
- package/dist/tools/shell/shell-exec.js +87 -1
- package/dist/trace/writer.d.ts +7 -0
- package/dist/trace/writer.js +7 -0
- package/dist/verification/classify.js +4 -3
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +25 -0
- package/dist/verification/registry.js +16 -5
- package/dist/verification/scoped.js +36 -5
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks engine (10.2) — preToolUse / postToolUse shell hooks.
|
|
3
|
+
*
|
|
4
|
+
* Config files:
|
|
5
|
+
* - project: `<cwd>/.klyro/hooks.json`
|
|
6
|
+
* - global: `~/.klyro/hooks.json` (merged; project wins on name clash)
|
|
7
|
+
*
|
|
8
|
+
* Schema: `{ hooks: Array<{ name, event, command, timeoutMs? }> }`.
|
|
9
|
+
*
|
|
10
|
+
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
11
|
+
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
12
|
+
* command with `shell: true` (commands are strings, must work on win +
|
|
13
|
+
* posix), a default 30s timeout, a filtered env, plus `KLYRO_TOOL_NAME`
|
|
14
|
+
* and `KLYRO_TOOL_INPUT_JSON` for the hook's inspection.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
export declare const HookSchema: z.ZodObject<{
|
|
18
|
+
name: z.ZodString;
|
|
19
|
+
event: z.ZodEnum<{
|
|
20
|
+
preToolUse: "preToolUse";
|
|
21
|
+
postToolUse: "postToolUse";
|
|
22
|
+
}>;
|
|
23
|
+
command: z.ZodString;
|
|
24
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export type Hook = z.infer<typeof HookSchema>;
|
|
27
|
+
export interface HookResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
exitCode: number | null;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_HOOK_TIMEOUT_MS = 30000;
|
|
34
|
+
/**
|
|
35
|
+
* Load hooks for a run. Global first, then project — a project hook with
|
|
36
|
+
* the same `name` replaces the global one. Never throws.
|
|
37
|
+
*/
|
|
38
|
+
export declare function loadHooks(cwd: string): Hook[];
|
|
39
|
+
export interface HookContext {
|
|
40
|
+
toolName: string;
|
|
41
|
+
input: unknown;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run one hook. Resolves (never rejects) with the exit code + sliced
|
|
45
|
+
* output. `ok` is true only when the process exited 0.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runHook(hook: Hook, ctx: HookContext): Promise<HookResult>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks engine (10.2) — preToolUse / postToolUse shell hooks.
|
|
3
|
+
*
|
|
4
|
+
* Config files:
|
|
5
|
+
* - project: `<cwd>/.klyro/hooks.json`
|
|
6
|
+
* - global: `~/.klyro/hooks.json` (merged; project wins on name clash)
|
|
7
|
+
*
|
|
8
|
+
* Schema: `{ hooks: Array<{ name, event, command, timeoutMs? }> }`.
|
|
9
|
+
*
|
|
10
|
+
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
11
|
+
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
12
|
+
* command with `shell: true` (commands are strings, must work on win +
|
|
13
|
+
* posix), a default 30s timeout, a filtered env, plus `KLYRO_TOOL_NAME`
|
|
14
|
+
* and `KLYRO_TOOL_INPUT_JSON` for the hook's inspection.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
export const HookSchema = z.object({
|
|
22
|
+
name: z.string().min(1),
|
|
23
|
+
event: z.enum(['preToolUse', 'postToolUse']),
|
|
24
|
+
command: z.string().min(1),
|
|
25
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
26
|
+
});
|
|
27
|
+
const HooksFileSchema = z.object({
|
|
28
|
+
hooks: z.array(HookSchema),
|
|
29
|
+
});
|
|
30
|
+
export const DEFAULT_HOOK_TIMEOUT_MS = 30_000;
|
|
31
|
+
const MAX_HOOK_OUTPUT_CHARS = 4000;
|
|
32
|
+
/** Paths already warned about (invalid JSON/schema) — warn once per path. */
|
|
33
|
+
const warnedPaths = new Set();
|
|
34
|
+
function warnOnce(filePath, detail) {
|
|
35
|
+
if (warnedPaths.has(filePath))
|
|
36
|
+
return;
|
|
37
|
+
warnedPaths.add(filePath);
|
|
38
|
+
try {
|
|
39
|
+
process.stderr.write(`klyro: hooks: ignoring invalid file ${filePath}: ${detail}\n`);
|
|
40
|
+
}
|
|
41
|
+
catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
/** Read + validate one hooks file. Missing → []. Invalid → [] + warn once. */
|
|
44
|
+
function readHooksFile(filePath) {
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return []; // missing/unreadable — silent, zero-cost fast path stays silent
|
|
51
|
+
}
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
warnOnce(filePath, err instanceof Error ? err.message : String(err));
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
const checked = HooksFileSchema.safeParse(parsed);
|
|
61
|
+
if (!checked.success) {
|
|
62
|
+
const first = checked.error.issues[0];
|
|
63
|
+
warnOnce(filePath, first ? `${first.path.join('.') || 'hooks'}: ${first.message}` : 'schema mismatch');
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
return checked.data.hooks;
|
|
67
|
+
}
|
|
68
|
+
function globalHooksPath() {
|
|
69
|
+
try {
|
|
70
|
+
return path.join(os.homedir() || process.cwd(), '.klyro', 'hooks.json');
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return path.join(process.cwd(), '.klyro', 'hooks.json');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Load hooks for a run. Global first, then project — a project hook with
|
|
78
|
+
* the same `name` replaces the global one. Never throws.
|
|
79
|
+
*/
|
|
80
|
+
export function loadHooks(cwd) {
|
|
81
|
+
let out = [];
|
|
82
|
+
try {
|
|
83
|
+
const byName = new Map();
|
|
84
|
+
for (const h of readHooksFile(globalHooksPath()))
|
|
85
|
+
byName.set(h.name, h);
|
|
86
|
+
const projectFile = path.join(cwd, '.klyro', 'hooks.json');
|
|
87
|
+
for (const h of readHooksFile(projectFile))
|
|
88
|
+
byName.set(h.name, h);
|
|
89
|
+
out = [...byName.values()];
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Minimal filtered env for hook children (mirrors the shell_exec policy:
|
|
98
|
+
* secrets stripped, only safe prefixes pass). Kept local so the hooks
|
|
99
|
+
* engine never depends on the tool layer at import time.
|
|
100
|
+
*/
|
|
101
|
+
function hookEnv() {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
104
|
+
if (k.startsWith('KLYRO_') && k.includes('API_KEY'))
|
|
105
|
+
continue;
|
|
106
|
+
if (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
|
|
107
|
+
continue;
|
|
108
|
+
if (k.startsWith('PATH') || k.startsWith('HOME') || k.startsWith('USER') ||
|
|
109
|
+
k.startsWith('SHELL') || k.startsWith('TERM') || k.startsWith('LANG') ||
|
|
110
|
+
k.startsWith('NODE_') || k.startsWith('NPM_') || k.startsWith('PNPM_') ||
|
|
111
|
+
k.startsWith('YARN_') || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP' ||
|
|
112
|
+
k === 'SystemRoot' || k === 'windir' || k === 'PATHEXT' || k === 'OS') {
|
|
113
|
+
out[k] = v;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
out.PATH = process.env.PATH;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Run one hook. Resolves (never rejects) with the exit code + sliced
|
|
121
|
+
* output. `ok` is true only when the process exited 0.
|
|
122
|
+
*/
|
|
123
|
+
export function runHook(hook, ctx) {
|
|
124
|
+
const timeoutMs = hook.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
let env;
|
|
127
|
+
try {
|
|
128
|
+
// Filtered env (same policy as shell_exec): secrets stripped, only
|
|
129
|
+
// safe prefixes pass — plus the hook's own inspection vars below.
|
|
130
|
+
env = hookEnv();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
env = { PATH: process.env.PATH };
|
|
134
|
+
}
|
|
135
|
+
env.KLYRO_TOOL_NAME = ctx.toolName;
|
|
136
|
+
try {
|
|
137
|
+
env.KLYRO_TOOL_INPUT_JSON = JSON.stringify(ctx.input ?? {}).slice(0, 16_000);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
env.KLYRO_TOOL_INPUT_JSON = '{}';
|
|
141
|
+
}
|
|
142
|
+
let child;
|
|
143
|
+
try {
|
|
144
|
+
child = spawn(hook.command, {
|
|
145
|
+
cwd: process.cwd(),
|
|
146
|
+
shell: true,
|
|
147
|
+
windowsHide: true,
|
|
148
|
+
timeout: timeoutMs,
|
|
149
|
+
env,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
resolve({ ok: false, exitCode: -1, stdout: '', stderr: String(err instanceof Error ? err.message : err).slice(0, MAX_HOOK_OUTPUT_CHARS) });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let stdout = '';
|
|
157
|
+
let stderr = '';
|
|
158
|
+
child.stdout?.on('data', (d) => {
|
|
159
|
+
stdout += d.toString();
|
|
160
|
+
if (stdout.length > MAX_HOOK_OUTPUT_CHARS * 2)
|
|
161
|
+
stdout = stdout.slice(0, MAX_HOOK_OUTPUT_CHARS * 2);
|
|
162
|
+
});
|
|
163
|
+
child.stderr?.on('data', (d) => {
|
|
164
|
+
stderr += d.toString();
|
|
165
|
+
if (stderr.length > MAX_HOOK_OUTPUT_CHARS * 2)
|
|
166
|
+
stderr = stderr.slice(0, MAX_HOOK_OUTPUT_CHARS * 2);
|
|
167
|
+
});
|
|
168
|
+
child.on('error', (err) => {
|
|
169
|
+
resolve({ ok: false, exitCode: -1, stdout: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS), stderr: String(err.message ?? err).slice(0, MAX_HOOK_OUTPUT_CHARS) });
|
|
170
|
+
});
|
|
171
|
+
child.on('close', (code) => {
|
|
172
|
+
const exitCode = typeof code === 'number' ? code : -1;
|
|
173
|
+
resolve({
|
|
174
|
+
ok: exitCode === 0,
|
|
175
|
+
exitCode,
|
|
176
|
+
stdout: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS),
|
|
177
|
+
stderr: stderr.slice(0, MAX_HOOK_OUTPUT_CHARS),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -185,6 +185,27 @@ export async function startRepl(opts = {}) {
|
|
|
185
185
|
},
|
|
186
186
|
});
|
|
187
187
|
let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
|
|
188
|
+
// L15 failover — initial adapter only: extra `providers.failover` entries
|
|
189
|
+
// become fallback adapters for agent runs in this session. Live
|
|
190
|
+
// /provider switches rebuild a single adapter (buildAdapter) and bypass
|
|
191
|
+
// the chain — documented, not a bug.
|
|
192
|
+
let replFailoverAdapters = [];
|
|
193
|
+
try {
|
|
194
|
+
const { resolveProviderChain } = await import('./config.js');
|
|
195
|
+
const chain = await resolveProviderChain(cwd);
|
|
196
|
+
for (const entry of chain.slice(1)) {
|
|
197
|
+
if (!entry.apiKey)
|
|
198
|
+
continue;
|
|
199
|
+
try {
|
|
200
|
+
replFailoverAdapters.push(buildAdapter(entry.provider, entry.baseURL ?? currentBaseUrl, entry.apiKey));
|
|
201
|
+
}
|
|
202
|
+
catch { /* skip unbuildable entries */ }
|
|
203
|
+
}
|
|
204
|
+
if (replFailoverAdapters.length > 0) {
|
|
205
|
+
process.stderr.write(`klyro: failover chain: ${replFailoverAdapters.map((b) => b.id).join(' → ')}\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch { /* best-effort — single-provider session proceeds */ }
|
|
188
209
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
189
210
|
let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
190
211
|
// 4.4 KLYRO.md hierarchy (mutable — /reload refreshes). Content is gated
|
|
@@ -646,6 +667,19 @@ export async function startRepl(opts = {}) {
|
|
|
646
667
|
if (isMounted && directHooks)
|
|
647
668
|
directHooks.clearTranscript();
|
|
648
669
|
}
|
|
670
|
+
// Update nudge (best-effort, cached 24h, silent fail): a single stderr
|
|
671
|
+
// line when a newer version exists. No prompt, no blocking — the check
|
|
672
|
+
// itself is fire-and-forget.
|
|
673
|
+
void (async () => {
|
|
674
|
+
try {
|
|
675
|
+
const { checkForUpdate } = await import('./update.js');
|
|
676
|
+
const cur = readVersion();
|
|
677
|
+
const latest = await checkForUpdate(cur);
|
|
678
|
+
if (latest)
|
|
679
|
+
process.stderr.write(`Update available: ${cur} → ${latest} (klyro update)\n`);
|
|
680
|
+
}
|
|
681
|
+
catch { /* silent */ }
|
|
682
|
+
})();
|
|
649
683
|
app = render(React.createElement(App, {
|
|
650
684
|
initialModel: model,
|
|
651
685
|
maxSteps: currentMaxSteps,
|
|
@@ -897,8 +931,14 @@ export async function startRepl(opts = {}) {
|
|
|
897
931
|
else if (ev.kind === 'aborted') {
|
|
898
932
|
queuedStatus({ status: 'aborted' });
|
|
899
933
|
}
|
|
934
|
+
else if (ev.kind === 'model_override') {
|
|
935
|
+
queuedAppend({ id: `movr-${Date.now()}`, kind: 'text', text: `[model] override: requested ${ev.requested} → effective ${ev.effective}`, role: 'assistant' });
|
|
936
|
+
}
|
|
900
937
|
},
|
|
901
|
-
}, {
|
|
938
|
+
}, {
|
|
939
|
+
adapter, registry, policy, approval, systemPrompt: systemPromptFn,
|
|
940
|
+
...(replFailoverAdapters.length > 0 ? { failoverAdapters: replFailoverAdapters } : {}),
|
|
941
|
+
});
|
|
902
942
|
if (result.finalText)
|
|
903
943
|
lastAssistantText = result.finalText;
|
|
904
944
|
if (result.verification) {
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -70,6 +70,12 @@ export interface RunCliOptions {
|
|
|
70
70
|
agent?: string;
|
|
71
71
|
maxDepth?: number;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
|
|
75
|
+
* within 1500ms of the first forces `process.exit(130)`. The live handler
|
|
76
|
+
* below owns the timestamp closure; tests exercise only this predicate.
|
|
77
|
+
*/
|
|
78
|
+
export declare function shouldForceExit(lastSigintAt: number | undefined, now: number): boolean;
|
|
73
79
|
export declare function runOnce(opts: RunCliOptions): Promise<number>;
|
|
74
80
|
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
|
|
75
81
|
export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn): Promise<SystemPromptFn>;
|
package/dist/cli/run.js
CHANGED
|
@@ -16,6 +16,7 @@ import { run } from '../agent/runtime.js';
|
|
|
16
16
|
import { builtinRegistry } from '../tools/registry.js';
|
|
17
17
|
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
18
18
|
import { DenyAllApprovalPrompt } from '../policy/approval.js';
|
|
19
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
19
20
|
import { buildLevel6Context } from '../context/level6.js';
|
|
20
21
|
import { memoryBlock } from '../context/memory.js';
|
|
21
22
|
import { resolveSessionId } from '../persistence/session.js';
|
|
@@ -24,6 +25,14 @@ function readEnv(name, fallback) {
|
|
|
24
25
|
const v = process.env[name];
|
|
25
26
|
return v && v.length > 0 ? v : fallback;
|
|
26
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
|
|
30
|
+
* within 1500ms of the first forces `process.exit(130)`. The live handler
|
|
31
|
+
* below owns the timestamp closure; tests exercise only this predicate.
|
|
32
|
+
*/
|
|
33
|
+
export function shouldForceExit(lastSigintAt, now) {
|
|
34
|
+
return lastSigintAt !== undefined && now - lastSigintAt < 1500;
|
|
35
|
+
}
|
|
27
36
|
export async function runOnce(opts) {
|
|
28
37
|
// P0.5 — load <cwd>/.env first so KLYRO_* vars resolve without `export`.
|
|
29
38
|
// Never throws (missing file is a no-op); explicit env wins (no-clobber).
|
|
@@ -88,6 +97,36 @@ export async function runOnce(opts) {
|
|
|
88
97
|
adapter = retryingAdapter(httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: opts.timeoutMs ?? 60_000 }), { onRetry: onRetryEmit });
|
|
89
98
|
}
|
|
90
99
|
}
|
|
100
|
+
// L15 provider failover: extra chain entries (after the primary) become
|
|
101
|
+
// fallback adapters for the runtime. Custom injected adapters (tests)
|
|
102
|
+
// skip chain wiring. Failures resolving the chain never block the run.
|
|
103
|
+
let failoverAdapters;
|
|
104
|
+
if (!opts.adapter) {
|
|
105
|
+
try {
|
|
106
|
+
const { resolveProviderChain } = await import('./config.js');
|
|
107
|
+
const chain = await resolveProviderChain(opts.cwd);
|
|
108
|
+
const fallbacks = chain.slice(1);
|
|
109
|
+
if (fallbacks.length > 0) {
|
|
110
|
+
const built = [];
|
|
111
|
+
for (const entry of fallbacks) {
|
|
112
|
+
if (!entry.apiKey)
|
|
113
|
+
continue;
|
|
114
|
+
const base = entry.provider === 'anthropic'
|
|
115
|
+
? anthropicAdapter({ baseURL: entry.baseURL, apiKey: entry.apiKey, timeoutMs: opts.timeoutMs ?? 60_000 })
|
|
116
|
+
: entry.baseURL
|
|
117
|
+
? httpChatAdapter({ baseURL: entry.baseURL, apiKey: entry.apiKey, timeoutMs: opts.timeoutMs ?? 60_000 })
|
|
118
|
+
: null;
|
|
119
|
+
if (base)
|
|
120
|
+
built.push(retryingAdapter(base, { onRetry: onRetryEmit }));
|
|
121
|
+
}
|
|
122
|
+
if (built.length > 0) {
|
|
123
|
+
failoverAdapters = built;
|
|
124
|
+
stderr.write(`klyro: failover chain: ${built.map((b) => b.id).join(' → ')}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* best-effort — single-provider run proceeds */ }
|
|
129
|
+
}
|
|
91
130
|
const registry = builtinRegistry();
|
|
92
131
|
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
93
132
|
// Persisted "always allow" patterns apply to one-shot runs too.
|
|
@@ -149,6 +188,16 @@ export async function runOnce(opts) {
|
|
|
149
188
|
return 2;
|
|
150
189
|
}
|
|
151
190
|
sessionId = full;
|
|
191
|
+
// M22 resume-lock warning (read-only probe — takeover proceeds anyway).
|
|
192
|
+
try {
|
|
193
|
+
const { readSessionLock } = await import('../persistence/store.js');
|
|
194
|
+
const { getDefaultSessionsDir } = await import('../persistence/session.js');
|
|
195
|
+
const lock = readSessionLock(opts.sessionsDir ?? getDefaultSessionsDir(), sessionId);
|
|
196
|
+
if (lock.held && lock.alive) {
|
|
197
|
+
stderr.write(`klyro: session ${sessionId.slice(0, 8)} is locked by live pid ${lock.pid ?? '?'} — taking over (proceeding anyway)\n`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch { /* probe is best-effort; never block resume */ }
|
|
152
201
|
const msgs = await store.loadMessages(sessionId);
|
|
153
202
|
// Convert StoredMessage to Message
|
|
154
203
|
initialTranscript = msgs.map((m) => ({ role: m.role, content: m.content }));
|
|
@@ -176,7 +225,17 @@ export async function runOnce(opts) {
|
|
|
176
225
|
// persistence is disabled or the session was never created).
|
|
177
226
|
sessionIdForRetry.id = sessionId ?? 'ephemeral';
|
|
178
227
|
const ac = new AbortController();
|
|
228
|
+
// Double-Ctrl+C: first press aborts the run; a second press within
|
|
229
|
+
// 1500ms forces process.exit(130) (shouldForceExit owns the predicate).
|
|
230
|
+
let lastSigintAt;
|
|
179
231
|
const onSigint = () => {
|
|
232
|
+
const now = Date.now();
|
|
233
|
+
if (shouldForceExit(lastSigintAt, now)) {
|
|
234
|
+
stderr.write('\nklyro: SIGINT twice — forcing exit\n');
|
|
235
|
+
process.exit(130);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
lastSigintAt = now;
|
|
180
239
|
stderr.write('\nklyro: SIGINT — aborting\n');
|
|
181
240
|
ac.abort();
|
|
182
241
|
};
|
|
@@ -298,8 +357,20 @@ export async function runOnce(opts) {
|
|
|
298
357
|
if (output === 'human')
|
|
299
358
|
stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
|
|
300
359
|
}
|
|
360
|
+
else if (ev.kind === 'provider_failover') {
|
|
361
|
+
stderr.write(`[failover] ${ev.from} → ${ev.to}: ${ev.reason.slice(0, 200)}\n`);
|
|
362
|
+
}
|
|
363
|
+
else if (ev.kind === 'budget_warning') {
|
|
364
|
+
stderr.write(`[budget] ${(ev.ratio * 100).toFixed(0)}% of max cost used (threshold ${(ev.threshold * 100).toFixed(0)}%)\n`);
|
|
365
|
+
}
|
|
366
|
+
else if (ev.kind === 'model_override') {
|
|
367
|
+
stderr.write(`[model] override: requested ${ev.requested} → effective ${ev.effective}\n`);
|
|
368
|
+
}
|
|
301
369
|
},
|
|
302
|
-
}, {
|
|
370
|
+
}, {
|
|
371
|
+
adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt,
|
|
372
|
+
...(failoverAdapters ? { failoverAdapters } : {}),
|
|
373
|
+
});
|
|
303
374
|
}
|
|
304
375
|
finally {
|
|
305
376
|
doneSigint();
|
|
@@ -404,8 +475,10 @@ async function dryRunReport(opts) {
|
|
|
404
475
|
maxSteps: opts.maxSteps,
|
|
405
476
|
maxTokens: opts.maxTokens,
|
|
406
477
|
temperature: opts.temperature,
|
|
407
|
-
|
|
408
|
-
task
|
|
478
|
+
// Secrets must never leak into a printable report: redact both the
|
|
479
|
+
// assembled system prompt and the task before printing.
|
|
480
|
+
systemPrompt: redact(systemPrompt),
|
|
481
|
+
task: redact(opts.task),
|
|
409
482
|
toolCount: registry.list().length,
|
|
410
483
|
toolNames: registry.list().map((t) => t.name),
|
|
411
484
|
policyRules: rules.map((r) => r.name),
|
package/dist/events/catalog.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 3.1 — KlyroEvent catalog (Appendix C)
|
|
3
3
|
* Every observable action in the harness is a typed event.
|
|
4
|
+
*
|
|
5
|
+
* Reserved-future members (declared but intentionally unproduced — no
|
|
6
|
+
* emitter exists yet, so do not treat their absence as a bug):
|
|
7
|
+
* `subtask.tool_call`, `subtask.tool_result`.
|
|
8
|
+
* `subtask.progress` IS emitted (throttled: max 1 per child tool call,
|
|
9
|
+
* in-process children only; process-isolated children don't run the emitter).
|
|
10
|
+
* The orchestrator otherwise emits started/completed/failed/cancelled/
|
|
11
|
+
* timed_out/merged only; grep for emitters before assuming one of the
|
|
12
|
+
* reserved members fires.
|
|
4
13
|
*/
|
|
5
14
|
export type KlyroEvent = {
|
|
6
15
|
type: 'session.start';
|
package/dist/events/catalog.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 3.1 — KlyroEvent catalog (Appendix C)
|
|
3
3
|
* Every observable action in the harness is a typed event.
|
|
4
|
+
*
|
|
5
|
+
* Reserved-future members (declared but intentionally unproduced — no
|
|
6
|
+
* emitter exists yet, so do not treat their absence as a bug):
|
|
7
|
+
* `subtask.tool_call`, `subtask.tool_result`.
|
|
8
|
+
* `subtask.progress` IS emitted (throttled: max 1 per child tool call,
|
|
9
|
+
* in-process children only; process-isolated children don't run the emitter).
|
|
10
|
+
* The orchestrator otherwise emits started/completed/failed/cancelled/
|
|
11
|
+
* timed_out/merged only; grep for emitters before assuming one of the
|
|
12
|
+
* reserved members fires.
|
|
4
13
|
*/
|
|
5
14
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -349,7 +349,7 @@ async function main() {
|
|
|
349
349
|
});
|
|
350
350
|
program
|
|
351
351
|
.command('chat [prompt]')
|
|
352
|
-
.description('Legacy streamed chat. Without a prompt, start an interactive REPL.')
|
|
352
|
+
.description('Legacy streamed chat. Without a prompt, start an interactive REPL. (deprecated: history truncation is approximate; prefer `klyro tui`)')
|
|
353
353
|
.option('-s, --system <text>', 'System message', 'You are a helpful assistant.')
|
|
354
354
|
.option('-m, --model <id>', 'Override the model (default: env KLYRO_MODEL)')
|
|
355
355
|
.option('-t, --timeout <ms>', 'Request timeout in ms (default: env KLYRO_TIMEOUT_MS or 60000)', (v) => parsePositiveInt('-t/--timeout', v))
|
|
@@ -613,11 +613,78 @@ async function main() {
|
|
|
613
613
|
}
|
|
614
614
|
});
|
|
615
615
|
mcp.command('add <name> <url>').description('Add MCP server').action(async (name) => { process.stdout.write(`added mcp ${name} (stub)\n`); });
|
|
616
|
+
mcp.command('probe <name>').description('Connect to an MCP server (15s timeout), list its tools, print count+names').action(async (name) => {
|
|
617
|
+
const { loadMcpServers } = await import('./mcp/config.js');
|
|
618
|
+
const cfg = loadMcpServers(process.cwd());
|
|
619
|
+
const spec = cfg.servers[name];
|
|
620
|
+
if (!spec) {
|
|
621
|
+
process.stderr.write(`klyro: mcp server not found: ${name}\n`);
|
|
622
|
+
process.exit(2);
|
|
623
|
+
}
|
|
624
|
+
const { McpClient } = await import('./mcp/client.js');
|
|
625
|
+
const client = new McpClient(name, spec);
|
|
626
|
+
// 15s overall probe budget (connect has its own internal timeout too).
|
|
627
|
+
const timer = setTimeout(() => {
|
|
628
|
+
process.stderr.write(`klyro: mcp probe ${name} timed out after 15s\n`);
|
|
629
|
+
process.exit(2);
|
|
630
|
+
}, 15_000);
|
|
631
|
+
try {
|
|
632
|
+
await client.connect();
|
|
633
|
+
const tools = await client.listTools();
|
|
634
|
+
const names = tools.map((t) => t.name);
|
|
635
|
+
process.stdout.write(`${name}: ${tools.length} tool(s)${names.length > 0 ? `: ${names.join(', ')}` : ''}\n`);
|
|
636
|
+
process.exit(0);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
process.stderr.write(`klyro: mcp probe ${name} failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
640
|
+
process.exit(2);
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
clearTimeout(timer);
|
|
644
|
+
try {
|
|
645
|
+
await client.close();
|
|
646
|
+
}
|
|
647
|
+
catch { /* ignore */ }
|
|
648
|
+
}
|
|
649
|
+
});
|
|
616
650
|
mcp.command('serve').description('Serve as MCP server').action(async () => { process.stdout.write('klyro mcp serve — exposing tools (stub)\n'); });
|
|
617
|
-
// 10.2 — Hooks /
|
|
618
|
-
program.command('hooks').description('
|
|
619
|
-
|
|
651
|
+
// 10.2 — Hooks: list configured preToolUse/postToolUse hooks.
|
|
652
|
+
program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` or `klyro hooks list` prints configured hooks').action(async (cmd) => {
|
|
653
|
+
if (cmd && cmd !== 'list') {
|
|
654
|
+
process.stderr.write(`klyro: unknown hooks command: ${cmd} (usage: klyro hooks [list])\n`);
|
|
655
|
+
process.exit(2);
|
|
656
|
+
}
|
|
657
|
+
const { loadHooks } = await import('./cli/hooks.js');
|
|
658
|
+
const hooks = loadHooks(process.cwd());
|
|
659
|
+
if (hooks.length === 0) {
|
|
660
|
+
process.stdout.write('hooks: none configured (.klyro/hooks.json, ~/.klyro/hooks.json)\n');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
for (const h of hooks)
|
|
664
|
+
process.stdout.write(`${h.name} ${h.event} ${h.command}\n`);
|
|
665
|
+
});
|
|
666
|
+
program.command('agents [name] [extra...]').description('List agents (10.2), show one, or run: agents run <name> <task...>').action(async (name, extra) => {
|
|
620
667
|
const { BUILTIN_AGENTS } = await import('./agent/orchestrator.js');
|
|
668
|
+
// `klyro agents run <name> <task...>`: one-shot run under a named agent.
|
|
669
|
+
if (name === 'run') {
|
|
670
|
+
const [agentName, ...taskParts] = extra ?? [];
|
|
671
|
+
if (!agentName || !BUILTIN_AGENTS.some((a) => a.id === agentName)) {
|
|
672
|
+
process.stderr.write(`klyro: unknown agent: ${agentName ?? '(missing)'} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
|
|
673
|
+
process.exit(2);
|
|
674
|
+
}
|
|
675
|
+
const task = (taskParts ?? []).join(' ').trim();
|
|
676
|
+
if (!task) {
|
|
677
|
+
process.stderr.write('klyro: agents run requires a task (usage: klyro agents run <name> <task...>)\n');
|
|
678
|
+
process.exit(2);
|
|
679
|
+
}
|
|
680
|
+
const model = process.env.KLYRO_MODEL;
|
|
681
|
+
if (!model) {
|
|
682
|
+
process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model via klyro run)\n');
|
|
683
|
+
process.exit(2);
|
|
684
|
+
}
|
|
685
|
+
const code = await runOnce({ task, cwd: process.cwd(), model, agent: agentName });
|
|
686
|
+
process.exit(code);
|
|
687
|
+
}
|
|
621
688
|
if (!name) {
|
|
622
689
|
for (const a of BUILTIN_AGENTS)
|
|
623
690
|
process.stdout.write(`${a.id} — ${a.description}\n`);
|
|
@@ -642,7 +709,24 @@ async function main() {
|
|
|
642
709
|
process.stdout.write(lines.join('\n') + '\n');
|
|
643
710
|
});
|
|
644
711
|
// 10.3 — Web / git workflows / SDK
|
|
645
|
-
program
|
|
712
|
+
program
|
|
713
|
+
.command('commit')
|
|
714
|
+
.description('Commit staged changes with a conventional message (verification hooks always run)')
|
|
715
|
+
.option('--dry-run', 'Print the message + files without committing')
|
|
716
|
+
.option('--message <msg>', 'Summary for the conventional message (default: update <n> files)')
|
|
717
|
+
.option('--force-secret', 'Commit even if the staged diff looks like it contains a secret')
|
|
718
|
+
.action(async (opts) => {
|
|
719
|
+
const { runCommit } = await import('./cli/commit.js');
|
|
720
|
+
const globalYes = program.opts().yes ?? process.env.KLYRO_YES === '1';
|
|
721
|
+
const code = await runCommit({
|
|
722
|
+
cwd: process.cwd(),
|
|
723
|
+
yes: !!globalYes,
|
|
724
|
+
dryRun: !!opts.dryRun,
|
|
725
|
+
...(opts.message !== undefined ? { message: opts.message } : {}),
|
|
726
|
+
forceSecret: !!opts.forceSecret,
|
|
727
|
+
});
|
|
728
|
+
process.exit(code);
|
|
729
|
+
});
|
|
646
730
|
program.command('audit [session]').description('Verify audit chain (13.4)').action(async (session) => {
|
|
647
731
|
if (!session) {
|
|
648
732
|
process.stderr.write('klyro: audit requires a session id (usage: klyro audit <session>)\n');
|
package/dist/mcp/client.js
CHANGED
|
@@ -90,7 +90,7 @@ export class McpClient {
|
|
|
90
90
|
await this.request('initialize', {
|
|
91
91
|
protocolVersion: PROTOCOL_VERSION,
|
|
92
92
|
capabilities: {},
|
|
93
|
-
clientInfo: { name: 'klyro', version: '1.0.
|
|
93
|
+
clientInfo: { name: 'klyro', version: '1.0.2' },
|
|
94
94
|
}, timeoutMs);
|
|
95
95
|
this.notify('notifications/initialized', {});
|
|
96
96
|
this.connected = true;
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -1,21 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* P1 — MCP tool registration (r-11-17.md §3.1).
|
|
3
|
-
*
|
|
4
|
-
* Exposes MCP server tools through the {@link ToolRegistry} under
|
|
5
|
-
* `mcp__<server>__<tool>` names. Security properties (non-negotiable):
|
|
6
|
-
*
|
|
7
|
-
* 1. Deny-by-default: `evaluateMcpPolicy` runs BEFORE any client I/O; a
|
|
8
|
-
* denied tool returns POLICY_DENIED without touching the subprocess.
|
|
9
|
-
* 2. Redact-before-transcript: every success value AND error message passes
|
|
10
|
-
* through `redact()` before it can reach the model or trace.
|
|
11
|
-
* 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
|
|
12
|
-
* runtime loop prompts before executing.
|
|
13
|
-
*
|
|
14
|
-
* Permission class: no code in src consumes `Tool.permission` (it is
|
|
15
|
-
* write-only metadata today), so we pick the most restrictive class,
|
|
16
|
-
* 'admin' — the same class as `spawn_agent`, since MCP tools execute
|
|
17
|
-
* arbitrary external side effects (read/write/network) outside our control.
|
|
18
|
-
*/
|
|
19
1
|
import { type McpClientLike } from './client.js';
|
|
20
2
|
import { type McpServerSpec } from './config.js';
|
|
21
3
|
import type { PolicyEngine } from '../policy/engine.js';
|