@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.
- package/CHANGELOG.md +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -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 +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -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 +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -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 +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- 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
|
+
}
|