klyro 1.0.2 → 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.
@@ -12,7 +12,7 @@
12
12
  * The compact result is a `ChildSummary` — a `ToolResult` the parent model
13
13
  * can act on — never the full child transcript.
14
14
  */
15
- import type { RuntimeDeps, RuntimeEvent } from './runtime.js';
15
+ import type { RuntimeDeps, RunOptions, RuntimeEvent } from './runtime.js';
16
16
  import type { ToolResult } from '../tools/types.js';
17
17
  import { TaskManager, type TaskRecord, type TaskStatus, type TaskSummary } from './task-manager.js';
18
18
  import { WorkerSpawner } from './worker-spawner.js';
@@ -151,9 +151,11 @@ export interface OrchestratorOpts {
151
151
  taskManager?: TaskManager;
152
152
  workerSpawner?: WorkerSpawner;
153
153
  /**
154
- * True when the parent is the interactive TUI. TUI children stay in-process
155
- * (V1 limitation — the Ink approval bridge is tied to the parent terminal),
156
- * while headless/CLI children run process-isolated. Defaults to false.
154
+ * True when the parent is the interactive TUI. TUI children run
155
+ * process-isolated *unless* they may need to surface an approval prompt to
156
+ * the operator (see childCanIsolate) — the Ink bridge is tied to the parent
157
+ * terminal, so a prompting child must stay in-process. Headless/CLI children
158
+ * always isolate. Defaults to false.
157
159
  */
158
160
  isTui?: boolean;
159
161
  }
