@yefengr/pi-subagents 0.0.0-stage → 0.1.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 (103) hide show
  1. package/CHANGELOG.md +2411 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/UPSTREAM.md +48 -0
  5. package/dist/public.d.ts +201 -0
  6. package/dist/settings.d.ts +82 -0
  7. package/docs/architecture/architecture.md +833 -0
  8. package/docs/architecture/client-server-opportunities.md +127 -0
  9. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  10. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  11. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  12. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  13. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  14. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  15. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  16. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  17. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  18. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  19. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  20. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  21. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  22. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  23. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  24. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  25. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  26. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  27. package/docs/architecture/history/phase-8-testability.md +103 -0
  28. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  29. package/docs/comparison-with-upstream.md +79 -0
  30. package/docs/configuration.md +215 -0
  31. package/docs/decisions/0001-deferred-patches.md +80 -0
  32. package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
  33. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  34. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  35. package/index.ts +1 -0
  36. package/package.json +91 -4
  37. package/src/config/agent-enablement.ts +132 -0
  38. package/src/config/agent-types.ts +144 -0
  39. package/src/config/custom-agents.ts +114 -0
  40. package/src/config/default-agents.ts +120 -0
  41. package/src/config/invocation-config.ts +39 -0
  42. package/src/config/resource-limits.ts +26 -0
  43. package/src/config/settings-file.ts +174 -0
  44. package/src/debug.ts +14 -0
  45. package/src/handlers/index.ts +3 -0
  46. package/src/handlers/interrupt.ts +58 -0
  47. package/src/handlers/lifecycle.ts +65 -0
  48. package/src/handlers/tool-start.ts +32 -0
  49. package/src/index.ts +257 -0
  50. package/src/layered-settings.ts +105 -0
  51. package/src/lifecycle/child-lifecycle.ts +89 -0
  52. package/src/lifecycle/child-shutdown.ts +105 -0
  53. package/src/lifecycle/concurrency-limiter.ts +123 -0
  54. package/src/lifecycle/create-subagent-session.ts +265 -0
  55. package/src/lifecycle/invocation-control.ts +109 -0
  56. package/src/lifecycle/lifecycle-record.ts +159 -0
  57. package/src/lifecycle/parent-snapshot.ts +46 -0
  58. package/src/lifecycle/run-listeners.ts +41 -0
  59. package/src/lifecycle/subagent-manager.ts +392 -0
  60. package/src/lifecycle/subagent-session.ts +287 -0
  61. package/src/lifecycle/subagent-state.ts +307 -0
  62. package/src/lifecycle/subagent.ts +854 -0
  63. package/src/lifecycle/usage.ts +60 -0
  64. package/src/lifecycle/workspace-bracket.ts +59 -0
  65. package/src/lifecycle/workspace.ts +47 -0
  66. package/src/observation/composite-subagent-observer.ts +53 -0
  67. package/src/observation/notification.ts +233 -0
  68. package/src/observation/record-observer.ts +78 -0
  69. package/src/observation/renderer.ts +106 -0
  70. package/src/observation/subagent-events-observer.ts +75 -0
  71. package/src/runtime.ts +78 -0
  72. package/src/service/service-adapter.ts +159 -0
  73. package/src/service/service.ts +147 -0
  74. package/src/session/content-items.ts +53 -0
  75. package/src/session/context.ts +80 -0
  76. package/src/session/conversation.ts +49 -0
  77. package/src/session/env.ts +40 -0
  78. package/src/session/model-resolver.ts +135 -0
  79. package/src/session/package-exclusions.ts +75 -0
  80. package/src/session/prompts.ts +145 -0
  81. package/src/session/provider-inheritance.ts +38 -0
  82. package/src/session/session-config.ts +177 -0
  83. package/src/session/session-dir.ts +38 -0
  84. package/src/settings.ts +436 -0
  85. package/src/tools/agent-tool.ts +289 -0
  86. package/src/tools/background-spawner.ts +73 -0
  87. package/src/tools/foreground-runner.ts +135 -0
  88. package/src/tools/get-result-report.ts +73 -0
  89. package/src/tools/get-result-tool.ts +109 -0
  90. package/src/tools/helpers.ts +134 -0
  91. package/src/tools/result-renderer.ts +121 -0
  92. package/src/tools/spawn-config.ts +182 -0
  93. package/src/tools/steer-tool.ts +120 -0
  94. package/src/types.ts +145 -0
  95. package/src/ui/agent-widget.ts +313 -0
  96. package/src/ui/display.ts +214 -0
  97. package/src/ui/glyphs.ts +62 -0
  98. package/src/ui/session-navigation.ts +152 -0
  99. package/src/ui/session-navigator.ts +257 -0
  100. package/src/ui/subagents-agents.ts +116 -0
  101. package/src/ui/subagents-settings.ts +207 -0
  102. package/src/ui/transcript-content.ts +374 -0
  103. package/src/ui/widget-renderer.ts +289 -0
