@gotgenes/pi-subagents 13.0.0 → 13.2.0

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.
@@ -14,16 +14,16 @@
14
14
  * The child's working directory is supplied by a registered WorkspaceProvider
15
15
  * (the workspace seam); with no provider the child runs in the parent cwd.
16
16
  *
17
- * Phase-specific collaborators (execution, notification) are attached
17
+ * Phase-specific collaborators (subagentSession, notification) are attached
18
18
  * after construction as lifecycle information becomes available.
19
19
  */
20
20
 
21
21
  import type { Model } from "@earendil-works/pi-ai";
22
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
22
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
23
23
  import { debugLog } from "#src/debug";
24
- import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
25
- import type { ExecutionState } from "#src/lifecycle/execution-state";
24
+ import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
26
25
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
26
+ import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
27
27
  import type { LifetimeUsage } from "#src/lifecycle/usage";
28
28
  import { addUsage } from "#src/lifecycle/usage";
29
29
  import type { Workspace, WorkspaceProvider } from "#src/lifecycle/workspace";
@@ -36,8 +36,8 @@ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType,
36
36
  export interface AgentLifecycleObserver {
37
37
  /** Fires when the agent transitions to running (inside run(), after markRunning). */
38
38
  onStarted?(agent: Agent): void;
39
- /** Fires when the runner creates the session delivers the session to external consumers. */
40
- onSessionCreated?(agent: Agent, session: AgentSession): void;
39
+ /** Fires once the session is created — the agent's subagentSession is now available. */
40
+ onSessionCreated?(agent: Agent): void;
41
41
  /** Fires once when the run completes or fails (for concurrency drain). */
42
42
  onRunFinished?(agent: Agent): void;
43
43
  /** Fires on compaction events during the run. */
@@ -68,7 +68,8 @@ export interface AgentInit {
68
68
  error?: string;
69
69
 
70
70
  // Shared deps (required for run(), optional for tests)
71
- runner?: AgentRunner;
71
+ /** Assembly factory that produces a born-complete SubagentSession. */
72
+ createSubagentSession?: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
72
73
  observer?: AgentLifecycleObserver;
73
74
  getRunConfig?: () => RunConfig;
74
75
  /** Resolves the registered workspace provider (if any) at run-start. */
@@ -126,7 +127,7 @@ export class Agent {
126
127
  promise?: Promise<void>;
127
128
 
128
129
  // Shared deps — optional (required for run())
129
- private readonly _runner?: AgentRunner;
130
+ private readonly _createSubagentSession?: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
130
131
  readonly observer?: AgentLifecycleObserver;
131
132
  private readonly _getRunConfig?: () => RunConfig;
132
133
  private readonly _getWorkspaceProvider?: () => WorkspaceProvider | undefined;
@@ -144,7 +145,8 @@ export class Agent {
144
145
  private readonly _signal?: AbortSignal;
145
146
 
146
147
  // Phase-specific collaborators — each born complete when their info becomes available
147
- execution?: ExecutionState;
148
+ /** The born-complete child session — set when the factory returns inside run(). */
149
+ subagentSession?: SubagentSession;
148
150
  notification?: NotificationState;
149
151
 
150
152
  // Steer buffer — messages queued before the session is ready
@@ -152,14 +154,50 @@ export class Agent {
152
154
  /** Number of steer messages waiting to be delivered. */
153
155
  get pendingSteerCount(): number { return this._pendingSteers.length; }
154
156
 
155
- /** The active agent session, or undefined before the session is created. */
156
- get session(): AgentSession | undefined {
157
- return this.execution?.session;
158
- }
159
-
160
157
  /** Path to the agent's session JSONL file, or undefined if not yet available. */
161
158
  get outputFile(): string | undefined {
162
- return this.execution?.outputFile;
159
+ return this.subagentSession?.outputFile;
160
+ }
161
+
162
+ /** Returns true when a SubagentSession is available (session is ready). */
163
+ isSessionReady(): boolean {
164
+ return this.subagentSession != null;
165
+ }
166
+
167
+ /**
168
+ * Deliver or buffer a steer message.
169
+ * Returns true when delivered immediately; false when buffered for later delivery.
170
+ */
171
+ async steer(message: string): Promise<boolean> {
172
+ if (!this.subagentSession) {
173
+ this.queueSteer(message);
174
+ return false;
175
+ }
176
+ await this.subagentSession.steer(message);
177
+ return true;
178
+ }
179
+
180
+ /** Return the session conversation as formatted text, or undefined if no session. */
181
+ getConversation(): string | undefined {
182
+ return this.subagentSession?.getConversation();
183
+ }
184
+
185
+ /** Return the session context window utilization (0-100), or null if unavailable. */
186
+ getContextPercent(): number | null {
187
+ return this.subagentSession?.getContextPercent() ?? null;
188
+ }
189
+
190
+ /**
191
+ * Subscribe to session events for live updates (e.g., conversation viewer).
192
+ * Returns an unsubscribe function, or undefined if no session is available.
193
+ */
194
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
195
+ return this.subagentSession?.subscribe(fn);
196
+ }
197
+
198
+ /** The session's message history, or an empty array if no session. */
199
+ get messages(): readonly unknown[] {
200
+ return this.subagentSession?.messages ?? [];
163
201
  }
164
202
 
165
203
  constructor(init: AgentInit) {
@@ -185,7 +223,7 @@ export class Agent {
185
223
  this.abortController = new AbortController();
186
224
 
187
225
  // Shared deps
188
- this._runner = init.runner;
226
+ this._createSubagentSession = init.createSubagentSession;
189
227
  this.observer = init.observer;
190
228
  this._getRunConfig = init.getRunConfig;
191
229
  this._getWorkspaceProvider = init.getWorkspaceProvider;
@@ -207,16 +245,16 @@ export class Agent {
207
245
  }
208
246
 
209
247
  /**
210
- * Execute the full agent lifecycle: workspace preparation, runner invocation,
211
- * session-creation handling, observer wiring, workspace disposal, and
248
+ * Execute the full agent lifecycle: workspace preparation, session creation
249
+ * via the factory, observer wiring, the turn loop, workspace disposal, and
212
250
  * status transitions.
213
251
  *
214
- * Requires runner and snapshot to be set at construction.
252
+ * Requires the session factory and snapshot to be set at construction.
215
253
  * The returned promise always resolves (errors are captured internally).
216
254
  */
217
255
  async run(): Promise<void> {
218
- if (!this._runner) {
219
- throw new Error("Agent not configured for execution — missing runner");
256
+ if (!this._createSubagentSession) {
257
+ throw new Error("Agent not configured for execution — missing session factory");
220
258
  }
221
259
  if (!this._snapshot || !this._prompt) {
222
260
  throw new Error("Agent not configured for execution — missing snapshot or prompt");
@@ -247,29 +285,34 @@ export class Agent {
247
285
  return;
248
286
  }
249
287
 
250
- const runConfig = this._getRunConfig?.();
251
288
  try {
252
- const result = await this._runner.run(this._snapshot, this.type, this._prompt, {
253
- context: {
254
- cwd,
255
- parentSession: this._parentSession,
256
- },
289
+ this.subagentSession = await this._createSubagentSession({
290
+ snapshot: this._snapshot,
291
+ type: this.type,
292
+ cwd,
293
+ parentSession: this._parentSession,
257
294
  model: this._model,
295
+ thinkingLevel: this._thinkingLevel,
296
+ });
297
+ } catch (err) {
298
+ // The factory disposed its own session on a post-creation failure.
299
+ this.failRun(err);
300
+ return;
301
+ }
302
+
303
+ this.flushPendingSteers();
304
+ this.attachObserver(subscribeAgentObserver(this.subagentSession, this, {
305
+ onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
306
+ }));
307
+ this.observer?.onSessionCreated?.(this);
308
+
309
+ const runConfig = this._getRunConfig?.();
310
+ try {
311
+ const result = await this.subagentSession.runTurnLoop(this._prompt, {
258
312
  maxTurns: this._maxTurns,
259
313
  defaultMaxTurns: runConfig?.defaultMaxTurns,
260
314
  graceTurns: runConfig?.graceTurns,
261
- thinkingLevel: this._thinkingLevel,
262
315
  signal: this.abortController.signal,
263
- onSessionCreated: (session) => {
264
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
265
- const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
266
- this.execution = { session, outputFile };
267
- this.flushPendingSteers(session);
268
- this.attachObserver(subscribeAgentObserver(session, this, {
269
- onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
270
- }));
271
- this.observer?.onSessionCreated?.(this, session);
272
- },
273
316
  });
274
317
  this.completeRun(result);
275
318
  } catch (err) {
@@ -281,27 +324,24 @@ export class Agent {
281
324
  * Resume an existing session with a new prompt, managing the observer
282
325
  * subscription lifecycle internally (same wiring as run()).
283
326
  *
284
- * Requires runner and an existing session (set when the original run created it).
327
+ * Requires an existing SubagentSession (set when the original run created it).
285
328
  * The returned promise always resolves (errors are captured internally).
286
- * The parent signal flows straight through to runner.resume — resume does not
329
+ * The parent signal flows straight through to resumeTurnLoop — resume does not
287
330
  * route through this.abortController.
288
331
  */
289
332
  async resume(prompt: string, signal?: AbortSignal): Promise<void> {
290
- if (!this._runner) {
291
- throw new Error("Agent not configured for execution — missing runner");
292
- }
293
- const session = this.session;
294
- if (!session) {
333
+ const subagentSession = this.subagentSession;
334
+ if (!subagentSession) {
295
335
  throw new Error("Agent not configured for resume — missing session");
296
336
  }
297
337
 
298
338
  this.resetForResume(Date.now());
299
- this.attachObserver(subscribeAgentObserver(session, this, {
339
+ this.attachObserver(subscribeAgentObserver(subagentSession, this, {
300
340
  onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
301
341
  }));
302
342
 
303
343
  try {
304
- const responseText = await this._runner.resume(session, prompt, { signal });
344
+ const responseText = await subagentSession.resumeTurnLoop(prompt, signal);
305
345
  this.markCompleted(responseText);
306
346
  } catch (err) {
307
347
  this.markError(err);
@@ -399,19 +439,19 @@ export class Agent {
399
439
 
400
440
  /**
401
441
  * Buffer a steer message for delivery once the session is ready.
402
- * Called when steer is requested before onSessionCreated fires.
442
+ * Called internally from steer() before the session is ready.
403
443
  */
404
- queueSteer(message: string): void {
444
+ private queueSteer(message: string): void {
405
445
  this._pendingSteers.push(message);
406
446
  }
407
447
 
408
448
  /**
409
449
  * Flush all buffered steer messages to the session and clear the buffer.
410
- * Called from onSessionCreated once the session is available.
450
+ * Called once the session is available (inside run()).
411
451
  */
412
- flushPendingSteers(session: AgentSession): void {
452
+ private flushPendingSteers(): void {
413
453
  for (const msg of this._pendingSteers) {
414
- session.steer(msg).catch(() => {});
454
+ this.subagentSession?.steer(msg).catch(() => {});
415
455
  }
416
456
  this._pendingSteers = [];
417
457
  }
@@ -451,8 +491,8 @@ export class Agent {
451
491
  this._detachFn = undefined;
452
492
  }
453
493
 
454
- /** Complete a run: release listeners, dispose the workspace, status transition, execution update, notify observer. */
455
- completeRun(result: RunResult): void {
494
+ /** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
495
+ completeRun(result: TurnLoopResult): void {
456
496
  this.releaseListeners();
457
497
 
458
498
  let finalResult = result.responseText;
@@ -470,14 +510,14 @@ export class Agent {
470
510
  else if (result.steered) this.markSteered(finalResult);
471
511
  else this.markCompleted(finalResult);
472
512
 
473
- this.execution = {
474
- session: result.session,
475
- outputFile: result.sessionFile ?? this.execution?.outputFile,
476
- };
477
-
478
513
  this.observer?.onRunFinished?.(this);
479
514
  }
480
515
 
516
+ /** Dispose the wrapped session, firing the `disposed` lifecycle event. */
517
+ disposeSession(): void {
518
+ this.subagentSession?.dispose();
519
+ }
520
+
481
521
  /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
482
522
  failRun(err: unknown): void {
483
523
  this.markError(err);
@@ -0,0 +1,242 @@
1
+ /**
2
+ * create-subagent-session.ts — Assembly factory for born-complete child sessions (issue #265).
3
+ *
4
+ * `createSubagentSession()` does the assembly portion that the old runner's
5
+ * `runAgent()` did up front: detect the environment, assemble the session config,
6
+ * create the SDK session, publish `spawning`/`session-created`, bind extensions,
7
+ * and apply the recursion guard. It returns a fully usable `SubagentSession` —
8
+ * `Agent` then only coordinates (turn loop, steer, dispose).
9
+ *
10
+ * The factory takes a resolved `cwd` value, never the WorkspaceProvider: `cwd`
11
+ * is a value the factory consumes directly (detectEnv, assembleSessionConfig,
12
+ * createSession), so threading the provider through here would be a relay smell.
13
+ */
14
+
15
+ import type { Model } from "@earendil-works/pi-ai";
16
+ import {
17
+ type AgentSession,
18
+ type SettingsManager,
19
+ } from "@earendil-works/pi-coding-agent";
20
+ import type { AgentConfigLookup } from "#src/config/agent-types";
21
+ import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
22
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
23
+ import { SubagentSession } from "#src/lifecycle/subagent-session";
24
+ import type { EnvInfo } from "#src/session/env";
25
+ import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
26
+ import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
27
+
28
+ /** Names of tools registered by this extension that subagents must NOT inherit. */
29
+ const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
30
+
31
+ /**
32
+ * Apply the recursion guard: remove this extension's dispatch tools from the
33
+ * child's active set. Runs after `bindExtensions` so extension-registered tools
34
+ * are also covered. Unconditional: children always load the parent's extensions.
35
+ */
36
+ function applyRecursionGuard(session: AgentSession): void {
37
+ const filtered = session
38
+ .getActiveToolNames()
39
+ .filter((t) => !EXCLUDED_TOOL_NAMES.includes(t));
40
+ session.setActiveToolsByName(filtered);
41
+ }
42
+
43
+ // ── IO boundary ───────────────────────────────────────────────────────────────
44
+
45
+ /** Minimal resource-loader contract used by the factory. */
46
+ export interface ResourceLoaderLike {
47
+ reload(): Promise<void>;
48
+ }
49
+
50
+ /** Minimal session-manager contract used by the factory. */
51
+ export interface SessionManagerLike {
52
+ newSession(opts: { parentSession?: string }): void;
53
+ getSessionFile(): string | undefined;
54
+ }
55
+
56
+ /** Options passed to EnvironmentIO/SessionFactoryIO methods. */
57
+ export interface ResourceLoaderOptions {
58
+ cwd: string;
59
+ agentDir: string;
60
+ noPromptTemplates?: boolean;
61
+ noThemes?: boolean;
62
+ noContextFiles?: boolean;
63
+ systemPromptOverride?: () => string;
64
+ /** Override the append system prompt. Receives the current base value; return the replacement. */
65
+ appendSystemPromptOverride?: (base: string[]) => string[];
66
+ }
67
+
68
+ /** Options passed to SessionFactoryIO.createSession. */
69
+ export interface CreateSessionOptions {
70
+ cwd: string;
71
+ agentDir: string;
72
+ sessionManager: SessionManagerLike;
73
+ settingsManager: SettingsManager;
74
+ modelRegistry: unknown;
75
+ model?: unknown;
76
+ tools: string[];
77
+ resourceLoader: ResourceLoaderLike;
78
+ thinkingLevel?: ThinkingLevel;
79
+ }
80
+
81
+ /**
82
+ * Environment discovery - detect runtime context and resolve directories.
83
+ *
84
+ * Decouples the factory from direct process/SDK reads so each can be stubbed
85
+ * independently in tests.
86
+ */
87
+ export interface EnvironmentIO {
88
+ detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
89
+ getAgentDir: () => string;
90
+ deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
91
+ }
92
+
93
+ /**
94
+ * Session factory - create SDK objects for a child agent session.
95
+ *
96
+ * Decouples the factory from direct Pi SDK imports and sibling-module IO,
97
+ * making it testable via plain stub objects without vi.mock().
98
+ */
99
+ export interface SessionFactoryIO {
100
+ createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
101
+ createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
102
+ createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
103
+ createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
104
+ assemblerIO: AssemblerIO;
105
+ }
106
+
107
+ /**
108
+ * IO boundary injected into createSubagentSession().
109
+ *
110
+ * Intersection of EnvironmentIO and SessionFactoryIO — callers satisfy both
111
+ * sub-interfaces via TypeScript's structural typing.
112
+ */
113
+ export type SubagentSessionIO = EnvironmentIO & SessionFactoryIO;
114
+
115
+ /**
116
+ * Dependencies injected at construction time — the IO boundary plus the two
117
+ * static domain deps (exec, registry) every creation needs.
118
+ */
119
+ export interface SubagentSessionDeps {
120
+ io: SubagentSessionIO;
121
+ exec: ShellExec;
122
+ registry: AgentConfigLookup;
123
+ /** Publishes the child-execution lifecycle so consumers can observe it. */
124
+ lifecycle: ChildLifecyclePublisher;
125
+ }
126
+
127
+ /** Per-spawn parameters — the fields that vary per child session. */
128
+ export interface CreateSubagentSessionParams {
129
+ snapshot: ParentSnapshot;
130
+ type: SubagentType;
131
+ /** Resolved workspace cwd; undefined → parent cwd. */
132
+ cwd?: string;
133
+ /** Parent session identity (file path + session ID). */
134
+ parentSession?: ParentSessionInfo;
135
+ model?: Model<any>;
136
+ thinkingLevel?: ThinkingLevel;
137
+ }
138
+
139
+ /**
140
+ * Build a born-complete SubagentSession: assemble config, create the SDK
141
+ * session, publish lifecycle events, bind extensions, apply the recursion guard.
142
+ */
143
+ export async function createSubagentSession(
144
+ params: CreateSubagentSessionParams,
145
+ deps: SubagentSessionDeps,
146
+ ): Promise<SubagentSession> {
147
+ const { snapshot, type } = params;
148
+ const parentSessionId = params.parentSession?.parentSessionId;
149
+ deps.lifecycle.spawning({ agentName: type, parentSessionId });
150
+
151
+ // Resolve working directory upfront - needed for detectEnv before assembly.
152
+ const effectiveCwd = params.cwd ?? snapshot.cwd;
153
+ const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
154
+
155
+ // Assemble session configuration (synchronous, no SDK objects).
156
+ const cfg = assembleSessionConfig(
157
+ type,
158
+ {
159
+ cwd: snapshot.cwd,
160
+ parentSystemPrompt: snapshot.systemPrompt,
161
+ parentModel: snapshot.model,
162
+ modelRegistry: snapshot.modelRegistry,
163
+ },
164
+ {
165
+ cwd: params.cwd,
166
+ model: params.model,
167
+ thinkingLevel: params.thinkingLevel,
168
+ },
169
+ env,
170
+ deps.registry,
171
+ deps.io.assemblerIO,
172
+ );
173
+
174
+ const agentDir = deps.io.getAgentDir();
175
+
176
+ // Children always load the parent's extensions and skills.
177
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
178
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
179
+ // would defeat prompt_mode: replace. Parent context, if wanted, reaches the
180
+ // subagent via prompt_mode: append (parentSystemPrompt is embedded in
181
+ // systemPromptOverride) or inherit_context (conversation).
182
+ const loader = deps.io.createResourceLoader({
183
+ cwd: cfg.effectiveCwd,
184
+ agentDir,
185
+ noPromptTemplates: true,
186
+ noThemes: true,
187
+ noContextFiles: true,
188
+ systemPromptOverride: () => cfg.systemPrompt,
189
+ appendSystemPromptOverride: () => [],
190
+ });
191
+ await loader.reload();
192
+
193
+ // Create a persisted SessionManager so transcripts are written in Pi's
194
+ // official JSONL format. Falls back to a temp directory when the parent
195
+ // session is not persisted (e.g. headless/API mode).
196
+ const sessionDir = deps.io.deriveSessionDir(params.parentSession?.parentSessionFile, cfg.effectiveCwd);
197
+ const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
198
+ sessionManager.newSession({ parentSession: params.parentSession?.parentSessionId });
199
+
200
+ const { session } = await deps.io.createSession({
201
+ cwd: cfg.effectiveCwd,
202
+ agentDir,
203
+ sessionManager,
204
+ settingsManager: deps.io.createSettingsManager(cfg.effectiveCwd, agentDir),
205
+ modelRegistry: snapshot.modelRegistry,
206
+ model: cfg.model,
207
+ tools: cfg.toolNames,
208
+ resourceLoader: loader,
209
+ thinkingLevel: cfg.thinkingLevel,
210
+ });
211
+
212
+ const subagentSession = new SubagentSession(session, {
213
+ outputFile: sessionManager.getSessionFile(),
214
+ sessionDir,
215
+ agentName: type,
216
+ agentMaxTurns: cfg.agentMaxTurns,
217
+ parentContext: snapshot.parentContext,
218
+ lifecycle: deps.lifecycle,
219
+ });
220
+
221
+ // Publish session-created before bindExtensions() so observers (e.g. the
222
+ // permission system) can register the child synchronously and have their
223
+ // entry in place for the first permission check during child extension
224
+ // initialization. The event bus dispatches synchronously, so a synchronous
225
+ // subscriber completes before this returns.
226
+ deps.lifecycle.sessionCreated({ sessionDir, agentName: type, parentSessionId });
227
+
228
+ try {
229
+ // Bind extensions so that session_start fires and extensions can initialize.
230
+ await session.bindExtensions({});
231
+ // Apply recursion guard after bindExtensions so extension-registered tools
232
+ // are included in the post-bind active set.
233
+ applyRecursionGuard(session);
234
+ } catch (err) {
235
+ // Binding failed after session-created — dispose (emit disposed +
236
+ // session.dispose()) before rethrowing so registration is never leaked.
237
+ subagentSession.dispose();
238
+ throw err;
239
+ }
240
+
241
+ return subagentSession;
242
+ }