@dyyz1993/pi-coding-agent 0.74.45 → 0.74.47

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 (33) hide show
  1. package/dist/core/agent-session.d.ts.map +1 -1
  2. package/dist/core/agent-session.js +13 -0
  3. package/dist/core/agent-session.js.map +1 -1
  4. package/dist/extensions/auto-memory/__tests__/extract-result.test.ts +42 -0
  5. package/dist/extensions/auto-memory/__tests__/prefetch-history.test.ts +136 -0
  6. package/dist/extensions/auto-memory/__tests__/prompts.test.ts +29 -0
  7. package/dist/extensions/auto-memory/__tests__/skip-rules.test.ts +366 -0
  8. package/dist/extensions/auto-memory/contract.d.ts +16 -0
  9. package/dist/extensions/auto-memory/contract.d.ts.map +1 -1
  10. package/dist/extensions/auto-memory/contract.js.map +1 -1
  11. package/dist/extensions/auto-memory/contract.ts +16 -0
  12. package/dist/extensions/auto-memory/index.ts +134 -13
  13. package/dist/extensions/auto-memory/prompts.ts +10 -0
  14. package/dist/extensions/auto-memory/skip-rules.ts +2 -0
  15. package/dist/extensions/bash-ext/index.ts +855 -845
  16. package/dist/extensions/claude-hooks-compat/index.ts +12 -7
  17. package/dist/extensions/coordinator/handler.test.ts +388 -123
  18. package/dist/extensions/coordinator/handler.ts +78 -12
  19. package/dist/extensions/coordinator/index.ts +267 -198
  20. package/dist/extensions/coordinator/types.d.ts +16 -0
  21. package/dist/extensions/coordinator/types.d.ts.map +1 -1
  22. package/dist/extensions/coordinator/types.js.map +1 -1
  23. package/dist/extensions/coordinator/types.ts +57 -49
  24. package/dist/extensions/lsp/lsp/index.ts +15 -9
  25. package/dist/extensions/lsp/lsp/lsp-clangd-e2e.test.ts +229 -0
  26. package/dist/extensions/message-bridge/index.ts +14 -11
  27. package/dist/extensions/session-supervisor/index.ts +14 -8
  28. package/dist/extensions/subagent-v2/index.ts +58 -42
  29. package/dist/extensions/todo-ext/index.ts +7 -3
  30. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  31. package/dist/modes/rpc/rpc-mode.js +9 -1
  32. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  33. package/package.json +1 -1
@@ -9,8 +9,10 @@ export interface ProcessManagerApi {
9
9
  delegate_status(sessionId: string): Promise<{ status: SessionStatus }>;
10
10
  delegate_list(): Promise<Array<{ sessionId: string; status: SessionStatus; projectPath: string }>>;
11
11
  delegate_stop(sessionId: string): Promise<boolean>;
12
- delegate_fork(sessionId: string, task: string, title?: string): Promise<{ sessionId: string; status: "started" | "already_running" }>;
12
+ delegate_fork(sessionId: string, task: string, title?: string, projectPath?: string): Promise<{ sessionId: string; status: "started" | "already_running" }>;
13
13
  delegate_compact_status(sessionId: string): Promise<{ isCompacting: boolean; contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } }>;
14
+ delegate_remove(sessionId: string): Promise<boolean>;
15
+ delegate_clear_stopped(): Promise<number>;
14
16
  }
15
17
 
