@nmzpy/pi-ember-stack 0.2.1 → 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 (30) hide show
  1. package/README.md +83 -83
  2. package/package.json +63 -59
  3. package/plugins/devin-auth/extensions/index.ts +23 -0
  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/oauth/register-user.ts +174 -174
  11. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  12. package/plugins/devin-auth/src/stream.ts +1 -1
  13. package/plugins/pi-compact-tools/index.ts +19 -1
  14. package/plugins/pi-compact-tools/renderer.ts +231 -61
  15. package/plugins/pi-custom-agents/index.ts +310 -102
  16. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  17. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  18. package/plugins/pi-custom-agents/subagent/extensions/index.ts +116 -224
  19. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  20. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  21. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +241 -177
  22. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  23. package/plugins/pi-ember-fff/index.ts +275 -17
  24. package/plugins/pi-ember-fff/query.ts +170 -10
  25. package/plugins/pi-ember-fff/test/query.test.ts +157 -1
  26. package/plugins/pi-ember-fff/test/renderer.test.ts +367 -16
  27. package/plugins/pi-ember-tps/index.ts +27 -5
  28. package/plugins/pi-ember-ui/ember.json +3 -0
  29. package/plugins/pi-ember-ui/index.ts +223 -36
  30. package/plugins/pi-ember-ui/mode-colors.ts +36 -8
@@ -1,33 +1,20 @@
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
- * - The agent's system prompt is prepended with project context files
9
- * (AGENTS.md from cwd and ancestors) so sub-agents share the same
10
- * codebase conventions as the main agent.
11
- * - No extensions, no skills, no prompt templates loaded.
12
- * - Thinking disabled, compaction disabled, retry disabled.
13
- * - In-memory session (no disk I/O).
14
- * - Shared auth/model infrastructure (no re-connection).
15
- *
16
- * 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.
17
9
  */
18
10
 
11
+ import * as path from "node:path";
12
+ import { Worker } from "node:worker_threads";
19
13
  import type { Message, Model } from "@earendil-works/pi-ai";
20
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
21
14
  import {
22
- AuthStorage,
23
- createAgentSession,
24
- createExtensionRuntime,
15
+ type AuthStorage,
25
16
  getAgentDir,
26
- loadProjectContextFiles,
27
- ModelRegistry,
28
- type ResourceLoader,
29
- SessionManager,
30
- SettingsManager,
17
+ type ModelRegistry,
31
18
  } from "@earendil-works/pi-coding-agent";
32
19
 
33
20
  // ---------------------------------------------------------------------------
@@ -41,7 +28,7 @@ const PARALLEL_TOOL_CALL_GUIDANCE = `
41
28
  When multiple independent tool calls are needed (e.g. reading several files,
42
29
  searching for different patterns), emit them all in a single response rather
43
30
  than one at a time. The runtime executes independent tool calls in parallel,
44
- so batching them saves round-trips and reduces latency.
31
+ so batching saves round-trips and reduces latency.
45
32
  `;
46
33
 
47
34
  // ---------------------------------------------------------------------------
@@ -70,6 +57,123 @@ export interface SubAgentResult {
70
57
  errorMessage?: string;
71
58
  }
72
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
+
73
177
  // ---------------------------------------------------------------------------
74
178
  // Public API
75
179
  // ---------------------------------------------------------------------------
@@ -99,187 +203,147 @@ export async function runSubAgent(options: {
99
203
  signal,
100
204
  agentName = "subagent",
101
205
  thinkingLevel = "off",
102
- onUpdate,
103
206
  onMessage,
104
207
  } = options;
105
208
 
106
- const result: SubAgentResult = {
107
- agent: agentName,
108
- task,
109
- exitCode: 0,
110
- messages: [],
111
- stderr: "",
112
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
113
- model: `${model.provider}/${model.id}`,
114
- };
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
+ }
221
+
222
+ const agentDir = getAgentDir();
223
+ const authPath = getAuthStoragePath(authStorage);
224
+ const modelsPath = getModelsPath(modelRegistry);
115
225
 
116
- // Load project context files (AGENTS.md from cwd and ancestors) so the
117
- // sub-agent has the same codebase conventions as the main agent.
118
- const contextFiles = loadProjectContextFiles({ cwd, agentDir: getAgentDir() });
226
+ const contextFiles = loadProjectContextFilesCompat({ cwd, agentDir });
119
227
  const contextPrefix = contextFiles.length > 0
120
228
  ? contextFiles.map((f) => f.content).join("\n\n---\n\n") + "\n\n---\n\n"
121
229
  : "";
122
230
  const fullSystemPrompt = contextPrefix + systemPrompt + PARALLEL_TOOL_CALL_GUIDANCE;
123
231
 
124
- // Build a minimal resource loader. The sub-agent sees the agent's system
125
- // prompt plus project context files (AGENTS.md) — no extensions, no skills.
126
- const resourceLoader: ResourceLoader = {
127
- getExtensions: () => ({ extensions: [], errors: [], runtime: createExtensionRuntime() }),
128
- getSkills: () => ({ skills: [], diagnostics: [] }),
129
- getPrompts: () => ({ prompts: [], diagnostics: [] }),
130
- getThemes: () => ({ themes: [], diagnostics: [] }),
131
- getAgentsFiles: () => ({ agentsFiles: [] }),
132
- getSystemPrompt: () => fullSystemPrompt,
133
- getAppendSystemPrompt: () => [],
134
- extendResources: () => {},
135
- reload: async () => {},
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,
136
244
  };
