klyro 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/anthropic-adapter.d.ts +1 -1
- package/dist/agent/anthropic-adapter.js +22 -20
- package/dist/agent/provider-adapter.js +30 -7
- package/dist/agent/registry.js +24 -7
- package/dist/agent/retry.js +54 -9
- package/dist/agent/runtime.d.ts +45 -1
- package/dist/agent/runtime.js +236 -23
- package/dist/chat.d.ts +3 -3
- package/dist/chat.js +137 -39
- package/dist/cli/auth.d.ts +9 -0
- package/dist/cli/auth.js +84 -0
- package/dist/cli/completion.d.ts +6 -0
- package/dist/cli/completion.js +66 -0
- package/dist/cli/config.d.ts +44 -0
- package/dist/cli/config.js +428 -0
- package/dist/cli/doctor.d.ts +8 -0
- package/dist/cli/doctor.js +136 -0
- package/dist/cli/errors.d.ts +5 -0
- package/dist/cli/errors.js +37 -0
- package/dist/cli/markdown.d.ts +9 -0
- package/dist/cli/markdown.js +77 -0
- package/dist/cli/repl.js +113 -5
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.js +97 -2
- package/dist/cli/slash/parser.d.ts +10 -0
- package/dist/cli/slash/parser.js +6 -1
- package/dist/cli/trace.d.ts +7 -0
- package/dist/cli/trace.js +63 -0
- package/dist/cli/update.d.ts +6 -0
- package/dist/cli/update.js +70 -0
- package/dist/context/system-prompt.d.ts +14 -0
- package/dist/context/system-prompt.js +47 -0
- package/dist/events/bus.d.ts +16 -0
- package/dist/events/bus.js +28 -0
- package/dist/events/catalog.d.ts +136 -0
- package/dist/events/catalog.js +5 -0
- package/dist/index.js +335 -22
- package/dist/persistence/session.d.ts +12 -0
- package/dist/persistence/session.js +45 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +85 -2
- package/dist/providers/model-info.d.ts +18 -0
- package/dist/providers/model-info.js +18 -0
- package/dist/providers.js +3 -2
- package/dist/renderers/json.d.ts +11 -0
- package/dist/renderers/json.js +14 -0
- package/dist/renderers/terminal.d.ts +9 -0
- package/dist/renderers/terminal.js +41 -0
- package/dist/repl.js +52 -15
- package/dist/shared/errors.d.ts +18 -0
- package/dist/shared/errors.js +33 -0
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/types.d.ts +19 -0
- package/dist/shared/types.js +11 -0
- package/dist/tools/fs/read-file.d.ts +1 -31
- package/dist/tools/fs/read-file.js +45 -17
- package/dist/tools/fs/read-history.d.ts +3 -0
- package/dist/tools/fs/read-history.js +13 -0
- package/dist/tools/fs/write-file.d.ts +1 -4
- package/dist/tools/fs/write-file.js +35 -1
- package/dist/tools/normalize.d.ts +1 -1
- package/dist/tools/shell/shell-exec.d.ts +1 -26
- package/dist/tools/shell/shell-exec.js +121 -17
- package/dist/tools/types.d.ts +25 -0
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +53 -0
- package/dist/tui/app.d.ts +1 -15
- package/dist/tui/app.js +203 -22
- package/dist/tui/approval.js +25 -4
- package/dist/tui/approval.test.js +1 -1
- package/dist/tui/snapshot.test.js +2 -2
- package/dist/tui/status.js +5 -1
- package/dist/util/log.d.ts +12 -0
- package/dist/util/log.js +75 -0
- package/dist/verification/auto.d.ts +8 -0
- package/dist/verification/auto.js +52 -0
- package/package.json +3 -2
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* - Non-zero exit is returned as a successful tool call (with exitCode in
|
|
9
9
|
* the output). The agent reads exitCode to decide what to do next.
|
|
10
10
|
*/
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
11
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
12
|
+
import * as os from 'node:os';
|
|
13
|
+
import * as path from 'node:path';
|
|
12
14
|
import { z } from 'zod';
|
|
13
15
|
import { defineTool } from '../types.js';
|
|
14
16
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
@@ -19,37 +21,99 @@ const InputSchema = z.object({
|
|
|
19
21
|
timeoutMs: z.number().int().min(1).max(600_000).optional().describe('Timeout in milliseconds (default 30000, max 600000)'),
|
|
20
22
|
env: z.record(z.string(), z.string()).optional().describe('Extra env vars merged onto process.env'),
|
|
21
23
|
});
|
|
22
|
-
const DEFAULT_TIMEOUT_MS =
|
|
23
|
-
const
|
|
24
|
-
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
25
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
26
|
+
const MAX_OUTPUT_CHARS = 30_000; // head+tail cap per 3.3
|
|
27
|
+
const FULL_OUTPUT_DIR = (() => {
|
|
28
|
+
try {
|
|
29
|
+
const home = os.homedir() || process.cwd();
|
|
30
|
+
return path.join(home, '.klyro', 'tool-output');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return path.join(process.cwd(), '.klyro', 'tool-output');
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
// Persistent cwd across calls (3.3)
|
|
37
|
+
let persistentCwd = null;
|
|
38
|
+
// Filtered env — only safe vars, secrets stripped
|
|
39
|
+
const ALLOWED_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
|
|
40
|
+
function filteredEnv(extra) {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
43
|
+
if (k.startsWith('KLYRO_') && k.includes('API_KEY'))
|
|
44
|
+
continue;
|
|
45
|
+
if (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
|
|
46
|
+
continue;
|
|
47
|
+
if (ALLOWED_ENV_PREFIXES.some((p) => k.startsWith(p)) || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP') {
|
|
48
|
+
out[k] = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Always allow basic
|
|
52
|
+
out.PATH = process.env.PATH;
|
|
53
|
+
if (extra)
|
|
54
|
+
Object.assign(out, extra);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
// Interactive command detection (3.3)
|
|
58
|
+
const INTERACTIVE_PATTERNS = [/^\s*vim\b/, /^\s*nano\b/, /^\s*htop\b/, /^\s*less\b/, /^\s*more\b/, /^\s*ssh\b/, /^\s*tmux\b/];
|
|
59
|
+
// Hard-coded dangerous patterns. These are non-overridable, configurable via --yolo only.
|
|
25
60
|
const DANGEROUS_PATTERNS = [
|
|
26
61
|
{ pattern: /rm\s+-rf?\s+\//, reason: 'recursive delete at filesystem root' },
|
|
62
|
+
{ pattern: /rm\s+-rf?\s+\/\/+/, reason: 'recursive delete at filesystem root (//)' },
|
|
63
|
+
{ pattern: /rm\s+-rf?\s+\/\*/, reason: 'recursive delete at filesystem root (/*)' },
|
|
64
|
+
{ pattern: /rm\s+-rf?\s+\.\s*($|[;&|])/, reason: 'recursive delete current directory' },
|
|
65
|
+
{ pattern: /rm\s+-rf?\s+\*\s*($|[;&|])/, reason: 'recursive delete all files via *' },
|
|
66
|
+
{ pattern: /rm\s+-rf?\s+\.\/\*\s*($|[;&|])/, reason: 'recursive delete all files' },
|
|
27
67
|
{ pattern: /del\s+\/s\s+\/q\s+[a-z]:\\/i, reason: 'recursive delete on Windows drive root' },
|
|
28
68
|
{ pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: 'fork bomb' },
|
|
69
|
+
{ pattern: /bomb\(\)\s*\{\s*bomb\|bomb/, reason: 'fork bomb variant' },
|
|
29
70
|
{ pattern: />\s*\/dev\/sd[a-z]/, reason: 'overwrite raw block device' },
|
|
30
71
|
{ pattern: /mkfs(\.|\s)/, reason: 'format filesystem' },
|
|
31
72
|
{ pattern: /dd\s+.*of=\/dev\//, reason: 'dd write to device' },
|
|
73
|
+
{ pattern: /chmod\s+-R\s+777\s+\//, reason: 'chmod 777 on root' },
|
|
74
|
+
{ pattern: /curl.*\|\s*(sh|bash|python|perl|ruby)/i, reason: 'curl|sh to unknown host' },
|
|
75
|
+
{ pattern: /rm\s+-rf\s+--no-preserve-root\s+\//, reason: 'recursive delete --no-preserve-root' },
|
|
32
76
|
];
|
|
33
77
|
export const shellExecTool = defineTool({
|
|
34
78
|
name: 'shell_exec',
|
|
35
|
-
description: 'Execute a shell command. Default timeout
|
|
79
|
+
description: 'Execute a shell command. Default timeout 120s (max 600s). Output head+tail 30k chars, full to ~/.klyro/tool-output/<id>.txt. Persistent cwd, filtered env, tree-kill on timeout.',
|
|
36
80
|
inputSchema: InputSchema,
|
|
81
|
+
permission: 'execute',
|
|
82
|
+
isConcurrencySafe: false,
|
|
83
|
+
renderCall: (input) => `$ ${input.command.slice(0, 80)}`,
|
|
84
|
+
renderResult: (output) => `exit ${output.exitCode} (${output.durationMs}ms${output.timedOut ? ' timedOut' : ''})`,
|
|
37
85
|
execute: async (input, ctx) => {
|
|
38
86
|
return safe(async () => {
|
|
87
|
+
// Interactive detection
|
|
88
|
+
for (const pat of INTERACTIVE_PATTERNS) {
|
|
89
|
+
if (pat.test(input.command)) {
|
|
90
|
+
throw Object.assign(new Error(`Interactive command blocked: ${input.command.split(' ')[0]} — hint: use non-interactive mode or run in background`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
39
93
|
for (const { pattern, reason } of DANGEROUS_PATTERNS) {
|
|
40
94
|
if (pattern.test(input.command)) {
|
|
41
|
-
|
|
42
|
-
ok: false,
|
|
43
|
-
error: { code: TOOL_ERROR_CODES.COMMAND_DENIED, message: `Command blocked: ${reason}` },
|
|
44
|
-
};
|
|
95
|
+
throw Object.assign(new Error(`Command blocked: ${reason}`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
|
|
45
96
|
}
|
|
46
97
|
}
|
|
47
|
-
|
|
98
|
+
// Persistent cwd
|
|
99
|
+
let cwd = persistentCwd ?? ctx.cwd;
|
|
48
100
|
if (input.cwd) {
|
|
49
101
|
cwd = resolveWithinCwd(ctx.cwd, input.cwd).resolved;
|
|
102
|
+
persistentCwd = cwd;
|
|
103
|
+
}
|
|
104
|
+
else if (input.command.trim().startsWith('cd ')) {
|
|
105
|
+
const m = /^\s*cd\s+(.+?)\s*(?:&&|;|$)/.exec(input.command);
|
|
106
|
+
if (m?.[1]) {
|
|
107
|
+
try {
|
|
108
|
+
const target = m[1].replace(/^["']|["']$/g, '');
|
|
109
|
+
const resolved = resolveWithinCwd(cwd, target).resolved;
|
|
110
|
+
persistentCwd = resolved;
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
113
|
+
}
|
|
50
114
|
}
|
|
51
|
-
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
52
|
-
const env =
|
|
115
|
+
const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
116
|
+
const env = filteredEnv(input.env);
|
|
53
117
|
const start = Date.now();
|
|
54
118
|
const child = spawn(input.command, {
|
|
55
119
|
cwd,
|
|
@@ -61,20 +125,29 @@ export const shellExecTool = defineTool({
|
|
|
61
125
|
});
|
|
62
126
|
const stdoutChunks = [];
|
|
63
127
|
const stderrChunks = [];
|
|
128
|
+
const fullStdout = [];
|
|
129
|
+
const fullStderr = [];
|
|
64
130
|
let totalOut = 0;
|
|
65
131
|
let totalErr = 0;
|
|
66
132
|
let truncated = false;
|
|
67
133
|
child.stdout?.on('data', (chunk) => {
|
|
68
|
-
|
|
134
|
+
fullStdout.push(chunk);
|
|
135
|
+
if (totalOut + chunk.length > MAX_OUTPUT_CHARS) {
|
|
69
136
|
truncated = true;
|
|
137
|
+
// Keep head+tail: drop middle, keep first half and last half later
|
|
138
|
+
if (stdoutChunks.length < 10)
|
|
139
|
+
stdoutChunks.push(chunk);
|
|
70
140
|
return;
|
|
71
141
|
}
|
|
72
142
|
stdoutChunks.push(chunk);
|
|
73
143
|
totalOut += chunk.length;
|
|
74
144
|
});
|
|
75
145
|
child.stderr?.on('data', (chunk) => {
|
|
76
|
-
|
|
146
|
+
fullStderr.push(chunk);
|
|
147
|
+
if (totalErr + chunk.length > MAX_OUTPUT_CHARS) {
|
|
77
148
|
truncated = true;
|
|
149
|
+
if (stderrChunks.length < 10)
|
|
150
|
+
stderrChunks.push(chunk);
|
|
78
151
|
return;
|
|
79
152
|
}
|
|
80
153
|
stderrChunks.push(chunk);
|
|
@@ -100,7 +173,7 @@ export const shellExecTool = defineTool({
|
|
|
100
173
|
// -SIGKILL signals the group so children go too.
|
|
101
174
|
if (process.platform === 'win32') {
|
|
102
175
|
try {
|
|
103
|
-
|
|
176
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
|
|
104
177
|
}
|
|
105
178
|
catch { /* already gone */ }
|
|
106
179
|
}
|
|
@@ -116,8 +189,39 @@ export const shellExecTool = defineTool({
|
|
|
116
189
|
child.on('error', () => finish({ code: null, signal: null, timedOut: false }));
|
|
117
190
|
});
|
|
118
191
|
const { code: exitCode, signal, timedOut } = outcome;
|
|
119
|
-
const
|
|
120
|
-
const
|
|
192
|
+
const fullOutBuf = Buffer.concat(fullStdout);
|
|
193
|
+
const fullErrBuf = Buffer.concat(fullStderr);
|
|
194
|
+
// Write full output to ~/.klyro/tool-output/<id>.txt for 3.3
|
|
195
|
+
const outId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
196
|
+
try {
|
|
197
|
+
const fs = await import('node:fs/promises');
|
|
198
|
+
const path = await import('node:path');
|
|
199
|
+
await fs.mkdir(FULL_OUTPUT_DIR, { recursive: true });
|
|
200
|
+
const fullPath = path.join(FULL_OUTPUT_DIR, `${outId}.txt`);
|
|
201
|
+
const fullContent = `STDOUT:\n${fullOutBuf.toString('utf-8')}\n\nSTDERR:\n${fullErrBuf.toString('utf-8')}\n`;
|
|
202
|
+
await fs.writeFile(fullPath, fullContent, 'utf-8');
|
|
203
|
+
}
|
|
204
|
+
catch { /* ignore */ }
|
|
205
|
+
// Head+tail truncation: keep first 15k and last 15k chars
|
|
206
|
+
let stdout;
|
|
207
|
+
let stderr;
|
|
208
|
+
if (truncated) {
|
|
209
|
+
const fullOutStr = fullOutBuf.toString('utf-8');
|
|
210
|
+
const fullErrStr = fullErrBuf.toString('utf-8');
|
|
211
|
+
const headTail = (s) => {
|
|
212
|
+
if (s.length <= MAX_OUTPUT_CHARS)
|
|
213
|
+
return s;
|
|
214
|
+
const head = s.slice(0, 15000);
|
|
215
|
+
const tail = s.slice(-15000);
|
|
216
|
+
return head + `\n... [truncated ${s.length - 30000} chars, full at ~/.klyro/tool-output/${outId}.txt]\n` + tail;
|
|
217
|
+
};
|
|
218
|
+
stdout = headTail(fullOutStr);
|
|
219
|
+
stderr = headTail(fullErrStr);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
|
223
|
+
stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
|
224
|
+
}
|
|
121
225
|
return {
|
|
122
226
|
command: input.command,
|
|
123
227
|
exitCode,
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -33,6 +33,21 @@ export interface ToolContext {
|
|
|
33
33
|
signal?: AbortSignal;
|
|
34
34
|
/** True when running headless (CI, pipe). Tools should avoid prompts. */
|
|
35
35
|
nonInteractive?: boolean;
|
|
36
|
+
/** Current session id (for tracing). */
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
/** Permissions snapshot for this turn. */
|
|
39
|
+
permissions?: {
|
|
40
|
+
mode?: string;
|
|
41
|
+
allow?: string[];
|
|
42
|
+
deny?: string[];
|
|
43
|
+
};
|
|
44
|
+
/** Logger (pino-like) */
|
|
45
|
+
logger?: {
|
|
46
|
+
debug: (msg: string, data?: unknown) => void;
|
|
47
|
+
info: (msg: string, data?: unknown) => void;
|
|
48
|
+
};
|
|
49
|
+
/** Emit KlyroEvent */
|
|
50
|
+
emit?: (ev: import('../events/catalog.js').KlyroEvent) => void;
|
|
36
51
|
}
|
|
37
52
|
/**
|
|
38
53
|
* A typed tool definition. TInput is the validated input shape; TOutput is
|
|
@@ -45,6 +60,16 @@ export interface Tool<TInput, TOutput> {
|
|
|
45
60
|
description: string;
|
|
46
61
|
/** Zod schema for runtime validation. */
|
|
47
62
|
inputSchema: z.ZodType<TInput>;
|
|
63
|
+
/** Permission class: read | edit | execute | admin */
|
|
64
|
+
permission?: 'read' | 'edit' | 'execute' | 'admin';
|
|
65
|
+
/** True if tool is safe to run in parallel with others */
|
|
66
|
+
isConcurrencySafe?: boolean;
|
|
67
|
+
/** Render call for approval UI */
|
|
68
|
+
renderCall?: (input: TInput) => string;
|
|
69
|
+
/** Render result for UI */
|
|
70
|
+
renderResult?: (output: TOutput) => string;
|
|
71
|
+
/** Truncate large results to maxResultTokens (default 8k) */
|
|
72
|
+
truncate?: (output: TOutput, maxTokens: number) => TOutput | string;
|
|
48
73
|
/** Execute the tool. Must never throw — return a ToolResult instead. */
|
|
49
74
|
execute(input: TInput, ctx: ToolContext): Promise<ToolResult<TOutput>>;
|
|
50
75
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.1 — TraceWriter (JSONL, fsync on tool results)
|
|
3
|
+
*/
|
|
4
|
+
import type { KlyroEvent } from '../events/catalog.js';
|
|
5
|
+
export declare class TraceWriter {
|
|
6
|
+
private filePath;
|
|
7
|
+
constructor(sessionId: string, dir?: string);
|
|
8
|
+
init(): Promise<void>;
|
|
9
|
+
write(ev: KlyroEvent): Promise<void>;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
get path(): string;
|
|
12
|
+
readAll(): Promise<KlyroEvent[]>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 3.1 — TraceWriter (JSONL, fsync on tool results)
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
export class TraceWriter {
|
|
7
|
+
filePath;
|
|
8
|
+
constructor(sessionId, dir) {
|
|
9
|
+
const base = dir ?? path.join(process.cwd(), '.klyro', 'traces');
|
|
10
|
+
this.filePath = path.join(base, `${sessionId}.jsonl`);
|
|
11
|
+
}
|
|
12
|
+
async init() {
|
|
13
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
14
|
+
// Ensure file exists
|
|
15
|
+
try {
|
|
16
|
+
await fs.appendFile(this.filePath, '', 'utf-8');
|
|
17
|
+
}
|
|
18
|
+
catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
async write(ev) {
|
|
21
|
+
const line = JSON.stringify(ev) + '\n';
|
|
22
|
+
await fs.appendFile(this.filePath, line, 'utf-8');
|
|
23
|
+
// fsync on tool results for crash safety
|
|
24
|
+
if (ev.type === 'tool.result') {
|
|
25
|
+
try {
|
|
26
|
+
const handle = await fs.open(this.filePath, 'r+');
|
|
27
|
+
try {
|
|
28
|
+
await handle.sync();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await handle.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async close() {
|
|
38
|
+
// No persistent handle to close
|
|
39
|
+
}
|
|
40
|
+
get path() {
|
|
41
|
+
return this.filePath;
|
|
42
|
+
}
|
|
43
|
+
// For tests: read back
|
|
44
|
+
async readAll() {
|
|
45
|
+
try {
|
|
46
|
+
const raw = await fs.readFile(this.filePath, 'utf-8');
|
|
47
|
+
return raw.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ink app root — status line + scrollable transcript + input box.
|
|
3
|
-
*
|
|
4
|
-
* Owns the session state machine:
|
|
5
|
-
* - transcript: array of TranscriptItem (rendered by <Transcript/>)
|
|
6
|
-
* - status: snapshot for the <StatusLine/>
|
|
7
|
-
* - input: the current input-box buffer
|
|
8
|
-
*
|
|
9
|
-
* The actual agent loop runs externally; the app just listens for
|
|
10
|
-
* RuntimeEvents via the onEvent callback wired by cli/repl.ts and
|
|
11
|
-
* translates them into transcript/status updates.
|
|
3
|
+
* 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
|
|
12
4
|
*/
|
|
13
5
|
import React from 'react';
|
|
14
6
|
import { type StatusSnapshot } from './status.js';
|
|
@@ -18,18 +10,12 @@ import type { PlanStep } from '../agent/runtime.js';
|
|
|
18
10
|
export interface AppProps {
|
|
19
11
|
initialModel: string;
|
|
20
12
|
maxSteps: number;
|
|
21
|
-
/** Working directory to display in the header. */
|
|
22
13
|
cwd: string;
|
|
23
|
-
/** Called when the user submits a non-slash prompt. */
|
|
24
14
|
onPrompt: (text: string) => void | Promise<void>;
|
|
25
|
-
/** Called when the user types a slash command. */
|
|
26
15
|
onSlash: (cmd: import('../cli/slash/parser.js').SlashCommand) => void | Promise<void>;
|
|
27
|
-
/** Initial state (e.g. when resuming a session). */
|
|
28
16
|
initialTranscript?: TranscriptItem[];
|
|
29
17
|
initialStatus?: Partial<StatusSnapshot>;
|
|
30
|
-
/** Optional approval bridge — when set, the modal prompts inline. */
|
|
31
18
|
approvalBridge?: TuiApprovalBridge;
|
|
32
|
-
/** Called once after mount with direct hooks; also installs global compat hooks. */
|
|
33
19
|
onMounted?: (hooks: {
|
|
34
20
|
append: (i: TranscriptItem) => void;
|
|
35
21
|
updateStatus: (s: Partial<StatusSnapshot>) => void;
|
package/dist/tui/app.js
CHANGED
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Ink app root — status line + scrollable transcript + input box.
|
|
4
|
-
*
|
|
5
|
-
* Owns the session state machine:
|
|
6
|
-
* - transcript: array of TranscriptItem (rendered by <Transcript/>)
|
|
7
|
-
* - status: snapshot for the <StatusLine/>
|
|
8
|
-
* - input: the current input-box buffer
|
|
9
|
-
*
|
|
10
|
-
* The actual agent loop runs externally; the app just listens for
|
|
11
|
-
* RuntimeEvents via the onEvent callback wired by cli/repl.ts and
|
|
12
|
-
* translates them into transcript/status updates.
|
|
4
|
+
* 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
|
|
13
5
|
*/
|
|
14
6
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
15
7
|
import { Box, Text, useInput } from 'ink';
|
|
@@ -19,11 +11,60 @@ import { Header } from './header.js';
|
|
|
19
11
|
import { ApprovalModal, TuiApprovalBridge } from './approval.js';
|
|
20
12
|
import { PlanView } from './plan.js';
|
|
21
13
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
22
18
|
let _itemCounter = 0;
|
|
23
19
|
function nextId(prefix) {
|
|
24
20
|
_itemCounter += 1;
|
|
25
21
|
return `${prefix}-${_itemCounter}`;
|
|
26
22
|
}
|
|
23
|
+
function getGitBranch(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
|
|
26
|
+
if (r.status === 0 && r.stdout)
|
|
27
|
+
return r.stdout.trim().slice(0, 40);
|
|
28
|
+
}
|
|
29
|
+
catch { /* ignore */ }
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
function getHistoryPath() {
|
|
33
|
+
const home = os.homedir() || process.cwd();
|
|
34
|
+
return path.join(home, '.klyro', 'history');
|
|
35
|
+
}
|
|
36
|
+
function loadHistory(cwd) {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(getHistoryPath(), 'utf-8');
|
|
39
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const line of lines) {
|
|
42
|
+
try {
|
|
43
|
+
const obj = JSON.parse(line);
|
|
44
|
+
if (obj.cwd === cwd && typeof obj.text === 'string')
|
|
45
|
+
out.push(obj.text);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// legacy plain text per line
|
|
49
|
+
if (line.trim())
|
|
50
|
+
out.push(line.trim());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out.slice(-200);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function appendHistory(cwd, text) {
|
|
60
|
+
try {
|
|
61
|
+
const p = getHistoryPath();
|
|
62
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
+
const entry = JSON.stringify({ cwd, text, ts: Date.now() });
|
|
64
|
+
fs.appendFileSync(p, entry + '\n', 'utf-8');
|
|
65
|
+
}
|
|
66
|
+
catch { /* ignore */ }
|
|
67
|
+
}
|
|
27
68
|
export function App(props) {
|
|
28
69
|
const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
|
|
29
70
|
const [input, setInput] = useState('');
|
|
@@ -41,14 +82,21 @@ export function App(props) {
|
|
|
41
82
|
status: 'idle',
|
|
42
83
|
...props.initialStatus,
|
|
43
84
|
});
|
|
44
|
-
|
|
85
|
+
const [history, setHistory] = useState(() => loadHistory(props.cwd));
|
|
86
|
+
const historyIndexRef = useRef(-1);
|
|
87
|
+
const lastCtrlCRef = useRef(0);
|
|
88
|
+
const [gitBranch, setGitBranch] = useState(() => getGitBranch(props.cwd));
|
|
89
|
+
const [queued, setQueued] = useState(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const t = setInterval(() => setGitBranch(getGitBranch(props.cwd)), 5000);
|
|
92
|
+
return () => clearInterval(t);
|
|
93
|
+
}, [props.cwd]);
|
|
45
94
|
useEffect(() => {
|
|
46
95
|
return bridge.subscribe((p) => setAwaitingApproval(p !== null));
|
|
47
96
|
}, [bridge]);
|
|
48
97
|
const append = useCallback((item) => {
|
|
49
98
|
setTranscript((prev) => {
|
|
50
99
|
const last = prev[prev.length - 1];
|
|
51
|
-
// Only coalesce when IDs match — separate turns have different IDs
|
|
52
100
|
if (last?.kind === 'text' &&
|
|
53
101
|
item.kind === 'text' &&
|
|
54
102
|
last.role === 'assistant' &&
|
|
@@ -66,13 +114,10 @@ export function App(props) {
|
|
|
66
114
|
setPlan(p);
|
|
67
115
|
setPlanExpanded(true);
|
|
68
116
|
}, []);
|
|
69
|
-
// Stabilize onMounted to avoid re-installing hooks on every parent re-render
|
|
70
117
|
const onMountedRef = useRef(props.onMounted);
|
|
71
118
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
72
119
|
useEffect(() => {
|
|
73
|
-
// Instance-local hooks via callback (preferred)
|
|
74
120
|
onMountedRef.current?.({ append, updateStatus, updatePlan });
|
|
75
|
-
// Global compat hooks for tests / legacy callers (single instance at a time)
|
|
76
121
|
globalThis.__klyroAppAppend = append;
|
|
77
122
|
globalThis.__klyroAppStatus = updateStatus;
|
|
78
123
|
globalThis.__klyroAppPlan = updatePlan;
|
|
@@ -82,21 +127,131 @@ export function App(props) {
|
|
|
82
127
|
delete globalThis.__klyroAppPlan;
|
|
83
128
|
};
|
|
84
129
|
}, [append, updateStatus, updatePlan]);
|
|
130
|
+
// Handle Windows raw-mode fallback warning
|
|
131
|
+
const [rawModeWarning, setRawModeWarning] = useState(null);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
try {
|
|
134
|
+
const stdin = process.stdin;
|
|
135
|
+
if (stdin.isTTY && typeof stdin.setRawMode !== 'function') {
|
|
136
|
+
setRawModeWarning('Raw mode not available (mintty) — input may be limited');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch { /* ignore */ }
|
|
140
|
+
}, []);
|
|
141
|
+
// Queue next message if typed during stream (2.4)
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (queued && status.status !== 'running' && !awaitingApproval) {
|
|
144
|
+
const toSend = queued;
|
|
145
|
+
setQueued(null);
|
|
146
|
+
const trimmed = toSend.trim();
|
|
147
|
+
if (!trimmed)
|
|
148
|
+
return;
|
|
149
|
+
append({ id: nextId('text'), kind: 'text', text: toSend, role: 'user' });
|
|
150
|
+
const cmd = parseSlash(trimmed);
|
|
151
|
+
if (cmd.kind === 'prompt')
|
|
152
|
+
void props.onPrompt(cmd.text);
|
|
153
|
+
else if (cmd.kind === 'plan') {
|
|
154
|
+
if (plan.length > 0)
|
|
155
|
+
setPlanExpanded((v) => !v);
|
|
156
|
+
}
|
|
157
|
+
else
|
|
158
|
+
void props.onSlash(cmd);
|
|
159
|
+
}
|
|
160
|
+
}, [queued, status.status, awaitingApproval, plan.length, append]);
|
|
85
161
|
useInput((inputStr, key) => {
|
|
86
|
-
if (
|
|
162
|
+
if (awaitingApproval)
|
|
163
|
+
return;
|
|
164
|
+
if (status.status === 'running') {
|
|
165
|
+
if (key.ctrl && inputStr === 'c') {
|
|
166
|
+
void props.onSlash({ kind: 'quit' });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (key.return) {
|
|
170
|
+
const value = input.trim();
|
|
171
|
+
if (!value)
|
|
172
|
+
return;
|
|
173
|
+
setQueued(value);
|
|
174
|
+
setInput('');
|
|
175
|
+
append({ id: nextId('text'), kind: 'text', text: `queued: ${value.slice(0, 80)}`, role: 'assistant' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!key.ctrl && !key.meta) {
|
|
179
|
+
// Show typing indicator but don't change input (queued mode)
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// History navigation
|
|
185
|
+
if (key.upArrow) {
|
|
186
|
+
if (history.length === 0)
|
|
187
|
+
return;
|
|
188
|
+
if (historyIndexRef.current === -1)
|
|
189
|
+
historyIndexRef.current = history.length - 1;
|
|
190
|
+
else if (historyIndexRef.current > 0)
|
|
191
|
+
historyIndexRef.current--;
|
|
192
|
+
setInput(history[historyIndexRef.current] ?? '');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (key.downArrow) {
|
|
196
|
+
if (historyIndexRef.current === -1)
|
|
197
|
+
return;
|
|
198
|
+
historyIndexRef.current++;
|
|
199
|
+
if (historyIndexRef.current >= history.length) {
|
|
200
|
+
historyIndexRef.current = -1;
|
|
201
|
+
setInput('');
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
setInput(history[historyIndexRef.current] ?? '');
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Ctrl+R search — simple: cycle history
|
|
209
|
+
if (key.ctrl && inputStr === 'r') {
|
|
210
|
+
if (history.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
const term = input.toLowerCase();
|
|
213
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
214
|
+
if (history[i].toLowerCase().includes(term)) {
|
|
215
|
+
setInput(history[i]);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
87
219
|
return;
|
|
220
|
+
}
|
|
88
221
|
if (key.return) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
222
|
+
// Multiline: trailing \ or Shift+Enter (where supported, key.shift is true)
|
|
223
|
+
// Ink's key object has `shift` for Shift+Enter on some terminals
|
|
224
|
+
const isShiftEnter = key.shift === true;
|
|
225
|
+
if (isShiftEnter || input.endsWith('\\')) {
|
|
226
|
+
// Replace trailing \ with newline, or just add newline for Shift+Enter
|
|
227
|
+
if (input.endsWith('\\'))
|
|
228
|
+
setInput((v) => v.slice(0, -1) + '\n');
|
|
229
|
+
else
|
|
230
|
+
setInput((v) => v + '\n');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const value = input;
|
|
234
|
+
// Preserve newlines for bracketed paste — don't trim inner newlines, only outer
|
|
235
|
+
const trimmedOuter = value.replace(/^\s+|\s+$/g, '');
|
|
236
|
+
if (!trimmedOuter) {
|
|
237
|
+
setInput('');
|
|
92
238
|
return;
|
|
239
|
+
}
|
|
240
|
+
// Check for Ctrl+C double at empty prompt handled below, but here handle submit
|
|
241
|
+
setInput('');
|
|
242
|
+
historyIndexRef.current = -1;
|
|
243
|
+
// Save to history
|
|
244
|
+
setHistory((prev) => {
|
|
245
|
+
const next = [...prev, value];
|
|
246
|
+
appendHistory(props.cwd, value);
|
|
247
|
+
return next.slice(-200);
|
|
248
|
+
});
|
|
93
249
|
append({ id: nextId('text'), kind: 'text', text: value, role: 'user' });
|
|
94
|
-
const cmd = parseSlash(
|
|
250
|
+
const cmd = parseSlash(trimmedOuter);
|
|
95
251
|
if (cmd.kind === 'prompt') {
|
|
96
252
|
void props.onPrompt(cmd.text);
|
|
97
253
|
}
|
|
98
254
|
else if (cmd.kind === 'plan') {
|
|
99
|
-
// Local UI command — toggle the plan view inline.
|
|
100
255
|
if (plan.length > 0)
|
|
101
256
|
setPlanExpanded((v) => !v);
|
|
102
257
|
}
|
|
@@ -109,13 +264,39 @@ export function App(props) {
|
|
|
109
264
|
setInput((v) => v.slice(0, -1));
|
|
110
265
|
return;
|
|
111
266
|
}
|
|
267
|
+
// Ctrl+C double at empty prompt exits
|
|
112
268
|
if (key.ctrl && inputStr === 'c') {
|
|
269
|
+
if (input === '') {
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
if (now - lastCtrlCRef.current < 1500) {
|
|
272
|
+
void props.onSlash({ kind: 'quit' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
lastCtrlCRef.current = now;
|
|
276
|
+
append({ id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Single Ctrl+C cancels input
|
|
280
|
+
setInput('');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (key.ctrl && inputStr === 'd') {
|
|
113
284
|
void props.onSlash({ kind: 'quit' });
|
|
114
285
|
return;
|
|
115
286
|
}
|
|
287
|
+
if (key.ctrl && inputStr === 'l') {
|
|
288
|
+
// Clear — keep session but clear transcript marker
|
|
289
|
+
append({ id: nextId('text'), kind: 'text', text: '(cleared)', role: 'assistant' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Handle bracketed paste: inputStr may contain \r\n or multiple lines
|
|
116
293
|
if (!key.ctrl && !key.meta) {
|
|
117
|
-
|
|
294
|
+
// Preserve all characters including newlines from paste
|
|
295
|
+
// Normalize \r\n to \n
|
|
296
|
+
const normalized = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
297
|
+
setInput((v) => v + normalized);
|
|
118
298
|
}
|
|
119
299
|
});
|
|
120
|
-
|
|
300
|
+
const promptStr = `klyro › ${path.basename(props.cwd)}${gitBranch ? ` (${gitBranch})` : ''}`;
|
|
301
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(Header, { cwd: props.cwd, model: status.model, step: status.step, maxSteps: status.maxSteps }), rawModeWarning ? _jsx(Box, { children: _jsx(Text, { color: "yellow", children: rawModeWarning }) }) : null, _jsx(StatusLine, { snapshot: status }), plan.length > 0 ? (_jsx(PlanView, { steps: plan, expanded: planExpanded, onToggle: () => setPlanExpanded((v) => !v) })) : null, _jsx(Transcript, { items: transcript }), awaitingApproval ? _jsx(ApprovalModal, { bridge: bridge }) : null, _jsxs(Box, { borderStyle: "single", borderColor: awaitingApproval ? 'yellow' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "gray", children: awaitingApproval ? '! ' : `${promptStr} ` }), _jsx(Text, { children: awaitingApproval ? '(awaiting approval — see above)' : input }), status.status === 'running' ? _jsx(Text, { color: "cyan", children: " \u258D" }) : _jsx(Text, { children: "\u258D" })] }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Tab: slash completion \u00B7 Shift+Enter: newline \u00B7 Ctrl+C twice: exit \u00B7 Ctrl+R: history" }) })] }));
|
|
121
302
|
}
|