klyro 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/anthropic-adapter.d.ts +13 -5
- package/dist/agent/anthropic-adapter.js +19 -2
- package/dist/agent/capabilities.js +7 -1
- package/dist/agent/orchestrator.d.ts +44 -4
- package/dist/agent/orchestrator.js +102 -8
- package/dist/agent/retry.js +52 -10
- package/dist/agent/runtime.d.ts +34 -0
- package/dist/agent/runtime.js +169 -15
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +26 -0
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +45 -0
- package/dist/cli/config.js +82 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/markdown.js +29 -1
- package/dist/cli/repl.js +41 -1
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.js +76 -3
- package/dist/context/tokenizer.d.ts +18 -0
- package/dist/context/tokenizer.js +50 -1
- package/dist/events/bus.d.ts +5 -0
- package/dist/events/bus.js +10 -0
- package/dist/events/catalog.d.ts +9 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +89 -5
- package/dist/mcp/client.js +1 -1
- package/dist/mcp/registry.d.ts +0 -18
- package/dist/mcp/registry.js +49 -2
- package/dist/policy/engine.d.ts +16 -0
- package/dist/policy/engine.js +74 -1
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/providers/model-info.d.ts +6 -0
- package/dist/providers/model-info.js +8 -0
- package/dist/tools/fs/apply-patch.js +6 -1
- package/dist/tools/fs/edit-file.js +4 -1
- package/dist/tools/fs/multi-edit.js +4 -1
- package/dist/tools/fs/write-file.js +16 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/shell/sandbox.d.ts +4 -3
- package/dist/tools/shell/sandbox.js +23 -3
- package/dist/tools/shell/shell-exec.d.ts +28 -0
- package/dist/tools/shell/shell-exec.js +87 -1
- package/dist/trace/writer.d.ts +7 -0
- package/dist/trace/writer.js +7 -0
- package/dist/tui/app.js +20 -1
- package/dist/tui/app.test.js +18 -12
- package/dist/tui/approval.test.js +20 -3
- package/dist/tui/markdown.d.ts +13 -0
- package/dist/tui/markdown.js +169 -2
- package/dist/tui/scroll-flow.test.js +3 -1
- package/dist/verification/classify.js +4 -3
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +25 -0
- package/dist/verification/registry.js +16 -5
- package/dist/verification/scoped.js +36 -5
- package/package.json +1 -1
package/dist/agent/runtime.js
CHANGED
|
@@ -22,12 +22,13 @@ import * as path from 'node:path';
|
|
|
22
22
|
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
23
23
|
import { detectVerifyCommand } from '../verification/auto.js';
|
|
24
24
|
import { ensureBaseline, getBaseline } from '../verification/baseline.js';
|
|
25
|
-
import { compressTranscript, totalTokens } from '../context/tokenizer.js';
|
|
26
|
-
import { ratesFor } from '../providers/model-info.js';
|
|
25
|
+
import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
|
|
26
|
+
import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
|
|
27
27
|
import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
|
|
28
28
|
import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
|
|
29
29
|
import { globalBus } from '../events/bus.js';
|
|
30
30
|
import { TraceWriter } from '../trace/writer.js';
|
|
31
|
+
import { loadHooks, runHook } from '../cli/hooks.js';
|
|
31
32
|
/** Normalize either systemPrompt shape into {system, suffix}. */
|
|
32
33
|
export function resolveSystemPrompt(fn, ctx) {
|
|
33
34
|
const r = fn(ctx);
|
|
@@ -42,18 +43,30 @@ export function toolDefinitions(registry) {
|
|
|
42
43
|
inputSchema: t.function.parameters,
|
|
43
44
|
}));
|
|
44
45
|
}
|
|
45
|
-
//
|
|
46
|
+
// Model-aware cost estimation, single-sourced from the
|
|
46
47
|
// providers/model-info.ts rate table (local/unknown models are $0).
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// full input rates would overstate spend, silently dropping them
|
|
51
|
-
// understates it, so we keep them visible and out of the math.
|
|
48
|
+
// Cache-aware: for Anthropic-family models (isAnthropicModel), cacheRead
|
|
49
|
+
// bills at 0.1× the input rate and cacheWrite at 1.25×; all other
|
|
50
|
+
// families ignore cache counters (discounted billing, unmodeled).
|
|
52
51
|
/** Estimate USD cost of a usage block given the model name. */
|
|
53
52
|
export function estimateCost(model, usage) {
|
|
54
53
|
const { input: inRate, output: outRate } = ratesFor(model);
|
|
55
|
-
|
|
54
|
+
const base = (usage.input / 1000) * inRate + (usage.output / 1000) * outRate;
|
|
55
|
+
if (!isAnthropicModel(model))
|
|
56
|
+
return base;
|
|
57
|
+
const read = ((usage.cacheRead ?? 0) / 1000) * inRate * 0.1;
|
|
58
|
+
const write = ((usage.cacheWrite ?? 0) / 1000) * inRate * 1.25;
|
|
59
|
+
return base + read + write;
|
|
56
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Parallel fan-out cap: approved concurrencySafe tool calls execute in
|
|
63
|
+
* sequential chunks of at most this size. Commit order stays identical
|
|
64
|
+
* (commits run sequentially after execution), so the transcript reads as
|
|
65
|
+
* if the calls ran in order.
|
|
66
|
+
*/
|
|
67
|
+
export const MAX_PARALLEL_TOOLS = 8;
|
|
68
|
+
/** Progressive budget-warning thresholds (fraction of maxCost), fired once each per run. */
|
|
69
|
+
export const BUDGET_WARNING_THRESHOLDS = [0.4, 0.7, 0.9];
|
|
57
70
|
// PERF-002: Memoized token counting cache.
|
|
58
71
|
let tokenCache = {
|
|
59
72
|
lastRef: null,
|
|
@@ -90,6 +103,18 @@ export async function run(opts, deps) {
|
|
|
90
103
|
return [{ role: 'user', content: [text(opts.task)] }];
|
|
91
104
|
})();
|
|
92
105
|
const usage = { input: 0, output: 0 };
|
|
106
|
+
/** Emit one `budget_warning` per threshold the cost ratio has crossed. */
|
|
107
|
+
const checkBudgetWarnings = () => {
|
|
108
|
+
if (maxCost === undefined || maxCost <= 0)
|
|
109
|
+
return;
|
|
110
|
+
const ratio = estimateCost(opts.model, usage) / maxCost;
|
|
111
|
+
for (const threshold of BUDGET_WARNING_THRESHOLDS) {
|
|
112
|
+
if (ratio >= threshold && !firedBudgetWarnings.has(threshold)) {
|
|
113
|
+
firedBudgetWarnings.add(threshold);
|
|
114
|
+
emit?.({ kind: 'budget_warning', ratio, threshold });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
93
118
|
let steps = 0;
|
|
94
119
|
let toolCallCount = 0;
|
|
95
120
|
let finalText = '';
|
|
@@ -120,6 +145,27 @@ export async function run(opts, deps) {
|
|
|
120
145
|
const emit = opts.onEvent;
|
|
121
146
|
const telemetry = new RuntimeTelemetry();
|
|
122
147
|
telemetry.setMaxSteps(maxSteps);
|
|
148
|
+
// Model-override surfacing (informational): when a parent/orchestrator
|
|
149
|
+
// context carries a model override, emit it once so UIs can show which
|
|
150
|
+
// model actually serves this run.
|
|
151
|
+
if (opts.parentContext?.model) {
|
|
152
|
+
emit?.({ kind: 'model_override', requested: opts.model, effective: opts.parentContext.model });
|
|
153
|
+
}
|
|
154
|
+
// Hooks engine: loaded once per run. Zero-cost fast path — when no hooks
|
|
155
|
+
// file exists, both lists are empty and every hook call site is skipped.
|
|
156
|
+
let runHooks = [];
|
|
157
|
+
try {
|
|
158
|
+
runHooks = loadHooks(opts.cwd);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
runHooks = [];
|
|
162
|
+
}
|
|
163
|
+
const preHooks = runHooks.filter((h) => h.event === 'preToolUse');
|
|
164
|
+
const postHooks = runHooks.filter((h) => h.event === 'postToolUse');
|
|
165
|
+
// L15 failover chain: the active adapter starts as deps.adapter; each
|
|
166
|
+
// terminal provider error consumes one fallback. Bounded — never loops.
|
|
167
|
+
let activeAdapter = deps.adapter;
|
|
168
|
+
const failoverQueue = [...(deps.failoverAdapters ?? [])];
|
|
123
169
|
// 3.1 — Event bus + TraceWriter
|
|
124
170
|
const bus = deps.bus ?? globalBus;
|
|
125
171
|
let tracer;
|
|
@@ -140,6 +186,10 @@ export async function run(opts, deps) {
|
|
|
140
186
|
// 5.1 — phases and limits
|
|
141
187
|
const maxCost = opts.maxCost;
|
|
142
188
|
const maxTimeMs = opts.maxTimeMs;
|
|
189
|
+
// Progressive budget warnings: fire once per threshold per run when the
|
|
190
|
+
// cost ratio crosses 0.4 / 0.7 / 0.9 of maxCost (checker defined after
|
|
191
|
+
// `usage` is declared below).
|
|
192
|
+
const firedBudgetWarnings = new Set();
|
|
143
193
|
const startTime = Date.now();
|
|
144
194
|
let phase = 'understanding';
|
|
145
195
|
const setPhase = (p) => {
|
|
@@ -270,7 +320,7 @@ export async function run(opts, deps) {
|
|
|
270
320
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
|
271
321
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
272
322
|
};
|
|
273
|
-
const events =
|
|
323
|
+
const events = activeAdapter.stream(req);
|
|
274
324
|
let textBuf = '';
|
|
275
325
|
// Thinking is ephemeral: streamed to the UI live, never stored in the
|
|
276
326
|
// transcript, and cleared when the turn's answer completes.
|
|
@@ -279,6 +329,12 @@ export async function run(opts, deps) {
|
|
|
279
329
|
let lastFinishReason;
|
|
280
330
|
// Set when this step's request must be re-issued after overflow recovery.
|
|
281
331
|
let overflowRetryPending = false;
|
|
332
|
+
// Set when a terminal provider error consumed a failover adapter — the
|
|
333
|
+
// step is re-issued against the next adapter without consuming budget.
|
|
334
|
+
let failoverPending = false;
|
|
335
|
+
let failoverFrom = '';
|
|
336
|
+
let failoverTo = '';
|
|
337
|
+
let failoverReason = '';
|
|
282
338
|
for await (const ev of events) {
|
|
283
339
|
if (opts.signal?.aborted)
|
|
284
340
|
break outer;
|
|
@@ -313,11 +369,16 @@ export async function run(opts, deps) {
|
|
|
313
369
|
if (ev.usage.cacheWrite !== undefined)
|
|
314
370
|
usage.cacheWrite = (usage.cacheWrite ?? 0) + ev.usage.cacheWrite;
|
|
315
371
|
telemetry.recordUsage(ev.usage.input, ev.usage.output);
|
|
372
|
+
// R3 — calibrate the local chars/4 heuristic toward the real
|
|
373
|
+
// per-character ratio this provider/model reports, so future budget
|
|
374
|
+
// checks (and overflow recovery) estimate accurately.
|
|
375
|
+
calibrateEstimate(transcriptCharLength(systemForBudget, reqMessages), ev.usage.input);
|
|
316
376
|
emit?.({
|
|
317
377
|
kind: 'usage', input: usage.input, output: usage.output,
|
|
318
378
|
...(usage.cacheRead !== undefined ? { cacheRead: usage.cacheRead } : {}),
|
|
319
379
|
...(usage.cacheWrite !== undefined ? { cacheWrite: usage.cacheWrite } : {}),
|
|
320
380
|
});
|
|
381
|
+
checkBudgetWarnings();
|
|
321
382
|
}
|
|
322
383
|
else {
|
|
323
384
|
// Providers that omit usage (Ollama, vLLM, proxies): estimate from
|
|
@@ -329,6 +390,7 @@ export async function run(opts, deps) {
|
|
|
329
390
|
usage.estimated = true;
|
|
330
391
|
telemetry.recordUsage(est.input, est.output);
|
|
331
392
|
emit?.({ kind: 'usage', input: usage.input, output: usage.output, estimated: true });
|
|
393
|
+
checkBudgetWarnings();
|
|
332
394
|
}
|
|
333
395
|
}
|
|
334
396
|
else if (ev.kind === 'error') {
|
|
@@ -357,6 +419,24 @@ export async function run(opts, deps) {
|
|
|
357
419
|
catch { /* ignore — retry with the transcript as-is */ }
|
|
358
420
|
break;
|
|
359
421
|
}
|
|
422
|
+
// L15 failover: a terminal provider error swaps to the next chained
|
|
423
|
+
// adapter and re-issues the step (bounded by chain length). Context
|
|
424
|
+
// overflow is excluded — it owns its own recovery above. By the time
|
|
425
|
+
// an error reaches the runtime, per-adapter retries are exhausted,
|
|
426
|
+
// so any provider error here is terminal for the active adapter.
|
|
427
|
+
if (failoverQueue.length > 0) {
|
|
428
|
+
const next = failoverQueue.shift();
|
|
429
|
+
failoverPending = true;
|
|
430
|
+
failoverFrom = activeAdapter.id;
|
|
431
|
+
failoverTo = next.id;
|
|
432
|
+
failoverReason = `${ev.code}: ${ev.message}`.slice(0, 300);
|
|
433
|
+
activeAdapter = next;
|
|
434
|
+
telemetry.recordError(`failover: ${ev.code}`);
|
|
435
|
+
emit?.({ kind: 'provider_failover', from: failoverFrom, to: failoverTo, reason: failoverReason });
|
|
436
|
+
emit?.({ kind: 'status', message: `provider ${failoverFrom} failed (${ev.code}) — failing over to ${failoverTo}` });
|
|
437
|
+
emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: ev.code, message: `failing over ${failoverFrom} → ${failoverTo}: ${ev.message.slice(0, 200)}` });
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
360
440
|
telemetry.recordError(`stream_error: ${ev.code}`);
|
|
361
441
|
if (store && sessionId) {
|
|
362
442
|
try {
|
|
@@ -386,6 +466,19 @@ export async function run(opts, deps) {
|
|
|
386
466
|
emit?.({ kind: 'step_end', step: steps + 1 });
|
|
387
467
|
continue outer;
|
|
388
468
|
}
|
|
469
|
+
// Failover lands here via `break`: discard the failed attempt's partial
|
|
470
|
+
// output and re-issue the same step against the next adapter, again
|
|
471
|
+
// without consuming the step budget.
|
|
472
|
+
if (failoverPending) {
|
|
473
|
+
failoverPending = false;
|
|
474
|
+
textBuf = '';
|
|
475
|
+
thinkingBuf = '';
|
|
476
|
+
pendingToolCalls.clear();
|
|
477
|
+
lastFinishReason = undefined;
|
|
478
|
+
steps--;
|
|
479
|
+
emit?.({ kind: 'step_end', step: steps + 1 });
|
|
480
|
+
continue outer;
|
|
481
|
+
}
|
|
389
482
|
// Build the assistant message. Tool calls are finalized here: JSON is
|
|
390
483
|
// parsed and schema-validated BEFORE policy/execution. Malformed calls
|
|
391
484
|
// become structured MALFORMED_TOOL_CALL results — garbage arguments must
|
|
@@ -478,7 +571,8 @@ export async function run(opts, deps) {
|
|
|
478
571
|
emit?.({ kind: 'verification_started', command: advisoryCmd });
|
|
479
572
|
let advisoryResult;
|
|
480
573
|
try {
|
|
481
|
-
|
|
574
|
+
// CONTRACT (a): sessionId passthrough to the verify engine.
|
|
575
|
+
advisoryResult = await verify({ cwd: opts.cwd, command: advisoryCmd, timeoutMs: opts.verify?.timeoutMs, ...(sessionId ? { sessionId } : {}) });
|
|
482
576
|
}
|
|
483
577
|
catch (e) {
|
|
484
578
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -557,7 +651,7 @@ export async function run(opts, deps) {
|
|
|
557
651
|
vResult = { ok: sr.ok, exitCode: sr.exitCode, stdout: sr.stdout, stderr: sr.stderr, ...(det ? { failure: det } : {}) };
|
|
558
652
|
}
|
|
559
653
|
else {
|
|
560
|
-
vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs });
|
|
654
|
+
vResult = await verify({ cwd: opts.cwd, command: cmdToRun, timeoutMs: opts.verify?.timeoutMs, ...(sessionId ? { sessionId } : {}) });
|
|
561
655
|
}
|
|
562
656
|
}
|
|
563
657
|
catch (e) {
|
|
@@ -568,7 +662,7 @@ export async function run(opts, deps) {
|
|
|
568
662
|
// If scoped passed but full may still fail, run full before declaring success
|
|
569
663
|
if (vResult.ok && isScoped) {
|
|
570
664
|
try {
|
|
571
|
-
const full = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
|
|
665
|
+
const full = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs, ...(sessionId ? { sessionId } : {}) });
|
|
572
666
|
if (!full.ok)
|
|
573
667
|
vResult = full;
|
|
574
668
|
}
|
|
@@ -787,6 +881,32 @@ export async function run(opts, deps) {
|
|
|
787
881
|
const execTool = async (call) => {
|
|
788
882
|
const t0 = Date.now();
|
|
789
883
|
emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
|
|
884
|
+
// Hooks: every preToolUse hook runs before execution. A non-zero exit
|
|
885
|
+
// denies the tool with POLICY_DENIED — the real tool never runs.
|
|
886
|
+
if (preHooks.length > 0) {
|
|
887
|
+
for (const hook of preHooks) {
|
|
888
|
+
let exitCode = -1;
|
|
889
|
+
let detail = '';
|
|
890
|
+
try {
|
|
891
|
+
const r = await runHook(hook, { toolName: call.name, input: call.input });
|
|
892
|
+
exitCode = r.exitCode;
|
|
893
|
+
detail = (r.stderr || r.stdout || '').slice(0, 300);
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
detail = String(err instanceof Error ? err.message : err).slice(0, 300);
|
|
897
|
+
}
|
|
898
|
+
if (exitCode !== 0) {
|
|
899
|
+
const reason = `hook ${hook.name} denied: ${detail || 'hook failed'}`;
|
|
900
|
+
emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: 'deny', reason });
|
|
901
|
+
emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: 'deny', reason });
|
|
902
|
+
const latencyMs = Date.now() - t0;
|
|
903
|
+
return {
|
|
904
|
+
obs: { ok: false, error: { code: 'POLICY_DENIED', message: reason } },
|
|
905
|
+
latencyMs,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
790
910
|
let obs;
|
|
791
911
|
try {
|
|
792
912
|
obs = await deps.registry.execute(call.name, call.input, toolCtx);
|
|
@@ -874,6 +994,31 @@ export async function run(opts, deps) {
|
|
|
874
994
|
if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
|
|
875
995
|
await markStuck(`identical call ×3: ${sig}`);
|
|
876
996
|
}
|
|
997
|
+
// Hooks: postToolUse hooks are best-effort — failures warn on stderr
|
|
998
|
+
// plus a bus event, and never fail the turn.
|
|
999
|
+
if (postHooks.length > 0) {
|
|
1000
|
+
for (const hook of postHooks) {
|
|
1001
|
+
try {
|
|
1002
|
+
const r = await runHook(hook, { toolName: call.name, input: call.input });
|
|
1003
|
+
if (!r.ok || r.exitCode !== 0) {
|
|
1004
|
+
const msg = `klyro: hooks: postToolUse ${hook.name} failed (exit ${String(r.exitCode)}): ${(r.stderr || r.stdout || '').slice(0, 200)}\n`;
|
|
1005
|
+
try {
|
|
1006
|
+
process.stderr.write(msg);
|
|
1007
|
+
}
|
|
1008
|
+
catch { /* ignore */ }
|
|
1009
|
+
emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'hook_failed', message: msg.slice(0, 300) });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
catch (err) {
|
|
1013
|
+
const msg = `klyro: hooks: postToolUse ${hook.name} error: ${String(err instanceof Error ? err.message : err).slice(0, 200)}\n`;
|
|
1014
|
+
try {
|
|
1015
|
+
process.stderr.write(msg);
|
|
1016
|
+
}
|
|
1017
|
+
catch { /* ignore */ }
|
|
1018
|
+
emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'hook_failed', message: msg.slice(0, 300) });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
877
1022
|
};
|
|
878
1023
|
// Sequential path: gate → execute → commit per call, in order.
|
|
879
1024
|
const runOne = async (call) => {
|
|
@@ -897,8 +1042,17 @@ export async function run(opts, deps) {
|
|
|
897
1042
|
break;
|
|
898
1043
|
}
|
|
899
1044
|
if (approved.length > 0 && !opts.signal?.aborted) {
|
|
900
|
-
|
|
901
|
-
|
|
1045
|
+
// Fan-out cap: execute in sequential chunks of MAX_PARALLEL_TOOLS.
|
|
1046
|
+
// Commits below stay in original call order, so the transcript is
|
|
1047
|
+
// unaffected by the chunking.
|
|
1048
|
+
const settled = [];
|
|
1049
|
+
for (let off = 0; off < approved.length; off += MAX_PARALLEL_TOOLS) {
|
|
1050
|
+
if (opts.signal?.aborted)
|
|
1051
|
+
break;
|
|
1052
|
+
const chunk = approved.slice(off, off + MAX_PARALLEL_TOOLS);
|
|
1053
|
+
settled.push(...await Promise.allSettled(chunk.map((c) => execTool(c))));
|
|
1054
|
+
}
|
|
1055
|
+
for (let i = 0; i < settled.length; i++) {
|
|
902
1056
|
const s = settled[i];
|
|
903
1057
|
if (s.status === 'fulfilled') {
|
|
904
1058
|
await commitResult(approved[i], s.value.obs, s.value.latencyMs);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit scheduler for provider streams.
|
|
3
|
+
*
|
|
4
|
+
* A global in-flight counter + FIFO semaphore caps concurrent
|
|
5
|
+
* `ProviderAdapter.stream` calls at `MAX_CONCURRENT_STREAMS` (4). An
|
|
6
|
+
* adaptive cooldown collapses the cap to 1 while a recent 429 was
|
|
7
|
+
* observed (`noteRateLimited`), honouring an optional server-provided
|
|
8
|
+
* `Retry-After` delay, otherwise 60s.
|
|
9
|
+
*
|
|
10
|
+
* Consumed by `retryingAdapter` (see `./retry.js`), which acquires one
|
|
11
|
+
* slot around each `inner.stream` call and releases it in a `finally`.
|
|
12
|
+
* Aborted waiters are dequeued and rejected promptly.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_CONCURRENT_STREAMS = 4;
|
|
15
|
+
/** Fallback cooldown when no Retry-After delay was provided. */
|
|
16
|
+
export declare const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
17
|
+
/**
|
|
18
|
+
* Record a 429 (or equivalent) rate-limit signal. Collapses the stream
|
|
19
|
+
* cap to 1 for `retryAfterMs` (or 60s when absent/invalid).
|
|
20
|
+
*/
|
|
21
|
+
export declare function noteRateLimited(retryAfterMs?: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Acquire a stream slot. Grants immediately when `inFlight` is under the
|
|
24
|
+
* effective cap, otherwise queues FIFO until a release (or the cooldown
|
|
25
|
+
* lifting) frees one. Abort-aware: an already-aborted signal rejects
|
|
26
|
+
* immediately; aborting while queued dequeues and rejects promptly.
|
|
27
|
+
*/
|
|
28
|
+
export declare function acquireStreamSlot(signal?: AbortSignal): Promise<() => void>;
|
|
29
|
+
/** Observable budget state (tests/diagnostics). */
|
|
30
|
+
export declare function streamBudgetState(): {
|
|
31
|
+
inFlight: number;
|
|
32
|
+
queued: number;
|
|
33
|
+
cap: number;
|
|
34
|
+
};
|
|
35
|
+
/** Reset the budget (counters, cooldown, queued waiters). Tests only. */
|
|
36
|
+
export declare function setStreamBudgetForTests(): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit scheduler for provider streams.
|
|
3
|
+
*
|
|
4
|
+
* A global in-flight counter + FIFO semaphore caps concurrent
|
|
5
|
+
* `ProviderAdapter.stream` calls at `MAX_CONCURRENT_STREAMS` (4). An
|
|
6
|
+
* adaptive cooldown collapses the cap to 1 while a recent 429 was
|
|
7
|
+
* observed (`noteRateLimited`), honouring an optional server-provided
|
|
8
|
+
* `Retry-After` delay, otherwise 60s.
|
|
9
|
+
*
|
|
10
|
+
* Consumed by `retryingAdapter` (see `./retry.js`), which acquires one
|
|
11
|
+
* slot around each `inner.stream` call and releases it in a `finally`.
|
|
12
|
+
* Aborted waiters are dequeued and rejected promptly.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_CONCURRENT_STREAMS = 4;
|
|
15
|
+
/** Fallback cooldown when no Retry-After delay was provided. */
|
|
16
|
+
export const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
17
|
+
let inFlight = 0;
|
|
18
|
+
/** `Date.now()` timestamp until which the cap stays collapsed at 1. */
|
|
19
|
+
let recent429Until = 0;
|
|
20
|
+
const queue = [];
|
|
21
|
+
function abortError() {
|
|
22
|
+
const err = new Error('stream slot acquisition aborted');
|
|
23
|
+
err.name = 'AbortError';
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
function effectiveCap(now = Date.now()) {
|
|
27
|
+
return now < recent429Until ? 1 : MAX_CONCURRENT_STREAMS;
|
|
28
|
+
}
|
|
29
|
+
function makeRelease() {
|
|
30
|
+
let released = false;
|
|
31
|
+
return () => {
|
|
32
|
+
if (released)
|
|
33
|
+
return;
|
|
34
|
+
released = true;
|
|
35
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
36
|
+
pump();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Grant queued waiters while a slot is free under the current cap. */
|
|
40
|
+
function pump() {
|
|
41
|
+
while (queue.length > 0 && inFlight < effectiveCap()) {
|
|
42
|
+
const waiter = queue.shift();
|
|
43
|
+
if (!waiter)
|
|
44
|
+
break;
|
|
45
|
+
if (waiter.signal?.aborted) {
|
|
46
|
+
waiter.reject(abortError());
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (waiter.signal && waiter.onAbort) {
|
|
50
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
51
|
+
}
|
|
52
|
+
inFlight += 1;
|
|
53
|
+
waiter.resolve(makeRelease());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Record a 429 (or equivalent) rate-limit signal. Collapses the stream
|
|
58
|
+
* cap to 1 for `retryAfterMs` (or 60s when absent/invalid).
|
|
59
|
+
*/
|
|
60
|
+
export function noteRateLimited(retryAfterMs) {
|
|
61
|
+
const cooldown = typeof retryAfterMs === 'number' &&
|
|
62
|
+
Number.isFinite(retryAfterMs) &&
|
|
63
|
+
retryAfterMs >= 0
|
|
64
|
+
? retryAfterMs
|
|
65
|
+
: DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
66
|
+
recent429Until = Date.now() + cooldown;
|
|
67
|
+
// Wake queued waiters once the cooldown lifts even if no release
|
|
68
|
+
// happens in between (holders may outlive the cooldown). Unref'd so
|
|
69
|
+
// tests and short-lived processes never hang on this timer.
|
|
70
|
+
if (cooldown > 0 && cooldown < 3_600_000) {
|
|
71
|
+
const timer = setTimeout(pump, cooldown);
|
|
72
|
+
timer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Acquire a stream slot. Grants immediately when `inFlight` is under the
|
|
77
|
+
* effective cap, otherwise queues FIFO until a release (or the cooldown
|
|
78
|
+
* lifting) frees one. Abort-aware: an already-aborted signal rejects
|
|
79
|
+
* immediately; aborting while queued dequeues and rejects promptly.
|
|
80
|
+
*/
|
|
81
|
+
export function acquireStreamSlot(signal) {
|
|
82
|
+
if (signal?.aborted)
|
|
83
|
+
return Promise.reject(abortError());
|
|
84
|
+
if (inFlight < effectiveCap()) {
|
|
85
|
+
inFlight += 1;
|
|
86
|
+
return Promise.resolve(makeRelease());
|
|
87
|
+
}
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const waiter = { resolve, reject };
|
|
90
|
+
if (signal) {
|
|
91
|
+
waiter.signal = signal;
|
|
92
|
+
waiter.onAbort = () => {
|
|
93
|
+
const idx = queue.indexOf(waiter);
|
|
94
|
+
if (idx >= 0)
|
|
95
|
+
queue.splice(idx, 1);
|
|
96
|
+
reject(abortError());
|
|
97
|
+
};
|
|
98
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true });
|
|
99
|
+
}
|
|
100
|
+
queue.push(waiter);
|
|
101
|
+
// Re-check: the cap may have widened (cooldown expiry) between the
|
|
102
|
+
// fast-path check and the push.
|
|
103
|
+
pump();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Observable budget state (tests/diagnostics). */
|
|
107
|
+
export function streamBudgetState() {
|
|
108
|
+
return { inFlight, queued: queue.length, cap: effectiveCap() };
|
|
109
|
+
}
|
|
110
|
+
/** Reset the budget (counters, cooldown, queued waiters). Tests only. */
|
|
111
|
+
export function setStreamBudgetForTests() {
|
|
112
|
+
inFlight = 0;
|
|
113
|
+
recent429Until = 0;
|
|
114
|
+
const pending = queue.splice(0, queue.length);
|
|
115
|
+
for (const waiter of pending) {
|
|
116
|
+
if (waiter.signal && waiter.onAbort) {
|
|
117
|
+
waiter.signal.removeEventListener('abort', waiter.onAbort);
|
|
118
|
+
}
|
|
119
|
+
waiter.reject(new Error('stream budget reset'));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
|
|
3
|
+
*
|
|
4
|
+
* Snapshot bytes stay RAW (no redaction): checkpoint copies must be
|
|
5
|
+
* bit-identical to the working tree so undo() restores exact fidelity —
|
|
6
|
+
* redacting at snapshot time would corrupt restores (a redacted snapshot
|
|
7
|
+
* written back would permanently replace real code with [REDACTED]).
|
|
8
|
+
* Same-trust-domain rationale: snapshots never leave the project dir and
|
|
9
|
+
* are only read back by undo() into the same tree, so secret hygiene is
|
|
10
|
+
* enforced at the trace/persist boundaries (TraceWriter, SessionStore)
|
|
11
|
+
* instead of here.
|
|
3
12
|
*/
|
|
4
13
|
export declare function snapshot(cwd: string, files: string[]): Promise<string>;
|
|
5
14
|
export declare function listCheckpoints(cwd: string): Promise<string[]>;
|
|
@@ -1,12 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
|
|
3
|
+
*
|
|
4
|
+
* Snapshot bytes stay RAW (no redaction): checkpoint copies must be
|
|
5
|
+
* bit-identical to the working tree so undo() restores exact fidelity —
|
|
6
|
+
* redacting at snapshot time would corrupt restores (a redacted snapshot
|
|
7
|
+
* written back would permanently replace real code with [REDACTED]).
|
|
8
|
+
* Same-trust-domain rationale: snapshots never leave the project dir and
|
|
9
|
+
* are only read back by undo() into the same tree, so secret hygiene is
|
|
10
|
+
* enforced at the trace/persist boundaries (TraceWriter, SessionStore)
|
|
11
|
+
* instead of here.
|
|
3
12
|
*/
|
|
4
13
|
import * as fs from 'node:fs/promises';
|
|
14
|
+
import * as fsSync from 'node:fs';
|
|
5
15
|
import * as path from 'node:path';
|
|
6
16
|
import * as crypto from 'node:crypto';
|
|
7
17
|
function ckptDir(cwd) {
|
|
8
18
|
return path.join(cwd, '.klyro', 'checkpoints');
|
|
9
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Best-effort permission lockdown (0600 files / 0700 dirs).
|
|
22
|
+
* Windows ACLs ignore POSIX mode bits — no-op by design.
|
|
23
|
+
*/
|
|
24
|
+
function lockDown(p, mode) {
|
|
25
|
+
if (process.platform === 'win32')
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
fsSync.chmodSync(p, mode);
|
|
29
|
+
}
|
|
30
|
+
catch { /* best-effort only */ }
|
|
31
|
+
}
|
|
10
32
|
/** Best-effort fsync of a just-written file (crash safety). */
|
|
11
33
|
async function fsyncFile(p) {
|
|
12
34
|
try {
|
|
@@ -35,9 +57,11 @@ function containedPath(cwd, base, rel) {
|
|
|
35
57
|
export async function snapshot(cwd, files) {
|
|
36
58
|
const dir = ckptDir(cwd);
|
|
37
59
|
await fs.mkdir(dir, { recursive: true });
|
|
60
|
+
lockDown(dir, 0o700);
|
|
38
61
|
const id = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
39
62
|
const dest = path.join(dir, id);
|
|
40
63
|
await fs.mkdir(dest, { recursive: true });
|
|
64
|
+
lockDown(dest, 0o700);
|
|
41
65
|
const missing = [];
|
|
42
66
|
const kept = [];
|
|
43
67
|
for (const f of files) {
|
|
@@ -52,6 +76,7 @@ export async function snapshot(cwd, files) {
|
|
|
52
76
|
continue;
|
|
53
77
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
54
78
|
await fs.writeFile(out, data);
|
|
79
|
+
lockDown(out, 0o600);
|
|
55
80
|
await fsyncFile(out);
|
|
56
81
|
kept.push(rel);
|
|
57
82
|
}
|
|
@@ -65,6 +90,7 @@ export async function snapshot(cwd, files) {
|
|
|
65
90
|
// SessionStore.writeIndex atomic pattern).
|
|
66
91
|
const metaPath = path.join(dest, '.meta.json');
|
|
67
92
|
await fs.writeFile(metaPath, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
|
|
93
|
+
lockDown(metaPath, 0o600);
|
|
68
94
|
await fsyncFile(metaPath);
|
|
69
95
|
// Best-effort last.diff for the repair guard (guardRepair reads it).
|
|
70
96
|
try {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `klyro commit` — conventional commit of already-staged changes.
|
|
3
|
+
*
|
|
4
|
+
* Steps:
|
|
5
|
+
* (a) `git status --porcelain` must show staged entries (index column set).
|
|
6
|
+
* Nothing staged → error 'nothing staged (git add first)', exit 2.
|
|
7
|
+
* (`--yes` never bypasses this — there is nothing to commit.)
|
|
8
|
+
* (b) Secret-scan the staged diff via `redact()`: if redaction shrinks or
|
|
9
|
+
* alters the diff, refuse and list the files (exit 2) unless
|
|
10
|
+
* `--force-secret` is passed.
|
|
11
|
+
* (c) Build a conventional message: type heuristic + top-dir scope +
|
|
12
|
+
* `--message` summary (or `update <n> files`).
|
|
13
|
+
* (d) `git commit -m` via execFileSync (no shell). Verification hooks always
|
|
14
|
+
* run — by construction this file contains no flag that skips them.
|
|
15
|
+
* (e) `--dry-run` prints the message + files and exits 0 without committing.
|
|
16
|
+
*/
|
|
17
|
+
export interface CommitOptions {
|
|
18
|
+
cwd: string;
|
|
19
|
+
yes?: boolean;
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
message?: string;
|
|
22
|
+
forceSecret?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Porcelain XY: staged iff the index (first) column is set. Handles renames. */
|
|
25
|
+
export declare function stagedFilesFromPorcelain(porcelain: string): string[];
|
|
26
|
+
/** Type heuristic: test-only→test, docs-only→docs, lockfiles→chore, else feat. */
|
|
27
|
+
export declare function commitTypeFor(files: string[]): string;
|
|
28
|
+
/** Scope = most common top-level dir among staged files with a dir; '' if none. */
|
|
29
|
+
export declare function commitScopeFor(files: string[]): string;
|
|
30
|
+
export declare function buildCommitMessage(files: string[], summaryOpt?: string): string;
|
|
31
|
+
export declare function runCommit(opts: CommitOptions): Promise<number>;
|