@ian-pascoe/pi-minimal-subagents 0.12.0 → 0.14.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,296 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import { contentText } from "@earendil-works/pi-ai";
4
+ import {
5
+ AssistantMessageComponent,
6
+ BashExecutionComponent,
7
+ BranchSummaryMessageComponent,
8
+ CompactionSummaryMessageComponent,
9
+ CustomMessageComponent,
10
+ ToolExecutionComponent,
11
+ UserMessageComponent,
12
+ type TruncationResult,
13
+ } from "@earendil-works/pi-coding-agent";
14
+ import { Container, Text, type Component, type TUI } from "@earendil-works/pi-tui";
15
+ import {
16
+ ACTIVITY_RAIL_WIDTH,
17
+ drawActivityRail,
18
+ renderMinimalSubagentsMessage,
19
+ renderMinimalSubagentsResult,
20
+ type MinimalSubagentsRenderTheme,
21
+ } from "./minimal-subagents-rendering.js";
22
+ import type { ChildAgentTranscriptSnapshot } from "./minimal-subagents-types.js";
23
+
24
+ interface CachedTranscriptMessage {
25
+ container: Container;
26
+ tools: Map<string, ToolExecutionComponent>;
27
+ expanded: boolean;
28
+ streaming: boolean;
29
+ }
30
+
31
+ /** Rendered transcript lines and the first line of each message. */
32
+ export interface TranscriptLayout {
33
+ lines: string[];
34
+ messageStarts: number[];
35
+ /** First line of each native component, such as one assistant reply or one tool execution. */
36
+ itemStarts: number[];
37
+ }
38
+
39
+ /** Native components reused across renders while their source messages are unchanged. */
40
+ export interface TranscriptRenderCache {
41
+ messages: WeakMap<AgentMessage, CachedTranscriptMessage>;
42
+ results: WeakMap<ToolExecutionComponent, AgentMessage>;
43
+ }
44
+
45
+ /** Render a Child Session Transcript with Pi's native message and tool components. */
46
+ export function renderTranscriptSnapshot(
47
+ snapshot: ChildAgentTranscriptSnapshot,
48
+ tui: TUI,
49
+ cwd: string,
50
+ expanded: boolean,
51
+ width: number,
52
+ cache: TranscriptRenderCache,
53
+ ): TranscriptLayout {
54
+ if (snapshot.messages.length === 0) {
55
+ return {
56
+ lines: new Text(snapshot.fallback || "No conversation messages yet.", 3, 0).render(width),
57
+ messageStarts: [0],
58
+ itemStarts: [0],
59
+ };
60
+ }
61
+ const blocks: Container[] = [];
62
+ const tools = new Map(
63
+ snapshot.toolDefinitions.map((definition) => [definition.name, definition]),
64
+ );
65
+ const pendingTools = new Map<string, ToolExecutionComponent>();
66
+ const currentMessages = new Set(snapshot.messages);
67
+
68
+ for (const [messageIndex, message] of snapshot.messages.entries()) {
69
+ if (message.role === "toolResult") {
70
+ const paired = pendingTools.get(message.toolCallId);
71
+ if (paired) {
72
+ if (cache.results.get(paired) !== message) {
73
+ paired.updateResult(message);
74
+ cache.results.set(paired, message);
75
+ }
76
+ pendingTools.delete(message.toolCallId);
77
+ blocks.push(new Container());
78
+ continue;
79
+ }
80
+ }
81
+ const streaming = messageIndex === snapshot.streamingAssistantIndex;
82
+ const cached = cache.messages.get(message);
83
+ const staleResult =
84
+ cached &&
85
+ [...cached.tools.values()].some((tool) => {
86
+ const result = cache.results.get(tool);
87
+ return result !== undefined && !currentMessages.has(result);
88
+ });
89
+ if (cached && !staleResult && cached.expanded === expanded && cached.streaming === streaming) {
90
+ blocks.push(cached.container);
91
+ for (const [id, tool] of cached.tools) pendingTools.set(id, tool);
92
+ continue;
93
+ }
94
+ const container = new Container();
95
+ const messageTools = new Map<string, ToolExecutionComponent>();
96
+ switch (message.role) {
97
+ case "user": {
98
+ const text = contentText(message.content, "\n\n");
99
+ if (text) container.addChild(new UserMessageComponent(text));
100
+ break;
101
+ }
102
+ case "assistant": {
103
+ const assistant = new AssistantMessageComponent(message);
104
+ assistant.updateContent(message, messageIndex === snapshot.streamingAssistantIndex);
105
+ container.addChild(assistant);
106
+ for (const content of message.content) {
107
+ if (content.type !== "toolCall") continue;
108
+ const tool = new ToolExecutionComponent(
109
+ content.name,
110
+ content.id,
111
+ content.arguments,
112
+ { showImages: false },
113
+ tools.get(content.name) ?? {},
114
+ tui,
115
+ cwd,
116
+ );
117
+ tool.setExpanded(expanded);
118
+ container.addChild(tool);
119
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
120
+ tool.updateResult({
121
+ content: [
122
+ {
123
+ type: "text",
124
+ text:
125
+ message.stopReason === "aborted"
126
+ ? "Operation aborted"
127
+ : (message.errorMessage ?? "Error"),
128
+ },
129
+ ],
130
+ isError: true,
131
+ });
132
+ } else {
133
+ pendingTools.set(content.id, tool);
134
+ messageTools.set(content.id, tool);
135
+ }
136
+ }
137
+ break;
138
+ }
139
+ case "toolResult": {
140
+ const inherited = new ToolExecutionComponent(
141
+ message.toolName,
142
+ message.toolCallId,
143
+ {},
144
+ { showImages: false },
145
+ {},
146
+ tui,
147
+ cwd,
148
+ );
149
+ inherited.setExpanded(expanded);
150
+ inherited.updateResult(message);
151
+ container.addChild(inherited);
152
+ break;
153
+ }
154
+ case "custom": {
155
+ if (!message.display) break;
156
+ const renderer =
157
+ message.customType === "minimal-subagents.message"
158
+ ? renderMinimalSubagentsMessage
159
+ : message.customType === "minimal-subagents.result"
160
+ ? renderMinimalSubagentsResult
161
+ : undefined;
162
+ const custom = new CustomMessageComponent(message, renderer);
163
+ custom.setExpanded(expanded);
164
+ container.addChild(custom);
165
+ break;
166
+ }
167
+ case "bashExecution": {
168
+ const bash = new BashExecutionComponent(message.command, tui, message.excludeFromContext);
169
+ if (message.output) bash.appendOutput(message.output);
170
+ bash.setComplete(
171
+ message.exitCode,
172
+ message.cancelled,
173
+ // SAFETY: Persisted bash messages retain only the truncation flag; BashExecutionComponent reads that flag here.
174
+ message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
175
+ message.fullOutputPath,
176
+ );
177
+ bash.setExpanded(expanded);
178
+ container.addChild(bash);
179
+ break;
180
+ }
181
+ case "branchSummary": {
182
+ const summary = new BranchSummaryMessageComponent(message);
183
+ summary.setExpanded(expanded);
184
+ container.addChild(summary);
185
+ break;
186
+ }
187
+ case "compactionSummary": {
188
+ const summary = new CompactionSummaryMessageComponent(message);
189
+ summary.setExpanded(expanded);
190
+ container.addChild(summary);
191
+ break;
192
+ }
193
+ }
194
+ cache.messages.set(message, { container, tools: messageTools, expanded, streaming });
195
+ blocks.push(container);
196
+ }
197
+ let length = 0;
198
+ const messageStarts: number[] = [];
199
+ const itemStarts: number[] = [];
200
+ // Containers concatenate their children, so rendering children one by one yields the same lines.
201
+ const lines = blocks.flatMap((block) => {
202
+ messageStarts.push(length);
203
+ return block.children.flatMap((child) => {
204
+ itemStarts.push(length);
205
+ // Native user-message prompt zones belong to the main terminal, not an embedded overlay.
206
+ const rendered = child
207
+ .render(width)
208
+ .map((line) =>
209
+ line
210
+ .replaceAll("\x1b]133;A\x07", "")
211
+ .replaceAll("\x1b]133;B\x07", "")
212
+ .replaceAll("\x1b]133;C\x07", ""),
213
+ );
214
+ length += rendered.length;
215
+ return rendered;
216
+ });
217
+ });
218
+ return { lines, messageStarts, itemStarts };
219
+ }
220
+
221
+ /** Create an empty cache for {@link renderTranscriptSnapshot}. */
222
+ export function createTranscriptRenderCache(): TranscriptRenderCache {
223
+ return { messages: new WeakMap(), results: new WeakMap() };
224
+ }
225
+
226
+ /** Collapsed live views keep at most this many lines of the child's recent work. */
227
+ const COLLAPSED_TRANSCRIPT_RAIL_LINES = 20;
228
+
229
+ function isSpacerLine(line: string): boolean {
230
+ // Includes a tool box's coloured padding rows, so each rail connector meets visible content.
231
+ return stripVTControlCharacters(line).trim().length === 0;
232
+ }
233
+
234
+ function withoutSpacerEdges(lines: string[]): string[] {
235
+ const start = lines.findIndex((line) => !isSpacerLine(line));
236
+ if (start < 0) return [];
237
+ const end = lines.findLastIndex((line) => !isSpacerLine(line));
238
+ return lines.slice(start, end + 1);
239
+ }
240
+
241
+ /**
242
+ * A child's recent transcript drawn with Pi's native message and tool components, one rail item
243
+ * per component. Collapsed, it keeps the latest items that fit the line budget.
244
+ */
245
+ export class TranscriptRail implements Component {
246
+ constructor(
247
+ private readonly snapshot: ChildAgentTranscriptSnapshot,
248
+ private readonly tui: TUI,
249
+ private readonly cwd: string,
250
+ private readonly expanded: boolean,
251
+ private readonly cache: TranscriptRenderCache,
252
+ private readonly theme: MinimalSubagentsRenderTheme,
253
+ ) {}
254
+
255
+ render(width: number): string[] {
256
+ const layout = renderTranscriptSnapshot(
257
+ this.snapshot,
258
+ this.tui,
259
+ this.cwd,
260
+ this.expanded,
261
+ Math.max(1, width - ACTIVITY_RAIL_WIDTH),
262
+ this.cache,
263
+ );
264
+ const items = layout.itemStarts
265
+ .map((start, index) =>
266
+ withoutSpacerEdges(layout.lines.slice(start, layout.itemStarts[index + 1])),
267
+ )
268
+ .filter((lines) => lines.length > 0);
269
+ if (this.expanded) return drawActivityRail(items, this.theme);
270
+ let shown = 0;
271
+ let lineCount = 0;
272
+ for (const item of items.toReversed()) {
273
+ if (shown > 0 && lineCount + item.length > COLLAPSED_TRANSCRIPT_RAIL_LINES) break;
274
+ shown++;
275
+ lineCount += item.length;
276
+ }
277
+ const kept = items
278
+ .slice(items.length - shown)
279
+ .map((lines) => lines.slice(0, COLLAPSED_TRANSCRIPT_RAIL_LINES));
280
+ const earlier = items.length - shown;
281
+ return drawActivityRail(
282
+ earlier > 0
283
+ ? [
284
+ [this.theme.fg("dim", `… ${earlier} earlier ${earlier === 1 ? "step" : "steps"}`)],
285
+ ...kept,
286
+ ]
287
+ : kept,
288
+ this.theme,
289
+ );
290
+ }
291
+
292
+ invalidate(): void {
293
+ this.cache.messages = new WeakMap();
294
+ this.cache.results = new WeakMap();
295
+ }
296
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Usage } from "@earendil-works/pi-ai";
3
3
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
4
5
 
