@gotgenes/pi-subagents 5.8.2 → 6.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/CHANGELOG.md +37 -0
- package/docs/architecture/architecture.md +304 -209
- package/docs/plans/0061-session-format-transcript.md +284 -0
- package/docs/plans/0102-consolidate-test-record-factory.md +176 -0
- package/docs/retro/0061-session-format-transcript.md +41 -0
- package/docs/retro/0077-inject-project-agents-dir.md +33 -0
- package/package.json +6 -3
- package/src/agent-manager.ts +13 -12
- package/src/agent-runner.ts +22 -2
- package/src/session-dir.ts +38 -0
- package/src/tools/agent-tool.ts +5 -17
- package/src/types.ts +1 -3
- package/src/output-file.ts +0 -99
package/src/agent-runner.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { buildParentContext, extractText } from "./context.js";
|
|
18
18
|
import { detectEnv } from "./env.js";
|
|
19
19
|
import { assembleSessionConfig } from "./session-config.js";
|
|
20
|
+
import { deriveSubagentSessionDir } from "./session-dir.js";
|
|
20
21
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
21
22
|
|
|
22
23
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
@@ -82,6 +83,10 @@ export interface RunOptions {
|
|
|
82
83
|
thinkingLevel?: ThinkingLevel;
|
|
83
84
|
/** Override working directory (e.g. for worktree isolation). */
|
|
84
85
|
cwd?: string;
|
|
86
|
+
/** Path to the parent session's JSONL file (for deriving the subagent session directory). */
|
|
87
|
+
parentSessionFile?: string;
|
|
88
|
+
/** Session ID of the parent agent (stored in the child session's parentSession header). */
|
|
89
|
+
parentSessionId?: string;
|
|
85
90
|
/** Called on tool start/end with activity info. */
|
|
86
91
|
onToolActivity?: (activity: ToolActivity) => void;
|
|
87
92
|
/** Called on streaming text deltas from the assistant response. */
|
|
@@ -127,6 +132,8 @@ export interface RunResult {
|
|
|
127
132
|
aborted: boolean;
|
|
128
133
|
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
|
|
129
134
|
steered: boolean;
|
|
135
|
+
/** Path to the persisted session JSONL file, if the session was persisted. */
|
|
136
|
+
sessionFile?: string;
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
/** Options for resuming an existing agent session. */
|
|
@@ -240,10 +247,17 @@ export async function runAgent(
|
|
|
240
247
|
});
|
|
241
248
|
await loader.reload();
|
|
242
249
|
|
|
250
|
+
// Create a persisted SessionManager so transcripts are written in Pi's
|
|
251
|
+
// official JSONL format. Falls back to a temp directory when the parent
|
|
252
|
+
// session is not persisted (e.g. headless/API mode).
|
|
253
|
+
const sessionDir = deriveSubagentSessionDir(options.parentSessionFile, cfg.effectiveCwd);
|
|
254
|
+
const sessionManager = SessionManager.create(cfg.effectiveCwd, sessionDir);
|
|
255
|
+
sessionManager.newSession({ parentSession: options.parentSessionId });
|
|
256
|
+
|
|
243
257
|
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
|
|
244
258
|
cwd: cfg.effectiveCwd,
|
|
245
259
|
agentDir,
|
|
246
|
-
sessionManager
|
|
260
|
+
sessionManager,
|
|
247
261
|
settingsManager: SettingsManager.create(cfg.effectiveCwd, agentDir),
|
|
248
262
|
modelRegistry: ctx.modelRegistry,
|
|
249
263
|
model: cfg.model as Model<any> | undefined,
|
|
@@ -383,7 +397,13 @@ export async function runAgent(
|
|
|
383
397
|
|
|
384
398
|
const responseText =
|
|
385
399
|
collector.getText().trim() || getLastAssistantText(session);
|
|
386
|
-
return {
|
|
400
|
+
return {
|
|
401
|
+
responseText,
|
|
402
|
+
session,
|
|
403
|
+
aborted,
|
|
404
|
+
steered: softLimitReached,
|
|
405
|
+
sessionFile: sessionManager.getSessionFile(),
|
|
406
|
+
};
|
|
387
407
|
}
|
|
388
408
|
|
|
389
409
|
/**
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-dir.ts — Pure function for deriving subagent session directories.
|
|
3
|
+
*
|
|
4
|
+
* Subagent sessions are nested under the parent session's basename so they are
|
|
5
|
+
* discoverable via the parent session path without cluttering the main session list.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { basename, dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Derive the session directory for a subagent from the parent session file.
|
|
13
|
+
*
|
|
14
|
+
* Layout: `<parent-dir>/<parent-basename>/tasks/`
|
|
15
|
+
*
|
|
16
|
+
* Example:
|
|
17
|
+
* parent: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_.jsonl`
|
|
18
|
+
* result: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_/tasks`
|
|
19
|
+
*
|
|
20
|
+
* Falls back to a temp directory when the parent session is not persisted
|
|
21
|
+
* (e.g. API/headless mode where the parent uses `SessionManager.inMemory()`).
|
|
22
|
+
*/
|
|
23
|
+
export function deriveSubagentSessionDir(
|
|
24
|
+
parentSessionFile: string | undefined,
|
|
25
|
+
cwd: string,
|
|
26
|
+
): string {
|
|
27
|
+
if (parentSessionFile) {
|
|
28
|
+
const dir = dirname(parentSessionFile);
|
|
29
|
+
const base = basename(parentSessionFile, ".jsonl");
|
|
30
|
+
return join(dir, base, "tasks");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback: use a temp directory keyed by uid and cwd so different
|
|
34
|
+
// projects don't collide when the parent session is not persisted.
|
|
35
|
+
const encoded = cwd.replace(/[/\\]/g, "-").replace(/^[A-Za-z]:-/, "").replace(/^-+/, "");
|
|
36
|
+
const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
|
|
37
|
+
return join(root, encoded, "tasks");
|
|
38
|
+
}
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { normalizeMaxTurns } from "../agent-runner.js";
|
|
|
6
6
|
import { resolveAgentConfig, resolveType } from "../agent-types.js";
|
|
7
7
|
import { resolveAgentInvocationConfig } from "../invocation-config.js";
|
|
8
8
|
import { resolveInvocationModel } from "../model-resolver.js";
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
|
|
11
11
|
import {
|
|
12
12
|
type AgentActivity,
|
|
@@ -454,19 +454,12 @@ Guidelines:
|
|
|
454
454
|
const { state: bgState, callbacks: bgCallbacks } =
|
|
455
455
|
createActivityTracker(effectiveMaxTurns);
|
|
456
456
|
|
|
457
|
-
// Wrap onSessionCreated to wire output file streaming.
|
|
458
457
|
let id: string;
|
|
459
|
-
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
460
|
-
bgCallbacks.onSessionCreated = (session: any) => {
|
|
461
|
-
origBgOnSession(session);
|
|
462
|
-
const rec = deps.manager.getRecord(id);
|
|
463
|
-
if (rec?.outputFile) {
|
|
464
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
458
|
|
|
468
459
|
try {
|
|
469
460
|
id = deps.manager.spawn(ctx, subagentType, params.prompt as string, {
|
|
461
|
+
parentSessionFile: ctx.sessionManager.getSessionFile(),
|
|
462
|
+
parentSessionId: ctx.sessionManager.getSessionId(),
|
|
470
463
|
description: params.description as string,
|
|
471
464
|
model,
|
|
472
465
|
maxTurns: effectiveMaxTurns,
|
|
@@ -482,16 +475,9 @@ Guidelines:
|
|
|
482
475
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
483
476
|
}
|
|
484
477
|
|
|
485
|
-
// Set output file synchronously after spawn
|
|
486
478
|
const record = deps.manager.getRecord(id);
|
|
487
479
|
if (record) {
|
|
488
480
|
record.toolCallId = toolCallId;
|
|
489
|
-
record.outputFile = createOutputFilePath(
|
|
490
|
-
ctx.cwd,
|
|
491
|
-
id,
|
|
492
|
-
ctx.sessionManager.getSessionId(),
|
|
493
|
-
);
|
|
494
|
-
writeInitialEntry(record.outputFile, id, params.prompt as string, ctx.cwd);
|
|
495
481
|
}
|
|
496
482
|
|
|
497
483
|
deps.agentActivity.set(id, bgState);
|
|
@@ -596,6 +582,8 @@ Guidelines:
|
|
|
596
582
|
isolation,
|
|
597
583
|
invocation: agentInvocation,
|
|
598
584
|
signal,
|
|
585
|
+
parentSessionFile: ctx.sessionManager.getSessionFile(),
|
|
586
|
+
parentSessionId: ctx.sessionManager.getSessionId(),
|
|
599
587
|
...fgCallbacks,
|
|
600
588
|
},
|
|
601
589
|
);
|
package/src/types.ts
CHANGED
|
@@ -78,10 +78,8 @@ export interface AgentRecord {
|
|
|
78
78
|
worktreeResult?: { hasChanges: boolean; branch?: string };
|
|
79
79
|
/** The tool_use_id from the original Agent tool call. */
|
|
80
80
|
toolCallId?: string;
|
|
81
|
-
/** Path to the
|
|
81
|
+
/** Path to the persisted session transcript file. */
|
|
82
82
|
outputFile?: string;
|
|
83
|
-
/** Cleanup function for the output file stream subscription. */
|
|
84
|
-
outputCleanup?: () => void;
|
|
85
83
|
/**
|
|
86
84
|
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
|
|
87
85
|
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
|
package/src/output-file.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* output-file.ts — Streaming JSONL output file for agent transcripts.
|
|
3
|
-
*
|
|
4
|
-
* Creates a per-agent output file that streams conversation turns as JSONL,
|
|
5
|
-
* matching Claude Code's task output file format.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { debugLog } from "./debug.js";
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Encode a cwd path as a filesystem-safe directory name. Handles:
|
|
16
|
-
* - POSIX: "/home/user/project" → "home-user-project"
|
|
17
|
-
* - Windows: "C:\Users\foo\project" → "Users-foo-project"
|
|
18
|
-
* - UNC: "\\\\server\\share\\project" → "server-share-project"
|
|
19
|
-
*/
|
|
20
|
-
export function encodeCwd(cwd: string): string {
|
|
21
|
-
return cwd
|
|
22
|
-
.replace(/[/\\]/g, "-") // both separators → dash
|
|
23
|
-
.replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
|
|
24
|
-
.replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Create the output file path, ensuring the directory exists.
|
|
28
|
-
* Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
|
|
29
|
-
export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
|
|
30
|
-
const encoded = encodeCwd(cwd);
|
|
31
|
-
const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
|
|
32
|
-
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
33
|
-
// chmod is a no-op on Windows and throws on some Windows filesystems.
|
|
34
|
-
// On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
|
|
35
|
-
try {
|
|
36
|
-
chmodSync(root, 0o700);
|
|
37
|
-
} catch (err) {
|
|
38
|
-
if (process.platform !== "win32") throw err;
|
|
39
|
-
}
|
|
40
|
-
const dir = join(root, encoded, sessionId, "tasks");
|
|
41
|
-
mkdirSync(dir, { recursive: true });
|
|
42
|
-
return join(dir, `${agentId}.output`);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Write the initial user prompt entry. */
|
|
46
|
-
export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
|
|
47
|
-
const entry = {
|
|
48
|
-
isSidechain: true,
|
|
49
|
-
agentId,
|
|
50
|
-
type: "user",
|
|
51
|
-
message: { role: "user", content: prompt },
|
|
52
|
-
timestamp: new Date().toISOString(),
|
|
53
|
-
cwd,
|
|
54
|
-
};
|
|
55
|
-
writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Subscribe to session events and flush new messages to the output file on each turn_end.
|
|
60
|
-
* Returns a cleanup function that does a final flush and unsubscribes.
|
|
61
|
-
*/
|
|
62
|
-
export function streamToOutputFile(
|
|
63
|
-
session: AgentSession,
|
|
64
|
-
path: string,
|
|
65
|
-
agentId: string,
|
|
66
|
-
cwd: string,
|
|
67
|
-
): () => void {
|
|
68
|
-
let writtenCount = 1; // initial user prompt already written
|
|
69
|
-
|
|
70
|
-
const flush = () => {
|
|
71
|
-
const messages = session.messages;
|
|
72
|
-
while (writtenCount < messages.length) {
|
|
73
|
-
const msg = messages[writtenCount];
|
|
74
|
-
const entry = {
|
|
75
|
-
isSidechain: true,
|
|
76
|
-
agentId,
|
|
77
|
-
type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult",
|
|
78
|
-
message: msg,
|
|
79
|
-
timestamp: new Date().toISOString(),
|
|
80
|
-
cwd,
|
|
81
|
-
};
|
|
82
|
-
try {
|
|
83
|
-
appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
|
|
84
|
-
} catch (err) {
|
|
85
|
-
debugLog("write JSONL chunk", err);
|
|
86
|
-
}
|
|
87
|
-
writtenCount++;
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
92
|
-
if (event.type === "turn_end") flush();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
return () => {
|
|
96
|
-
flush();
|
|
97
|
-
unsubscribe();
|
|
98
|
-
};
|
|
99
|
-
}
|