klyro 0.1.40 → 0.1.42

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.
@@ -191,6 +191,11 @@ export interface RunResult {
191
191
  }
192
192
  /** Convert a registry of tools into ToolDefinitions for the provider. */
193
193
  export declare function toolDefinitions(registry: ToolRegistry): ToolDefinition[];
194
+ /** Estimate USD cost of a usage block given the model name. */
195
+ export declare function estimateCost(model: string, usage: {
196
+ input: number;
197
+ output: number;
198
+ }): number;
194
199
  /** Run the autonomous loop. */
195
200
  export declare function run(opts: RunOptions, deps: RuntimeDeps): Promise<RunResult>;
196
201
  export declare function defaultSystemPrompt(ctx: {
@@ -35,6 +35,36 @@ export function toolDefinitions(registry) {
35
35
  inputSchema: t.function.parameters,
36
36
  }));
37
37
  }
38
+ // BUG-005: Model-aware cost estimation with sensible defaults.
39
+ // Rates are per-1K tokens (input / output). Local models are $0.
40
+ const MODEL_RATES = [
41
+ { test: (m) => /gpt-4/i.test(m), input: 0.003, output: 0.015 },
42
+ { test: (m) => /gpt-3\.5/i.test(m), input: 0.0005, output: 0.0015 },
43
+ { test: (m) => /claude|anthropic/i.test(m), input: 0.003, output: 0.015 },
44
+ { test: (m) => /gemini/i.test(m), input: 0.00075, output: 0.003 },
45
+ { test: (m) => /o1/i.test(m), input: 0.015, output: 0.06 },
46
+ ];
47
+ /** Estimate USD cost of a usage block given the model name. */
48
+ export function estimateCost(model, usage) {
49
+ const match = MODEL_RATES.find((r) => r.test(model));
50
+ const { input: inRate, output: outRate } = match ?? { input: 0.003, output: 0.015 };
51
+ return (usage.input / 1000) * inRate + (usage.output / 1000) * outRate;
52
+ }
53
+ // PERF-002: Memoized token counting cache.
54
+ let tokenCache = {
55
+ lastRef: null,
56
+ lastSystem: undefined,
57
+ lastCount: 0,
58
+ };
59
+ /** Memoized totalTokens � only recomputes when transcript ref or system changes. */
60
+ function cachedTotalTokens(system, messages) {
61
+ if (tokenCache.lastRef === messages && tokenCache.lastSystem === system) {
62
+ return tokenCache.lastCount;
63
+ }
64
+ const count = totalTokens(system, messages);
65
+ tokenCache = { lastRef: messages, lastSystem: system, lastCount: count };
66
+ return count;
67
+ }
38
68
  /** Run the autonomous loop. */