16
18
  export class TaskStore {
@@ -43,6 +45,9 @@ export class TaskStore {
43
45
  }
44
46
 
45
47
  add(task: DelegatedTask): void {
48
+ if (!task.sessionId) {
49
+ throw new Error("[coordinator] cannot add task with empty sessionId");
50
+ }
46
51
  this.tasks.set(task.sessionId, task);
47
52
  this.save();
48
53
  }
@@ -67,15 +72,35 @@ export class TaskStore {
67
72
  return Array.from(this.tasks.values());
68
73
  }
69
74
 
75
+ clearStopped(): number {
76
+ let removed = 0;
77
+ for (const [id, task] of this.tasks) {
78
+ if (task.status === "stopped" || task.status === "completed") {
79
+ this.tasks.delete(id);
80
+ removed++;
81
+ }
82
+ }
83
+ if (removed > 0) this.save();
84
+ return removed;
85
+ }
86
+
70
87
  buildPrompt(): string {
71
- const tasks = this.list();
88
+ const FINISHED_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
89
+ const now = Date.now();
90
+ const tasks = this.list().filter((t) => {
91
+ if ((t.status === "stopped" || t.status === "completed") && t.completedAt && now - t.completedAt > FINISHED_MAX_AGE_MS) {
92
+ return false;
93
+ }
94
+ return true;
95
+ });
72
96
  if (tasks.length === 0) return "";
73
97
 
74
98
  const lines = ["## Delegated Tasks", ""];
75
99
  for (const t of tasks) {
76
100
  const status = t.status === "completed" ? "DONE" : t.status === "stopped" ? "STOPPED" : t.status.toUpperCase();
77
- const compactTag = (t as any).isCompacting ? " COMPACTING" : "";
78
- const ctxTag = (t as any).contextUsage?.percent != null ? ` ctx:${Math.round((t as any).contextUsage.percent)}%` : "";
101
+ const compactTag = (t as Record<string, unknown>).isCompacting ? " COMPACTING" : "";
102
+ const ctxUsage = (t as Record<string, unknown>).contextUsage as { percent: number | null } | undefined;
103
+ const ctxTag = ctxUsage?.percent != null ? ` ctx:${Math.round(ctxUsage.percent)}%` : "";
79
104
  const elapsed = t.completedAt
80
105
  ? `${((t.completedAt - t.dispatchedAt) / 1000).toFixed(1)}s`
81
106
  : `${((Date.now() - t.dispatchedAt) / 1000).toFixed(0)}s elapsed`;
@@ -96,9 +121,19 @@ export function createCoordinatorHandler(
96
121
  getStore: () => TaskStore,
97
122
  ): void {
98
123
  channel.handle("session_delegate", async (params: unknown) => {
99
- const { task, title } = params as { task: string; title?: string };
100
- const projectPath = process.cwd();
101
- const result = await pm.delegate(task, projectPath);
124
+ const { task, title, projectPath: rawProjectPath } = params as { task: string; title?: string; projectPath?: string };
125
+ const projectPath = rawProjectPath || process.cwd();
126
+
127
+ let result: { sessionId: string; status: "started" | "already_running" };
128
+ try {
129
+ result = await pm.delegate(task, projectPath);
130
+ } catch (err) {
131
+ return { __error: err instanceof Error ? err.message : String(err) };
132
+ }
133
+
134
+ if (!result.sessionId) {
135
+ return { __error: "[coordinator] delegate failed: no sessionId returned" };
136
+ }
102
137
 
103
138
  getStore().add({
104
139
  sessionId: result.sessionId,
@@ -126,7 +161,7 @@ export function createCoordinatorHandler(
126
161
  const store = getStore();
127
162
  const task = store.get(targetSessionId);
128
163
  if (task && task.status === "stopped") {
129
- store.update(targetSessionId, { status: "idle" });
164
+ store.update(targetSessionId, { status: "idle", completedAt: undefined });
130
165
  }
131
166
  }
132
167
 
@@ -160,20 +195,51 @@ export function createCoordinatorHandler(
160
195
  const { sessionId } = params as { sessionId: string };
161
196
  const ok = await pm.delegate_stop(sessionId);
162
197
  if (ok) {
163
- getStore().update(sessionId, { status: "stopped" });
198
+ const store = getStore();
199
+ store.update(sessionId, { status: "stopped", completedAt: Date.now() });
164
200
  channel.emit("task_stopped", { sessionId });
165
201
  }
166
202
  return { ok };
167
203
  });
168
204
 
205
+ channel.handle("session_delegate_remove", async (params: unknown) => {
206
+ const { sessionId } = params as { sessionId: string };
207
+ const store = getStore();
208
+ const task = store.get(sessionId);
209
+ if (!task) {
210
+ return { ok: false };
211
+ }
212
+ await pm.delegate_stop(sessionId).catch(() => {});
213
+ store.remove(sessionId);
214
+ return { ok: true };
215
+ });
216
+
217
+ channel.handle("session_delegate_clear_stopped", async () => {
218
+ const store = getStore();
219
+ const removed = store.clearStopped();
220
+ return { removed };
221
+ });
222
+
169
223
  channel.handle("session_delegate_fork", async (params: unknown) => {
170
- const { sessionId, task, title } = params as { sessionId: string; task: string; title?: string };
171
- const result = await pm.delegate_fork(sessionId, task, title);
224
+ const { sessionId, task, title, projectPath: rawProjectPath } = params as { sessionId: string; task: string; title?: string; projectPath?: string };
225
+ const projectPath = rawProjectPath || process.cwd();
226
+
227
+ let result: { sessionId: string; status: "started" | "already_running" };
228
+ try {
229
+ result = await pm.delegate_fork(sessionId, task, title, projectPath);
230
+ } catch (err) {
231
+ return { __error: err instanceof Error ? err.message : String(err) };
232
+ }
233
+
234
+ if (!result.sessionId) {
235
+ return { __error: "[coordinator] fork failed: no sessionId returned" };
236
+ }
237
+
172
238
  getStore().add({
173
239
  sessionId: result.sessionId,
174
240
  title: title || task.slice(0, 60),
175
241
  task,
176
- projectPath: process.cwd(),
242
+ projectPath,
177
243
  dispatchedAt: Date.now(),
178
244
  status: "idle",
179
245
  });
@@ -1,158 +1,178 @@
1
1
  import {
2
- createTypedChannel,
3
- type ExtensionAPI,
2
+ createTypedChannel,
3
+ type ExtensionAPI,
4
4
  } from "@dyyz1993/pi-coding-agent";
5
5
  import { Type } from "typebox";
6
6
  import { COORDINATOR_CHANNEL_NAME, type CoordinatorChannelContract, type SessionStatus } from "./types.js";
7
7
  import { createCoordinatorHandler, TaskStore, type ProcessManagerApi } from "./handler.js";
8
8
 
9
9
  const DelegateParams = Type.Object({
10
- task: Type.String({ description: "Task description to delegate to the background session" }),
11
- title: Type.Optional(Type.String({ description: "Short title for this delegated task" })),
10
+ task: Type.String({ description: "Task description to delegate to the background session" }),
11
+ title: Type.Optional(Type.String({ description: "Short title for this delegated task" })),
12
+ projectPath: Type.Optional(Type.String({ description: "Project directory to run the delegated session in. Defaults to the current working directory." })),
12
13
  });
13
14
 
14
15
  const DelegateSendParams = Type.Object({
15
- targetSessionId: Type.String({ description: "Session ID to send the message to" }),
16
- message: Type.String({ description: "Message content to send" }),
16
+ targetSessionId: Type.String({ description: "Session ID to send the message to" }),
17
+ message: Type.String({ description: "Message content to send" }),
17
18
  });
18
19
 
19
20
  const DelegateStatusParams = Type.Object({
20
- sessionId: Type.String({ description: "Session ID to check status for" }),
21
+ sessionId: Type.String({ description: "Session ID to check status for" }),
21
22
  });
22
23
 
23
24
  const DelegateStopParams = Type.Object({
24
- sessionId: Type.String({ description: "Session ID to stop" }),
25
+ sessionId: Type.String({ description: "Session ID to stop" }),
25
26
  });
26
27
 
27
28
  const DelegateForkParams = Type.Object({
28
- sessionId: Type.String({ description: "Source session ID to fork from" }),
29
- task: Type.String({ description: "Task description for the forked session" }),
30
- title: Type.Optional(Type.String({ description: "Short title for the forked task" })),
29
+ sessionId: Type.String({ description: "Source session ID to fork from" }),
30
+ task: Type.String({ description: "Task description for the forked session" }),
31
+ title: Type.Optional(Type.String({ description: "Short title for the forked task" })),
32
+ projectPath: Type.Optional(Type.String({ description: "Project directory to run the forked session in. Defaults to the current working directory." })),
31
33
  });
32
34
 
33
35
  export default function coordinatorExtension(pi: ExtensionAPI) {
34
- const rawChannel = pi.registerChannel(COORDINATOR_CHANNEL_NAME);
35
-
36
- const { server: serverChannel, client } = createTypedChannel<CoordinatorChannelContract>(rawChannel);
37
-
38
- let currentSessionId = "";
39
- let store: TaskStore | null = null;
40
-
41
- pi.on("session_start", (_event, ctx) => {
42
- currentSessionId = ctx.sessionManager.getSessionId();
43
- store = new TaskStore(ctx.sessionManager.getSessionDir());
44
- });
45
-
46
- const serverProxy: ProcessManagerApi = {
47
- async delegate(task, _projectPath) {
48
- return client.call("session_delegate", { task }) as Promise<{ sessionId: string; status: "started" | "already_running" }>;
49
- },
50
-
51
- async delegate_send(fromSessionId, toSessionId, message) {
52
- return client.call("session_delegate_send", {
53
- targetSessionId: toSessionId,
54
- message,
55
- }) as Promise<{ delivered: boolean; targetStatus: "active" | "started" | "not_found" }>;
56
- },
57
-
58
- async delegate_status(sessionId) {
59
- try {
60
- const result = await client.call("session_delegate_status", { sessionId }) as { task: { status: string } | null };
61
- return result.task ? { status: result.task.status as SessionStatus } : { status: "stopped" as const };
62
- } catch (err) {
63
- console.debug("[coordinator] delegate_status failed:", err instanceof Error ? err.message : err);
64
- return { status: "stopped" as const };
65
- }
66
- },
67
-
68
- async delegate_list() {
69
- try {
70
- const result = await client.call("session_delegate_list", {}) as { tasks: Array<{ sessionId: string; status: SessionStatus; projectPath: string }> };
71
- return result.tasks;
72
- } catch (err) {
73
- console.debug("[coordinator] delegate_list failed:", err instanceof Error ? err.message : err);
74
- return [];
75
- }
76
- },
77
-
78
- async delegate_stop(sessionId) {
79
- try {
80
- const result = await client.call("session_delegate_stop", { sessionId }) as { ok: boolean };
81
- return result.ok;
82
- } catch (err) {
83
- console.debug("[coordinator] delegate_stop failed:", err instanceof Error ? err.message : err);
84
- return false;
85
- }
86
- },
87
-
88
- async delegate_fork(sessionId, task, title) {
89
- return client.call("session_delegate_fork", { sessionId, task, title }) as Promise<{ sessionId: string; status: "started" | "already_running" }>;
90
- },
91
-
92
- async delegate_compact_status(sessionId: string) {
93
- try {
94
- const result = await client.call("session_delegate_status", { sessionId }) as { isCompacting?: boolean; contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null } };
95
- return {
96
- isCompacting: result.isCompacting ?? false,
97
- contextUsage: result.contextUsage ?? { tokens: null as number | null, contextWindow: 0, percent: null as number | null },
98
- };
99
- } catch (err) {
100
- console.debug("[coordinator] delegate_compact_status failed:", err instanceof Error ? err.message : err);
101
- return { isCompacting: false, contextUsage: { tokens: null as number | null, contextWindow: 0, percent: null as number | null } };
102
- }
103
- },
104
- };
105
-
106
- createCoordinatorHandler(
107
- serverChannel,
108
- serverProxy,
109
- () => currentSessionId,
110
- () => store ?? new TaskStore("/tmp/coordinator-fallback"),
111
- );
112
-
113
- pi.on("context", (event, _ctx) => {
114
- if (!store) return;
115
- const prompt = store.buildPrompt();
116
- if (prompt) {
117
- event.messages.push({
118
- role: "user",
119
- content: [{ type: "text", text: prompt }],
120
- timestamp: Date.now(),
121
- });
122
- }
123
- });
36
+ const rawChannel = pi.registerChannel(COORDINATOR_CHANNEL_NAME);
37
+
38
+ const { server: serverChannel, client } = createTypedChannel<CoordinatorChannelContract>(rawChannel);
39
+
40
+ let currentSessionId = "";
41
+ let store: TaskStore | null = null;
42
+
43
+ pi.on("session_start", (_event, ctx) => {
44
+ currentSessionId = ctx.sessionManager.getSessionId();
45
+ store = new TaskStore(ctx.sessionManager.getSessionDir());
46
+ });
47
+
48
+ const serverProxy: ProcessManagerApi = {
49
+ async delegate(task, projectPath) {
50
+ return client.call("session_delegate", { task, projectPath }) as Promise<{ sessionId: string; status: "started" | "already_running" }>;
51
+ },
52
+
53
+ async delegate_send(fromSessionId, toSessionId, message) {
54
+ return client.call("session_delegate_send", {
55
+ targetSessionId: toSessionId,
56
+ message,
57
+ }) as Promise<{ delivered: boolean; targetStatus: "active" | "started" | "not_found" }>;
58
+ },
59
+
60
+ async delegate_status(sessionId) {
61
+ try {
62
+ const result = await client.call("session_delegate_status", { sessionId }) as { task: { status: string } | null };
63
+ return result.task ? { status: result.task.status as SessionStatus } : { status: "stopped" as const };
64
+ } catch (err) {
65
+ console.debug("[coordinator] delegate_status failed:", err instanceof Error ? err.message : err);
66
+ return { status: "stopped" as const };
67
+ }
68
+ },
69
+
70
+ async delegate_list() {
71
+ try {
72
+ const result = await client.call("session_delegate_list", {}) as { tasks: Array<{ sessionId: string; status: SessionStatus; projectPath: string }> };
73
+ return result.tasks;
74
+ } catch (err) {
75
+ console.debug("[coordinator] delegate_list failed:", err instanceof Error ? err.message : err);
76
+ return [];
77
+ }
78
+ },
79
+
80
+ async delegate_stop(sessionId) {
81
+ try {
82
+ const result = await client.call("session_delegate_stop", { sessionId }) as { ok: boolean };
83
+ return result.ok;
84
+ } catch (err) {
85
+ console.debug("[coordinator] delegate_stop failed:", err instanceof Error ? err.message : err);
86
+ return false;
87
+ }
88
+ },
89
+
90
+ async delegate_fork(sessionId, task, title, projectPath) {
91
+ return client.call("session_delegate_fork", { sessionId, task, title, projectPath }) as Promise<{ sessionId: string; status: "started" | "already_running" }>;
92
+ },
93
+
94
+ async delegate_compact_status(sessionId: string) {
95
+ try {
96
+ const result = await client.call("session_delegate_status", { sessionId }) as { isCompacting?: boolean; contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null } };
97
+ return {
98
+ isCompacting: result.isCompacting ?? false,
99
+ contextUsage: result.contextUsage ?? { tokens: null as number | null, contextWindow: 0, percent: null as number | null },
100
+ };
101
+ } catch (err) {
102
+ console.debug("[coordinator] delegate_compact_status failed:", err instanceof Error ? err.message : err);
103
+ return { isCompacting: false, contextUsage: { tokens: null as number | null, contextWindow: 0, percent: null as number | null } };
104
+ }
105
+ },
106
+
107
+ async delegate_remove(sessionId: string) {
108
+ try {
109
+ const result = await client.call("session_delegate_remove", { sessionId }) as { ok: boolean };
110
+ return result.ok;
111
+ } catch (err) {
112
+ console.debug("[coordinator] delegate_remove failed:", err instanceof Error ? err.message : err);
113
+ return false;
114
+ }
115
+ },
116
+
117
+ async delegate_clear_stopped() {
118
+ try {
119
+ const result = await client.call("session_delegate_clear_stopped", {}) as { removed: number };
120
+ return result.removed;
121
+ } catch (err) {
122
+ console.debug("[coordinator] delegate_clear_stopped failed:", err instanceof Error ? err.message : err);
123
+ return 0;
124
+ }
125
+ },
126
+ };
127
+
128
+ createCoordinatorHandler(
129
+ serverChannel,
130
+ serverProxy,
131
+ () => currentSessionId,
132
+ () => store ?? new TaskStore("/tmp/coordinator-fallback"),
133
+ );
134
+
135
+ pi.on("context", (event, _ctx) => {
136
+ if (!store) return;
137
+ const prompt = store.buildPrompt();
138
+ if (prompt) {
139
+ event.messages.push({
140
+ role: "user",
141
+ content: [{ type: "text", text: prompt }],
142
+ timestamp: Date.now(),
143
+ });
144
+ }
145
+ });
124
146
 
125
147
  pi.registerTool({
126
148
  name: "session_delegate",
127
149
  label: "Session Delegate",
128
150
  description: [
129
151
  "Delegate a task to a background pi session.",
152
+ "Optionally specify a projectPath to run the session in a specific project directory.",
130
153
  "Returns a sessionId for communication via session_delegate_send.",
131
154
  "The delegated session can message back using its own coordinator channel.",
132
155
  "The delegate session is automatically restarted if inactive when receiving messages.",
133
156
  ].join(" "),
134
- parameters: DelegateParams,
135
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
136
- const sid = currentSessionId || ctx.sessionManager.getSessionId();
137
- const result = await serverProxy.delegate(params.task, ctx.cwd);
138
-
139
- if (store) {
140
- store.add({
141
- sessionId: result.sessionId,
142
- title: params.title || params.task.slice(0, 60),
143
- task: params.task,
144
- projectPath: ctx.cwd,
145
- dispatchedAt: Date.now(),
146
- status: "idle",
147
- });
148
- }
149
-
150
- return {
151
- content: [{ type: "text" as const, text: `Delegated task to session ${result.sessionId} (status: ${result.status}). Use session_delegate_send to communicate.` }],
152
- details: { ...result, dispatchedBy: sid },
153
- };
154
- },
155
- });
157
+ parameters: DelegateParams,
158
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
159
+ const sid = currentSessionId || ctx.sessionManager.getSessionId();
160
+ const projectPath = params.projectPath || ctx.cwd;
161
+ const result = await serverProxy.delegate(params.task, projectPath);
162
+
163
+ if (!result.sessionId) {
164
+ return {
165
+ content: [{ type: "text" as const, text: `Failed to delegate task: no sessionId returned.` }],
166
+ details: { error: "no sessionId" },
167
+ };
168
+ }
169
+
170
+ return {
171
+ content: [{ type: "text" as const, text: `Delegated task to session ${result.sessionId} (status: ${result.status}, cwd: ${projectPath}). Use session_delegate_send to communicate.` }],
172
+ details: { ...result, dispatchedBy: sid, projectPath },
173
+ };
174
+ },
175
+ });
156
176
 
157
177
  pi.registerTool({
158
178
  name: "session_delegate_send",
@@ -183,79 +203,128 @@ export default function coordinatorExtension(pi: ExtensionAPI) {
183
203
  },
184
204
  });
185
205
 
186
- pi.registerTool({
187
- name: "session_delegate_status",
188
- label: "Session Delegate Status",
189
- description: "Check the status of a delegated task session.",
190
- parameters: DelegateStatusParams,
191
- async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
192
- const task = store?.get(params.sessionId);
193
- if (task) {
194
- const status = task.status === "completed" ? "DONE" : task.status.toUpperCase();
195
- return {
196
- content: [{ type: "text" as const, text: `Task "${task.title}" (${params.sessionId}): ${status}` }],
197
- details: { task },
198
- };
199
- }
200
- const remote = await serverProxy.delegate_status(params.sessionId);
201
- return {
202
- content: [{ type: "text" as const, text: `Session ${params.sessionId} status: ${remote.status}` }],
203
- details: { task: null },
204
- };
205
- },
206
- });
207
-
208
- pi.registerTool({
209
- name: "session_delegate_stop",
210
- label: "Session Delegate Stop",
211
- description: "Stop a delegated task session.",
212
- parameters: DelegateStopParams,
213
- async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
214
- const ok = await serverProxy.delegate_stop(params.sessionId);
215
- if (ok && store) {
216
- store.update(params.sessionId, { status: "stopped" });
217
- }
218
- return {
219
- content: [{ type: "text" as const, text: ok ? `Session ${params.sessionId} stopped.` : `Session ${params.sessionId} not found or already stopped.` }],
220
- details: { ok },
221
- };
222
- },
223
- });
224
-
225
- pi.registerTool({
226
- name: "session_delegate_fork",
227
- label: "Session Delegate Fork",
228
- description: [
229
- "Fork an existing session and delegate a new task to the forked session.",
230
- "The forked session starts with a copy of the source session's conversation history.",
231
- "The original session continues running unchanged.",
232
- ].join(" "),
233
- parameters: DelegateForkParams,
234
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
235
- const sid = currentSessionId || ctx.sessionManager.getSessionId();
236
- const result = await serverProxy.delegate_fork(params.sessionId, params.task, params.title);
237
- if (store) {
238
- store.add({
239
- sessionId: result.sessionId,
240
- title: params.title || params.task.slice(0, 60),
241
- task: params.task,
242
- projectPath: ctx.cwd,
243
- dispatchedAt: Date.now(),
244
- status: "idle",
245
- });
246
- }
247
- return {
248
- content: [{ type: "text" as const, text: `Forked session ${params.sessionId} ${result.sessionId} (status: ${result.status}). Task: ${params.task}` }],
249
- details: { ...result, forkedFrom: params.sessionId, dispatchedBy: sid },
250
- };
251
- },
252
- });
253
-
254
- client.on("message_received", (data: unknown) => {
255
- const d = data as { fromSessionId: string; message: string };
256
- pi.sendUserMessage(
257
- `[Coordinator] Message from session ${d.fromSessionId}:\n${d.message}`,
258
- { deliverAs: "followUp" },
259
- );
260
- });
206
+ pi.registerTool({
207
+ name: "session_delegate_status",
208
+ label: "Session Delegate Status",
209
+ description: "Check the status of a delegated task session.",
210
+ parameters: DelegateStatusParams,
211
+ async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
212
+ const task = store?.get(params.sessionId);
213
+ if (task) {
214
+ const status = task.status === "completed" ? "DONE" : task.status.toUpperCase();
215
+ return {
216
+ content: [{ type: "text" as const, text: `Task "${task.title}" (${params.sessionId}): ${status}` }],
217
+ details: { task },
218
+ };
219
+ }
220
+ const remote = await serverProxy.delegate_status(params.sessionId);
221
+ return {
222
+ content: [{ type: "text" as const, text: `Session ${params.sessionId} status: ${remote.status}` }],
223
+ details: { task: null },
224
+ };
225
+ },
226
+ });
227
+
228
+ pi.registerTool({
229
+ name: "session_delegate_stop",
230
+ label: "Session Delegate Stop",
231
+ description: "Stop a delegated task session.",
232
+ parameters: DelegateStopParams,
233
+ async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
234
+ const ok = await serverProxy.delegate_stop(params.sessionId);
235
+ return {
236
+ content: [{ type: "text" as const, text: ok ? `Session ${params.sessionId} stopped.` : `Session ${params.sessionId} not found or already stopped.` }],
237
+ details: { ok },
238
+ };
239
+ },
240
+ });
241
+
242
+ pi.registerTool({
243
+ name: "session_delegate_fork",
244
+ label: "Session Delegate Fork",
245
+ description: [
246
+ "Fork an existing session and delegate a new task to the forked session.",
247
+ "The forked session starts with a copy of the source session's conversation history.",
248
+ "Optionally specify a projectPath to run the forked session in a specific project directory.",
249
+ "The original session continues running unchanged.",
250
+ ].join(" "),
251
+ parameters: DelegateForkParams,
252
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
253
+ const sid = currentSessionId || ctx.sessionManager.getSessionId();
254
+ const projectPath = params.projectPath || ctx.cwd;
255
+ const result = await serverProxy.delegate_fork(params.sessionId, params.task, params.title, projectPath);
256
+ return {
257
+ content: [{ type: "text" as const, text: `Forked session ${params.sessionId} → ${result.sessionId} (status: ${result.status}, cwd: ${projectPath}). Task: ${params.task}` }],
258
+ details: { ...result, forkedFrom: params.sessionId, dispatchedBy: sid, projectPath },
259
+ };
260
+ },
261
+ });
262
+
263
+ pi.registerTool({
264
+ name: "session_delegate_remove",
265
+ label: "Session Delegate Remove",
266
+ description: [
267
+ "Remove a delegated task from the task list.",
268
+ "Stops the session if still running, then removes the task entry.",
269
+ "Use this to clean up completed, stopped, or zombie tasks.",
270
+ ].join(" "),
271
+ parameters: DelegateStatusParams,
272
+ async execute(toolCallId, params, _signal, _onUpdate, _ctx) {
273
+ const ok = await serverProxy.delegate_remove(params.sessionId);
274
+ return {
275
+ content: [{ type: "text" as const, text: ok ? `Task ${params.sessionId} removed.` : `Task ${params.sessionId} not found.` }],
276
+ details: { ok },
277
+ };
278
+ },
279
+ });
280
+
281
+ pi.registerTool({
282
+ name: "session_delegate_clear_stopped",
283
+ label: "Session Delegate Clear Stopped",
284
+ description: [
285
+ "Remove all stopped and completed tasks from the task list.",
286
+ "Use this to clean up accumulated zombie tasks.",
287
+ ].join(" "),
288
+ parameters: Type.Object({}),
289
+ async execute(toolCallId, _params, _signal, _onUpdate, _ctx) {
290
+ const removed = await serverProxy.delegate_clear_stopped();
291
+ return {
292
+ content: [{ type: "text" as const, text: `Cleared ${removed} stopped/completed task(s).` }],
293
+ details: { removed },
294
+ };
295
+ },
296
+ });
297
+
298
+ client.on("message_received", (data: unknown) => {
299
+ const d = data as { fromSessionId: string; message: string };
300
+ // Skip messages from sessions that have been stopped
301
+ const task = store?.get(d.fromSessionId);
302
+ if (task?.status === "stopped") return;
303
+
304
+ // Detect completion signals from delegated sessions
305
+ if (store && task) {
306
+ const lowerMsg = d.message.toLowerCase();
307
+ const isCompletion = lowerMsg.includes("[completed]") || lowerMsg.includes("[done]") || lowerMsg.includes("task completed");
308
+ if (isCompletion) {
309
+ store.update(d.fromSessionId, { status: "completed", completedAt: Date.now(), result: d.message });
310
+ } else if (task.status !== "completed") {
311
+ store.update(d.fromSessionId, { status: "streaming" });
312
+ }
313
+ }
314
+
315
+ try {
316
+ pi.sendUserMessage(
317
+ `[Coordinator] Message from session ${d.fromSessionId}:\n${d.message}`,
318
+ { deliverAs: "followUp" },
319
+ );
320
+ } catch (err) {
321
+ // Silently ignore stale-ctx errors: the extension runtime may have been
322
+ // invalidated by a concurrent session replacement or reload. The new
323
+ // runtime's handler will take over.
324
+ if (err instanceof Error && err.message.includes("stale")) {
325
+ return;
326
+ }
327
+ throw err;
328
+ }
329
+ });
261
330
  }