@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.3

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 (85) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +423 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/export/html/template.css +43 -13
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.html +1 -0
  27. package/src/export/html/template.js +107 -0
  28. package/src/extensibility/extensions/types.ts +31 -8
  29. package/src/internal-urls/docs-index.generated.ts +1 -1
  30. package/src/lsp/index.ts +1 -1
  31. package/src/main.ts +44 -44
  32. package/src/mcp/oauth-discovery.ts +1 -1
  33. package/src/modes/acp/acp-agent.ts +957 -0
  34. package/src/modes/acp/acp-event-mapper.ts +531 -0
  35. package/src/modes/acp/acp-mode.ts +13 -0
  36. package/src/modes/acp/index.ts +2 -0
  37. package/src/modes/components/agent-dashboard.ts +5 -4
  38. package/src/modes/components/custom-editor.ts +53 -51
  39. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  40. package/src/modes/components/history-search.ts +2 -1
  41. package/src/modes/components/hook-editor.ts +2 -1
  42. package/src/modes/components/hook-input.ts +8 -7
  43. package/src/modes/components/hook-selector.ts +15 -10
  44. package/src/modes/components/keybinding-hints.ts +9 -9
  45. package/src/modes/components/login-dialog.ts +3 -3
  46. package/src/modes/components/mcp-add-wizard.ts +2 -1
  47. package/src/modes/components/model-selector.ts +14 -3
  48. package/src/modes/components/oauth-selector.ts +2 -1
  49. package/src/modes/components/session-selector.ts +2 -1
  50. package/src/modes/components/settings-selector.ts +2 -1
  51. package/src/modes/components/status-line-segment-editor.ts +2 -1
  52. package/src/modes/components/tree-selector.ts +3 -2
  53. package/src/modes/components/user-message-selector.ts +3 -8
  54. package/src/modes/components/user-message.ts +16 -0
  55. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  56. package/src/modes/controllers/input-controller.ts +48 -29
  57. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  58. package/src/modes/index.ts +1 -0
  59. package/src/modes/interactive-mode.ts +17 -5
  60. package/src/modes/print-mode.ts +1 -1
  61. package/src/modes/prompt-action-autocomplete.ts +7 -7
  62. package/src/modes/rpc/rpc-mode.ts +7 -2
  63. package/src/modes/rpc/rpc-types.ts +1 -0
  64. package/src/modes/theme/theme.ts +53 -44
  65. package/src/modes/types.ts +9 -2
  66. package/src/modes/utils/hotkeys-markdown.ts +20 -20
  67. package/src/modes/utils/keybinding-matchers.ts +21 -0
  68. package/src/modes/utils/ui-helpers.ts +1 -1
  69. package/src/patch/hashline.ts +139 -127
  70. package/src/patch/index.ts +77 -59
  71. package/src/patch/shared.ts +19 -11
  72. package/src/prompts/tools/hashline.md +43 -116
  73. package/src/sdk.ts +34 -17
  74. package/src/session/agent-session.ts +436 -86
  75. package/src/session/messages.ts +23 -0
  76. package/src/session/session-manager.ts +97 -31
  77. package/src/tools/ask.ts +56 -30
  78. package/src/tools/bash-interceptor.ts +1 -39
  79. package/src/tools/bash-skill-urls.ts +1 -1
  80. package/src/tools/browser.ts +1 -1
  81. package/src/tools/gemini-image.ts +1 -1
  82. package/src/tools/resolve.ts +1 -1
  83. package/src/utils/child-process.ts +88 -0
  84. package/src/utils/image-input.ts +11 -1
  85. package/src/web/search/providers/codex.ts +10 -3
