pi-agenticoding 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.
- package/LICENSE +21 -0
- package/README.md +240 -0
- package/agenticoding.test.ts +2079 -0
- package/handoff/command.ts +36 -0
- package/handoff/compact.ts +35 -0
- package/handoff/tool.ts +151 -0
- package/index.ts +149 -0
- package/ledger/rehydration.ts +94 -0
- package/ledger/store.ts +82 -0
- package/ledger/tools.ts +166 -0
- package/package.json +21 -0
- package/spawn/index.ts +487 -0
- package/spawn/renderer.ts +809 -0
- package/spawn/shared.ts +34 -0
- package/state.ts +108 -0
- package/system-prompt.ts +59 -0
- package/test-loader.mjs +32 -0
- package/watchdog.ts +65 -0
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering components for spawned child agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* Provides the live-updating NestedAgentSessionComponent that renders a
|
|
5
|
+
* child agent's ongoing work in the parent's TUI, plus the renderCall
|
|
6
|
+
* and renderResult functions used by the spawn tool definitions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
AssistantMessageComponent,
|
|
12
|
+
BashExecutionComponent,
|
|
13
|
+
CustomMessageComponent,
|
|
14
|
+
getMarkdownTheme,
|
|
15
|
+
keyHint,
|
|
16
|
+
parseSkillBlock,
|
|
17
|
+
SkillInvocationMessageComponent,
|
|
18
|
+
ToolExecutionComponent,
|
|
19
|
+
UserMessageComponent,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
24
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
25
|
+
import type { AgenticodingState } from "../state.js";
|
|
26
|
+
import {
|
|
27
|
+
getLastAssistantText,
|
|
28
|
+
type SpawnOutcome,
|
|
29
|
+
type SpawnResultDetails,
|
|
30
|
+
} from "./shared.js";
|
|
31
|
+
|
|
32
|
+
// ── Render-only constants ────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const COLLAPSED_PREVIEW_MAX_LINES = 5;
|
|
35
|
+
const INDENT_SPACES_PER_DEPTH = 4;
|
|
36
|
+
const PROMPT_PREVIEW_COLLAPSED_LINES = 3;
|
|
37
|
+
const TOOL_RESULT_PREVIEW_CHARS = 60;
|
|
38
|
+
const LIVE_TEXT_PREVIEW_CHARS = 80;
|
|
39
|
+
const COST_THRESHOLD_COMPACT = 1000;
|
|
40
|
+
const COST_THRESHOLD_DECIMAL = 10;
|
|
41
|
+
|
|
42
|
+
// ── Render-only types ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
type ToolResultLike = {
|
|
45
|
+
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
|
46
|
+
details?: unknown;
|
|
47
|
+
isError?: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Message shapes from a spawned child session.
|
|
52
|
+
* Covers both standard LLM messages and extension-injected custom types
|
|
53
|
+
* (bashExecution, custom) without depending on SDK module augmentation types.
|
|
54
|
+
*/
|
|
55
|
+
type SpawnChildMessage = {
|
|
56
|
+
role: string;
|
|
57
|
+
content?: Array<{ type: string; text?: string; id?: string; name?: string; arguments?: Record<string, unknown> }>;
|
|
58
|
+
stopReason?: unknown;
|
|
59
|
+
errorMessage?: string;
|
|
60
|
+
toolCallId?: string;
|
|
61
|
+
command?: string;
|
|
62
|
+
output?: string;
|
|
63
|
+
exitCode?: number;
|
|
64
|
+
cancelled?: boolean;
|
|
65
|
+
truncated?: boolean;
|
|
66
|
+
fullOutputPath?: string;
|
|
67
|
+
excludeFromContext?: boolean;
|
|
68
|
+
customType?: string;
|
|
69
|
+
display?: boolean;
|
|
70
|
+
details?: unknown;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ── Render-only helpers ──────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Runtime guard: validate that a value is structurally compatible with ToolResultLike. */
|
|
76
|
+
function asToolResult(value: unknown): ToolResultLike {
|
|
77
|
+
if (typeof value === "object" && value !== null && Array.isArray((value as any).content)) {
|
|
78
|
+
return value as ToolResultLike;
|
|
79
|
+
}
|
|
80
|
+
return { content: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getStopReasonOutcome(stopReason: unknown): SpawnOutcome | undefined {
|
|
84
|
+
if (stopReason === "aborted") return "aborted";
|
|
85
|
+
if (stopReason === "error") return "error";
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getOutcomeMarker(outcome: SpawnOutcome): string {
|
|
90
|
+
switch (outcome) {
|
|
91
|
+
case "success":
|
|
92
|
+
return "✅ ";
|
|
93
|
+
case "aborted":
|
|
94
|
+
return "✗ ";
|
|
95
|
+
case "error":
|
|
96
|
+
return "⚠ ";
|
|
97
|
+
default:
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getOutcomeStatusText(outcome: SpawnOutcome): string | undefined {
|
|
103
|
+
switch (outcome) {
|
|
104
|
+
case "success":
|
|
105
|
+
return "💬 done";
|
|
106
|
+
case "aborted":
|
|
107
|
+
return "💬 aborted";
|
|
108
|
+
case "error":
|
|
109
|
+
return "💬 error";
|
|
110
|
+
default:
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isExpectedToolComponentFailure(error: unknown): boolean {
|
|
116
|
+
return error instanceof Error && (
|
|
117
|
+
/missing tool definition/i.test(error.message)
|
|
118
|
+
|| /theme not initialized/i.test(error.message)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderPromptPreview(prompt: string, expanded: boolean): { shown: string; remaining: number } {
|
|
123
|
+
const lines = prompt.split("\n");
|
|
124
|
+
const maxLines = expanded ? lines.length : PROMPT_PREVIEW_COLLAPSED_LINES;
|
|
125
|
+
return {
|
|
126
|
+
shown: lines.slice(0, maxLines).join("\n"),
|
|
127
|
+
remaining: Math.max(0, lines.length - maxLines),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Safe wrapper around keyHint().
|
|
133
|
+
* keyHint() may throw when the TUI keybinding registry isn't initialized
|
|
134
|
+
* (e.g., during tests or headless mode). Returns the fallback in that case.
|
|
135
|
+
*/
|
|
136
|
+
function safeKeyHint(action: string, fallback: string): string {
|
|
137
|
+
try {
|
|
138
|
+
return keyHint(action, fallback);
|
|
139
|
+
} catch {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── NestedAgentSessionComponent ───────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Renders a live child agent session in the parent's TUI.
|
|
148
|
+
*
|
|
149
|
+
* Three responsibilities:
|
|
150
|
+
* 1. Collapsed view — identity line with completion marker (✅ when done),
|
|
151
|
+
* live "last action" summary (tool name + result preview, or assistant
|
|
152
|
+
* text preview), 5-line preview of last assistant output when available,
|
|
153
|
+
* token/cost summary.
|
|
154
|
+
* 2. Expanded view — full chat history with 4-space indent per depth level.
|
|
155
|
+
* 3. Session lifecycle — subscribes to child session events, streams tool
|
|
156
|
+
* executions and assistant messages in real time, maintains live action
|
|
157
|
+
* tracking via lastAction field updated on every event.
|
|
158
|
+
*
|
|
159
|
+
* Render caching: caches output by width/expanded/showImages to avoid
|
|
160
|
+
* unnecessary re-renders when none of those inputs changed.
|
|
161
|
+
*/
|
|
162
|
+
class NestedAgentSessionComponent extends Container {
|
|
163
|
+
private session?: AgentSession;
|
|
164
|
+
private pendingTools = new Map<string, ToolExecutionComponent>();
|
|
165
|
+
private toolComponents = new Set<ToolExecutionComponent>();
|
|
166
|
+
private streamingComponent?: AssistantMessageComponent;
|
|
167
|
+
private unsubscribe?: () => void;
|
|
168
|
+
private expanded = false;
|
|
169
|
+
private showImages = true;
|
|
170
|
+
private requestRender: () => void = () => {};
|
|
171
|
+
private readonly markdownTheme = getMarkdownTheme();
|
|
172
|
+
// Minimal TUI mock for ToolExecutionComponent/BashExecutionComponent.
|
|
173
|
+
// Spawn runs in-memory without a real TUI — only requestRender is needed
|
|
174
|
+
// to trigger parent re-renders when child events arrive.
|
|
175
|
+
private readonly fakeUi = {
|
|
176
|
+
requestRender: () => this.requestRender(),
|
|
177
|
+
} as { requestRender: () => void };
|
|
178
|
+
private details?: SpawnResultDetails;
|
|
179
|
+
private nestTheme?: Theme;
|
|
180
|
+
private ownedToolCallId?: string;
|
|
181
|
+
private liveChildSessions?: Map<string, AgentSession>;
|
|
182
|
+
private liveOutcome: SpawnOutcome = "running";
|
|
183
|
+
// States: "⏳ initializing…" → "💭 thinking…" → "[tool] …/preview" or live text → terminal outcome
|
|
184
|
+
private lastAction = "";
|
|
185
|
+
private toolNames = new Map<string, string>();
|
|
186
|
+
private toolComponentFailures = new Set<string>();
|
|
187
|
+
private cachedWidth?: number;
|
|
188
|
+
private cachedExpanded?: boolean;
|
|
189
|
+
private cachedLines?: string[];
|
|
190
|
+
private cachedShowImages?: boolean;
|
|
191
|
+
|
|
192
|
+
private clearRenderCache(): void {
|
|
193
|
+
this.cachedWidth = undefined;
|
|
194
|
+
this.cachedExpanded = undefined;
|
|
195
|
+
this.cachedLines = undefined;
|
|
196
|
+
this.cachedShowImages = undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
setRequestRender(requestRender: () => void): void {
|
|
200
|
+
this.requestRender = requestRender;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setExpanded(expanded: boolean): void {
|
|
204
|
+
if (this.expanded === expanded) return;
|
|
205
|
+
this.expanded = expanded;
|
|
206
|
+
this.clearRenderCache();
|
|
207
|
+
for (const component of this.toolComponents) {
|
|
208
|
+
component.setExpanded(expanded);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setShowImages(showImages: boolean): void {
|
|
213
|
+
if (this.showImages === showImages) return;
|
|
214
|
+
this.showImages = showImages;
|
|
215
|
+
this.clearRenderCache();
|
|
216
|
+
for (const component of this.toolComponents) {
|
|
217
|
+
component.setShowImages(showImages);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setDetails(details: SpawnResultDetails, theme: Theme): void {
|
|
222
|
+
const changed = this.details !== details || this.nestTheme !== theme;
|
|
223
|
+
this.details = details;
|
|
224
|
+
this.nestTheme = theme;
|
|
225
|
+
this.liveOutcome = details.outcome;
|
|
226
|
+
if (changed) this.clearRenderCache();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
attachSession(toolCallId: string, session: AgentSession, liveChildSessions?: Map<string, AgentSession>): void {
|
|
230
|
+
if (
|
|
231
|
+
this.session === session
|
|
232
|
+
&& this.ownedToolCallId === toolCallId
|
|
233
|
+
&& this.liveChildSessions === liveChildSessions
|
|
234
|
+
) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.unsubscribe?.();
|
|
239
|
+
this.unsubscribe = undefined;
|
|
240
|
+
this.session = session;
|
|
241
|
+
this.ownedToolCallId = toolCallId;
|
|
242
|
+
this.liveChildSessions = liveChildSessions;
|
|
243
|
+
this.liveOutcome = this.details?.outcome ?? "running";
|
|
244
|
+
this.toolNames.clear();
|
|
245
|
+
this.toolComponentFailures.clear();
|
|
246
|
+
this.clearRenderCache();
|
|
247
|
+
this.rebuildFromSession();
|
|
248
|
+
try {
|
|
249
|
+
this.unsubscribe = typeof session.subscribe === "function"
|
|
250
|
+
? session.subscribe((event) => {
|
|
251
|
+
this.handleEvent(event);
|
|
252
|
+
})
|
|
253
|
+
: undefined;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this.unsubscribe = undefined;
|
|
256
|
+
console.warn("[spawn] Failed to subscribe to child session events:", this.ownedToolCallId, error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override invalidate(): void {
|
|
261
|
+
super.invalidate();
|
|
262
|
+
this.clearRenderCache();
|
|
263
|
+
if (this.session) {
|
|
264
|
+
this.rebuildFromSession();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
hasSession(): boolean {
|
|
269
|
+
return !!this.session;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Returns true when the session held by this component has been replaced
|
|
274
|
+
* in the liveChildSessions map (e.g., after a resetState). When stale, the
|
|
275
|
+
* component silently drops all events to avoid operating on a different
|
|
276
|
+
* session than what it was attached to.
|
|
277
|
+
*/
|
|
278
|
+
private isStaleSession(): boolean {
|
|
279
|
+
return !!(
|
|
280
|
+
this.session
|
|
281
|
+
&& this.ownedToolCallId
|
|
282
|
+
&& this.liveChildSessions
|
|
283
|
+
&& this.liveChildSessions.get(this.ownedToolCallId) !== this.session
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
dispose(): void {
|
|
288
|
+
this.unsubscribe?.();
|
|
289
|
+
this.unsubscribe = undefined;
|
|
290
|
+
// Snapshot fields before clearing: if session.abort() triggers re-entrant
|
|
291
|
+
// dispose, the nulled-out fields prevent double-abort.
|
|
292
|
+
const session = this.session;
|
|
293
|
+
const ownedToolCallId = this.ownedToolCallId;
|
|
294
|
+
const liveChildSessions = this.liveChildSessions;
|
|
295
|
+
this.clearRenderCache();
|
|
296
|
+
this.details = undefined;
|
|
297
|
+
this.nestTheme = undefined;
|
|
298
|
+
this.liveOutcome = "running";
|
|
299
|
+
this.toolNames.clear();
|
|
300
|
+
this.toolComponentFailures.clear();
|
|
301
|
+
this.session = undefined;
|
|
302
|
+
this.ownedToolCallId = undefined;
|
|
303
|
+
this.liveChildSessions = undefined;
|
|
304
|
+
if (session && ownedToolCallId && liveChildSessions?.get(ownedToolCallId) === session) {
|
|
305
|
+
session.abort().catch(e => console.error("[spawn] abort failed:", ownedToolCallId, e));
|
|
306
|
+
liveChildSessions.delete(ownedToolCallId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private addToolComponent(component?: ToolExecutionComponent): void {
|
|
311
|
+
if (!component) return;
|
|
312
|
+
component.setExpanded(this.expanded);
|
|
313
|
+
component.setShowImages(this.showImages);
|
|
314
|
+
this.toolComponents.add(component);
|
|
315
|
+
this.addChild(component);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private createToolComponent(toolName: string, toolCallId: string, args: Record<string, unknown>): ToolExecutionComponent | undefined {
|
|
319
|
+
try {
|
|
320
|
+
return new ToolExecutionComponent(
|
|
321
|
+
toolName,
|
|
322
|
+
toolCallId,
|
|
323
|
+
args,
|
|
324
|
+
{ showImages: this.showImages },
|
|
325
|
+
this.session?.getToolDefinition(toolName),
|
|
326
|
+
this.fakeUi as unknown as TUI,
|
|
327
|
+
this.session?.sessionManager.getCwd() ?? process.cwd(),
|
|
328
|
+
);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (isExpectedToolComponentFailure(error)) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
const failureKey = `${toolCallId}:${toolName}`;
|
|
334
|
+
if (!this.toolComponentFailures.has(failureKey)) {
|
|
335
|
+
this.toolComponentFailures.add(failureKey);
|
|
336
|
+
console.warn("[spawn] Failed to create tool component:", toolCallId, toolName, error);
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private addMessageToChat(message: SpawnChildMessage): void {
|
|
343
|
+
switch (message.role) {
|
|
344
|
+
case "bashExecution": {
|
|
345
|
+
const component = new BashExecutionComponent(message.command, this.fakeUi as unknown as TUI, message.excludeFromContext);
|
|
346
|
+
if (message.output) {
|
|
347
|
+
component.appendOutput(message.output);
|
|
348
|
+
}
|
|
349
|
+
component.setComplete(message.exitCode, message.cancelled, message.truncated ? { truncated: true } : undefined, message.fullOutputPath);
|
|
350
|
+
this.addChild(component);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case "custom": {
|
|
354
|
+
if (message.display) {
|
|
355
|
+
const component = new CustomMessageComponent(message, undefined, this.markdownTheme);
|
|
356
|
+
component.setExpanded(this.expanded);
|
|
357
|
+
this.addChild(component);
|
|
358
|
+
}
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "user": {
|
|
362
|
+
const blocks = Array.isArray(message.content) ? message.content : [];
|
|
363
|
+
const text = blocks
|
|
364
|
+
.filter((block: { type: string; text?: string }) => block.type === "text" && typeof block.text === "string")
|
|
365
|
+
.map((block: { type: string; text?: string }) => block.text ?? "")
|
|
366
|
+
.join("\n")
|
|
367
|
+
.trim();
|
|
368
|
+
if (!text) break;
|
|
369
|
+
if (this.children.length > 0) {
|
|
370
|
+
this.addChild(new Spacer(1));
|
|
371
|
+
}
|
|
372
|
+
const skillBlock = parseSkillBlock(text);
|
|
373
|
+
if (skillBlock) {
|
|
374
|
+
const component = new SkillInvocationMessageComponent(skillBlock, this.markdownTheme);
|
|
375
|
+
component.setExpanded(this.expanded);
|
|
376
|
+
this.addChild(component);
|
|
377
|
+
if (skillBlock.userMessage) {
|
|
378
|
+
this.addChild(new UserMessageComponent(skillBlock.userMessage, this.markdownTheme));
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
this.addChild(new UserMessageComponent(text, this.markdownTheme));
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case "assistant": {
|
|
386
|
+
this.addChild(new AssistantMessageComponent(message, false, this.markdownTheme, "Thinking..."));
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case "toolResult": {
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private rebuildFromSession(): void {
|
|
396
|
+
if (!this.session) return;
|
|
397
|
+
|
|
398
|
+
this.clear();
|
|
399
|
+
this.pendingTools.clear();
|
|
400
|
+
this.toolComponents.clear();
|
|
401
|
+
this.streamingComponent = undefined;
|
|
402
|
+
this.liveOutcome = this.details?.outcome ?? "running";
|
|
403
|
+
this.lastAction = getOutcomeStatusText(this.liveOutcome) ?? "";
|
|
404
|
+
const renderedPendingTools = new Map<string, ToolExecutionComponent>();
|
|
405
|
+
|
|
406
|
+
for (const message of this.session.messages as SpawnChildMessage[]) {
|
|
407
|
+
if (message.role === "assistant") {
|
|
408
|
+
const stopOutcome = getStopReasonOutcome(message.stopReason);
|
|
409
|
+
if (stopOutcome) {
|
|
410
|
+
this.liveOutcome = stopOutcome;
|
|
411
|
+
this.lastAction = getOutcomeStatusText(stopOutcome) ?? this.lastAction;
|
|
412
|
+
}
|
|
413
|
+
this.addMessageToChat(message);
|
|
414
|
+
for (const content of message.content ?? []) {
|
|
415
|
+
if (content.type !== "toolCall") continue;
|
|
416
|
+
const component = this.createToolComponent(content.name, content.id, content.arguments ?? {});
|
|
417
|
+
this.addToolComponent(component);
|
|
418
|
+
if (!component) continue;
|
|
419
|
+
if (stopOutcome) {
|
|
420
|
+
const errorMessage = stopOutcome === "aborted"
|
|
421
|
+
? message.errorMessage || "Operation aborted"
|
|
422
|
+
: message.errorMessage || "Error";
|
|
423
|
+
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
|
|
424
|
+
} else {
|
|
425
|
+
renderedPendingTools.set(content.id, component);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (message.role === "toolResult") {
|
|
432
|
+
const component = renderedPendingTools.get(message.toolCallId);
|
|
433
|
+
if (component) {
|
|
434
|
+
component.updateResult(message);
|
|
435
|
+
renderedPendingTools.delete(message.toolCallId);
|
|
436
|
+
}
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.addMessageToChat(message);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const [toolCallId, component] of renderedPendingTools) {
|
|
444
|
+
this.pendingTools.set(toolCallId, component);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
override render(width: number): string[] {
|
|
449
|
+
if (
|
|
450
|
+
this.cachedLines
|
|
451
|
+
&& this.cachedWidth === width
|
|
452
|
+
&& this.cachedExpanded === this.expanded
|
|
453
|
+
&& this.cachedShowImages === this.showImages
|
|
454
|
+
) {
|
|
455
|
+
return this.cachedLines;
|
|
456
|
+
}
|
|
457
|
+
const lines = this.expanded ? this.renderExpanded(width) : this.renderCollapsed(width);
|
|
458
|
+
this.cachedWidth = width;
|
|
459
|
+
this.cachedExpanded = this.expanded;
|
|
460
|
+
this.cachedShowImages = this.showImages;
|
|
461
|
+
this.cachedLines = lines;
|
|
462
|
+
return lines;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private extractPreview(result: ToolResultLike): string {
|
|
466
|
+
const text = result.content?.find(c => c.type === "text" && c.text)?.text;
|
|
467
|
+
if (!text) return "";
|
|
468
|
+
return text.trim().split("\n")[0].slice(0, TOOL_RESULT_PREVIEW_CHARS);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private renderCollapsed(width: number): string[] {
|
|
472
|
+
const lines: string[] = [];
|
|
473
|
+
const details = this.details;
|
|
474
|
+
const theme = this.nestTheme;
|
|
475
|
+
const outcome = this.liveOutcome;
|
|
476
|
+
// Theme may be undefined in tests or before setDetails — fall back to plain text
|
|
477
|
+
const color = (name: ThemeColor, text: string) => theme ? theme.fg(name, text) : text;
|
|
478
|
+
|
|
479
|
+
// Identity line — distinguishes nested spawns in collapsed view
|
|
480
|
+
if (details) {
|
|
481
|
+
const depthLabel = details.depth > 0 ? `[depth ${details.depth}] ` : "";
|
|
482
|
+
lines.push(truncateToWidth(
|
|
483
|
+
color("dim", `${getOutcomeMarker(outcome)}${depthLabel}${details.model} • ${details.thinking}`),
|
|
484
|
+
width,
|
|
485
|
+
));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (outcome === "running") {
|
|
489
|
+
const liveSummary = this.lastAction || "⏳ initializing…";
|
|
490
|
+
lines.push(truncateToWidth(color("dim", liveSummary), width));
|
|
491
|
+
} else if (outcome !== "success") {
|
|
492
|
+
const outcomeText = getOutcomeStatusText(outcome);
|
|
493
|
+
if (outcomeText) {
|
|
494
|
+
lines.push(truncateToWidth(color(outcome === "error" ? "warning" : "dim", outcomeText), width));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Preview last assistant output — 5 lines for context without noise
|
|
499
|
+
const summaryText = this.session ? getLastAssistantText(this.session.messages) : "";
|
|
500
|
+
if (summaryText) {
|
|
501
|
+
const textLines = summaryText.split("\n");
|
|
502
|
+
const maxLines = COLLAPSED_PREVIEW_MAX_LINES;
|
|
503
|
+
const shown = textLines.slice(0, maxLines);
|
|
504
|
+
for (const line of shown) {
|
|
505
|
+
lines.push(truncateToWidth(color("toolOutput", line), width));
|
|
506
|
+
}
|
|
507
|
+
const remaining = textLines.length - maxLines;
|
|
508
|
+
if (remaining > 0) {
|
|
509
|
+
lines.push(truncateToWidth(
|
|
510
|
+
color("muted", `... ${remaining} more lines`),
|
|
511
|
+
width,
|
|
512
|
+
));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Token/cost summary — quick usage check without expanding
|
|
517
|
+
if (details?.stats) {
|
|
518
|
+
const s = details.stats;
|
|
519
|
+
const cost = s.cost ?? 0;
|
|
520
|
+
const costStr = cost >= COST_THRESHOLD_COMPACT ? cost.toFixed(0) : cost >= COST_THRESHOLD_DECIMAL ? cost.toFixed(2) : cost.toFixed(4);
|
|
521
|
+
const truncated = details.truncated ? color("warning", " [truncated]") : "";
|
|
522
|
+
const statsLine = `tokens: ${s.inputTokens ?? "?"}/${s.outputTokens ?? "?"} · ${s.turns ?? "?"} turns · $${costStr}${truncated}`;
|
|
523
|
+
lines.push(truncateToWidth(color("dim", statsLine), width));
|
|
524
|
+
} else if (details?.statsUnavailable) {
|
|
525
|
+
lines.push(truncateToWidth(color("muted", "stats unavailable"), width));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return lines;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private renderExpanded(width: number): string[] {
|
|
532
|
+
// Renders children directly rather than via super.render() to apply
|
|
533
|
+
// depth-based indentation. Container.render() from pi-tui is a simple
|
|
534
|
+
// passthrough (no layout/decoration) so this is equivalent. If it ever
|
|
535
|
+
// adds padding or inter-child spacing, switch to super.render() and
|
|
536
|
+
// post-process lines to add indentation.
|
|
537
|
+
const depth = this.details?.depth ?? 0;
|
|
538
|
+
const indent = depth * INDENT_SPACES_PER_DEPTH;
|
|
539
|
+
const childWidth = Math.max(1, width - indent);
|
|
540
|
+
const leftPad = " ".repeat(indent);
|
|
541
|
+
const lines: string[] = [];
|
|
542
|
+
|
|
543
|
+
// Show identity header when expanded — anchors which nested session this is
|
|
544
|
+
const colorExpanded = (name: ThemeColor, text: string) => this.nestTheme ? this.nestTheme.fg(name, text) : text;
|
|
545
|
+
if (this.details) {
|
|
546
|
+
const header = `${getOutcomeMarker(this.liveOutcome)}${this.details.model} • ${this.details.thinking}`;
|
|
547
|
+
lines.push(leftPad + truncateToWidth(
|
|
548
|
+
colorExpanded("dim", header),
|
|
549
|
+
childWidth,
|
|
550
|
+
));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const child of this.children) {
|
|
554
|
+
const childLines = child.render(childWidth);
|
|
555
|
+
for (const line of childLines) {
|
|
556
|
+
lines.push(leftPad + line);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return lines;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private resetStreamingComponent(error: unknown, eventType: string): void {
|
|
563
|
+
this.streamingComponent = undefined;
|
|
564
|
+
if (isExpectedToolComponentFailure(error)) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
console.warn(`[spawn] streaming component error (${eventType}):`, this.ownedToolCallId, error);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private handleMessageStart(event: Extract<AgentSessionEvent, { type: "message_start" }>): void {
|
|
571
|
+
if (event.message.role === "custom" || event.message.role === "user") {
|
|
572
|
+
this.addMessageToChat(event.message);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (event.message.role === "assistant") {
|
|
576
|
+
this.liveOutcome = "running";
|
|
577
|
+
this.lastAction = "💭 thinking…";
|
|
578
|
+
try {
|
|
579
|
+
this.streamingComponent = new AssistantMessageComponent(undefined, false, this.markdownTheme, "Thinking...");
|
|
580
|
+
this.addChild(this.streamingComponent);
|
|
581
|
+
this.streamingComponent.updateContent(event.message);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
this.resetStreamingComponent(error, "message_start");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): void {
|
|
589
|
+
if (event.message.role !== "assistant") return;
|
|
590
|
+
if (this.streamingComponent) {
|
|
591
|
+
try {
|
|
592
|
+
this.streamingComponent.updateContent(event.message);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
this.resetStreamingComponent(error, "message_update");
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const content of event.message.content ?? []) {
|
|
598
|
+
if (content.type !== "toolCall") continue;
|
|
599
|
+
let component = this.pendingTools.get(content.id);
|
|
600
|
+
if (!component) {
|
|
601
|
+
component = this.createToolComponent(content.name, content.id, content.arguments ?? {});
|
|
602
|
+
this.addToolComponent(component);
|
|
603
|
+
if (component) {
|
|
604
|
+
this.pendingTools.set(content.id, component);
|
|
605
|
+
}
|
|
606
|
+
} else {
|
|
607
|
+
component.updateArgs(content.arguments ?? {});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const textBlock = event.message.content?.find(
|
|
611
|
+
(c: any) => c.type === "text" && c.text,
|
|
612
|
+
);
|
|
613
|
+
if (textBlock?.text) {
|
|
614
|
+
const firstLine = textBlock.text.trim().split("\n")[0];
|
|
615
|
+
if (firstLine) {
|
|
616
|
+
this.lastAction = firstLine.slice(0, LIVE_TEXT_PREVIEW_CHARS);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): void {
|
|
622
|
+
if (event.message.role !== "assistant") return;
|
|
623
|
+
if (this.streamingComponent) {
|
|
624
|
+
try {
|
|
625
|
+
this.streamingComponent.updateContent(event.message);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
this.resetStreamingComponent(error, "message_end");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const stopOutcome = getStopReasonOutcome(event.message.stopReason);
|
|
631
|
+
if (stopOutcome) {
|
|
632
|
+
const errorMessage = stopOutcome === "aborted"
|
|
633
|
+
? event.message.errorMessage || "Operation aborted"
|
|
634
|
+
: event.message.errorMessage || "Error";
|
|
635
|
+
this.liveOutcome = stopOutcome;
|
|
636
|
+
this.lastAction = getOutcomeStatusText(stopOutcome) ?? this.lastAction;
|
|
637
|
+
for (const component of this.pendingTools.values()) {
|
|
638
|
+
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
|
|
639
|
+
}
|
|
640
|
+
this.pendingTools.clear();
|
|
641
|
+
} else {
|
|
642
|
+
this.liveOutcome = "success";
|
|
643
|
+
this.lastAction = "💬 done";
|
|
644
|
+
for (const component of this.pendingTools.values()) {
|
|
645
|
+
component.setArgsComplete();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
this.streamingComponent = undefined;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): void {
|
|
652
|
+
this.liveOutcome = "running";
|
|
653
|
+
let component = this.pendingTools.get(event.toolCallId);
|
|
654
|
+
if (!component) {
|
|
655
|
+
component = this.createToolComponent(event.toolName, event.toolCallId, event.args ?? {});
|
|
656
|
+
this.addToolComponent(component);
|
|
657
|
+
if (component) {
|
|
658
|
+
this.pendingTools.set(event.toolCallId, component);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
this.toolNames.set(event.toolCallId, event.toolName);
|
|
662
|
+
this.lastAction = `[${event.toolName}] …`;
|
|
663
|
+
component?.markExecutionStarted();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private handleToolExecutionUpdate(event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>): void {
|
|
667
|
+
const component = this.pendingTools.get(event.toolCallId);
|
|
668
|
+
// Update live action and flush render cache even when the tool
|
|
669
|
+
// component isn't tracked (e.g. createToolComponent failed in
|
|
670
|
+
// test or degraded environment).
|
|
671
|
+
const name = this.toolNames.get(event.toolCallId) ?? "tool";
|
|
672
|
+
const preview = this.extractPreview(asToolResult(event.partialResult));
|
|
673
|
+
this.lastAction = preview
|
|
674
|
+
? `[${name}] ${preview}`
|
|
675
|
+
: `[${name}] …`;
|
|
676
|
+
if (component) {
|
|
677
|
+
component.updateResult({ ...asToolResult(event.partialResult), isError: false }, true);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): void {
|
|
682
|
+
const component = this.pendingTools.get(event.toolCallId);
|
|
683
|
+
// Update live action and flush render cache even without a
|
|
684
|
+
// tracked tool component, so the "✓"/"✗" state is always
|
|
685
|
+
// reflected in the next render.
|
|
686
|
+
const name = this.toolNames.get(event.toolCallId) ?? "tool";
|
|
687
|
+
this.toolNames.delete(event.toolCallId);
|
|
688
|
+
this.pendingTools.delete(event.toolCallId);
|
|
689
|
+
this.lastAction = event.isError
|
|
690
|
+
? `[${name}] ✗`
|
|
691
|
+
: `[${name}] ✓`;
|
|
692
|
+
if (component) {
|
|
693
|
+
component.updateResult({ ...asToolResult(event.result), isError: event.isError });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private handleEvent(event: AgentSessionEvent): void {
|
|
698
|
+
if (this.isStaleSession()) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
try {
|
|
703
|
+
switch (event.type) {
|
|
704
|
+
case "message_start": this.handleMessageStart(event); break;
|
|
705
|
+
case "message_update": this.handleMessageUpdate(event); break;
|
|
706
|
+
case "message_end": this.handleMessageEnd(event); break;
|
|
707
|
+
case "tool_execution_start": this.handleToolExecutionStart(event); break;
|
|
708
|
+
case "tool_execution_update": this.handleToolExecutionUpdate(event); break;
|
|
709
|
+
case "tool_execution_end": this.handleToolExecutionEnd(event); break;
|
|
710
|
+
}
|
|
711
|
+
this.clearRenderCache();
|
|
712
|
+
this.requestRender();
|
|
713
|
+
} catch (error) {
|
|
714
|
+
// Prevent a single bad event from killing the subscription.
|
|
715
|
+
// The TUI degrades gracefully — stale content until next successful event.
|
|
716
|
+
console.warn("[spawn] Event handler error:", event.type, this.ownedToolCallId, error);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── Spawn call/result renderers ───────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Renders the spawn tool call in the parent's TUI.
|
|
725
|
+
*
|
|
726
|
+
* Collapsed: shows up to PROMPT_PREVIEW_COLLAPSED_LINES of the prompt with
|
|
727
|
+
* "... N more lines, to expand" hint when truncated.
|
|
728
|
+
* Expanded: shows the full prompt text.
|
|
729
|
+
* Returns a static Text component — live updates come through renderResult.
|
|
730
|
+
*/
|
|
731
|
+
function renderSpawnCall(args: any, theme: Theme, context: { expanded: boolean }): Text {
|
|
732
|
+
const prompt = typeof args.prompt === "string" ? args.prompt : "...";
|
|
733
|
+
const { shown, remaining } = renderPromptPreview(prompt, context.expanded);
|
|
734
|
+
let text = theme.fg("toolTitle", theme.bold("spawn ")) + theme.fg("accent", "child");
|
|
735
|
+
if (typeof args.thinking === "string") {
|
|
736
|
+
text += theme.fg("dim", ` [${args.thinking}]`);
|
|
737
|
+
}
|
|
738
|
+
text += `\n${theme.fg("dim", shown)}`;
|
|
739
|
+
if (remaining > 0) {
|
|
740
|
+
text += theme.fg("muted", `\n... (${remaining} more lines, ${safeKeyHint("app.tools.expand", "to expand")})`);
|
|
741
|
+
}
|
|
742
|
+
return new Text(text, 0, 0);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Renders the result of a spawn execution into a TUI component.
|
|
747
|
+
*
|
|
748
|
+
* Three return paths:
|
|
749
|
+
* 1. Live session in state → attach to component, delete from state
|
|
750
|
+
* (ownership transfer), return the component.
|
|
751
|
+
* 2. Component already has a session (from a prior render) → return as-is.
|
|
752
|
+
* 3. Neither → dispose component, return static Text with model/thinking + output.
|
|
753
|
+
*
|
|
754
|
+
* Side effect on path (1): mutates state.childSessions via .delete().
|
|
755
|
+
*/
|
|
756
|
+
function renderSpawnResult(
|
|
757
|
+
result: { content: { type: string; text?: string }[]; details?: unknown },
|
|
758
|
+
expanded: boolean,
|
|
759
|
+
theme: Theme,
|
|
760
|
+
context: { toolCallId: string; lastComponent?: unknown; invalidate: () => void; showImages: boolean },
|
|
761
|
+
state: AgenticodingState,
|
|
762
|
+
): NestedAgentSessionComponent | Text {
|
|
763
|
+
// Runtime guard — both parent and child use executeSpawn which produces matching shape,
|
|
764
|
+
// but an explicit check ensures we don't crash on unexpected input
|
|
765
|
+
const details: SpawnResultDetails | undefined = result.details && typeof result.details === "object"
|
|
766
|
+
? (result.details as SpawnResultDetails)
|
|
767
|
+
: undefined;
|
|
768
|
+
const component = context.lastComponent instanceof NestedAgentSessionComponent
|
|
769
|
+
? context.lastComponent
|
|
770
|
+
: new NestedAgentSessionComponent();
|
|
771
|
+
component.setRequestRender(context.invalidate);
|
|
772
|
+
component.setExpanded(expanded);
|
|
773
|
+
component.setShowImages(context.showImages);
|
|
774
|
+
if (details) {
|
|
775
|
+
component.setDetails(details, theme);
|
|
776
|
+
}
|
|
777
|
+
const child = state.childSessions.get(context.toolCallId);
|
|
778
|
+
if (child) {
|
|
779
|
+
const liveChildSessions = state.liveChildSessions.get(context.toolCallId) === child
|
|
780
|
+
? state.liveChildSessions
|
|
781
|
+
: undefined;
|
|
782
|
+
component.attachSession(context.toolCallId, child, liveChildSessions);
|
|
783
|
+
state.childSessions.delete(context.toolCallId);
|
|
784
|
+
return component;
|
|
785
|
+
}
|
|
786
|
+
if (component.hasSession()) {
|
|
787
|
+
return component;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
component.dispose();
|
|
791
|
+
|
|
792
|
+
const output = result.content
|
|
793
|
+
.filter((block): block is { type: string; text: string } => block.type === "text" && typeof block.text === "string")
|
|
794
|
+
.map((block) => block.text)
|
|
795
|
+
.join("\n\n")
|
|
796
|
+
.trim();
|
|
797
|
+
const summary = output || "(no output)";
|
|
798
|
+
const outcome = details?.outcome ?? "running";
|
|
799
|
+
const meta = details ? `${getOutcomeMarker(outcome)}${details.model} • ${details.thinking}` : "";
|
|
800
|
+
const status = getOutcomeStatusText(outcome);
|
|
801
|
+
const text = [
|
|
802
|
+
meta ? theme.fg("dim", meta) : "",
|
|
803
|
+
status ? theme.fg(outcome === "error" ? "warning" : "dim", status) : "",
|
|
804
|
+
theme.fg("toolOutput", summary),
|
|
805
|
+
].filter(Boolean).join("\n");
|
|
806
|
+
return new Text(text, 0, 0);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export { NestedAgentSessionComponent, renderSpawnCall, renderSpawnResult };
|