5
6
  /** Controls how much committed caller conversation enters a new child session. */
6
7
  export type SessionContextMode = "inherit" | "compact" | "omit";
@@ -35,17 +36,24 @@ export interface SpawnParameters {
35
36
  agent_id?: string;
36
37
  session_context?: SessionContextMode;
37
38
  project_context?: ProjectContextMode;
39
+ /** Configured model role whose model and thinking level apply unless overridden below. */
40
+ role?: string;
38
41
  model?: string;
39
42
  thinking_level?: ThinkingLevel;
40
43
  tools?: ToolSelection;
41
44
  delegation?: DelegationMode;
42
45
  }
43
46
 
44
- /** Returns persistent agent and turn identities immediately after launch scheduling. */
45
- export interface SpawnResult {
47
+ /** Returns persistent agent and turn identities plus the resolved grant immediately after launch scheduling. */
48
+ export interface SpawnResult extends RuntimeProfile {
46
49
  agent_id: string;
47
50
  turn_id: string;
48
51
  status: "running";
52
+ /** Concrete ordinary tools granted by the Launch Contract. */
53
+ tools: string[];
54
+ delegation: DelegationMode;
55
+ /** Non-fatal tool-resolution warnings, such as skipped configured tools. */
56
+ warnings?: string[];
49
57
  }
50
58
 
51
59
  /** Retains one keyed terminal turn result for waits and durable delivery recovery. */
@@ -59,13 +67,18 @@ export interface TurnResult {
59
67
  elapsed_ms?: number;
60
68
  }
61
69
 
62
- /** Reports whether one direct message was handed to a wait, queued, or failed. */
63
- export type AgentMessageDisposition = "delivered-via-wait" | "queued" | "failed";
70
+ /**
71
+ * Reports whether one direct message was handed to a wait, queued into an active turn, started a
72
+ * new turn on an idle recipient, or failed.
73
+ */
74
+ export type AgentMessageDisposition = "delivered-via-wait" | "queued" | "started-turn" | "failed";
64
75
 
65
76
  export interface AgentMessageResult {
66
77
  agent_id: string;
67
78
  message_id: string;
68
79
  disposition: AgentMessageDisposition;
80
+ /** Turn started on an idle recipient; present only for `started-turn`. */
81
+ turn_id?: string;
69
82
  error?: string;
70
83
  }
71
84
 
@@ -112,7 +125,8 @@ export interface AgentSummary extends RuntimeProfile {
112
125
  latest_activity_at?: string;
113
126
  task?: string;
114
127
  child_count: number;
115
- children: AgentSummary[];
128
+ /** Nested summaries; omitted from caller-scoped status, which reports only `child_count`. */
129
+ children?: AgentSummary[];
116
130
  }
117
131
 
118
132
  /** Provides bounded recent child conversation text for detailed status. */
@@ -129,6 +143,12 @@ export interface RecentAgentActivity {
129
143
  truncated: boolean;
130
144
  }
131
145
 
146
+ /** Live progress of a Child Agent's running turn, shown while its parent waits. */
147
+ export interface ActiveTurnProgress {
148
+ turn_id: string;
149
+ tool_calls: number;
150
+ }
151
+
132
152
  /** Holds the complete selected-branch Child Session Transcript for trusted status UI. */
133
153
  export interface ChildAgentTranscriptSnapshot {
134
154
  messages: AgentMessage[];
@@ -182,6 +202,8 @@ export interface DeleteResult {
182
202
 
183
203
  /** Persists immutable context, model, thinking, and ordinary-tool launch choices. */
184
204
  export interface LaunchContract extends RuntimeProfile {
205
+ /** Configured model role the caller named at launch; absent when none was used. */
206
+ role?: string;
185
207
  session_context: SessionContextMode;
186
208
  project_context: ProjectContextMode;
187
209
  tools: ToolSelection | undefined;
@@ -407,6 +429,7 @@ export interface CoordinatorDependencies {
407
429
  root: RootConversationEndpoint;
408
430
  maxSubagentDepth?: number;
409
431
  toolsets?: MinimalSubagentsToolsets;
432
+ modelRoles?: readonly MinimalSubagentsModelRole[];
410
433
  now?: () => Date;
411
434
  automaticDeliveryGraceMs?: number;
412
435
  notify?: (notification: CoordinatorNotification) => void;
@@ -59,7 +59,7 @@ function flattenAgentHierarchy(agents: readonly AgentSummary[]): FlattenedAgentS
59
59
  const flattened: FlattenedAgentSummary[] = [];
60
60
  const visit = (agent: AgentSummary, depth: number, parentId?: string) => {
61
61
  flattened.push({ agent, depth, parentId, order: flattened.length });
62
- for (const child of agent.children) visit(child, depth + 1, agent.agent_id);
62
+ for (const child of agent.children ?? []) visit(child, depth + 1, agent.agent_id);
63
63
  };
64
64
  for (const agent of agents) visit(agent, 0);
65
65
  return flattened;
@@ -1,17 +1,28 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai";
2
2
 
3
+ /** Return a clone with optional counters present, so every reported usage has one shape. */
4
+ export function normalizeMinimalSubagentsUsage(usage: Usage): Usage {
5
+ return {
6
+ ...structuredClone(usage),
7
+ cacheWrite1h: usage.cacheWrite1h ?? 0,
8
+ reasoning: usage.reasoning ?? 0,
9
+ };
10
+ }
11
+
3
12
  /** Add two optional Pi usage totals without retaining mutable references to either input. */
4
13
  export function addMinimalSubagentsUsage(
5
14
  left: Usage | undefined,
6
15
  right: Usage | undefined,
7
16
  ): Usage | undefined {
8
- if (!left) return right ? structuredClone(right) : undefined;
9
- if (!right) return structuredClone(left);
17
+ if (!left) return right ? normalizeMinimalSubagentsUsage(right) : undefined;
18
+ if (!right) return normalizeMinimalSubagentsUsage(left);
10
19
  return {
11
20
  input: left.input + right.input,
12
21
  output: left.output + right.output,
13
22
  cacheRead: left.cacheRead + right.cacheRead,
14
23
  cacheWrite: left.cacheWrite + right.cacheWrite,
24
+ cacheWrite1h: (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0),
25
+ reasoning: (left.reasoning ?? 0) + (right.reasoning ?? 0),
15
26
  totalTokens: left.totalTokens + right.totalTokens,
16
27
  cost: {
17
28
  input: left.cost.input + right.cost.input,