@nmzpy/pi-ember-stack 0.1.6 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +83 -83
  2. package/package.json +62 -48
  3. package/plugins/devin-auth/extensions/index.ts +68 -7
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/models.ts +42 -196
  11. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  12. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  13. package/plugins/devin-auth/src/stream.ts +1 -1
  14. package/plugins/index.ts +29 -6
  15. package/plugins/pi-compact-tools/index.ts +35 -192
  16. package/plugins/pi-compact-tools/renderer.ts +589 -0
  17. package/plugins/pi-custom-agents/index.ts +727 -373
  18. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  19. package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
  20. package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
  21. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  22. package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
  23. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  24. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  25. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
  26. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  27. package/plugins/pi-ember-fff/index.ts +975 -0
  28. package/plugins/pi-ember-fff/query.ts +247 -0
  29. package/plugins/pi-ember-fff/test/query.test.ts +222 -0
  30. package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
  31. package/plugins/pi-ember-tps/index.ts +144 -0
  32. package/plugins/pi-ember-ui/ember.json +99 -0
  33. package/plugins/pi-ember-ui/index.ts +805 -0
  34. package/plugins/pi-ember-ui/mode-colors.ts +196 -0
  35. package/tsconfig.json +2 -1
  36. package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
@@ -1,31 +1,36 @@
1
1
  /**
2
2
  * SDK-based sub-agent runner for pi-subagent.
3
3
  *
4
- * Creates an in-process AgentSession via the pi SDK instead of spawning a
5
- * separate `pi` process. This eliminates cold-start overhead and allows
6
- * fine-grained control over token budget:
7
- *
8
- * - Only the agent's system prompt is used (no pi defaults).
9
- * - No AGENTS.md, no extensions, no skills, no prompt templates loaded.
10
- * - Thinking disabled, compaction disabled, retry disabled.
11
- * - In-memory session (no disk I/O).
12
- * - Shared auth/model infrastructure (no re-connection).
13
- *
14
- * Estimated token savings vs process-spawn: ~4-11K tokens per invocation.
4
+ * Compared to process-spawn, this saves ~4-11K tokens per invocation by using
5
+ * the pi SDK directly with a minimal system prompt, no extensions, no skills,
6
+ * no prompt templates, no thinking, and no compaction. To avoid blocking the
7
+ * parent TUI thread while the subagent runs, each agent is executed in a
8
+ * Node worker_thread and messages its progress back.
15
9
  */
16
10
 
11
+ import * as path from "node:path";
12
+ import { Worker } from "node:worker_threads";
17
13
  import type { Message, Model } from "@earendil-works/pi-ai";
18
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
19
14
  import {
20
- AuthStorage,
21
- createAgentSession,
22
- createExtensionRuntime,
23
- ModelRegistry,
24
- type ResourceLoader,
25
- SessionManager,
26
- SettingsManager,
15
+ type AuthStorage,
16
+ getAgentDir,
17
+ type ModelRegistry,
27
18
  } from "@earendil-works/pi-coding-agent";
28
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const PARALLEL_TOOL_CALL_GUIDANCE = `
25
+
26
+ ## Tool Call Efficiency
27
+
28
+ When multiple independent tool calls are needed (e.g. reading several files,
29
+ searching for different patterns), emit them all in a single response rather
30
+ than one at a time. The runtime executes independent tool calls in parallel,
31
+ so batching saves round-trips and reduces latency.
32
+ `;
33
+
29
34
  // ---------------------------------------------------------------------------
30
35
  // Types
31
36
  // ---------------------------------------------------------------------------
@@ -52,6 +57,123 @@ export interface SubAgentResult {
52
57
  errorMessage?: string;
53
58
  }
54
59
 
