klyro 0.1.62 → 1.0.0
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 +30 -9
- package/dist/agent/anthropic-adapter.js +107 -49
- package/dist/agent/capabilities.d.ts +122 -0
- package/dist/agent/capabilities.js +150 -0
- package/dist/agent/orchestrator.d.ts +131 -0
- package/dist/agent/orchestrator.js +269 -0
- package/dist/agent/provider-adapter.d.ts +9 -0
- package/dist/agent/provider-adapter.js +85 -39
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +12 -1
- package/dist/agent/retry.js +19 -1
- package/dist/agent/runtime.d.ts +23 -1
- package/dist/agent/runtime.js +236 -26
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/cli/config.d.ts +21 -0
- package/dist/cli/config.js +31 -0
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/repl.js +72 -4
- package/dist/cli/run.d.ts +3 -0
- package/dist/cli/run.js +127 -7
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +21 -15
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +71 -0
- package/dist/index.js +4 -0
- package/dist/mcp/client.d.ts +53 -0
- package/dist/mcp/client.js +225 -0
- package/dist/mcp/config.d.ts +30 -0
- package/dist/mcp/config.js +82 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +50 -0
- package/dist/mcp/registry.js +172 -0
- package/dist/mcp/schema.d.ts +11 -0
- package/dist/mcp/schema.js +46 -0
- package/dist/persistence/store.d.ts +1 -1
- package/dist/policy/approval.d.ts +27 -6
- package/dist/policy/approval.js +36 -8
- package/dist/policy/engine.d.ts +13 -0
- package/dist/policy/engine.js +31 -2
- package/dist/policy/patterns.d.ts +16 -0
- package/dist/policy/patterns.js +26 -0
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/types.d.ts +12 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task manager — tracks the lifecycle of a (possibly nested) agent run.
|
|
3
|
+
*
|
|
4
|
+
* Each `spawn_agent` call creates a `TaskRecord` in a `TaskManager`.
|
|
5
|
+
* The manager owns an `AbortController` per task so that the parent (or
|
|
6
|
+
* the user) can cancel a running child without tearing down the entire
|
|
7
|
+
* process. It enforces a recursion / depth cap and emits `subtask.*`
|
|
8
|
+
* events so the event bus and trace writer can see the task tree.
|
|
9
|
+
*
|
|
10
|
+
* Exactly one task manager exists per session, wired into the orchestrator
|
|
11
|
+
* (src/agent/orchestrator.ts), which the runtime reaches via
|
|
12
|
+
* `parentContext`.
|
|
13
|
+
*/
|
|
14
|
+
import { globalBus } from '../events/bus.js';
|
|
15
|
+
export class TaskManager {
|
|
16
|
+
tasks = new Map();
|
|
17
|
+
watchers = new Map();
|
|
18
|
+
counter = 0;
|
|
19
|
+
sessionId;
|
|
20
|
+
bus;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.sessionId = opts.sessionId;
|
|
23
|
+
this.bus = opts.bus ?? globalBus;
|
|
24
|
+
}
|
|
25
|
+
/** Create a task. Blocks instead of running when the recursion cap is exceeded. */
|
|
26
|
+
create(opts) {
|
|
27
|
+
const id = `task_${++this.counter}`;
|
|
28
|
+
const running = opts.autoStart !== false;
|
|
29
|
+
let resolveDone;
|
|
30
|
+
const record = {
|
|
31
|
+
id,
|
|
32
|
+
parentTaskId: opts.parentTaskId,
|
|
33
|
+
sessionId: this.sessionId,
|
|
34
|
+
agentName: opts.agentName,
|
|
35
|
+
status: running ? 'running' : 'queued',
|
|
36
|
+
cwd: opts.cwd,
|
|
37
|
+
depth: opts.depth,
|
|
38
|
+
maxDepth: opts.maxDepth,
|
|
39
|
+
model: opts.model,
|
|
40
|
+
startedAt: Date.now(),
|
|
41
|
+
summary: [],
|
|
42
|
+
abortController: new AbortController(),
|
|
43
|
+
done: new Promise((res) => {
|
|
44
|
+
resolveDone = res;
|
|
45
|
+
}),
|
|
46
|
+
_resolveDone: (r) => resolveDone(r),
|
|
47
|
+
};
|
|
48
|
+
this.tasks.set(id, record);
|
|
49
|
+
// Recursion / depth guard — enforced at the boundary so a deep-deep
|
|
50
|
+
// call chain can never be constructed in the first place.
|
|
51
|
+
if (opts.depth > opts.maxDepth) {
|
|
52
|
+
record.status = 'blocked';
|
|
53
|
+
record.timeoutHandle = undefined;
|
|
54
|
+
record.error = {
|
|
55
|
+
code: 'BLOCKED',
|
|
56
|
+
message: `recursion limit: depth ${opts.depth} exceeds maxDepth ${opts.maxDepth}`,
|
|
57
|
+
};
|
|
58
|
+
this.resolve(record);
|
|
59
|
+
this.emit(record);
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
// Parent abort → child abort chain.
|
|
63
|
+
if (opts.abortOnParent) {
|
|
64
|
+
const onAbort = () => this.cancel(id, 'parent aborted');
|
|
65
|
+
if (opts.abortOnParent.aborted) {
|
|
66
|
+
onAbort();
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
opts.abortOnParent.addEventListener('abort', onAbort, { once: true });
|
|
70
|
+
record._cleanupParentAbort = () => opts.abortOnParent.removeEventListener('abort', onAbort);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Timeout: transition to 'timed_out' and abort if still live.
|
|
74
|
+
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
|
|
75
|
+
record.timeoutHandle = setTimeout(() => {
|
|
76
|
+
const r = this.tasks.get(id);
|
|
77
|
+
if (r && (r.status === 'running' || r.status === 'queued')) {
|
|
78
|
+
this.cancel(id, 'timeout', 'timed_out');
|
|
79
|
+
}
|
|
80
|
+
}, opts.timeoutMs);
|
|
81
|
+
}
|
|
82
|
+
this.emit(record);
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
/** Look up a live record. */
|
|
86
|
+
get(id) {
|
|
87
|
+
return this.tasks.get(id);
|
|
88
|
+
}
|
|
89
|
+
/** List tasks, optionally filtered by parent and/or status. Returns summaries. */
|
|
90
|
+
list(filter) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const r of this.tasks.values()) {
|
|
93
|
+
if (filter?.parentTaskId !== undefined && r.parentTaskId !== filter.parentTaskId)
|
|
94
|
+
continue;
|
|
95
|
+
if (filter?.status !== undefined && r.status !== filter.status)
|
|
96
|
+
continue;
|
|
97
|
+
out.push(this.toSummary(r));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Transition a task to a terminal status and resolve its `done`. */
|
|
102
|
+
finish(id, status, patch) {
|
|
103
|
+
const r = this.get(id);
|
|
104
|
+
if (!r)
|
|
105
|
+
throw new Error(`unknown task: ${id}`);
|
|
106
|
+
if (status !== 'blocked' && r.timeoutHandle) {
|
|
107
|
+
clearTimeout(r.timeoutHandle);
|
|
108
|
+
r.timeoutHandle = undefined;
|
|
109
|
+
}
|
|
110
|
+
if (patch?.summary)
|
|
111
|
+
r.summary.push(...patch.summary);
|
|
112
|
+
r.status = status;
|
|
113
|
+
r.finishedAt = Date.now();
|
|
114
|
+
if (patch?.error)
|
|
115
|
+
r.error = patch.error;
|
|
116
|
+
this.resolve(r);
|
|
117
|
+
this.emit(r);
|
|
118
|
+
return r;
|
|
119
|
+
}
|
|
120
|
+
/** Cancel a live task: abort, mark cancelled/timed_out, resolve done. */
|
|
121
|
+
cancel(id, reason = 'cancelled', finalStatus = 'cancelled') {
|
|
122
|
+
const r = this.get(id);
|
|
123
|
+
if (!r)
|
|
124
|
+
throw new Error(`unknown task: ${id}`);
|
|
125
|
+
if (r.status !== 'queued' && r.status !== 'running')
|
|
126
|
+
return r; // idempotent on terminal
|
|
127
|
+
r.abortController.abort(reason);
|
|
128
|
+
r._cleanupParentAbort?.();
|
|
129
|
+
r._cleanupParentAbort = undefined;
|
|
130
|
+
if (r.timeoutHandle) {
|
|
131
|
+
clearTimeout(r.timeoutHandle);
|
|
132
|
+
r.timeoutHandle = undefined;
|
|
133
|
+
}
|
|
134
|
+
r.status = finalStatus;
|
|
135
|
+
r.finishedAt = Date.now();
|
|
136
|
+
r.error = {
|
|
137
|
+
code: finalStatus === 'timed_out' ? 'TIMED_OUT' : 'CANCELLED',
|
|
138
|
+
message: reason,
|
|
139
|
+
};
|
|
140
|
+
this.resolve(r);
|
|
141
|
+
this.emit(r);
|
|
142
|
+
return r;
|
|
143
|
+
}
|
|
144
|
+
/** Confirm a task that policy/depth prevented from running. */
|
|
145
|
+
block(id, reason) {
|
|
146
|
+
return this.finish(id, 'blocked', {
|
|
147
|
+
error: { code: 'BLOCKED', message: reason },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** Cancel `id` and every transitive descendant. */
|
|
151
|
+
async cancelTree(id, reason = 'cancelled') {
|
|
152
|
+
const ids = [id];
|
|
153
|
+
const walk = (pid) => {
|
|
154
|
+
for (const r of this.tasks.values()) {
|
|
155
|
+
if (r.parentTaskId === pid) {
|
|
156
|
+
ids.push(r.id);
|
|
157
|
+
walk(r.id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
walk(id);
|
|
162
|
+
// Children first so a parent's abort doesn't race a child still being torn down.
|
|
163
|
+
ids.reverse();
|
|
164
|
+
for (const tid of ids) {
|
|
165
|
+
this.cancel(tid, reason);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Subscribe to status transitions for one task. Delivers the current status immediately. */
|
|
169
|
+
subscribe(id, listener) {
|
|
170
|
+
let set = this.watchers.get(id);
|
|
171
|
+
if (!set) {
|
|
172
|
+
set = new Set();
|
|
173
|
+
this.watchers.set(id, set);
|
|
174
|
+
}
|
|
175
|
+
set.add(listener);
|
|
176
|
+
const r = this.get(id);
|
|
177
|
+
if (r) {
|
|
178
|
+
try {
|
|
179
|
+
listener(r);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* ignore */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return () => {
|
|
186
|
+
set.delete(listener);
|
|
187
|
+
if (set.size === 0)
|
|
188
|
+
this.watchers.delete(id);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Cancel every live task (used on shutdown / ctrl-c). */
|
|
192
|
+
async shutdown() {
|
|
193
|
+
for (const id of [...this.tasks.keys()]) {
|
|
194
|
+
const r = this.get(id);
|
|
195
|
+
if (r && (r.status === 'queued' || r.status === 'running')) {
|
|
196
|
+
this.cancel(id, 'shutdown');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Compact serialisable summary. */
|
|
201
|
+
toSummary(r) {
|
|
202
|
+
return {
|
|
203
|
+
id: r.id,
|
|
204
|
+
...(r.parentTaskId ? { parentTaskId: r.parentTaskId } : {}),
|
|
205
|
+
agentName: r.agentName,
|
|
206
|
+
sessionId: r.sessionId,
|
|
207
|
+
status: r.status,
|
|
208
|
+
cwd: r.cwd,
|
|
209
|
+
depth: r.depth,
|
|
210
|
+
...(typeof r.model === 'string' ? { model: r.model } : {}),
|
|
211
|
+
...(r.finishedAt ? { durationMs: r.finishedAt - r.startedAt } : {}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/* ---------------------- internals ---------------------- */
|
|
215
|
+
/** Resolve a record's `done` (idempotent). */
|
|
216
|
+
resolve(r) {
|
|
217
|
+
this._resolveOnce(r);
|
|
218
|
+
}
|
|
219
|
+
_resolveOnce(r) {
|
|
220
|
+
try {
|
|
221
|
+
r._resolveDone(r);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
/* already resolved */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Deliver a transition to the task's own watchers, then each ancestor's. */
|
|
228
|
+
emit(r) {
|
|
229
|
+
this.notify(r.id, r);
|
|
230
|
+
let pid = r.parentTaskId;
|
|
231
|
+
while (pid) {
|
|
232
|
+
const p = this.get(pid);
|
|
233
|
+
this.notify(pid, r);
|
|
234
|
+
pid = p?.parentTaskId;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
notify(id, r) {
|
|
238
|
+
const set = this.watchers.get(id);
|
|
239
|
+
if (!set)
|
|
240
|
+
return;
|
|
241
|
+
for (const fn of [...set]) {
|
|
242
|
+
try {
|
|
243
|
+
fn(r);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* ignore */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-process worker spawner
|
|
2
|
+
* In-process worker spawner.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
4
|
+
* A "worker" here is just an async task tied to an AbortController. The
|
|
5
|
+
* spawner keeps the controllers so workers can actually be cancelled
|
|
6
|
+
* (user Ctrl+C, /abort, session shutdown, or a parent signalling a child).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Real subprocess / child-process spawning belongs to a later level;
|
|
9
|
+
* this module is the single-process cancellation primitive the agent
|
|
10
|
+
* orchestration layer builds on.
|
|
11
11
|
*/
|
|
12
12
|
export interface WorkerHandle {
|
|
13
13
|
/** Stable id, monotonically increasing per spawner instance. */
|
|
14
14
|
readonly id: number;
|
|
15
15
|
/** Human-readable label, used in logs. */
|
|
16
16
|
readonly label: string;
|
|
17
|
-
/**
|
|
17
|
+
/** AbortSignal delivered to the factory so it can observe cancellation. */
|
|
18
18
|
readonly signal: AbortSignal;
|
|
19
|
+
/** The controller backing `signal` — retained so we can actually abort. */
|
|
20
|
+
readonly controller: AbortController;
|
|
19
21
|
/** Resolves when the worker's main promise settles. */
|
|
20
22
|
readonly done: Promise<void>;
|
|
21
23
|
}
|
|
@@ -28,12 +30,15 @@ export declare class WorkerSpawner {
|
|
|
28
30
|
private nextId;
|
|
29
31
|
private readonly handles;
|
|
30
32
|
/**
|
|
31
|
-
* Spawn a worker. The factory
|
|
32
|
-
*
|
|
33
|
+
* Spawn a worker. The factory receives an AbortSignal; when the worker is
|
|
34
|
+
* cancelled (via `cancel`, `cancelAll`, or the parent signal wiring) that
|
|
35
|
+
* signal fires and `done` settles.
|
|
33
36
|
*/
|
|
34
37
|
spawn(factory: (signal: AbortSignal) => Promise<void>, opts?: SpawnOptions): WorkerHandle;
|
|
35
|
-
/**
|
|
36
|
-
|
|
38
|
+
/** Cancel a single worker: fires its AbortSignal. */
|
|
39
|
+
cancel(handle: WorkerHandle, reason?: unknown): void;
|
|
40
|
+
/** Cancel every active worker by firing their AbortSignals. */
|
|
41
|
+
cancelAll(reason?: unknown): void;
|
|
37
42
|
/** Count of currently-active workers. */
|
|
38
43
|
activeCount(): number;
|
|
39
44
|
/** Snapshot of all live worker handles. */
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* In-process worker spawner
|
|
2
|
+
* In-process worker spawner.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
4
|
+
* A "worker" here is just an async task tied to an AbortController. The
|
|
5
|
+
* spawner keeps the controllers so workers can actually be cancelled
|
|
6
|
+
* (user Ctrl+C, /abort, session shutdown, or a parent signalling a child).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Real subprocess / child-process spawning belongs to a later level;
|
|
9
|
+
* this module is the single-process cancellation primitive the agent
|
|
10
|
+
* orchestration layer builds on.
|
|
11
11
|
*/
|
|
12
12
|
export class WorkerSpawner {
|
|
13
13
|
nextId = 0;
|
|
14
14
|
handles = new Set();
|
|
15
15
|
/**
|
|
16
|
-
* Spawn a worker. The factory
|
|
17
|
-
*
|
|
16
|
+
* Spawn a worker. The factory receives an AbortSignal; when the worker is
|
|
17
|
+
* cancelled (via `cancel`, `cancelAll`, or the parent signal wiring) that
|
|
18
|
+
* signal fires and `done` settles.
|
|
18
19
|
*/
|
|
19
20
|
spawn(factory, opts = {}) {
|
|
20
21
|
const id = this.nextId++;
|
|
@@ -31,16 +32,16 @@ export class WorkerSpawner {
|
|
|
31
32
|
id,
|
|
32
33
|
label,
|
|
33
34
|
signal: ac.signal,
|
|
35
|
+
controller: ac,
|
|
34
36
|
done,
|
|
35
37
|
};
|
|
36
38
|
this.handles.add(handle);
|
|
37
39
|
if (!autoStart) {
|
|
38
|
-
// Caller will invoke factory manually and pass the signal
|
|
39
|
-
// we just resolve immediately so .done doesn't hang.
|
|
40
|
+
// Caller will invoke the factory manually and pass the signal.
|
|
40
41
|
resolveDone();
|
|
41
42
|
return handle;
|
|
42
43
|
}
|
|
43
|
-
// Fire-and-forget;
|
|
44
|
+
// Fire-and-forget; the caller awaits handle.done.
|
|
44
45
|
factory(ac.signal).then(() => {
|
|
45
46
|
this.handles.delete(handle);
|
|
46
47
|
resolveDone();
|
|
@@ -50,20 +51,25 @@ export class WorkerSpawner {
|
|
|
50
51
|
});
|
|
51
52
|
return handle;
|
|
52
53
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
54
|
+
/** Cancel a single worker: fires its AbortSignal. */
|
|
55
|
+
cancel(handle, reason) {
|
|
56
|
+
try {
|
|
57
|
+
handle.controller.abort(reason);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* already aborted */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Cancel every active worker by firing their AbortSignals. */
|
|
64
|
+
cancelAll(reason) {
|
|
55
65
|
for (const h of [...this.handles]) {
|
|
56
66
|
try {
|
|
57
|
-
h.
|
|
67
|
+
h.controller.abort(reason);
|
|
58
68
|
}
|
|
59
69
|
catch {
|
|
60
|
-
|
|
70
|
+
/* already aborted */
|
|
61
71
|
}
|
|
62
72
|
}
|
|
63
|
-
// Real abort uses the controller stored on the handle's signal — we
|
|
64
|
-
// don't keep the controller here. In the MVP, callers can pass their
|
|
65
|
-
// own AbortController via factory; this method is a no-op stub for
|
|
66
|
-
// the contract. See `cancel(handle)` for the per-worker variant.
|
|
67
73
|
}
|
|
68
74
|
/** Count of currently-active workers. */
|
|
69
75
|
activeCount() {
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -34,6 +34,27 @@ declare function parseValue(raw: string): unknown;
|
|
|
34
34
|
export declare function loadConfig(): Promise<Record<string, unknown>>;
|
|
35
35
|
export declare function loadConfigSync(): Record<string, unknown>;
|
|
36
36
|
export declare function loadMergedConfig(cwd?: string, flags?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
37
|
+
export interface PermissionRules {
|
|
38
|
+
allow: string[];
|
|
39
|
+
deny: string[];
|
|
40
|
+
ask: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Permission glob rules (`tool(glob)` grammar) from the merged config
|
|
44
|
+
* layers — home settings, project settings, project local. Fed into the
|
|
45
|
+
* policy engine at startup so persisted "always allow" patterns apply
|
|
46
|
+
* without re-prompting.
|
|
47
|
+
*/
|
|
48
|
+
export declare function loadPermissionRules(cwd?: string): Promise<PermissionRules>;
|
|
49
|
+
/**
|
|
50
|
+
* Persist an "always allow" pattern to the home settings file
|
|
51
|
+
* (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
|
|
52
|
+
* added (false when already present) and the file written.
|
|
53
|
+
*/
|
|
54
|
+
export declare function persistAllowRule(rule: string): Promise<{
|
|
55
|
+
added: boolean;
|
|
56
|
+
path: string;
|
|
57
|
+
}>;
|
|
37
58
|
export declare function saveConfig(obj: Record<string, unknown>): Promise<void>;
|
|
38
59
|
export declare function runConfig(args: string[]): Promise<number>;
|
|
39
60
|
export declare const _helpers: {
|
package/dist/cli/config.js
CHANGED
|
@@ -320,6 +320,37 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
|
320
320
|
}
|
|
321
321
|
return merged;
|
|
322
322
|
}
|
|
323
|
+
function asStringArray(v) {
|
|
324
|
+
return Array.isArray(v) ? v.filter((e) => typeof e === 'string') : [];
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Permission glob rules (`tool(glob)` grammar) from the merged config
|
|
328
|
+
* layers — home settings, project settings, project local. Fed into the
|
|
329
|
+
* policy engine at startup so persisted "always allow" patterns apply
|
|
330
|
+
* without re-prompting.
|
|
331
|
+
*/
|
|
332
|
+
export async function loadPermissionRules(cwd = process.cwd()) {
|
|
333
|
+
const merged = await loadMergedConfig(cwd, {});
|
|
334
|
+
return {
|
|
335
|
+
allow: asStringArray(merged.allow),
|
|
336
|
+
deny: asStringArray(merged.deny),
|
|
337
|
+
ask: asStringArray(merged.ask),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Persist an "always allow" pattern to the home settings file
|
|
342
|
+
* (~/.klyro/settings.json, honors KLYRO_CONFIG). Returns whether it was
|
|
343
|
+
* added (false when already present) and the file written.
|
|
344
|
+
*/
|
|
345
|
+
export async function persistAllowRule(rule) {
|
|
346
|
+
const cfg = await loadConfig();
|
|
347
|
+
const allow = asStringArray(cfg.allow);
|
|
348
|
+
if (allow.includes(rule))
|
|
349
|
+
return { added: false, path: getConfigPath() };
|
|
350
|
+
cfg.allow = [...allow, rule];
|
|
351
|
+
await saveConfig(cfg);
|
|
352
|
+
return { added: true, path: getConfigPath() };
|
|
353
|
+
}
|
|
323
354
|
export async function saveConfig(obj) {
|
|
324
355
|
const p = getConfigPath();
|
|
325
356
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0.5 — `.env` auto-load (r-11-17.md).
|
|
3
|
+
*
|
|
4
|
+
* Loads `<cwd>/.env` at startup so `KLYRO_API_KEY` etc. work without `export`.
|
|
5
|
+
* Rules:
|
|
6
|
+
* - Never overwrites an already-set variable (explicit env wins).
|
|
7
|
+
* - Only `KEY=VALUE` lines; `#` comments and `export ` prefixes tolerated.
|
|
8
|
+
* - Single/double quotes stripped; missing file is a no-op (never throws).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
export function parseDotenv(text) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const rawLine of text.split('\n')) {
|
|
15
|
+
const line = rawLine.trim();
|
|
16
|
+
if (!line || line.startsWith('#'))
|
|
17
|
+
continue;
|
|
18
|
+
const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line;
|
|
19
|
+
const eq = body.indexOf('=');
|
|
20
|
+
if (eq <= 0)
|
|
21
|
+
continue;
|
|
22
|
+
const key = body.slice(0, eq).trim();
|
|
23
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
24
|
+
continue;
|
|
25
|
+
let val = body.slice(eq + 1).trim();
|
|
26
|
+
if (val.length >= 2 && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) {
|
|
27
|
+
val = val.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
// Strip trailing inline comments on unquoted values.
|
|
30
|
+
if (!body.slice(eq + 1).trim().startsWith('"') && !body.slice(eq + 1).trim().startsWith("'")) {
|
|
31
|
+
const hash = val.indexOf(' #');
|
|
32
|
+
if (hash >= 0)
|
|
33
|
+
val = val.slice(0, hash).trimEnd();
|
|
34
|
+
}
|
|
35
|
+
out[key] = val;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Load `<cwd>/.env` into `process.env` (no-clobber). Returns loaded keys. */
|
|
40
|
+
export function loadDotenv(cwd) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = fs.readFileSync(path.join(cwd, '.env'), 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseDotenv(text);
|
|
49
|
+
const loaded = [];
|
|
50
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
51
|
+
if (process.env[k] === undefined) {
|
|
52
|
+
process.env[k] = v;
|
|
53
|
+
loaded.push(k);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return loaded;
|
|
57
|
+
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -12,11 +12,13 @@ import { render } from 'ink';
|
|
|
12
12
|
import { App } from '../tui/app.js';
|
|
13
13
|
import { httpChatAdapter } from '../agent/provider-adapter.js';
|
|
14
14
|
import { anthropicAdapter } from '../agent/anthropic-adapter.js';
|
|
15
|
+
import { retryingAdapter } from '../agent/retry.js';
|
|
16
|
+
import { globalBus } from '../events/bus.js';
|
|
15
17
|
import { run } from '../agent/runtime.js';
|
|
16
18
|
import { builtinRegistry } from '../tools/registry.js';
|
|
17
19
|
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
18
20
|
import { buildLevel6Context } from '../context/level6.js';
|
|
19
|
-
import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
|
|
21
|
+
import { DenyAllApprovalPrompt, PatternApprovalCache, StdinApprovalPrompt } from '../policy/approval.js';
|
|
20
22
|
import { TuiApprovalBridge } from '../tui/approval.js';
|
|
21
23
|
import { parseUnifiedDiff } from '../tui/diff-parser.js';
|
|
22
24
|
import { parse } from './slash/parser.js';
|
|
@@ -28,6 +30,13 @@ import { getDefaultSessionStore } from '../persistence/session.js';
|
|
|
28
30
|
import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
|
|
29
31
|
import { estimateCost } from '../providers/model-info.js';
|
|
30
32
|
export async function startRepl(opts = {}) {
|
|
33
|
+
// P0.5 — load <cwd>/.env first so provider resolution below sees KLYRO_*
|
|
34
|
+
// vars without `export`. Never throws; explicit env wins (no-clobber).
|
|
35
|
+
try {
|
|
36
|
+
const { loadDotenv } = await import('./dotenv.js');
|
|
37
|
+
loadDotenv(opts.cwd ?? process.cwd());
|
|
38
|
+
}
|
|
39
|
+
catch { /* ignore */ }
|
|
31
40
|
// Reuse the same provider resolution as legacy repl.ts — probes local
|
|
32
41
|
// Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
|
|
33
42
|
// works with a local model just like `klyro chat` does.
|
|
@@ -72,6 +81,30 @@ export async function startRepl(opts = {}) {
|
|
|
72
81
|
// Clone: /mode and /sandbox mutate this config — it must never leak into
|
|
73
82
|
// the shared DEFAULT_POLICY_CONFIG across sessions.
|
|
74
83
|
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
84
|
+
// Persisted permission rules (home + project settings layers) — "always"
|
|
85
|
+
// choices from previous sessions apply without re-prompting.
|
|
86
|
+
try {
|
|
87
|
+
const { loadPermissionRules } = await import('./config.js');
|
|
88
|
+
policy.applyRules(await loadPermissionRules(cwd));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* ignore — engine defaults stand */
|
|
92
|
+
}
|
|
93
|
+
// Best-effort MCP tools: never fatal, never prompts. src/mcp/registry.ts
|
|
94
|
+
// lands from a sibling agent — the lazy import keeps runtime + builds green
|
|
95
|
+
// until then (import failure is caught below). Servers live for the process
|
|
96
|
+
// lifetime, so no close wiring here (unlike runOnce's finally).
|
|
97
|
+
try {
|
|
98
|
+
// @ts-ignore — sibling-owned module may not exist yet
|
|
99
|
+
const { loadAndRegisterMcp } = await import('../mcp/registry.js');
|
|
100
|
+
const mcp = await loadAndRegisterMcp({ cwd, registry, policy });
|
|
101
|
+
for (const e of mcp.errors)
|
|
102
|
+
process.stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
|
|
103
|
+
if (mcp.registered.length > 0) {
|
|
104
|
+
process.stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore — MCP is optional */ }
|
|
75
108
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
76
109
|
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
77
110
|
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
@@ -84,9 +117,24 @@ export async function startRepl(opts = {}) {
|
|
|
84
117
|
let currentApiKey = apiKey;
|
|
85
118
|
let currentMaxSteps = opts.maxSteps ?? 30;
|
|
86
119
|
let effortLevel = 'medium';
|
|
87
|
-
|
|
120
|
+
// Retry wrapper lives inside the factory so the initial construction AND
|
|
121
|
+
// the /provider switch + /reload rebuilds (which re-call buildAdapter)
|
|
122
|
+
// all stay wrapped. REPL has no session id at adapter scope, so retry
|
|
123
|
+
// telemetry uses 'ephemeral'.
|
|
124
|
+
const buildAdapter = (prov, url, key) => retryingAdapter(prov === 'anthropic'
|
|
88
125
|
? anthropicAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 })
|
|
89
|
-
: httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 })
|
|
126
|
+
: httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 }), {
|
|
127
|
+
onRetry: (info) => {
|
|
128
|
+
globalBus.emit({
|
|
129
|
+
type: 'provider.retry',
|
|
130
|
+
ts: Date.now(),
|
|
131
|
+
sessionId: 'ephemeral',
|
|
132
|
+
attempt: info.attempt,
|
|
133
|
+
status: info.status,
|
|
134
|
+
...(info.retryAfterMs !== undefined ? { retryAfterMs: info.retryAfterMs } : {}),
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
});
|
|
90
138
|
let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
|
|
91
139
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
92
140
|
let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
@@ -105,9 +153,29 @@ export async function startRepl(opts = {}) {
|
|
|
105
153
|
// App and the runtime so the modal can resolve the runtime's ask().
|
|
106
154
|
const tuiBridge = new TuiApprovalBridge();
|
|
107
155
|
const useTui = opts.forceTty || process.stdin.isTTY;
|
|
108
|
-
|
|
156
|
+
// Ask-once-per-pattern: session cache + optional persist. `a` records for
|
|
157
|
+
// the session, `A` additionally appends the pattern to settings and the
|
|
158
|
+
// live engine (matches the modal's [a] session / [A] always→settings).
|
|
159
|
+
const approvalBase = opts.nonInteractive
|
|
109
160
|
? new DenyAllApprovalPrompt()
|
|
110
161
|
: (useTui ? tuiBridge : new StdinApprovalPrompt());
|
|
162
|
+
const approval = opts.nonInteractive
|
|
163
|
+
? approvalBase
|
|
164
|
+
: new PatternApprovalCache(approvalBase, {
|
|
165
|
+
onPersist: async (pattern) => {
|
|
166
|
+
const { persistAllowRule } = await import('./config.js');
|
|
167
|
+
const res = await persistAllowRule(pattern);
|
|
168
|
+
policy.addAllow(pattern);
|
|
169
|
+
queuedAppend({
|
|
170
|
+
id: `allow-${Date.now()}`,
|
|
171
|
+
kind: 'text',
|
|
172
|
+
text: res.added
|
|
173
|
+
? `allowed always: ${pattern} (saved to ${res.path} — revoke by deleting the line)`
|
|
174
|
+
: `allowed always: ${pattern} (already in ${res.path})`,
|
|
175
|
+
role: 'assistant',
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
});
|
|
111
179
|
let inflight = null;
|
|
112
180
|
let lastStatus = null;
|
|
113
181
|
const pendingQueue = [];
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -64,6 +64,9 @@ export interface RunCliOptions {
|
|
|
64
64
|
persist?: boolean;
|
|
65
65
|
sessionId?: string;
|
|
66
66
|
sessionsDir?: string;
|
|
67
|
+
/** P1.4 — run the task under a named child-capable orchestrator context. */
|
|
68
|
+
agent?: string;
|
|
69
|
+
maxDepth?: number;
|
|
67
70
|
}
|
|
68
71
|
export declare function runOnce(opts: RunCliOptions): Promise<number>;
|
|
69
72
|
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
|