pi-subagentura 2.0.0 → 2.0.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.
- package/artifact.ts +15 -9
- package/helpers.ts +660 -0
- package/package.json +8 -3
- package/subagent.ts +194 -53
package/artifact.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
+
import ndjson from "ndjson";
|
|
21
22
|
|
|
22
23
|
// ── Types ───────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -87,6 +88,11 @@ export function writeOutput(art: SubagentArtifact, content: string): void {
|
|
|
87
88
|
* ts >= since are returned. Malformed lines are silently skipped (the
|
|
88
89
|
* sub-agent CLI is the only writer, but a partial write could in theory
|
|
89
90
|
* leave a truncated line).
|
|
91
|
+
*
|
|
92
|
+
* Uses the `ndjson` library with `strict: false` so a single bad line does not abort the whole
|
|
93
|
+
* file — ndjson drops the bad row and continues with the rest. Any trailing partial line (file
|
|
94
|
+
* did not end with a newline) is buffered by the parser and dropped on `end()`; it is treated as a
|
|
95
|
+
* in-progress write that the next reader will pick up once completed.
|
|
90
96
|
*/
|
|
91
97
|
export function readEvents(art: SubagentArtifact, since?: number): SubagentEvent[] {
|
|
92
98
|
if (!existsSync(art.statusFile)) return [];
|
|
@@ -96,16 +102,16 @@ export function readEvents(art: SubagentArtifact, since?: number): SubagentEvent
|
|
|
96
102
|
} catch {
|
|
97
103
|
return [];
|
|
98
104
|
}
|
|
105
|
+
const parser = ndjson.parse({ strict: false });
|
|
99
106
|
const events: SubagentEvent[] = [];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
107
|
+
parser.on("data", (obj: unknown) => {
|
|
108
|
+
const ev = obj as SubagentEvent;
|
|
109
|
+
if (since === undefined || ev.ts >= since) events.push(ev);
|
|
110
|
+
});
|
|
111
|
+
// Non-strict mode never emits 'error' for bad JSON; attach a no-op so an unhandled error event
|
|
112
|
+
// can never crash the parent process.
|
|
113
|
+
parser.on("error", () => {});
|
|
114
|
+
parser.end(Buffer.from(content, "utf8"));
|
|
109
115
|
return events;
|
|
110
116
|
}
|
|
111
117
|
|
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,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-subagentura",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Public Pi package that adds in-process sub-agents via the SDK",
|
|
5
|
+
"author": "lmn451",
|
|
6
|
+
"license": "MIT",
|
|
5
7
|
"main": "subagent.ts",
|
|
6
8
|
"type": "module",
|
|
7
9
|
"keywords": [
|
|
@@ -13,7 +15,6 @@
|
|
|
13
15
|
"swarm",
|
|
14
16
|
"crew"
|
|
15
17
|
],
|
|
16
|
-
"license": "MIT",
|
|
17
18
|
"repository": {
|
|
18
19
|
"type": "git",
|
|
19
20
|
"url": "https://github.com/lmn451/pi-subagentura"
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"interactive-tmux.ts",
|
|
28
29
|
"artifact.ts",
|
|
29
30
|
"subagent-artifact-cli.ts",
|
|
30
|
-
"
|
|
31
|
+
"helpers.ts",
|
|
31
32
|
"LICENSE"
|
|
32
33
|
],
|
|
33
34
|
"engines": {
|
|
@@ -58,5 +59,9 @@
|
|
|
58
59
|
"prettier": "^3.8.3",
|
|
59
60
|
"typescript": "^6.0.3",
|
|
60
61
|
"vitest": "^3.0.0"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"is-path-inside": "^4.0.0",
|
|
65
|
+
"ndjson": "^2.0.0"
|
|
61
66
|
}
|
|
62
67
|
}
|
package/subagent.ts
CHANGED
|
@@ -59,12 +59,12 @@ import {
|
|
|
59
59
|
} from "./interactive-tmux";
|
|
60
60
|
import { appendEvent, artifactPath, lastEvent, readEvents, readOutput, type SubagentArtifact, type SubagentEvent } from "./artifact";
|
|
61
61
|
|
|
62
|
-
import { openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
62
|
+
import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from "node:fs";
|
|
63
63
|
import { homedir } from "node:os";
|
|
64
64
|
import { basename, dirname, join } from "node:path";
|
|
65
65
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
66
66
|
import { Type } from "typebox";
|
|
67
|
-
|
|
67
|
+
import ndjson from "ndjson";
|
|
68
68
|
// ── Footer Status Key ─────────────────────────────────────────────────────────────────────
|
|
69
69
|
const FOOTER_KEY = "subagentura-running";
|
|
70
70
|
const WIDGET_KEY = "subagentura-activity";
|
|
@@ -544,9 +544,69 @@ export function pollArtifactChanges(pi: ExtensionAPI): void {
|
|
|
544
544
|
}
|
|
545
545
|
}
|
|
546
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Per-state ndjson parser instance used to tail-read the child's session JSONL.
|
|
549
|
+
*
|
|
550
|
+
* The parser buffers partial trailing lines internally (via split2 underneath), so we can
|
|
551
|
+
* safely write raw bytes from the file on every poll and let the parser emit complete JSON
|
|
552
|
+
* objects as 'data' events. This replaces a hand-rolled partial-line + cursor scheme that had
|
|
553
|
+
* three latent bugs:
|
|
554
|
+
* - A 1 MiB per-tick read cap combined with cursor-pinning on a missing newline caused a
|
|
555
|
+
* permanent re-read loop on any single JSONL line larger than 1 MiB (e.g. a multi-MB tool
|
|
556
|
+
* call result that the child pi runtime writes as a single line).
|
|
557
|
+
* - File truncation left the cursor pointing past EOF, silently dropping any post-truncation
|
|
558
|
+
* content.
|
|
559
|
+
* - A `require("node:fs").closeSync(fd)` call in the finally block leaked file descriptors on
|
|
560
|
+
* Node < 22.12 in some bundling paths.
|
|
561
|
+
*
|
|
562
|
+
* Keyed by sub-agent id; one parser per state lives for the lifetime of the process. The parser
|
|
563
|
+
* is destroyed and recreated on file truncation so the buffered partial state is cleared.
|
|
564
|
+
*/
|
|
565
|
+
const sessionParsers = new Map<string, ReturnType<typeof ndjson.parse>>();
|
|
566
|
+
|
|
567
|
+
/** Defensive upper bound on the per-tick Buffer.alloc. With ndjson, a partial line is buffered
|
|
568
|
+
* internally across polls, so the cap is no longer required for correctness — it is kept purely
|
|
569
|
+
* to bound worst-case memory if the file explodes in a single tick. 1 MiB is plenty. */
|
|
570
|
+
const MAX_SESSION_READ_BYTES = 1 * 1024 * 1024;
|
|
571
|
+
|
|
572
|
+
/** Get-or-create the per-state session parser and wire its 'data' event to the entry handler. */
|
|
573
|
+
function getOrCreateSessionParser(state: InteractiveSubagentState): ReturnType<typeof ndjson.parse> {
|
|
574
|
+
const existing = sessionParsers.get(state.id);
|
|
575
|
+
if (existing) return existing;
|
|
576
|
+
// strict: false → malformed lines are silently dropped instead of triggering an 'error' event
|
|
577
|
+
// that would force us to recreate the parser mid-stream. Same best-effort delivery semantics as
|
|
578
|
+
// the old hand-rolled try/catch around JSON.parse.
|
|
579
|
+
const parser = ndjson.parse({ strict: false });
|
|
580
|
+
parser.on("data", (entry: unknown) => {
|
|
581
|
+
const art = artifactPath(dirname(state.artifactDir), basename(state.artifactDir));
|
|
582
|
+
processSessionLogEntry(state, art, entry as any);
|
|
583
|
+
});
|
|
584
|
+
// In non-strict mode the parser does not emit 'error' for bad JSON, but we still attach a no-op
|
|
585
|
+
// handler so an unhandled error event can never crash the process.
|
|
586
|
+
parser.on("error", () => {
|
|
587
|
+
// Drop the broken parser so the next tick creates a fresh one. The cursor is reset in the
|
|
588
|
+
// truncation handler, so this only fires for pathological non-truncation errors.
|
|
589
|
+
sessionParsers.delete(state.id);
|
|
590
|
+
});
|
|
591
|
+
sessionParsers.set(state.id, parser);
|
|
592
|
+
return parser;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Destroy a state's parser (used on truncation and on state removal). */
|
|
596
|
+
function destroySessionParser(state: InteractiveSubagentState): void {
|
|
597
|
+
const parser = sessionParsers.get(state.id);
|
|
598
|
+
if (!parser) return;
|
|
599
|
+
try {
|
|
600
|
+
parser.end();
|
|
601
|
+
} catch {
|
|
602
|
+
// ignore — we're tearing down
|
|
603
|
+
}
|
|
604
|
+
sessionParsers.delete(state.id);
|
|
605
|
+
}
|
|
606
|
+
|
|
547
607
|
/** Tail-read the child's session JSONL and append `tool_activity` events to events.ndjson.
|
|
548
608
|
* Updates `state.lastDeliveredSessionByte` so subsequent ticks re-read only new lines. */
|
|
549
|
-
function tailReadSessionLog(state: InteractiveSubagentState,
|
|
609
|
+
function tailReadSessionLog(state: InteractiveSubagentState, _art: SubagentArtifact): void {
|
|
550
610
|
const sessionFile = state.sessionFile;
|
|
551
611
|
if (!sessionFile) return;
|
|
552
612
|
|
|
@@ -557,9 +617,25 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
|
|
|
557
617
|
return; // file not yet created by the child
|
|
558
618
|
}
|
|
559
619
|
|
|
620
|
+
const initialCursor = state.lastDeliveredSessionByte ?? 0;
|
|
621
|
+
if (size < initialCursor) {
|
|
622
|
+
// File shrunk under us (truncation, rotation, manual edit). Reset cursor and parser and fall
|
|
623
|
+
// through to the read below so any content already written after the truncation is processed in
|
|
624
|
+
// the same tick (e.g. test does truncateSync → writeFileSync → poll). The parser is recreated so the
|
|
625
|
+
// buffered partial state is cleared. Any duplicate tool_activity events are acceptable — the
|
|
626
|
+
// artifact log is best-effort and the LLM never sees these (TUI-widget only).
|
|
627
|
+
state.lastDeliveredSessionByte = 0;
|
|
628
|
+
destroySessionParser(state);
|
|
629
|
+
}
|
|
560
630
|
const cursor = state.lastDeliveredSessionByte ?? 0;
|
|
561
631
|
if (size <= cursor) return;
|
|
562
632
|
|
|
633
|
+
// Defensive cap on per-tick allocation. ndjson handles partial lines correctly across writes,
|
|
634
|
+
// so a single multi-MB line split across ticks works fine — no cursor pin.
|
|
635
|
+
const requested = size - cursor;
|
|
636
|
+
const toRead = Math.min(requested, MAX_SESSION_READ_BYTES);
|
|
637
|
+
if (toRead <= 0) return;
|
|
638
|
+
|
|
563
639
|
let fd: number;
|
|
564
640
|
try {
|
|
565
641
|
fd = openSync(sessionFile, "r");
|
|
@@ -567,59 +643,53 @@ function tailReadSessionLog(state: InteractiveSubagentState, art: SubagentArtifa
|
|
|
567
643
|
return;
|
|
568
644
|
}
|
|
569
645
|
try {
|
|
570
|
-
const
|
|
571
|
-
const buf = Buffer.alloc(len);
|
|
646
|
+
const buf = Buffer.alloc(toRead);
|
|
572
647
|
let bytesRead = 0;
|
|
573
|
-
while (bytesRead <
|
|
574
|
-
const n = readSync(fd, buf, bytesRead,
|
|
648
|
+
while (bytesRead < toRead) {
|
|
649
|
+
const n = readSync(fd, buf, bytesRead, toRead - bytesRead, cursor + bytesRead);
|
|
575
650
|
if (n <= 0) break;
|
|
576
651
|
bytesRead += n;
|
|
577
652
|
}
|
|
578
|
-
|
|
579
|
-
|
|
653
|
+
if (bytesRead === 0) return;
|
|
654
|
+
const parser = getOrCreateSessionParser(state);
|
|
655
|
+
parser.write(buf.subarray(0, bytesRead));
|
|
656
|
+
// Always advance the cursor by the bytes we fed the parser. The parser buffers any partial
|
|
657
|
+
// trailing line internally and will emit the completed object on a later write. We do NOT
|
|
658
|
+
// rewind to the last newline the way the old code did — doing so would re-feed the same bytes
|
|
659
|
+
// to the parser and double-emit on the next tick.
|
|
580
660
|
state.lastDeliveredSessionByte = cursor + bytesRead;
|
|
581
661
|
} finally {
|
|
582
|
-
try { require("node:fs").closeSync(fd); } catch {}
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/** Parse a chunk of session JSONL, append a tool_activity event per tool call. */
|
|
587
|
-
function processSessionLogChunk(state: InteractiveSubagentState, art: SubagentArtifact, chunk: string): void {
|
|
588
|
-
const lines = chunk.split("\n");
|
|
589
|
-
// Last entry may be a partial line (the child hasn't finished writing it yet).
|
|
590
|
-
// We still process complete lines; the partial line will be re-read on the next tick.
|
|
591
|
-
const completeLines = chunk.endsWith("\n") ? lines : lines.slice(0, -1);
|
|
592
|
-
for (const line of completeLines) {
|
|
593
|
-
if (!line.trim()) continue;
|
|
594
|
-
let entry: any;
|
|
595
662
|
try {
|
|
596
|
-
|
|
663
|
+
closeSync(fd);
|
|
597
664
|
} catch {
|
|
598
|
-
|
|
599
|
-
continue;
|
|
665
|
+
/* fd already closed or never opened — ignore */
|
|
600
666
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Process a single parsed JSONL entry from the session log; append tool_activity events. */
|
|
671
|
+
function processSessionLogEntry(state: InteractiveSubagentState, art: SubagentArtifact, entry: any): void {
|
|
672
|
+
if (entry.type !== "message") return;
|
|
673
|
+
const msg = entry.message;
|
|
674
|
+
if (!msg) return;
|
|
675
|
+
|
|
676
|
+
// Assistant message: extract toolCall blocks.
|
|
677
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
678
|
+
for (const block of msg.content) {
|
|
679
|
+
if (block.type !== "toolCall") continue;
|
|
680
|
+
const summary = summarizeToolCall(block.name, block.arguments);
|
|
681
|
+
if (!summary) continue;
|
|
682
|
+
const ev: SubagentEvent = {
|
|
683
|
+
ts: msg.timestamp ?? Date.now(),
|
|
684
|
+
type: "tool_activity",
|
|
685
|
+
status: "running",
|
|
686
|
+
tool: block.name,
|
|
687
|
+
summary,
|
|
688
|
+
};
|
|
689
|
+
appendEvent(art, ev);
|
|
690
|
+
state.lastToolName = block.name;
|
|
691
|
+
state.lastToolSummary = summary;
|
|
692
|
+
state.lastActivityAt = ev.ts;
|
|
623
693
|
}
|
|
624
694
|
}
|
|
625
695
|
}
|
|
@@ -734,8 +804,27 @@ function labelFor(event: SubagentEvent): string {
|
|
|
734
804
|
* default artifacts root (PI_CODING_AGENT_SESSION_DIR or ~/.pi/agent/sessions/subagentura).
|
|
735
805
|
* For v1 this is a best-effort lookup; a future iteration can track all artifact roots.
|
|
736
806
|
*/
|
|
737
|
-
|
|
807
|
+
import isPathInside from "is-path-inside";
|
|
808
|
+
|
|
809
|
+
export function findArtifactById(id: string): SubagentArtifact | null {
|
|
810
|
+
// Sub-agent ids are randomBytes(4).toString("hex") at spawn time, i.e. 8 hex
|
|
811
|
+
// chars. Validate the id before joining it into a path so that an
|
|
812
|
+
// LLM-supplied id like "../../../etc" can't escape the artifact root
|
|
813
|
+
// (path.join normalises "..", so a malicious id would otherwise resolve
|
|
814
|
+
// to a sibling directory and get exfiltrated to the parent LLM via
|
|
815
|
+
// read_subagent_artifact).
|
|
816
|
+
if (!/^[a-f0-9]{8}$/.test(id)) return null;
|
|
817
|
+
|
|
738
818
|
const root = process.env.PI_CODING_AGENT_SESSION_DIR ?? join(homedir(), ".pi", "agent", "sessions");
|
|
819
|
+
// Resolve the root once, with symlinks followed, so the containment check below
|
|
820
|
+
// is anchored on the real on-disk location. realpathSync throws if root doesn't
|
|
821
|
+
// exist; in that case there's nothing for us to find.
|
|
822
|
+
let realRoot: string;
|
|
823
|
+
try {
|
|
824
|
+
realRoot = realpathSync(root);
|
|
825
|
+
} catch {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
739
828
|
let topLevel: string[];
|
|
740
829
|
try {
|
|
741
830
|
topLevel = readdirSync(root);
|
|
@@ -746,6 +835,19 @@ function findArtifactById(id: string): SubagentArtifact | null {
|
|
|
746
835
|
const candidate = join(root, entry, "artifacts", id);
|
|
747
836
|
try {
|
|
748
837
|
if (statSync(candidate).isDirectory()) {
|
|
838
|
+
// statSync follows symlinks, so a symlink at
|
|
839
|
+
// <root>/<cwd>/artifacts/<id> pointing outside the artifact root
|
|
840
|
+
// would otherwise be returned as a valid artifact. Resolve the
|
|
841
|
+
// candidate with realpath and verify it is still inside the
|
|
842
|
+
// resolved root. realpathSync is safe here because statSync
|
|
843
|
+
// above already confirmed candidate exists as a directory.
|
|
844
|
+
let realCandidate: string;
|
|
845
|
+
try {
|
|
846
|
+
realCandidate = realpathSync(candidate);
|
|
847
|
+
} catch {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (!isPathInside(realCandidate, realRoot)) continue;
|
|
749
851
|
return artifactPath(join(root, entry, "artifacts"), id);
|
|
750
852
|
}
|
|
751
853
|
} catch {
|
|
@@ -830,10 +932,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
830
932
|
// every running interactive sub-agent and fires pointer notifications for new events.
|
|
831
933
|
// The poller survives parent restarts (artifacts on disk + per-state lastDeliveredEventTs).
|
|
832
934
|
if (!g2.__piSubagenturaInteractivePollerHandle) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
);
|
|
935
|
+
const handle = setInterval(() => pollArtifactChanges(pi), 5000);
|
|
936
|
+
// Don't pin the event loop on a long-lived parent. unref() lets the process exit
|
|
937
|
+
// cleanly when nothing else is keeping it alive (no other ref'd handles).
|
|
938
|
+
handle.unref?.();
|
|
939
|
+
g2.__piSubagenturaInteractivePollerHandle = handle;
|
|
837
940
|
}
|
|
838
941
|
// ── Tool 1: inherits conversation history ────────────────────────
|
|
839
942
|
pi.registerTool({
|
|
@@ -1776,6 +1879,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1776
1879
|
}),
|
|
1777
1880
|
|
|
1778
1881
|
async execute(_toolCallId, params): Promise<any> {
|
|
1882
|
+
// Validate the id shape FIRST so a malformed id gets a precise error
|
|
1883
|
+
// instead of being collapsed into the generic "not found" message.
|
|
1884
|
+
if (!/^[a-f0-9]{8}$/.test(params.id)) {
|
|
1885
|
+
return {
|
|
1886
|
+
content: [{ type: "text", text: `Invalid sub-agent id ${JSON.stringify(params.id)}; expected 8 lowercase hex chars.` }],
|
|
1887
|
+
details: { id: params.id, status: "invalid_id" },
|
|
1888
|
+
isError: true,
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1779
1891
|
const state = interactiveSubagentRegistry.get(params.id);
|
|
1780
1892
|
const art = state
|
|
1781
1893
|
? artifactPath(dirname(state.artifactDir), basename(state.artifactDir))
|
|
@@ -1987,10 +2099,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
1987
2099
|
},
|
|
1988
2100
|
});
|
|
1989
2101
|
|
|
1990
|
-
// ── Session shutdown: abort all jobs
|
|
2102
|
+
// ── Session shutdown: abort all jobs, kill tmux panes, stop the poller ─
|
|
1991
2103
|
(pi as any).on?.("session_shutdown", () => {
|
|
1992
2104
|
const g2 = typeof global !== "undefined" ? global : globalThis;
|
|
1993
2105
|
|
|
2106
|
+
// Stop the global poller so it doesn't fire after we're gone. Without
|
|
2107
|
+
// clearInterval the handle would keep the event loop alive across restarts.
|
|
2108
|
+
if (g2.__piSubagenturaInteractivePollerHandle) {
|
|
2109
|
+
try {
|
|
2110
|
+
clearInterval(g2.__piSubagenturaInteractivePollerHandle);
|
|
2111
|
+
} catch { /* defensive */ }
|
|
2112
|
+
g2.__piSubagenturaInteractivePollerHandle = undefined;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// Kill any tmux panes backing live interactive sub-agents. We can't leave them
|
|
2116
|
+
// running — the parent process is shutting down. cancelInteractiveSubagent
|
|
2117
|
+
// does the right thing (writes .cancelled, kills pane, lets trap record the event).
|
|
2118
|
+
try {
|
|
2119
|
+
for (const state of interactiveSubagentRegistry.values()) {
|
|
2120
|
+
if (state.status === "running") {
|
|
2121
|
+
try {
|
|
2122
|
+
cancelInteractiveSubagent(state.id);
|
|
2123
|
+
} catch { /* best effort */ }
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
} catch { /* best effort */ }
|
|
2127
|
+
|
|
2128
|
+
// Drop in-memory state for cancelled/exited interactive sub-agents. Without
|
|
2129
|
+
// this, the Map grows unbounded across session_start/session_shutdown cycles
|
|
2130
|
+
// and list_subagent_artifacts returns stale entries from previous sessions.
|
|
2131
|
+
try {
|
|
2132
|
+
interactiveSubagentRegistry.clear();
|
|
2133
|
+
} catch { /* best effort */ }
|
|
2134
|
+
|
|
1994
2135
|
// Abort all running subagent sessions before clearing
|
|
1995
2136
|
for (const job of jobRegistry.values()) {
|
|
1996
2137
|
if (job.status === "running") {
|