@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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* record-observer.ts — Subscribes to session events and accumulates SubagentState stats.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the scattered callback-wrapping logic in SubagentManager's startAgent()
|
|
5
|
+
* and resume() with a single direct subscription. The observer targets the
|
|
6
|
+
* SubagentState value object directly, so it carries no dependency on Subagent;
|
|
7
|
+
* the caller forwards itself to its own lifecycle observer via onCompact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SubagentState } from "#src/lifecycle/subagent-state";
|
|
11
|
+
import type { CompactionInfo, SubscribableSession } from "#src/types";
|
|
12
|
+
|
|
13
|
+
export interface SubagentObserverOptions {
|
|
14
|
+
onCompact?: (info: CompactionInfo) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to session events and accumulate stats on the subagent state.
|
|
19
|
+
*
|
|
20
|
+
* Handles:
|
|
21
|
+
* - `tool_execution_start` → `state.addActiveTool(name)`
|
|
22
|
+
* - `tool_execution_end` → `state.removeActiveTool(name)`, `state.incrementToolUses()`
|
|
23
|
+
* - `message_start` → `state.resetResponseText()`
|
|
24
|
+
* - `message_update` (text_delta) → `state.appendResponseText(delta)`
|
|
25
|
+
* - `message_end` (assistant, with usage) → `state.addUsage(…)`
|
|
26
|
+
* - `turn_end` → `state.incrementTurnCount()`
|
|
27
|
+
* - `compaction_end` (not aborted) → `state.incrementCompactions()`, call `onCompact`
|
|
28
|
+
*
|
|
29
|
+
* @returns An unsubscribe function.
|
|
30
|
+
*/
|
|
31
|
+
export function subscribeSubagentObserver(
|
|
32
|
+
session: SubscribableSession,
|
|
33
|
+
state: SubagentState,
|
|
34
|
+
options?: SubagentObserverOptions,
|
|
35
|
+
): () => void {
|
|
36
|
+
return session.subscribe((event) => {
|
|
37
|
+
if (event.type === "tool_execution_start") {
|
|
38
|
+
state.addActiveTool(event.toolName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (event.type === "tool_execution_end") {
|
|
42
|
+
state.removeActiveTool(event.toolName);
|
|
43
|
+
state.incrementToolUses();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (event.type === "message_start") {
|
|
47
|
+
state.resetResponseText();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
event.type === "message_update" &&
|
|
52
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
53
|
+
) {
|
|
54
|
+
state.appendResponseText(event.assistantMessageEvent.delta);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (event.type === "turn_end") {
|
|
58
|
+
state.incrementTurnCount();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
62
|
+
const u = event.message.usage;
|
|
63
|
+
state.addUsage({
|
|
64
|
+
input: u.input,
|
|
65
|
+
output: u.output,
|
|
66
|
+
cacheWrite: u.cacheWrite,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
71
|
+
state.incrementCompactions();
|
|
72
|
+
options?.onCompact?.({
|
|
73
|
+
reason: event.reason,
|
|
74
|
+
tokensBefore: event.result.tokensBefore,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import {
|
|
3
|
+
isTerminalErrorStatus,
|
|
4
|
+
type SubagentStatus,
|
|
5
|
+
} from "#src/lifecycle/subagent-state";
|
|
6
|
+
import type {
|
|
7
|
+
NotificationDetails,
|
|
8
|
+
UpdateDetails,
|
|
9
|
+
WorkspaceNoticeDetails,
|
|
10
|
+
} from "#src/observation/notification";
|
|
11
|
+
import { formatMs, formatTokens, formatTurns } from "#src/ui/display";
|
|
12
|
+
import { GLYPHS } from "#src/ui/glyphs";
|
|
13
|
+
|
|
14
|
+
/** Narrow theme interface — only the methods the renderer actually calls. */
|
|
15
|
+
interface RendererTheme {
|
|
16
|
+
fg(style: string, text: string): string;
|
|
17
|
+
bold(text: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Narrow message interface — only the fields the renderer reads. */
|
|
21
|
+
interface RendererMessage {
|
|
22
|
+
details?: NotificationDetails;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Narrow message interface for the update renderer. */
|
|
26
|
+
interface UpdateMessage {
|
|
27
|
+
details?: UpdateDetails;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Narrow message interface for the workspace-notice renderer. */
|
|
31
|
+
interface WorkspaceNoticeMessage {
|
|
32
|
+
details?: WorkspaceNoticeDetails;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Narrow render options — only the fields the renderer reads. */
|
|
36
|
+
interface RenderOptions {
|
|
37
|
+
expanded: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Pure helpers (exported for unit testing) ----
|
|
41
|
+
|
|
42
|
+
/** Resolved status→presentation product: icon glyph/style and status label. */
|
|
43
|
+
export interface StatusPresentation {
|
|
44
|
+
iconGlyph: string;
|
|
45
|
+
iconStyle: string;
|
|
46
|
+
statusText: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Decide the icon and status label for a notification's status, once. */
|
|
50
|
+
export function resolveStatusPresentation(status: SubagentStatus): StatusPresentation {
|
|
51
|
+
if (isTerminalErrorStatus(status))
|
|
52
|
+
return { iconGlyph: GLYPHS.failure, iconStyle: "error", statusText: status };
|
|
53
|
+
const statusText = status === "steered" ? "completed (steered)" : "completed";
|
|
54
|
+
return { iconGlyph: GLYPHS.success, iconStyle: "success", statusText };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fields `buildStatsParts` reads from a `NotificationDetails`. */
|
|
58
|
+
type StatsSource = Pick<
|
|
59
|
+
NotificationDetails,
|
|
60
|
+
"turnCount" | "maxTurns" | "toolUses" | "totalTokens" | "durationMs"
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
/** Assemble the stats-line parts (turns, tool uses, tokens, duration), omitting zero fields. */
|
|
64
|
+
export function buildStatsParts(d: StatsSource): string[] {
|
|
65
|
+
const parts: string[] = [];
|
|
66
|
+
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
67
|
+
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
68
|
+
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
69
|
+
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
|
|
70
|
+
return parts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Content lines for the result preview: the whole result (capped at 30 lines)
|
|
75
|
+
* when expanded, or just the first line (capped at 80 columns) when collapsed.
|
|
76
|
+
*/
|
|
77
|
+
export function buildPreviewLines(resultPreview: string, expanded: boolean): string[] {
|
|
78
|
+
if (expanded) return resultPreview.split("\n").slice(0, 30);
|
|
79
|
+
return [resultPreview.split("\n")[0]?.slice(0, 80) ?? ""];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create the notification renderer callback for `pi.registerMessageRenderer`.
|
|
84
|
+
* Returns a factory so the renderer is independently testable without the Pi SDK.
|
|
85
|
+
*/
|
|
86
|
+
export function createNotificationRenderer() {
|
|
87
|
+
return (message: RendererMessage, { expanded }: RenderOptions, theme: RendererTheme): Text | undefined => {
|
|
88
|
+
const d = message.details;
|
|
89
|
+
if (!d) return undefined;
|
|
90
|
+
|
|
91
|
+
const { iconGlyph, iconStyle, statusText } = resolveStatusPresentation(d.status);
|
|
92
|
+
|
|
93
|
+
// Line 1: icon + agent description + status
|
|
94
|
+
let line = `${theme.fg(iconStyle, iconGlyph)} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
|
|
95
|
+
|
|
96
|
+
// Line 2: stats
|
|
97
|
+
const parts = buildStatsParts(d);
|
|
98
|
+
if (parts.length) {
|
|
99
|
+
line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Line 3: result preview (collapsed) or full (expanded)
|
|
103
|
+
const previewLines = buildPreviewLines(d.resultPreview, expanded);
|
|
104
|
+
if (expanded) {
|
|
105
|
+
for (const l of previewLines) line += "\n" + theme.fg("dim", ` ${l}`);
|
|
106
|
+
} else {
|
|
107
|
+
line += "\n " + theme.fg("dim", `${GLYPHS.subLine} ${previewLines[0] ?? ""}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Line 4: output file link (if present)
|
|
111
|
+
if (d.outputFile) {
|
|
112
|
+
line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new Text(line, 0, 0);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create the mid-run update renderer for `pi.registerMessageRenderer`.
|
|
121
|
+
*
|
|
122
|
+
* Separate from the completion renderer because the agent has not finished:
|
|
123
|
+
* `resolveStatusPresentation` speaks only terminal statuses, so reusing it
|
|
124
|
+
* would draw a still-running child as completed.
|
|
125
|
+
*/
|
|
126
|
+
export function createUpdateRenderer() {
|
|
127
|
+
return (message: UpdateMessage, { expanded }: RenderOptions, theme: RendererTheme): Text | undefined => {
|
|
128
|
+
const d = message.details;
|
|
129
|
+
if (!d) return undefined;
|
|
130
|
+
|
|
131
|
+
let line = `${theme.fg("info", GLYPHS.agentsActive)} ${theme.bold(d.description)} ${theme.fg("dim", "update")}`;
|
|
132
|
+
for (const l of buildPreviewLines(d.message, expanded)) {
|
|
133
|
+
line += "\n " + theme.fg("dim", expanded ? ` ${l}` : `${GLYPHS.subLine} ${l}`);
|
|
134
|
+
}
|
|
135
|
+
return new Text(line, 0, 0);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create the workspace-notice renderer for `pi.registerMessageRenderer`.
|
|
141
|
+
*
|
|
142
|
+
* Separate from the completion renderer because the agent's outcome was
|
|
143
|
+
* reported long ago and has not changed: this reports only what a teardown did
|
|
144
|
+
* with the child's work, so it carries no status, stats, or result preview.
|
|
145
|
+
*/
|
|
146
|
+
export function createWorkspaceNoticeRenderer() {
|
|
147
|
+
return (
|
|
148
|
+
message: WorkspaceNoticeMessage,
|
|
149
|
+
{ expanded }: RenderOptions,
|
|
150
|
+
theme: RendererTheme,
|
|
151
|
+
): Text | undefined => {
|
|
152
|
+
const d = message.details;
|
|
153
|
+
if (!d) return undefined;
|
|
154
|
+
|
|
155
|
+
let line = `${theme.fg("warning", GLYPHS.success)} ${theme.bold(d.description)} ${theme.fg("dim", "workspace")}`;
|
|
156
|
+
for (const l of buildPreviewLines(d.notice.trim(), expanded)) {
|
|
157
|
+
line += "\n " + theme.fg("dim", expanded ? ` ${l}` : `${GLYPHS.subLine} ${l}`);
|
|
158
|
+
}
|
|
159
|
+
return new Text(line, 0, 0);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
|
|
2
|
+
import { buildEventData, type NotificationSystem } from "#src/observation/notification";
|
|
3
|
+
import type { CompactionInfo, Subagent } from "#src/types";
|
|
4
|
+
|
|
5
|
+
/** Emit callback — a subset of `pi.events.emit`. */
|
|
6
|
+
export type EventEmit = (channel: string, data: unknown) => void;
|
|
7
|
+
|
|
8
|
+
/** Append callback — a subset of `pi.appendEntry`. */
|
|
9
|
+
export type AppendEntry = (customType: string, data: unknown) => void;
|
|
10
|
+
|
|
11
|
+
export interface SubagentEventsObserverDeps {
|
|
12
|
+
emit: EventEmit;
|
|
13
|
+
appendEntry: AppendEntry;
|
|
14
|
+
notifications: NotificationSystem;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Receives agent lifecycle notifications from SubagentManager and dispatches
|
|
19
|
+
* them to three concerns: pi.events lifecycle events, session-entry persistence,
|
|
20
|
+
* and completion notifications.
|
|
21
|
+
*
|
|
22
|
+
* Constructed with narrow deps (emit, appendEntry, NotificationSystem) so all
|
|
23
|
+
* three concerns are unit-testable without booting the extension.
|
|
24
|
+
*/
|
|
25
|
+
export class SubagentEventsObserver implements SubagentManagerObserver {
|
|
26
|
+
private readonly emit: EventEmit;
|
|
27
|
+
private readonly appendEntry: AppendEntry;
|
|
28
|
+
private readonly notifications: NotificationSystem;
|
|
29
|
+
|
|
30
|
+
constructor(deps: SubagentEventsObserverDeps) {
|
|
31
|
+
this.emit = deps.emit;
|
|
32
|
+
this.appendEntry = deps.appendEntry;
|
|
33
|
+
this.notifications = deps.notifications;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onSubagentStarted(record: Subagent): void {
|
|
37
|
+
// Emit started event when agent transitions to running (including from queue).
|
|
38
|
+
this.emit("subagents:started", {
|
|
39
|
+
id: record.id,
|
|
40
|
+
type: record.type,
|
|
41
|
+
description: record.description,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onSubagentCompleted(record: Subagent): void {
|
|
46
|
+
// Emit lifecycle event based on terminal status.
|
|
47
|
+
const isError = record.isTerminalError();
|
|
48
|
+
const eventData = buildEventData(record);
|
|
49
|
+
if (isError) {
|
|
50
|
+
this.emit("subagents:failed", eventData);
|
|
51
|
+
} else {
|
|
52
|
+
this.emit("subagents:completed", eventData);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.persistAndNotify(record);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A settled agent went back to running. Announced only, and on its own
|
|
60
|
+
* channel: `subagents:started` reports the first run, and a consumer counting
|
|
61
|
+
* it once per agent must not see it twice. Nothing is persisted — the session
|
|
62
|
+
* entry records outcomes, and a run that has just begun is not one.
|
|
63
|
+
*/
|
|
64
|
+
onSubagentResuming(record: Subagent): void {
|
|
65
|
+
this.emit("subagents:resuming", {
|
|
66
|
+
id: record.id,
|
|
67
|
+
type: record.type,
|
|
68
|
+
description: record.description,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onSubagentResumed(record: Subagent): void {
|
|
73
|
+
// A resumed run terminates only as completed or error; a single distinct
|
|
74
|
+
// channel carries both — the payload's status/error discriminate. Existing
|
|
75
|
+
// subagents:completed/failed subscribers keep their once-per-run semantics.
|
|
76
|
+
this.emit("subagents:resumed", buildEventData(record));
|
|
77
|
+
this.persistAndNotify(record);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Persist the terminal record for cross-extension history reconstruction and
|
|
82
|
+
* announce completion. Shared by every terminal-state handler (fresh and
|
|
83
|
+
* resumed). Whether a nudge is actually owed is the notification manager's
|
|
84
|
+
* decision — it suppresses itself when a carrier has claimed the outcome or
|
|
85
|
+
* the parent has already consumed it. Both are domain state on the record,
|
|
86
|
+
* not owned here.
|
|
87
|
+
*/
|
|
88
|
+
private persistAndNotify(record: Subagent): void {
|
|
89
|
+
this.appendEntry("subagents:record", {
|
|
90
|
+
id: record.id,
|
|
91
|
+
type: record.type,
|
|
92
|
+
description: record.description,
|
|
93
|
+
status: record.status,
|
|
94
|
+
result: record.result,
|
|
95
|
+
error: record.error,
|
|
96
|
+
startedAt: record.startedAt,
|
|
97
|
+
completedAt: record.completedAt,
|
|
98
|
+
});
|
|
99
|
+
this.notifications.sendCompletion(record);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A still-running child sent its parent a message. Announced, never
|
|
104
|
+
* persisted: the session entry reconstructs terminal outcomes, and this is
|
|
105
|
+
* not one.
|
|
106
|
+
*/
|
|
107
|
+
onSubagentUpdate(record: Subagent, message: string): void {
|
|
108
|
+
this.emit("subagents:update", {
|
|
109
|
+
id: record.id,
|
|
110
|
+
type: record.type,
|
|
111
|
+
description: record.description,
|
|
112
|
+
message,
|
|
113
|
+
});
|
|
114
|
+
this.notifications.sendUpdate(record, message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A teardown after the child's result was delivered reported where its work
|
|
119
|
+
* went. Announced only: no event channel, because no consumer asks for one,
|
|
120
|
+
* and nothing is persisted — the outcome this belongs to was recorded when
|
|
121
|
+
* the run ended.
|
|
122
|
+
*/
|
|
123
|
+
onSubagentWorkspaceNotice(record: Subagent, notice: string): void {
|
|
124
|
+
this.notifications.sendWorkspaceNotice(record, notice);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
|
|
128
|
+
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
129
|
+
this.emit("subagents:compacted", {
|
|
130
|
+
id: record.id,
|
|
131
|
+
type: record.type,
|
|
132
|
+
description: record.description,
|
|
133
|
+
reason: info.reason,
|
|
134
|
+
tokensBefore: info.tokensBefore,
|
|
135
|
+
compactionCount: record.compactionCount,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
onSubagentCreated(record: Subagent): void {
|
|
140
|
+
// Emit created event for background agents (before limiter admission).
|
|
141
|
+
this.emit("subagents:created", {
|
|
142
|
+
id: record.id,
|
|
143
|
+
type: record.type,
|
|
144
|
+
description: record.description,
|
|
145
|
+
isBackground: record.isBackground,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime.ts — SubagentRuntime: composition root for all mutable extension state.
|
|
3
|
+
*
|
|
4
|
+
* Eliminates module-scope state in agent-runner.ts and closure-scoped state
|
|
5
|
+
* in index.ts by consolidating them into a single, testable object.
|
|
6
|
+
* Follows the same pattern as pi-permission-system's ExtensionRuntime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildParentSnapshot,
|
|
11
|
+
type ParentPromptOptions,
|
|
12
|
+
type ParentSnapshot,
|
|
13
|
+
} from "#src/lifecycle/parent-snapshot";
|
|
14
|
+
import type { SelectionScopeHandle } from "#src/lifecycle/selection-scope";
|
|
15
|
+
import { SpawnSelectionScope } from "#src/lifecycle/spawn-selection";
|
|
16
|
+
import type { SpawnSelectionProvider, SpawnSelectionRegistration } from "#src/service/service";
|
|
17
|
+
import type { ModelInfo } from "#src/tools/spawn-config";
|
|
18
|
+
import type { SessionContext } from "#src/types";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Narrow config subset read by Agent when driving the turn loop (defaultMaxTurns, graceTurns).
|
|
22
|
+
* Kept separate so callers can satisfy it without depending on the full runtime.
|
|
23
|
+
*/
|
|
24
|
+
export interface RunConfig {
|
|
25
|
+
readonly defaultMaxTurns: number | undefined;
|
|
26
|
+
readonly graceTurns: number;
|
|
27
|
+
/** Whether a background child gets the `notify_parent` channel. */
|
|
28
|
+
readonly midRunUpdates: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* All mutable state owned by the pi-subagents extension.
|
|
33
|
+
*
|
|
34
|
+
* Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`.
|
|
35
|
+
* Tests construct a fresh runtime per test for full isolation.
|
|
36
|
+
*/
|
|
37
|
+
export class SubagentRuntime {
|
|
38
|
+
// ── Session state (was closure-scoped in index.ts) ───────────────────────
|
|
39
|
+
/** Active Pi session context — set on session_start, cleared on session_shutdown. */
|
|
40
|
+
currentCtx: SessionContext | undefined = undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prompt options Pi assembled for the parent's latest turn, captured from
|
|
44
|
+
* `before_agent_start`. Undefined until the parent has run one.
|
|
45
|
+
*/
|
|
46
|
+
private lastPromptOptions: ParentPromptOptions | undefined = undefined;
|
|
47
|
+
|
|
48
|
+
// ── Spawn selection ─────────────────────────────────────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* The selection scope this runtime retains: an owned root scope when the
|
|
51
|
+
* factory found no ambient construction context, an inherited child handle
|
|
52
|
+
* when it did. Captured once at factory initialization — never re-read from
|
|
53
|
+
* ambient state later.
|
|
54
|
+
*/
|
|
55
|
+
private readonly selectionScope: SelectionScopeHandle;
|
|
56
|
+
|
|
57
|
+
constructor(selectionScope?: SelectionScopeHandle) {
|
|
58
|
+
this.selectionScope = selectionScope ?? new SpawnSelectionScope();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Register a spawn-selection provider on the retained scope — `owned` on a
|
|
63
|
+
* root, `inherited` (nothing installed) on a descendant.
|
|
64
|
+
*/
|
|
65
|
+
registerSpawnSelectionProvider(provider: SpawnSelectionProvider): SpawnSelectionRegistration {
|
|
66
|
+
return this.selectionScope.register(provider);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Close the retained scope: owner-aware — a root revokes its lease and every
|
|
71
|
+
* descendant handle; a child frees only its own subtree. First operation of
|
|
72
|
+
* session shutdown, before anything that could still be observed by a
|
|
73
|
+
* pending selection.
|
|
74
|
+
*/
|
|
75
|
+
closeSelectionScope(): void {
|
|
76
|
+
this.selectionScope.close();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Session-context methods ──────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/** Store the active Pi session context (called from session_start). */
|
|
82
|
+
setSessionContext(ctx: SessionContext): void {
|
|
83
|
+
this.currentCtx = ctx;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Clear the session context (called from session_shutdown). */
|
|
87
|
+
clearSessionContext(): void {
|
|
88
|
+
this.currentCtx = undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Record the prompt options Pi assembled for the parent's latest turn.
|
|
93
|
+
*
|
|
94
|
+
* Captured from `before_agent_start`, which is the only event carrying them:
|
|
95
|
+
* `getSystemPromptOptions()` is attached to a command context, not to the
|
|
96
|
+
* session context this runtime holds.
|
|
97
|
+
*/
|
|
98
|
+
setSystemPromptOptions(options: ParentPromptOptions): void {
|
|
99
|
+
this.lastPromptOptions = options;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build a parent snapshot from the current session context.
|
|
104
|
+
* Only valid during an active session (currentCtx is defined).
|
|
105
|
+
*/
|
|
106
|
+
buildSnapshot(inheritContext: boolean): ParentSnapshot {
|
|
107
|
+
|
|
108
|
+
return buildParentSnapshot(this.currentCtx!, inheritContext, this.lastPromptOptions);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extract model info from the current session context. */
|
|
112
|
+
getModelInfo(): ModelInfo {
|
|
113
|
+
return {
|
|
114
|
+
parentModel: this.currentCtx?.model,
|
|
115
|
+
modelRegistry: this.currentCtx?.modelRegistry,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Extract session identity from the current session context. */
|
|
120
|
+
getSessionInfo(): { parentSessionFile: string; parentSessionId: string } {
|
|
121
|
+
return {
|
|
122
|
+
parentSessionFile: this.currentCtx?.sessionManager.getSessionFile() ?? "",
|
|
123
|
+
parentSessionId: this.currentCtx?.sessionManager.getSessionId() ?? "",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a fully-initialized SubagentRuntime.
|
|
130
|
+
*
|
|
131
|
+
* Call once at extension startup with the captured selection scope (a root
|
|
132
|
+
* lease here, an inherited handle in a child); pass the result to factories
|
|
133
|
+
* and handlers.
|
|
134
|
+
*/
|
|
135
|
+
export function createSubagentRuntime(selectionScope?: SelectionScopeHandle): SubagentRuntime {
|
|
136
|
+
return new SubagentRuntime(selectionScope);
|
|
137
|
+
}
|