@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.
@@ -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: SessionManager.inMemory(cfg.effectiveCwd),
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 { responseText, session, aborted, steered: softLimitReached };
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
+ }
@@ -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
- import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "../output-file.js";
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 streaming output transcript file. */
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
@@ -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
- }