@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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/index.ts +1521 -0
- package/package.json +103 -0
- package/skills/pi-acp-agents/SKILL.md +112 -0
- package/src/acp-widget.ts +379 -0
- package/src/adapter-factory.ts +55 -0
- package/src/adapters/acpx.ts +215 -0
- package/src/adapters/base.ts +117 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/custom.ts +14 -0
- package/src/adapters/gemini.ts +66 -0
- package/src/adapters/opencode.ts +101 -0
- package/src/config/config.ts +312 -0
- package/src/config/types.ts +203 -0
- package/src/coordination/alias-resolver.ts +208 -0
- package/src/coordination/coordinator.ts +266 -0
- package/src/coordination/worker-dispatcher.ts +191 -0
- package/src/core/async-executor.ts +149 -0
- package/src/core/circuit-breaker.ts +254 -0
- package/src/core/client.ts +661 -0
- package/src/core/health-monitor.ts +200 -0
- package/src/core/protocol-validator.ts +259 -0
- package/src/core/session-lifecycle.ts +46 -0
- package/src/core/session-manager.ts +64 -0
- package/src/extension-safety.ts +200 -0
- package/src/logger.ts +92 -0
- package/src/management/event-log.ts +31 -0
- package/src/management/governance-store.ts +123 -0
- package/src/management/heartbeat-parser.ts +92 -0
- package/src/management/mailbox-manager.ts +95 -0
- package/src/management/runtime-paths.ts +34 -0
- package/src/management/safe-mkdir.ts +78 -0
- package/src/management/session-archive-store.ts +136 -0
- package/src/management/session-name-store.ts +88 -0
- package/src/management/task-store.ts +260 -0
- package/src/management/worker-store.ts +164 -0
- package/src/public-api.ts +72 -0
- package/src/settings/agent-config-tui.ts +456 -0
- package/src/settings/agents-command.ts +138 -0
- package/src/settings/config.ts +201 -0
- 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
|
+
}
|