39
69
  export async function run(opts, deps) {
40
70
  const maxSteps = opts.maxTurns ?? opts.maxSteps ?? DEFAULT_MAX_STEPS;
@@ -143,12 +173,10 @@ export async function run(opts, deps) {
143
173
  // 5.2 — stuck detection state
144
174
  const callHistory = [];
145
175
  const fileEditCounts = new Map();
146
- let stuckCount = 0;
147
- let lastSignal;
148
176
  outer: while (steps < maxSteps) {
149
177
  // 5.1 limits: max-cost, max-time
150
178
  if (maxCost !== undefined) {
151
- const cost = (usage.input / 1000) * 0.003 + (usage.output / 1000) * 0.015;
179
+ const cost = estimateCost(opts.model, usage);
152
180
  if (cost >= maxCost) {
153
181
  setPhase('limit');
154
182
  await closeTracer();
@@ -189,10 +217,11 @@ export async function run(opts, deps) {
189
217
  const BUDGET = { total: 120_000, reservedOutput: 4000 };
190
218
  let reqMessages = transcript;
191
219
  let reqSystem = systemPrompt;
192
- if (totalTokens(systemPrompt, transcript) > BUDGET.total) {
220
+ if (cachedTotalTokens(systemPrompt, transcript) > BUDGET.total) {
193
221
  const c = compressTranscript(systemPrompt, transcript, BUDGET);
194
222
  reqSystem = c.system;
195
223
  reqMessages = c.messages;
224
+ tokenCache = { lastRef: null, lastSystem: undefined, lastCount: 0 };
196
225
  if (c.dropped > 0)
197
226
  emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
198
227
  }
@@ -279,6 +308,8 @@ export async function run(opts, deps) {
279
308
  const assistantMsg = { role: 'assistant', content: assistantContent };
280
309
  transcript.push(assistantMsg);
281
310
  await checkpoint(assistantMsg);
311
+ // Invalidate token cache since transcript changed
312
+ tokenCache = { lastRef: null, lastSystem: undefined, lastCount: 0 };
282
313
  // No tool calls → potential completion (Level 8 verify gate)
283
314
  if (opts.signal?.aborted) {
284
315
  finalText = textBuf;
@@ -363,7 +394,7 @@ export async function run(opts, deps) {
363
394
  }
364
395
  // 6.4 — classify
365
396
  const baseline = await getBaseline(opts.cwd, verifyCmd);
366
- const isFlaky = await rerunOnce(opts.cwd, cmdToRun, 30_000);
397
+ const isFlaky = await rerunOnce(opts.cwd, cmdToRun, opts.verify?.timeoutMs ?? 45_000);
367
398
  const cls = classifyFailure({ failure: vResult.failure, stdout: vResult.stdout, stderr: vResult.stderr }, baseline, isFlaky);
368
399
  if (cls === 'flaky') {
369
400
  // rerun succeeded on second try — treat as flaky, don't count as repair
@@ -596,16 +627,15 @@ export async function run(opts, deps) {
596
627
  }
597
628
  };
598
629
  // 3.5 — parallel if all concurrencySafe, sequential otherwise
599
- // For parallel, execute concurrently but commit transcript in original call order to preserve determinism
630
+ // BUG-002: preserve call order run sequentially even when allSafe to avoid out-of-order transcript
631
+ // Parallel execution previously pushed tool_results out of order via Promise.all
600
632
  if (allSafe) {
601
- toolCallCount += finalizedCalls.length;
602
- // runOne internally pushes to transcript — we need ordered commits, so we serialize the push phase
603
- // Collect via a temporary queue: run all, but gather transcript deltas and replay in order
604
- const pending = [];
605
- // Wrap runOne to capture its pushes without interleaving: we monkey-patch transcript push via staging
606
- // Simpler: just run sequentially when deterministic order matters — parallel benefit is limited for <4 tools
607
- // So we run Promise.all for execution but checkpoint writes are already serialized via store mutex
608
- await Promise.all(finalizedCalls.map((c) => runOne(c)));
633
+ for (const call of finalizedCalls) {
634
+ toolCallCount++;
635
+ await runOne(call);
636
+ if (opts.signal?.aborted)
637
+ break;
638
+ }
609
639
  }
610
640
  else {
611
641
  for (const call of finalizedCalls) {
@@ -6,7 +6,8 @@ import * as path from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { defineTool } from '../types.js';
8
8
  import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
9
- import { safe } from '../normalize.js';
9
+ import { safe, TOOL_ERROR_CODES } from '../normalize.js';
10
+ import { wasRead } from './read-history.js';
10
11
  const InputSchema = z.object({
11
12
  patch: z.string().min(1).describe('Unified diff patch text'),
12
13
  });
@@ -29,6 +30,18 @@ export const applyPatchTool = defineTool({
29
30
  // Flush previous
30
31
  if (currentFile && fileContent !== null) {
31
32
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
33
+ if (fileContent === null)
34
+ throw Object.assign(new Error('fileContent is null — cannot write'), { code: TOOL_ERROR_CODES.INTERNAL });
35
+ try {
36
+ const existing = await fs.readFile(resolved, 'utf-8');
37
+ if (!wasRead(currentFile) && existing.length > 200) {
38
+ throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
39
+ }
40
+ }
41
+ catch (e) {
42
+ if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
43
+ throw e;
44
+ }
32
45
  await fs.mkdir(path.dirname(resolved), { recursive: true });
33
46
  await fs.writeFile(resolved, fileContent, 'utf-8');
34
47
  patchedFiles.push(currentFile);
@@ -48,6 +61,18 @@ export const applyPatchTool = defineTool({
48
61
  if (line.startsWith('*** Add File:')) {
49
62
  if (currentFile && fileContent !== null) {
50
63
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
64
+ if (fileContent === null)
65
+ throw Object.assign(new Error('fileContent is null — cannot write'), { code: TOOL_ERROR_CODES.INTERNAL });
66
+ try {
67
+ const existing = await fs.readFile(resolved, 'utf-8');
68
+ if (!wasRead(currentFile) && existing.length > 200) {
69
+ throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
70
+ }
71
+ }
72
+ catch (e) {
73
+ if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
74
+ throw e;
75
+ }
51
76
  await fs.mkdir(path.dirname(resolved), { recursive: true });
52
77
  await fs.writeFile(resolved, fileContent, 'utf-8');
53
78
  patchedFiles.push(currentFile);
@@ -67,6 +92,18 @@ export const applyPatchTool = defineTool({
67
92
  }
68
93
  if (currentFile && fileContent !== null) {
69
94
  const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
95
+ if (fileContent === null)
96
+ throw Object.assign(new Error('fileContent is null — cannot write'), { code: TOOL_ERROR_CODES.INTERNAL });
97
+ try {
98
+ const existing = await fs.readFile(resolved, 'utf-8');
99
+ if (!wasRead(currentFile) && existing.length > 200) {
100
+ throw Object.assign(new Error('File was not read this session and is >200 bytes — re-read before writing (POLICY_DENIED)'), { code: 'POLICY_DENIED' });
101
+ }
102
+ }
103
+ catch (e) {
104
+ if (e && typeof e === 'object' && e.code === 'POLICY_DENIED')
105
+ throw e;
106
+ }
70
107
  await fs.mkdir(path.dirname(resolved), { recursive: true });
71
108
  await fs.writeFile(resolved, fileContent, 'utf-8');
72
109
  patchedFiles.push(currentFile);
@@ -48,10 +48,21 @@ function filteredEnv(extra) {
48
48
  out[k] = v;
49
49
  }
50
50
  }
51
- // Always allow basic
51
+ // Merge user extra AFTER filtering — but only allowed prefixes, block injection vectors
52
+ if (extra) {
53
+ for (const [k, v] of Object.entries(extra)) {
54
+ if (k === 'NODE_OPTIONS' || k === 'LD_PRELOAD' || k === 'LD_LIBRARY_PATH')
55
+ continue;
56
+ if (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
57
+ continue;
58
+ if (ALLOWED_ENV_PREFIXES.some((p) => k.startsWith(p)) || k === 'PATH' || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP') {
59
+ out[k] = v;
60
+ }
61
+ }
62
+ }
52
63
  out.PATH = process.env.PATH;
53
- if (extra)
54
- Object.assign(out, extra);
64
+ if (out.NODE_OPTIONS)
65
+ delete out.NODE_OPTIONS;
55
66
  return out;
56
67
  }
57
68
  // Interactive command detection (3.3)
package/dist/tui/app.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Klyro TUI — opencode-clean — no clumsy words, correct wrap, markdown, scroll
3
- * Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
2
+ * Klyro TUI - opencode-clean - no clumsy words, correct wrap, markdown, scroll
3
+ * Header 3 rows, guide at col2, Klyro accent, prose wrapped at word boundaries
4
4
  */
5
5
  import React from 'react';
6
6
  import type { StatusSnapshot } from './status.js';
package/dist/tui/app.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
- * Klyro TUI — opencode-clean — no clumsy words, correct wrap, markdown, scroll
4
- * Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
3
+ * Klyro TUI - opencode-clean - no clumsy words, correct wrap, markdown, scroll
4
+ * Header 3 rows, guide at col2, Klyro accent, prose wrapped at word boundaries
5
5
  */
6
- import { useState, useEffect, useRef, useCallback } from 'react';
6
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
7
7
  import { Box, Text, useInput, useStdout } from 'ink';
8
8
  import { TuiApprovalBridge } from './approval.js';
9
9
  import { parse as parseSlash } from '../cli/slash/parser.js';
@@ -19,7 +19,7 @@ function Header({ cwd, model, version, width }) {
19
19
  return '';
20
20
  } })();
21
21
  const showLinks = width >= 120;
22
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u00E2\u201D\u201A /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, "[200k] \u00C2\u00B7 API Usage Billing"] }), _jsxs(Text, { color: tokens.colors.dim, children: [cwd, branch ? ` · ${branch}` : ''] })] }));
22
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, "[200k] \u00B7 API Usage Billing"] }), _jsxs(Text, { color: tokens.colors.dim, children: [cwd, branch ? ` · ${branch}` : ''] })] }));
23
23
  }
24
24
  function verbForTool(name) {
25
25
  if (name === 'read_file')
@@ -71,7 +71,7 @@ function groupTools(items) {
71
71
  flush();
72
72
  return out;
73
73
  }
74
- // Simple markdown: **bold** → bold, keep lists/tables, wrap at word boundaries
74
+ // Simple markdown: **bold** †’ bold, keep lists/tables, wrap at word boundaries
75
75
  function MarkdownText({ text, dim, width }) {
76
76
  // Split by **bold** segments
77
77
  const parts = [];
@@ -89,9 +89,82 @@ function MarkdownText({ text, dim, width }) {
89
89
  parts.push(_jsx(Text, { color: dim ? tokens.colors.dim : undefined, wrap: "wrap", children: text.slice(last) }, `t-${idx++}`));
90
90
  if (parts.length === 0)
91
91
  return _jsx(Text, { color: dim ? tokens.colors.dim : undefined, wrap: "wrap", children: text });
92
- // Render as single line with bold segments — Ink will wrap the parent Box
92
+ // Render as single line with bold segments Ink will wrap the parent Box
93
93
  return _jsx(Text, { wrap: "wrap", children: parts });
94
94
  }
95
+ // Chat scroll state: scrollOffset, pinned (user scrolled away from bottom),
96
+ // pendingNew (rows arrived while pinned), and a commands bag for key handlers.
97
+ // The `tick` prop is a monotonic value that increments on every content mutation,
98
+ // including in-place text growth during streaming (appendDelta mutates by index,
99
+ // so transcript.length does not change on a delta — the effect must fire anyway).
100
+ function useChatScroll(opts) {
101
+ const { totalRows, viewportH, messageBoundaries, tick } = opts;
102
+ const [scrollOffset, setScrollOffset] = useState(0);
103
+ const [pinned, setPinned] = useState(false);
104
+ const [pendingNew, setPendingNew] = useState(0);
105
+ const pinnedRef = useRef(false);
106
+ const maxOffset = Math.max(0, totalRows - viewportH);
107
+ // 1-line tolerance: maxOffset can shift by 1 during streaming and leave us
108
+ // at maxOffset - 1, which would otherwise be "not at bottom". The +1 tolerance
109
+ // keeps follow-tail engaged through that off-by-one.
110
+ const isAtBottom = scrollOffset + 1 >= maxOffset;
111
+ const recomputePinned = useCallback((next) => {
112
+ const atBottom = next + 1 >= maxOffset;
113
+ pinnedRef.current = !atBottom;
114
+ setPinned(!atBottom);
115
+ if (atBottom)
116
+ setPendingNew(0);
117
+ }, [maxOffset]);
118
+ // Watch `tick` — fires on every content change (add, remove, in-place delta).
119
+ const lastTickRef = useRef(tick);
120
+ const lastMaxOffsetRef = useRef(maxOffset);
121
+ const firstEffectRef = useRef(true);
122
+ useEffect(() => {
123
+ if (firstEffectRef.current) {
124
+ // Initial mount: if there's content, follow the tail (preserves the
125
+ // pre-refactor behavior where scrollOffset was 0 only on empty state).
126
+ firstEffectRef.current = false;
127
+ lastTickRef.current = tick;
128
+ lastMaxOffsetRef.current = maxOffset;
129
+ if (maxOffset > 0) {
130
+ setScrollOffset(maxOffset);
131
+ }
132
+ return;
133
+ }
134
+ if (tick === lastTickRef.current)
135
+ return;
136
+ lastTickRef.current = tick;
137
+ const grew = maxOffset - lastMaxOffsetRef.current;
138
+ lastMaxOffsetRef.current = maxOffset;
139
+ if (pinnedRef.current) {
140
+ if (grew > 0)
141
+ setPendingNew((p) => p + grew);
142
+ }
143
+ else {
144
+ // FollowTail: snap to the new bottom.
145
+ setScrollOffset(maxOffset);
146
+ }
147
+ }, [tick, maxOffset]);
148
+ const commands = {
149
+ lineUp: () => { const next = Math.max(0, scrollOffset - 1); setScrollOffset(next); recomputePinned(next); },
150
+ lineDown: () => { const next = Math.min(maxOffset, scrollOffset + 1); setScrollOffset(next); recomputePinned(next); },
151
+ pageUp: () => {
152
+ const prev = [...messageBoundaries].reverse().find((b) => b < scrollOffset);
153
+ const next = prev ?? Math.max(0, scrollOffset - viewportH);
154
+ setScrollOffset(next);
155
+ recomputePinned(next);
156
+ },
157
+ pageDown: () => {
158
+ const nxt = messageBoundaries.find((b) => b > scrollOffset);
159
+ const next = nxt ?? Math.min(maxOffset, scrollOffset + viewportH);
160
+ setScrollOffset(next);
161
+ recomputePinned(next);
162
+ },
163
+ jumpTop: () => { setScrollOffset(0); recomputePinned(0); },
164
+ jumpBottom: () => { setScrollOffset(maxOffset); recomputePinned(maxOffset); },
165
+ };
166
+ return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
167
+ }
95
168
  export function App(props) {
96
169
  const { stdout } = useStdout();
97
170
  const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
@@ -103,7 +176,6 @@ export function App(props) {
103
176
  const [elapsed, setElapsed] = useState(0);
104
177
  const [queuedInputs, setQueuedInputs] = useState([]);
105
178
  const [expandedGroups, setExpandedGroups] = useState(new Set());
106
- const [scrollOffset, setScrollOffset] = useState(0);
107
179
  const streamingIdRef = useRef(null);
108
180
  const width = stdout?.columns ?? 100;
109
181
  const height = stdout?.rows ?? 30;
@@ -111,8 +183,18 @@ export function App(props) {
111
183
  const grouped = groupTools(transcript);
112
184
  const viewportH = Math.max(5, height - 10);
113
185
  const totalRows = grouped.length + (plan.length > 0 ? 1 : 0) + 2;
114
- const maxOffset = Math.max(0, totalRows - viewportH);
115
- const isAtBottom = scrollOffset >= maxOffset;
186
+ const messageBoundaries = useMemo(() => grouped.map((_, i) => i), [grouped]);
187
+ // Monotonic tick: increments on every render, so the scroll hook fires
188
+ // for every content mutation — including in-place text deltas.
189
+ const tickRef = useRef(0);
190
+ useEffect(() => { tickRef.current += 1; });
191
+ const scroll = useChatScroll({
192
+ totalRows,
193
+ viewportH,
194
+ messageBoundaries,
195
+ tick: tickRef.current,
196
+ });
197
+ const { scrollOffset, isAtBottom, maxOffset, pinned, pendingNew, commands } = scroll;
116
198
  const trackH = viewportH;
117
199
  const thumbPos = maxOffset === 0 ? 0 : Math.round((scrollOffset / maxOffset) * (trackH - 1));
118
200
  const visibleGrouped = isFullscreen ? grouped.slice(scrollOffset, scrollOffset + viewportH) : grouped;
@@ -132,8 +214,6 @@ export function App(props) {
132
214
  }, [queuedInputs, status.status, awaitingApproval]);
133
215
  useEffect(() => { if (status.status !== 'running')
134
216
  return; const start = Date.now() - elapsed; const t = setInterval(() => setElapsed(Date.now() - start), 1000); return () => clearInterval(t); }, [status.status, elapsed]);
135
- useEffect(() => { if (isAtBottom)
136
- setScrollOffset(maxOffset); }, [transcript.length, plan.length, maxOffset, isAtBottom]);
137
217
  const append = useCallback((item) => { if (item.kind !== 'text' || item.role !== 'assistant')
138
218
  streamingIdRef.current = null; setTranscript((prev) => [...prev, item]); }, []);
139
219
  const appendDelta = useCallback((text) => {
@@ -160,28 +240,37 @@ export function App(props) {
160
240
  n.delete(id);
161
241
  else
162
242
  n.add(id); return n; });
163
- const scrollUp = (n = 3) => setScrollOffset((p) => Math.max(0, p - n));
164
- const scrollDown = (n = 3) => setScrollOffset((p) => Math.min(maxOffset, p + n));
165
243
  useInput((inputStr, key) => {
166
244
  if (key.escape && queuedInputs.length > 0) {
167
245
  setQueuedInputs((prev) => prev.slice(1));
168
246
  return;
169
247
  }
170
- if (key.pageUp || (key.ctrl && inputStr === 'u')) {
171
- scrollUp(5);
172
- return;
173
- }
174
- if (key.pageDown || (key.ctrl && inputStr === 'd')) {
175
- scrollDown(5);
176
- return;
177
- }
178
- if (key.upArrow && (key.shift || key.ctrl)) {
179
- scrollUp(1);
180
- return;
181
- }
182
- if (key.downArrow && (key.shift || key.ctrl)) {
183
- scrollDown(1);
184
- return;
248
+ // Scroll keys (work in any mode, including while running).
249
+ if (isFullscreen && maxOffset > 0) {
250
+ if (key.home) {
251
+ commands.jumpTop();
252
+ return;
253
+ }
254
+ if (key.end) {
255
+ commands.jumpBottom();
256
+ return;
257
+ }
258
+ if (key.pageUp || (key.ctrl && inputStr === 'u')) {
259
+ commands.pageUp();
260
+ return;
261
+ }
262
+ if (key.pageDown || (key.ctrl && inputStr === 'd')) {
263
+ commands.pageDown();
264
+ return;
265
+ }
266
+ if (key.upArrow && (key.shift || key.ctrl)) {
267
+ commands.lineUp();
268
+ return;
269
+ }
270
+ if (key.downArrow && (key.shift || key.ctrl)) {
271
+ commands.lineDown();
272
+ return;
273
+ }
185
274
  }
186
275
  if (awaitingApproval)
187
276
  return;
@@ -241,9 +330,9 @@ export function App(props) {
241
330
  const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
242
331
  const totalTokens = status.usageInput + status.usageOutput;
243
332
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
244
- const baseHints = status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
245
- const hints = maxOffset > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
246
- return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro\u00E2\u20AC\u00A6" })) : visibleGrouped.map((item) => {
333
+ const baseHints = status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · †‘†“ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
334
+ const hints = maxOffset > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
335
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." })) : visibleGrouped.map((item) => {
247
336
  if (item.verb) {
248
337
  const gr = item;
249
338
  const isExpanded = expandedGroups.has(gr.id);
@@ -276,8 +365,8 @@ export function App(props) {
276
365
  return `Edited ${gr.items.length} files`;
277
366
  return `${gr.verb} ${gr.items.length} items`;
278
367
  })();
279
- const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? '✗' : `${gr.totalMs}ms`;
280
- const marker = isExpanded ? '▼' : '✓';
368
+ const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? 'œ—' : `${gr.totalMs}ms`;
369
+ const marker = isExpanded ? '–¼' : 'œ“';
281
370
  const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
282
371
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? gr.items.map((it) => {
283
372
  let friendly = '';
@@ -305,11 +394,11 @@ export function App(props) {
305
394
  return _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsx(Text, { wrap: "wrap", children: it.text })] }, it.id);
306
395
  }
307
396
  if (it.kind === 'text') {
308
- // prose — render markdown, not raw **, with proper wrap and guide
397
+ // prose render markdown, not raw **, with proper wrap and guide
309
398
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [g('agentBullet'), " Klyro"] })] }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.dim, children: [" ", g('guide'), " "] }), _jsx(Box, { flexGrow: 1, children: _jsx(MarkdownText, { text: it.text }) })] }) })] }, it.id));
310
399
  }
