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.
Files changed (63) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +13 -5
  2. package/dist/agent/anthropic-adapter.js +19 -2
  3. package/dist/agent/capabilities.js +7 -1
  4. package/dist/agent/orchestrator.d.ts +44 -4
  5. package/dist/agent/orchestrator.js +102 -8
  6. package/dist/agent/retry.js +52 -10
  7. package/dist/agent/runtime.d.ts +34 -0
  8. package/dist/agent/runtime.js +169 -15
  9. package/dist/agent/stream-budget.d.ts +36 -0
  10. package/dist/agent/stream-budget.js +121 -0
  11. package/dist/checkpoints/store.d.ts +9 -0
  12. package/dist/checkpoints/store.js +26 -0
  13. package/dist/cli/commit.d.ts +31 -0
  14. package/dist/cli/commit.js +142 -0
  15. package/dist/cli/config.d.ts +45 -0
  16. package/dist/cli/config.js +82 -0
  17. package/dist/cli/doctor.d.ts +1 -0
  18. package/dist/cli/doctor.js +71 -6
  19. package/dist/cli/hooks.d.ts +47 -0
  20. package/dist/cli/hooks.js +181 -0
  21. package/dist/cli/markdown.js +29 -1
  22. package/dist/cli/repl.js +41 -1
  23. package/dist/cli/run.d.ts +6 -0
  24. package/dist/cli/run.js +76 -3
  25. package/dist/context/tokenizer.d.ts +18 -0
  26. package/dist/context/tokenizer.js +50 -1
  27. package/dist/events/bus.d.ts +5 -0
  28. package/dist/events/bus.js +10 -0
  29. package/dist/events/catalog.d.ts +9 -0
  30. package/dist/events/catalog.js +9 -0
  31. package/dist/index.js +89 -5
  32. package/dist/mcp/client.js +1 -1
  33. package/dist/mcp/registry.d.ts +0 -18
  34. package/dist/mcp/registry.js +49 -2
  35. package/dist/policy/engine.d.ts +16 -0
  36. package/dist/policy/engine.js +74 -1
  37. package/dist/policy/path-guard.d.ts +24 -0
  38. package/dist/policy/path-guard.js +46 -0
  39. package/dist/providers/model-info.d.ts +6 -0
  40. package/dist/providers/model-info.js +8 -0
  41. package/dist/tools/fs/apply-patch.js +6 -1
  42. package/dist/tools/fs/edit-file.js +4 -1
  43. package/dist/tools/fs/multi-edit.js +4 -1
  44. package/dist/tools/fs/write-file.js +16 -6
  45. package/dist/tools/plan/todo-write.js +1 -1
  46. package/dist/tools/shell/sandbox.d.ts +4 -3
  47. package/dist/tools/shell/sandbox.js +23 -3
  48. package/dist/tools/shell/shell-exec.d.ts +28 -0
  49. package/dist/tools/shell/shell-exec.js +87 -1
  50. package/dist/trace/writer.d.ts +7 -0
  51. package/dist/trace/writer.js +7 -0
  52. package/dist/tui/app.js +20 -1
  53. package/dist/tui/app.test.js +18 -12
  54. package/dist/tui/approval.test.js +20 -3
  55. package/dist/tui/markdown.d.ts +13 -0
  56. package/dist/tui/markdown.js +169 -2
  57. package/dist/tui/scroll-flow.test.js +3 -1
  58. package/dist/verification/classify.js +4 -3
  59. package/dist/verification/engine.d.ts +8 -0
  60. package/dist/verification/engine.js +25 -0
  61. package/dist/verification/registry.js +16 -5
  62. package/dist/verification/scoped.js +36 -5
  63. package/package.json +1 -1
@@ -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
- // BUG-005: Model-aware cost estimation, single-sourced from the
46
+ // Model-aware cost estimation, single-sourced from the
46
47
  // providers/model-info.ts rate table (local/unknown models are $0).
47
- // Cost is computed on input/output ONLY: cacheRead/cacheWrite are tracked
48
- // for observability but excluded because cached tokens bill at
49
- // provider-specific discounted rates we don't model — charging them at
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
- return (usage.input / 1000) * inRate + (usage.output / 1000) * outRate;
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 = deps.adapter.stream(req);
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
- advisoryResult = await verify({ cwd: opts.cwd, command: advisoryCmd, timeoutMs: opts.verify?.timeoutMs });
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
- const settled = await Promise.allSettled(approved.map((c) => execTool(c)));
901
- for (let i = 0; i < approved.length; i++) {
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>;