@pi-archimedes/subagent 1.5.0 → 1.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "1.5.0"
14
+ "@pi-archimedes/core": "1.7.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-ai": ">=0.1.0",
package/src/compact.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
2
  import type { SubagentDetails, SubagentProgress, SubagentResult, SubagentToolResult } from "./types.js";
3
- import { formatTokens, formatDuration, formatCost, truncLine, buildStatsLine } from "./format.js";
3
+ import { formatTokens, formatDuration, formatCost, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
4
4
 
5
5
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
6
6
  type RenderContext = { state: Record<string, unknown>; invalidate: () => void };
@@ -39,7 +39,7 @@ export function buildActivityLine(
39
39
  if (data.currentTool) {
40
40
  const arrow = theme.fg("muted", "↳ ");
41
41
  const argsPreview = data.currentToolArgs
42
- ? truncLine(data.currentToolArgs ?? "", 60)
42
+ ? truncLine(data.currentToolArgs, 60)
43
43
  : "";
44
44
  const durationPart = data.currentToolStartedAt
45
45
  ? " | " + formatDuration(Date.now() - data.currentToolStartedAt)
@@ -90,6 +90,7 @@ export function renderCompactSingle(
90
90
  theme: Theme,
91
91
  context: RenderContext,
92
92
  ): Text {
93
+ // agentName sourced from result; defaults to "subagent"
93
94
  const agentName = result.agent ?? "subagent";
94
95
  const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
95
96
  const isRunning = progress?.status === "running";
@@ -133,7 +134,8 @@ export function renderCompactSingle(
133
134
  ? theme.fg("accent", modelName)
134
135
  : "";
135
136
  const expandHint = theme.fg("muted", "(ctrl+o)");
136
- let output = [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
137
+ const agentLabel = buildAgentLabel(agentName, result.task, theme);
138
+ let output = agentLabel + "\n" + [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
137
139
  output += "\n" + activityLine;
138
140
 
139
141
  text.setText(output);
@@ -150,7 +152,7 @@ export function renderCompactParallel(
150
152
  ): Text {
151
153
  const lines = details.results.map((result, i) => {
152
154
  const progress = details.progress?.[i];
153
- const agentName = result.agent ?? "agent-" + i;
155
+ const agentName = result.agent ?? "subagent";
154
156
  const summary = result.progressSummary ?? { toolCount: 0, tokens: 0, durationMs: 0 };
155
157
  const isRunning = progress?.status === "running";
156
158
  // Prefer result.exitCode as the source of truth for completion status
@@ -189,7 +191,7 @@ export function renderCompactParallel(
189
191
  };
190
192
  const activityLine = buildActivityLine(activityData, theme);
191
193
 
192
- let line = `${glyphColored} ${agentName}${statsPart}`;
194
+ let line = `${glyphColored} ${buildAgentLabel(agentName, result.task, theme)}${statsPart}`;
193
195
  if (activityLine) {
194
196
  line += "\n" + activityLine;
195
197
  }
@@ -208,6 +210,7 @@ export function renderCompactProgress(
208
210
  theme: Theme,
209
211
  context: RenderContext,
210
212
  ): Text {
213
+ // agentName sourced from progress; defaults to "subagent"
211
214
  const agentName = progress.agent ?? "subagent";
212
215
  const status = progress.status;
213
216
  const isRunning = status === "running";
@@ -253,7 +256,8 @@ export function renderCompactProgress(
253
256
  : "";
254
257
  const statsPart = statsLine;
255
258
  const expandHint = theme.fg("muted", "(ctrl+o)");
256
- let output = [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
259
+ const agentLabel = buildAgentLabel(agentName, progress.task, theme);
260
+ let output = agentLabel + "\n" + [modelLabel, statsPart, expandHint].filter(Boolean).join(" ");
257
261
  output += "\n" + activityLine;
258
262
 
259
263
  text.setText(output);
@@ -269,7 +273,7 @@ export function renderCompactParallelProgress(
269
273
  context: RenderContext,
270
274
  ): Text {
271
275
  const lines = (details.progress ?? []).map((progress, i) => {
272
- const agentName = progress.agent ?? "agent-" + i;
276
+ const agentName = progress.agent ?? "subagent";
273
277
  const status = progress.status;
274
278
  const isRunning = status === "running";
275
279
 
@@ -301,7 +305,7 @@ export function renderCompactParallelProgress(
301
305
  };
302
306
  const activityLine = buildActivityLine(activityData, theme);
303
307
 
304
- let line = `${glyphColored} ${agentName}${statsPart}`;
308
+ let line = `${glyphColored} ${buildAgentLabel(agentName, progress.task, theme)}${statsPart}`;
305
309
  if (activityLine) {
306
310
  line += "\n" + activityLine;
307
311
  }
package/src/execute.ts CHANGED
@@ -16,7 +16,7 @@ export interface ExecuteOptions {
16
16
  }
17
17
 
18
18
  /**
19
- * Execute a single subagent synchronously blocks until completion.
19
+ * Execute a single subagent — waits for completion before resolving.
20
20
  */
21
21
  export async function executeSubagent(options: ExecuteOptions): Promise<SubagentResult> {
22
22
  const agentName = options.agent ?? "subagent";
@@ -95,7 +95,7 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
95
95
  } catch (err) {
96
96
  const errorMessage = err instanceof Error ? err.message : String(err);
97
97
  const durationMs = Date.now() - startTime;
98
- return {
98
+ const result: SubagentResult = {
99
99
  agent: agentName,
100
100
  task: options.task,
101
101
  exitCode: 1,
@@ -135,6 +135,10 @@ export async function executeSubagent(options: ExecuteOptions): Promise<Subagent
135
135
  },
136
136
  progressSummary: { toolCount: 0, tokens: 0, durationMs },
137
137
  };
138
+ // Emit final failure progress so executeParallel's progress slot updates
139
+ // from the pending placeholder to "failed" (prevents stale "Starting..." display).
140
+ options.onUpdate?.(result.progress!);
141
+ return result;
138
142
  }
139
143
  }
140
144
 
@@ -146,19 +150,41 @@ export async function executeParallel(options: {
146
150
  signal: AbortSignal | undefined;
147
151
  onUpdate: ((progress: SubagentProgress[]) => void) | undefined;
148
152
  }): Promise<SubagentResult[]> {
149
- // Collect latest progress per agent for aggregated reporting
150
- const latestProgress = new Map<string, SubagentProgress>();
153
+ // Pre-fill one pending slot per task, keyed by task index (NOT agent name).
154
+ // This keeps all N lines stacked from t=0 in stable task order, with no
155
+ // collisions when multiple subagents share an agent name (e.g. 8 x "general").
156
+ const latestProgress: SubagentProgress[] = options.tasks.map((taskDef) => ({
157
+ agent: taskDef.agent ?? "subagent",
158
+ status: "running" as const,
159
+ task: taskDef.task,
160
+ currentTool: undefined,
161
+ currentToolArgs: undefined,
162
+ currentToolStartedAt: undefined,
163
+ toolCount: 0,
164
+ inputTokens: 0,
165
+ outputTokens: 0,
166
+ tokens: 0,
167
+ cost: 0,
168
+ durationMs: 0,
169
+ error: undefined,
170
+ output: undefined,
171
+ recentOutput: undefined,
172
+ toolCalls: undefined,
173
+ // Match the model executeSubagent will report for this task, so the
174
+ // pending placeholder's model label matches the streaming label exactly.
175
+ model: taskDef.model,
176
+ }));
151
177
 
152
178
  const results = await Promise.all(
153
- options.tasks.map((taskDef) =>
179
+ options.tasks.map((taskDef, index) =>
154
180
  executeSubagent({
155
181
  ...taskDef,
156
182
  signal: options.signal,
157
183
  onUpdate: (progress: SubagentProgress) => {
158
- // Store latest progress keyed by agent name
159
- latestProgress.set(progress.agent, progress);
160
- // Emit aggregated progress across ALL agents
161
- options.onUpdate?.([...latestProgress.values()]);
184
+ // Store latest progress in this task's stable slot (by index).
185
+ latestProgress[index] = progress;
186
+ // Emit all N entries in stable task order.
187
+ options.onUpdate?.([...latestProgress]);
162
188
  },
163
189
  }),
164
190
  ),
package/src/expanded.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
2
  import type { SubagentDetails, SubagentProgress, SubagentResult } from "./types.js";
3
- import { formatTokens, formatDuration, truncLine, buildStatsLine } from "./format.js";
3
+ import { formatTokens, formatDuration, truncLine, buildStatsLine, buildAgentLabel } from "./format.js";
4
4
 
5
5
  type Theme = { fg: (token: string, text: string) => string; bold: (text: string) => string };
6
6
 
@@ -13,6 +13,9 @@ export function buildExpandedText(
13
13
  ): string {
14
14
  const lines: string[] = [];
15
15
 
16
+ lines.push(buildAgentLabel(result.agent, result.task, theme));
17
+ lines.push("");
18
+
16
19
  // Stats line (same as compact view)
17
20
  const modelName = progress?.model ?? result.model;
18
21
  const modelLabel = modelName ? theme.fg("accent", modelName) : "";
@@ -85,6 +88,9 @@ export function renderProgressExpanded(
85
88
  ): Text {
86
89
  const lines: string[] = [];
87
90
 
91
+ lines.push(buildAgentLabel(progress.agent, progress.task, theme));
92
+ lines.push("");
93
+
88
94
  // Stats line (same as compact view)
89
95
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
90
96
  const statsLine = buildStatsLine({
@@ -158,6 +164,9 @@ export function buildProgressExpandedText(
158
164
  ): string {
159
165
  const lines: string[] = [];
160
166
 
167
+ lines.push(buildAgentLabel(progress.agent, progress.task, theme));
168
+ lines.push("");
169
+
161
170
  // Stats line (same as compact view)
162
171
  const modelLabel = progress.model ? theme.fg("accent", progress.model) : "";
163
172
  const statsLine = buildStatsLine({
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { truncLine } from "./format.js";
3
+
4
+ // ── truncLine ───────────────────────────────────────────────────────────────
5
+
6
+ describe("truncLine", () => {
7
+ it("returns text unchanged when within limit", () => {
8
+ expect(truncLine("hello", 10)).toBe("hello");
9
+ });
10
+
11
+ it("truncates with '...' when exceeding limit", () => {
12
+ expect(truncLine("hello world", 8)).toBe("hello...");
13
+ });
14
+
15
+ it("stops at newline boundary instead of bleeding into next line", () => {
16
+ expect(truncLine("line one\nline two\nline three", 15)).toBe("line one...");
17
+ });
18
+
19
+ it("truncates first line if it itself exceeds limit", () => {
20
+ expect(truncLine("this is a very long first line\nsecond", 12)).toBe("this is a...");
21
+ });
22
+
23
+ it("handles multiple consecutive newlines", () => {
24
+ expect(truncLine("a\n\n\nb", 10)).toBe("a...");
25
+ });
26
+
27
+ it("handles text starting with newline", () => {
28
+ expect(truncLine("\nhello", 10)).toBe("...");
29
+ });
30
+ });
package/src/format.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { clampLine } from "@pi-archimedes/core/text";
2
-
3
1
  export function formatTokens(n: number): string {
4
2
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
5
3
  if (n >= 1_000) return Math.round(n / 1_000) + "k";
@@ -22,8 +20,25 @@ export function formatCost(cost: number): string {
22
20
  return "$" + cost.toFixed(2);
23
21
  }
24
22
 
25
- export function truncLine(text: string, width: number): string {
26
- return clampLine(text, width);
23
+ /**
24
+ * Truncate plain text to a max character length, appending "..." if truncated.
25
+ *
26
+ * Unlike truncateToWidth from pi-tui, this does not handle wide characters or
27
+ * measure ANSI display width. The benefit: the "..." is plain text so it
28
+ * inherits any ANSI color wrapper applied around the result (pi-tui's
29
+ * truncateToWidth appends "..." after closing color codes, leaving it unstyled).
30
+ *
31
+ * If the text contains newlines, truncates at the first newline boundary
32
+ * rather than bleeding into the next line.
33
+ */
34
+ export function truncLine(text: string, maxLen: number): string {
35
+ // Check for newline first — it's a hard boundary regardless of length
36
+ const nlIdx = text.indexOf("\n");
37
+ if (nlIdx !== -1) {
38
+ return text.slice(0, nlIdx).slice(0, maxLen - 3) + "...";
39
+ }
40
+ if (text.length <= maxLen) return text;
41
+ return text.slice(0, maxLen - 3) + "...";
27
42
  }
28
43
 
29
44
  export interface StatsData {
@@ -57,3 +72,18 @@ export function buildStatsLine(
57
72
 
58
73
  return parts.map(p => theme.fg("dim", "· " + p)).join(" ");
59
74
  }
75
+
76
+ // Build an agent label for display: "agentName: truncated task preview".
77
+ // Truncation width of 60 keeps compact rows readable on standard terminal widths
78
+ // while still showing meaningful task context.
79
+ export function buildAgentLabel(
80
+ agent: string,
81
+ task: string | undefined,
82
+ theme: { fg: (token: string, text: string) => string; bold: (text: string) => string },
83
+ ): string {
84
+ const name = theme.bold(agent);
85
+ if (task) {
86
+ return name + theme.fg("dim", ": " + truncLine(task, 60));
87
+ }
88
+ return name;
89
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractArgsPreview } from "./handlers.js";
3
+
4
+ // ── extractArgsPreview ──────────────────────────────────────────────────────
5
+
6
+ describe("extractArgsPreview", () => {
7
+ it("returns string args truncated", () => {
8
+ const long = "a".repeat(200);
9
+ expect(extractArgsPreview(long)).toBe("a".repeat(120));
10
+ });
11
+
12
+ it("replaces newlines in string args", () => {
13
+ expect(extractArgsPreview("hello\nworld")).toBe("hello world");
14
+ });
15
+
16
+ it("replaces newlines in single-key object value", () => {
17
+ const args = { command: "cat file.txt\ngrep -A5 \"test\"" };
18
+ const result = extractArgsPreview(args);
19
+ expect(result).not.toContain("\n");
20
+ expect(result).toBe("cat file.txt grep -A5 \"test\"");
21
+ });
22
+
23
+ it("replaces newlines in multi-key longest string value", () => {
24
+ const args = { short: "x", long: "hello\nworld\nfoo" };
25
+ expect(extractArgsPreview(args)).toBe("hello world foo");
26
+ });
27
+
28
+ it("handles number and boolean values", () => {
29
+ expect(extractArgsPreview({ count: 42 })).toBe("42");
30
+ expect(extractArgsPreview({ enabled: true })).toBe("true");
31
+ });
32
+ });
package/src/handlers.ts CHANGED
@@ -15,14 +15,17 @@ export interface JsonEvent {
15
15
  * Generic: no hardcoded tool names.
16
16
  */
17
17
  export function extractArgsPreview(args: unknown): string {
18
- if (typeof args === "string") return args.slice(0, ARGS_PREVIEW_MAX);
18
+ if (typeof args === "string") {
19
+ // Replace newlines so previews stay single-line
20
+ return args.replace(/\n/g, " ").slice(0, ARGS_PREVIEW_MAX);
21
+ }
19
22
  if (args && typeof args === "object" && !Array.isArray(args)) {
20
23
  const obj = args as Record<string, unknown>;
21
24
  const keys = Object.keys(obj);
22
25
  // Single-key object: show the value directly (or serialize if complex)
23
26
  if (keys.length === 1) {
24
27
  const v = obj[keys[0]!];
25
- if (typeof v === "string") return v.slice(0, ARGS_PREVIEW_MAX);
28
+ if (typeof v === "string") return v.replace(/\n/g, " ").slice(0, ARGS_PREVIEW_MAX);
26
29
  if (typeof v === "number" || typeof v === "boolean") return String(v);
27
30
  // Complex value (array/nested object) — serialize just this value
28
31
  const serialized = JSON.stringify(v);
@@ -35,7 +38,7 @@ export function extractArgsPreview(args: unknown): string {
35
38
  best = v;
36
39
  }
37
40
  }
38
- if (best) return best.slice(0, ARGS_PREVIEW_MAX);
41
+ if (best) return best.replace(/\n/g, " ").slice(0, ARGS_PREVIEW_MAX);
39
42
  }
40
43
  const serialized = JSON.stringify(args);
41
44
  return serialized?.slice(0, ARGS_PREVIEW_MAX) ?? "";