@@ -0,0 +1,957 @@
1
+ import * as path from "node:path";
2
+ import {
3
+ type Agent,
4
+ type AgentSideConnection,
5
+ type AuthenticateRequest,
6
+ type AuthenticateResponse,
7
+ type AvailableCommand,
8
+ type InitializeRequest,
9
+ type InitializeResponse,
10
+ type ListSessionsRequest,
11
+ type ListSessionsResponse,
12
+ type LoadSessionRequest,
13
+ type LoadSessionResponse,
14
+ type McpServer,
15
+ type NewSessionRequest,
16
+ type NewSessionResponse,
17
+ PROTOCOL_VERSION,
18
+ type PromptRequest,
19
+ type PromptResponse,
20
+ type SessionConfigOption,
21
+ type SessionInfo,
22
+ type SessionModeState,
23
+ type SessionNotification,
24
+ type SessionUpdate,
25
+ type SetSessionConfigOptionRequest,
26
+ type SetSessionConfigOptionResponse,
27
+ type SetSessionModeRequest,
28
+ type SetSessionModeResponse,
29
+ } from "@agentclientprotocol/sdk";
30
+ import type { Model } from "@oh-my-pi/pi-ai";
31
+ import { logger, VERSION } from "@oh-my-pi/pi-utils";
32
+ import type { ExtensionUIContext } from "../../extensibility/extensions";
33
+ import { loadSlashCommands } from "../../extensibility/slash-commands";
34
+ import { MCPManager } from "../../mcp/manager";
35
+ import type { MCPServerConfig } from "../../mcp/types";
36
+ import { theme } from "../../modes/theme/theme";
37
+ import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
38
+ import { SessionManager, type SessionInfo as StoredSessionInfo } from "../../session/session-manager";
39
+ import { parseThinkingLevel } from "../../thinking";
40
+ import { mapAgentSessionEventToAcpSessionUpdates, mapToolKind } from "./acp-event-mapper";
41
+
42
+ const ACP_MODE_ID = "default";
43
+ const MODE_CONFIG_ID = "mode";
44
+ const MODEL_CONFIG_ID = "model";
45
+ const THINKING_CONFIG_ID = "thinking";
46
+ const THINKING_OFF = "off";
47
+ const SESSION_PAGE_SIZE = 50;
48
+
49
+ type AgentImageContent = {
50
+ type: "image";
51
+ data: string;
52
+ mimeType: string;
53
+ };
54
+
55
+ type PromptTurnState = {
56
+ messageId: string | null;
57
+ cancelRequested: boolean;
58
+ settled: boolean;
59
+ unsubscribe: (() => void) | undefined;
60
+ resolve: (value: PromptResponse) => void;
61
+ reject: (reason?: unknown) => void;
62
+ };
63
+
64
+ type ReplayableMessage = {
65
+ role: string;
66
+ content?: unknown;
67
+ errorMessage?: string;
68
+ toolCallId?: string;
69
+ toolName?: string;
70
+ details?: unknown;
71
+ isError?: boolean;
72
+ };
73
+
74
+ type MCPConfigMap = {
75
+ [name: string]: MCPServerConfig;
76
+ };
77
+
78
+ type MCPSource = {
79
+ provider: string;
80
+ providerName: string;
81
+ path: string;
82
+ level: "project";
83
+ };
84
+
85
+ type MCPSourceMap = {
86
+ [name: string]: MCPSource;
87
+ };
88
+
89
+ const acpExtensionUiContext: ExtensionUIContext = {
90
+ select: async () => undefined,
91
+ confirm: async () => false,
92
+ input: async () => undefined,
93
+ notify: (message, type) => {
94
+ logger.debug("ACP extension notification", { message, type });
95
+ },
96
+ onTerminalInput: () => () => {},
97
+ setStatus: () => {},
98
+ setWorkingMessage: () => {},
99
+ setWidget: () => {},
100
+ setFooter: () => {},
101
+ setHeader: () => {},
102
+ setTitle: () => {},
103
+ custom: async () => undefined as never,
104
+ pasteToEditor: () => {},
105
+ setEditorText: () => {},
106
+ getEditorText: () => "",
107
+ editor: async () => undefined,
108
+ setEditorComponent: () => {},
109
+ get theme() {
110
+ return theme;
111
+ },
112
+ getAllThemes: async () => [],
113
+ getTheme: async () => undefined,
114
+ setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
115
+ getToolsExpanded: () => false,
116
+ setToolsExpanded: () => {},
117
+ };
118
+
119
+ export class AcpAgent implements Agent {
120
+ #connection: AgentSideConnection;
121
+ #session: AgentSession;
122
+ #mcpManager: MCPManager | undefined;
123
+ #promptTurn: PromptTurnState | undefined;
124
+ #hasOpenedSession = false;
125
+
126
+ constructor(connection: AgentSideConnection, session: AgentSession) {
127
+ this.#connection = connection;
128
+ this.#session = session;
129
+ }
130
+
131
+ async initialize(_params: InitializeRequest): Promise<InitializeResponse> {
132
+ return {
133
+ protocolVersion: PROTOCOL_VERSION,
134
+ agentInfo: {
135
+ name: "oh-my-pi",
136
+ title: "Oh My Pi",
137
+ version: VERSION,
138
+ },
139
+ authMethods: [
140
+ {
141
+ id: "agent",
142
+ name: "Agent-managed authentication",
143
+ description: "Oh My Pi uses its existing local authentication and provider configuration.",
144
+ },
145
+ ],
146
+ agentCapabilities: {
147
+ loadSession: true,
148
+ mcpCapabilities: {
149
+ http: true,
150
+ sse: true,
151
+ },
152
+ promptCapabilities: {
153
+ embeddedContext: true,
154
+ image: true,
155
+ },
156
+ sessionCapabilities: {
157
+ list: {},
158
+ },
159
+ },
160
+ };
161
+ }
162
+
163
+ async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
164
+ return {};
165
+ }
166
+
167
+ async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
168
+ this.#assertAbsoluteCwd(params.cwd);
169
+ await this.#session.sessionManager.flush();
170
+ await this.#session.sessionManager.moveTo(params.cwd);
171
+ if (this.#hasOpenedSession) {
172
+ const success = await this.#session.newSession();
173
+ if (!success) {
174
+ throw new Error("ACP session creation was cancelled");
175
+ }
176
+ }
177
+ this.#hasOpenedSession = true;
178
+ await this.#session.sessionManager.ensureOnDisk();
179
+ await this.#configureExtensions();
180
+ await this.#configureMcpServers(params.mcpServers);
181
+ const response: NewSessionResponse = {
182
+ sessionId: this.#sessionId,
183
+ configOptions: this.#buildConfigOptions(),
184
+ modes: this.#buildModeState(),
185
+ };
186
+ this.#scheduleBootstrapUpdates(this.#sessionId);
187
+ return response;
188
+ }
189
+
190
+ async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
191
+ this.#assertAbsoluteCwd(params.cwd);
192
+ await this.#session.sessionManager.flush();
193
+ const storedSession = await this.#findStoredSession(params.sessionId, params.cwd);
194
+ if (!storedSession) {
195
+ throw new Error(`ACP session not found: ${params.sessionId}`);
196
+ }
197
+ const currentSessionFile = this.#session.sessionManager.getSessionFile();
198
+ if (currentSessionFile !== storedSession.path) {
199
+ const success = await this.#session.switchSession(storedSession.path);
200
+ if (!success) {
201
+ throw new Error(`ACP session load was cancelled: ${params.sessionId}`);
202
+ }
203
+ }
204
+ this.#hasOpenedSession = true;
205
+ await this.#configureExtensions();
206
+ await this.#configureMcpServers(params.mcpServers);
207
+ await this.#replaySessionHistory();
208
+ const response: LoadSessionResponse = {
209
+ configOptions: this.#buildConfigOptions(),
210
+ modes: this.#buildModeState(),
211
+ };
212
+ this.#scheduleBootstrapUpdates(this.#sessionId);
213
+ return response;
214
+ }
215
+
216
+ async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
217
+ if (params.cwd) {
218
+ this.#assertAbsoluteCwd(params.cwd);
219
+ }
220
+ await this.#session.sessionManager.flush();
221
+ const sessions = await this.#listStoredSessions(params.cwd ?? undefined);
222
+ const offset = this.#parseCursor(params.cursor ?? undefined);
223
+ const paged = sessions.slice(offset, offset + SESSION_PAGE_SIZE);
224
+ const nextOffset = offset + paged.length;
225
+ return {
226
+ sessions: paged.map(session => this.#toSessionInfo(session)),
227
+ nextCursor: nextOffset < sessions.length ? String(nextOffset) : undefined,
228
+ };
229
+ }
230
+
231
+ async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
232
+ this.#assertSameSession(params.sessionId);
233
+ if (params.modeId !== ACP_MODE_ID) {
234
+ throw new Error(`Unsupported ACP mode: ${params.modeId}`);
235
+ }
236
+ await this.#connection.sessionUpdate({
237
+ sessionId: this.#sessionId,
238
+ update: this.#buildCurrentModeUpdate(),
239
+ });
240
+ return {};
241
+ }
242
+
243
+ async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
244
+ this.#assertSameSession(params.sessionId);
245
+ if (typeof params.value === "boolean") {
246
+ throw new Error(`Unsupported boolean ACP config option: ${params.configId}`);
247
+ }
248
+
249
+ switch (params.configId) {
250
+ case MODE_CONFIG_ID:
251
+ if (params.value !== ACP_MODE_ID) {
252
+ throw new Error(`Unsupported ACP mode config value: ${params.value}`);
253
+ }
254
+ break;
255
+ case MODEL_CONFIG_ID:
256
+ await this.#setModelById(params.value);
257
+ break;
258
+ case THINKING_CONFIG_ID:
259
+ this.#setThinkingLevelById(params.value);
260
+ break;
261
+ default:
262
+ throw new Error(`Unknown ACP config option: ${params.configId}`);
263
+ }
264
+
265
+ const configOptions = this.#buildConfigOptions();
266
+ await this.#connection.sessionUpdate({
267
+ sessionId: this.#sessionId,
268
+ update: {
269
+ sessionUpdate: "config_option_update",
270
+ configOptions,
271
+ },
272
+ });
273
+ return { configOptions };
274
+ }
275
+
276
+ async prompt(params: PromptRequest): Promise<PromptResponse> {
277
+ this.#assertSameSession(params.sessionId);
278
+ if (this.#promptTurn && !this.#promptTurn.settled) {
279
+ throw new Error("ACP prompt already in progress for this session");
280
+ }
281
+
282
+ const converted = this.#convertPromptBlocks(params.prompt);
283
+ const pendingPrompt = Promise.withResolvers<PromptResponse>();
284
+ this.#promptTurn = {
285
+ messageId: params.messageId ?? null,
286
+ cancelRequested: false,
287
+ settled: false,
288
+ unsubscribe: undefined,
289
+ resolve: pendingPrompt.resolve,
290
+ reject: pendingPrompt.reject,
291
+ };
292
+
293
+ this.#promptTurn.unsubscribe = this.#session.subscribe(event => {
294
+ void this.#handlePromptEvent(event);
295
+ });
296
+
297
+ this.#session.prompt(converted.text, { images: converted.images }).catch((error: unknown) => {
298
+ this.#finishPrompt(undefined, error);
299
+ });
300
+
301
+ return await pendingPrompt.promise;
302
+ }
303
+
304
+ async cancel(params: { sessionId: string }): Promise<void> {
305
+ this.#assertSameSession(params.sessionId);
306
+ const promptTurn = this.#promptTurn;
307
+ if (!promptTurn || promptTurn.settled) {
308
+ return;
309
+ }
310
+ promptTurn.cancelRequested = true;
311
+ try {
312
+ await this.#session.abort();
313
+ this.#finishPrompt({
314
+ stopReason: "cancelled",
315
+ userMessageId: promptTurn.messageId,
316
+ });
317
+ } catch (error: unknown) {
318
+ this.#finishPrompt(undefined, error);
319
+ }
320
+ }
321
+
322
+ async extMethod(_method: string, _params: { [key: string]: unknown }): Promise<{ [key: string]: unknown }> {
323
+ throw new Error("ACP extension methods are not implemented");
324
+ }
325
+
326
+ async extNotification(_method: string, _params: { [key: string]: unknown }): Promise<void> {}
327
+
328
+ get signal(): AbortSignal {
329
+ return this.#connection.signal;
330
+ }
331
+
332
+ get closed(): Promise<void> {
333
+ return this.#connection.closed;
334
+ }
335
+
336
+ get #sessionId(): string {
337
+ return this.#session.sessionId;
338
+ }
339
+
340
+ async #handlePromptEvent(event: AgentSessionEvent): Promise<void> {
341
+ const promptTurn = this.#promptTurn;
342
+ if (!promptTurn || promptTurn.settled) {
343
+ return;
344
+ }
345
+
346
+ for (const notification of mapAgentSessionEventToAcpSessionUpdates(event, this.#sessionId)) {
347
+ await this.#connection.sessionUpdate(notification);
348
+ }
349
+
350
+ if (event.type === "agent_end") {
351
+ await this.#emitEndOfTurnUpdates();
352
+ this.#finishPrompt({
353
+ stopReason: promptTurn.cancelRequested ? "cancelled" : "end_turn",
354
+ userMessageId: promptTurn.messageId,
355
+ });
356
+ }
357
+ }
358
+
359
+ #finishPrompt(response?: PromptResponse, error?: unknown): void {
360
+ const promptTurn = this.#promptTurn;
361
+ if (!promptTurn || promptTurn.settled) {
362
+ return;
363
+ }
364
+ promptTurn.settled = true;
365
+ promptTurn.unsubscribe?.();
366
+ this.#promptTurn = undefined;
367
+ if (error !== undefined) {
368
+ promptTurn.reject(error);
369
+ return;
370
+ }
371
+ promptTurn.resolve(response ?? { stopReason: "end_turn" });
372
+ }
373
+
374
+ #assertSameSession(sessionId: string): void {
375
+ if (sessionId !== this.#sessionId) {
376
+ throw new Error(`Unsupported ACP session: ${sessionId}`);
377
+ }
378
+ }
379
+
380
+ #assertAbsoluteCwd(cwd: string): void {
381
+ if (!path.isAbsolute(cwd)) {
382
+ throw new Error(`ACP cwd must be absolute: ${cwd}`);
383
+ }
384
+ }
385
+
386
+ #convertPromptBlocks(blocks: PromptRequest["prompt"]): { text: string; images: AgentImageContent[] } {
387
+ const textParts: string[] = [];
388
+ const images: AgentImageContent[] = [];
389
+ for (const block of blocks) {
390
+ switch (block.type) {
391
+ case "text":
392
+ textParts.push(block.text);
393
+ break;
394
+ case "image":
395
+ images.push({ type: "image", data: block.data, mimeType: block.mimeType });
396
+ break;
397
+ case "resource":
398
+ if ("text" in block.resource) {
399
+ textParts.push(block.resource.text);
400
+ } else {
401
+ textParts.push(`[embedded resource: ${block.resource.uri}]`);
402
+ }
403
+ break;
404
+ case "resource_link":
405
+ textParts.push(block.title ?? block.name ?? block.uri);
406
+ break;
407
+ case "audio":
408
+ textParts.push("[audio omitted]");
409
+ break;
410
+ }
411
+ }
412
+ return {
413
+ text: textParts.join("\n\n").trim(),
414
+ images,
415
+ };
416
+ }
417
+
418
+ #buildConfigOptions(): SessionConfigOption[] {
419
+ const configOptions: SessionConfigOption[] = [
420
+ {
421
+ id: MODE_CONFIG_ID,
422
+ name: "Mode",
423
+ category: "mode",
424
+ type: "select",
425
+ currentValue: ACP_MODE_ID,
426
+ options: [{ value: ACP_MODE_ID, name: "Default", description: "Standard ACP headless mode" }],
427
+ },
428
+ ];
429
+
430
+ const models = this.#session.getAvailableModels();
431
+ const currentModel = this.#session.model;
432
+ if (models.length > 0) {
433
+ configOptions.push({
434
+ id: MODEL_CONFIG_ID,
435
+ name: "Model",
436
+ category: "model",
437
+ type: "select",
438
+ currentValue: currentModel ? this.#toModelId(currentModel) : this.#toModelId(models[0]),
439
+ options: models.map(model => ({
440
+ value: this.#toModelId(model),
441
+ name: model.name,
442
+ description: `${model.provider}/${model.id}`,
443
+ })),
444
+ });
445
+ }
446
+
447
+ configOptions.push({
448
+ id: THINKING_CONFIG_ID,
449
+ name: "Thinking",
450
+ category: "thought_level",
451
+ type: "select",
452
+ currentValue: this.#toThinkingConfigValue(this.#session.thinkingLevel),
453
+ options: this.#buildThinkingOptions(),
454
+ });
455
+ return configOptions;
456
+ }
457
+
458
+ #buildThinkingOptions(): Array<{ value: string; name: string; description?: string }> {
459
+ return [
460
+ { value: THINKING_OFF, name: "Off" },
461
+ ...this.#session.getAvailableThinkingLevels().map(level => ({
462
+ value: level,
463
+ name: level,
464
+ })),
465
+ ];
466
+ }
467
+
468
+ #toThinkingConfigValue(value: string | undefined): string {
469
+ return value && value !== "inherit" ? value : THINKING_OFF;
470
+ }
471
+
472
+ async #setModelById(modelId: string): Promise<void> {
473
+ const model = this.#session.getAvailableModels().find(candidate => this.#toModelId(candidate) === modelId);
474
+ if (!model) {
475
+ throw new Error(`Unknown ACP model: ${modelId}`);
476
+ }
477
+ await this.#session.setModel(model);
478
+ }
479
+
480
+ #setThinkingLevelById(value: string): void {
481
+ const thinkingLevel = parseThinkingLevel(value);
482
+ if (!thinkingLevel) {
483
+ throw new Error(`Unknown ACP thinking level: ${value}`);
484
+ }
485
+ this.#session.setThinkingLevel(thinkingLevel);
486
+ }
487
+
488
+ #toModelId(model: Model): string {
489
+ return `${model.provider}/${model.id}`;
490
+ }
491
+
492
+ #buildModeState(): SessionModeState {
493
+ return {
494
+ availableModes: [{ id: ACP_MODE_ID, name: "Default", description: "Standard ACP headless mode" }],
495
+ currentModeId: ACP_MODE_ID,
496
+ };
497
+ }
498
+
499
+ #buildCurrentModeUpdate(): SessionUpdate {
500
+ return {
501
+ sessionUpdate: "current_mode_update",
502
+ currentModeId: ACP_MODE_ID,
503
+ };
504
+ }
505
+
506
+ async #buildAvailableCommands(): Promise<AvailableCommand[]> {
507
+ const commands: AvailableCommand[] = [];
508
+ const seenNames = new Set<string>();
509
+ const appendCommand = (command: AvailableCommand): void => {
510
+ if (seenNames.has(command.name)) {
511
+ return;
512
+ }
513
+ seenNames.add(command.name);
514
+ commands.push(command);
515
+ };
516
+
517
+ for (const command of this.#session.customCommands) {
518
+ appendCommand({
519
+ name: command.command.name,
520
+ description: command.command.description,
521
+ input: { hint: "arguments" },
522
+ });
523
+ }
524
+
525
+ for (const command of await loadSlashCommands({ cwd: this.#session.sessionManager.getCwd() })) {
526
+ appendCommand({
527
+ name: command.name,
528
+ description: command.description,
529
+ });
530
+ }
531
+
532
+ return commands;
533
+ }
534
+
535
+ #toSessionInfo(session: StoredSessionInfo): SessionInfo {
536
+ return {
537
+ sessionId: session.id,
538
+ cwd: session.cwd,
539
+ title: session.title,
540
+ updatedAt: session.modified.toISOString(),
541
+ };
542
+ }
543
+
544
+ #scheduleBootstrapUpdates(sessionId: string): void {
545
+ setTimeout(() => {
546
+ if (sessionId !== this.#sessionId || this.#connection.signal.aborted) {
547
+ return;
548
+ }
549
+ void this.#emitBootstrapUpdates(sessionId);
550
+ }, 0);
551
+ }
552
+
553
+ async #emitBootstrapUpdates(sessionId: string): Promise<void> {
554
+ if (sessionId !== this.#sessionId) {
555
+ return;
556
+ }
557
+ await this.#connection.sessionUpdate({
558
+ sessionId,
559
+ update: {
560
+ sessionUpdate: "available_commands_update",
561
+ availableCommands: await this.#buildAvailableCommands(),
562
+ },
563
+ });
564
+ await this.#connection.sessionUpdate({
565
+ sessionId,
566
+ update: {
567
+ sessionUpdate: "session_info_update",
568
+ title: this.#session.sessionName,
569
+ updatedAt: this.#session.sessionManager.getHeader()?.timestamp,
570
+ },
571
+ });
572
+ }
573
+
574
+ async #emitEndOfTurnUpdates(): Promise<void> {
575
+ const sessionId = this.#sessionId;
576
+
577
+ // Emit usage update with context token counts
578
+ const contextUsage = this.#session.getContextUsage();
579
+ if (contextUsage) {
580
+ const usageStats = this.#session.sessionManager.getUsageStatistics();
581
+ await this.#connection.sessionUpdate({
582
+ sessionId,
583
+ update: {
584
+ sessionUpdate: "usage_update",
585
+ size: contextUsage.contextWindow,
586
+ used: contextUsage.tokens ?? 0,
587
+ cost: usageStats.cost > 0 ? { amount: usageStats.cost, currency: "USD" } : undefined,
588
+ },
589
+ });
590
+ }
591
+
592
+ // Push latest session title
593
+ await this.#connection.sessionUpdate({
594
+ sessionId,
595
+ update: {
596
+ sessionUpdate: "session_info_update",
597
+ title: this.#session.sessionName,
598
+ updatedAt: new Date().toISOString(),
599
+ },
600
+ });
601
+ }
602
+
603
+ async #listStoredSessions(cwd?: string): Promise<StoredSessionInfo[]> {
604
+ const sessions = cwd ? await SessionManager.list(cwd) : await SessionManager.listAll();
605
+ return sessions.sort((left, right) => right.modified.getTime() - left.modified.getTime());
606
+ }
607
+
608
+ async #findStoredSession(sessionId: string, cwd: string): Promise<StoredSessionInfo | undefined> {
609
+ const sessions = await this.#listStoredSessions(cwd);
610
+ return sessions.find(session => session.id === sessionId);
611
+ }
612
+
613
+ #parseCursor(cursor: string | undefined): number {
614
+ if (!cursor) {
615
+ return 0;
616
+ }
617
+ const parsed = Number.parseInt(cursor, 10);
618
+ if (!Number.isFinite(parsed) || parsed < 0) {
619
+ throw new Error(`Invalid ACP session cursor: ${cursor}`);
620
+ }
621
+ return parsed;
622
+ }
623
+
624
+ async #replaySessionHistory(): Promise<void> {
625
+ for (const message of this.#session.sessionManager.buildSessionContext().messages as ReplayableMessage[]) {
626
+ for (const notification of this.#messageToReplayNotifications(message)) {
627
+ await this.#connection.sessionUpdate(notification);
628
+ }
629
+ }
630
+ }
631
+
632
+ #messageToReplayNotifications(message: ReplayableMessage): SessionNotification[] {
633
+ if (message.role === "assistant") {
634
+ return this.#replayAssistantMessage(message);
635
+ }
636
+ if (
637
+ message.role === "user" ||
638
+ message.role === "developer" ||
639
+ message.role === "custom" ||
640
+ message.role === "hookMessage"
641
+ ) {
642
+ return this.#wrapReplayContent(this.#extractReplayContent(message.content, undefined), "user_message_chunk");
643
+ }
644
+ if (
645
+ message.role === "toolResult" &&
646
+ typeof message.toolCallId === "string" &&
647
+ typeof message.toolName === "string"
648
+ ) {
649
+ return this.#replayToolResult({ ...message, toolCallId: message.toolCallId, toolName: message.toolName });
650
+ }
651
+ if (
652
+ message.role === "bashExecution" ||
653
+ message.role === "pythonExecution" ||
654
+ message.role === "compactionSummary"
655
+ ) {
656
+ return this.#wrapReplayContent(this.#extractReplayContent(message.content, undefined), "user_message_chunk");
657
+ }
658
+ return [];
659
+ }
660
+
661
+ #replayAssistantMessage(message: ReplayableMessage): SessionNotification[] {
662
+ const notifications: SessionNotification[] = [];
663
+ const sessionId = this.#sessionId;
664
+ if (Array.isArray(message.content)) {
665
+ for (const item of message.content) {
666
+ if (typeof item !== "object" || item === null || !("type" in item)) {
667
+ continue;
668
+ }
669
+ if (item.type === "text" && "text" in item && typeof item.text === "string" && item.text.length > 0) {
670
+ notifications.push({
671
+ sessionId,
672
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: item.text } },
673
+ });
674
+ continue;
675
+ }
676
+ if (
677
+ item.type === "thinking" &&
678
+ "thinking" in item &&
679
+ typeof item.thinking === "string" &&
680
+ item.thinking.length > 0
681
+ ) {
682
+ notifications.push({
683
+ sessionId,
684
+ update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: item.thinking } },
685
+ });
686
+ continue;
687
+ }
688
+ if (
689
+ (item.type === "toolCall" || item.type === "tool_use") &&
690
+ "id" in item &&
691
+ typeof item.id === "string" &&
692
+ "name" in item &&
693
+ typeof item.name === "string"
694
+ ) {
695
+ const update: SessionUpdate = {
696
+ sessionUpdate: "tool_call",
697
+ toolCallId: item.id,
698
+ title: item.name,
699
+ kind: mapToolKind(item.name),
700
+ status: "completed",
701
+ };
702
+ if ("arguments" in item && typeof item.arguments === "string") {
703
+ update.rawInput = item.arguments;
704
+ }
705
+ notifications.push({ sessionId, update });
706
+ }
707
+ }
708
+ }
709
+ if (notifications.length === 0 && message.errorMessage) {
710
+ notifications.push({
711
+ sessionId,
712
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: message.errorMessage } },
713
+ });
714
+ }
715
+ return notifications;
716
+ }
717
+
718
+ #replayToolResult(
719
+ message: Required<Pick<ReplayableMessage, "toolCallId" | "toolName">> & ReplayableMessage,
720
+ ): SessionNotification[] {
721
+ const args = this.#buildReplayToolArgs(message.details);
722
+ const startEvent: AgentSessionEvent = {
723
+ type: "tool_execution_start",
724
+ toolCallId: message.toolCallId,
725
+ toolName: message.toolName,
726
+ args,
727
+ };
728
+ const endEvent: AgentSessionEvent = {
729
+ type: "tool_execution_end",
730
+ toolCallId: message.toolCallId,
731
+ toolName: message.toolName,
732
+ isError: message.isError === true,
733
+ result: {
734
+ content: message.content,
735
+ details: message.details,
736
+ errorMessage: message.errorMessage,
737
+ },
738
+ };
739
+ return [
740
+ ...mapAgentSessionEventToAcpSessionUpdates(startEvent, this.#sessionId),
741
+ ...mapAgentSessionEventToAcpSessionUpdates(endEvent, this.#sessionId),
742
+ ];
743
+ }
744
+
745
+ #buildReplayToolArgs(details: unknown): { path?: string } {
746
+ if (typeof details !== "object" || details === null || !("path" in details)) {
747
+ return {};
748
+ }
749
+ const value = (details as { path?: unknown }).path;
750
+ return typeof value === "string" && value.length > 0 ? { path: value } : {};
751
+ }
752
+
753
+ #wrapReplayContent(
754
+ content: PromptRequest["prompt"],
755
+ kind: "agent_message_chunk" | "user_message_chunk",
756
+ ): SessionNotification[] {
757
+ return content.map(block => ({
758
+ sessionId: this.#sessionId,
759
+ update: {
760
+ sessionUpdate: kind,
761
+ content: block,
762
+ },
763
+ }));
764
+ }
765
+
766
+ #extractReplayContent(content: unknown, errorMessage: string | undefined): PromptRequest["prompt"] {
767
+ const replay: PromptRequest["prompt"] = [];
768
+ if (Array.isArray(content)) {
769
+ for (const item of content) {
770
+ if (typeof item !== "object" || item === null || !("type" in item)) {
771
+ continue;
772
+ }
773
+ if (item.type === "text" && "text" in item && typeof item.text === "string" && item.text.length > 0) {
774
+ replay.push({ type: "text", text: item.text });
775
+ continue;
776
+ }
777
+ if (
778
+ item.type === "image" &&
779
+ "data" in item &&
780
+ "mimeType" in item &&
781
+ typeof item.data === "string" &&
782
+ typeof item.mimeType === "string"
783
+ ) {
784
+ replay.push({ type: "image", data: item.data, mimeType: item.mimeType });
785
+ }
786
+ }
787
+ }
788
+ if (replay.length === 0 && errorMessage) {
789
+ replay.push({ type: "text", text: errorMessage });
790
+ }
791
+ return replay;
792
+ }
793
+
794
+ async #configureExtensions(): Promise<void> {
795
+ const extensionRunner = this.#session.extensionRunner;
796
+ if (!extensionRunner) {
797
+ return;
798
+ }
799
+
800
+ extensionRunner.initialize(
801
+ {
802
+ sendMessage: (message, options) => {
803
+ this.#session.sendCustomMessage(message, options).catch((error: unknown) => {
804
+ logger.warn("ACP extension sendMessage failed", { error });
805
+ });
806
+ },
807
+ sendUserMessage: (content, options) => {
808
+ this.#session.sendUserMessage(content, options).catch((error: unknown) => {
809
+ logger.warn("ACP extension sendUserMessage failed", { error });
810
+ });
811
+ },
812
+ appendEntry: (customType, data) => {
813
+ this.#session.sessionManager.appendCustomEntry(customType, data);
814
+ },
815
+ setLabel: (targetId, label) => {
816
+ this.#session.sessionManager.appendLabelChange(targetId, label);
817
+ },
818
+ getActiveTools: () => this.#session.getActiveToolNames(),
819
+ getAllTools: () => this.#session.getAllToolNames(),
820
+ setActiveTools: toolNames => this.#session.setActiveToolsByName(toolNames),
821
+ getCommands: () => [],
822
+ setModel: async model => {
823
+ const apiKey = await this.#session.modelRegistry.getApiKey(model);
824
+ if (!apiKey) {
825
+ return false;
826
+ }
827
+ await this.#session.setModel(model);
828
+ return true;
829
+ },
830
+ getThinkingLevel: () => this.#session.thinkingLevel,
831
+ setThinkingLevel: level => this.#session.setThinkingLevel(level),
832
+ },
833
+ {
834
+ getModel: () => this.#session.model,
835
+ isIdle: () => !this.#session.isStreaming,
836
+ abort: () => {
837
+ void this.#session.abort();
838
+ },
839
+ hasPendingMessages: () => this.#session.queuedMessageCount > 0,
840
+ shutdown: () => {},
841
+ getContextUsage: () => this.#session.getContextUsage(),
842
+ getSystemPrompt: () => this.#session.systemPrompt,
843
+ compact: async instructionsOrOptions => {
844
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
845
+ const options =
846
+ instructionsOrOptions && typeof instructionsOrOptions === "object"
847
+ ? instructionsOrOptions
848
+ : undefined;
849
+ await this.#session.compact(instructions, options);
850
+ },
851
+ },
852
+ {
853
+ getContextUsage: () => this.#session.getContextUsage(),
854
+ waitForIdle: () => this.#session.agent.waitForIdle(),
855
+ newSession: async options => {
856
+ const success = await this.#session.newSession({ parentSession: options?.parentSession });
857
+ if (success && options?.setup) {
858
+ await options.setup(this.#session.sessionManager);
859
+ }
860
+ return { cancelled: !success };
861
+ },
862
+ branch: async entryId => {
863
+ const result = await this.#session.branch(entryId);
864
+ return { cancelled: result.cancelled };
865
+ },
866
+ navigateTree: async (targetId, options) => {
867
+ const result = await this.#session.navigateTree(targetId, { summarize: options?.summarize });
868
+ return { cancelled: result.cancelled };
869
+ },
870
+ switchSession: async sessionPath => {
871
+ const success = await this.#session.switchSession(sessionPath);
872
+ return { cancelled: !success };
873
+ },
874
+ reload: async () => {
875
+ await this.#session.reload();
876
+ },
877
+ compact: async instructionsOrOptions => {
878
+ const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
879
+ const options =
880
+ instructionsOrOptions && typeof instructionsOrOptions === "object"
881
+ ? instructionsOrOptions
882
+ : undefined;
883
+ await this.#session.compact(instructions, options);
884
+ },
885
+ },
886
+ acpExtensionUiContext,
887
+ );
888
+ await extensionRunner.emit({ type: "session_start" });
889
+ }
890
+
891
+ async #configureMcpServers(servers: McpServer[]): Promise<void> {
892
+ if (this.#mcpManager) {
893
+ await this.#mcpManager.disconnectAll();
894
+ }
895
+ if (servers.length === 0) {
896
+ this.#mcpManager = undefined;
897
+ await this.#session.refreshMCPTools([]);
898
+ return;
899
+ }
900
+
901
+ const manager = new MCPManager(this.#session.sessionManager.getCwd());
902
+ const configs: MCPConfigMap = {};
903
+ const sources: MCPSourceMap = {};
904
+ for (const server of servers) {
905
+ configs[server.name] = this.#toMcpConfig(server);
906
+ sources[server.name] = {
907
+ provider: "acp",
908
+ providerName: "ACP Client",
909
+ path: `acp://${server.name}`,
910
+ level: "project",
911
+ };
912
+ }
913
+
914
+ const result = await manager.connectServers(configs, sources);
915
+ if (result.errors.size > 0) {
916
+ throw new Error(
917
+ Array.from(result.errors.entries())
918
+ .map(([name, message]) => `${name}: ${message}`)
919
+ .join("; "),
920
+ );
921
+ }
922
+
923
+ this.#mcpManager = manager;
924
+ await this.#session.refreshMCPTools(result.tools);
925
+ }
926
+
927
+ #toMcpConfig(server: McpServer): MCPServerConfig {
928
+ if ("command" in server) {
929
+ return {
930
+ type: "stdio",
931
+ command: server.command,
932
+ args: server.args,
933
+ env: this.#toNameValueMap(server.env),
934
+ };
935
+ }
936
+ if (server.type === "http") {
937
+ return {
938
+ type: "http",
939
+ url: server.url,
940
+ headers: this.#toNameValueMap(server.headers),
941
+ };
942
+ }
943
+ return {
944
+ type: "sse",
945
+ url: server.url,
946
+ headers: this.#toNameValueMap(server.headers),
947
+ };
948
+ }
949
+
950
+ #toNameValueMap(values: Array<{ name: string; value: string }>): { [name: string]: string } {
951
+ const mapped: { [name: string]: string } = {};
952
+ for (const value of values) {
953
+ mapped[value.name] = value.value;
954
+ }
955
+ return mapped;
956
+ }
957
+ }