lonny-agent 0.2.2 → 0.2.4

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 (64) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/dist/agent/index.js +1 -1
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/agent/project.d.ts +26 -0
  5. package/dist/agent/project.d.ts.map +1 -0
  6. package/dist/agent/project.js +303 -0
  7. package/dist/agent/project.js.map +1 -0
  8. package/dist/agent/prompt-builder.d.ts +1 -1
  9. package/dist/agent/prompt-builder.d.ts.map +1 -1
  10. package/dist/agent/prompt-builder.js +10 -5
  11. package/dist/agent/prompt-builder.js.map +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +20 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.js +1 -1
  16. package/dist/agent/providers/google.js.map +1 -1
  17. package/dist/agent/providers/ollama.d.ts.map +1 -1
  18. package/dist/agent/providers/ollama.js +2 -1
  19. package/dist/agent/providers/ollama.js.map +1 -1
  20. package/dist/agent/providers/openai.d.ts.map +1 -1
  21. package/dist/agent/providers/openai.js +23 -3
  22. package/dist/agent/providers/openai.js.map +1 -1
  23. package/dist/agent/session.d.ts +4 -2
  24. package/dist/agent/session.d.ts.map +1 -1
  25. package/dist/agent/session.js +206 -147
  26. package/dist/agent/session.js.map +1 -1
  27. package/dist/tools/__tests__/edit.test.js +14 -14
  28. package/dist/tools/__tests__/edit.test.js.map +1 -1
  29. package/dist/tools/edit.d.ts +17 -0
  30. package/dist/tools/edit.d.ts.map +1 -1
  31. package/dist/tools/edit.js +81 -34
  32. package/dist/tools/edit.js.map +1 -1
  33. package/dist/tools/read.d.ts +6 -0
  34. package/dist/tools/read.d.ts.map +1 -1
  35. package/dist/tools/read.js +42 -15
  36. package/dist/tools/read.js.map +1 -1
  37. package/dist/tools/registry.d.ts.map +1 -1
  38. package/dist/tools/registry.js +0 -4
  39. package/dist/tools/registry.js.map +1 -1
  40. package/dist/tools/write_plan.d.ts +1 -1
  41. package/dist/tools/write_plan.d.ts.map +1 -1
  42. package/dist/tools/write_plan.js +14 -4
  43. package/dist/tools/write_plan.js.map +1 -1
  44. package/dist/tui/index.d.ts.map +1 -1
  45. package/dist/tui/index.js +5 -4
  46. package/dist/tui/index.js.map +1 -1
  47. package/dist/web/index.js +5 -5
  48. package/dist/web/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/agent/index.ts +1 -1
  51. package/src/agent/project.ts +366 -0
  52. package/src/agent/prompt-builder.ts +11 -5
  53. package/src/agent/providers/anthropic.ts +21 -2
  54. package/src/agent/providers/google.ts +1 -1
  55. package/src/agent/providers/ollama.ts +5 -1
  56. package/src/agent/providers/openai.ts +27 -3
  57. package/src/agent/session.ts +216 -150
  58. package/src/tools/__tests__/edit.test.ts +14 -14
  59. package/src/tools/edit.ts +103 -36
  60. package/src/tools/read.ts +61 -25
  61. package/src/tools/registry.ts +260 -265
  62. package/src/tools/write_plan.ts +21 -5
  63. package/src/tui/index.ts +5 -4
  64. package/src/web/index.ts +5 -5
