@meistrari/tela-cli 0.1.0

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.
@@ -0,0 +1,1601 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+ import { getRepositoryRoot } from '../git.ts'
4
+ import { createUserFacingError } from '../errors.ts'
5
+ import {
6
+ commandExists,
7
+ runInteractiveCommand,
8
+ runStreamingCommand,
9
+ spawnStreamingCommand,
10
+ type CommandResult,
11
+ type RunStreamingCommandOptions,
12
+ type StreamingCommandHandle,
13
+ } from '../process.ts'
14
+ import { bulletList, joinBlocks, kvList, resultCard, section, type RenderOptions } from '../ui.ts'
15
+
16
+ const INITIAL_PROMPT = 'Follow the instructions and return the result.'
17
+ const REQUIRED_AGENT_FILES = [
18
+ 'CLAUDE.md',
19
+ 'instructions.md',
20
+ 'output-format.json',
21
+ 'agent.config.json',
22
+ ] as const
23
+ const TOOL_PROGRESS_MILESTONES = [0, 5, 15, 30] as const
24
+
25
+ export type RunMode = 'chat' | 'one-shot'
26
+ export type RunOutputSurface = 'plain' | 'viewer' | 'json'
27
+ export type RunExecutionStatus = 'starting' | 'running' | 'success' | 'failed' | 'cancelled'
28
+
29
+ export interface AgentRunConfig {
30
+ model?: string
31
+ isMultiturn: boolean
32
+ maxRounds?: number
33
+ }
34
+
35
+ export interface PreparedLocalAgentRun {
36
+ repoRoot: string
37
+ config: AgentRunConfig
38
+ resultKind: 'json' | 'markdown'
39
+ resultPath: string
40
+ }
41
+
42
+ export interface RunStepSummary {
43
+ id: string
44
+ kind: 'assistant' | 'tool' | 'status' | 'retry' | 'hook' | 'task' | 'artifact' | 'result'
45
+ summary: string
46
+ detail?: string
47
+ notable?: boolean
48
+ }
49
+
50
+ export interface RunActiveTool {
51
+ id: string
52
+ name: string
53
+ summary: string
54
+ detail?: string
55
+ elapsedSeconds: number
56
+ }
57
+
58
+ export interface RunExecutionSummary {
59
+ status: RunExecutionStatus
60
+ durationMs?: number
61
+ durationApiMs?: number
62
+ numTurns?: number
63
+ stopReason?: string | null
64
+ totalCostUsd?: number
65
+ permissionDenials: string[]
66
+ errors: string[]
67
+ notableEvents: string[]
68
+ structuredOutputPresent: boolean
69
+ }
70
+
71
+ export interface RunArtifactSummary {
72
+ path: string
73
+ kind: 'json' | 'markdown'
74
+ value: unknown
75
+ }
76
+
77
+ export interface RunCommandResult {
78
+ ok: true
79
+ repoRoot: string
80
+ mode: RunMode
81
+ config: AgentRunConfig
82
+ execution: RunExecutionSummary
83
+ steps: RunStepSummary[]
84
+ artifact: RunArtifactSummary
85
+ resultPath: string
86
+ resultKind: 'json' | 'markdown'
87
+ resultValue: unknown
88
+ }
89
+
90
+ export interface RunExecutionSnapshot {
91
+ repoRoot: string
92
+ mode: RunMode
93
+ model?: string
94
+ artifactPath: string
95
+ startedAtMs: number
96
+ status: RunExecutionStatus
97
+ currentActivity: string
98
+ steps: RunStepSummary[]
99
+ activeTools: RunActiveTool[]
100
+ execution: RunExecutionSummary
101
+ }
102
+
103
+ export interface RunExecutionControl {
104
+ interrupt(): void
105
+ kill(): void
106
+ }
107
+
108
+ export interface RunLocalAgentCommandOptions {
109
+ cwd?: string
110
+ contract?: PreparedLocalAgentRun
111
+ surface?: RunOutputSurface
112
+ commandExists?: typeof commandExists
113
+ runInteractiveCommand?: typeof runInteractiveCommand
114
+ runStreamingCommand?: typeof runStreamingCommand
115
+ spawnStreamingCommand?: typeof spawnStreamingCommand
116
+ writeStdout?: (message: string) => void
117
+ writeStderr?: (message: string) => void
118
+ onSnapshot?: (snapshot: RunExecutionSnapshot) => void
119
+ onControl?: (control: RunExecutionControl) => void
120
+ stdinIsTTY?: boolean
121
+ stdoutIsTTY?: boolean
122
+ verbose?: boolean
123
+ }
124
+
125
+ interface AssistantToolUse {
126
+ id: string
127
+ name: string
128
+ input: unknown
129
+ summary: string
130
+ detail?: string
131
+ }
132
+
133
+ interface ClaudeResultEvent {
134
+ type?: string
135
+ subtype?: string
136
+ duration_ms?: unknown
137
+ duration_api_ms?: unknown
138
+ is_error?: boolean
139
+ num_turns?: unknown
140
+ stop_reason?: unknown
141
+ total_cost_usd?: unknown
142
+ permission_denials?: unknown
143
+ errors?: unknown
144
+ structured_output?: unknown
145
+ }
146
+
147
+ type RunEvent =
148
+ | {
149
+ kind: 'session_init'
150
+ summary: string
151
+ detail?: string
152
+ model?: string
153
+ recordStep: boolean
154
+ }
155
+ | {
156
+ kind: 'assistant_text'
157
+ text: string
158
+ summary: string
159
+ detail?: string
160
+ recordStep: true
161
+ }
162
+ | {
163
+ kind: 'tool_batch'
164
+ summary: string
165
+ detail?: string
166
+ tools: RunActiveTool[]
167
+ recordStep: true
168
+ }
169
+ | {
170
+ kind: 'tool_progress'
171
+ toolUseId: string
172
+ toolName: string
173
+ summary: string
174
+ detail?: string
175
+ elapsedSeconds: number
176
+ recordStep: false
177
+ }
178
+ | {
179
+ kind: 'status_notice'
180
+ summary: string
181
+ detail?: string
182
+ recordStep: boolean
183
+ notable?: boolean
184
+ }
185
+ | {
186
+ kind: 'retry_notice'
187
+ summary: string
188
+ detail?: string
189
+ recordStep: true
190
+ notable: true
191
+ }
192
+ | {
193
+ kind: 'hook_notice'
194
+ summary: string
195
+ detail?: string
196
+ recordStep: boolean
197
+ }
198
+ | {
199
+ kind: 'task_notice'
200
+ summary: string
201
+ detail?: string
202
+ recordStep: true
203
+ notable?: boolean
204
+ }
205
+ | {
206
+ kind: 'artifact_notice'
207
+ summary: string
208
+ detail?: string
209
+ recordStep: boolean
210
+ notable?: boolean
211
+ }
212
+ | {
213
+ kind: 'result'
214
+ summary: string
215
+ detail?: string
216
+ recordStep: true
217
+ execution: Partial<RunExecutionSummary> & Pick<RunExecutionSummary, 'status'>
218
+ }
219
+
220
+ function writeLine(
221
+ writer: ((message: string) => void) | undefined,
222
+ line: string,
223
+ ): void {
224
+ const message = `${line}\n`
225
+ if (writer) {
226
+ writer(message)
227
+ return
228
+ }
229
+
230
+ process.stdout.write(message)
231
+ }
232
+
233
+ function writeErrorLine(
234
+ writer: ((message: string) => void) | undefined,
235
+ line: string,
236
+ ): void {
237
+ const message = `${line}\n`
238
+ if (writer) {
239
+ writer(message)
240
+ return
241
+ }
242
+
243
+ process.stderr.write(message)
244
+ }
245
+
246
+ function splitVisibleLines(value: string): string[] {
247
+ return value
248
+ .split(/\r?\n/)
249
+ .map(line => line.trim())
250
+ .filter(Boolean)
251
+ }
252
+
253
+ function truncate(value: string, maxLength = 96): string {
254
+ const normalized = value.replace(/\s+/g, ' ').trim()
255
+ if (normalized.length <= maxLength) {
256
+ return normalized
257
+ }
258
+
259
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`
260
+ }
261
+
262
+ function safeString(value: unknown): string | undefined {
263
+ return typeof value === 'string' && value.trim().length > 0
264
+ ? value.trim()
265
+ : undefined
266
+ }
267
+
268
+ function safeNumber(value: unknown): number | undefined {
269
+ return typeof value === 'number' && Number.isFinite(value)
270
+ ? value
271
+ : undefined
272
+ }
273
+
274
+ function isRecord(value: unknown): value is Record<string, unknown> {
275
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
276
+ }
277
+
278
+ function formatDurationMs(durationMs: number | undefined): string {
279
+ if (!durationMs || durationMs <= 0) {
280
+ return '<unknown>'
281
+ }
282
+
283
+ if (durationMs < 1000) {
284
+ return `${durationMs}ms`
285
+ }
286
+
287
+ const seconds = durationMs / 1000
288
+ if (seconds < 60) {
289
+ return `${seconds.toFixed(seconds >= 10 ? 1 : 2)}s`
290
+ }
291
+
292
+ const minutes = Math.floor(seconds / 60)
293
+ const remainingSeconds = Math.round(seconds % 60)
294
+ return `${minutes}m ${remainingSeconds}s`
295
+ }
296
+
297
+ function formatElapsedSeconds(seconds: number): string {
298
+ if (seconds < 60) {
299
+ return `${seconds}s`
300
+ }
301
+
302
+ const minutes = Math.floor(seconds / 60)
303
+ const remainder = seconds % 60
304
+ return `${minutes}m ${remainder}s`
305
+ }
306
+
307
+ function summarizePermissionDenial(value: unknown): string {
308
+ if (!isRecord(value)) {
309
+ return 'Permission denied for a tool call.'
310
+ }
311
+
312
+ const toolName = safeString(value.tool_name) ?? 'tool'
313
+ if (!isRecord(value.tool_input)) {
314
+ return `Permission denied: ${toolName}`
315
+ }
316
+
317
+ const inputSummary = summarizeToolInput(toolName, value.tool_input)
318
+ return inputSummary ? `Permission denied: ${toolName} ${inputSummary}` : `Permission denied: ${toolName}`
319
+ }
320
+
321
+ function parsePermissionDenials(value: unknown): string[] {
322
+ if (!Array.isArray(value)) {
323
+ return []
324
+ }
325
+
326
+ return value.map(summarizePermissionDenial)
327
+ }
328
+
329
+ function summarizeLines(value: string): { summary: string, detail?: string } {
330
+ const lines = splitVisibleLines(value)
331
+ if (lines.length === 0) {
332
+ return { summary: '<empty>' }
333
+ }
334
+
335
+ const summary = truncate(lines[0] ?? '')
336
+ if (lines.length === 1 && summary === lines[0]) {
337
+ return { summary }
338
+ }
339
+
340
+ return {
341
+ summary,
342
+ detail: lines.join('\n'),
343
+ }
344
+ }
345
+
346
+ function summarizeStructuredOutputPresence(value: unknown): boolean {
347
+ return value !== undefined
348
+ }
349
+
350
+ function summarizeToolInput(toolName: string, input: unknown): string | undefined {
351
+ if (!isRecord(input)) {
352
+ return undefined
353
+ }
354
+
355
+ const pathValue = [
356
+ input.file_path,
357
+ input.path,
358
+ input.filename,
359
+ input.file,
360
+ input.target_file,
361
+ input.to_path,
362
+ input.from_path,
363
+ ].find(candidate => typeof candidate === 'string' && candidate.trim().length > 0)
364
+ if (typeof pathValue === 'string') {
365
+ return truncate(basename(pathValue))
366
+ }
367
+
368
+ const commandValue = [
369
+ input.command,
370
+ input.cmd,
371
+ input.script,
372
+ input.shell_command,
373
+ ].find(candidate => typeof candidate === 'string' && candidate.trim().length > 0)
374
+ if (typeof commandValue === 'string') {
375
+ return truncate(commandValue.replace(/\s+/g, ' '))
376
+ }
377
+
378
+ const queryValue = [
379
+ input.pattern,
380
+ input.query,
381
+ input.q,
382
+ input.search_query,
383
+ ].find(candidate => typeof candidate === 'string' && candidate.trim().length > 0)
384
+ if (typeof queryValue === 'string') {
385
+ return truncate(queryValue)
386
+ }
387
+
388
+ const urlValue = [
389
+ input.url,
390
+ input.href,
391
+ ].find(candidate => typeof candidate === 'string' && candidate.trim().length > 0)
392
+ if (typeof urlValue === 'string') {
393
+ return truncate(urlValue)
394
+ }
395
+
396
+ const locationValue = safeString(input.location)
397
+ if (locationValue) {
398
+ return truncate(locationValue)
399
+ }
400
+
401
+ const titleValue = safeString(input.title)
402
+ if (titleValue) {
403
+ return truncate(titleValue)
404
+ }
405
+
406
+ const pageIndex = safeNumber(input.page_index)
407
+ if (pageIndex != null) {
408
+ return `${toolName} page ${pageIndex}`
409
+ }
410
+
411
+ return undefined
412
+ }
413
+
414
+ function summarizeToolUse(name: string, input: unknown): { summary: string, detail?: string } {
415
+ const detail = summarizeToolInput(name, input)
416
+ return detail
417
+ ? { summary: truncate(`${name} ${detail}`), detail: `${name}: ${detail}` }
418
+ : { summary: name }
419
+ }
420
+
421
+ function extractAssistantText(message: unknown): string {
422
+ if (!message || typeof message !== 'object') {
423
+ return ''
424
+ }
425
+
426
+ const content = (message as { content?: unknown }).content
427
+ if (typeof content === 'string') {
428
+ return content
429
+ }
430
+
431
+ if (!Array.isArray(content)) {
432
+ return ''
433
+ }
434
+
435
+ return content
436
+ .flatMap((block) => {
437
+ if (!isRecord(block) || block.type !== 'text') {
438
+ return []
439
+ }
440
+
441
+ return typeof block.text === 'string' ? [block.text] : []
442
+ })
443
+ .join('\n')
444
+ }
445
+
446
+ function extractAssistantToolUses(message: unknown): AssistantToolUse[] {
447
+ if (!message || typeof message !== 'object') {
448
+ return []
449
+ }
450
+
451
+ const content = (message as { content?: unknown }).content
452
+ if (!Array.isArray(content)) {
453
+ return []
454
+ }
455
+
456
+ return content.flatMap((block, index) => {
457
+ if (!isRecord(block) || block.type !== 'tool_use') {
458
+ return []
459
+ }
460
+
461
+ const name = safeString(block.name)
462
+ if (!name) {
463
+ return []
464
+ }
465
+
466
+ const { summary, detail } = summarizeToolUse(name, block.input)
467
+ return [{
468
+ id: safeString(block.id) ?? `${name}-${index}`,
469
+ name,
470
+ input: block.input,
471
+ summary,
472
+ detail,
473
+ }]
474
+ })
475
+ }
476
+
477
+ function capitalize(value: string): string {
478
+ return value.length > 0
479
+ ? `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}`
480
+ : value
481
+ }
482
+
483
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
484
+ return `${count} ${count === 1 ? singular : plural}`
485
+ }
486
+
487
+ function categorizeToolName(name: string): 'searches' | 'reads' | 'writes' | 'commands' | 'other' {
488
+ const normalized = name.toLowerCase()
489
+
490
+ if (/(grep|glob|search|lsp|find)/.test(normalized)) {
491
+ return 'searches'
492
+ }
493
+
494
+ if (/(read|fetch|get|open|view)/.test(normalized)) {
495
+ return 'reads'
496
+ }
497
+
498
+ if (/(write|edit|patch|replace|update|insert|delete|move|format|apply)/.test(normalized)) {
499
+ return 'writes'
500
+ }
501
+
502
+ if (/(bash|shell|exec|command|tmux|terminal)/.test(normalized)) {
503
+ return 'commands'
504
+ }
505
+
506
+ return 'other'
507
+ }
508
+
509
+ function buildDerivedToolSummary(toolUses: AssistantToolUse[]): string {
510
+ if (toolUses.length === 1) {
511
+ return toolUses[0]?.summary ?? 'Tool call'
512
+ }
513
+
514
+ const counts = {
515
+ searches: 0,
516
+ reads: 0,
517
+ writes: 0,
518
+ commands: 0,
519
+ other: 0,
520
+ }
521
+
522
+ for (const toolUse of toolUses) {
523
+ counts[categorizeToolName(toolUse.name)] += 1
524
+ }
525
+
526
+ const parts: string[] = []
527
+ if (counts.searches > 0) {
528
+ parts.push(`searched ${pluralize(counts.searches, 'pattern')}`)
529
+ }
530
+ if (counts.reads > 0) {
531
+ parts.push(`read ${pluralize(counts.reads, 'file')}`)
532
+ }
533
+ if (counts.writes > 0) {
534
+ parts.push(`wrote ${pluralize(counts.writes, 'file')}`)
535
+ }
536
+ if (counts.commands > 0) {
537
+ parts.push(`ran ${pluralize(counts.commands, 'command')}`)
538
+ }
539
+ if (counts.other > 0) {
540
+ parts.push(`${pluralize(counts.other, 'other tool')}`)
541
+ }
542
+
543
+ return capitalize(parts.join(', '))
544
+ }
545
+
546
+ function buildToolBatchEvent(
547
+ summary: string,
548
+ toolUses: AssistantToolUse[],
549
+ ): RunEvent {
550
+ const detailLines = toolUses
551
+ .map(toolUse => toolUse.detail ?? toolUse.summary)
552
+ .filter(Boolean)
553
+ return {
554
+ kind: 'tool_batch',
555
+ summary,
556
+ detail: detailLines.length > 0 ? detailLines.join('\n') : undefined,
557
+ tools: toolUses.map(toolUse => ({
558
+ id: toolUse.id,
559
+ name: toolUse.name,
560
+ summary: toolUse.summary,
561
+ detail: toolUse.detail,
562
+ elapsedSeconds: 0,
563
+ })),
564
+ recordStep: true,
565
+ }
566
+ }
567
+
568
+ function normalizeClaudePrintEvent(event: Record<string, unknown>): RunEvent[] {
569
+ switch (event.type) {
570
+ case 'system': {
571
+ switch (event.subtype) {
572
+ case 'init':
573
+ return [{
574
+ kind: 'session_init',
575
+ summary: 'Claude session started.',
576
+ detail: safeString(event.cwd),
577
+ model: safeString(event.model),
578
+ recordStep: true,
579
+ }]
580
+ case 'status': {
581
+ const status = safeString(event.status)
582
+ if (!status) {
583
+ return []
584
+ }
585
+
586
+ return [{
587
+ kind: 'status_notice',
588
+ summary: status === 'compacting' ? 'Compacting conversation…' : `Status: ${status}`,
589
+ recordStep: status === 'compacting',
590
+ notable: status === 'compacting',
591
+ }]
592
+ }
593
+ case 'api_retry': {
594
+ const attempt = safeNumber(event.attempt) ?? '?'
595
+ const maxRetries = safeNumber(event.max_retries) ?? '?'
596
+ const error = safeString(event.error) ?? 'Retrying Claude API request.'
597
+ return [{
598
+ kind: 'retry_notice',
599
+ summary: `Retry ${attempt}/${maxRetries}`,
600
+ detail: error,
601
+ recordStep: true,
602
+ notable: true,
603
+ }]
604
+ }
605
+ case 'hook_started': {
606
+ const hookName = safeString(event.hook_name) ?? 'hook'
607
+ const hookEvent = safeString(event.hook_event)
608
+ return [{
609
+ kind: 'hook_notice',
610
+ summary: hookEvent ? `Hook started: ${hookName} (${hookEvent})` : `Hook started: ${hookName}`,
611
+ recordStep: false,
612
+ }]
613
+ }
614
+ case 'hook_progress': {
615
+ const hookName = safeString(event.hook_name) ?? 'hook'
616
+ const detail = [safeString(event.output), safeString(event.stdout), safeString(event.stderr)]
617
+ .find(Boolean)
618
+ return [{
619
+ kind: 'hook_notice',
620
+ summary: `Hook output: ${hookName}`,
621
+ detail,
622
+ recordStep: true,
623
+ }]
624
+ }
625
+ case 'task_notification': {
626
+ const status = safeString(event.status) ?? 'completed'
627
+ const summary = safeString(event.summary)
628
+ ?? safeString(event.output_file)
629
+ ?? safeString(event.task_id)
630
+ ?? 'Task finished'
631
+ return [{
632
+ kind: 'task_notice',
633
+ summary: `Task ${status}: ${summary}`,
634
+ detail: safeString(event.output_file),
635
+ recordStep: true,
636
+ notable: status !== 'completed',
637
+ }]
638
+ }
639
+ case 'files_persisted': {
640
+ const failed = Array.isArray(event.failed)
641
+ ? event.failed.filter(isRecord)
642
+ : []
643
+ if (failed.length === 0) {
644
+ const persistedCount = Array.isArray(event.files) ? event.files.length : 0
645
+ return [{
646
+ kind: 'artifact_notice',
647
+ summary: `Persisted ${pluralize(persistedCount, 'file')}.`,
648
+ recordStep: false,
649
+ }]
650
+ }
651
+
652
+ return [{
653
+ kind: 'artifact_notice',
654
+ summary: 'Some files could not be persisted.',
655
+ detail: failed
656
+ .map(entry => `${safeString(entry.filename) ?? '<unknown>'}: ${safeString(entry.error) ?? 'Unknown error'}`)
657
+ .join('\n'),
658
+ recordStep: true,
659
+ notable: true,
660
+ }]
661
+ }
662
+ default:
663
+ return []
664
+ }
665
+ }
666
+ case 'assistant': {
667
+ const normalized: RunEvent[] = []
668
+ const text = extractAssistantText(event.message)
669
+ if (text.trim()) {
670
+ const summary = summarizeLines(text)
671
+ normalized.push({
672
+ kind: 'assistant_text',
673
+ text,
674
+ summary: summary.summary,
675
+ detail: summary.detail,
676
+ recordStep: true,
677
+ })
678
+ }
679
+
680
+ const toolUses = extractAssistantToolUses(event.message)
681
+ if (toolUses.length > 0) {
682
+ normalized.push(buildToolBatchEvent(buildDerivedToolSummary(toolUses), toolUses))
683
+ }
684
+
685
+ return normalized
686
+ }
687
+ case 'streamlined_text': {
688
+ const text = safeString(event.text)
689
+ if (!text) {
690
+ return []
691
+ }
692
+
693
+ const summary = summarizeLines(text)
694
+ return [{
695
+ kind: 'assistant_text',
696
+ text,
697
+ summary: summary.summary,
698
+ detail: summary.detail,
699
+ recordStep: true,
700
+ }]
701
+ }
702
+ case 'tool_use_summary': {
703
+ const summary = safeString(event.summary)
704
+ return summary
705
+ ? [buildToolBatchEvent(summary, [])]
706
+ : []
707
+ }
708
+ case 'streamlined_tool_use_summary': {
709
+ const summary = safeString(event.tool_summary)
710
+ return summary
711
+ ? [buildToolBatchEvent(summary, [])]
712
+ : []
713
+ }
714
+ case 'tool_progress': {
715
+ const toolUseId = safeString(event.tool_use_id)
716
+ const toolName = safeString(event.tool_name)
717
+ const elapsedSeconds = safeNumber(event.elapsed_time_seconds)
718
+ if (!toolUseId || !toolName || elapsedSeconds == null) {
719
+ return []
720
+ }
721
+
722
+ return [{
723
+ kind: 'tool_progress',
724
+ toolUseId,
725
+ toolName,
726
+ summary: `${toolName} running for ${formatElapsedSeconds(elapsedSeconds)}`,
727
+ detail: safeString(event.task_id),
728
+ elapsedSeconds,
729
+ recordStep: false,
730
+ }]
731
+ }
732
+ case 'result': {
733
+ const subtype = safeString(event.subtype) ?? 'success'
734
+ const status: RunExecutionStatus = subtype === 'success'
735
+ ? 'success'
736
+ : 'failed'
737
+ const durationMs = safeNumber(event.duration_ms)
738
+ const detail = Array.isArray(event.errors)
739
+ ? event.errors.filter((value): value is string => typeof value === 'string' && value.trim().length > 0).join('\n')
740
+ : undefined
741
+
742
+ return [{
743
+ kind: 'result',
744
+ summary: status === 'success'
745
+ ? `Run completed in ${formatDurationMs(durationMs)}.`
746
+ : `Run failed after ${formatDurationMs(durationMs)}.`,
747
+ detail,
748
+ recordStep: true,
749
+ execution: {
750
+ status,
751
+ durationMs,
752
+ durationApiMs: safeNumber(event.duration_api_ms),
753
+ numTurns: safeNumber(event.num_turns),
754
+ stopReason: safeString(event.stop_reason) ?? null,
755
+ totalCostUsd: safeNumber(event.total_cost_usd),
756
+ permissionDenials: parsePermissionDenials(event.permission_denials),
757
+ errors: Array.isArray(event.errors)
758
+ ? event.errors.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
759
+ : [],
760
+ structuredOutputPresent: summarizeStructuredOutputPresence(event.structured_output),
761
+ notableEvents: [],
762
+ },
763
+ }]
764
+ }
765
+ default:
766
+ return []
767
+ }
768
+ }
769
+
770
+ class RunStateTracker {
771
+ private readonly steps: RunStepSummary[] = []
772
+ private readonly activeTools = new Map<string, RunActiveTool>()
773
+ private readonly execution: RunExecutionSummary
774
+ private stepIndex = 0
775
+ private status: RunExecutionStatus = 'starting'
776
+ private currentActivity = 'Preparing Claude run…'
777
+ private model?: string
778
+
779
+ constructor(
780
+ private readonly prepared: PreparedLocalAgentRun,
781
+ private readonly mode: RunMode,
782
+ private readonly startedAtMs = Date.now(),
783
+ ) {
784
+ this.execution = {
785
+ status: 'starting',
786
+ permissionDenials: [],
787
+ errors: [],
788
+ notableEvents: [],
789
+ structuredOutputPresent: false,
790
+ }
791
+ this.model = prepared.config.model
792
+ }
793
+
794
+ private addStep(
795
+ kind: RunStepSummary['kind'],
796
+ summary: string,
797
+ detail?: string,
798
+ notable = false,
799
+ ): void {
800
+ this.steps.push({
801
+ id: `step-${++this.stepIndex}`,
802
+ kind,
803
+ summary,
804
+ detail,
805
+ notable,
806
+ })
807
+ }
808
+
809
+ private note(message: string | undefined): void {
810
+ if (!message) {
811
+ return
812
+ }
813
+
814
+ if (!this.execution.notableEvents.includes(message)) {
815
+ this.execution.notableEvents.push(message)
816
+ }
817
+ }
818
+
819
+ private clearActiveTools(): void {
820
+ this.activeTools.clear()
821
+ }
822
+
823
+ update(event: RunEvent): RunExecutionSnapshot {
824
+ switch (event.kind) {
825
+ case 'session_init':
826
+ this.status = 'running'
827
+ this.execution.status = 'running'
828
+ this.currentActivity = event.summary
829
+ this.model = event.model ?? this.model
830
+ if (event.recordStep) {
831
+ this.addStep('status', event.summary, event.detail)
832
+ }
833
+ break
834
+ case 'assistant_text':
835
+ this.clearActiveTools()
836
+ this.status = 'running'
837
+ this.execution.status = 'running'
838
+ this.currentActivity = event.summary
839
+ this.addStep('assistant', event.summary, event.detail)
840
+ break
841
+ case 'tool_batch':
842
+ this.clearActiveTools()
843
+ this.status = 'running'
844
+ this.execution.status = 'running'
845
+ this.currentActivity = event.summary
846
+ this.addStep('tool', event.summary, event.detail)
847
+ for (const tool of event.tools) {
848
+ this.activeTools.set(tool.id, tool)
849
+ }
850
+ break
851
+ case 'tool_progress': {
852
+ const previous = this.activeTools.get(event.toolUseId)
853
+ this.activeTools.set(event.toolUseId, {
854
+ id: event.toolUseId,
855
+ name: event.toolName,
856
+ summary: previous?.summary ?? event.toolName,
857
+ detail: previous?.detail ?? event.detail,
858
+ elapsedSeconds: event.elapsedSeconds,
859
+ })
860
+ this.status = 'running'
861
+ this.execution.status = 'running'
862
+ this.currentActivity = event.summary
863
+ break
864
+ }
865
+ case 'status_notice':
866
+ this.status = 'running'
867
+ this.execution.status = 'running'
868
+ this.currentActivity = event.summary
869
+ if (event.recordStep) {
870
+ this.addStep('status', event.summary, event.detail, Boolean(event.notable))
871
+ }
872
+ if (event.notable) {
873
+ this.note(event.summary)
874
+ }
875
+ break
876
+ case 'retry_notice':
877
+ this.status = 'running'
878
+ this.execution.status = 'running'
879
+ this.currentActivity = event.summary
880
+ this.addStep('retry', event.summary, event.detail, true)
881
+ this.note(event.summary)
882
+ break
883
+ case 'hook_notice':
884
+ this.status = 'running'
885
+ this.execution.status = 'running'
886
+ this.currentActivity = event.summary
887
+ if (event.recordStep) {
888
+ this.addStep('hook', event.summary, event.detail)
889
+ }
890
+ break
891
+ case 'task_notice':
892
+ this.status = 'running'
893
+ this.execution.status = 'running'
894
+ this.currentActivity = event.summary
895
+ this.addStep('task', event.summary, event.detail, Boolean(event.notable))
896
+ if (event.notable) {
897
+ this.note(event.summary)
898
+ }
899
+ break
900
+ case 'artifact_notice':
901
+ this.status = 'running'
902
+ this.execution.status = 'running'
903
+ this.currentActivity = event.summary
904
+ if (event.recordStep) {
905
+ this.addStep('artifact', event.summary, event.detail, Boolean(event.notable))
906
+ }
907
+ if (event.notable) {
908
+ this.note(event.summary)
909
+ }
910
+ break
911
+ case 'result':
912
+ this.clearActiveTools()
913
+ this.status = event.execution.status
914
+ this.currentActivity = event.summary
915
+ this.execution.status = event.execution.status
916
+ this.execution.durationMs = event.execution.durationMs
917
+ this.execution.durationApiMs = event.execution.durationApiMs
918
+ this.execution.numTurns = event.execution.numTurns
919
+ this.execution.stopReason = event.execution.stopReason
920
+ this.execution.totalCostUsd = event.execution.totalCostUsd
921
+ this.execution.permissionDenials = [...(event.execution.permissionDenials ?? [])]
922
+ this.execution.errors = [...(event.execution.errors ?? [])]
923
+ this.execution.structuredOutputPresent = Boolean(event.execution.structuredOutputPresent)
924
+ for (const message of event.execution.notableEvents ?? []) {
925
+ this.note(message)
926
+ }
927
+ this.addStep('result', event.summary, event.detail, event.execution.status !== 'success')
928
+ break
929
+ }
930
+
931
+ return this.snapshot()
932
+ }
933
+
934
+ finalize(status: Extract<RunExecutionStatus, 'success' | 'failed' | 'cancelled'>, detail?: {
935
+ errors?: string[]
936
+ durationMs?: number
937
+ durationApiMs?: number
938
+ numTurns?: number
939
+ stopReason?: string | null
940
+ totalCostUsd?: number
941
+ permissionDenials?: string[]
942
+ structuredOutputPresent?: boolean
943
+ summary?: string
944
+ }): RunExecutionSnapshot {
945
+ this.clearActiveTools()
946
+ this.status = status
947
+ this.execution.status = status
948
+ this.execution.errors = detail?.errors ?? this.execution.errors
949
+ this.execution.durationMs = detail?.durationMs ?? this.execution.durationMs
950
+ this.execution.durationApiMs = detail?.durationApiMs ?? this.execution.durationApiMs
951
+ this.execution.numTurns = detail?.numTurns ?? this.execution.numTurns
952
+ this.execution.stopReason = detail?.stopReason ?? this.execution.stopReason
953
+ this.execution.totalCostUsd = detail?.totalCostUsd ?? this.execution.totalCostUsd
954
+ this.execution.permissionDenials = detail?.permissionDenials ?? this.execution.permissionDenials
955
+ this.execution.structuredOutputPresent = detail?.structuredOutputPresent ?? this.execution.structuredOutputPresent
956
+
957
+ const summary = detail?.summary ?? (
958
+ status === 'success'
959
+ ? `Run completed in ${formatDurationMs(this.execution.durationMs)}.`
960
+ : status === 'cancelled'
961
+ ? 'Run cancelled.'
962
+ : `Run failed after ${formatDurationMs(this.execution.durationMs)}.`
963
+ )
964
+ this.currentActivity = summary
965
+
966
+ const lastStep = this.steps.at(-1)
967
+ if (!lastStep || lastStep.kind !== 'result' || lastStep.summary !== summary) {
968
+ this.addStep('result', summary, detail?.errors?.join('\n'), status !== 'success')
969
+ }
970
+
971
+ return this.snapshot()
972
+ }
973
+
974
+ snapshot(): RunExecutionSnapshot {
975
+ return {
976
+ repoRoot: this.prepared.repoRoot,
977
+ mode: this.mode,
978
+ model: this.model,
979
+ artifactPath: this.prepared.resultPath,
980
+ startedAtMs: this.startedAtMs,
981
+ status: this.status,
982
+ currentActivity: this.currentActivity,
983
+ steps: this.steps.map(step => ({ ...step })),
984
+ activeTools: Array.from(this.activeTools.values()).map(tool => ({ ...tool })),
985
+ execution: {
986
+ status: this.execution.status,
987
+ durationMs: this.execution.durationMs,
988
+ durationApiMs: this.execution.durationApiMs,
989
+ numTurns: this.execution.numTurns,
990
+ stopReason: this.execution.stopReason,
991
+ totalCostUsd: this.execution.totalCostUsd,
992
+ permissionDenials: [...this.execution.permissionDenials],
993
+ errors: [...this.execution.errors],
994
+ notableEvents: [...this.execution.notableEvents],
995
+ structuredOutputPresent: this.execution.structuredOutputPresent,
996
+ },
997
+ }
998
+ }
999
+ }
1000
+
1001
+ class PlainTranscriptRenderer {
1002
+ private readonly nextMilestoneIndex = new Map<string, number>()
1003
+
1004
+ render(
1005
+ event: RunEvent,
1006
+ snapshot: RunExecutionSnapshot,
1007
+ verbose = false,
1008
+ ): string[] {
1009
+ switch (event.kind) {
1010
+ case 'session_init':
1011
+ return [snapshot.model ? `Claude session started (${snapshot.model}).` : event.summary]
1012
+ case 'assistant_text':
1013
+ return splitVisibleLines(event.text).map(line => `Assistant: ${line}`)
1014
+ case 'tool_batch': {
1015
+ const lines = [`Tools: ${event.summary}`]
1016
+ if (verbose && event.detail) {
1017
+ lines.push(...splitVisibleLines(event.detail).map(line => ` ${line}`))
1018
+ }
1019
+ return lines
1020
+ }
1021
+ case 'tool_progress': {
1022
+ const nextIndex = this.nextMilestoneIndex.get(event.toolUseId) ?? 0
1023
+ const milestone = TOOL_PROGRESS_MILESTONES[nextIndex]
1024
+ const shouldEmit = milestone != null
1025
+ ? event.elapsedSeconds >= milestone
1026
+ : event.elapsedSeconds % 60 === 0
1027
+ if (!shouldEmit) {
1028
+ return []
1029
+ }
1030
+
1031
+ if (milestone != null) {
1032
+ this.nextMilestoneIndex.set(event.toolUseId, nextIndex + 1)
1033
+ }
1034
+
1035
+ return [`Working: ${event.toolName} (${formatElapsedSeconds(event.elapsedSeconds)})`]
1036
+ }
1037
+ case 'status_notice':
1038
+ return event.recordStep ? [event.summary] : []
1039
+ case 'retry_notice': {
1040
+ const lines = [event.summary]
1041
+ if (event.detail) {
1042
+ lines.push(` ${event.detail}`)
1043
+ }
1044
+ return lines
1045
+ }
1046
+ case 'hook_notice':
1047
+ if (!event.recordStep) {
1048
+ return []
1049
+ }
1050
+ return [event.summary, ...(verbose && event.detail ? splitVisibleLines(event.detail).map(line => ` ${line}`) : [])]
1051
+ case 'task_notice':
1052
+ return [event.summary, ...(verbose && event.detail ? [` ${event.detail}`] : [])]
1053
+ case 'artifact_notice':
1054
+ if (!event.recordStep && !verbose) {
1055
+ return []
1056
+ }
1057
+ return [event.summary, ...(verbose && event.detail ? splitVisibleLines(event.detail).map(line => ` ${line}`) : [])]
1058
+ case 'result':
1059
+ return [event.summary]
1060
+ default:
1061
+ return []
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ function parseJsonObjectFile(
1067
+ path: string,
1068
+ content: string,
1069
+ invalidSummary: string,
1070
+ ): Record<string, unknown> {
1071
+ let parsed: unknown
1072
+ try {
1073
+ parsed = JSON.parse(content)
1074
+ }
1075
+ catch (error) {
1076
+ throw createUserFacingError({
1077
+ code: 'run_invalid_json',
1078
+ summary: invalidSummary,
1079
+ details: error instanceof Error ? error.message : String(error),
1080
+ fixes: [`Fix the JSON syntax in \`${path}\`.`],
1081
+ })
1082
+ }
1083
+
1084
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1085
+ throw createUserFacingError({
1086
+ code: 'run_invalid_json_shape',
1087
+ summary: invalidSummary,
1088
+ fixes: [`Update \`${path}\` so it contains a JSON object.`],
1089
+ })
1090
+ }
1091
+
1092
+ return parsed as Record<string, unknown>
1093
+ }
1094
+
1095
+ function parseAgentConfig(path: string, content: string): AgentRunConfig {
1096
+ const parsed = parseJsonObjectFile(path, content, 'agent.config.json must be a valid JSON object.')
1097
+
1098
+ if (parsed.model !== undefined && typeof parsed.model !== 'string') {
1099
+ throw createUserFacingError({
1100
+ code: 'run_invalid_model',
1101
+ summary: 'agent.config.json has an invalid `model` value.',
1102
+ fixes: ['Set `model` to a string, or remove it from agent.config.json.'],
1103
+ })
1104
+ }
1105
+
1106
+ if (parsed.isMultiturn !== undefined && typeof parsed.isMultiturn !== 'boolean') {
1107
+ throw createUserFacingError({
1108
+ code: 'run_invalid_multiturn',
1109
+ summary: 'agent.config.json has an invalid `isMultiturn` value.',
1110
+ fixes: ['Set `isMultiturn` to `true` or `false`, or remove it from agent.config.json.'],
1111
+ })
1112
+ }
1113
+
1114
+ if (parsed.maxRounds !== undefined && (!Number.isInteger(parsed.maxRounds) || Number(parsed.maxRounds) <= 0)) {
1115
+ throw createUserFacingError({
1116
+ code: 'run_invalid_max_rounds',
1117
+ summary: 'agent.config.json has an invalid `maxRounds` value.',
1118
+ fixes: ['Set `maxRounds` to a positive integer, or remove it from agent.config.json.'],
1119
+ })
1120
+ }
1121
+
1122
+ const model = typeof parsed.model === 'string' && parsed.model.trim().length > 0
1123
+ ? parsed.model.trim()
1124
+ : undefined
1125
+
1126
+ return {
1127
+ model,
1128
+ isMultiturn: parsed.isMultiturn === true,
1129
+ maxRounds: typeof parsed.maxRounds === 'number' ? parsed.maxRounds : undefined,
1130
+ }
1131
+ }
1132
+
1133
+ async function resolveRepositoryRoot(cwd: string): Promise<string> {
1134
+ try {
1135
+ return await getRepositoryRoot(cwd)
1136
+ }
1137
+ catch (error) {
1138
+ if (error instanceof Error && error.message.startsWith('Not a git repository:')) {
1139
+ throw createUserFacingError({
1140
+ code: 'run_outside_repo',
1141
+ summary: 'Run must be executed from inside a git repository.',
1142
+ fixes: [
1143
+ 'Change into an agent repository, or run `tela sync <repo>` first.',
1144
+ ],
1145
+ })
1146
+ }
1147
+
1148
+ throw error
1149
+ }
1150
+ }
1151
+
1152
+ async function validateRequiredFiles(repoRoot: string): Promise<void> {
1153
+ const missing: string[] = []
1154
+
1155
+ for (const file of REQUIRED_AGENT_FILES) {
1156
+ try {
1157
+ await readFile(join(repoRoot, file), 'utf-8')
1158
+ }
1159
+ catch {
1160
+ missing.push(file)
1161
+ }
1162
+ }
1163
+
1164
+ if (missing.length === 0) {
1165
+ return
1166
+ }
1167
+
1168
+ throw createUserFacingError({
1169
+ code: 'run_missing_contract_files',
1170
+ summary: 'Agent repository is missing required files.',
1171
+ fixes: missing.map(file => `Add \`${file}\` to the repository root.`),
1172
+ evidence: missing.map(file => `Missing: ${join(repoRoot, file)}`),
1173
+ })
1174
+ }
1175
+
1176
+ export async function prepareLocalAgentRun(options: {
1177
+ cwd?: string
1178
+ commandExists?: typeof commandExists
1179
+ } = {}): Promise<PreparedLocalAgentRun> {
1180
+ const cwd = options.cwd ?? process.cwd()
1181
+ const commandExistsFn = options.commandExists ?? commandExists
1182
+ const repoRoot = await resolveRepositoryRoot(cwd)
1183
+
1184
+ if (!await commandExistsFn('claude')) {
1185
+ throw createUserFacingError({
1186
+ code: 'run_claude_missing',
1187
+ summary: 'Claude CLI is not available on PATH.',
1188
+ fixes: ['Install the Claude CLI before using `tela run`.'],
1189
+ })
1190
+ }
1191
+
1192
+ await validateRequiredFiles(repoRoot)
1193
+
1194
+ const [outputFormatContent, agentConfigContent] = await Promise.all([
1195
+ readFile(join(repoRoot, 'output-format.json'), 'utf-8'),
1196
+ readFile(join(repoRoot, 'agent.config.json'), 'utf-8'),
1197
+ ])
1198
+
1199
+ const outputFormat = parseJsonObjectFile(
1200
+ 'output-format.json',
1201
+ outputFormatContent,
1202
+ 'output-format.json must be a valid JSON object.',
1203
+ )
1204
+ const config = parseAgentConfig('agent.config.json', agentConfigContent)
1205
+ const resultKind = Object.keys(outputFormat).length > 0 ? 'json' : 'markdown'
1206
+
1207
+ return {
1208
+ repoRoot,
1209
+ config,
1210
+ resultKind,
1211
+ resultPath: join(repoRoot, 'output', resultKind === 'json' ? 'result.json' : 'result.md'),
1212
+ }
1213
+ }
1214
+
1215
+ export function createInitialRunSnapshot(
1216
+ prepared: PreparedLocalAgentRun,
1217
+ mode: RunMode = prepared.config.isMultiturn ? 'chat' : 'one-shot',
1218
+ ): RunExecutionSnapshot {
1219
+ return {
1220
+ repoRoot: prepared.repoRoot,
1221
+ mode,
1222
+ model: prepared.config.model,
1223
+ artifactPath: prepared.resultPath,
1224
+ startedAtMs: Date.now(),
1225
+ status: 'starting',
1226
+ currentActivity: mode === 'chat'
1227
+ ? 'Preparing Claude chat handoff…'
1228
+ : 'Validating repository contract…',
1229
+ steps: [],
1230
+ activeTools: [],
1231
+ execution: {
1232
+ status: 'starting',
1233
+ permissionDenials: [],
1234
+ errors: [],
1235
+ notableEvents: [],
1236
+ structuredOutputPresent: false,
1237
+ },
1238
+ }
1239
+ }
1240
+
1241
+ function snapshotStepEvidence(snapshot: RunExecutionSnapshot): string[] {
1242
+ return snapshot.steps
1243
+ .slice(-5)
1244
+ .map(step => step.summary)
1245
+ }
1246
+
1247
+ function buildRunFailureError(
1248
+ result: CommandResult,
1249
+ snapshot: RunExecutionSnapshot,
1250
+ ): ReturnType<typeof createUserFacingError> {
1251
+ const details = snapshot.execution.errors[0]
1252
+ ?? result.stderr.trim()
1253
+ ?? result.stdout.trim()
1254
+ ?? `Claude exited with code ${result.exitCode}.`
1255
+
1256
+ return createUserFacingError({
1257
+ code: 'run_failed',
1258
+ summary: 'Claude run failed.',
1259
+ details,
1260
+ fixes: [
1261
+ 'Review the streamed Claude output above for the failing step.',
1262
+ 'Fix the repository instructions or inputs, then rerun `tela run`.',
1263
+ ],
1264
+ evidence: [
1265
+ `Command: ${result.command.join(' ')}`,
1266
+ ...snapshotStepEvidence(snapshot),
1267
+ ...snapshot.execution.errors,
1268
+ result.stderr.trim() ? `stderr: ${result.stderr.trim()}` : '',
1269
+ ].filter(Boolean),
1270
+ })
1271
+ }
1272
+
1273
+ function buildRunCancelledError(snapshot: RunExecutionSnapshot): ReturnType<typeof createUserFacingError> {
1274
+ return createUserFacingError({
1275
+ code: 'run_cancelled',
1276
+ summary: 'Claude run was cancelled.',
1277
+ details: snapshot.currentActivity,
1278
+ fixes: ['Run `tela run` again when you are ready to continue.'],
1279
+ evidence: snapshotStepEvidence(snapshot),
1280
+ exitCode: 130,
1281
+ })
1282
+ }
1283
+
1284
+ async function readResultArtifact(
1285
+ prepared: PreparedLocalAgentRun,
1286
+ snapshot: RunExecutionSnapshot,
1287
+ ): Promise<RunCommandResult> {
1288
+ let content: string
1289
+ try {
1290
+ content = await readFile(prepared.resultPath, 'utf-8')
1291
+ }
1292
+ catch {
1293
+ throw createUserFacingError({
1294
+ code: 'run_missing_result',
1295
+ summary: `Expected result artifact was not created: ${prepared.resultPath}`,
1296
+ details: joinBlocks([
1297
+ `Last activity: ${snapshot.currentActivity}`,
1298
+ snapshot.execution.numTurns != null ? `Turns: ${snapshot.execution.numTurns}` : undefined,
1299
+ ]),
1300
+ fixes: [
1301
+ 'Ensure the agent writes the required output file before exiting.',
1302
+ 'Rerun `tela run` after fixing the repository instructions.',
1303
+ ],
1304
+ evidence: snapshotStepEvidence(snapshot),
1305
+ })
1306
+ }
1307
+
1308
+ let resultValue: unknown = content
1309
+ if (prepared.resultKind === 'json') {
1310
+ try {
1311
+ resultValue = JSON.parse(content)
1312
+ }
1313
+ catch (error) {
1314
+ throw createUserFacingError({
1315
+ code: 'run_invalid_result_json',
1316
+ summary: 'output/result.json is not valid JSON.',
1317
+ details: error instanceof Error ? error.message : String(error),
1318
+ fixes: ['Ensure the agent writes valid JSON to output/result.json.'],
1319
+ evidence: snapshotStepEvidence(snapshot),
1320
+ })
1321
+ }
1322
+ }
1323
+
1324
+ const executionStatus = snapshot.execution.status === 'cancelled'
1325
+ ? 'cancelled'
1326
+ : snapshot.execution.status === 'failed'
1327
+ ? 'failed'
1328
+ : 'success'
1329
+
1330
+ return {
1331
+ ok: true,
1332
+ repoRoot: prepared.repoRoot,
1333
+ mode: prepared.config.isMultiturn ? 'chat' : 'one-shot',
1334
+ config: prepared.config,
1335
+ execution: {
1336
+ ...snapshot.execution,
1337
+ status: executionStatus,
1338
+ },
1339
+ steps: snapshot.steps,
1340
+ artifact: {
1341
+ path: prepared.resultPath,
1342
+ kind: prepared.resultKind,
1343
+ value: resultValue,
1344
+ },
1345
+ resultPath: prepared.resultPath,
1346
+ resultKind: prepared.resultKind,
1347
+ resultValue,
1348
+ }
1349
+ }
1350
+
1351
+ async function runInteractiveAgent(
1352
+ prepared: PreparedLocalAgentRun,
1353
+ runInteractiveCommandFn: typeof runInteractiveCommand,
1354
+ options: RunLocalAgentCommandOptions,
1355
+ ): Promise<void> {
1356
+ if (options.surface === 'json') {
1357
+ throw createUserFacingError({
1358
+ code: 'run_multiturn_json_unsupported',
1359
+ summary: 'Multiturn run requires an interactive TTY in the current hybrid design.',
1360
+ fixes: ['Run `tela run` from an interactive terminal without `--json`.'],
1361
+ })
1362
+ }
1363
+
1364
+ const stdinIsTTY = options.stdinIsTTY ?? Boolean(process.stdin.isTTY)
1365
+ const stdoutIsTTY = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY)
1366
+ if (!stdinIsTTY || !stdoutIsTTY) {
1367
+ throw createUserFacingError({
1368
+ code: 'run_multiturn_tty_required',
1369
+ summary: 'Multiturn run requires an interactive TTY in the current hybrid design.',
1370
+ fixes: ['Run `tela run` from an interactive terminal.'],
1371
+ })
1372
+ }
1373
+
1374
+ if (options.surface === 'plain') {
1375
+ writeLine(options.writeStdout, 'Launching Claude in interactive chat mode. Tela will resume after Claude exits.')
1376
+ }
1377
+
1378
+ const command = [
1379
+ 'claude',
1380
+ ...(prepared.config.model ? ['--model', prepared.config.model] : []),
1381
+ INITIAL_PROMPT,
1382
+ ]
1383
+
1384
+ await runInteractiveCommandFn(command, { cwd: prepared.repoRoot })
1385
+ }
1386
+
1387
+ async function runOneShotAgent(
1388
+ prepared: PreparedLocalAgentRun,
1389
+ options: RunLocalAgentCommandOptions,
1390
+ tracker: RunStateTracker,
1391
+ ): Promise<RunExecutionSnapshot> {
1392
+ const plainRenderer = new PlainTranscriptRenderer()
1393
+ const runStreamingCommandFn = options.runStreamingCommand ?? runStreamingCommand
1394
+ const spawnStreamingCommandFn = options.spawnStreamingCommand ?? ((command: string[], spawnOptions?: RunStreamingCommandOptions): StreamingCommandHandle => ({
1395
+ result: runStreamingCommandFn(command, spawnOptions),
1396
+ interrupt() {},
1397
+ kill() {},
1398
+ }))
1399
+
1400
+ const notifySnapshot = (snapshot: RunExecutionSnapshot) => {
1401
+ options.onSnapshot?.(snapshot)
1402
+ }
1403
+
1404
+ notifySnapshot(tracker.snapshot())
1405
+
1406
+ if (options.surface === 'plain') {
1407
+ writeLine(options.writeStdout, 'Streaming Claude execution in one-shot mode.')
1408
+ }
1409
+
1410
+ let cancelled = false
1411
+ const command = [
1412
+ 'claude',
1413
+ '-p',
1414
+ '--output-format',
1415
+ 'stream-json',
1416
+ '--verbose',
1417
+ ...(prepared.config.model ? ['--model', prepared.config.model] : []),
1418
+ INITIAL_PROMPT,
1419
+ ]
1420
+
1421
+ const handle = spawnStreamingCommandFn(command, {
1422
+ cwd: prepared.repoRoot,
1423
+ allowFailure: true,
1424
+ onStdoutLine: async (line) => {
1425
+ const trimmed = line.trim()
1426
+ if (!trimmed) {
1427
+ return
1428
+ }
1429
+
1430
+ try {
1431
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>
1432
+ const events = normalizeClaudePrintEvent(parsed)
1433
+ for (const event of events) {
1434
+ const snapshot = tracker.update(event)
1435
+ notifySnapshot(snapshot)
1436
+ if (options.surface === 'plain') {
1437
+ for (const renderedLine of plainRenderer.render(event, snapshot, options.verbose)) {
1438
+ writeLine(options.writeStdout, renderedLine)
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+ catch {
1444
+ if (options.surface === 'plain') {
1445
+ writeLine(options.writeStdout, `Claude: ${trimmed}`)
1446
+ }
1447
+ }
1448
+ },
1449
+ onStderrLine: async (line) => {
1450
+ const trimmed = line.trim()
1451
+ if (!trimmed) {
1452
+ return
1453
+ }
1454
+
1455
+ if (options.surface === 'plain') {
1456
+ writeErrorLine(options.writeStderr, trimmed)
1457
+ }
1458
+ },
1459
+ })
1460
+
1461
+ options.onControl?.({
1462
+ interrupt() {
1463
+ cancelled = true
1464
+ handle.interrupt()
1465
+ },
1466
+ kill() {
1467
+ cancelled = true
1468
+ handle.kill()
1469
+ },
1470
+ })
1471
+
1472
+ const result = await handle.result
1473
+ let snapshot = tracker.snapshot()
1474
+
1475
+ if (result.exitCode !== 0) {
1476
+ if (cancelled) {
1477
+ snapshot = tracker.finalize('cancelled', {
1478
+ summary: 'Run cancelled.',
1479
+ })
1480
+ notifySnapshot(snapshot)
1481
+ throw buildRunCancelledError(snapshot)
1482
+ }
1483
+
1484
+ snapshot = tracker.finalize('failed')
1485
+ notifySnapshot(snapshot)
1486
+ throw buildRunFailureError(result, snapshot)
1487
+ }
1488
+
1489
+ if (snapshot.execution.status === 'running' || snapshot.execution.status === 'starting') {
1490
+ snapshot = tracker.finalize('success')
1491
+ notifySnapshot(snapshot)
1492
+ }
1493
+
1494
+ if (snapshot.execution.status === 'failed') {
1495
+ throw buildRunFailureError(result, snapshot)
1496
+ }
1497
+
1498
+ return snapshot
1499
+ }
1500
+
1501
+ export async function runLocalAgentCommand(
1502
+ options: RunLocalAgentCommandOptions = {},
1503
+ ): Promise<RunCommandResult> {
1504
+ const surface = options.surface ?? 'plain'
1505
+ const effectiveOptions: RunLocalAgentCommandOptions = {
1506
+ ...options,
1507
+ surface,
1508
+ }
1509
+ const prepared = options.contract ?? await prepareLocalAgentRun({
1510
+ cwd: options.cwd ?? process.cwd(),
1511
+ commandExists: options.commandExists,
1512
+ })
1513
+ const mode: RunMode = prepared.config.isMultiturn ? 'chat' : 'one-shot'
1514
+ const tracker = new RunStateTracker(prepared, mode)
1515
+
1516
+ let snapshot = tracker.snapshot()
1517
+ effectiveOptions.onSnapshot?.(snapshot)
1518
+
1519
+ if (prepared.config.isMultiturn) {
1520
+ await runInteractiveAgent(prepared, options.runInteractiveCommand ?? runInteractiveCommand, effectiveOptions)
1521
+ snapshot = tracker.finalize('success', {
1522
+ summary: 'Claude chat exited. Reading result artifact…',
1523
+ })
1524
+ effectiveOptions.onSnapshot?.(snapshot)
1525
+ }
1526
+ else {
1527
+ snapshot = await runOneShotAgent(prepared, effectiveOptions, tracker)
1528
+ }
1529
+
1530
+ return await readResultArtifact(prepared, snapshot)
1531
+ }
1532
+
1533
+ function renderExecutionSteps(result: RunCommandResult, options: RenderOptions): string | undefined {
1534
+ const recentSteps = result.steps.slice(-8).map(step => step.summary)
1535
+ if (recentSteps.length === 0) {
1536
+ return undefined
1537
+ }
1538
+
1539
+ return section('Recent steps', bulletList(recentSteps), options)
1540
+ }
1541
+
1542
+ export function renderRunExecutionRecap(
1543
+ result: RunCommandResult,
1544
+ options: RenderOptions = {},
1545
+ ): string {
1546
+ return resultCard('Execution recap', [
1547
+ section('Execution', kvList([
1548
+ { label: 'Repo root', value: result.repoRoot },
1549
+ { label: 'Mode', value: result.mode },
1550
+ { label: 'Model', value: result.config.model ?? '<default>' },
1551
+ { label: 'Elapsed', value: formatDurationMs(result.execution.durationMs) },
1552
+ { label: 'Turns', value: result.execution.numTurns != null ? String(result.execution.numTurns) : '<unknown>' },
1553
+ { label: 'Status', value: result.execution.status },
1554
+ { label: 'Stop reason', value: result.execution.stopReason ?? '<none>' },
1555
+ { label: 'Cost', value: result.execution.totalCostUsd != null ? `$${result.execution.totalCostUsd.toFixed(4)}` : '<unknown>' },
1556
+ {
1557
+ label: 'maxRounds',
1558
+ value: result.config.maxRounds != null
1559
+ ? `${result.config.maxRounds} (ignored in local CLI)`
1560
+ : '<unspecified>',
1561
+ },
1562
+ ], options), options),
1563
+ result.execution.notableEvents.length > 0
1564
+ ? section('Notable events', bulletList(result.execution.notableEvents), options)
1565
+ : undefined,
1566
+ result.execution.permissionDenials.length > 0
1567
+ ? section('Permission denials', bulletList(result.execution.permissionDenials), options)
1568
+ : undefined,
1569
+ result.execution.errors.length > 0
1570
+ ? section('Errors', bulletList(result.execution.errors), options)
1571
+ : undefined,
1572
+ renderExecutionSteps(result, options),
1573
+ ], options)
1574
+ }
1575
+
1576
+ export function renderRunArtifactCard(
1577
+ result: RunCommandResult,
1578
+ options: RenderOptions = {},
1579
+ ): string {
1580
+ const resultContent = result.resultKind === 'json'
1581
+ ? JSON.stringify(result.resultValue, null, 2)
1582
+ : String(result.resultValue)
1583
+
1584
+ return resultCard('Run result', [
1585
+ section('Artifact', kvList([
1586
+ { label: 'Path', value: result.resultPath },
1587
+ { label: 'Kind', value: result.resultKind },
1588
+ ], options), options),
1589
+ section('Result', resultContent, options),
1590
+ ], options)
1591
+ }
1592
+
1593
+ export function renderRunResult(
1594
+ result: RunCommandResult,
1595
+ options: RenderOptions = {},
1596
+ ): string {
1597
+ return joinBlocks([
1598
+ renderRunExecutionRecap(result, options),
1599
+ renderRunArtifactCard(result, options),
1600
+ ])
1601
+ }