@mseep/obsidian-agent-client 0.10.6

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 (146) hide show
  1. package/.claude/hooks/gh-setup.sh +49 -0
  2. package/.claude/settings.json +15 -0
  3. package/.claude/skills/release-notes/SKILL.md +331 -0
  4. package/.editorconfig +10 -0
  5. package/.github/FUNDING.yml +2 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  7. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  8. package/.github/ISSUE_TEMPLATE/feature_request.yml +59 -0
  9. package/.github/copilot-instructions.md +45 -0
  10. package/.github/pull_request_template.md +32 -0
  11. package/.github/workflows/ci.yaml +25 -0
  12. package/.github/workflows/docs.yml +58 -0
  13. package/.github/workflows/relay_to_openclaw.yml +59 -0
  14. package/.github/workflows/release.yaml +45 -0
  15. package/.prettierignore +10 -0
  16. package/.prettierrc +13 -0
  17. package/.vscode/extensions.json +7 -0
  18. package/.vscode/settings.json +37 -0
  19. package/.zed/settings.json +42 -0
  20. package/AGENTS.md +330 -0
  21. package/ARCHITECTURE.md +390 -0
  22. package/CONTRIBUTING.md +216 -0
  23. package/LICENSE +202 -0
  24. package/NOTICE +2 -0
  25. package/README.ja.md +121 -0
  26. package/README.md +125 -0
  27. package/docs/.vitepress/config.mts +124 -0
  28. package/docs/.vitepress/theme/custom.css +111 -0
  29. package/docs/.vitepress/theme/index.ts +4 -0
  30. package/docs/agent-setup/claude-code.md +84 -0
  31. package/docs/agent-setup/codex.md +76 -0
  32. package/docs/agent-setup/custom-agents.md +67 -0
  33. package/docs/agent-setup/gemini-cli.md +99 -0
  34. package/docs/agent-setup/index.md +34 -0
  35. package/docs/announcements/gemini-cli-deprecation.md +73 -0
  36. package/docs/getting-started/index.md +78 -0
  37. package/docs/getting-started/quick-start.md +38 -0
  38. package/docs/help/faq.md +181 -0
  39. package/docs/help/troubleshooting.md +221 -0
  40. package/docs/index.md +63 -0
  41. package/docs/public/apple-touch-icon.png +0 -0
  42. package/docs/public/demo.mp4 +0 -0
  43. package/docs/public/favicon-16x16.png +0 -0
  44. package/docs/public/favicon-32x32.png +0 -0
  45. package/docs/public/favicon.ico +0 -0
  46. package/docs/public/images/editing.webp +0 -0
  47. package/docs/public/images/export.webp +0 -0
  48. package/docs/public/images/floating-chat-button.webp +0 -0
  49. package/docs/public/images/floating-chat-instance-menu.webp +0 -0
  50. package/docs/public/images/floating-chat-view.webp +0 -0
  51. package/docs/public/images/mode-selection.webp +0 -0
  52. package/docs/public/images/model-selection.webp +0 -0
  53. package/docs/public/images/multi-session.webp +0 -0
  54. package/docs/public/images/remove-image.webp +0 -0
  55. package/docs/public/images/ribbon-icon.webp +0 -0
  56. package/docs/public/images/selection-context.gif +0 -0
  57. package/docs/public/images/sending-images.webp +0 -0
  58. package/docs/public/images/sending-messages.webp +0 -0
  59. package/docs/public/images/session-history-button.webp +0 -0
  60. package/docs/public/images/slash-commands-1.webp +0 -0
  61. package/docs/public/images/slash-commands-2.webp +0 -0
  62. package/docs/public/images/switch-agent.webp +0 -0
  63. package/docs/public/images/switch-default-agent.webp +0 -0
  64. package/docs/public/images/temporary-disable.gif +0 -0
  65. package/docs/reference/acp-support.md +110 -0
  66. package/docs/usage/chat-export.md +80 -0
  67. package/docs/usage/commands.md +51 -0
  68. package/docs/usage/context-files.md +57 -0
  69. package/docs/usage/editing.md +69 -0
  70. package/docs/usage/floating-chat.md +84 -0
  71. package/docs/usage/index.md +97 -0
  72. package/docs/usage/mcp-tools.md +33 -0
  73. package/docs/usage/mentions.md +70 -0
  74. package/docs/usage/mode-selection.md +28 -0
  75. package/docs/usage/model-selection.md +32 -0
  76. package/docs/usage/multi-session.md +68 -0
  77. package/docs/usage/sending-images.md +64 -0
  78. package/docs/usage/session-history.md +91 -0
  79. package/docs/usage/slash-commands.md +44 -0
  80. package/esbuild.config.mjs +49 -0
  81. package/eslint.config.mjs +25 -0
  82. package/main.js +228 -0
  83. package/manifest.json +11 -0
  84. package/package.json +52 -0
  85. package/src/acp/acp-client.ts +921 -0
  86. package/src/acp/acp-handler.ts +252 -0
  87. package/src/acp/permission-handler.ts +282 -0
  88. package/src/acp/terminal-handler.ts +264 -0
  89. package/src/acp/type-converter.ts +272 -0
  90. package/src/hooks/useAgent.ts +250 -0
  91. package/src/hooks/useAgentMessages.ts +470 -0
  92. package/src/hooks/useAgentSession.ts +544 -0
  93. package/src/hooks/useChatActions.ts +400 -0
  94. package/src/hooks/useHistoryModal.ts +219 -0
  95. package/src/hooks/useSessionHistory.ts +863 -0
  96. package/src/hooks/useSettings.ts +19 -0
  97. package/src/hooks/useSuggestions.ts +342 -0
  98. package/src/main.ts +9 -0
  99. package/src/plugin.ts +1126 -0
  100. package/src/services/chat-exporter.ts +552 -0
  101. package/src/services/message-sender.ts +755 -0
  102. package/src/services/message-state.ts +375 -0
  103. package/src/services/session-helpers.ts +211 -0
  104. package/src/services/session-state.ts +130 -0
  105. package/src/services/session-storage.ts +267 -0
  106. package/src/services/settings-normalizer.ts +255 -0
  107. package/src/services/settings-service.ts +285 -0
  108. package/src/services/update-checker.ts +128 -0
  109. package/src/services/vault-service.ts +558 -0
  110. package/src/services/view-registry.ts +345 -0
  111. package/src/types/agent.ts +92 -0
  112. package/src/types/chat.ts +351 -0
  113. package/src/types/errors.ts +136 -0
  114. package/src/types/obsidian-internals.d.ts +14 -0
  115. package/src/types/session.ts +731 -0
  116. package/src/ui/ChangeDirectoryModal.ts +137 -0
  117. package/src/ui/ChatContext.ts +25 -0
  118. package/src/ui/ChatHeader.tsx +295 -0
  119. package/src/ui/ChatPanel.tsx +1162 -0
  120. package/src/ui/ChatView.tsx +348 -0
  121. package/src/ui/ErrorBanner.tsx +104 -0
  122. package/src/ui/FloatingButton.tsx +351 -0
  123. package/src/ui/FloatingChatView.tsx +531 -0
  124. package/src/ui/InputArea.tsx +1107 -0
  125. package/src/ui/InputToolbar.tsx +371 -0
  126. package/src/ui/MessageBubble.tsx +442 -0
  127. package/src/ui/MessageList.tsx +265 -0
  128. package/src/ui/PermissionBanner.tsx +61 -0
  129. package/src/ui/SessionHistoryModal.tsx +821 -0
  130. package/src/ui/SettingsTab.ts +1337 -0
  131. package/src/ui/SuggestionPopup.tsx +138 -0
  132. package/src/ui/TerminalBlock.tsx +107 -0
  133. package/src/ui/ToolCallBlock.tsx +456 -0
  134. package/src/ui/shared/AttachmentStrip.tsx +57 -0
  135. package/src/ui/shared/IconButton.tsx +55 -0
  136. package/src/ui/shared/MarkdownRenderer.tsx +103 -0
  137. package/src/ui/view-host.ts +56 -0
  138. package/src/utils/error-utils.ts +274 -0
  139. package/src/utils/logger.ts +44 -0
  140. package/src/utils/mention-parser.ts +129 -0
  141. package/src/utils/paths.ts +246 -0
  142. package/src/utils/platform.ts +425 -0
  143. package/styles.css +2322 -0
  144. package/tsconfig.json +18 -0
  145. package/version-bump.mjs +18 -0
  146. package/versions.json +3 -0
