@yandy0725/pi-subagents 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.
Files changed (61) hide show
  1. package/README.md +155 -0
  2. package/README.zh.md +155 -0
  3. package/index.ts +1 -0
  4. package/package.json +49 -0
  5. package/src/config/agent-types.ts +127 -0
  6. package/src/config/custom-agents.ts +109 -0
  7. package/src/config/default-agents.ts +117 -0
  8. package/src/config/invocation-config.ts +30 -0
  9. package/src/debug.ts +14 -0
  10. package/src/handlers/index.ts +3 -0
  11. package/src/handlers/interrupt.ts +49 -0
  12. package/src/handlers/lifecycle.ts +63 -0
  13. package/src/handlers/tool-start.ts +32 -0
  14. package/src/index.ts +186 -0
  15. package/src/layered-settings.ts +105 -0
  16. package/src/lifecycle/child-lifecycle.ts +88 -0
  17. package/src/lifecycle/concurrency-limiter.ts +55 -0
  18. package/src/lifecycle/create-subagent-session.ts +240 -0
  19. package/src/lifecycle/parent-snapshot.ts +45 -0
  20. package/src/lifecycle/run-listeners.ts +37 -0
  21. package/src/lifecycle/subagent-manager.ts +353 -0
  22. package/src/lifecycle/subagent-session.ts +232 -0
  23. package/src/lifecycle/subagent-state.ts +216 -0
  24. package/src/lifecycle/subagent.ts +498 -0
  25. package/src/lifecycle/turn-limits.ts +13 -0
  26. package/src/lifecycle/usage.ts +65 -0
  27. package/src/lifecycle/workspace-bracket.ts +59 -0
  28. package/src/lifecycle/workspace.ts +47 -0
  29. package/src/observation/composite-subagent-observer.ts +49 -0
  30. package/src/observation/notification-state.ts +27 -0
  31. package/src/observation/notification.ts +186 -0
  32. package/src/observation/record-observer.ts +75 -0
  33. package/src/observation/renderer.ts +63 -0
  34. package/src/observation/subagent-events-observer.ts +94 -0
  35. package/src/runtime.ts +77 -0
  36. package/src/service/service-adapter.ts +131 -0
  37. package/src/service/service.ts +123 -0
  38. package/src/session/content-items.ts +51 -0
  39. package/src/session/context.ts +78 -0
  40. package/src/session/conversation.ts +44 -0
  41. package/src/session/env.ts +40 -0
  42. package/src/session/model-resolver.ts +121 -0
  43. package/src/session/prompts.ts +83 -0
  44. package/src/session/session-config.ts +172 -0
  45. package/src/session/session-dir.ts +38 -0
  46. package/src/settings.ts +227 -0
  47. package/src/tools/agent-tool.ts +220 -0
  48. package/src/tools/background-spawner.ts +66 -0
  49. package/src/tools/foreground-runner.ts +114 -0
  50. package/src/tools/get-result-tool.ts +120 -0
  51. package/src/tools/helpers.ts +105 -0
  52. package/src/tools/result-renderer.ts +109 -0
  53. package/src/tools/spawn-config.ts +150 -0
  54. package/src/tools/steer-tool.ts +90 -0
  55. package/src/types.ts +115 -0
  56. package/src/ui/agent-widget.ts +311 -0
  57. package/src/ui/display.ts +174 -0
  58. package/src/ui/session-navigation.ts +147 -0
  59. package/src/ui/session-navigator.ts +406 -0
  60. package/src/ui/subagents-settings.ts +77 -0
  61. package/src/ui/widget-renderer.ts +296 -0