60
+ interface WorkerInput {
61
+ cwd: string;
62
+ agentDir: string;
63
+ authPath: string;
64
+ modelsPath: string | undefined;
65
+ systemPrompt: string;
66
+ fullSystemPrompt: string;
67
+ task: string;
68
+ tools: string[];
69
+ model: Model<any>;
70
+ agentName: string;
71
+ thinkingLevel: string;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Worker script — kept inline so the package stays self-contained. Worker
76
+ // threads cannot load .ts via jiti, so the code is written as plain JS and
77
+ // executed from a data: URL.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const WORKER_SCRIPT = `
81
+ const { parentPort, workerData } = require("node:worker_threads");
82
+
83
+ const { createAgentSession, getAgentDir, loadProjectContextFiles, AuthStorage, ModelRegistry, SessionManager, SettingsManager } = require("@earendil-works/pi-coding-agent");
84
+
85
+ function post(type, payload) {
86
+ parentPort?.postMessage({ type, payload });
87
+ }
88
+
89
+ const result = {
90
+ agent: workerData.agentName || "subagent",
91
+ task: workerData.task,
92
+ exitCode: 0,
93
+ messages: [],
94
+ stderr: "",
95
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
96
+ model: workerData.model ? \`\${workerData.model.provider}/\${workerData.model.id}\` : undefined,
97
+ };
98
+
99
+ async function main() {
100
+ const authStorage = AuthStorage.create(workerData.authPath);
101
+ const modelRegistry = ModelRegistry.create(authStorage, workerData.modelsPath);
102
+ const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
103
+
104
+ const resourceLoader = {
105
+ getExtensions: () => ({ extensions: [], errors: [], runtime: {} }),
106
+ getSkills: () => ({ skills: [], diagnostics: [] }),
107
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
108
+ getThemes: () => ({ themes: [], diagnostics: [] }),
109
+ getAgentsFiles: () => ({ agentsFiles: [] }),
110
+ getSystemPrompt: () => workerData.fullSystemPrompt,
111
+ getAppendSystemPrompt: () => [],
112
+ extendResources: () => {},
113
+ reload: async () => {},
114
+ };
115
+
116
+ const { session } = await createAgentSession({
117
+ cwd: workerData.cwd,
118
+ model: workerData.model,
119
+ thinkingLevel: workerData.thinkingLevel || "off",
120
+ authStorage,
121
+ modelRegistry,
122
+ resourceLoader,
123
+ tools: workerData.tools,
124
+ sessionManager: SessionManager.inMemory(workerData.cwd),
125
+ settingsManager,
126
+ });
127
+
128
+ const unsubscribe = session.subscribe((event) => {
129
+ if (event.type === "message_end") {
130
+ const msg = event.message;
131
+ if (msg.role === "assistant") {
132
+ result.usage.turns++;
133
+ if (msg.usage) {
134
+ result.usage.input += msg.usage.input || 0;
135
+ result.usage.output += msg.usage.output || 0;
136
+ result.usage.cacheRead += msg.usage.cacheRead || 0;
137
+ result.usage.cacheWrite += msg.usage.cacheWrite || 0;
138
+ result.usage.cost += msg.usage.cost?.total || 0;
139
+ result.usage.contextTokens = msg.usage.totalTokens || 0;
140
+ }
141
+ if (!result.model && msg.model) {
142
+ result.model = \`\${msg.provider || "?"}/\${msg.model}\`;
143
+ }
144
+ if (msg.stopReason) result.stopReason = msg.stopReason;
145
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
146
+ }
147
+ result.messages.push(msg);
148
+ post("message", { ...result, messages: [...result.messages] });
149
+ }
150
+ if (event.type === "agent_end" && result.messages.length === 0 && event.messages) {
151
+ result.messages = event.messages;
152
+ }
153
+ });
154
+
155
+ try {
156
+ await session.prompt(workerData.task);
157
+ result.exitCode = result.stopReason === "aborted" ? 1 : 0;
158
+ } catch (error) {
159
+ result.exitCode = 1;
160
+ result.stopReason = result.stopReason || "error";
161
+ result.errorMessage = result.errorMessage || (error instanceof Error ? error.message : String(error));
162
+ } finally {
163
+ unsubscribe?.();
164
+ try { session.dispose(); } catch {}
165
+ }
166
+ post("done", result);
167
+ }
168
+
169
+ main().catch((err) => {
170
+ result.exitCode = 1;
171
+ result.stopReason = result.stopReason || "error";
172
+ result.errorMessage = result.errorMessage || (err instanceof Error ? err.message : String(err));
173
+ post("done", result);
174
+ });
175
+ `;
176
+
55
177
  // ---------------------------------------------------------------------------
56
178
  // Public API
57
179
  // ---------------------------------------------------------------------------
@@ -81,179 +203,147 @@ export async function runSubAgent(options: {
81
203
  signal,
82
204
  agentName = "subagent",
83
205
  thinkingLevel = "off",
84
- onUpdate,
85
206
  onMessage,
86
207
  } = options;
87
208
 
88
- const result: SubAgentResult = {
89
- agent: agentName,
90
- task,
91
- exitCode: 0,
92
- messages: [],
93
- stderr: "",
94
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
95
- model: `${model.provider}/${model.id}`,
96
- };
209
+ if (signal?.aborted) {
210
+ return {
211
+ agent: agentName,
212
+ task,
213
+ exitCode: 1,
214
+ messages: [],
215
+ stderr: "",
216
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
217
+ stopReason: "aborted",
218
+ errorMessage: "Sub-agent aborted before start",
219
+ };
220
+ }
97
221
 
98
- // Build a minimal resource loader. The sub-agent sees ONLY the agent's
99
- // system prompt — no pi defaults, no AGENTS.md, no extensions, no skills.
100
- const resourceLoader: ResourceLoader = {
101
- getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
102
- getSkills: () => ({ skills: [], diagnostics: [] }),
103
- getPrompts: () => ({ prompts: [], diagnostics: [] }),
104
- getThemes: () => ({ themes: [], diagnostics: [] }),
105
- getAgentsFiles: () => ({ agentsFiles: [] }),
106
- getSystemPrompt: () => systemPrompt,
107
- getAppendSystemPrompt: () => [],
108
- extendResources: () => {},
109
- reload: async () => {},
222
+ const agentDir = getAgentDir();
223
+ const authPath = getAuthStoragePath(authStorage);
224
+ const modelsPath = getModelsPath(modelRegistry);
225
+
226
+ const contextFiles = loadProjectContextFilesCompat({ cwd, agentDir });
227
+ const contextPrefix = contextFiles.length > 0
228
+ ? contextFiles.map((f) => f.content).join("\n\n---\n\n") + "\n\n---\n\n"
229
+ : "";
230
+ const fullSystemPrompt = contextPrefix + systemPrompt + PARALLEL_TOOL_CALL_GUIDANCE;
231
+
232
+ const input: WorkerInput = {
233
+ cwd,
234
+ agentDir,
235
+ authPath,
236
+ modelsPath,
237
+ systemPrompt,
238
+ fullSystemPrompt,
239
+ task,
240
+ tools,
241
+ model,
242
+ agentName,
243
+ thinkingLevel,
110
244
  };
111
245
 
112
- const settingsManager = SettingsManager.inMemory({
113
- compaction: { enabled: false },
114
- retry: { enabled: false },
115
- });
246
+ return new Promise<SubAgentResult>((resolve, reject) => {
247
+ let settled = false;
248
+ const worker = new Worker(WORKER_SCRIPT, { eval: true, workerData: input as any });
116
249
 
117
- try {
118
- if (signal?.aborted) {
119
- result.exitCode = 1;
120
- result.stopReason = "aborted";
121
- result.errorMessage = "Sub-agent aborted before start";
122
- return result;
250
+ function finish(result: SubAgentResult) {
251
+ if (settled) return;
252
+ settled = true;
253
+ worker.terminate().catch(() => {});
254
+ resolve(result);
123
255
  }
124
256
 
125
- const { session } = await createAgentSession({
126
- cwd,
127
- model,
128
- thinkingLevel,
129
- authStorage,
130
- modelRegistry,
131
- resourceLoader,
132
- tools,
133
- sessionManager: SessionManager.inMemory(cwd),
134
- settingsManager,
135
- });
136
-
137
- let cleanupAbort: (() => void) | undefined;
138
- let cleanupEventAbort: (() => void) | undefined;
139
- try {
140
- // Wire abort signal
141
- if (signal) {
142
- const onAbort = () => session.abort();
143
- if (signal.aborted) {
144
- // Already aborted — shortcut
145
- result.exitCode = 1;
146
- result.stopReason = "aborted";
147
- result.errorMessage = "Sub-agent aborted before start";
148
- onAbort();
149
- return result;
150
- }
151
- signal.addEventListener("abort", onAbort, { once: true });
152
- cleanupAbort = () => signal.removeEventListener("abort", onAbort);
257
+ worker.on("message", (message: { type: "message" | "done"; payload: SubAgentResult }) => {
258
+ if (message.type === "message") {
259
+ onMessage?.(message.payload);
260
+ } else if (message.type === "done") {
261
+ finish(message.payload);
153
262
  }
263
+ });
154
264
 
155
- // Collect all messages and usage stats from events
156
- const eventPromise = new Promise<void>((resolve, reject) => {
157
- let settled = false;
158
- const finish = (fn: () => void) => {
159
- if (settled) return;
160
- settled = true;
161
- fn();
162
- };
163
-
164
- const unsubscribe = session.subscribe((event) => {
165
- try {
166
- switch (event.type) {
167
- case "message_end": {
168
- const msg = event.message as AgentMessage;
169
- if (msg.role === "assistant") {
170
- result.usage.turns++;
171
- if (msg.usage) {
172
- result.usage.input += msg.usage.input || 0;
173
- result.usage.output += msg.usage.output || 0;
174
- result.usage.cacheRead += msg.usage.cacheRead || 0;
175
- result.usage.cacheWrite += msg.usage.cacheWrite || 0;
176
- result.usage.cost += msg.usage.cost?.total || 0;
177
- result.usage.contextTokens = msg.usage.totalTokens || 0;
178
- }
179
- if (!result.model && msg.model) {
180
- result.model = `${msg.provider || "?"}/${msg.model}`;
181
- }
182
- if (msg.stopReason) result.stopReason = msg.stopReason;
183
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
184
- }
185
- // Collect all messages for extraction
186
- result.messages.push(msg as unknown as Message);
187
- if (onMessage) onMessage({ ...result, messages: [...result.messages] });
188
- break;
189
- }
190
- case "agent_end": {
191
- // agent_end carries all messages; use them if we haven't collected
192
- if (result.messages.length === 0 && event.messages) {
193
- result.messages = event.messages as unknown as Message[];
194
- }
195
- finish(() => {
196
- unsubscribe();
197
- resolve();
198
- });
199
- break;
200
- }
201
- }
202
- } catch (err) {
203
- finish(() => {
204
- unsubscribe();
205
- reject(err);
206
- });
207
- }
208
- });
209
-
210
- // Resolve on abort so the eventPromise doesn't hang
211
- if (signal) {
212
- const onAbortResolve = () => {
213
- finish(() => {
214
- result.exitCode = 1;
215
- result.stopReason = "aborted";
216
- if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
217
- unsubscribe();
218
- resolve();
219
- });
220
- };
221
- signal.addEventListener("abort", onAbortResolve, { once: true });
222
- cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
223
- }
265
+ worker.on("error", (err) => {
266
+ finish({
267
+ agent: agentName,
268
+ task,
269
+ exitCode: 1,
270
+ messages: [],
271
+ stderr: "",
272
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
273
+ stopReason: "error",
274
+ errorMessage: err instanceof Error ? err.message : String(err),
224
275
  });
276
+ });
225
277
 
226
- await Promise.race([
227
- session.prompt(task),
228
- eventPromise,
229
- ]);
278
+ worker.on("exit", (code) => {
279
+ if (settled) return;
280
+ finish({
281
+ agent: agentName,
282
+ task,
283
+ exitCode: 1,
284
+ messages: [],
285
+ stderr: "",
286
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
287
+ stopReason: "error",
288
+ errorMessage: `Sub-agent worker exited unexpectedly (code ${code})`,
289
+ });
290
+ });
230
291
 
231
- if (result.stopReason !== "aborted") {
232
- result.exitCode = 0;
233
- }
234
- return result;
235
- } finally {
236
- cleanupAbort?.();
237
- cleanupEventAbort?.();
238
- try {
239
- session.dispose();
240
- } catch {
241
- // Best-effort cleanup
292
+ if (signal) {
293
+ const onAbort = () => {
294
+ if (settled) return;
295
+ worker.terminate().catch(() => {});
296
+ finish({
297
+ agent: agentName,
298
+ task,
299
+ exitCode: 1,
300
+ messages: [],
301
+ stderr: "",
302
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
303
+ stopReason: "aborted",
304
+ errorMessage: "Sub-agent aborted",
305
+ });
306
+ };
307
+ if (signal.aborted) {
308
+ onAbort();
309
+ return;
242
310
  }
311
+ signal.addEventListener("abort", onAbort, { once: true });
243
312
  }
244
- } catch (err) {
245
- const message = err instanceof Error ? err.message : String(err);
246
- result.exitCode = 1;
247
- result.errorMessage = message;
248
- if (!result.stopReason) result.stopReason = "error";
249
- return result;
250
- }
313
+ });
251
314
  }
252
315
 
253
316
  // ---------------------------------------------------------------------------
254
317
  // Helpers
255
318
  // ---------------------------------------------------------------------------
256
319
 
320
+ function getAuthStoragePath(authStorage: AuthStorage): string {
321
+ try {
322
+ const storage = authStorage as unknown as { authPath?: string; path?: string };
323
+ if (storage.authPath) return storage.authPath;
324
+ if (storage.path) return storage.path;
325
+ } catch {}
326
+ return path.join(getAgentDir(), "auth.json");
327
+ }
328
+
329
+ function getModelsPath(modelRegistry: ModelRegistry): string | undefined {
330
+ try {
331
+ const registry = modelRegistry as unknown as { modelsPath?: string; path?: string };
332
+ if (registry.modelsPath) return registry.modelsPath;
333
+ if (registry.path) return registry.path;
334
+ } catch {}
335
+ return undefined;
336
+ }
337
+
338
+ function loadProjectContextFilesCompat({ cwd, agentDir }: { cwd: string; agentDir: string }): { content: string }[] {
339
+ try {
340
+ const { loadProjectContextFiles } = require("@earendil-works/pi-coding-agent");
341
+ return loadProjectContextFiles({ cwd, agentDir });
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+
257
347
  export function getFinalOutput(messages: Message[]): string {
258
348
  // Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
259
349
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -0,0 +1,145 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { renderSubagentLayout, anySubagentRunning, SubagentCapLine } from "../render.ts";
3
+
4
+ function makeTheme() {
5
+ const fg = mock((tag: string, text: string) => `[${tag}:${text}]`);
6
+ return {
7
+ fg,
8
+ bold: mock((s: string) => `*${s}*`),
9
+ };
10
+ }
11
+
12
+ function stripAnsi(text: string): string {
13
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
14
+ }
15
+
16
+ function makeResult(agent: string, exitCode: number, failed = false) {
17
+ return {
18
+ agent,
19
+ task: "test",
20
+ exitCode,
21
+ messages: [] as any[],
22
+ stderr: "",
23
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
24
+ ...(failed ? { stopReason: "error", errorMessage: "fail" } : {}),
25
+ } as any;
26
+ }
27
+
28
+ describe("SubagentCapLine", () => {
29
+ test("renders exactly the current width and disappears when hidden", () => {
30
+ let visible = true;
31
+ const cap = new SubagentCapLine(() => visible, (_color, text) => text);
32
+
33
+ expect(cap.render(37)).toEqual(["\u2500".repeat(37)]);
34
+ expect(cap.render(241)).toEqual(["\u2500".repeat(241)]);
35
+
36
+ visible = false;
37
+ expect(cap.render(80)).toEqual([]);
38
+ });
39
+ });
40
+
41
+ describe("renderSubagentLayout", () => {
42
+ test("running single mode uses the Thinking gradient without a bullet", () => {
43
+ const theme = makeTheme() as any;
44
+ const out = renderSubagentLayout({ agent: "coder", task: "do stuff" }, [], theme);
45
+ expect(stripAnsi(out)).toContain("coder");
46
+ expect(out).toContain("\u001b[38;2;");
47
+ expect(stripAnsi(out)).not.toContain("\u2022");
48
+ expect(out).not.toContain("\u2713");
49
+ expect(out).not.toContain("\u2717");
50
+ expect(out).not.toContain("subagent");
51
+ expect(out).not.toContain("[user]");
52
+ expect(out).not.toContain("\u23f3");
53
+ });
54
+
55
+ test("completed single mode shows checkmark", () => {
56
+ const theme = makeTheme() as any;
57
+ const out = renderSubagentLayout({ agent: "coder", task: "do stuff" }, [makeResult("coder", 0)], theme);
58
+ expect(stripAnsi(out)).toContain("coder");
59
+ expect(stripAnsi(out)).toContain("\u2713");
60
+ expect(stripAnsi(out)).not.toContain("\u2717");
61
+ expect(out).not.toContain("\u001b[38;2;");
62
+ expect(stripAnsi(out)).not.toContain("\u2022");
63
+ });
64
+
65
+ test("failed single mode shows X mark", () => {
66
+ const theme = makeTheme() as any;
67
+ const out = renderSubagentLayout({ agent: "coder", task: "do stuff" }, [makeResult("coder", 1, true)], theme);
68
+ expect(stripAnsi(out)).toContain("coder");
69
+ expect(stripAnsi(out)).toContain("\u2717");
70
+ expect(stripAnsi(out)).not.toContain("\u2713");
71
+ expect(out).not.toContain("\u001b[38;2;");
72
+ expect(stripAnsi(out)).not.toContain("\u2022");
73
+ });
74
+
75
+ test("failed single mode includes inline error text", () => {
76
+ const theme = makeTheme() as any;
77
+ const result = makeResult("coder", 1, true);
78
+ result.errorMessage = "timeout during read";
79
+ const out = renderSubagentLayout({ agent: "coder", task: "do stuff" }, [result], theme);
80
+ expect(stripAnsi(out)).toContain("timeout during read");
81
+ });
82
+
83
+ test("completed single mode does not include error text", () => {
84
+ const theme = makeTheme() as any;
85
+ const out = renderSubagentLayout({ agent: "coder", task: "do stuff" }, [makeResult("coder", 0)], theme);
86
+ expect(stripAnsi(out)).not.toContain("error");
87
+ expect(stripAnsi(out)).not.toContain("Error");
88
+ });
89
+
90
+ test("failed parallel mode shows inline error only on failed child", () => {
91
+ const theme = makeTheme() as any;
92
+ const args = { tasks: [{ agent: "coder", task: "a" }, { agent: "scout", task: "b" }] };
93
+ const failed = makeResult("coder", 1, true);
94
+ failed.errorMessage = "could not read file";
95
+ const out = renderSubagentLayout(args, [failed, makeResult("scout", 0)], theme);
96
+ const output = stripAnsi(out);
97
+ expect(output).toContain("coder");
98
+ expect(output).toContain("scout");
99
+ expect(output).toContain("could not read file");
100
+ const matches = output.split("could not read file").length - 1;
101
+ expect(matches).toBe(1);
102
+ });
103
+
104
+ test("parallel mode shows plain Subagents header with children", () => {
105
+ const theme = makeTheme() as any;
106
+ const args = { tasks: [{ agent: "coder", task: "a" }, { agent: "scout", task: "b" }] };
107
+ const out = renderSubagentLayout(args, [], theme);
108
+ const lines = out.split("\n");
109
+ const header = lines[0];
110
+ expect(stripAnsi(header)).toContain("Subagents");
111
+ expect(header).not.toContain("\u001b[38;2;");
112
+ expect(stripAnsi(out)).toContain("coder");
113
+ expect(stripAnsi(out)).toContain("scout");
114
+ expect(out).not.toContain("\u23f3");
115
+ expect(out).not.toContain("parallel");
116
+ expect(out).not.toContain("[user]");
117
+ expect(stripAnsi(out)).not.toContain("\u2022");
118
+ });
119
+
120
+ test("chain mode only shows started steps", () => {
121
+ const theme = makeTheme() as any;
122
+ const args = { chain: [{ agent: "scout", task: "a" }, { agent: "coder", task: "b" }] };
123
+ const out = renderSubagentLayout(args, [makeResult("scout", 0)], theme);
124
+ expect(out).toContain("scout");
125
+ expect(out).not.toContain("coder");
126
+ });
127
+
128
+ test("no hourglass glyphs anywhere", () => {
129
+ const theme = makeTheme() as any;
130
+ const args = { tasks: [{ agent: "coder", task: "a" }] };
131
+ const out = renderSubagentLayout(args, [makeResult("coder", -1)], theme);
132
+ expect(out).not.toContain("\u23f3");
133
+ expect(out).not.toContain("\u25d0");
134
+ });
135
+
136
+ test("anySubagentRunning true when exitCode is -1", () => {
137
+ const args = { tasks: [{ agent: "coder", task: "a" }] };
138
+ expect(anySubagentRunning(args, [makeResult("coder", -1)])).toBe(true);
139
+ });
140
+
141
+ test("anySubagentRunning false when all done", () => {
142
+ const args = { tasks: [{ agent: "coder", task: "a" }] };
143
+ expect(anySubagentRunning(args, [makeResult("coder", 0)])).toBe(false);
144
+ });
145
+ });