klyro 1.0.18 → 1.0.21
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.js +9 -1
- package/dist/agent/child-worker.js +21 -7
- package/dist/agent/orchestrator.js +27 -4
- package/dist/agent/provider-adapter.js +5 -0
- package/dist/agent/retry.js +6 -3
- package/dist/agent/runtime.js +15 -6
- package/dist/agent/worktree-manager.d.ts +2 -0
- package/dist/agent/worktree-manager.js +9 -2
- package/dist/checkpoints/store.d.ts +13 -0
- package/dist/checkpoints/store.js +65 -2
- package/dist/cli/dotenv.js +1 -1
- package/dist/cli/repl.js +22 -5
- package/dist/eval/harness.js +8 -3
- package/dist/mcp/remote.js +31 -7
- package/dist/mcp/sse.js +0 -1
- package/dist/persistence/audit.d.ts +0 -6
- package/dist/persistence/audit.js +11 -18
- package/dist/persistence/store.js +19 -13
- package/dist/policy/engine.js +43 -7
- package/dist/providers/model-info.d.ts +4 -1
- package/dist/providers/model-info.js +10 -2
- package/dist/repl.js +4 -2
- package/dist/tools/git/run-git.js +23 -4
- package/dist/tools/lsp/diagnostics.d.ts +50 -0
- package/dist/tools/lsp/diagnostics.js +109 -3
- package/dist/tools/registry.js +3 -1
- package/dist/tools/shell/background.js +52 -23
- package/dist/tools/verify/run-verify.js +11 -1
- package/dist/tui/app.d.ts +5 -0
- package/dist/tui/app.js +156 -51
- package/dist/tui/app.test.js +21 -0
- package/dist/tui/diff-parser.js +28 -5
- package/dist/tui/markdown.js +1 -1
- package/dist/tui/measure.js +4 -1
- package/dist/tui/scroll-model.d.ts +2 -0
- package/dist/tui/scroll-model.js +5 -0
- package/dist/tui/status.d.ts +3 -0
- package/dist/verification/engine.js +35 -2
- package/dist/verification/scoped.js +20 -5
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { assertSafeBaseURL } from '../chat.js';
|
|
19
19
|
import { parseRetryAfterMs } from './provider-adapter.js';
|
|
20
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
20
21
|
import { estimateTokens } from '../context/tokenizer.js';
|
|
21
22
|
import { proxiedFetch } from '../shared/proxy.js';
|
|
22
23
|
const DEFAULT_VERSION = '2023-06-01';
|
|
@@ -174,7 +175,9 @@ async function* streamAnthropic(req, opts) {
|
|
|
174
175
|
yield {
|
|
175
176
|
kind: 'error',
|
|
176
177
|
code: isOverflow ? 'REQUEST_TOO_LARGE' : `http_${resp.status}`,
|
|
177
|
-
|
|
178
|
+
// Redact before slicing (mirrors the OpenAI adapter): a 400 body can
|
|
179
|
+
// echo the prompt, leaking secrets into transcript/logs/trace.
|
|
180
|
+
message: `Anthropic API returned ${resp.status}: ${redact(text).slice(0, 500)}`,
|
|
178
181
|
retryable,
|
|
179
182
|
status: String(resp.status),
|
|
180
183
|
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
@@ -204,6 +207,11 @@ async function* streamAnthropic(req, opts) {
|
|
|
204
207
|
if (done)
|
|
205
208
|
break;
|
|
206
209
|
buf += decoder.decode(value, { stream: true });
|
|
210
|
+
// SSE spec: CRLF/CR line endings normalize to LF (same as the
|
|
211
|
+
// OpenAI adapter) — otherwise '\n\n' never matches and the stream
|
|
212
|
+
// stalls to timeout.
|
|
213
|
+
if (buf.includes('\r'))
|
|
214
|
+
buf = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
207
215
|
// Split on SSE event boundary.
|
|
208
216
|
const events = [];
|
|
209
217
|
let idx = 0;
|
|
@@ -130,20 +130,34 @@ export async function forkChild(entry, payload, opts = {}) {
|
|
|
130
130
|
});
|
|
131
131
|
child.on('close', (code, signal) => {
|
|
132
132
|
cleanup();
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// The child may print progress/log lines before the JSON result —
|
|
134
|
+
// scan from the LAST non-empty line back so an early log line can't
|
|
135
|
+
// mask the result (previously the first line was parsed instead).
|
|
136
|
+
const lines = stdoutCap.text().split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
|
137
|
+
let parsed = null;
|
|
138
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
135
139
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
+
const cand = JSON.parse(lines[i]);
|
|
141
|
+
if (cand && typeof cand === 'object' && typeof cand.status === 'string') {
|
|
142
|
+
parsed = cand;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
140
145
|
}
|
|
146
|
+
catch { /* not JSON — keep scanning */ }
|
|
147
|
+
}
|
|
148
|
+
if (code === 0 && parsed) {
|
|
149
|
+
resolve(parsed);
|
|
141
150
|
return;
|
|
142
151
|
}
|
|
143
152
|
const cause = signal === 'SIGKILL' || signal === 'SIGTERM'
|
|
144
153
|
? (killedForTimeout ? 'timeout' : 'oom')
|
|
145
154
|
: 'exit';
|
|
146
|
-
|
|
155
|
+
// Exit 0 with no parseable result keeps the historical "malformed"
|
|
156
|
+
// message (pinned by forkChild tests); other exits name the cause.
|
|
157
|
+
const what = code === 0
|
|
158
|
+
? 'child exited 0 but emitted malformed ChildResult'
|
|
159
|
+
: `child exited ${code ?? signal ?? '??'} without a ChildResult`;
|
|
160
|
+
reject(new ChildCrashError(what + (stderr ? `: ${stderr.slice(-500)}` : ''), cause, stderr.slice(-1000)));
|
|
147
161
|
});
|
|
148
162
|
});
|
|
149
163
|
}
|
|
@@ -666,13 +666,36 @@ export class AgentOrchestrator {
|
|
|
666
666
|
return {
|
|
667
667
|
ok: false,
|
|
668
668
|
error: {
|
|
669
|
-
code: 'MERGE_CONFLICT',
|
|
670
|
-
message:
|
|
671
|
-
|
|
669
|
+
code: merged.error ? 'MERGE_FAILED' : 'MERGE_CONFLICT',
|
|
670
|
+
message: merged.error
|
|
671
|
+
? `merge of ${meta.worktree.branch} failed: ${merged.error}`
|
|
672
|
+
: `merge of ${meta.worktree.branch} conflicted`,
|
|
673
|
+
details: { conflictFiles: merged.conflictFiles, ...(merged.error ? { error: merged.error } : {}) },
|
|
672
674
|
},
|
|
673
675
|
};
|
|
674
676
|
}
|
|
675
|
-
|
|
677
|
+
// Post-merge the branch content is committed, so a dirty worktree must
|
|
678
|
+
// not strand it: try a clean remove first, fall back to force, and
|
|
679
|
+
// only delete the branch once the worktree is really gone. Never
|
|
680
|
+
// report success while orphaning `.klyro/worktrees/<taskId>`.
|
|
681
|
+
try {
|
|
682
|
+
await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath });
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
try {
|
|
686
|
+
await removeWorktree({ repoCwd: meta.repoCwd, worktreePath: meta.worktree.worktreePath, force: true });
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
error: {
|
|
692
|
+
code: 'WORKTREE_CLEANUP',
|
|
693
|
+
message: `merged ${meta.worktree.branch} but could not remove worktree ${meta.worktree.worktreePath}`,
|
|
694
|
+
details: { worktreePath: meta.worktree.worktreePath, error: err instanceof Error ? err.message : String(err) },
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
}
|
|
676
699
|
await deleteBranch({ repoCwd: meta.repoCwd, branch: meta.worktree.branch });
|
|
677
700
|
didMerge = true;
|
|
678
701
|
}
|
|
@@ -293,6 +293,11 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
293
293
|
if (done)
|
|
294
294
|
break;
|
|
295
295
|
buf += decoder.decode(value, { stream: true });
|
|
296
|
+
// SSE spec: CRLF/CR line endings normalize to LF. Without this a
|
|
297
|
+
// CRLF stream never contains '\n\n', so no event boundary splits
|
|
298
|
+
// and the stream stalls to timeout.
|
|
299
|
+
if (buf.includes('\r'))
|
|
300
|
+
buf = buf.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
296
301
|
// SSE: events separated by \n\n
|
|
297
302
|
let idx;
|
|
298
303
|
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
package/dist/agent/retry.js
CHANGED
|
@@ -210,9 +210,12 @@ export function retryingAdapter(inner, opts = {}) {
|
|
|
210
210
|
? retryAfterRaw
|
|
211
211
|
: undefined;
|
|
212
212
|
opts.onRetry?.({ attempt: attempt + 1, status, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
213
|
-
// Honor a server-provided Retry-After delay when present
|
|
214
|
-
//
|
|
215
|
-
|
|
213
|
+
// Honor a server-provided Retry-After delay when present, capped at
|
|
214
|
+
// maxMs: an unbounded server delay (malicious or buggy endpoint)
|
|
215
|
+
// could otherwise park the agent for hours per attempt.
|
|
216
|
+
const delay = retryAfterMs !== undefined
|
|
217
|
+
? Math.min(retryAfterMs, cfg.maxMs)
|
|
218
|
+
: computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
|
|
216
219
|
// Abort-aware backoff: Ctrl+C during the sleep must stop promptly
|
|
217
220
|
// instead of stalling up to maxMs before noticing.
|
|
218
221
|
if (delay > 0)
|
package/dist/agent/runtime.js
CHANGED
|
@@ -106,11 +106,16 @@ export async function run(opts, deps) {
|
|
|
106
106
|
return [{ role: 'user', content: [text(opts.task)] }];
|
|
107
107
|
})();
|
|
108
108
|
const usage = { input: 0, output: 0 };
|
|
109
|
+
// Effective model for cost accounting: the request actually sends
|
|
110
|
+
// parentContext.model when an orchestrator overrides it, so budget
|
|
111
|
+
// warnings and the max-cost guard must rate against the same model —
|
|
112
|
+
// otherwise a cheap-model budget supervises expensive-model spend.
|
|
113
|
+
const effectiveModel = opts.parentContext?.model ?? opts.model;
|
|
109
114
|
/** Emit one `budget_warning` per threshold the cost ratio has crossed. */
|
|
110
115
|
const checkBudgetWarnings = () => {
|
|
111
116
|
if (maxCost === undefined || maxCost <= 0)
|
|
112
117
|
return;
|
|
113
|
-
const ratio = estimateCost(
|
|
118
|
+
const ratio = estimateCost(effectiveModel, usage) / maxCost;
|
|
114
119
|
for (const threshold of BUDGET_WARNING_THRESHOLDS) {
|
|
115
120
|
if (ratio >= threshold && !firedBudgetWarnings.has(threshold)) {
|
|
116
121
|
firedBudgetWarnings.add(threshold);
|
|
@@ -309,7 +314,7 @@ export async function run(opts, deps) {
|
|
|
309
314
|
outer: while (steps < maxSteps) {
|
|
310
315
|
// 5.1 limits: max-cost, max-time
|
|
311
316
|
if (maxCost !== undefined) {
|
|
312
|
-
const cost = estimateCost(
|
|
317
|
+
const cost = estimateCost(effectiveModel, usage);
|
|
313
318
|
if (cost >= maxCost) {
|
|
314
319
|
setPhase('limit');
|
|
315
320
|
await closeTracer();
|
|
@@ -512,10 +517,12 @@ export async function run(opts, deps) {
|
|
|
512
517
|
}
|
|
513
518
|
// L15 failover: a terminal provider error swaps to the next chained
|
|
514
519
|
// adapter and re-issues the step (bounded by chain length). Context
|
|
515
|
-
// overflow is excluded — it owns its own recovery above
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
520
|
+
// overflow is excluded — it owns its own recovery above, and a
|
|
521
|
+
// second overflow must abort, not re-spray the oversized transcript
|
|
522
|
+
// at the next provider. By the time an error reaches the runtime,
|
|
523
|
+
// per-adapter retries are exhausted, so any provider error here is
|
|
524
|
+
// terminal for the active adapter.
|
|
525
|
+
if (failoverQueue.length > 0 && ev.code !== 'REQUEST_TOO_LARGE') {
|
|
519
526
|
const next = failoverQueue.shift();
|
|
520
527
|
failoverPending = true;
|
|
521
528
|
failoverFrom = activeAdapter.id;
|
|
@@ -1082,6 +1089,7 @@ export async function run(opts, deps) {
|
|
|
1082
1089
|
await snapshot(opts.cwd, [...fileEditCounts.keys()].slice(-20), {
|
|
1083
1090
|
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1084
1091
|
eventId: call.id,
|
|
1092
|
+
transcript,
|
|
1085
1093
|
});
|
|
1086
1094
|
}
|
|
1087
1095
|
catch { /* ignore */ }
|
|
@@ -1176,6 +1184,7 @@ export async function run(opts, deps) {
|
|
|
1176
1184
|
await snapshot(opts.cwd, [fileChanged.path], {
|
|
1177
1185
|
...(sessionId !== undefined ? { sessionId } : {}),
|
|
1178
1186
|
eventId: call.id,
|
|
1187
|
+
transcript,
|
|
1179
1188
|
});
|
|
1180
1189
|
}
|
|
1181
1190
|
catch { /* ignore */ }
|
|
@@ -30,6 +30,8 @@ export interface WorktreeInfo {
|
|
|
30
30
|
export interface MergeResult {
|
|
31
31
|
merged: boolean;
|
|
32
32
|
conflictFiles: string[];
|
|
33
|
+
/** Present when the merge failed for a non-conflict reason (bad branch, dirty tree, git error). */
|
|
34
|
+
error?: string;
|
|
33
35
|
}
|
|
34
36
|
/** True when `cwd` is inside a git working tree. Never throws — false on any failure. */
|
|
35
37
|
export declare function ensureGitRepo(cwd: string): Promise<boolean>;
|
|
@@ -86,7 +86,8 @@ export async function mergeWorktree(opts) {
|
|
|
86
86
|
await git(opts.repoCwd, ['merge', '--no-ff', '--no-edit', opts.branch]);
|
|
87
87
|
return { merged: true, conflictFiles: [] };
|
|
88
88
|
}
|
|
89
|
-
catch {
|
|
89
|
+
catch (err) {
|
|
90
|
+
const errText = err instanceof Error ? err.message : String(err);
|
|
90
91
|
const conflictFiles = await unmergedFiles(opts.repoCwd);
|
|
91
92
|
try {
|
|
92
93
|
await git(opts.repoCwd, ['merge', '--abort']);
|
|
@@ -94,7 +95,13 @@ export async function mergeWorktree(opts) {
|
|
|
94
95
|
catch {
|
|
95
96
|
/* best-effort: leave the repo as-is if abort itself fails */
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
// Only call it a conflict when unmerged paths exist; anything else
|
|
99
|
+
// (unknown branch, dirty tree, git missing) is a hard error, not an
|
|
100
|
+
// empty MERGE_CONFLICT the caller can't act on.
|
|
101
|
+
if (conflictFiles.length > 0 || /CONFLICT/i.test(errText)) {
|
|
102
|
+
return { merged: false, conflictFiles };
|
|
103
|
+
}
|
|
104
|
+
return { merged: false, conflictFiles, error: errText };
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
107
|
async function unmergedFiles(repoCwd) {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
export interface SnapshotOptions {
|
|
15
15
|
sessionId?: string;
|
|
16
16
|
eventId?: string;
|
|
17
|
+
transcript?: unknown[];
|
|
17
18
|
}
|
|
18
19
|
/** Checkpoint meta on disk — sessionId/eventId are absent on old metas. */
|
|
19
20
|
export interface CheckpointMeta {
|
|
@@ -23,6 +24,7 @@ export interface CheckpointMeta {
|
|
|
23
24
|
ts: number;
|
|
24
25
|
sessionId?: string;
|
|
25
26
|
eventId?: string;
|
|
27
|
+
transcriptHash?: string;
|
|
26
28
|
}
|
|
27
29
|
export declare function snapshot(cwd: string, files: string[], opts?: SnapshotOptions): Promise<string>;
|
|
28
30
|
export declare function listCheckpoints(cwd: string): Promise<string[]>;
|
|
@@ -37,6 +39,17 @@ export interface CheckpointInfo {
|
|
|
37
39
|
export declare function listCheckpointInfo(cwd: string): Promise<CheckpointInfo[]>;
|
|
38
40
|
/** File paths a snapshot would restore (for `/rewind <n> preview`). */
|
|
39
41
|
export declare function snapshotFiles(cwd: string, id: string): Promise<string[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Transcript captured at a checkpoint, or null when the snapshot predates
|
|
44
|
+
* transcript capture or the payload is missing/corrupt.
|
|
45
|
+
*
|
|
46
|
+
* The stored hash is verified on read: a checkpoint is only ever trusted for
|
|
47
|
+
* restoring the conversation when its transcript.json still matches the
|
|
48
|
+
* transcriptHash recorded in .meta.json. A mismatch (partial write, manual
|
|
49
|
+
* edit, tampering) yields null so the caller can report "conversation
|
|
50
|
+
* untouched" instead of rewinding onto unverified content.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readSnapshotTranscript(cwd: string, id: string): Promise<unknown[] | null>;
|
|
40
53
|
export declare function diff(cwd: string, id?: string): Promise<string>;
|
|
41
54
|
export declare function undo(cwd: string, n?: number): Promise<void>;
|
|
42
55
|
export declare function rewind(cwd: string): Promise<void>;
|
|
@@ -97,6 +97,14 @@ export async function snapshot(cwd, files, opts) {
|
|
|
97
97
|
meta.sessionId = opts.sessionId;
|
|
98
98
|
if (opts?.eventId !== undefined)
|
|
99
99
|
meta.eventId = opts.eventId;
|
|
100
|
+
if (opts?.transcript) {
|
|
101
|
+
const transcriptPath = path.join(dest, 'transcript.json');
|
|
102
|
+
await fs.writeFile(transcriptPath, JSON.stringify(opts.transcript), 'utf-8');
|
|
103
|
+
lockDown(transcriptPath, 0o600);
|
|
104
|
+
await fsyncFile(transcriptPath);
|
|
105
|
+
const hash = crypto.createHash('sha256').update(JSON.stringify(opts.transcript)).digest('hex');
|
|
106
|
+
meta.transcriptHash = hash;
|
|
107
|
+
}
|
|
100
108
|
await fs.writeFile(metaTmp, JSON.stringify(meta, null, 2));
|
|
101
109
|
lockDown(metaTmp, 0o600);
|
|
102
110
|
await fsyncFile(metaTmp);
|
|
@@ -208,6 +216,35 @@ export async function snapshotFiles(cwd, id) {
|
|
|
208
216
|
return [];
|
|
209
217
|
}
|
|
210
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Transcript captured at a checkpoint, or null when the snapshot predates
|
|
221
|
+
* transcript capture or the payload is missing/corrupt.
|
|
222
|
+
*
|
|
223
|
+
* The stored hash is verified on read: a checkpoint is only ever trusted for
|
|
224
|
+
* restoring the conversation when its transcript.json still matches the
|
|
225
|
+
* transcriptHash recorded in .meta.json. A mismatch (partial write, manual
|
|
226
|
+
* edit, tampering) yields null so the caller can report "conversation
|
|
227
|
+
* untouched" instead of rewinding onto unverified content.
|
|
228
|
+
*/
|
|
229
|
+
export async function readSnapshotTranscript(cwd, id) {
|
|
230
|
+
try {
|
|
231
|
+
const dir = path.join(ckptDir(cwd), id);
|
|
232
|
+
const meta = JSON.parse(await fs.readFile(path.join(dir, '.meta.json'), 'utf-8'));
|
|
233
|
+
const raw = await fs.readFile(path.join(dir, 'transcript.json'), 'utf-8');
|
|
234
|
+
const parsed = JSON.parse(raw);
|
|
235
|
+
if (!Array.isArray(parsed))
|
|
236
|
+
return null;
|
|
237
|
+
if (typeof meta.transcriptHash === 'string') {
|
|
238
|
+
const actual = crypto.createHash('sha256').update(raw).digest('hex');
|
|
239
|
+
if (actual !== meta.transcriptHash)
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return parsed;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
211
248
|
export async function diff(cwd, id) {
|
|
212
249
|
const ckpts = await listCheckpoints(cwd);
|
|
213
250
|
const target = id ?? ckpts[ckpts.length - 1];
|
|
@@ -218,9 +255,33 @@ export async function diff(cwd, id) {
|
|
|
218
255
|
return new Promise((resolve) => {
|
|
219
256
|
const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
|
|
220
257
|
const sink = cappedOutput(64 * 1024);
|
|
258
|
+
let done = false;
|
|
259
|
+
// A wedged git (locked index) must not hang undo diagnostics forever.
|
|
260
|
+
const t = setTimeout(() => {
|
|
261
|
+
if (done)
|
|
262
|
+
return;
|
|
263
|
+
done = true;
|
|
264
|
+
try {
|
|
265
|
+
child.kill('SIGKILL');
|
|
266
|
+
}
|
|
267
|
+
catch { /* ignore */ }
|
|
268
|
+
resolve('No diff (git timed out)');
|
|
269
|
+
}, 10_000);
|
|
221
270
|
child.stdout.on('data', (b) => sink.push(b));
|
|
222
|
-
child.on('close', () =>
|
|
223
|
-
|
|
271
|
+
child.on('close', () => {
|
|
272
|
+
if (done)
|
|
273
|
+
return;
|
|
274
|
+
done = true;
|
|
275
|
+
clearTimeout(t);
|
|
276
|
+
resolve(sink.text() || 'No diff');
|
|
277
|
+
});
|
|
278
|
+
child.on('error', () => {
|
|
279
|
+
if (done)
|
|
280
|
+
return;
|
|
281
|
+
done = true;
|
|
282
|
+
clearTimeout(t);
|
|
283
|
+
resolve('No git diff available');
|
|
284
|
+
});
|
|
224
285
|
});
|
|
225
286
|
}
|
|
226
287
|
export async function undo(cwd, n = 1) {
|
|
@@ -232,6 +293,8 @@ export async function undo(cwd, n = 1) {
|
|
|
232
293
|
const metaRaw = await fs.readFile(path.join(srcDir, '.meta.json'), 'utf-8');
|
|
233
294
|
const meta = JSON.parse(metaRaw);
|
|
234
295
|
// Restore modified/created files to their snapshotted content…
|
|
296
|
+
// Transcript restore is handled by caller via transcript snapshot — read via
|
|
297
|
+
// readSnapshotTranscript() if the caller wants conversation rewind.
|
|
235
298
|
for (const f of meta.files) {
|
|
236
299
|
const src = containedPath(cwd, srcDir, f);
|
|
237
300
|
const dest = src ? containedPath(cwd, cwd, f) : null;
|
package/dist/cli/dotenv.js
CHANGED
|
@@ -89,7 +89,7 @@ const BLOCKED_DOTENV_SECURITY_RELAXATIONS = new Set([
|
|
|
89
89
|
'KLYRO_WORKER',
|
|
90
90
|
]);
|
|
91
91
|
/** npm reads `npm_config_*` from the environment — a repo must not pick the registry. */
|
|
92
|
-
const BLOCKED_DOTENV_PREFIXES = ['NPM_CONFIG_'];
|
|
92
|
+
const BLOCKED_DOTENV_PREFIXES = ['NPM_CONFIG_', 'KLYRO_SANDBOX']; // KLYRO_SANDBOX=0 / KLYRO_SANDBOX_NET=1 de-harden the shell sandbox (see shell-exec.ts)
|
|
93
93
|
/** True when a `.env` key would change process behaviour or loosen a boundary. */
|
|
94
94
|
export function isBlockedDotenvKey(key) {
|
|
95
95
|
const k = key.toUpperCase();
|
package/dist/cli/repl.js
CHANGED
|
@@ -711,8 +711,6 @@ export async function startRepl(opts = {}) {
|
|
|
711
711
|
directHooks.clearTranscript();
|
|
712
712
|
else
|
|
713
713
|
pendingQueue.length = 0;
|
|
714
|
-
if (isMounted && directHooks)
|
|
715
|
-
directHooks.clearTranscript();
|
|
716
714
|
}
|
|
717
715
|
// Update nudge (best-effort, cached 24h, silent fail): a single stderr
|
|
718
716
|
// line when a newer version exists. No prompt, no blocking — the check
|
|
@@ -956,7 +954,12 @@ export async function startRepl(opts = {}) {
|
|
|
956
954
|
// streamingId is closed by status change; no extra handling needed
|
|
957
955
|
}
|
|
958
956
|
else if (ev.kind === 'usage') {
|
|
959
|
-
queuedStatus({
|
|
957
|
+
queuedStatus({
|
|
958
|
+
usageInput: ev.input,
|
|
959
|
+
usageOutput: ev.output,
|
|
960
|
+
usageCacheRead: ev.cacheRead ?? 0,
|
|
961
|
+
usageCacheWrite: ev.cacheWrite ?? 0,
|
|
962
|
+
});
|
|
960
963
|
}
|
|
961
964
|
else if (ev.kind === 'plan_update') {
|
|
962
965
|
queuedPlan(ev.plan);
|
|
@@ -1261,6 +1264,20 @@ export async function startRepl(opts = {}) {
|
|
|
1261
1264
|
}
|
|
1262
1265
|
await undo(cwd, n);
|
|
1263
1266
|
let text = `Rewound to checkpoint ${n} (${target.id.slice(0, 12)}, ${target.files} file(s))`;
|
|
1267
|
+
// If transcript was captured for this checkpoint, restore it into the
|
|
1268
|
+
// active REPL session so /rewind full also reverts the conversation.
|
|
1269
|
+
const { readSnapshotTranscript } = await import('../checkpoints/store.js');
|
|
1270
|
+
const raw = await readSnapshotTranscript(cwd, target.id);
|
|
1271
|
+
// readSnapshotTranscript returns `unknown[]`; verify minimal Message shape
|
|
1272
|
+
// before giving it to adoptTranscript to avoid runtime type errors.
|
|
1273
|
+
const isMessage = (v) => !!v && typeof v === 'object' && 'role' in v && 'content' in v;
|
|
1274
|
+
if (Array.isArray(raw) && raw.length > 0 && raw.every(isMessage)) {
|
|
1275
|
+
sessionMessages = adoptTranscript(raw);
|
|
1276
|
+
text += '\nConversation restored from checkpoint transcript.';
|
|
1277
|
+
}
|
|
1278
|
+
else if (mode === 'full') {
|
|
1279
|
+
text += '\nConversation rewind skipped — no transcript captured for this checkpoint.';
|
|
1280
|
+
}
|
|
1264
1281
|
if (mode === 'summary' || mode === 'full') {
|
|
1265
1282
|
const files = before.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 20);
|
|
1266
1283
|
text += files.length > 0 ? `\nReverted working-tree changes:\n${files.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree was clean before restore.';
|
|
@@ -2074,8 +2091,8 @@ export async function startRepl(opts = {}) {
|
|
|
2074
2091
|
queuedAppend({ id: `ckpt-${Date.now()}`, kind: 'text', text: 'Working tree clean — nothing to checkpoint.', role: 'assistant' });
|
|
2075
2092
|
}
|
|
2076
2093
|
else {
|
|
2077
|
-
const id = await snapshot(cwd, files.slice(0, 50));
|
|
2078
|
-
queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)`, role: 'assistant' });
|
|
2094
|
+
const id = await snapshot(cwd, files.slice(0, 50), { transcript: sessionMessages });
|
|
2095
|
+
queuedAppend({ id: `ckpt2-${Date.now()}`, kind: 'text', text: `checkpoint ${String(id).slice(0, 8)} — ${files.length} file(s)` + ` (conversation archived)`, role: 'assistant' });
|
|
2079
2096
|
}
|
|
2080
2097
|
}
|
|
2081
2098
|
catch (err) {
|
package/dist/eval/harness.js
CHANGED
|
@@ -23,7 +23,7 @@ import { globTool } from '../tools/search/glob.js';
|
|
|
23
23
|
import { grepTool } from '../tools/search/grep.js';
|
|
24
24
|
import { gitStatusTool } from '../tools/git/git-status.js';
|
|
25
25
|
import { gitDiffTool } from '../tools/git/git-diff.js';
|
|
26
|
-
import { PolicyEngine, builtinRules,
|
|
26
|
+
import { PolicyEngine, builtinRules, clonePolicyConfig } from '../policy/engine.js';
|
|
27
27
|
import { DenyAllApprovalPrompt } from '../policy/approval.js';
|
|
28
28
|
import { defaultSystemPrompt } from '../agent/runtime.js';
|
|
29
29
|
import { verify } from '../verification/engine.js';
|
|
@@ -59,7 +59,9 @@ export async function runTask(t, opts = {}) {
|
|
|
59
59
|
.register(listDirTool).register(globTool).register(grepTool)
|
|
60
60
|
.register(shellExecTool).register(runVerifyTool)
|
|
61
61
|
.register(gitStatusTool).register(gitDiffTool);
|
|
62
|
-
|
|
62
|
+
// Clone: registerMcpServers mutates the config via addAsk — sharing the
|
|
63
|
+
// global DEFAULT_POLICY_CONFIG would leak ask-rules across tasks/runs.
|
|
64
|
+
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
63
65
|
try {
|
|
64
66
|
const result = await run({ task: t.task, cwd, model: 'mock', maxSteps: 12, nonInteractive: true }, { adapter: scriptedAdapter(t.script), registry: reg, policy, approval: new DenyAllApprovalPrompt(), systemPrompt: defaultSystemPrompt });
|
|
65
67
|
let observedStatus = result.status;
|
|
@@ -72,7 +74,10 @@ export async function runTask(t, opts = {}) {
|
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
let details = '';
|
|
75
|
-
|
|
77
|
+
// Grade the *observed* status (agent result possibly overridden by the
|
|
78
|
+
// verify step above) — a failed verifyCommand must fail the task even
|
|
79
|
+
// when the agent itself reported 'complete'.
|
|
80
|
+
let pass = observedStatus === t.expectStatus;
|
|
76
81
|
if (t.expectToolCalls !== undefined && result.toolCalls !== t.expectToolCalls) {
|
|
77
82
|
details += ` toolCalls=${result.toolCalls} expected=${t.expectToolCalls};`;
|
|
78
83
|
pass = false;
|
package/dist/mcp/remote.js
CHANGED
|
@@ -15,18 +15,42 @@ export function isLoopback(url) {
|
|
|
15
15
|
/** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
|
|
16
16
|
export function parseSseBody(text) {
|
|
17
17
|
const out = [];
|
|
18
|
+
// Consecutive data: lines belong to ONE event per the SSE spec (joined
|
|
19
|
+
// with \n). Parse the joined chunk first; if that fails, fall back to
|
|
20
|
+
// per-line parsing so servers that (against spec) emit bare JSON per
|
|
21
|
+
// line without blank separators keep working.
|
|
22
|
+
let parts = [];
|
|
23
|
+
const flush = () => {
|
|
24
|
+
if (parts.length === 0)
|
|
25
|
+
return;
|
|
26
|
+
const joined = parts.join('\n').trim();
|
|
27
|
+
parts = [];
|
|
28
|
+
if (joined === '[DONE]')
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
out.push(JSON.parse(joined));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
catch { /* try per-line below */ }
|
|
35
|
+
for (const part of joined.split('\n')) {
|
|
36
|
+
const t = part.trim();
|
|
37
|
+
if (!t || t === '[DONE]')
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
out.push(JSON.parse(t));
|
|
41
|
+
}
|
|
42
|
+
catch { /* ignore malformed lines */ }
|
|
43
|
+
}
|
|
44
|
+
};
|
|
18
45
|
for (const line of text.split('\n')) {
|
|
19
46
|
const t = line.trim();
|
|
20
|
-
if (!t.startsWith('data:'))
|
|
21
|
-
|
|
22
|
-
const payload = t.slice(5).trim();
|
|
23
|
-
if (payload === '[DONE]')
|
|
47
|
+
if (!t.startsWith('data:')) {
|
|
48
|
+
flush();
|
|
24
49
|
continue;
|
|
25
|
-
try {
|
|
26
|
-
out.push(JSON.parse(payload));
|
|
27
50
|
}
|
|
28
|
-
|
|
51
|
+
parts.push(t.slice(5).trim());
|
|
29
52
|
}
|
|
53
|
+
flush();
|
|
30
54
|
return out;
|
|
31
55
|
}
|
|
32
56
|
export class RemoteMcpClient {
|
package/dist/mcp/sse.js
CHANGED
|
@@ -163,7 +163,6 @@ export class SseMcpClient {
|
|
|
163
163
|
return;
|
|
164
164
|
this.pending.delete(m.id);
|
|
165
165
|
clearTimeout(p.timer);
|
|
166
|
-
clearTimeout(p.timer);
|
|
167
166
|
if (m.error)
|
|
168
167
|
p.reject(new McpError(`mcp server "${this.name}" error: ${m.error.message ?? 'unknown'}`, 'SERVER_ERROR', m.error));
|
|
169
168
|
else
|
|
@@ -93,12 +93,6 @@ export declare class AuditLog {
|
|
|
93
93
|
private readonly keepSegments;
|
|
94
94
|
/** Serializes chained appends so concurrent writes can't fork the chain. */
|
|
95
95
|
private chain;
|
|
96
|
-
/**
|
|
97
|
-
* Cached last-written hash: avoids re-reading the whole file (O(n) per
|
|
98
|
-
* write) on every append. Null = not loaded yet — first append reads the
|
|
99
|
-
* file (picks up an existing chain), then the cache takes over.
|
|
100
|
-
*/
|
|
101
|
-
private lastHash;
|
|
102
96
|
constructor(filePath: string, maxBytes?: number, keepSegments?: number);
|
|
103
97
|
write(event: AuditEvent): Promise<void>;
|
|
104
98
|
private appendChained;
|
|
@@ -64,9 +64,9 @@ export const AUDIT_ROTATE_KEEP = 5;
|
|
|
64
64
|
* Rotate an audit log once it exceeds `maxBytes`: shift the newest segment
|
|
65
65
|
* over the oldest (deleted), leave a fresh live file. Each segment carries
|
|
66
66
|
* its own GENESIS-rooted chain, so rotation is tamper-evident per segment.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* Callers re-read the tail after rotating, so the next live record starts
|
|
68
|
+
* a fresh chain with no in-memory state to reset. Best-effort — rotation
|
|
69
|
+
* failures never break appends.
|
|
70
70
|
*/
|
|
71
71
|
async function rotateAuditLog(filePath, maxBytes, keep) {
|
|
72
72
|
try {
|
|
@@ -97,12 +97,6 @@ export class AuditLog {
|
|
|
97
97
|
keepSegments;
|
|
98
98
|
/** Serializes chained appends so concurrent writes can't fork the chain. */
|
|
99
99
|
chain = Promise.resolve();
|
|
100
|
-
/**
|
|
101
|
-
* Cached last-written hash: avoids re-reading the whole file (O(n) per
|
|
102
|
-
* write) on every append. Null = not loaded yet — first append reads the
|
|
103
|
-
* file (picks up an existing chain), then the cache takes over.
|
|
104
|
-
*/
|
|
105
|
-
lastHash = null;
|
|
106
100
|
constructor(filePath, maxBytes = AUDIT_MAX_BYTES, keepSegments = AUDIT_ROTATE_KEEP) {
|
|
107
101
|
this.filePath = filePath;
|
|
108
102
|
this.maxBytes = maxBytes;
|
|
@@ -115,17 +109,16 @@ export class AuditLog {
|
|
|
115
109
|
return task;
|
|
116
110
|
}
|
|
117
111
|
async appendChained(event) {
|
|
118
|
-
// Rotation leaves a fresh live file
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const prevHash = this.
|
|
112
|
+
// Rotation leaves a fresh live file; the tail read below then naturally
|
|
113
|
+
// restarts at GENESIS (each segment is its own chain).
|
|
114
|
+
await rotateAuditLog(this.filePath, this.maxBytes, this.keepSegments);
|
|
115
|
+
// Always re-read the tail instead of caching the tip: a cached tip forks
|
|
116
|
+
// the chain whenever another instance or process appends in between
|
|
117
|
+
// (e.g. a long-lived TUI plus a one-shot CLI on the same project).
|
|
118
|
+
// Audit volume is low and segments are size-bounded, so the read is cheap.
|
|
119
|
+
const prevHash = await prevHashFor(this.filePath);
|
|
126
120
|
const body = { ...event, prevHash };
|
|
127
121
|
const record = { ...body, prevHash, hash: sha256Hex(canonicalJson(body)) };
|
|
128
|
-
this.lastHash = record.hash;
|
|
129
122
|
await SessionStore.appendJsonl(this.filePath, record);
|
|
130
123
|
}
|
|
131
124
|
}
|
|
@@ -297,20 +297,26 @@ export class SessionStore {
|
|
|
297
297
|
await fs.unlink(tmp).catch(() => undefined);
|
|
298
298
|
throw new Error(`Failed to write session ${id}`);
|
|
299
299
|
}
|
|
300
|
-
// Update index
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
300
|
+
// Update index under the shared 'index' lock (same key create/delete
|
|
301
|
+
// use): the read-modify-write below races under concurrent writers on
|
|
302
|
+
// different sessions, and last-writer-wins would silently drop entries.
|
|
303
|
+
// Deadlock-free: no caller holds 'index' while calling writeSession
|
|
304
|
+
// (create/delete write the index inline instead).
|
|
305
|
+
await this.withLock('index', async () => {
|
|
306
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
307
|
+
try {
|
|
308
|
+
const idx = await this.readIndex();
|
|
309
|
+
idx[id] = data.record;
|
|
310
|
+
await this.writeIndex(idx);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
if (attempt === 2)
|
|
315
|
+
throw err;
|
|
316
|
+
await new Promise((r) => setTimeout(r, 10 * (attempt + 1)));
|
|
317
|
+
}
|
|
312
318
|
}
|
|
313
|
-
}
|
|
319
|
+
});
|
|
314
320
|
}
|
|
315
321
|
async appendMessage(id, message) {
|
|
316
322
|
return this.withLock(id, async () => {
|