@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.
- package/CHANGELOG.md +44 -0
- package/README.md +8 -127
- package/docs/architecture/architecture.md +4 -8
- package/docs/plans/0049-remove-group-join-output-file-rpc.md +163 -0
- package/docs/plans/0052-remove-scheduled-subagents.md +131 -0
- package/docs/retro/0051-update-adr-0001-hard-fork.md +33 -0
- package/package.json +1 -2
- package/src/agent-manager.ts +2 -2
- package/src/index.ts +10 -287
- package/src/invocation-config.ts +1 -5
- package/src/settings.ts +0 -24
- package/src/types.ts +1 -49
- package/src/cross-extension-rpc.ts +0 -95
- package/src/group-join.ts +0 -141
- package/src/schedule-store.ts +0 -143
- package/src/schedule.ts +0 -365
- package/src/ui/schedule-menu.ts +0 -104
|
@@ -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
|
-
}
|
package/src/schedule-store.ts
DELETED
|
@@ -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
|
-
}
|