@@ -1,265 +1,260 @@
1
- import type { FileReadTracker } from '../diff/apply.js'
2
- import { bashTool } from './bash.js'
3
- import { createEditTool } from './edit.js'
4
- import { fmtErr } from './errors.js'
5
- import { createExecTool, updateExecToolDefinition } from './exec.js'
6
- import { fetchTool } from './fetch.js'
7
- import { createFindTool } from './find.js'
8
- import { createGitTool } from './git.js'
9
- import { globTool } from './glob.js'
10
- import { createGrepTool } from './grep.js'
11
- import { createInstallSkillTool } from './install_skill.js'
12
- import { createLsTool } from './ls.js'
13
- import { createReadTool } from './read.js'
14
- import { searchTool } from './search.js'
15
- import type { Tool, ToolCall, ToolDefinition, ToolResult } from './types.js'
16
- import { createWritePlanTool } from './write_plan.js'
17
-
18
- /**
19
- * Normalize tool input to prevent common LLM call misuses.
20
- * The LLM sometimes passes parameters in the wrong format — this function
21
- * auto-corrects those patterns so tools work reliably.
22
- *
23
- * Common misuses handled:
24
- * 1. Passed as a string instead of { ... } object wrapper
25
- * 2. Passed top-level params directly instead of nested in the expected key
26
- * 3. Array passed directly (for tools that expect { items: [...] })
27
- */
28
- function normalizeToolInput(
29
- toolName: string,
30
- _definition: ToolDefinition,
31
- input: Record<string, unknown>,
32
- ): Record<string, unknown> {
33
- // If input is a string (e.g. install_skill("package-name") or bash("ls -la"))
34
- // wrap it into the appropriate key
35
- if (typeof input === 'string') {
36
- if (toolName === 'bash' || toolName === 'git') {
37
- return { command: input }
38
- }
39
- if (
40
- toolName === 'install_skill' ||
41
- toolName === 'find' ||
42
- toolName === 'glob' ||
43
- toolName === 'grep'
44
- ) {
45
- return { pattern: input }
46
- }
47
- if (toolName === 'search' || toolName === 'fetch') {
48
- return { query: input, url: input }
49
- }
50
- if (toolName === 'write_plan') {
51
- return { filename: input, content: input }
52
- }
53
- if (toolName === 'exec') {
54
- return { code: input }
55
- }
56
- // For tools that take a single string param, guess from the definition
57
- const params = Object.keys(_definition.parameters)
58
- if (params.length === 1) {
59
- return { [params[0]]: input }
60
- }
61
- return input
62
- }
63
-
64
- // If input is an array (e.g. read called with ["file.ts"] directly)
65
- if (Array.isArray(input)) {
66
- if (toolName === 'read' || toolName === 'ls') {
67
- return { paths: input, path: (input as string[])[0] }
68
- }
69
- if (toolName === 'edit') {
70
- return { edits: input }
71
- }
72
- return input
73
- }
74
-
75
- // If edit tool gets an empty object, some models hallucinate the call
76
- if (toolName === 'edit' && Object.keys(input).length === 0) {
77
- return { edits: [] }
78
- }
79
-
80
- return input
81
- }
82
-
83
- export interface ToolContext {
84
- cwd: string
85
- autoApprove: boolean
86
- applier: FileReadTracker
87
- mode: 'code' | 'plan' | 'ask'
88
- onPlanWritten?: (display: string) => void
89
- }
90
-
91
- export interface ToolPlugin {
92
- name: string
93
- description: string
94
- create: (context: ToolContext) => Tool
95
- }
96
-
97
- /**
98
- * Extensible ToolRegistry with plugin support.
99
- * Inspired by pi's extension system for dynamic tool registration.
100
- */
101
- export class ToolRegistry {
102
- private tools: Map<string, Tool> = new Map()
103
- private context: ToolContext
104
- private plugins: Map<string, ToolPlugin> = new Map()
105
- /** Reference to the exec tool for dynamic description updates */
106
- private execTool: Tool | null = null
107
-
108
- constructor(context: ToolContext) {
109
- this.context = context
110
- this.registerBuiltins()
111
- }
112
-
113
- /** Register all built-in tools */
114
- private registerBuiltins(): void {
115
- // ask mode: only fetch and search + exec (exec works with any toolset)
116
- if (this.context.mode === 'ask') {
117
- this.register(fetchTool)
118
- this.register(searchTool)
119
- return
120
- }
121
-
122
- this.register(createReadTool(this.context.applier, this.context.cwd))
123
- this.register(globTool)
124
- this.register(createGrepTool(this.context.cwd))
125
- this.register(createLsTool(this.context.cwd))
126
- this.register(createFindTool(this.context.cwd))
127
- this.register(fetchTool)
128
- this.register(searchTool)
129
-
130
- if (this.context.mode === 'code') {
131
- // Code mode: full toolset including write operations
132
- this.register(bashTool)
133
- this.register(createGitTool(this.context.cwd))
134
- this.register(createInstallSkillTool(this.context.cwd))
135
- this.register(createEditTool(this.context.applier, this.context.cwd))
136
- this.registerExecTool()
137
- } else {
138
- // Plan mode: read-only investigation + write_plan
139
- this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
140
- }
141
- }
142
-
143
- /** Register the exec tool with dynamic type declarations for all registered tools */
144
- private registerExecTool(): void {
145
- const execTool = createExecTool(() => Array.from(this.tools.values()))
146
- this.execTool = execTool
147
- this.register(execTool)
148
- }
149
-
150
- /** Refresh the exec tool's description to include current tool type declarations */
151
- private refreshExecDescription(): void {
152
- if (this.execTool) {
153
- updateExecToolDefinition(this.execTool, this.getDefinitions())
154
- }
155
- }
156
-
157
- setMode(mode: 'code' | 'plan' | 'ask'): void {
158
- // Update context.mode FIRST so registerBuiltins() and mode-based logic
159
- // use the NEW mode, not the stale one (fixes bug when switching from
160
- // ask→code or plan→code where all tools would be missing)
161
- this.context.mode = mode
162
-
163
- if (mode === 'code') {
164
- // Re-register all built-in tools for code mode
165
- this.tools.clear()
166
- this.execTool = null
167
- this.registerBuiltins()
168
- } else if (mode === 'plan') {
169
- // Keep existing tools but swap edit/write_plan, remove exec
170
- if (!this.tools.has('write_plan')) {
171
- this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
172
- }
173
- this.tools.delete('edit')
174
- this.tools.delete('exec')
175
- this.execTool = null
176
- } else if (mode === 'ask') {
177
- // Ask mode: only fetch and search
178
- this.tools.clear()
179
- this.execTool = null
180
- this.register(fetchTool)
181
- this.register(searchTool)
182
- }
183
- }
184
-
185
- /** Register a single tool */
186
- register(tool: Tool): void {
187
- this.tools.set(tool.definition.name, tool)
188
- // Refresh exec tool description to include the new tool's type declarations
189
- if (this.execTool && tool.definition.name !== 'exec') {
190
- this.refreshExecDescription()
191
- }
192
- }
193
-
194
- /** Register a tool plugin (lazy creation pattern) */
195
- registerPlugin(plugin: ToolPlugin): void {
196
- this.plugins.set(plugin.name, plugin)
197
- // Activate immediately
198
- try {
199
- const tool = plugin.create(this.context)
200
- this.register(tool)
201
- } catch (err) {
202
- console.error(`Failed to activate plugin "${plugin.name}": ${err}`)
203
- }
204
- }
205
-
206
- /** Unregister a tool by name */
207
- unregister(name: string): boolean {
208
- return this.tools.delete(name)
209
- }
210
-
211
- /** Check if a tool is registered */
212
- has(name: string): boolean {
213
- return this.tools.has(name)
214
- }
215
-
216
- /** Re-register write_plan tool with an updated callback */
217
- reRegisterWritePlan(cwd: string, cb?: (display: string) => void): void {
218
- this.tools.delete('write_plan')
219
- this.tools.set('write_plan', createWritePlanTool(cwd, cb))
220
- }
221
-
222
- /** Partially update the context (used by session.onPlanWritten setter) */
223
- updateContext(partial: Partial<ToolContext>): void {
224
- Object.assign(this.context, partial)
225
- }
226
-
227
- /** List all registered tool names */
228
- listTools(): string[] {
229
- return Array.from(this.tools.keys())
230
- }
231
-
232
- getDefinitions(): ToolDefinition[] {
233
- return Array.from(this.tools.values()).map(t => t.definition)
234
- }
235
-
236
- async dispatch(call: ToolCall): Promise<ToolResult> {
237
- const tool = this.tools.get(call.name)
238
- if (!tool) {
239
- return {
240
- success: false,
241
- output: '',
242
- error: `Unknown tool: "${call.name}". Available: ${Array.from(this.tools.keys()).join(', ')}`,
243
- }
244
- }
245
-
246
- // ── Universal input normalization ──────────────────────────────────
247
- // Auto-correct common LLM call misuses before they reach tool execute()
248
- let input = call.input
249
- try {
250
- input = normalizeToolInput(call.name, tool.definition, input)
251
- } catch {
252
- // If normalization throws, let the tool handle it (or fail naturally)
253
- }
254
-
255
- try {
256
- return await tool.execute(input)
257
- } catch (err) {
258
- return {
259
- success: false,
260
- output: '',
261
- error: `Tool "${call.name}" threw: ${fmtErr(err)}`,
262
- }
263
- }
264
- }
265
- }
1
+ import type { FileReadTracker } from '../diff/apply.js'
2
+ import { bashTool } from './bash.js'
3
+ import { createEditTool } from './edit.js'
4
+ import { fmtErr } from './errors.js'
5
+ import { createExecTool, updateExecToolDefinition } from './exec.js'
6
+ import { fetchTool } from './fetch.js'
7
+ import { createFindTool } from './find.js'
8
+ import { createGitTool } from './git.js'
9
+ import { globTool } from './glob.js'
10
+ import { createGrepTool } from './grep.js'
11
+ import { createInstallSkillTool } from './install_skill.js'
12
+ import { createLsTool } from './ls.js'
13
+ import { createReadTool } from './read.js'
14
+ import { searchTool } from './search.js'
15
+ import type { Tool, ToolCall, ToolDefinition, ToolResult } from './types.js'
16
+ import { createWritePlanTool } from './write_plan.js'
17
+
18
+ /**
19
+ * Normalize tool input to prevent common LLM call misuses.
20
+ * The LLM sometimes passes parameters in the wrong format — this function
21
+ * auto-corrects those patterns so tools work reliably.
22
+ *
23
+ * Common misuses handled:
24
+ * 1. Passed as a string instead of { ... } object wrapper
25
+ * 2. Passed top-level params directly instead of nested in the expected key
26
+ * 3. Array passed directly (for tools that expect { items: [...] })
27
+ */
28
+ function normalizeToolInput(
29
+ toolName: string,
30
+ _definition: ToolDefinition,
31
+ input: Record<string, unknown>,
32
+ ): Record<string, unknown> {
33
+ // If input is a string (e.g. install_skill("package-name") or bash("ls -la"))
34
+ // wrap it into the appropriate key
35
+ if (typeof input === 'string') {
36
+ if (toolName === 'bash' || toolName === 'git') {
37
+ return { command: input }
38
+ }
39
+ if (
40
+ toolName === 'install_skill' ||
41
+ toolName === 'find' ||
42
+ toolName === 'glob' ||
43
+ toolName === 'grep'
44
+ ) {
45
+ return { pattern: input }
46
+ }
47
+ if (toolName === 'search' || toolName === 'fetch') {
48
+ return { query: input, url: input }
49
+ }
50
+ if (toolName === 'write_plan') {
51
+ return { filename: input, content: input }
52
+ }
53
+ if (toolName === 'exec') {
54
+ return { code: input }
55
+ }
56
+ // For tools that take a single string param, guess from the definition
57
+ const params = Object.keys(_definition.parameters)
58
+ if (params.length === 1) {
59
+ return { [params[0]]: input }
60
+ }
61
+ return input
62
+ }
63
+
64
+ // If input is an array (e.g. read called with ["file.ts"] directly)
65
+ if (Array.isArray(input)) {
66
+ if (toolName === 'read' || toolName === 'ls') {
67
+ return { paths: input, path: (input as string[])[0] }
68
+ }
69
+ if (toolName === 'edit') {
70
+ return { edits: input }
71
+ }
72
+ return input
73
+ }
74
+
75
+ return input
76
+ }
77
+
78
+ export interface ToolContext {
79
+ cwd: string
80
+ autoApprove: boolean
81
+ applier: FileReadTracker
82
+ mode: 'code' | 'plan' | 'ask'
83
+ onPlanWritten?: (display: string) => void
84
+ }
85
+
86
+ export interface ToolPlugin {
87
+ name: string
88
+ description: string
89
+ create: (context: ToolContext) => Tool
90
+ }
91
+
92
+ /**
93
+ * Extensible ToolRegistry with plugin support.
94
+ * Inspired by pi's extension system for dynamic tool registration.
95
+ */
96
+ export class ToolRegistry {
97
+ private tools: Map<string, Tool> = new Map()
98
+ private context: ToolContext
99
+ private plugins: Map<string, ToolPlugin> = new Map()
100
+ /** Reference to the exec tool for dynamic description updates */
101
+ private execTool: Tool | null = null
102
+
103
+ constructor(context: ToolContext) {
104
+ this.context = context
105
+ this.registerBuiltins()
106
+ }
107
+
108
+ /** Register all built-in tools */
109
+ private registerBuiltins(): void {
110
+ // ask mode: only fetch and search + exec (exec works with any toolset)
111
+ if (this.context.mode === 'ask') {
112
+ this.register(fetchTool)
113
+ this.register(searchTool)
114
+ return
115
+ }
116
+
117
+ this.register(createReadTool(this.context.applier, this.context.cwd))
118
+ this.register(globTool)
119
+ this.register(createGrepTool(this.context.cwd))
120
+ this.register(createLsTool(this.context.cwd))
121
+ this.register(createFindTool(this.context.cwd))
122
+ this.register(fetchTool)
123
+ this.register(searchTool)
124
+
125
+ if (this.context.mode === 'code') {
126
+ // Code mode: full toolset including write operations
127
+ this.register(bashTool)
128
+ this.register(createGitTool(this.context.cwd))
129
+ this.register(createInstallSkillTool(this.context.cwd))
130
+ this.register(createEditTool(this.context.applier, this.context.cwd))
131
+ this.registerExecTool()
132
+ } else {
133
+ // Plan mode: read-only investigation + write_plan
134
+ this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
135
+ }
136
+ }
137
+
138
+ /** Register the exec tool with dynamic type declarations for all registered tools */
139
+ private registerExecTool(): void {
140
+ const execTool = createExecTool(() => Array.from(this.tools.values()))
141
+ this.execTool = execTool
142
+ this.register(execTool)
143
+ }
144
+
145
+ /** Refresh the exec tool's description to include current tool type declarations */
146
+ private refreshExecDescription(): void {
147
+ if (this.execTool) {
148
+ updateExecToolDefinition(this.execTool, this.getDefinitions())
149
+ }
150
+ }
151
+
152
+ setMode(mode: 'code' | 'plan' | 'ask'): void {
153
+ // Update context.mode FIRST so registerBuiltins() and mode-based logic
154
+ // use the NEW mode, not the stale one (fixes bug when switching from
155
+ // ask→code or plan→code where all tools would be missing)
156
+ this.context.mode = mode
157
+
158
+ if (mode === 'code') {
159
+ // Re-register all built-in tools for code mode
160
+ this.tools.clear()
161
+ this.execTool = null
162
+ this.registerBuiltins()
163
+ } else if (mode === 'plan') {
164
+ // Keep existing tools but swap edit/write_plan, remove exec
165
+ if (!this.tools.has('write_plan')) {
166
+ this.register(createWritePlanTool(this.context.cwd, this.context.onPlanWritten))
167
+ }
168
+ this.tools.delete('edit')
169
+ this.tools.delete('exec')
170
+ this.execTool = null
171
+ } else if (mode === 'ask') {
172
+ // Ask mode: only fetch and search
173
+ this.tools.clear()
174
+ this.execTool = null
175
+ this.register(fetchTool)
176
+ this.register(searchTool)
177
+ }
178
+ }
179
+
180
+ /** Register a single tool */
181
+ register(tool: Tool): void {
182
+ this.tools.set(tool.definition.name, tool)
183
+ // Refresh exec tool description to include the new tool's type declarations
184
+ if (this.execTool && tool.definition.name !== 'exec') {
185
+ this.refreshExecDescription()
186
+ }
187
+ }
188
+
189
+ /** Register a tool plugin (lazy creation pattern) */
190
+ registerPlugin(plugin: ToolPlugin): void {
191
+ this.plugins.set(plugin.name, plugin)
192
+ // Activate immediately
193
+ try {
194
+ const tool = plugin.create(this.context)
195
+ this.register(tool)
196
+ } catch (err) {
197
+ console.error(`Failed to activate plugin "${plugin.name}": ${err}`)
198
+ }
199
+ }
200
+
201
+ /** Unregister a tool by name */
202
+ unregister(name: string): boolean {
203
+ return this.tools.delete(name)
204
+ }
205
+
206
+ /** Check if a tool is registered */
207
+ has(name: string): boolean {
208
+ return this.tools.has(name)
209
+ }
210
+
211
+ /** Re-register write_plan tool with an updated callback */
212
+ reRegisterWritePlan(cwd: string, cb?: (display: string) => void): void {
213
+ this.tools.delete('write_plan')
214
+ this.tools.set('write_plan', createWritePlanTool(cwd, cb))
215
+ }
216
+
217
+ /** Partially update the context (used by session.onPlanWritten setter) */
218
+ updateContext(partial: Partial<ToolContext>): void {
219
+ Object.assign(this.context, partial)
220
+ }
221
+
222
+ /** List all registered tool names */
223
+ listTools(): string[] {
224
+ return Array.from(this.tools.keys())
225
+ }
226
+
227
+ getDefinitions(): ToolDefinition[] {
228
+ return Array.from(this.tools.values()).map(t => t.definition)
229
+ }
230
+
231
+ async dispatch(call: ToolCall): Promise<ToolResult> {
232
+ const tool = this.tools.get(call.name)
233
+ if (!tool) {
234
+ return {
235
+ success: false,
236
+ output: '',
237
+ error: `Unknown tool: "${call.name}". Available: ${Array.from(this.tools.keys()).join(', ')}`,
238
+ }
239
+ }
240
+
241
+ // ── Universal input normalization ──────────────────────────────────
242
+ // Auto-correct common LLM call misuses before they reach tool execute()
243
+ let input = call.input
244
+ try {
245
+ input = normalizeToolInput(call.name, tool.definition, input)
246
+ } catch {
247
+ // If normalization throws, let the tool handle it (or fail naturally)
248
+ }
249
+
250
+ try {
251
+ return await tool.execute(input)
252
+ } catch (err) {
253
+ return {
254
+ success: false,
255
+ output: '',
256
+ error: `Tool "${call.name}" threw: ${fmtErr(err)}`,
257
+ }
258
+ }
259
+ }
260
+ }
@@ -1,4 +1,4 @@
1
- import * as fs from 'node:fs'
1
+ import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
  import { fmtErr } from './errors.js'
