@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,201 @@
1
+ /**
2
+ * service-adapter.ts — Adapter that wraps SubagentManager to satisfy SubagentsService.
3
+ *
4
+ * Handles model resolution at the API boundary, record serialization
5
+ * (stripping non-serializable fields), and session gating.
6
+ */
7
+
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+ import { parseThinkingLevel, thinkingLevelError } from "#src/config/thinking-level";
10
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
11
+ import type { AgentSpawnConfig, ResumeCallOptions, ResumeOutcome } from "#src/lifecycle/subagent-manager";
12
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
13
+ import type {
14
+ ResumeOptions,
15
+ ResumeResult,
16
+ SpawnOptions,
17
+ SpawnSelectionProvider,
18
+ SpawnSelectionRegistration,
19
+ SubagentRecord,
20
+ SubagentsService,
21
+ } from "#src/service/service";
22
+ import type { ModelRegistry } from "#src/session/model-resolver";
23
+ import type { SessionContext, Subagent, ThinkingLevel } from "#src/types";
24
+
25
+ /** Narrow interface for the SubagentManager — avoids coupling to the concrete class. */
26
+ export interface SubagentManagerLike {
27
+ spawn(snapshot: ParentSnapshot, type: string, prompt: string, options: AgentSpawnConfig): string;
28
+ getRecord(id: string): Subagent | undefined;
29
+ listAgents(): Subagent[];
30
+ abort(id: string): boolean;
31
+ waitForAll(): Promise<void>;
32
+ hasRunning(): boolean;
33
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
34
+ resume(id: string, prompt: string, options: ResumeCallOptions): Promise<ResumeOutcome>;
35
+ }
36
+
37
+ /**
38
+ * Narrow runtime interface consumed by the service adapter.
39
+ * `SubagentRuntime` satisfies this structurally; tests use plain stubs.
40
+ */
41
+ export interface ServiceRuntimeLike {
42
+ readonly currentCtx: SessionContext | undefined;
43
+ buildSnapshot(inheritContext: boolean): ParentSnapshot;
44
+ /** Parent session identity, so an SDK-spawned child nests under its parent. */
45
+ getSessionInfo(): { parentSessionFile: string; parentSessionId: string };
46
+ /** Selection-provider registration, delegated to the runtime-owned scope. */
47
+ registerSpawnSelectionProvider(provider: SpawnSelectionProvider): SpawnSelectionRegistration;
48
+ }
49
+
50
+ /** Adapter that wraps SubagentManager to satisfy SubagentsService. */
51
+ export class SubagentsServiceAdapter implements SubagentsService {
52
+ constructor(
53
+ private readonly manager: SubagentManagerLike,
54
+ private readonly resolveModel: (input: string, registry: ModelRegistry) => Model<any> | string,
55
+ private readonly runtime: ServiceRuntimeLike,
56
+ ) {}
57
+
58
+ spawn(type: string, prompt: string, options?: SpawnOptions): string {
59
+ if (!this.runtime.currentCtx) {
60
+ throw new Error("No active session — cannot spawn agents outside a session.");
61
+ }
62
+
63
+ const model = this.resolveModelOption(options?.model);
64
+ const description = options?.description ?? prompt.slice(0, 80);
65
+
66
+ const snapshot = this.runtime.buildSnapshot(options?.inheritContext ?? false);
67
+ const { parentSessionFile, parentSessionId } = this.runtime.getSessionInfo();
68
+ return this.manager.spawn(snapshot, type, prompt, {
69
+ description,
70
+ model,
71
+ // No toolCallId — an SDK spawn has no originating tool call, and
72
+ // Subagent.toolCallId reporting undefined there is the truth.
73
+ parentSession: { parentSessionFile, parentSessionId },
74
+ maxTurns: options?.maxTurns,
75
+ thinkingLevel: this.resolveThinkingLevel(options?.thinkingLevel),
76
+ inheritContext: options?.inheritContext,
77
+ bypassQueue: options?.bypassQueue,
78
+ // A caller that names `foreground` has committed; one that omits it has
79
+ // not, so the agent's own frontmatter decides and background is the
80
+ // SDK-door default.
81
+ background:
82
+ options?.foreground === undefined
83
+ ? { kind: "default", isBackground: true }
84
+ : { kind: "explicit", isBackground: !options.foreground },
85
+ });
86
+ }
87
+
88
+ getRecord(id: string): SubagentRecord | undefined {
89
+ const record = this.manager.getRecord(id);
90
+ return record ? toSubagentRecord(record) : undefined;
91
+ }
92
+
93
+ listAgents(): SubagentRecord[] {
94
+ return this.manager.listAgents().map(toSubagentRecord);
95
+ }
96
+
97
+ abort(id: string): boolean {
98
+ return this.manager.abort(id);
99
+ }
100
+
101
+ async steer(id: string, message: string): Promise<boolean> {
102
+ const record = this.manager.getRecord(id);
103
+ if (!record) {
104
+ return false;
105
+ }
106
+ const outcome = await record.steer(message);
107
+ return outcome.kind !== "rejected";
108
+ }
109
+
110
+ async resume(id: string, prompt: string, options?: ResumeOptions): Promise<ResumeResult> {
111
+ const outcome = await this.manager.resume(id, prompt, {
112
+ claimOutcome: options?.claimOutcome,
113
+ signal: options?.signal,
114
+ });
115
+ // A refusal is the same value on both sides; only the resumed arm crosses
116
+ // the by-value boundary the snapshot draws.
117
+ return outcome.kind === "refused"
118
+ ? outcome
119
+ : { kind: "resumed", record: toSubagentRecord(outcome.record) };
120
+ }
121
+
122
+ async waitForAll(): Promise<void> {
123
+ return this.manager.waitForAll();
124
+ }
125
+
126
+ hasRunning(): boolean {
127
+ return this.manager.hasRunning();
128
+ }
129
+
130
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
131
+ return this.manager.registerWorkspaceProvider(provider);
132
+ }
133
+
134
+ registerSpawnSelectionProvider(provider: SpawnSelectionProvider): SpawnSelectionRegistration {
135
+ return this.runtime.registerSpawnSelectionProvider(provider);
136
+ }
137
+
138
+ /**
139
+ * Narrow an optional thinking-level override, rejecting one the SDK does not know.
140
+ *
141
+ * `SpawnOptions` widens the field to `string` for the public surface, and Pi clamps an
142
+ * unrecognized level down to `off` rather than reporting it — so a typo would silently
143
+ * disable thinking in the child. Throwing matches the adapter's other input failures.
144
+ */
145
+ private resolveThinkingLevel(input: string | undefined): ThinkingLevel | undefined {
146
+ if (input == null) return undefined;
147
+ const level = parseThinkingLevel(input);
148
+ if (level === undefined) throw new Error(thinkingLevelError(input));
149
+ return level;
150
+ }
151
+
152
+ /** Resolve an optional model-string override against the current session's registry. */
153
+ private resolveModelOption(modelInput: string | undefined): Model<any> | undefined {
154
+ if (!modelInput) return undefined;
155
+ const registry = this.runtime.currentCtx?.modelRegistry;
156
+ if (!registry) {
157
+ throw new Error("No model registry available.");
158
+ }
159
+ const resolved = this.resolveModel(modelInput, registry);
160
+ if (typeof resolved === "string") {
161
+ throw new Error(resolved);
162
+ }
163
+ return resolved;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Convert an internal Subagent to a serializable SubagentRecord.
169
+ *
170
+ * The allowlist is explicit because the snapshot admits only discrete facts —
171
+ * identity, resolved spawn decisions, cumulative metrics, and pointers to
172
+ * durable artifacts. Live objects, momentary activity, and package-internal
173
+ * bookkeeping stay out; see
174
+ * `docs/decisions/0005-subagent-record-admission-policy.md`.
175
+ */
176
+ export function toSubagentRecord(record: Subagent): SubagentRecord {
177
+ const out: SubagentRecord = {
178
+ id: record.id,
179
+ type: record.type,
180
+ description: record.description,
181
+ status: record.status,
182
+ isBackground: record.isBackground,
183
+ toolUses: record.toolUses,
184
+ turnCount: record.turnCount,
185
+ startedAt: record.startedAt,
186
+ // Copy: the agent accumulates into its own object on every message_end, so
187
+ // an aliased snapshot would drift, and a consumer could write into the
188
+ // agent's totals.
189
+ lifetimeUsage: { ...record.lifetimeUsage },
190
+ compactionCount: record.compactionCount,
191
+ };
192
+
193
+ if (record.result !== undefined) out.result = record.result;
194
+ if (record.pendingQuestion !== undefined) out.pendingQuestion = record.pendingQuestion;
195
+ if (record.error !== undefined) out.error = record.error;
196
+ if (record.completedAt !== undefined) out.completedAt = record.completedAt;
197
+ if (record.maxTurns !== undefined) out.maxTurns = record.maxTurns;
198
+ if (record.outputFile !== undefined) out.outputFile = record.outputFile;
199
+
200
+ return out;
201
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * service.ts — Public API surface for cross-extension access to subagents.
3
+ *
4
+ * Consumers declare this package as an optional peer dependency and use
5
+ * dynamic import to access the accessor functions:
6
+ *
7
+ * const { getSubagentsService } = await import("@jopqior/pi-subagents");
8
+ * const svc = getSubagentsService();
9
+ * svc?.spawn("Explore", "Check for stale TODOs");
10
+ */
11
+
12
+ import type { Api, Model } from "@earendil-works/pi-ai";
13
+ import type { SubagentThinkingLevel } from "#src/config/thinking-level";
14
+ import type { ResumeRefusal, SubagentStatus } from "#src/lifecycle/subagent";
15
+ import type { ResumeRefusalReason } from "#src/lifecycle/subagent-manager";
16
+ import type { LifetimeUsage } from "#src/lifecycle/usage";
17
+ import type {
18
+ Workspace,
19
+ WorkspaceDisposeOutcome,
20
+ WorkspaceDisposeResult,
21
+ WorkspacePrepareContext,
22
+ WorkspaceProvider,
23
+ } from "#src/lifecycle/workspace";
24
+
25
+
26
+ // SubagentStatus is defined in the lifecycle layer (single home) and re-exported
27
+ // here for the public API surface — mirrors the LifetimeUsage / workspace pattern.
28
+ export type { SubagentStatus } from "#src/lifecycle/subagent";
29
+ // The resume vocabulary is re-exported for the same reason: the record owns the
30
+ // reasons a resume is refused, and the manager adds the one that is not a fact
31
+ // about a record.
32
+ // Generative extension seam (ADR 0002, Phase 16 Step 2). The provider type
33
+ // and all four collaborator types it references are re-exported by name so
34
+ // consumers can import them directly rather than recovering them via
35
+ // indexed-access inference (e.g. `Parameters<WorkspaceProvider["prepare"]>[0]`).
36
+ export type {
37
+ LifetimeUsage,
38
+ ResumeRefusal,
39
+ ResumeRefusalReason,
40
+ Workspace,
41
+ WorkspaceDisposeOutcome,
42
+ WorkspaceDisposeResult,
43
+ WorkspacePrepareContext,
44
+ WorkspaceProvider,
45
+ };
46
+
47
+ /**
48
+ * Serializable by-value snapshot of an agent's state.
49
+ *
50
+ * Produced by this package and read by consumers — not a contract third
51
+ * parties implement, so a new field is a minor release. What earns a field a
52
+ * place here (and what the snapshot deliberately withholds) is decided in
53
+ * `docs/decisions/0005-subagent-record-admission-policy.md`.
54
+ */
55
+ export interface SubagentRecord {
56
+ id: string;
57
+ type: string;
58
+ description: string;
59
+ status: SubagentStatus;
60
+ /** Scheduling and announcement mode, resolved once at the manager choke point. */
61
+ isBackground: boolean;
62
+ result?: string;
63
+ /** The question the agent ended its turn with, when it declared one. */
64
+ pendingQuestion?: string;
65
+ error?: string;
66
+ toolUses: number;
67
+ /** Turns consumed so far; starts at 1. */
68
+ turnCount: number;
69
+ /** Turn ceiling for this run, when one was set. */
70
+ maxTurns?: number;
71
+ startedAt: number;
72
+ completedAt?: number;
73
+ lifetimeUsage: LifetimeUsage;
74
+ compactionCount: number;
75
+ /** Path to the agent's session JSONL, once the session exists. */
76
+ outputFile?: string;
77
+ }
78
+
79
+ /** Options for resuming an agent via the service. */
80
+ export interface ResumeOptions {
81
+ /**
82
+ * Declare that the caller will deliver the resumed outcome to the parent,
83
+ * suppressing the completion nudge for it. Omitted, the resumed outcome is
84
+ * announced exactly as a background completion is.
85
+ */
86
+ claimOutcome?: boolean;
87
+ /**
88
+ * Cancels the resumed turn loop. `abort(id)` does not reach it: a resume does
89
+ * not run under the record's own abort controller.
90
+ */
91
+ signal?: AbortSignal;
92
+ }
93
+
94
+ /**
95
+ * What a resume attempt produced.
96
+ *
97
+ * A resumed run that *failed* is still `resumed` — the snapshot carries
98
+ * `status: "error"` and the message. `refused` means no turn loop ran.
99
+ */
100
+ export type ResumeResult =
101
+ | { kind: "resumed"; record: SubagentRecord }
102
+ | { kind: "refused"; reason: ResumeRefusalReason };
103
+
104
+ /** Options for spawning an agent via the service. */
105
+ export interface SpawnOptions {
106
+ description?: string;
107
+ model?: string;
108
+ maxTurns?: number;
109
+ thinkingLevel?: string;
110
+ inheritContext?: boolean;
111
+ foreground?: boolean;
112
+ bypassQueue?: boolean;
113
+ }
114
+
115
+ /** The pair a human selected for one new run — the authority for model and thinking. */
116
+ export interface SpawnSelection {
117
+ readonly model: Model<Api>;
118
+ /** Mandatory and never `undefined`: `off` through `max`, including `off`. */
119
+ readonly thinkingLevel: SubagentThinkingLevel;
120
+ }
121
+
122
+ /** What a provider is asked, for one admitted new run about to create a child session. */
123
+ export interface SpawnSelectionRequest {
124
+ /** Identifies the request in the root UI. */
125
+ readonly agentId: string;
126
+ readonly agentType: string;
127
+ readonly description: string;
128
+ /** The authenticated available models of the session whose manager is spawning — the actual choices. */
129
+ readonly availableModels: readonly Model<Api>[];
130
+ }
131
+
132
+ /**
133
+ * Asks for the model and thinking level a new run should use.
134
+ *
135
+ * Resolves with the selected pair, or `undefined` for user cancellation —
136
+ * never an approval that silently keeps inherited values. Infrastructure
137
+ * failures (no UI, unavailable catalogue) reject rather than resolve.
138
+ */
139
+ export interface SpawnSelectionProvider {
140
+ select(
141
+ request: SpawnSelectionRequest,
142
+ signal: AbortSignal,
143
+ ): Promise<SpawnSelection | undefined>;
144
+ }
145
+
146
+ /**
147
+ * The outcome of registering a selection provider.
148
+ *
149
+ * `owned` — this session is the root of its subagent tree and now holds the
150
+ * lease; `dispose()` revokes it (idempotent) and a new session is required to
151
+ * register again.
152
+ *
153
+ * `inherited` — this session is a descendant: the supplied provider is NOT
154
+ * installed and the root's ownership is untouched (including when the
155
+ * inherited lease is already closed); `dispose()` is a no-op.
156
+ */
157
+ export type SpawnSelectionRegistration =
158
+ | { readonly kind: "owned"; dispose(): void }
159
+ | { readonly kind: "inherited"; dispose(): void };
160
+
161
+ /** The public service contract for cross-extension subagent access. */
162
+ export interface SubagentsService {
163
+ /** Spawn an agent. Returns the agent ID immediately. */
164
+ spawn(type: string, prompt: string, options?: SpawnOptions): string;
165
+
166
+ /** Get a snapshot of an agent's current state. */
167
+ getRecord(id: string): SubagentRecord | undefined;
168
+
169
+ /** List all tracked agents, most recent first. */
170
+ listAgents(): SubagentRecord[];
171
+
172
+ /** Abort a running or queued agent. Returns false if not found. */
173
+ abort(id: string): boolean;
174
+
175
+ /** Send a steering message to a running agent. */
176
+ steer(id: string, message: string): Promise<boolean>;
177
+
178
+ /**
179
+ * Resume a settled agent with a new prompt, continuing its session.
180
+ *
181
+ * Resolves when the resumed run reaches a terminal state, carrying the
182
+ * terminal snapshot — a caller that does not need the outcome can ignore the
183
+ * promise. A refusal resolves promptly instead: the checks are synchronous
184
+ * and no turn loop is started.
185
+ */
186
+ resume(id: string, prompt: string, options?: ResumeOptions): Promise<ResumeResult>;
187
+
188
+ /** Wait for all running and queued agents to complete. */
189
+ waitForAll(): Promise<void>;
190
+
191
+ /** Whether any agents are running or queued. */
192
+ hasRunning(): boolean;
193
+
194
+ /**
195
+ * Register the single workspace provider that supplies a child's working
196
+ * directory plus bracketed setup/teardown. Throws if one is already
197
+ * registered. Returns a disposer that unregisters the provider.
198
+ */
199
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
200
+
201
+ /**
202
+ * Register the per-spawn selection provider this session's subagent tree
203
+ * will consult before creating any new child session.
204
+ *
205
+ * On the tree's root session: installs the provider (throws if one is
206
+ * already registered, or once this session's scope is closed) and returns an
207
+ * `owned` registration whose disposer revokes it. On a descendant session:
208
+ * installs nothing and returns `inherited` — the root's chooser serves the
209
+ * whole tree.
210
+ */
211
+ registerSpawnSelectionProvider(provider: SpawnSelectionProvider): SpawnSelectionRegistration;
212
+ }
213
+
214
+ /** Event channel constants for pi.events subscriptions. */
215
+ export const SUBAGENT_EVENTS = {
216
+ STARTED: "subagents:started",
217
+ COMPLETED: "subagents:completed",
218
+ FAILED: "subagents:failed",
219
+ RESUMING: "subagents:resuming",
220
+ RESUMED: "subagents:resumed",
221
+ COMPACTED: "subagents:compacted",
222
+ CREATED: "subagents:created",
223
+ STEERED: "subagents:steered",
224
+ } as const;
225
+
226
+ // ---- Accessor functions ----
227
+
228
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service");
229
+
230
+ /** Publish the SubagentsService on globalThis for cross-extension access. */
231
+ export function publishSubagentsService(service: SubagentsService): void {
232
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
233
+ }
234
+
235
+ /** Retrieve the published SubagentsService, or undefined if not yet published. */
236
+ export function getSubagentsService(): SubagentsService | undefined {
237
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
238
+ | SubagentsService
239
+ | undefined;
240
+ }
241
+
242
+ /** Remove the SubagentsService from globalThis (call on shutdown/reload). */
243
+ export function unpublishSubagentsService(): void {
244
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
245
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
246
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ask-parent-tool.ts — The tool a child asks its delegating agent a question with.
3
+ *
4
+ * A child that cannot finish without information only its parent has records
5
+ * the question here and ends its turn. The parent answers by resuming the
6
+ * child, which continues with its context intact.
7
+ *
8
+ * The tool records; it does not announce. Every result carrier already renders
9
+ * a pending question — with the exact resume call while the record is still
10
+ * resumable, and why it is not once `Subagent.resumeRefusal` says otherwise —
11
+ * so announcing here would tell the parent the same thing twice.
12
+ *
13
+ * Lives in `session/` rather than `tools/` because it is installed on the
14
+ * child's session by the assembly factory, where every `tools/` module is
15
+ * registered on the parent's.
16
+ */
17
+
18
+ import { defineTool } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "@sinclair/typebox";
20
+
21
+ export const ASK_PARENT_TOOL_NAME = "ask_parent";
22
+
23
+ const RESULT_TEXT =
24
+ "Question recorded for the delegating agent. End your turn now — it will answer by resuming you, and you will continue with your context intact.";
25
+
26
+ /** Records a child's question against its own subagent record. */
27
+ export type QuestionRecorder = (question: string) => void;
28
+
29
+ export class AskParentTool {
30
+ constructor(private readonly record: QuestionRecorder) {}
31
+
32
+ execute(
33
+ _toolCallId: string,
34
+ params: { question: string },
35
+ _signal: AbortSignal,
36
+ _onUpdate: unknown,
37
+ _ctx: unknown,
38
+ ) {
39
+ this.record(params.question);
40
+ // `details` is required by the SDK's result type; this tool renders none.
41
+ return { content: [{ type: "text" as const, text: RESULT_TEXT }], details: undefined };
42
+ }
43
+
44
+ toToolDefinition() {
45
+ return defineTool({
46
+ name: ASK_PARENT_TOOL_NAME,
47
+ label: "Ask Parent",
48
+ promptSnippet: "Ask the delegating agent a question, then end your turn.",
49
+ description:
50
+ "Record a question for the agent that delegated this task. Use it when you cannot finish " +
51
+ "without information only the delegating agent has, and the answer changes what you would do; " +
52
+ "otherwise state your assumption and continue. After calling this, end your turn immediately — " +
53
+ "the delegating agent answers by resuming you, and you continue with your context intact.",
54
+ parameters: Type.Object({
55
+ question: Type.String({
56
+ description: "The question the delegating agent must answer before you can continue.",
57
+ }),
58
+ }),
59
+ // The tool's own work is synchronous; the SDK's execute contract is not.
60
+ execute: (
61
+ toolCallId: string,
62
+ params: { question: string },
63
+ signal: AbortSignal,
64
+ onUpdate: unknown,
65
+ ctx: unknown,
66
+ ) => Promise.resolve(this.execute(toolCallId, params, signal, onUpdate, ctx)),
67
+ });
68
+ }
69
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * content-items.ts — Shared parsing utilities for Pi SDK message content items.
3
+ *
4
+ * Provides type-safe extraction of text parts and tool-call names from
5
+ * assistant message content arrays. Pure functions — no IO.
6
+ */
7
+
8
+ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
9
+
10
+ // ── Types ─────────────────────────────────────────────────────────────────────
11
+
12
+ /** Extracted text parts and tool names from assistant message content. */
13
+ export interface AssistantContentParts {
14
+ textParts: string[];
15
+ toolNames: string[];
16
+ }
17
+
18
+ // ── Functions ─────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Extracts the display name from a tool-call content item.
22
+ *
23
+ * Returns 'unknown' for non-toolCall items.
24
+ * The Pi SDK's ToolCall.name is always present — no fallback chain needed.
25
+ */
26
+ export function getToolCallName(c: { type: string }): string {
27
+ if (c.type !== "toolCall") return "unknown";
28
+ return (c as ToolCall).name;
29
+ }
30
+
31
+ /**
32
+ * Extract text parts and tool-call names from assistant message content items.
33
+ *
34
+ * Accepts any array whose elements carry a `type` discriminant — all Pi SDK
35
+ * content types (TextContent, ThinkingContent, ToolCall) satisfy this constraint.
36
+ * Pure data extraction — consumers apply their own presentation formatting.
37
+ * Skips items of unknown types (e.g. thinking blocks, images) and empty text.
38
+ */
39
+ export function extractAssistantContent(
40
+ content: ReadonlyArray<{ type: string }>,
41
+ ): AssistantContentParts {
42
+ const textParts: string[] = [];
43
+ const toolNames: string[] = [];
44
+ for (const c of content) {
45
+ if (c.type === "text") {
46
+ const text = (c as TextContent).text;
47
+ if (text) textParts.push(text);
48
+ } else if (c.type === "toolCall") {
49
+ toolNames.push(getToolCallName(c));
50
+ }
51
+ }
52
+ return { textParts, toolNames };
53
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * context.ts — Extract parent conversation context for subagent inheritance.
3
+ */
4
+
5
+ import type { TextContent } from "@earendil-works/pi-ai";
6
+ import type { SessionContext } from "#src/types";
7
+
8
+ /**
9
+ * Minimal structural types for session branch entries consumed by buildParentContext.
10
+ * `getBranch()` returns `unknown[]` in SessionContext (ISP), so we cast to these
11
+ * local shapes instead of coupling to the SDK's SessionEntry type.
12
+ */
13
+ type MessageEntry = {
14
+ type: "message";
15
+ message: { role: string; content: string | { type: string }[] };
16
+ };
17
+ type CompactionEntry = { type: "compaction"; summary?: string };
18
+ type BranchEntry = MessageEntry | CompactionEntry | { type: string };
19
+
20
+ /** Type predicate: narrow an unknown content block to TextContent. */
21
+ function isTextContent(c: unknown): c is TextContent {
22
+ return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
23
+ }
24
+
25
+ /** Extract text from a message content block array. */
26
+ export function extractText(content: unknown[]): string {
27
+ return content
28
+ .filter(isTextContent)
29
+ .map((c) => c.text)
30
+ .join("\n");
31
+ }
32
+
33
+ /** Format a message entry (user/assistant); returns undefined for roles to skip. */
34
+ function formatMessageEntry(entry: MessageEntry): string | undefined {
35
+ const msg = entry.message;
36
+ const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
37
+ if (!text.trim()) return undefined;
38
+ if (msg.role === "user") return `[User]: ${text.trim()}`;
39
+ if (msg.role === "assistant") return `[Assistant]: ${text.trim()}`;
40
+ return undefined; // skip toolResult and other roles
41
+ }
42
+
43
+ /** Format a compaction entry; returns undefined when no summary is present. */
44
+ function formatCompactionEntry(entry: CompactionEntry): string | undefined {
45
+ return entry.summary ? `[Summary]: ${entry.summary}` : undefined;
46
+ }
47
+
48
+ /** Dispatch a branch entry to the appropriate formatter. */
49
+ function formatBranchEntry(entry: BranchEntry): string | undefined {
50
+ if (entry.type === "message") return formatMessageEntry(entry as MessageEntry);
51
+ if (entry.type === "compaction") return formatCompactionEntry(entry as CompactionEntry);
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * Build a text representation of the parent conversation context.
57
+ * Used when inherit_context is true to give the subagent visibility
58
+ * into what has been discussed/done so far.
59
+ */
60
+ export function buildParentContext(ctx: SessionContext): string {
61
+ const entries = ctx.sessionManager.getBranch();
62
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- getBranch() may return undefined at runtime despite its type
63
+ if (!entries || entries.length === 0) return "";
64
+
65
+ const parts = (entries as BranchEntry[])
66
+ .map(formatBranchEntry)
67
+ .filter((p): p is string => p !== undefined);
68
+
69
+ if (parts.length === 0) return "";
70
+
71
+ return `# Parent Conversation Context
72
+ The following is the conversation history from the parent session that spawned you.
73
+ Use this context to understand what has been discussed and decided so far.
74
+
75
+ ${parts.join("\n\n")}
76
+
77
+ ---
78
+ # Your Task (below)
79
+ `;
80
+ }