311
400
  if (it.kind === 'error')
312
- return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " \u00E2\u0153\u2014 ", it.message] }) }, it.id);
401
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " \u0153\u2014 ", it.message] }) }, it.id);
313
402
  if (it.kind === 'policy')
314
403
  return null;
315
404
  if (it.kind === 'file_changed')
@@ -317,5 +406,5 @@ export function App(props) {
317
406
  if (it.kind === 'diff')
318
407
  return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
319
408
  return null;
320
- }), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id)))] })) : null, queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro\u00E2\u20AC\u00A6" }), "\u00E2\u2013\u008F"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: tokens.colors.dim, children: [baseHints, maxOffset > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ●' : ''] })] })] }));
409
+ }), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id)))] })) : null, queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '' : '' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew, ' new ', pendingNew === 1 ? 'message' : 'messages', ' '] }) })) : null, _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: tokens.colors.dim, children: [baseHints, maxOffset > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ' : ''] })] })] }));
321
410
  }
@@ -79,4 +79,146 @@ describe('App', () => {
79
79
  const call = onSlash.mock.calls[0]?.[0];
80
80
  expect(call?.kind).toBe('quit');
81
81
  });
82
+ // --- Chat scroll behavior (TUI_DESIGN chat_scroll.md) -----------------
83
+ // Build a 25-item initial transcript. Each item has a unique tag so we can
84
+ // grep `lastFrame()` for it.
85
+ function makeInitialTranscript(n) {
86
+ const out = [];
87
+ for (let i = 0; i < n; i++) {
88
+ out.push({
89
+ id: `seed-${i}`,
90
+ kind: 'text',
91
+ text: `MSG-${i.toString().padStart(2, '0')}-tag`,
92
+ role: 'user',
93
+ });
94
+ }
95
+ return out;
96
+ }
97
+ // ANSI sequences Ink's parse-keypress recognizes.
98
+ const KEY_HOME = '\x1b[H';
99
+ const KEY_END = '\x1b[F';
100
+ const KEY_PGUP = '\x1b[5~';
101
+ const KEY_PGDN = '\x1b[6~';
102
+ const KEY_SHIFT_UP = '\x1b[1;2A';
103
+ const KEY_SHIFT_DOWN = '\x1b[1;2B';
104
+ it('starts at the bottom (follow-tail) when initial content fills the viewport', async () => {
105
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
106
+ await new Promise((r) => setTimeout(r, 50));
107
+ const frame = lastFrame() ?? '';
108
+ // The viewport is 20 rows; the last few seeded items (MSG-22..MSG-24) should
109
+ // be in the visible window. The first item (MSG-00) should NOT be visible.
110
+ expect(frame).toMatch(/MSG-24-tag/);
111
+ expect(frame).toMatch(/MSG-23-tag/);
112
+ expect(frame).not.toMatch(/MSG-00-tag/);
113
+ });
114
+ it('Home jumps to the top; End re-engages follow-tail', async () => {
115
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
116
+ await new Promise((r) => setTimeout(r, 50));
117
+ stdin.write(KEY_HOME);
118
+ await new Promise((r) => setTimeout(r, 30));
119
+ const top = lastFrame() ?? '';
120
+ expect(top).toMatch(/MSG-00-tag/);
121
+ expect(top).not.toMatch(/MSG-24-tag/);
122
+ // End re-engages follow-tail.
123
+ stdin.write(KEY_END);
124
+ await new Promise((r) => setTimeout(r, 30));
125
+ const bottom = lastFrame() ?? '';
126
+ expect(bottom).toMatch(/MSG-24-tag/);
127
+ expect(bottom).not.toMatch(/MSG-00-tag/);
128
+ });
129
+ it('PageUp/PageDown snap to message boundaries', async () => {
130
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
131
+ await new Promise((r) => setTimeout(r, 50));
132
+ // Go to top, then PageDown 3 times. Each PageDown should land on a message
133
+ // boundary, so visible window starts at one of the seeded indices.
134
+ stdin.write(KEY_HOME);
135
+ await new Promise((r) => setTimeout(r, 30));
136
+ stdin.write(KEY_PGDN);
137
+ await new Promise((r) => setTimeout(r, 30));
138
+ stdin.write(KEY_PGDN);
139
+ await new Promise((r) => setTimeout(r, 30));
140
+ stdin.write(KEY_PGDN);
141
+ await new Promise((r) => setTimeout(r, 30));
142
+ const frame = lastFrame() ?? '';
143
+ // After 3 PageDowns from top, the earliest visible item should be MSG-03
144
+ // (snap-to-message keeps the boundary on the first visible row). We assert
145
+ // that MSG-03 is visible and MSG-00 is not.
146
+ expect(frame).toMatch(/MSG-03-tag/);
147
+ expect(frame).not.toMatch(/MSG-00-tag/);
148
+ });
149
+ it('pins to top: new content does NOT auto-scroll when user has scrolled up', async () => {
150
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
151
+ await new Promise((r) => setTimeout(r, 100));
152
+ // Pin: scroll up to top.
153
+ stdin.write(KEY_HOME);
154
+ await new Promise((r) => setTimeout(r, 100));
155
+ const before = lastFrame() ?? '';
156
+ expect(before).toMatch(/MSG-00-tag/);
157
+ expect(before).not.toMatch(/MSG-24-tag/);
158
+ // New content arrives while pinned.
159
+ const g = globalThis;
160
+ g.__klyroAppAppend({
161
+ id: 'late-1',
162
+ kind: 'text',
163
+ text: 'LATE-1-tag',
164
+ role: 'assistant',
165
+ });
166
+ g.__klyroAppAppend({
167
+ id: 'late-2',
168
+ kind: 'text',
169
+ text: 'LATE-2-tag',
170
+ role: 'assistant',
171
+ });
172
+ g.__klyroAppAppend({
173
+ id: 'late-3',
174
+ kind: 'text',
175
+ text: 'LATE-3-tag',
176
+ role: 'assistant',
177
+ });
178
+ await new Promise((r) => setTimeout(r, 200));
179
+ const after = lastFrame() ?? '';
180
+ // Still pinned at top: MSG-00 visible, LATE items not in viewport.
181
+ expect(after).toMatch(/MSG-00-tag/);
182
+ expect(after).not.toMatch(/LATE-1-tag/);
183
+ });
184
+ it('pressing End re-engages follow-tail and reveals new content', async () => {
185
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
186
+ await new Promise((r) => setTimeout(r, 100));
187
+ stdin.write(KEY_HOME);
188
+ await new Promise((r) => setTimeout(r, 100));
189
+ const g = globalThis;
190
+ g.__klyroAppAppend({
191
+ id: 'late-1',
192
+ kind: 'text',
193
+ text: 'LATE-1-tag',
194
+ role: 'assistant',
195
+ });
196
+ await new Promise((r) => setTimeout(r, 200));
197
+ expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
198
+ // End re-engages follow-tail and shows the new content.
199
+ stdin.write(KEY_END);
200
+ await new Promise((r) => setTimeout(r, 100));
201
+ const frame = lastFrame() ?? '';
202
+ expect(frame).toMatch(/LATE-1-tag/);
203
+ });
204
+ it('Shift+Up / Shift+Down scroll by one line', async () => {
205
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
206
+ await new Promise((r) => setTimeout(r, 50));
207
+ // Jump to top, then shift+down a few times, then back with shift+up.
208
+ stdin.write(KEY_HOME);
209
+ await new Promise((r) => setTimeout(r, 30));
210
+ stdin.write(KEY_SHIFT_DOWN);
211
+ stdin.write(KEY_SHIFT_DOWN);
212
+ await new Promise((r) => setTimeout(r, 30));
213
+ const frame = lastFrame() ?? '';
214
+ // Shift+Down from scrollOffset=0 moves us down by 2. The visible window
215
+ // is now [2..22). MSG-00 should be off-screen, MSG-02 should be on-screen.
216
+ expect(frame).not.toMatch(/MSG-00-tag/);
217
+ expect(frame).toMatch(/MSG-02-tag/);
218
+ // Shift+Up once: scrollOffset back to 1, MSG-01 visible, MSG-02 still visible.
219
+ stdin.write(KEY_SHIFT_UP);
220
+ await new Promise((r) => setTimeout(r, 30));
221
+ const frame2 = lastFrame() ?? '';
222
+ expect(frame2).toMatch(/MSG-01-tag/);
223
+ });
82
224
  });
