klyro 0.1.3 → 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.
Files changed (80) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +1 -1
  2. package/dist/agent/anthropic-adapter.js +22 -20
  3. package/dist/agent/provider-adapter.js +30 -7
  4. package/dist/agent/registry.js +24 -7
  5. package/dist/agent/retry.js +54 -9
  6. package/dist/agent/runtime.d.ts +45 -1
  7. package/dist/agent/runtime.js +236 -23
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +137 -39
  10. package/dist/cli/auth.d.ts +9 -0
  11. package/dist/cli/auth.js +84 -0
  12. package/dist/cli/completion.d.ts +6 -0
  13. package/dist/cli/completion.js +66 -0
  14. package/dist/cli/config.d.ts +44 -0
  15. package/dist/cli/config.js +428 -0
  16. package/dist/cli/doctor.d.ts +8 -0
  17. package/dist/cli/doctor.js +136 -0
  18. package/dist/cli/errors.d.ts +5 -0
  19. package/dist/cli/errors.js +37 -0
  20. package/dist/cli/markdown.d.ts +9 -0
  21. package/dist/cli/markdown.js +77 -0
  22. package/dist/cli/repl.d.ts +2 -0
  23. package/dist/cli/repl.js +280 -52
  24. package/dist/cli/run.d.ts +9 -0
  25. package/dist/cli/run.js +97 -2
  26. package/dist/cli/slash/parser.d.ts +10 -0
  27. package/dist/cli/slash/parser.js +6 -1
  28. package/dist/cli/trace.d.ts +7 -0
  29. package/dist/cli/trace.js +63 -0
  30. package/dist/cli/update.d.ts +6 -0
  31. package/dist/cli/update.js +70 -0
  32. package/dist/context/system-prompt.d.ts +14 -0
  33. package/dist/context/system-prompt.js +47 -0
  34. package/dist/events/bus.d.ts +16 -0
  35. package/dist/events/bus.js +28 -0
  36. package/dist/events/catalog.d.ts +136 -0
  37. package/dist/events/catalog.js +5 -0
  38. package/dist/index.js +369 -23
  39. package/dist/persistence/session.d.ts +12 -0
  40. package/dist/persistence/session.js +45 -0
  41. package/dist/policy/engine.d.ts +11 -1
  42. package/dist/policy/engine.js +85 -2
  43. package/dist/providers/model-info.d.ts +18 -0
  44. package/dist/providers/model-info.js +18 -0
  45. package/dist/providers.js +3 -2
  46. package/dist/renderers/json.d.ts +11 -0
  47. package/dist/renderers/json.js +14 -0
  48. package/dist/renderers/terminal.d.ts +9 -0
  49. package/dist/renderers/terminal.js +41 -0
  50. package/dist/repl.js +52 -15
  51. package/dist/shared/errors.d.ts +18 -0
  52. package/dist/shared/errors.js +33 -0
  53. package/dist/shared/index.d.ts +2 -0
  54. package/dist/shared/index.js +2 -0
  55. package/dist/shared/types.d.ts +19 -0
  56. package/dist/shared/types.js +11 -0
  57. package/dist/tools/fs/read-file.d.ts +1 -31
  58. package/dist/tools/fs/read-file.js +45 -17
  59. package/dist/tools/fs/read-history.d.ts +3 -0
  60. package/dist/tools/fs/read-history.js +13 -0
  61. package/dist/tools/fs/write-file.d.ts +1 -4
  62. package/dist/tools/fs/write-file.js +35 -1
  63. package/dist/tools/normalize.d.ts +1 -1
  64. package/dist/tools/shell/shell-exec.d.ts +1 -26
  65. package/dist/tools/shell/shell-exec.js +121 -17
  66. package/dist/tools/types.d.ts +25 -0
  67. package/dist/trace/writer.d.ts +13 -0
  68. package/dist/trace/writer.js +53 -0
  69. package/dist/tui/app.d.ts +7 -14
  70. package/dist/tui/app.js +228 -26
  71. package/dist/tui/approval.js +25 -4
  72. package/dist/tui/approval.test.js +1 -1
  73. package/dist/tui/snapshot.test.d.ts +6 -0
  74. package/dist/tui/snapshot.test.js +87 -0
  75. package/dist/tui/status.js +5 -1
  76. package/dist/util/log.d.ts +12 -0
  77. package/dist/util/log.js +75 -0
  78. package/dist/verification/auto.d.ts +8 -0
  79. package/dist/verification/auto.js +52 -0
  80. package/package.json +3 -2
@@ -15,18 +15,36 @@ const InputSchema = z.object({
15
15
  path: z.string().min(1).describe('Path relative to cwd or absolute (must be inside cwd)'),
16
16
  content: z.string().describe('File content. Use empty string to truncate.'),
17
17
  });
