goatchain 0.0.1

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/cli/turn.mjs ADDED
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Run one interactive turn, supporting tool-approval pause/resume via `requires_action`.
3
+ *
4
+ * This is intentionally UI-agnostic: the caller provides `askApproval(prompt)`.
5
+ */
6
+ import path from 'node:path'
7
+ import { ansi, formatOneLine } from './ui.mjs'
8
+
9
+ export async function runTurnWithApprovals({
10
+ agent,
11
+ sessionId,
12
+ history,
13
+ input,
14
+ signal,
15
+ askApproval,
16
+ askApprovalForTool,
17
+ approvalStrategy = 'high_risk',
18
+ autoApprove = false,
19
+ emoji = process.env.GOATCHAIN_EMOJI !== '0',
20
+ showTools = process.env.GOATCHAIN_SHOW_TOOLS !== '0',
21
+ toolStyle = process.env.GOATCHAIN_TOOL_STYLE ?? 'inline',
22
+ showStatus = process.env.GOATCHAIN_SHOW_STATUS !== '0',
23
+ showActivity = process.env.GOATCHAIN_SHOW_ACTIVITY !== '0',
24
+ showStatusLine = process.env.GOATCHAIN_SHOW_STATUSLINE !== '0',
25
+ promptContinue = process.env.GOATCHAIN_PROMPT_CONTINUE !== '0',
26
+ autoContinue = process.env.GOATCHAIN_AUTO_CONTINUE === '1',
27
+ write = (s) => process.stdout.write(s),
28
+ }) {
29
+ const useEmoji = Boolean(emoji) && process.stdout.isTTY && process.env.TERM !== 'dumb'
30
+ const icon = (s) => (useEmoji ? `${s} ` : '')
31
+
32
+ let atLineStart = true
33
+ /** @type {null | { start: () => void, stop: () => void, set: (p: string, d?: string) => void, onOutput: () => void, onPauseForInput: () => void }} */
34
+ let activity = null
35
+
36
+ const out = (s) => {
37
+ activity?.onOutput()
38
+ const str = String(s ?? '')
39
+ write(str)
40
+ const lastNl = str.lastIndexOf('\n')
41
+ if (lastNl === -1) {
42
+ atLineStart = false
43
+ return
44
+ }
45
+ atLineStart = lastNl === str.length - 1
46
+ }
47
+
48
+ const ensureNewline = () => {
49
+ if (!atLineStart)
50
+ out('\n')
51
+ }
52
+
53
+ /** @type {any[]} */
54
+ const messagesToAppend = []
55
+
56
+ /** @type {{ text: string, toolCalls: any[], flushedToolCallAssistant: boolean }} */
57
+ let iteration = { text: '', toolCalls: [], flushedToolCallAssistant: false }
58
+ /** @type {Map<string, string>} */
59
+ let toolNameById = new Map()
60
+ /** @type {Map<string, string>} */
61
+ let toolArgsSummaryById = new Map()
62
+ /** @type {Set<string>} */
63
+ let printedToolCallIds = new Set()
64
+ /** @type {Set<string>} */
65
+ const executedToolNames = new Set()
66
+ /** @type {{ stopReason?: string, modelStopReason?: string, tools: Array<{ toolName: string, argsSummary?: string, resultSummary?: string }> }} */
67
+ const meta = { tools: [] }
68
+
69
+ let approveAllToolsThisTurn = false
70
+
71
+ const byteLen = (s) => {
72
+ try {
73
+ return Buffer.byteLength(String(s ?? ''), 'utf8')
74
+ }
75
+ catch {
76
+ return String(s ?? '').length
77
+ }
78
+ }
79
+
80
+ const humanBytes = (n) => {
81
+ const num = Number(n)
82
+ if (!Number.isFinite(num) || num < 0)
83
+ return ''
84
+ if (num < 1024)
85
+ return `${num} B`
86
+ if (num < 1024 * 1024)
87
+ return `${(num / 1024).toFixed(2)} KB`
88
+ return `${(num / (1024 * 1024)).toFixed(2)} MB`
89
+ }
90
+
91
+ const summarizeArgsForDisplay = (toolName, args) => {
92
+ const name = String(toolName ?? '')
93
+ const a = args && typeof args === 'object' ? args : {}
94
+
95
+ if (name === 'Write') {
96
+ const filePath = typeof a.file_path === 'string' ? a.file_path : ''
97
+ const size = typeof a.content === 'string' ? humanBytes(byteLen(a.content)) : ''
98
+ return {
99
+ summary: `${filePath}${size ? ` (content ${size})` : ''}`,
100
+ lines: [
101
+ filePath ? `file: ${filePath}` : undefined,
102
+ size ? `content: ${size}` : undefined,
103
+ ].filter(Boolean),
104
+ }
105
+ }
106
+
107
+ if (name === 'Edit') {
108
+ const filePath = typeof a.file_path === 'string' ? a.file_path : ''
109
+ const edits = Array.isArray(a.edits) ? a.edits : []
110
+ const first = edits[0] && typeof edits[0] === 'object' ? edits[0] : undefined
111
+ const oldText = first && typeof first.oldText === 'string' ? first.oldText : ''
112
+ const newText = first && typeof first.newText === 'string' ? first.newText : ''
113
+ const firstPreview = oldText || newText
114
+ ? `first edit: ${formatOneLine(oldText, 60)} → ${formatOneLine(newText, 60)}`
115
+ : undefined
116
+ return {
117
+ summary: `${filePath}${edits.length ? ` (${edits.length} edits)` : ''}`,
118
+ lines: [
119
+ filePath ? `file: ${filePath}` : undefined,
120
+ edits.length ? `edits: ${edits.length}` : undefined,
121
+ firstPreview,
122
+ ].filter(Boolean),
123
+ }
124
+ }
125
+
126
+ if (name === 'Read') {
127
+ const filePath = typeof a.file_path === 'string' ? a.file_path : ''
128
+ const offset = typeof a.offset === 'number' ? a.offset : undefined
129
+ const limit = typeof a.limit === 'number' ? a.limit : undefined
130
+ const range = offset || limit
131
+ ? ` (${offset ? `from ${offset}` : ''}${offset && limit ? ', ' : ''}${limit ? `limit ${limit}` : ''})`
132
+ : ''
133
+ return {
134
+ summary: `${filePath}${range}`,
135
+ lines: [
136
+ filePath ? `file: ${filePath}` : undefined,
137
+ offset ? `offset: ${offset}` : undefined,
138
+ limit ? `limit: ${limit}` : undefined,
139
+ ].filter(Boolean),
140
+ }
141
+ }
142
+
143
+ if (name === 'Glob') {
144
+ const pattern = typeof a.pattern === 'string' ? a.pattern : ''
145
+ return {
146
+ summary: pattern,
147
+ lines: [pattern ? `pattern: ${pattern}` : undefined].filter(Boolean),
148
+ }
149
+ }
150
+
151
+ if (name === 'Grep') {
152
+ const pattern = typeof a.pattern === 'string' ? a.pattern : ''
153
+ const outputMode = typeof a.output_mode === 'string' ? a.output_mode : ''
154
+ const path = typeof a.path === 'string' ? a.path : ''
155
+ return {
156
+ summary: `${pattern}${path ? ` in ${path}` : ''}`,
157
+ lines: [
158
+ pattern ? `pattern: ${formatOneLine(pattern, 120)}` : undefined,
159
+ path ? `path: ${path}` : undefined,
160
+ outputMode ? `mode: ${outputMode}` : undefined,
161
+ ].filter(Boolean),
162
+ }
163
+ }
164
+
165
+ if (name === 'WebSearch') {
166
+ const q = typeof a.query === 'string' ? a.query : ''
167
+ return {
168
+ summary: formatOneLine(q, 120),
169
+ lines: [q ? `query: ${formatOneLine(q, 200)}` : undefined].filter(Boolean),
170
+ }
171
+ }
172
+
173
+ // Generic fallback: show keys but avoid huge payloads.
174
+ const keys = Object.keys(a)
175
+ const short = keys.slice(0, 6).join(', ')
176
+ return {
177
+ summary: short ? `{${short}${keys.length > 6 ? ', …' : ''}}` : '{}',
178
+ lines: short ? [`args: { ${short}${keys.length > 6 ? ', …' : ''} }`] : [],
179
+ }
180
+ }
181
+
182
+ const createActivity = () => {
183
+ if ((!showActivity && !showStatusLine) || !process.stdout.isTTY)
184
+ return null
185
+
186
+ const baseTitle = process.env.GOATCHAIN_TITLE ?? 'GoatChain'
187
+ let phase = 'working'
188
+ let detail = ''
189
+ let tick = 0
190
+ let timer = null
191
+ let spinnerVisible = false
192
+ let lastOutputAt = Date.now()
193
+
194
+ const dots = () => {
195
+ tick = (tick + 1) % 4
196
+ return '.'.repeat(tick)
197
+ }
198
+
199
+ const phaseIcon = (p) => {
200
+ if (!useEmoji)
201
+ return ''
202
+ if (p === 'thinking')
203
+ return '🤔'
204
+ if (p === 'tool_call')
205
+ return '🛠️'
206
+ if (p === 'responding')
207
+ return '✍️'
208
+ if (p === 'working')
209
+ return '…'
210
+ return ''
211
+ }
212
+
213
+ const label = () => {
214
+ const pi = phaseIcon(phase)
215
+ const p = pi ? `${pi} ${phase}` : phase
216
+ return detail ? `${p}: ${detail}` : p
217
+ }
218
+
219
+ const setTitle = (text) => {
220
+ if (!showActivity)
221
+ return
222
+ process.stdout.write(`\u001B]0;${text}\u0007`)
223
+ }
224
+
225
+ const showSpinner = () => {
226
+ if (!showStatusLine)
227
+ return
228
+ if (Date.now() - lastOutputAt < 450)
229
+ return
230
+ // Put spinner on its own line at the bottom of current output, update in-place.
231
+ if (!spinnerVisible) {
232
+ if (!atLineStart) {
233
+ process.stdout.write('\n')
234
+ atLineStart = true
235
+ }
236
+ spinnerVisible = true
237
+ }
238
+ const d = dots()
239
+ const txt = `${label()}${d}`
240
+ process.stdout.write(`\r\u001B[2K${ansi.dim(formatOneLine(txt, 120))}`)
241
+ atLineStart = false
242
+ }
243
+
244
+ const hideSpinner = () => {
245
+ if (!spinnerVisible)
246
+ return
247
+ process.stdout.write('\r\u001B[2K')
248
+ spinnerVisible = false
249
+ atLineStart = true
250
+ }
251
+
252
+ const render = () => {
253
+ const d = dots()
254
+ const t = `${baseTitle} — ${label()}${d}`
255
+ setTitle(t)
256
+ showSpinner()
257
+ }
258
+
259
+ const start = () => {
260
+ if (timer)
261
+ return
262
+ render()
263
+ timer = setInterval(render, 250)
264
+ if (typeof timer?.unref === 'function')
265
+ timer.unref()
266
+ }
267
+
268
+ const stop = () => {
269
+ if (timer) {
270
+ clearInterval(timer)
271
+ timer = null
272
+ }
273
+ hideSpinner()
274
+ setTitle(baseTitle)
275
+ }
276
+
277
+ return {
278
+ start,
279
+ stop,
280
+ set(nextPhase, nextDetail) {
281
+ phase = String(nextPhase ?? 'working')
282
+ detail = String(nextDetail ?? '')
283
+ },
284
+ onOutput() {
285
+ lastOutputAt = Date.now()
286
+ hideSpinner()
287
+ },
288
+ onPauseForInput() {
289
+ hideSpinner()
290
+ },
291
+ }
292
+ }
293
+
294
+ const resetIteration = () => {
295
+ iteration = { text: '', toolCalls: [], flushedToolCallAssistant: false }
296
+ toolNameById = new Map()
297
+ toolArgsSummaryById = new Map()
298
+ printedToolCallIds = new Set()
299
+ }
300
+
301
+ const flushAssistantToolCallsMessageIfNeeded = () => {
302
+ if (iteration.flushedToolCallAssistant)
303
+ return
304
+ if (!Array.isArray(iteration.toolCalls) || iteration.toolCalls.length === 0)
305
+ return
306
+ messagesToAppend.push({
307
+ role: 'assistant',
308
+ content: iteration.text || '',
309
+ tool_calls: iteration.toolCalls,
310
+ })
311
+ iteration.flushedToolCallAssistant = true
312
+ }
313
+
314
+ const pushToolMessage = (toolCallId, result) => {
315
+ const content = typeof result === 'string' ? result : JSON.stringify(result)
316
+ messagesToAppend.push({
317
+ role: 'tool',
318
+ tool_call_id: toolCallId,
319
+ content,
320
+ })
321
+ }
322
+
323
+ const previewToolArgs = (toolCall) => {
324
+ try {
325
+ const name = toolCall?.function?.name ?? 'unknown'
326
+ const raw = toolCall?.function?.arguments
327
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
328
+ return summarizeArgsForDisplay(name, parsed).summary
329
+ }
330
+ catch {
331
+ return ''
332
+ }
333
+ }
334
+
335
+ const previewToolResult = (result) => {
336
+ try {
337
+ if (result && typeof result === 'object') {
338
+ const sc = result.structuredContent && typeof result.structuredContent === 'object'
339
+ ? result.structuredContent
340
+ : null
341
+
342
+ // Glob: { files, totalMatches, truncated }
343
+ if (sc && 'totalMatches' in sc && 'files' in sc && Array.isArray(sc.files)) {
344
+ const totalMatches = Number(sc.totalMatches ?? sc.files.length)
345
+ const shown = sc.files.length
346
+ const truncated = sc.truncated === true
347
+ const parts = [
348
+ `Found ${Number.isFinite(totalMatches) ? totalMatches : shown} file(s)`,
349
+ truncated ? `(showing ${shown})` : shown !== totalMatches ? `(showing ${shown})` : '',
350
+ ].filter(Boolean)
351
+ return parts.join(' ')
352
+ }
353
+
354
+ // Read: { fileSize, totalLines, linesReturned, truncated, isBinary, mimeType }
355
+ if (sc && 'fileSize' in sc && ('totalLines' in sc || 'linesReturned' in sc)) {
356
+ const totalLines = Number(sc.totalLines ?? 0)
357
+ const linesReturned = Number(sc.linesReturned ?? 0)
358
+ const truncated = sc.truncated === true
359
+ const fileSize = Number(sc.fileSize ?? 0)
360
+ const parts = [
361
+ 'Read file',
362
+ Number.isFinite(linesReturned) && Number.isFinite(totalLines) && totalLines > 0
363
+ ? `(${linesReturned}/${totalLines} lines)`
364
+ : linesReturned > 0
365
+ ? `(${linesReturned} lines)`
366
+ : '',
367
+ Number.isFinite(fileSize) && fileSize > 0 ? `(${humanBytes(fileSize)})` : '',
368
+ truncated ? '(truncated)' : '',
369
+ ].filter(Boolean)
370
+ return parts.join(' ')
371
+ }
372
+
373
+ // Write: { success, filePath, bytesWritten, overwritten }
374
+ if (sc && 'bytesWritten' in sc && 'filePath' in sc) {
375
+ const filePath = String(sc.filePath ?? '')
376
+ const bytes = Number(sc.bytesWritten ?? 0)
377
+ const overwritten = sc.overwritten === true
378
+ const parts = [
379
+ overwritten ? 'Overwrote' : 'Wrote',
380
+ filePath || 'file',
381
+ bytes > 0 ? `(${humanBytes(bytes)})` : '',
382
+ ].filter(Boolean)
383
+ return parts.join(' ')
384
+ }
385
+
386
+ // Fallback: first text block
387
+ if (Array.isArray(result.content)) {
388
+ const first = result.content.find(b => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string')
389
+ if (first && typeof first.text === 'string')
390
+ return formatOneLine(first.text, 200)
391
+ }
392
+ }
393
+ return formatOneLine(typeof result === 'string' ? result : JSON.stringify(result), 200)
394
+ }
395
+ catch {
396
+ return ''
397
+ }
398
+ }
399
+
400
+ const maybeApprove = async (event) => {
401
+ if (approveAllToolsThisTurn)
402
+ return { approved: true }
403
+
404
+ if (typeof askApprovalForTool === 'function') {
405
+ const toolName = event.toolName
406
+ const info = summarizeArgsForDisplay(toolName, event.args)
407
+ const actionRaw = await askApprovalForTool({
408
+ toolName,
409
+ riskLevel: event.riskLevel,
410
+ args: event.args,
411
+ display: info,
412
+ })
413
+ if (typeof actionRaw === 'boolean')
414
+ return { approved: actionRaw }
415
+ const action = String(actionRaw ?? '').trim().toLowerCase()
416
+ if (action === 'approve_all' || action === 'all') {
417
+ approveAllToolsThisTurn = true
418
+ return { approved: true }
419
+ }
420
+ if (action === 'approve' || action === 'yes' || action === 'y')
421
+ return { approved: true }
422
+ if (action === 'abort')
423
+ throw new Error('Aborted by user')
424
+ return { approved: false }
425
+ }
426
+
427
+ if (!askApproval)
428
+ return { approved: false, reason: 'No approval handler available' }
429
+ const toolName = event.toolName
430
+ const risk = event.riskLevel ? ` (${event.riskLevel})` : ''
431
+ const info = summarizeArgsForDisplay(toolName, event.args)
432
+ const header = ansi.highlight(` approval required `)
433
+ const title = `${header}${useEmoji ? ` ${icon('🛑').trimEnd()}` : ''} ${ansi.bold(String(toolName ?? 'tool'))}${risk}\n`
434
+ const body = (info.lines.length > 0 ? info.lines : ['(no args)'])
435
+ .map(l => `- ${l}`)
436
+ .join('\n')
437
+ const q = `\n${title}${ansi.dim(body)}\n${ansi.bold(`${icon('✅').trimEnd()}Allow?`)} [y/N] `
438
+ const answer = await askApproval(q)
439
+ const ok = String(answer ?? '').trim().toLowerCase()
440
+ return { approved: ok === 'y' || ok === 'yes' }
441
+ }
442
+
443
+ const printToolBlock = (title, lines, padAfter = false) => {
444
+ if (!showTools)
445
+ return
446
+ ensureNewline()
447
+ out(`${ansi.dim(`┌─ ${useEmoji ? '🛠️ ' : ''}${title}`)}\n`)
448
+ for (const line of lines) {
449
+ if (line)
450
+ out(`${ansi.dim(`│ ${line}`)}\n`)
451
+ }
452
+ out(`${ansi.dim('└─')}\n`)
453
+ if (padAfter)
454
+ out('\n')
455
+ }
456
+
457
+ const shortPath = (p) => {
458
+ const s = String(p ?? '')
459
+ if (!s)
460
+ return ''
461
+ try {
462
+ const rel = s.startsWith('/')
463
+ ? path.relative(process.cwd(), s)
464
+ : s
465
+ return formatOneLine(rel, 80)
466
+ }
467
+ catch {
468
+ return formatOneLine(s, 80)
469
+ }
470
+ }
471
+
472
+ const printToolInlineCall = (toolName, argsSummary) => {
473
+ if (!showTools)
474
+ return
475
+ ensureNewline()
476
+ const name = ansi.bold(String(toolName ?? 'tool'))
477
+ const a = String(argsSummary ?? '').trim()
478
+ const tag = useEmoji ? `${icon('🛠️').trimEnd()} [tool]` : '[tool]'
479
+ const lhs = [ansi.dim(tag), name, a ? ansi.dim(a) : ''].filter(Boolean).join(' ')
480
+ out(`${ansi.dim('•')} ${lhs}\n`)
481
+ }
482
+
483
+ const printToolInlineResult = (resultSummary) => {
484
+ if (!showTools)
485
+ return
486
+ const r = String(resultSummary ?? '').trim() || '[no output]'
487
+ out(`${ansi.dim(' └')} ${ansi.dim(`${useEmoji ? '📄 ' : ''}${r}`)}\n`)
488
+ }
489
+
490
+ const toolResultIcon = (result) => {
491
+ if (!useEmoji)
492
+ return ''
493
+ if (result && typeof result === 'object') {
494
+ if (result.isError === true)
495
+ return '❌'
496
+ const sc = result.structuredContent && typeof result.structuredContent === 'object'
497
+ ? result.structuredContent
498
+ : null
499
+ if (sc && sc.truncated === true)
500
+ return '⚠️'
501
+ }
502
+ return '✅'
503
+ }
504
+
505
+ const looksUnfinished = (text) => {
506
+ const t = String(text ?? '').trim()
507
+ if (!t)
508
+ return false
509
+ if (/[。.!??!]$/.test(t))
510
+ return false
511
+ if (/(我将|我會|我会|接下来|下一步|然后|让我|开始创建|我将创建|我会创建)/.test(t))
512
+ return true
513
+ if ((/(\.html|\.ts|tank-battle)/i.test(t)) && (/(创建|写入|生成|create|write|implement)/i.test(t)))
514
+ return true
515
+ return false
516
+ }
517
+
518
+ const runSegment = async (segmentIter) => {
519
+ let doneReason = ''
520
+ let modelStopReason = ''
521
+ activity = createActivity()
522
+ activity?.start()
523
+ for await (const event of segmentIter) {
524
+ if (event.type === 'thinking_start') {
525
+ activity?.set('thinking', '')
526
+ }
527
+ else if (event.type === 'thinking_end') {
528
+ activity?.set('working', '')
529
+ }
530
+ else if (event.type === 'iteration_start') {
531
+ resetIteration()
532
+ activity?.set('thinking', '')
533
+ }
534
+ else if (event.type === 'text_delta') {
535
+ iteration.text += event.delta
536
+ out(event.delta)
537
+ activity?.set('responding', '')
538
+ }
539
+ else if (event.type === 'tool_call_start') {
540
+ activity?.set('tool_call', event.toolName ?? '')
541
+ }
542
+ else if (event.type === 'tool_call_end') {
543
+ iteration.toolCalls.push(event.toolCall)
544
+ const name = event.toolCall?.function?.name ?? 'unknown'
545
+ if (event.toolCall?.id)
546
+ toolNameById.set(event.toolCall.id, name)
547
+ let parsedArgs = {}
548
+ try {
549
+ const raw = event.toolCall?.function?.arguments
550
+ parsedArgs = typeof raw === 'string' ? JSON.parse(raw) : (raw ?? {})
551
+ }
552
+ catch {
553
+ parsedArgs = { _raw: event.toolCall?.function?.arguments }
554
+ }
555
+ const info = summarizeArgsForDisplay(name, parsedArgs)
556
+ if (event.toolCall?.id) {
557
+ // Prefer a readable path summary for common file tools.
558
+ if (name === 'Read' || name === 'Write' || name === 'Edit') {
559
+ const filePath = typeof parsedArgs.file_path === 'string' ? parsedArgs.file_path : ''
560
+ if (name === 'Write') {
561
+ const size = typeof parsedArgs.content === 'string' ? humanBytes(byteLen(parsedArgs.content)) : ''
562
+ toolArgsSummaryById.set(event.toolCall.id, `${shortPath(filePath)}${size ? ` (${size})` : ''}`.trim())
563
+ }
564
+ else if (name === 'Edit') {
565
+ const edits = Array.isArray(parsedArgs.edits) ? parsedArgs.edits.length : 0
566
+ toolArgsSummaryById.set(event.toolCall.id, `${shortPath(filePath)}${edits ? ` (${edits} edits)` : ''}`.trim())
567
+ }
568
+ else {
569
+ // Read
570
+ toolArgsSummaryById.set(event.toolCall.id, shortPath(filePath) || '')
571
+ }
572
+ }
573
+ else {
574
+ toolArgsSummaryById.set(event.toolCall.id, info.summary || '')
575
+ }
576
+ }
577
+ if (toolStyle === 'box') {
578
+ printToolBlock(`${ansi.bold(name)}${info.summary ? ` ${ansi.dim(info.summary)}` : ''}`, info.lines.length ? info.lines : [info.summary || '(no args)'])
579
+ }
580
+ else if (event.toolCall?.id && !printedToolCallIds.has(event.toolCall.id)) {
581
+ printedToolCallIds.add(event.toolCall.id)
582
+ const argsSummary = toolArgsSummaryById.get(event.toolCall.id) ?? ''
583
+ printToolInlineCall(name, argsSummary || info.summary || '')
584
+ meta.tools.push({ toolName: name, argsSummary: argsSummary || info.summary || '' })
585
+ }
586
+ activity?.set('tool_call', event.toolCall?.function?.name ?? '')
587
+ }
588
+ else if (event.type === 'tool_result') {
589
+ flushAssistantToolCallsMessageIfNeeded()
590
+ pushToolMessage(event.tool_call_id, event.result)
591
+ const prev = previewToolResult(event.result)
592
+ const toolName = toolNameById.get(event.tool_call_id)
593
+ if (toolName)
594
+ executedToolNames.add(toolName)
595
+ if (toolStyle !== 'box') {
596
+ // If we didn't print the call line (e.g. legacy tool events), do it now.
597
+ if (toolName && !printedToolCallIds.has(event.tool_call_id)) {
598
+ printedToolCallIds.add(event.tool_call_id)
599
+ const argsSummary = toolArgsSummaryById.get(event.tool_call_id) ?? ''
600
+ printToolInlineCall(toolName, argsSummary)
601
+ meta.tools.push({ toolName, argsSummary })
602
+ }
603
+ const argsSummary = toolArgsSummaryById.get(event.tool_call_id) ?? ''
604
+ void argsSummary
605
+ const status = toolResultIcon(event.result)
606
+ printToolInlineResult(`${status ? `${status} ` : ''}${prev || '[no output]'}`)
607
+ if (toolName) {
608
+ const last = meta.tools.slice().reverse().find(t => t.toolName === toolName && !t.resultSummary)
609
+ if (last)
610
+ last.resultSummary = prev || '[no output]'
611
+ else
612
+ meta.tools.push({ toolName, argsSummary, resultSummary: prev || '[no output]' })
613
+ }
614
+ }
615
+ else {
616
+ const status = toolResultIcon(event.result)
617
+ const title = toolName ? `tool result ${ansi.bold(toolName)}${status ? ` ${status}` : ''}` : `tool result${status ? ` ${status}` : ''}`
618
+ printToolBlock(title, [prev || '[no output]'])
619
+ }
620
+ activity?.set('thinking', '')
621
+ }
622
+ else if (event.type === 'requires_action' && event.kind === 'tool_approval') {
623
+ flushAssistantToolCallsMessageIfNeeded()
624
+ // Stop any spinner/title updates while waiting for user input.
625
+ // Otherwise the status line can overwrite the readline prompt and make input impossible.
626
+ activity?.stop()
627
+ activity = null
628
+ ensureNewline()
629
+
630
+ const decision = await maybeApprove(event)
631
+ const checkpoint = event.checkpoint
632
+ if (!checkpoint) {
633
+ throw new Error('Approval requested but no checkpoint provided; configure a checkpoint store or enable inline checkpoint')
634
+ }
635
+
636
+ return runSegment(
637
+ agent.streamFromCheckpoint({
638
+ checkpoint,
639
+ signal,
640
+ toolContext: {
641
+ approval: {
642
+ decisions: {
643
+ [event.tool_call_id]: decision,
644
+ },
645
+ },
646
+ },
647
+ }),
648
+ )
649
+ }
650
+ else if (event.type === 'iteration_end') {
651
+ if (event.willContinue === false) {
652
+ messagesToAppend.push({ role: 'assistant', content: iteration.text || '' })
653
+ }
654
+ }
655
+ else if (event.type === 'done') {
656
+ doneReason = event.stopReason ? String(event.stopReason) : ''
657
+ modelStopReason = event.modelStopReason ? String(event.modelStopReason) : ''
658
+ break
659
+ }
660
+ else if (event.type === 'error') {
661
+ const msg = event.error instanceof Error ? event.error.message : String(event.error)
662
+ ensureNewline()
663
+ out(`${ansi.red(`${icon('❌').trimEnd()}[error] ${msg}`)}\n`)
664
+ doneReason = 'error'
665
+ }
666
+ }
667
+
668
+ activity?.stop()
669
+ return { doneReason, modelStopReason }
670
+ }
671
+
672
+ let lastDoneReason = ''
673
+ let lastModelStopReason = ''
674
+
675
+ const first = await runSegment(agent.stream({
676
+ sessionId,
677
+ input,
678
+ messages: history,
679
+ signal,
680
+ toolContext: {
681
+ approval: { strategy: approvalStrategy, autoApprove },
682
+ },
683
+ }))
684
+ lastDoneReason = first.doneReason
685
+ lastModelStopReason = first.modelStopReason
686
+
687
+ // If the model ended in a "plan-y" way without writing files, optionally prompt to continue.
688
+ const finalAssistant = [...messagesToAppend].reverse().find(m => m && typeof m === 'object' && m.role === 'assistant')
689
+ const finalText = typeof finalAssistant?.content === 'string' ? finalAssistant.content : ''
690
+ const autoContinueUnfinished = autoContinue
691
+ if (
692
+ (autoContinueUnfinished || (promptContinue && typeof askApproval === 'function'))
693
+ && !executedToolNames.has('Write')
694
+ && !executedToolNames.has('Edit')
695
+ && looksUnfinished(finalText)
696
+ ) {
697
+ let shouldContinue = autoContinueUnfinished
698
+ if (!shouldContinue) {
699
+ const ans = await askApproval(`${ansi.dim('\nThe model seems to have stopped before implementing.')} Continue? [Y/n] `)
700
+ const ok = String(ans ?? '').trim().toLowerCase()
701
+ shouldContinue = ok === '' || ok === 'y' || ok === 'yes'
702
+ }
703
+ if (shouldContinue) {
704
+ const continuePrompt = '继续。不要只说计划:请立刻开始实现,使用 Write/Edit 创建或修改文件。'
705
+ // Persist the continuation user message in the caller history.
706
+ messagesToAppend.push({ role: 'user', content: continuePrompt })
707
+ // Build the full message history including the initial user input.
708
+ const base = [...history, { role: 'user', content: input }, ...messagesToAppend.slice(0, -1)]
709
+ const second = await runSegment(agent.stream({
710
+ sessionId,
711
+ input: continuePrompt,
712
+ messages: base,
713
+ signal,
714
+ toolContext: {
715
+ approval: { strategy: approvalStrategy, autoApprove },
716
+ },
717
+ }))
718
+ lastDoneReason = second.doneReason
719
+ lastModelStopReason = second.modelStopReason
720
+ }
721
+ }
722
+
723
+ meta.stopReason = lastDoneReason
724
+ meta.modelStopReason = lastModelStopReason
725
+
726
+ if (showStatus) {
727
+ ensureNewline()
728
+ const parts = []
729
+ if (lastDoneReason && lastDoneReason !== 'final_response')
730
+ parts.push(`stopReason=${lastDoneReason}`)
731
+ if (lastModelStopReason && lastModelStopReason !== 'final')
732
+ parts.push(`modelStopReason=${lastModelStopReason}`)
733
+ const label = `${icon('✅').trimEnd()}done${parts.length ? ` (${parts.join(' ')})` : ''}`
734
+ out(`${ansi.dim(`─ ${label} ─`)}\n`)
735
+
736
+ if (lastModelStopReason === 'length') {
737
+ out(`${ansi.dim(`${icon('💡').trimEnd()}hint: increase maxTokens via /set maxTokens 2048 if output is truncated`)}\n`)
738
+ }
739
+ else {
740
+ const finalAssistant2 = [...messagesToAppend].reverse().find(m => m && typeof m === 'object' && m.role === 'assistant')
741
+ const finalText2 = typeof finalAssistant2?.content === 'string' ? finalAssistant2.content : ''
742
+ if (!executedToolNames.has('Write') && !executedToolNames.has('Edit') && looksUnfinished(finalText2 || iteration.text || '')) {
743
+ out(`${ansi.dim(`${icon('💡').trimEnd()}hint: response ended before any Write/Edit; ask “继续并开始写文件” or increase maxTokens`)}\n`)
744
+ }
745
+ }
746
+ }
747
+
748
+ ensureNewline()
749
+ out('\n')
750
+ return { messagesToAppend, meta }
751
+ }