@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.
- package/CHANGELOG.md +2411 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/UPSTREAM.md +48 -0
- package/dist/public.d.ts +201 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +833 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +79 -0
- package/docs/configuration.md +215 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/index.ts +1 -0
- package/package.json +91 -4
- package/src/config/agent-enablement.ts +132 -0
- package/src/config/agent-types.ts +144 -0
- package/src/config/custom-agents.ts +114 -0
- package/src/config/default-agents.ts +120 -0
- package/src/config/invocation-config.ts +39 -0
- package/src/config/resource-limits.ts +26 -0
- package/src/config/settings-file.ts +174 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +65 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +257 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +123 -0
- package/src/lifecycle/create-subagent-session.ts +265 -0
- package/src/lifecycle/invocation-control.ts +109 -0
- package/src/lifecycle/lifecycle-record.ts +159 -0
- package/src/lifecycle/parent-snapshot.ts +46 -0
- package/src/lifecycle/run-listeners.ts +41 -0
- package/src/lifecycle/subagent-manager.ts +392 -0
- package/src/lifecycle/subagent-session.ts +287 -0
- package/src/lifecycle/subagent-state.ts +307 -0
- package/src/lifecycle/subagent.ts +854 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +53 -0
- package/src/observation/notification.ts +233 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +106 -0
- package/src/observation/subagent-events-observer.ts +75 -0
- package/src/runtime.ts +78 -0
- package/src/service/service-adapter.ts +159 -0
- package/src/service/service.ts +147 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +135 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +145 -0
- package/src/session/provider-inheritance.ts +38 -0
- package/src/session/session-config.ts +177 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +436 -0
- package/src/tools/agent-tool.ts +289 -0
- package/src/tools/background-spawner.ts +73 -0
- package/src/tools/foreground-runner.ts +135 -0
- package/src/tools/get-result-report.ts +73 -0
- package/src/tools/get-result-tool.ts +109 -0
- package/src/tools/helpers.ts +134 -0
- package/src/tools/result-renderer.ts +121 -0
- package/src/tools/spawn-config.ts +182 -0
- package/src/tools/steer-tool.ts +120 -0
- package/src/types.ts +145 -0
- package/src/ui/agent-widget.ts +313 -0
- package/src/ui/display.ts +214 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +152 -0
- package/src/ui/session-navigator.ts +257 -0
- package/src/ui/subagents-agents.ts +116 -0
- package/src/ui/subagents-settings.ts +207 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +289 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
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 { AgentEnablementSettings } from "#src/config/agent-enablement";
|
|
26
|
+
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
27
|
+
import { loadCustomAgents } from "#src/config/custom-agents";
|
|
28
|
+
import { InterruptHandler, SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
|
|
29
|
+
import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
30
|
+
import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
31
|
+
import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
|
|
32
|
+
import { SubagentManager } from "#src/lifecycle/subagent-manager";
|
|
33
|
+
import { CompositeSubagentObserver } from "#src/observation/composite-subagent-observer";
|
|
34
|
+
import { type NotificationDetails, NotificationManager } from "#src/observation/notification";
|
|
35
|
+
import { createNotificationRenderer } from "#src/observation/renderer";
|
|
36
|
+
import { SubagentEventsObserver } from "#src/observation/subagent-events-observer";
|
|
37
|
+
import { createSubagentRuntime } from "#src/runtime";
|
|
38
|
+
import { publishSubagentsService, unpublishSubagentsService } from "#src/service/service";
|
|
39
|
+
import { SubagentsServiceAdapter } from "#src/service/service-adapter";
|
|
40
|
+
import { detectEnv } from "#src/session/env";
|
|
41
|
+
|
|
42
|
+
import { resolveModel } from "#src/session/model-resolver";
|
|
43
|
+
import { createExcludedPackagesStorage } from "#src/session/package-exclusions";
|
|
44
|
+
import { buildAgentPrompt } from "#src/session/prompts";
|
|
45
|
+
import { inheritRegisteredProviders } from "#src/session/provider-inheritance";
|
|
46
|
+
import { deriveSubagentSessionDir } from "#src/session/session-dir";
|
|
47
|
+
import { SettingsManager } from "#src/settings";
|
|
48
|
+
import { AgentTool } from "#src/tools/agent-tool";
|
|
49
|
+
import { GetResultTool } from "#src/tools/get-result-tool";
|
|
50
|
+
import { SteerTool } from "#src/tools/steer-tool";
|
|
51
|
+
import { AgentWidget } from "#src/ui/agent-widget";
|
|
52
|
+
import { SessionNavigatorHandler } from "#src/ui/session-navigator";
|
|
53
|
+
import { SubagentsAgentsHandler } from "#src/ui/subagents-agents";
|
|
54
|
+
import { SubagentsSettingsHandler } from "#src/ui/subagents-settings";
|
|
55
|
+
|
|
56
|
+
export default function (pi: ExtensionAPI) {
|
|
57
|
+
// ---- Register custom notification renderer ----
|
|
58
|
+
pi.registerMessageRenderer<NotificationDetails>("subagent-notification", createNotificationRenderer());
|
|
59
|
+
|
|
60
|
+
const agentEnablement = new AgentEnablementSettings({
|
|
61
|
+
agentDir: getAgentDir(),
|
|
62
|
+
cwd: process.cwd(),
|
|
63
|
+
});
|
|
64
|
+
const registry = new AgentTypeRegistry(
|
|
65
|
+
() => loadCustomAgents(process.cwd()),
|
|
66
|
+
agentEnablement,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// ---- Runtime: all mutable extension state in one place ----
|
|
70
|
+
const runtime = createSubagentRuntime();
|
|
71
|
+
|
|
72
|
+
// ---- Notification system ----
|
|
73
|
+
// Owns completion nudges and live-activity cleanup. The widget detects finished
|
|
74
|
+
// agents itself (AgentWidget.update self-seeds), so NotificationManager has no
|
|
75
|
+
// widget dependency — keeping the construction graph a cycle-free DAG.
|
|
76
|
+
const notifications = new NotificationManager(
|
|
77
|
+
(msg, opts) => pi.sendMessage(msg, opts),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Gate nudge delivery on the parent's agent run. agent_settled fires exactly
|
|
81
|
+
// once per run (from a finally block, so it also covers error and abort),
|
|
82
|
+
// whereas agent_end fires once per run segment — retries, auto-compaction and
|
|
83
|
+
// followUp continuations each emit one.
|
|
84
|
+
pi.on("agent_start", () => notifications.onParentAgentStart());
|
|
85
|
+
pi.on("agent_settled", () => notifications.onParentAgentSettled());
|
|
86
|
+
|
|
87
|
+
// Settings: owns all three in-memory values and handles load/save/emit.
|
|
88
|
+
// onMaxConcurrentChanged is wired to the limiter directly (closure captures by reference).
|
|
89
|
+
const settings = new SettingsManager({
|
|
90
|
+
emit: (event, payload) => pi.events.emit(event, payload),
|
|
91
|
+
cwd: process.cwd(),
|
|
92
|
+
agentDir: getAgentDir(),
|
|
93
|
+
onMaxConcurrentChanged: () => limiter.recheck(),
|
|
94
|
+
});
|
|
95
|
+
settings.load();
|
|
96
|
+
|
|
97
|
+
// Observer: receives agent lifecycle notifications and dispatches events/notifications.
|
|
98
|
+
const eventsObserver = new SubagentEventsObserver({
|
|
99
|
+
emit: (channel, data) => pi.events.emit(channel, data),
|
|
100
|
+
appendEntry: (customType, data) => pi.appendEntry(customType, data),
|
|
101
|
+
notifications,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Fan-out observer: lets the widget subscribe as a second lifecycle consumer
|
|
105
|
+
// while the manager keeps its single-observer contract. The widget is added
|
|
106
|
+
// after construction (it needs the manager); the manager consults the observer
|
|
107
|
+
// only at spawn time, so registering late is safe.
|
|
108
|
+
const observer = new CompositeSubagentObserver([eventsObserver]);
|
|
109
|
+
|
|
110
|
+
const subagentSessionDeps: SubagentSessionDeps = {
|
|
111
|
+
io: {
|
|
112
|
+
detectEnv,
|
|
113
|
+
getAgentDir,
|
|
114
|
+
createResourceLoader: (opts) => new DefaultResourceLoader(opts),
|
|
115
|
+
deriveSessionDir: deriveSubagentSessionDir,
|
|
116
|
+
createSessionManager: (cwd, dir) => SessionManager.create(cwd, dir),
|
|
117
|
+
createSettingsManager: (cwd, dir) => SdkSettingsManager.create(cwd, dir),
|
|
118
|
+
// The exclusion policy is resolved here, at the composition root, so the
|
|
119
|
+
// assembly factory stays free of it and gets a ready-made settings view.
|
|
120
|
+
createLoaderSettingsManager: (parent) => {
|
|
121
|
+
const excluded = new Set(settings.excludedExtensionPackages);
|
|
122
|
+
if (excluded.size === 0) return parent;
|
|
123
|
+
return SdkSettingsManager.fromStorage(createExcludedPackagesStorage(parent, excluded), {
|
|
124
|
+
projectTrusted: parent.isProjectTrusted(),
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
// Pi 0.85 creates a fresh ModelRuntime for a child session. Runtime
|
|
128
|
+
// provider registrations live on the parent runtime rather than in
|
|
129
|
+
// models.json, so replay them onto the child before session creation.
|
|
130
|
+
createSession: async ({ sessionManager, resourceLoader, modelRegistry, ...rest }) => {
|
|
131
|
+
const childRuntime = await ModelRuntime.create({
|
|
132
|
+
authPath: join(rest.agentDir, "auth.json"),
|
|
133
|
+
modelsPath: join(rest.agentDir, "models.json"),
|
|
134
|
+
});
|
|
135
|
+
const childRegistry = new SdkModelRegistry(childRuntime);
|
|
136
|
+
inheritRegisteredProviders(modelRegistry as SdkModelRegistry, {
|
|
137
|
+
registerNative: (provider) => {
|
|
138
|
+
childRegistry.registerProvider(provider);
|
|
139
|
+
},
|
|
140
|
+
registerConfigured: (id, config) => {
|
|
141
|
+
childRegistry.registerProvider(id, config);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
return createAgentSession({
|
|
145
|
+
...rest,
|
|
146
|
+
sessionManager: sessionManager as SessionManager,
|
|
147
|
+
resourceLoader: resourceLoader as ResourceLoader,
|
|
148
|
+
modelRuntime: childRuntime,
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
assemblerIO: {
|
|
152
|
+
buildAgentPrompt,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
|
|
156
|
+
registry,
|
|
157
|
+
lifecycle: createChildLifecyclePublisher((channel, data) => pi.events.emit(channel, data)),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// ConcurrencyLimiter: schedules background run thunks FIFO against the limit.
|
|
161
|
+
// It knows nothing about agents or the manager — dependency direction is strictly manager → limiter.
|
|
162
|
+
const limiter = new ConcurrencyLimiter(() => settings.maxConcurrent);
|
|
163
|
+
|
|
164
|
+
const manager = new SubagentManager({
|
|
165
|
+
createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
|
|
166
|
+
baseCwd: process.cwd(),
|
|
167
|
+
observer,
|
|
168
|
+
limiter,
|
|
169
|
+
getRunConfig: () => settings,
|
|
170
|
+
getRetentionPolicy: () => settings,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Typed service published via Symbol.for() for cross-extension access.
|
|
174
|
+
// Consumers: const { getSubagentsService } = await import("@yefengr/pi-subagents");
|
|
175
|
+
const service = new SubagentsServiceAdapter(manager, resolveModel, runtime, registry);
|
|
176
|
+
publishSubagentsService(service);
|
|
177
|
+
|
|
178
|
+
const lifecycle = new SessionLifecycleHandler(
|
|
179
|
+
runtime,
|
|
180
|
+
manager,
|
|
181
|
+
() => notifications.dispose(),
|
|
182
|
+
unpublishSubagentsService,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
|
|
186
|
+
pi.on("session_before_switch", () => lifecycle.handleSessionBeforeSwitch());
|
|
187
|
+
pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
|
|
188
|
+
|
|
189
|
+
// Live widget: constructed after the manager (it polls listAgents()) and
|
|
190
|
+
// registered as a lifecycle observer so it self-drives its update timer.
|
|
191
|
+
const widget = new AgentWidget(manager, registry);
|
|
192
|
+
observer.add(widget);
|
|
193
|
+
|
|
194
|
+
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
195
|
+
const toolStart = new ToolStartHandler(widget);
|
|
196
|
+
pi.on("tool_execution_start", (event, ctx) => toolStart.handleToolExecutionStart(event, ctx));
|
|
197
|
+
|
|
198
|
+
// Abort all subagents when the parent agent loop is interrupted (ESC), unless
|
|
199
|
+
// the user has turned that policy off. The predicate is read at abort time.
|
|
200
|
+
const interrupt = new InterruptHandler(manager, () => settings.abortAllOnInterrupt);
|
|
201
|
+
pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx));
|
|
202
|
+
|
|
203
|
+
// ---- Agent tool ----
|
|
204
|
+
|
|
205
|
+
pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
|
|
206
|
+
|
|
207
|
+
// ---- get_subagent_result tool ----
|
|
208
|
+
|
|
209
|
+
pi.registerTool(new GetResultTool(manager, registry).toToolDefinition());
|
|
210
|
+
|
|
211
|
+
// ---- steer_subagent tool ----
|
|
212
|
+
|
|
213
|
+
pi.registerTool(new SteerTool(manager, pi.events).toToolDefinition());
|
|
214
|
+
|
|
215
|
+
// ---- /subagents:settings command ----
|
|
216
|
+
|
|
217
|
+
const subagentsSettings = new SubagentsSettingsHandler(settings);
|
|
218
|
+
|
|
219
|
+
pi.registerCommand("subagents:settings", {
|
|
220
|
+
description: "Configure subagent settings (concurrency, turn limits, retention, interrupt policy)",
|
|
221
|
+
handler: async (_args, ctx) => {
|
|
222
|
+
await subagentsSettings.handle({ ui: ctx.ui });
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ---- /subagents:agents command ----
|
|
227
|
+
|
|
228
|
+
const subagentsAgents = new SubagentsAgentsHandler(
|
|
229
|
+
registry,
|
|
230
|
+
agentEnablement,
|
|
231
|
+
() => registry.reload(),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
pi.registerCommand("subagents:agents", {
|
|
235
|
+
description: "List and enable or disable subagent types for the project or globally",
|
|
236
|
+
handler: async (_args, ctx) => {
|
|
237
|
+
await subagentsAgents.handle({ ui: ctx.ui });
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---- /subagents:sessions command ----
|
|
242
|
+
|
|
243
|
+
const sessionNavigator = new SessionNavigatorHandler();
|
|
244
|
+
|
|
245
|
+
pi.registerCommand("subagents:sessions", {
|
|
246
|
+
description: "View a subagent's session transcript (read-only)",
|
|
247
|
+
handler: async (_args, ctx) => {
|
|
248
|
+
await sessionNavigator.handle({
|
|
249
|
+
ui: ctx.ui,
|
|
250
|
+
agents: manager.listAgents(),
|
|
251
|
+
registry,
|
|
252
|
+
cwd: ctx.cwd,
|
|
253
|
+
readFile: (path) => readFileSync(path, "utf8"),
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -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 "@yefengr/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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-lifecycle.ts — Child-execution lifecycle event contract and publisher.
|
|
3
|
+
*
|
|
4
|
+
* The core publishes its child-execution lifecycle as ordered events on the Pi
|
|
5
|
+
* event bus; reactive consumers (permissions, telemetry, UI) subscribe rather
|
|
6
|
+
* than the core reaching out to them (ADR 0002). This module owns the channel
|
|
7
|
+
* names, payload shapes, and the publisher that emits them.
|
|
8
|
+
*
|
|
9
|
+
* The publisher takes an injected `emit` callback so this module stays free of
|
|
10
|
+
* Pi SDK imports — `index.ts` wires it to `pi.events.emit`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Emitted at the start of a child run, before the session is created. */
|
|
14
|
+
export const SUBAGENT_CHILD_SPAWNING = "subagents:child:spawning";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Emitted after the child session is created, immediately before
|
|
18
|
+
* `bindExtensions()`. Carries the child session id consumers need to register
|
|
19
|
+
* the session in `SubagentSessionRegistry`. Subscribers must register
|
|
20
|
+
* synchronously so the entry lands before binding proceeds (see ADR 0002 /
|
|
21
|
+
* the event-bus synchronous-dispatch guarantee).
|
|
22
|
+
*/
|
|
23
|
+
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
|
|
24
|
+
|
|
25
|
+
/** Emitted after the child's prompt resolves with a classified invocation outcome. */
|
|
26
|
+
export const SUBAGENT_CHILD_COMPLETED = "subagents:child:completed";
|
|
27
|
+
|
|
28
|
+
/** Emitted in the run's `finally` — always fires, on success and error. */
|
|
29
|
+
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
|
|
30
|
+
|
|
31
|
+
/** Payload for `subagents:child:spawning`. */
|
|
32
|
+
export interface ChildSpawningEvent {
|
|
33
|
+
agentName: string;
|
|
34
|
+
parentSessionId?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Payload for `subagents:child:session-created`. */
|
|
38
|
+
export interface ChildSessionCreatedEvent {
|
|
39
|
+
/** Child session id — the registry key. Unique per child; concurrent
|
|
40
|
+
* siblings of the same parent occupy distinct keys. */
|
|
41
|
+
sessionId: string;
|
|
42
|
+
parentSessionId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Payload for `subagents:child:completed`. */
|
|
46
|
+
export interface ChildCompletedEvent {
|
|
47
|
+
sessionDir: string;
|
|
48
|
+
agentName: string;
|
|
49
|
+
outcome: "completed" | "turn_limit" | "timeout" | "cancelled";
|
|
50
|
+
/** Whether the run received the configured wrap-up instruction. */
|
|
51
|
+
wrappedUp: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Payload for `subagents:child:disposed`. */
|
|
55
|
+
export interface ChildDisposedEvent {
|
|
56
|
+
/** Child session id — the registry key. Must match `session-created`. */
|
|
57
|
+
sessionId: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow emit seam — injected, never imports the Pi SDK. */
|
|
61
|
+
export type LifecycleEmit = (channel: string, data: unknown) => void;
|
|
62
|
+
|
|
63
|
+
/** Publishes the child-execution lifecycle on the event bus. */
|
|
64
|
+
export interface ChildLifecyclePublisher {
|
|
65
|
+
spawning(event: ChildSpawningEvent): void;
|
|
66
|
+
sessionCreated(event: ChildSessionCreatedEvent): void;
|
|
67
|
+
completed(event: ChildCompletedEvent): void;
|
|
68
|
+
disposed(event: ChildDisposedEvent): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Build a publisher backed by an injected `emit` callback. */
|
|
72
|
+
export function createChildLifecyclePublisher(
|
|
73
|
+
emit: LifecycleEmit,
|
|
74
|
+
): ChildLifecyclePublisher {
|
|
75
|
+
return {
|
|
76
|
+
spawning(event) {
|
|
77
|
+
emit(SUBAGENT_CHILD_SPAWNING, event);
|
|
78
|
+
},
|
|
79
|
+
sessionCreated(event) {
|
|
80
|
+
emit(SUBAGENT_CHILD_SESSION_CREATED, event);
|
|
81
|
+
},
|
|
82
|
+
completed(event) {
|
|
83
|
+
emit(SUBAGENT_CHILD_COMPLETED, event);
|
|
84
|
+
},
|
|
85
|
+
disposed(event) {
|
|
86
|
+
emit(SUBAGENT_CHILD_DISPOSED, event);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-shutdown.ts — Bounded `session_shutdown` emission for a child session (issue #709).
|
|
3
|
+
*
|
|
4
|
+
* A child session binds the full extension set, so `session_start` fires and every
|
|
5
|
+
* inherited extension initializes. Pi's `AgentSession.dispose()` does not emit the
|
|
6
|
+
* matching `session_shutdown` — only `AgentSessionRuntime` does, and children do not
|
|
7
|
+
* use that path — so extension-owned subprocesses, timers, and sockets outlive the
|
|
8
|
+
* child. This module supplies the missing half of the pair.
|
|
9
|
+
*
|
|
10
|
+
* Two constraints shape it:
|
|
11
|
+
*
|
|
12
|
+
* 1. `AgentSession.dispose()` invalidates the extension runner, and every `ctx`
|
|
13
|
+
* accessor throws once invalidated. The emit must therefore be awaited to
|
|
14
|
+
* completion *before* disposal, never fired and forgotten alongside it.
|
|
15
|
+
* 2. Pi's runner catches each handler's errors but does not bound a handler that
|
|
16
|
+
* hangs. An unbounded await here would stall the parent's teardown and Pi's exit,
|
|
17
|
+
* so the emit races a timeout and teardown proceeds either way.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here throws: a child that will not shut down cleanly must still be disposed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { debugLog } from "#src/debug";
|
|
23
|
+
|
|
24
|
+
/** Upper bound on how long one child's shutdown handlers may take. */
|
|
25
|
+
export const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
26
|
+
|
|
27
|
+
const SESSION_SHUTDOWN = "session_shutdown";
|
|
28
|
+
|
|
29
|
+
/** The event a disposed child session dispatches to its extensions. */
|
|
30
|
+
interface ChildSessionShutdownEvent {
|
|
31
|
+
type: typeof SESSION_SHUTDOWN;
|
|
32
|
+
reason: "quit";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Narrow session seam — only what child shutdown reads.
|
|
37
|
+
*
|
|
38
|
+
* Both members are optional so the emitter stays safe against a Pi build that
|
|
39
|
+
* predates them; either absence means "nothing to emit."
|
|
40
|
+
*/
|
|
41
|
+
export interface ShutdownCapableSession {
|
|
42
|
+
hasExtensionHandlers?(eventType: string): boolean;
|
|
43
|
+
readonly extensionRunner?: {
|
|
44
|
+
emit(event: ChildSessionShutdownEvent): Promise<unknown>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dispatch one `session_shutdown` to a child's extensions and await it, bounded
|
|
50
|
+
* by `timeoutMs`. Resolves quietly on every failure mode — no runner, no handlers,
|
|
51
|
+
* a rejected emit, or a handler that never settles — so disposal always proceeds.
|
|
52
|
+
*/
|
|
53
|
+
export async function emitChildSessionShutdown(
|
|
54
|
+
session: ShutdownCapableSession,
|
|
55
|
+
timeoutMs: number = CHILD_SHUTDOWN_TIMEOUT_MS,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const runner = session.extensionRunner;
|
|
58
|
+
if (!runner || !hasShutdownHandlers(session)) return;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const emitted = runner.emit({ type: SESSION_SHUTDOWN, reason: "quit" });
|
|
62
|
+
if ((await settleWithinBound(emitted, timeoutMs)) === "timed-out") {
|
|
63
|
+
debugLog("child session_shutdown exceeded its bound (ms)", timeoutMs);
|
|
64
|
+
}
|
|
65
|
+
} catch (err) {
|
|
66
|
+
debugLog("child session_shutdown emit", err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether the child has anything to hear the event; unknown counts as yes. */
|
|
71
|
+
function hasShutdownHandlers(session: ShutdownCapableSession): boolean {
|
|
72
|
+
return session.hasExtensionHandlers?.(SESSION_SHUTDOWN) ?? true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ShutdownOutcome = "settled" | "timed-out";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve when `emitted` settles or when `timeoutMs` elapses, whichever is first.
|
|
79
|
+
* A rejection counts as settled (the caller logs it); the timer is always cleared,
|
|
80
|
+
* so a prompt shutdown leaves nothing holding the event loop.
|
|
81
|
+
*/
|
|
82
|
+
async function settleWithinBound(
|
|
83
|
+
emitted: Promise<unknown>,
|
|
84
|
+
timeoutMs: number,
|
|
85
|
+
): Promise<ShutdownOutcome> {
|
|
86
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
87
|
+
const bound = new Promise<ShutdownOutcome>((resolve) => {
|
|
88
|
+
timer = setTimeout(() => {
|
|
89
|
+
resolve("timed-out");
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
});
|
|
92
|
+
const settled = emitted.then(
|
|
93
|
+
(): ShutdownOutcome => "settled",
|
|
94
|
+
(err: unknown): ShutdownOutcome => {
|
|
95
|
+
debugLog("child session_shutdown handler", err);
|
|
96
|
+
return "settled";
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
return await Promise.race([settled, bound]);
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* concurrency-limiter.ts — FIFO admission gate for background work.
|
|
3
|
+
*
|
|
4
|
+
* Schedules run closures (thunks) against a dynamic limit, running them in
|
|
5
|
+
* scheduling order as slots free. The limiter knows nothing about agents, IDs,
|
|
6
|
+
* or the manager — it owns only the active count and the pending queue.
|
|
7
|
+
*
|
|
8
|
+
* Every scheduled promise settles: it follows the task's settlement when the
|
|
9
|
+
* task runs, resolves early if clear() drops it before it starts, or settles
|
|
10
|
+
* when its queued-timeout elapses first (resolving, or rejecting if onTimeout
|
|
11
|
+
* throws).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Optional per-task bounds for schedule(). */
|
|
15
|
+
export interface ScheduleOptions {
|
|
16
|
+
/** Positive milliseconds the task may sit queued before it is dropped. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Called once when the queued-timeout drops the task; never called by clear(). */
|
|
19
|
+
onTimeout?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingEntry {
|
|
23
|
+
/** Start the task: consumes the entry and cancels its queued-timeout timer. */
|
|
24
|
+
start: () => void;
|
|
25
|
+
/** Resolve the schedule() promise without running the task. */
|
|
26
|
+
settle: () => void;
|
|
27
|
+
/** Cancel the queued-timeout timer if it is still armed. */
|
|
28
|
+
cancelTimer: () => void;
|
|
29
|
+
/** Timer armed while the entry waits in the queue. */
|
|
30
|
+
timeout: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
/** Set once the entry starts or times out, so neither happens twice. */
|
|
32
|
+
started: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ConcurrencyLimiter {
|
|
36
|
+
private active = 0;
|
|
37
|
+
private readonly pending: PendingEntry[] = [];
|
|
38
|
+
|
|
39
|
+
constructor(private readonly getLimit: () => number) {}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Schedule a task to run FIFO once a slot is free.
|
|
43
|
+
* Returns a promise that settles with the task, resolves early if the task
|
|
44
|
+
* is dropped by clear(), or settles early when options.timeoutMs elapses
|
|
45
|
+
* while the task is still pending. An already-started task ignores its
|
|
46
|
+
* timeout.
|
|
47
|
+
*/
|
|
48
|
+
schedule(task: () => Promise<void>, options?: ScheduleOptions): Promise<void> {
|
|
49
|
+
const timeoutMs = options?.timeoutMs;
|
|
50
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
|
51
|
+
throw new RangeError(`timeoutMs must be a positive finite number, got ${timeoutMs}`);
|
|
52
|
+
}
|
|
53
|
+
const onTimeout = options?.onTimeout;
|
|
54
|
+
|
|
55
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>(); // eslint-disable-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void> is valid; rule does not allow void in generic fn call type args
|
|
56
|
+
const entry: PendingEntry = {
|
|
57
|
+
timeout: undefined,
|
|
58
|
+
started: false,
|
|
59
|
+
settle: resolve,
|
|
60
|
+
cancelTimer: () => {
|
|
61
|
+
if (entry.timeout !== undefined) {
|
|
62
|
+
clearTimeout(entry.timeout);
|
|
63
|
+
entry.timeout = undefined;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
start: () => {
|
|
67
|
+
entry.started = true;
|
|
68
|
+
entry.cancelTimer();
|
|
69
|
+
this.active++;
|
|
70
|
+
task()
|
|
71
|
+
.then(resolve, reject)
|
|
72
|
+
.finally(() => {
|
|
73
|
+
this.active--;
|
|
74
|
+
this.recheck();
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (timeoutMs !== undefined) {
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
entry.timeout = undefined;
|
|
82
|
+
// start() already consumed the entry and cleared us.
|
|
83
|
+
if (entry.started) return;
|
|
84
|
+
entry.started = true;
|
|
85
|
+
// clear() already dropped the entry; a late timer must settle nothing.
|
|
86
|
+
const index = this.pending.indexOf(entry);
|
|
87
|
+
if (index === -1) return;
|
|
88
|
+
this.pending.splice(index, 1);
|
|
89
|
+
try {
|
|
90
|
+
onTimeout?.();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
reject(error);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
resolve();
|
|
96
|
+
}, timeoutMs);
|
|
97
|
+
timer.unref?.();
|
|
98
|
+
entry.timeout = timer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.pending.push(entry);
|
|
102
|
+
this.recheck();
|
|
103
|
+
return promise;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Start pending tasks until the limit is reached. Call when the limit may have grown. */
|
|
107
|
+
recheck(): void {
|
|
108
|
+
while (this.active < this.getLimit()) {
|
|
109
|
+
const next = this.pending.shift();
|
|
110
|
+
if (!next) break;
|
|
111
|
+
next.start();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Drop all pending tasks, resolving their promises without running them or firing their timeouts. */
|
|
116
|
+
clear(): void {
|
|
117
|
+
const dropped = this.pending.splice(0);
|
|
118
|
+
for (const entry of dropped) {
|
|
119
|
+
entry.cancelTimer();
|
|
120
|
+
entry.settle();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|