4
4
  import type { Tool, ToolResult } from './types.js'
@@ -6,6 +6,9 @@ import type { Tool, ToolResult } from './types.js'
6
6
  /** Directory where plans are stored, relative to project root */
7
7
  export const PLAN_DIR = '.lonny'
8
8
 
9
+ /** Maximum plan content size (1MB) */
10
+ const MAX_CONTENT_SIZE = 1_000_000
11
+
9
12
  function sanitizeFilename(name: string): string {
10
13
  const trimmed = name.trim()
11
14
  if (!trimmed) return ''
@@ -22,7 +25,10 @@ function sanitizeFilename(name: string): string {
22
25
  return normalized
23
26
  }
24
27
 
25
- export function createWritePlanTool(cwd: string, onPlanWritten?: (display: string) => void): Tool {
28
+ export function createWritePlanTool(
29
+ cwd: string,
30
+ onPlanWritten?: (display: string) => void | Promise<void>,
31
+ ): Tool {
26
32
  return {
27
33
  definition: {
28
34
  name: 'write_plan',
@@ -49,9 +55,19 @@ export function createWritePlanTool(cwd: string, onPlanWritten?: (display: strin
49
55
  if (typeof input.content !== 'string') {
50
56
  return { success: false, output: '', error: 'content is required (string)' }
51
57
  }
58
+
52
59
  const filename = input.filename
53
60
  const content = input.content
54
61
 
62
+ // Check content size
63
+ if (content.length > MAX_CONTENT_SIZE) {
64
+ return {
65
+ success: false,
66
+ output: '',
67
+ error: `Content too large (max ${MAX_CONTENT_SIZE} bytes)`,
68
+ }
69
+ }
70
+
55
71
  const safeName = sanitizeFilename(filename)
56
72
  if (!safeName) {
57
73
  return {
@@ -69,10 +85,10 @@ export function createWritePlanTool(cwd: string, onPlanWritten?: (display: strin
69
85
  }
70
86
 
71
87
  try {
72
- fs.mkdirSync(path.dirname(target), { recursive: true })
73
- fs.writeFileSync(target, content, 'utf8')
88
+ await fs.mkdir(path.dirname(target), { recursive: true })
89
+ await fs.writeFile(target, content, 'utf8')
74
90
  const display = path.relative(cwd, target).replace(/\\/g, '/')
75
- onPlanWritten?.(display)
91
+ await Promise.resolve(onPlanWritten?.(display))
76
92
  return {
77
93
  success: true,
78
94
  output: `Wrote plan to ${display} (${Buffer.byteLength(content, 'utf8')} bytes)`,
package/src/tui/index.ts CHANGED
@@ -225,7 +225,7 @@ export async function startTui(config: Config): Promise<void> {
225
225
 
226
226
  // Try to restore a saved session for this directory (MUST be before landing screen setup)
227
227
  let restored = false
228
- const restoredSession = Session.load(config, output)
228
+ const restoredSession = await Session.load(config, output)
229
229
  if (restoredSession) {
230
230
  restored = true
231
231
  session = restoredSession
@@ -484,7 +484,7 @@ export async function startTui(config: Config): Promise<void> {
484
484
  }
485
485
 
486
486
  // ── Input handling ──────────────────────────────────────────────────────
487
- function sendMessage(text: string): void {
487
+ async function sendMessage(text: string): Promise<void> {
488
488
  const trimmed = text.trim()
489
489
  if (!trimmed) return
490
490
  editor.setText('')
@@ -532,7 +532,7 @@ export async function startTui(config: Config): Promise<void> {
532
532
 
533
533
  if (cmd === 'mode') {
534
534
  if (arg === 'code' || arg === 'plan' || arg === 'ask') {
535
- session.setMode(arg)
535
+ await session.setMode(arg)
536
536
  chatContent += `\n${colors.warn('\u21E8')} Switched to ${arg === 'ask' ? colors.success(arg) : colors.warn(arg)} mode\n`
537
537
  chatMarkdown.setText(chatContent)
538
538
  updateFooter()
@@ -547,7 +547,7 @@ export async function startTui(config: Config): Promise<void> {
547
547
  if (arg) {
548
548
  session.config.model = arg
549
549
  // Rebuild system prompt with new model context
550
- session.setMode(session.config.mode) // triggers rebuild
550
+ await session.setMode(session.config.mode) // triggers rebuild
551
551
  chatContent += `\n${colors.warn('\u21E8')} Model switched to ${colors.warn(arg)}\n`
552
552
  chatMarkdown.setText(chatContent)
553
553
  updateFooter()
@@ -625,6 +625,7 @@ export async function startTui(config: Config): Promise<void> {
625
625
  chatContent += `\n${colors.warn('\u23F9')} Stopping agent...\n`
626
626
  chatMarkdown.setText(chatContent)
627
627
  isRunning = false
628
+ loader.stop()
628
629
  loader.setMessage('')
629
630
  tui.setShowHardwareCursor(true)
630
631
  updateFooter()