@posthog/agent 1.29.0 → 2.0.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.
@@ -10,7 +10,7 @@ import * as os from "node:os";
10
10
  import * as path from "node:path";
11
11
  import {
12
12
  type Agent,
13
- AgentSideConnection,
13
+ type AgentSideConnection,
14
14
  type AuthenticateRequest,
15
15
  type AvailableCommand,
16
16
  type CancelNotification,
@@ -21,7 +21,6 @@ import {
21
21
  type LoadSessionResponse,
22
22
  type NewSessionRequest,
23
23
  type NewSessionResponse,
24
- ndJsonStream,
25
24
  type PromptRequest,
26
25
  type PromptResponse,
27
26
  type ReadTextFileRequest,
@@ -58,7 +57,6 @@ import type {
58
57
  SessionStore,
59
58
  } from "@/session-store.js";
60
59
  import { Logger } from "@/utils/logger.js";
61
- import { createTappedWritableStream } from "@/utils/tapped-stream.js";
62
60
  import packageJson from "../../../package.json" with { type: "json" };
63
61
  import { createMcpServer, EDIT_TOOL_NAMES, toolNames } from "./mcp-server.js";
64
62
  import {
@@ -69,22 +67,138 @@ import {
69
67
  toolInfoFromToolUse,
70
68
  toolUpdateFromToolResult,
71
69
  } from "./tools.js";
72
- import {
73
- createBidirectionalStreams,
74
- Pushable,
75
- type StreamPair,
76
- unreachable,
77
- } from "./utils.js";
70
+ import { Pushable, unreachable } from "./utils.js";
78
71
 
79
72
  /**
80
73
  * Clears the statsig cache to work around a claude-agent-sdk bug where cached
81
74
  * tool definitions include input_examples which causes API errors.
82
75
  * See: https://github.com/anthropics/claude-code/issues/11678
83
76
  */
77
+ function getClaudeConfigDir(): string {
78
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
79
+ }
80
+
81
+ function getClaudePlansDir(): string {
82
+ return path.join(getClaudeConfigDir(), "plans");
83
+ }
84
+
85
+ function isClaudePlanFilePath(filePath: string | undefined): boolean {
86
+ if (!filePath) return false;
87
+ const resolved = path.resolve(filePath);
88
+ const plansDir = path.resolve(getClaudePlansDir());
89
+ return resolved === plansDir || resolved.startsWith(plansDir + path.sep);
90
+ }
91
+
92
+ /**
93
+ * Whitelist of command prefixes that are considered read-only.
94
+ * These commands can be used in plan mode since they don't modify files or state.
95
+ */
96
+ const READ_ONLY_COMMAND_PREFIXES = [
97
+ // File listing and info
98
+ "ls",
99
+ "find",
100
+ "tree",
101
+ "stat",
102
+ "file",
103
+ "wc",
104
+ "du",
105
+ "df",
106
+ // File reading (non-modifying)
107
+ "cat",
108
+ "head",
109
+ "tail",
110
+ "less",
111
+ "more",
112
+ "bat",
113
+ // Search
114
+ "grep",
115
+ "rg",
116
+ "ag",
117
+ "ack",
118
+ "fzf",
119
+ // Git read operations
120
+ "git status",
121
+ "git log",
122
+ "git diff",
123
+ "git show",
124
+ "git branch",
125
+ "git remote",
126
+ "git fetch",
127
+ "git rev-parse",
128
+ "git ls-files",
129
+ "git blame",
130
+ "git shortlog",
131
+ "git describe",
132
+ "git tag -l",
133
+ "git tag --list",
134
+ // System info
135
+ "pwd",
136
+ "whoami",
137
+ "which",
138
+ "where",
139
+ "type",
140
+ "printenv",
141
+ "env",
142
+ "echo",
143
+ "printf",
144
+ "date",
145
+ "uptime",
146
+ "uname",
147
+ "id",
148
+ "groups",
149
+ // Process info
150
+ "ps",
151
+ "top",
152
+ "htop",
153
+ "pgrep",
154
+ "lsof",
155
+ // Network read-only
156
+ "curl",
157
+ "wget",
158
+ "ping",
159
+ "host",
160
+ "dig",
161
+ "nslookup",
162
+ // Package managers (info only)
163
+ "npm list",
164
+ "npm ls",
165
+ "npm view",
166
+ "npm info",
167
+ "npm outdated",
168
+ "pnpm list",
169
+ "pnpm ls",
170
+ "pnpm why",
171
+ "yarn list",
172
+ "yarn why",
173
+ "yarn info",
174
+ // Other read-only
175
+ "jq",
176
+ "yq",
177
+ "xargs",
178
+ "sort",
179
+ "uniq",
180
+ "tr",
181
+ "cut",
182
+ "awk",
183
+ "sed -n",
184
+ ];
185
+
186
+ /**
187
+ * Checks if a bash command is read-only based on a whitelist of command prefixes.
188
+ * Used to allow safe bash commands in plan mode.
189
+ */
190
+ function isReadOnlyBashCommand(command: string): boolean {
191
+ const trimmed = command.trim();
192
+ return READ_ONLY_COMMAND_PREFIXES.some(
193
+ (prefix) =>
194
+ trimmed === prefix ||
195
+ trimmed.startsWith(`${prefix} `) ||
196
+ trimmed.startsWith(`${prefix}\t`),
197
+ );
198
+ }
199
+
84
200
  function clearStatsigCache(): void {
85
- const configDir =
86
- process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
87
- const statsigPath = path.join(configDir, "statsig");
201
+ const statsigPath = path.join(getClaudeConfigDir(), "statsig");
88
202
 
89
203
  try {
90
204
  if (fs.existsSync(statsigPath)) {
@@ -102,6 +216,8 @@ type Session = {
102
216
  permissionMode: PermissionMode;
103
217
  notificationHistory: SessionNotification[];
104
218
  sdkSessionId?: string;
219
+ lastPlanFilePath?: string;
220
+ lastPlanContent?: string;
105
221
  };
106
222
 
107
223
  type BackgroundTerminal =
@@ -156,7 +272,7 @@ type ToolUseCache = {
156
272
  type: "tool_use" | "server_tool_use" | "mcp_tool_use";
157
273
  id: string;
158
274
  name: string;
159
- input: any;
275
+ input: unknown;
160
276
  };
161
277
  };
162
278
 
@@ -201,6 +317,44 @@ export class ClaudeAcpAgent implements Agent {
201
317
  return session;
202
318
  }
203
319
 
320
+ private getLatestAssistantText(
321
+ notifications: SessionNotification[],
322
+ ): string | null {
323
+ const chunks: string[] = [];
324
+ let started = false;
325
+
326
+ for (let i = notifications.length - 1; i >= 0; i -= 1) {
327
+ const update = notifications[i]?.update;
328
+ if (!update) continue;
329
+
330
+ if (update.sessionUpdate === "agent_message_chunk") {
331
+ started = true;
332
+ const content = update.content as {
333
+ type?: string;
334
+ text?: string;
335
+ } | null;
336
+ if (content?.type === "text" && content.text) {
337
+ chunks.push(content.text);
338
+ }
339
+ continue;
340
+ }
341
+
342
+ if (started) {
343
+ break;
344
+ }
345
+ }
346
+
347
+ if (chunks.length === 0) return null;
348
+ return chunks.reverse().join("");
349
+ }
350
+
351
+ private isPlanReady(plan: string | undefined): boolean {
352
+ if (!plan) return false;
353
+ const trimmed = plan.trim();
354
+ if (trimmed.length < 40) return false;
355
+ return /(^|\n)#{1,6}\s+\S/.test(trimmed);
356
+ }
357
+
204
358
  appendNotification(
205
359
  sessionId: string,
206
360
  notification: SessionNotification,
@@ -213,7 +367,7 @@ export class ClaudeAcpAgent implements Agent {
213
367
  this.clientCapabilities = request.clientCapabilities;
214
368
 
215
369
  // Default authMethod
216
- const authMethod: any = {
370
+ const authMethod: { description: string; name: string; id: string } = {
217
371
  description: "Run `claude /login` in the terminal",
218
372
  name: "Log in with Claude Code",
219
373
  id: "claude-login",
@@ -323,7 +477,12 @@ export class ClaudeAcpAgent implements Agent {
323
477
  }
324
478
  }
325
479
 
326
- const permissionMode = "default";
480
+ // Use initialModeId from _meta if provided (e.g., "plan" for plan mode), otherwise default
481
+ const initialModeId = (
482
+ params._meta as { initialModeId?: string } | undefined
483
+ )?.initialModeId;
484
+ const ourPermissionMode = (initialModeId ?? "default") as PermissionMode;
485
+ const sdkPermissionMode: PermissionMode = ourPermissionMode;
327
486
 
328
487
  // Extract options from _meta if provided
329
488
  const userProvidedOptions = (params._meta as NewSessionMeta | undefined)
@@ -341,7 +500,8 @@ export class ClaudeAcpAgent implements Agent {
341
500
  // If we want bypassPermissions to be an option, we have to allow it here.
342
501
  // But it doesn't work in root mode, so we only activate it if it will work.
343
502
  allowDangerouslySkipPermissions: !IS_ROOT,
344
- permissionMode,
503
+ // Use the requested permission mode (including plan mode)
504
+ permissionMode: sdkPermissionMode,
345
505
  canUseTool: this.canUseTool(sessionId),
346
506
  // Use "node" to resolve via PATH where a symlink to Electron exists.
347
507
  // This avoids launching the Electron binary directly from the app bundle,
@@ -349,7 +509,12 @@ export class ClaudeAcpAgent implements Agent {
349
509
  executable: "node",
350
510
  // Prevent spawned Electron processes from showing in dock/tray.
351
511
  // Must merge with process.env since SDK replaces rather than merges.
352
- env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
512
+ // Enable AskUserQuestion tool via environment variable (required by SDK feature flag)
513
+ env: {
514
+ ...process.env,
515
+ ELECTRON_RUN_AS_NODE: "1",
516
+ CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
517
+ },
353
518
  ...(process.env.CLAUDE_CODE_EXECUTABLE && {
354
519
  pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
355
520
  }),
@@ -364,8 +529,9 @@ export class ClaudeAcpAgent implements Agent {
364
529
  },
365
530
  };
366
531
 
367
- const allowedTools = [];
368
- const disallowedTools = [];
532
+ // AskUserQuestion must be explicitly allowed for the agent to use it
533
+ const allowedTools: string[] = ["AskUserQuestion"];
534
+ const disallowedTools: string[] = [];
369
535
 
370
536
  // Check if built-in tools should be disabled
371
537
  const disableBuiltInTools = params._meta?.disableBuiltInTools === true;
@@ -411,6 +577,11 @@ export class ClaudeAcpAgent implements Agent {
411
577
  );
412
578
  }
413
579
 
580
+ // ExitPlanMode should only be available during plan mode
581
+ if (ourPermissionMode !== "plan") {
582
+ disallowedTools.push("ExitPlanMode");
583
+ }
584
+
414
585
  if (allowedTools.length > 0) {
415
586
  options.allowedTools = allowedTools;
416
587
  }
@@ -432,7 +603,7 @@ export class ClaudeAcpAgent implements Agent {
432
603
  options,
433
604
  });
434
605
 
435
- this.createSession(sessionId, q, input, permissionMode);
606
+ this.createSession(sessionId, q, input, ourPermissionMode);
436
607
 
437
608
  // Register for S3 persistence if config provided
438
609
  const persistence = params._meta?.persistence as
@@ -502,7 +673,7 @@ export class ClaudeAcpAgent implements Agent {
502
673
  sessionId,
503
674
  models,
504
675
  modes: {
505
- currentModeId: permissionMode,
676
+ currentModeId: ourPermissionMode,
506
677
  availableModes,
507
678
  },
508
679
  };
@@ -519,7 +690,8 @@ export class ClaudeAcpAgent implements Agent {
519
690
 
520
691
  this.sessions[params.sessionId].cancelled = false;
521
692
 
522
- const { query, input } = this.sessions[params.sessionId];
693
+ const session = this.sessions[params.sessionId];
694
+ const { query, input } = session;
523
695
 
524
696
  // Capture and store user message for replay
525
697
  for (const chunk of params.prompt) {
@@ -534,7 +706,7 @@ export class ClaudeAcpAgent implements Agent {
534
706
  this.appendNotification(params.sessionId, userNotification);
535
707
  }
536
708
 
537
- input.push(promptToClaude(params));
709
+ input.push(promptToClaude({ ...params, prompt: params.prompt }));
538
710
  while (true) {
539
711
  const { value: message, done } = await query.next();
540
712
  if (done || !message) {
@@ -545,7 +717,7 @@ export class ClaudeAcpAgent implements Agent {
545
717
  }
546
718
  this.logger.debug("SDK message received", {
547
719
  type: message.type,
548
- subtype: (message as any).subtype,
720
+ subtype: (message as { subtype?: string }).subtype,
549
721
  });
550
722
 
551
723
  switch (message.type) {
@@ -673,12 +845,16 @@ export class ClaudeAcpAgent implements Agent {
673
845
  throw RequestError.authRequired();
674
846
  }
675
847
 
676
- // For assistant messages, text/thinking are normally streamed via stream_event.
677
- // But some gateways (like LiteLLM) don't stream, so we process all content.
848
+ // Text/thinking is streamed via stream_event, so skip them here to avoid duplication.
678
849
  const content = message.message.content;
850
+ const contentToProcess = Array.isArray(content)
851
+ ? content.filter(
852
+ (block) => block.type !== "text" && block.type !== "thinking",
853
+ )
854
+ : content;
679
855
 
680
856
  for (const notification of toAcpNotifications(
681
- content,
857
+ contentToProcess as typeof content,
682
858
  message.message.role,
683
859
  params.sessionId,
684
860
  this.toolUseCache,
@@ -786,7 +962,89 @@ export class ClaudeAcpAgent implements Agent {
786
962
  };
787
963
  }
788
964
 
965
+ // Helper to emit a tool denial notification so the UI shows the reason
966
+ const emitToolDenial = async (message: string) => {
967
+ this.logger.info(`[canUseTool] Tool denied: ${toolName}`, { message });
968
+ await this.client.sessionUpdate({
969
+ sessionId,
970
+ update: {
971
+ sessionUpdate: "tool_call_update",
972
+ toolCallId: toolUseID,
973
+ status: "failed",
974
+ content: [
975
+ {
976
+ type: "content",
977
+ content: {
978
+ type: "text",
979
+ text: message,
980
+ },
981
+ },
982
+ ],
983
+ },
984
+ });
985
+ };
986
+
789
987
  if (toolName === "ExitPlanMode") {
988
+ // If we're already not in plan mode, just allow the tool without prompting
989
+ // This handles the case where mode was already changed by a previous ExitPlanMode call
990
+ // (Claude may call ExitPlanMode again after writing the plan file)
991
+ if (session.permissionMode !== "plan") {
992
+ return {
993
+ behavior: "allow",
994
+ updatedInput: toolInput,
995
+ };
996
+ }
997
+
998
+ let updatedInput = toolInput;
999
+ const planFromFile =
1000
+ session.lastPlanContent ||
1001
+ (session.lastPlanFilePath
1002
+ ? this.fileContentCache[session.lastPlanFilePath]
1003
+ : undefined);
1004
+ const hasPlan =
1005
+ typeof (toolInput as { plan?: unknown } | undefined)?.plan ===
1006
+ "string";
1007
+ if (!hasPlan) {
1008
+ const fallbackPlan = planFromFile
1009
+ ? planFromFile
1010
+ : this.getLatestAssistantText(session.notificationHistory);
1011
+ if (fallbackPlan) {
1012
+ updatedInput = {
1013
+ ...(toolInput as Record<string, unknown>),
1014
+ plan: fallbackPlan,
1015
+ };
1016
+ }
1017
+ }
1018
+
1019
+ const planText =
1020
+ typeof (updatedInput as { plan?: unknown } | undefined)?.plan ===
1021
+ "string"
1022
+ ? String((updatedInput as { plan?: unknown }).plan)
1023
+ : undefined;
1024
+ if (!planText) {
1025
+ const message = `Plan not ready. Provide the full markdown plan in ExitPlanMode or write it to ${getClaudePlansDir()} before requesting approval.`;
1026
+ await emitToolDenial(message);
1027
+ return {
1028
+ behavior: "deny",
1029
+ message,
1030
+ interrupt: false,
1031
+ };
1032
+ }
1033
+ if (!this.isPlanReady(planText)) {
1034
+ const message =
1035
+ "Plan not ready. Provide the full markdown plan in ExitPlanMode before requesting approval.";
1036
+ await emitToolDenial(message);
1037
+ return {
1038
+ behavior: "deny",
1039
+ message,
1040
+ interrupt: false,
1041
+ };
1042
+ }
1043
+
1044
+ // ExitPlanMode is a signal to show the permission dialog
1045
+ // The plan content should already be in the agent's text response
1046
+ // Note: The SDK's ExitPlanMode tool includes a plan parameter, so ensure it is present
1047
+
790
1048
  const response = await this.client.requestPermission({
791
1049
  options: [
792
1050
  {
@@ -808,9 +1066,9 @@ export class ClaudeAcpAgent implements Agent {
808
1066
  sessionId,
809
1067
  toolCall: {
810
1068
  toolCallId: toolUseID,
811
- rawInput: toolInput,
1069
+ rawInput: { ...updatedInput, toolName },
812
1070
  title: toolInfoFromToolUse(
813
- { name: toolName, input: toolInput },
1071
+ { name: toolName, input: updatedInput },
814
1072
  this.fileContentCache,
815
1073
  this.logger,
816
1074
  ).title,
@@ -833,7 +1091,7 @@ export class ClaudeAcpAgent implements Agent {
833
1091
 
834
1092
  return {
835
1093
  behavior: "allow",
836
- updatedInput: toolInput,
1094
+ updatedInput,
837
1095
  updatedPermissions: suggestions ?? [
838
1096
  {
839
1097
  type: "setMode",
@@ -843,12 +1101,216 @@ export class ClaudeAcpAgent implements Agent {
843
1101
  ],
844
1102
  };
845
1103
  } else {
1104
+ // User chose "No, keep planning" - stay in plan mode and let agent continue
1105
+ const message =
1106
+ "User wants to continue planning. Please refine your plan based on any feedback provided, or ask clarifying questions if needed.";
1107
+ await emitToolDenial(message);
846
1108
  return {
847
1109
  behavior: "deny",
848
- message: "User rejected request to exit plan mode.",
1110
+ message,
1111
+ interrupt: false,
1112
+ };
1113
+ }
1114
+ }
1115
+
1116
+ // AskUserQuestion always prompts user - never auto-approve
1117
+ if (toolName === "AskUserQuestion") {
1118
+ interface QuestionItem {
1119
+ question: string;
1120
+ header?: string;
1121
+ options: Array<{ label: string; description?: string }>;
1122
+ multiSelect?: boolean;
1123
+ }
1124
+ interface AskUserQuestionInput {
1125
+ // Full format: array of questions with options
1126
+ questions?: QuestionItem[];
1127
+ // Simple format: just a question string (used when Claude doesn't have proper schema)
1128
+ question?: string;
1129
+ header?: string;
1130
+ options?: Array<{ label: string; description?: string }>;
1131
+ multiSelect?: boolean;
1132
+ }
1133
+ const input = toolInput as AskUserQuestionInput;
1134
+
1135
+ // Normalize to questions array format
1136
+ // Support both: { questions: [...] } and { question: "..." }
1137
+ let questions: QuestionItem[];
1138
+ if (input.questions && input.questions.length > 0) {
1139
+ // Full format with questions array
1140
+ questions = input.questions;
1141
+ } else if (input.question) {
1142
+ // Simple format - convert to array
1143
+ // If no options provided, just use "Other" for free-form input
1144
+ questions = [
1145
+ {
1146
+ question: input.question,
1147
+ header: input.header,
1148
+ options: input.options || [],
1149
+ multiSelect: input.multiSelect,
1150
+ },
1151
+ ];
1152
+ } else {
1153
+ return {
1154
+ behavior: "deny",
1155
+ message: "No questions provided",
849
1156
  interrupt: true,
850
1157
  };
851
1158
  }
1159
+
1160
+ // Collect all answers from all questions
1161
+ const allAnswers: Record<string, string | string[]> = {};
1162
+
1163
+ for (let i = 0; i < questions.length; i++) {
1164
+ const question = questions[i];
1165
+
1166
+ // Convert question options to permission options
1167
+ const options = (question.options || []).map(
1168
+ (opt: { label: string; description?: string }, idx: number) => ({
1169
+ kind: "allow_once" as const,
1170
+ name: opt.label,
1171
+ optionId: `option_${idx}`,
1172
+ description: opt.description,
1173
+ }),
1174
+ );
1175
+
1176
+ // Add "Other" option for free-form response
1177
+ options.push({
1178
+ kind: "allow_once" as const,
1179
+ name: "Other",
1180
+ optionId: "other",
1181
+ description: "Provide a custom response",
1182
+ });
1183
+
1184
+ const response = await this.client.requestPermission({
1185
+ options,
1186
+ sessionId,
1187
+ toolCall: {
1188
+ toolCallId: toolUseID,
1189
+ rawInput: {
1190
+ ...toolInput,
1191
+ toolName,
1192
+ // Include full question data for UI rendering
1193
+ currentQuestion: question,
1194
+ questionIndex: i,
1195
+ totalQuestions: questions.length,
1196
+ },
1197
+ // Use the full question text as title for the selection input
1198
+ title: question.question,
1199
+ },
1200
+ });
1201
+
1202
+ if (response.outcome?.outcome === "selected") {
1203
+ const selectedOptionId = response.outcome.optionId;
1204
+ // Type assertion for extended outcome fields
1205
+ const extendedOutcome = response.outcome as {
1206
+ optionId: string;
1207
+ selectedOptionIds?: string[];
1208
+ customInput?: string;
1209
+ };
1210
+
1211
+ if (selectedOptionId === "other" && extendedOutcome.customInput) {
1212
+ // "Other" was selected with custom text
1213
+ allAnswers[question.question] = extendedOutcome.customInput;
1214
+ } else if (selectedOptionId === "other") {
1215
+ // "Other" was selected but no custom text - just record "other"
1216
+ allAnswers[question.question] = "other";
1217
+ } else if (
1218
+ question.multiSelect &&
1219
+ extendedOutcome.selectedOptionIds
1220
+ ) {
1221
+ // Multi-select: collect all selected option labels
1222
+ const selectedLabels = extendedOutcome.selectedOptionIds
1223
+ .map((id: string) => {
1224
+ const idx = parseInt(id.replace("option_", ""), 10);
1225
+ return question.options?.[idx]?.label;
1226
+ })
1227
+ .filter(Boolean) as string[];
1228
+ allAnswers[question.question] = selectedLabels;
1229
+ } else {
1230
+ // Single select
1231
+ const selectedIdx = parseInt(
1232
+ selectedOptionId.replace("option_", ""),
1233
+ 10,
1234
+ );
1235
+ const selectedOption = question.options?.[selectedIdx];
1236
+ allAnswers[question.question] =
1237
+ selectedOption?.label || selectedOptionId;
1238
+ }
1239
+ } else {
1240
+ // User cancelled or did not answer
1241
+ return {
1242
+ behavior: "deny",
1243
+ message: "User did not complete all questions",
1244
+ interrupt: true,
1245
+ };
1246
+ }
1247
+ }
1248
+
1249
+ // Return all answers in updatedInput
1250
+ return {
1251
+ behavior: "allow",
1252
+ updatedInput: {
1253
+ ...toolInput,
1254
+ answers: allAnswers,
1255
+ },
1256
+ };
1257
+ }
1258
+
1259
+ // In plan mode, deny write/edit tools except for Claude's plan files
1260
+ // This includes both MCP-wrapped tools and built-in SDK tools
1261
+ const WRITE_TOOL_NAMES = [
1262
+ ...EDIT_TOOL_NAMES,
1263
+ "Edit",
1264
+ "Write",
1265
+ "NotebookEdit",
1266
+ ];
1267
+ if (
1268
+ session.permissionMode === "plan" &&
1269
+ WRITE_TOOL_NAMES.includes(toolName)
1270
+ ) {
1271
+ // Allow writes to Claude Code's plan files
1272
+ const filePath = (toolInput as { file_path?: string })?.file_path;
1273
+ const isPlanFile = isClaudePlanFilePath(filePath);
1274
+
1275
+ if (isPlanFile) {
1276
+ session.lastPlanFilePath = filePath;
1277
+ const content = (toolInput as { content?: string })?.content;
1278
+ if (typeof content === "string") {
1279
+ session.lastPlanContent = content;
1280
+ }
1281
+ return {
1282
+ behavior: "allow",
1283
+ updatedInput: toolInput,
1284
+ };
1285
+ }
1286
+
1287
+ const message =
1288
+ "Cannot use write tools in plan mode. Use ExitPlanMode to request permission to make changes.";
1289
+ await emitToolDenial(message);
1290
+ return {
1291
+ behavior: "deny",
1292
+ message,
1293
+ interrupt: false,
1294
+ };
1295
+ }
1296
+
1297
+ // In plan mode, handle Bash separately - allow read-only commands
1298
+ if (
1299
+ session.permissionMode === "plan" &&
1300
+ (toolName === "Bash" || toolName === toolNames.bash)
1301
+ ) {
1302
+ const command = (toolInput as { command?: string })?.command ?? "";
1303
+ if (!isReadOnlyBashCommand(command)) {
1304
+ const message =
1305
+ "Cannot run write/modify bash commands in plan mode. Use ExitPlanMode to request permission to make changes.";
1306
+ await emitToolDenial(message);
1307
+ return {
1308
+ behavior: "deny",
1309
+ message,
1310
+ interrupt: false,
1311
+ };
1312
+ }
1313
+ // Read-only bash commands are allowed - fall through to normal permission flow
852
1314
  }
853
1315
 
854
1316
  if (
@@ -916,9 +1378,11 @@ export class ClaudeAcpAgent implements Agent {
916
1378
  updatedInput: toolInput,
917
1379
  };
918
1380
  } else {
1381
+ const message = "User refused permission to run tool";
1382
+ await emitToolDenial(message);
919
1383
  return {
920
1384
  behavior: "deny",
921
- message: "User refused permission to run tool",
1385
+ message,
922
1386
  interrupt: true,
923
1387
  };
924
1388
  }
@@ -947,6 +1411,15 @@ export class ClaudeAcpAgent implements Agent {
947
1411
  return {};
948
1412
  }
949
1413
 
1414
+ if (method === "session/setMode") {
1415
+ const { sessionId, modeId } = params as {
1416
+ sessionId: string;
1417
+ modeId: string;
1418
+ };
1419
+ await this.setSessionMode({ sessionId, modeId });
1420
+ return {};
1421
+ }
1422
+
950
1423
  throw RequestError.methodNotFound(method);
951
1424
  }
952
1425
 
@@ -1156,8 +1629,8 @@ function formatUriAsLink(uri: string): string {
1156
1629
  }
1157
1630
 
1158
1631
  export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
1159
- const content: any[] = [];
1160
- const context: any[] = [];
1632
+ const content: ContentBlockParam[] = [];
1633
+ const context: ContentBlockParam[] = [];
1161
1634
 
1162
1635
  for (const chunk of prompt.prompt) {
1163
1636
  switch (chunk.type) {
@@ -1202,7 +1675,11 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
1202
1675
  source: {
1203
1676
  type: "base64",
1204
1677
  data: chunk.data,
1205
- media_type: chunk.mimeType,
1678
+ media_type: chunk.mimeType as
1679
+ | "image/jpeg"
1680
+ | "image/png"
1681
+ | "image/gif"
1682
+ | "image/webp",
1206
1683
  },
1207
1684
  });
1208
1685
  } else if (chunk.uri?.startsWith("http")) {
@@ -1465,75 +1942,6 @@ export function streamEventToAcpNotifications(
1465
1942
  }
1466
1943
  }
1467
1944
 
1468
- export type AcpConnectionConfig = {
1469
- sessionStore?: SessionStore;
1470
- sessionId?: string;
1471
- taskId?: string;
1472
- };
1473
-
1474
- export type InProcessAcpConnection = {
1475
- agentConnection: AgentSideConnection;
1476
- clientStreams: StreamPair;
1477
- };
1478
-
1479
- export function createAcpConnection(
1480
- config: AcpConnectionConfig = {},
1481
- ): InProcessAcpConnection {
1482
- const logger = new Logger({ debug: true, prefix: "[AcpConnection]" });
1483
- const streams = createBidirectionalStreams();
1484
-
1485
- const { sessionStore } = config;
1486
-
1487
- // Tap both streams for automatic persistence
1488
- // All messages (bidirectional) will be persisted as they flow through
1489
- let agentWritable = streams.agent.writable;
1490
- let clientWritable = streams.client.writable;
1491
-
1492
- if (config.sessionId && sessionStore) {
1493
- // Register session for persistence BEFORE tapping streams
1494
- // This ensures all messages from the start get persisted
1495
- if (!sessionStore.isRegistered(config.sessionId)) {
1496
- sessionStore.register(config.sessionId, {
1497
- taskId: config.taskId ?? config.sessionId,
1498
- runId: config.sessionId,
1499
- logUrl: "", // Will be updated when we get the real logUrl
1500
- });
1501
- }
1502
-
1503
- // Tap agent→client stream
1504
- agentWritable = createTappedWritableStream(streams.agent.writable, {
1505
- onMessage: (line) => {
1506
- sessionStore.appendRawLine(config.sessionId!, line);
1507
- },
1508
- logger,
1509
- });
1510
-
1511
- // Tap client→agent stream
1512
- clientWritable = createTappedWritableStream(streams.client.writable, {
1513
- onMessage: (line) => {
1514
- sessionStore.appendRawLine(config.sessionId!, line);
1515
- },
1516
- logger,
1517
- });
1518
- } else {
1519
- logger.info("Tapped streams NOT enabled", {
1520
- hasSessionId: !!config.sessionId,
1521
- hasSessionStore: !!sessionStore,
1522
- });
1523
- }
1524
-
1525
- const agentStream = ndJsonStream(agentWritable, streams.agent.readable);
1526
-
1527
- const agentConnection = new AgentSideConnection(
1528
- (client) => new ClaudeAcpAgent(client, sessionStore),
1529
- agentStream,
1530
- );
1531
-
1532
- return {
1533
- agentConnection,
1534
- clientStreams: {
1535
- readable: streams.client.readable,
1536
- writable: clientWritable,
1537
- },
1538
- };
1539
- }
1945
+ // Note: createAcpConnection has been moved to ../connection.ts
1946
+ // Import from there instead:
1947
+ // import { createAcpConnection } from "../connection.js";