@d3ara1n/pi-subagent 0.6.0 → 0.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/src/utils.ts CHANGED
@@ -13,22 +13,22 @@ import type { ActivityEntry, SubagentRole, SubagentResult, ToolStatus } from "./
13
13
  export const MAX_OUTPUT_CHARS = 50_000;
14
14
 
15
15
  export function formatTokens(count: number): string {
16
- if (count < 1000) return count.toString();
17
- if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
18
- if (count < 1000000) return `${Math.round(count / 1000)}k`;
19
- return `${(count / 1000000).toFixed(1)}M`;
16
+ if (count < 1000) return count.toString();
17
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
18
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
19
+ return `${(count / 1000000).toFixed(1)}M`;
20
20
  }
21
21
 
22
22
  export function formatUsageStats(usage: SubagentResult["usage"], model?: string): string {
23
- const parts: string[] = [];
24
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
25
- if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`);
26
- if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`);
27
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
28
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
29
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
30
- if (model) parts.push(model);
31
- return parts.join(" ");
23
+ const parts: string[] = [];
24
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
25
+ if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`);
26
+ if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`);
27
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
28
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
29
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
30
+ if (model) parts.push(model);
31
+ return parts.join(" ");
32
32
  }
33
33
 
34
34
  /**
@@ -41,157 +41,168 @@ export function formatUsageStats(usage: SubagentResult["usage"], model?: string)
41
41
  * and tested in pure-helper contexts without importing the full type.
42
42
  */
43
43
  export function elapsedSeconds(r: {
44
- exitCode: number;
45
- startTime?: number;
46
- elapsedMs?: number;
44
+ exitCode: number;
45
+ startTime?: number;
46
+ elapsedMs?: number;
47
47
  }): number | undefined {
48
- if (r.exitCode === -1 && typeof r.startTime === "number") {
49
- return Math.max(0, Math.round((Date.now() - r.startTime) / 1000));
50
- }
51
- if (r.exitCode !== -1 && typeof r.elapsedMs === "number") {
52
- return Math.round(r.elapsedMs / 1000);
53
- }
54
- return undefined;
48
+ if (r.exitCode === -1 && typeof r.startTime === "number") {
49
+ return Math.max(0, Math.round((Date.now() - r.startTime) / 1000));
50
+ }
51
+ if (r.exitCode !== -1 && typeof r.elapsedMs === "number") {
52
+ return Math.round(r.elapsedMs / 1000);
53
+ }
54
+ return undefined;
55
55
  }
56
56
 
57
57
  export type DisplayItem =
58
- | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
59
- | { type: "thinking"; status?: ToolStatus };
58
+ | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
59
+ | { type: "thinking"; status?: ToolStatus };
60
60
 
61
61
  /** Map the real-time activity log into renderable display items (in order). */
62
62
  export function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
