@gotgenes/pi-subagents 2.0.0 → 4.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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * service-adapter.ts — Adapter that wraps AgentManager 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 { ModelRegistry } from "./model-resolver.js";
9
+ import type { SubagentRecord, SubagentsService } from "./service.js";
10
+ import type { AgentRecord } from "./types.js";
11
+
12
+ /** Narrow interface for the AgentManager — avoids coupling to the concrete class. */
13
+ export interface AgentManagerLike {
14
+ spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: unknown): string;
15
+ getRecord(id: string): AgentRecord | undefined;
16
+ listAgents(): AgentRecord[];
17
+ abort(id: string): boolean;
18
+ waitForAll(): Promise<void>;
19
+ hasRunning(): boolean;
20
+ }
21
+
22
+ /** Dependencies injected into the adapter factory. */
23
+ export interface AdapterDeps {
24
+ manager: AgentManagerLike;
25
+ resolveModel: (input: string, registry: ModelRegistry) => unknown | string;
26
+ getCtx: () => { pi: unknown; ctx: unknown } | undefined;
27
+ getModelRegistry: () => ModelRegistry | undefined;
28
+ }
29
+
30
+ /** Create a SubagentsService backed by the given dependencies. */
31
+ export function createSubagentsService(deps: AdapterDeps): SubagentsService {
32
+ const { manager } = deps;
33
+
34
+ return {
35
+ spawn(type: string, prompt: string, options?) {
36
+ const session = deps.getCtx();
37
+ if (!session) {
38
+ throw new Error("No active session — cannot spawn agents outside a session.");
39
+ }
40
+
41
+ let model: unknown;
42
+ if (options?.model) {
43
+ const registry = deps.getModelRegistry();
44
+ if (!registry) {
45
+ throw new Error("No model registry available.");
46
+ }
47
+ const resolved = deps.resolveModel(options.model, registry);
48
+ if (typeof resolved === "string") {
49
+ throw new Error(resolved);
50
+ }
51
+ model = resolved;
52
+ }
53
+
54
+ const description = options?.description ?? prompt.slice(0, 80);
55
+ const isBackground = !(options?.foreground ?? false);
56
+
57
+ return manager.spawn(session.pi, session.ctx, type, prompt, {
58
+ description,
59
+ model,
60
+ maxTurns: options?.maxTurns,
61
+ thinkingLevel: options?.thinkingLevel,
62
+ isolated: options?.isolated,
63
+ inheritContext: options?.inheritContext,
64
+ bypassQueue: options?.bypassQueue,
65
+ isolation: options?.isolation,
66
+ isBackground,
67
+ });
68
+ },
69
+
70
+ getRecord(id: string): SubagentRecord | undefined {
71
+ const record = manager.getRecord(id);
72
+ return record ? toSubagentRecord(record) : undefined;
73
+ },
74
+
75
+ listAgents(): SubagentRecord[] {
76
+ return manager.listAgents().map(toSubagentRecord);
77
+ },
78
+
79
+ abort(id: string): boolean {
80
+ return manager.abort(id);
81
+ },
82
+
83
+ async steer(id: string, message: string): Promise<boolean> {
84
+ const record = manager.getRecord(id);
85
+ if (!record || record.status !== "running") {
86
+ return false;
87
+ }
88
+ if (!record.session) {
89
+ // Session not ready yet — queue for delivery once initialized
90
+ if (!record.pendingSteers) record.pendingSteers = [];
91
+ record.pendingSteers.push(message);
92
+ return true;
93
+ }
94
+ await record.session.steer(message);
95
+ return true;
96
+ },
97
+
98
+ async waitForAll(): Promise<void> {
99
+ return manager.waitForAll();
100
+ },
101
+
102
+ hasRunning(): boolean {
103
+ return manager.hasRunning();
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Convert an internal AgentRecord to a serializable SubagentRecord.
110
+ * Uses an explicit allowlist — new fields must be opted in.
111
+ */
112
+ export function toSubagentRecord(record: AgentRecord): SubagentRecord {
113
+ const out: SubagentRecord = {
114
+ id: record.id,
115
+ type: record.type,
116
+ description: record.description,
117
+ status: record.status,
118
+ toolUses: record.toolUses,
119
+ startedAt: record.startedAt,
120
+ lifetimeUsage: record.lifetimeUsage,
121
+ compactionCount: record.compactionCount,
122
+ };
123
+
124
+ if (record.result !== undefined) out.result = record.result;
125
+ if (record.error !== undefined) out.error = record.error;
126
+ if (record.completedAt !== undefined) out.completedAt = record.completedAt;
127
+ if (record.worktreeResult !== undefined) out.worktreeResult = record.worktreeResult;
128
+
129
+ return out;
130
+ }
package/src/service.ts ADDED
@@ -0,0 +1,104 @@
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("@gotgenes/pi-subagents");
8
+ * const svc = getSubagentsService();
9
+ * svc?.spawn("Explore", "Check for stale TODOs");
10
+ */
11
+
12
+ import type { LifetimeUsage } from "./usage.js";
13
+
14
+ export type { LifetimeUsage };
15
+
16
+ export type SubagentStatus =
17
+ | "queued"
18
+ | "running"
19
+ | "completed"
20
+ | "steered"
21
+ | "aborted"
22
+ | "stopped"
23
+ | "error";
24
+
25
+ /** Serializable snapshot of an agent's state — no live session objects. */
26
+ export interface SubagentRecord {
27
+ id: string;
28
+ type: string;
29
+ description: string;
30
+ status: SubagentStatus;
31
+ result?: string;
32
+ error?: string;
33
+ toolUses: number;
34
+ startedAt: number;
35
+ completedAt?: number;
36
+ lifetimeUsage: LifetimeUsage;
37
+ compactionCount: number;
38
+ worktreeResult?: { hasChanges: boolean; branch?: string };
39
+ }
40
+
41
+ /** Options for spawning an agent via the service. */
42
+ export interface SpawnOptions {
43
+ description?: string;
44
+ model?: string;
45
+ maxTurns?: number;
46
+ thinkingLevel?: string;
47
+ isolated?: boolean;
48
+ inheritContext?: boolean;
49
+ foreground?: boolean;
50
+ bypassQueue?: boolean;
51
+ isolation?: "worktree";
52
+ }
53
+
54
+ /** The public service contract for cross-extension subagent access. */
55
+ export interface SubagentsService {
56
+ /** Spawn an agent. Returns the agent ID immediately. */
57
+ spawn(type: string, prompt: string, options?: SpawnOptions): string;
58
+
59
+ /** Get a snapshot of an agent's current state. */
60
+ getRecord(id: string): SubagentRecord | undefined;
61
+
62
+ /** List all tracked agents, most recent first. */
63
+ listAgents(): SubagentRecord[];
64
+
65
+ /** Abort a running or queued agent. Returns false if not found. */
66
+ abort(id: string): boolean;
67
+
68
+ /** Send a steering message to a running agent. */
69
+ steer(id: string, message: string): Promise<boolean>;
70
+
71
+ /** Wait for all running and queued agents to complete. */
72
+ waitForAll(): Promise<void>;
73
+
74
+ /** Whether any agents are running or queued. */
75
+ hasRunning(): boolean;
76
+ }
77
+
78
+ /** Event channel constants for pi.events subscriptions. */
79
+ export const SUBAGENT_EVENTS = {
80
+ STARTED: "subagents:started",
81
+ COMPLETED: "subagents:completed",
82
+ ACTIVITY: "subagents:activity",
83
+ } as const;
84
+
85
+ // ---- Accessor functions ----
86
+
87
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service");
88
+
89
+ /** Publish the SubagentsService on globalThis for cross-extension access. */
90
+ export function publishSubagentsService(service: SubagentsService): void {
91
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
92
+ }
93
+
94
+ /** Retrieve the published SubagentsService, or undefined if not yet published. */
95
+ export function getSubagentsService(): SubagentsService | undefined {
96
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
97
+ | SubagentsService
98
+ | undefined;
99
+ }
100
+
101
+ /** Remove the SubagentsService from globalThis (call on shutdown/reload). */
102
+ export function unpublishSubagentsService(): void {
103
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
104
+ }
package/src/settings.ts CHANGED
@@ -5,8 +5,6 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
- import type { JoinMode } from "./types.js";
9
-
10
8
  export interface SubagentsSettings {
11
9
  maxConcurrent?: number;
12
10
  /**
@@ -16,7 +14,6 @@ export interface SubagentsSettings {
16
14
  */
17
15
  defaultMaxTurns?: number;
18
16
  graceTurns?: number;
19
- defaultJoinMode?: JoinMode;
20
17
  }
21
18
 
22
19
  /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
@@ -24,14 +21,11 @@ export interface SettingsAppliers {
24
21
  setMaxConcurrent: (n: number) => void;
25
22
  setDefaultMaxTurns: (n: number) => void;
26
23
  setGraceTurns: (n: number) => void;
27
- setDefaultJoinMode: (mode: JoinMode) => void;
28
24
  }
29
25
 
30
26
  /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
31
27
  export type SettingsEmit = (event: string, payload: unknown) => void;
32
28
 
33
- const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
34
-
35
29
  // Sanity ceilings — prevent hand-edited configs from asking for values that
36
30
  // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
37
31
  // that any realistic power-user setting passes through.
@@ -65,9 +59,6 @@ function sanitize(raw: unknown): SubagentsSettings {
65
59
  ) {
66
60
  out.graceTurns = r.graceTurns as number;
67
61
  }
68
- if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
69
- out.defaultJoinMode = r.defaultJoinMode as JoinMode;
70
- }
71
62
  return out;
72
63
  }
73
64
 
@@ -121,7 +112,6 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
121
112
  if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
122
113
  if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
123
114
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
124
- if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
125
115
  }
