@buihongduc132/pi-acp-agents 0.3.1

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 (43) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +21 -0
  3. package/README.md +359 -0
  4. package/index.ts +1521 -0
  5. package/package.json +103 -0
  6. package/skills/pi-acp-agents/SKILL.md +112 -0
  7. package/src/acp-widget.ts +379 -0
  8. package/src/adapter-factory.ts +55 -0
  9. package/src/adapters/acpx.ts +215 -0
  10. package/src/adapters/base.ts +117 -0
  11. package/src/adapters/codex.ts +77 -0
  12. package/src/adapters/custom.ts +14 -0
  13. package/src/adapters/gemini.ts +66 -0
  14. package/src/adapters/opencode.ts +101 -0
  15. package/src/config/config.ts +312 -0
  16. package/src/config/types.ts +203 -0
  17. package/src/coordination/alias-resolver.ts +208 -0
  18. package/src/coordination/coordinator.ts +266 -0
  19. package/src/coordination/worker-dispatcher.ts +191 -0
  20. package/src/core/async-executor.ts +149 -0
  21. package/src/core/circuit-breaker.ts +254 -0
  22. package/src/core/client.ts +661 -0
  23. package/src/core/health-monitor.ts +200 -0
  24. package/src/core/protocol-validator.ts +259 -0
  25. package/src/core/session-lifecycle.ts +46 -0
  26. package/src/core/session-manager.ts +64 -0
  27. package/src/extension-safety.ts +200 -0
  28. package/src/logger.ts +92 -0
  29. package/src/management/event-log.ts +31 -0
  30. package/src/management/governance-store.ts +123 -0
  31. package/src/management/heartbeat-parser.ts +92 -0
  32. package/src/management/mailbox-manager.ts +95 -0
  33. package/src/management/runtime-paths.ts +34 -0
  34. package/src/management/safe-mkdir.ts +78 -0
  35. package/src/management/session-archive-store.ts +136 -0
  36. package/src/management/session-name-store.ts +88 -0
  37. package/src/management/task-store.ts +260 -0
  38. package/src/management/worker-store.ts +164 -0
  39. package/src/public-api.ts +72 -0
  40. package/src/settings/agent-config-tui.ts +456 -0
  41. package/src/settings/agents-command.ts +138 -0
  42. package/src/settings/config.ts +201 -0
  43. package/src/settings/configure-tui.ts +135 -0
