@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,58 @@
1
+ /**
2
+ * turn_start event handler that aborts subagents on a parent interrupt (ESC),
3
+ * subject to the abort-all policy.
4
+ *
5
+ * The parent agent loop creates a fresh AbortController per run and only aborts
6
+ * it on an explicit interrupt — never on normal completion. So latching to the
7
+ * current run's signal and aborting on its `abort` event fires exactly on ESC.
8
+ *
9
+ * `turn_start` carries the live per-run `ctx.signal`, so re-latching each turn
10
+ * keeps the handler tracking the current signal across runs and tool-less turns.
11
+ */
12
+
13
+ /** Narrow manager interface — only the method the interrupt handler calls. */
14
+ export interface InterruptManager {
15
+ abortAll(): number;
16
+ }
17
+
18
+ /** Minimal context shape — only the field the handler reads. */
19
+ interface InterruptCtx {
20
+ signal: AbortSignal | undefined;
21
+ }
22
+
23
+ /**
24
+ * Latches the current parent abort signal and aborts all subagents when it fires,
25
+ * unless `shouldAbortAll` declines.
26
+ *
27
+ * The latch dedups by reference: most turns reuse the same signal (no-op); a new
28
+ * run's signal triggers a detach-and-rewire. The `abort` listener is one-shot.
29
+ *
30
+ * `shouldAbortAll` is consulted inside the listener, so a mid-session settings
31
+ * change applies to the very next interrupt without re-wiring.
32
+ */
33
+ export class InterruptHandler {
34
+ private latched?: AbortSignal;
35
+ private detach?: () => void;
36
+
37
+ constructor(
38
+ private readonly manager: InterruptManager,
39
+ private readonly shouldAbortAll: () => boolean,
40
+ ) {}
41
+
42
+ handleTurnStart(ctx: InterruptCtx): void {
43
+ const signal = ctx.signal;
44
+ if (signal === this.latched) return;
45
+
46
+ this.detach?.();
47
+ this.detach = undefined;
48
+ this.latched = signal;
49
+ if (!signal) return;
50
+
51
+ const onAbort = (): void => {
52
+ if (!this.shouldAbortAll()) return;
53
+ this.manager.abortAll();
54
+ };
55
+ signal.addEventListener("abort", onAbort, { once: true });
56
+ this.detach = () => signal.removeEventListener("abort", onAbort);
57
+ }
58
+ }
@@ -0,0 +1,71 @@
1
+ import type { SessionContext } from "#src/types";
2
+
3
+ /**
4
+ * Session lifecycle event handlers: session_start, session_before_switch, session_shutdown.
5
+ *
6
+ * Extracted from index.ts so each handler can be tested in isolation
7
+ * with mocked narrow interfaces.
8
+ */
9
+
10
+ /** Narrow manager interface — only the methods lifecycle handlers call. */
11
+ export interface LifecycleManager {
12
+ clearCompleted(): Promise<void>;
13
+ abortAll(): void;
14
+ dispose(): Promise<void>;
15
+ }
16
+
17
+ /** Narrow runtime interface — only the methods lifecycle handlers call. */
18
+ export interface LifecycleRuntime {
19
+ setSessionContext(ctx: SessionContext): void;
20
+ clearSessionContext(): void;
21
+ /** Closes the retained selection scope — owner-aware root revoke or child release. */
22
+ closeSelectionScope(): void;
23
+ }
24
+
25
+ /**
26
+ * Handles session lifecycle events.
27
+ *
28
+ * Constructor deps:
29
+ * - `runtime` — owns session context state
30
+ * - `manager` — manages agent lifecycle (clear, abort, dispose)
31
+ * - `disposeNotifications` — tears down the notification system on shutdown
32
+ * - `unpublishService` — unpublishes the SubagentsService symbol on shutdown
33
+ */
34
+ export class SessionLifecycleHandler {
35
+ constructor(
36
+ private readonly runtime: LifecycleRuntime,
37
+ private readonly manager: LifecycleManager,
38
+ private readonly disposeNotifications: () => void,
39
+ private readonly unpublishService: () => void,
40
+ ) {}
41
+
42
+ handleSessionStart(_event: unknown, ctx: unknown): Promise<void> {
43
+ this.runtime.setSessionContext(ctx as SessionContext);
44
+ return this.manager.clearCompleted();
45
+ }
46
+
47
+ handleSessionBeforeSwitch(): Promise<void> {
48
+ return this.manager.clearCompleted();
49
+ }
50
+
51
+ // Cleanup order matters:
52
+ // 0. Close the selection scope — revoke the root lease (or free this
53
+ // child's subtree) before anything else: a pending selection must lose
54
+ // its authority now, not whenever a later companion handler runs
55
+ // 1. Unpublish service — prevent new cross-extension calls
56
+ // 2. Clear session context — no more session state
57
+ // 3. Dispose notifications — silence nudges *before* the aborts that would
58
+ // raise them: no parent run is active at shutdown, so a terminal
59
+ // transition delivers its nudge synchronously and Pi cannot recall it
60
+ // 4. Abort all agents — stop running and queued work
61
+ // 5. Dispose manager — final cleanup, awaited so each child's extensions get
62
+ // their `session_shutdown` before Pi tears the parent down (#709)
63
+ handleSessionShutdown(): Promise<void> {
64
+ this.runtime.closeSelectionScope();
65
+ this.unpublishService();
66
+ this.runtime.clearSessionContext();
67
+ this.disposeNotifications();
68
+ this.manager.abortAll();
69
+ return this.manager.dispose();
70
+ }
71
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Host-event handlers for the agent widget.
3
+ *
4
+ * Extracted from index.ts so each handler can be tested in isolation
5
+ * with a mocked narrow widget interface.
6
+ *
7
+ * The three events are unrelated to each other and none is a tool call.
8
+ * The widget can draw nothing until it holds a UI context, so the capture
9
+ * belongs on an event that fires in every session; the linger clock counts
10
+ * parent turns, so it belongs on the event that marks one; and the widget's
11
+ * two resources have to be released when the session they belong to ends.
12
+ */
13
+
14
+ /** Narrow widget interface — only the methods these handlers call. */
15
+ export interface EventDrivenWidget {
16
+ setUICtx(ctx: unknown): void;
17
+ onTurnStart(): void;
18
+ dispose(): void;
19
+ }
20
+
21
+ /** Minimal context shape for session_start — only the field the handler reads. */
22
+ interface SessionStartCtx {
23
+ ui: unknown;
24
+ }
25
+
26
+ /**
27
+ * Feeds the widget the three host events it depends on.
28
+ *
29
+ * `session_start` supplies the UI context: Pi starts the TUI before it
30
+ * initializes extensions and binds the UI context before emitting the event,
31
+ * so `ctx.ui` is live here, and headless binds a no-op context rather than
32
+ * none. `turn_start` ages finished agents out of the widget's roster.
33
+ * `session_shutdown` releases the update interval and both UI registrations.
34
+ */
35
+ export class WidgetEventsHandler {
36
+ constructor(private readonly widget: EventDrivenWidget) {}
37
+
38
+ handleSessionStart(_event: unknown, ctx: SessionStartCtx): void {
39
+ this.widget.setUICtx(ctx.ui);
40
+ }
41
+
42
+ handleTurnStart(): void {
43
+ this.widget.onTurnStart();
44
+ }
45
+
46
+ handleSessionShutdown(): void {
47
+ this.widget.dispose();
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * pi-agents — A pi extension providing focused, in-process autonomous sub-agents.
3
+ *
4
+ * Tools:
5
+ * Agent — LLM-callable: spawn a sub-agent
6
+ * get_subagent_result — LLM-callable: check background agent status/result
7
+ * steer_subagent — LLM-callable: send a steering message to a running agent
8
+ *
9
+ * Commands:
10
+ */
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import {
15
+ createAgentSession,
16
+ DefaultResourceLoader,
17
+ type ExtensionAPI,
18
+ getAgentDir,
19
+ ModelRuntime,
20
+ type ResourceLoader,
21
+ ModelRegistry as SdkModelRegistry,
22
+ SettingsManager as SdkSettingsManager,
23
+ SessionManager,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import { AgentTypeRegistry } from "#src/config/agent-types";
26
+ import { loadCustomAgents } from "#src/config/custom-agents";
27
+ import { InterruptHandler, SessionLifecycleHandler, WidgetEventsHandler } from "#src/handlers/index";
28
+ import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
29
+ import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
30
+ import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
31
+ import { captureInheritedSelectionScope } from "#src/lifecycle/selection-scope";
32
+ import { SpawnSelectionScope } from "#src/lifecycle/spawn-selection";
33
+ import { SubagentManager } from "#src/lifecycle/subagent-manager";
34
+ import { CompositeSubagentObserver } from "#src/observation/composite-subagent-observer";
35
+ import {
36
+ type NotificationDetails,
37
+ NotificationManager,
38
+ type UpdateDetails,
39
+ type WorkspaceNoticeDetails,
40
+ } from "#src/observation/notification";
41
+ import {
42
+ createNotificationRenderer,
43
+ createUpdateRenderer,
44
+ createWorkspaceNoticeRenderer,
45
+ } from "#src/observation/renderer";
46
+ import { SubagentEventsObserver } from "#src/observation/subagent-events-observer";
47
+ import { createSubagentRuntime } from "#src/runtime";
48
+ import { publishSubagentsService, unpublishSubagentsService } from "#src/service/service";
49
+ import { SubagentsServiceAdapter } from "#src/service/service-adapter";
50
+ import { detectEnv } from "#src/session/env";
51
+
52
+ import { resolveModel } from "#src/session/model-resolver";
53
+ import { createExcludedPackagesStorage } from "#src/session/package-exclusions";
54
+ import { buildAgentPrompt } from "#src/session/prompts";
55
+ import { inheritRegisteredProviders } from "#src/session/provider-inheritance";
56
+ import { deriveSubagentSessionDir } from "#src/session/session-dir";
57
+ import { SettingsManager } from "#src/settings";
58
+ import { AgentTool } from "#src/tools/agent-tool";
59
+ import { GetResultTool } from "#src/tools/get-result-tool";
60
+ import { SteerTool } from "#src/tools/steer-tool";
61
+ import { AgentWidget } from "#src/ui/agent-widget";
62
+ import { SessionNavigatorHandler } from "#src/ui/session-navigator";
63
+ import { SubagentsSettingsHandler } from "#src/ui/subagents-settings";
64
+
65
+ export default function (pi: ExtensionAPI) {
66
+ // ---- Register custom notification renderer ----
67
+ pi.registerMessageRenderer<NotificationDetails>("subagent-notification", createNotificationRenderer());
68
+ pi.registerMessageRenderer<UpdateDetails>("subagent-update", createUpdateRenderer());
69
+ pi.registerMessageRenderer<WorkspaceNoticeDetails>(
70
+ "subagent-workspace-notice",
71
+ createWorkspaceNoticeRenderer(),
72
+ );
73
+
74
+ const registry = new AgentTypeRegistry(() => loadCustomAgents(process.cwd()));
75
+
76
+ // ---- Runtime: all mutable extension state in one place ----
77
+ // Selection scope first: capture the inherited handle while the construction
78
+ // context is still ambient (this factory runs inside the parent's constructChild
79
+ // wrapper), or create this session's own root lease. A root never gets here
80
+ // with a context; a child core never gets here without one.
81
+ const selectionScope = captureInheritedSelectionScope() ?? new SpawnSelectionScope();
82
+ const runtime = createSubagentRuntime(selectionScope);
83
+
84
+ // ---- Notification system ----
85
+ // Owns completion nudges and live-activity cleanup. The widget detects finished
86
+ // agents itself (AgentWidget.update self-seeds), so NotificationManager has no
87
+ // widget dependency — keeping the construction graph a cycle-free DAG.
88
+ const notifications = new NotificationManager(
89
+ (msg, opts) => pi.sendMessage(msg, opts),
90
+ );
91
+
92
+ // Gate nudge delivery on the parent's agent run. agent_settled fires exactly
93
+ // once per run (from a finally block, so it also covers error and abort),
94
+ // whereas agent_end fires once per run segment — retries, auto-compaction and
95
+ // followUp continuations each emit one.
96
+ pi.on("agent_start", () => notifications.onParentAgentStart());
97
+ pi.on("agent_settled", () => notifications.onParentAgentSettled());
98
+
99
+ // Settings: owns all three in-memory values and handles load/save/emit.
100
+ // onMaxConcurrentChanged is wired to the limiter directly (closure captures by reference).
101
+ const settings = new SettingsManager({
102
+ emit: (event, payload) => pi.events.emit(event, payload),
103
+ cwd: process.cwd(),
104
+ agentDir: getAgentDir(),
105
+ onMaxConcurrentChanged: () => limiter.recheck(),
106
+ });
107
+ settings.load();
108
+
109
+ // Observer: receives agent lifecycle notifications and dispatches events/notifications.
110
+ const eventsObserver = new SubagentEventsObserver({
111
+ emit: (channel, data) => pi.events.emit(channel, data),
112
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
113
+ notifications,
114
+ });
115
+
116
+ // Fan-out observer: lets the widget subscribe as a second lifecycle consumer
117
+ // while the manager keeps its single-observer contract. The widget is added
118
+ // after construction (it needs the manager); the manager consults the observer
119
+ // only at spawn time, so registering late is safe.
120
+ const observer = new CompositeSubagentObserver([eventsObserver]);
121
+
122
+ const subagentSessionDeps: SubagentSessionDeps = {
123
+ io: {
124
+ detectEnv,
125
+ getAgentDir,
126
+ createResourceLoader: (opts) => new DefaultResourceLoader(opts),
127
+ deriveSessionDir: deriveSubagentSessionDir,
128
+ createSessionManager: (cwd, dir) => SessionManager.create(cwd, dir),
129
+ createSettingsManager: (cwd, dir) => SdkSettingsManager.create(cwd, dir),
130
+ // The exclusion policy is resolved here, at the composition root, so the
131
+ // assembly factory stays free of it and gets a ready-made settings view.
132
+ createLoaderSettingsManager: (parent) => {
133
+ const excluded = new Set(settings.excludedExtensionPackages);
134
+ if (excluded.size === 0) return parent;
135
+ return SdkSettingsManager.fromStorage(createExcludedPackagesStorage(parent, excluded), {
136
+ projectTrusted: parent.isProjectTrusted(),
137
+ });
138
+ },
139
+ // The factory states its collaborators as narrow structural contracts so
140
+ // it can be tested with plain stubs. Here at the composition root the
141
+ // values really are the SDK objects, so widen those three and let every
142
+ // other option type-check against the SDK signature.
143
+ createSession: async ({ sessionManager, resourceLoader, modelRegistry, ...rest }) => {
144
+ // Pi builds the child a fresh ModelRuntime whenever it is not given
145
+ // one, and runtime registrations live on the instance rather than in
146
+ // models.json or auth.json — so the child would lose every provider the
147
+ // parent registered via pi.registerProvider. Build the runtime here
148
+ // instead and replay those registrations onto it. The child keeps its
149
+ // own pool, so a child-loaded extension cannot mutate the parent's
150
+ // (Refs #812). The path derivation mirrors the SDK's own.
151
+ const childRuntime = await ModelRuntime.create({
152
+ authPath: join(rest.agentDir, "auth.json"),
153
+ modelsPath: join(rest.agentDir, "models.json"),
154
+ });
155
+ const childRegistry = new SdkModelRegistry(childRuntime);
156
+ inheritRegisteredProviders(modelRegistry as SdkModelRegistry, {
157
+ registerNative: (provider) => {
158
+ childRegistry.registerProvider(provider);
159
+ },
160
+ registerConfigured: (id, config) => {
161
+ childRegistry.registerProvider(id, config);
162
+ },
163
+ });
164
+ return createAgentSession({
165
+ ...rest,
166
+ sessionManager: sessionManager as SessionManager,
167
+ resourceLoader: resourceLoader as ResourceLoader,
168
+ modelRuntime: childRuntime,
169
+ });
170
+ },
171
+ assemblerIO: {
172
+ buildAgentPrompt,
173
+ },
174
+ },
175
+ exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
176
+ registry,
177
+ lifecycle: createChildLifecyclePublisher((channel, data) => pi.events.emit(channel, data)),
178
+ // Resolved here, at the composition root, so the assembly factory stays
179
+ // free of the policy and gets a ready-made settings view — the same shape
180
+ // the extension-exclusion policy reaches it in. It is a resolver rather
181
+ // than a value because only the assembler knows the child's provider.
182
+ resolvePromptInheritance: (provider) => settings.promptInheritanceFor(provider),
183
+ };
184
+
185
+ // ConcurrencyLimiter: schedules background run thunks FIFO against the limit.
186
+ // It knows nothing about agents or the manager — dependency direction is strictly manager → limiter.
187
+ const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
188
+
189
+ const manager = new SubagentManager({
190
+ // The complete child factory call — loader reload, session creation, and
191
+ // extension binding — runs inside the construction wrapper, so a child
192
+ // core factory initializing during loader.reload() captures the inherited
193
+ // selection handle whether or not a provider is ever configured here.
194
+ createSubagentSession: (params) =>
195
+ selectionScope.constructChild(() => createSubagentSession(params, subagentSessionDeps)),
196
+ baseCwd: process.cwd(),
197
+ observer,
198
+ limiter,
199
+ getRunConfig: () => settings,
200
+ getRetentionPolicy: () => settings,
201
+ registry,
202
+ // The same retained scope: an admitted run consults the tree's active
203
+ // provider before creating its child session.
204
+ selectionScope,
205
+ });
206
+
207
+ // Typed service published via Symbol.for() for cross-extension access.
208
+ // Consumers: const { getSubagentsService } = await import("@jopqior/pi-subagents");
209
+ const service = new SubagentsServiceAdapter(manager, resolveModel, runtime);
210
+ publishSubagentsService(service);
211
+
212
+ const lifecycle = new SessionLifecycleHandler(
213
+ runtime,
214
+ manager,
215
+ () => notifications.dispose(),
216
+ unpublishSubagentsService,
217
+ );
218
+
219
+ // Live widget: constructed after the manager (it polls listAgents()) and
220
+ // registered as a lifecycle observer so it self-drives its update timer.
221
+ const widget = new AgentWidget(manager, registry);
222
+ observer.add(widget);
223
+
224
+ // Give the widget its UI context and its turn ticks. Pi fans an event out to
225
+ // every handler an extension registers for it, so these take their own
226
+ // registrations rather than sharing a lambda with an unrelated concern.
227
+ const widgetEvents = new WidgetEventsHandler(widget);
228
+
229
+ pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
230
+ pi.on("session_start", (event, ctx) => widgetEvents.handleSessionStart(event, ctx));
231
+ pi.on("session_before_switch", () => lifecycle.handleSessionBeforeSwitch());
232
+ pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
233
+ // Registered after the lifecycle handler on purpose. Pi awaits an extension's
234
+ // handlers for an event in registration order, so the widget is torn down once
235
+ // `abortAll()` and the awaited `manager.dispose()` have finished — no terminal
236
+ // transition is left to drive an `update()` at a half-disposed widget.
237
+ pi.on("session_shutdown", () => widgetEvents.handleSessionShutdown());
238
+
239
+ // Capture the prompt parts Pi assembled for the parent's turn. This is the
240
+ // only event carrying them — `getSystemPromptOptions()` is attached to a
241
+ // command context, not to the session context the runtime holds — and a spawn
242
+ // renders the parent's portable identity from the latest capture.
243
+ pi.on("before_agent_start", (event) => {
244
+ runtime.setSystemPromptOptions(event.systemPromptOptions);
245
+ });
246
+
247
+ // Abort all subagents when the parent agent loop is interrupted (ESC), unless
248
+ // the user has turned that policy off. The predicate is read at abort time.
249
+ const interrupt = new InterruptHandler(manager, () => settings.abortAllOnInterrupt);
250
+ pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx));
251
+ pi.on("turn_start", () => widgetEvents.handleTurnStart());
252
+
253
+ // ---- Agent tool ----
254
+
255
+ pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
256
+
257
+ // ---- get_subagent_result tool ----
258
+
259
+ pi.registerTool(new GetResultTool(manager, registry).toToolDefinition());
260
+
261
+ // ---- steer_subagent tool ----
262
+
263
+ pi.registerTool(new SteerTool(manager, pi.events).toToolDefinition());
264
+
265
+ // ---- /subagents:settings command ----
266
+
267
+ const subagentsSettings = new SubagentsSettingsHandler(settings);
268
+
269
+ pi.registerCommand("subagents:settings", {
270
+ description: "Configure subagent settings (concurrency, turn limits, retention, interrupt policy)",
271
+ handler: async (_args, ctx) => {
272
+ await subagentsSettings.handle({ ui: ctx.ui });
273
+ },
274
+ });
275
+
276
+ // ---- /subagents:sessions command ----
277
+
278
+ const sessionNavigator = new SessionNavigatorHandler();
279
+
280
+ pi.registerCommand("subagents:sessions", {
281
+ description: "View a subagent's session transcript (read-only)",
282
+ handler: async (_args, ctx) => {
283
+ await sessionNavigator.handle({
284
+ ui: ctx.ui,
285
+ agents: manager.listAgents(),
286
+ registry,
287
+ cwd: ctx.cwd,
288
+ readFile: (path) => readFileSync(path, "utf8"),
289
+ });
290
+ },
291
+ });
292
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Generic layered settings loader for `@gotgenes/pi-*` extensions.
3
+ *
4
+ * Extensions that store configuration in JSON files under a global agent
5
+ * directory and a per-project `.pi/` folder share the same three-step idiom:
6
+ *
7
+ * 1. Read the global file (`<agentDir>/<filename>`).
8
+ * 2. Read the project file (`<cwd>/.pi/<filename>`).
9
+ * 3. Merge them — project wins on conflicts — and return the result.
10
+ *
11
+ * Both layers are optional: a missing file is silent (`{}`), and a file that
12
+ * cannot be parsed warns to stderr and is treated as absent so startup
13
+ * proceeds normally.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```typescript
18
+ * import { loadLayeredSettings, type LayeredSettingsSource } from "@jopqior/pi-subagents/settings";
19
+ *
20
+ * interface MyConfig { enabled?: boolean; limit?: number }
21
+ *
22
+ * function sanitize(raw: unknown): Partial<MyConfig> {
23
+ * if (!raw || typeof raw !== "object") return {};
24
+ * const r = raw as Record<string, unknown>;
25
+ * const out: Partial<MyConfig> = {};
26
+ * if (typeof r.enabled === "boolean") out.enabled = r.enabled;
27
+ * if (typeof r.limit === "number") out.limit = r.limit;
28
+ * return out;
29
+ * }
30
+ *
31
+ * const config = loadLayeredSettings<MyConfig>({
32
+ * agentDir, // e.g. from the Pi runtime env — the agent home directory
33
+ * cwd, // project root — project file is at <cwd>/.pi/<filename>
34
+ * filename: "my-extension.json",
35
+ * sanitize,
36
+ * warnLabel: "my-extension",
37
+ * });
38
+ * ```
39
+ *
40
+ * @public
41
+ */
42
+
43
+ import { existsSync, readFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+
46
+ /**
47
+ * Parameters for one layered settings load: describes where the files live,
48
+ * how to validate their contents, and what label to use in warnings.
49
+ *
50
+ * @public
51
+ */
52
+ export interface LayeredSettingsSource<T> {
53
+ /** Directory holding the global settings file (typically the Pi agent dir). */
54
+ agentDir: string;
55
+ /** Project root; the project file lives at `<cwd>/.pi/<filename>`. */
56
+ cwd: string;
57
+ /** Base filename for both layers, e.g. `"subagents.json"`. */
58
+ filename: string;
59
+ /**
60
+ * Validate and coerce parsed JSON into a partial settings object.
61
+ * Unknown or invalid fields should be silently dropped — return `{}` for
62
+ * unrecognised shapes. Never throw.
63
+ */
64
+ sanitize: (raw: unknown) => Partial<T>;
65
+ /**
66
+ * Short label used in the malformed-file warning prefix,
67
+ * e.g. `"pi-subagents"` → `"[pi-subagents] Ignoring malformed settings at …"`.
68
+ */
69
+ warnLabel: string;
70
+ }
71
+
72
+ /**
73
+ * Load merged layered settings: global provides defaults, project overrides.
74
+ *
75
+ * - A missing file is silent — returns `{}` for that layer.
76
+ * - A file that exists but cannot be parsed warns to stderr and returns `{}` for
77
+ * that layer, so startup proceeds normally.
78
+ * - The two layers are merged with a shallow spread; project keys win.
79
+ *
80
+ * Throws nothing. All error conditions produce a warning and fall back to `{}`.
81
+ *
82
+ * @public
83
+ */
84
+ export function loadLayeredSettings<T>(source: LayeredSettingsSource<T>): Partial<T> {
85
+ const { agentDir, cwd, filename, sanitize, warnLabel } = source;
86
+ const global = readLayer(join(agentDir, filename), sanitize, warnLabel);
87
+ const project = readLayer(join(cwd, ".pi", filename), sanitize, warnLabel);
88
+ return { ...global, ...project };
89
+ }
90
+
91
+ // ── Private helpers ──────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Read one settings file. Missing → `{}` (silent). Malformed → `{}` + warn.
95
+ */
96
+ function readLayer<T>(path: string, sanitize: (raw: unknown) => Partial<T>, warnLabel: string): Partial<T> {
97
+ if (!existsSync(path)) return {};
98
+ try {
99
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
100
+ } catch (err) {
101
+ const reason = err instanceof Error ? err.message : String(err);
102
+ console.warn(`[${warnLabel}] Ignoring malformed settings at ${path}: ${reason}`);
103
+ return {};
104
+ }
105
+ }