@dyyz1993/pi-coding-agent 0.74.24 → 0.74.27

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 (157) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/core/agent-session.d.ts.map +1 -1
  3. package/dist/core/agent-session.js +3 -0
  4. package/dist/core/agent-session.js.map +1 -1
  5. package/dist/core/session-manager.d.ts +5 -0
  6. package/dist/core/session-manager.d.ts.map +1 -1
  7. package/dist/core/session-manager.js +8 -0
  8. package/dist/core/session-manager.js.map +1 -1
  9. package/dist/extensions/agent-permissions/index.ts +235 -0
  10. package/dist/extensions/ask-tools/index.ts +115 -0
  11. package/dist/extensions/auto-memory/contract.d.ts +51 -0
  12. package/dist/extensions/auto-memory/contract.d.ts.map +1 -0
  13. package/dist/extensions/auto-memory/contract.js +2 -0
  14. package/dist/extensions/auto-memory/contract.js.map +1 -0
  15. package/dist/extensions/auto-memory/contract.ts +56 -0
  16. package/dist/extensions/auto-memory/index.ts +969 -0
  17. package/dist/extensions/auto-memory/prompts.ts +202 -0
  18. package/dist/extensions/auto-memory/skip-rules.ts +297 -0
  19. package/dist/extensions/auto-memory/utils.ts +208 -0
  20. package/dist/extensions/auto-session-title/index.ts +83 -0
  21. package/dist/extensions/bash-ext/contract.d.ts +79 -0
  22. package/dist/extensions/bash-ext/contract.d.ts.map +1 -0
  23. package/dist/extensions/bash-ext/contract.js +2 -0
  24. package/dist/extensions/bash-ext/contract.js.map +1 -0
  25. package/dist/extensions/bash-ext/contract.ts +69 -0
  26. package/dist/extensions/bash-ext/index.ts +858 -0
  27. package/dist/extensions/claude-hooks-compat/config-loader.ts +49 -0
  28. package/dist/extensions/claude-hooks-compat/handler-runner.ts +377 -0
  29. package/dist/extensions/claude-hooks-compat/if-parser.ts +53 -0
  30. package/dist/extensions/claude-hooks-compat/index.ts +178 -0
  31. package/dist/extensions/claude-hooks-compat/matcher.ts +17 -0
  32. package/dist/extensions/claude-hooks-compat/stdin-builder.ts +27 -0
  33. package/dist/extensions/claude-hooks-compat/types.ts +77 -0
  34. package/dist/extensions/compaction-manager/config.ts +47 -0
  35. package/dist/extensions/compaction-manager/context-fold.ts +63 -0
  36. package/dist/extensions/compaction-manager/index.ts +151 -0
  37. package/dist/extensions/compaction-manager/microcompact.ts +49 -0
  38. package/dist/extensions/compaction-manager/reactive.ts +9 -0
  39. package/dist/extensions/compaction-manager/session-memory.ts +48 -0
  40. package/dist/extensions/coordinator/INTEGRATION.md +376 -0
  41. package/dist/extensions/coordinator/handler.test.ts +277 -0
  42. package/dist/extensions/coordinator/handler.ts +189 -0
  43. package/dist/extensions/coordinator/index.ts +261 -0
  44. package/dist/extensions/coordinator/types.d.ts +100 -0
  45. package/dist/extensions/coordinator/types.d.ts.map +1 -0
  46. package/dist/extensions/coordinator/types.js +2 -0
  47. package/dist/extensions/coordinator/types.js.map +1 -0
  48. package/dist/extensions/coordinator/types.ts +72 -0
  49. package/dist/extensions/file-snapshot/index.ts +131 -0
  50. package/dist/extensions/file-time-guard/README.md +133 -0
  51. package/dist/extensions/file-time-guard/config.ts +13 -0
  52. package/dist/extensions/file-time-guard/index.ts +171 -0
  53. package/dist/extensions/hooks-engine/index.ts +117 -0
  54. package/dist/extensions/lsp/lsp/client/file-tracker.ts +70 -0
  55. package/dist/extensions/lsp/lsp/client/registry.ts +305 -0
  56. package/dist/extensions/lsp/lsp/client/runtime.ts +832 -0
  57. package/dist/extensions/lsp/lsp/config/resolver.ts +573 -0
  58. package/dist/extensions/lsp/lsp/contract.d.ts +101 -0
  59. package/dist/extensions/lsp/lsp/contract.d.ts.map +1 -0
  60. package/dist/extensions/lsp/lsp/contract.js +2 -0
  61. package/dist/extensions/lsp/lsp/contract.js.map +1 -0
  62. package/dist/extensions/lsp/lsp/contract.ts +103 -0
  63. package/dist/extensions/lsp/lsp/hooks/agent-end.ts +169 -0
  64. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts +10 -0
  65. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.d.ts.map +1 -0
  66. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js +30 -0
  67. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.js.map +1 -0
  68. package/dist/extensions/lsp/lsp/hooks/diagnostics-mode.ts +41 -0
  69. package/dist/extensions/lsp/lsp/hooks/writethrough.ts +342 -0
  70. package/dist/extensions/lsp/lsp/index.ts +310 -0
  71. package/dist/extensions/lsp/lsp/lsp.test.ts +684 -0
  72. package/dist/extensions/lsp/lsp/monitoring/server-metrics.ts +176 -0
  73. package/dist/extensions/lsp/lsp/tools/lsp-tool.ts +402 -0
  74. package/dist/extensions/lsp/lsp/utils/dependency-resolver.ts +147 -0
  75. package/dist/extensions/lsp/lsp/utils/diagnostics-wait.ts +41 -0
  76. package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts +20 -0
  77. package/dist/extensions/lsp/lsp/utils/lsp-helpers.d.ts.map +1 -0
  78. package/dist/extensions/lsp/lsp/utils/lsp-helpers.js +64 -0
  79. package/dist/extensions/lsp/lsp/utils/lsp-helpers.js.map +1 -0
  80. package/dist/extensions/lsp/lsp/utils/lsp-helpers.ts +76 -0
  81. package/dist/extensions/message-bridge/GUIDE.md +210 -0
  82. package/dist/extensions/message-bridge/index.ts +222 -0
  83. package/dist/extensions/output-guard/index.ts +446 -0
  84. package/dist/extensions/preview/index.ts +278 -0
  85. package/dist/extensions/rules-engine/MATCH_HISTORY_RECONCILIATION.md +111 -0
  86. package/dist/extensions/rules-engine/RULES-ENGINE-GUIDE.md +470 -0
  87. package/dist/extensions/rules-engine/cache.js +232 -0
  88. package/dist/extensions/rules-engine/cache.ts +38 -0
  89. package/dist/extensions/rules-engine/config.js +63 -0
  90. package/dist/extensions/rules-engine/config.ts +70 -0
  91. package/dist/extensions/rules-engine/index.js +1530 -0
  92. package/dist/extensions/rules-engine/index.ts +552 -0
  93. package/dist/extensions/rules-engine/injector.js +68 -0
  94. package/dist/extensions/rules-engine/injector.ts +74 -0
  95. package/dist/extensions/rules-engine/loader.js +179 -0
  96. package/dist/extensions/rules-engine/loader.ts +205 -0
  97. package/dist/extensions/rules-engine/matcher.js +534 -0
  98. package/dist/extensions/rules-engine/matcher.ts +52 -0
  99. package/dist/extensions/rules-engine/types.d.ts +156 -0
  100. package/dist/extensions/rules-engine/types.d.ts.map +1 -0
  101. package/dist/extensions/rules-engine/types.js +2 -0
  102. package/dist/extensions/rules-engine/types.js.map +1 -0
  103. package/dist/extensions/rules-engine/types.ts +169 -0
  104. package/dist/extensions/session-supervisor/checker.ts +116 -0
  105. package/dist/extensions/session-supervisor/config.ts +45 -0
  106. package/dist/extensions/session-supervisor/index.ts +726 -0
  107. package/dist/extensions/session-supervisor/prompts.ts +132 -0
  108. package/dist/extensions/session-supervisor/scheduler.ts +69 -0
  109. package/dist/extensions/session-supervisor/types.ts +215 -0
  110. package/dist/extensions/subagent/README.md +172 -0
  111. package/dist/extensions/subagent/agents/explorer.md +25 -0
  112. package/dist/extensions/subagent/agents/guide.md +27 -0
  113. package/dist/extensions/subagent/agents/planner.md +37 -0
  114. package/dist/extensions/subagent/agents/reviewer.md +35 -0
  115. package/dist/extensions/subagent/agents/scout.md +50 -0
  116. package/dist/extensions/subagent/agents/verification.md +35 -0
  117. package/dist/extensions/subagent/agents/worker.md +24 -0
  118. package/dist/extensions/subagent/agents.ts +25 -0
  119. package/dist/extensions/subagent/index.ts +987 -0
  120. package/dist/extensions/subagent/prompts/implement-and-review.md +10 -0
  121. package/dist/extensions/subagent/prompts/implement.md +10 -0
  122. package/dist/extensions/subagent/prompts/scout-and-plan.md +9 -0
  123. package/dist/extensions/subagent-ext/contract.d.ts +2 -0
  124. package/dist/extensions/subagent-ext/contract.d.ts.map +1 -0
  125. package/dist/extensions/subagent-ext/contract.js +2 -0
  126. package/dist/extensions/subagent-ext/contract.js.map +1 -0
  127. package/dist/extensions/subagent-ext/contract.ts +1 -0
  128. package/dist/extensions/subagent-ext/index.ts +347 -0
  129. package/dist/extensions/subagent-shared/contract.d.ts +25 -0
  130. package/dist/extensions/subagent-shared/contract.d.ts.map +1 -0
  131. package/dist/extensions/subagent-shared/contract.js +2 -0
  132. package/dist/extensions/subagent-shared/contract.js.map +1 -0
  133. package/dist/extensions/subagent-shared/contract.ts +28 -0
  134. package/dist/extensions/subagent-shared/index.ts +4 -0
  135. package/dist/extensions/subagent-shared/render.ts +166 -0
  136. package/dist/extensions/subagent-shared/types.ts +35 -0
  137. package/dist/extensions/subagent-shared/utils.ts +112 -0
  138. package/dist/extensions/subagent-v2/contract.d.ts +2 -0
  139. package/dist/extensions/subagent-v2/contract.d.ts.map +1 -0
  140. package/dist/extensions/subagent-v2/contract.js +2 -0
  141. package/dist/extensions/subagent-v2/contract.js.map +1 -0
  142. package/dist/extensions/subagent-v2/contract.ts +1 -0
  143. package/dist/extensions/subagent-v2/index.ts +599 -0
  144. package/dist/extensions/todo-ext/contract.d.ts +27 -0
  145. package/dist/extensions/todo-ext/contract.d.ts.map +1 -0
  146. package/dist/extensions/todo-ext/contract.js +2 -0
  147. package/dist/extensions/todo-ext/contract.js.map +1 -0
  148. package/dist/extensions/todo-ext/contract.ts +30 -0
  149. package/dist/extensions/todo-ext/index.ts +419 -0
  150. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  151. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  152. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  153. package/examples/extensions/sandbox/package-lock.json +2 -2
  154. package/examples/extensions/sandbox/package.json +1 -1
  155. package/examples/extensions/with-deps/package-lock.json +2 -2
  156. package/examples/extensions/with-deps/package.json +1 -1
  157. package/package.json +6 -5
@@ -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
+ });