pi-subagentura 1.0.3 → 1.0.4

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 (4) hide show
  1. package/README.md +4 -4
  2. package/helpers.ts +509 -0
  3. package/package.json +12 -6
  4. package/subagent.ts +1021 -296
package/README.md CHANGED
@@ -90,12 +90,12 @@ Best for:
90
90
 
91
91
  ## Development
92
92
 
93
- This repo uses Bun for local development.
93
+ This repo uses npm for local development.
94
94
 
95
95
  ```bash
96
- bun install
97
- bun test
98
- bun run pack:check
96
+ npm install
97
+ npm test
98
+ npm run pack:check
99
99
  ```
100
100
 
101
101
  ## Contributing
package/helpers.ts ADDED
@@ -0,0 +1,509 @@
1
+ /**
2
+ * Shared helpers for pi-subagentura
3
+ *
4
+ * Exported so both subagent.ts and test files can import them.
5
+ * Keeps helper logic in one place — single source of truth.
6
+ */
7
+
8
+ import { randomBytes } from "node:crypto";
9
+ import { getModel, getProviders } from "@mariozechner/pi-ai";
10
+ import type { Model } from "@mariozechner/pi-ai";
11
+
12
+ // Note: Model<TApi> and AgentToolResult<T> are SDK generics. We use `unknown` as
13
+ // the type argument to avoid strict generic instantiation issues with tsc.
14
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
15
+
16
+ import {
17
+ AuthStorage,
18
+ createAgentSession,
19
+ ModelRegistry,
20
+ SessionManager,
21
+ type AgentSession,
22
+ } from "@mariozechner/pi-coding-agent";
23
+
24
+ // ── Constants ────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Milliseconds to wait before showing activeTool in the live status preview.
28
+ * Prevents UI flicker for fast tool executions that start and end within this window.
29
+ *
30
+ * Note: If Pi adds new model providers, update KNOWN_PROVIDERS below.
31
+ */
32
+ export const ACTIVE_TOOL_DEBOUNCE_MS = 150;
33
+
34
+ // Note: If Pi adds new providers, getProviders() from @mariozechner/pi-ai will
35
+ // return them automatically. We no longer maintain a hardcoded list.
36
+
37
+ // ── Types ───────────────────────────────────────────────────────────
38
+
39
+ export interface SubagentResult {
40
+ output: string;
41
+ usage: {
42
+ input: number;
43
+ output: number;
44
+ cacheRead: number;
45
+ cacheWrite: number;
46
+ cost: number;
47
+ turns: number;
48
+ };
49
+ model?: string;
50
+ isError: boolean;
51
+ errorMessage?: string;
52
+ }
53
+
54
+ export interface SubagentLiveStatus {
55
+ turn: number;
56
+ activeTool?: { name: string; args: Record<string, unknown> };
57
+ output: string;
58
+ usage: SubagentResult["usage"];
59
+ }
60
+
61
+ // ── Async Job Types ─────────────────────────────────────────────────
62
+
63
+ export type JobStatus = "running" | "done" | "error" | "cancelled";
64
+
65
+ /** Notification delivery mode for async subagent completion */
66
+ export type NotifyOnComplete = "notify" | "inject";
67
+
68
+ export interface JobState {
69
+ id: string;
70
+ status: JobStatus;
71
+ liveStatus: SubagentLiveStatus;
72
+ result?: SubagentResult;
73
+ session: AgentSession;
74
+ startedAt: number;
75
+ promise: Promise<SubagentResult>;
76
+ modelLabel?: string;
77
+ /** Notification mode requested by spawner's notifyOnComplete param */
78
+ notifyOnComplete?: NotifyOnComplete;
79
+ /** At-most-once delivery guard */
80
+ notificationDelivered?: boolean;
81
+ /** Set true by get_subagent_result to suppress redundant notification */
82
+ resultRetrieved?: boolean;
83
+ /** Optional TTL in ms for completed job retention */
84
+ maxAge?: number;
85
+ }
86
+
87
+ // ── Job Registry ────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Persisted job registry using global to survive module reloads (jiti).
91
+ *
92
+ * Lifecycle:
93
+ * - Jobs added on async subagent spawn
94
+ * - Completed/error jobs persist indefinitely (no TTL)
95
+ * - Cancelled jobs removed immediately
96
+ * - All jobs lost on Pi restart (in-memory only)
97
+ */
98
+
99
+ // Use 'global' for Node.js global, fall back to globalThis
100
+ const g = typeof global !== "undefined" ? global : globalThis;
101
+
102
+ // Create or reuse the registry on the global object
103
+ if (!g.__piSubagenturaRegistry) {
104
+ g.__piSubagenturaRegistry = new Map<string, JobState>();
105
+ }
106
+
107
+ export const jobRegistry = g.__piSubagenturaRegistry as Map<string, JobState>;
108
+
109
+ // Declare global piref for notification delivery (set by extension factory, read by delivery code)
110
+ declare global {
111
+ var __piSubagenturaPiRef: unknown; // ExtensionAPI ref — set in subagent.ts factory
112
+ }
113
+
114
+ // Initialize the global pi ref
115
+ if (!g.__piSubagenturaPiRef) {
116
+ g.__piSubagenturaPiRef = undefined;
117
+ }
118
+
119
+ /** Jobs persist indefinitely — no automatic expiration */
120
+ export const JOB_CLEANUP_TTL_MS = 0;
121
+
122
+ /** Maximum number of jobs to retain in the registry */
123
+ export const MAX_REGISTRY_SIZE = 100;
124
+
125
+ /** Remove the oldest completed or error job from the registry */
126
+ export function pruneOldestJob(): boolean {
127
+ for (const [jobId, job] of jobRegistry) {
128
+ if (job.status === "done" || job.status === "error") {
129
+ jobRegistry.delete(jobId);
130
+ return true;
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+
136
+ /** Remove all completed and error jobs from the registry. Returns count removed. */
137
+ export function pruneCompletedJobs(): number {
138
+ let removed = 0;
139
+ for (const [jobId, job] of jobRegistry) {
140
+ if (job.status === "done" || job.status === "error") {
141
+ jobRegistry.delete(jobId);
142
+ removed++;
143
+ }
144
+ }
145
+ return removed;
146
+ }
147
+
148
+ export function scheduleJobCleanup(
149
+ jobId: string,
150
+ immediate = false,
151
+ maxAge?: number,
152
+ ): void {
153
+ if (!immediate) {
154
+ if (maxAge && maxAge > 0) {
155
+ setTimeout(() => {
156
+ jobRegistry.delete(jobId);
157
+ }, maxAge);
158
+ }
159
+ return; // persist indefinitely unless maxAge specified
160
+ }
161
+ setTimeout(() => {
162
+ jobRegistry.delete(jobId);
163
+ }, 0);
164
+ }
165
+
166
+ /** Generate a unique job ID (16 hex chars from crypto.randomBytes) */
167
+ export function generateJobId(): string {
168
+ // Uses randomBytes for Node 18 compatibility (randomUUID needs Node 19+)
169
+ return randomBytes(8).toString("hex");
170
+ }
171
+
172
+ // ── Helpers ─────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Resolve a model from a string identifier and an optional default.
176
+ *
177
+ * The caller (LLM agent) is responsible for providing the correct model id.
178
+ * This function does NOT guess — it only does exact lookups:
179
+ * 1. undefined → defaultModel
180
+ * 2. "provider/id" format → exact getModel lookup
181
+ * 3. Bare id → exact getModel scan across all providers
182
+ * 4. Falls back to defaultModel when nothing matches
183
+ */
184
+ export function resolveModel(
185
+ modelId: string | undefined,
186
+ // @ts-expect-error — Model<TApi> requires type arg; unknown is a safe placeholder here
187
+ defaultModel: Model | undefined,
188
+ ) {
189
+ if (!modelId) return defaultModel;
190
+
191
+ // "provider/id" format — exact lookup only
192
+ if (modelId.includes("/")) {
193
+ const [provider, id] = modelId.split("/", 2);
194
+ // @ts-expect-error — getModel requires KnownProvider union; we trust the caller
195
+ return getModel(provider, id) ?? defaultModel;
196
+ }
197
+
198
+ // Bare id — exact match across all providers
199
+ for (const provider of getProviders()) {
200
+ // @ts-expect-error — KnownProvider cast needed; string is assignable to it at runtime
201
+ const found = getModel(provider, modelId);
202
+ if (found) return found;
203
+ }
204
+
205
+ return defaultModel;
206
+ }
207
+ export function formatTokens(count: number): string {
208
+ if (count < 1000) return count.toString();
209
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
210
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
211
+ return `${(count / 1000000).toFixed(1)}M`;
212
+ }
213
+
214
+ export function formatUsage(
215
+ u: SubagentResult["usage"],
216
+ model?: string,
217
+ ): string {
218
+ const parts: string[] = [];
219
+ if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
220
+ if (u.input) parts.push(`↑${formatTokens(u.input)}`);
221
+ if (u.output) parts.push(`↓${formatTokens(u.output)}`);
222
+ if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
223
+ if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
224
+ if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
225
+ if (model) parts.push(model);
226
+ return parts.join(" ");
227
+ }
228
+
229
+ export function buildLiveUpdate(
230
+ status: SubagentLiveStatus,
231
+ model?: string,
232
+ // @ts-expect-error — AgentToolResult<T> requires type arg; unknown is a safe placeholder
233
+ ): AgentToolResult {
234
+ return {
235
+ content: [{ type: "text", text: status.output }],
236
+ details: {
237
+ status: "running",
238
+ subagentStatus: status,
239
+ model,
240
+ },
241
+ };
242
+ }
243
+
244
+ // ── startSubagentJob ────────────────────────────────────────────────
245
+
246
+ export interface StartSubagentJobParams {
247
+ task: string;
248
+ persona: string | undefined;
249
+ modelOverride: string | undefined;
250
+ cwd: string;
251
+ contextText: string | null;
252
+ signal: AbortSignal | undefined;
253
+ // @ts-expect-error — AgentToolResult<T> requires type arg
254
+ onUpdate: ((partial: AgentToolResult) => void) | undefined;
255
+ // @ts-expect-error — Model<TApi> requires type arg
256
+ defaultModel: Model | undefined;
257
+ maxAge?: number;
258
+ }
259
+
260
+ export interface StartSubagentJobResult {
261
+ jobId: string;
262
+ jobPromise: Promise<SubagentResult>;
263
+ session: AgentSession;
264
+ liveStatus: SubagentLiveStatus;
265
+ modelLabel?: string;
266
+ }
267
+
268
+ /**
269
+ * Create a subagent session and start its prompt execution.
270
+ *
271
+ * Returns immediately with { jobId, jobPromise, session, liveStatus }.
272
+ * The jobPromise resolves to a SubagentResult when the subagent completes.
273
+ * The liveStatus object is mutated in real-time by the event subscriber.
274
+ *
275
+ * This is the shared core used by both sync (runSubagent) and async paths.
276
+ */
277
+ export async function startSubagentJob(
278
+ params: StartSubagentJobParams,
279
+ ): Promise<StartSubagentJobResult> {
280
+ const {
281
+ task,
282
+ persona,
283
+ modelOverride,
284
+ cwd,
285
+ contextText,
286
+ signal,
287
+ onUpdate,
288
+ defaultModel,
289
+ } = params;
290
+
291
+ // Enforce registry size cap before adding a new job
292
+ while (jobRegistry.size >= MAX_REGISTRY_SIZE) {
293
+ if (!pruneOldestJob()) break; // no old jobs to evict, allow slight overcap
294
+ }
295
+
296
+ const jobId = generateJobId();
297
+ const authStorage = AuthStorage.create();
298
+ const modelRegistry = ModelRegistry.create(authStorage);
299
+
300
+ // Resolve model: exact match only, fallback to default
301
+ const targetModel = resolveModel(modelOverride, defaultModel);
302
+ const modelLabel = targetModel
303
+ ? `${targetModel.provider}/${targetModel.id}`
304
+ : undefined;
305
+
306
+ let handleAbort: (() => void) | undefined;
307
+ let unsubscribe: (() => void) | undefined;
308
+
309
+ const liveStatus: SubagentLiveStatus = {
310
+ turn: 0,
311
+ output: "",
312
+ usage: {
313
+ input: 0,
314
+ output: 0,
315
+ cacheRead: 0,
316
+ cacheWrite: 0,
317
+ cost: 0,
318
+ turns: 0,
319
+ },
320
+ };
321
+
322
+ // Debounce activeTool updates to prevent flickering on fast tool calls.
323
+ // When onUpdate is undefined (async path), skip the debounce entirely —
324
+ // no rendering to flicker, and the timer overhead is wasted.
325
+ let activeToolTimer: ReturnType<typeof setTimeout> | undefined;
326
+ let pendingActiveTool: SubagentLiveStatus["activeTool"] = undefined;
327
+
328
+ function setActiveToolDebounced(tool: SubagentLiveStatus["activeTool"]) {
329
+ pendingActiveTool = tool;
330
+ if (activeToolTimer) {
331
+ clearTimeout(activeToolTimer);
332
+ activeToolTimer = undefined;
333
+ }
334
+ if (tool) {
335
+ if (!onUpdate) {
336
+ // Async path: no rendering, apply immediately
337
+ liveStatus.activeTool = tool;
338
+ return;
339
+ }
340
+ activeToolTimer = setTimeout(() => {
341
+ activeToolTimer = undefined;
342
+ liveStatus.activeTool = pendingActiveTool;
343
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
344
+ }, ACTIVE_TOOL_DEBOUNCE_MS);
345
+ } else {
346
+ if (liveStatus.activeTool) {
347
+ liveStatus.activeTool = undefined;
348
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
349
+ }
350
+ }
351
+ }
352
+
353
+ // Create session
354
+ const session = (
355
+ await createAgentSession({
356
+ sessionManager: SessionManager.inMemory(),
357
+ authStorage,
358
+ modelRegistry,
359
+ model: targetModel,
360
+ cwd,
361
+ })
362
+ ).session;
363
+
364
+ // Wire abort signal
365
+ if (signal) {
366
+ handleAbort = () => {
367
+ session.abort().catch(() => {});
368
+ };
369
+ if (signal.aborted) {
370
+ handleAbort();
371
+ } else {
372
+ signal.addEventListener("abort", handleAbort);
373
+ }
374
+ }
375
+
376
+ // Wire session events
377
+ unsubscribe = session.subscribe((event) => {
378
+ switch (event.type) {
379
+ case "turn_start": {
380
+ liveStatus.turn++;
381
+ liveStatus.usage.turns = liveStatus.turn;
382
+ liveStatus.output = "";
383
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
384
+ break;
385
+ }
386
+ case "tool_execution_start": {
387
+ setActiveToolDebounced({
388
+ name: event.toolName,
389
+ args: event.args as Record<string, unknown>,
390
+ });
391
+ break;
392
+ }
393
+ case "tool_execution_end": {
394
+ setActiveToolDebounced(undefined);
395
+ break;
396
+ }
397
+ case "turn_end": {
398
+ if (activeToolTimer) {
399
+ clearTimeout(activeToolTimer);
400
+ activeToolTimer = undefined;
401
+ }
402
+ liveStatus.activeTool = undefined;
403
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
404
+ break;
405
+ }
406
+ case "message_update": {
407
+ if (event.assistantMessageEvent.type === "text_delta") {
408
+ liveStatus.output += event.assistantMessageEvent.delta;
409
+ onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
410
+ }
411
+ break;
412
+ }
413
+ }
414
+ });
415
+
416
+ // Build prompt text
417
+ const personaPrefix = persona ? `${persona}\n\n` : "";
418
+ const finalPrompt = contextText
419
+ ? `${personaPrefix}You are a SEPARATE background sub-agent. Your ONLY job is the task below.\nThe conversation history above is CONTEXT ONLY — do NOT comment on it, do NOT role-play as the main assistant, do NOT describe the spawning process. Execute ONLY the task and return ONLY the result.\n\n## Conversation History (context only — do not respond to this)\n${contextText}\n\n## Your Task (respond ONLY to this)\n${task}`
420
+ : `${personaPrefix}Task: ${task}`;
421
+
422
+ // Launch the prompt in a promise chain (NOT awaited — returns immediately).
423
+ // The jobPromise represents the full lifecycle: prompt → extraction → cleanup.
424
+ const jobPromise = (async (): Promise<SubagentResult> => {
425
+ let result: SubagentResult;
426
+ try {
427
+ await session.prompt(finalPrompt);
428
+
429
+ // Extract final assistant output
430
+ const messages = session.agent.state.messages;
431
+ let finalOutput = liveStatus.output;
432
+ for (let i = messages.length - 1; i >= 0; i--) {
433
+ const msg = messages[i];
434
+ if (msg.role === "assistant") {
435
+ const textParts = msg.content
436
+ ?.filter(
437
+ (c: {
438
+ type: string;
439
+ text?: string;
440
+ }): c is { type: "text"; text: string } => c.type === "text",
441
+ )
442
+ .map((c) => c.text)
443
+ .join("\n");
444
+ if (textParts) {
445
+ finalOutput = textParts;
446
+ break;
447
+ }
448
+ }
449
+ }
450
+
451
+ const usage = {
452
+ input: 0,
453
+ output: 0,
454
+ cacheRead: 0,
455
+ cacheWrite: 0,
456
+ cost: 0,
457
+ turns: 0,
458
+ };
459
+ for (const msg of messages) {
460
+ if (msg.role === "assistant" && msg.usage) {
461
+ usage.turns++;
462
+ usage.input += msg.usage.input;
463
+ usage.output += msg.usage.output;
464
+ usage.cacheRead += msg.usage.cacheRead;
465
+ usage.cacheWrite += msg.usage.cacheWrite;
466
+ usage.cost += msg.usage.cost.total;
467
+ }
468
+ }
469
+
470
+ result = {
471
+ output: finalOutput || "(no output)",
472
+ usage,
473
+ model: session.model
474
+ ? `${session.model.provider}/${session.model.id}`
475
+ : undefined,
476
+ isError: !!session.agent.state.errorMessage,
477
+ errorMessage: session.agent.state.errorMessage,
478
+ };
479
+ } catch (err) {
480
+ const msg = err instanceof Error ? err.message : String(err);
481
+ result = {
482
+ output: `Sub-agent crashed: ${msg}`,
483
+ usage: {
484
+ input: 0,
485
+ output: 0,
486
+ cacheRead: 0,
487
+ cacheWrite: 0,
488
+ cost: 0,
489
+ turns: 0,
490
+ },
491
+ model: undefined,
492
+ isError: true,
493
+ errorMessage: msg,
494
+ };
495
+ } finally {
496
+ if (activeToolTimer) {
497
+ clearTimeout(activeToolTimer);
498
+ activeToolTimer = undefined;
499
+ }
500
+ if (signal && handleAbort)
501
+ signal.removeEventListener("abort", handleAbort);
502
+ if (unsubscribe) unsubscribe();
503
+ session?.dispose();
504
+ }
505
+ return result;
506
+ })();
507
+
508
+ return { jobId, jobPromise, session, liveStatus, modelLabel };
509
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagentura",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Public Pi package that adds in-process sub-agents via the SDK",
5
5
  "main": "subagent.ts",
6
6
  "type": "module",
@@ -24,11 +24,12 @@
24
24
  },
25
25
  "files": [
26
26
  "subagent.ts",
27
+ "helpers.ts",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
30
31
  "engines": {
31
- "bun": ">=1.0.0"
32
+ "node": ">=18.0.0"
32
33
  },
33
34
  "publishConfig": {
34
35
  "access": "public"
@@ -38,17 +39,22 @@
38
39
  "./subagent.ts"
39
40
  ]
40
41
  },
41
- "dependencies": {},
42
42
  "scripts": {
43
- "test": "bun test",
43
+ "test": "vitest run",
44
+ "typecheck": "tsc --noEmit",
44
45
  "pack:check": "npm pack --dry-run"
45
46
  },
46
47
  "peerDependencies": {
47
- "@mariozechner/pi-coding-agent": "*",
48
48
  "@mariozechner/pi-agent-core": "*",
49
49
  "@mariozechner/pi-ai": "*",
50
+ "@mariozechner/pi-coding-agent": "*",
50
51
  "@mariozechner/pi-tui": "*",
51
52
  "typebox": "*"
52
53
  },
53
- "packageManager": "bun@1.3.11"
54
+ "devDependencies": {
55
+ "@types/node": "^22.0.0",
56
+ "prettier": "^3.8.3",
57
+ "typescript": "^6.0.3",
58
+ "vitest": "^3.0.0"
59
+ }
54
60
  }