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