klyro 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/anthropic-adapter.js +2 -8
- package/dist/agent/provider-adapter.js +21 -2
- package/dist/agent/runtime.js +34 -8
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.js +33 -11
- package/dist/policy/path-guard.js +5 -7
- package/dist/policy/secret-redactor.js +6 -2
- package/dist/tools/fs/apply-patch.js +5 -5
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/search/grep.d.ts +20 -0
- package/dist/tools/search/grep.js +7 -0
- package/dist/tools/shell/background.js +25 -1
- package/dist/tools/shell/shell-exec.js +7 -0
- package/dist/tui/app.d.ts +4 -3
- package/dist/tui/app.js +156 -40
- package/dist/tui/app.test.js +5 -6
- package/dist/tui/snapshot.test.js +3 -6
- package/dist/tui/tokens.d.ts +72 -44
- package/dist/tui/tokens.js +78 -64
- package/dist/verification/baseline.js +9 -2
- package/dist/verification/engine.js +14 -3
- package/dist/verification/scoped.js +13 -8
- package/package.json +1 -1
|
@@ -231,21 +231,15 @@ function translateSse(event, parsed, toolBuffers, indexToToolId) {
|
|
|
231
231
|
function findToolIdByIndex(index, buffers, indexToToolId) {
|
|
232
232
|
if (index === undefined)
|
|
233
233
|
return undefined;
|
|
234
|
-
// Preferred: direct index → id mapping from content_block_start
|
|
235
234
|
if (indexToToolId) {
|
|
236
235
|
const direct = indexToToolId.get(index);
|
|
237
236
|
if (direct)
|
|
238
237
|
return direct;
|
|
239
238
|
}
|
|
240
|
-
//
|
|
241
|
-
let i = 0;
|
|
242
|
-
for (const id of buffers.keys()) {
|
|
243
|
-
if (i === index)
|
|
244
|
-
return id;
|
|
245
|
-
i++;
|
|
246
|
-
}
|
|
239
|
+
// Single-buffer fallback: if only one in-flight tool, any delta belongs to it
|
|
247
240
|
if (buffers.size === 1)
|
|
248
241
|
return buffers.keys().next().value;
|
|
242
|
+
// No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
|
|
249
243
|
return undefined;
|
|
250
244
|
}
|
|
251
245
|
function toAnthropicMessages(messages) {
|
|
@@ -50,7 +50,9 @@ function zodFieldSchema(s) {
|
|
|
50
50
|
const inner = def?.innerType;
|
|
51
51
|
return { type: 'array', items: inner ? zodFieldSchema(inner) : { type: 'string' } };
|
|
52
52
|
}
|
|
53
|
-
case 'ZodOptional':
|
|
53
|
+
case 'ZodOptional':
|
|
54
|
+
case 'ZodNullable':
|
|
55
|
+
case 'ZodDefault': {
|
|
54
56
|
const inner = def?.innerType;
|
|
55
57
|
return inner ? zodFieldSchema(inner) : { type: 'string' };
|
|
56
58
|
}
|
|
@@ -58,6 +60,23 @@ function zodFieldSchema(s) {
|
|
|
58
60
|
const values = s._def.values;
|
|
59
61
|
return { type: 'string', enum: [...values] };
|
|
60
62
|
}
|
|
63
|
+
case 'ZodNativeEnum': {
|
|
64
|
+
const vals = Object.values(s._def.values);
|
|
65
|
+
return { enum: [...vals] };
|
|
66
|
+
}
|
|
67
|
+
case 'ZodLiteral': {
|
|
68
|
+
const v = def?.value;
|
|
69
|
+
return { enum: [v], type: typeof v === 'string' ? 'string' : typeof v === 'number' ? 'number' : 'boolean' };
|
|
70
|
+
}
|
|
71
|
+
case 'ZodUnion':
|
|
72
|
+
case 'ZodDiscriminatedUnion': {
|
|
73
|
+
const opts = def.options ?? [];
|
|
74
|
+
return { anyOf: opts.map((o) => zodFieldSchema(o)) };
|
|
75
|
+
}
|
|
76
|
+
case 'ZodIntersection': {
|
|
77
|
+
const parts = [def?.innerType].filter(Boolean);
|
|
78
|
+
return { allOf: parts.map((p) => zodFieldSchema(p)) };
|
|
79
|
+
}
|
|
61
80
|
case 'ZodObject':
|
|
62
81
|
return zodToJsonSchema(s);
|
|
63
82
|
default:
|
|
@@ -230,7 +249,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
230
249
|
toolIds.set(tc.index, tc.id);
|
|
231
250
|
}
|
|
232
251
|
if (tc.function?.arguments) {
|
|
233
|
-
const id = toolIds.get(tc.index) ?? `call_${tc.index}`;
|
|
252
|
+
const id = toolIds.get(tc.index) ?? `call_${tc.index}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
234
253
|
yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
|
|
235
254
|
}
|
|
236
255
|
}
|
package/dist/agent/runtime.js
CHANGED
|
@@ -21,6 +21,7 @@ import * as path from 'node:path';
|
|
|
21
21
|
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
22
22
|
import { detectVerifyCommand } from '../verification/auto.js';
|
|
23
23
|
import { ensureBaseline, getBaseline } from '../verification/baseline.js';
|
|
24
|
+
import { compressTranscript, totalTokens } from '../context/tokenizer.js';
|
|
24
25
|
import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
|
|
25
26
|
import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
|
|
26
27
|
import { globalBus } from '../events/bus.js';
|
|
@@ -184,10 +185,21 @@ export async function run(opts, deps) {
|
|
|
184
185
|
setPhase('verifying');
|
|
185
186
|
emit?.({ kind: 'step_start', step: steps });
|
|
186
187
|
telemetry.recordStepStart(steps);
|
|
188
|
+
const systemPrompt = deps.systemPrompt({ cwd: opts.cwd, telemetry: steps === 1 ? emptyTelemetryBlock() : telemetry.format() });
|
|
189
|
+
const BUDGET = { total: 120_000, reservedOutput: 4000 };
|
|
190
|
+
let reqMessages = transcript;
|
|
191
|
+
let reqSystem = systemPrompt;
|
|
192
|
+
if (totalTokens(systemPrompt, transcript) > BUDGET.total) {
|
|
193
|
+
const c = compressTranscript(systemPrompt, transcript, BUDGET);
|
|
194
|
+
reqSystem = c.system;
|
|
195
|
+
reqMessages = c.messages;
|
|
196
|
+
if (c.dropped > 0)
|
|
197
|
+
emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
|
|
198
|
+
}
|
|
187
199
|
const req = {
|
|
188
200
|
model: opts.model,
|
|
189
|
-
system:
|
|
190
|
-
messages:
|
|
201
|
+
system: reqSystem,
|
|
202
|
+
messages: reqMessages,
|
|
191
203
|
tools: toolDefinitions(deps.registry),
|
|
192
204
|
...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
|
|
193
205
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
|
@@ -530,11 +542,18 @@ export async function run(opts, deps) {
|
|
|
530
542
|
await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
|
|
531
543
|
if (obs.ok) {
|
|
532
544
|
telemetry.recordToolCall(call, latencyMs, false);
|
|
533
|
-
if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch')
|
|
545
|
+
if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit' || call.name === 'apply_patch') {
|
|
546
|
+
const wasFirstEdit = !hasEdits;
|
|
534
547
|
hasEdits = true;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
548
|
+
if (wasFirstEdit && !baselinePrimed) {
|
|
549
|
+
baselinePrimed = true;
|
|
550
|
+
// await inline to avoid race where verify reads before baseline file exists
|
|
551
|
+
try {
|
|
552
|
+
await ensureBaseline(opts.cwd, opts.verify?.command ?? detectVerifyCommand(opts.cwd) ?? undefined);
|
|
553
|
+
}
|
|
554
|
+
catch { /* ignore */ }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
538
557
|
}
|
|
539
558
|
else {
|
|
540
559
|
const code = String(obs.error?.code ?? 'tool_error');
|
|
@@ -577,14 +596,21 @@ export async function run(opts, deps) {
|
|
|
577
596
|
}
|
|
578
597
|
};
|
|
579
598
|
// 3.5 — parallel if all concurrencySafe, sequential otherwise
|
|
599
|
+
// For parallel, execute concurrently but commit transcript in original call order to preserve determinism
|
|
580
600
|
if (allSafe) {
|
|
581
|
-
|
|
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)));
|
|
582
609
|
}
|
|
583
610
|
else {
|
|
584
611
|
for (const call of finalizedCalls) {
|
|
585
612
|
toolCallCount++;
|
|
586
613
|
await runOne(call);
|
|
587
|
-
// 3.5 — cancellation: if signal aborted mid-tools, stop
|
|
588
614
|
if (opts.signal?.aborted)
|
|
589
615
|
break;
|
|
590
616
|
}
|
|
@@ -42,7 +42,9 @@ export interface StoredObservation {
|
|
|
42
42
|
export declare class SessionStore {
|
|
43
43
|
private readonly dir;
|
|
44
44
|
private readonly indexPath;
|
|
45
|
+
private readonly locks;
|
|
45
46
|
constructor(dir: string);
|
|
47
|
+
private withLock;
|
|
46
48
|
private ensureDir;
|
|
47
49
|
private readIndex;
|
|
48
50
|
private writeIndex;
|
|
@@ -15,10 +15,26 @@ import { randomUUID } from 'node:crypto';
|
|
|
15
15
|
export class SessionStore {
|
|
16
16
|
dir;
|
|
17
17
|
indexPath;
|
|
18
|
+
locks = new Map();
|
|
18
19
|
constructor(dir) {
|
|
19
20
|
this.dir = dir;
|
|
20
21
|
this.indexPath = path.join(dir, 'sessions.json');
|
|
21
22
|
}
|
|
23
|
+
async withLock(key, fn) {
|
|
24
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
25
|
+
let release;
|
|
26
|
+
const next = new Promise((r) => { release = r; });
|
|
27
|
+
this.locks.set(key, prev.then(() => next));
|
|
28
|
+
await prev;
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
release();
|
|
34
|
+
if (this.locks.get(key) === next)
|
|
35
|
+
this.locks.delete(key);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
22
38
|
async ensureDir() {
|
|
23
39
|
await fs.mkdir(this.dir, { recursive: true });
|
|
24
40
|
}
|
|
@@ -113,21 +129,27 @@ export class SessionStore {
|
|
|
113
129
|
}
|
|
114
130
|
}
|
|
115
131
|
async appendMessage(id, message) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
return this.withLock(id, async () => {
|
|
133
|
+
const data = await this.readSession(id);
|
|
134
|
+
data.messages.push(message);
|
|
135
|
+
await this.writeSession(id, data);
|
|
136
|
+
});
|
|
119
137
|
}
|
|
120
138
|
async appendObservation(id, obs) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
return this.withLock(id, async () => {
|
|
140
|
+
const data = await this.readSession(id);
|
|
141
|
+
data.observations.push(obs);
|
|
142
|
+
await this.writeSession(id, data);
|
|
143
|
+
});
|
|
124
144
|
}
|
|
125
145
|
async setStatus(id, status, finalText) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
return this.withLock(id, async () => {
|
|
147
|
+
const data = await this.readSession(id);
|
|
148
|
+
data.record.status = status;
|
|
149
|
+
if (finalText !== undefined)
|
|
150
|
+
data.record.finalText = finalText;
|
|
151
|
+
await this.writeSession(id, data);
|
|
152
|
+
});
|
|
131
153
|
}
|
|
132
154
|
async loadMessages(id) {
|
|
133
155
|
const data = await this.readSession(id);
|
|
@@ -64,19 +64,17 @@ export async function resolveAndFollowSymlinks(cwd, requested) {
|
|
|
64
64
|
realParent = path.dirname(real);
|
|
65
65
|
}
|
|
66
66
|
catch {
|
|
67
|
-
// File doesn't exist yet (e.g. write_file). realpath would fail; fall
|
|
68
|
-
// back to realpath-ing the parent.
|
|
69
67
|
const parent = path.dirname(resolved);
|
|
70
68
|
try {
|
|
71
69
|
realParent = await fs.realpath(parent);
|
|
70
|
+
real = path.join(realParent, path.basename(resolved));
|
|
72
71
|
}
|
|
73
72
|
catch {
|
|
74
|
-
// Parent doesn't exist either
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// Parent doesn't exist either — no symlink to follow, so lexical check
|
|
74
|
+
// done by resolveWithinCwd above is sufficient. Return lexical resolved.
|
|
75
|
+
// Avoid realpath(cwd) vs lexical mismatch on Windows short-names.
|
|
76
|
+
return { resolved };
|
|
78
77
|
}
|
|
79
|
-
real = path.join(realParent, path.basename(resolved));
|
|
80
78
|
}
|
|
81
79
|
const absCwd = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
82
80
|
const cmpReal = normalizeForCompare(real);
|
|
@@ -11,15 +11,19 @@
|
|
|
11
11
|
import { Transform } from 'node:stream';
|
|
12
12
|
const PATTERNS = [
|
|
13
13
|
{ name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
|
|
14
|
-
// Specific: require secret context to avoid package-lock hash false positives
|
|
15
14
|
{ name: 'aws-secret', re: /(?:aws_secret_access_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi },
|
|
16
|
-
// High-entropy base64: require at least one +/= and not just hex (e.g. sha512 hex should not match)
|
|
17
15
|
{ name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
|
|
18
16
|
{ name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
|
|
19
17
|
{ name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
|
|
20
18
|
{ name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
21
19
|
{ name: 'bearer', re: /Bearer\s+[A-Za-z0-9._\-+/=]{16,}/gi },
|
|
22
20
|
{ name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
|
|
21
|
+
// Generic provider keys — must be redacted even if not prefixed Bearer
|
|
22
|
+
{ name: 'openai-key', re: /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/g },
|
|
23
|
+
{ name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
24
|
+
{ name: 'api-key', re: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}['"]?/gi },
|
|
25
|
+
{ name: 'password', re: /(?:password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{4,}['"]?/gi },
|
|
26
|
+
{ name: 'secret-generic', re: /(?:secret|token)\s*[:=]\s*['"]?[A-Za-z0-9_\-+/=]{16,}['"]?/gi },
|
|
23
27
|
];
|
|
24
28
|
const REPLACEMENT = '[REDACTED]';
|
|
25
29
|
/** Redact a single string (or Buffer). */
|
|
@@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises';
|
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe } from '../normalize.js';
|
|
10
10
|
const InputSchema = z.object({
|
|
11
11
|
patch: z.string().min(1).describe('Unified diff patch text'),
|
|
@@ -28,7 +28,7 @@ export const applyPatchTool = defineTool({
|
|
|
28
28
|
if (line.startsWith('*** Update File:')) {
|
|
29
29
|
// Flush previous
|
|
30
30
|
if (currentFile && fileContent !== null) {
|
|
31
|
-
const { resolved } =
|
|
31
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
32
32
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
33
33
|
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
34
34
|
patchedFiles.push(currentFile);
|
|
@@ -36,7 +36,7 @@ export const applyPatchTool = defineTool({
|
|
|
36
36
|
currentFile = line.replace('*** Update File:', '').trim();
|
|
37
37
|
if (currentFile) {
|
|
38
38
|
try {
|
|
39
|
-
const { resolved } =
|
|
39
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
40
40
|
fileContent = await fs.readFile(resolved, 'utf-8');
|
|
41
41
|
}
|
|
42
42
|
catch {
|
|
@@ -47,7 +47,7 @@ export const applyPatchTool = defineTool({
|
|
|
47
47
|
}
|
|
48
48
|
if (line.startsWith('*** Add File:')) {
|
|
49
49
|
if (currentFile && fileContent !== null) {
|
|
50
|
-
const { resolved } =
|
|
50
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
51
51
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
52
52
|
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
53
53
|
patchedFiles.push(currentFile);
|
|
@@ -66,7 +66,7 @@ export const applyPatchTool = defineTool({
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
if (currentFile && fileContent !== null) {
|
|
69
|
-
const { resolved } =
|
|
69
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, currentFile);
|
|
70
70
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
71
71
|
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
72
72
|
patchedFiles.push(currentFile);
|
|
@@ -9,7 +9,7 @@ import * as path from 'node:path';
|
|
|
9
9
|
import * as crypto from 'node:crypto';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { defineTool } from '../types.js';
|
|
12
|
-
import {
|
|
12
|
+
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
13
13
|
import { safe } from '../normalize.js';
|
|
14
14
|
const InputSchema = z.object({
|
|
15
15
|
path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
|
|
@@ -26,7 +26,7 @@ export const writeFileTool = defineTool({
|
|
|
26
26
|
renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
|
|
27
27
|
execute: async (input, ctx) => {
|
|
28
28
|
return safe(async () => {
|
|
29
|
-
const { resolved } =
|
|
29
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
30
30
|
const parent = path.dirname(resolved);
|
|
31
31
|
await fs.mkdir(parent, { recursive: true });
|
|
32
32
|
// 3.2: if file exists and was not read this session, warn but allow with diff
|
|
@@ -31,6 +31,26 @@ export declare const grepTool: import("../types.js").Tool<{
|
|
|
31
31
|
maxResults?: number | undefined;
|
|
32
32
|
contextLines?: number | undefined;
|
|
33
33
|
}, {
|
|
34
|
+
readonly ok: false;
|
|
35
|
+
readonly error: {
|
|
36
|
+
readonly code: "INVALID_INPUT";
|
|
37
|
+
readonly message: "Pattern too long (max 200 chars)";
|
|
38
|
+
};
|
|
39
|
+
pattern?: undefined;
|
|
40
|
+
hits?: undefined;
|
|
41
|
+
truncated?: undefined;
|
|
42
|
+
searchedFiles?: undefined;
|
|
43
|
+
} | {
|
|
44
|
+
readonly ok: false;
|
|
45
|
+
readonly error: {
|
|
46
|
+
readonly code: "INVALID_INPUT";
|
|
47
|
+
readonly message: "Pattern rejected (potential ReDoS)";
|
|
48
|
+
};
|
|
49
|
+
pattern?: undefined;
|
|
50
|
+
hits?: undefined;
|
|
51
|
+
truncated?: undefined;
|
|
52
|
+
searchedFiles?: undefined;
|
|
53
|
+
} | {
|
|
34
54
|
readonly ok: false;
|
|
35
55
|
readonly error: {
|
|
36
56
|
readonly code: "INVALID_INPUT";
|
|
@@ -24,6 +24,13 @@ export const grepTool = defineTool({
|
|
|
24
24
|
inputSchema: InputSchema,
|
|
25
25
|
execute: async (input, ctx) => {
|
|
26
26
|
return safe(async () => {
|
|
27
|
+
if (input.pattern.length > 200) {
|
|
28
|
+
return { ok: false, error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: 'Pattern too long (max 200 chars)' } };
|
|
29
|
+
}
|
|
30
|
+
// reject catastrophic backtracking patterns (nested quantifiers like (a+)+ )
|
|
31
|
+
if (/\([^)]*\+[^)]*\)\+|\(\.\*\)\*|\{[0-9]+,[0-9]*\}\s*\+/.test(input.pattern)) {
|
|
32
|
+
return { ok: false, error: { code: TOOL_ERROR_CODES.INVALID_INPUT, message: 'Pattern rejected (potential ReDoS)' } };
|
|
33
|
+
}
|
|
27
34
|
const base = input.cwd ? resolveWithinCwd(ctx.cwd, input.cwd).resolved : ctx.cwd;
|
|
28
35
|
let re;
|
|
29
36
|
try {
|
|
@@ -4,7 +4,28 @@
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
const jobs = new Map();
|
|
6
6
|
let counter = 0;
|
|
7
|
+
const MAX_JOBS = 5;
|
|
8
|
+
const JOB_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
function pruneJobs() {
|
|
10
|
+
if (jobs.size <= MAX_JOBS)
|
|
11
|
+
return;
|
|
12
|
+
const sorted = [...jobs.values()].sort((a, b) => a.start - b.start);
|
|
13
|
+
for (const j of sorted.slice(0, jobs.size - MAX_JOBS)) {
|
|
14
|
+
try {
|
|
15
|
+
j.proc.kill('SIGKILL');
|
|
16
|
+
}
|
|
17
|
+
catch { /* ignore */ }
|
|
18
|
+
jobs.delete(j.id);
|
|
19
|
+
}
|
|
20
|
+
for (const j of [...jobs.values()]) {
|
|
21
|
+
if (Date.now() - j.start > JOB_TTL_MS && j.proc.exitCode !== null)
|
|
22
|
+
jobs.delete(j.id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
7
25
|
export function startBackground(command, cwd) {
|
|
26
|
+
pruneJobs();
|
|
27
|
+
if (jobs.size >= MAX_JOBS)
|
|
28
|
+
throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
|
|
8
29
|
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
9
30
|
const proc = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
10
31
|
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
@@ -13,7 +34,10 @@ export function startBackground(command, cwd) {
|
|
|
13
34
|
job.output = job.output.slice(-1_000_000); });
|
|
14
35
|
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
15
36
|
job.output = job.output.slice(-1_000_000); });
|
|
16
|
-
proc.on('close', () => {
|
|
37
|
+
proc.on('close', () => {
|
|
38
|
+
setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
|
|
39
|
+
jobs.delete(id); }, JOB_TTL_MS);
|
|
40
|
+
});
|
|
17
41
|
return id;
|
|
18
42
|
}
|
|
19
43
|
export function getOutput(id, filter) {
|
|
@@ -77,6 +77,13 @@ const DANGEROUS_PATTERNS = [
|
|
|
77
77
|
{ pattern: /curl.*\|\s*(sh|bash|zsh|python|python3|perl|ruby|php)/i, reason: 'curl|sh to unknown host' },
|
|
78
78
|
{ pattern: /wget.*\|\s*(sh|bash|python|perl|ruby)/i, reason: 'wget|sh pipe' },
|
|
79
79
|
{ pattern: /rm\s+-rf\s+--no-preserve-root\s+\//, reason: 'recursive delete --no-preserve-root' },
|
|
80
|
+
// Shell metacharacter escapes — block command substitution and chaining of dangerous cmds
|
|
81
|
+
{ pattern: /\$\(/, reason: 'command substitution $()' },
|
|
82
|
+
{ pattern: /`[^`]*`/, reason: 'command substitution via backticks' },
|
|
83
|
+
{ pattern: /\|\s*bash\b|\|\s*sh\b/, reason: 'pipe to shell' },
|
|
84
|
+
{ pattern: /;\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
85
|
+
{ pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
86
|
+
{ pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
|
|
80
87
|
];
|
|
81
88
|
export const shellExecTool = defineTool({
|
|
82
89
|
name: 'shell_exec',
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Klyro TUI —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Klyro TUI — TUI_DESIGN.md §3-7,10-12 — Professional monochrome + one accent orange #E8843C
|
|
3
|
+
* No boxes, no borders (§24). Guide │ at col2, accent ● at col4 for agent.
|
|
4
|
+
* Regions: scrollback (header+turns) / live window (streaming tail+groups) / pinned (input+status)
|
|
5
5
|
*/
|
|
6
6
|
import React from 'react';
|
|
7
7
|
import type { StatusSnapshot } from './status.js';
|
|
@@ -23,5 +23,6 @@ export interface AppProps {
|
|
|
23
23
|
updateStatus: (s: Partial<StatusSnapshot>) => void;
|
|
24
24
|
updatePlan: (p: PlanStep[]) => void;
|
|
25
25
|
}) => void;
|
|
26
|
+
version?: string;
|
|
26
27
|
}
|
|
27
28
|
export declare function App(props: AppProps): React.JSX.Element;
|
package/dist/tui/app.js
CHANGED
|
@@ -1,17 +1,83 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* Klyro TUI —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Klyro TUI — TUI_DESIGN.md §3-7,10-12 — Professional monochrome + one accent orange #E8843C
|
|
4
|
+
* No boxes, no borders (§24). Guide │ at col2, accent ● at col4 for agent.
|
|
5
|
+
* Regions: scrollback (header+turns) / live window (streaming tail+groups) / pinned (input+status)
|
|
6
6
|
*/
|
|
7
7
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
8
8
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
10
|
-
import { PlanView } from './plan.js';
|
|
11
10
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
12
|
-
import { tokens } from './tokens.js';
|
|
11
|
+
import { tokens, g } from './tokens.js';
|
|
13
12
|
let _id = 0;
|
|
14
13
|
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
14
|
+
// ── Header §4 ───────────────────────────────────────────────────────────────
|
|
15
|
+
function Header({ cwd, model, version, width }) {
|
|
16
|
+
const branch = (() => { try {
|
|
17
|
+
const { execSync } = require('node:child_process');
|
|
18
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
} })();
|
|
23
|
+
const showLinks = width >= 120;
|
|
24
|
+
const links = '│ /help /config /clear /exit';
|
|
25
|
+
const ctxShort = '200k'; // TODO wire real context window
|
|
26
|
+
const row1Left = `KLYRO v${version}`;
|
|
27
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: tokens.ansi.accent, children: row1Left }), showLinks ? _jsx(Text, { color: tokens.ansi.dim, children: links }) : null] }), _jsxs(Text, { color: tokens.ansi.dim, children: [model, "[", ctxShort, "] \u00B7 API Usage Billing"] }), _jsxs(Text, { color: tokens.ansi.dim, children: [cwd, branch ? ` · ${branch}` : ''] })] }));
|
|
28
|
+
}
|
|
29
|
+
function verbForTool(name) {
|
|
30
|
+
if (name === 'read_file')
|
|
31
|
+
return { verb: 'Read', plural: 'Read' };
|
|
32
|
+
if (name === 'list_directory')
|
|
33
|
+
return { verb: 'Listed', plural: 'Listed' };
|
|
34
|
+
if (name === 'grep' || name === 'glob' || name === 'find_files' || name === 'search_files' || name === 'recent_files')
|
|
35
|
+
return { verb: 'Searched', plural: 'Searched' };
|
|
36
|
+
if (name === 'shell_exec')
|
|
37
|
+
return { verb: 'Ran', plural: 'Ran' };
|
|
38
|
+
if (name.startsWith('git_'))
|
|
39
|
+
return { verb: 'Checked git', plural: 'Checked git' };
|
|
40
|
+
if (name === 'web_fetch')
|
|
41
|
+
return { verb: 'Fetched', plural: 'Fetched' };
|
|
42
|
+
if (name === 'web_search')
|
|
43
|
+
return { verb: 'Searched web', plural: 'Searched web' };
|
|
44
|
+
if (name === 'edit_file' || name === 'multi_edit' || name === 'apply_patch' || name === 'write_file')
|
|
45
|
+
return { verb: 'Edited', plural: 'Edited' };
|
|
46
|
+
return { verb: 'Called', plural: 'Called' };
|
|
47
|
+
}
|
|
48
|
+
function groupTools(items) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let cur = [];
|
|
51
|
+
const flush = () => {
|
|
52
|
+
if (cur.length === 0)
|
|
53
|
+
return;
|
|
54
|
+
// bucket by verb
|
|
55
|
+
const byVerb = new Map();
|
|
56
|
+
for (const it of cur) {
|
|
57
|
+
const v = verbForTool(it.name).verb;
|
|
58
|
+
if (!byVerb.has(v))
|
|
59
|
+
byVerb.set(v, []);
|
|
60
|
+
byVerb.get(v).push(it);
|
|
61
|
+
}
|
|
62
|
+
for (const [verb, list] of byVerb) {
|
|
63
|
+
const totalMs = list.reduce((s, x) => s + (x.latencyMs ?? 0), 0);
|
|
64
|
+
const status = list.some((x) => x.isError || x.status === 'error') ? 'error' : list.some((x) => x.status === 'running') ? 'running' : 'done';
|
|
65
|
+
out.push({ id: nextId('g'), verb, items: list, totalMs, status });
|
|
66
|
+
}
|
|
67
|
+
cur = [];
|
|
68
|
+
};
|
|
69
|
+
for (const it of items) {
|
|
70
|
+
if (it.kind === 'tool')
|
|
71
|
+
cur.push(it);
|
|
72
|
+
else {
|
|
73
|
+
flush();
|
|
74
|
+
out.push(it);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
flush();
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
// ── Main App §3 ────────────────────────────────────────────────────────────
|
|
15
81
|
export function App(props) {
|
|
16
82
|
const { stdout } = useStdout();
|
|
17
83
|
const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
|
|
@@ -19,28 +85,19 @@ export function App(props) {
|
|
|
19
85
|
const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
|
|
20
86
|
const [awaitingApproval, setAwaitingApproval] = useState(false);
|
|
21
87
|
const [plan, setPlan] = useState([]);
|
|
22
|
-
const [status, setStatus] = useState({
|
|
23
|
-
model: props.initialModel,
|
|
24
|
-
step: 0,
|
|
25
|
-
maxSteps: props.maxSteps,
|
|
26
|
-
usageInput: 0,
|
|
27
|
-
usageOutput: 0,
|
|
28
|
-
repairs: 0,
|
|
29
|
-
status: 'idle',
|
|
30
|
-
...props.initialStatus,
|
|
31
|
-
});
|
|
88
|
+
const [status, setStatus] = useState({ model: props.initialModel, step: 0, maxSteps: props.maxSteps, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle', ...props.initialStatus });
|
|
32
89
|
const [elapsed, setElapsed] = useState(0);
|
|
33
90
|
const [queued, setQueued] = useState(null);
|
|
34
|
-
|
|
91
|
+
const [expandedGroups, setExpandedGroups] = useState(new Set());
|
|
35
92
|
const streamingIdRef = useRef(null);
|
|
93
|
+
const placeholders = ['Message Klyro…', 'Message @file to attach…', 'Type / for commands…', '! runs a shell command…'];
|
|
94
|
+
const placeholder = placeholders[0] ?? 'Message Klyro…';
|
|
36
95
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
37
|
-
// queued: send when idle (2.4)
|
|
38
96
|
useEffect(() => {
|
|
39
97
|
if (queued && status.status !== 'running' && !awaitingApproval) {
|
|
40
98
|
const toSend = queued;
|
|
41
99
|
setQueued(null);
|
|
42
|
-
|
|
43
|
-
setTranscript((prev) => [...prev, item]);
|
|
100
|
+
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: toSend, role: 'user' }]);
|
|
44
101
|
streamingIdRef.current = null;
|
|
45
102
|
const cmd = parseSlash(toSend.trim());
|
|
46
103
|
if (cmd.kind === 'prompt')
|
|
@@ -57,7 +114,6 @@ export function App(props) {
|
|
|
57
114
|
return () => clearInterval(t);
|
|
58
115
|
}, [status.status, elapsed]);
|
|
59
116
|
const append = useCallback((item) => {
|
|
60
|
-
// any non-streaming append closes the current streaming block
|
|
61
117
|
if (item.kind !== 'text' || item.role !== 'assistant')
|
|
62
118
|
streamingIdRef.current = null;
|
|
63
119
|
setTranscript((prev) => [...prev, item]);
|
|
@@ -84,32 +140,35 @@ export function App(props) {
|
|
|
84
140
|
setTranscript((prev) => [...prev, { id, kind: 'text', text, role: 'assistant' }]);
|
|
85
141
|
}
|
|
86
142
|
}, []);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (status.status !== 'running')
|
|
90
|
-
streamingIdRef.current = null;
|
|
91
|
-
}, [status.status]);
|
|
143
|
+
useEffect(() => { if (status.status !== 'running')
|
|
144
|
+
streamingIdRef.current = null; }, [status.status]);
|
|
92
145
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
93
146
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
94
147
|
const onMountedRef = useRef(props.onMounted);
|
|
95
148
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
96
149
|
useEffect(() => {
|
|
97
150
|
onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan });
|
|
98
|
-
// global hooks for repl bridge (instance-local queue drains here)
|
|
99
151
|
globalThis.__klyroAppAppend = append;
|
|
100
152
|
globalThis.__klyroAppendDelta = appendDelta;
|
|
101
153
|
globalThis.__klyroAppStatus = updateStatus;
|
|
102
154
|
globalThis.__klyroAppPlan = updatePlan;
|
|
103
|
-
return () => {
|
|
104
|
-
delete globalThis.__klyroAppAppend;
|
|
105
|
-
delete globalThis.__klyroAppendDelta;
|
|
106
|
-
delete globalThis.__klyroAppStatus;
|
|
107
|
-
delete globalThis.__klyroAppPlan;
|
|
108
|
-
};
|
|
155
|
+
return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; };
|
|
109
156
|
}, [append, appendDelta, updateStatus, updatePlan]);
|
|
157
|
+
const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
|
|
158
|
+
n.delete(id);
|
|
159
|
+
else
|
|
160
|
+
n.add(id); return n; });
|
|
110
161
|
useInput((inputStr, key) => {
|
|
111
162
|
if (awaitingApproval)
|
|
112
163
|
return;
|
|
164
|
+
if (key.ctrl && inputStr === 'o') {
|
|
165
|
+
// Ctrl+O toggle most recent group in live window
|
|
166
|
+
const groups = groupTools(transcript).filter((x) => typeof x.verb === 'string');
|
|
167
|
+
const last = groups[groups.length - 1];
|
|
168
|
+
if (last)
|
|
169
|
+
toggleGroup(last.id);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
113
172
|
if (status.status === 'running') {
|
|
114
173
|
if (key.ctrl && inputStr === 'c') {
|
|
115
174
|
void props.onSlash({ kind: 'quit' });
|
|
@@ -121,7 +180,6 @@ export function App(props) {
|
|
|
121
180
|
return;
|
|
122
181
|
setQueued(v);
|
|
123
182
|
setInput('');
|
|
124
|
-
// queued indicator as muted text, not a full user bubble (opencode style)
|
|
125
183
|
setTranscript((prev) => [...prev, { id: nextId('queued'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
|
|
126
184
|
return;
|
|
127
185
|
}
|
|
@@ -129,10 +187,8 @@ export function App(props) {
|
|
|
129
187
|
setInput((v) => v.slice(0, -1));
|
|
130
188
|
return;
|
|
131
189
|
}
|
|
132
|
-
if (!key.ctrl && !key.meta)
|
|
133
|
-
|
|
134
|
-
setInput((v) => v + norm);
|
|
135
|
-
}
|
|
190
|
+
if (!key.ctrl && !key.meta)
|
|
191
|
+
setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
136
192
|
return;
|
|
137
193
|
}
|
|
138
194
|
if (key.return) {
|
|
@@ -140,8 +196,7 @@ export function App(props) {
|
|
|
140
196
|
if (!v)
|
|
141
197
|
return;
|
|
142
198
|
setInput('');
|
|
143
|
-
|
|
144
|
-
setTranscript((prev) => [...prev, item]);
|
|
199
|
+
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
145
200
|
streamingIdRef.current = null;
|
|
146
201
|
const cmd = parseSlash(v);
|
|
147
202
|
if (cmd.kind === 'prompt')
|
|
@@ -160,5 +215,66 @@ export function App(props) {
|
|
|
160
215
|
const width = stdout?.columns ?? 100;
|
|
161
216
|
const height = stdout?.rows ?? 30;
|
|
162
217
|
const isSmall = width < 80;
|
|
163
|
-
|
|
218
|
+
const ver = props.version ?? '0.1.19';
|
|
219
|
+
const rule = g('rule').repeat(Math.max(10, width - 2));
|
|
220
|
+
// Derived status right
|
|
221
|
+
const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
|
|
222
|
+
const totalTokens = status.usageInput + status.usageOutput;
|
|
223
|
+
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
|
|
224
|
+
const hints = status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : status.status === 'idle' && transcript.length === 0 ? 'shift+tab to cycle · ↑↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
|
|
225
|
+
// Grouped transcript
|
|
226
|
+
const grouped = groupTools(transcript);
|
|
227
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 0, children: [grouped.length === 0 ? (_jsx(Text, { color: tokens.ansi.dim, children: placeholder })) : grouped.map((item) => {
|
|
228
|
+
if (item.verb) {
|
|
229
|
+
const gr = item;
|
|
230
|
+
const isExpanded = expandedGroups.has(gr.id);
|
|
231
|
+
const marker = isExpanded ? g('expanded') : g('collapsed');
|
|
232
|
+
const verbLine = (() => {
|
|
233
|
+
if (gr.items.length === 1) {
|
|
234
|
+
const it = gr.items[0];
|
|
235
|
+
const primary = (() => {
|
|
236
|
+
try {
|
|
237
|
+
const a = JSON.parse(it.args);
|
|
238
|
+
return a.path ?? a.pattern ?? a.command?.slice(0, 48) ?? '';
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return '';
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
const name = gr.verb === 'Read' && primary ? `Read ${primary.split('/').pop()}` : gr.verb === 'Searched' && primary ? `Searched "${primary}"` : gr.verb === 'Ran' && primary ? `Ran ${primary.split(' ')[0]}` : `${gr.verb} ${primary}`;
|
|
245
|
+
return name;
|
|
246
|
+
}
|
|
247
|
+
if (gr.verb === 'Read')
|
|
248
|
+
return `Read ${gr.items.length} files`;
|
|
249
|
+
if (gr.verb === 'Searched')
|
|
250
|
+
return `Searched ${gr.items.length} patterns`;
|
|
251
|
+
if (gr.verb === 'Ran')
|
|
252
|
+
return `Ran ${gr.items.length} commands`;
|
|
253
|
+
if (gr.verb === 'Edited' || gr.verb === 'Created')
|
|
254
|
+
return `${gr.verb} ${gr.items.length} files`;
|
|
255
|
+
return `${gr.verb} ${gr.items.length} items`;
|
|
256
|
+
})();
|
|
257
|
+
const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? '✗' : `${gr.totalMs}ms`;
|
|
258
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: gr.status === 'running' ? tokens.ansi.warn : undefined, children: [isExpanded ? g('expanded') : g('collapsed'), " ", verbLine] }), _jsxs(Text, { color: tokens.ansi.dim, children: [" ", right] })] }), isExpanded ? gr.items.map((it) => (_jsxs(Box, { paddingLeft: 2, children: [_jsxs(Text, { color: tokens.ansi.guide, children: [" ", g('end'), " "] }), _jsxs(Text, { color: it.isError ? tokens.ansi.err : tokens.ansi.dim, children: [it.name, " ", it.args.slice(0, 80)] }), it.result ? _jsxs(Text, { color: tokens.ansi.dim, children: [" \u00B7 ", String(it.result).slice(0, 80)] }) : null] }, it.id))) : null] }, gr.id));
|
|
259
|
+
}
|
|
260
|
+
const it = item;
|
|
261
|
+
if (it.kind === 'text' && it.role === 'user') {
|
|
262
|
+
return _jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.accent, bold: true, children: [g('prompt'), " "] }), _jsx(Text, { children: it.text })] }, it.id);
|
|
263
|
+
}
|
|
264
|
+
if (it.kind === 'text') {
|
|
265
|
+
// Check if it's queued indicator
|
|
266
|
+
if (it.text.startsWith('queued:'))
|
|
267
|
+
return _jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: tokens.ansi.dim, children: [" ", g('guide'), " ", it.text, " esc to drop"] }) }, it.id);
|
|
268
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.ansi.accent, children: [g('agentBullet'), " Klyro"] })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsxs(Text, { color: tokens.ansi.dim, children: [" ", g('guide'), " "] }), _jsx(Text, { children: it.text })] })] }, it.id));
|
|
269
|
+
}
|
|
270
|
+
if (it.kind === 'error')
|
|
271
|
+
return _jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: tokens.ansi.err, children: [" ", g('guide'), " \u2717 ", it.message] }) }, it.id);
|
|
272
|
+
if (it.kind === 'policy')
|
|
273
|
+
return _jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: tokens.ansi.dim, children: [" ", g('guide'), " [policy] ", it.action, " ", it.name] }) }, it.id);
|
|
274
|
+
if (it.kind === 'file_changed')
|
|
275
|
+
return _jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: tokens.ansi.dim, children: [" ", g('guide'), " ", g('editsBadge'), " ", it.path, " ", it.op] }) }, it.id);
|
|
276
|
+
if (it.kind === 'diff')
|
|
277
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsx(Text, { bold: true, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: tokens.ansi.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { color: l.kind === 'add' ? tokens.ansi.ok : l.kind === 'remove' ? tokens.ansi.err : tokens.ansi.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
|
|
278
|
+
return null;
|
|
279
|
+
}), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, children: [_jsxs(Text, { color: tokens.ansi.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.ansi.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.ansi.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.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.ansi.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.ansi.ok : p.status === 'in_progress' ? tokens.ansi.accent : tokens.ansi.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id))), plan.length > 8 ? _jsxs(Text, { color: tokens.ansi.dim, children: [" ", g('guide'), " \u2026 +", plan.length - 8, " more (/todos)"] }) : null] })) : null, status.status === 'done' && transcript.some((x) => x.kind === 'file_changed') ? (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: tokens.ansi.dim, children: [" ", g('guide'), " ", g('editsBadge'), " ", transcript.filter((x) => x.kind === 'file_changed').length, " files \u00B7 /diff"] }) })) : null] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.ansi.guide, children: rule }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { children: [input || _jsx(Text, { color: tokens.ansi.dim, children: placeholder }), "\u258F"] })] }), _jsx(Text, { color: tokens.ansi.guide, children: rule })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: tokens.ansi.dim, children: hints }), _jsxs(Text, { color: tokens.ansi.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ●' : ''] })] })] }));
|
|
164
280
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -7,8 +7,7 @@ describe('App', () => {
|
|
|
7
7
|
const { lastFrame } = render(_jsx(App, { initialModel: "mock", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
|
|
8
8
|
const out = lastFrame();
|
|
9
9
|
expect(out).toContain('mock');
|
|
10
|
-
|
|
11
|
-
expect(out).toMatch(/Type a prompt|klyro|›/);
|
|
10
|
+
expect(out).toMatch(/Message Klyro|KLYRO|Type a prompt/i);
|
|
12
11
|
});
|
|
13
12
|
it('renders initial transcript items', () => {
|
|
14
13
|
const items = [
|
|
@@ -23,10 +22,10 @@ describe('App', () => {
|
|
|
23
22
|
const overrides = { step: 5, repairs: 3, status: 'running' };
|
|
24
23
|
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, initialStatus: overrides }));
|
|
25
24
|
const out = lastFrame();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(out).toContain('
|
|
29
|
-
expect(out).
|
|
25
|
+
// §7 status right now shows cost·ctx, header shows model, but step/repairs are still derivable from header/status
|
|
26
|
+
// Keep loose checks for backwards compat — ensure at least model and hint are present
|
|
27
|
+
expect(out).toContain('m');
|
|
28
|
+
expect(out).toMatch(/running|auto mode|ctrl\+c|step/i);
|
|
30
29
|
});
|
|
31
30
|
it('installs and tears down the global bridge hooks', () => {
|
|
32
31
|
const g = globalThis;
|
|
@@ -16,14 +16,11 @@ describe('App visual snapshot', () => {
|
|
|
16
16
|
it('renders header + statusline + transcript + input at idle', () => {
|
|
17
17
|
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
|
|
18
18
|
const frame = lastFrame();
|
|
19
|
-
// Header should be visible (uppercase KLYRO as rendered)
|
|
20
19
|
expect(frame).toContain('KLYRO');
|
|
21
|
-
expect(frame).toContain('demo');
|
|
20
|
+
expect(frame).toContain('demo');
|
|
22
21
|
expect(frame).toContain('gpt-4o-mini');
|
|
23
|
-
|
|
24
|
-
expect(frame).toMatch(/
|
|
25
|
-
// Input prompt should be visible (klyro › per 1.4)
|
|
26
|
-
expect(frame).toMatch(/klyro|›/);
|
|
22
|
+
expect(frame).toMatch(/shift\+tab|for history|Message Klyro/);
|
|
23
|
+
expect(frame).toMatch(/KLYRO|Message Klyro|>/i);
|
|
27
24
|
});
|
|
28
25
|
it('renders a transcript with assistant text', () => {
|
|
29
26
|
const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
|
package/dist/tui/tokens.d.ts
CHANGED
|
@@ -1,65 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* §2.1 Color tokens + §2.2 Glyph set — TUI_DESIGN.md
|
|
3
|
+
* Accent is Orange #E8843C (256:209, 16: yellow bold), one accent ≤5%
|
|
4
|
+
* No backgrounds except diff viewer. fg.dim ≥4.5:1 on near-black.
|
|
4
5
|
*/
|
|
5
6
|
export declare const tokens: {
|
|
6
7
|
readonly colors: {
|
|
7
|
-
readonly accent: "#
|
|
8
|
+
readonly accent: "#E8843C";
|
|
8
9
|
readonly fg: "#E6E6E6";
|
|
9
|
-
readonly
|
|
10
|
-
readonly
|
|
11
|
-
readonly
|
|
12
|
-
readonly
|
|
13
|
-
readonly
|
|
14
|
-
readonly
|
|
15
|
-
readonly
|
|
16
|
-
readonly
|
|
17
|
-
readonly
|
|
18
|
-
readonly thinking: "#7A7A7A";
|
|
10
|
+
readonly soft: "#B3B3B3";
|
|
11
|
+
readonly dim: "#6F6F6F";
|
|
12
|
+
readonly guide: "#3A3A3A";
|
|
13
|
+
readonly ok: "#6BBF6B";
|
|
14
|
+
readonly err: "#E06C6C";
|
|
15
|
+
readonly warn: "#D9A441";
|
|
16
|
+
readonly info: "#6FA8DC";
|
|
17
|
+
readonly diffAddBg: "#12250F";
|
|
18
|
+
readonly diffDelBg: "#2A1212";
|
|
19
19
|
};
|
|
20
20
|
readonly ansi: {
|
|
21
|
-
readonly accent: "
|
|
22
|
-
readonly
|
|
21
|
+
readonly accent: "yellow";
|
|
22
|
+
readonly accentBold: "yellowBright";
|
|
23
|
+
readonly fg: string | undefined;
|
|
24
|
+
readonly soft: "white";
|
|
25
|
+
readonly dim: "gray";
|
|
26
|
+
readonly guide: "gray";
|
|
27
|
+
readonly ok: "green";
|
|
28
|
+
readonly err: "red";
|
|
29
|
+
readonly warn: "yellow";
|
|
30
|
+
readonly info: "blue";
|
|
31
|
+
readonly border: "gray";
|
|
23
32
|
readonly muted: "gray";
|
|
24
33
|
readonly success: "green";
|
|
25
34
|
readonly error: "red";
|
|
26
35
|
readonly warning: "yellow";
|
|
27
|
-
readonly info: "blue";
|
|
28
|
-
readonly border: "gray";
|
|
29
36
|
};
|
|
30
37
|
};
|
|
31
38
|
export declare const glyphs: {
|
|
32
|
-
readonly prompt: "
|
|
33
|
-
readonly
|
|
34
|
-
readonly
|
|
35
|
-
readonly
|
|
36
|
-
readonly
|
|
37
|
-
readonly
|
|
38
|
-
readonly
|
|
39
|
-
readonly
|
|
40
|
-
readonly
|
|
41
|
-
readonly
|
|
42
|
-
readonly
|
|
43
|
-
readonly
|
|
44
|
-
readonly
|
|
45
|
-
readonly spinnerAscii: readonly ["-", "\\", "|", "/"];
|
|
46
|
-
readonly pending: "○";
|
|
47
|
-
readonly pendingAscii: "o";
|
|
48
|
-
readonly checkboxDone: "☒";
|
|
49
|
-
readonly checkboxTodo: "☐";
|
|
39
|
+
readonly prompt: ">";
|
|
40
|
+
readonly agentBullet: "●";
|
|
41
|
+
readonly collapsed: "▸";
|
|
42
|
+
readonly expanded: "▾";
|
|
43
|
+
readonly guide: "│";
|
|
44
|
+
readonly branch: "├";
|
|
45
|
+
readonly end: "└";
|
|
46
|
+
readonly rule: "─";
|
|
47
|
+
readonly treeBranch: "├──";
|
|
48
|
+
readonly treeEnd: "└──";
|
|
49
|
+
readonly success: "✓";
|
|
50
|
+
readonly failure: "✗";
|
|
51
|
+
readonly warning: "!";
|
|
50
52
|
readonly repair: "↻";
|
|
51
|
-
readonly
|
|
52
|
-
readonly
|
|
53
|
-
readonly
|
|
54
|
-
readonly
|
|
55
|
-
readonly
|
|
56
|
-
readonly
|
|
57
|
-
readonly
|
|
53
|
+
readonly todoPending: "○";
|
|
54
|
+
readonly todoActive: "●";
|
|
55
|
+
readonly todoDone: "✓";
|
|
56
|
+
readonly todoPlan: "◇";
|
|
57
|
+
readonly modeAccept: "◐";
|
|
58
|
+
readonly modePlan: "○";
|
|
59
|
+
readonly modeAuto: "●";
|
|
60
|
+
readonly editsBadge: "✎";
|
|
61
|
+
readonly dot: "·";
|
|
62
|
+
readonly ellipsis: "…";
|
|
63
|
+
readonly meterFilled: "▰";
|
|
64
|
+
readonly meterEmpty: "▱";
|
|
65
|
+
readonly continuation: "↪";
|
|
58
66
|
readonly brand: "◆";
|
|
59
|
-
readonly
|
|
67
|
+
readonly compaction: "⟲";
|
|
68
|
+
};
|
|
69
|
+
export declare const glyphAscii: {
|
|
70
|
+
readonly prompt: ">";
|
|
71
|
+
readonly agentBullet: "*";
|
|
72
|
+
readonly collapsed: ">";
|
|
73
|
+
readonly expanded: "v";
|
|
74
|
+
readonly guide: "|";
|
|
75
|
+
readonly branch: "|";
|
|
76
|
+
readonly end: "\\";
|
|
77
|
+
readonly rule: "-";
|
|
78
|
+
readonly treeBranch: "|--";
|
|
79
|
+
readonly treeEnd: "`--";
|
|
80
|
+
readonly success: "ok";
|
|
81
|
+
readonly failure: "x";
|
|
82
|
+
readonly warning: "!";
|
|
83
|
+
readonly repair: "~";
|
|
84
|
+
readonly todoPending: "[ ]";
|
|
85
|
+
readonly todoActive: "[>]";
|
|
86
|
+
readonly todoDone: "[x]";
|
|
87
|
+
readonly todoPlan: "#";
|
|
60
88
|
};
|
|
61
89
|
export declare function isAsciiMode(): boolean;
|
|
62
|
-
export declare function
|
|
90
|
+
export declare function g(name: keyof typeof glyphs): string;
|
|
63
91
|
export declare const spacing: {
|
|
64
92
|
readonly maxWidth: 120;
|
|
65
93
|
readonly indent: 2;
|
package/dist/tui/tokens.js
CHANGED
|
@@ -1,88 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* §2.1 Color tokens + §2.2 Glyph set — TUI_DESIGN.md
|
|
3
|
+
* Accent is Orange #E8843C (256:209, 16: yellow bold), one accent ≤5%
|
|
4
|
+
* No backgrounds except diff viewer. fg.dim ≥4.5:1 on near-black.
|
|
4
5
|
*/
|
|
5
6
|
export const tokens = {
|
|
6
7
|
colors: {
|
|
7
|
-
accent: '#
|
|
8
|
+
accent: '#E8843C',
|
|
8
9
|
fg: '#E6E6E6',
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
thinking: '#7A7A7A',
|
|
10
|
+
soft: '#B3B3B3',
|
|
11
|
+
dim: '#6F6F6F',
|
|
12
|
+
guide: '#3A3A3A',
|
|
13
|
+
ok: '#6BBF6B',
|
|
14
|
+
err: '#E06C6C',
|
|
15
|
+
warn: '#D9A441',
|
|
16
|
+
info: '#6FA8DC',
|
|
17
|
+
diffAddBg: '#12250F',
|
|
18
|
+
diffDelBg: '#2A1212',
|
|
19
19
|
},
|
|
20
|
-
// For Ink, map to closest ANSI names
|
|
21
20
|
ansi: {
|
|
22
|
-
accent: '
|
|
21
|
+
accent: 'yellow',
|
|
22
|
+
accentBold: 'yellowBright',
|
|
23
23
|
fg: undefined,
|
|
24
|
+
soft: 'white',
|
|
25
|
+
dim: 'gray',
|
|
26
|
+
guide: 'gray',
|
|
27
|
+
ok: 'green',
|
|
28
|
+
err: 'red',
|
|
29
|
+
warn: 'yellow',
|
|
30
|
+
info: 'blue',
|
|
31
|
+
border: 'gray',
|
|
32
|
+
// compat aliases for older components (TUI_DESIGN §24 Don'ts still happy — no boxes)
|
|
24
33
|
muted: 'gray',
|
|
25
34
|
success: 'green',
|
|
26
35
|
error: 'red',
|
|
27
36
|
warning: 'yellow',
|
|
28
|
-
info: 'blue',
|
|
29
|
-
border: 'gray',
|
|
30
37
|
},
|
|
31
38
|
};
|
|
32
39
|
export const glyphs = {
|
|
33
|
-
prompt: '
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
spinnerAscii: ['-', '\\', '|', '/'],
|
|
47
|
-
pending: '○',
|
|
48
|
-
pendingAscii: 'o',
|
|
49
|
-
checkboxDone: '☒',
|
|
50
|
-
checkboxTodo: '☐',
|
|
40
|
+
prompt: '>',
|
|
41
|
+
agentBullet: '●',
|
|
42
|
+
collapsed: '▸',
|
|
43
|
+
expanded: '▾',
|
|
44
|
+
guide: '│',
|
|
45
|
+
branch: '├',
|
|
46
|
+
end: '└',
|
|
47
|
+
rule: '─',
|
|
48
|
+
treeBranch: '├──',
|
|
49
|
+
treeEnd: '└──',
|
|
50
|
+
success: '✓',
|
|
51
|
+
failure: '✗',
|
|
52
|
+
warning: '!',
|
|
51
53
|
repair: '↻',
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
todoPending: '○',
|
|
55
|
+
todoActive: '●',
|
|
56
|
+
todoDone: '✓',
|
|
57
|
+
todoPlan: '◇',
|
|
58
|
+
modeAccept: '◐',
|
|
59
|
+
modePlan: '○',
|
|
60
|
+
modeAuto: '●',
|
|
61
|
+
editsBadge: '✎',
|
|
62
|
+
dot: '·',
|
|
63
|
+
ellipsis: '…',
|
|
64
|
+
meterFilled: '▰',
|
|
65
|
+
meterEmpty: '▱',
|
|
66
|
+
continuation: '↪',
|
|
67
|
+
// compat
|
|
59
68
|
brand: '◆',
|
|
60
|
-
|
|
69
|
+
compaction: '⟲',
|
|
70
|
+
};
|
|
71
|
+
export const glyphAscii = {
|
|
72
|
+
prompt: '>',
|
|
73
|
+
agentBullet: '*',
|
|
74
|
+
collapsed: '>',
|
|
75
|
+
expanded: 'v',
|
|
76
|
+
guide: '|',
|
|
77
|
+
branch: '|',
|
|
78
|
+
end: '\\',
|
|
79
|
+
rule: '-',
|
|
80
|
+
treeBranch: '|--',
|
|
81
|
+
treeEnd: '`--',
|
|
82
|
+
success: 'ok',
|
|
83
|
+
failure: 'x',
|
|
84
|
+
warning: '!',
|
|
85
|
+
repair: '~',
|
|
86
|
+
todoPending: '[ ]',
|
|
87
|
+
todoActive: '[>]',
|
|
88
|
+
todoDone: '[x]',
|
|
89
|
+
todoPlan: '#',
|
|
61
90
|
};
|
|
62
91
|
export function isAsciiMode() {
|
|
63
92
|
return (process.env.TERM === 'dumb' ||
|
|
64
93
|
process.env.KLYRO_ASCII === '1' ||
|
|
65
94
|
(process.env.LANG !== undefined && !process.env.LANG.toLowerCase().includes('utf-8')) ||
|
|
66
|
-
|
|
67
|
-
);
|
|
95
|
+
false);
|
|
68
96
|
}
|
|
69
|
-
export function
|
|
70
|
-
if (isAsciiMode())
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (typeof val === 'string')
|
|
74
|
-
return val;
|
|
75
|
-
if (Array.isArray(val))
|
|
76
|
-
return val[0] ?? '>';
|
|
77
|
-
return '>';
|
|
78
|
-
}
|
|
79
|
-
const val = glyphs[name];
|
|
80
|
-
if (Array.isArray(val))
|
|
81
|
-
return val[0] ?? '●';
|
|
82
|
-
return val;
|
|
97
|
+
export function g(name) {
|
|
98
|
+
if (isAsciiMode())
|
|
99
|
+
return glyphAscii[name] ?? glyphs[name];
|
|
100
|
+
return glyphs[name];
|
|
83
101
|
}
|
|
84
|
-
export const spacing = {
|
|
85
|
-
maxWidth: 120,
|
|
86
|
-
indent: 2,
|
|
87
|
-
gap: 1,
|
|
88
|
-
};
|
|
102
|
+
export const spacing = { maxWidth: 120, indent: 2, gap: 1 };
|
|
@@ -65,6 +65,13 @@ export async function runBaseline(cwd, command, timeoutMs = 90_000) {
|
|
|
65
65
|
catch { /* ignore */ }
|
|
66
66
|
return baseline;
|
|
67
67
|
}
|
|
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
|
+
}
|
|
68
75
|
function runCmd(cwd, command, timeoutMs) {
|
|
69
76
|
return new Promise((resolve) => {
|
|
70
77
|
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
@@ -81,8 +88,8 @@ function runCmd(cwd, command, timeoutMs) {
|
|
|
81
88
|
catch { /* ignore */ }
|
|
82
89
|
resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[baseline timeout]' });
|
|
83
90
|
}, timeoutMs);
|
|
84
|
-
child.stdout.on('data', (b) => { stdout
|
|
85
|
-
child.stderr.on('data', (b) => { stderr
|
|
91
|
+
child.stdout.on('data', (b) => { stdout = cap(stdout, b.toString()); });
|
|
92
|
+
child.stderr.on('data', (b) => { stderr = cap(stderr, b.toString()); });
|
|
86
93
|
child.on('close', (code) => {
|
|
87
94
|
if (done)
|
|
88
95
|
return;
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { detect, summarize } from './detect.js';
|
|
12
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
13
|
+
const MAX_VERIFY_BYTES = 256 * 1024;
|
|
14
|
+
function appendCapped(current, chunk) {
|
|
15
|
+
if (current.length >= MAX_VERIFY_BYTES)
|
|
16
|
+
return current;
|
|
17
|
+
const next = current + chunk;
|
|
18
|
+
return next.length > MAX_VERIFY_BYTES ? next.slice(0, MAX_VERIFY_BYTES) + '\n... [truncated]' : next;
|
|
19
|
+
}
|
|
12
20
|
export async function verify(opts) {
|
|
13
21
|
const timeout = opts.timeoutMs ?? 5 * 60 * 1000;
|
|
14
22
|
return new Promise((resolve) => {
|
|
@@ -30,8 +38,8 @@ export async function verify(opts) {
|
|
|
30
38
|
failure: { type: 'runtime', files: [], raw, exitCode: -1 },
|
|
31
39
|
});
|
|
32
40
|
}, timeout);
|
|
33
|
-
child.stdout.on('data', (b) => { stdout
|
|
34
|
-
child.stderr.on('data', (b) => { stderr
|
|
41
|
+
child.stdout.on('data', (b) => { stdout = appendCapped(stdout, b.toString()); });
|
|
42
|
+
child.stderr.on('data', (b) => { stderr = appendCapped(stderr, b.toString()); });
|
|
35
43
|
child.on('close', (code) => {
|
|
36
44
|
if (done)
|
|
37
45
|
return;
|
|
@@ -53,5 +61,8 @@ export function diagnosticForModel(result) {
|
|
|
53
61
|
return 'Verification passed.';
|
|
54
62
|
if (!result.failure)
|
|
55
63
|
return `Verification failed with exit ${result.exitCode}.`;
|
|
56
|
-
|
|
64
|
+
// redact raw before summarizing so secrets don't enter transcript
|
|
65
|
+
const redactedRaw = redact(result.failure.raw);
|
|
66
|
+
const redactedFailure = { ...result.failure, raw: redactedRaw, files: result.failure.files.map((f) => ({ ...f, message: redact(f.message) })) };
|
|
67
|
+
return summarize(redactedFailure);
|
|
57
68
|
}
|
|
@@ -70,6 +70,13 @@ export function buildScopedCommand(cwd, baseCommand, relatedTests) {
|
|
|
70
70
|
}
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
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
|
+
}
|
|
73
80
|
export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
|
|
74
81
|
return new Promise((resolve) => {
|
|
75
82
|
const child = spawn(command, { cwd, shell: true, env: process.env });
|
|
@@ -86,8 +93,8 @@ export async function runScopedVerify(cwd, command, timeoutMs = 45_000) {
|
|
|
86
93
|
catch { /* ignore */ }
|
|
87
94
|
resolve({ ok: false, exitCode: -1, stdout, stderr: stderr + '\n[scoped timeout]' });
|
|
88
95
|
}, timeoutMs);
|
|
89
|
-
child.stdout.on('data', (b) => { stdout
|
|
90
|
-
child.stderr.on('data', (b) => { stderr
|
|
96
|
+
child.stdout.on('data', (b) => { stdout = appendCappedScoped(stdout, b.toString()); });
|
|
97
|
+
child.stderr.on('data', (b) => { stderr = appendCappedScoped(stderr, b.toString()); });
|
|
91
98
|
child.on('close', (code) => {
|
|
92
99
|
if (done)
|
|
93
100
|
return;
|
|
@@ -116,9 +123,8 @@ export async function syntaxCheck(cwd, file) {
|
|
|
116
123
|
// minimal check: try to parse via new Function (for js) or just check no obvious syntax error via tsc
|
|
117
124
|
// For now, use tsc --noEmit --skipLibCheck on single file quickly
|
|
118
125
|
if (ext === '.ts') {
|
|
119
|
-
// spawn tsc --noEmit --skipLibCheck <file> with 10s timeout
|
|
120
126
|
const ok = await new Promise((resolve) => {
|
|
121
|
-
const child = spawn(
|
|
127
|
+
const child = spawn('npx', ['tsc', '--noEmit', '--skipLibCheck', full], { cwd, shell: false, env: process.env });
|
|
122
128
|
let done = false;
|
|
123
129
|
const t = setTimeout(() => { if (!done) {
|
|
124
130
|
done = true;
|
|
@@ -137,9 +143,8 @@ export async function syntaxCheck(cwd, file) {
|
|
|
137
143
|
return { ok: false, error: `syntax error in ${file} (tsc)` };
|
|
138
144
|
return { ok: true };
|
|
139
145
|
}
|
|
140
|
-
// js: node --check
|
|
141
146
|
const ok2 = await new Promise((resolve) => {
|
|
142
|
-
const child = spawn(
|
|
147
|
+
const child = spawn(process.execPath, ['--check', full], { cwd, shell: false, env: process.env });
|
|
143
148
|
let done = false;
|
|
144
149
|
const t = setTimeout(() => { if (!done) {
|
|
145
150
|
done = true;
|
|
@@ -164,7 +169,7 @@ export async function syntaxCheck(cwd, file) {
|
|
|
164
169
|
}
|
|
165
170
|
if (ext === '.py') {
|
|
166
171
|
const ok = await new Promise((resolve) => {
|
|
167
|
-
const child = spawn(
|
|
172
|
+
const child = spawn('python', ['-m', 'py_compile', full], { cwd, shell: false, env: process.env });
|
|
168
173
|
let done = false;
|
|
169
174
|
const t = setTimeout(() => { if (!done) {
|
|
170
175
|
done = true;
|
|
@@ -177,7 +182,7 @@ export async function syntaxCheck(cwd, file) {
|
|
|
177
182
|
child.on('close', (code) => { if (done)
|
|
178
183
|
return; done = true; clearTimeout(t); resolve(code === 0); });
|
|
179
184
|
child.on('error', () => { if (done)
|
|
180
|
-
return; done = true; clearTimeout(t); resolve(true); });
|
|
185
|
+
return; done = true; clearTimeout(t); resolve(true); });
|
|
181
186
|
});
|
|
182
187
|
if (!ok)
|
|
183
188
|
return { ok: false, error: `syntax error in ${file} (py_compile)` };
|