@causyne/agent 1.10.21 → 1.11.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.
Files changed (55) hide show
  1. package/native-skills/media/professional-document-publishing/SKILL.md +44 -0
  2. package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
  3. package/package.json +1 -1
  4. package/src/acp/client.js +235 -0
  5. package/src/acp/client.ts +282 -0
  6. package/src/acp/peerProfiles.js +122 -0
  7. package/src/acp/peerProfiles.ts +128 -0
  8. package/src/acp/protocol.js +54 -0
  9. package/src/acp/protocol.ts +434 -0
  10. package/src/acp/server.js +609 -0
  11. package/src/acp/server.ts +676 -0
  12. package/src/agentLoop.d.ts +43 -0
  13. package/src/agentLoop.js +142 -8
  14. package/src/commands.js +203 -0
  15. package/src/compiledSkills.js +10 -0
  16. package/src/documentEngine/antiSlopAuditor.js +156 -0
  17. package/src/documentEngine/antiSlopAuditor.ts +221 -0
  18. package/src/documentEngine/documentForge.js +443 -0
  19. package/src/documentEngine/documentForge.ts +519 -0
  20. package/src/documentEngine/screenplayFormatter.js +336 -0
  21. package/src/documentEngine/screenplayFormatter.ts +407 -0
  22. package/src/entrypoint.js +13 -0
  23. package/src/execSafe.js +10 -13
  24. package/src/execSafe.ts +25 -0
  25. package/src/modelRouter.js +56 -123
  26. package/src/modelRouter.ts +134 -0
  27. package/src/observer/advisory.js +164 -0
  28. package/src/observer/advisory.ts +171 -0
  29. package/src/observer/coldObserver.js +462 -0
  30. package/src/observer/coldObserver.ts +540 -0
  31. package/src/observer/evidence.js +435 -0
  32. package/src/observer/evidence.ts +499 -0
  33. package/src/perception.js +303 -118
  34. package/src/perception.ts +387 -0
  35. package/src/procSignal.js +15 -16
  36. package/src/procSignal.ts +31 -0
  37. package/src/promptMentions.js +170 -0
  38. package/src/promptMentions.ts +212 -0
  39. package/src/sandbox.js +1 -1
  40. package/src/textDiff.js +117 -109
  41. package/src/textDiff.ts +169 -0
  42. package/src/toolPacks.js +5 -2
  43. package/src/tools/documentForge.js +215 -0
  44. package/src/tools/documentForge.ts +265 -0
  45. package/src/tools/peerAgent.js +102 -0
  46. package/src/tools/peerAgent.ts +116 -0
  47. package/src/tools/registry.js +3 -1
  48. package/src/tui/completion.js +97 -41
  49. package/src/tui/prompt.js +17 -17
  50. package/src/uncertainty.js +93 -205
  51. package/src/uncertainty.ts +255 -0
  52. package/src/utils/paths.js +15 -18
  53. package/src/utils/paths.ts +36 -0
  54. package/src/vfeTracker.js +122 -181
  55. package/src/vfeTracker.ts +242 -0
