@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.
- package/dist/index.d.ts +57 -87
- package/dist/index.js +916 -2203
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/acp-extensions.ts +0 -37
- package/src/adapters/claude/claude.ts +515 -107
- package/src/adapters/claude/tools.ts +178 -101
- package/src/adapters/connection.ts +95 -0
- package/src/agent.ts +50 -184
- package/src/file-manager.ts +1 -34
- package/src/git-manager.ts +2 -20
- package/src/posthog-api.ts +4 -4
- package/src/tools/registry.ts +5 -0
- package/src/tools/types.ts +6 -0
- package/src/types.ts +5 -25
- package/src/utils/gateway.ts +15 -0
- package/src/worktree-manager.ts +92 -46
- package/dist/templates/plan-template.md +0 -41
- package/src/agents/execution.ts +0 -37
- package/src/agents/planning.ts +0 -60
- package/src/agents/research.ts +0 -160
- package/src/prompt-builder.ts +0 -497
- package/src/template-manager.ts +0 -240
- package/src/templates/plan-template.md +0 -41
- package/src/workflow/config.ts +0 -53
- package/src/workflow/steps/build.ts +0 -135
- package/src/workflow/steps/finalize.ts +0 -241
- package/src/workflow/steps/plan.ts +0 -167
- package/src/workflow/steps/research.ts +0 -223
- package/src/workflow/types.ts +0 -62
- package/src/workflow/utils.ts +0 -53
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
const
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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:
|
|
1069
|
+
rawInput: { ...updatedInput, toolName },
|
|
812
1070
|
title: toolInfoFromToolUse(
|
|
813
|
-
{ name: toolName, input:
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
1160
|
-
const context:
|
|
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
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
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";
|