klyro 1.0.20 → 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 +13 -6
- package/dist/agent/worktree-manager.d.ts +2 -0
- package/dist/agent/worktree-manager.js +9 -2
- package/dist/checkpoints/store.js +26 -2
- package/dist/cli/dotenv.js +1 -1
- package/dist/cli/repl.js +6 -3
- 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/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;
|
|
@@ -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) {
|
|
@@ -255,9 +255,33 @@ export async function diff(cwd, id) {
|
|
|
255
255
|
return new Promise((resolve) => {
|
|
256
256
|
const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
|
|
257
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);
|
|
258
270
|
child.stdout.on('data', (b) => sink.push(b));
|
|
259
|
-
child.on('close', () =>
|
|
260
|
-
|
|
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
|
+
});
|
|
261
285
|
});
|
|
262
286
|
}
|
|
263
287
|
export async function undo(cwd, n = 1) {
|
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);
|
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 () => {
|
package/dist/policy/engine.js
CHANGED
|
@@ -76,6 +76,18 @@ export class PolicyEngine {
|
|
|
76
76
|
call.name === 'memory_write') {
|
|
77
77
|
return { action: 'deny', reason: 'plan mode: writes blocked — use /permissions to allow or switch mode' };
|
|
78
78
|
}
|
|
79
|
+
// plan mode is read-only investigation: shells run only allowlisted
|
|
80
|
+
// read-only commands, and nothing may escape to subagents or MCP
|
|
81
|
+
// servers (both can write through their own tool surfaces).
|
|
82
|
+
if (call.name === 'shell_exec' || call.name === 'run_verify') {
|
|
83
|
+
const cmd = asString(call.input.command);
|
|
84
|
+
if (!cmd || !startsWithAny(cmd, ctx.config.shellAllow)) {
|
|
85
|
+
return { action: 'deny', reason: 'plan mode: only allowlisted read-only commands — use /permissions to allow or switch mode' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (call.name === 'spawn_agent' || call.name.startsWith('mcp__')) {
|
|
89
|
+
return { action: 'deny', reason: 'plan mode: subagents and MCP tools blocked — use /permissions to allow or switch mode' };
|
|
90
|
+
}
|
|
79
91
|
}
|
|
80
92
|
else if (ctx.config.mode === 'accept-edits') {
|
|
81
93
|
// accept-edits: auto-allow edit tools
|
|
@@ -196,7 +208,8 @@ function isEnvPath(p) {
|
|
|
196
208
|
// not secrets — reads AND writes are allowed.
|
|
197
209
|
if (/[.](example|sample|template|dist)$/.test(lower))
|
|
198
210
|
return false;
|
|
199
|
-
|
|
211
|
+
// Separator class covers POSIX and Windows paths (C:\proj\.env.local).
|
|
212
|
+
return /(^|[/\\])\.env(\.|$)/.test(lower) || lower.endsWith('.env');
|
|
200
213
|
}
|
|
201
214
|
function extractPatchPaths(patch) {
|
|
202
215
|
const out = [];
|
|
@@ -238,9 +251,25 @@ export function matchesGlobRule(call, rule) {
|
|
|
238
251
|
}
|
|
239
252
|
function startsWithAny(haystack, needles) {
|
|
240
253
|
const h = haystack.trim().toLowerCase();
|
|
254
|
+
// Quote-stripped variant: `rm -rf "/"` is `rm -rf /` with camouflage.
|
|
255
|
+
// Stripping quotes can only add deny matches at position 0, never remove.
|
|
256
|
+
const variants = [h, h.replace(/['"]/g, '')];
|
|
241
257
|
return needles.some((n) => {
|
|
242
258
|
const nl = n.toLowerCase().replace(/\s+$/, ''); // strip trailing ws from needle
|
|
243
|
-
return
|
|
259
|
+
return variants.some((hv) => {
|
|
260
|
+
if (hv === nl)
|
|
261
|
+
return true;
|
|
262
|
+
if (!hv.startsWith(nl))
|
|
263
|
+
return false;
|
|
264
|
+
// Boundary after the needle: end, whitespace, quote, or a path
|
|
265
|
+
// separator/glob/chain char. Without the separator class, `rm -rf /*`,
|
|
266
|
+
// `rm -rf ~/*`, `rm -rf ./*` and `rm -rf ./` all slip past the deny
|
|
267
|
+
// list. Note this intentionally also matches scoped forms like
|
|
268
|
+
// `rm -rf ./build` — destructive-prefix commands stay denied and the
|
|
269
|
+
// caller allowlists (or uses a bare relative path).
|
|
270
|
+
const c = hv[nl.length];
|
|
271
|
+
return c === undefined || /[\s/'"`*?\[\\;&|]/.test(c);
|
|
272
|
+
});
|
|
244
273
|
});
|
|
245
274
|
}
|
|
246
275
|
/** Branches that `git push` must never target without an explicit opt-out. */
|
|
@@ -407,13 +436,20 @@ export const writeFileCwdRule = {
|
|
|
407
436
|
if (!p)
|
|
408
437
|
return null;
|
|
409
438
|
if (p.includes('..') || path.isAbsolute(p)) {
|
|
410
|
-
//
|
|
439
|
+
// Absolute paths are fine inside the workspace roots (cwd or
|
|
440
|
+
// additionalDirs) — only true escapes are denied.
|
|
411
441
|
if (path.isAbsolute(p)) {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
const roots = [ctx.cwd, ...(ctx.config.additionalDirs ?? [])];
|
|
443
|
+
const inside = roots.some((dir) => {
|
|
444
|
+
try {
|
|
445
|
+
const rel = path.relative(path.resolve(dir), path.resolve(p));
|
|
446
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return false; // different drive on Windows: not inside
|
|
450
|
+
}
|
|
415
451
|
});
|
|
416
|
-
if (
|
|
452
|
+
if (inside)
|
|
417
453
|
return null;
|
|
418
454
|
}
|
|
419
455
|
return { action: 'deny', reason: 'path traversal or absolute path not allowed' };
|
|
@@ -39,4 +39,7 @@ export declare function ratesFor(modelId: string): {
|
|
|
39
39
|
* other families keep ignoring those counters.
|
|
40
40
|
*/
|
|
41
41
|
export declare function isAnthropicModel(modelId: string): boolean;
|
|
42
|
-
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number
|
|
42
|
+
export declare function estimateCost(modelId: string, inputTokens: number, outputTokens: number, cache?: {
|
|
43
|
+
read?: number;
|
|
44
|
+
write?: number;
|
|
45
|
+
}): number;
|
|
@@ -70,9 +70,17 @@ export function ratesFor(modelId) {
|
|
|
70
70
|
export function isAnthropicModel(modelId) {
|
|
71
71
|
return /anthropic|claude/i.test(modelId);
|
|
72
72
|
}
|
|
73
|
-
export function estimateCost(modelId, inputTokens, outputTokens) {
|
|
73
|
+
export function estimateCost(modelId, inputTokens, outputTokens, cache) {
|
|
74
74
|
// Single source: delegate to ratesFor so registry, family fallbacks, and
|
|
75
75
|
// local-$0 rules live in exactly one place (no split-brain with getModelInfo).
|
|
76
76
|
const { input, output } = ratesFor(modelId);
|
|
77
|
-
|
|
77
|
+
let cost = (inputTokens / 1000) * input + (outputTokens / 1000) * output;
|
|
78
|
+
// Cache-aware (Anthropic billing): cacheRead at 0.1x input, cacheWrite at
|
|
79
|
+
// 1.25x input. Other families ignore cache counters. Mirrors the
|
|
80
|
+
// runtime's estimateCost — keep the two in sync.
|
|
81
|
+
if (isAnthropicModel(modelId)) {
|
|
82
|
+
cost += ((cache?.read ?? 0) / 1000) * input * 0.1;
|
|
83
|
+
cost += ((cache?.write ?? 0) / 1000) * input * 1.25;
|
|
84
|
+
}
|
|
85
|
+
return cost;
|
|
78
86
|
}
|
package/dist/repl.js
CHANGED
|
@@ -94,8 +94,10 @@ export async function repl(system) {
|
|
|
94
94
|
// EOF on stdin (Ctrl-D) or close — exit cleanly.
|
|
95
95
|
break;
|
|
96
96
|
}
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// EOF on stdin (Ctrl-D) rejects the question and exits via the
|
|
98
|
+
// catch above. An empty line is just a stray Enter — keep going.
|
|
99
|
+
if (line === '')
|
|
100
|
+
continue;
|
|
99
101
|
const step = splitContinuedLine(continued, line);
|
|
100
102
|
continued = step.pending;
|
|
101
103
|
if (step.complete === undefined)
|