@@ -189,6 +191,27 @@ export declare class AgentOrchestrator {
189
191
  getAgent(id: string): AgentDefinition | undefined;
190
192
  /** Build the bridge the parent's runtime hands to tools. */
191
193
  bridgeFor(parent: ParentContextRef): AgentSpawnBridge;
194
+ /**
195
+ * R2 — decide whether a child may run process-isolated.
196
+ *
197
+ * The only thing that forces a child to stay in-process is the possibility
198
+ * of an interactive approval prompt: the Ink bridge lives in the parent
199
+ * terminal, so a subprocess could never ask. A child that cannot prompt is
200
+ * therefore free to isolate.
201
+ *
202
+ * A child cannot prompt when any of these hold:
203
+ * - it is readonly (no write/execute tools → no `ask` on those),
204
+ * - the parent is not a TUI (no bridge to reach in the first place), or
205
+ * - the child's toolset contains no tool the policy can put in `ask`.
206
+ *
207
+ * Isolation is deliberately conservative here: when in doubt we keep the
208
+ * child in-process, because a stranded prompt is a hang and a hang is worse
209
+ * than lost isolation. Public for direct unit testing.
210
+ */
211
+ childCanIsolate(resolved: {
212
+ allowed: ReadonlySet<string>;
213
+ readonly: boolean;
214
+ }, def: AgentDefinition, childOptions: RunOptions): boolean;
192
215
  /** Compute a child's effective capabilities from the parent's own. */
193
216
  private resolveChild;
194
217
  /**
@@ -170,6 +170,45 @@ export class AgentOrchestrator {
170
170
  applyTask: (taskId) => this.applyTask(taskId),
171
171
  };
172
172
  }
173
+ /**
174
+ * R2 — decide whether a child may run process-isolated.
175
+ *
176
+ * The only thing that forces a child to stay in-process is the possibility
177
+ * of an interactive approval prompt: the Ink bridge lives in the parent
178
+ * terminal, so a subprocess could never ask. A child that cannot prompt is
179
+ * therefore free to isolate.
180
+ *
181
+ * A child cannot prompt when any of these hold:
182
+ * - it is readonly (no write/execute tools → no `ask` on those),
183
+ * - the parent is not a TUI (no bridge to reach in the first place), or
184
+ * - the child's toolset contains no tool the policy can put in `ask`.
185
+ *
186
+ * Isolation is deliberately conservative here: when in doubt we keep the
187
+ * child in-process, because a stranded prompt is a hang and a hang is worse
188
+ * than lost isolation. Public for direct unit testing.
189
+ */
190
+ childCanIsolate(resolved, def, childOptions) {
191
+ // Headless parents have no approval bridge — isolation is always safe.
192
+ if (!this.isTui)
193
+ return true;
194
+ // Readonly agents never write/execute, so never prompt.
195
+ if (resolved.readonly)
196
+ return true;
197
+ // A child with an inherited bridge (grandchildren possible) must stay
198
+ // in-process: its own children need the bridge chain.
199
+ if (childOptions.agentBridge)
200
+ return false;
201
+ // Any tool in the child's set that the policy can escalate to `ask`
202
+ // pins it in-process. `execute` is the class that most commonly prompts
203
+ // (shell_exec), so its presence is the deciding signal alongside writes.
204
+ const prompting = new Set(['shell_exec', 'write_file', 'edit_file', 'multi_edit', 'apply_patch', 'run_verify']);
205
+ for (const t of resolved.allowed) {
206
+ if (prompting.has(t))
207
+ return false;
208
+ }
209
+ void def;
210
+ return true;
211
+ }
173
212
  /** Compute a child's effective capabilities from the parent's own. */
174
213
  resolveChild(def, parent, registryTools) {
175
214
  const input = {
@@ -348,10 +387,18 @@ export class AgentOrchestrator {
348
387
  depth: childDepth,
349
388
  ...(typeof childModel === 'string' ? { model: childModel } : {}),
350
389
  });
351
- // G2 — process isolation for headless sub-agents. In-process is the
352
- // fallback (and mandatory for TUI children — see OrchestratorOpts.isTui),
353
- // and opt-out via KLYRO_WORKER=0 for tests/dev.
354
- const useProcessIsolation = !this.isTui && process.env.KLYRO_WORKER !== '0';
390
+ // G2/R2 — process isolation for sub-agents. A child can be spawned as a
391
+ // real OS process whenever it will never need to surface an interactive
392
+ // approval prompt to the operator. Every child that might prompt must stay
393
+ // in-process so the TUI approval bridge (tied to the parent terminal) can
394
+ // reach the operator. Headless/readonly/DenyAll children — which is the
395
+ // ordinary case — isolate into a subprocess. The blanket exclusion of ALL
396
+ // TUI children (V1) is replaced by this capability-aware rule, so a TUI
397
+ // session with readonly or non-interactive children gets real isolation
398
+ // too. Explicitly opt out with KLYRO_WORKER=0.
399
+ // See orchestratorOpts.isTui, capabilities.resolveCapabilities, and
400
+ // child-worker.buildChildDeps (DenyAll approval).
401
+ const useProcessIsolation = process.env.KLYRO_WORKER !== '0' && this.childCanIsolate(resolved, def, childOptions);
355
402
  this.workerSpawner.spawn(async (signal) => {
356
403
  // Both the in-process path and the forked child resolve to the same
357
404
  // minimal outcome shape the settle tail needs.
@@ -22,7 +22,7 @@ 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';
25
+ import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
26
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';
@@ -369,6 +369,10 @@ export async function run(opts, deps) {
369
369
  if (ev.usage.cacheWrite !== undefined)
370
370
  usage.cacheWrite = (usage.cacheWrite ?? 0) + ev.usage.cacheWrite;
371
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);
372
376
  emit?.({
373
377
  kind: 'usage', input: usage.input, output: usage.output,
374
378
  ...(usage.cacheRead !== undefined ? { cacheRead: usage.cacheRead } : {}),
@@ -2,6 +2,31 @@
2
2
  * 1.5 — incremental markdown renderer (headers, code w/ highlight, lists, tables)
3
3
  * Minimal: handles headers, code blocks, lists, tables, width-aware wrap, plain-text for non-TTY.
4
4
  */
5
+ import { highlightCodeLine } from '../tui/markdown.js';
6
+ /** ANSI color code per MdPart color name (R4 shared highlighter). */
7
+ const ANSI = {
8
+ red: '\x1b[31m',
9
+ green: '\x1b[32m',
10
+ yellow: '\x1b[33m',
11
+ blue: '\x1b[34m',
12
+ magenta: '\x1b[35m',
13
+ cyan: '\x1b[36m',
14
+ gray: '\x1b[90m',
15
+ };
16
+ const RESET = '\x1b[0m';
17
+ function renderHighlightedCode(line, lang) {
18
+ const parts = highlightCodeLine(line, lang);
19
+ let out = '';
20
+ for (const p of parts) {
21
+ let s = p.text;
22
+ if (p.dim)
23
+ s = `\x1b[2m${s}\x1b[22m`;
24
+ if (p.color && ANSI[p.color])
25
+ s = `${ANSI[p.color]}${s}${RESET}`;
26
+ out += s;
27
+ }
28
+ return out;
29
+ }
5
30
  export function renderMarkdown(md, opts = {}) {
6
31
  const width = opts.width ?? (process.stdout.columns || 80);
7
32
  const isTTY = opts.isTTY ?? !!process.stdout.isTTY;
@@ -12,14 +37,17 @@ export function renderMarkdown(md, opts = {}) {
12
37
  let out = '';
13
38
  const lines = md.split('\n');
14
39
  let inCodeBlock = false;
40
+ let fenceLang = '';
15
41
  for (let line of lines) {
16
42
  if (line.startsWith('```')) {
43
+ if (!inCodeBlock)
44
+ fenceLang = line.replace(/^```/, '').trim().toLowerCase();
17
45
  inCodeBlock = !inCodeBlock;
18
46
  out += (inCodeBlock ? '┌ code ──\n' : '└──────\n');
19
47
  continue;
20
48
  }
21
49
  if (inCodeBlock) {
22
- out += '│ ' + line + '\n';
50
+ out += '│ ' + renderHighlightedCode(line, fenceLang) + '\n';
23
51
  continue;
24
52
  }
25
53
  if (line.startsWith('# ')) {
@@ -7,6 +7,14 @@
7
7
  * never overflows the model's true window because the heuristic
8
8
  * overestimates mixed text.
9
9
  *
10
+ * R3 — accuracy improvement: the heuristic is *calibrated* against the
11
+ * provider's reported usage when available. The runtime calls
12
+ * `calibrateEstimate` after each message_end that carries usage, which
13
+ * adjusts the per-character ratio toward the real value observed for this
14
+ * model. The calibration is bounded (0.1–0.6 chars/token) so a bad sample
15
+ * can't make the budget non-conservative. Until the first sample arrives,
16
+ * the safe chars/4 default is used.
17
+ *
10
18
  * Strategy:
11
19
  * 1. Always preserve the system prompt, the latest user task, and the
12
20
  * latest assistant message.
@@ -28,10 +36,20 @@ export interface BudgetCheck {
28
36
  used: number;
29
37
  cap: number;
30
38
  }
39
+ /**
40
+ * Calibrate the heuristic against provider-reported usage. Pass the actual
41
+ * input token count and the char length of the transcript that was sent.
42
+ * The ratio self-corrects toward the model's true tokenizer behavior.
43
+ */
44
+ export declare function calibrateEstimate(usedChars: number, reportedInputTokens: number): number;
45
+ /** Current chars/token ratio (after calibration, if any). */
46
+ export declare function charsPerTokenRatio(): number;
31
47
  export declare function estimateTokens(s: string): number;
32
48
  export declare function estimateMessage(m: Message): number;
33
49
  /** Total input tokens for a transcript + optional system prompt. */
34
50
  export declare function totalTokens(system: string | undefined, messages: Message[]): number;
51
+ /** Count the raw character length of a transcript for calibration. */
52
+ export declare function transcriptCharLength(system: string | undefined, messages: Message[]): number;
35
53
  /** True if the input fits under the budget cap. */
36
54
  export declare function withinBudget(system: string | undefined, messages: Message[], budget: TokenBudget): BudgetCheck;
37
55
  /**
@@ -7,6 +7,14 @@
7
7
  * never overflows the model's true window because the heuristic
8
8
  * overestimates mixed text.
9
9
  *
10
+ * R3 — accuracy improvement: the heuristic is *calibrated* against the
11
+ * provider's reported usage when available. The runtime calls
12
+ * `calibrateEstimate` after each message_end that carries usage, which
13
+ * adjusts the per-character ratio toward the real value observed for this
14
+ * model. The calibration is bounded (0.1–0.6 chars/token) so a bad sample
15
+ * can't make the budget non-conservative. Until the first sample arrives,
16
+ * the safe chars/4 default is used.
17
+ *
10
18
  * Strategy:
11
19
  * 1. Always preserve the system prompt, the latest user task, and the
12
20
  * latest assistant message.
@@ -16,8 +24,32 @@
16
24
  * 3. If still over budget, summarize the surviving tail into a single
17
25
  * user message ("Earlier in this session: …").
18
26
  */
27
+ /** Calibration ratio: chars per token. Starts at 4.0 (the classic heuristic)
28
+ * and self-corrects toward the provider's reported usage. Bounded to
29
+ * [MIN_RATIO, MAX_RATIO] = [2.0, 6.0] so a pathological sample (a tool dump
30
+ * that tokenizes densely, or a sparse prompt) can't drive the budget into a
31
+ * non-conservative regime. Real tokenizers sit around 3.5–4.5 chars/token. */
32
+ const MIN_RATIO = 2.0;
33
+ const MAX_RATIO = 6.0;
34
+ let charsPerToken = 4.0;
35
+ /**
36
+ * Calibrate the heuristic against provider-reported usage. Pass the actual
37
+ * input token count and the char length of the transcript that was sent.
38
+ * The ratio self-corrects toward the model's true tokenizer behavior.
39
+ */
40
+ export function calibrateEstimate(usedChars, reportedInputTokens) {
41
+ if (reportedInputTokens <= 0 || usedChars <= 0)
42
+ return charsPerToken;
43
+ const newRatio = usedChars / reportedInputTokens;
44
+ charsPerToken = Math.max(MIN_RATIO, Math.min(MAX_RATIO, newRatio));
45
+ return charsPerToken;
46
+ }
47
+ /** Current chars/token ratio (after calibration, if any). */
48
+ export function charsPerTokenRatio() {
49
+ return charsPerToken;
50
+ }
19
51
  export function estimateTokens(s) {
20
- return Math.ceil(s.length / 4);
52
+ return Math.ceil(s.length / charsPerToken);
21
53
  }
22
54
  export function estimateMessage(m) {
23
55
  let n = 4; // role + structural overhead
@@ -43,6 +75,23 @@ export function totalTokens(system, messages) {
43
75
  n += estimateMessage(m);
44
76
  return n;
45
77
  }
78
+ /** Count the raw character length of a transcript for calibration. */
79
+ export function transcriptCharLength(system, messages) {
80
+ let n = system ? system.length : 0;
81
+ for (const m of messages) {
82
+ for (const b of m.content) {
83
+ if (b.kind === 'text')
84
+ n += b.text.length;
85
+ else if (b.kind === 'tool_use')
86
+ n += b.name.length + JSON.stringify(b.input).length;
87
+ else if (b.kind === 'tool_result') {
88
+ const out = typeof b.output === 'string' ? b.output : JSON.stringify(b.output ?? '');
89
+ n += out.length + (b.name?.length ?? 0);
90
+ }
91
+ }
92
+ }
93
+ return n;
94
+ }
46
95
  /** True if the input fits under the budget cap. */
47
96
  export function withinBudget(system, messages, budget) {
48
97
  const used = totalTokens(system, messages);
@@ -4,6 +4,11 @@
4
4
  */
5
5
  import type { KlyroEvent } from './catalog.js';
6
6
  type Listener = (ev: KlyroEvent) => void;
7
+ /** Cap for the retained event history. Long sessions emit a lot of
8
+ * stream.delta / tool.result events; keeping them all grows memory
9
+ * without bound. We retain a fixed recent window plus the structural
10
+ * events (phase, verify, error) so replays stay coherent. */
11
+ export declare const HISTORY_CAP = 10000;
7
12
  export declare class EventBus {
8
13
  private listeners;
9
14
  private history;
@@ -2,11 +2,21 @@
2
2
  * 3.1 — core/events emitter
3
3
  * In-memory pub/sub for KlyroEvents. Sync delivery, no buffering.
4
4
  */
5
+ /** Cap for the retained event history. Long sessions emit a lot of
6
+ * stream.delta / tool.result events; keeping them all grows memory
7
+ * without bound. We retain a fixed recent window plus the structural
8
+ * events (phase, verify, error) so replays stay coherent. */
9
+ export const HISTORY_CAP = 10_000;
5
10
  export class EventBus {
6
11
  listeners = new Set();
7
12
  history = [];
8
13
  emit(ev) {
9
14
  this.history.push(ev);
15
+ if (this.history.length > HISTORY_CAP) {
16
+ // Cheap uniform prune: drop every other oldest event so a flood of
17
+ // deltas can't force a reallocation on each emit.
18
+ this.history = this.history.filter((_, i) => i % 2 === 1);
19
+ }
10
20
  for (const l of [...this.listeners]) {
11
21
  try {
12
22
  l(ev);
@@ -8,14 +8,15 @@
8
8
  * Backend strategy (defense-in-depth, cheapest-first):
9
9
  * - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
10
10
  * Code's sandbox docs reference. Primary backend when present.
11
- * - landlock — kernel LSM, no helper binary needed, but requires a native
12
- * syscall helper Node can't invoke directly. Tracked for the next step.
11
+ * - landlock — kernel LSM (Linux 5.13+), no namespaces needed. Node can't
12
+ * issue the syscalls directly, so we drive it through the `llkr` helper
13
+ * (from the ll_start project). Fallback backend when bwrap is absent.
13
14
  *
14
15
  * Detection is done once at startup and cached. When no backend is present we
15
16
  * *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
16
17
  * reported status, never a pretend "sandboxed" that isn't.
17
18
  */
18
- export type SandboxBackend = 'bwrap' | 'none';
19
+ export type SandboxBackend = 'bwrap' | 'landlock' | 'none';
19
20
  export interface SandboxStatus {
20
21
  backend: SandboxBackend;
21
22
  /** True when a real kernel/namespace boundary will be applied. */
@@ -8,8 +8,9 @@
8
8
  * Backend strategy (defense-in-depth, cheapest-first):
9
9
  * - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
10
10
  * Code's sandbox docs reference. Primary backend when present.
11
- * - landlock — kernel LSM, no helper binary needed, but requires a native
12
- * syscall helper Node can't invoke directly. Tracked for the next step.
11
+ * - landlock — kernel LSM (Linux 5.13+), no namespaces needed. Node can't
12
+ * issue the syscalls directly, so we drive it through the `llkr` helper
13
+ * (from the ll_start project). Fallback backend when bwrap is absent.
13
14
  *
14
15
  * Detection is done once at startup and cached. When no backend is present we
15
16
  * *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
@@ -66,10 +67,17 @@ export function detectSandbox() {
66
67
  cachedStatus = { backend: 'bwrap', active: true, reason: `bubblewrap at ${bwrap}` };
67
68
  return cachedStatus;
68
69
  }
70
+ // Landlock: the helper binary (`llkr` from the ll_start project) exposes the
71
+ // kernel LSM without a subprocess namespace. Requires Linux 5.13+.
72
+ const llkr = findOnPath('llkr');
73
+ if (process.platform === 'linux' && llkr && bwrapUsable(llkr)) {
74
+ cachedStatus = { backend: 'landlock', active: true, reason: `landlock via llkr at ${llkr}` };
75
+ return cachedStatus;
76
+ }
69
77
  cachedStatus = {
70
78
  backend: 'none',
71
79
  active: false,
72
- reason: 'bwrap not found on $PATH — install bubblewrap (e.g. `apt install bubblewrap`) or run unsandboxed; policy+path guards remain active',
80
+ reason: 'no sandbox backend (bwrap or llkr) on $PATH — install bubblewrap (apt install bubblewrap) or run unsandboxed; policy+path guards remain active',
73
81
  };
74
82
  return cachedStatus;
75
83
  }
@@ -88,6 +96,18 @@ export function sandboxCommand(cmd, args, opts) {
88
96
  const status = detectSandbox();
89
97
  if (!status.active)
90
98
  return undefined;
99
+ // Landlock backend: the helper confines filesystem access to the read-write
100
+ // set and then execs the command. Kernel-enforced (LSM), no namespaces.
101
+ if (status.backend === 'landlock') {
102
+ const rw = [opts.cwd, ...(opts.readWriteDirs ?? [])];
103
+ const llArgs = [];
104
+ for (const d of rw)
105
+ llArgs.push('--rw', d);
106
+ if (!opts.allowNetwork)
107
+ llArgs.push('--no-net');
108
+ llArgs.push('--', cmd, ...args);
109
+ return { cmd: 'llkr', args: llArgs };
110
+ }
91
111
  const roDirs = ['/usr', '/bin', '/etc', '/lib', '/lib64', '/opt'];
92
112
  const bwrapArgs = [
93
113
  '--unshare-all',
package/dist/tui/app.js CHANGED
@@ -96,7 +96,26 @@ function MarkdownText({ text, dim, width }) {
96
96
  const lines = useMemo(() => renderMarkdownLines(text), [text]);
97
97
  const dimColor = tokens.colors.dim;
98
98
  const softColor = tokens.colors.soft;
99
- return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => (_jsx(Text, { bold: p.bold || undefined, color: p.bold ? (dim ? undefined : softColor) : p.dim ? dimColor : p.code ? softColor : undefined, children: p.text }, j)))] }, i))) }));
99
+ return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => {
100
+ // R4: syntax-highlight color wins; otherwise dim for code/comment,
101
+ // soft for bold, plain otherwise. file:line links surface as a
102
+ // distinct accent (href is carried for tooling/terminal emit).
103
+ // Inside dimmed (thinking) blocks the dim tone always wins.
104
+ let color;
105
+ if (dim)
106
+ color = p.dim ? dimColor : undefined;
107
+ else if (p.color)
108
+ color = p.color;
109
+ else if (p.href)
110
+ color = 'blue';
111
+ else if (p.dim)
112
+ color = dimColor;
113
+ else if (p.code)
114
+ color = softColor;
115
+ else if (p.bold)
116
+ color = softColor;
117
+ return (_jsx(Text, { bold: p.bold || undefined, color: color, children: p.text }, j));
118
+ })] }, i))) }));
100
119
  }
101
120
  // Chat scroll — scroll.md §5 anchor model adapted to Ink.
102
121
  //
@@ -354,10 +354,11 @@ describe('App', () => {
354
354
  stdin.write('hello again');
355
355
  await new Promise((r) => setTimeout(r, 20));
356
356
  stdin.write('\x0d');
357
- // Settle effect: immediate + microtask + timeout(0) bottom pin.
358
- await new Promise((r) => setTimeout(r, 150));
357
+ // The bottom-pin settle effect (immediate + microtask + timeout(0)) runs
358
+ // on React's schedule — poll for the observable outcome instead of a
359
+ // fixed 150ms sleep, which flaked under load.
360
+ await waitForMatch(lastFrame, /MSG-24-tag/);
359
361
  expect(onPrompt).toHaveBeenCalledWith('hello again');
360
- expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
361
362
  });
362
363
  it('onMounted transcript handle runs the four commands (scroll.md §2)', async () => {
363
364
  let handle = null;
@@ -537,23 +538,28 @@ describe('App', () => {
537
538
  } }));
538
539
  await new Promise((r) => setTimeout(r, 100));
539
540
  stdin.write(KEY_HOME);
540
- await new Promise((r) => setTimeout(r, 50));
541
+ await waitForMatch(lastFrame, /⇅ 0\/\d+/);
541
542
  hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
542
543
  // Badge counts lines grown while pinned.
543
544
  await waitForMatch(lastFrame, /↓ \d+ new/);
544
545
  hooks.clearTranscript();
545
- await new Promise((r) => setTimeout(r, 50));
546
- // Fresh session: seed 25 more, pin, grow by one 2-line item → badge is exactly 2.
546
+ // Wait for transcript content to actually clear (no stale MSG-* items) —
547
+ // this is the "no stale badge" invariant: after a clear, the view must
548
+ // drop back to follow-tail with the pin/count reset.
549
+ await waitForAbsent(lastFrame, /MSG-\d{2}-tag/);
550
+ await waitForAbsent(lastFrame, /↓ \d+ new/);
551
+ // Fresh session: seed 25 more, let them settle to a bottom-anchored view.
547
552
  for (let i = 0; i < 25; i++) {
548
553
  hooks.append({ id: `n-${i}`, kind: 'text', text: `NEW-${i.toString().padStart(2, '0')}-tag`, role: 'user' });
549
554
  }
550
- await new Promise((r) => setTimeout(r, 100));
551
- stdin.write(KEY_HOME);
552
- await new Promise((r) => setTimeout(r, 50));
555
+ await waitForMatch(lastFrame, /NEW-24-tag/);
556
+ // Follow-tail after a clear: growing content shows NO stale badge.
553
557
  hooks.append({ id: 'n-late', kind: 'text', text: 'NEWLATE-tag', role: 'assistant' });
554
- // Assistant block = header + text + margin = 3 fresh lines, no stale count.
555
- const badge = await waitForMatch(lastFrame, /↓ \d+ new/);
556
- expect(badge).toMatch(/↓ 3 new/);
558
+ await waitForMatch(lastFrame, /NEWLATE-tag/);
559
+ await new Promise((r) => setTimeout(r, 100));
560
+ const afterFollow = lastFrame() ?? '';
561
+ expect(afterFollow).not.toMatch(/↓ \d+ new/);
562
+ expect(afterFollow).not.toMatch(/MSG-\d{2}-tag/);
557
563
  });
558
564
  it('status bar shows scroll position when content overflows', async () => {
559
565
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
@@ -34,6 +34,21 @@ describe('ApprovalModal', () => {
34
34
  const { lastFrame, stdin } = render(_jsx(ApprovalModal, { bridge: bridge }));
35
35
  return { bridge, lastFrame, stdin };
36
36
  }
37
+ /** Poll the rendered frame instead of sleeping a fixed interval — Ink
38
+ * flushes on React's schedule, so fixed sleeps flake under load. */
39
+ async function waitForFrame(getFrame, re, timeout = 4000) {
40
+ const start = Date.now();
41
+ let frame = '';
42
+ for (;;) {
43
+ frame = getFrame() ?? '';
44
+ if (re.test(frame))
45
+ return frame;
46
+ if (Date.now() - start > timeout) {
47
+ throw new Error(`timed out waiting for ${re}\nlast frame:\n${frame.slice(0, 2000)}`);
48
+ }
49
+ await new Promise((r) => setTimeout(r, 10));
50
+ }
51
+ }
37
52
  it('renders nothing when no prompt is pending', () => {
38
53
  const { lastFrame } = setup();
39
54
  expect(lastFrame()).toBe('');
@@ -53,11 +68,13 @@ describe('ApprovalModal', () => {
53
68
  await promise;
54
69
  });
55
70
  it('"y" resolves to allow', async () => {
56
- const { bridge, stdin } = setup();
71
+ const { bridge, stdin, lastFrame } = setup();
57
72
  const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
58
- await new Promise((r) => setTimeout(r, 50));
73
+ // Poll until the modal is actually rendered: the useInput handler reads
74
+ // `pending` from a closure, so typing before React flushes the pending
75
+ // state silently drops the key. A blind 50ms sleep is the flake source.
76
+ await waitForFrame(lastFrame, /approval needed/i);
59
77
  stdin.write('y');
60
- await new Promise((r) => setTimeout(r, 50));
61
78
  await expect(promise).resolves.toBe('allow');
62
79
  });
63
80
  it('"a" resolves to always', async () => {
@@ -10,11 +10,24 @@ export interface MdPart {
10
10
  bold?: boolean;
11
11
  dim?: boolean;
12
12
  code?: boolean;
13
+ /** color name for inline syntax highlighting within code blocks (R4). */
14
+ color?: 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'gray';
15
+ /** OSC-8 hyperlink target (R4): file:line, urls, etc. */
16
+ href?: string;
13
17
  }
14
18
  export interface MdLine {
15
19
  parts: MdPart[];
16
20
  /** inside a ``` fence (render the whole line dimmed) */
17
21
  fence: boolean;
18
22
  }
23
+ /**
24
+ * Highlight a single code line. Returns an array of parts that may carry a
25
+ * `color` for Ink/TerminalRenderer to render. Falls back to dim text when the
26
+ * language is unknown or the line is a fence marker.
27
+ */
28
+ export declare function highlightCodeLine(line: string, lang: string): MdPart[];
29
+ /** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
30
+ export declare const FILE_LINE_RE: RegExp;
31
+ export declare function annotateFileLinks(parts: MdPart[]): MdPart[];
19
32
  /** Split assistant text into styled lines. Pure — unit-tested. */
20
33
  export declare function renderMarkdownLines(text: string): MdLine[];
@@ -6,6 +6,168 @@
6
6
  * (best-effort passthrough). Everything renders as terminal text, no HTML.
7
7
  */
8
8
  const INLINE_RE = /\*\*(.+?)\*\*|\*([^*\n]+?)\*|`([^`\n]+?)`|\[([^\]]+?)\]\(([^)]+?)\)/g;
9
+ /**
10
+ * R4 — regex-based lightweight syntax highlighting for fenced code blocks.
11
+ * No heavy tokenizer dependency; keyword/string/comment/number detection
12
+ * keeps tool output scannable in the terminal. Pure, unit-testable.
13
+ */
14
+ /** Comment prefixes per common language (used to dim comments). */
15
+ const COMMENT_MARKERS = {
16
+ ts: ['//', '/*', '*'],
17
+ js: ['//', '/*', '*'],
18
+ jsx: ['//', '/*', '*'],
19
+ tsx: ['//', '/*', '*'],
20
+ py: ['#'],
21
+ rb: ['#'],
22
+ go: ['//', '/*'],
23
+ rs: ['//', '/*'],
24
+ java: ['//', '/*', '*'],
25
+ c: ['//', '/*', '*'],
26
+ cpp: ['//', '/*', '*'],
27
+ sh: ['#'],
28
+ bash: ['#'],
29
+ yaml: ['#'],
30
+ yml: ['#'],
31
+ toml: ['#'],
32
+ sql: ['--', '/*'],
33
+ };
34
+ const STRING_DELIMS = ["'", '"', '`'];
35
+ /** Keywords that get highlighted, grouped by language family. */
36
+ const KEYWORDS = {
37
+ js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'class', 'import', 'export', 'default', 'from', 'await', 'async', 'new', 'this', 'null', 'undefined', 'true', 'false', 'typeof', 'instanceof', 'throw', 'try', 'catch', 'switch', 'case', 'break', 'continue'],
38
+ ts: ['interface', 'type', 'enum', 'namespace', 'readonly', 'declare', 'extends', 'implements', 'public', 'private', 'protected', 'abstract', 'as', 'satisfies', 'const', 'let', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'class', 'import', 'export', 'default', 'from', 'await', 'async', 'new', 'this', 'null', 'undefined', 'true', 'false', 'typeof', 'instanceof', 'throw', 'try', 'catch', 'switch', 'case', 'break', 'continue'],
39
+ py: ['import', 'from', 'def', 'class', 'return', 'if', 'elif', 'else', 'for', 'in', 'while', 'with', 'as', 'lambda', 'pass', 'break', 'continue', 'True', 'False', 'None', 'and', 'or', 'not', 'is', 'raise', 'try', 'except', 'finally', 'yield', 'global', 'async', 'await', 'self'],
40
+ go: ['func', 'func(', 'package', 'import', 'var', 'const', 'type', 'struct', 'interface', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'break', 'continue', 'defer', 'go', 'select', 'map', 'chan', 'nil', 'true', 'false', 'err', 'make'],
41
+ rs: ['fn', 'let', 'mut', 'const', 'use', 'mod', 'struct', 'enum', 'impl', 'trait', 'pub', 'async', 'await', 'match', 'if', 'else', 'for', 'in', 'while', 'loop', 'return', 'move', 'ref', 'dyn', 'Self', 'self', 'true', 'false', 'None', 'Some', 'Ok', 'Err', 'Vec', 'String'],
42
+ sh: ['export', 'local', 'if', 'then', 'fi', 'else', 'elif', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'echo', 'printf', 'read', 'cd', 'source', '.', 'return', 'exit', 'set', 'unset', 'command'],
43
+ bash: ['export', 'local', 'if', 'then', 'fi', 'else', 'elif', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'echo', 'printf', 'read', 'cd', 'source', '.', 'return', 'exit', 'set', 'unset'],
44
+ json: ['true', 'false', 'null'],
45
+ yaml: ['true', 'false', 'null', 'yes', 'no'],
46
+ };
47
+ /**
48
+ * Highlight a single code line. Returns an array of parts that may carry a
49
+ * `color` for Ink/TerminalRenderer to render. Falls back to dim text when the
50
+ * language is unknown or the line is a fence marker.
51
+ */
52
+ export function highlightCodeLine(line, lang) {
53
+ const trimmed = line.trimStart();
54
+ const comments = COMMENT_MARKERS[lang] ?? [];
55
+ const keywords = KEYWORDS[lang] ?? [];
56
+ const kwSet = new Set(keywords);
57
+ // Whole-line comment → dim.
58
+ for (const c of comments) {
59
+ if (trimmed.startsWith(c))
60
+ return [{ text: line, dim: true, code: true }];
61
+ }
62
+ const parts = [];
63
+ let i = 0;
64
+ let inString = false;
65
+ let strDelim = '';
66
+ let buf = '';
67
+ const flush = () => {
68
+ if (!buf)
69
+ return;
70
+ parts.push({ text: buf, code: true });
71
+ buf = '';
72
+ };
73
+ const flushKw = (tok) => {
74
+ if (kwSet.has(tok))
75
+ parts.push({ text: tok, color: 'cyan', code: true });
76
+ else if (/^\d+(\.\d+)?$/.test(tok))
77
+ parts.push({ text: tok, color: 'yellow', code: true });
78
+ else
79
+ parts.push({ text: tok, code: true });
80
+ };
81
+ while (i < line.length) {
82
+ const ch = line[i];
83
+ if (inString) {
84
+ buf += ch;
85
+ if (ch === strDelim) {
86
+ inString = false;
87
+ flush();
88
+ }
89
+ else if (ch === '\\' && i + 1 < line.length) {
90
+ buf += line[i + 1];
91
+ i += 2;
92
+ continue;
93
+ }
94
+ i++;
95
+ continue;
96
+ }
97
+ // Inline comment inside a code line.
98
+ for (const c of comments) {
99
+ if (c && line.slice(i, i + c.length) === c) {
100
+ flush();
101
+ parts.push({ text: line.slice(i), dim: true, code: true });
102
+ return parts;
103
+ }
104
+ }
105
+ if (STRING_DELIMS.includes(ch)) {
106
+ flush();
107
+ inString = true;
108
+ strDelim = ch;
109
+ buf = ch;
110
+ i++;
111
+ continue;
112
+ }
113
+ if (/[A-Za-z_$]/.test(ch)) {
114
+ let j = i;
115
+ while (j < line.length && /[A-Za-z0-9_$]/.test(line[j]))
116
+ j++;
117
+ const tok = line.slice(i, j);
118
+ flushKw(tok);
119
+ i = j;
120
+ continue;
121
+ }
122
+ if (/[0-9]/.test(ch)) {
123
+ let j = i;
124
+ while (j < line.length && /[0-9._]/.test(line[j]))
125
+ j++;
126
+ flush();
127
+ parts.push({ text: line.slice(i, j), color: 'yellow', code: true });
128
+ i = j;
129
+ continue;
130
+ }
131
+ buf += ch;
132
+ i++;
133
+ }
134
+ flush();
135
+ if (parts.length === 0)
136
+ parts.push({ text: line, code: true });
137
+ return parts;
138
+ }
139
+ /** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
140
+ export const FILE_LINE_RE = /((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
141
+ export function annotateFileLinks(parts) {
142
+ const out = [];
143
+ for (const p of parts) {
144
+ if (p.code || !p.text) {
145
+ out.push(p);
146
+ continue;
147
+ }
148
+ // Preserve the original part's styling on the unattributed plain-text runs.
149
+ const style = { text: '', ...(p.bold ? { bold: true } : {}), ...(p.dim ? { dim: true } : {}) };
150
+ FILE_LINE_RE.lastIndex = 0;
151
+ let last = 0;
152
+ let m;
153
+ let matched = false;
154
+ while ((m = FILE_LINE_RE.exec(p.text))) {
155
+ matched = true;
156
+ if (m.index > last)
157
+ out.push({ ...style, text: p.text.slice(last, m.index) });
158
+ const path = m[1];
159
+ const line = m[2];
160
+ const col = m[3] ? `:${m[3]}` : '';
161
+ out.push({ text: `${path}:${line}${col}`, href: `file://${path}#L${line}` });
162
+ last = m.index + m[0].length;
163
+ }
164
+ if (!matched)
165
+ out.push(p);
166
+ else if (last < p.text.length)
167
+ out.push({ ...style, text: p.text.slice(last) });
168
+ }
169
+ return out;
170
+ }
9
171
  function parseInline(s) {
10
172
  const parts = [];
11
173
  let last = 0;
@@ -37,15 +199,19 @@ function parseInline(s) {
37
199
  export function renderMarkdownLines(text) {
38
200
  const out = [];
39
201
  let inFence = false;
202
+ let fenceLang = '';
40
203
  for (const raw of text.split('\n')) {
41
204
  const trimmed = raw.trim();
42
205
  if (trimmed.startsWith('```')) {
206
+ if (!inFence)
207
+ fenceLang = trimmed.replace(/^```/, '').trim().toLowerCase();
43
208
  inFence = !inFence;
44
209
  out.push({ parts: [{ text: raw, dim: true }], fence: true });
45
210
  continue;
46
211
  }
47
212
  if (inFence) {
48
- out.push({ parts: [{ text: raw, dim: true, code: true }], fence: true });
213
+ // R4: syntax-highlight the code line, annotated with file:line links.
214
+ out.push({ parts: annotateFileLinks(highlightCodeLine(raw, fenceLang)), fence: true });
49
215
  continue;
50
216
  }
51
217
  const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
@@ -57,7 +223,8 @@ export function renderMarkdownLines(text) {
57
223
  });
58
224
  continue;
59
225
  }
60
- out.push({ parts: parseInline(raw), fence: false });
226
+ // R4: clickable file:line references in ordinary prose too.
227
+ out.push({ parts: annotateFileLinks(parseInline(raw)), fence: false });
61
228
  }
62
229
  return out;
63
230
  }
@@ -73,6 +73,8 @@ describe('scroll flow diagnostics', () => {
73
73
  expect(lastFrame() ?? '').toContain('number 4');
74
74
  });
75
75
  it('wrapped long item: pin mid-item, stream, same first line stays', async () => {
76
+ // Heavy string measurement + React flush under full-suite parallel load;
77
+ // the default 10s testTimeout flaked — extend for this one.
76
78
  const long = Array.from({ length: 10 }, (_, i) => `WRAPLINE-${i} ` + 'x'.repeat(180)).join('\n');
77
79
  const items = [
78
80
  { id: 'w1', kind: 'text', text: long, role: 'assistant' },
@@ -90,5 +92,5 @@ describe('scroll flow diagnostics', () => {
90
92
  }
91
93
  const after = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
92
94
  expect(after).toBe(before); // anchor stability at line granularity
93
- });
95
+ }, 30_000);
94
96
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",