126
116
 
127
117
  /**
package/src/types.ts CHANGED
@@ -55,8 +55,6 @@ export interface AgentConfig {
55
55
  source?: "default" | "project" | "global";
56
56
  }
57
57
 
58
- export type JoinMode = 'async' | 'group' | 'smart';
59
-
60
58
  export interface AgentRecord {
61
59
  id: string;
62
60
  type: SubagentType;
@@ -70,8 +68,6 @@ export interface AgentRecord {
70
68
  session?: AgentSession;
71
69
  abortController?: AbortController;
72
70
  promise?: Promise<string>;
73
- groupId?: string;
74
- joinMode?: JoinMode;
75
71
  /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
76
72
  resultConsumed?: boolean;
77
73
  /** Steering messages queued before the session was ready. */
@@ -122,8 +118,7 @@ export interface NotificationDetails {
122
118
  outputFile?: string;
123
119
  error?: string;
124
120
  resultPreview: string;
125
- /** Additional agents in a group notification. */
126
- others?: NotificationDetails[];
121
+
127
122
  }
128
123
 
129
124
  export interface EnvInfo {
@@ -1,95 +0,0 @@
1
- /**
2
- * Cross-extension RPC handlers for the subagents extension.
3
- *
4
- * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
- * using per-request scoped reply channels.
6
- *
7
- * Reply envelope follows pi-mono convention:
8
- * success → { success: true, data?: T }
9
- * error → { success: false, error: string }
10
- */
11
-
12
- /** Minimal event bus interface needed by the RPC handlers. */
13
- export interface EventBus {
14
- on(event: string, handler: (data: unknown) => void): () => void;
15
- emit(event: string, data: unknown): void;
16
- }
17
-
18
- /** RPC reply envelope — matches pi-mono's RpcResponse shape. */
19
- export type RpcReply<T = void> =
20
- | { success: true; data?: T }
21
- | { success: false; error: string };
22
-
23
- /** RPC protocol version — bumped when the envelope or method contracts change. */
24
- export const PROTOCOL_VERSION = 2;
25
-
26
- /** Minimal AgentManager interface needed by the spawn/stop RPCs. */
27
- export interface SpawnCapable {
28
- spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
29
- abort(id: string): boolean;
30
- }
31
-
32
- export interface RpcDeps {
33
- events: EventBus;
34
- pi: unknown; // passed through to manager.spawn
35
- getCtx: () => unknown | undefined; // returns current ExtensionContext
36
- manager: SpawnCapable;
37
- }
38
-
39
- export interface RpcHandle {
40
- unsubPing: () => void;
41
- unsubSpawn: () => void;
42
- unsubStop: () => void;
43
- }
44
-
45
- /**
46
- * Wire a single RPC handler: listen on `channel`, run `fn(params)`,
47
- * emit the reply envelope on `channel:reply:${requestId}`.
48
- */
49
- function handleRpc<P extends { requestId: string }>(
50
- events: EventBus,
51
- channel: string,
52
- fn: (params: P) => unknown | Promise<unknown>,
53
- ): () => void {
54
- return events.on(channel, async (raw: unknown) => {
55
- const params = raw as P;
56
- try {
57
- const data = await fn(params);
58
- const reply: { success: true; data?: unknown } = { success: true };
59
- if (data !== undefined) reply.data = data;
60
- events.emit(`${channel}:reply:${params.requestId}`, reply);
61
- } catch (err: any) {
62
- events.emit(`${channel}:reply:${params.requestId}`, {
63
- success: false, error: err?.message ?? String(err),
64
- });
65
- }
66
- });
67
- }
68
-
69
- /**
70
- * Register ping, spawn, and stop RPC handlers on the event bus.
71
- * Returns unsub functions for cleanup.
72
- */
73
- export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
74
- const { events, pi, getCtx, manager } = deps;
75
-
76
- const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
77
- return { version: PROTOCOL_VERSION };
78
- });
79
-
80
- const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
81
- events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
82
- const ctx = getCtx();
83
- if (!ctx) throw new Error("No active session");
84
- return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
85
- },
86
- );
87
-
88
- const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
89
- events, "subagents:rpc:stop", ({ agentId }) => {
90
- if (!manager.abort(agentId)) throw new Error("Agent not found");
91
- },
92
- );
93
-
94
- return { unsubPing, unsubSpawn, unsubStop };
95
- }
package/src/group-join.ts DELETED
@@ -1,141 +0,0 @@
1
- /**
2
- * group-join.ts — Manages grouped background agent completion notifications.
3
- *
4
- * Instead of each agent individually nudging the main agent on completion,
5
- * agents in a group are held until all complete (or a timeout fires),
6
- * then a single consolidated notification is sent.
7
- */
8
-
9
- import type { AgentRecord } from "./types.js";
10
-
11
- export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
12
-
13
- interface AgentGroup {
14
- groupId: string;
15
- agentIds: Set<string>;
16
- completedRecords: Map<string, AgentRecord>;
17
- timeoutHandle?: ReturnType<typeof setTimeout>;
18
- delivered: boolean;
19
- /** Shorter timeout for stragglers after a partial delivery. */
20
- isStraggler: boolean;
21
- }
22
-
23
- /** Default timeout: 30s after first completion in a group. */
24
- const DEFAULT_TIMEOUT = 30_000;
25
- /** Straggler re-batch timeout: 15s. */
26
- const STRAGGLER_TIMEOUT = 15_000;
27
-
28
- export class GroupJoinManager {
29
- private groups = new Map<string, AgentGroup>();
30
- private agentToGroup = new Map<string, string>();
31
-
32
- constructor(
33
- private deliverCb: DeliveryCallback,
34
- private groupTimeout = DEFAULT_TIMEOUT,
35
- ) {}
36
-
37
- /** Register a group of agent IDs that should be joined. */
38
- registerGroup(groupId: string, agentIds: string[]): void {
39
- const group: AgentGroup = {
40
- groupId,
41
- agentIds: new Set(agentIds),
42
- completedRecords: new Map(),
43
- delivered: false,
44
- isStraggler: false,
45
- };
46
- this.groups.set(groupId, group);
47
- for (const id of agentIds) {
48
- this.agentToGroup.set(id, groupId);
49
- }
50
- }
51
-
52
- /**
53
- * Called when an agent completes.
54
- * Returns:
55
- * - 'pass' — agent is not grouped, caller should send individual nudge
56
- * - 'held' — result held, waiting for group completion
57
- * - 'delivered' — this completion triggered the group notification
58
- */
59
- onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
60
- const groupId = this.agentToGroup.get(record.id);
61
- if (!groupId) return 'pass';
62
-
63
- const group = this.groups.get(groupId);
64
- if (!group || group.delivered) return 'pass';
65
-
66
- group.completedRecords.set(record.id, record);
67
-
68
- // All done — deliver immediately
69
- if (group.completedRecords.size >= group.agentIds.size) {
70
- this.deliver(group, false);
71
- return 'delivered';
72
- }
73
-
74
- // First completion in this batch — start timeout
75
- if (!group.timeoutHandle) {
76
- const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
77
- group.timeoutHandle = setTimeout(() => {
78
- this.onTimeout(group);
79
- }, timeout);
80
- }
81
-
82
- return 'held';
83
- }
84
-
85
- private onTimeout(group: AgentGroup): void {
86
- if (group.delivered) return;
87
- group.timeoutHandle = undefined;
88
-
89
- // Partial delivery — some agents still running
90
- const remaining = new Set<string>();
91
- for (const id of group.agentIds) {
92
- if (!group.completedRecords.has(id)) remaining.add(id);
93
- }
94
-
95
- // Clean up agentToGroup for delivered agents (they won't complete again)
96
- for (const id of group.completedRecords.keys()) {
97
- this.agentToGroup.delete(id);
98
- }
99
-
100
- // Deliver what we have
101
- this.deliverCb([...group.completedRecords.values()], true);
102
-
103
- // Set up straggler group for remaining agents
104
- group.completedRecords.clear();
105
- group.agentIds = remaining;
106
- group.isStraggler = true;
107
- // Timeout will be started when the next straggler completes
108
- }
109
-
110
- private deliver(group: AgentGroup, partial: boolean): void {
111
- if (group.timeoutHandle) {
112
- clearTimeout(group.timeoutHandle);
113
- group.timeoutHandle = undefined;
114
- }
115
- group.delivered = true;
116
- this.deliverCb([...group.completedRecords.values()], partial);
117
- this.cleanupGroup(group.groupId);
118
- }
119
-
120
- private cleanupGroup(groupId: string): void {
121
- const group = this.groups.get(groupId);
122
- if (!group) return;
123
- for (const id of group.agentIds) {
124
- this.agentToGroup.delete(id);
125
- }
126
- this.groups.delete(groupId);
127
- }
128
-
129
- /** Check if an agent is in a group. */
130
- isGrouped(agentId: string): boolean {
131
- return this.agentToGroup.has(agentId);
132
- }
133
-
134
- dispose(): void {
135
- for (const group of this.groups.values()) {
136
- if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
137
- }
138
- this.groups.clear();
139
- this.agentToGroup.clear();
140
- }
141
- }