63
- return activityLog.map((a) =>
64
- a.kind === "thinking"
65
- ? { type: "thinking", status: a.status }
66
- : { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
67
- );
63
+ return activityLog.map((a) =>
64
+ a.kind === "thinking"
65
+ ? { type: "thinking", status: a.status }
66
+ : { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
67
+ );
68
68
  }
69
69
 
70
70
  export function shortenPath(p: string): string {
71
- const home = os.homedir();
72
- if (process.platform === "win32") {
73
- return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
74
- }
75
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
71
+ const home = os.homedir();
72
+ if (process.platform === "win32") {
73
+ return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
74
+ }
75
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
76
76
  }
77
77
 
78
78
  export function formatToolCall(
79
- toolName: string,
80
- args: Record<string, unknown>,
81
- fg: (color: string, text: string) => string,
79
+ toolName: string,
80
+ args: Record<string, unknown>,
81
+ fg: (color: string, text: string) => string,
82
82
  ): string {
83
- switch (toolName) {
84
- case "delegate": {
85
- const subRole = args.role as string | undefined;
86
- return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
87
- }
88
- case "bash": {
89
- const command = (args.command as string) || "...";
90
- const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
91
- return fg("muted", "$ ") + fg("toolOutput", preview);
92
- }
93
- case "read": {
94
- const rawPath = (args.file_path || args.path || "...") as string;
95
- const filePath = shortenPath(rawPath);
96
- const offset = args.offset as number | undefined;
97
- const limit = args.limit as number | undefined;
98
- let text = fg("accent", filePath);
99
- if (offset !== undefined || limit !== undefined) {
100
- const startLine = offset ?? 1;
101
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
102
- text += fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
103
- }
104
- return fg("muted", "read ") + text;
105
- }
106
- case "write": {
107
- const rawPath = (args.file_path || args.path || "...") as string;
108
- const content = (args.content || "") as string;
109
- const lines = content.split("\n").length;
110
- let text = fg("muted", "write ") + fg("accent", shortenPath(rawPath));
111
- if (lines > 1) text += fg("dim", ` (${lines} lines)`);
112
- return text;
113
- }
114
- case "edit": {
115
- const rawPath = (args.file_path || args.path || "...") as string;
116
- return fg("muted", "edit ") + fg("accent", shortenPath(rawPath));
117
- }
118
- case "grep": {
119
- const pattern = (args.pattern || "") as string;
120
- const rawPath = (args.path || ".") as string;
121
- return fg("muted", "grep ") + fg("accent", `/${pattern}/`) + fg("dim", ` in ${shortenPath(rawPath)}`);
122
- }
123
- case "find": {
124
- const pattern = (args.pattern || "*") as string;
125
- return fg("muted", "find ") + fg("accent", pattern);
126
- }
127
- case "glob": {
128
- const pattern = (args.pattern || "*") as string;
129
- return fg("muted", "glob ") + fg("accent", pattern);
130
- }
131
- default: {
132
- const preview = previewArgs(args);
133
- return fg("accent", toolName) + (preview ? fg("dim", ` ${preview}`) : "");
134
- }
135
- }
83
+ switch (toolName) {
84
+ case "delegate": {
85
+ const subRole = args.role as string | undefined;
86
+ return fg("muted", "delegate ") + fg("accent", subRole ?? "...");
87
+ }
88
+ case "bash": {
89
+ const command = (args.command as string) || "...";
90
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
91
+ return fg("muted", "$ ") + fg("toolOutput", preview);
92
+ }
93
+ case "read": {
94
+ const rawPath = (args.file_path || args.path || "...") as string;
95
+ const filePath = shortenPath(rawPath);
96
+ const offset = args.offset as number | undefined;
97
+ const limit = args.limit as number | undefined;
98
+ let text = fg("accent", filePath);
99
+ if (offset !== undefined || limit !== undefined) {
100
+ const startLine = offset ?? 1;
101
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
102
+ text += fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
103
+ }
104
+ return fg("muted", "read ") + text;
105
+ }
106
+ case "write": {
107
+ const rawPath = (args.file_path || args.path || "...") as string;
108
+ const content = (args.content || "") as string;
109
+ const lines = content.split("\n").length;
110
+ let text = fg("muted", "write ") + fg("accent", shortenPath(rawPath));
111
+ if (lines > 1) text += fg("dim", ` (${lines} lines)`);
112
+ return text;
113
+ }
114
+ case "edit": {
115
+ const rawPath = (args.file_path || args.path || "...") as string;
116
+ return fg("muted", "edit ") + fg("accent", shortenPath(rawPath));
117
+ }
118
+ case "grep": {
119
+ const pattern = (args.pattern || "") as string;
120
+ const rawPath = (args.path || ".") as string;
121
+ return (
122
+ fg("muted", "grep ") +
123
+ fg("accent", `/${pattern}/`) +
124
+ fg("dim", ` in ${shortenPath(rawPath)}`)
125
+ );
126
+ }
127
+ case "find": {
128
+ const pattern = (args.pattern || "*") as string;
129
+ return fg("muted", "find ") + fg("accent", pattern);
130
+ }
131
+ case "glob": {
132
+ const pattern = (args.pattern || "*") as string;
133
+ return fg("muted", "glob ") + fg("accent", pattern);
134
+ }
135
+ default: {
136
+ const preview = previewArgs(args);
137
+ return fg("accent", toolName) + (preview ? fg("dim", ` ${preview}`) : "");
138
+ }
139
+ }
136
140
  }
137
141
 
138
142
  /** Per-tool-call visual styling: prefix glyph + color function keyed by status. */
139
143
  export function statusStyle(
140
- status: ToolStatus | undefined,
141
- fg: (color: string, text: string) => string,
144
+ status: ToolStatus | undefined,
145
+ fg: (color: string, text: string) => string,
142
146
  ): { prefix: string; color: (c: string, text: string) => string } {
143
- switch (status) {
144
- case "running":
145
- return { prefix: fg("accent", "\u2192 "), color: fg };
146
- case "failed":
147
- return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
148
- case "done":
149
- default:
150
- return { prefix: fg("dim", "\u2022 "), color: (_c, text) => fg("dim", text) };
151
- }
147
+ switch (status) {
148
+ case "running":
149
+ return { prefix: fg("accent", "\u2192 "), color: fg };
150
+ case "failed":
151
+ return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
152
+ case "done":
153
+ default:
154
+ return { prefix: fg("dim", "\u2022 "), color: (_c, text) => fg("dim", text) };
155
+ }
152
156
  }
153
157
 
154
158
  /** Render a thinking-block row: diamond glyph + label, colored by status.
155
159
  * Running = hollow diamond (unformed thought); done = solid diamond (settled). */
156
160
  export function formatThinking(
157
- status: ToolStatus | undefined,
158
- fg: (color: string, text: string) => string,
161
+ status: ToolStatus | undefined,
162
+ fg: (color: string, text: string) => string,
159
163
  ): string {
160
- if (status === "running") {
161
- return fg("accent", "\u25C7 thinking");
162
- }
163
- // done (or unknown) — dim past tense, solid diamond
164
- return fg("dim", "\u25C6 thought");
164
+ if (status === "running") {
165
+ return fg("accent", "\u25C7 thinking");
166
+ }
167
+ // done (or unknown) — dim past tense, solid diamond
168
+ return fg("dim", "\u25C6 thought");
165
169
  }
166
170
 
167
171
  export function renderDisplayItems(
168
- items: DisplayItem[],
169
- limit: number | undefined,
170
- fg: (color: string, text: string) => string,
172
+ items: DisplayItem[],
173
+ limit: number | undefined,
174
+ fg: (color: string, text: string) => string,
171
175
  ): string {
172
- const toShow = limit ? items.slice(-limit) : items;
173
- const skipped = limit && items.length > limit ? items.length - limit : 0;
174
- let text = "";
175
- if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
176
- for (const item of toShow) {
177
- if (item.type === "thinking") {
178
- text += `${formatThinking(item.status, fg)}\n`;
179
- } else {
180
- const { prefix, color } = statusStyle(item.status, fg);
181
- text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
182
- }
183
- }
184
- return text.trimEnd();
176
+ const toShow = limit ? items.slice(-limit) : items;
177
+ const skipped = limit && items.length > limit ? items.length - limit : 0;
178
+ let text = "";
179
+ if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
180
+ for (const item of toShow) {
181
+ if (item.type === "thinking") {
182
+ text += `${formatThinking(item.status, fg)}\n`;
183
+ } else {
184
+ const { prefix, color } = statusStyle(item.status, fg);
185
+ text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
186
+ }
187
+ }
188
+ return text.trimEnd();
185
189
  }
186
190
 
187
191
  export function isFailedResult(r: SubagentResult): boolean {
188
- return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted" || r.stopReason === "timeout";
192
+ return (
193
+ r.exitCode !== 0 ||
194
+ r.stopReason === "error" ||
195
+ r.stopReason === "aborted" ||
196
+ r.stopReason === "timeout"
197
+ );
189
198
  }
190
199
 
191
200
  /** Heuristic: does this result look like a provider-side failure worth retrying on the fallback role? */
192
201
  export function isProviderError(result: SubagentResult): boolean {
193
- const haystack = `${result.stderr || ""}\n${result.errorMessage || ""}`;
194
- return /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i.test(haystack);
202
+ const haystack = `${result.stderr || ""}\n${result.errorMessage || ""}`;
203
+ return /429|quota|rate.?limit|auth|timeout|exhausted|unavailable|503|server error|temporary|declined|overloaded|econnreset|socket hang up|epipe|network|connection/i.test(
204
+ haystack,
205
+ );
195
206
  }
196
207
 
197
208
  /**
@@ -199,16 +210,16 @@ export function isProviderError(result: SubagentResult): boolean {
199
210
  * @internal — exported for testing; used internally by {@link formatToolCall}.
200
211
  */
201
212
  export function previewArgs(args: Record<string, unknown>): string {
202
- const command = args.command as string | undefined;
203
- if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
204
- const fp = (args.file_path || args.path) as string | undefined;
205
- if (fp) return shortenPath(fp);
206
- const url = args.url as string | undefined;
207
- if (url) return url.length > 60 ? url.slice(0, 60) + "..." : url;
208
- const query = (args.query || args.pattern || args.regex || args.search) as string | undefined;
209
- if (query) return `/${query.length > 60 ? query.slice(0, 60) + "..." : query}/`;
210
- const argsStr = JSON.stringify(args);
211
- return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
213
+ const command = args.command as string | undefined;
214
+ if (command) return `$ ${command.length > 60 ? command.slice(0, 60) + "..." : command}`;
215
+ const fp = (args.file_path || args.path) as string | undefined;
216
+ if (fp) return shortenPath(fp);
217
+ const url = args.url as string | undefined;
218
+ if (url) return url.length > 60 ? url.slice(0, 60) + "..." : url;
219
+ const query = (args.query || args.pattern || args.regex || args.search) as string | undefined;
220
+ if (query) return `/${query.length > 60 ? query.slice(0, 60) + "..." : query}/`;
221
+ const argsStr = JSON.stringify(args);
222
+ return argsStr.length > 50 ? argsStr.slice(0, 50) + "..." : argsStr;
212
223
  }
213
224
 
214
225
  // ── Concurrency gate ───────────────────────────────────────────────
@@ -219,44 +230,44 @@ export function previewArgs(args: Record<string, unknown>): string {
219
230
  * Pass an AbortSignal to cancel while waiting (rejects and removes the waiter).
220
231
  */
221
232
  export class AsyncSemaphore {
222
- private active = 0;
223
- private waiters: Array<() => void> = [];
224
- private max: number;
225
- constructor(max: number) {
226
- this.max = max;
227
- }
228
- async acquire(signal?: AbortSignal): Promise<void> {
229
- if (this.active < this.max) {
230
- this.active++;
231
- return;
232
- }
233
- return new Promise<void>((resolve, reject) => {
234
- const wakeup = () => {
235
- signal?.removeEventListener("abort", onAbort);
236
- this.active++;
237
- resolve();
238
- };
239
- const onAbort = () => {
240
- signal?.removeEventListener("abort", onAbort);
241
- const idx = this.waiters.indexOf(wakeup);
242
- if (idx >= 0) this.waiters.splice(idx, 1);
243
- reject(new Error("aborted while waiting for concurrency slot"));
244
- };
245
- this.waiters.push(wakeup);
246
- if (signal) {
247
- if (signal.aborted) {
248
- onAbort();
249
- return;
250
- }
251
- signal.addEventListener("abort", onAbort, { once: true });
252
- }
253
- });
254
- }
255
- release(): void {
256
- this.active = Math.max(0, this.active - 1);
257
- const next = this.waiters.shift();
258
- if (next) next();
259
- }
233
+ private active = 0;
234
+ private waiters: Array<() => void> = [];
235
+ private max: number;
236
+ constructor(max: number) {
237
+ this.max = max;
238
+ }
239
+ async acquire(signal?: AbortSignal): Promise<void> {
240
+ if (this.active < this.max) {
241
+ this.active++;
242
+ return;
243
+ }
244
+ return new Promise<void>((resolve, reject) => {
245
+ const wakeup = () => {
246
+ signal?.removeEventListener("abort", onAbort);
247
+ this.active++;
248
+ resolve();
249
+ };
250
+ const onAbort = () => {
251
+ signal?.removeEventListener("abort", onAbort);
252
+ const idx = this.waiters.indexOf(wakeup);
253
+ if (idx >= 0) this.waiters.splice(idx, 1);
254
+ reject(new Error("aborted while waiting for concurrency slot"));
255
+ };
256
+ this.waiters.push(wakeup);
257
+ if (signal) {
258
+ if (signal.aborted) {
259
+ onAbort();
260
+ return;
261
+ }
262
+ signal.addEventListener("abort", onAbort, { once: true });
263
+ }
264
+ });
265
+ }
266
+ release(): void {
267
+ this.active = Math.max(0, this.active - 1);
268
+ const next = this.waiters.shift();
269
+ if (next) next();
270
+ }
260
271
  }
261
272
 
262
273
  // ── Timeout policy ────────────────────────────────────────
@@ -268,23 +279,23 @@ export class AsyncSemaphore {
268
279
  * All inputs/outputs are in SECONDS — convert to ms at the spawn boundary.
269
280
  */
270
281
  export function effectiveTimeout(roleDef: SubagentRole, baseTimeoutSec: number): number {
271
- const canDelegate = (roleDef.tools ?? []).includes("delegate");
272
- if (canDelegate && roleDef.timeout == null) {
273
- return baseTimeoutSec * 2;
274
- }
275
- return roleDef.timeout ?? baseTimeoutSec;
282
+ const canDelegate = (roleDef.tools ?? []).includes("delegate");
283
+ if (canDelegate && roleDef.timeout == null) {
284
+ return baseTimeoutSec * 2;
285
+ }
286
+ return roleDef.timeout ?? baseTimeoutSec;
276
287
  }
277
288
 
278
289
  // ── Output truncation ────────────────────────────────────────
279
290
 
280
291
  /** Strip path separators / traversal so sessionId/toolCallId can't escape the history dir. */
281
292
  export function sanitizeFilename(s: string): string {
282
- return s.replace(/[^\w.-]/g, "_").replace(/^[.]+/, "") || "unknown";
293
+ return s.replace(/[^\w.-]/g, "_").replace(/^[.]+/, "") || "unknown";
283
294
  }
284
295
 
285
296
  /** Mechanical fallback: keep head (findings) + tail (summary), drop the middle. */
286
297
  export function truncateOutput(t: string): string {
287
- const head = t.slice(0, 30_000);
288
- const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
289
- return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
298
+ const head = t.slice(0, 30_000);
299
+ const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
300
+ return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
290
301
  }