18
+ import { wasRead } from './read-history.js';
18
19
  export const writeFileTool = defineTool({
19
20
  name: 'write_file',
20
21
  description: 'Write content to a file. Atomic: writes to a temp file then renames. Creates parent directories as needed.',
21
22
  inputSchema: InputSchema,
23
+ permission: 'edit',
24
+ isConcurrencySafe: false,
25
+ renderCall: (input) => `write_file ${input.path} (${input.content.length} chars)`,
26
+ renderResult: (output) => `${output.path} written ${output.bytesWritten} bytes`,
22
27
  execute: async (input, ctx) => {
23
28
  return safe(async () => {
24
29
  const { resolved } = resolveWithinCwd(ctx.cwd, input.path);
25
30
  const parent = path.dirname(resolved);
26
31
  await fs.mkdir(parent, { recursive: true });
32
+ // 3.2: if file exists and was not read this session, warn but allow with diff
33
+ let existing = null;
34
+ let needsApproval = false;
35
+ try {
36
+ existing = await fs.readFile(resolved, 'utf-8');
37
+ if (!wasRead(input.path) && existing.length > 200) {
38
+ needsApproval = true;
39
+ }
40
+ }
41
+ catch {
42
+ // not exists — fine
43
+ }
27
44
  const suffix = crypto.randomBytes(8).toString('hex');
28
45
  const tmp = path.join(parent, `.klyro-write-${process.pid}-${suffix}.tmp`);
29
46
  const data = Buffer.from(input.content, 'utf-8');
47
+ // Truncate to 8k tokens ~32k chars for result
30
48
  const fh = await fs.open(tmp, 'w');
31
49
  try {
32
50
  await fh.writeFile(data);
@@ -42,7 +60,23 @@ export const writeFileTool = defineTool({
42
60
  await fs.unlink(tmp).catch(() => undefined);
43
61
  throw err;
44
62
  }
45
- return { path: input.path, bytesWritten: data.byteLength };
63
+ // Compute diff for approval UI if needed
64
+ let diff = '';
65
+ if (existing !== null) {
66
+ const before = existing.split('\n').slice(0, 20).join('\n');
67
+ const after = input.content.split('\n').slice(0, 20).join('\n');
68
+ if (before !== after)
69
+ diff = `--- before\n${before.slice(0, 500)}\n+++ after\n${after.slice(0, 500)}`;
70
+ }
71
+ const result = {
72
+ path: input.path,
73
+ bytesWritten: data.byteLength,
74
+ };
75
+ if (diff)
76
+ result.diff = diff;
77
+ if (needsApproval)
78
+ result.needsApproval = true;
79
+ return result;
46
80
  });
47
81
  },
48
82
  });
@@ -28,4 +28,4 @@ export declare function toToolError(err: unknown, fallbackCode?: ToolErrorCode):
28
28
  * If the function returns a ToolResult (already-shaped), it is passed through
29
29
  * unwrapped. Otherwise the function's return value becomes `value` on success.
30
30
  */
31
- export declare function safe<T>(fn: () => Promise<T>): Promise<ToolResult<T>>;
31
+ export declare function safe<T>(fn: () => Promise<T | ToolResult<T>>): Promise<ToolResult<T>>;
@@ -30,31 +30,6 @@ export declare const shellExecTool: import("../types.js").Tool<{
30
30
  cwd?: string | undefined;
31
31
  timeoutMs?: number | undefined;
32
32
  env?: Record<string, string> | undefined;
33
- }, {
34
- readonly ok: false;
35
- readonly error: {
36
- readonly code: "COMMAND_DENIED";
37
- readonly message: `Command blocked: ${string}`;
38
- };
39
- command?: undefined;
40
- exitCode?: undefined;
41
- signal?: undefined;
42
- stdout?: undefined;
43
- stderr?: undefined;
44
- durationMs?: undefined;
45
- timedOut?: undefined;
46
- truncated?: undefined;
47
- } | {
48
- command: string;
49
- exitCode: number | null;
50
- signal: NodeJS.Signals | null;
51
- stdout: string;
52
- stderr: string;
53
- durationMs: number;
54
- timedOut: boolean;
55
- truncated: false;
56
- readonly ok?: undefined;
57
- readonly error?: undefined;
58
- }>;
33
+ }, ShellOutput>;
59
34
  export type ShellInput = z.infer<typeof InputSchema>;
60
35
  export {};
@@ -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 = 30_000;
23
- const MAX_OUTPUT_BYTES = 128 * 1024;
24
- // Hard-coded dangerous patterns. These are non-overridable in MVP.
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 30s. Output is truncated at 128 KiB. Returns exitCode (or null if killed by signal). Non-zero exit code is NOT a tool error — the tool succeeds and the agent sees the exit code in the output.',
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
- return {
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
- let cwd = ctx.cwd;
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 = { ...process.env, ...(input.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
- if (totalOut + chunk.length > MAX_OUTPUT_BYTES) {
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
- if (totalErr + chunk.length > MAX_OUTPUT_BYTES) {
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
- require('node:child_process').spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
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 stdout = Buffer.concat(stdoutChunks).toString('utf-8');
120
- const stderr = Buffer.concat(stderrChunks).toString('utf-8');
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,
@@ -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,32 +1,25 @@
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';
15
7
  import { type TranscriptItem } from './transcript.js';
16
8
  import { TuiApprovalBridge } from './approval.js';
9
+ import type { PlanStep } from '../agent/runtime.js';
17
10
  export interface AppProps {
18
11
  initialModel: string;
19
12
  maxSteps: number;
20
- /** Working directory to display in the header. */
21
13
  cwd: string;
22
- /** Called when the user submits a non-slash prompt. */
23
14
  onPrompt: (text: string) => void | Promise<void>;
24
- /** Called when the user types a slash command. */
25
15
  onSlash: (cmd: import('../cli/slash/parser.js').SlashCommand) => void | Promise<void>;
26
- /** Initial state (e.g. when resuming a session). */
27
16
  initialTranscript?: TranscriptItem[];
28
17
  initialStatus?: Partial<StatusSnapshot>;
29
- /** Optional approval bridge — when set, the modal prompts inline. */
30
18
  approvalBridge?: TuiApprovalBridge;
19
+ onMounted?: (hooks: {
20
+ append: (i: TranscriptItem) => void;
21
+ updateStatus: (s: Partial<StatusSnapshot>) => void;
22
+ updatePlan: (p: PlanStep[]) => void;
23
+ }) => void;
31
24
  }
32
25
  export declare function App(props: AppProps): React.JSX.Element;