@@ -0,0 +1,921 @@
1
+ import { spawn, ChildProcess } from "child_process";
2
+ import * as acp from "@agentclientprotocol/sdk";
3
+ import { Platform } from "obsidian";
4
+
5
+ import type {
6
+ InitializeResult,
7
+ SessionConfigOption,
8
+ SessionUpdate,
9
+ ListSessionsResult,
10
+ SessionResult,
11
+ } from "../types/session";
12
+ import type { PromptContent } from "../types/chat";
13
+ import type { ProcessError } from "../types/errors";
14
+ import { AcpTypeConverter } from "./type-converter";
15
+ import { TerminalManager } from "./terminal-handler";
16
+ import { PermissionManager } from "./permission-handler";
17
+ import { AcpHandler } from "./acp-handler";
18
+ import { getLogger, Logger } from "../utils/logger";
19
+ import type AgentClientPlugin from "../plugin";
20
+ import {
21
+ convertWindowsPathToWsl,
22
+ getEnhancedWindowsEnv,
23
+ prepareShellCommand,
24
+ } from "../utils/platform";
25
+ import { resolveNodeDirectory } from "../utils/paths";
26
+ import {
27
+ extractStderrErrorHint,
28
+ getSpawnErrorInfo,
29
+ getCommandNotFoundSuggestion,
30
+ isEmptyResponseError,
31
+ isUserAbortedError,
32
+ } from "../utils/error-utils";
33
+
34
+ /**
35
+ * Runtime configuration for launching an AI agent process.
36
+ * Converted from BaseAgentSettings by toAgentConfig() in settings-service.
37
+ */
38
+ export interface AgentConfig {
39
+ id: string;
40
+ displayName: string;
41
+ command: string;
42
+ args: string[];
43
+ env?: Record<string, string>;
44
+ workingDirectory: string;
45
+ }
46
+
47
+ /**
48
+ * Result of polling terminal output.
49
+ */
50
+ export interface TerminalOutputResult {
51
+ output: string;
52
+ truncated: boolean;
53
+ exitStatus: {
54
+ exitCode: number | null;
55
+ signal: string | null;
56
+ } | null;
57
+ }
58
+
59
+ /**
60
+ * ACP client for agent communication and process lifecycle management.
61
+ */
62
+ export class AcpClient {
63
+ // Connection & process
64
+ private connection: acp.ClientSideConnection | null = null;
65
+ private agentProcess: ChildProcess | null = null;
66
+ private currentConfig: AgentConfig | null = null;
67
+ private isInitializedFlag = false;
68
+ private currentAgentId: string | null = null;
69
+ private currentSessionId: string | null = null;
70
+
71
+ // Callbacks (none — all events flow through onSessionUpdate via AcpHandler)
72
+
73
+ // Delegates
74
+ private terminalManager: TerminalManager;
75
+ private permissionManager: PermissionManager;
76
+ private handler: AcpHandler;
77
+
78
+ // Prompt state (reset per sendPrompt)
79
+ private recentStderr = "";
80
+
81
+ private logger: Logger;
82
+
83
+ constructor(private plugin: AgentClientPlugin) {
84
+ this.logger = getLogger();
85
+
86
+ // Initialize managers
87
+ this.terminalManager = new TerminalManager(plugin);
88
+ this.permissionManager = new PermissionManager(
89
+ {
90
+ onSessionUpdate: (update) =>
91
+ this.handler.emitSessionUpdate(update),
92
+ },
93
+ false, // autoAllow — updated in initialize()
94
+ );
95
+
96
+ // Initialize protocol handler
97
+ this.handler = new AcpHandler(
98
+ this.permissionManager,
99
+ this.terminalManager,
100
+ () => this.currentConfig?.workingDirectory ?? "",
101
+ () => this.currentSessionId,
102
+ this.logger,
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Initialize connection to an AI agent.
108
+ * Spawns the agent process and establishes ACP connection.
109
+ */
110
+ async initialize(config: AgentConfig): Promise<InitializeResult> {
111
+ this.logger.log(
112
+ "[AcpClient] Starting initialization with config:",
113
+ config,
114
+ );
115
+ this.logger.log(
116
+ `[AcpClient] Current state - process: ${!!this.agentProcess}, PID: ${this.agentProcess?.pid}`,
117
+ );
118
+
119
+ // Clean up existing process if any (e.g., when switching agents)
120
+ if (this.agentProcess) {
121
+ this.killProcessTree();
122
+ }
123
+
124
+ // Clean up existing connection
125
+ if (this.connection) {
126
+ this.logger.log("[AcpClient] Cleaning up existing connection");
127
+ this.connection = null;
128
+ }
129
+
130
+ this.currentConfig = config;
131
+
132
+ // Update auto-allow permissions from plugin settings
133
+ this.permissionManager.setAutoAllow(
134
+ this.plugin.settings.autoAllowPermissions,
135
+ );
136
+
137
+ // Validate command
138
+ if (!config.command || config.command.trim().length === 0) {
139
+ throw new Error(
140
+ `Command not configured for agent "${config.displayName}" (${config.id}). Please configure the agent command in settings.`,
141
+ );
142
+ }
143
+
144
+ const command = config.command.trim();
145
+ const args = config.args.length > 0 ? [...config.args] : [];
146
+
147
+ this.logger.log(
148
+ `[AcpClient] Active agent: ${config.displayName} (${config.id})`,
149
+ );
150
+ this.logger.log("[AcpClient] Command:", command);
151
+ this.logger.log(
152
+ "[AcpClient] Args:",
153
+ args.length > 0 ? args.join(" ") : "(none)",
154
+ );
155
+
156
+ // Prepare environment variables
157
+ let baseEnv: NodeJS.ProcessEnv = {
158
+ ...process.env,
159
+ ...(config.env || {}),
160
+ };
161
+
162
+ // On Windows, enhance PATH with full system/user PATH from registry.
163
+ // Electron apps launched from shortcuts don't inherit the full PATH,
164
+ // which causes executables like python, node, etc. to not be found.
165
+ if (Platform.isWin && !this.plugin.settings.windowsWslMode) {
166
+ baseEnv = getEnhancedWindowsEnv(baseEnv);
167
+ }
168
+
169
+ // Add Node.js directory to PATH only when nodePath is an explicit absolute path.
170
+ // When nodePath is empty or a bare command name, the login shell handles it.
171
+ const nodeDir = resolveNodeDirectory(this.plugin.settings.nodePath);
172
+ if (nodeDir) {
173
+ const separator = Platform.isWin ? ";" : ":";
174
+ baseEnv.PATH = baseEnv.PATH
175
+ ? `${nodeDir}${separator}${baseEnv.PATH}`
176
+ : nodeDir;
177
+ this.logger.log(
178
+ "[AcpClient] Node.js directory added to PATH:",
179
+ nodeDir,
180
+ );
181
+ }
182
+
183
+ this.logger.log(
184
+ "[AcpClient] Starting agent process in directory:",
185
+ config.workingDirectory,
186
+ );
187
+
188
+ // Prepare command and args for spawning (platform-specific shell wrapping)
189
+ const prepared = prepareShellCommand(
190
+ command,
191
+ args,
192
+ config.workingDirectory,
193
+ {
194
+ wslMode: this.plugin.settings.windowsWslMode,
195
+ wslDistribution: this.plugin.settings.windowsWslDistribution,
196
+ nodeDir,
197
+ alwaysEscape: true,
198
+ },
199
+ );
200
+ const spawnCommand = prepared.command;
201
+ const spawnArgs = prepared.args;
202
+ const needsShell = prepared.needsShell;
203
+
204
+ this.logger.log(
205
+ "[AcpClient] Prepared spawn command:",
206
+ spawnCommand,
207
+ spawnArgs,
208
+ );
209
+
210
+ // Spawn the agent process
211
+ // detached: true (Unix only) creates a new process group, allowing us to kill
212
+ // the entire process tree (agent + child processes) with process.kill(-pid).
213
+ // On Windows, detached: true opens a new console window, so we skip it
214
+ // and use taskkill /T instead for tree kill.
215
+ const agentProcess = spawn(spawnCommand, spawnArgs, {
216
+ stdio: ["pipe", "pipe", "pipe"],
217
+ env: baseEnv,
218
+ cwd: config.workingDirectory,
219
+ shell: needsShell,
220
+ detached: !Platform.isWin,
221
+ });
222
+ this.agentProcess = agentProcess;
223
+
224
+ const agentLabel = `${config.displayName} (${config.id})`;
225
+
226
+ // Set up process event handlers
227
+ agentProcess.on("spawn", () => {
228
+ this.logger.log(
229
+ `[AcpClient] ${agentLabel} process spawned successfully, PID:`,
230
+ agentProcess.pid,
231
+ );
232
+ });
233
+
234
+ agentProcess.on("error", (error) => {
235
+ this.logger.error(
236
+ `[AcpClient] ${agentLabel} process error:`,
237
+ error,
238
+ );
239
+
240
+ const processError: ProcessError = {
241
+ type: "spawn_failed",
242
+ agentId: config.id,
243
+ errorCode: (error as NodeJS.ErrnoException).code,
244
+ originalError: error,
245
+ ...getSpawnErrorInfo(
246
+ error,
247
+ command,
248
+ agentLabel,
249
+ this.plugin.settings.windowsWslMode,
250
+ ),
251
+ };
252
+
253
+ this.handler.emitSessionUpdate({
254
+ type: "process_error",
255
+ sessionId: this.currentSessionId ?? "",
256
+ error: processError,
257
+ });
258
+ });
259
+
260
+ agentProcess.on("exit", (code, signal) => {
261
+ this.logger.log(
262
+ `[AcpClient] ${agentLabel} process exited with code:`,
263
+ code,
264
+ "signal:",
265
+ signal,
266
+ );
267
+
268
+ if (code === 127) {
269
+ this.logger.error(`[AcpClient] Command not found: ${command}`);
270
+
271
+ const processError: ProcessError = {
272
+ type: "command_not_found",
273
+ agentId: config.id,
274
+ exitCode: code,
275
+ title: "Command Not Found",
276
+ message: `The command "${command}" could not be found. Please check the path configuration for ${agentLabel}.`,
277
+ suggestion: getCommandNotFoundSuggestion(
278
+ command,
279
+ this.plugin.settings.windowsWslMode,
280
+ ),
281
+ };
282
+
283
+ this.handler.emitSessionUpdate({
284
+ type: "process_error",
285
+ sessionId: this.currentSessionId ?? "",
286
+ error: processError,
287
+ });
288
+ }
289
+ });
290
+
291
+ agentProcess.on("close", (code, signal) => {
292
+ this.logger.log(
293
+ `[AcpClient] ${agentLabel} process closed with code:`,
294
+ code,
295
+ "signal:",
296
+ signal,
297
+ );
298
+ });
299
+
300
+ agentProcess.stderr?.setEncoding("utf8");
301
+ agentProcess.stderr?.on("data", (data) => {
302
+ this.logger.log(`[AcpClient] ${agentLabel} stderr:`, data);
303
+ // Keep a rolling window of recent stderr for error diagnostics
304
+ this.recentStderr += data;
305
+ if (this.recentStderr.length > 8192) {
306
+ this.recentStderr = this.recentStderr.slice(-4096);
307
+ }
308
+ });
309
+
310
+ // Create stream for ACP communication
311
+ // stdio is configured as ["pipe", "pipe", "pipe"] so stdin/stdout are guaranteed to exist
312
+ if (!agentProcess.stdin || !agentProcess.stdout) {
313
+ throw new Error("Agent process stdin/stdout not available");
314
+ }
315
+
316
+ const stdin = agentProcess.stdin;
317
+ const stdout = agentProcess.stdout;
318
+
319
+ const input = new WritableStream<Uint8Array>({
320
+ write(chunk: Uint8Array) {
321
+ stdin.write(chunk);
322
+ },
323
+ close() {
324
+ stdin.end();
325
+ },
326
+ });
327
+ const output = new ReadableStream<Uint8Array>({
328
+ start(controller) {
329
+ stdout.on("data", (chunk: Uint8Array) => {
330
+ controller.enqueue(chunk);
331
+ });
332
+ stdout.on("end", () => {
333
+ controller.close();
334
+ });
335
+ },
336
+ });
337
+
338
+ this.logger.log(
339
+ "[AcpClient] Using working directory:",
340
+ config.workingDirectory,
341
+ );
342
+
343
+ const stream = acp.ndJsonStream(input, output);
344
+ this.connection = new acp.ClientSideConnection(
345
+ () => this.handler,
346
+ stream,
347
+ );
348
+
349
+ try {
350
+ this.logger.log("[AcpClient] Starting ACP initialization...");
351
+
352
+ const initResult = await this.connection.initialize({
353
+ protocolVersion: acp.PROTOCOL_VERSION,
354
+ clientCapabilities: {
355
+ fs: {
356
+ readTextFile: false,
357
+ writeTextFile: false,
358
+ },
359
+ terminal: true,
360
+ },
361
+ clientInfo: {
362
+ name: "obsidian-agent-client",
363
+ title: "Agent Client for Obsidian",
364
+ version: this.plugin.manifest.version,
365
+ },
366
+ });
367
+
368
+ this.logger.log(
369
+ `[AcpClient] ✅ Connected to agent (protocol v${initResult.protocolVersion})`,
370
+ );
371
+
372
+ this.isInitializedFlag = true;
373
+ this.currentAgentId = config.id;
374
+
375
+ return AcpTypeConverter.toInitializeResult(initResult);
376
+ } catch (error) {
377
+ this.logger.error("[AcpClient] Initialization Error:", error);
378
+
379
+ // Reset flags on failure
380
+ this.isInitializedFlag = false;
381
+ this.currentAgentId = null;
382
+
383
+ throw error;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Create a new chat session with the agent.
389
+ */
390
+ async newSession(workingDirectory: string): Promise<SessionResult> {
391
+ const connection = this.requireConnection();
392
+
393
+ try {
394
+ this.logger.log("[AcpClient] Creating new session...");
395
+
396
+ const response = await connection.newSession({
397
+ cwd: this.toSessionCwd(workingDirectory),
398
+ mcpServers: [],
399
+ });
400
+
401
+ this.logger.log(
402
+ `[AcpClient] Created session: ${response.sessionId}`,
403
+ );
404
+ const result = AcpTypeConverter.toSessionResult(
405
+ response.sessionId,
406
+ response,
407
+ );
408
+ this.currentSessionId = result.sessionId;
409
+ return result;
410
+ } catch (error) {
411
+ this.logger.error("[AcpClient] New Session Error:", error);
412
+ throw error;
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Authenticate with the agent using a specific method.
418
+ */
419
+ async authenticate(methodId: string): Promise<boolean> {
420
+ const connection = this.requireConnection();
421
+
422
+ try {
423
+ await connection.authenticate({ methodId });
424
+ this.logger.log("[AcpClient] ✅ authenticate ok:", methodId);
425
+ return true;
426
+ } catch (error: unknown) {
427
+ this.logger.error("[AcpClient] Authentication Error:", error);
428
+ return false;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Send a message to the agent in a specific session.
434
+ */
435
+ async sendPrompt(
436
+ sessionId: string,
437
+ content: PromptContent[],
438
+ ): Promise<void> {
439
+ const connection = this.requireConnection();
440
+
441
+ this.handler.resetUpdateCount();
442
+ this.recentStderr = "";
443
+
444
+ try {
445
+ // Convert domain PromptContent to ACP ContentBlock
446
+ const acpContent = content.map((c) =>
447
+ AcpTypeConverter.toAcpContentBlock(c),
448
+ );
449
+
450
+ this.logger.log(
451
+ `[AcpClient] Sending prompt with ${content.length} content blocks`,
452
+ );
453
+
454
+ const promptResult = await connection.prompt({
455
+ sessionId: sessionId,
456
+ prompt: acpContent,
457
+ });
458
+
459
+ this.logger.log(
460
+ `[AcpClient] Agent completed with: ${promptResult.stopReason}`,
461
+ );
462
+
463
+ // Detect silent failures: agent returned end_turn but sent no content.
464
+ // Only surface an error when stderr contains a recognized error pattern
465
+ // (e.g., missing API key). Some commands like /compact legitimately
466
+ // return no session updates, so we avoid false positives.
467
+ if (
468
+ !this.handler.hasReceivedUpdates() &&
469
+ promptResult.stopReason === "end_turn"
470
+ ) {
471
+ // Allow pending stderr data events to flush before checking
472
+ await new Promise((r) => window.setTimeout(r, 100));
473
+
474
+ const stderrHint = extractStderrErrorHint(this.recentStderr);
475
+ if (stderrHint) {
476
+ this.logger.warn(
477
+ "[AcpClient] Agent returned end_turn with no session updates — detected error in stderr",
478
+ );
479
+ throw new Error(
480
+ `The agent returned an empty response. ${stderrHint}`,
481
+ );
482
+ } else {
483
+ this.logger.log(
484
+ "[AcpClient] Agent returned end_turn with no session updates (may be expected for some commands)",
485
+ );
486
+ }
487
+ }
488
+ } catch (error: unknown) {
489
+ if (isEmptyResponseError(error) || isUserAbortedError(error)) {
490
+ return;
491
+ }
492
+ this.logger.error("[AcpClient] Prompt Error:", error);
493
+ throw error;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Cancel the current operation in a session.
499
+ */
500
+ async cancel(sessionId: string): Promise<void> {
501
+ if (!this.connection) {
502
+ this.cancelAllOperations();
503
+ return;
504
+ }
505
+ try {
506
+ this.logger.log(
507
+ "[AcpClient] Sending session/cancel notification...",
508
+ );
509
+ await this.connection.cancel({ sessionId });
510
+ this.logger.log(
511
+ "[AcpClient] Cancellation request sent successfully",
512
+ );
513
+ } catch (error) {
514
+ this.logger.warn("[AcpClient] Failed to send cancellation:", error);
515
+ } finally {
516
+ this.cancelAllOperations();
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Kill the agent process and its entire process tree.
522
+ * Uses process.kill(-pid) to send SIGTERM to the process group
523
+ * (requires detached: true on spawn). Falls back to regular kill.
524
+ * On Windows, uses taskkill /T for tree kill.
525
+ */
526
+ private killProcessTree(): void {
527
+ if (!this.agentProcess) return;
528
+
529
+ const pid = this.agentProcess.pid;
530
+ this.logger.log(`[AcpClient] Killing process tree (PID: ${pid})`);
531
+
532
+ try {
533
+ if (Platform.isWin && pid) {
534
+ // Windows: taskkill /T kills the entire process tree
535
+ spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
536
+ stdio: "ignore",
537
+ });
538
+ } else if (pid) {
539
+ // Unix: kill the entire process group (negative PID)
540
+ // Requires detached: true on spawn to create a process group
541
+ process.kill(-pid, "SIGTERM");
542
+ } else {
543
+ this.agentProcess.kill();
544
+ }
545
+ } catch {
546
+ // Fallback: kill just the direct process
547
+ try {
548
+ this.agentProcess.kill();
549
+ } catch {
550
+ // Process may already be dead
551
+ }
552
+ }
553
+
554
+ this.agentProcess = null;
555
+ }
556
+
557
+ /**
558
+ * Disconnect from the agent and clean up resources.
559
+ */
560
+ disconnect(): Promise<void> {
561
+ this.logger.log("[AcpClient] Disconnecting...");
562
+
563
+ // Cancel all pending operations
564
+ this.cancelAllOperations();
565
+
566
+ // Kill the agent process tree
567
+ this.killProcessTree();
568
+
569
+ // Clear connection and config references
570
+ this.connection = null;
571
+ this.currentConfig = null;
572
+
573
+ // Reset initialization state
574
+ this.isInitializedFlag = false;
575
+ this.currentAgentId = null;
576
+ this.currentSessionId = null;
577
+
578
+ this.logger.log("[AcpClient] Disconnected");
579
+ return Promise.resolve();
580
+ }
581
+
582
+ /**
583
+ * Check if the agent connection is initialized and ready.
584
+ */
585
+ isInitialized(): boolean {
586
+ return (
587
+ this.isInitializedFlag &&
588
+ this.connection !== null &&
589
+ this.agentProcess !== null
590
+ );
591
+ }
592
+
593
+ /**
594
+ * Get the ID of the currently connected agent.
595
+ */
596
+ getCurrentAgentId(): string | null {
597
+ return this.currentAgentId;
598
+ }
599
+
600
+ /**
601
+ * DEPRECATED: Use setSessionConfigOption instead.
602
+ */
603
+ async setSessionMode(sessionId: string, modeId: string): Promise<void> {
604
+ const connection = this.requireConnection();
605
+
606
+ this.logger.log(
607
+ `[AcpClient] Setting session mode to: ${modeId} for session: ${sessionId}`,
608
+ );
609
+
610
+ try {
611
+ await connection.setSessionMode({
612
+ sessionId,
613
+ modeId,
614
+ });
615
+ this.logger.log(`[AcpClient] Session mode set to: ${modeId}`);
616
+ } catch (error) {
617
+ this.logger.error("[AcpClient] Failed to set session mode:", error);
618
+ throw error;
619
+ }
620
+ }
621
+
622
+ /**
623
+ * DEPRECATED: Use setSessionConfigOption instead.
624
+ */
625
+ async setSessionModel(sessionId: string, modelId: string): Promise<void> {
626
+ const connection = this.requireConnection();
627
+
628
+ this.logger.log(
629
+ `[AcpClient] Setting session model to: ${modelId} for session: ${sessionId}`,
630
+ );
631
+
632
+ try {
633
+ await connection.unstable_setSessionModel({
634
+ sessionId,
635
+ modelId,
636
+ });
637
+ this.logger.log(`[AcpClient] Session model set to: ${modelId}`);
638
+ } catch (error) {
639
+ this.logger.error(
640
+ "[AcpClient] Failed to set session model:",
641
+ error,
642
+ );
643
+ throw error;
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Set a session configuration option.
649
+ *
650
+ * Sends a config option change to the agent. The response contains the
651
+ * complete set of all config options with their current values, as changing
652
+ * one option may affect others.
653
+ */
654
+ async setSessionConfigOption(
655
+ sessionId: string,
656
+ configId: string,
657
+ value: string,
658
+ ): Promise<SessionConfigOption[]> {
659
+ const connection = this.requireConnection();
660
+
661
+ this.logger.log(
662
+ `[AcpClient] Setting config option: ${configId}=${value} for session: ${sessionId}`,
663
+ );
664
+
665
+ try {
666
+ const response = await connection.setSessionConfigOption({
667
+ sessionId,
668
+ configId,
669
+ value,
670
+ });
671
+ this.logger.log(
672
+ `[AcpClient] Config option set. Updated options:`,
673
+ response.configOptions,
674
+ );
675
+ return AcpTypeConverter.toSessionConfigOptions(
676
+ response.configOptions,
677
+ );
678
+ } catch (error) {
679
+ this.logger.error(
680
+ "[AcpClient] Failed to set config option:",
681
+ error,
682
+ );
683
+ throw error;
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Register a callback to receive session updates from the agent.
689
+ *
690
+ * This unified callback receives all session update events:
691
+ * - agent_message_chunk: Text chunk from agent's response
692
+ * - agent_thought_chunk: Text chunk from agent's reasoning
693
+ * - tool_call: New tool call event
694
+ * - tool_call_update: Update to existing tool call
695
+ * - plan: Agent's task plan
696
+ * - available_commands_update: Slash commands changed
697
+ * - current_mode_update: Mode changed
698
+ */
699
+ onSessionUpdate(callback: (update: SessionUpdate) => void): () => void {
700
+ return this.handler.onSessionUpdate(callback);
701
+ }
702
+
703
+ /**
704
+ * Respond to a permission request from the agent.
705
+ */
706
+ respondToPermission(requestId: string, optionId: string): Promise<void> {
707
+ this.requireConnection();
708
+
709
+ this.logger.log(
710
+ "[AcpClient] Responding to permission request:",
711
+ requestId,
712
+ "with option:",
713
+ optionId,
714
+ );
715
+ this.permissionManager.respond(requestId, optionId);
716
+ return Promise.resolve();
717
+ }
718
+
719
+ // Helper methods
720
+
721
+ /**
722
+ * Assert that the ACP connection is initialized and return it.
723
+ * @throws Error if connection is not available
724
+ */
725
+ private requireConnection(): acp.ClientSideConnection {
726
+ if (!this.connection) {
727
+ throw new Error(
728
+ "Connection not initialized. Call initialize() first.",
729
+ );
730
+ }
731
+ return this.connection;
732
+ }
733
+
734
+ /**
735
+ * Convert working directory to WSL path if in WSL mode on Windows.
736
+ */
737
+ private toSessionCwd(cwd: string): string {
738
+ if (Platform.isWin && this.plugin.settings.windowsWslMode) {
739
+ return convertWindowsPathToWsl(cwd);
740
+ }
741
+ return cwd;
742
+ }
743
+
744
+ private cancelAllOperations(): void {
745
+ this.permissionManager.cancelAll();
746
+ this.terminalManager.killAllTerminals();
747
+ }
748
+
749
+ /**
750
+ * Get terminal output for UI rendering.
751
+ */
752
+ getTerminalOutput(terminalId: string): Promise<TerminalOutputResult> {
753
+ const result = this.terminalManager.getOutput(terminalId);
754
+ if (!result) {
755
+ throw new Error(`Terminal ${terminalId} not found`);
756
+ }
757
+ return Promise.resolve(result);
758
+ }
759
+
760
+ // ========================================================================
761
+ // Session Management Methods
762
+ // ========================================================================
763
+
764
+ /**
765
+ * List available sessions (unstable).
766
+ *
767
+ * Only available if session.agentCapabilities.sessionCapabilities?.list is defined.
768
+ *
769
+ * @param cwd - Optional filter by working directory
770
+ * @param cursor - Pagination cursor from previous call
771
+ * @returns Promise resolving to sessions array and optional next cursor
772
+ */
773
+ async listSessions(
774
+ cwd?: string,
775
+ cursor?: string,
776
+ ): Promise<ListSessionsResult> {
777
+ const connection = this.requireConnection();
778
+
779
+ try {
780
+ this.logger.log("[AcpClient] Listing sessions...");
781
+
782
+ const filterCwd = cwd ? this.toSessionCwd(cwd) : undefined;
783
+
784
+ const response = await connection.unstable_listSessions({
785
+ cwd: filterCwd ?? null,
786
+ cursor: cursor ?? null,
787
+ });
788
+
789
+ this.logger.log(
790
+ `[AcpClient] Found ${response.sessions.length} sessions`,
791
+ );
792
+
793
+ return {
794
+ sessions: response.sessions.map((s) => ({
795
+ sessionId: s.sessionId,
796
+ cwd: s.cwd,
797
+ title: s.title ?? undefined,
798
+ updatedAt: s.updatedAt ?? undefined,
799
+ })),
800
+ nextCursor: response.nextCursor ?? undefined,
801
+ };
802
+ } catch (error) {
803
+ this.logger.error("[AcpClient] List Sessions Error:", error);
804
+ throw error;
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Load a previous session with history replay (stable).
810
+ *
811
+ * Conversation history is received via onSessionUpdate callback
812
+ * as user_message_chunk, agent_message_chunk, tool_call, etc.
813
+ *
814
+ * @param sessionId - Session to load
815
+ * @param cwd - Working directory
816
+ * @returns Promise resolving to session result with modes and models
817
+ */
818
+ async loadSession(sessionId: string, cwd: string): Promise<SessionResult> {
819
+ const connection = this.requireConnection();
820
+
821
+ // Set sessionId before await so replay updates pass the sessionId filter
822
+ this.currentSessionId = sessionId;
823
+
824
+ try {
825
+ this.logger.log(`[AcpClient] Loading session: ${sessionId}...`);
826
+
827
+ const response = await connection.loadSession({
828
+ sessionId,
829
+ cwd: this.toSessionCwd(cwd),
830
+ mcpServers: [],
831
+ });
832
+
833
+ this.logger.log(`[AcpClient] Session loaded: ${sessionId}`);
834
+ const result = AcpTypeConverter.toSessionResult(
835
+ sessionId,
836
+ response,
837
+ );
838
+ this.currentSessionId = result.sessionId;
839
+ return result;
840
+ } catch (error) {
841
+ this.logger.error("[AcpClient] Load Session Error:", error);
842
+ throw error;
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Resume a session without history replay (unstable).
848
+ *
849
+ * Use when client manages its own history storage.
850
+ *
851
+ * @param sessionId - Session to resume
852
+ * @param cwd - Working directory
853
+ * @returns Promise resolving to session result with modes and models
854
+ */
855
+ async resumeSession(
856
+ sessionId: string,
857
+ cwd: string,
858
+ ): Promise<SessionResult> {
859
+ const connection = this.requireConnection();
860
+
861
+ // Set sessionId before await so any updates pass the sessionId filter
862
+ this.currentSessionId = sessionId;
863
+
864
+ try {
865
+ this.logger.log(`[AcpClient] Resuming session: ${sessionId}...`);
866
+
867
+ const response = await connection.unstable_resumeSession({
868
+ sessionId,
869
+ cwd: this.toSessionCwd(cwd),
870
+ mcpServers: [],
871
+ });
872
+
873
+ this.logger.log(`[AcpClient] Session resumed: ${sessionId}`);
874
+ const result = AcpTypeConverter.toSessionResult(
875
+ sessionId,
876
+ response,
877
+ );
878
+ this.currentSessionId = result.sessionId;
879
+ return result;
880
+ } catch (error) {
881
+ this.logger.error("[AcpClient] Resume Session Error:", error);
882
+ throw error;
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Fork a session to create a new branch (unstable).
888
+ *
889
+ * Creates a new session with inherited context from the original.
890
+ *
891
+ * @param sessionId - Session to fork from
892
+ * @param cwd - Working directory
893
+ * @returns Promise resolving to session result with new sessionId
894
+ */
895
+ async forkSession(sessionId: string, cwd: string): Promise<SessionResult> {
896
+ const connection = this.requireConnection();
897
+
898
+ try {
899
+ this.logger.log(`[AcpClient] Forking session: ${sessionId}...`);
900
+
901
+ const response = await connection.unstable_forkSession({
902
+ sessionId,
903
+ cwd: this.toSessionCwd(cwd),
904
+ mcpServers: [],
905
+ });
906
+
907
+ this.logger.log(
908
+ `[AcpClient] Session forked: ${sessionId} -> ${response.sessionId}`,
909
+ );
910
+ const result = AcpTypeConverter.toSessionResult(
911
+ response.sessionId,
912
+ response,
913
+ );
914
+ this.currentSessionId = result.sessionId;
915
+ return result;
916
+ } catch (error) {
917
+ this.logger.error("[AcpClient] Fork Session Error:", error);
918
+ throw error;
919
+ }
920
+ }
921
+ }