@@ -0,0 +1,265 @@
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
+ } 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 { ModelRegistry } from "#src/session/model-resolver";
26
+ import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
27
+ import type {
28
+ ParentSessionInfo,
29
+ ShellExec,
30
+ SubagentSessionIdentity,
31
+ SubagentType,
32
+ ThinkingLevel,
33
+ } from "#src/types";
34
+
35
+ /**
36
+ * Recursion guard: names of tools registered by this extension that subagents
37
+ * must NOT inherit. Passed to the SDK as a denylist, which it applies whenever
38
+ * it rebuilds the child's tool registry — including the rebuild triggered by a
39
+ * child extension registering a tool of its own. Filtering the active set once
40
+ * after `bindExtensions` would be undone by that rebuild (#725).
41
+ */
42
+ const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
43
+
44
+ // ── IO boundary ───────────────────────────────────────────────────────────────
45
+
46
+ /** Minimal resource-loader contract used by the factory. */
47
+ export interface ResourceLoaderLike {
48
+ reload(): Promise<void>;
49
+ }
50
+
51
+ /** Minimal session-manager contract used by the factory. */
52
+ export interface SessionManagerLike {
53
+ newSession(opts: { parentSession?: string }): void;
54
+ getSessionFile(): string | undefined;
55
+ getSessionId(): string;
56
+ }
57
+
58
+ /** Options passed to EnvironmentIO/SessionFactoryIO methods. */
59
+ export interface ResourceLoaderOptions {
60
+ cwd: string;
61
+ agentDir: string;
62
+ /** Settings the loader resolves packages from; defaults to the ambient ones when absent. */
63
+ settingsManager?: SettingsManager;
64
+ noPromptTemplates?: boolean;
65
+ noThemes?: boolean;
66
+ noContextFiles?: boolean;
67
+ systemPromptOverride?: () => string;
68
+ /** Override the append system prompt. Receives the current base value; return the replacement. */
69
+ appendSystemPromptOverride?: (base: string[]) => string[];
70
+ }
71
+
72
+ /** Options passed to SessionFactoryIO.createSession. */
73
+ export interface CreateSessionOptions {
74
+ cwd: string;
75
+ agentDir: string;
76
+ sessionManager: SessionManagerLike;
77
+ settingsManager: SettingsManager;
78
+ modelRegistry: ModelRegistry;
79
+ model?: Model<any>;
80
+ /** Allowlist: only these tool names are enabled in the session. */
81
+ tools: string[];
82
+ /** Denylist applied after `tools`, on every tool-registry rebuild. */
83
+ excludeTools?: string[];
84
+ resourceLoader: ResourceLoaderLike;
85
+ thinkingLevel?: ThinkingLevel;
86
+ }
87
+
88
+ /**
89
+ * Environment discovery - detect runtime context and resolve directories.
90
+ *
91
+ * Decouples the factory from direct process/SDK reads so each can be stubbed
92
+ * independently in tests.
93
+ */
94
+ export interface EnvironmentIO {
95
+ detectEnv: (exec: ShellExec, cwd: string) => Promise<EnvInfo>;
96
+ getAgentDir: () => string;
97
+ deriveSessionDir: (parentSessionFile: string | undefined, effectiveCwd: string) => string;
98
+ }
99
+
100
+ /**
101
+ * Session factory - create SDK objects for a child agent session.
102
+ *
103
+ * Decouples the factory from direct Pi SDK imports and sibling-module IO,
104
+ * making it testable via plain stub objects without vi.mock().
105
+ */
106
+ export interface SessionFactoryIO {
107
+ createResourceLoader: (opts: ResourceLoaderOptions) => ResourceLoaderLike;
108
+ createSessionManager: (cwd: string, sessionDir: string) => SessionManagerLike;
109
+ createSettingsManager: (cwd: string, agentDir: string) => SettingsManager;
110
+ /**
111
+ * Settings view the child's resource loader resolves packages from.
112
+ * The composition root decides whether any package extensions are excluded;
113
+ * the identity function reproduces the child's default full inheritance.
114
+ */
115
+ createLoaderSettingsManager: (parent: SettingsManager) => SettingsManager;
116
+ createSession: (opts: CreateSessionOptions) => Promise<{ session: AgentSession }>;
117
+ assemblerIO: AssemblerIO;
118
+ }
119
+
120
+ /**
121
+ * IO boundary injected into createSubagentSession().
122
+ *
123
+ * Intersection of EnvironmentIO and SessionFactoryIO — callers satisfy both
124
+ * sub-interfaces via TypeScript's structural typing.
125
+ */
126
+ export type SubagentSessionIO = EnvironmentIO & SessionFactoryIO;
127
+
128
+ /**
129
+ * Dependencies injected at construction time — the IO boundary plus the two
130
+ * static domain deps (exec, registry) every creation needs.
131
+ */
132
+ export interface SubagentSessionDeps {
133
+ io: SubagentSessionIO;
134
+ exec: ShellExec;
135
+ registry: AgentConfigLookup;
136
+ /** Publishes the child-execution lifecycle so consumers can observe it. */
137
+ lifecycle: ChildLifecyclePublisher;
138
+ }
139
+
140
+ /** Per-spawn parameters — the fields that vary per child session. */
141
+ export interface CreateSubagentSessionParams {
142
+ snapshot: ParentSnapshot;
143
+ type: SubagentType;
144
+ /** Resolved workspace cwd; undefined → parent cwd. */
145
+ cwd?: string;
146
+ /** Parent session identity (file path + session ID). */
147
+ parentSession?: ParentSessionInfo;
148
+ /** Captures stable child identity before extension binding can fail. */
149
+ onSessionCreated?: (identity: SubagentSessionIdentity) => void;
150
+ model?: Model<any>;
151
+ thinkingLevel?: ThinkingLevel;
152
+ }
153
+
154
+ /**
155
+ * Build a born-complete SubagentSession: assemble config, create the SDK
156
+ * session, publish lifecycle events, bind extensions, apply the recursion guard.
157
+ */
158
+ export async function createSubagentSession(
159
+ params: CreateSubagentSessionParams,
160
+ deps: SubagentSessionDeps,
161
+ ): Promise<SubagentSession> {
162
+ const { snapshot, type } = params;
163
+ const parentSessionId = params.parentSession?.parentSessionId;
164
+ deps.lifecycle.spawning({ agentName: type, parentSessionId });
165
+
166
+ // Resolve working directory upfront - needed for detectEnv before assembly.
167
+ const effectiveCwd = params.cwd ?? snapshot.cwd;
168
+ const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
169
+
170
+ // Assemble session configuration (synchronous, no SDK objects).
171
+ const cfg = assembleSessionConfig(
172
+ type,
173
+ {
174
+ cwd: snapshot.cwd,
175
+ parentSystemPrompt: snapshot.systemPrompt,
176
+ parentModel: snapshot.model,
177
+ modelRegistry: snapshot.modelRegistry,
178
+ },
179
+ {
180
+ cwd: params.cwd,
181
+ model: params.model,
182
+ thinkingLevel: params.thinkingLevel,
183
+ },
184
+ env,
185
+ deps.registry,
186
+ deps.io.assemblerIO,
187
+ );
188
+
189
+ const agentDir = deps.io.getAgentDir();
190
+ const sessionSettings = deps.io.createSettingsManager(cfg.effectiveCwd, agentDir);
191
+ const loaderSettings = deps.io.createLoaderSettingsManager(sessionSettings);
192
+
193
+ // Children inherit the parent's skills and every extension the composition
194
+ // root did not exclude (#696).
195
+ //
196
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
197
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
198
+ // would defeat prompt_mode: replace. Parent context, if wanted, reaches the
199
+ // subagent via prompt_mode: append (parentSystemPrompt is embedded in
200
+ // systemPromptOverride) or inherit_context (conversation).
201
+ const loader = deps.io.createResourceLoader({
202
+ cwd: cfg.effectiveCwd,
203
+ agentDir,
204
+ settingsManager: loaderSettings,
205
+ noPromptTemplates: true,
206
+ noThemes: true,
207
+ noContextFiles: true,
208
+ systemPromptOverride: () => cfg.systemPrompt,
209
+ appendSystemPromptOverride: () => [],
210
+ });
211
+ await loader.reload();
212
+
213
+ // Create a persisted SessionManager so transcripts are written in Pi's
214
+ // official JSONL format. Falls back to a temp directory when the parent
215
+ // session is not persisted (e.g. headless/API mode).
216
+ const sessionDir = deps.io.deriveSessionDir(params.parentSession?.parentSessionFile, cfg.effectiveCwd);
217
+ const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
218
+ sessionManager.newSession({ parentSession: params.parentSession?.parentSessionId });
219
+ const sessionId = sessionManager.getSessionId();
220
+
221
+ const { session } = await deps.io.createSession({
222
+ cwd: cfg.effectiveCwd,
223
+ agentDir,
224
+ sessionManager,
225
+ settingsManager: sessionSettings,
226
+ modelRegistry: snapshot.modelRegistry,
227
+ model: cfg.model,
228
+ tools: cfg.toolNames,
229
+ excludeTools: EXCLUDED_TOOL_NAMES,
230
+ resourceLoader: loader,
231
+ thinkingLevel: cfg.thinkingLevel,
232
+ });
233
+
234
+ const outputFile = sessionManager.getSessionFile();
235
+ const subagentSession = new SubagentSession(session, {
236
+ outputFile,
237
+ sessionId,
238
+ sessionDir,
239
+ agentName: type,
240
+ parentContext: snapshot.parentContext,
241
+ lifecycle: deps.lifecycle,
242
+ });
243
+
244
+ params.onSessionCreated?.({ sessionId, outputFile });
245
+
246
+ // Publish session-created before bindExtensions() so observers (e.g. the
247
+ // permission system) can register the child synchronously and have their
248
+ // entry in place for the first permission check during child extension
249
+ // initialization. The event bus dispatches synchronously, so a synchronous
250
+ // subscriber completes before this returns.
251
+ deps.lifecycle.sessionCreated({ sessionId, parentSessionId });
252
+
253
+ try {
254
+ // Bind extensions so that session_start fires and extensions can initialize.
255
+ await session.bindExtensions({});
256
+ } catch (err) {
257
+ // Binding failed after session-created — dispose (child session_shutdown +
258
+ // session.dispose() + emit disposed) before rethrowing so neither the
259
+ // registration nor a partially-initialized extension's resources leak.
260
+ await subagentSession.dispose();
261
+ throw err;
262
+ }
263
+
264
+ return subagentSession;
265
+ }
@@ -0,0 +1,109 @@
1
+ export class InvocationTimeoutError extends Error {
2
+ constructor(readonly timeoutMs: number) {
3
+ super(`Subagent invocation timed out after ${Math.ceil(timeoutMs / 1000)} seconds`);
4
+ this.name = "InvocationTimeoutError";
5
+ }
6
+ }
7
+
8
+ export class InvocationCancelledError extends Error {
9
+ constructor() {
10
+ super("Subagent invocation was cancelled");
11
+ this.name = "InvocationCancelledError";
12
+ }
13
+ }
14
+
15
+ export interface InvocationControlOptions {
16
+ maxDurationMs: number;
17
+ abortGraceMs: number;
18
+ cancelSignal: AbortSignal;
19
+ onTimeout: () => void;
20
+ }
21
+
22
+ /**
23
+ * Applies one wall-clock budget to a complete invocation. An in-flight timeout
24
+ * or cancellation first aborts cooperative work, then releases the waiter after
25
+ * the abort grace when that work does not settle.
26
+ */
27
+ export class InvocationControl {
28
+ private readonly timeoutController = new AbortController();
29
+ private readonly timeoutError: InvocationTimeoutError;
30
+ private readonly failure: Promise<never>;
31
+ private deadlineTimer: ReturnType<typeof setTimeout> | undefined;
32
+ private graceTimer: ReturnType<typeof setTimeout> | undefined;
33
+ private rejectFailure!: (reason: unknown) => void;
34
+ private timedOut = false;
35
+ private cancelled = false;
36
+ private disposed = false;
37
+
38
+ readonly signal: AbortSignal;
39
+
40
+ constructor(private readonly options: InvocationControlOptions) {
41
+ this.timeoutError = new InvocationTimeoutError(options.maxDurationMs);
42
+ this.signal = AbortSignal.any([options.cancelSignal, this.timeoutController.signal]);
43
+ this.failure = new Promise<never>((_resolve, reject) => {
44
+ this.rejectFailure = reject;
45
+ });
46
+ void this.failure.catch(() => {});
47
+
48
+ if (options.cancelSignal.aborted) {
49
+ this.rejectFailure(new InvocationCancelledError());
50
+ } else {
51
+ options.cancelSignal.addEventListener("abort", this.handleCancel, { once: true });
52
+ }
53
+
54
+ this.deadlineTimer = setTimeout(this.handleDeadline, options.maxDurationMs);
55
+ this.deadlineTimer.unref?.();
56
+ }
57
+
58
+ get didTimeOut(): boolean {
59
+ return this.timedOut;
60
+ }
61
+
62
+ async waitFor<T>(operation: Promise<T>): Promise<T> {
63
+ try {
64
+ const value = await Promise.race([operation, this.failure]);
65
+ if (this.timedOut) throw this.timeoutError;
66
+ return value;
67
+ } catch (error) {
68
+ if (this.timedOut) throw this.timeoutError;
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ dispose(): void {
74
+ if (this.disposed) return;
75
+ this.disposed = true;
76
+ if (this.deadlineTimer) clearTimeout(this.deadlineTimer);
77
+ if (this.graceTimer) clearTimeout(this.graceTimer);
78
+ this.options.cancelSignal.removeEventListener("abort", this.handleCancel);
79
+ }
80
+
81
+ private readonly handleCancel = (): void => {
82
+ if (this.disposed || this.cancelled) return;
83
+ this.cancelled = true;
84
+ if (this.deadlineTimer) clearTimeout(this.deadlineTimer);
85
+ this.deadlineTimer = undefined;
86
+ this.graceTimer = setTimeout(
87
+ () => this.rejectFailure(new InvocationCancelledError()),
88
+ this.options.abortGraceMs,
89
+ );
90
+ this.graceTimer.unref?.();
91
+ };
92
+
93
+ private readonly handleDeadline = (): void => {
94
+ if (this.disposed || this.timedOut) return;
95
+ this.timedOut = true;
96
+ this.options.onTimeout();
97
+ this.timeoutController.abort(this.timeoutError);
98
+ this.graceTimer = setTimeout(() => this.rejectFailure(this.timeoutError), this.options.abortGraceMs);
99
+ this.graceTimer.unref?.();
100
+ };
101
+ }
102
+
103
+ export function isInvocationTimeout(error: unknown): error is InvocationTimeoutError {
104
+ return error instanceof InvocationTimeoutError;
105
+ }
106
+
107
+ export function isInvocationCancelled(error: unknown): error is InvocationCancelledError {
108
+ return error instanceof InvocationCancelledError;
109
+ }
@@ -0,0 +1,159 @@
1
+ import type { Subagent } from "#src/lifecycle/subagent";
2
+ import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent-state";
3
+ import type { LifetimeUsage } from "#src/lifecycle/usage";
4
+
5
+ export const SUBAGENT_LIFECYCLE_RECORD_VERSION = 3 as const;
6
+
7
+ export interface LifecycleUsageV3 {
8
+ input: number;
9
+ output: number;
10
+ cacheWrite: number;
11
+ total: number;
12
+ }
13
+
14
+ export interface SubagentLifecycleDataV3 {
15
+ recordVersion: typeof SUBAGENT_LIFECYCLE_RECORD_VERSION;
16
+ id: string;
17
+ type: string;
18
+ description: string;
19
+ phase: SubagentPhase;
20
+ outcome?: SubagentOutcome;
21
+ isBackground: boolean;
22
+ invocationKind: "fresh" | "resume";
23
+ invocationSource: "tool" | "service";
24
+ invocationIndex: number;
25
+ toolCallId?: string;
26
+ sessionState: "created" | "not-created";
27
+ sessionId?: string;
28
+ outputFile?: string;
29
+ startedAt: number;
30
+ completedAt?: number;
31
+ durationMs: number;
32
+ result?: string;
33
+ error?: string;
34
+ invocationTurnCount: number;
35
+ lifetimeTurnCount: number;
36
+ maxTurns: number;
37
+ wrapUpTurns: number;
38
+ maxDurationSeconds: number;
39
+ invocationToolUses: number;
40
+ invocationUsage: LifecycleUsageV3;
41
+ lifetimeToolUses: number;
42
+ lifetimeUsage: LifecycleUsageV3;
43
+ }
44
+
45
+ export interface SubagentLifecycleRecordV3 extends SubagentLifecycleDataV3 {
46
+ phase: "terminal";
47
+ outcome: SubagentOutcome;
48
+ completedAt: number;
49
+ }
50
+
51
+ /** Build the shared lifecycle-v3 payload used by public events. */
52
+ export function buildLifecycleEventData(record: Subagent): SubagentLifecycleDataV3 {
53
+ const completedAt = record.completedAt;
54
+ const session = lifecycleSessionFields(record);
55
+ return {
56
+ recordVersion: SUBAGENT_LIFECYCLE_RECORD_VERSION,
57
+ id: record.id,
58
+ type: record.type,
59
+ description: sanitizeDescription(record.description),
60
+ phase: record.phase,
61
+ ...(record.outcome ? { outcome: record.outcome } : {}),
62
+ isBackground: record.isBackground,
63
+ invocationKind: record.lifecycleInvocation.kind,
64
+ invocationSource: record.lifecycleInvocation.source,
65
+ invocationIndex: record.invocationIndex,
66
+ ...(record.toolCallId ? { toolCallId: record.toolCallId } : {}),
67
+ ...session,
68
+ startedAt: record.invocationStartedAt,
69
+ ...(completedAt == null ? {} : { completedAt }),
70
+ durationMs: Math.max(0, (completedAt ?? Date.now()) - record.invocationStartedAt),
71
+ result: record.result,
72
+ error: record.error,
73
+ invocationTurnCount: record.invocationTurnCount,
74
+ lifetimeTurnCount: record.lifetimeTurnCount,
75
+ maxTurns: record.maxTurns,
76
+ wrapUpTurns: record.wrapUpTurns,
77
+ maxDurationSeconds: record.maxDurationSeconds,
78
+ invocationToolUses: record.invocationToolUses,
79
+ invocationUsage: usageWithTotal(record.invocationUsage),
80
+ lifetimeToolUses: record.toolUses,
81
+ lifetimeUsage: usageWithTotal(record.lifetimeUsage),
82
+ };
83
+ }
84
+
85
+ /** Build a persisted terminal record, rejecting unsupported lifecycle states. */
86
+ export function buildLifecycleRecord(record: Subagent): SubagentLifecycleRecordV3 {
87
+ assertTerminal(record.phase, record.outcome);
88
+ if (record.completedAt == null) {
89
+ throw new Error(`Lifecycle v3 terminal record ${record.id} is missing completedAt`);
90
+ }
91
+ if (record.sessionState === "not-created" && !allowsMissingSession(record)) {
92
+ throw new Error(
93
+ `Lifecycle v3 ${record.outcome} record ${record.id} cannot omit sessionId`,
94
+ );
95
+ }
96
+ return buildLifecycleEventData(record) as SubagentLifecycleRecordV3;
97
+ }
98
+
99
+ /** Build a non-terminal steer event without inventing a separate invocation. */
100
+ export function buildSteerLifecycleEvent(
101
+ record: Subagent,
102
+ instruction: string,
103
+ ): SubagentLifecycleDataV3 & { instruction: string } {
104
+ return { ...buildLifecycleEventData(record), instruction };
105
+ }
106
+
107
+ function lifecycleSessionFields(
108
+ record: Subagent,
109
+ ): Pick<SubagentLifecycleDataV3, "sessionState" | "sessionId" | "outputFile"> {
110
+ if (record.sessionState === "not-created") {
111
+ if (record.sessionId || record.outputFile) {
112
+ throw new Error(`Lifecycle v3 record ${record.id} has contradictory session identity`);
113
+ }
114
+ return { sessionState: "not-created" };
115
+ }
116
+ if (!record.sessionId?.trim()) {
117
+ throw new Error(`Lifecycle v3 record ${record.id} is missing sessionId`);
118
+ }
119
+ return {
120
+ sessionState: "created",
121
+ sessionId: record.sessionId,
122
+ ...(record.outputFile ? { outputFile: record.outputFile } : {}),
123
+ };
124
+ }
125
+
126
+ function assertTerminal(phase: SubagentPhase, outcome: SubagentOutcome | undefined): void {
127
+ if (phase !== "terminal") {
128
+ throw new Error(`Lifecycle v3 records must be terminal; received phase ${phase}`);
129
+ }
130
+ if (outcome == null) {
131
+ throw new Error("Lifecycle v3 terminal records require an outcome");
132
+ }
133
+ }
134
+
135
+ /**
136
+ * A terminal record may omit the session identity only when no session can
137
+ * exist: failures before session creation, and never-started cancellations or
138
+ * queue timeouts (the agent was stopped while still queued).
139
+ */
140
+ function allowsMissingSession(record: Subagent): boolean {
141
+ if (record.outcome === "error") return true;
142
+ return (
143
+ record.stoppedWhileQueued &&
144
+ (record.outcome === "cancelled" || record.outcome === "queue_timeout")
145
+ );
146
+ }
147
+
148
+ function usageWithTotal(usage: Readonly<LifetimeUsage>): LifecycleUsageV3 {
149
+ return {
150
+ input: usage.input,
151
+ output: usage.output,
152
+ cacheWrite: usage.cacheWrite,
153
+ total: usage.input + usage.output + usage.cacheWrite,
154
+ };
155
+ }
156
+
157
+ function sanitizeDescription(description: string): string {
158
+ return description.replace(/[\t\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim();
159
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * parent-snapshot.ts — Capture parent session state as a plain data snapshot.
3
+ */
4
+
5
+ import type { Model } from "@earendil-works/pi-ai";
6
+ import { buildParentContext } from "#src/session/context";
7
+ import type { ModelRegistry } from "#src/session/model-resolver";
8
+ import type { SessionContext } from "#src/types";
9
+
10
+ /**
11
+ * Plain data snapshot of the parent session state captured at spawn time.
12
+ * Replaces live `ExtensionContext` references so queued agents don't read stale state.
13
+ */
14
+ export interface ParentSnapshot {
15
+ /** Parent working directory. */
16
+ cwd: string;
17
+ /** Parent's effective system prompt (for append-mode agents). */
18
+ systemPrompt: string;
19
+ /** Parent's current model instance (fallback when agent config has no model). */
20
+ model: Model<any> | undefined;
21
+ /** Model registry for resolving config.model strings and creating sessions. */
22
+ modelRegistry: ModelRegistry;
23
+ /** Pre-built parent conversation text (when inheritContext was requested). */
24
+ parentContext?: string;
25
+ }
26
+
27
+ /**
28
+ * Build an immutable snapshot of the parent session state.
29
+ *
30
+ * Called once at spawn time so queued agents capture state as it existed
31
+ * when the user requested the agent, not when a queue slot opens.
32
+ */
33
+ export function buildParentSnapshot(
34
+ ctx: SessionContext,
35
+ inheritContext?: boolean,
36
+ ): ParentSnapshot {
37
+ const parentContext = inheritContext ? buildParentContext(ctx) : undefined;
38
+ return {
39
+ cwd: ctx.cwd,
40
+ systemPrompt: ctx.getSystemPrompt(),
41
+ model: ctx.model,
42
+ modelRegistry: ctx.modelRegistry,
43
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: converts empty string to undefined as well as null/undefined
44
+ parentContext: parentContext || undefined,
45
+ };
46
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * run-listeners.ts — Per-run observer-unsubscribe and signal-detach handles.
3
+ *
4
+ * Owns the two teardown handles that a Subagent wires at run start (signal
5
+ * listener) and after session creation (record-observer unsub), releasing
6
+ * both atomically when the run ends or the agent is resumed.
7
+ */
8
+
9
+ /** Owns the per-run observer-unsubscribe and signal-detach handles. */
10
+ export class RunListeners {
11
+ private unsub?: () => void;
12
+ private detach?: () => void;
13
+
14
+ /**
15
+ * Wire a parent AbortSignal so it triggers onAbort when fired.
16
+ * No-op when signal is undefined.
17
+ */
18
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
19
+ if (!signal) return;
20
+ if (signal.aborted) {
21
+ onAbort();
22
+ return;
23
+ }
24
+ const listener = () => onAbort();
25
+ signal.addEventListener("abort", listener, { once: true });
26
+ this.detach = () => signal.removeEventListener("abort", listener);
27
+ }
28
+
29
+ /** Store the record-observer unsubscribe handle. */
30
+ attachObserver(unsub: () => void): void {
31
+ this.unsub = unsub;
32
+ }
33
+
34
+ /** Release the observer + signal handles. Idempotent. */
35
+ release(): void {
36
+ this.unsub?.();
37
+ this.unsub = undefined;
38
+ this.detach?.();
39
+ this.detach = undefined;
40
+ }
41
+ }