@yefengr/pi-subagents 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +2411 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/UPSTREAM.md +48 -0
  5. package/dist/public.d.ts +201 -0
  6. package/dist/settings.d.ts +82 -0
  7. package/docs/architecture/architecture.md +833 -0
  8. package/docs/architecture/client-server-opportunities.md +127 -0
  9. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  10. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  11. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  12. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  13. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  14. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  15. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  16. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  17. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  18. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  19. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  20. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  21. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  22. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  23. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  24. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  25. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  26. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  27. package/docs/architecture/history/phase-8-testability.md +103 -0
  28. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  29. package/docs/comparison-with-upstream.md +79 -0
  30. package/docs/configuration.md +215 -0
  31. package/docs/decisions/0001-deferred-patches.md +80 -0
  32. package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
  33. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  34. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  35. package/index.ts +1 -0
  36. package/package.json +91 -4
  37. package/src/config/agent-enablement.ts +132 -0
  38. package/src/config/agent-types.ts +144 -0
  39. package/src/config/custom-agents.ts +114 -0
  40. package/src/config/default-agents.ts +120 -0
  41. package/src/config/invocation-config.ts +39 -0
  42. package/src/config/resource-limits.ts +26 -0
  43. package/src/config/settings-file.ts +174 -0
  44. package/src/debug.ts +14 -0
  45. package/src/handlers/index.ts +3 -0
  46. package/src/handlers/interrupt.ts +58 -0
  47. package/src/handlers/lifecycle.ts +65 -0
  48. package/src/handlers/tool-start.ts +32 -0
  49. package/src/index.ts +257 -0
  50. package/src/layered-settings.ts +105 -0
  51. package/src/lifecycle/child-lifecycle.ts +89 -0
  52. package/src/lifecycle/child-shutdown.ts +105 -0
  53. package/src/lifecycle/concurrency-limiter.ts +123 -0
  54. package/src/lifecycle/create-subagent-session.ts +265 -0
  55. package/src/lifecycle/invocation-control.ts +109 -0
  56. package/src/lifecycle/lifecycle-record.ts +159 -0
  57. package/src/lifecycle/parent-snapshot.ts +46 -0
  58. package/src/lifecycle/run-listeners.ts +41 -0
  59. package/src/lifecycle/subagent-manager.ts +392 -0
  60. package/src/lifecycle/subagent-session.ts +287 -0
  61. package/src/lifecycle/subagent-state.ts +307 -0
  62. package/src/lifecycle/subagent.ts +854 -0
  63. package/src/lifecycle/usage.ts +60 -0
  64. package/src/lifecycle/workspace-bracket.ts +59 -0
  65. package/src/lifecycle/workspace.ts +47 -0
  66. package/src/observation/composite-subagent-observer.ts +53 -0
  67. package/src/observation/notification.ts +233 -0
  68. package/src/observation/record-observer.ts +78 -0
  69. package/src/observation/renderer.ts +106 -0
  70. package/src/observation/subagent-events-observer.ts +75 -0
  71. package/src/runtime.ts +78 -0
  72. package/src/service/service-adapter.ts +159 -0
  73. package/src/service/service.ts +147 -0
  74. package/src/session/content-items.ts +53 -0
  75. package/src/session/context.ts +80 -0
  76. package/src/session/conversation.ts +49 -0
  77. package/src/session/env.ts +40 -0
  78. package/src/session/model-resolver.ts +135 -0
  79. package/src/session/package-exclusions.ts +75 -0
  80. package/src/session/prompts.ts +145 -0
  81. package/src/session/provider-inheritance.ts +38 -0
  82. package/src/session/session-config.ts +177 -0
  83. package/src/session/session-dir.ts +38 -0
  84. package/src/settings.ts +436 -0
  85. package/src/tools/agent-tool.ts +289 -0
  86. package/src/tools/background-spawner.ts +73 -0
  87. package/src/tools/foreground-runner.ts +135 -0
  88. package/src/tools/get-result-report.ts +73 -0
  89. package/src/tools/get-result-tool.ts +109 -0
  90. package/src/tools/helpers.ts +134 -0
  91. package/src/tools/result-renderer.ts +121 -0
  92. package/src/tools/spawn-config.ts +182 -0
  93. package/src/tools/steer-tool.ts +120 -0
  94. package/src/types.ts +145 -0
  95. package/src/ui/agent-widget.ts +313 -0
  96. package/src/ui/display.ts +214 -0
  97. package/src/ui/glyphs.ts +62 -0
  98. package/src/ui/session-navigation.ts +152 -0
  99. package/src/ui/session-navigator.ts +257 -0
  100. package/src/ui/subagents-agents.ts +116 -0
  101. package/src/ui/subagents-settings.ts +207 -0
  102. package/src/ui/transcript-content.ts +374 -0
  103. package/src/ui/widget-renderer.ts +289 -0
