@enderfga/claw-orchestrator 4.12.2 → 4.13.0
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/README.md +16 -0
- package/dist/bin/acp-server.d.ts +2 -0
- package/dist/bin/acp-server.js +83 -0
- package/dist/bin/acp-server.js.map +1 -0
- package/dist/bin/cli.js +12 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/src/acp-server.d.ts +188 -0
- package/dist/src/acp-server.js +656 -0
- package/dist/src/acp-server.js.map +1 -0
- package/dist/src/constants.d.ts +2 -0
- package/dist/src/constants.js +2 -0
- package/dist/src/constants.js.map +1 -1
- package/dist/src/index.js +12 -1
- package/dist/src/index.js.map +1 -1
- package/package.json +4 -2
- package/skills/SKILL.md +11 -1
- package/skills/references/acp.md +173 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Client Protocol (ACP) adapter — claw-orchestrator as an ACP *agent*.
|
|
3
|
+
*
|
|
4
|
+
* ACP is the editor↔coding-agent standard (Zed, JetBrains, Neovim, Emacs, the
|
|
5
|
+
* VS Code ACP extension all speak it as clients; `dsh`'s `subagent-acp` provider
|
|
6
|
+
* spawns an arbitrary ACP server as a subagent). Every agent in the ecosystem is
|
|
7
|
+
* a single agent; this one is a fleet, so pointing any of those clients at it
|
|
8
|
+
* gives them a cross-engine session they cannot get anywhere else.
|
|
9
|
+
*
|
|
10
|
+
* This module is the protocol adapter only — it owns translation, not transport.
|
|
11
|
+
* `bin/acp-server.ts` supplies the stdio stream and a stderr-only logger. The
|
|
12
|
+
* split, the module-private structural `SessionManagerLike`, and the
|
|
13
|
+
* "exported pure helpers, testable without a process" shape all mirror
|
|
14
|
+
* `src/openai-compat.ts`, which is the same kind of adapter over the same
|
|
15
|
+
* manager.
|
|
16
|
+
*
|
|
17
|
+
* Built against **stable ACP v1** (`@agentclientprotocol/sdk` 1.3.0). ACP v2 is
|
|
18
|
+
* a published draft whose wire protocol may change incompatibly in any SDK
|
|
19
|
+
* release, so it is deliberately not used.
|
|
20
|
+
*/
|
|
21
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
22
|
+
import { ACP_SESSION_PREFIX } from './constants.js';
|
|
23
|
+
import { getContextWindow, getModelList, resolveEngineAndModel } from './models.js';
|
|
24
|
+
// ─── Modes ──────────────────────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Session modes advertised to the client.
|
|
27
|
+
*
|
|
28
|
+
* ACP renders these as a picker, which is the natural home for "what shape of
|
|
29
|
+
* orchestration should this turn use" — and it is the whole point of this agent:
|
|
30
|
+
* a single-engine agent has nothing to put here.
|
|
31
|
+
*/
|
|
32
|
+
export const ACP_MODES = [
|
|
33
|
+
{
|
|
34
|
+
id: 'single',
|
|
35
|
+
name: 'Single agent',
|
|
36
|
+
description: 'One engine answers the turn. The default, and the fastest.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'council',
|
|
40
|
+
name: 'Council',
|
|
41
|
+
description: 'Several engines debate in isolated git worktrees and vote on a result.',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'ultraplan',
|
|
45
|
+
name: 'Ultraplan',
|
|
46
|
+
description: 'Long-horizon planning pass; produces a plan rather than edits.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'ultrareview',
|
|
50
|
+
name: 'Ultrareview',
|
|
51
|
+
description: 'Parallel reviewers sweep the working tree and a synthesis pass merges findings.',
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
export const ACP_DEFAULT_MODE = 'single';
|
|
55
|
+
/**
|
|
56
|
+
* Council defaults for the ACP path, deliberately far below the library's own.
|
|
57
|
+
*
|
|
58
|
+
* `getDefaultCouncilConfig` is tuned for a long unattended run: three agents,
|
|
59
|
+
* fifteen rounds, a thirty-minute per-agent timeout, and one session spawned per
|
|
60
|
+
* agent *per round*. Behind an editor turn that is the wrong shape entirely — a
|
|
61
|
+
* client is waiting — so the ACP path uses two agents on distinct engines and
|
|
62
|
+
* three rounds. It also names engines explicitly rather than inheriting the
|
|
63
|
+
* library defaults, which still reference the retired Gemini CLI.
|
|
64
|
+
*/
|
|
65
|
+
export const ACP_COUNCIL_MAX_ROUNDS = 3;
|
|
66
|
+
const ACP_COUNCIL_AGENTS = [
|
|
67
|
+
{
|
|
68
|
+
name: 'Builder',
|
|
69
|
+
emoji: '🟠',
|
|
70
|
+
engine: 'claude',
|
|
71
|
+
persona: 'You are an implementation engineer. Propose the smallest correct change that satisfies the task, and say plainly what you are unsure of rather than papering over it.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'Critic',
|
|
75
|
+
emoji: '🟢',
|
|
76
|
+
engine: 'codex',
|
|
77
|
+
persona: 'You are an independent quality gate. Do not assume the other agent is right — look for cases where the proposal breaks, and give either a blocking issue list or a reasoned approval.',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
/** How often the poll-only orchestrations are checked, and how long they may run. */
|
|
81
|
+
const ACP_POLL_INTERVAL_MS = 3_000;
|
|
82
|
+
const ACP_POLL_TIMEOUT_MS = 1_800_000;
|
|
83
|
+
/** Slash commands offered once a council run is parked at its human gate. */
|
|
84
|
+
export const ACP_COUNCIL_COMMANDS = [
|
|
85
|
+
{ name: 'council_accept', description: 'Accept the council result and merge the winning agent worktree.' },
|
|
86
|
+
{ name: 'council_reject', description: 'Reject the council result. Text after the command is passed as feedback.' },
|
|
87
|
+
];
|
|
88
|
+
// ─── Config options ─────────────────────────────────────────────────────────
|
|
89
|
+
export const ACP_CONFIG_MODEL = 'model';
|
|
90
|
+
export const ACP_CONFIG_PERMISSION = 'permission';
|
|
91
|
+
/** Human-facing group label per engine, used by the model selector. */
|
|
92
|
+
const ENGINE_LABELS = {
|
|
93
|
+
claude: 'Claude Code',
|
|
94
|
+
codex: 'Codex',
|
|
95
|
+
'codex-app': 'Codex (app-server)',
|
|
96
|
+
agy: 'Antigravity',
|
|
97
|
+
cursor: 'Cursor',
|
|
98
|
+
opencode: 'OpenCode',
|
|
99
|
+
custom: 'Custom',
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Engines kept out of the picker.
|
|
103
|
+
*
|
|
104
|
+
* `gemini` still works for callers that already name it, but the Gemini CLI is
|
|
105
|
+
* sunset and superseded by Antigravity, so offering it in a new user-facing
|
|
106
|
+
* selector would be advertising a dead end.
|
|
107
|
+
*
|
|
108
|
+
* `opencode` is absent for a different reason: its models are open-ended
|
|
109
|
+
* `provider/model` strings passed straight through, so there is nothing in the
|
|
110
|
+
* registry to enumerate. An opencode session is reachable by naming the model
|
|
111
|
+
* at session start, just not by picking it from this list.
|
|
112
|
+
*/
|
|
113
|
+
const HIDDEN_ENGINES = new Set(['gemini']);
|
|
114
|
+
/**
|
|
115
|
+
* The cross-engine model selector, grouped by engine.
|
|
116
|
+
*
|
|
117
|
+
* This is the cheapest thing that is impossible for a single-engine ACP agent:
|
|
118
|
+
* one dropdown in the editor holding Claude, GPT, Composer and OpenCode models
|
|
119
|
+
* at once. The values come from the shared registry in `models.ts`, so a model
|
|
120
|
+
* added there shows up here with no extra wiring.
|
|
121
|
+
*/
|
|
122
|
+
export function buildModelConfigOption(currentModel) {
|
|
123
|
+
const groups = new Map();
|
|
124
|
+
for (const entry of getModelList().data) {
|
|
125
|
+
const { engine } = resolveEngineAndModel(entry.id);
|
|
126
|
+
if (HIDDEN_ENGINES.has(engine))
|
|
127
|
+
continue;
|
|
128
|
+
const bucket = groups.get(engine) ?? [];
|
|
129
|
+
bucket.push({ value: entry.id, name: entry.id, description: entry.owned_by });
|
|
130
|
+
groups.set(engine, bucket);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
type: 'select',
|
|
134
|
+
id: ACP_CONFIG_MODEL,
|
|
135
|
+
name: 'Model',
|
|
136
|
+
description: 'Model for this session. Switching model switches engine with it.',
|
|
137
|
+
category: 'model',
|
|
138
|
+
currentValue: currentModel,
|
|
139
|
+
options: [...groups.entries()].map(([engine, options]) => ({
|
|
140
|
+
group: engine,
|
|
141
|
+
name: ENGINE_LABELS[engine] ?? engine,
|
|
142
|
+
options,
|
|
143
|
+
})),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Permission selector.
|
|
148
|
+
*
|
|
149
|
+
* ACP has `session/request_permission` for asking the user mid-turn, but nothing
|
|
150
|
+
* in this codebase can surface such a request: permission is resolved once into
|
|
151
|
+
* engine CLI flags at session start, and `permissionPromptTool` routes to an MCP
|
|
152
|
+
* tool the caller hosts rather than back through the manager. Offering a
|
|
153
|
+
* per-turn prompt we cannot honour would be worse than saying so, so the choice
|
|
154
|
+
* is made up-front instead — which also suits `dsh-subagent-acp`, whose default
|
|
155
|
+
* is to auto-reject permission requests.
|
|
156
|
+
*/
|
|
157
|
+
export function buildPermissionConfigOption(current) {
|
|
158
|
+
return {
|
|
159
|
+
type: 'select',
|
|
160
|
+
id: ACP_CONFIG_PERMISSION,
|
|
161
|
+
name: 'Permission',
|
|
162
|
+
description: 'How much the agent may do without asking. Chosen up-front, not per turn.',
|
|
163
|
+
currentValue: current,
|
|
164
|
+
options: [
|
|
165
|
+
{ value: 'plan', name: 'Plan (read-only)', description: 'Investigate and propose; never writes.' },
|
|
166
|
+
{ value: 'acceptEdits', name: 'Accept edits', description: 'May edit files in the workspace.' },
|
|
167
|
+
{ value: 'bypassPermissions', name: 'Full access', description: 'No prompts. Use in trusted workspaces.' },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Parse a leading slash command out of a prompt. */
|
|
172
|
+
export function parseSlashCommand(message) {
|
|
173
|
+
const match = /^\/([a-z_]+)\s*([\s\S]*)$/.exec(message.trim());
|
|
174
|
+
return match ? { name: match[1], rest: match[2].trim() } : null;
|
|
175
|
+
}
|
|
176
|
+
/** `session/new` ids are ours to mint; keep them short, opaque and prefixed. */
|
|
177
|
+
function mintSessionId() {
|
|
178
|
+
return `${ACP_SESSION_PREFIX}${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
|
|
179
|
+
}
|
|
180
|
+
/** Concatenate the text blocks of a prompt into one user message. */
|
|
181
|
+
export function flattenPromptContent(blocks) {
|
|
182
|
+
if (!Array.isArray(blocks))
|
|
183
|
+
return '';
|
|
184
|
+
const parts = [];
|
|
185
|
+
for (const block of blocks) {
|
|
186
|
+
const b = block;
|
|
187
|
+
if (b?.type === 'text' && typeof b.text === 'string')
|
|
188
|
+
parts.push(b.text);
|
|
189
|
+
// A resource link is flattened to a textual reference the model may open
|
|
190
|
+
// with its own tools; we do not fetch it on the model's behalf.
|
|
191
|
+
else if (b?.type === 'resource_link' && b.uri)
|
|
192
|
+
parts.push(`[resource_link name=${b.name ?? ''} uri=${b.uri}]`);
|
|
193
|
+
}
|
|
194
|
+
return parts.join('');
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Build the ACP agent over a SessionManager.
|
|
198
|
+
*
|
|
199
|
+
* Returns the configured `AgentApp` without connecting it, so a test can drive
|
|
200
|
+
* the handlers directly and `bin/acp-server.ts` owns the stdio stream.
|
|
201
|
+
*/
|
|
202
|
+
export function createAcpAgent(manager, options = {}) {
|
|
203
|
+
const sessions = new Map();
|
|
204
|
+
const defaultModel = options.defaultModel || 'claude-sonnet-4-6';
|
|
205
|
+
const defaultPermissionMode = options.defaultPermissionMode || 'acceptEdits';
|
|
206
|
+
const log = options.logger;
|
|
207
|
+
const stateFor = (sessionId) => {
|
|
208
|
+
const state = sessions.get(sessionId);
|
|
209
|
+
if (!state)
|
|
210
|
+
throw acp.RequestError.invalidParams(`Unknown session: ${sessionId}`);
|
|
211
|
+
return state;
|
|
212
|
+
};
|
|
213
|
+
return acp
|
|
214
|
+
.agent({ name: 'claw-orchestrator' })
|
|
215
|
+
.onRequest('initialize', () => ({
|
|
216
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
217
|
+
agentCapabilities: {
|
|
218
|
+
// Resume is deliberately not advertised: mapping an ACP session id onto
|
|
219
|
+
// each engine's own resume handle (codex thread id, agy's log-harvested
|
|
220
|
+
// conversation id, cursor/opencode session ids) is its own piece of work,
|
|
221
|
+
// and claiming the capability without it would strand a client.
|
|
222
|
+
loadSession: false,
|
|
223
|
+
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
|
224
|
+
},
|
|
225
|
+
}))
|
|
226
|
+
.onRequest('authenticate', () => ({}))
|
|
227
|
+
.onRequest('session/new', async (ctx) => {
|
|
228
|
+
const cwd = ctx.params.cwd;
|
|
229
|
+
if (!cwd || !cwd.startsWith('/')) {
|
|
230
|
+
throw acp.RequestError.invalidParams('cwd must be an absolute path');
|
|
231
|
+
}
|
|
232
|
+
const sessionId = mintSessionId();
|
|
233
|
+
const { engine, model } = resolveEngineAndModel(defaultModel);
|
|
234
|
+
await manager.startSession({
|
|
235
|
+
name: sessionId,
|
|
236
|
+
cwd,
|
|
237
|
+
engine,
|
|
238
|
+
model,
|
|
239
|
+
permissionMode: defaultPermissionMode,
|
|
240
|
+
skipPersistence: true,
|
|
241
|
+
});
|
|
242
|
+
sessions.set(sessionId, {
|
|
243
|
+
name: sessionId,
|
|
244
|
+
cwd,
|
|
245
|
+
model,
|
|
246
|
+
engine,
|
|
247
|
+
permissionMode: defaultPermissionMode,
|
|
248
|
+
modeId: ACP_DEFAULT_MODE,
|
|
249
|
+
});
|
|
250
|
+
log?.info(`session/new ${sessionId} engine=${engine} model=${model} cwd=${cwd}`);
|
|
251
|
+
return {
|
|
252
|
+
sessionId,
|
|
253
|
+
modes: { currentModeId: ACP_DEFAULT_MODE, availableModes: ACP_MODES },
|
|
254
|
+
configOptions: [buildModelConfigOption(model), buildPermissionConfigOption(defaultPermissionMode)],
|
|
255
|
+
};
|
|
256
|
+
})
|
|
257
|
+
.onRequest('session/set_mode', (ctx) => {
|
|
258
|
+
const state = stateFor(ctx.params.sessionId);
|
|
259
|
+
const mode = ACP_MODES.find((m) => m.id === ctx.params.modeId);
|
|
260
|
+
if (!mode)
|
|
261
|
+
throw acp.RequestError.invalidParams(`Unknown mode: ${ctx.params.modeId}`);
|
|
262
|
+
state.modeId = mode.id;
|
|
263
|
+
return {};
|
|
264
|
+
})
|
|
265
|
+
.onRequest('session/set_config_option', async (ctx) => {
|
|
266
|
+
const state = stateFor(ctx.params.sessionId);
|
|
267
|
+
const value = String(ctx.params.value ?? '');
|
|
268
|
+
if (ctx.params.configId === ACP_CONFIG_MODEL) {
|
|
269
|
+
const { engine, model } = resolveEngineAndModel(value);
|
|
270
|
+
// Engine is fixed at spawn time, so a model that changes engine has to
|
|
271
|
+
// be a new underlying session. The ACP session id is unaffected.
|
|
272
|
+
await manager.stopSession(state.name).catch(() => { });
|
|
273
|
+
await manager.startSession({
|
|
274
|
+
name: state.name,
|
|
275
|
+
cwd: state.cwd,
|
|
276
|
+
engine,
|
|
277
|
+
model,
|
|
278
|
+
permissionMode: state.permissionMode,
|
|
279
|
+
skipPersistence: true,
|
|
280
|
+
});
|
|
281
|
+
state.engine = engine;
|
|
282
|
+
state.model = model;
|
|
283
|
+
}
|
|
284
|
+
else if (ctx.params.configId === ACP_CONFIG_PERMISSION) {
|
|
285
|
+
state.permissionMode = value;
|
|
286
|
+
await manager.stopSession(state.name).catch(() => { });
|
|
287
|
+
await manager.startSession({
|
|
288
|
+
name: state.name,
|
|
289
|
+
cwd: state.cwd,
|
|
290
|
+
engine: state.engine,
|
|
291
|
+
model: state.model,
|
|
292
|
+
permissionMode: state.permissionMode,
|
|
293
|
+
skipPersistence: true,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
throw acp.RequestError.invalidParams(`Unknown config option: ${ctx.params.configId}`);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
configOptions: [buildModelConfigOption(state.model), buildPermissionConfigOption(state.permissionMode)],
|
|
301
|
+
};
|
|
302
|
+
})
|
|
303
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
304
|
+
const state = stateFor(ctx.params.sessionId);
|
|
305
|
+
const sessionId = ctx.params.sessionId;
|
|
306
|
+
const message = flattenPromptContent(ctx.params.prompt);
|
|
307
|
+
if (!message.trim())
|
|
308
|
+
throw acp.RequestError.invalidParams('Prompt contained no text content');
|
|
309
|
+
const emit = (update) => ctx.client.notify('session/update', { sessionId, update });
|
|
310
|
+
const say = (text) => emit({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
|
|
311
|
+
// A parked council owns the turn until it is accepted or rejected: any
|
|
312
|
+
// other prompt would start a second run over the same worktrees.
|
|
313
|
+
const command = parseSlashCommand(message);
|
|
314
|
+
if (state.parkedCouncilId) {
|
|
315
|
+
const decided = await resolveParkedCouncil(manager, state, command, say, emit);
|
|
316
|
+
if (decided)
|
|
317
|
+
return { stopReason: 'end_turn' };
|
|
318
|
+
}
|
|
319
|
+
else if (command && command.name.startsWith('council_')) {
|
|
320
|
+
throw acp.RequestError.invalidParams('No council is awaiting a decision.');
|
|
321
|
+
}
|
|
322
|
+
if (state.modeId === 'council') {
|
|
323
|
+
return runCouncilMode(manager, state, sessionId, message, emit, say, log);
|
|
324
|
+
}
|
|
325
|
+
if (state.modeId === 'ultraplan' || state.modeId === 'ultrareview') {
|
|
326
|
+
return runPollingMode(manager, state, message, emit, say);
|
|
327
|
+
}
|
|
328
|
+
// There is no mid-turn cancel in the session layer, so cancellation is
|
|
329
|
+
// modelled here: the prompt races a settle-on-cancel promise, and the
|
|
330
|
+
// underlying session is torn down separately. The turn returns promptly
|
|
331
|
+
// even though the engine subprocess may take a moment longer to die.
|
|
332
|
+
let cancelled = false;
|
|
333
|
+
const cancelSignal = new Promise((resolve) => {
|
|
334
|
+
state.cancelInFlight = () => {
|
|
335
|
+
cancelled = true;
|
|
336
|
+
resolve('cancelled');
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
// `sendMessage` reports the whole answer as its return value AND streams it
|
|
340
|
+
// through `onChunk` for engines that have a delta channel. Emitting both
|
|
341
|
+
// would send the answer twice, so the final block is only emitted when
|
|
342
|
+
// nothing streamed — which is the case for one-shot wrappers.
|
|
343
|
+
let streamedChars = 0;
|
|
344
|
+
const turn = manager.sendMessage(state.name, message, {
|
|
345
|
+
onChunk: (chunk) => {
|
|
346
|
+
if (cancelled || !chunk)
|
|
347
|
+
return;
|
|
348
|
+
streamedChars += chunk.length;
|
|
349
|
+
void ctx.client.notify('session/update', {
|
|
350
|
+
sessionId,
|
|
351
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk } },
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
onEvent: (event) => {
|
|
355
|
+
if (cancelled)
|
|
356
|
+
return;
|
|
357
|
+
if (event.type === 'tool_use' && event.tool?.name) {
|
|
358
|
+
void ctx.client.notify('session/update', {
|
|
359
|
+
sessionId,
|
|
360
|
+
update: {
|
|
361
|
+
sessionUpdate: 'tool_call',
|
|
362
|
+
toolCallId: `${sessionId}-${event.tool.name}-${Date.now()}`,
|
|
363
|
+
title: event.tool.name,
|
|
364
|
+
kind: 'other',
|
|
365
|
+
status: 'in_progress',
|
|
366
|
+
rawInput: (event.tool.input ?? {}),
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
try {
|
|
373
|
+
const raced = await Promise.race([turn, cancelSignal]);
|
|
374
|
+
if (raced === 'cancelled')
|
|
375
|
+
return { stopReason: 'cancelled' };
|
|
376
|
+
// Engines that never stream (one-shot wrappers with no delta channel)
|
|
377
|
+
// still have to deliver something, and the text stream is the only
|
|
378
|
+
// channel some consumers read — dsh's ACP subagent collects nothing else.
|
|
379
|
+
const output = raced.output ?? '';
|
|
380
|
+
if (output && streamedChars === 0) {
|
|
381
|
+
await ctx.client.notify('session/update', {
|
|
382
|
+
sessionId,
|
|
383
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: output } },
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
await emitUsage(manager, state, emit);
|
|
387
|
+
return { stopReason: 'end_turn' };
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
if (cancelled)
|
|
391
|
+
return { stopReason: 'cancelled' };
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
state.cancelInFlight = undefined;
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
.onNotification('session/cancel', (ctx) => {
|
|
399
|
+
const state = sessions.get(ctx.params.sessionId);
|
|
400
|
+
if (!state)
|
|
401
|
+
return;
|
|
402
|
+
state.cancelInFlight?.();
|
|
403
|
+
// Best-effort teardown. `stopSession` is the only lever the session layer
|
|
404
|
+
// offers, and it destroys the session rather than pausing the turn, so the
|
|
405
|
+
// session is recreated lazily on the next prompt.
|
|
406
|
+
void manager
|
|
407
|
+
.stopSession(state.name)
|
|
408
|
+
.then(() => manager.startSession({
|
|
409
|
+
name: state.name,
|
|
410
|
+
cwd: state.cwd,
|
|
411
|
+
engine: state.engine,
|
|
412
|
+
model: state.model,
|
|
413
|
+
permissionMode: state.permissionMode,
|
|
414
|
+
skipPersistence: true,
|
|
415
|
+
}))
|
|
416
|
+
.catch((err) => log?.warn(`cancel teardown failed for ${state.name}: ${String(err)}`));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Run a council behind one ACP turn.
|
|
421
|
+
*
|
|
422
|
+
* The council emits progress on an EventEmitter and parks at a human gate rather
|
|
423
|
+
* than finishing, so the translation is not a straight pipe:
|
|
424
|
+
*
|
|
425
|
+
* - Each agent becomes a `tool_call` the client can collapse, so an editor shows
|
|
426
|
+
* who is thinking and how far along they are.
|
|
427
|
+
* - Agent deltas are buffered per agent and delivered on that agent's
|
|
428
|
+
* `tool_call_update`, NOT streamed into `agent_message_chunk`. Several agents
|
|
429
|
+
* speak at once, and a consumer that only reads the text stream — `dsh`'s ACP
|
|
430
|
+
* subagent reads nothing else — would receive them interleaved into one
|
|
431
|
+
* unreadable blob.
|
|
432
|
+
* - Only the synthesis reaches the text stream, which keeps that stream
|
|
433
|
+
* self-sufficient without making it a transcript of everyone at once.
|
|
434
|
+
*/
|
|
435
|
+
async function runCouncilMode(manager, state, sessionId, task, emit, say, log) {
|
|
436
|
+
if (!manager.councilStart || !manager.getCouncil || !manager.councilStatus) {
|
|
437
|
+
throw acp.RequestError.internalError('Council is not available on this manager');
|
|
438
|
+
}
|
|
439
|
+
let council;
|
|
440
|
+
try {
|
|
441
|
+
council = manager.councilStart(task, {
|
|
442
|
+
name: 'ACP Council',
|
|
443
|
+
agents: ACP_COUNCIL_AGENTS.map((a) => ({ ...a, permissionMode: state.permissionMode })),
|
|
444
|
+
maxRounds: ACP_COUNCIL_MAX_ROUNDS,
|
|
445
|
+
projectDir: state.cwd,
|
|
446
|
+
defaultPermissionMode: state.permissionMode,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
// Council refuses to run outside a git repo, on a too-short task, and on a
|
|
451
|
+
// few other guardrails. Those are the caller's problem to fix, so report the
|
|
452
|
+
// reason rather than a bare failure.
|
|
453
|
+
throw acp.RequestError.invalidParams(`Council could not start: ${err.message}`);
|
|
454
|
+
}
|
|
455
|
+
const buffers = new Map();
|
|
456
|
+
const toolCallId = (agent, round) => `${sessionId}-${agent}-r${round ?? 0}`;
|
|
457
|
+
const emitter = manager.getCouncil(council.id);
|
|
458
|
+
let poll;
|
|
459
|
+
let timeout;
|
|
460
|
+
const terminal = new Promise((resolve) => {
|
|
461
|
+
let settled = false;
|
|
462
|
+
const done = () => {
|
|
463
|
+
if (settled)
|
|
464
|
+
return;
|
|
465
|
+
settled = true;
|
|
466
|
+
// Clear here rather than after the await: the backstop poll and the
|
|
467
|
+
// 30-minute cap must stop the moment the run is over, or every council
|
|
468
|
+
// turn leaves a live timer behind for the rest of the process's life.
|
|
469
|
+
if (poll)
|
|
470
|
+
clearInterval(poll);
|
|
471
|
+
if (timeout)
|
|
472
|
+
clearTimeout(timeout);
|
|
473
|
+
resolve();
|
|
474
|
+
};
|
|
475
|
+
emitter?.on('council-event', (event) => {
|
|
476
|
+
const agent = event.agent ?? 'agent';
|
|
477
|
+
switch (event.type) {
|
|
478
|
+
case 'round-start':
|
|
479
|
+
void emit({
|
|
480
|
+
sessionUpdate: 'plan',
|
|
481
|
+
entries: ACP_COUNCIL_AGENTS.map((a) => ({
|
|
482
|
+
content: `Round ${event.round ?? 1}: ${a.name} (${a.engine})`,
|
|
483
|
+
priority: 'medium',
|
|
484
|
+
status: 'in_progress',
|
|
485
|
+
})),
|
|
486
|
+
});
|
|
487
|
+
break;
|
|
488
|
+
case 'agent-start':
|
|
489
|
+
buffers.set(toolCallId(agent, event.round), '');
|
|
490
|
+
void emit({
|
|
491
|
+
sessionUpdate: 'tool_call',
|
|
492
|
+
toolCallId: toolCallId(agent, event.round),
|
|
493
|
+
title: `${agent} — round ${event.round ?? 1}`,
|
|
494
|
+
kind: 'think',
|
|
495
|
+
status: 'in_progress',
|
|
496
|
+
});
|
|
497
|
+
break;
|
|
498
|
+
case 'agent-chunk': {
|
|
499
|
+
const id = toolCallId(agent, event.round);
|
|
500
|
+
buffers.set(id, (buffers.get(id) ?? '') + (event.content ?? ''));
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
case 'agent-complete': {
|
|
504
|
+
const id = toolCallId(agent, event.round);
|
|
505
|
+
void emit({
|
|
506
|
+
sessionUpdate: 'tool_call_update',
|
|
507
|
+
toolCallId: id,
|
|
508
|
+
status: 'completed',
|
|
509
|
+
content: [{ type: 'content', content: { type: 'text', text: buffers.get(id) || '(no output)' } }],
|
|
510
|
+
});
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case 'error':
|
|
514
|
+
log?.warn(`council ${council.id} error: ${event.error ?? 'unknown'}`);
|
|
515
|
+
done();
|
|
516
|
+
break;
|
|
517
|
+
case 'complete':
|
|
518
|
+
done();
|
|
519
|
+
break;
|
|
520
|
+
default:
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
// The emitter is live-only with no replay buffer, and a run that finishes
|
|
525
|
+
// before the first listener attaches would never resolve. Polling the status
|
|
526
|
+
// is the backstop; it is also how the parked state is detected, because
|
|
527
|
+
// parking is a status, not an event.
|
|
528
|
+
poll = setInterval(() => {
|
|
529
|
+
const snapshot = manager.councilStatus?.(council.id);
|
|
530
|
+
if (snapshot && snapshot.status !== 'running')
|
|
531
|
+
done();
|
|
532
|
+
}, ACP_POLL_INTERVAL_MS);
|
|
533
|
+
timeout = setTimeout(() => {
|
|
534
|
+
manager.councilAbort?.(council.id);
|
|
535
|
+
done();
|
|
536
|
+
}, ACP_POLL_TIMEOUT_MS);
|
|
537
|
+
state.cancelInFlight = () => {
|
|
538
|
+
manager.councilAbort?.(council.id);
|
|
539
|
+
done();
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
await terminal;
|
|
543
|
+
state.cancelInFlight = undefined;
|
|
544
|
+
const final = manager.councilStatus(council.id);
|
|
545
|
+
const summary = final?.finalSummary?.trim();
|
|
546
|
+
await say(summary || `Council finished with status '${final?.status ?? 'unknown'}' and produced no summary.`);
|
|
547
|
+
// Consensus parks the run for a human decision rather than completing it, so
|
|
548
|
+
// the ACP turn ends at the gate and the decision becomes a slash command.
|
|
549
|
+
if (final?.status === 'awaiting_user') {
|
|
550
|
+
state.parkedCouncilId = council.id;
|
|
551
|
+
await emit({ sessionUpdate: 'available_commands_update', availableCommands: ACP_COUNCIL_COMMANDS });
|
|
552
|
+
await say('\n\nThe council reached consensus and is holding its worktrees for you. ' +
|
|
553
|
+
'Send `/council_accept` to merge the result, or `/council_reject <feedback>` to discard it.');
|
|
554
|
+
}
|
|
555
|
+
return { stopReason: final?.status === 'error' ? 'refusal' : 'end_turn' };
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Run one of the poll-only orchestrations (ultraplan, ultrareview).
|
|
559
|
+
*
|
|
560
|
+
* Neither emits events — they return a handle and are polled — so progress is
|
|
561
|
+
* reported as periodic thought chunks and the result arrives as both a `plan`
|
|
562
|
+
* update and text. Ultraplan in particular has no abort path at all, so
|
|
563
|
+
* cancelling here abandons the poll rather than stopping the work.
|
|
564
|
+
*/
|
|
565
|
+
async function runPollingMode(manager, state, task, emit, say) {
|
|
566
|
+
const isPlan = state.modeId === 'ultraplan';
|
|
567
|
+
const started = isPlan
|
|
568
|
+
? manager.ultraplanStart?.(task, { cwd: state.cwd, model: state.model })
|
|
569
|
+
: manager.ultrareviewStart?.(state.cwd, { focus: task });
|
|
570
|
+
if (!started)
|
|
571
|
+
throw acp.RequestError.internalError(`Mode '${state.modeId}' is not available on this manager`);
|
|
572
|
+
const readStatus = () => (isPlan ? manager.ultraplanStatus?.(started.id) : manager.ultrareviewStatus?.(started.id));
|
|
573
|
+
let cancelled = false;
|
|
574
|
+
state.cancelInFlight = () => {
|
|
575
|
+
cancelled = true;
|
|
576
|
+
};
|
|
577
|
+
const deadline = Date.now() + ACP_POLL_TIMEOUT_MS;
|
|
578
|
+
let snapshot = readStatus();
|
|
579
|
+
while (snapshot?.status === 'running' && Date.now() < deadline && !cancelled) {
|
|
580
|
+
await new Promise((r) => setTimeout(r, ACP_POLL_INTERVAL_MS));
|
|
581
|
+
snapshot = readStatus();
|
|
582
|
+
await emit({
|
|
583
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
584
|
+
content: { type: 'text', text: '.' },
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
state.cancelInFlight = undefined;
|
|
588
|
+
if (cancelled)
|
|
589
|
+
return { stopReason: 'cancelled' };
|
|
590
|
+
const body = (isPlan ? snapshot?.plan : snapshot?.findings) ?? '';
|
|
591
|
+
if (snapshot?.status === 'error' || !body) {
|
|
592
|
+
await say(snapshot?.error ? `${state.modeId} failed: ${snapshot.error}` : `${state.modeId} produced no output.`);
|
|
593
|
+
return { stopReason: 'refusal' };
|
|
594
|
+
}
|
|
595
|
+
await emit({
|
|
596
|
+
sessionUpdate: 'plan',
|
|
597
|
+
entries: [{ content: body.slice(0, 500), priority: 'high', status: 'completed' }],
|
|
598
|
+
});
|
|
599
|
+
await say(body);
|
|
600
|
+
return { stopReason: 'end_turn' };
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Apply `/council_accept` or `/council_reject` to a parked run.
|
|
604
|
+
*
|
|
605
|
+
* Returns true when the prompt was a decision and the turn is finished.
|
|
606
|
+
*/
|
|
607
|
+
export async function resolveParkedCouncil(manager, state, command, say, emit) {
|
|
608
|
+
const id = state.parkedCouncilId;
|
|
609
|
+
if (!id || !command)
|
|
610
|
+
return false;
|
|
611
|
+
if (command.name === 'council_accept') {
|
|
612
|
+
await manager.councilAccept?.(id);
|
|
613
|
+
await say('Council result accepted; the winning worktree has been merged.');
|
|
614
|
+
}
|
|
615
|
+
else if (command.name === 'council_reject') {
|
|
616
|
+
await manager.councilReject?.(id, command.rest || 'rejected via ACP');
|
|
617
|
+
await say('Council result rejected and its worktrees discarded.');
|
|
618
|
+
}
|
|
619
|
+
else {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
state.parkedCouncilId = undefined;
|
|
623
|
+
await emit({ sessionUpdate: 'available_commands_update', availableCommands: [] });
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Report context occupancy and cumulative cost to the client.
|
|
628
|
+
*
|
|
629
|
+
* ACP wants absolute token counts; the session layer exposes a percentage and a
|
|
630
|
+
* window, so `used` is derived from the two. That is the honest reading — the
|
|
631
|
+
* percentage is what the engines actually report, and reconstructing a token
|
|
632
|
+
* count from it is lossy in the last digit but not in the shape.
|
|
633
|
+
*
|
|
634
|
+
* Cost is the cross-engine total, which is the number worth showing here: a
|
|
635
|
+
* session that switched from Claude to Codex mid-way has spent on both, and no
|
|
636
|
+
* single-engine agent can report that.
|
|
637
|
+
*/
|
|
638
|
+
async function emitUsage(manager, state, emit) {
|
|
639
|
+
try {
|
|
640
|
+
const percent = manager.getStatus?.(state.name)?.stats?.contextPercent ?? 0;
|
|
641
|
+
const size = getContextWindow(state.model);
|
|
642
|
+
const cost = manager.getCost?.(state.name)?.totalUsd;
|
|
643
|
+
if (!size)
|
|
644
|
+
return;
|
|
645
|
+
await emit({
|
|
646
|
+
sessionUpdate: 'usage_update',
|
|
647
|
+
used: Math.round((size * percent) / 100),
|
|
648
|
+
size,
|
|
649
|
+
...(typeof cost === 'number' ? { cost: { amount: cost, currency: 'USD' } } : {}),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Usage is decoration. A manager that cannot report it must not break a turn.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
//# sourceMappingURL=acp-server.js.map
|