klyro 1.0.0 → 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 +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
|
@@ -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
|
@@ -15,6 +15,7 @@ import { anthropicAdapter } from '../agent/anthropic-adapter.js';
|
|
|
15
15
|
import { retryingAdapter } from '../agent/retry.js';
|
|
16
16
|
import { globalBus } from '../events/bus.js';
|
|
17
17
|
import { run } from '../agent/runtime.js';
|
|
18
|
+
import { resolveSystemPrompt } from '../agent/runtime.js';
|
|
18
19
|
import { builtinRegistry } from '../tools/registry.js';
|
|
19
20
|
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
20
21
|
import { buildLevel6Context } from '../context/level6.js';
|
|
@@ -28,7 +29,10 @@ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, createReadWrapper } from '../
|
|
|
28
29
|
import { inferProviderFromBaseURL } from '../agent/registry.js';
|
|
29
30
|
import { getDefaultSessionStore } from '../persistence/session.js';
|
|
30
31
|
import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
|
|
32
|
+
import { memoryBlock } from '../context/memory.js';
|
|
31
33
|
import { estimateCost } from '../providers/model-info.js';
|
|
34
|
+
import { ContextTrust } from '../context/trust.js';
|
|
35
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
32
36
|
export async function startRepl(opts = {}) {
|
|
33
37
|
// P0.5 — load <cwd>/.env first so provider resolution below sees KLYRO_*
|
|
34
38
|
// vars without `export`. Never throws; explicit env wins (no-clobber).
|
|
@@ -90,21 +94,66 @@ export async function startRepl(opts = {}) {
|
|
|
90
94
|
catch {
|
|
91
95
|
/* ignore — engine defaults stand */
|
|
92
96
|
}
|
|
93
|
-
// Best-effort MCP tools: never fatal, never prompts.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
97
|
+
// Best-effort MCP tools: never fatal, never prompts. Servers live for the
|
|
98
|
+
// process lifetime, so no close wiring here (unlike runOnce's finally).
|
|
99
|
+
// NOTE: the actual loadAndRegisterMcp call lives below (after the approval
|
|
100
|
+
// object exists) so project-server approval can route through it.
|
|
101
|
+
// Local inline sanitizer for the approval prompt (duplicates the shared
|
|
102
|
+
// sanitizeForPrompt in sibling-owned policy/approval.ts — intentionally
|
|
103
|
+
// not imported; kept local to avoid coupling the REPL to that module).
|
|
104
|
+
function sanitizeForPrompt(s) {
|
|
105
|
+
return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g, '').slice(0, 120);
|
|
106
|
+
}
|
|
107
|
+
async function loadMcpTools() {
|
|
108
|
+
try {
|
|
109
|
+
const { loadAndRegisterMcp } = await import('../mcp/registry.js');
|
|
110
|
+
const mcp = await loadAndRegisterMcp({
|
|
111
|
+
cwd,
|
|
112
|
+
registry,
|
|
113
|
+
policy,
|
|
114
|
+
// CONTRACT (sibling D): project-server approval hook. Registries that
|
|
115
|
+
// predate the contract ignore the unknown opt (checked via try/catch
|
|
116
|
+
// below with a skip-with-warning fallback).
|
|
117
|
+
approveProjectServer: async (info) => {
|
|
118
|
+
// Show the disabled state alongside source before asking, so a
|
|
119
|
+
// disabled server's approval can't be mistaken for activation.
|
|
120
|
+
let disabledNote = '';
|
|
121
|
+
try {
|
|
122
|
+
const { loadMcpServers } = await import('../mcp/config.js');
|
|
123
|
+
const cfg = loadMcpServers(cwd);
|
|
124
|
+
const spec = cfg.servers[info.name];
|
|
125
|
+
if (spec?.disabled)
|
|
126
|
+
disabledNote = ' (disabled in config)';
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore — prompt without the note */ }
|
|
129
|
+
const safeName = sanitizeForPrompt(info.name);
|
|
130
|
+
const safeSource = sanitizeForPrompt(info.source);
|
|
131
|
+
try {
|
|
132
|
+
const choice = await approval.ask({
|
|
133
|
+
toolName: 'mcp_project_server',
|
|
134
|
+
reason: `MCP project server "${safeName}"${disabledNote} from ${safeSource} — approve to register its tools`,
|
|
135
|
+
summary: safeName,
|
|
136
|
+
});
|
|
137
|
+
return choice === 'allow' || choice === 'always' || choice === 'always-persist';
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
for (const e of mcp.errors)
|
|
145
|
+
process.stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
|
|
146
|
+
if (mcp.registered.length > 0) {
|
|
147
|
+
process.stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
try {
|
|
152
|
+
process.stderr.write('klyro: mcp project-server approval skipped (registry unavailable)\n');
|
|
153
|
+
}
|
|
154
|
+
catch { /* ignore */ }
|
|
105
155
|
}
|
|
106
156
|
}
|
|
107
|
-
catch { /* ignore — MCP is optional */ }
|
|
108
157
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
109
158
|
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
110
159
|
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
@@ -136,16 +185,39 @@ export async function startRepl(opts = {}) {
|
|
|
136
185
|
},
|
|
137
186
|
});
|
|
138
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 */ }
|
|
139
209
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
140
210
|
let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
141
|
-
// 4.4 KLYRO.md hierarchy (mutable — /reload refreshes)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
211
|
+
// 4.4 KLYRO.md hierarchy (mutable — /reload refreshes). Content is gated
|
|
212
|
+
// through the context trust store (see loadKlyroBlockTrust below) — the
|
|
213
|
+
// initial value stays empty until the gated load runs after approval setup.
|
|
214
|
+
let klyroBlock = '';
|
|
215
|
+
// 2.3 layered system prompt (split shape: stable base + volatile telemetry suffix)
|
|
145
216
|
const systemPromptFn = (_ctx) => {
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
217
|
+
// 8.4 — inject session memory so memory_write actually takes effect.
|
|
218
|
+
const memBlock = memoryBlock(cwd);
|
|
219
|
+
const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock + memBlock });
|
|
220
|
+
return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
|
|
149
221
|
};
|
|
150
222
|
let ac = new AbortController();
|
|
151
223
|
// When the TUI is mounted, use the inline Ink prompt. Otherwise
|
|
@@ -176,6 +248,45 @@ export async function startRepl(opts = {}) {
|
|
|
176
248
|
});
|
|
177
249
|
},
|
|
178
250
|
});
|
|
251
|
+
// Context-file trust gate (context/trust.js): KLYRO.md / AGENTS.md content
|
|
252
|
+
// only enters the prompt after the trust store approves it. Untrusted files
|
|
253
|
+
// prompt via the repl approval object (allow/always/always-persist → true,
|
|
254
|
+
// deny → false); every decision emits context.trust_prompt on globalBus.
|
|
255
|
+
const trustStore = new ContextTrust();
|
|
256
|
+
async function loadKlyroBlockTrust() {
|
|
257
|
+
try {
|
|
258
|
+
const { loadTrustedKlyroMd } = await import('../context/trust.js');
|
|
259
|
+
const text = await loadTrustedKlyroMd(cwd, {
|
|
260
|
+
trust: trustStore,
|
|
261
|
+
approve: async (file, reason) => {
|
|
262
|
+
try {
|
|
263
|
+
const choice = await approval.ask({
|
|
264
|
+
toolName: 'context_trust',
|
|
265
|
+
reason: `untrusted context file (${reason}): ${file.path}`,
|
|
266
|
+
summary: file.path,
|
|
267
|
+
});
|
|
268
|
+
return choice === 'allow' || choice === 'always' || choice === 'always-persist';
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
sessionId: typeof tuiSessionId === 'string' ? tuiSessionId : 'ephemeral',
|
|
275
|
+
emit: (ev) => globalBus.emit(ev),
|
|
276
|
+
});
|
|
277
|
+
return text ? `\n\n<KLYRO.md>\n${text.slice(0, 4000)}\n</KLYRO.md>` : '';
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// MCP servers need the approval object above — load them now.
|
|
284
|
+
await loadMcpTools();
|
|
285
|
+
// Non-TUI runs can prompt on stdin immediately; the TUI defers the gated
|
|
286
|
+
// load to onMounted (the modal can't render before mount — see below).
|
|
287
|
+
if (!useTui) {
|
|
288
|
+
klyroBlock = await loadKlyroBlockTrust().catch(() => '');
|
|
289
|
+
}
|
|
179
290
|
let inflight = null;
|
|
180
291
|
let lastStatus = null;
|
|
181
292
|
const pendingQueue = [];
|
|
@@ -479,14 +590,41 @@ export async function startRepl(opts = {}) {
|
|
|
479
590
|
return s.length > cap ? s.slice(0, cap) + `\n... [truncated ${s.length - cap} chars]` : s;
|
|
480
591
|
}
|
|
481
592
|
async function execShell(command, timeoutMs = 120_000) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
593
|
+
// Internal shell executions bypass the agent runtime's event path, so
|
|
594
|
+
// audit them here: a redacted tool.call before, the matching tool.result
|
|
595
|
+
// after. One helper covers every slash handler (/commit, /push, …).
|
|
596
|
+
const callId = `internal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
597
|
+
const sessionId = typeof tuiSessionId === 'string' ? tuiSessionId : 'ephemeral';
|
|
598
|
+
const startedAt = Date.now();
|
|
599
|
+
globalBus.emit({
|
|
600
|
+
type: 'tool.call', ts: startedAt, sessionId, callId,
|
|
601
|
+
name: 'shell_exec', input: { internal: true, command: redact(command).slice(0, 2000) },
|
|
602
|
+
});
|
|
603
|
+
try {
|
|
604
|
+
const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
|
|
605
|
+
if (!r.ok)
|
|
606
|
+
throw new Error(r.error.message ?? 'shell failed');
|
|
607
|
+
const v = r.value;
|
|
608
|
+
globalBus.emit({
|
|
609
|
+
type: 'tool.result', ts: Date.now(), sessionId, callId,
|
|
610
|
+
name: 'shell_exec', output: { internal: true, exitCode: v.exitCode },
|
|
611
|
+
isError: false, latencyMs: Date.now() - startedAt,
|
|
612
|
+
});
|
|
613
|
+
return v;
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
globalBus.emit({
|
|
617
|
+
type: 'tool.result', ts: Date.now(), sessionId, callId,
|
|
618
|
+
name: 'shell_exec', output: { internal: true, error: err instanceof Error ? err.message : String(err) },
|
|
619
|
+
isError: true, latencyMs: Date.now() - startedAt,
|
|
620
|
+
});
|
|
621
|
+
throw err;
|
|
622
|
+
}
|
|
486
623
|
}
|
|
487
624
|
/** Read-only LLM answer (no tools) — powers /ask, /explain, /compact. */
|
|
488
625
|
async function answerReadOnly(question, context) {
|
|
489
|
-
|
|
626
|
+
// Legacy chat path (no modes): resolve the split prompt back to one string.
|
|
627
|
+
const sys = resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system + '\n\nAnswer read-only: do not call tools, do not edit files.';
|
|
490
628
|
const attached = await buildAttachedBlock();
|
|
491
629
|
const userText = (context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question) + attached;
|
|
492
630
|
const req = {
|
|
@@ -529,6 +667,19 @@ export async function startRepl(opts = {}) {
|
|
|
529
667
|
if (isMounted && directHooks)
|
|
530
668
|
directHooks.clearTranscript();
|
|
531
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
|
+
})();
|
|
532
683
|
app = render(React.createElement(App, {
|
|
533
684
|
initialModel: model,
|
|
534
685
|
maxSteps: currentMaxSteps,
|
|
@@ -564,6 +715,14 @@ export async function startRepl(opts = {}) {
|
|
|
564
715
|
hooks.append(ev.item);
|
|
565
716
|
}
|
|
566
717
|
pendingQueue.length = 0;
|
|
718
|
+
// Deferred trust-gated KLYRO.md load: the approval modal can only
|
|
719
|
+
// render once the App is mounted. Prompts sent before this resolves
|
|
720
|
+
// run without the KLYRO.md block (noted in /reload output).
|
|
721
|
+
if (useTui && !klyroBlock) {
|
|
722
|
+
void loadKlyroBlockTrust()
|
|
723
|
+
.then((b) => { klyroBlock = b; })
|
|
724
|
+
.catch(() => undefined);
|
|
725
|
+
}
|
|
567
726
|
},
|
|
568
727
|
}), { patchConsole: false });
|
|
569
728
|
// Install SIGINT handler only after app exists (avoids TDZ) and use once
|
|
@@ -618,7 +777,8 @@ export async function startRepl(opts = {}) {
|
|
|
618
777
|
try {
|
|
619
778
|
const simpleReq = {
|
|
620
779
|
model,
|
|
621
|
-
|
|
780
|
+
// Legacy chat path (no modes): plain string system, untouched by the split.
|
|
781
|
+
system: resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system,
|
|
622
782
|
messages: [{ role: 'user', content: [{ kind: 'text', text: taskText }] }],
|
|
623
783
|
tools: [],
|
|
624
784
|
signal: ac.signal,
|
|
@@ -771,8 +931,14 @@ export async function startRepl(opts = {}) {
|
|
|
771
931
|
else if (ev.kind === 'aborted') {
|
|
772
932
|
queuedStatus({ status: 'aborted' });
|
|
773
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
|
+
}
|
|
774
937
|
},
|
|
775
|
-
}, {
|
|
938
|
+
}, {
|
|
939
|
+
adapter, registry, policy, approval, systemPrompt: systemPromptFn,
|
|
940
|
+
...(replFailoverAdapters.length > 0 ? { failoverAdapters: replFailoverAdapters } : {}),
|
|
941
|
+
});
|
|
776
942
|
if (result.finalText)
|
|
777
943
|
lastAssistantText = result.finalText;
|
|
778
944
|
if (result.verification) {
|
|
@@ -2380,8 +2546,9 @@ export async function startRepl(opts = {}) {
|
|
|
2380
2546
|
}
|
|
2381
2547
|
const ctx = await buildLevel6Context({ cwd });
|
|
2382
2548
|
ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
|
|
2383
|
-
|
|
2384
|
-
|
|
2549
|
+
// Trust-gated like the initial load (the modal is mounted here,
|
|
2550
|
+
// so untrusted files prompt instead of being silently dropped).
|
|
2551
|
+
klyroBlock = await loadKlyroBlockTrust().catch(() => '');
|
|
2385
2552
|
queuedAppend({
|
|
2386
2553
|
id: `reload-${Date.now()}`,
|
|
2387
2554
|
kind: 'text',
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* even on a long task. Tool calls and policy decisions go to stderr so
|
|
8
8
|
* they don't pollute the captured `finalText` if the user pipes.
|
|
9
9
|
*/
|
|
10
|
+
import type { VerifyMode } from '../agent/runtime.js';
|
|
11
|
+
import type { SystemPromptFn } from '../agent/runtime.js';
|
|
10
12
|
import type { Message } from '../agent/message.js';
|
|
11
13
|
export interface RunCliOptions {
|
|
12
14
|
task: string;
|
|
@@ -15,10 +17,7 @@ export interface RunCliOptions {
|
|
|
15
17
|
maxSteps?: number;
|
|
16
18
|
maxTokens?: number;
|
|
17
19
|
temperature?: number;
|
|
18
|
-
systemPrompt?:
|
|
19
|
-
cwd: string;
|
|
20
|
-
telemetry?: string;
|
|
21
|
-
}) => string;
|
|
20
|
+
systemPrompt?: SystemPromptFn;
|
|
22
21
|
baseUrl?: string;
|
|
23
22
|
apiKey?: string;
|
|
24
23
|
timeoutMs?: number;
|
|
@@ -60,6 +59,9 @@ export interface RunCliOptions {
|
|
|
60
59
|
maxRepairAttempts?: number;
|
|
61
60
|
verifyTimeoutMs?: number;
|
|
62
61
|
requireVerify?: boolean;
|
|
62
|
+
/** Verification mode: strict (default) runs the full repair pipeline;
|
|
63
|
+
* advisory reports once without repairs; off skips verification. */
|
|
64
|
+
verifyMode?: VerifyMode;
|
|
63
65
|
/** Level 9 — persistence */
|
|
64
66
|
persist?: boolean;
|
|
65
67
|
sessionId?: string;
|
|
@@ -68,13 +70,13 @@ export interface RunCliOptions {
|
|
|
68
70
|
agent?: string;
|
|
69
71
|
maxDepth?: number;
|
|
70
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;
|
|
71
79
|
export declare function runOnce(opts: RunCliOptions): Promise<number>;
|
|
72
80
|
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
|
|
73
|
-
export declare function makeRunSystemPrompt(cwd: string, base:
|
|
74
|
-
cwd: string;
|
|
75
|
-
telemetry?: string;
|
|
76
|
-
}) => string): Promise<(ctx: {
|
|
77
|
-
cwd: string;
|
|
78
|
-
telemetry?: string;
|
|
79
|
-
}) => string>;
|
|
81
|
+
export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn): Promise<SystemPromptFn>;
|
|
80
82
|
export declare function loadTranscript(path: string): Message[];
|