@jopqior/pi-subagents 1.0.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * child-lifecycle.ts — Child-execution lifecycle event contract and publisher.
3
+ *
4
+ * The core publishes its child-execution lifecycle as ordered events on the Pi
5
+ * event bus; reactive consumers (permissions, telemetry, UI) subscribe rather
6
+ * than the core reaching out to them (ADR 0002). This module owns the channel
7
+ * names, payload shapes, and the publisher that emits them.
8
+ *
9
+ * The publisher takes an injected `emit` callback so this module stays free of
10
+ * Pi SDK imports — `index.ts` wires it to `pi.events.emit`.
11
+ *
12
+ * `spawning`, `session-created`, `completed`, and `disposed` are the announcement
13
+ * a consumer may rely on. `bound` is additional: it reports that a child finished
14
+ * binding its extensions, which is the only moment a parent can observe what those
15
+ * extensions did or did not install.
16
+ */
17
+
18
+ /** Emitted at the start of a child run, before the session is created. */
19
+ export const SUBAGENT_CHILD_SPAWNING = "subagents:child:spawning";
20
+
21
+ /**
22
+ * Emitted after the child session is created, immediately before
23
+ * `bindExtensions()`. Carries the child session id consumers need to register
24
+ * the session in `SubagentSessionRegistry`. Subscribers must register
25
+ * synchronously so the entry lands before binding proceeds (see ADR 0002 /
26
+ * the event-bus synchronous-dispatch guarantee).
27
+ */
28
+ export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
29
+
30
+ /**
31
+ * Emitted once the child's extensions have bound, after every child
32
+ * `session_start` handler has run and before the child takes its first turn.
33
+ *
34
+ * Skipped when binding throws: that child never runs, so there is nothing to
35
+ * report about it.
36
+ */
37
+ export const SUBAGENT_CHILD_BOUND = "subagents:child:bound";
38
+
39
+ /** Emitted after the child's prompt resolves (normal, steered, or aborted). */
40
+ export const SUBAGENT_CHILD_COMPLETED = "subagents:child:completed";
41
+
42
+ /** Emitted in the run's `finally` — always fires, on success and error. */
43
+ export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
44
+
45
+ /** Payload for `subagents:child:spawning`. */
46
+ export interface ChildSpawningEvent {
47
+ agentName: string;
48
+ parentSessionId?: string;
49
+ }
50
+
51
+ /** Payload for `subagents:child:session-created`. */
52
+ export interface ChildSessionCreatedEvent {
53
+ /** Child session id — the registry key. Unique per child; concurrent
54
+ * siblings of the same parent occupy distinct keys. */
55
+ sessionId: string;
56
+ parentSessionId?: string;
57
+ }
58
+
59
+ /** Payload for `subagents:child:bound`. */
60
+ export interface ChildBoundEvent {
61
+ /** Child session id — the same key `session-created` carried. */
62
+ sessionId: string;
63
+ parentSessionId?: string;
64
+ }
65
+
66
+ /** Payload for `subagents:child:completed`. */
67
+ export interface ChildCompletedEvent {
68
+ sessionDir: string;
69
+ agentName: string;
70
+ /** True if the run was hard-aborted (max turns + grace exceeded). */
71
+ aborted: boolean;
72
+ /** True if the run was steered to wrap up (soft turn limit) but finished. */
73
+ steered: boolean;
74
+ }
75
+
76
+ /** Payload for `subagents:child:disposed`. */
77
+ export interface ChildDisposedEvent {
78
+ /** Child session id — the registry key. Must match `session-created`. */
79
+ sessionId: string;
80
+ }
81
+
82
+ /** Narrow emit seam — injected, never imports the Pi SDK. */
83
+ export type LifecycleEmit = (channel: string, data: unknown) => void;
84
+
85
+ /** Publishes the child-execution lifecycle on the event bus. */
86
+ export interface ChildLifecyclePublisher {
87
+ spawning(event: ChildSpawningEvent): void;
88
+ sessionCreated(event: ChildSessionCreatedEvent): void;
89
+ bound(event: ChildBoundEvent): void;
90
+ completed(event: ChildCompletedEvent): void;
91
+ disposed(event: ChildDisposedEvent): void;
92
+ }
93
+
94
+ /** Build a publisher backed by an injected `emit` callback. */
95
+ export function createChildLifecyclePublisher(
96
+ emit: LifecycleEmit,
97
+ ): ChildLifecyclePublisher {
98
+ return {
99
+ spawning(event) {
100
+ emit(SUBAGENT_CHILD_SPAWNING, event);
101
+ },
102
+ sessionCreated(event) {
103
+ emit(SUBAGENT_CHILD_SESSION_CREATED, event);
104
+ },
105
+ bound(event) {
106
+ emit(SUBAGENT_CHILD_BOUND, event);
107
+ },
108
+ completed(event) {
109
+ emit(SUBAGENT_CHILD_COMPLETED, event);
110
+ },
111
+ disposed(event) {
112
+ emit(SUBAGENT_CHILD_DISPOSED, event);
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * child-shutdown.ts — Bounded `session_shutdown` emission for a child session (issue #709).
3
+ *
4
+ * A child session binds the full extension set, so `session_start` fires and every
5
+ * inherited extension initializes. Pi's `AgentSession.dispose()` does not emit the
6
+ * matching `session_shutdown` — only `AgentSessionRuntime` does, and children do not
7
+ * use that path — so extension-owned subprocesses, timers, and sockets outlive the
8
+ * child. This module supplies the missing half of the pair.
9
+ *
10
+ * Two constraints shape it:
11
+ *
12
+ * 1. `AgentSession.dispose()` invalidates the extension runner, and every `ctx`
13
+ * accessor throws once invalidated. The emit must therefore be awaited to
14
+ * completion *before* disposal, never fired and forgotten alongside it.
15
+ * 2. Pi's runner catches each handler's errors but does not bound a handler that
16
+ * hangs. An unbounded await here would stall the parent's teardown and Pi's exit,
17
+ * so the emit races a timeout and teardown proceeds either way.
18
+ *
19
+ * Nothing here throws: a child that will not shut down cleanly must still be disposed.
20
+ */
21
+
22
+ import { debugLog } from "#src/debug";
23
+
24
+ /** Upper bound on how long one child's shutdown handlers may take. */
25
+ export const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
26
+
27
+ const SESSION_SHUTDOWN = "session_shutdown";
28
+
29
+ /** The event a disposed child session dispatches to its extensions. */
30
+ interface ChildSessionShutdownEvent {
31
+ type: typeof SESSION_SHUTDOWN;
32
+ reason: "quit";
33
+ }
34
+
35
+ /**
36
+ * Narrow session seam — only what child shutdown reads.
37
+ *
38
+ * Both members are optional so the emitter stays safe against a Pi build that
39
+ * predates them; either absence means "nothing to emit."
40
+ */
41
+ export interface ShutdownCapableSession {
42
+ hasExtensionHandlers?(eventType: string): boolean;
43
+ readonly extensionRunner?: {
44
+ emit(event: ChildSessionShutdownEvent): Promise<unknown>;
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Dispatch one `session_shutdown` to a child's extensions and await it, bounded
50
+ * by `timeoutMs`. Resolves quietly on every failure mode — no runner, no handlers,
51
+ * a rejected emit, or a handler that never settles — so disposal always proceeds.
52
+ */
53
+ export async function emitChildSessionShutdown(
54
+ session: ShutdownCapableSession,
55
+ timeoutMs: number = CHILD_SHUTDOWN_TIMEOUT_MS,
56
+ ): Promise<void> {
57
+ const runner = session.extensionRunner;
58
+ if (!runner || !hasShutdownHandlers(session)) return;
59
+
60
+ try {
61
+ const emitted = runner.emit({ type: SESSION_SHUTDOWN, reason: "quit" });
62
+ if ((await settleWithinBound(emitted, timeoutMs)) === "timed-out") {
63
+ debugLog("child session_shutdown exceeded its bound (ms)", timeoutMs);
64
+ }
65
+ } catch (err) {
66
+ debugLog("child session_shutdown emit", err);
67
+ }
68
+ }
69
+
70
+ /** Whether the child has anything to hear the event; unknown counts as yes. */
71
+ function hasShutdownHandlers(session: ShutdownCapableSession): boolean {
72
+ return session.hasExtensionHandlers?.(SESSION_SHUTDOWN) ?? true;
73
+ }
74
+
75
+ type ShutdownOutcome = "settled" | "timed-out";
76
+
77
+ /**
78
+ * Resolve when `emitted` settles or when `timeoutMs` elapses, whichever is first.
79
+ * A rejection counts as settled (the caller logs it); the timer is always cleared,
80
+ * so a prompt shutdown leaves nothing holding the event loop.
81
+ */
82
+ async function settleWithinBound(
83
+ emitted: Promise<unknown>,
84
+ timeoutMs: number,
85
+ ): Promise<ShutdownOutcome> {
86
+ let timer: ReturnType<typeof setTimeout> | undefined;
87
+ const bound = new Promise<ShutdownOutcome>((resolve) => {
88
+ timer = setTimeout(() => {
89
+ resolve("timed-out");
90
+ }, timeoutMs);
91
+ });
92
+ const settled = emitted.then(
93
+ (): ShutdownOutcome => "settled",
94
+ (err: unknown): ShutdownOutcome => {
95
+ debugLog("child session_shutdown handler", err);
96
+ return "settled";
97
+ },
98
+ );
99
+
100
+ try {
101
+ return await Promise.race([settled, bound]);
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * concurrency-limiter.ts — FIFO admission gate for background work.
3
+ *
4
+ * Schedules run closures (thunks) against a dynamic limit, running them in
5
+ * scheduling order as slots free. The limiter knows nothing about agents, IDs,
6
+ * or the manager — it owns only the active count and the pending queue.
7
+ *
8
+ * Every scheduled promise settles: it follows the task's settlement when the
9
+ * task runs, or resolves early if clear() drops it before it starts.
10
+ */
11
+
12
+ export class ConcurrencyLimiter {
13
+ private active = 0;
14
+ private readonly pending: Array<{ start: () => void; settle: () => void }> = [];
15
+
16
+ constructor(private readonly getLimit: () => number) {}
17
+
18
+ /**
19
+ * Schedule a task to run FIFO once a slot is free.
20
+ * Returns a promise that settles with the task, or resolves early if the
21
+ * task is dropped by clear() before it starts.
22
+ */
23
+ schedule(task: () => Promise<void>): Promise<void> {
24
+ const { promise, resolve, reject } = Promise.withResolvers<void>(); // eslint-disable-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void> is valid; rule does not allow void in generic fn call type args
25
+ this.pending.push({
26
+ start: () => {
27
+ this.active++;
28
+ task()
29
+ .then(resolve, reject)
30
+ .finally(() => {
31
+ this.active--;
32
+ this.recheck();
33
+ });
34
+ },
35
+ settle: resolve,
36
+ });
37
+ this.recheck();
38
+ return promise;
39
+ }
40
+
41
+ /** Start pending tasks until the limit is reached. Call when the limit may have grown. */
42
+ recheck(): void {
43
+ while (this.active < this.getLimit()) {
44
+ const next = this.pending.shift();
45
+ if (!next) break;
46
+ next.start();
47
+ }
48
+ }
49
+
50
+ /** Drop all pending tasks, resolving their promises without running them. */
51
+ clear(): void {
52
+ const dropped = this.pending.splice(0);
53
+ for (const task of dropped) task.settle();
54
+ }
55
+ }
@@ -0,0 +1,335 @@
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 (with the recursion guard as a tool denylist), publish
7
+ * `spawning`/`session-created`, and bind extensions. It returns a fully usable
8
+ * `SubagentSession` — `Subagent` 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
+ type ToolDefinition,
20
+ } from "@earendil-works/pi-coding-agent";
21
+ import type { AgentConfigLookup } from "#src/config/agent-types";
22
+ import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
23
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
24
+ import { SelectionCancelledError } from "#src/lifecycle/spawn-selection";
25
+ import { SubagentSession } from "#src/lifecycle/subagent-session";
26
+ import { AskParentTool, type QuestionRecorder } from "#src/session/ask-parent-tool";
27
+ import type { EnvInfo } from "#src/session/env";
28
+ import type { ModelRegistry } from "#src/session/model-resolver";
29
+ import { NotifyParentTool, type UpdateAnnouncer } from "#src/session/notify-parent-tool";
30
+ import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
31
+ import type {
32
+ ParentSessionInfo,
33
+ PromptInheritance,
34
+ ShellExec,
35
+ SubagentType,
36
+ ThinkingLevel,
37
+ } from "#src/types";
38
+
39
+ /**
40
+ * Recursion guard: names of tools registered by this extension that subagents
41
+ * must NOT inherit. Passed to the SDK as a denylist, which it applies whenever
42
+ * it rebuilds the child's tool registry — including the rebuild triggered by a
43
+ * child extension registering a tool of its own. Filtering the active set once
44
+ * after `bindExtensions` would be undone by that rebuild (#725).
45
+ */
46
+ const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
47
+
48
+ // ── IO boundary ───────────────────────────────────────────────────────────────
49
+
50
+ /** Minimal resource-loader contract used by the factory. */
51
+ export interface ResourceLoaderLike {
52
+ reload(): Promise<void>;
53
+ }
54
+
55
+ /** Minimal session-manager contract used by the factory. */
56
+ export interface SessionManagerLike {
57
+ newSession(opts: { parentSession?: string }): void;
58
+ getSessionFile(): string | undefined;
59
+ getSessionId(): string;
60
+ }
61
+
62
+ /** Options passed to EnvironmentIO/SessionFactoryIO methods. */
63
+ export interface ResourceLoaderOptions {
64
+ cwd: string;
65
+ agentDir: string;
66
+ /** Settings the loader resolves packages from; defaults to the ambient ones when absent. */
67
+ settingsManager?: SettingsManager;
68
+ noPromptTemplates?: boolean;
69
+ noThemes?: boolean;
70
+ noContextFiles?: boolean;
71
+ systemPromptOverride?: () => string;
72
+ /** Override the append system prompt. Receives the current base value; return the replacement. */
73
+ appendSystemPromptOverride?: (base: string[]) => string[];
74
+ }
75
+
76
+ /** Options passed to SessionFactoryIO.createSession. */
77
+ export interface CreateSessionOptions {
78
+ cwd: string;
79
+ agentDir: string;
80
+ sessionManager: SessionManagerLike;
81
+ settingsManager: SettingsManager;
82
+ modelRegistry: ModelRegistry;
83
+ model?: Model<any>;
84
+ /** Allowlist: only these tool names are enabled in the session. */
85
+ tools: string[];
86
+ /**
87
+ * Tool definitions supplied directly rather than by an extension. The SDK
88
+ * filters these through `tools` too, so every name here must also be listed
89
+ * there or the definition is silently dropped.
90
+ */
91
+ customTools?: ToolDefinition[];
92
+ /** Denylist applied after `tools`, on every tool-registry rebuild. */
93
+ excludeTools?: string[];
94
+ resourceLoader: ResourceLoaderLike;
95
+ thinkingLevel?: ThinkingLevel;
96
+ }
97
+
98
+ /**
99
+ * Environment discovery - detect runtime context and resolve directories.
100
+ *
101
+ * Decouples the factory from direct process/SDK reads so each can be stubbed
102
+ * independently in tests.
103
+ */
104
+ export interface EnvironmentIO {
105
+ detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
106
+ getAgentDir: () => string;
107
+ deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
108
+ }
109
+
110
+ /**
111
+ * Session factory - create SDK objects for a child agent session.
112
+ *
113
+ * Decouples the factory from direct Pi SDK imports and sibling-module IO,
114
+ * making it testable via plain stub objects without vi.mock().
115
+ */
116
+ export interface SessionFactoryIO {
117
+ createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
118
+ createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
119
+ createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
120
+ /**
121
+ * Settings view the child's resource loader resolves packages from.
122
+ * The composition root decides whether any package extensions are excluded;
123
+ * the identity function reproduces the child's default full inheritance.
124
+ */
125
+ createLoaderSettingsManager: (parent: SettingsManager) => SettingsManager;
126
+ createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
127
+ assemblerIO: AssemblerIO;
128
+ }
129
+
130
+ /**
131
+ * IO boundary injected into createSubagentSession().
132
+ *
133
+ * Intersection of EnvironmentIO and SessionFactoryIO — callers satisfy both
134
+ * sub-interfaces via TypeScript's structural typing.
135
+ */
136
+ export type SubagentSessionIO = EnvironmentIO & SessionFactoryIO;
137
+
138
+ /**
139
+ * Dependencies injected at construction time — the IO boundary plus the two
140
+ * static domain deps (exec, registry) every creation needs.
141
+ */
142
+ export interface SubagentSessionDeps {
143
+ io: SubagentSessionIO;
144
+ exec: ShellExec;
145
+ registry: AgentConfigLookup;
146
+ /** Publishes the child-execution lifecycle so consumers can observe it. */
147
+ lifecycle: ChildLifecyclePublisher;
148
+ /**
149
+ * Which prompt-inheritance strategy a child on the given provider adopts.
150
+ *
151
+ * Resolved at the composition root from the operator's settings, so this
152
+ * factory stays policy-free — the same shape the extension-exclusion policy
153
+ * reaches it in.
154
+ */
155
+ resolvePromptInheritance: (provider: string | undefined) => PromptInheritance;
156
+ }
157
+
158
+ /** Per-spawn parameters — the fields that vary per child session. */
159
+ export interface CreateSubagentSessionParams {
160
+ snapshot: ParentSnapshot;
161
+ type: SubagentType;
162
+ /** Resolved workspace cwd; undefined → parent cwd. */
163
+ cwd?: string;
164
+ /** Parent session identity (file path + session ID). */
165
+ parentSession?: ParentSessionInfo;
166
+ model?: Model<any>;
167
+ thinkingLevel?: ThinkingLevel;
168
+ /**
169
+ * Combined abort signal for a gated run (a selection was required). Checked
170
+ * after the asynchronous preparation awaits and immediately before the SDK
171
+ * session-creation call; a session that creation returned anyway is disposed
172
+ * before binding. Absent on the ordinary no-provider path.
173
+ */
174
+ selectionSignal?: AbortSignal;
175
+ /**
176
+ * Records a question the child declares with `ask_parent`. Supplied for every
177
+ * child; its absence installs no ask-back tool.
178
+ */
179
+ askParent?: QuestionRecorder;
180
+ /**
181
+ * Announces a mid-run update the child sends with `notify_parent`. Supplied
182
+ * only for a background child whose operator left the channel on; its absence
183
+ * installs no update tool.
184
+ */
185
+ notifyParent?: UpdateAnnouncer;
186
+ }
187
+
188
+ /**
189
+ * The core's own child-facing tools, built for whichever callbacks this run
190
+ * supplied. An agent's `tools:` list is its complete capability allowlist, so
191
+ * these are appended to it rather than drawn from it: they are protocol the
192
+ * core installs in every child, and neither reaches the filesystem, the shell,
193
+ * or the network.
194
+ */
195
+ function buildChildTools(params: CreateSubagentSessionParams): ToolDefinition[] {
196
+ const tools: ToolDefinition[] = [];
197
+ if (params.askParent) tools.push(new AskParentTool(params.askParent).toToolDefinition());
198
+ if (params.notifyParent)
199
+ tools.push(new NotifyParentTool(params.notifyParent).toToolDefinition());
200
+ return tools;
201
+ }
202
+
203
+ /**
204
+ * Build a born-complete SubagentSession: assemble config, create the SDK
205
+ * session, publish lifecycle events, bind extensions, apply the recursion guard.
206
+ */
207
+ export async function createSubagentSession(
208
+ params: CreateSubagentSessionParams,
209
+ deps: SubagentSessionDeps,
210
+ ): Promise<SubagentSession> {
211
+ const { snapshot, type } = params;
212
+ const parentSessionId = params.parentSession?.parentSessionId;
213
+ deps.lifecycle.spawning({ agentName: type, parentSessionId });
214
+
215
+ // Resolve working directory upfront - needed for detectEnv before assembly.
216
+ const effectiveCwd = params.cwd ?? snapshot.cwd;
217
+ const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
218
+
219
+ // Assemble session configuration (synchronous, no SDK objects).
220
+ const cfg = assembleSessionConfig(
221
+ type,
222
+ {
223
+ cwd: snapshot.cwd,
224
+ parentSystemPrompt: snapshot.systemPrompt,
225
+ parentPortablePrompt: snapshot.portablePrompt,
226
+ parentModel: snapshot.model,
227
+ modelRegistry: snapshot.modelRegistry,
228
+ resolvePromptInheritance: deps.resolvePromptInheritance,
229
+ },
230
+ {
231
+ cwd: params.cwd,
232
+ model: params.model,
233
+ thinkingLevel: params.thinkingLevel,
234
+ },
235
+ env,
236
+ deps.registry,
237
+ deps.io.assemblerIO,
238
+ );
239
+
240
+ const agentDir = deps.io.getAgentDir();
241
+ const sessionSettings = deps.io.createSettingsManager(cfg.effectiveCwd, agentDir);
242
+ const loaderSettings = deps.io.createLoaderSettingsManager(sessionSettings);
243
+
244
+ // Children inherit the parent's skills and every extension the composition
245
+ // root did not exclude (#696).
246
+ //
247
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
248
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
249
+ // would defeat prompt_mode: replace. Parent context, if wanted, reaches the
250
+ // subagent via prompt_mode: append (parentSystemPrompt is embedded in
251
+ // systemPromptOverride) or inherit_context (conversation).
252
+ const loader = deps.io.createResourceLoader({
253
+ cwd: cfg.effectiveCwd,
254
+ agentDir,
255
+ settingsManager: loaderSettings,
256
+ noPromptTemplates: true,
257
+ noThemes: true,
258
+ noContextFiles: true,
259
+ systemPromptOverride: () => cfg.systemPrompt,
260
+ appendSystemPromptOverride: () => [],
261
+ });
262
+ await loader.reload();
263
+
264
+ // Create a persisted SessionManager so transcripts are written in Pi's
265
+ // official JSONL format. Falls back to a temp directory when the parent
266
+ // session is not persisted (e.g. headless/API mode).
267
+ const sessionDir = deps.io.deriveSessionDir(params.parentSession?.parentSessionFile, cfg.effectiveCwd);
268
+ const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
269
+ sessionManager.newSession({ parentSession: params.parentSession?.parentSessionId });
270
+ const sessionId = sessionManager.getSessionId();
271
+
272
+ // A gated run rechecks its signal after the environment/loader awaits:
273
+ // revocation during loader.reload() must not reach SDK creation.
274
+ if (params.selectionSignal?.aborted) {
275
+ throw new SelectionCancelledError();
276
+ }
277
+
278
+ const childTools = buildChildTools(params);
279
+ const { session } = await deps.io.createSession({
280
+ cwd: cfg.effectiveCwd,
281
+ agentDir,
282
+ sessionManager,
283
+ settingsManager: sessionSettings,
284
+ modelRegistry: snapshot.modelRegistry,
285
+ model: cfg.model,
286
+ tools: [...cfg.toolNames, ...childTools.map((tool) => tool.name)],
287
+ customTools: childTools,
288
+ excludeTools: EXCLUDED_TOOL_NAMES,
289
+ resourceLoader: loader,
290
+ thinkingLevel: cfg.thinkingLevel,
291
+ });
292
+
293
+ // Creation had already begun when the cancellation landed; the SDK call
294
+ // cannot be undone, but its session is torn down before binding or prompting.
295
+ if (params.selectionSignal?.aborted) {
296
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
297
+ session.dispose?.();
298
+ throw new SelectionCancelledError();
299
+ }
300
+
301
+ const subagentSession = new SubagentSession(session, {
302
+ outputFile: sessionManager.getSessionFile(),
303
+ sessionId,
304
+ sessionDir,
305
+ agentName: type,
306
+ agentMaxTurns: cfg.agentMaxTurns,
307
+ parentContext: snapshot.parentContext,
308
+ lifecycle: deps.lifecycle,
309
+ });
310
+
311
+ // Publish session-created before bindExtensions() so observers (e.g. the
312
+ // permission system) can register the child synchronously and have their
313
+ // entry in place for the first permission check during child extension
314
+ // initialization. The event bus dispatches synchronously, so a synchronous
315
+ // subscriber completes before this returns.
316
+ deps.lifecycle.sessionCreated({ sessionId, parentSessionId });
317
+
318
+ try {
319
+ // Bind extensions so that session_start fires and extensions can initialize.
320
+ await session.bindExtensions({});
321
+ } catch (err) {
322
+ // Binding failed after session-created — dispose (child session_shutdown +
323
+ // session.dispose() + emit disposed) before rethrowing so neither the
324
+ // registration nor a partially-initialized extension's resources leak.
325
+ await subagentSession.dispose();
326
+ throw err;
327
+ }
328
+
329
+ // Every child session_start handler has now run, so this is the first — and
330
+ // only — moment a parent can observe what the child's extensions installed.
331
+ // Deliberately outside the try above: a child whose binding threw never ran.
332
+ deps.lifecycle.bound({ sessionId, parentSessionId });
333
+
334
+ return subagentSession;
335
+ }