@enderfga/claw-orchestrator 4.12.1 → 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.
@@ -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