package/index.ts ADDED
@@ -0,0 +1,1521 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import type { AgentToolResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import { Type } from "typebox";
6
+ import { type AcpWidgetState, createAcpWidget } from "./src/acp-widget.js";
7
+ import { createAdapter } from "./src/adapter-factory.js";
8
+ import { loadConfig } from "./src/config/config.js";
9
+ import type { AcpArchivedSessionMetadata, AcpConfig, AcpPromptResult, AcpSessionHandle, AcpWorkerStatus } from "./src/config/types.js";
10
+ import { AgentCoordinator } from "./src/coordination/coordinator.js";
11
+ import { WorkerDispatcher, type WorkerDispatcherDeps } from "./src/coordination/worker-dispatcher.js";
12
+ import { AcpCircuitBreaker } from "./src/core/circuit-breaker.js";
13
+ import { HealthMonitor } from "./src/core/health-monitor.js";
14
+ import { getSessionAutoCloseReason } from "./src/core/session-lifecycle.js";
15
+ import { SessionManager } from "./src/core/session-manager.js";
16
+ import { createFileLogger } from "./src/logger.js";
17
+ import { AcpEventLog } from "./src/management/event-log.js";
18
+ import { GovernanceStore } from "./src/management/governance-store.js";
19
+ import { consumeHeartbeat } from "./src/management/heartbeat-parser.js";
20
+ import { MailboxManager } from "./src/management/mailbox-manager.js";
21
+ import { AcpTaskStore, type AcpTaskStatus } from "./src/management/task-store.js";
22
+ import { WorkerStore } from "./src/management/worker-store.js";
23
+ import { SessionArchiveStore } from "./src/management/session-archive-store.js";
24
+ import { SessionNameStore } from "./src/management/session-name-store.js";
25
+ import { ensureRuntimeDir } from "./src/management/runtime-paths.js";
26
+ import { loadSettings, isToolEnabled, type AcpToolSettings } from "./src/settings/config.js";
27
+ import { configureToolSettings } from "./src/settings/configure-tui.js";
28
+
29
+ function textContent(text: string): { type: "text"; text: string } {
30
+ return { type: "text", text };
31
+ }
32
+
33
+ function formatJson(value: unknown): string {
34
+ return JSON.stringify(value, null, 2);
35
+ }
36
+
37
+ /** Wrap a promise with a timeout. Throws on expiry with descriptive message. */
38
+ function withTimeoutMs<T>(promise: Promise<T>, ms: number | undefined, label: string): Promise<T> {
39
+ const effectiveMs = ms ?? 300_000;
40
+ if (effectiveMs <= 0) return promise;
41
+ let timer: ReturnType<typeof setTimeout>;
42
+ return Promise.race([
43
+ promise,
44
+ new Promise<never>((_, reject) => {
45
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${effectiveMs}ms`)), effectiveMs);
46
+ }),
47
+ ]).finally(() => clearTimeout(timer));
48
+ }
49
+
50
+ function requireString(value: unknown, label: string): string {
51
+ if (typeof value !== "string" || value.trim() === "") {
52
+ throw new Error(`${label} is required`);
53
+ }
54
+ return value.trim();
55
+ }
56
+
57
+ function normalizeOptionalSessionName(value: unknown): string | undefined {
58
+ if (value === undefined) return undefined;
59
+ return requireString(value, "session_name");
60
+ }
61
+
62
+ export default function (pi: ExtensionAPI) {
63
+ const sessionMgr = new SessionManager();
64
+ const activeAdapters = new Map<string, ReturnType<typeof createAdapter>>();
65
+ const busySessions = new Map<string, boolean>();
66
+ const workerSessionMap = new Map<string, string>(); // sessionId → workerName for heartbeat consumer
67
+ const DELEGATION_HISTORY_CAP = 20;
68
+ const widgetActivity = {
69
+ activeDelegations: 0,
70
+ activeBroadcasts: 0,
71
+ activeCompares: 0,
72
+ delegations: [] as Array<{ id: string; agentName: string; phase: string; startedAt: Date; lastActivityAt: Date; text?: string }>,
73
+ delegationHistory: [] as Array<{ agentName: string; status: "completed" | "error"; error?: string; sessionId?: string; finishedAt: Date }>,
74
+ lastError: undefined as string | undefined,
75
+ };
76
+
77
+ let config: AcpConfig = loadConfig();
78
+ let widgetRegistered = false;
79
+
80
+ const logsDir = config.logsDir ?? join(homedir(), ".pi", "acp-agents", "logs");
81
+ const logger = createFileLogger(logsDir);
82
+ const runtimePaths = ensureRuntimeDir(config.runtimeDir);
83
+ const eventLog = new AcpEventLog(runtimePaths.rootDir);
84
+ const taskStore = new AcpTaskStore(runtimePaths.rootDir);
85
+ const workerStore = new WorkerStore(runtimePaths.rootDir);
86
+ const mailboxManager = new MailboxManager(runtimePaths.rootDir);
87
+ const governanceStore = new GovernanceStore(runtimePaths.rootDir);
88
+ const sessionArchiveStore = new SessionArchiveStore(runtimePaths.rootDir);
89
+ const sessionNameStore = new SessionNameStore(runtimePaths.rootDir);
90
+ governanceStore.setModelPolicy(config.modelPolicy ?? {});
91
+
92
+ const cb = new AcpCircuitBreaker(
93
+ config.circuitBreakerMaxFailures ?? 3,
94
+ config.circuitBreakerResetMs ?? 60_000,
95
+ config.stallTimeoutMs ?? 300_000,
96
+ );
97
+
98
+ function archiveSession(handle: AcpSessionHandle): AcpArchivedSessionMetadata {
99
+ return sessionArchiveStore.upsert(handle);
100
+ }
101
+
102
+ async function closeSession(handle: AcpSessionHandle, closeReason: string, autoClosed = false): Promise<void> {
103
+ handle.autoClosed = autoClosed;
104
+ handle.closeReason = closeReason;
105
+ archiveSession(handle);
106
+ await sessionMgr.remove(handle.sessionId);
107
+ activeAdapters.delete(handle.sessionId);
108
+ busySessions.delete(handle.sessionId);
109
+ eventLog.append("session_closed", { sessionId: handle.sessionId, agentName: handle.agentName, closeReason, autoClosed });
110
+ }
111
+
112
+ function markPromptLifecycle(handle: AcpSessionHandle, promptResult: AcpPromptResult): void {
113
+ const now = new Date();
114
+ handle.lastActivityAt = now;
115
+ handle.lastResponseAt = now;
116
+ handle.completedAt = now;
117
+ handle.autoClosed = false;
118
+ handle.closeReason = undefined;
119
+ handle.accumulatedText += promptResult.text;
120
+ archiveSession(handle);
121
+ }
122
+
123
+ function getArchivedSession(sessionId: string): AcpArchivedSessionMetadata | undefined {
124
+ return sessionArchiveStore.get(sessionId);
125
+ }
126
+
127
+ function findLiveSessionByName(sessionName: string): AcpSessionHandle | undefined {
128
+ return sessionMgr.list().find((session) => session.sessionName === sessionName);
129
+ }
130
+
131
+ function findArchivedSessionByName(sessionName: string): AcpArchivedSessionMetadata | undefined {
132
+ const resolvedSessionId = sessionNameStore.getSessionId(sessionName);
133
+ if (resolvedSessionId) {
134
+ return getArchivedSession(resolvedSessionId);
135
+ }
136
+ const live = findLiveSessionByName(sessionName);
137
+ if (live) return live;
138
+ return undefined;
139
+ }
140
+
141
+ function getSessionMetadata(sessionId: string): AcpArchivedSessionMetadata | AcpSessionHandle | undefined {
142
+ return sessionMgr.get(sessionId) ?? getArchivedSession(sessionId);
143
+ }
144
+
145
+ function resolveSessionTarget(params: { session_id?: string; session_name?: string; agent?: string }): {
146
+ sessionId?: string;
147
+ sessionName?: string;
148
+ metadata?: AcpArchivedSessionMetadata | AcpSessionHandle;
149
+ } {
150
+ const sessionId = params.session_id?.trim();
151
+ const sessionName = normalizeOptionalSessionName(params.session_name);
152
+ const byId = sessionId ? getSessionMetadata(sessionId) : undefined;
153
+ const mappedSessionId = sessionName ? sessionNameStore.getSessionId(sessionName) : undefined;
154
+ const byName = sessionName ? findLiveSessionByName(sessionName) ?? (mappedSessionId ? getSessionMetadata(mappedSessionId) : undefined) : undefined;
155
+ if (sessionId && sessionName && byName && byName.sessionId !== sessionId) {
156
+ if (!byId) {
157
+ throw new Error(`session_id "${sessionId}" was not found and does not match resolved session_name "${sessionName}".`);
158
+ }
159
+ throw new Error(`session_id "${sessionId}" does not match session_name "${sessionName}".`);
160
+ }
161
+
162
+ // Agent validation: cross-check params.agent against archive metadata
163
+ const resolvedTarget = byId ?? byName;
164
+ if (resolvedTarget && params.agent) {
165
+ const archivedAgent = resolvedTarget.agentName;
166
+ if (archivedAgent && archivedAgent !== params.agent) {
167
+ const targetLabel = sessionName ?? sessionId ?? "unknown";
168
+ throw new Error(
169
+ `Session "${targetLabel}" was created with agent "${archivedAgent}". ` +
170
+ `Cannot resume with agent "${params.agent}". ` +
171
+ `Omit the agent parameter to resume with the original agent.`,
172
+ );
173
+ }
174
+ }
175
+
176
+ if (sessionId) {
177
+ return { sessionId, sessionName: byId?.sessionName ?? sessionNameStore.getName(sessionId) ?? sessionName, metadata: byId };
178
+ }
179
+ if (byName) {
180
+ return { sessionId: byName.sessionId, sessionName, metadata: byName };
181
+ }
182
+ return { sessionId, sessionName, metadata: undefined };
183
+ }
184
+
185
+ const heartbeatDeps = {
186
+ resolveWorkerName: (sid: string) => workerSessionMap.get(sid),
187
+ touch: (name: string, deltas?: { tokenDelta?: number; toolCallDelta?: number }) =>
188
+ workerStore.touch(name, deltas),
189
+ logParseError: (entry: { workerName: string; sessionId: string; error: string }) =>
190
+ eventLog.append("heartbeat_parse_error", entry),
191
+ };
192
+
193
+ /**
194
+ * Heartbeat consumer — processes ACP session/update events for worker-bound sessions.
195
+ * Extracts token/tool deltas and calls WorkerStore.touch().
196
+ * (Tasks 2.1 + 2.2: defensive parsing + error logging in consumeHeartbeat)
197
+ */
198
+ function heartbeatConsumer(sessionId: string, update: import("@agentclientprotocol/sdk").SessionUpdate): void {
199
+ consumeHeartbeat(heartbeatDeps, sessionId, update);
200
+ }
201
+
202
+ const monitor = new HealthMonitor({
203
+ intervalMs: config.healthCheckIntervalMs ?? 5_000,
204
+ staleTimeoutMs: config.staleTimeoutMs ?? 600_000,
205
+ needsAttentionMs: config.needsAttentionMs ?? 120_000,
206
+ autoInterruptMs: config.autoInterruptMs ?? 300_000,
207
+ interruptGraceMs: config.interruptGraceMs ?? 10_000,
208
+ async onNeedsAttention(sessionId: string) {
209
+ // UI notification only — don't interrupt
210
+ const handle = sessionMgr.get(sessionId);
211
+ if (handle) {
212
+ eventLog.append('session_needs_attention', { sessionId, agentName: handle.agentName });
213
+ }
214
+ },
215
+ async onStale(sessionId: string) {
216
+ const handle = sessionMgr.get(sessionId);
217
+ if (!handle) return;
218
+
219
+ // Check if this is a prompt stall (activity-based) vs idle stale
220
+ const isPromptStall = handle.isPrompting === true;
221
+ const closeReason = getSessionAutoCloseReason(handle, config.staleTimeoutMs ?? 600_000);
222
+
223
+ if (isPromptStall) {
224
+ // Escalation: cancel → grace → kill
225
+ eventLog.append('session_stalled_prompt', { sessionId, agentName: handle.agentName });
226
+ try {
227
+ const adapter = activeAdapters.get(sessionId);
228
+ if (adapter) {
229
+ await adapter.cancel();
230
+ await new Promise(resolve => setTimeout(resolve, config.interruptGraceMs ?? 10_000));
231
+ }
232
+ } catch (e) {
233
+ // cancel() can throw if process already dead — proceed to kill
234
+ logger.debug("cancel during stall interrupt failed", e);
235
+ }
236
+ handle.isPrompting = false;
237
+ monitor.markPromptEnd(sessionId);
238
+ await closeSession(handle, 'stalled-prompt-auto-interrupt', true);
239
+ eventLog.append('session_stalled_prompt_killed', { sessionId });
240
+ } else if (closeReason) {
241
+ // Existing idle/disposed handling
242
+ logger.info('session stale, disposing', { sessionId, closeReason });
243
+ await closeSession(handle, closeReason, true);
244
+ eventLog.append('session_stale', { sessionId, closeReason });
245
+ }
246
+ },
247
+ });
248
+ monitor.start();
249
+
250
+ // ── Worker Dispatcher (tasks 3.1-3.8) ──
251
+ let workerDispatcher: WorkerDispatcher | null = null;
252
+ const workerAutoClaim = config.workerAutoClaim ?? true;
253
+
254
+ if (workerAutoClaim) {
255
+ const dispatchDeps: WorkerDispatcherDeps = {
256
+ workerStore: workerStore as unknown as WorkerDispatcherDeps["workerStore"],
257
+ taskStore: taskStore as unknown as WorkerDispatcherDeps["taskStore"],
258
+ eventLog,
259
+ busySessions,
260
+ getSessionIdForWorker: (workerName: string) => {
261
+ const worker = workerStore.get(workerName);
262
+ if (!worker) return undefined;
263
+ // Find sessionId from the workerSessionMap by reversing the lookup
264
+ for (const [sid, name] of workerSessionMap.entries()) {
265
+ if (name === workerName) return sid;
266
+ }
267
+ return undefined;
268
+ },
269
+ dispatchTask: async (sessionId: string, prompt: string) => {
270
+ const adapter = activeAdapters.get(sessionId);
271
+ if (!adapter) return { ok: false, error: "No adapter for session" };
272
+ try {
273
+ busySessions.set(sessionId, true);
274
+ const result = (await withTimeoutMs(adapter.prompt(prompt), config.toolTimeouts?.prompt ?? config.stallTimeoutMs, `dispatch:${sessionId}`)) as AcpPromptResult;
275
+ return { ok: true, value: result.text };
276
+ } catch (err) {
277
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
278
+ } finally {
279
+ busySessions.delete(sessionId);
280
+ }
281
+ },
282
+ };
283
+ workerDispatcher = new WorkerDispatcher(
284
+ dispatchDeps,
285
+ config.workerClaimIntervalMs ?? 5000,
286
+ );
287
+ workerDispatcher.start();
288
+ }
289
+
290
+ const getWidgetState = (): AcpWidgetState => ({
291
+ sessions: sessionMgr.list().map((s) => ({
292
+ sessionId: s.sessionId,
293
+ sessionName: s.sessionName,
294
+ agentName: s.agentName,
295
+ cwd: s.cwd,
296
+ status: s.disposed
297
+ ? "error"
298
+ : busySessions.get(s.sessionId)
299
+ ? "active"
300
+ : getSessionAutoCloseReason(s, config.staleTimeoutMs ?? 600_000)
301
+ ? "stale"
302
+ : "idle",
303
+ lastActivityAt: s.lastActivityAt,
304
+ createdAt: s.createdAt,
305
+ model: s.model,
306
+ })),
307
+ circuitBreakerState: cb.state as "closed" | "open" | "half-open",
308
+ configuredAgentNames: Object.keys(config.agent_servers),
309
+ configuredAliases: config.agent_aliases ? Object.keys(config.agent_aliases) : [],
310
+ defaultAgent: config.defaultAgent,
311
+ activity: { ...widgetActivity },
312
+ workers: workerStore.list().map((w) => {
313
+ const now = Date.now();
314
+ const ageSec = Math.floor((now - new Date(w.lastActivityAt).getTime()) / 1000);
315
+ const derived = deriveWorkerStatus(w);
316
+ return {
317
+ name: w.name,
318
+ agentName: w.agentName,
319
+ status: derived.status,
320
+ tokenCountTotal: w.tokenCountTotal ?? 0,
321
+ toolCallCount: w.toolCallCount ?? 0,
322
+ ageSeconds: ageSec,
323
+ stale: derived.stale || isWorkerStale(w),
324
+ currentTaskId: w.currentTaskId,
325
+ };
326
+ }),
327
+ });
328
+
329
+ const widgetFactory = createAcpWidget({ getState: getWidgetState });
330
+
331
+ function ensureWidget(ctx: { ui: { setWidget: Function } }) {
332
+ if (widgetRegistered) return;
333
+ try {
334
+ ctx.ui.setWidget("pi-acp-agents", widgetFactory);
335
+ widgetRegistered = true;
336
+ } catch (e) {
337
+ // Widget registration may fail if UI not ready
338
+ logger.debug("widget registration failed", e);
339
+ }
340
+ }
341
+
342
+ function refreshWidget(ctx: { ui: { setWidget: Function } }) {
343
+ if (!widgetRegistered) {
344
+ ensureWidget(ctx);
345
+ return;
346
+ }
347
+ try {
348
+ ctx.ui.setWidget("pi-acp-agents", widgetFactory);
349
+ } catch (e) {
350
+ // Widget refresh may fail if UI not ready
351
+ logger.debug("widget refresh failed", e);
352
+ }
353
+ }
354
+
355
+ function setWidgetError(error: string | undefined): void {
356
+ widgetActivity.lastError = error ? error.slice(0, 120) : undefined;
357
+ }
358
+
359
+ function beginWidgetActivity(kind: "delegate" | "broadcast" | "compare", ctx: { ui: { setWidget: Function } }): void {
360
+ setWidgetError(undefined);
361
+ if (kind === "delegate") widgetActivity.activeDelegations += 1;
362
+ if (kind === "broadcast") widgetActivity.activeBroadcasts += 1;
363
+ if (kind === "compare") widgetActivity.activeCompares += 1;
364
+ refreshWidget(ctx);
365
+ }
366
+
367
+ function endWidgetActivity(
368
+ kind: "delegate" | "broadcast" | "compare",
369
+ ctx: { ui: { setWidget: Function } },
370
+ error?: string,
371
+ ): void {
372
+ if (kind === "delegate") widgetActivity.activeDelegations = Math.max(0, widgetActivity.activeDelegations - 1);
373
+ if (kind === "broadcast") widgetActivity.activeBroadcasts = Math.max(0, widgetActivity.activeBroadcasts - 1);
374
+ if (kind === "compare") widgetActivity.activeCompares = Math.max(0, widgetActivity.activeCompares - 1);
375
+ setWidgetError(error);
376
+ refreshWidget(ctx);
377
+ }
378
+
379
+ function makeSessionHandle(
380
+ sessionId: string,
381
+ agentName: string,
382
+ cwd: string,
383
+ adapter: ReturnType<typeof createAdapter>,
384
+ metadata?: Partial<AcpArchivedSessionMetadata>,
385
+ sessionName?: string,
386
+ ): AcpSessionHandle {
387
+ const now = metadata?.createdAt ?? new Date();
388
+ // Phase 3.1: Append random hex suffix to prevent name collisions across agents
389
+ let resolvedSessionName = sessionName ?? metadata?.sessionName ?? sessionNameStore.getName(sessionId);
390
+ if (resolvedSessionName) {
391
+ const suffix = randomBytes(2).toString("hex"); // 4 hex chars
392
+ resolvedSessionName = `${resolvedSessionName}-${suffix}`;
393
+ }
394
+ const handle: AcpSessionHandle = {
395
+ sessionId,
396
+ sessionName: resolvedSessionName,
397
+ agentName,
398
+ cwd,
399
+ createdAt: now,
400
+ lastActivityAt: metadata?.lastActivityAt ?? now,
401
+ lastResponseAt: metadata?.lastResponseAt,
402
+ completedAt: metadata?.completedAt,
403
+ accumulatedText: "",
404
+ disposed: false,
405
+ busy: false,
406
+ autoClosed: metadata?.autoClosed,
407
+ closeReason: metadata?.closeReason,
408
+ model: metadata?.model,
409
+ mode: metadata?.mode,
410
+ planStatus: "none",
411
+ dispose: async () => {
412
+ handle.disposed = true;
413
+ archiveSession(handle);
414
+ adapter.dispose();
415
+ activeAdapters.delete(sessionId);
416
+ },
417
+ };
418
+ sessionMgr.add(handle);
419
+ monitor.register(handle);
420
+ activeAdapters.set(sessionId, adapter);
421
+ archiveSession(handle);
422
+ eventLog.append("session_created", { sessionId, agentName, cwd });
423
+ return handle;
424
+ }
425
+
426
+ async function safeExecute<T>(
427
+ fn: () => Promise<T>,
428
+ label: string,
429
+ opts?: { timeoutMs?: number },
430
+ ): Promise<{ ok: true; value: T } | { ok: false; error: string; circuitOpen?: boolean }> {
431
+ try {
432
+ const value = await cb.execute(fn, opts);
433
+ return { ok: true, value };
434
+ } catch (err: unknown) {
435
+ const msg = err instanceof Error ? err.message : String(err);
436
+ const circuitOpen = err instanceof Error && err.name === "CircuitOpenError";
437
+ logger.error(`error in ${label}`, { error: msg });
438
+ eventLog.append("operation_error", { label, error: msg, circuitOpen });
439
+ return { ok: false, error: msg, circuitOpen };
440
+ }
441
+ }
442
+
443
+ function getAgentName(requested?: string): string {
444
+ return requested ?? config.defaultAgent ?? Object.keys(config.agent_servers)[0] ?? "";
445
+ }
446
+
447
+ /** Check if a name is an alias */
448
+ function isAlias(name: string): boolean {
449
+ return !!config.agent_aliases?.[name];
450
+ }
451
+
452
+ /** Get the first agent name from an alias chain */
453
+ function resolveAliasToAgent(aliasName: string): string {
454
+ const alias = config.agent_aliases?.[aliasName];
455
+ if (!alias?.agents?.length) return aliasName;
456
+ return alias.agents[0];
457
+ }
458
+
459
+ function getAgentConfigOrThrow(agentName: string) {
460
+ // If it's an alias, resolve to first agent in chain
461
+ if (isAlias(agentName)) {
462
+ const resolved = resolveAliasToAgent(agentName);
463
+ const agentCfg = config.agent_servers[resolved];
464
+ if (!agentCfg) {
465
+ throw new Error(`Alias \"${agentName}\" resolves to agent \"${resolved}\" which is not found. Available: ${Object.keys(config.agent_servers).join(", ") || "none"}`);
466
+ }
467
+ return agentCfg;
468
+ }
469
+ const agentCfg = config.agent_servers[agentName];
470
+ if (!agentCfg) {
471
+ throw new Error(`Agent \"${agentName}\" not found. Available: ${Object.keys(config.agent_servers).join(", ") || "none"}`);
472
+ }
473
+ return agentCfg;
474
+ }
475
+
476
+ function renderSessionSummary(handle: AcpArchivedSessionMetadata | AcpSessionHandle): string {
477
+ return [
478
+ `Session: ${handle.sessionId}`,
479
+ `Name: ${handle.sessionName ?? "(none)"}`,
480
+ `Agent: ${handle.agentName}`,
481
+ `CWD: ${handle.cwd}`,
482
+ `Created: ${handle.createdAt.toISOString()}`,
483
+ `Active: ${handle.lastActivityAt.toISOString()}`,
484
+ `Response:${handle.lastResponseAt ? ` ${handle.lastResponseAt.toISOString()}` : " (none)"}`,
485
+ `Done: ${handle.completedAt ? handle.completedAt.toISOString() : "(none)"}`,
486
+ `Busy: ${busySessions.get(handle.sessionId) ? "yes" : "no"}`,
487
+ `Plan: ${(handle as AcpSessionHandle).planStatus ?? "none"}`,
488
+ `Closed: ${handle.closeReason ?? "open"}`,
489
+ `Disposed:${handle.disposed ? " yes" : " no"}`,
490
+ ].join("\n");
491
+ }
492
+
493
+ // Load tool visibility settings
494
+ const toolSettings: AcpToolSettings = loadSettings(process.cwd());
495
+
496
+ // Core tools
497
+ if (isToolEnabled(toolSettings, "acp_prompt")) pi.registerTool({
498
+ name: "acp_prompt",
499
+ label: "ACP Prompt",
500
+ description: "Send a prompt to an ACP-compatible agent (e.g., Gemini CLI). Returns the agent's text response. Creates a new session if needed.",
501
+ promptSnippet: "acp_prompt — send a prompt to an ACP agent and get the response",
502
+ parameters: Type.Object({
503
+ message: Type.String({ description: "The message/prompt to send to the agent" }),
504
+ agent: Type.Optional(Type.String({ description: "Agent name from config. Default: use defaultAgent setting" })),
505
+ session_id: Type.Optional(Type.String({ description: "Existing session ID to reuse" })),
506
+ session_name: Type.Optional(Type.String({ description: "Friendly session name to reuse or assign when creating" })),
507
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
508
+ dispose: Type.Optional(Type.Boolean({ description: "Create ephemeral session and dispose after response" })),
509
+ model: Type.Optional(Type.String({ description: "Model to set on the session" })),
510
+ mode: Type.Optional(Type.String({ description: "Mode/thinking level to set on the session" })),
511
+ }),
512
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
513
+ const agentName = getAgentName(params.agent);
514
+ try {
515
+ getAgentConfigOrThrow(agentName);
516
+ } catch (error) {
517
+ return { content: [textContent(String((error as Error).message))], details: { agent: agentName, error: "not found" } };
518
+ }
519
+
520
+ const result = await safeExecute(async () => {
521
+ const target = resolveSessionTarget(params);
522
+ if (target.sessionId && activeAdapters.has(target.sessionId)) {
523
+ const handle = sessionMgr.get(target.sessionId);
524
+ if (!handle || handle.disposed) {
525
+ throw new Error(`Session \"${target.sessionId}\" not found or disposed.`);
526
+ }
527
+ if (busySessions.get(target.sessionId)) {
528
+ throw new Error(`Session \"${target.sessionId}\" is busy. Try again later.`);
529
+ }
530
+ busySessions.set(target.sessionId, true);
531
+ handle.busy = true;
532
+ handle.lastActivityAt = new Date();
533
+ handle.isPrompting = true;
534
+ handle.promptStartedAt = new Date();
535
+ monitor.markPromptStart(target.sessionId);
536
+ archiveSession(handle);
537
+ try {
538
+ const adapter = activeAdapters.get(target.sessionId)!;
539
+ const promptResult = (await withTimeoutMs(adapter.prompt(params.message), config.toolTimeouts?.prompt ?? config.stallTimeoutMs, `acp_prompt(reused:${target.sessionId})`)) as AcpPromptResult;
540
+ markPromptLifecycle(handle, promptResult);
541
+ eventLog.append("prompt_reused_session", { agentName, sessionId: target.sessionId, sessionName: handle.sessionName });
542
+ return { ...promptResult, sessionId: target.sessionId, sessionName: handle.sessionName };
543
+ } finally {
544
+ busySessions.delete(target.sessionId);
545
+ handle.busy = false;
546
+ handle.isPrompting = false;
547
+ monitor.markPromptEnd(target.sessionId);
548
+ archiveSession(handle);
549
+ }
550
+ }
551
+
552
+ if (target.sessionId && target.metadata) {
553
+ // Auto-reload archived session if it exists
554
+ const archived = target.metadata;
555
+
556
+ // Phase 3.4: Skip loadSession for known-unloadable sessions
557
+ if (archived.loadStatus === "unloadable" && (archived.loadAttemptCount ?? 0) >= 3) {
558
+ // Permanently unloadable — go straight to fresh session with warning
559
+ const freshAgentCfg = getAgentConfigOrThrow(agentName);
560
+ const freshAdapter = createAdapter(agentName, freshAgentCfg, config, params.cwd ?? ctx.cwd, {
561
+ onActivity: (sid) => monitor.touch(sid),
562
+ });
563
+ try {
564
+ await withTimeoutMs(freshAdapter.spawn(), config.staleTimeoutMs, `acp_spawn(unloadable:${target.sessionId})`);
565
+ await freshAdapter.initialize();
566
+ const freshSessionId = await freshAdapter.newSession(params.cwd ?? ctx.cwd);
567
+ if (target.sessionName) sessionNameStore.register(target.sessionName, freshSessionId);
568
+ const handle = makeSessionHandle(freshSessionId, agentName, params.cwd ?? ctx.cwd, freshAdapter, undefined, target.sessionName);
569
+ handle.busy = true; busySessions.set(freshSessionId, true);
570
+ try {
571
+ const pr = (await withTimeoutMs(freshAdapter.prompt(params.message), config.toolTimeouts?.prompt ?? config.staleTimeoutMs, `acp_prompt(unloadable:${freshSessionId})`)) as AcpPromptResult;
572
+ markPromptLifecycle(handle, pr);
573
+ pr.text = `[WARNING: Previous session could not be recovered. This is an entirely new session with no conversation history. Previous session was marked permanently unloadable after ${archived.loadAttemptCount} failed attempts.]
574
+ ${pr.text}`;
575
+ return { ...pr, sessionId: freshSessionId, sessionName: handle.sessionName };
576
+ } finally {
577
+ busySessions.delete(freshSessionId); handle.busy = false; archiveSession(handle);
578
+ }
579
+ } catch (freshErr) { freshAdapter.dispose(); throw freshErr; }
580
+ }
581
+
582
+ const agentCfg = getAgentConfigOrThrow(agentName);
583
+ const adapter = createAdapter(agentName, agentCfg, config, params.cwd ?? ctx.cwd, {
584
+ onActivity: (sid) => monitor.touch(sid),
585
+ });
586
+ try {
587
+ await withTimeoutMs(adapter.spawn(), config.staleTimeoutMs, `acp_spawn(archived:${target.sessionId})`);
588
+ await adapter.initialize();
589
+ try {
590
+ await adapter.loadSession(target.sessionId);
591
+ // Phase 3.3: Track successful load
592
+ if (archived) {
593
+ archived.loadStatus = "loadable";
594
+ archived.lastLoadAttemptAt = new Date().toISOString();
595
+ archived.loadAttemptCount = (archived.loadAttemptCount ?? 0) + 1;
596
+ archiveSession(archived as AcpSessionHandle);
597
+ }
598
+ } catch (loadErr) {
599
+ // Phase 3.3: Track failed load
600
+ if (archived) {
601
+ archived.loadStatus = "unloadable";
602
+ archived.lastLoadAttemptAt = new Date().toISOString();
603
+ archived.lastLoadError = (loadErr as Error).message;
604
+ archived.loadAttemptCount = (archived.loadAttemptCount ?? 0) + 1;
605
+ archiveSession(archived as AcpSessionHandle);
606
+ }
607
+ // Archived session cannot be reloaded — fall back to fresh
608
+ adapter.dispose();
609
+ const freshAdapter = createAdapter(agentName, agentCfg, config, params.cwd ?? ctx.cwd, {
610
+ onActivity: (sid) => monitor.touch(sid),
611
+ });
612
+ try {
613
+ await withTimeoutMs(freshAdapter.spawn(), config.staleTimeoutMs, `acp_spawn(fresh:${target.sessionId})`);
614
+ await freshAdapter.initialize();
615
+ const freshSessionId = await freshAdapter.newSession(params.cwd ?? ctx.cwd);
616
+ if (target.sessionName) sessionNameStore.register(target.sessionName, freshSessionId);
617
+ const handle = makeSessionHandle(freshSessionId, agentName, params.cwd ?? ctx.cwd, freshAdapter, undefined, target.sessionName);
618
+ handle.busy = true; busySessions.set(freshSessionId, true);
619
+ try {
620
+ const pr = (await withTimeoutMs(freshAdapter.prompt(params.message), config.toolTimeouts?.prompt ?? config.staleTimeoutMs, `acp_prompt(fresh:${freshSessionId})`)) as AcpPromptResult;
621
+ markPromptLifecycle(handle, pr);
622
+ pr.text = `[WARNING: Previous session could not be recovered. This is an entirely new session with no conversation history. Error: ${(loadErr as Error).message}]
623
+ ${pr.text}`;
624
+ return { ...pr, sessionId: freshSessionId, sessionName: handle.sessionName };
625
+ } finally {
626
+ busySessions.delete(freshSessionId); handle.busy = false; archiveSession(handle);
627
+ }
628
+ } catch (freshErr) { freshAdapter.dispose(); throw freshErr; }
629
+ }
630
+ const handle = makeSessionHandle(target.sessionId, agentName, archived.cwd ?? params.cwd ?? ctx.cwd, adapter, undefined, target.sessionName);
631
+ handle.busy = true; busySessions.set(target.sessionId, true);
632
+ try {
633
+ const pr = (await withTimeoutMs(adapter.prompt(params.message), config.toolTimeouts?.prompt ?? config.staleTimeoutMs, `acp_prompt(archived:${target.sessionId})`)) as AcpPromptResult;
634
+ markPromptLifecycle(handle, pr);
635
+ return { ...pr, sessionId: target.sessionId, sessionName: handle.sessionName };
636
+ } finally {
637
+ busySessions.delete(target.sessionId); handle.busy = false; archiveSession(handle);
638
+ }
639
+ } catch (err) { adapter.dispose(); throw err; }
640
+ }
641
+ const agentCfg = getAgentConfigOrThrow(agentName);
642
+ const adapter = createAdapter(agentName, agentCfg, config, params.cwd ?? ctx.cwd, {
643
+ onActivity: (sid) => monitor.touch(sid),
644
+ });
645
+ try {
646
+ await withTimeoutMs(adapter.spawn(), config.stallTimeoutMs, `acp_spawn(new:${agentName})`);
647
+ await adapter.initialize();
648
+ const sessionId = await adapter.newSession(params.cwd ?? ctx.cwd);
649
+ if (params.model) await adapter.setModel(params.model);
650
+ if (params.mode) await adapter.setMode(params.mode);
651
+ if (target.sessionName && !params.dispose) {
652
+ sessionNameStore.register(target.sessionName, sessionId);
653
+ }
654
+ const handle = makeSessionHandle(sessionId, agentName, params.cwd ?? ctx.cwd, adapter, undefined, params.dispose ? undefined : target.sessionName);
655
+ handle.busy = true;
656
+ busySessions.set(sessionId, true);
657
+ handle.lastActivityAt = new Date();
658
+ handle.isPrompting = true;
659
+ handle.promptStartedAt = new Date();
660
+ monitor.markPromptStart(sessionId);
661
+ archiveSession(handle);
662
+ try {
663
+ const promptResult = (await withTimeoutMs(adapter.prompt(params.message), config.toolTimeouts?.prompt ?? config.stallTimeoutMs, `acp_prompt(new:${sessionId})`)) as AcpPromptResult;
664
+ markPromptLifecycle(handle, promptResult);
665
+ eventLog.append("prompt_new_session", { agentName, sessionId, sessionName: handle.sessionName });
666
+ return { ...promptResult, sessionId, sessionName: handle.sessionName };
667
+ } finally {
668
+ busySessions.delete(sessionId);
669
+ handle.busy = false;
670
+ handle.isPrompting = false;
671
+ monitor.markPromptEnd(sessionId);
672
+ archiveSession(handle);
673
+ if (params.dispose) adapter.dispose();
674
+ }
675
+ } catch (err) {
676
+ adapter.dispose();
677
+ throw err;
678
+ }
679
+ }, `acp_prompt(${agentName})`);
680
+
681
+ refreshWidget(ctx);
682
+ if (result.ok) {
683
+ return {
684
+ content: [textContent(result.value.text || "(no response)")],
685
+ details: {
686
+ sessionId: result.value.sessionId,
687
+ sessionName: result.value.sessionName,
688
+ stopReason: result.value.stopReason,
689
+ agent: agentName,
690
+ },
691
+ } as AgentToolResult<{ sessionId: string; stopReason: string; agent: string }>;
692
+ }
693
+
694
+ const prefix = result.circuitOpen ? "Circuit breaker open — too many failures. Retry later.\n" : "";
695
+ return {
696
+ content: [textContent(`${prefix}ACP error (${agentName}): ${result.error}`)],
697
+ details: { sessionId: "", sessionName: params.session_name, stopReason: "error", agent: agentName },
698
+ };
699
+ },
700
+ });
701
+
702
+ if (isToolEnabled(toolSettings, "acp_status")) pi.registerTool({
703
+ name: "acp_status",
704
+ label: "ACP Status",
705
+ description: "Check the status of ACP agent connections. Shows configured agents and active sessions.",
706
+ promptSnippet: "acp_status — check ACP agent and session status",
707
+ parameters: Type.Object({
708
+ session_id: Type.Optional(Type.String({ description: "Specific session ID to inspect" })),
709
+ session_name: Type.Optional(Type.String({ description: "Friendly session name to inspect" })),
710
+ }),
711
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
712
+ config = loadConfig();
713
+ governanceStore.setModelPolicy(config.modelPolicy ?? {});
714
+ if (params.session_id || params.session_name) {
715
+ let target;
716
+ try {
717
+ target = resolveSessionTarget(params);
718
+ } catch (error) {
719
+ return {
720
+ content: [textContent(String((error as Error).message))],
721
+ details: { circuitBreaker: cb.state, agentCount: Object.keys(config.agent_servers).length, sessionCount: sessionMgr.size },
722
+ };
723
+ }
724
+ const handle = target.metadata;
725
+ if (!handle || !target.sessionId) {
726
+ return {
727
+ content: [textContent(`Session \"${params.session_name ?? params.session_id}\" not found.`)],
728
+ details: { circuitBreaker: cb.state, agentCount: Object.keys(config.agent_servers).length, sessionCount: sessionMgr.size },
729
+ };
730
+ }
731
+ refreshWidget(ctx);
732
+ return {
733
+ content: [textContent(renderSessionSummary(handle))],
734
+ details: { circuitBreaker: cb.state, agentCount: Object.keys(config.agent_servers).length, sessionCount: sessionMgr.size },
735
+ };
736
+ }
737
+
738
+ const agentLines = Object.entries(config.agent_servers)
739
+ .map(([name, cfg]) => ` ${name}: ${cfg.command} ${(cfg.args ?? []).join(" ")}`)
740
+ .join("\n");
741
+ const sessionLines = sessionMgr.list().map((s) => ` ${s.sessionName ? `${s.sessionName} ` : ""}${s.sessionId} (${s.agentName}) — ${s.cwd}`).join("\n");
742
+ refreshWidget(ctx);
743
+ return {
744
+ content: [textContent(
745
+ `ACP Agent Servers Status\n─────────────────\nCircuit Breaker: ${cb.state}\nAgent Servers: ${Object.keys(config.agent_servers).length} configured\nAliases: ${config.agent_aliases ? Object.keys(config.agent_aliases).length : 0}\nDefault: ${config.defaultAgent ?? "none"}\n\nAgent Servers:\n${agentLines || " (none)"}${config.agent_aliases ? `\n\nAliases:\n${Object.entries(config.agent_aliases).map(([name, cfg]) => ` ${name} → [${cfg.agents.join(", ")}] (${cfg.strategy})`).join("\n")}` : ""}\n\nActive Sessions (${sessionMgr.size}):\n${sessionLines || " (none)"}`,
746
+ )],
747
+ details: { circuitBreaker: cb.state, agentCount: Object.keys(config.agent_servers).length, sessionCount: sessionMgr.size },
748
+ };
749
+ },
750
+ });
751
+
752
+ // Session lifecycle tools — moved to pi-acp-advanced extension
753
+
754
+
755
+ if (isToolEnabled(toolSettings, "acp_cancel")) pi.registerTool({
756
+ name: "acp_cancel",
757
+ label: "ACP Cancel",
758
+ description: "Cancel an ongoing prompt on an ACP agent session.",
759
+ promptSnippet: "acp_cancel — cancel ongoing ACP prompt",
760
+ parameters: Type.Object({
761
+ session_id: Type.Optional(Type.String({ description: "Session ID to cancel" })),
762
+ session_name: Type.Optional(Type.String({ description: "Friendly session name to cancel" })),
763
+ }),
764
+ async execute(_toolCallId, params) {
765
+ const target = resolveSessionTarget(params);
766
+ const handle = target.sessionId ? sessionMgr.get(target.sessionId) : undefined;
767
+ if (!handle || handle.disposed) {
768
+ return { content: [textContent(`Session \"${target.sessionName ?? target.sessionId ?? params.session_name ?? params.session_id}\" not found or disposed.`)], details: { sessionId: target.sessionId, sessionName: target.sessionName, cancelled: false } };
769
+ }
770
+ const result = await safeExecute(async () => {
771
+ const adapter = activeAdapters.get(handle.sessionId)!;
772
+ await adapter.cancel();
773
+ const now = new Date();
774
+ handle.lastActivityAt = now;
775
+ handle.completedAt = now;
776
+ archiveSession(handle);
777
+ eventLog.append("session_cancel", { sessionId: handle.sessionId, sessionName: handle.sessionName });
778
+ return true;
779
+ }, "acp_cancel");
780
+ if (result.ok) {
781
+ return { content: [textContent(`Cancelled prompt on session ${handle.sessionId}`)], details: { sessionId: handle.sessionId, sessionName: handle.sessionName, cancelled: true } };
782
+ }
783
+ return { content: [textContent(`Failed to cancel: ${result.error}`)], details: { sessionId: handle.sessionId, sessionName: handle.sessionName, cancelled: false } };
784
+ },
785
+ });
786
+
787
+ // Level 3 tools
788
+
789
+ if (isToolEnabled(toolSettings, "acp_broadcast")) pi.registerTool({
790
+ name: "acp_broadcast",
791
+ label: "ACP Broadcast",
792
+ description: "Send the same prompt to multiple ACP agents in parallel. Returns each agent's response. Individual failures don't affect others.",
793
+ promptSnippet: "acp_broadcast — broadcast to multiple ACP agents",
794
+ parameters: Type.Object({
795
+ message: Type.String({ description: "Prompt to send to all agents" }),
796
+ agents: Type.Optional(Type.Array(Type.String(), { description: "Agent names. Default: all configured agents" })),
797
+ cwd: Type.Optional(Type.String({ description: "Working directory" })),
798
+ }),
799
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
800
+ const agentNames = params.agents ?? Object.keys(config.agent_servers);
801
+ if (agentNames.length === 0) {
802
+ return { content: [textContent("No agent servers configured or specified.")], details: { results: [], error: "no agents" } };
803
+ }
804
+ const coordinator = new AgentCoordinator(config, params.cwd ?? ctx.cwd, {
805
+ isHealthyFn: (name) => cb.isHealthy(name),
806
+ recordSuccessFn: (name) => cb.recordSuccess(name),
807
+ recordFailureFn: (name) => cb.recordFailure(name),
808
+ });
809
+ beginWidgetActivity("broadcast", ctx);
810
+ const result = await safeExecute(async () => {
811
+ const output = await coordinator.broadcast(agentNames, params.message, params.cwd ?? ctx.cwd);
812
+ eventLog.append("broadcast", { agentNames, cwd: params.cwd ?? ctx.cwd });
813
+ return output;
814
+ }, `acp_broadcast(${agentNames.join(",")})`, { timeoutMs: config.toolTimeouts?.broadcast ?? config.stallTimeoutMs });
815
+ if (!result.ok) {
816
+ endWidgetActivity("broadcast", ctx, result.error);
817
+ return { content: [textContent(`Broadcast failed: ${result.error}`)], details: { results: [], error: result.error, circuitOpen: result.circuitOpen } };
818
+ }
819
+ const lines = result.value.map((r) => r.error ? `── ${r.agent} ──\n(ERROR: ${r.error})` : `── ${r.agent} ──\n${r.text}`);
820
+ endWidgetActivity("broadcast", ctx);
821
+ return { content: [textContent(`Broadcast results:\n\n${lines.join("\n\n")}`)], details: { results: result.value } };
822
+ },
823
+ });
824
+
825
+
826
+ // Parallel delegation tool
827
+
828
+ // ── Consolidated tools (33→7 mode) ──
829
+ if (isToolEnabled(toolSettings, "acp_task_update")) pi.registerTool({
830
+ name: "acp_task_update",
831
+ label: "ACP Task Update",
832
+ description: "Update task status, assignee, dependencies, or result. Consolidates acp_task_assign, acp_task_set_status, acp_task_dep_add/rm, acp_task_clear. Supports bulk ops with task_id='*'.",
833
+ promptSnippet: "acp_task_update — update task properties",
834
+ parameters: Type.Object({
835
+ task_id: Type.String({ description: "Task ID, or '*' for bulk operations" }),
836
+ status: Type.Optional(Type.String({ description: "New status: pending, in_progress, completed, deleted" })),
837
+ assignee: Type.Optional(Type.String({ description: "Assign to agent, or empty string to unassign" })),
838
+ deps_add: Type.Optional(Type.Array(Type.String(), { description: "Add these task IDs as dependencies" })),
839
+ deps_remove: Type.Optional(Type.Array(Type.String(), { description: "Remove these task IDs from dependencies" })),
840
+ result: Type.Optional(Type.String({ description: "Store result text on the task" })),
841
+ filter: Type.Optional(Type.String({ description: "Filter for bulk ops: 'completed', 'pending', 'in_progress'" })),
842
+ }),
843
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
844
+ // Bulk operation
845
+ if (params.task_id === "*") {
846
+ const filter = params.filter ?? "";
847
+ const updated = taskStore.updateWhere(filter, (t: any) => {
848
+ if (params.status) t.status = params.status;
849
+ if (params.assignee !== undefined) t.assignee = params.assignee || null;
850
+ if (params.result) t.result = params.result;
851
+ t.updatedAt = new Date().toISOString();
852
+ });
853
+ return { content: [textContent(`Bulk updated ${updated.length} tasks matching '${filter}'.`)], details: { updated: updated.length } };
854
+ }
855
+
856
+ // Single task
857
+ const updated = taskStore.update(params.task_id, (t: any) => {
858
+ if (params.status) t.status = params.status;
859
+ if (params.assignee !== undefined) t.assignee = params.assignee || null;
860
+ if (params.result) t.result = params.result;
861
+ if (params.deps_add) {
862
+ for (const dep of params.deps_add) {
863
+ if (!t.blockedBy.includes(dep)) t.blockedBy.push(dep);
864
+ }
865
+ }
866
+ if (params.deps_remove) {
867
+ t.blockedBy = t.blockedBy.filter((d: string) => !params.deps_remove!.includes(d));
868
+ }
869
+ t.updatedAt = new Date().toISOString();
870
+ });
871
+ if (!updated) {
872
+ return { content: [textContent(`Task ${params.task_id} not found.`)], details: { error: "not_found" } };
873
+ }
874
+ return { content: [textContent(`Task ${params.task_id} updated.`)], details: { task: updated } };
875
+ },
876
+ });
877
+
878
+ if (isToolEnabled(toolSettings, "acp_message")) pi.registerTool({
879
+ name: "acp_message",
880
+ label: "ACP Message",
881
+ description: "Send or list messages. Consolidates acp_message_send and acp_message_list. Use action:'send' with kind:'dm'/'steer'/'broadcast', or action:'list'.",
882
+ promptSnippet: "acp_message — send or list messages",
883
+ parameters: Type.Object({
884
+ action: Type.String({ description: "'send' or 'list'" }),
885
+ to: Type.Optional(Type.String({ description: "Recipient agent name, or '*' for broadcast" })),
886
+ message: Type.Optional(Type.String({ description: "Message text (for send)" })),
887
+ kind: Type.Optional(Type.String({ description: "'dm', 'steer', 'broadcast'" })),
888
+ from: Type.Optional(Type.String({ description: "Sender name" })),
889
+ recipient: Type.Optional(Type.String({ description: "Recipient for list action" })),
890
+ filter: Type.Optional(Type.String({ description: "Filter for list: 'unread'" })),
891
+ }),
892
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
893
+ if (params.action === "send") {
894
+ const kind: "dm" | "steer" | "broadcast" = params.to === "*" ? "broadcast" : ((params.kind as "dm" | "steer" | "broadcast" | undefined) ?? "dm");
895
+ const result = mailboxManager.send({
896
+ from: params.from ?? "user",
897
+ to: params.to ?? "",
898
+ message: params.message ?? "",
899
+ kind,
900
+ });
901
+ return { content: [textContent(`Message sent to ${params.to} (${kind}).`)], details: { messageId: result.id } };
902
+ }
903
+
904
+ if (params.action === "list") {
905
+ if (params.recipient) {
906
+ const messages = mailboxManager.listFor(params.recipient);
907
+ return { content: [textContent(`${messages.length} messages for ${params.recipient}.`)], details: { messages } };
908
+ }
909
+ // List all
910
+ const messages = mailboxManager.listAll?.() ?? [];
911
+ return { content: [textContent(`${messages.length} total messages.`)], details: { messages } };
912
+ }
913
+
914
+ return { content: [textContent(`Unknown action: ${params.action}. Use 'send' or 'list'.`)], details: { error: "unknown_action" } };
915
+ },
916
+ });
917
+
918
+ // Lifecycle / management tools
919
+
920
+
921
+ // Task layer tools
922
+ if (isToolEnabled(toolSettings, "acp_task_create")) pi.registerTool({
923
+ name: "acp_task_create",
924
+ label: "ACP Task Create",
925
+ description: "Create a persistent ACP task in the runtime task store.",
926
+ promptSnippet: "acp_task_create — create ACP task",
927
+ parameters: Type.Object({
928
+ subject: Type.String({ description: "Short task subject" }),
929
+ description: Type.Optional(Type.String({ description: "Longer task details" })),
930
+ assignee: Type.Optional(Type.String({ description: "Optional agent assignee" })),
931
+ deps: Type.Optional(Type.Array(Type.String(), { description: "Task IDs this task depends on" })),
932
+ }),
933
+ async execute(_toolCallId, params) {
934
+ const task = taskStore.create({ subject: params.subject, description: params.description, assignee: params.assignee, deps: params.deps });
935
+ eventLog.append("task_create", { taskId: task.id, assignee: task.assignee });
936
+ return { content: [textContent(formatJson(task))], details: task };
937
+ },
938
+ });
939
+
940
+
941
+ // Messaging + governance + diagnostics
942
+
943
+
944
+ function showAcpConfig(ctx: { ui: { notify: Function; setWidget: Function } }): void {
945
+ config = loadConfig();
946
+ const agents = Object.entries(config.agent_servers)
947
+ .map(([name, cfg]) => `${name}: ${cfg.command} ${(cfg.args ?? []).join(" ")}`)
948
+ .join("\n");
949
+ refreshWidget(ctx);
950
+ ctx.ui.notify(
951
+ `ACP Agent Servers Config\n${agents}\nDefault: ${config.defaultAgent ?? "none"}\nSessions: ${sessionMgr.size} | Circuit: ${cb.state}`,
952
+ "info",
953
+ );
954
+ }
955
+
956
+ function showAcpDoctor(ctx: { ui: { notify: Function; setWidget: Function } }): void {
957
+ const payload = {
958
+ configuredAgentServers: Object.keys(config.agent_servers),
959
+ defaultAgent: config.defaultAgent,
960
+ sessionCount: sessionMgr.size,
961
+ runtimeDir: runtimePaths.rootDir,
962
+ circuitBreaker: cb.state,
963
+ };
964
+ ctx.ui.notify(formatJson(payload), "info");
965
+ refreshWidget(ctx);
966
+ }
967
+
968
+ const acpCommandGroups = {
969
+ session: ["new", "load", "list", "shutdown", "kill", "prune", "set-model", "set-mode", "cancel"],
970
+ prompt: [],
971
+ delegate: [],
972
+ broadcast: [],
973
+ compare: [],
974
+ task: ["create", "list", "get", "assign", "set-status", "dep-add", "dep-rm", "clear"],
975
+ message: ["send", "list"],
976
+ plan: ["request", "resolve"],
977
+ runtime: ["status", "config", "env", "info", "event-log", "cleanup", "doctor"],
978
+ settings: [],
979
+ } as const;
980
+
981
+ function renderAcpCommandSurface(): string {
982
+ const lines = [
983
+ "ACP command surface",
984
+ "/acp session <new|load|list|shutdown|kill|prune|set-model|set-mode|cancel>",
985
+ "/acp prompt",
986
+ "/acp delegate",
987
+ "/acp broadcast",
988
+ "/acp compare",
989
+ "/acp task <create|list|get|assign|set-status|dep-add|dep-rm|clear>",
990
+ "/acp message <send|list>",
991
+ "/acp plan <request|resolve>",
992
+ "/acp runtime <status|config|env|info|event-log|cleanup|doctor>",
993
+ "/acp settings — configure tool visibility",
994
+ "Aliases: /acp-doctor, /acp-config",
995
+ ];
996
+ return lines.join("\n");
997
+ }
998
+
999
+ pi.registerCommand("acp", {
1000
+ description: "ACP root command: session, prompt, delegate, broadcast, compare, task, message, plan, runtime",
1001
+ async handler(args, ctx) {
1002
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
1003
+ if (tokens.length === 0) {
1004
+ ctx.ui.notify(renderAcpCommandSurface(), "info");
1005
+ refreshWidget(ctx);
1006
+ return;
1007
+ }
1008
+
1009
+ const [group, subcommand] = tokens;
1010
+ const validGroup = Object.hasOwn(acpCommandGroups, group);
1011
+ if (!validGroup) {
1012
+ ctx.ui.notify(`${renderAcpCommandSurface()}\nUnknown group: ${group}`, "error");
1013
+ refreshWidget(ctx);
1014
+ return;
1015
+ }
1016
+
1017
+ if (group === "settings") {
1018
+ await configureToolSettings(ctx as Parameters<typeof configureToolSettings>[0], ctx.cwd ?? process.cwd());
1019
+ return;
1020
+ }
1021
+
1022
+ if (group === "runtime" && subcommand === "doctor") {
1023
+ showAcpDoctor(ctx);
1024
+ return;
1025
+ }
1026
+ if (group === "runtime" && subcommand === "config") {
1027
+ showAcpConfig(ctx);
1028
+ return;
1029
+ }
1030
+
1031
+ const lines = [`Group: ${group}`];
1032
+ if (subcommand) lines.push(`Subcommand: ${subcommand}`);
1033
+ const supportedSubcommands = acpCommandGroups[group as keyof typeof acpCommandGroups];
1034
+ if (supportedSubcommands.length > 0) {
1035
+ lines.push(`Supported: ${supportedSubcommands.join(", ")}`);
1036
+ }
1037
+ ctx.ui.notify(lines.join("\n"), "info");
1038
+ refreshWidget(ctx);
1039
+ },
1040
+ });
1041
+
1042
+ pi.registerCommand("acp-config", {
1043
+ description: "Compatibility alias for /acp runtime config",
1044
+ async handler(_args, ctx) {
1045
+ showAcpConfig(ctx);
1046
+ },
1047
+ });
1048
+
1049
+ /**
1050
+ * Derive worker status from liveliness signals (LIVELINESS-1).
1051
+ * - online: activity < workerOnlineMs
1052
+ * - busy: has in-flight task (currentTaskId set)
1053
+ * - idle: no task, activity < workerStaleMs
1054
+ * - stale(Ns): activity > workerStaleMs
1055
+ */
1056
+ function deriveWorkerStatus(worker: import("./src/config/types.js").AcpWorkerRecord): { status: string; stale: boolean } {
1057
+ const now = Date.now();
1058
+ const lastActivity = new Date(worker.lastActivityAt).getTime();
1059
+ const ageMs = now - lastActivity;
1060
+ const onlineMs = config.workerOnlineMs ?? 60_000;
1061
+ const staleMs = config.workerStaleMs ?? 60_000;
1062
+
1063
+ // If offline in the store, keep offline
1064
+ if (worker.status === "offline") {
1065
+ return { status: "offline", stale: false };
1066
+ }
1067
+
1068
+ // Busy if has in-flight task
1069
+ if (worker.currentTaskId) {
1070
+ return { status: "busy", stale: false };
1071
+ }
1072
+
1073
+ // Stale if activity exceeds threshold
1074
+ if (ageMs > staleMs) {
1075
+ const ageSec = Math.floor(ageMs / 1000);
1076
+ return { status: `stale(${ageSec}s)`, stale: true };
1077
+ }
1078
+
1079
+ // Online if recently active
1080
+ if (ageMs < onlineMs) {
1081
+ return { status: "online", stale: false };
1082
+ }
1083
+
1084
+ // Idle: no task, activity between online and stale thresholds
1085
+ return { status: "idle", stale: false };
1086
+ }
1087
+
1088
+ /**
1089
+ * Check if worker is stale (⚠ stale indicator).
1090
+ * All three signals frozen: tokenCountTotal, toolCallCount, lastActivityAt
1091
+ * beyond stallTimeoutMs with no change.
1092
+ */
1093
+ function isWorkerStale(worker: import("./src/config/types.js").AcpWorkerRecord): boolean {
1094
+ const stallMs = config.stallTimeoutMs ?? 300_000;
1095
+ const now = Date.now();
1096
+ const ageMs = now - new Date(worker.lastActivityAt).getTime();
1097
+ // If lastActivity was recent enough, not stale
1098
+ if (ageMs < stallMs) return false;
1099
+ // If tokens have been used, signals aren't frozen
1100
+ if ((worker.tokenCountTotal ?? 0) > 0) return false;
1101
+ // If tools have been called, signals aren't frozen
1102
+ if ((worker.toolCallCount ?? 0) > 0) return false;
1103
+ // All signals frozen beyond stall timeout
1104
+ return true;
1105
+ }
1106
+
1107
+ // ── Worker tools (persistent-workers) ──
1108
+
1109
+ if (isToolEnabled(toolSettings, "acp_worker_spawn")) pi.registerTool({
1110
+ name: "acp_worker_spawn",
1111
+ label: "ACP Worker Spawn",
1112
+ description: "Spawn a persistent named ACP worker. Creates a long-lived ACP session bound to a worker identity in WorkerStore. The worker persists across task completions and can be controlled via steer, shutdown, and kill tools.",
1113
+ promptSnippet: "acp_worker_spawn — spawn a persistent named ACP worker",
1114
+ parameters: Type.Object({
1115
+ name: Type.String({ description: "Worker name (1-64 chars, alphanumeric + hyphens + underscores). Must be unique." }),
1116
+ agent: Type.String({ description: "Agent name from config to use for the worker" }),
1117
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent" }),
1118
+ ),
1119
+ model: Type.Optional(Type.String({ description: "Model to set on the session" })),
1120
+ thinking: Type.Optional(Type.String({ description: "Thinking/mode level to set on the session" })),
1121
+ initPrompt: Type.Optional(Type.String({ description: "Optional initial prompt to send after session creation" })),
1122
+ }),
1123
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1124
+ const name = requireString(params.name, "name");
1125
+ const agentName = getAgentName(params.agent);
1126
+
1127
+ // Validate name format: 1-64 chars, alphanumeric + hyphens + underscores
1128
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
1129
+ return {
1130
+ content: [textContent("Worker name must be 1-64 characters and contain only alphanumeric characters, hyphens, and underscores.")],
1131
+ details: { error: "invalid_name" },
1132
+ };
1133
+ }
1134
+
1135
+ // Validate agent exists
1136
+ try {
1137
+ getAgentConfigOrThrow(agentName);
1138
+ } catch (error) {
1139
+ return {
1140
+ content: [textContent(String((error as Error).message))],
1141
+ details: { error: "agent_not_found", agent: agentName },
1142
+ };
1143
+ }
1144
+
1145
+ // Check for duplicate name
1146
+ const existing = workerStore.get(name);
1147
+ if (existing) {
1148
+ return {
1149
+ content: [textContent(`Worker '${name}' already exists`)],
1150
+ details: { error: "duplicate_name", name },
1151
+ };
1152
+ }
1153
+
1154
+ const result = await safeExecute(async () => {
1155
+ const agentCfg = getAgentConfigOrThrow(agentName);
1156
+ const effectiveCwd = params.cwd ?? ctx.cwd;
1157
+ const adapter = createAdapter(agentName, agentCfg, config, effectiveCwd, {
1158
+ onActivity: (sid) => monitor.touch(sid),
1159
+ onSessionUpdate: heartbeatConsumer,
1160
+ });
1161
+ try {
1162
+ await withTimeoutMs(adapter.spawn(), config.stallTimeoutMs, `acp_worker_spawn:${name}`);
1163
+ await adapter.initialize();
1164
+ const sessionId = await adapter.newSession(effectiveCwd);
1165
+ if (params.model) await adapter.setModel(params.model);
1166
+ if (params.thinking) await adapter.setMode(params.thinking);
1167
+ const handle = makeSessionHandle(sessionId, agentName, effectiveCwd, adapter);
1168
+ // Register in WorkerStore
1169
+ const worker = workerStore.register({ name, sessionId, agentName });
1170
+ // Register session → worker mapping for heartbeat consumer
1171
+ workerSessionMap.set(sessionId, name);
1172
+ // Store adapter for dispatcher access
1173
+ activeAdapters.set(sessionId, adapter);
1174
+ eventLog.append("worker_spawn", { name, sessionId, agentName });
1175
+ // Send init prompt if provided
1176
+ if (params.initPrompt) {
1177
+ busySessions.set(sessionId, true);
1178
+ handle.busy = true;
1179
+ handle.isPrompting = true;
1180
+ handle.promptStartedAt = new Date();
1181
+ monitor.markPromptStart(sessionId);
1182
+ archiveSession(handle);
1183
+ try {
1184
+ const promptResult = (await withTimeoutMs(adapter.prompt(params.initPrompt), config.toolTimeouts?.prompt ?? config.stallTimeoutMs, `acp_worker_spawn:${name}:init`)) as AcpPromptResult;
1185
+ markPromptLifecycle(handle, promptResult);
1186
+ } finally {
1187
+ busySessions.delete(sessionId);
1188
+ handle.busy = false;
1189
+ handle.isPrompting = false;
1190
+ monitor.markPromptEnd(sessionId);
1191
+ archiveSession(handle);
1192
+ }
1193
+ }
1194
+ return { name, sessionId, status: "online" };
1195
+ } catch (err) {
1196
+ adapter.dispose();
1197
+ throw err;
1198
+ }
1199
+ }, `acp_worker_spawn(${name})`);
1200
+
1201
+ refreshWidget(ctx);
1202
+ if (result.ok) {
1203
+ return {
1204
+ content: [textContent(`Worker '${result.value.name}' spawned with session ${result.value.sessionId} (status: ${result.value.status})`)],
1205
+ details: result.value,
1206
+ };
1207
+ }
1208
+ return {
1209
+ content: [textContent(`Failed to spawn worker '${name}': ${result.error}`)],
1210
+ details: { error: result.error, circuitOpen: result.circuitOpen },
1211
+ };
1212
+ },
1213
+ });
1214
+
1215
+ if (isToolEnabled(toolSettings, "acp_worker_list")) pi.registerTool({
1216
+ name: "acp_worker_list",
1217
+ label: "ACP Worker List",
1218
+ description: "List all persistent ACP workers with their status and liveliness counters (tokenCountTotal, toolCallCount, age since last activity).",
1219
+ promptSnippet: "acp_worker_list — list all persistent ACP workers with status and liveliness",
1220
+ parameters: Type.Object({
1221
+ filter: Type.Optional(Type.String({ description: "Filter by derived status: 'online', 'idle', 'busy', 'stale', 'offline'" })),
1222
+ }),
1223
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1224
+ // Fetch all workers, then filter by derived status if requested
1225
+ const allWorkers = workerStore.list();
1226
+ const rawFilter = params.filter as string | undefined;
1227
+ const workers = rawFilter
1228
+ ? allWorkers.filter((w) => {
1229
+ const derived = deriveWorkerStatus(w);
1230
+ // For 'stale', match the stale(Ns) prefix
1231
+ if (rawFilter === "stale") return derived.status.startsWith("stale");
1232
+ return derived.status === rawFilter;
1233
+ })
1234
+ : allWorkers;
1235
+
1236
+ if (workers.length === 0) {
1237
+ return {
1238
+ content: [textContent("No workers found")],
1239
+ details: { workers: [] },
1240
+ };
1241
+ }
1242
+
1243
+ const now = Date.now();
1244
+ const lines = workers.map((w) => {
1245
+ const ageSec = Math.floor((now - new Date(w.lastActivityAt).getTime()) / 1000);
1246
+ const tok = w.tokenCountTotal ?? 0;
1247
+ const tools = w.toolCallCount ?? 0;
1248
+ const taskInfo = w.currentTaskId ? ` · task=${w.currentTaskId}` : "";
1249
+ const derived = deriveWorkerStatus(w);
1250
+ const staleIndicator = isWorkerStale(w) ? " ⚠ stale" : "";
1251
+ return `${w.name}: ${derived.status} · tok=${tok} · tools=${tools} · ${ageSec}s ago${taskInfo}${staleIndicator}`;
1252
+ });
1253
+
1254
+ refreshWidget(ctx);
1255
+ return {
1256
+ content: [textContent(`Workers (${workers.length}):\n${lines.join("\n")}`)],
1257
+ details: {
1258
+ workers: workers.map((w) => {
1259
+ const derived = deriveWorkerStatus(w);
1260
+ return {
1261
+ name: w.name,
1262
+ status: w.status,
1263
+ derivedStatus: derived.status,
1264
+ agentName: w.agentName,
1265
+ sessionId: w.sessionId,
1266
+ currentTaskId: w.currentTaskId,
1267
+ tokenCountTotal: w.tokenCountTotal ?? 0,
1268
+ toolCallCount: w.toolCallCount ?? 0,
1269
+ ageSeconds: Math.floor((now - new Date(w.lastActivityAt).getTime()) / 1000),
1270
+ spawnedAt: w.spawnedAt,
1271
+ lastActivityAt: w.lastActivityAt,
1272
+ lastHeartbeatAt: w.lastHeartbeatAt,
1273
+ };
1274
+ }),
1275
+ count: workers.length,
1276
+ },
1277
+ };
1278
+ },
1279
+ });
1280
+
1281
+ // ── Worker Steer (4.1-4.5) ──
1282
+
1283
+ if (isToolEnabled(toolSettings, "acp_worker_steer")) pi.registerTool({
1284
+ name: "acp_worker_steer",
1285
+ label: "ACP Worker Steer",
1286
+ description: "Send a steering message to a persistent ACP worker. If the worker is busy (in-flight turn), attempts to interrupt the active session. If idle, queues the steer as a prefix for the next prompt the dispatcher issues.",
1287
+ promptSnippet: "acp_worker_steer — send a steering message to a worker",
1288
+ parameters: Type.Object({
1289
+ name: Type.String({ description: "Worker name" }),
1290
+ message: Type.String({ description: "Steering message to send" }),
1291
+ }),
1292
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1293
+ const name = requireString(params.name, "name");
1294
+ const message = requireString(params.message, "message");
1295
+
1296
+ // 4.5: Return error if worker not found
1297
+ const worker = workerStore.get(name);
1298
+ if (!worker) {
1299
+ return { content: [textContent(`Worker '${name}' not found`)], details: { error: "worker_not_found", name } };
1300
+ }
1301
+
1302
+ // Resolve worker's session
1303
+ const sessionId = worker.sessionId;
1304
+ const adapter = activeAdapters.get(sessionId);
1305
+ const isBusy = !!worker.currentTaskId || !!busySessions.get(sessionId);
1306
+
1307
+ if (isBusy && adapter) {
1308
+ // 4.1/4.3: Attempt interrupt for in-flight workers
1309
+ try {
1310
+ await adapter.cancel();
1311
+ // Cancel succeeded — queue steer for next prompt after worker returns to idle
1312
+ workerStore.updateMetadata(name, { pendingSteer: message });
1313
+ eventLog.append("worker_steer_interrupt", { name, sessionId, message });
1314
+ refreshWidget(ctx);
1315
+ return {
1316
+ content: [textContent(`Interrupt sent to worker '${name}'. Steer queued for next prompt: ${message}`)],
1317
+ details: { name, message, interruptAttempted: true, queued: true },
1318
+ };
1319
+ } catch (err) {
1320
+ // Interrupt failed — queue as next-prompt-prefix with warning
1321
+ workerStore.updateMetadata(name, { pendingSteer: message });
1322
+ eventLog.append("worker_steer_queued", { name, sessionId, message, reason: "interrupt_failed" });
1323
+ refreshWidget(ctx);
1324
+ return {
1325
+ content: [textContent(`Provider does not support live interrupt; steer queued for next prompt. Worker '${name}': ${message}`)],
1326
+ details: { name, message, interruptAttempted: true, queued: true, warning: true },
1327
+ };
1328
+ }
1329
+ }
1330
+
1331
+ // Idle or no adapter — queue as next-prompt-prefix (4.4)
1332
+ workerStore.updateMetadata(name, { pendingSteer: message });
1333
+ eventLog.append("worker_steer_queued", { name, sessionId, message, reason: isBusy ? "no_adapter" : "idle" });
1334
+ refreshWidget(ctx);
1335
+ return { content: [textContent(`Steer message queued for worker '${name}': ${message}`)], details: { name, message, queued: true } };
1336
+ },
1337
+ });
1338
+
1339
+ // ── Worker Shutdown (5.1-5.2) ──
1340
+
1341
+ if (isToolEnabled(toolSettings, "acp_worker_shutdown")) pi.registerTool({
1342
+ name: "acp_worker_shutdown",
1343
+ label: "ACP Worker Shutdown",
1344
+ description: "Gracefully shut down a persistent ACP worker. Waits for in-flight turn to complete (with timeout), then disposes the session and marks the worker offline.",
1345
+ promptSnippet: "acp_worker_shutdown — gracefully shut down a worker",
1346
+ parameters: Type.Object({
1347
+ name: Type.Optional(Type.String({ description: "Worker name to shut down" })),
1348
+ all: Type.Optional(Type.Boolean({ description: "Shut down all workers" })),
1349
+ }),
1350
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1351
+ const shutdownTimeoutMs = config.workerShutdownTimeoutMs ?? 30_000;
1352
+
1353
+ // Determine which workers to shut down
1354
+ let targets: string[];
1355
+ if (params.all) {
1356
+ targets = workerStore.list().filter((w) => w.status !== "offline").map((w) => w.name);
1357
+ } else if (params.name) {
1358
+ const worker = workerStore.get(params.name);
1359
+ if (!worker) {
1360
+ return { content: [textContent(`Worker '${params.name}' not found`)], details: { error: "worker_not_found" } };
1361
+ }
1362
+ targets = [params.name];
1363
+ } else {
1364
+ return { content: [textContent("Specify 'name' or 'all'")], details: { error: "missing_params" } };
1365
+ }
1366
+
1367
+ const results: Array<{ name: string; ok: boolean; error?: string }> = [];
1368
+
1369
+ for (const name of targets) {
1370
+ const worker = workerStore.get(name);
1371
+ if (!worker) continue;
1372
+
1373
+ try {
1374
+ // 5.2: Wait for turn completion if busy
1375
+ if (worker.currentTaskId) {
1376
+ const startTime = Date.now();
1377
+ while (worker.currentTaskId && (Date.now() - startTime) < shutdownTimeoutMs) {
1378
+ await new Promise((r) => setTimeout(r, 500));
1379
+ const fresh = workerStore.get(name);
1380
+ if (fresh && !fresh.currentTaskId) break;
1381
+ }
1382
+ // Check if still busy after timeout
1383
+ const afterWait = workerStore.get(name);
1384
+ if (afterWait?.currentTaskId) {
1385
+ results.push({ name, ok: false, error: `Shutdown timed out; worker '${name}' still busy. Use acp_worker_kill to force.` });
1386
+ continue;
1387
+ }
1388
+ }
1389
+
1390
+ // Dispose session
1391
+ const sessionId = worker.sessionId;
1392
+ const adapter = activeAdapters.get(sessionId);
1393
+ if (adapter) {
1394
+ adapter.dispose();
1395
+ activeAdapters.delete(sessionId);
1396
+ }
1397
+ workerSessionMap.delete(sessionId);
1398
+ busySessions.delete(sessionId);
1399
+
1400
+ // Mark worker offline
1401
+ workerStore.updateStatus(name, "offline");
1402
+
1403
+ // 6.2: Emit worker_shutdown event
1404
+ eventLog.append("worker_shutdown", { name, sessionId });
1405
+
1406
+ results.push({ name, ok: true });
1407
+ } catch (err) {
1408
+ results.push({ name, ok: false, error: err instanceof Error ? err.message : String(err) });
1409
+ }
1410
+ }
1411
+
1412
+ refreshWidget(ctx);
1413
+ const failed = results.filter((r) => !r.ok);
1414
+ return {
1415
+ content: [textContent(`Shutdown ${results.length} workers: ${failed.length} failed` + (failed.length > 0 ? ` (${failed.map((f) => f.error).join(", ")})` : ""))],
1416
+ details: { results },
1417
+ };
1418
+ },
1419
+ });
1420
+
1421
+ // ── Worker Kill (5.3) ──
1422
+
1423
+ if (isToolEnabled(toolSettings, "acp_worker_kill")) pi.registerTool({
1424
+ name: "acp_worker_kill",
1425
+ label: "ACP Worker Kill",
1426
+ description: "Force-kill a persistent ACP worker. Immediately disposes the session, unassigns active tasks (set to pending), and marks the worker offline.",
1427
+ promptSnippet: "acp_worker_kill — force-kill a worker",
1428
+ parameters: Type.Object({
1429
+ name: Type.String({ description: "Worker name to kill" }),
1430
+ }),
1431
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1432
+ const name = requireString(params.name, "name");
1433
+ const worker = workerStore.get(name);
1434
+ if (!worker) {
1435
+ return { content: [textContent(`Worker '${name}' not found`)], details: { error: "worker_not_found" } };
1436
+ }
1437
+
1438
+ // Force-dispose session
1439
+ const sessionId = worker.sessionId;
1440
+ const adapter = activeAdapters.get(sessionId);
1441
+ if (adapter) {
1442
+ adapter.dispose();
1443
+ activeAdapters.delete(sessionId);
1444
+ }
1445
+ workerSessionMap.delete(sessionId);
1446
+ busySessions.delete(sessionId);
1447
+
1448
+ // Unassign active tasks (set to pending)
1449
+ if (worker.currentTaskId) {
1450
+ try {
1451
+ taskStore.update(worker.currentTaskId, (t) => { t.status = "pending"; });
1452
+ } catch { /* ignore */ }
1453
+ workerStore.unassignTask(name);
1454
+ }
1455
+
1456
+ // Mark worker offline
1457
+ workerStore.updateStatus(name, "offline");
1458
+
1459
+ // 6.2: Emit worker_shutdown event
1460
+ eventLog.append("worker_shutdown", { name, sessionId, forced: true });
1461
+
1462
+ refreshWidget(ctx);
1463
+ return { content: [textContent(`Worker '${name}' killed`)], details: { name, sessionId } };
1464
+ },
1465
+ });
1466
+
1467
+ // ── Worker Prune (5.4) ──
1468
+
1469
+ if (isToolEnabled(toolSettings, "acp_worker_prune")) pi.registerTool({
1470
+ name: "acp_worker_prune",
1471
+ label: "ACP Worker Prune",
1472
+ description: "Prune stale persistent workers. Finds all workers with derived stale status, unassigns active tasks, marks them offline, and returns the list of pruned workers.",
1473
+ promptSnippet: "acp_worker_prune — prune stale workers",
1474
+ parameters: Type.Object({}),
1475
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1476
+ const workers = workerStore.list();
1477
+ const pruned: string[] = [];
1478
+
1479
+ for (const w of workers) {
1480
+ if (w.status === "offline") continue;
1481
+ const derived = deriveWorkerStatus(w);
1482
+ if (derived.stale) {
1483
+ // Unassign active tasks
1484
+ if (w.currentTaskId) {
1485
+ try {
1486
+ taskStore.update(w.currentTaskId, (t) => { t.status = "pending"; });
1487
+ } catch { /* ignore */ }
1488
+ workerStore.unassignTask(w.name);
1489
+ }
1490
+ // Mark offline
1491
+ workerStore.updateStatus(w.name, "offline");
1492
+ pruned.push(w.name);
1493
+ }
1494
+ }
1495
+
1496
+ refreshWidget(ctx);
1497
+ return {
1498
+ content: [textContent(pruned.length > 0 ? `Pruned ${pruned.length} stale workers: ${pruned.join(", ")}` : "No stale workers found")],
1499
+ details: { pruned, count: pruned.length },
1500
+ };
1501
+ },
1502
+ });
1503
+
1504
+ pi.registerCommand("acp-doctor", {
1505
+ description: "Compatibility alias for /acp runtime doctor",
1506
+ async handler(_args, ctx) {
1507
+ showAcpDoctor(ctx);
1508
+ },
1509
+ });
1510
+
1511
+ pi.on("session_shutdown", async () => {
1512
+ monitor.stop();
1513
+ workerDispatcher?.stop();
1514
+ await sessionMgr.disposeAll();
1515
+ for (const adapter of activeAdapters.values()) {
1516
+ adapter.dispose();
1517
+ }
1518
+ activeAdapters.clear();
1519
+ eventLog.append("session_shutdown_all");
1520
+ });
1521
+ }