pi-subagentura 2.0.0 → 2.0.1
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/helpers.ts +660 -0
- package/package.json +2 -2
- package/subagent.ts +60 -11
package/helpers.ts
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
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 { appendFileSync, mkdirSync, existsSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import { getModel, getProviders } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
13
|
+
|
|
14
|
+
// Note: Model<TApi> and AgentToolResult<T> are SDK generics. We use `unknown` as
|
|
15
|
+
// the type argument to avoid strict generic instantiation issues with tsc.
|
|
16
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
AuthStorage,
|
|
20
|
+
createAgentSession,
|
|
21
|
+
ModelRegistry,
|
|
22
|
+
SessionManager,
|
|
23
|
+
type AgentSession,
|
|
24
|
+
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
|
|
26
|
+
// ── Debug Logging ─────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const DEBUG_LOG_DIR = process.env.SUBAGENT_DEBUG_LOG_DIR
|
|
29
|
+
? resolve(process.env.SUBAGENT_DEBUG_LOG_DIR)
|
|
30
|
+
: undefined;
|
|
31
|
+
|
|
32
|
+
export function debugLog(level: string, event: string, data: Record<string, unknown> = {}) {
|
|
33
|
+
if (!DEBUG_LOG_DIR) return;
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(DEBUG_LOG_DIR)) {
|
|
36
|
+
mkdirSync(DEBUG_LOG_DIR, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
const entry = JSON.stringify({
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
level,
|
|
41
|
+
event,
|
|
42
|
+
...data,
|
|
43
|
+
}) + "\n";
|
|
44
|
+
const fileName = resolve(DEBUG_LOG_DIR, `debug-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
45
|
+
appendFileSync(fileName, entry);
|
|
46
|
+
} catch {
|
|
47
|
+
// Silently fail to avoid polluting output
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function extractTextFromContent(content: unknown): string {
|
|
52
|
+
if (Array.isArray(content)) {
|
|
53
|
+
return content
|
|
54
|
+
.filter((c): c is { type: "text"; text: string } =>
|
|
55
|
+
typeof c === "object" && c !== null && c.type === "text" && typeof c.text === "string"
|
|
56
|
+
)
|
|
57
|
+
.map((c) => c.text)
|
|
58
|
+
.join("\n");
|
|
59
|
+
}
|
|
60
|
+
if (typeof content === "string") {
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
debugLog("warn", "unexpected_content_type", {
|
|
64
|
+
contentType: typeof content,
|
|
65
|
+
content: String(String(content).slice(0, 200)),
|
|
66
|
+
});
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Milliseconds to wait before showing activeTool in the live status preview.
|
|
74
|
+
* Prevents UI flicker for fast tool executions that start and end within this window.
|
|
75
|
+
*
|
|
76
|
+
* Note: If Pi adds new model providers, update KNOWN_PROVIDERS below.
|
|
77
|
+
*/
|
|
78
|
+
export const ACTIVE_TOOL_DEBOUNCE_MS = 150;
|
|
79
|
+
|
|
80
|
+
// Note: If Pi adds new providers, getProviders() from @earendil-works/pi-ai will
|
|
81
|
+
// return them automatically. We no longer maintain a hardcoded list.
|
|
82
|
+
|
|
83
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export interface SubagentResult {
|
|
86
|
+
output: string;
|
|
87
|
+
usage: {
|
|
88
|
+
input: number;
|
|
89
|
+
output: number;
|
|
90
|
+
cacheRead: number;
|
|
91
|
+
cacheWrite: number;
|
|
92
|
+
cost: number;
|
|
93
|
+
turns: number;
|
|
94
|
+
};
|
|
95
|
+
model?: string;
|
|
96
|
+
isError: boolean;
|
|
97
|
+
errorMessage?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SubagentLiveStatus {
|
|
101
|
+
turn: number;
|
|
102
|
+
activeTool?: { name: string; args: Record<string, unknown> };
|
|
103
|
+
output: string;
|
|
104
|
+
usage: SubagentResult["usage"];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Async Job Types ─────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export type JobStatus = "running" | "done" | "error" | "cancelled";
|
|
110
|
+
|
|
111
|
+
/** Notification delivery mode for async subagent completion */
|
|
112
|
+
export type NotifyOnComplete = "notify" | "inject";
|
|
113
|
+
|
|
114
|
+
export interface JobState {
|
|
115
|
+
id: string;
|
|
116
|
+
status: JobStatus;
|
|
117
|
+
liveStatus: SubagentLiveStatus;
|
|
118
|
+
result?: SubagentResult;
|
|
119
|
+
session: AgentSession;
|
|
120
|
+
startedAt: number;
|
|
121
|
+
promise: Promise<SubagentResult>;
|
|
122
|
+
modelLabel?: string;
|
|
123
|
+
/** Notification mode requested by spawner's notifyOnComplete param */
|
|
124
|
+
notifyOnComplete?: NotifyOnComplete;
|
|
125
|
+
/** At-most-once delivery guard */
|
|
126
|
+
notificationDelivered?: boolean;
|
|
127
|
+
/** Set true by get_subagent_result to suppress redundant notification */
|
|
128
|
+
resultRetrieved?: boolean;
|
|
129
|
+
/** Optional TTL in ms for completed job retention */
|
|
130
|
+
maxAge?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Job Registry ────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Persisted job registry using global to survive module reloads (jiti).
|
|
137
|
+
*
|
|
138
|
+
* Lifecycle:
|
|
139
|
+
* - Jobs added on async subagent spawn
|
|
140
|
+
* - Completed/error jobs persist indefinitely (no TTL)
|
|
141
|
+
* - Cancelled jobs removed immediately
|
|
142
|
+
* - All jobs lost on Pi restart (in-memory only)
|
|
143
|
+
*/
|
|
144
|
+
|
|
145
|
+
// Use 'global' for Node.js global, fall back to globalThis
|
|
146
|
+
const g = typeof global !== "undefined" ? global : globalThis;
|
|
147
|
+
|
|
148
|
+
// Create or reuse the registry on the global object
|
|
149
|
+
if (!g.__piSubagenturaRegistry) {
|
|
150
|
+
g.__piSubagenturaRegistry = new Map<string, JobState>();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const jobRegistry = g.__piSubagenturaRegistry as Map<string, JobState>;
|
|
154
|
+
|
|
155
|
+
// Declare global piref for notification delivery (set by extension factory, read by delivery code)
|
|
156
|
+
declare global {
|
|
157
|
+
var __piSubagenturaPiRef: unknown; // ExtensionAPI ref — set in subagent.ts factory
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Initialize the global pi ref
|
|
161
|
+
if (!g.__piSubagenturaPiRef) {
|
|
162
|
+
g.__piSubagenturaPiRef = undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Jobs persist indefinitely — no automatic expiration */
|
|
166
|
+
export const JOB_CLEANUP_TTL_MS = 0;
|
|
167
|
+
|
|
168
|
+
/** Maximum number of jobs to retain in the registry */
|
|
169
|
+
export const MAX_REGISTRY_SIZE = 100;
|
|
170
|
+
|
|
171
|
+
/** Remove the oldest completed or error job from the registry */
|
|
172
|
+
export function pruneOldestJob(): boolean {
|
|
173
|
+
for (const [jobId, job] of jobRegistry) {
|
|
174
|
+
if (job.status === "done" || job.status === "error") {
|
|
175
|
+
jobRegistry.delete(jobId);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Remove all completed and error jobs from the registry. Returns count removed. */
|
|
183
|
+
export function pruneCompletedJobs(): number {
|
|
184
|
+
let removed = 0;
|
|
185
|
+
for (const [jobId, job] of jobRegistry) {
|
|
186
|
+
if (job.status === "done" || job.status === "error") {
|
|
187
|
+
jobRegistry.delete(jobId);
|
|
188
|
+
removed++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return removed;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function scheduleJobCleanup(
|
|
195
|
+
jobId: string,
|
|
196
|
+
immediate = false,
|
|
197
|
+
maxAge?: number,
|
|
198
|
+
): void {
|
|
199
|
+
if (!immediate) {
|
|
200
|
+
if (maxAge && maxAge > 0) {
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
jobRegistry.delete(jobId);
|
|
203
|
+
}, maxAge);
|
|
204
|
+
}
|
|
205
|
+
return; // persist indefinitely unless maxAge specified
|
|
206
|
+
}
|
|
207
|
+
setTimeout(() => {
|
|
208
|
+
jobRegistry.delete(jobId);
|
|
209
|
+
}, 0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Generate a unique job ID (16 hex chars from crypto.randomBytes) */
|
|
213
|
+
export function generateJobId(): string {
|
|
214
|
+
// Uses randomBytes for Node 18 compatibility (randomUUID needs Node 19+)
|
|
215
|
+
return randomBytes(8).toString("hex");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a model from a string identifier and an optional default.
|
|
222
|
+
*
|
|
223
|
+
* The caller (LLM agent) is responsible for providing the correct model id.
|
|
224
|
+
* This function does NOT guess — it only does exact lookups:
|
|
225
|
+
* 1. undefined → defaultModel
|
|
226
|
+
* 2. Use parent modelRegistry (has extension-added models like minimax)
|
|
227
|
+
* 3. "provider/id" format → exact getModel lookup (global static registry)
|
|
228
|
+
* 4. Bare id → exact getModel scan across all providers (global static registry)
|
|
229
|
+
* 5. Falls back to defaultModel when nothing matches
|
|
230
|
+
*/
|
|
231
|
+
export function resolveModel(
|
|
232
|
+
modelId: string | undefined,
|
|
233
|
+
// @ts-expect-error — Model<TApi> requires type arg; unknown is a safe placeholder
|
|
234
|
+
defaultModel: Model | undefined,
|
|
235
|
+
parentModelRegistry?: ModelRegistry,
|
|
236
|
+
) {
|
|
237
|
+
if (!modelId) return defaultModel;
|
|
238
|
+
|
|
239
|
+
// Only exact matching — no fuzzy/substring guessing.
|
|
240
|
+
// The AI should call list_available_models and pick from the list.
|
|
241
|
+
if (parentModelRegistry) {
|
|
242
|
+
if (modelId.includes("/")) {
|
|
243
|
+
const [provider, id] = modelId.split("/", 2);
|
|
244
|
+
const exact = parentModelRegistry.find(provider, id);
|
|
245
|
+
if (exact) return exact as any;
|
|
246
|
+
} else {
|
|
247
|
+
// Bare id — search all models in parent registry
|
|
248
|
+
for (const m of parentModelRegistry.getAll()) {
|
|
249
|
+
if (m.id === modelId) return m as any;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Fall back to global static registry (built-in models only)
|
|
255
|
+
if (modelId.includes("/")) {
|
|
256
|
+
const [provider, id] = modelId.split("/", 2);
|
|
257
|
+
// @ts-expect-error — getModel requires KnownProvider union; we trust the caller
|
|
258
|
+
return getModel(provider, id) ?? defaultModel;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Bare id — exact match across all providers
|
|
262
|
+
for (const provider of getProviders()) {
|
|
263
|
+
// @ts-expect-error — KnownProvider cast needed; string is assignable to it at runtime
|
|
264
|
+
const found = getModel(provider, modelId);
|
|
265
|
+
if (found) return found;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return defaultModel;
|
|
269
|
+
}
|
|
270
|
+
export function formatTokens(count: number): string {
|
|
271
|
+
if (count < 1000) return count.toString();
|
|
272
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
273
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
274
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function formatUsage(
|
|
278
|
+
u: SubagentResult["usage"],
|
|
279
|
+
model?: string,
|
|
280
|
+
): string {
|
|
281
|
+
const parts: string[] = [];
|
|
282
|
+
if (u.turns) parts.push(`${u.turns} turn${u.turns > 1 ? "s" : ""}`);
|
|
283
|
+
if (u.input) parts.push(`↑${formatTokens(u.input)}`);
|
|
284
|
+
if (u.output) parts.push(`↓${formatTokens(u.output)}`);
|
|
285
|
+
if (u.cacheRead) parts.push(`R${formatTokens(u.cacheRead)}`);
|
|
286
|
+
if (u.cacheWrite) parts.push(`W${formatTokens(u.cacheWrite)}`);
|
|
287
|
+
if (u.cost) parts.push(`$${u.cost.toFixed(4)}`);
|
|
288
|
+
if (model) parts.push(model);
|
|
289
|
+
return parts.join(" ");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function buildLiveUpdate(
|
|
293
|
+
status: SubagentLiveStatus,
|
|
294
|
+
model?: string,
|
|
295
|
+
// @ts-expect-error — AgentToolResult<T> requires type arg; unknown is a safe placeholder
|
|
296
|
+
): AgentToolResult {
|
|
297
|
+
return {
|
|
298
|
+
content: [{ type: "text", text: status.output }],
|
|
299
|
+
details: {
|
|
300
|
+
status: "running",
|
|
301
|
+
subagentStatus: status,
|
|
302
|
+
model,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── startSubagentJob ────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
export interface StartSubagentJobParams {
|
|
310
|
+
task: string;
|
|
311
|
+
persona: string | undefined;
|
|
312
|
+
modelOverride: string | undefined;
|
|
313
|
+
cwd: string;
|
|
314
|
+
contextText: string | null;
|
|
315
|
+
signal: AbortSignal | undefined;
|
|
316
|
+
// @ts-expect-error — AgentToolResult<T> requires type arg
|
|
317
|
+
onUpdate: ((partial: AgentToolResult) => void) | undefined;
|
|
318
|
+
// @ts-expect-error — Model<TApi> requires type arg
|
|
319
|
+
defaultModel: Model | undefined;
|
|
320
|
+
maxAge?: number;
|
|
321
|
+
/** Parent session's model registry for resolving extension-added models (e.g. minimax) */
|
|
322
|
+
parentModelRegistry?: ModelRegistry;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface StartSubagentJobResult {
|
|
326
|
+
jobId: string;
|
|
327
|
+
jobPromise: Promise<SubagentResult>;
|
|
328
|
+
session: AgentSession;
|
|
329
|
+
liveStatus: SubagentLiveStatus;
|
|
330
|
+
modelLabel?: string;
|
|
331
|
+
/** Warning when modelOverride was specified but not found — lists available models */
|
|
332
|
+
modelWarning?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Create a subagent session and start its prompt execution.
|
|
337
|
+
*
|
|
338
|
+
* Returns immediately with { jobId, jobPromise, session, liveStatus }.
|
|
339
|
+
* The jobPromise resolves to a SubagentResult when the subagent completes.
|
|
340
|
+
* The liveStatus object is mutated in real-time by the event subscriber.
|
|
341
|
+
*
|
|
342
|
+
* This is the shared core used by both sync (runSubagent) and async paths.
|
|
343
|
+
*/
|
|
344
|
+
export async function startSubagentJob(
|
|
345
|
+
params: StartSubagentJobParams,
|
|
346
|
+
): Promise<StartSubagentJobResult> {
|
|
347
|
+
const {
|
|
348
|
+
task,
|
|
349
|
+
persona,
|
|
350
|
+
modelOverride,
|
|
351
|
+
cwd,
|
|
352
|
+
contextText,
|
|
353
|
+
signal,
|
|
354
|
+
onUpdate,
|
|
355
|
+
defaultModel,
|
|
356
|
+
parentModelRegistry,
|
|
357
|
+
} = params;
|
|
358
|
+
|
|
359
|
+
// Enforce registry size cap before adding a new job
|
|
360
|
+
while (jobRegistry.size >= MAX_REGISTRY_SIZE) {
|
|
361
|
+
if (!pruneOldestJob()) break; // no old jobs to evict, allow slight overcap
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const jobId = generateJobId();
|
|
365
|
+
const authStorage = AuthStorage.create();
|
|
366
|
+
const modelRegistry = ModelRegistry.create(authStorage);
|
|
367
|
+
|
|
368
|
+
// Resolve model: exact match only, fallback to default
|
|
369
|
+
// Uses parent's modelRegistry to find extension-added models (e.g. minimax)
|
|
370
|
+
const targetModel = resolveModel(modelOverride, defaultModel, parentModelRegistry);
|
|
371
|
+
const modelLabel = targetModel
|
|
372
|
+
? `${targetModel.provider}/${targetModel.id}`
|
|
373
|
+
: undefined;
|
|
374
|
+
|
|
375
|
+
// Build model warning when override was specified (helps AI discover valid models)
|
|
376
|
+
let modelWarning: string | undefined;
|
|
377
|
+
if (modelOverride && parentModelRegistry) {
|
|
378
|
+
const available = parentModelRegistry.getAvailable();
|
|
379
|
+
const modelList = available
|
|
380
|
+
.map((m) => ` ${m.provider}/${m.id}${m.name ? ` (${m.name})` : ""}`)
|
|
381
|
+
.join("\n");
|
|
382
|
+
modelWarning =
|
|
383
|
+
`Requested model "${modelOverride}" resolved to ${modelLabel ?? "none"}. ` +
|
|
384
|
+
`Available models:\n${modelList || " (none)"}\n` +
|
|
385
|
+
`Use list_available_models to discover more.`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let handleAbort: (() => void) | undefined;
|
|
389
|
+
let unsubscribe: (() => void) | undefined;
|
|
390
|
+
|
|
391
|
+
const liveStatus: SubagentLiveStatus = {
|
|
392
|
+
turn: 0,
|
|
393
|
+
output: "",
|
|
394
|
+
usage: {
|
|
395
|
+
input: 0,
|
|
396
|
+
output: 0,
|
|
397
|
+
cacheRead: 0,
|
|
398
|
+
cacheWrite: 0,
|
|
399
|
+
cost: 0,
|
|
400
|
+
turns: 0,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Debounce activeTool updates to prevent flickering on fast tool calls.
|
|
405
|
+
// When onUpdate is undefined (async path), skip the debounce entirely —
|
|
406
|
+
// no rendering to flicker, and the timer overhead is wasted.
|
|
407
|
+
let activeToolTimer: ReturnType<typeof setTimeout> | undefined;
|
|
408
|
+
let pendingActiveTool: SubagentLiveStatus["activeTool"] = undefined;
|
|
409
|
+
|
|
410
|
+
function setActiveToolDebounced(tool: SubagentLiveStatus["activeTool"]) {
|
|
411
|
+
pendingActiveTool = tool;
|
|
412
|
+
if (activeToolTimer) {
|
|
413
|
+
clearTimeout(activeToolTimer);
|
|
414
|
+
activeToolTimer = undefined;
|
|
415
|
+
}
|
|
416
|
+
if (tool) {
|
|
417
|
+
if (!onUpdate) {
|
|
418
|
+
// Async path: no rendering, apply immediately
|
|
419
|
+
liveStatus.activeTool = tool;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
activeToolTimer = setTimeout(() => {
|
|
423
|
+
activeToolTimer = undefined;
|
|
424
|
+
liveStatus.activeTool = pendingActiveTool;
|
|
425
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
426
|
+
}, ACTIVE_TOOL_DEBOUNCE_MS);
|
|
427
|
+
} else {
|
|
428
|
+
if (liveStatus.activeTool) {
|
|
429
|
+
liveStatus.activeTool = undefined;
|
|
430
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Create session
|
|
436
|
+
debugLog("info", "session_creating", {
|
|
437
|
+
jobId,
|
|
438
|
+
model: modelLabel ?? "default",
|
|
439
|
+
cwd,
|
|
440
|
+
});
|
|
441
|
+
const session = (
|
|
442
|
+
await createAgentSession({
|
|
443
|
+
sessionManager: SessionManager.inMemory(),
|
|
444
|
+
authStorage,
|
|
445
|
+
modelRegistry,
|
|
446
|
+
model: targetModel,
|
|
447
|
+
cwd,
|
|
448
|
+
})
|
|
449
|
+
).session;
|
|
450
|
+
debugLog("info", "session_created", {
|
|
451
|
+
jobId,
|
|
452
|
+
sessionModel: session.model ? `${session.model.provider}/${session.model.id}` : null,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// Wire abort signal
|
|
456
|
+
if (signal) {
|
|
457
|
+
handleAbort = () => {
|
|
458
|
+
debugLog("warn", "job_abort", { jobId });
|
|
459
|
+
session.abort().catch(() => {});
|
|
460
|
+
};
|
|
461
|
+
if (signal.aborted) {
|
|
462
|
+
handleAbort();
|
|
463
|
+
} else {
|
|
464
|
+
signal.addEventListener("abort", handleAbort);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Wire session events
|
|
469
|
+
unsubscribe = session.subscribe((event) => {
|
|
470
|
+
switch (event.type) {
|
|
471
|
+
case "turn_start": {
|
|
472
|
+
liveStatus.turn++;
|
|
473
|
+
liveStatus.usage.turns = liveStatus.turn;
|
|
474
|
+
liveStatus.output = "";
|
|
475
|
+
debugLog("info", "turn_start", { jobId, turn: liveStatus.turn });
|
|
476
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
case "tool_execution_start": {
|
|
480
|
+
debugLog("info", "tool_start", {
|
|
481
|
+
jobId,
|
|
482
|
+
toolName: event.toolName,
|
|
483
|
+
args: event.args as Record<string, unknown>,
|
|
484
|
+
});
|
|
485
|
+
setActiveToolDebounced({
|
|
486
|
+
name: event.toolName,
|
|
487
|
+
args: event.args as Record<string, unknown>,
|
|
488
|
+
});
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "tool_execution_end": {
|
|
492
|
+
debugLog("info", "tool_end", { jobId, toolName: liveStatus.activeTool?.name });
|
|
493
|
+
setActiveToolDebounced(undefined);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case "turn_end": {
|
|
497
|
+
debugLog("info", "turn_end", {
|
|
498
|
+
jobId,
|
|
499
|
+
turn: liveStatus.turn,
|
|
500
|
+
outputLength: liveStatus.output.length,
|
|
501
|
+
activeTool: liveStatus.activeTool?.name ?? null,
|
|
502
|
+
});
|
|
503
|
+
if (activeToolTimer) {
|
|
504
|
+
clearTimeout(activeToolTimer);
|
|
505
|
+
activeToolTimer = undefined;
|
|
506
|
+
}
|
|
507
|
+
liveStatus.activeTool = undefined;
|
|
508
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case "message_update": {
|
|
512
|
+
const evt = event.assistantMessageEvent;
|
|
513
|
+
debugLog("info", "message_update", {
|
|
514
|
+
jobId,
|
|
515
|
+
updateType: evt.type,
|
|
516
|
+
...(evt.type === "text_delta" && {
|
|
517
|
+
delta: evt.delta.slice(0, 200),
|
|
518
|
+
outputLength: liveStatus.output.length,
|
|
519
|
+
}),
|
|
520
|
+
...(evt.type === "thinking_delta" && { delta: evt.delta.slice(0, 200) }),
|
|
521
|
+
...(evt.type === "toolcall_delta" && { partial: String(evt.partial).slice(0, 200) }),
|
|
522
|
+
...(evt.type === "toolcall_end" && { toolCallId: evt.toolCall?.id }),
|
|
523
|
+
});
|
|
524
|
+
if (evt.type === "text_delta") {
|
|
525
|
+
liveStatus.output += evt.delta;
|
|
526
|
+
onUpdate?.(buildLiveUpdate(liveStatus, modelLabel));
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// Build prompt text
|
|
534
|
+
const personaPrefix = persona ? `${persona}\n\n` : "";
|
|
535
|
+
const finalPrompt = contextText
|
|
536
|
+
? `${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}`
|
|
537
|
+
: `${personaPrefix}Task: ${task}`;
|
|
538
|
+
|
|
539
|
+
debugLog("info", "prompt_built", {
|
|
540
|
+
jobId,
|
|
541
|
+
hasContext: !!contextText,
|
|
542
|
+
contextLength: contextText?.length ?? 0,
|
|
543
|
+
taskLength: task.length,
|
|
544
|
+
persona: persona ?? null,
|
|
545
|
+
promptPreview: finalPrompt.slice(0, 500),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// Launch the prompt in a promise chain (NOT awaited — returns immediately).
|
|
549
|
+
// The jobPromise represents the full lifecycle: prompt → extraction → cleanup.
|
|
550
|
+
const jobPromise = (async (): Promise<SubagentResult> => {
|
|
551
|
+
let result: SubagentResult;
|
|
552
|
+
try {
|
|
553
|
+
debugLog("info", "prompt_start", { jobId });
|
|
554
|
+
await session.prompt(finalPrompt);
|
|
555
|
+
debugLog("info", "prompt_complete", { jobId });
|
|
556
|
+
|
|
557
|
+
// Extract final assistant output
|
|
558
|
+
const messages = session.agent.state.messages;
|
|
559
|
+
debugLog("info", "messages_extracted", {
|
|
560
|
+
jobId,
|
|
561
|
+
messageCount: messages.length,
|
|
562
|
+
messageRoles: messages.map((m) => m.role),
|
|
563
|
+
lastMessageContentType: typeof (messages[messages.length - 1] as any)?.content,
|
|
564
|
+
lastMessageContentIsArray: Array.isArray((messages[messages.length - 1] as any)?.content),
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
let finalOutput = liveStatus.output;
|
|
568
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
569
|
+
const msg = messages[i];
|
|
570
|
+
debugLog("info", "message_check", {
|
|
571
|
+
jobId,
|
|
572
|
+
index: i,
|
|
573
|
+
role: msg.role,
|
|
574
|
+
contentType: typeof (msg as any).content,
|
|
575
|
+
contentIsArray: Array.isArray((msg as any).content),
|
|
576
|
+
});
|
|
577
|
+
if (msg.role === "assistant") {
|
|
578
|
+
const textParts = extractTextFromContent(msg.content);
|
|
579
|
+
if (textParts) {
|
|
580
|
+
finalOutput = textParts;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const usage = {
|
|
587
|
+
input: 0,
|
|
588
|
+
output: 0,
|
|
589
|
+
cacheRead: 0,
|
|
590
|
+
cacheWrite: 0,
|
|
591
|
+
cost: 0,
|
|
592
|
+
turns: 0,
|
|
593
|
+
};
|
|
594
|
+
for (const msg of messages) {
|
|
595
|
+
if (msg.role === "assistant" && msg.usage) {
|
|
596
|
+
usage.turns++;
|
|
597
|
+
usage.input += msg.usage.input;
|
|
598
|
+
usage.output += msg.usage.output;
|
|
599
|
+
usage.cacheRead += msg.usage.cacheRead;
|
|
600
|
+
usage.cacheWrite += msg.usage.cacheWrite;
|
|
601
|
+
usage.cost += msg.usage.cost.total;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
result = {
|
|
606
|
+
output: finalOutput || "(no output)",
|
|
607
|
+
usage,
|
|
608
|
+
model: session.model
|
|
609
|
+
? `${session.model.provider}/${session.model.id}`
|
|
610
|
+
: undefined,
|
|
611
|
+
isError: !!session.agent.state.errorMessage,
|
|
612
|
+
errorMessage: session.agent.state.errorMessage,
|
|
613
|
+
};
|
|
614
|
+
} catch (err) {
|
|
615
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
616
|
+
const stack = err instanceof Error ? err.stack : undefined;
|
|
617
|
+
debugLog("error", "subagent_error", {
|
|
618
|
+
jobId,
|
|
619
|
+
error: msg,
|
|
620
|
+
stack: stack ?? null,
|
|
621
|
+
errorName: err instanceof Error ? err.name : typeof err,
|
|
622
|
+
});
|
|
623
|
+
result = {
|
|
624
|
+
output: `Sub-agent crashed: ${msg}`,
|
|
625
|
+
usage: {
|
|
626
|
+
input: 0,
|
|
627
|
+
output: 0,
|
|
628
|
+
cacheRead: 0,
|
|
629
|
+
cacheWrite: 0,
|
|
630
|
+
cost: 0,
|
|
631
|
+
turns: 0,
|
|
632
|
+
},
|
|
633
|
+
model: undefined,
|
|
634
|
+
isError: true,
|
|
635
|
+
errorMessage: msg,
|
|
636
|
+
};
|
|
637
|
+
} finally {
|
|
638
|
+
debugLog("info", "job_complete", {
|
|
639
|
+
jobId,
|
|
640
|
+
outputLength: result.output.length,
|
|
641
|
+
output: result.output.slice(0, 200),
|
|
642
|
+
isError: result.isError,
|
|
643
|
+
errorMessage: result.errorMessage ?? null,
|
|
644
|
+
usage: result.usage,
|
|
645
|
+
});
|
|
646
|
+
if (activeToolTimer) {
|
|
647
|
+
clearTimeout(activeToolTimer);
|
|
648
|
+
activeToolTimer = undefined;
|
|
649
|
+
}
|
|
650
|
+
if (signal && handleAbort)
|
|
651
|
+
signal.removeEventListener("abort", handleAbort);
|
|
652
|
+
if (unsubscribe) unsubscribe();
|
|
653
|
+
session?.dispose();
|
|
654
|
+
debugLog("info", "session_disposed", { jobId });
|
|
655
|
+
}
|
|
656
|
+
return result;
|
|
657
|
+
})();
|
|
658
|
+
|
|
659
|
+
return { jobId, jobPromise, session, liveStatus, modelLabel, modelWarning };
|
|
660
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-subagentura",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Public Pi package that adds in-process sub-agents via the SDK",
|
|
5
5
|
"main": "subagent.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"interactive-tmux.ts",
|
|
28
28
|
"artifact.ts",
|
|
29
29
|
"subagent-artifact-cli.ts",
|
|
30
|
-
"
|
|
30
|
+
"helpers.ts",
|
|
31
31
|
"LICENSE"
|
|
32
32
|
],
|
|
33
33
|
"engines": {
|
package/subagent.ts
CHANGED
|
@@ -546,6 +546,10 @@ export function pollArtifactChanges(pi: ExtensionAPI): void {
|
|
|
546
546
|
|
|
547
547
|
/** Tail-read the child's session JSONL and append `tool_activity` events to events.ndjson.
|
|
548
548
|
* Updates `state.lastDeliveredSessionByte` so subsequent ticks re-read only new lines. */
|
|
549
|
+
/** Hard cap on the per-tick read window. Session JSONL files can grow quickly
|
|
550
|
+
* in a long-running sub-agent, so we never allocate more than this in a single
|
|
551
|
+
* tailRead call. 1 MiB is plenty for many thousands of typical entries. */
|
|
552
|
+
const MAX_SESSION_READ_BYTES = 1 * 1024 * 1024;
|
|
549
553
|
function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifact): void {
|
|
550
554
|
const sessionFile = state.sessionFile;
|
|
551
555
|
if (!sessionFile) return;
|
|
@@ -560,6 +564,11 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
|
|
|
560
564
|
const cursor = state.lastDeliveredSessionByte ?? 0;
|
|
561
565
|
if (size <= cursor) return;
|
|
562
566
|
|
|
567
|
+
// Cap the per-tick read so a runaway file can't trigger an unbounded Buffer.alloc.
|
|
568
|
+
const requested = size - cursor;
|
|
569
|
+
const toRead = Math.min(requested, MAX_SESSION_READ_BYTES);
|
|
570
|
+
if (toRead <= 0) return;
|
|
571
|
+
|
|
563
572
|
let fd: number;
|
|
564
573
|
try {
|
|
565
574
|
fd = openSync(sessionFile, "r");
|
|
@@ -567,17 +576,25 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
|
|
|
567
576
|
return;
|
|
568
577
|
}
|
|
569
578
|
try {
|
|
570
|
-
const
|
|
571
|
-
const buf = Buffer.alloc(len);
|
|
579
|
+
const buf = Buffer.alloc(toRead);
|
|
572
580
|
let bytesRead = 0;
|
|
573
|
-
while (bytesRead <
|
|
574
|
-
const n = readSync(fd, buf, bytesRead,
|
|
581
|
+
while (bytesRead < toRead) {
|
|
582
|
+
const n = readSync(fd, buf, bytesRead, toRead - bytesRead, cursor + bytesRead);
|
|
575
583
|
if (n <= 0) break;
|
|
576
584
|
bytesRead += n;
|
|
577
585
|
}
|
|
578
586
|
const chunk = buf.subarray(0, bytesRead).toString("utf8");
|
|
579
587
|
processSessionLogChunk(state, art, chunk);
|
|
580
|
-
|
|
588
|
+
// Only advance the cursor to the end of the LAST complete line in the chunk.
|
|
589
|
+
// If the chunk ends mid-line (partial trailing JSONL), the partial must be
|
|
590
|
+
// re-read on the next tick after the child finishes writing it. Advancing the
|
|
591
|
+
// cursor past the partial would silently drop bytes and corrupt the event log.
|
|
592
|
+
const endOfComplete = chunk.lastIndexOf("\n");
|
|
593
|
+
if (endOfComplete >= 0) {
|
|
594
|
+
state.lastDeliveredSessionByte = cursor + endOfComplete + 1;
|
|
595
|
+
}
|
|
596
|
+
// If no newline in chunk and we hit the cap, leave the cursor where it was:
|
|
597
|
+
// the child is still mid-line; we'll re-read from the same offset next tick.
|
|
581
598
|
} finally {
|
|
582
599
|
try { require("node:fs").closeSync(fd); } catch {}
|
|
583
600
|
}
|
|
@@ -734,7 +751,15 @@ function labelFor(event: SubagentEvent): string {
|
|
|
734
751
|
* default artifacts root (PI_CODING_AGENT_SESSION_DIR or ~/.pi/agent/sessions/subagentura).
|
|
735
752
|
* For v1 this is a best-effort lookup; a future iteration can track all artifact roots.
|
|
736
753
|
*/
|
|
737
|
-
function findArtifactById(id: string): SubagentArtifact | null {
|
|
754
|
+
export function findArtifactById(id: string): SubagentArtifact | null {
|
|
755
|
+
// Sub-agent ids are randomBytes(4).toString("hex") at spawn time, i.e. 8 hex
|
|
756
|
+
// chars. Validate the id before joining it into a path so that an
|
|
757
|
+
// LLM-supplied id like "../../../etc" can't escape the artifact root
|
|
758
|
+
// (path.join normalises "..", so a malicious id would otherwise resolve
|
|
759
|
+
// to a sibling directory and get exfiltrated to the parent LLM via
|
|
760
|
+
// read_subagent_artifact).
|
|
761
|
+
if (!/^[a-f0-9]{8}$/.test(id)) return null;
|
|
762
|
+
|
|
738
763
|
const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi", "agent", "sessions");
|
|
739
764
|
let topLevel: string[];
|
|
740
765
|
try {
|
|
@@ -754,6 +779,7 @@ function findArtifactById(id: string): SubagentArtifact | null {
|
|
|
754
779
|
}
|
|
755
780
|
return null;
|
|
756
781
|
}
|
|
782
|
+
|
|
757
783
|
/** Sanitize a string by redacting common sensitive patterns (API keys, tokens, JWTs). */
|
|
758
784
|
function sanitizeOutput(text: string): string {
|
|
759
785
|
return text.replace(
|
|
@@ -830,10 +856,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
830
856
|
// every running interactive sub-agent and fires pointer notifications for new events.
|
|
831
857
|
// The poller survives parent restarts (artifacts on disk + per-state lastDeliveredEventTs).
|
|
832
858
|
if (!g2.__piSubagenturaInteractivePollerHandle) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
);
|
|
859
|
+
const handle = setInterval(() => pollArtifactChanges(pi), 5000);
|
|
860
|
+
// Don't pin the event loop on a long-lived parent. unref() lets the process exit
|
|
861
|
+
// cleanly when nothing else is keeping it alive (no other ref'd handles).
|
|
862
|
+
handle.unref?.();
|
|
863
|
+
g2.__piSubagenturaInteractivePollerHandle = handle;
|
|
837
864
|
}
|
|
838
865
|
// ── Tool 1: inherits conversation history ────────────────────────
|
|
839
866
|
pi.registerTool({
|
|
@@ -1987,10 +2014,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
1987
2014
|
},
|
|
1988
2015
|
});
|
|
1989
2016
|
|
|
1990
|
-
// ── Session shutdown: abort all jobs
|
|
2017
|
+
// ── Session shutdown: abort all jobs, kill tmux panes, stop the poller ─
|
|
1991
2018
|
(pi as any).on?.("session_shutdown", () => {
|
|
1992
2019
|
const g2 = typeof global !== "undefined" ? global : globalThis;
|
|
1993
2020
|
|
|
2021
|
+
// Stop the global poller so it doesn't fire after we're gone. Without
|
|
2022
|
+
// clearInterval the handle would keep the event loop alive across restarts.
|
|
2023
|
+
if (g2.__piSubagenturaInteractivePollerHandle) {
|
|
2024
|
+
try {
|
|
2025
|
+
clearInterval(g2.__piSubagenturaInteractivePollerHandle);
|
|
2026
|
+
} catch { /* defensive */ }
|
|
2027
|
+
g2.__piSubagenturaInteractivePollerHandle = undefined;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Kill any tmux panes backing live interactive sub-agents. We can't leave them
|
|
2031
|
+
// running — the parent process is shutting down. cancelInteractiveSubagent
|
|
2032
|
+
// does the right thing (writes .cancelled, kills pane, lets trap record the event).
|
|
2033
|
+
try {
|
|
2034
|
+
for (const state of interactiveSubagentRegistry.values()) {
|
|
2035
|
+
if (state.status === "running") {
|
|
2036
|
+
try {
|
|
2037
|
+
cancelInteractiveSubagent(state.id);
|
|
2038
|
+
} catch { /* best effort */ }
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
} catch { /* best effort */ }
|
|
2042
|
+
|
|
1994
2043
|
// Abort all running subagent sessions before clearing
|
|
1995
2044
|
for (const job of jobRegistry.values()) {
|
|
1996
2045
|
if (job.status === "running") {
|