@@ -0,0 +1,75 @@
1
+ import {
2
+ buildLifecycleEventData,
3
+ buildLifecycleRecord,
4
+ } from "#src/lifecycle/lifecycle-record";
5
+ import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
6
+ import type { NotificationSystem } from "#src/observation/notification";
7
+ import type { CompactionInfo, Subagent } from "#src/types";
8
+
9
+ /** Emit callback — a subset of `pi.events.emit`. */
10
+ export type EventEmit = (channel: string, data: unknown) => void;
11
+
12
+ /** Append callback — a subset of `pi.appendEntry`. */
13
+ export type AppendEntry = (customType: string, data: unknown) => void;
14
+
15
+ export interface SubagentEventsObserverDeps {
16
+ emit: EventEmit;
17
+ appendEntry: AppendEntry;
18
+ notifications: NotificationSystem;
19
+ }
20
+
21
+ /**
22
+ * Receives agent lifecycle notifications from SubagentManager and dispatches
23
+ * them to three concerns: pi.events lifecycle events, session-entry persistence,
24
+ * and completion notifications.
25
+ *
26
+ * Constructed with narrow deps (emit, appendEntry, NotificationSystem) so all
27
+ * three concerns are unit-testable without booting the extension.
28
+ */
29
+ export class SubagentEventsObserver implements SubagentManagerObserver {
30
+ private readonly emit: EventEmit;
31
+ private readonly appendEntry: AppendEntry;
32
+ private readonly notifications: NotificationSystem;
33
+
34
+ constructor(deps: SubagentEventsObserverDeps) {
35
+ this.emit = deps.emit;
36
+ this.appendEntry = deps.appendEntry;
37
+ this.notifications = deps.notifications;
38
+ }
39
+
40
+ onSubagentStarted(record: Subagent): void {
41
+ this.emit("subagents:started", buildLifecycleEventData(record));
42
+ }
43
+
44
+ onSubagentCompleted(record: Subagent): void {
45
+ this.persistTerminal(
46
+ record,
47
+ record.isTerminalError() ? "subagents:failed" : "subagents:completed",
48
+ );
49
+ }
50
+
51
+ onSubagentResumed(record: Subagent): void {
52
+ this.persistTerminal(record, "subagents:resumed");
53
+ }
54
+
55
+ /** Public terminal event and persisted custom entry share one exact v2 object. */
56
+ private persistTerminal(record: Subagent, eventName: string): void {
57
+ const data = buildLifecycleRecord(record);
58
+ this.emit(eventName, data);
59
+ this.appendEntry("subagents:record", data);
60
+ if (record.isBackground) this.notifications.sendCompletion(record);
61
+ }
62
+
63
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
64
+ this.emit("subagents:compacted", {
65
+ ...buildLifecycleEventData(record),
66
+ reason: info.reason,
67
+ tokensBefore: info.tokensBefore,
68
+ compactionCount: record.compactionCount,
69
+ });
70
+ }
71
+
72
+ onSubagentCreated(record: Subagent): void {
73
+ this.emit("subagents:created", buildLifecycleEventData(record));
74
+ }
75
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,78 @@
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 { buildParentSnapshot, type ParentSnapshot } from "#src/lifecycle/parent-snapshot";
10
+ import type { ModelInfo } from "#src/tools/spawn-config";
11
+ import type { SessionContext } from "#src/types";
12
+
13
+ /** Runtime safety ceilings shared by tool and service-driven invocations. */
14
+ export interface RunConfig {
15
+ readonly maxTurns: number;
16
+ readonly wrapUpTurns: number;
17
+ readonly maxDurationSeconds: number;
18
+ readonly maxQueueWaitSeconds: number;
19
+ readonly abortGraceSeconds: number;
20
+ }
21
+
22
+ /**
23
+ * All mutable state owned by the pi-subagents extension.
24
+ *
25
+ * Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`.
26
+ * Tests construct a fresh runtime per test for full isolation.
27
+ */
28
+ export class SubagentRuntime {
29
+ // ── Session state (was closure-scoped in index.ts) ───────────────────────
30
+ /** Active Pi session context — set on session_start, cleared on session_shutdown. */
31
+ currentCtx: SessionContext | undefined = undefined;
32
+
33
+ // ── Session-context methods ──────────────────────────────────────────────
34
+
35
+ /** Store the active Pi session context (called from session_start). */
36
+ setSessionContext(ctx: SessionContext): void {
37
+ this.currentCtx = ctx;
38
+ }
39
+
40
+ /** Clear the session context (called from session_shutdown). */
41
+ clearSessionContext(): void {
42
+ this.currentCtx = undefined;
43
+ }
44
+
45
+ /**
46
+ * Build a parent snapshot from the current session context.
47
+ * Only valid during an active session (currentCtx is defined).
48
+ */
49
+ buildSnapshot(inheritContext: boolean): ParentSnapshot {
50
+
51
+ return buildParentSnapshot(this.currentCtx!, inheritContext);
52
+ }
53
+
54
+ /** Extract model info from the current session context. */
55
+ getModelInfo(): ModelInfo {
56
+ return {
57
+ parentModel: this.currentCtx?.model,
58
+ modelRegistry: this.currentCtx?.modelRegistry,
59
+ };
60
+ }
61
+
62
+ /** Extract session identity from the current session context. */
63
+ getSessionInfo(): { parentSessionFile: string; parentSessionId: string } {
64
+ return {
65
+ parentSessionFile: this.currentCtx?.sessionManager.getSessionFile() ?? "",
66
+ parentSessionId: this.currentCtx?.sessionManager.getSessionId() ?? "",
67
+ };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Create a fully-initialized SubagentRuntime with default values.
73
+ *
74
+ * Call once at extension startup; pass the result to factories and handlers.
75
+ */
76
+ export function createSubagentRuntime(): SubagentRuntime {
77
+ return new SubagentRuntime();
78
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * service-adapter.ts — Adapter that wraps SubagentManager to satisfy SubagentsService.
3
+ *
4
+ * Handles model resolution at the API boundary, record serialization
5
+ * (stripping non-serializable fields), and session gating.
6
+ */
7
+
8
+ import type { Model } from "@earendil-works/pi-ai";
9
+ import { AgentTypeRegistry } from "#src/config/agent-types";
10
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
11
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
12
+ import type { SpawnOptions, SubagentRecord, SubagentsService } from "#src/service/service";
13
+ import type { ModelRegistry } from "#src/session/model-resolver";
14
+ import type { SessionContext, Subagent } from "#src/types";
15
+
16
+ /** Narrow interface for the SubagentManager — avoids coupling to the concrete class. */
17
+ export interface SubagentManagerLike {
18
+ spawn(snapshot: ParentSnapshot, type: string, prompt: string, options: unknown): string;
19
+ getRecord(id: string): Subagent | undefined;
20
+ listAgents(): Subagent[];
21
+ abort(id: string): boolean;
22
+ waitForAll(): Promise<void>;
23
+ hasRunning(): boolean;
24
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
25
+ }
26
+
27
+ /**
28
+ * Narrow runtime interface consumed by the service adapter.
29
+ * `SubagentRuntime` satisfies this structurally; tests use plain stubs.
30
+ */
31
+ export interface ServiceRuntimeLike {
32
+ readonly currentCtx: SessionContext | undefined;
33
+ buildSnapshot(inheritContext: boolean): ParentSnapshot;
34
+ }
35
+
36
+ /** Test-only fallback; the extension composition root always injects the live registry. */
37
+ const DEFAULT_REGISTRY = new AgentTypeRegistry(() => new Map());
38
+
39
+ /** Adapter that wraps SubagentManager to satisfy SubagentsService. */
40
+ export class SubagentsServiceAdapter implements SubagentsService {
41
+ constructor(
42
+ private readonly manager: SubagentManagerLike,
43
+ private readonly resolveModel: (input: string, registry: ModelRegistry) => Model<any> | string,
44
+ private readonly runtime: ServiceRuntimeLike,
45
+ private readonly registry: Pick<AgentTypeRegistry, "reload" | "resolveSpawnType"> = DEFAULT_REGISTRY,
46
+ ) {}
47
+
48
+ spawn(type: string, prompt: string, options?: SpawnOptions): string {
49
+ if (!this.runtime.currentCtx) {
50
+ throw new Error("No active session — cannot spawn agents outside a session.");
51
+ }
52
+
53
+ this.registry.reload();
54
+ const agentType = this.registry.resolveSpawnType(type);
55
+ const model = this.resolveModelOption(options?.model);
56
+ const description = options?.description ?? prompt.slice(0, 80);
57
+ const isBackground = !(options?.foreground ?? false);
58
+
59
+ const snapshot = this.runtime.buildSnapshot(options?.inheritContext ?? false);
60
+ return this.manager.spawn(snapshot, agentType, prompt, {
61
+ description,
62
+ model,
63
+ maxTurns: options?.maxTurns,
64
+ wrapUpTurns: options?.wrapUpTurns,
65
+ maxDurationSeconds: options?.maxDurationSeconds,
66
+ thinkingLevel: options?.thinkingLevel,
67
+ inheritContext: options?.inheritContext,
68
+ bypassQueue: options?.bypassQueue,
69
+ isBackground,
70
+ lifecycleInvocation: { kind: "fresh", source: "service" },
71
+ });
72
+ }
73
+
74
+ getRecord(id: string): SubagentRecord | undefined {
75
+ const record = this.manager.getRecord(id);
76
+ return record ? toSubagentRecord(record) : undefined;
77
+ }
78
+
79
+ listAgents(): SubagentRecord[] {
80
+ return this.manager.listAgents().map(toSubagentRecord);
81
+ }
82
+
83
+ abort(id: string): boolean {
84
+ return this.manager.abort(id);
85
+ }
86
+
87
+ async steer(id: string, message: string): Promise<boolean> {
88
+ const record = this.manager.getRecord(id);
89
+ if (!record) {
90
+ return false;
91
+ }
92
+ const outcome = await record.steer(message);
93
+ return outcome.kind !== "rejected";
94
+ }
95
+
96
+ async waitForAll(): Promise<void> {
97
+ return this.manager.waitForAll();
98
+ }
99
+
100
+ hasRunning(): boolean {
101
+ return this.manager.hasRunning();
102
+ }
103
+
104
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
105
+ return this.manager.registerWorkspaceProvider(provider);
106
+ }
107
+
108
+ /** Resolve an optional model-string override against the current session's registry. */
109
+ private resolveModelOption(modelInput: string | undefined): Model<any> | undefined {
110
+ if (!modelInput) return undefined;
111
+ const registry = this.runtime.currentCtx?.modelRegistry;
112
+ if (!registry) {
113
+ throw new Error("No model registry available.");
114
+ }
115
+ const resolved = this.resolveModel(modelInput, registry);
116
+ if (typeof resolved === "string") {
117
+ throw new Error(resolved);
118
+ }
119
+ return resolved;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Convert an internal Subagent to a serializable SubagentRecord.
125
+ * Uses an explicit allowlist — new fields must be opted in.
126
+ */
127
+ export function toSubagentRecord(record: Subagent): SubagentRecord {
128
+ const out: SubagentRecord = {
129
+ id: record.id,
130
+ type: record.type,
131
+ description: record.description,
132
+ phase: record.phase,
133
+ isBackground: record.isBackground,
134
+ lifecycleInvocation: { ...record.lifecycleInvocation },
135
+ invocationIndex: record.invocationIndex,
136
+ sessionState: record.sessionState,
137
+ toolUses: record.toolUses,
138
+ startedAt: record.startedAt,
139
+ lifetimeUsage: { ...record.lifetimeUsage },
140
+ invocationStartedAt: record.invocationStartedAt,
141
+ invocationToolUses: record.invocationToolUses,
142
+ invocationUsage: { ...record.invocationUsage },
143
+ compactionCount: record.compactionCount,
144
+ invocationTurnCount: record.invocationTurnCount,
145
+ lifetimeTurnCount: record.lifetimeTurnCount,
146
+ maxTurns: record.maxTurns,
147
+ wrapUpTurns: record.wrapUpTurns,
148
+ maxDurationSeconds: record.maxDurationSeconds,
149
+ };
150
+
151
+ if (record.outcome !== undefined) out.outcome = record.outcome;
152
+ if (record.result !== undefined) out.result = record.result;
153
+ if (record.error !== undefined) out.error = record.error;
154
+ if (record.sessionId !== undefined) out.sessionId = record.sessionId;
155
+ if (record.outputFile !== undefined) out.outputFile = record.outputFile;
156
+ if (record.completedAt !== undefined) out.completedAt = record.completedAt;
157
+
158
+ return out;
159
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * service.ts — Public API surface for cross-extension access to subagents.
3
+ *
4
+ * Consumers declare this package as an optional peer dependency and use
5
+ * dynamic import to access the accessor functions:
6
+ *
7
+ * const { getSubagentsService } = await import("@yefengr/pi-subagents");
8
+ * const svc = getSubagentsService();
9
+ * svc?.spawn("Explore", "Check for stale TODOs");
10
+ */
11
+
12
+ import type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent";
13
+ import type { LifetimeUsage } from "#src/lifecycle/usage";
14
+ import type {
15
+ Workspace,
16
+ WorkspaceDisposeOutcome,
17
+ WorkspaceDisposeResult,
18
+ WorkspacePrepareContext,
19
+ WorkspaceProvider,
20
+ } from "#src/lifecycle/workspace";
21
+ import type { SubagentInvocationIdentity } from "#src/types";
22
+
23
+
24
+ // SubagentPhase/SubagentOutcome are defined in the lifecycle layer (single home)
25
+ // and re-exported here for the public API surface — mirrors the LifetimeUsage /
26
+ // workspace pattern.
27
+ export type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent";
28
+ // Generative extension seam (ADR 0002, Phase 16 Step 2). The provider type
29
+ // and all four collaborator types it references are re-exported by name so
30
+ // consumers can import them directly rather than recovering them via
31
+ // indexed-access inference (e.g. `Parameters<WorkspaceProvider["prepare"]>[0]`).
32
+ export type {
33
+ LifetimeUsage,
34
+ SubagentInvocationIdentity,
35
+ Workspace,
36
+ WorkspaceDisposeOutcome,
37
+ WorkspaceDisposeResult,
38
+ WorkspacePrepareContext,
39
+ WorkspaceProvider,
40
+ };
41
+
42
+ /** Serializable snapshot of an agent's state — no live session objects. */
43
+ export interface SubagentRecord {
44
+ id: string;
45
+ type: string;
46
+ description: string;
47
+ phase: SubagentPhase;
48
+ outcome?: SubagentOutcome;
49
+ result?: string;
50
+ error?: string;
51
+ isBackground: boolean;
52
+ lifecycleInvocation: SubagentInvocationIdentity;
53
+ invocationIndex: number;
54
+ sessionState: "created" | "not-created";
55
+ sessionId?: string;
56
+ outputFile?: string;
57
+ toolUses: number;
58
+ startedAt: number;
59
+ completedAt?: number;
60
+ lifetimeUsage: LifetimeUsage;
61
+ invocationStartedAt: number;
62
+ invocationToolUses: number;
63
+ invocationUsage: LifetimeUsage;
64
+ compactionCount: number;
65
+ invocationTurnCount: number;
66
+ lifetimeTurnCount: number;
67
+ maxTurns: number;
68
+ wrapUpTurns: number;
69
+ maxDurationSeconds: number;
70
+ }
71
+
72
+ /** Options for spawning an agent via the service. */
73
+ export interface SpawnOptions {
74
+ description?: string;
75
+ model?: string;
76
+ maxTurns?: number;
77
+ wrapUpTurns?: number;
78
+ maxDurationSeconds?: number;
79
+ thinkingLevel?: string;
80
+ inheritContext?: boolean;
81
+ foreground?: boolean;
82
+ bypassQueue?: boolean;
83
+ }
84
+
85
+ /** The public service contract for cross-extension subagent access. */
86
+ export interface SubagentsService {
87
+ /** Spawn an agent. Returns the agent ID immediately. */
88
+ spawn(type: string, prompt: string, options?: SpawnOptions): string;
89
+
90
+ /** Get a snapshot of an agent's current state. */
91
+ getRecord(id: string): SubagentRecord | undefined;
92
+
93
+ /** List all tracked agents, most recent first. */
94
+ listAgents(): SubagentRecord[];
95
+
96
+ /** Abort a running or queued agent. Returns false if not found. */
97
+ abort(id: string): boolean;
98
+
99
+ /** Send a steering message to a running agent. */
100
+ steer(id: string, message: string): Promise<boolean>;
101
+
102
+ /** Wait for all running and queued agents to complete. */
103
+ waitForAll(): Promise<void>;
104
+
105
+ /** Whether any agents are running or queued. */
106
+ hasRunning(): boolean;
107
+
108
+ /**
109
+ * Register the single workspace provider that supplies a child's working
110
+ * directory plus bracketed setup/teardown. Throws if one is already
111
+ * registered. Returns a disposer that unregisters the provider.
112
+ */
113
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
114
+ }
115
+
116
+ /** Event channel constants for pi.events subscriptions. */
117
+ export const SUBAGENT_EVENTS = {
118
+ STARTED: "subagents:started",
119
+ COMPLETED: "subagents:completed",
120
+ FAILED: "subagents:failed",
121
+ RESUMED: "subagents:resumed",
122
+ COMPACTED: "subagents:compacted",
123
+ CREATED: "subagents:created",
124
+ STEERED: "subagents:steered",
125
+ } as const;
126
+
127
+ // ---- Accessor functions ----
128
+
129
+ const SERVICE_KEY = Symbol.for("@yefengr/pi-subagents:service");
130
+
131
+ /** Publish the SubagentsService on globalThis for cross-extension access. */
132
+ export function publishSubagentsService(service: SubagentsService): void {
133
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
134
+ }
135
+
136
+ /** Retrieve the published SubagentsService, or undefined if not yet published. */
137
+ export function getSubagentsService(): SubagentsService | undefined {
138
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
139
+ | SubagentsService
140
+ | undefined;
141
+ }
142
+
143
+ /** Remove the SubagentsService from globalThis (call on shutdown/reload). */
144
+ export function unpublishSubagentsService(): void {
145
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
146
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
147
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * content-items.ts — Shared parsing utilities for Pi SDK message content items.
3
+ *
4
+ * Provides type-safe extraction of text parts and tool-call names from
5
+ * assistant message content arrays. Pure functions — no IO.
6
+ */
7
+
8
+ import type { TextContent, ToolCall } from "@earendil-works/pi-ai";
9
+
10
+ // ── Types ─────────────────────────────────────────────────────────────────────
11
+
12
+ /** Extracted text parts and tool names from assistant message content. */
13
+ export interface AssistantContentParts {
14
+ textParts: string[];
15
+ toolNames: string[];
16
+ }
17
+
18
+ // ── Functions ─────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Extracts the display name from a tool-call content item.
22
+ *
23
+ * Returns 'unknown' for non-toolCall items.
24
+ * The Pi SDK's ToolCall.name is always present — no fallback chain needed.
25
+ */
26
+ export function getToolCallName(c: { type: string }): string {
27
+ if (c.type !== "toolCall") return "unknown";
28
+ return (c as ToolCall).name;
29
+ }
30
+
31
+ /**
32
+ * Extract text parts and tool-call names from assistant message content items.
33
+ *
34
+ * Accepts any array whose elements carry a `type` discriminant — all Pi SDK
35
+ * content types (TextContent, ThinkingContent, ToolCall) satisfy this constraint.
36
+ * Pure data extraction — consumers apply their own presentation formatting.
37
+ * Skips items of unknown types (e.g. thinking blocks, images) and empty text.
38
+ */
39
+ export function extractAssistantContent(
40
+ content: ReadonlyArray<{ type: string }>,
41
+ ): AssistantContentParts {
42
+ const textParts: string[] = [];
43
+ const toolNames: string[] = [];
44
+ for (const c of content) {
45
+ if (c.type === "text") {
46
+ const text = (c as TextContent).text;
47
+ if (text) textParts.push(text);
48
+ } else if (c.type === "toolCall") {
49
+ toolNames.push(getToolCallName(c));
50
+ }
51
+ }
52
+ return { textParts, toolNames };
53
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * context.ts — Extract parent conversation context for subagent inheritance.
3
+ */
4
+
5
+ import type { TextContent } from "@earendil-works/pi-ai";
6
+ import type { SessionContext } from "#src/types";
7
+
8
+ /**
9
+ * Minimal structural types for session branch entries consumed by buildParentContext.
10
+ * `getBranch()` returns `unknown[]` in SessionContext (ISP), so we cast to these
11
+ * local shapes instead of coupling to the SDK's SessionEntry type.
12
+ */
13
+ type MessageEntry = {
14
+ type: "message";
15
+ message: { role: string; content: string | { type: string }[] };
16
+ };
17
+ type CompactionEntry = { type: "compaction"; summary?: string };
18
+ type BranchEntry = MessageEntry | CompactionEntry | { type: string };
19
+
20
+ /** Type predicate: narrow an unknown content block to TextContent. */
21
+ function isTextContent(c: unknown): c is TextContent {
22
+ return typeof c === "object" && c !== null && (c as { type: string }).type === "text";
23
+ }
24
+
25
+ /** Extract text from a message content block array. */
26
+ export function extractText(content: unknown[]): string {
27
+ return content
28
+ .filter(isTextContent)
29
+ .map((c) => c.text)
30
+ .join("\n");
31
+ }
32
+
33
+ /** Format a message entry (user/assistant); returns undefined for roles to skip. */
34
+ function formatMessageEntry(entry: MessageEntry): string | undefined {
35
+ const msg = entry.message;
36
+ const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
37
+ if (!text.trim()) return undefined;
38
+ if (msg.role === "user") return `[User]: ${text.trim()}`;
39
+ if (msg.role === "assistant") return `[Assistant]: ${text.trim()}`;
40
+ return undefined; // skip toolResult and other roles
41
+ }
42
+
43
+ /** Format a compaction entry; returns undefined when no summary is present. */
44
+ function formatCompactionEntry(entry: CompactionEntry): string | undefined {
45
+ return entry.summary ? `[Summary]: ${entry.summary}` : undefined;
46
+ }
47
+
48
+ /** Dispatch a branch entry to the appropriate formatter. */
49
+ function formatBranchEntry(entry: BranchEntry): string | undefined {
50
+ if (entry.type === "message") return formatMessageEntry(entry as MessageEntry);
51
+ if (entry.type === "compaction") return formatCompactionEntry(entry as CompactionEntry);
52
+ return undefined;
53
+ }
54
+
55
+ /**
56
+ * Build a text representation of the parent conversation context.
57
+ * Used when inherit_context is true to give the subagent visibility
58
+ * into what has been discussed/done so far.
59
+ */
60
+ export function buildParentContext(ctx: SessionContext): string {
61
+ const entries = ctx.sessionManager.getBranch();
62
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- getBranch() may return undefined at runtime despite its type
63
+ if (!entries || entries.length === 0) return "";
64
+
65
+ const parts = (entries as BranchEntry[])
66
+ .map(formatBranchEntry)
67
+ .filter((p): p is string => p !== undefined);
68
+
69
+ if (parts.length === 0) return "";
70
+
71
+ return `# Parent Conversation Context
72
+ The following is the conversation history from the parent session that spawned you.
73
+ Use this context to understand what has been discussed and decided so far.
74
+
75
+ ${parts.join("\n\n")}
76
+
77
+ ---
78
+ # Your Task (below)
79
+ `;
80
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * conversation.ts — Render a subagent session's messages as formatted text.
3
+ *
4
+ * Extracted from agent-runner.ts (issue #265) into the session domain, where the
5
+ * other message-extraction helpers (content-items, context) live. Consumed by
6
+ * the get_subagent_result tool's verbose output.
7
+ */
8
+
9
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
10
+ import { extractAssistantContent } from "#src/session/content-items";
11
+ import { extractText } from "#src/session/context";
12
+
13
+ /**
14
+ * Get the subagent's conversation messages as formatted text.
15
+ */
16
+ export function getAgentConversation(session: AgentSession): string {
17
+ const parts: string[] = [];
18
+
19
+ for (const msg of session.messages) {
20
+ if (msg.role === "user") {
21
+ const text =
22
+ typeof msg.content === "string"
23
+ ? msg.content
24
+ : extractText(msg.content);
25
+ if (text.trim()) parts.push(`[User]: ${text.trim()}`);
26
+ } else if (msg.role === "assistant") {
27
+ const { textParts, toolNames } = extractAssistantContent(msg.content);
28
+ const attribution = formatAttribution(msg);
29
+ if (textParts.length > 0)
30
+ parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
31
+ if (toolNames.length > 0)
32
+ parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
33
+ } else if (msg.role === "toolResult") {
34
+ const text = extractText(msg.content);
35
+ const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
36
+ parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
37
+ }
38
+ }
39
+
40
+ return parts.join("\n\n");
41
+ }
42
+
43
+ /** Build a `(provider/model)` attribution suffix for assistant messages. */
44
+ function formatAttribution(msg: { provider?: string; model?: string }): string {
45
+ const { provider, model } = msg;
46
+ if (!provider && !model) return "";
47
+ if (provider && model) return ` (${provider}/${model})`;
48
+ return ` (${provider ?? model})`;
49
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * env.ts — Detect environment info (git, platform) for subagent system prompts.
3
+ */
4
+
5
+ import { debugLog } from "#src/debug";
6
+ import type { ShellExec } from "#src/types";
7
+
8
+ export interface EnvInfo {
9
+ isGitRepo: boolean;
10
+ branch: string;
11
+ platform: string;
12
+ }
13
+
14
+ export async function detectEnv(exec: ShellExec, cwd: string): Promise<EnvInfo> {
15
+ let isGitRepo = false;
16
+ let branch = "";
17
+
18
+ try {
19
+ const result = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
20
+ isGitRepo = result.code === 0 && result.stdout.trim() === "true";
21
+ } catch (err) {
22
+ debugLog("git rev-parse", err);
23
+ }
24
+
25
+ if (isGitRepo) {
26
+ try {
27
+ const result = await exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
28
+ branch = result.code === 0 ? result.stdout.trim() : "unknown";
29
+ } catch (err) {
30
+ debugLog("git branch", err);
31
+ branch = "unknown";
32
+ }
33
+ }
34
+
35
+ return {
36
+ isGitRepo,
37
+ branch,
38
+ platform: process.platform,
39
+ };
40
+ }