@@ -66,17 +66,11 @@ export async function runBaseline(cwd, command, timeoutMs = 90_000) {
66
66
  return baseline;
67
67
  }
68
68
  const MAX_BASELINE_BYTES = 256 * 1024;
69
- function cap(cur, chunk) {
70
- if (cur.length >= MAX_BASELINE_BYTES)
71
- return cur;
72
- const n = cur + chunk;
73
- return n.length > MAX_BASELINE_BYTES ? n.slice(0, MAX_BASELINE_BYTES) + '\n... [truncated]' : n;
74
- }
75
69
  function runCmd(cwd, command, timeoutMs) {
76
70
  return new Promise((resolve) => {
77
71
  const child = spawn(command, { cwd, shell: true, env: process.env });
78
- let stdout = '';
79
- let stderr = '';
72
+ const outChunks = [];
73
+ const errChunks = [];
80
74
  let done = false;
81
75
  const timer = setTimeout(() => {
82
76
  if (done)
@@ -86,24 +80,31 @@ function runCmd(cwd, command, timeoutMs) {
86
80
  child.kill();
87
81
  }
88
82
  catch { /* ignore */ }
89
- resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[baseline timeout]' });
83
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_BASELINE_BYTES);
84
+ const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_BASELINE_BYTES);
85
+ resolve({ ok: false, exitCode: -1, stdout: so, stderr: se + '\n[baseline timeout]' });
90
86
  }, timeoutMs);
91
- child.stdout.on('data', (b) => { stdout = cap(stdout, b.toString()); });
92
- child.stderr.on('data', (b) => { stderr = cap(stderr, b.toString()); });
87
+ child.stdout.on('data', (b) => { if (Buffer.concat(outChunks).length < MAX_BASELINE_BYTES)
88
+ outChunks.push(b); });
89
+ child.stderr.on('data', (b) => { if (Buffer.concat(errChunks).length < MAX_BASELINE_BYTES)
90
+ errChunks.push(b); });
93
91
  child.on('close', (code) => {
94
92
  if (done)
95
93
  return;
96
94
  done = true;
97
95
  clearTimeout(timer);
98
96
  const exit = typeof code === 'number' ? code : -1;
99
- resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
97
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_BASELINE_BYTES);
98
+ const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_BASELINE_BYTES);
99
+ resolve({ ok: exit === 0, exitCode: exit, stdout: so, stderr: se });
100
100
  });
