@posthog/agent 1.30.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 +51 -95
- package/dist/index.js +887 -2187
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/acp-extensions.ts +1 -51
- package/src/adapters/claude/claude.ts +508 -104
- package/src/adapters/claude/tools.ts +178 -101
- package/src/adapters/connection.ts +95 -0
- package/src/agent.ts +30 -176
- package/src/file-manager.ts +1 -34
- package/src/tools/registry.ts +5 -0
- package/src/tools/types.ts +6 -0
- package/src/types.ts +3 -23
- 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 -499
- package/src/template-manager.ts +0 -236
- 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) {
|
|
@@ -790,7 +962,89 @@ export class ClaudeAcpAgent implements Agent {
|
|
|
790
962
|
};
|
|
791
963
|
}
|
|
792
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
|
+
|
|
793
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
|
+
|
|
794
1048
|
const response = await this.client.requestPermission({
|
|
795
1049
|
options: [
|
|
796
1050
|
{
|
|
@@ -812,9 +1066,9 @@ export class ClaudeAcpAgent implements Agent {
|
|
|
812
1066
|
sessionId,
|
|
813
1067
|
toolCall: {
|
|
814
1068
|
toolCallId: toolUseID,
|
|
815
|
-
rawInput:
|
|
1069
|
+
rawInput: { ...updatedInput, toolName },
|
|
816
1070
|
title: toolInfoFromToolUse(
|
|
817
|
-
{ name: toolName, input:
|
|
1071
|
+
{ name: toolName, input: updatedInput },
|
|
818
1072
|
this.fileContentCache,
|
|
819
1073
|
this.logger,
|
|
820
1074
|
).title,
|
|
@@ -837,7 +1091,7 @@ export class ClaudeAcpAgent implements Agent {
|
|
|
837
1091
|
|
|
838
1092
|
return {
|
|
839
1093
|
behavior: "allow",
|
|
840
|
-
updatedInput
|
|
1094
|
+
updatedInput,
|
|
841
1095
|
updatedPermissions: suggestions ?? [
|
|
842
1096
|
{
|
|
843
1097
|
type: "setMode",
|
|
@@ -847,12 +1101,216 @@ export class ClaudeAcpAgent implements Agent {
|
|
|
847
1101
|
],
|
|
848
1102
|
};
|
|
849
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);
|
|
850
1108
|
return {
|
|
851
1109
|
behavior: "deny",
|
|
852
|
-
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",
|
|
853
1156
|
interrupt: true,
|
|
854
1157
|
};
|
|
855
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
|
|
856
1314
|
}
|
|
857
1315
|
|
|
858
1316
|
if (
|
|
@@ -920,9 +1378,11 @@ export class ClaudeAcpAgent implements Agent {
|
|
|
920
1378
|
updatedInput: toolInput,
|
|
921
1379
|
};
|
|
922
1380
|
} else {
|
|
1381
|
+
const message = "User refused permission to run tool";
|
|
1382
|
+
await emitToolDenial(message);
|
|
923
1383
|
return {
|
|
924
1384
|
behavior: "deny",
|
|
925
|
-
message
|
|
1385
|
+
message,
|
|
926
1386
|
interrupt: true,
|
|
927
1387
|
};
|
|
928
1388
|
}
|
|
@@ -951,6 +1411,15 @@ export class ClaudeAcpAgent implements Agent {
|
|
|
951
1411
|
return {};
|
|
952
1412
|
}
|
|
953
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
|
+
|
|
954
1423
|
throw RequestError.methodNotFound(method);
|
|
955
1424
|
}
|
|
956
1425
|
|
|
@@ -1160,8 +1629,8 @@ function formatUriAsLink(uri: string): string {
|
|
|
1160
1629
|
}
|
|
1161
1630
|
|
|
1162
1631
|
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
|
1163
|
-
const content:
|
|
1164
|
-
const context:
|
|
1632
|
+
const content: ContentBlockParam[] = [];
|
|
1633
|
+
const context: ContentBlockParam[] = [];
|
|
1165
1634
|
|
|
1166
1635
|
for (const chunk of prompt.prompt) {
|
|
1167
1636
|
switch (chunk.type) {
|
|
@@ -1206,7 +1675,11 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
|
|
1206
1675
|
source: {
|
|
1207
1676
|
type: "base64",
|
|
1208
1677
|
data: chunk.data,
|
|
1209
|
-
media_type: chunk.mimeType
|
|
1678
|
+
media_type: chunk.mimeType as
|
|
1679
|
+
| "image/jpeg"
|
|
1680
|
+
| "image/png"
|
|
1681
|
+
| "image/gif"
|
|
1682
|
+
| "image/webp",
|
|
1210
1683
|
},
|
|
1211
1684
|
});
|
|
1212
1685
|
} else if (chunk.uri?.startsWith("http")) {
|
|
@@ -1469,75 +1942,6 @@ export function streamEventToAcpNotifications(
|
|
|
1469
1942
|
}
|
|
1470
1943
|
}
|
|
1471
1944
|
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
taskId?: string;
|
|
1476
|
-
};
|
|
1477
|
-
|
|
1478
|
-
export type InProcessAcpConnection = {
|
|
1479
|
-
agentConnection: AgentSideConnection;
|
|
1480
|
-
clientStreams: StreamPair;
|
|
1481
|
-
};
|
|
1482
|
-
|
|
1483
|
-
export function createAcpConnection(
|
|
1484
|
-
config: AcpConnectionConfig = {},
|
|
1485
|
-
): InProcessAcpConnection {
|
|
1486
|
-
const logger = new Logger({ debug: true, prefix: "[AcpConnection]" });
|
|
1487
|
-
const streams = createBidirectionalStreams();
|
|
1488
|
-
|
|
1489
|
-
const { sessionStore } = config;
|
|
1490
|
-
|
|
1491
|
-
// Tap both streams for automatic persistence
|
|
1492
|
-
// All messages (bidirectional) will be persisted as they flow through
|
|
1493
|
-
let agentWritable = streams.agent.writable;
|
|
1494
|
-
let clientWritable = streams.client.writable;
|
|
1495
|
-
|
|
1496
|
-
if (config.sessionId && sessionStore) {
|
|
1497
|
-
// Register session for persistence BEFORE tapping streams
|
|
1498
|
-
// This ensures all messages from the start get persisted
|
|
1499
|
-
if (!sessionStore.isRegistered(config.sessionId)) {
|
|
1500
|
-
sessionStore.register(config.sessionId, {
|
|
1501
|
-
taskId: config.taskId ?? config.sessionId,
|
|
1502
|
-
runId: config.sessionId,
|
|
1503
|
-
logUrl: "", // Will be updated when we get the real logUrl
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// Tap agent→client stream
|
|
1508
|
-
agentWritable = createTappedWritableStream(streams.agent.writable, {
|
|
1509
|
-
onMessage: (line) => {
|
|
1510
|
-
sessionStore.appendRawLine(config.sessionId!, line);
|
|
1511
|
-
},
|
|
1512
|
-
logger,
|
|
1513
|
-
});
|
|
1514
|
-
|
|
1515
|
-
// Tap client→agent stream
|
|
1516
|
-
clientWritable = createTappedWritableStream(streams.client.writable, {
|
|
1517
|
-
onMessage: (line) => {
|
|
1518
|
-
sessionStore.appendRawLine(config.sessionId!, line);
|
|
1519
|
-
},
|
|
1520
|
-
logger,
|
|
1521
|
-
});
|
|
1522
|
-
} else {
|
|
1523
|
-
logger.info("Tapped streams NOT enabled", {
|
|
1524
|
-
hasSessionId: !!config.sessionId,
|
|
1525
|
-
hasSessionStore: !!sessionStore,
|
|
1526
|
-
});
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
const agentStream = ndJsonStream(agentWritable, streams.agent.readable);
|
|
1530
|
-
|
|
1531
|
-
const agentConnection = new AgentSideConnection(
|
|
1532
|
-
(client) => new ClaudeAcpAgent(client, sessionStore),
|
|
1533
|
-
agentStream,
|
|
1534
|
-
);
|
|
1535
|
-
|
|
1536
|
-
return {
|
|
1537
|
-
agentConnection,
|
|
1538
|
-
clientStreams: {
|
|
1539
|
-
readable: streams.client.readable,
|
|
1540
|
-
writable: clientWritable,
|
|
1541
|
-
},
|
|
1542
|
-
};
|
|
1543
|
-
}
|
|
1945
|
+
// Note: createAcpConnection has been moved to ../connection.ts
|
|
1946
|
+
// Import from there instead:
|
|
1947
|
+
// import { createAcpConnection } from "../connection.js";
|