@dyyz1993/pi-coding-agent 0.74.23 → 0.74.25
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/dist/extensions/agent-permissions/index.ts +235 -0
- package/dist/extensions/ask-tools/index.ts +115 -0
- package/dist/extensions/auto-memory/contract.d.ts +51 -0
- package/dist/extensions/auto-memory/contract.d.ts.map +1 -0
- package/dist/extensions/auto-memory/contract.js +2 -0
- package/dist/extensions/auto-memory/contract.js.map +1 -0
- package/dist/extensions/auto-memory/contract.ts +56 -0
- package/dist/extensions/auto-memory/index.ts +969 -0
- package/dist/extensions/auto-memory/prompts.ts +202 -0
- package/dist/extensions/auto-memory/skip-rules.ts +297 -0
- package/dist/extensions/auto-memory/utils.ts +208 -0
- package/dist/extensions/auto-session-title/index.ts +83 -0
- package/dist/extensions/bash-ext/contract.d.ts +79 -0
- package/dist/extensions/bash-ext/contract.d.ts.map +1 -0
- package/dist/extensions/bash-ext/contract.js +2 -0
- package/dist/extensions/bash-ext/contract.js.map +1 -0
- package/dist/extensions/bash-ext/contract.ts +69 -0
- package/dist/extensions/bash-ext/index.ts +858 -0
- package/dist/extensions/claude-hooks-compat/config-loader.ts +49 -0
- package/dist/extensions/claude-hooks-compat/handler-runner.ts +377 -0
- package/dist/extensions/claude-hooks-compat/if-parser.ts +53 -0
- package/dist/extensions/claude-hooks-compat/index.ts +178 -0
- package/dist/extensions/claude-hooks-compat/matcher.ts +17 -0
- package/dist/extensions/claude-hooks-compat/stdin-builder.ts +27 -0
- package/dist/extensions/claude-hooks-compat/types.ts +77 -0
- package/dist/extensions/compaction-manager/config.ts +47 -0
- package/dist/extensions/compaction-manager/context-fold.ts +63 -0
- package/dist/extensions/compaction-manager/index.ts +151 -0
- package/dist/extensions/compaction-manager/microcompact.ts +49 -0
- package/dist/extensions/compaction-manager/reactive.ts +9 -0
- package/dist/extensions/compaction-manager/session-memory.ts +48 -0
- package/dist/extensions/coordinator/INTEGRATION.md +376 -0
- package/dist/extensions/coordinator/handler.test.ts +277 -0
- package/dist/extensions/coordinator/handler.ts +189 -0
- package/dist/extensions/coordinator/index.ts +261 -0
- package/dist/extensions/coordinator/types.d.ts +100 -0
- package/dist/extensions/coordinator/types.d.ts.map +1 -0
- package/dist/extensions/coordinator/types.js +2 -0
- package/dist/extensions/coordinator/types.js.map +1 -0
- package/dist/extensions/coordinator/types.ts +72 -0
- package/dist/extensions/file-snapshot/index.ts +131 -0
- package/dist/extensions/file-time-guard/README.md +133 -0
- package/dist/extensions/file-time-guard/config.ts +13 -0
- package/dist/extensions/file-time-guard/index.ts +171 -0
- package/dist/extensions/hooks-engine/index.ts +117 -0
- package/dist/extensions/lsp/lsp/client/file-tracker.ts +70 -0
- package/dist/extensions/lsp/lsp/client/registry.ts +305 -0
- package/dist/extensions/lsp/lsp/client/runtime.ts +832 -0
- package/dist/extensions/lsp/lsp/config/resolver.ts +573 -0
- package/dist/extensions/lsp/lsp/contract.d.ts +101 -0
- package/dist/extensions/lsp/lsp/contract.d.ts.map +1 -0
- package/dist/extensions/lsp/lsp/contract.js +2 -0
- package/dist/extensions/lsp/lsp/contract.js.map +1 -0
- package/dist/extensions/lsp/lsp/contract.ts +103 -0
- package/dist/extensions/lsp/lsp/hooks/agent-end.ts +169 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts +10 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts.map +1 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js +30 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js.map +1 -0
- package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.ts +41 -0
- package/dist/extensions/lsp/lsp/hooks/writethrough.ts +342 -0
- package/dist/extensions/lsp/lsp/index.ts +307 -0
- package/dist/extensions/lsp/lsp/lsp.test.ts +684 -0
- package/dist/extensions/lsp/lsp/monitoring/server-metrics.ts +176 -0
- package/dist/extensions/lsp/lsp/tools/lsp-tool.ts +402 -0
- package/dist/extensions/lsp/lsp/utils/dependency-resolver.ts +147 -0
- package/dist/extensions/lsp/lsp/utils/diagnostics-wait.ts +41 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts +20 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts.map +1 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.js +64 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.js.map +1 -0
- package/dist/extensions/lsp/lsp/utils/lsp-helpers.ts +76 -0
- package/dist/extensions/message-bridge/GUIDE.md +210 -0
- package/dist/extensions/message-bridge/index.ts +222 -0
- package/dist/extensions/output-guard/index.ts +384 -0
- package/dist/extensions/preview/index.ts +278 -0
- package/dist/extensions/rules-engine/MATCH_HISTORY_RECONCILIATION.md +111 -0
- package/dist/extensions/rules-engine/RULES-ENGINE-GUIDE.md +470 -0
- package/dist/extensions/rules-engine/cache.js +232 -0
- package/dist/extensions/rules-engine/cache.ts +38 -0
- package/dist/extensions/rules-engine/config.js +63 -0
- package/dist/extensions/rules-engine/config.ts +70 -0
- package/dist/extensions/rules-engine/index.js +1530 -0
- package/dist/extensions/rules-engine/index.ts +552 -0
- package/dist/extensions/rules-engine/injector.js +68 -0
- package/dist/extensions/rules-engine/injector.ts +74 -0
- package/dist/extensions/rules-engine/loader.js +179 -0
- package/dist/extensions/rules-engine/loader.ts +205 -0
- package/dist/extensions/rules-engine/matcher.js +534 -0
- package/dist/extensions/rules-engine/matcher.ts +52 -0
- package/dist/extensions/rules-engine/types.d.ts +156 -0
- package/dist/extensions/rules-engine/types.d.ts.map +1 -0
- package/dist/extensions/rules-engine/types.js +2 -0
- package/dist/extensions/rules-engine/types.js.map +1 -0
- package/dist/extensions/rules-engine/types.ts +169 -0
- package/dist/extensions/session-supervisor/checker.ts +116 -0
- package/dist/extensions/session-supervisor/config.ts +45 -0
- package/dist/extensions/session-supervisor/index.ts +726 -0
- package/dist/extensions/session-supervisor/prompts.ts +132 -0
- package/dist/extensions/session-supervisor/scheduler.ts +69 -0
- package/dist/extensions/session-supervisor/types.ts +215 -0
- package/dist/extensions/subagent/README.md +172 -0
- package/dist/extensions/subagent/agents/explorer.md +25 -0
- package/dist/extensions/subagent/agents/guide.md +27 -0
- package/dist/extensions/subagent/agents/planner.md +37 -0
- package/dist/extensions/subagent/agents/reviewer.md +35 -0
- package/dist/extensions/subagent/agents/scout.md +50 -0
- package/dist/extensions/subagent/agents/verification.md +35 -0
- package/dist/extensions/subagent/agents/worker.md +24 -0
- package/dist/extensions/subagent/agents.ts +25 -0
- package/dist/extensions/subagent/index.ts +987 -0
- package/dist/extensions/subagent/prompts/implement-and-review.md +10 -0
- package/dist/extensions/subagent/prompts/implement.md +10 -0
- package/dist/extensions/subagent/prompts/scout-and-plan.md +9 -0
- package/dist/extensions/subagent-ext/contract.d.ts +2 -0
- package/dist/extensions/subagent-ext/contract.d.ts.map +1 -0
- package/dist/extensions/subagent-ext/contract.js +2 -0
- package/dist/extensions/subagent-ext/contract.js.map +1 -0
- package/dist/extensions/subagent-ext/contract.ts +1 -0
- package/dist/extensions/subagent-ext/index.ts +347 -0
- package/dist/extensions/subagent-shared/contract.d.ts +25 -0
- package/dist/extensions/subagent-shared/contract.d.ts.map +1 -0
- package/dist/extensions/subagent-shared/contract.js +2 -0
- package/dist/extensions/subagent-shared/contract.js.map +1 -0
- package/dist/extensions/subagent-shared/contract.ts +28 -0
- package/dist/extensions/subagent-shared/index.ts +4 -0
- package/dist/extensions/subagent-shared/render.ts +166 -0
- package/dist/extensions/subagent-shared/types.ts +35 -0
- package/dist/extensions/subagent-shared/utils.ts +112 -0
- package/dist/extensions/subagent-v2/contract.d.ts +2 -0
- package/dist/extensions/subagent-v2/contract.d.ts.map +1 -0
- package/dist/extensions/subagent-v2/contract.js +2 -0
- package/dist/extensions/subagent-v2/contract.js.map +1 -0
- package/dist/extensions/subagent-v2/contract.ts +1 -0
- package/dist/extensions/subagent-v2/index.ts +599 -0
- package/dist/extensions/todo-ext/contract.d.ts +27 -0
- package/dist/extensions/todo-ext/contract.d.ts.map +1 -0
- package/dist/extensions/todo-ext/contract.js +2 -0
- package/dist/extensions/todo-ext/contract.js.map +1 -0
- package/dist/extensions/todo-ext/contract.ts +30 -0
- package/dist/extensions/todo-ext/index.ts +419 -0
- package/package.json +3 -2
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# Coordinator Extension - pi-agent-chat Integration Guide
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The coordinator extension enables cross-session communication and task delegation. It runs inside each pi agent process as an extension, communicating with pi-agent-chat's `AgentProcessManager` via the `"coordinator"` channel.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌──────────────────────────────────────────────────┐
|
|
11
|
+
│ pi-agent-chat (AgentProcessManager) │
|
|
12
|
+
│ │
|
|
13
|
+
│ client.channel("coordinator") │
|
|
14
|
+
│ .onReceive(data => { │
|
|
15
|
+
│ // Handle incoming __call from extension │
|
|
16
|
+
│ // Return result to respond │
|
|
17
|
+
│ }) │
|
|
18
|
+
│ │
|
|
19
|
+
│ client.channel("coordinator") │
|
|
20
|
+
│ .send({ type: "message_received", ... }) │
|
|
21
|
+
│ // Emit events to extension │
|
|
22
|
+
└───────────────────┬──────────────────────────────┘
|
|
23
|
+
│ stdin/stdout JSONL
|
|
24
|
+
└───────────────────┴──────────────────────────────┘
|
|
25
|
+
┌──────────────────────────────────────────────────┐
|
|
26
|
+
│ pi Agent Process (extension) │
|
|
27
|
+
│ │
|
|
28
|
+
│ ServerChannel: handles __call from pi-agent-chat│
|
|
29
|
+
│ ClientChannel: sends __call to pi-agent-chat │
|
|
30
|
+
│ │
|
|
31
|
+
│ Tools: session_delegate, session_delegate_send, │
|
|
32
|
+
│ session_delegate_status, session_delegate_│
|
|
33
|
+
│ stop, session_delegate_fork │
|
|
34
|
+
└──────────────────────────────────────────────────┘
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Key Communication Pattern
|
|
38
|
+
|
|
39
|
+
The extension uses `ClientChannel.call()` to send `__call` requests OUTBOUND to pi-agent-chat. pi-agent-chat must respond by returning a value from its `onReceive` handler.
|
|
40
|
+
|
|
41
|
+
**Critical**: The `RpcClient.handleLine()` does NOT await handler results. It calls handlers synchronously:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
for (const handler of handlers) {
|
|
45
|
+
const result = handler(data.data);
|
|
46
|
+
if (invokeId && result !== undefined) {
|
|
47
|
+
this.writeLine({ type: "channel_data", name, data: { ...result, invokeId } });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
This means async handlers that return Promises will NOT work correctly with the auto-response mechanism. You **must** use the manual response pattern for any handler that performs async operations.
|
|
53
|
+
|
|
54
|
+
## Step-by-Step Integration
|
|
55
|
+
|
|
56
|
+
### Step 1: Register coordinator channel in `start()`
|
|
57
|
+
|
|
58
|
+
In `AgentProcessManager.start()`, add `"coordinator"` to the channel registration loop:
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
for (const name of ["bash", "todo", "subagent", "lsp", "rules-engine", "memory", "coordinator"] as const) {
|
|
62
|
+
client.channel(name).onReceive((data: unknown) => {
|
|
63
|
+
this.handleCoordinatorCall(sessionId, data);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Step 2: Implement `handleCoordinatorCall()` with manual response
|
|
69
|
+
|
|
70
|
+
> **Warning**: You MUST use the manual response pattern below. The `RpcClient.handleLine()` does NOT await handlers, so returning a Promise from `onReceive` will send the unresolved Promise object as the response, causing the extension's `ClientChannel.call()` to receive garbage data.
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
private async handleCoordinatorCall(sessionId: string, data: unknown): Promise<void> {
|
|
74
|
+
const msg = data as Record<string, unknown>;
|
|
75
|
+
const method = msg.__call as string;
|
|
76
|
+
const invokeId = msg.invokeId as string | undefined;
|
|
77
|
+
|
|
78
|
+
let result: unknown;
|
|
79
|
+
try {
|
|
80
|
+
switch (method) {
|
|
81
|
+
case "session_delegate":
|
|
82
|
+
result = await this.handleDelegate(sessionId, msg);
|
|
83
|
+
break;
|
|
84
|
+
case "session_delegate_send":
|
|
85
|
+
result = await this.handleDelegateSend(sessionId, msg);
|
|
86
|
+
break;
|
|
87
|
+
case "session_delegate_status":
|
|
88
|
+
result = await this.handleDelegateStatus(sessionId, msg);
|
|
89
|
+
break;
|
|
90
|
+
case "session_delegate_list":
|
|
91
|
+
result = this.handleDelegateList();
|
|
92
|
+
break;
|
|
93
|
+
case "session_delegate_stop":
|
|
94
|
+
result = await this.handleDelegateStop(sessionId, msg);
|
|
95
|
+
break;
|
|
96
|
+
case "session_delegate_fork":
|
|
97
|
+
result = await this.handleDelegateFork(sessionId, msg);
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
result = { error: err instanceof Error ? err.message : String(err) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (invokeId) {
|
|
107
|
+
const managed = this.clients.get(sessionId);
|
|
108
|
+
if (managed) {
|
|
109
|
+
managed.client.channel("coordinator").send({ ...(result as object), invokeId });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Step 3: Implement each handler
|
|
116
|
+
|
|
117
|
+
#### `handleDelegate()` — Create new session and send task
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
private async handleDelegate(
|
|
121
|
+
parentSessionId: string,
|
|
122
|
+
msg: Record<string, unknown>,
|
|
123
|
+
): Promise<{ sessionId: string; status: "started" | "already_running" }> {
|
|
124
|
+
const task = msg.task as string;
|
|
125
|
+
const parent = this.clients.get(parentSessionId);
|
|
126
|
+
if (!parent) throw new Error("Parent session not found");
|
|
127
|
+
|
|
128
|
+
const projectPath = parent.info.projectPath;
|
|
129
|
+
|
|
130
|
+
const newSessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
131
|
+
const sessionDir = path.dirname(parent.info.sessionPath);
|
|
132
|
+
const sessionPath = path.join(sessionDir, `${newSessionId}.jsonl`);
|
|
133
|
+
|
|
134
|
+
const result = await this.start(newSessionId, projectPath, sessionPath);
|
|
135
|
+
|
|
136
|
+
this.send(newSessionId, task);
|
|
137
|
+
|
|
138
|
+
return { sessionId: newSessionId, status: result.status === "started" ? "started" : "already_running" };
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### `handleDelegateSend()` — Send message to target session
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
private async handleDelegateSend(
|
|
146
|
+
fromSessionId: string,
|
|
147
|
+
msg: Record<string, unknown>,
|
|
148
|
+
): Promise<{ delivered: boolean; targetStatus: "active" | "started" | "not_found" }> {
|
|
149
|
+
const targetSessionId = msg.targetSessionId as string;
|
|
150
|
+
const message = msg.message as string;
|
|
151
|
+
|
|
152
|
+
const target = this.clients.get(targetSessionId);
|
|
153
|
+
if (!target) {
|
|
154
|
+
return { delivered: false, targetStatus: "not_found" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (target.info.status === "idle") {
|
|
158
|
+
this.followUp(targetSessionId, message);
|
|
159
|
+
return { delivered: true, targetStatus: "active" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.followUp(targetSessionId, message);
|
|
163
|
+
return { delivered: true, targetStatus: "active" };
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### `handleDelegateStatus()` — Get session status with compact info
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
private async handleDelegateStatus(
|
|
171
|
+
_sessionId: string,
|
|
172
|
+
msg: Record<string, unknown>,
|
|
173
|
+
): Promise<{ status: string; isCompacting: boolean; contextUsage: unknown }> {
|
|
174
|
+
const targetSessionId = msg.sessionId as string;
|
|
175
|
+
|
|
176
|
+
const status = this.getStatus(targetSessionId);
|
|
177
|
+
if (status.status === "stopped") {
|
|
178
|
+
return { status: "stopped", isCompacting: false, contextUsage: { tokens: null, contextWindow: 0, percent: null } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const state = await this.getState(targetSessionId);
|
|
182
|
+
const contextUsage = await this.getContextUsage(targetSessionId);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
status: state?.isStreaming ? "streaming" : "idle",
|
|
186
|
+
isCompacting: state?.isCompacting ?? false,
|
|
187
|
+
contextUsage,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### `handleDelegateList()` — List all managed sessions
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
private handleDelegateList(): { sessions: Array<{ sessionId: string; status: string; projectPath: string }> } {
|
|
196
|
+
const sessions: Array<{ sessionId: string; status: string; projectPath: string }> = [];
|
|
197
|
+
for (const [sessionId, managed] of this.clients) {
|
|
198
|
+
sessions.push({
|
|
199
|
+
sessionId,
|
|
200
|
+
status: managed.info.status,
|
|
201
|
+
projectPath: managed.info.projectPath,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return { sessions };
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
#### `handleDelegateStop()` — Stop a session
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
private async handleDelegateStop(
|
|
212
|
+
_sessionId: string,
|
|
213
|
+
msg: Record<string, unknown>,
|
|
214
|
+
): Promise<{ ok: boolean }> {
|
|
215
|
+
const targetSessionId = msg.sessionId as string;
|
|
216
|
+
const ok = this.stop(targetSessionId);
|
|
217
|
+
return { ok };
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### `handleDelegateFork()` — Fork session without stopping original
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
private async handleDelegateFork(
|
|
225
|
+
parentSessionId: string,
|
|
226
|
+
msg: Record<string, unknown>,
|
|
227
|
+
): Promise<{ sessionId: string; status: "started" | "already_running" }> {
|
|
228
|
+
const task = msg.task as string;
|
|
229
|
+
const base = this.clients.get(parentSessionId);
|
|
230
|
+
if (!base) throw new Error("Base session not found");
|
|
231
|
+
|
|
232
|
+
const sessionPath = base.info.sessionPath;
|
|
233
|
+
const projectPath = base.info.projectPath;
|
|
234
|
+
const sessionDir = path.dirname(sessionPath);
|
|
235
|
+
|
|
236
|
+
// Primary approach: use SessionManager.createBranchedSession() for correct JSONL extraction
|
|
237
|
+
// This handles tree-structured sessions and avoids half-write corruption
|
|
238
|
+
import { SessionManager } from "@dyyz1993/pi-coding-agent";
|
|
239
|
+
|
|
240
|
+
const sourceManager = SessionManager.open(sessionPath, sessionDir);
|
|
241
|
+
const leafId = sourceManager.getLeafId();
|
|
242
|
+
const forkedPath = sourceManager.createBranchedSession(leafId);
|
|
243
|
+
|
|
244
|
+
// Fallback: if createBranchedSession is unavailable, use direct file copy
|
|
245
|
+
// const forkedSessionId = `sess_fork_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
246
|
+
// const forkedPath = path.join(sessionDir, `${forkedSessionId}.jsonl`);
|
|
247
|
+
// fs.copyFileSync(sessionPath, forkedPath);
|
|
248
|
+
|
|
249
|
+
const forkedSessionId = path.basename(forkedPath, ".jsonl");
|
|
250
|
+
const result = await this.start(forkedSessionId, projectPath, forkedPath);
|
|
251
|
+
|
|
252
|
+
this.send(forkedSessionId, task);
|
|
253
|
+
|
|
254
|
+
return { sessionId: forkedSessionId, status: result.status === "started" ? "started" : "already_running" };
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Why `SessionManager.createBranchedSession()` is preferred over file copy**:
|
|
259
|
+
|
|
260
|
+
1. Extracts a single linear path from tree-structured sessions (after navigateTree/branch operations)
|
|
261
|
+
2. Filters and recreates label entries correctly
|
|
262
|
+
3. Generates a new session ID and header
|
|
263
|
+
4. Rebuilds internal index
|
|
264
|
+
5. Avoids reading a partially-written JSONL file (the source session may be actively writing)
|
|
265
|
+
|
|
266
|
+
File copy (`fs.copyFileSync`) is only safe as a fallback when you can guarantee the source session is idle and the JSONL is not being written to.
|
|
267
|
+
|
|
268
|
+
### Step 4: Emit events from pi-agent-chat to extension
|
|
269
|
+
|
|
270
|
+
To send events to the extension (e.g., message_received, task_completed), use:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
if (delegatedSessionId) {
|
|
274
|
+
const managed = this.clients.get(parentSessionId);
|
|
275
|
+
if (managed) {
|
|
276
|
+
managed.client.channel("coordinator").send({
|
|
277
|
+
type: "message_received",
|
|
278
|
+
fromSessionId: delegatedSessionId,
|
|
279
|
+
message: "Task completed successfully",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Or use `sendChannelData()`:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
this.sendChannelData(parentSessionId, "coordinator", {
|
|
289
|
+
type: "task_completed",
|
|
290
|
+
sessionId: delegatedSessionId,
|
|
291
|
+
result: "Done",
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Step 5: Add coordinator extension path to config
|
|
296
|
+
|
|
297
|
+
In `server-config.ts`, add:
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
coordinator: process.env.PI_EXT_COORDINATOR,
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
In `.env`:
|
|
304
|
+
```
|
|
305
|
+
PI_EXT_COORDINATOR=/path/to/pi-momo-fork/packages/coding-agent/extensions/coordinator
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
And add to the `EXTENSION_ARGS` array in `process-manager.ts`:
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
const { coordinator, /* ...others */ } = config.piExtensionPaths;
|
|
312
|
+
const EXTENSION_ARGS = [
|
|
313
|
+
"--no-extensions",
|
|
314
|
+
...[subagent, todo, bash, lsp, preview, autoMemory, autoSessionTitle, rules, fileSnapshot, askTools, messageBridge, coordinator]
|
|
315
|
+
.filter((p): p is string => !!p)
|
|
316
|
+
.flatMap((p) => ["--extension", p]),
|
|
317
|
+
];
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
## Channel Contract Reference
|
|
321
|
+
|
|
322
|
+
### Methods (pi-agent-chat handles these)
|
|
323
|
+
|
|
324
|
+
| Method | Params | Return |
|
|
325
|
+
|--------|--------|--------|
|
|
326
|
+
| `session_delegate` | `{ task: string, title?: string }` | `{ sessionId: string, status: "started" \| "already_running" }` |
|
|
327
|
+
| `session_delegate_send` | `{ targetSessionId: string, message: string }` | `{ delivered: boolean, targetStatus: "active" \| "started" \| "not_found" }` |
|
|
328
|
+
| `session_delegate_status` | `{ sessionId: string }` | `{ task?: DelegatedTask, isCompacting?: boolean, contextUsage?: ContextUsage }` |
|
|
329
|
+
| `session_delegate_list` | `{}` | `{ tasks: DelegatedTask[] }` |
|
|
330
|
+
| `session_delegate_stop` | `{ sessionId: string }` | `{ ok: boolean }` |
|
|
331
|
+
| `session_delegate_fork` | `{ sessionId: string, task: string, title?: string }` | `{ sessionId: string, status: "started" \| "already_running" }` |
|
|
332
|
+
|
|
333
|
+
### Events (pi-agent-chat sends these)
|
|
334
|
+
|
|
335
|
+
| Event | Data | When |
|
|
336
|
+
|-------|------|------|
|
|
337
|
+
| `message_received` | `{ fromSessionId: string, message: string }` | Worker session sends a message |
|
|
338
|
+
| `task_started` | `{ sessionId: string, title: string, task: string }` | Worker starts processing |
|
|
339
|
+
| `task_stopped` | `{ sessionId: string }` | Worker stopped |
|
|
340
|
+
| `task_completed` | `{ sessionId: string, result?: string }` | Worker finished |
|
|
341
|
+
| `task_error` | `{ sessionId: string, error: string }` | Worker errored |
|
|
342
|
+
|
|
343
|
+
### Types
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
interface DelegatedTask {
|
|
347
|
+
sessionId: string;
|
|
348
|
+
title: string;
|
|
349
|
+
task: string;
|
|
350
|
+
projectPath: string;
|
|
351
|
+
dispatchedAt: number;
|
|
352
|
+
status: "idle" | "streaming" | "stopped" | "completed";
|
|
353
|
+
completedAt?: number;
|
|
354
|
+
result?: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface ContextUsage {
|
|
358
|
+
tokens: number | null;
|
|
359
|
+
contextWindow: number;
|
|
360
|
+
percent: number | null;
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Testing Checklist
|
|
365
|
+
|
|
366
|
+
- [ ] Register coordinator channel in `start()`
|
|
367
|
+
- [ ] `session_delegate` creates new session and sends task
|
|
368
|
+
- [ ] `session_delegate_send` delivers message via `followUp()`
|
|
369
|
+
- [ ] `session_delegate_status` returns status + isCompacting + contextUsage
|
|
370
|
+
- [ ] `session_delegate_list` lists all managed sessions
|
|
371
|
+
- [ ] `session_delegate_stop` stops target session
|
|
372
|
+
- [ ] `session_delegate_fork` copies session file and starts new process
|
|
373
|
+
- [ ] Events are emitted back to extension on state changes
|
|
374
|
+
- [ ] Async responses are sent manually via `channel.send()` with invokeId
|
|
375
|
+
- [ ] Coordinator extension path is configured in `.env`
|
|
376
|
+
- [ ] `handleEvent()` routes coordinator channel data correctly
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import type { DelegatedTask } from "./types.js";
|
|
6
|
+
import { TaskStore } from "./handler.js";
|
|
7
|
+
|
|
8
|
+
function makeTask(overrides: Partial<DelegatedTask> = {}): DelegatedTask {
|
|
9
|
+
return {
|
|
10
|
+
sessionId: `sess-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
11
|
+
title: "Test task",
|
|
12
|
+
task: "Do something useful",
|
|
13
|
+
projectPath: "/tmp/test",
|
|
14
|
+
dispatchedAt: Date.now(),
|
|
15
|
+
status: "idle",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("TaskStore.buildPrompt()", () => {
|
|
21
|
+
let tempDir: string;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
25
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("includes stopped tasks in prompt (by design, for re-activation)", () => {
|
|
30
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
31
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const store = new TaskStore(tempDir);
|
|
34
|
+
const task = makeTask({ title: "Re-activatable task", sessionId: "sess-stopped-1" });
|
|
35
|
+
store.add(task);
|
|
36
|
+
store.update("sess-stopped-1", { status: "stopped" });
|
|
37
|
+
|
|
38
|
+
const prompt = store.buildPrompt();
|
|
39
|
+
|
|
40
|
+
expect(prompt).toContain("Re-activatable task");
|
|
41
|
+
expect(prompt).toContain("STOPPED");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("includes completed tasks in prompt (by design, for re-activation)", () => {
|
|
45
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
46
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
const store = new TaskStore(tempDir);
|
|
49
|
+
const task = makeTask({
|
|
50
|
+
title: "Completed task still visible",
|
|
51
|
+
sessionId: "sess-completed-1",
|
|
52
|
+
status: "completed",
|
|
53
|
+
completedAt: Date.now(),
|
|
54
|
+
result: "Did the thing",
|
|
55
|
+
});
|
|
56
|
+
store.add(task);
|
|
57
|
+
|
|
58
|
+
const prompt = store.buildPrompt();
|
|
59
|
+
|
|
60
|
+
expect(prompt).toContain("Completed task still visible");
|
|
61
|
+
expect(prompt).toContain("DONE");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("persists tasks across restarts and buildPrompt includes them all", () => {
|
|
65
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
66
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
67
|
+
|
|
68
|
+
const store1 = new TaskStore(tempDir);
|
|
69
|
+
store1.add(makeTask({ title: "Task A", sessionId: "sess-a" }));
|
|
70
|
+
store1.add(makeTask({ title: "Task B", sessionId: "sess-b", status: "completed", completedAt: Date.now() }));
|
|
71
|
+
|
|
72
|
+
const store2 = new TaskStore(tempDir);
|
|
73
|
+
const prompt = store2.buildPrompt();
|
|
74
|
+
|
|
75
|
+
expect(prompt).toContain("Task A");
|
|
76
|
+
expect(prompt).toContain("Task B");
|
|
77
|
+
expect(prompt).toContain("DONE");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("store.remove() exists and works when called directly", () => {
|
|
81
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
82
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
const store = new TaskStore(tempDir);
|
|
85
|
+
store.add(makeTask({ sessionId: "sess-removable", title: "Can be removed" }));
|
|
86
|
+
expect(store.get("sess-removable")).toBeDefined();
|
|
87
|
+
|
|
88
|
+
store.remove("sess-removable");
|
|
89
|
+
|
|
90
|
+
expect(store.get("sess-removable")).toBeUndefined();
|
|
91
|
+
expect(store.buildPrompt()).toBe("");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("BUG: store.remove() is never called by any channel handler or tool", () => {
|
|
95
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
96
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
const handlerSource = fs.readFileSync(
|
|
99
|
+
path.join(__dirname, "handler.ts"),
|
|
100
|
+
"utf-8",
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const indexSource = fs.readFileSync(
|
|
104
|
+
path.join(__dirname, "index.ts"),
|
|
105
|
+
"utf-8",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(
|
|
109
|
+
handlerSource.includes("remove("),
|
|
110
|
+
"TaskStore.remove() method exists in handler.ts",
|
|
111
|
+
).toBe(true);
|
|
112
|
+
|
|
113
|
+
const handlerBodies: string[] = [];
|
|
114
|
+
const handlePattern = /channel\.handle\("([^"]+)",\s*async\s*\([^)]*\)\s*=>\s*\{/g;
|
|
115
|
+
let match;
|
|
116
|
+
while ((match = handlePattern.exec(handlerSource)) !== null) {
|
|
117
|
+
const startIdx = match.index + match[0].length;
|
|
118
|
+
let braceCount = 1;
|
|
119
|
+
let endIdx = startIdx;
|
|
120
|
+
while (braceCount > 0 && endIdx < handlerSource.length) {
|
|
121
|
+
if (handlerSource[endIdx] === "{") braceCount++;
|
|
122
|
+
else if (handlerSource[endIdx] === "}") braceCount--;
|
|
123
|
+
endIdx++;
|
|
124
|
+
}
|
|
125
|
+
handlerBodies.push(handlerSource.slice(startIdx, endIdx));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const body of handlerBodies) {
|
|
129
|
+
expect(
|
|
130
|
+
body.includes("remove("),
|
|
131
|
+
"No channel.handle() body should call remove()",
|
|
132
|
+
).toBe(false);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
expect(
|
|
136
|
+
indexSource.includes(".remove("),
|
|
137
|
+
"BUG: index.ts never calls store.remove() - no tool/command exposes removal",
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("session_delegate_stop only sets status=stopped, does not remove task", () => {
|
|
142
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
143
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
const store = new TaskStore(tempDir);
|
|
146
|
+
store.add(makeTask({ sessionId: "sess-stop-test", title: "Accumulated task" }));
|
|
147
|
+
store.update("sess-stop-test", { status: "stopped" });
|
|
148
|
+
|
|
149
|
+
const task = store.get("sess-stop-test");
|
|
150
|
+
expect(task).toBeDefined();
|
|
151
|
+
expect(task?.status).toBe("stopped");
|
|
152
|
+
|
|
153
|
+
const prompt = store.buildPrompt();
|
|
154
|
+
expect(prompt).toContain("Accumulated task");
|
|
155
|
+
expect(prompt).toContain("STOPPED");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("BUG: no tool or command exposes remove functionality to LLM or user", () => {
|
|
159
|
+
const indexSource = fs.readFileSync(
|
|
160
|
+
path.join(__dirname, "index.ts"),
|
|
161
|
+
"utf-8",
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const registeredTools: string[] = [];
|
|
165
|
+
const toolPattern = /pi\.registerTool\(\{[^}]*name:\s*"([^"]+)"/g;
|
|
166
|
+
let match;
|
|
167
|
+
while ((match = toolPattern.exec(indexSource)) !== null) {
|
|
168
|
+
registeredTools.push(match[1]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const registeredCommands: string[] = [];
|
|
172
|
+
const cmdPattern = /pi\.registerCommand\(\s*"([^"]+)"/g;
|
|
173
|
+
while ((match = cmdPattern.exec(indexSource)) !== null) {
|
|
174
|
+
registeredCommands.push(match[1]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const removalToolNames = [
|
|
178
|
+
"session_delegate_remove",
|
|
179
|
+
"session_delegate_cleanup",
|
|
180
|
+
"session_delegate_forget",
|
|
181
|
+
"session_delegate_delete",
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
for (const name of removalToolNames) {
|
|
185
|
+
expect(
|
|
186
|
+
registeredTools.includes(name),
|
|
187
|
+
`BUG: No tool named "${name}" is registered`,
|
|
188
|
+
).toBe(false);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const removalCmdPatterns = [
|
|
192
|
+
"coordinator-cleanup",
|
|
193
|
+
"coordinator-remove",
|
|
194
|
+
"coordinator-forget",
|
|
195
|
+
"coordinator-clear",
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const name of removalCmdPatterns) {
|
|
199
|
+
expect(
|
|
200
|
+
registeredCommands.includes(name),
|
|
201
|
+
`BUG: No command named "${name}" is registered`,
|
|
202
|
+
).toBe(false);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("BUG: no automatic cleanup of tasks whose sessions no longer exist", () => {
|
|
207
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
208
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
209
|
+
|
|
210
|
+
const store = new TaskStore(tempDir);
|
|
211
|
+
store.add(makeTask({
|
|
212
|
+
sessionId: "ghost-session-999",
|
|
213
|
+
title: "Ghost task",
|
|
214
|
+
status: "idle",
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const prompt = store.buildPrompt();
|
|
218
|
+
expect(prompt).toContain("ghost-session-999");
|
|
219
|
+
expect(prompt).toContain("Ghost task");
|
|
220
|
+
|
|
221
|
+
expect(store.list().length).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("BUG: no age-based or context-pressure-based cleanup mechanism", () => {
|
|
225
|
+
tempDir = path.join(os.tmpdir(), `coordinator-test-${Date.now()}`);
|
|
226
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
227
|
+
|
|
228
|
+
const store = new TaskStore(tempDir);
|
|
229
|
+
const oldTimestamp = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
230
|
+
|
|
231
|
+
for (let i = 0; i < 20; i++) {
|
|
232
|
+
store.add(makeTask({
|
|
233
|
+
sessionId: `sess-old-${i}`,
|
|
234
|
+
title: `Old task ${i}`,
|
|
235
|
+
dispatchedAt: oldTimestamp + i * 1000,
|
|
236
|
+
status: "completed",
|
|
237
|
+
completedAt: oldTimestamp + i * 1000 + 5000,
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const allTasks = store.list();
|
|
242
|
+
expect(allTasks.length).toBe(20);
|
|
243
|
+
|
|
244
|
+
const prompt = store.buildPrompt();
|
|
245
|
+
for (let i = 0; i < 20; i++) {
|
|
246
|
+
expect(prompt).toContain(`Old task ${i}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const handlerSource = fs.readFileSync(
|
|
250
|
+
path.join(__dirname, "handler.ts"),
|
|
251
|
+
"utf-8",
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const hasMaxAgeLogic = handlerSource.includes("maxAge") ||
|
|
255
|
+
handlerSource.includes("ttl") ||
|
|
256
|
+
handlerSource.includes("expiry") ||
|
|
257
|
+
handlerSource.includes("staleThreshold") ||
|
|
258
|
+
handlerSource.includes("cleanupInterval");
|
|
259
|
+
expect(
|
|
260
|
+
hasMaxAgeLogic,
|
|
261
|
+
"BUG: No age-based cleanup logic (maxAge/ttl/expiry) in handler.ts",
|
|
262
|
+
).toBe(false);
|
|
263
|
+
|
|
264
|
+
const indexSource = fs.readFileSync(
|
|
265
|
+
path.join(__dirname, "index.ts"),
|
|
266
|
+
"utf-8",
|
|
267
|
+
);
|
|
268
|
+
const hasContextEviction = indexSource.includes("contextPressure") ||
|
|
269
|
+
indexSource.includes("evict") ||
|
|
270
|
+
indexSource.includes("tokenBudget") ||
|
|
271
|
+
indexSource.includes("maxContextTasks");
|
|
272
|
+
expect(
|
|
273
|
+
hasContextEviction,
|
|
274
|
+
"BUG: No context-pressure-based eviction logic in index.ts",
|
|
275
|
+
).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
});
|