101
101
  child.on('error', (err) => {
102
102
  if (done)
103
103
  return;
104
104
  done = true;
105
105
  clearTimeout(timer);
106
- resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
106
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_BASELINE_BYTES);
107
+ resolve({ ok: false, exitCode: -1, stdout: so, stderr: String(err) });
107
108
  });
108
109
  });
109
110
  }
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process';
7
7
  export function classifyFailure(current, baseline, flakyRerunOk) {
8
8
  const combined = (current.stderr + '\n' + current.stdout).toLowerCase();
9
9
  // env: missing binary, network, permission, no such file, EACCES etc
10
- if (/enoent|command not found|no such file|network|econn|etimedout|eacces|permission denied|env/.test(combined) && /error/i.test(combined)) {
10
+ if (/enoent|command not found|no such file|network|econn|etimedout|eacces|permission denied|environment variable/.test(combined) && /error/i.test(combined)) {
11
11
  // only if not a real test failure but env
12
12
  if (!current.failure || current.failure.type === 'unknown')
13
13
  return 'env';
@@ -42,7 +42,7 @@ export async function rerunOnce(cwd, command, timeoutMs = 45_000) {
42
42
  const t = setTimeout(() => { if (!done) {
43
43
  done = true;
44
44
  try {
45
- child.kill();
45
+ child.kill('SIGKILL');
46
46
  }
47
47
  catch { }
48
48
  resolve(false);
@@ -72,19 +72,30 @@ export async function gatherRepairContext(cwd, failure) {
72
72
  }
73
73
  }
74
74
  // hunks: git diff --stat + --unified=2 for changed files
75
- const hunks = await execCapture(cwd, 'git diff --stat && echo "---" && git diff -U2 2>&1 | head -n 300');
76
- const blame = failure?.files[0]?.path
77
- ? await execCapture(cwd, `git blame "${failure.files[0].path}" 2>&1 | head -n 20`)
75
+ const hunks = await execCapturePipe(cwd, 'git diff --stat && echo "---" && git diff -U2 2>&1 | head -n 300');
76
+ const blamePath = failure?.files[0]?.path;
77
+ const blame = blamePath && /^[\w./-]+$/.test(blamePath) && !blamePath.includes('..')
78
+ ? await execCaptureArgv(cwd, blamePath, ['--no-pager', 'blame', '--'])
78
79
  : '';
79
80
  return { failingTests, hunks, blame };
80
81
  }
81
- function execCapture(cwd, cmd) {
82
+ function execCapturePipe(cwd, cmd) {
82
83
  return new Promise((resolve) => {
83
84
  const child = spawn(cmd, { cwd, shell: true, env: process.env });
84
- let out = '';
85
- child.stdout.on('data', (b) => { out += b.toString(); });
86
- child.stderr.on('data', (b) => { out += b.toString(); });
87
- child.on('close', () => resolve(out.slice(0, 4000)));
85
+ const chunks = [];
86
+ child.stdout.on('data', (b) => { chunks.push(b); });
87
+ child.stderr.on('data', (b) => { chunks.push(b); });
88
+ child.on('close', () => resolve(Buffer.concat(chunks).toString().slice(0, 4000)));
89
+ child.on('error', () => resolve(''));
90
+ });
91
+ }
92
+ function execCaptureArgv(cwd, file, args) {
93
+ return new Promise((resolve) => {
94
+ const child = spawn('git', [...args, file], { cwd, shell: false, env: process.env });
95
+ const chunks = [];
96
+ child.stdout.on('data', (b) => { chunks.push(b); });
97
+ child.stderr.on('data', (b) => { chunks.push(b); });
98
+ child.on('close', () => resolve(Buffer.concat(chunks).toString().split('\n').slice(0, 20).join('\n')));
88
99
  child.on('error', () => resolve(''));
89
100
  });
90
101
  }
@@ -17,41 +17,88 @@ function appendCapped(current, chunk) {
17
17
  const next = current + chunk;
18
18
  return next.length > MAX_VERIFY_BYTES ? next.slice(0, MAX_VERIFY_BYTES) + '\n... [truncated]' : next;
19
19
  }
20
+ // SEC-004: denylist for dangerous patterns in verify commands.
21
+ // Reuses the same patterns as shell-exec.ts DANGEROUS_PATTERNS.
22
+ const DANGEROUS_VERIFY_PATTERNS = [
23
+ { pattern: /rm\s+-rf?\s+\//, reason: 'recursive delete at filesystem root' },
24
+ { pattern: /rm\s+-rf?\s+\/\/+/, reason: 'recursive delete at filesystem root (//)' },
25
+ { pattern: /rm\s+-rf?\s+\/\*/, reason: 'recursive delete at filesystem root (/*)' },
26
+ { pattern: /rm\s+-rf?\s+\.\s*($|[;&|])/, reason: 'recursive delete current directory' },
27
+ { pattern: /rm\s+-rf?\s+\*\s*($|[;&|])/, reason: 'recursive delete all files via *' },
28
+ { pattern: /rm\s+-rf?\s+\.\/\*\s*($|[;&|])/, reason: 'recursive delete all files' },
29
+ { pattern: /rm\s+-rf?\s+~(\/|$)/, reason: 'recursive delete home directory via ~' },
30
+ { pattern: /rm\s+-rf?\s+\$HOME\b/, reason: 'recursive delete home via $HOME' },
31
+ { pattern: /rm\s+-rf?\s+\$PWD\b/, reason: 'recursive delete via $PWD' },
32
+ { pattern: /del\s+\/s\s+\/q\s+[a-z]:\\/i, reason: 'recursive delete on Windows drive root' },
33
+ { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: 'fork bomb' },
34
+ { pattern: /bomb\(\)\s*\{\s*bomb\|bomb/, reason: 'fork bomb variant' },
35
+ { pattern: />\s*\/dev\/sd[a-z]/, reason: 'overwrite raw block device' },
36
+ { pattern: /mkfs(\.|\s)/, reason: 'format filesystem' },
37
+ { pattern: /dd\s+.*of=\/dev\//, reason: 'dd write to device' },
38
+ { pattern: /chmod\s+-R\s+777\s+\//, reason: 'chmod 777 on root' },
39
+ { pattern: /curl.*\|\s*(sh|bash|zsh|python|python3|perl|ruby|php)/i, reason: 'curl|sh to unknown host' },
40
+ { pattern: /wget.*\|\s*(sh|bash|python|perl|ruby)/i, reason: 'wget|sh pipe' },
41
+ { pattern: /rm\s+-rf\s+--no-preserve-root\s+\//, reason: 'recursive delete --no-preserve-root' },
42
+ { pattern: /\$\(/, reason: 'command substitution $()' },
43
+ { pattern: /`[^`]*`/, reason: 'command substitution via backticks' },
44
+ { pattern: /\|\s*bash\b|\|\s*sh\b/, reason: 'pipe to shell' },
45
+ { pattern: /;\s*rm\s+-rf/, reason: 'chained rm -rf' },
46
+ { pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
47
+ { pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
48
+ ];
20
49
  export async function verify(opts) {
50
+ // SEC-004: reject dangerous patterns before spawning
51
+ for (const { pattern, reason } of DANGEROUS_VERIFY_PATTERNS) {
52
+ if (pattern.test(opts.command)) {
53
+ return {
54
+ ok: false,
55
+ exitCode: -1,
56
+ stdout: '',
57
+ stderr: `Command blocked: ${reason}`,
58
+ failure: { type: 'runtime', files: [], raw: `Command blocked: ${reason}`, exitCode: -1 },
59
+ };
60
+ }
61
+ }
21
62
  const timeout = opts.timeoutMs ?? 5 * 60 * 1000;
22
63
  return new Promise((resolve) => {
23
64
  const child = spawn(opts.command, { cwd: opts.cwd, shell: true, env: process.env });
24
- let stdout = '';
25
- let stderr = '';
65
+ const outChunks = [];
66
+ const errChunks = [];
26
67
  let done = false;
27
68
  const timer = setTimeout(() => {
28
69
  if (done)
29
70
  return;
30
71
  child.kill();
31
72
  done = true;
32
- const raw = stderr + '\n' + stdout;
73
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
74
+ const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
75
+ const raw = se + '\n' + so;
33
76
  resolve({
34
77
  ok: false,
35
78
  exitCode: -1,
36
- stdout,
37
- stderr: stderr + '\n[verify timeout]',
79
+ stdout: so,
80
+ stderr: se + '\n[verify timeout]',
38
81
  failure: { type: 'runtime', files: [], raw, exitCode: -1 },
39
82
  });
40
83
  }, timeout);
41
- child.stdout.on('data', (b) => { stdout = appendCapped(stdout, b.toString()); });
42
- child.stderr.on('data', (b) => { stderr = appendCapped(stderr, b.toString()); });
84
+ child.stdout.on('data', (b) => { if (Buffer.concat(outChunks).length < MAX_VERIFY_BYTES)
85
+ outChunks.push(b); });
86
+ child.stderr.on('data', (b) => { if (Buffer.concat(errChunks).length < MAX_VERIFY_BYTES)
87
+ errChunks.push(b); });
43
88
  child.on('close', (code) => {
44
89
  if (done)
45
90
  return;
46
91
  done = true;
47
92
  clearTimeout(timer);
93
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
94
+ const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_VERIFY_BYTES);
48
95
  const exit = typeof code === 'number' ? code : -1;
49
96
  if (exit === 0) {
50
- resolve({ ok: true, exitCode: 0, stdout, stderr });
97
+ resolve({ ok: true, exitCode: 0, stdout: so, stderr: se });
51
98
  return;
52
99
  }
53
- const failure = detect(stdout, stderr, exit);
54
- resolve({ ok: false, exitCode: exit, stdout, stderr, failure });
100
+ const failure = detect(so, se, exit);
101
+ resolve({ ok: false, exitCode: exit, stdout: so, stderr: se, failure });
55
102
  });
56
103
  });
57
104
  }
@@ -71,17 +71,11 @@ export function buildScopedCommand(cwd, baseCommand, relatedTests) {
71
71
  return null;
72
72
  }
73
73
  const MAX_SCOPED_BYTES = 256 * 1024;
74
- function appendCappedScoped(cur, chunk) {
75
- if (cur.length >= MAX_SCOPED_BYTES)
76
- return cur;
77
- const n = cur + chunk;
78
- return n.length > MAX_SCOPED_BYTES ? n.slice(0, MAX_SCOPED_BYTES) + '\n... [truncated]' : n;
79
- }
80
74
  export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
81
75
  return new Promise((resolve) => {
82
76
  const child = spawn(command, { cwd, shell: true, env: process.env });
83
- let stdout = '';
84
- let stderr = '';
77
+ const outChunks = [];
78
+ const errChunks = [];
85
79
  let done = false;
86
80
  const timer = setTimeout(() => {
87
81
  if (done)
@@ -91,24 +85,31 @@ export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
91
85
  child.kill();
92
86
  }
93
87
  catch { /* ignore */ }
94
- resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[scoped timeout]' });
88
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_SCOPED_BYTES);
89
+ const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_SCOPED_BYTES);
90
+ resolve({ ok: false, exitCode: -1, stdout: so, stderr: se + '\n[scoped timeout]' });
95
91
  }, timeoutMs);
96
- child.stdout.on('data', (b) => { stdout = appendCappedScoped(stdout, b.toString()); });
97
- child.stderr.on('data', (b) => { stderr = appendCappedScoped(stderr, b.toString()); });
92
+ child.stdout.on('data', (b) => { if (Buffer.concat(outChunks).length < MAX_SCOPED_BYTES)
93
+ outChunks.push(b); });
94
+ child.stderr.on('data', (b) => { if (Buffer.concat(errChunks).length < MAX_SCOPED_BYTES)
95
+ errChunks.push(b); });
98
96
  child.on('close', (code) => {
99
97
  if (done)
100
98
  return;
101
99
  done = true;
102
100
  clearTimeout(timer);
103
101
  const exit = typeof code === 'number' ? code : -1;
104
- resolve({ ok: exit === 0, exitCode: exit, stdout, stderr });
102
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_SCOPED_BYTES);
103
+ const se = Buffer.concat(errChunks).toString('utf-8').slice(0, MAX_SCOPED_BYTES);
104
+ resolve({ ok: exit === 0, exitCode: exit, stdout: so, stderr: se });
105
105
  });
106
106
  child.on('error', (err) => {
107
107
  if (done)
108
108
  return;
109
109
  done = true;
110
110
  clearTimeout(timer);
111
- resolve({ ok: false, exitCode: -1, stdout, stderr: String(err) });
111
+ const so = Buffer.concat(outChunks).toString('utf-8').slice(0, MAX_SCOPED_BYTES);
112
+ resolve({ ok: false, exitCode: -1, stdout: so, stderr: String(err) });
112
113
  });
113
114
  });
114
115
  }
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "klyro",
3
- "version": "0.1.40",
4
- "description": "Klyro autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "bin": {
9
- "klyro": "dist/index.js",
10
- "ky": "dist/index.js"
11
- },
12
- "files": [
13
- "dist",
14
- "README.md",
15
- "READ.md",
16
- "LICENSE"
17
- ],
18
- "engines": {
19
- "node": ">=20"
20
- },
21
- "scripts": {
22
- "build": "tsc",
23
- "start": "node dist/index.js",
24
- "dev": "tsx src/index.ts",
25
- "dev:watch": "tsx watch src/index.ts",
26
- "typecheck": "tsc --noEmit",
27
- "test": "vitest run",
28
- "test:watch": "vitest",
29
- "prepublishOnly": "npm run typecheck && npm test && npm run build",
30
- "pack:dry": "npm pack --dry-run",
31
- "publish:public": "npm publish --access public"
32
- },
33
- "keywords": [
34
- "llm",
35
- "ai",
36
- "cli",
37
- "openai",
38
- "anthropic",
39
- "harness",
40
- "agent",
41
- "streaming",
42
- "klyro"
43
- ],
44
- "license": "MIT",
45
- "dependencies": {
46
- "commander": "^12.1.0",
47
- "ink": "^7.1.1",
48
- "ink-spinner": "^5.0.0",
49
- "react": "^19.2.0",
50
- "zod": "^4.5.4"
51
- },
52
- "devDependencies": {
53
- "@types/node": "^22.9.0",
54
- "@types/react": "^19.2.18",
55
- "ink-testing-library": "^4.0.0",
56
- "tsx": "^4.23.13",
57
- "typescript": "^5.5.4",
58
- "vitest": "^4.1.11"
59
- }
60
- }
1
+ {
2
+ "name": "klyro",
3
+ "version": "0.1.42",
4
+ "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "klyro": "dist/index.js",
10
+ "ky": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "READ.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "start": "node dist/index.js",
24
+ "dev": "tsx src/index.ts",
25
+ "dev:watch": "tsx watch src/index.ts",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
30
+ "pack:dry": "npm pack --dry-run",
31
+ "publish:public": "npm publish --access public"
32
+ },
33
+ "keywords": [
34
+ "llm",
35
+ "ai",
36
+ "cli",
37
+ "openai",
38
+ "anthropic",
39
+ "harness",
40
+ "agent",
41
+ "streaming",
42
+ "klyro"
43
+ ],
44
+ "license": "MIT",
45
+ "dependencies": {
46
+ "commander": "^12.1.0",
47
+ "ink": "^7.1.1",
48
+ "ink-spinner": "^5.0.0",
49
+ "react": "^19.2.0",
50
+ "zod": "^4.5.4"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.9.0",
54
+ "@types/react": "^19.2.18",
55
+ "ink-testing-library": "^4.0.0",
56
+ "tsx": "^4.23.13",
57
+ "typescript": "^5.5.4",
58
+ "vitest": "^4.1.11"
59
+ }
60
+ }