@gotgenes/pi-subagents 1.0.2 → 3.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.
@@ -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
- }
@@ -1,143 +0,0 @@
1
- /**
2
- * schedule-store.ts — File-backed store for scheduled subagents.
3
- *
4
- * Session-scoped: each pi session owns its own schedules at
5
- * `<cwd>/.pi/subagent-schedules/<sessionId>.json`. `/new` starts a fresh
6
- * empty store; `/resume` reloads.
7
- *
8
- * Concurrency model lifted from pi-chonky-tasks/src/task-store.ts: every
9
- * mutation acquires a PID-based exclusion lock, re-reads the latest state
10
- * from disk, applies the change, atomic-writes via temp+rename, releases.
11
- */
12
-
13
- import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
14
- import { dirname, join } from "node:path";
15
- import type { ScheduledSubagent, ScheduleStoreData } from "./types.js";
16
-
17
- const LOCK_RETRY_MS = 50;
18
- const LOCK_MAX_RETRIES = 100;
19
-
20
- function isProcessRunning(pid: number): boolean {
21
- try { process.kill(pid, 0); return true; } catch { return false; }
22
- }
23
-
24
- function acquireLock(lockPath: string): void {
25
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
26
- try {
27
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
28
- return;
29
- } catch (e: any) {
30
- if (e.code === "EEXIST") {
31
- try {
32
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
33
- if (pid && !isProcessRunning(pid)) {
34
- unlinkSync(lockPath);
35
- continue;
36
- }
37
- } catch { /* ignore — try again */ }
38
- const start = Date.now();
39
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
40
- continue;
41
- }
42
- throw e;
43
- }
44
- }
45
- throw new Error(`Failed to acquire schedule lock: ${lockPath}`);
46
- }
47
-
48
- function releaseLock(lockPath: string): void {
49
- try { unlinkSync(lockPath); } catch { /* ignore */ }
50
- }
51
-
52
- /** Resolve the storage path for a session-scoped store. */
53
- export function resolveStorePath(cwd: string, sessionId: string): string {
54
- return join(cwd, ".pi", "subagent-schedules", `${sessionId}.json`);
55
- }
56
-
57
- export class ScheduleStore {
58
- private filePath: string;
59
- private lockPath: string;
60
- private jobs = new Map<string, ScheduledSubagent>();
61
-
62
- constructor(filePath: string) {
63
- this.filePath = filePath;
64
- this.lockPath = filePath + ".lock";
65
- mkdirSync(dirname(filePath), { recursive: true });
66
- this.load();
67
- }
68
-
69
- /** Load from disk into the in-memory cache. Silent on parse errors. */
70
- private load(): void {
71
- if (!existsSync(this.filePath)) return;
72
- try {
73
- const data: ScheduleStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
74
- this.jobs.clear();
75
- for (const j of data.jobs ?? []) this.jobs.set(j.id, j);
76
- } catch { /* corrupt — start fresh, next save rewrites */ }
77
- }
78
-
79
- /** Atomic write via temp file + rename (POSIX-atomic). */
80
- private save(): void {
81
- const data: ScheduleStoreData = { version: 1, jobs: [...this.jobs.values()] };
82
- const tmp = this.filePath + ".tmp";
83
- writeFileSync(tmp, JSON.stringify(data, null, 2));
84
- renameSync(tmp, this.filePath);
85
- }
86
-
87
- /** Acquire lock → reload → mutate → save → release. */
88
- private withLock<T>(fn: () => T): T {
89
- acquireLock(this.lockPath);
90
- try {
91
- this.load();
92
- const result = fn();
93
- this.save();
94
- return result;
95
- } finally {
96
- releaseLock(this.lockPath);
97
- }
98
- }
99
-
100
- /** Read-only — returns a snapshot of the in-memory cache. */
101
- list(): ScheduledSubagent[] {
102
- return [...this.jobs.values()];
103
- }
104
-
105
- /** Read-only check — uses the cache. */
106
- hasName(name: string, exceptId?: string): boolean {
107
- for (const j of this.jobs.values()) {
108
- if (j.id !== exceptId && j.name === name) return true;
109
- }
110
- return false;
111
- }
112
-
113
- get(id: string): ScheduledSubagent | undefined {
114
- return this.jobs.get(id);
115
- }
116
-
117
- add(job: ScheduledSubagent): void {
118
- this.withLock(() => {
119
- this.jobs.set(job.id, job);
120
- });
121
- }
122
-
123
- update(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
124
- return this.withLock(() => {
125
- const existing = this.jobs.get(id);
126
- if (!existing) return undefined;
127
- const updated = { ...existing, ...patch };
128
- this.jobs.set(id, updated);
129
- return updated;
130
- });
131
- }
132
-
133
- remove(id: string): boolean {
134
- return this.withLock(() => this.jobs.delete(id));
135
- }
136
-
137
- /** Delete the backing file (used when no jobs remain, optional cleanup). */
138
- deleteFileIfEmpty(): void {
139
- if (this.jobs.size === 0 && existsSync(this.filePath)) {
140
- try { unlinkSync(this.filePath); } catch { /* ignore */ }
141
- }
142
- }
143
- }