@@ -0,0 +1,676 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { AgentLoop } from '../agentLoop.js';
5
+ import type { AgentLoopEvent } from '../agentLoop.js';
6
+ import {
7
+ ACP_ERROR_CODES,
8
+ ACP_METHODS,
9
+ ACP_PROTOCOL_VERSION,
10
+ isJsonRpcNotification,
11
+ isJsonRpcRequest,
12
+ isJsonRpcResponse
13
+ } from './protocol.js';
14
+ import type {
15
+ AgentCapabilities,
16
+ CancelNotification,
17
+ CloseSessionRequest,
18
+ ContentBlock,
19
+ InitializeRequest,
20
+ InitializeResult,
21
+ JsonRpcError,
22
+ JsonRpcNotification,
23
+ JsonRpcRequest,
24
+ JsonRpcResponse,
25
+ ListSessionsRequest,
26
+ ListSessionsResponse,
27
+ NewSessionRequest,
28
+ NewSessionResponse,
29
+ PermissionOption,
30
+ PromptRequest,
31
+ PromptResponse,
32
+ RequestPermissionResponse,
33
+ SessionInfo,
34
+ SessionModeState,
35
+ SessionNotification,
36
+ SessionUpdate,
37
+ SetSessionModeRequest,
38
+ StopReason,
39
+ ToolCall,
40
+ ToolCallStatus,
41
+ ToolKind
42
+ } from './protocol.js';
43
+
44
+ export interface AcpEventSink {
45
+ readyState: number;
46
+ send(payload: string): void;
47
+ }
48
+
49
+ export interface AcpLoopFactoryParams {
50
+ sessionId: string;
51
+ taskId: string;
52
+ userPrompt: string;
53
+ cwd: string;
54
+ sink: AcpEventSink;
55
+ }
56
+
57
+ export type AcpLoopFactory = (params: AcpLoopFactoryParams) => AgentLoop;
58
+
59
+ export interface AcpServerOptions {
60
+ input?: NodeJS.ReadableStream;
61
+ output?: NodeJS.WritableStream;
62
+ loopFactory?: AcpLoopFactory;
63
+ agentName?: string;
64
+ agentVersion?: string;
65
+ permissionTimeoutMs?: number;
66
+ }
67
+
68
+ interface PendingClientRequest {
69
+ resolve: (value: unknown) => void;
70
+ reject: (error: Error) => void;
71
+ timer: NodeJS.Timeout;
72
+ }
73
+
74
+ interface AcpSession {
75
+ id: string;
76
+ cwd: string;
77
+ modeId: string;
78
+ loop: AgentLoop | null;
79
+ busy: boolean;
80
+ cancelled: boolean;
81
+ updatedAt: number;
82
+ queue: Promise<unknown>;
83
+ }
84
+
85
+ const MODE_TO_AUTONOMY: Record<string, 'A' | 'B' | 'C'> = {
86
+ autonomous: 'A',
87
+ standard: 'B',
88
+ supervised: 'C'
89
+ };
90
+
91
+ const MODES: SessionModeState = {
92
+ currentModeId: 'standard',
93
+ availableModes: [
94
+ { id: 'autonomous', name: 'Autonomous', description: 'Runs without pausing for routine approvals' },
95
+ { id: 'standard', name: 'Standard', description: 'Asks before dangerous actions' },
96
+ { id: 'supervised', name: 'Supervised', description: 'Asks before most mutating actions' }
97
+ ]
98
+ };
99
+
100
+ const MAX_PROMPT_CHARS = 200_000;
101
+ const MAX_RAW_CHARS = 4000;
102
+
103
+ function textFromPrompt(prompt: ContentBlock[]): string | null {
104
+ const parts: string[] = [];
105
+ for (const block of prompt) {
106
+ if (block.type !== 'text') return null;
107
+ parts.push(block.text);
108
+ }
109
+ return parts.join('\n').slice(0, MAX_PROMPT_CHARS);
110
+ }
111
+
112
+ function toolKind(name: string): ToolKind {
113
+ if (/^(fs_read|read_file|read|fs_list|list_dir|view_file)$/.test(name)) return 'read';
114
+ if (/^(fs_write|fs_edit|MultiEdit|write_file|edit_file)$/.test(name)) return 'edit';
115
+ if (/^(fs_delete|delete_file)$/.test(name)) return 'delete';
116
+ if (/^(fs_move|move_file)$/.test(name)) return 'move';
117
+ if (/^(grep|glob|search|find|code_search)$/.test(name)) return 'search';
118
+ if (/^(sysRun|sysRunBg|shell|terminal|terminal_persistent_shell|gitOp|proc_term)$/.test(name)) return 'execute';
119
+ if (/^(webFetch|webSearch|web_fetch|web_search)$/.test(name)) return 'fetch';
120
+ if (/^(request_decision|update_plan|think)$/.test(name)) return 'think';
121
+ return 'other';
122
+ }
123
+
124
+ function bounded(value: unknown, limit = MAX_RAW_CHARS): string {
125
+ let text: string;
126
+ try {
127
+ text = typeof value === 'string' ? value : JSON.stringify(value ?? null);
128
+ } catch {
129
+ text = '[unserializable]';
130
+ }
131
+ return text.length > limit ? `${text.slice(0, limit)}…[truncated]` : text;
132
+ }
133
+
134
+ function toolTitle(name: string, args: Record<string, unknown>): string {
135
+ const detail = args.path || args.file_path || args.command || args.cmd || args.query || args.url || args.action;
136
+ const suffix = typeof detail === 'string' && detail.trim() ? ` ${detail.trim()}` : '';
137
+ const title = `${name}${suffix}`;
138
+ return title.length > 200 ? `${title.slice(0, 200)}…` : title;
139
+ }
140
+
141
+ export class AcpServer {
142
+ private readonly input: NodeJS.ReadableStream | null;
143
+ private readonly output: NodeJS.WritableStream | null;
144
+ private readonly loopFactory: AcpLoopFactory | null;
145
+ private readonly agentName: string;
146
+ private readonly agentVersion: string;
147
+ private readonly permissionTimeoutMs: number;
148
+ private readonly sessions = new Map<string, AcpSession>();
149
+ private readonly pending = new Map<string | number, PendingClientRequest>();
150
+ private nextClientRequestId = 1;
151
+ private buffer = '';
152
+ private closed = false;
153
+ private workspaceRoot: string | null = null;
154
+ private dataListener: ((chunk: Buffer | string) => void) | null = null;
155
+ private endListener: (() => void) | null = null;
156
+ private errorListener: ((error: Error) => void) | null = null;
157
+
158
+ constructor(options: AcpServerOptions = {}) {
159
+ this.input = options.input ?? null;
160
+ this.output = options.output ?? null;
161
+ this.loopFactory = options.loopFactory ?? null;
162
+ this.agentName = options.agentName ?? 'Causyne Agent';
163
+ this.agentVersion = options.agentVersion ?? '0.0.0';
164
+ this.permissionTimeoutMs = options.permissionTimeoutMs ?? 10 * 60 * 1000;
165
+ }
166
+
167
+ start(): void {
168
+ if (!this.input || this.dataListener) return;
169
+ this.dataListener = (chunk: Buffer | string) => {
170
+ this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
171
+ void this.drainBuffer();
172
+ };
173
+ this.endListener = () => { void this.stop(); };
174
+ this.errorListener = () => { void this.stop(); };
175
+ this.input.on('data', this.dataListener);
176
+ this.input.on('end', this.endListener);
177
+ this.input.on('error', this.errorListener);
178
+ }
179
+
180
+ async stop(): Promise<void> {
181
+ if (this.closed) return;
182
+ this.closed = true;
183
+ if (this.input && this.dataListener) this.input.off('data', this.dataListener);
184
+ if (this.input && this.endListener) this.input.off('end', this.endListener);
185
+ if (this.input && this.errorListener) this.input.off('error', this.errorListener);
186
+ for (const session of this.sessions.values()) {
187
+ try {
188
+ session.loop?.abort();
189
+ } catch {
190
+ // Shutdown never blocks on a misbehaving tool.
191
+ }
192
+ }
193
+ for (const [id, pending] of this.pending) {
194
+ clearTimeout(pending.timer);
195
+ pending.reject(new Error('server stopped'));
196
+ this.pending.delete(id);
197
+ }
198
+ }
199
+
200
+ private async drainBuffer(): Promise<void> {
201
+ let index = this.buffer.indexOf('\n');
202
+ while (index >= 0) {
203
+ const line = this.buffer.slice(0, index).trim();
204
+ this.buffer = this.buffer.slice(index + 1);
205
+ if (line) await this.handleLine(line);
206
+ index = this.buffer.indexOf('\n');
207
+ }
208
+ }
209
+
210
+ async handleLine(line: string): Promise<void> {
211
+ let parsed: unknown;
212
+ try {
213
+ parsed = JSON.parse(line);
214
+ } catch {
215
+ this.write({ jsonrpc: '2.0', id: null, error: { code: ACP_ERROR_CODES.PARSE_ERROR, message: 'Parse error' } });
216
+ return;
217
+ }
218
+ if (isJsonRpcResponse(parsed)) {
219
+ this.resolveClientRequest(parsed);
220
+ return;
221
+ }
222
+ if (isJsonRpcRequest(parsed)) {
223
+ await this.handleRequest(parsed);
224
+ return;
225
+ }
226
+ if (isJsonRpcNotification(parsed)) {
227
+ await this.handleNotification(parsed);
228
+ return;
229
+ }
230
+ this.write({ jsonrpc: '2.0', id: null, error: { code: ACP_ERROR_CODES.INVALID_REQUEST, message: 'Invalid request' } });
231
+ }
232
+
233
+ private async handleNotification(notification: JsonRpcNotification): Promise<void> {
234
+ if (notification.method === ACP_METHODS.CANCEL) {
235
+ const params = notification.params as CancelNotification | undefined;
236
+ const session = params && typeof params.sessionId === 'string' ? this.sessions.get(params.sessionId) : undefined;
237
+ if (session) {
238
+ session.cancelled = true;
239
+ try {
240
+ session.loop?.abort();
241
+ } catch {
242
+ // Cancellation is best effort.
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ private async handleRequest(request: JsonRpcRequest): Promise<void> {
249
+ try {
250
+ switch (request.method) {
251
+ case ACP_METHODS.INITIALIZE:
252
+ this.writeResult(request.id, this.initialize(request.params as InitializeRequest | undefined));
253
+ return;
254
+ case ACP_METHODS.NEW_SESSION:
255
+ this.writeResult(request.id, this.newSession(request.params as NewSessionRequest | undefined));
256
+ return;
257
+ case ACP_METHODS.PROMPT:
258
+ this.writeResult(request.id, await this.prompt(request.params as PromptRequest | undefined));
259
+ return;
260
+ case ACP_METHODS.SET_MODE:
261
+ this.writeResult(request.id, this.setMode(request.params as SetSessionModeRequest | undefined));
262
+ return;
263
+ case ACP_METHODS.LIST:
264
+ this.writeResult(request.id, this.listSessions(request.params as ListSessionsRequest | undefined));
265
+ return;
266
+ case ACP_METHODS.CLOSE:
267
+ this.writeResult(request.id, this.closeSession(request.params as CloseSessionRequest | undefined));
268
+ return;
269
+ case ACP_METHODS.LOAD:
270
+ this.writeError(request.id, ACP_ERROR_CODES.METHOD_NOT_FOUND, 'session/load is not supported');
271
+ return;
272
+ default:
273
+ this.writeError(request.id, ACP_ERROR_CODES.METHOD_NOT_FOUND, `Unknown method: ${request.method}`);
274
+ }
275
+ } catch (error) {
276
+ const code = (error as { acpCode?: number }).acpCode ?? ACP_ERROR_CODES.INTERNAL_ERROR;
277
+ this.writeError(request.id, code, error instanceof Error ? error.message : String(error));
278
+ }
279
+ }
280
+
281
+ private initialize(params: InitializeRequest | undefined): InitializeResult {
282
+ if (!params || typeof params.protocolVersion !== 'number') {
283
+ throw this.paramsError('initialize requires protocolVersion');
284
+ }
285
+ const capabilities: AgentCapabilities = {
286
+ loadSession: false,
287
+ promptCapabilities: { image: false, audio: false, embeddedContext: false },
288
+ mcpCapabilities: { http: false, sse: false },
289
+ sessionCapabilities: { list: {}, close: {} }
290
+ };
291
+ return {
292
+ protocolVersion: ACP_PROTOCOL_VERSION,
293
+ agentCapabilities: capabilities,
294
+ agentInfo: { name: this.agentName, version: this.agentVersion }
295
+ };
296
+ }
297
+
298
+ private paramsError(message: string): Error & { acpCode: number } {
299
+ const error = new Error(message) as Error & { acpCode: number };
300
+ error.acpCode = ACP_ERROR_CODES.INVALID_PARAMS;
301
+ return error;
302
+ }
303
+
304
+ private newSession(params: NewSessionRequest | undefined): NewSessionResponse {
305
+ if (!params || typeof params.cwd !== 'string' || !params.cwd.trim()) {
306
+ throw this.paramsError('session/new requires cwd');
307
+ }
308
+ if (Array.isArray(params.mcpServers) && params.mcpServers.length > 0) {
309
+ throw this.paramsError('MCP servers are not supported by this agent');
310
+ }
311
+ if (Array.isArray(params.additionalDirectories) && params.additionalDirectories.length > 0) {
312
+ throw this.paramsError('additionalDirectories is not supported by this agent');
313
+ }
314
+ let cwd: string;
315
+ try {
316
+ cwd = fs.realpathSync(path.resolve(params.cwd));
317
+ if (!fs.statSync(cwd).isDirectory()) throw new Error('not a directory');
318
+ } catch {
319
+ throw this.paramsError(`cwd is not an accessible directory: ${params.cwd}`);
320
+ }
321
+ if (this.workspaceRoot === null) {
322
+ this.workspaceRoot = cwd;
323
+ process.chdir(cwd);
324
+ } else if (this.workspaceRoot !== cwd) {
325
+ throw this.paramsError(`server is bound to ${this.workspaceRoot}; one workspace per server`);
326
+ }
327
+ const sessionId = crypto.randomUUID();
328
+ this.sessions.set(sessionId, {
329
+ id: sessionId,
330
+ cwd,
331
+ modeId: MODES.currentModeId,
332
+ loop: null,
333
+ busy: false,
334
+ cancelled: false,
335
+ updatedAt: Date.now(),
336
+ queue: Promise.resolve()
337
+ });
338
+ return {
339
+ sessionId,
340
+ modes: {
341
+ currentModeId: MODES.currentModeId,
342
+ availableModes: MODES.availableModes.map((mode) => ({ ...mode }))
343
+ }
344
+ };
345
+ }
346
+
347
+ private requireSession(sessionId: unknown): AcpSession {
348
+ if (typeof sessionId !== 'string' || !sessionId) throw this.paramsError('sessionId is required');
349
+ const session = this.sessions.get(sessionId);
350
+ if (!session) {
351
+ const error = new Error(`Unknown session: ${sessionId}`) as Error & { acpCode: number };
352
+ error.acpCode = ACP_ERROR_CODES.RESOURCE_NOT_FOUND;
353
+ throw error;
354
+ }
355
+ return session;
356
+ }
357
+
358
+ private async prompt(params: PromptRequest | undefined): Promise<PromptResponse> {
359
+ if (!params || !Array.isArray(params.prompt)) {
360
+ throw this.paramsError('session/prompt requires a prompt array');
361
+ }
362
+ const session = this.requireSession(params.sessionId);
363
+ const text = textFromPrompt(params.prompt);
364
+ if (text === null || !text.trim()) {
365
+ return { stopReason: 'refusal' };
366
+ }
367
+ session.queue = session.queue.then(
368
+ () => this.runTurn(session, text),
369
+ () => this.runTurn(session, text)
370
+ );
371
+ const stopReason = await session.queue as StopReason;
372
+ return { stopReason };
373
+ }
374
+
375
+ private async runTurn(session: AcpSession, text: string): Promise<StopReason> {
376
+ session.updatedAt = Date.now();
377
+ session.cancelled = false;
378
+ session.busy = true;
379
+ const taskId = `acp-${crypto.randomUUID()}`;
380
+ const sink = {
381
+ readyState: 1,
382
+ send: (payload: string) => this.onLoopPayload(session, payload)
383
+ };
384
+ const loop = this.loopFactory
385
+ ? this.loopFactory({ sessionId: session.id, taskId, userPrompt: text, cwd: session.cwd, sink })
386
+ : new AgentLoop(sink, taskId, text);
387
+ const autonomy = MODE_TO_AUTONOMY[session.modeId];
388
+ if (autonomy) {
389
+ try {
390
+ loop.setAutonomyMode(autonomy);
391
+ } catch {
392
+ // A loop without autonomy control simply keeps its configured mode.
393
+ }
394
+ }
395
+ loop.waitForDecision = async (request: unknown) => this.requestDecision(session, loop, request);
396
+ session.loop = loop;
397
+ let stopReason: StopReason = 'end_turn';
398
+ try {
399
+ await loop.run();
400
+ } catch {
401
+ stopReason = 'refusal';
402
+ } finally {
403
+ session.loop = null;
404
+ session.busy = false;
405
+ if (session.cancelled) stopReason = 'cancelled';
406
+ }
407
+ return stopReason;
408
+ }
409
+
410
+ private requestDecision(session: AcpSession, loop: AgentLoop, request: unknown): Promise<{ optionId: string; label: string } | null> {
411
+ if (request === null || typeof request !== 'object' || Array.isArray(request)) return Promise.resolve(null);
412
+ const record = request as Record<string, unknown>;
413
+ const options = Array.isArray(record.options) ? record.options : [];
414
+ const permissionOptions: PermissionOption[] = [];
415
+ const labels = new Map<string, string>();
416
+ for (const option of options) {
417
+ if (option === null || typeof option !== 'object' || Array.isArray(option)) continue;
418
+ const item = option as Record<string, unknown>;
419
+ const id = typeof item.id === 'string' ? item.id : '';
420
+ const label = typeof item.label === 'string' ? item.label : id;
421
+ if (!id) continue;
422
+ labels.set(id, label);
423
+ permissionOptions.push({ optionId: id, name: label, kind: 'allow_once' });
424
+ }
425
+ if (permissionOptions.length === 0) return Promise.resolve(null);
426
+ const toolCall: ToolCall = {
427
+ toolCallId: typeof record.id === 'string' ? record.id : crypto.randomUUID(),
428
+ title: typeof record.question === 'string' ? record.question.slice(0, 300) : 'Decision required',
429
+ kind: 'other',
430
+ status: 'pending'
431
+ };
432
+ return this.sendClientRequest<RequestPermissionResponse>(session, ACP_METHODS.REQUEST_PERMISSION, {
433
+ sessionId: session.id,
434
+ toolCall,
435
+ options: permissionOptions
436
+ }).then((response) => {
437
+ if (!response || response.outcome.outcome !== 'selected') return null;
438
+ const optionId = response.outcome.optionId;
439
+ const label = labels.get(optionId);
440
+ if (!label) return null;
441
+ return { optionId, label };
442
+ }).catch(() => null);
443
+ }
444
+
445
+ private onLoopPayload(session: AcpSession, payload: string): void {
446
+ let event: AgentLoopEvent;
447
+ try {
448
+ event = JSON.parse(payload) as AgentLoopEvent;
449
+ } catch {
450
+ return;
451
+ }
452
+ this.onLoopEvent(session, event);
453
+ }
454
+
455
+ private onLoopEvent(session: AcpSession, event: AgentLoopEvent): void {
456
+ switch (event.type) {
457
+ case 'agent_message': {
458
+ this.notify(session.id, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: String(event.content ?? '') } });
459
+ return;
460
+ }
461
+ case 'agent_reasoning': {
462
+ const text = String(event.content ?? '');
463
+ if (text) this.notify(session.id, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text } });
464
+ return;
465
+ }
466
+ case 'status':
467
+ case 'notice': {
468
+ const text = String(event.message ?? '');
469
+ if (text) this.notify(session.id, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text } });
470
+ return;
471
+ }
472
+ case 'warning':
473
+ case 'budget_warning':
474
+ case 'error': {
475
+ const text = String(event.message ?? 'Task error');
476
+ this.notify(session.id, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
477
+ return;
478
+ }
479
+ case 'step_start': {
480
+ const tool = String(event.tool ?? 'tool');
481
+ const args = event.args !== null && typeof event.args === 'object' && !Array.isArray(event.args)
482
+ ? event.args as Record<string, unknown>
483
+ : {};
484
+ const toolCallId = typeof event.toolCallId === 'string' ? event.toolCallId : crypto.randomUUID();
485
+ this.notify(session.id, {
486
+ sessionUpdate: 'tool_call',
487
+ toolCallId,
488
+ title: toolTitle(tool, args),
489
+ kind: toolKind(tool),
490
+ status: 'in_progress',
491
+ rawInput: args
492
+ });
493
+ return;
494
+ }
495
+ case 'step_complete': {
496
+ const toolCallId = typeof event.toolCallId === 'string' ? event.toolCallId : null;
497
+ if (!toolCallId) return;
498
+ const result = event.result as { success?: boolean } | undefined;
499
+ const status: ToolCallStatus = result && result.success === false ? 'failed' : 'completed';
500
+ this.notify(session.id, {
501
+ sessionUpdate: 'tool_call_update',
502
+ toolCallId,
503
+ status,
504
+ content: [{ type: 'content', content: { type: 'text', text: bounded(event.result) } }]
505
+ });
506
+ return;
507
+ }
508
+ case 'step_error': {
509
+ const toolCallId = typeof event.toolCallId === 'string' ? event.toolCallId : null;
510
+ if (!toolCallId) return;
511
+ this.notify(session.id, {
512
+ sessionUpdate: 'tool_call_update',
513
+ toolCallId,
514
+ status: 'failed',
515
+ content: [{ type: 'content', content: { type: 'text', text: bounded(event.error ?? 'tool failed') } }]
516
+ });
517
+ return;
518
+ }
519
+ case 'agent_plan': {
520
+ const plan = Array.isArray(event.plan) ? event.plan : [];
521
+ this.notify(session.id, {
522
+ sessionUpdate: 'plan',
523
+ entries: plan.map((entry) => {
524
+ const item = entry !== null && typeof entry === 'object' && !Array.isArray(entry)
525
+ ? entry as Record<string, unknown>
526
+ : {};
527
+ const status = item.status === 'completed' || item.status === 'in_progress' ? item.status : 'pending';
528
+ return {
529
+ content: String(item.text ?? item.content ?? 'step'),
530
+ priority: 'medium' as const,
531
+ status
532
+ };
533
+ })
534
+ });
535
+ return;
536
+ }
537
+ case 'approval_request': {
538
+ const toolCallId = String(event.toolCallId ?? '');
539
+ if (!toolCallId) return;
540
+ const args = event.args !== null && typeof event.args === 'object' && !Array.isArray(event.args)
541
+ ? event.args as Record<string, unknown>
542
+ : {};
543
+ const tool = String(event.tool ?? 'tool');
544
+ const toolCall: ToolCall = {
545
+ toolCallId,
546
+ title: toolTitle(tool, args),
547
+ kind: toolKind(tool),
548
+ status: 'pending',
549
+ rawInput: args
550
+ };
551
+ const options: PermissionOption[] = [
552
+ { optionId: 'allow_once', name: 'Allow this action', kind: 'allow_once' },
553
+ { optionId: 'reject_once', name: 'Reject this action', kind: 'reject_once' }
554
+ ];
555
+ void this.sendClientRequest<RequestPermissionResponse>(session, ACP_METHODS.REQUEST_PERMISSION, {
556
+ sessionId: session.id,
557
+ toolCall,
558
+ options
559
+ }).then((response) => {
560
+ const approved = response?.outcome.outcome === 'selected' && response.outcome.optionId === 'allow_once';
561
+ session.loop?.handleApprovalResponse(toolCallId, approved);
562
+ }).catch(() => {
563
+ session.loop?.handleApprovalResponse(toolCallId, false);
564
+ });
565
+ return;
566
+ }
567
+ default:
568
+ return;
569
+ }
570
+ }
571
+
572
+ private notify(sessionId: string, update: SessionUpdate): void {
573
+ const notification: JsonRpcNotification<SessionNotification> = {
574
+ jsonrpc: '2.0',
575
+ method: ACP_METHODS.UPDATE,
576
+ params: { sessionId, update }
577
+ };
578
+ this.write(notification);
579
+ }
580
+
581
+ private setMode(params: SetSessionModeRequest | undefined): Record<string, never> {
582
+ if (!params || typeof params.modeId !== 'string') throw this.paramsError('session/set_mode requires modeId');
583
+ const session = this.requireSession(params.sessionId);
584
+ if (!MODES.availableModes.some((mode) => mode.id === params.modeId)) {
585
+ throw this.paramsError(`Unknown mode: ${params.modeId}`);
586
+ }
587
+ session.modeId = params.modeId;
588
+ session.updatedAt = Date.now();
589
+ const autonomy = MODE_TO_AUTONOMY[session.modeId];
590
+ if (autonomy && session.loop) {
591
+ try {
592
+ session.loop.setAutonomyMode(autonomy);
593
+ } catch {
594
+ // The running loop keeps its mode; the next turn picks the new one.
595
+ }
596
+ }
597
+ this.notify(session.id, { sessionUpdate: 'current_mode_update', currentModeId: session.modeId });
598
+ return {};
599
+ }
600
+
601
+ private listSessions(params: ListSessionsRequest | undefined): ListSessionsResponse {
602
+ const filter = params && typeof params.cwd === 'string' ? path.resolve(params.cwd) : null;
603
+ const sessions: SessionInfo[] = [];
604
+ for (const session of this.sessions.values()) {
605
+ if (filter && session.cwd !== filter) continue;
606
+ sessions.push({
607
+ sessionId: session.id,
608
+ cwd: session.cwd,
609
+ updatedAt: new Date(session.updatedAt).toISOString()
610
+ });
611
+ }
612
+ sessions.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''));
613
+ return { sessions };
614
+ }
615
+
616
+ private closeSession(params: CloseSessionRequest | undefined): Record<string, never> {
617
+ const session = this.requireSession(params?.sessionId);
618
+ session.cancelled = true;
619
+ try {
620
+ session.loop?.abort();
621
+ } catch {
622
+ // Closing never blocks on cancellation.
623
+ }
624
+ this.sessions.delete(session.id);
625
+ return {};
626
+ }
627
+
628
+ private sendClientRequest<T>(session: AcpSession, method: string, params: Record<string, unknown>): Promise<T> {
629
+ const id = `server-${this.nextClientRequestId++}`;
630
+ return new Promise<T>((resolve, reject) => {
631
+ const timer = setTimeout(() => {
632
+ this.pending.delete(id);
633
+ reject(new Error(`${method} timed out`));
634
+ }, this.permissionTimeoutMs);
635
+ this.pending.set(id, {
636
+ resolve: (value) => resolve(value as T),
637
+ reject,
638
+ timer
639
+ });
640
+ this.write({ jsonrpc: '2.0', id, method, params });
641
+ });
642
+ }
643
+
644
+ private resolveClientRequest(response: JsonRpcResponse): void {
645
+ if (response.id === null || response.id === undefined) return;
646
+ const pending = this.pending.get(response.id);
647
+ if (!pending) return;
648
+ clearTimeout(pending.timer);
649
+ this.pending.delete(response.id);
650
+ if (response.error) {
651
+ pending.reject(new Error(response.error.message));
652
+ return;
653
+ }
654
+ pending.resolve(response.result);
655
+ }
656
+
657
+ private writeResult(id: string | number, result: unknown): void {
658
+ this.write({ jsonrpc: '2.0', id, result });
659
+ }
660
+
661
+ private writeError(id: string | number, code: number, message: string): void {
662
+ const error: JsonRpcError = { code, message };
663
+ this.write({ jsonrpc: '2.0', id, error });
664
+ }
665
+
666
+ private write(message: JsonRpcResponse | JsonRpcNotification<unknown> | JsonRpcRequest<unknown>): void {
667
+ if (!this.output || this.closed) return;
668
+ try {
669
+ this.output.write(`${JSON.stringify(message)}\n`);
670
+ } catch {
671
+ // A closed pipe must not crash the agent process.
672
+ }
673
+ }
674
+ }
675
+
676
+ export { MODES as ACP_SESSION_MODES };