@@ -0,0 +1,13 @@
1
+ /**
2
+ * turn-limits.ts — Pure turn-limit normalization for subagent execution.
3
+ *
4
+ * Extracted from agent-runner.ts (issue #265) so the turn-counting policy has a
5
+ * focused home independent of session assembly. Consumed by the subagent tool's
6
+ * spawn-config resolution and by the turn loop in SubagentSession.
7
+ */
8
+
9
+ /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
10
+ export function normalizeMaxTurns(n: number | undefined): number | undefined {
11
+ if (n == null || n === 0) return undefined;
12
+ return Math.max(1, n);
13
+ }
@@ -0,0 +1,65 @@
1
+ /** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
2
+
3
+ /**
4
+ * Lifetime usage components, accumulated via `message_end` events. Survives
5
+ * compaction (which replaces session.state.messages and would reset any
6
+ * stats-derived sum). cacheRead is excluded because each turn's cacheRead is
7
+ * the cumulative cached prefix re-read on that one call — summing across
8
+ * turns counts the prefix N times. See issue #38.
9
+ */
10
+ export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
11
+
12
+ /** Sum of lifetime usage components, or 0 if undefined. */
13
+ export function getLifetimeTotal(u?: LifetimeUsage): number {
14
+ return u ? u.input + u.output + u.cacheWrite : 0;
15
+ }
16
+
17
+ /** Add a usage delta into a target accumulator (mutates target). */
18
+ export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
19
+ into.input += delta.input;
20
+ into.output += delta.output;
21
+ into.cacheWrite += delta.cacheWrite;
22
+ }
23
+
24
+ /** Minimal shape we read from upstream `getSessionStats()`. */
25
+ export type SessionStatsLike = {
26
+ tokens: { input: number; output: number; cacheWrite: number };
27
+ contextUsage?: { percent: number | null };
28
+ };
29
+ export type SessionLike = { getSessionStats(): SessionStatsLike };
30
+
31
+ /**
32
+ * Session-scoped token count: input + output + cacheWrite as reported by
33
+ * upstream `getSessionStats().tokens` for the *current* session window.
34
+ *
35
+ * RESETS at compaction — upstream replaces `session.state.messages` and the
36
+ * stats are derived from that array. For a lifetime total that survives
37
+ * compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
38
+ * from an independent accumulator fed by `message_end` events.
39
+ *
40
+ * Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
41
+ * and so counts the cumulative cached prefix N times across N turns
42
+ * (issue #38).
43
+ */
44
+ export function getSessionTokens(session: SessionLike | undefined): number {
45
+ if (!session) return 0;
46
+ try {
47
+ const t = session.getSessionStats().tokens;
48
+ return t.input + t.output + t.cacheWrite;
49
+ } catch {
50
+ return 0;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Context-window utilization (0–100), or null when unavailable
56
+ * (no model contextWindow, or post-compaction before the next response).
57
+ */
58
+ export function getSessionContextPercent(session: SessionLike | undefined): number | null {
59
+ if (!session) return null;
60
+ try {
61
+ return session.getSessionStats().contextUsage?.percent ?? null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * workspace-bracket.ts — Owned prepare/dispose lifecycle for a child workspace.
3
+ *
4
+ * Captures the provider resolver (not the provider itself) so provider
5
+ * resolution stays lazy at run-start. The prepared Workspace is held
6
+ * privately; dispose() centralises the guard and addendum-unwrap so callers
7
+ * never reach through to workspace.dispose().resultAddendum directly.
8
+ *
9
+ * dispose() deliberately does NOT catch errors — the best-effort try/catch
10
+ * for failRun() belongs at the call site, preserving the per-caller semantics.
11
+ */
12
+
13
+ import type {
14
+ Workspace,
15
+ WorkspaceDisposeOutcome,
16
+ WorkspacePrepareContext,
17
+ WorkspaceProvider,
18
+ } from "../lifecycle/workspace";
19
+
20
+ /** Owns the child workspace lifecycle: prepare at run-start, dispose at run-end. */
21
+ export class WorkspaceBracket {
22
+ private prepared?: Workspace;
23
+
24
+ constructor(private readonly resolveProvider: () => WorkspaceProvider | undefined) {}
25
+
26
+ /**
27
+ * Returns true when a workspace provider is currently registered.
28
+ * Use to guard the `await prepare(...)` call and avoid an unnecessary
29
+ * microtask boundary in the no-provider path.
30
+ */
31
+ hasProvider(): boolean {
32
+ return this.resolveProvider() !== undefined;
33
+ }
34
+
35
+ /**
36
+ * Resolve the registered provider and prepare the child workspace.
37
+ * Returns the workspace's cwd, or undefined when no provider is registered
38
+ * or the provider resolves to undefined.
39
+ */
40
+ async prepare(ctx: WorkspacePrepareContext): Promise<string | undefined> {
41
+ const provider = this.resolveProvider();
42
+ if (!provider) return undefined;
43
+ this.prepared = await provider.prepare(ctx);
44
+ return this.prepared?.cwd;
45
+ }
46
+
47
+ /**
48
+ * Dispose the prepared workspace (if any) and return the result addendum
49
+ * verbatim. Returns an empty string when no workspace was prepared or when
50
+ * the workspace returns no addendum.
51
+ *
52
+ * Throws propagate — wrap in try/catch at the call site when best-effort
53
+ * disposal is desired (e.g. failRun).
54
+ */
55
+ dispose(outcome: WorkspaceDisposeOutcome): string {
56
+ if (!this.prepared) return "";
57
+ return this.prepared.dispose(outcome)?.resultAddendum ?? "";
58
+ }
59
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
3
+ *
4
+ * "Where does a child run, and what brackets the run?" is a strategy (git
5
+ * worktree, container, tmpdir, remote sandbox), not core behavior. The core
6
+ * needs only a working directory plus a disposal hook; the default — the
7
+ * parent's cwd, with no setup/teardown — is always correct.
8
+ *
9
+ * Unlike the observational lifecycle events in child-lifecycle.ts, this is a
10
+ * *generative* seam: a registered provider returns a value the core consumes
11
+ * synchronously at run-start. The core has no knowledge of git or worktrees.
12
+ */
13
+
14
+ import type { SubagentStatus } from "../lifecycle/subagent";
15
+ import type { AgentInvocation, SubagentType } from "../types";
16
+
17
+ /** Context the core hands a provider when a child run starts. */
18
+ export interface WorkspacePrepareContext {
19
+ agentId: string;
20
+ agentType: SubagentType;
21
+ baseCwd: string;
22
+ invocation?: AgentInvocation;
23
+ }
24
+
25
+ /** Outcome the core reports to a workspace when the run ends. */
26
+ export interface WorkspaceDisposeOutcome {
27
+ status: SubagentStatus;
28
+ description: string;
29
+ }
30
+
31
+ /** What dispose may hand back for the core to fold into the child result. */
32
+ export interface WorkspaceDisposeResult {
33
+ /** Appended verbatim to the child's result text — the provider owns the wording. */
34
+ resultAddendum?: string;
35
+ }
36
+
37
+ /** A prepared working directory plus its bracketed teardown. Born complete. */
38
+ export interface Workspace {
39
+ /** The working directory — already exists when the workspace is handed back. */
40
+ readonly cwd: string;
41
+ dispose(outcome: WorkspaceDisposeOutcome): WorkspaceDisposeResult | undefined;
42
+ }
43
+
44
+ /** The single generative seam: supplies a child's workspace. */
45
+ export interface WorkspaceProvider {
46
+ prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
47
+ }
@@ -0,0 +1,49 @@
1
+ import { debugLog } from "../debug";
2
+ import type { SubagentManagerObserver } from "../lifecycle/subagent-manager";
3
+ import type { CompactionInfo, Subagent } from "../types";
4
+
5
+ /**
6
+ * Fans out SubagentManager lifecycle notifications to multiple observers.
7
+ *
8
+ * Lets the manager keep its single-observer contract while several independent
9
+ * consumers (event/notification dispatch, the reactive widget) subscribe.
10
+ * Each delegate is isolated: a throw in one does not suppress the others.
11
+ */
12
+ export class CompositeSubagentObserver implements SubagentManagerObserver {
13
+ private readonly delegates: SubagentManagerObserver[];
14
+
15
+ constructor(delegates: SubagentManagerObserver[]) {
16
+ this.delegates = [...delegates];
17
+ }
18
+
19
+ /** Register an additional observer (breaks the widget↔manager construction cycle). */
20
+ add(observer: SubagentManagerObserver): void {
21
+ this.delegates.push(observer);
22
+ }
23
+
24
+ onSubagentStarted(record: Subagent): void {
25
+ this.dispatch((o) => o.onSubagentStarted(record), "onSubagentStarted");
26
+ }
27
+
28
+ onSubagentCreated(record: Subagent): void {
29
+ this.dispatch((o) => o.onSubagentCreated(record), "onSubagentCreated");
30
+ }
31
+
32
+ onSubagentCompleted(record: Subagent): void {
33
+ this.dispatch((o) => o.onSubagentCompleted(record), "onSubagentCompleted");
34
+ }
35
+
36
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
37
+ this.dispatch((o) => o.onSubagentCompacted(record, info), "onSubagentCompacted");
38
+ }
39
+
40
+ private dispatch(call: (o: SubagentManagerObserver) => void, label: string): void {
41
+ for (const o of this.delegates) {
42
+ try {
43
+ call(o);
44
+ } catch (err) {
45
+ debugLog(`CompositeSubagentObserver.${label}`, err);
46
+ }
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * notification-state.ts — NotificationState: notification-scoped tracking per background agent.
3
+ *
4
+ * Constructed once when agent-tool assigns the tool call ID (background agents only).
5
+ * Foreground agents never get a NotificationState — record.notification stays undefined.
6
+ */
7
+
8
+ export class NotificationState {
9
+ /** The tool call ID that spawned this background agent. Used in task-notification XML. */
10
+ readonly toolCallId: string;
11
+
12
+ private _resultConsumed = false;
13
+
14
+ constructor(toolCallId: string) {
15
+ this.toolCallId = toolCallId;
16
+ }
17
+
18
+ /** Whether the parent agent has already consumed this result (suppresses duplicate notifications). */
19
+ get resultConsumed(): boolean {
20
+ return this._resultConsumed;
21
+ }
22
+
23
+ /** Mark the result as consumed — suppresses the completion notification. */
24
+ markConsumed(): void {
25
+ this._resultConsumed = true;
26
+ }
27
+ }
@@ -0,0 +1,186 @@
1
+ import { debugLog } from "../debug";
2
+ import { getLifetimeTotal } from "../lifecycle/usage";
3
+ import type { Subagent } from "../types";
4
+
5
+ /** Details attached to custom notification messages for visual rendering. */
6
+ export interface NotificationDetails {
7
+ id: string;
8
+ description: string;
9
+ status: string;
10
+ toolUses: number;
11
+ turnCount: number;
12
+ maxTurns?: number;
13
+ totalTokens: number;
14
+ durationMs: number;
15
+ outputFile?: string;
16
+ error?: string;
17
+ resultPreview: string;
18
+ }
19
+
20
+ // ---- Pure helpers (exported for unit testing) ----
21
+
22
+ /** Escape XML special characters to prevent injection in structured notifications. */
23
+ export function escapeXml(s: string): string {
24
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
25
+ }
26
+
27
+ /** Human-readable status label for agent completion. */
28
+ export function getStatusLabel(status: string, error?: string): string {
29
+ switch (status) {
30
+ case "error":
31
+ return `Error: ${error ?? "unknown"}`;
32
+ case "aborted":
33
+ return "Aborted (max turns exceeded)";
34
+ case "steered":
35
+ return "Wrapped up (turn limit)";
36
+ case "stopped":
37
+ return "Stopped";
38
+ default:
39
+ return "Done";
40
+ }
41
+ }
42
+
43
+ /** Format a structured <task-notification> XML block for the parent agent to parse. */
44
+ export function formatTaskNotification(record: Subagent, resultMaxLen: number): string {
45
+ const status = getStatusLabel(record.status, record.error);
46
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
47
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
48
+ const contextPercent = record.getContextPercent();
49
+ const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
50
+ const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
51
+
52
+ const resultPreview = record.result
53
+ ? record.result.length > resultMaxLen
54
+ ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
55
+ : record.result
56
+ : "No output.";
57
+
58
+ const toolCallId = record.notification?.toolCallId;
59
+ const outputFile = record.outputFile;
60
+ return [
61
+ "<task-notification>",
62
+ `<task-id>${record.id}</task-id>`,
63
+ toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
64
+ outputFile ? `<output-file>${escapeXml(outputFile)}</output-file>` : null,
65
+ `<status>${escapeXml(status)}</status>`,
66
+ `<summary>Subagent "${escapeXml(record.description)}" ${record.status}</summary>`,
67
+ `<result>${escapeXml(resultPreview)}</result>`,
68
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
69
+ "</task-notification>",
70
+ ]
71
+ .filter(Boolean)
72
+ .join("\n");
73
+ }
74
+
75
+ /** Build notification details for the custom message renderer. */
76
+ export function buildNotificationDetails(record: Subagent, resultMaxLen: number): NotificationDetails {
77
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
78
+
79
+ return {
80
+ id: record.id,
81
+ description: record.description,
82
+ status: record.status,
83
+ toolUses: record.toolUses,
84
+ turnCount: record.turnCount,
85
+ maxTurns: record.maxTurns,
86
+ totalTokens,
87
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
88
+ outputFile: record.outputFile,
89
+ error: record.error,
90
+ resultPreview: record.result
91
+ ? record.result.length > resultMaxLen
92
+ ? record.result.slice(0, resultMaxLen) + "…"
93
+ : record.result
94
+ : "No output.",
95
+ };
96
+ }
97
+
98
+ /** Build event data for lifecycle events from a Subagent. */
99
+ export function buildEventData(record: Subagent) {
100
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
101
+ const u = record.lifetimeUsage;
102
+ const total = getLifetimeTotal(u);
103
+ const tokens = total > 0 ? { input: u.input, output: u.output, total } : undefined;
104
+ return {
105
+ id: record.id,
106
+ type: record.type,
107
+ description: record.description,
108
+ result: record.result,
109
+ error: record.error,
110
+ status: record.status,
111
+ toolUses: record.toolUses,
112
+ durationMs,
113
+ tokens,
114
+ };
115
+ }
116
+
117
+ // ---- Notification system factory ----
118
+
119
+ export interface NotificationSystem {
120
+ cancelNudge: (key: string) => void;
121
+ sendCompletion: (record: Subagent) => void;
122
+ dispose: () => void;
123
+ }
124
+
125
+ const NUDGE_HOLD_MS = 200;
126
+
127
+ export class NotificationManager implements NotificationSystem {
128
+ private pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
129
+
130
+ constructor(
131
+ private sendMessage: (
132
+ msg: { customType: string; content: string; display: boolean; details?: unknown },
133
+ opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
134
+ ) => void,
135
+ ) {}
136
+
137
+ cancelNudge(key: string): void {
138
+ const timer = this.pendingNudges.get(key);
139
+ if (timer != null) {
140
+ clearTimeout(timer);
141
+ this.pendingNudges.delete(key);
142
+ }
143
+ }
144
+
145
+ sendCompletion(record: Subagent): void {
146
+ this.scheduleNudge(record.id, () => this.emitIndividualNudge(record));
147
+ }
148
+
149
+ dispose(): void {
150
+ for (const timer of this.pendingNudges.values()) clearTimeout(timer);
151
+ this.pendingNudges.clear();
152
+ }
153
+
154
+ private scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS): void {
155
+ this.cancelNudge(key);
156
+ this.pendingNudges.set(
157
+ key,
158
+ setTimeout(() => {
159
+ this.pendingNudges.delete(key);
160
+ try {
161
+ send();
162
+ } catch (err) {
163
+ debugLog("notification render", err);
164
+ }
165
+ }, delay),
166
+ );
167
+ }
168
+
169
+ private emitIndividualNudge(record: Subagent): void {
170
+ if (record.notification?.resultConsumed) return;
171
+
172
+ const notification = formatTaskNotification(record, 500);
173
+ const outputFile = record.outputFile;
174
+ const footer = outputFile ? `\nFull transcript available at: ${outputFile}` : "";
175
+
176
+ this.sendMessage(
177
+ {
178
+ customType: "subagent-notification",
179
+ content: notification + footer,
180
+ display: true,
181
+ details: buildNotificationDetails(record, 500),
182
+ },
183
+ { deliverAs: "followUp", triggerTurn: true },
184
+ );
185
+ }
186
+ }
@@ -0,0 +1,75 @@
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 "../lifecycle/subagent-state";
11
+ import type { CompactionInfo, SubscribableSession } from "../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 (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
51
+ state.appendResponseText(event.assistantMessageEvent.delta);
52
+ }
53
+
54
+ if (event.type === "turn_end") {
55
+ state.incrementTurnCount();
56
+ }
57
+
58
+ if (event.type === "message_end" && event.message.role === "assistant") {
59
+ const u = event.message.usage;
60
+ state.addUsage({
61
+ input: u.input,
62
+ output: u.output,
63
+ cacheWrite: u.cacheWrite,
64
+ });
65
+ }
66
+
67
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
68
+ state.incrementCompactions();
69
+ options?.onCompact?.({
70
+ reason: event.reason,
71
+ tokensBefore: event.result.tokensBefore,
72
+ });
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,63 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { NotificationDetails } from "../observation/notification";
3
+ import { formatMs, formatTokens, formatTurns } from "../ui/display";
4
+
5
+ /** Narrow theme interface — only the methods the renderer actually calls. */
6
+ interface RendererTheme {
7
+ fg(style: string, text: string): string;
8
+ bold(text: string): string;
9
+ }
10
+
11
+ /** Narrow message interface — only the fields the renderer reads. */
12
+ interface RendererMessage {
13
+ details?: NotificationDetails;
14
+ }
15
+
16
+ /** Narrow render options — only the fields the renderer reads. */
17
+ interface RenderOptions {
18
+ expanded: boolean;
19
+ }
20
+
21
+ /**
22
+ * Create the notification renderer callback for `pi.registerMessageRenderer`.
23
+ * Returns a factory so the renderer is independently testable without the Pi SDK.
24
+ */
25
+ export function createNotificationRenderer() {
26
+ return (message: RendererMessage, { expanded }: RenderOptions, theme: RendererTheme): Text | undefined => {
27
+ const d = message.details;
28
+ if (!d) return undefined;
29
+
30
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
31
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
32
+ const statusText = isError ? d.status : d.status === "steered" ? "completed (steered)" : "completed";
33
+
34
+ // Line 1: icon + agent description + status
35
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
36
+
37
+ // Line 2: stats
38
+ const parts: string[] = [];
39
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
40
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
41
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
42
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
43
+ if (parts.length) {
44
+ line += "\n " + parts.map((p) => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
45
+ }
46
+
47
+ // Line 3: result preview (collapsed) or full (expanded)
48
+ if (expanded) {
49
+ const lines = d.resultPreview.split("\n").slice(0, 30);
50
+ for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`);
51
+ } else {
52
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
53
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
54
+ }
55
+
56
+ // Line 4: output file link (if present)
57
+ if (d.outputFile) {
58
+ line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
59
+ }
60
+
61
+ return new Text(line, 0, 0);
62
+ };
63
+ }
@@ -0,0 +1,94 @@
1
+ import type { SubagentManagerObserver } from "../lifecycle/subagent-manager";
2
+ import { buildEventData, type NotificationSystem } from "../observation/notification";
3
+ import type { CompactionInfo, Subagent } from "../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.status === "error" || record.status === "stopped" || record.status === "aborted";
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
+ // Persist final record for cross-extension history reconstruction.
56
+ this.appendEntry("subagents:record", {
57
+ id: record.id,
58
+ type: record.type,
59
+ description: record.description,
60
+ status: record.status,
61
+ result: record.result,
62
+ error: record.error,
63
+ startedAt: record.startedAt,
64
+ completedAt: record.completedAt,
65
+ });
66
+
67
+ // Skip notification if result was already consumed via get_subagent_result.
68
+ if (record.notification?.resultConsumed) return;
69
+
70
+ this.notifications.sendCompletion(record);
71
+ }
72
+
73
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
74
+ // Emit compacted event when agent's session compacts (preserves count on record).
75
+ this.emit("subagents:compacted", {
76
+ id: record.id,
77
+ type: record.type,
78
+ description: record.description,
79
+ reason: info.reason,
80
+ tokensBefore: info.tokensBefore,
81
+ compactionCount: record.compactionCount,
82
+ });
83
+ }
84
+
85
+ onSubagentCreated(record: Subagent): void {
86
+ // Emit created event for background agents (before limiter admission).
87
+ this.emit("subagents:created", {
88
+ id: record.id,
89
+ type: record.type,
90
+ description: record.description,
91
+ isBackground: true,
92
+ });
93
+ }
94
+ }