@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,347 @@
1
+ /**
2
+ * subagent-session.ts — The born-complete child-session value object (issue #265).
3
+ *
4
+ * A SubagentSession wraps one SDK AgentSession plus its turn-driving and teardown.
5
+ * It is born complete: `createSubagentSession()` returns a fully usable instance
6
+ * (session created, extensions bound, recursion guard applied), so the only thing
7
+ * left for `Subagent` to do is coordinate — drive the turn loop, steer, dispose.
8
+ *
9
+ * Turn driving lives here, on the object that owns the AgentSession, rather than
10
+ * reaching through `subagentSession.session` from `Subagent` (Law of Demeter).
11
+ */
12
+
13
+ import {
14
+ type AgentSession,
15
+ type AgentSessionEvent,
16
+ type ToolDefinition,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import type { ChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
19
+ import { emitChildSessionShutdown } from "#src/lifecycle/child-shutdown";
20
+ import { normalizeMaxTurns } from "#src/lifecycle/turn-limits";
21
+ import { getSessionContextPercent, type SessionStatsLike } from "#src/lifecycle/usage";
22
+ import { extractText } from "#src/session/context";
23
+ import { getAgentConversation } from "#src/session/conversation";
24
+ import type { SessionMessage } from "#src/types";
25
+
26
+ /** Outcome of one turn loop. */
27
+ export interface TurnLoopResult {
28
+ responseText: string;
29
+ /** True if the agent was hard-aborted (max turns + grace exceeded). */
30
+ aborted: boolean;
31
+ /** True if the agent was steered to wrap up (soft turn limit) but finished in time. */
32
+ steered: boolean;
33
+ }
34
+
35
+ /** Per-call options for the initial run's turn loop. */
36
+ export interface TurnLoopOptions {
37
+ /** Per-call max-turns override — highest precedence. */
38
+ maxTurns?: number;
39
+ /** Runtime-config fallback when neither per-call nor per-agent limit is set. */
40
+ defaultMaxTurns?: number;
41
+ /** Grace turns after the soft-limit steer message before a hard abort. */
42
+ graceTurns?: number;
43
+ signal?: AbortSignal;
44
+ }
45
+
46
+ /** Session-level facts known at creation, supplied by the factory. */
47
+ export interface SubagentSessionMeta {
48
+ /** Path to the persisted session JSONL file, if the session was persisted. */
49
+ outputFile: string | undefined;
50
+ /** Child session id — the registry key carried on session-created/disposed events. */
51
+ sessionId: string;
52
+ /** Child session directory — carried on the completed event as transcript location. */
53
+ sessionDir: string;
54
+ agentName: string;
55
+ /** Per-agent max-turns from the resolved agent config — middle precedence. */
56
+ agentMaxTurns: number | undefined;
57
+ /** Parent context prepended to the run prompt, captured at spawn time. */
58
+ parentContext: string | undefined;
59
+ lifecycle: ChildLifecyclePublisher;
60
+ }
61
+
62
+ /**
63
+ * One child AgentSession plus its turn-driving and teardown — born complete.
64
+ */
65
+ export class SubagentSession {
66
+ private disposed = false;
67
+
68
+ /**
69
+ * How the session's last assistant turn ended, tracked for the session's
70
+ * whole life rather than per call.
71
+ *
72
+ * Per call is not enough: a `prompt()` can resolve without running a turn at
73
+ * all, and the failure a *previous* call observed may never have reached
74
+ * `session.messages` for a later one to re-derive — Pi's overflow recovery
75
+ * strips it and restores nothing when its compaction fails (#898).
76
+ */
77
+ private readonly turnFailure: ReturnType<typeof collectTurnFailure>;
78
+
79
+ constructor(
80
+ private readonly _session: AgentSession,
81
+ private readonly meta: SubagentSessionMeta,
82
+ ) {
83
+ this.turnFailure = collectTurnFailure(_session);
84
+ }
85
+
86
+ /**
87
+ * Wrapped session — for lifecycle-internal use only.
88
+ * @internal consumers outside lifecycle/ use the delegate methods below.
89
+ */
90
+ get session(): AgentSession {
91
+ return this._session;
92
+ }
93
+
94
+ get outputFile(): string | undefined {
95
+ return this.meta.outputFile;
96
+ }
97
+
98
+ /** Drive the initial run's turn loop; emits `completed` on success. */
99
+ async runTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult> {
100
+ const session = this._session;
101
+
102
+ // Track turns for graceful max_turns enforcement.
103
+ let turnCount = 0;
104
+ const maxTurns = normalizeMaxTurns(
105
+ opts.maxTurns ?? this.meta.agentMaxTurns ?? opts.defaultMaxTurns,
106
+ );
107
+ let softLimitReached = false;
108
+ let aborted = false;
109
+
110
+ const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
111
+ if (event.type === "turn_end") {
112
+ turnCount++;
113
+ if (maxTurns != null) {
114
+ if (!softLimitReached && turnCount >= maxTurns) {
115
+ softLimitReached = true;
116
+ void session.steer(
117
+ "You have reached your turn limit. Wrap up immediately - provide your final answer now.",
118
+ );
119
+ } else if (softLimitReached && turnCount >= maxTurns + (opts.graceTurns ?? 5)) {
120
+ aborted = true;
121
+ void session.abort();
122
+ }
123
+ }
124
+ }
125
+ });
126
+
127
+ const collector = collectResponseText(session);
128
+ const cleanupAbort = forwardAbortSignal(session, opts.signal);
129
+
130
+ // Prepend parent context if it was captured at spawn time.
131
+ const effectivePrompt = this.meta.parentContext
132
+ ? this.meta.parentContext + prompt
133
+ : prompt;
134
+
135
+ try {
136
+ await session.prompt(effectivePrompt);
137
+ failIfProviderErrored(this.turnFailure.getFailure());
138
+ this.meta.lifecycle.completed({
139
+ sessionDir: this.meta.sessionDir,
140
+ agentName: this.meta.agentName,
141
+ aborted,
142
+ steered: softLimitReached,
143
+ });
144
+ } finally {
145
+ unsubTurns();
146
+ collector.unsubscribe();
147
+ cleanupAbort();
148
+ }
149
+
150
+ const responseText = collector.getText().trim() || getLastAssistantText(session);
151
+ return { responseText, aborted, steered: softLimitReached };
152
+ }
153
+
154
+ /** Re-prompt the same session (resume); does not emit `completed`. */
155
+ async resumeTurnLoop(prompt: string, signal?: AbortSignal): Promise<string> {
156
+ const session = this._session;
157
+ const collector = collectResponseText(session);
158
+ const cleanupAbort = forwardAbortSignal(session, signal);
159
+
160
+ try {
161
+ await session.prompt(prompt);
162
+ failIfProviderErrored(this.turnFailure.getFailure());
163
+ } finally {
164
+ collector.unsubscribe();
165
+ cleanupAbort();
166
+ }
167
+
168
+ return collector.getText().trim() || getLastAssistantText(session);
169
+ }
170
+
171
+ /** Deliver a steer to the live session. */
172
+ async steer(message: string): Promise<void> {
173
+ await this._session.steer(message);
174
+ }
175
+
176
+ /** Return the session's conversation as formatted text. */
177
+ getConversation(): string {
178
+ return getAgentConversation(this._session);
179
+ }
180
+
181
+ /** Return the session context window utilization (0-100), or null when unavailable. */
182
+ getContextPercent(): number | null {
183
+ return getSessionContextPercent(this._session);
184
+ }
185
+
186
+ /** Subscribe to session events. Satisfies `SubscribableSession`. */
187
+ subscribe(fn: (event: AgentSessionEvent) => void): () => void {
188
+ return this._session.subscribe(fn);
189
+ }
190
+
191
+ /** Return session token statistics. Satisfies `SessionLike`. */
192
+ getSessionStats(): SessionStatsLike {
193
+ return this._session.getSessionStats();
194
+ }
195
+
196
+ /** The session's message history. */
197
+ get messages(): readonly unknown[] {
198
+ return this._session.messages as readonly unknown[];
199
+ }
200
+
201
+ /** The session's message history, typed for Pi's session-rendering machinery. */
202
+ get agentMessages(): readonly SessionMessage[] {
203
+ return this._session.messages;
204
+ }
205
+
206
+ /** Resolve a registered tool definition by name, for Pi's tool-execution components. */
207
+ getToolDefinition(name: string): ToolDefinition | undefined {
208
+ return this._session.getToolDefinition(name);
209
+ }
210
+
211
+ /**
212
+ * Tear down: child `session_shutdown` + session.dispose() + emit `disposed`
213
+ * (registry unregister).
214
+ *
215
+ * The order is load-bearing. The shutdown emit runs first because
216
+ * `AgentSession.dispose()` invalidates the extension runner, after which every
217
+ * handler's context throws (#709). The `disposed` event runs last because it
218
+ * unregisters the child from the permission system, so a shutdown handler
219
+ * still resolves against a live registration.
220
+ *
221
+ * Idempotent: the guard is set before the first await, so concurrent callers
222
+ * (a retention sweep racing a manager teardown) emit and dispose exactly once.
223
+ */
224
+ async dispose(): Promise<void> {
225
+ if (this.disposed) return;
226
+ this.disposed = true;
227
+ this.turnFailure.unsubscribe();
228
+ await emitChildSessionShutdown(this._session);
229
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
230
+ this._session.dispose?.();
231
+ this.meta.lifecycle.disposed({ sessionId: this.meta.sessionId });
232
+ }
233
+ }
234
+
235
+ // ── Private turn-loop helpers ───────────────────────────────────────────────────
236
+
237
+ /**
238
+ * What a failed turn reports when the provider named no reason. An empty
239
+ * `errorMessage` is as uninformative as an absent one, so both land here.
240
+ */
241
+ const PROVIDER_ERROR_WITHOUT_MESSAGE = "provider reported an error with no message";
242
+
243
+ /**
244
+ * Throw when the run's last turn ended in a provider error.
245
+ *
246
+ * Pi does not throw on a provider failure: the agent loop appends an assistant
247
+ * message with `stopReason: "error"` and an `errorMessage`, then ends the turn
248
+ * normally. Without this read a failed turn is indistinguishable from a quiet
249
+ * one, and the run reports a successful, empty completion (#889).
250
+ */
251
+ function failIfProviderErrored(failure: string | undefined): void {
252
+ if (failure) throw new Error(failure);
253
+ }
254
+
255
+ /**
256
+ * Subscribe to a session and record how its last assistant message ended.
257
+ *
258
+ * Read live rather than scanned back from `session.messages` once the run has
259
+ * settled: Pi's overflow recovery removes the failed message from agent state
260
+ * before attempting compaction and restores nothing when that compaction fails,
261
+ * so a run's own error may no longer be in the history by the time it ends
262
+ * (#898). `message_end` is emitted before any of that runs.
263
+ *
264
+ * Last-one-wins rather than latched: a successful auto-retry emits a later,
265
+ * clean `message_end`, and that run recovered. A hard abort and a user stop
266
+ * both yield `stopReason: "aborted"`, which is a terminal outcome the run
267
+ * already reports through its own channel.
268
+ *
269
+ * Subscribed for the session's whole life, and seeded from whatever history it
270
+ * already had. `prompt()` can resolve without running a turn at all — an
271
+ * extension command matched, an `input` handler reported the prompt handled, a
272
+ * message was queued while streaming — and a resume is not refused for an agent
273
+ * whose earlier run failed. Such a call observes no event of its own, so the
274
+ * answer has to be one the collector was already holding: what an earlier call
275
+ * observed, or, for turns that predate the subscription, what the history says.
276
+ */
277
+ function collectTurnFailure(session: AgentSession) {
278
+ let failure = readLastTurnFailure(session);
279
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
280
+ if (event.type !== "message_end" || event.message.role !== "assistant") return;
281
+ failure =
282
+ event.message.stopReason === "error"
283
+ ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty errorMessage is as uninformative as an absent one, and ?? would pass it through
284
+ event.message.errorMessage || PROVIDER_ERROR_WITHOUT_MESSAGE
285
+ : undefined;
286
+ });
287
+ return { getFailure: () => failure, unsubscribe };
288
+ }
289
+
290
+ /** How the session's last assistant message ended, read from its history. */
291
+ function readLastTurnFailure(session: AgentSession): string | undefined {
292
+ for (let i = session.messages.length - 1; i >= 0; i--) {
293
+ const msg = session.messages[i];
294
+ if (msg.role !== "assistant") continue;
295
+ if (msg.stopReason !== "error") return undefined;
296
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: an empty errorMessage is as uninformative as an absent one, and ?? would pass it through
297
+ return msg.errorMessage || PROVIDER_ERROR_WITHOUT_MESSAGE;
298
+ }
299
+ return undefined;
300
+ }
301
+
302
+ /**
303
+ * Subscribe to a session and collect the last assistant message text.
304
+ * Returns an object with a `getText()` getter and an `unsubscribe` function.
305
+ */
306
+ function collectResponseText(session: AgentSession) {
307
+ let text = "";
308
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
309
+ if (event.type === "message_start") {
310
+ text = "";
311
+ }
312
+ if (
313
+ event.type === "message_update" &&
314
+ event.assistantMessageEvent.type === "text_delta"
315
+ ) {
316
+ text += event.assistantMessageEvent.delta;
317
+ }
318
+ });
319
+ return { getText: () => text, unsubscribe };
320
+ }
321
+
322
+ /** Get the last assistant text from the completed session history. */
323
+ function getLastAssistantText(session: AgentSession): string {
324
+ for (let i = session.messages.length - 1; i >= 0; i--) {
325
+ const msg = session.messages[i];
326
+ if (msg.role !== "assistant") continue;
327
+ const text = extractText(msg.content).trim();
328
+ if (text) return text;
329
+ }
330
+ return "";
331
+ }
332
+
333
+ /**
334
+ * Wire an AbortSignal to abort a session.
335
+ * Returns a cleanup function to remove the listener.
336
+ */
337
+ function forwardAbortSignal(
338
+ session: AgentSession,
339
+ signal?: AbortSignal,
340
+ ): () => void {
341
+ if (!signal) return () => {};
342
+ const onAbort = (): void => {
343
+ void session.abort();
344
+ };
345
+ signal.addEventListener("abort", onAbort, { once: true });
346
+ return () => signal.removeEventListener("abort", onAbort);
347
+ }