137
245
 
138
- const settingsManager = SettingsManager.inMemory({
139
- compaction: { enabled: false },
140
- retry: { enabled: false },
141
- });
246
+ return new Promise<SubAgentResult>((resolve, reject) => {
247
+ let settled = false;
248
+ const worker = new Worker(WORKER_SCRIPT, { eval: true, workerData: input as any });
142
249
 
143
- try {
144
- if (signal?.aborted) {
145
- result.exitCode = 1;
146
- result.stopReason = "aborted";
147
- result.errorMessage = "Sub-agent aborted before start";
148
- return result;
250
+ function finish(result: SubAgentResult) {
251
+ if (settled) return;
252
+ settled = true;
253
+ worker.terminate().catch(() => {});
254
+ resolve(result);
149
255
  }
150
256
 
151
- const { session } = await createAgentSession({
152
- cwd,
153
- model,
154
- thinkingLevel,
155
- authStorage,
156
- modelRegistry,
157
- resourceLoader,
158
- tools,
159
- sessionManager: SessionManager.inMemory(cwd),
160
- settingsManager,
161
- });
162
-
163
- let cleanupAbort: (() => void) | undefined;
164
- let cleanupEventAbort: (() => void) | undefined;
165
- try {
166
- // Wire abort signal
167
- if (signal) {
168
- const onAbort = () => session.abort();
169
- if (signal.aborted) {
170
- // Already aborted — shortcut
171
- result.exitCode = 1;
172
- result.stopReason = "aborted";
173
- result.errorMessage = "Sub-agent aborted before start";
174
- onAbort();
175
- return result;
176
- }
177
- signal.addEventListener("abort", onAbort, { once: true });
178
- 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);
179
262
  }
263
+ });
180
264
 
181
- // Collect all messages and usage stats from events
182
- const eventPromise = new Promise<void>((resolve, reject) => {
183
- let settled = false;
184
- const finish = (fn: () => void) => {
185
- if (settled) return;
186
- settled = true;
187
- fn();
188
- };
189
-
190
- const unsubscribe = session.subscribe((event) => {
191
- try {
192
- switch (event.type) {
193
- case "message_end": {
194
- const msg = event.message as AgentMessage;
195
- if (msg.role === "assistant") {
196
- result.usage.turns++;
197
- if (msg.usage) {
198
- result.usage.input += msg.usage.input || 0;
199
- result.usage.output += msg.usage.output || 0;
200
- result.usage.cacheRead += msg.usage.cacheRead || 0;
201
- result.usage.cacheWrite += msg.usage.cacheWrite || 0;
202
- result.usage.cost += msg.usage.cost?.total || 0;
203
- result.usage.contextTokens = msg.usage.totalTokens || 0;
204
- }
205
- if (!result.model && msg.model) {
206
- result.model = `${msg.provider || "?"}/${msg.model}`;
207
- }
208
- if (msg.stopReason) result.stopReason = msg.stopReason;
209
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
210
- }
211
- // Collect all messages for extraction
212
- result.messages.push(msg as unknown as Message);
213
- if (onMessage) onMessage({ ...result, messages: [...result.messages] });
214
- break;
215
- }
216
- case "agent_end": {
217
- // agent_end carries all messages; use them if we haven't collected
218
- if (result.messages.length === 0 && event.messages) {
219
- result.messages = event.messages as unknown as Message[];
220
- }
221
- finish(() => {
222
- unsubscribe();
223
- resolve();
224
- });
225
- break;
226
- }
227
- }
228
- } catch (err) {
229
- finish(() => {
230
- unsubscribe();
231
- reject(err);
232
- });
233
- }
234
- });
235
-
236
- // Resolve on abort so the eventPromise doesn't hang
237
- if (signal) {
238
- const onAbortResolve = () => {
239
- finish(() => {
240
- result.exitCode = 1;
241
- result.stopReason = "aborted";
242
- if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
243
- unsubscribe();
244
- resolve();
245
- });
246
- };
247
- signal.addEventListener("abort", onAbortResolve, { once: true });
248
- cleanupEventAbort = () => signal.removeEventListener("abort", onAbortResolve);
249
- }
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),
250
275
  });
276
+ });
251
277
 
252
- await Promise.race([
253
- session.prompt(task),
254
- eventPromise,
255
- ]);
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
+ });
256
291
 
257
- if (result.stopReason !== "aborted") {
258
- result.exitCode = 0;
259
- }
260
- return result;
261
- } finally {
262
- cleanupAbort?.();
263
- cleanupEventAbort?.();
264
- try {
265
- session.dispose();
266
- } catch {
267
- // 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;
268
310
  }
311
+ signal.addEventListener("abort", onAbort, { once: true });
269
312
  }
270
- } catch (err) {
271
- const message = err instanceof Error ? err.message : String(err);
272
- result.exitCode = 1;
273
- result.errorMessage = message;
274
- if (!result.stopReason) result.stopReason = "error";
275
- return result;
276
- }
313
+ });
277
314
  }
278
315
 
279
316
  // ---------------------------------------------------------------------------
280
317
  // Helpers
281
318
  // ---------------------------------------------------------------------------
282
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
+
283
347
  export function getFinalOutput(messages: Message[]): string {
284
348
  // Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
285
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
+ });