@posthog/agent 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +221 -219
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +21 -0
  4. package/dist/adapters/claude/conversion/tool-use-to-acp.js +547 -0
  5. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -0
  6. package/dist/adapters/claude/permissions/permission-options.d.ts +13 -0
  7. package/dist/adapters/claude/permissions/permission-options.js +117 -0
  8. package/dist/adapters/claude/permissions/permission-options.js.map +1 -0
  9. package/dist/adapters/claude/questions/utils.d.ts +132 -0
  10. package/dist/adapters/claude/questions/utils.js +63 -0
  11. package/dist/adapters/claude/questions/utils.js.map +1 -0
  12. package/dist/adapters/claude/tools.d.ts +18 -0
  13. package/dist/adapters/claude/tools.js +95 -0
  14. package/dist/adapters/claude/tools.js.map +1 -0
  15. package/dist/agent-DBQY1BfC.d.ts +123 -0
  16. package/dist/agent.d.ts +5 -0
  17. package/dist/agent.js +3656 -0
  18. package/dist/agent.js.map +1 -0
  19. package/dist/claude-cli/cli.js +3695 -2746
  20. package/dist/claude-cli/vendor/ripgrep/COPYING +3 -0
  21. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  22. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/ripgrep.node +0 -0
  23. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  24. package/dist/claude-cli/vendor/ripgrep/arm64-linux/ripgrep.node +0 -0
  25. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  26. package/dist/claude-cli/vendor/ripgrep/x64-darwin/ripgrep.node +0 -0
  27. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  28. package/dist/claude-cli/vendor/ripgrep/x64-linux/ripgrep.node +0 -0
  29. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  30. package/dist/claude-cli/vendor/ripgrep/x64-win32/ripgrep.node +0 -0
  31. package/dist/gateway-models.d.ts +24 -0
  32. package/dist/gateway-models.js +93 -0
  33. package/dist/gateway-models.js.map +1 -0
  34. package/dist/index.d.ts +170 -1157
  35. package/dist/index.js +9373 -5135
  36. package/dist/index.js.map +1 -1
  37. package/dist/logger-DDBiMOOD.d.ts +24 -0
  38. package/dist/posthog-api.d.ts +40 -0
  39. package/dist/posthog-api.js +175 -0
  40. package/dist/posthog-api.js.map +1 -0
  41. package/dist/server/agent-server.d.ts +41 -0
  42. package/dist/server/agent-server.js +10503 -0
  43. package/dist/server/agent-server.js.map +1 -0
  44. package/dist/server/bin.d.ts +1 -0
  45. package/dist/server/bin.js +10558 -0
  46. package/dist/server/bin.js.map +1 -0
  47. package/dist/types.d.ts +129 -0
  48. package/dist/types.js +1 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +65 -13
  51. package/src/acp-extensions.ts +98 -16
  52. package/src/adapters/acp-connection.ts +494 -0
  53. package/src/adapters/base-acp-agent.ts +150 -0
  54. package/src/adapters/claude/claude-agent.ts +596 -0
  55. package/src/adapters/claude/conversion/acp-to-sdk.ts +102 -0
  56. package/src/adapters/claude/conversion/sdk-to-acp.ts +571 -0
  57. package/src/adapters/claude/conversion/tool-use-to-acp.ts +618 -0
  58. package/src/adapters/claude/hooks.ts +64 -0
  59. package/src/adapters/claude/mcp/tool-metadata.ts +102 -0
  60. package/src/adapters/claude/permissions/permission-handlers.ts +433 -0
  61. package/src/adapters/claude/permissions/permission-options.ts +103 -0
  62. package/src/adapters/claude/plan/utils.ts +56 -0
  63. package/src/adapters/claude/questions/utils.ts +92 -0
  64. package/src/adapters/claude/session/commands.ts +38 -0
  65. package/src/adapters/claude/session/mcp-config.ts +37 -0
  66. package/src/adapters/claude/session/models.ts +12 -0
  67. package/src/adapters/claude/session/options.ts +236 -0
  68. package/src/adapters/claude/tool-meta.ts +143 -0
  69. package/src/adapters/claude/tools.ts +53 -688
  70. package/src/adapters/claude/types.ts +61 -0
  71. package/src/adapters/codex/spawn.ts +130 -0
  72. package/src/agent.ts +96 -587
  73. package/src/execution-mode.ts +43 -0
  74. package/src/gateway-models.ts +135 -0
  75. package/src/index.ts +79 -0
  76. package/src/otel-log-writer.test.ts +105 -0
  77. package/src/otel-log-writer.ts +94 -0
  78. package/src/posthog-api.ts +75 -235
  79. package/src/resume.ts +115 -0
  80. package/src/sagas/apply-snapshot-saga.test.ts +690 -0
  81. package/src/sagas/apply-snapshot-saga.ts +88 -0
  82. package/src/sagas/capture-tree-saga.test.ts +892 -0
  83. package/src/sagas/capture-tree-saga.ts +141 -0
  84. package/src/sagas/resume-saga.test.ts +558 -0
  85. package/src/sagas/resume-saga.ts +332 -0
  86. package/src/sagas/test-fixtures.ts +250 -0
  87. package/src/server/agent-server.test.ts +220 -0
  88. package/src/server/agent-server.ts +748 -0
  89. package/src/server/bin.ts +88 -0
  90. package/src/server/jwt.ts +65 -0
  91. package/src/server/schemas.ts +47 -0
  92. package/src/server/types.ts +13 -0
  93. package/src/server/utils/retry.test.ts +122 -0
  94. package/src/server/utils/retry.ts +61 -0
  95. package/src/server/utils/sse-parser.test.ts +93 -0
  96. package/src/server/utils/sse-parser.ts +46 -0
  97. package/src/session-log-writer.test.ts +140 -0
  98. package/src/session-log-writer.ts +137 -0
  99. package/src/test/assertions.ts +114 -0
  100. package/src/test/controllers/sse-controller.ts +107 -0
  101. package/src/test/fixtures/api.ts +111 -0
  102. package/src/test/fixtures/config.ts +33 -0
  103. package/src/test/fixtures/notifications.ts +92 -0
  104. package/src/test/mocks/claude-sdk.ts +251 -0
  105. package/src/test/mocks/msw-handlers.ts +48 -0
  106. package/src/test/setup.ts +114 -0
  107. package/src/test/wait.ts +41 -0
  108. package/src/tree-tracker.ts +173 -0
  109. package/src/types.ts +54 -137
  110. package/src/utils/acp-content.ts +58 -0
  111. package/src/utils/async-mutex.test.ts +104 -0
  112. package/src/utils/async-mutex.ts +31 -0
  113. package/src/utils/common.ts +15 -0
  114. package/src/utils/gateway.ts +9 -6
  115. package/src/utils/logger.ts +0 -30
  116. package/src/utils/streams.ts +220 -0
  117. package/CLAUDE.md +0 -331
  118. package/src/adapters/claude/claude.ts +0 -1947
  119. package/src/adapters/claude/mcp-server.ts +0 -810
  120. package/src/adapters/claude/utils.ts +0 -267
  121. package/src/adapters/connection.ts +0 -95
  122. package/src/file-manager.ts +0 -273
  123. package/src/git-manager.ts +0 -577
  124. package/src/schemas.ts +0 -241
  125. package/src/session-store.ts +0 -259
  126. package/src/task-manager.ts +0 -163
  127. package/src/todo-manager.ts +0 -180
  128. package/src/tools/registry.ts +0 -134
  129. package/src/tools/types.ts +0 -133
  130. package/src/utils/tapped-stream.ts +0 -60
  131. package/src/worktree-manager.ts +0 -974
@@ -0,0 +1,102 @@
1
+ import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
5
+ import { Logger } from "../../../utils/logger.js";
6
+
7
+ export interface McpToolMetadata {
8
+ readOnly: boolean;
9
+ }
10
+
11
+ const mcpToolMetadataCache: Map<string, McpToolMetadata> = new Map();
12
+
13
+ function buildToolKey(serverName: string, toolName: string): string {
14
+ return `mcp__${serverName}__${toolName}`;
15
+ }
16
+
17
+ function isHttpMcpServer(
18
+ config: McpServerConfig,
19
+ ): config is McpServerConfig & { type: "http"; url: string } {
20
+ return config.type === "http" && typeof (config as any).url === "string";
21
+ }
22
+
23
+ async function fetchToolsFromHttpServer(
24
+ _serverName: string,
25
+ config: McpServerConfig & { type: "http"; url: string },
26
+ ): Promise<Tool[]> {
27
+ const transport = new StreamableHTTPClientTransport(new URL(config.url), {
28
+ requestInit: {
29
+ headers: (config as any).headers || {},
30
+ },
31
+ });
32
+
33
+ const client = new Client({
34
+ name: "twig-metadata-fetcher",
35
+ version: "1.0.0",
36
+ });
37
+
38
+ try {
39
+ await client.connect(transport);
40
+ const result = await client.listTools();
41
+ return result.tools;
42
+ } finally {
43
+ await client.close().catch(() => {});
44
+ }
45
+ }
46
+
47
+ function extractToolMetadata(tool: Tool): McpToolMetadata {
48
+ return {
49
+ readOnly: tool.annotations?.readOnlyHint === true,
50
+ };
51
+ }
52
+
53
+ export async function fetchMcpToolMetadata(
54
+ mcpServers: Record<string, McpServerConfig>,
55
+ logger: Logger = new Logger({ debug: false, prefix: "[McpToolMetadata]" }),
56
+ ): Promise<void> {
57
+ const fetchPromises: Promise<void>[] = [];
58
+
59
+ for (const [serverName, config] of Object.entries(mcpServers)) {
60
+ if (!isHttpMcpServer(config)) {
61
+ continue;
62
+ }
63
+
64
+ const fetchPromise = fetchToolsFromHttpServer(serverName, config)
65
+ .then((tools) => {
66
+ const toolCount = tools.length;
67
+ const readOnlyCount = tools.filter(
68
+ (t) => t.annotations?.readOnlyHint === true,
69
+ ).length;
70
+
71
+ for (const tool of tools) {
72
+ const toolKey = buildToolKey(serverName, tool.name);
73
+ mcpToolMetadataCache.set(toolKey, extractToolMetadata(tool));
74
+ }
75
+
76
+ logger.info("Fetched MCP tool metadata", {
77
+ serverName,
78
+ toolCount,
79
+ readOnlyCount,
80
+ });
81
+ })
82
+ .catch((error) => {
83
+ logger.error("Failed to fetch MCP tool metadata", {
84
+ serverName,
85
+ error: error instanceof Error ? error.message : String(error),
86
+ });
87
+ });
88
+
89
+ fetchPromises.push(fetchPromise);
90
+ }
91
+
92
+ await Promise.all(fetchPromises);
93
+ }
94
+
95
+ export function isMcpToolReadOnly(toolName: string): boolean {
96
+ const metadata = mcpToolMetadataCache.get(toolName);
97
+ return metadata?.readOnly === true;
98
+ }
99
+
100
+ export function clearMcpToolMetadataCache(): void {
101
+ mcpToolMetadataCache.clear();
102
+ }
@@ -0,0 +1,433 @@
1
+ import type {
2
+ AgentSideConnection,
3
+ RequestPermissionResponse,
4
+ } from "@agentclientprotocol/sdk";
5
+ import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk";
6
+ import { text } from "../../../utils/acp-content.js";
7
+ import type { Logger } from "../../../utils/logger.js";
8
+ import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp.js";
9
+ import {
10
+ getClaudePlansDir,
11
+ getLatestAssistantText,
12
+ isClaudePlanFilePath,
13
+ isPlanReady,
14
+ } from "../plan/utils.js";
15
+ import {
16
+ type AskUserQuestionInput,
17
+ normalizeAskUserQuestionInput,
18
+ OPTION_PREFIX,
19
+ type QuestionItem,
20
+ } from "../questions/utils.js";
21
+ import { isToolAllowedForMode, WRITE_TOOLS } from "../tools.js";
22
+ import type { Session } from "../types.js";
23
+ import {
24
+ buildExitPlanModePermissionOptions,
25
+ buildPermissionOptions,
26
+ } from "./permission-options.js";
27
+
28
+ export type ToolPermissionResult =
29
+ | {
30
+ behavior: "allow";
31
+ updatedInput: Record<string, unknown>;
32
+ updatedPermissions?: PermissionUpdate[];
33
+ }
34
+ | {
35
+ behavior: "deny";
36
+ message: string;
37
+ interrupt: boolean;
38
+ };
39
+
40
+ interface ToolHandlerContext {
41
+ session: Session;
42
+ toolName: string;
43
+ toolInput: Record<string, unknown>;
44
+ toolUseID: string;
45
+ suggestions?: PermissionUpdate[];
46
+ client: AgentSideConnection;
47
+ sessionId: string;
48
+ fileContentCache: { [key: string]: string };
49
+ logger: Logger;
50
+ emitConfigOptionsUpdate: () => Promise<void>;
51
+ }
52
+
53
+ async function emitToolDenial(
54
+ context: ToolHandlerContext,
55
+ message: string,
56
+ ): Promise<void> {
57
+ context.logger.info(`[canUseTool] Tool denied: ${context.toolName}`, {
58
+ message,
59
+ });
60
+ await context.client.sessionUpdate({
61
+ sessionId: context.sessionId,
62
+ update: {
63
+ sessionUpdate: "tool_call_update",
64
+ toolCallId: context.toolUseID,
65
+ status: "failed",
66
+ content: [{ type: "content", content: text(message) }],
67
+ },
68
+ });
69
+ }
70
+
71
+ function getPlanFromFile(
72
+ session: Session,
73
+ fileContentCache: { [key: string]: string },
74
+ ): string | undefined {
75
+ return (
76
+ session.lastPlanContent ||
77
+ (session.lastPlanFilePath
78
+ ? fileContentCache[session.lastPlanFilePath]
79
+ : undefined)
80
+ );
81
+ }
82
+
83
+ function ensurePlanInInput(
84
+ toolInput: Record<string, unknown>,
85
+ fallbackPlan: string | undefined,
86
+ ): Record<string, unknown> {
87
+ const hasPlan = typeof (toolInput as { plan?: unknown })?.plan === "string";
88
+ if (hasPlan || !fallbackPlan) {
89
+ return toolInput;
90
+ }
91
+ return { ...toolInput, plan: fallbackPlan };
92
+ }
93
+
94
+ function extractPlanText(input: Record<string, unknown>): string | undefined {
95
+ const plan = (input as { plan?: unknown })?.plan;
96
+ return typeof plan === "string" ? plan : undefined;
97
+ }
98
+
99
+ async function createPlanValidationError(
100
+ message: string,
101
+ context: ToolHandlerContext,
102
+ ): Promise<ToolPermissionResult> {
103
+ await emitToolDenial(context, message);
104
+ return { behavior: "deny", message, interrupt: false };
105
+ }
106
+
107
+ async function validatePlanContent(
108
+ planText: string | undefined,
109
+ context: ToolHandlerContext,
110
+ ): Promise<{ valid: true } | { valid: false; error: ToolPermissionResult }> {
111
+ if (!planText) {
112
+ const message = `Plan not ready. Provide the full markdown plan in ExitPlanMode or write it to ${getClaudePlansDir()} before requesting approval.`;
113
+ return {
114
+ valid: false,
115
+ error: await createPlanValidationError(message, context),
116
+ };
117
+ }
118
+
119
+ if (!isPlanReady(planText)) {
120
+ const message =
121
+ "Plan not ready. Provide the full markdown plan in ExitPlanMode before requesting approval.";
122
+ return {
123
+ valid: false,
124
+ error: await createPlanValidationError(message, context),
125
+ };
126
+ }
127
+
128
+ return { valid: true };
129
+ }
130
+
131
+ async function requestPlanApproval(
132
+ context: ToolHandlerContext,
133
+ updatedInput: Record<string, unknown>,
134
+ ): Promise<RequestPermissionResponse> {
135
+ const { client, sessionId, toolUseID, fileContentCache } = context;
136
+
137
+ const toolInfo = toolInfoFromToolUse(
138
+ { name: context.toolName, input: updatedInput },
139
+ fileContentCache,
140
+ context.logger,
141
+ );
142
+
143
+ return await client.requestPermission({
144
+ options: buildExitPlanModePermissionOptions(),
145
+ sessionId,
146
+ toolCall: {
147
+ toolCallId: toolUseID,
148
+ title: toolInfo.title,
149
+ kind: toolInfo.kind,
150
+ content: toolInfo.content,
151
+ locations: toolInfo.locations,
152
+ rawInput: { ...updatedInput, toolName: context.toolName },
153
+ },
154
+ });
155
+ }
156
+
157
+ async function applyPlanApproval(
158
+ response: RequestPermissionResponse,
159
+ context: ToolHandlerContext,
160
+ updatedInput: Record<string, unknown>,
161
+ ): Promise<ToolPermissionResult> {
162
+ const { session } = context;
163
+
164
+ if (
165
+ response.outcome?.outcome === "selected" &&
166
+ (response.outcome.optionId === "default" ||
167
+ response.outcome.optionId === "acceptEdits")
168
+ ) {
169
+ session.permissionMode = response.outcome.optionId;
170
+ await session.query.setPermissionMode(response.outcome.optionId);
171
+ await context.emitConfigOptionsUpdate();
172
+
173
+ return {
174
+ behavior: "allow",
175
+ updatedInput,
176
+ updatedPermissions: context.suggestions ?? [
177
+ {
178
+ type: "setMode",
179
+ mode: response.outcome.optionId,
180
+ destination: "localSettings",
181
+ },
182
+ ],
183
+ };
184
+ }
185
+
186
+ const message =
187
+ "User wants to continue planning. Please refine your plan based on any feedback provided, or ask clarifying questions if needed.";
188
+ await emitToolDenial(context, message);
189
+ return { behavior: "deny", message, interrupt: false };
190
+ }
191
+
192
+ async function handleEnterPlanModeTool(
193
+ context: ToolHandlerContext,
194
+ ): Promise<ToolPermissionResult> {
195
+ const { session, toolInput, logger } = context;
196
+
197
+ session.permissionMode = "plan";
198
+ await session.query.setPermissionMode("plan");
199
+ await context.emitConfigOptionsUpdate();
200
+
201
+ return {
202
+ behavior: "allow",
203
+ updatedInput: toolInput as Record<string, unknown>,
204
+ };
205
+ }
206
+
207
+ async function handleExitPlanModeTool(
208
+ context: ToolHandlerContext,
209
+ ): Promise<ToolPermissionResult> {
210
+ const { session, toolInput, fileContentCache } = context;
211
+
212
+ const planFromFile = getPlanFromFile(session, fileContentCache);
213
+ const latestText = getLatestAssistantText(session.notificationHistory);
214
+ const fallbackPlan = planFromFile || (latestText ?? undefined);
215
+ const updatedInput = ensurePlanInInput(toolInput, fallbackPlan);
216
+ const planText = extractPlanText(updatedInput);
217
+
218
+ const validationResult = await validatePlanContent(planText, context);
219
+ if (!validationResult.valid) {
220
+ return validationResult.error;
221
+ }
222
+
223
+ const response = await requestPlanApproval(context, updatedInput);
224
+ return await applyPlanApproval(response, context, updatedInput);
225
+ }
226
+
227
+ function buildQuestionOptions(question: QuestionItem) {
228
+ return (question.options || []).map((opt, idx) => ({
229
+ kind: "allow_once" as const,
230
+ name: opt.label,
231
+ optionId: `${OPTION_PREFIX}${idx}`,
232
+ _meta: opt.description ? { description: opt.description } : undefined,
233
+ }));
234
+ }
235
+
236
+ async function handleAskUserQuestionTool(
237
+ context: ToolHandlerContext,
238
+ ): Promise<ToolPermissionResult> {
239
+ const input = context.toolInput as AskUserQuestionInput;
240
+ context.logger.info("[AskUserQuestion] Received input", { input });
241
+ const questions = normalizeAskUserQuestionInput(input);
242
+ context.logger.info("[AskUserQuestion] Normalized questions", { questions });
243
+
244
+ if (!questions || questions.length === 0) {
245
+ context.logger.warn("[AskUserQuestion] No questions found in input");
246
+ return {
247
+ behavior: "deny",
248
+ message: "No questions provided",
249
+ interrupt: true,
250
+ };
251
+ }
252
+
253
+ const { client, sessionId, toolUseID, toolInput, fileContentCache } = context;
254
+ const firstQuestion = questions[0];
255
+ const options = buildQuestionOptions(firstQuestion);
256
+
257
+ const toolInfo = toolInfoFromToolUse(
258
+ { name: context.toolName, input: toolInput },
259
+ fileContentCache,
260
+ context.logger,
261
+ );
262
+
263
+ const response = await client.requestPermission({
264
+ options,
265
+ sessionId,
266
+ toolCall: {
267
+ toolCallId: toolUseID,
268
+ title: firstQuestion.question,
269
+ kind: "other",
270
+ content: toolInfo.content,
271
+ _meta: {
272
+ twigToolKind: "question",
273
+ questions,
274
+ },
275
+ },
276
+ });
277
+
278
+ if (response.outcome?.outcome !== "selected") {
279
+ return {
280
+ behavior: "deny",
281
+ message: "User cancelled the questions",
282
+ interrupt: true,
283
+ };
284
+ }
285
+
286
+ const answers = response._meta?.answers as Record<string, string> | undefined;
287
+ if (!answers || Object.keys(answers).length === 0) {
288
+ return {
289
+ behavior: "deny",
290
+ message: "User did not provide answers",
291
+ interrupt: true,
292
+ };
293
+ }
294
+
295
+ return {
296
+ behavior: "allow",
297
+ updatedInput: {
298
+ ...(context.toolInput as Record<string, unknown>),
299
+ answers,
300
+ },
301
+ };
302
+ }
303
+
304
+ async function handleDefaultPermissionFlow(
305
+ context: ToolHandlerContext,
306
+ ): Promise<ToolPermissionResult> {
307
+ const {
308
+ session,
309
+ toolName,
310
+ toolInput,
311
+ toolUseID,
312
+ client,
313
+ sessionId,
314
+ fileContentCache,
315
+ suggestions,
316
+ } = context;
317
+
318
+ const toolInfo = toolInfoFromToolUse(
319
+ { name: toolName, input: toolInput },
320
+ fileContentCache,
321
+ context.logger,
322
+ );
323
+
324
+ const options = buildPermissionOptions(
325
+ toolName,
326
+ toolInput as Record<string, unknown>,
327
+ session?.cwd,
328
+ );
329
+
330
+ const response = await client.requestPermission({
331
+ options,
332
+ sessionId,
333
+ toolCall: {
334
+ toolCallId: toolUseID,
335
+ title: toolInfo.title,
336
+ kind: toolInfo.kind,
337
+ content: toolInfo.content,
338
+ locations: toolInfo.locations,
339
+ rawInput: toolInput as Record<string, unknown>,
340
+ },
341
+ });
342
+
343
+ if (
344
+ response.outcome?.outcome === "selected" &&
345
+ (response.outcome.optionId === "allow" ||
346
+ response.outcome.optionId === "allow_always")
347
+ ) {
348
+ if (response.outcome.optionId === "allow_always") {
349
+ return {
350
+ behavior: "allow",
351
+ updatedInput: toolInput as Record<string, unknown>,
352
+ updatedPermissions: suggestions ?? [
353
+ {
354
+ type: "addRules",
355
+ rules: [{ toolName }],
356
+ behavior: "allow",
357
+ destination: "localSettings",
358
+ },
359
+ ],
360
+ };
361
+ }
362
+ return {
363
+ behavior: "allow",
364
+ updatedInput: toolInput as Record<string, unknown>,
365
+ };
366
+ } else {
367
+ const message = "User refused permission to run tool";
368
+ await emitToolDenial(context, message);
369
+ return {
370
+ behavior: "deny",
371
+ message,
372
+ interrupt: true,
373
+ };
374
+ }
375
+ }
376
+
377
+ function handlePlanFileException(
378
+ context: ToolHandlerContext,
379
+ ): ToolPermissionResult | null {
380
+ const { session, toolName, toolInput } = context;
381
+
382
+ if (session.permissionMode !== "plan" || !WRITE_TOOLS.has(toolName)) {
383
+ return null;
384
+ }
385
+
386
+ const filePath = (toolInput as { file_path?: string })?.file_path;
387
+ if (!isClaudePlanFilePath(filePath)) {
388
+ return null;
389
+ }
390
+
391
+ session.lastPlanFilePath = filePath;
392
+ const content = (toolInput as { content?: string })?.content;
393
+ if (typeof content === "string") {
394
+ session.lastPlanContent = content;
395
+ }
396
+
397
+ return {
398
+ behavior: "allow",
399
+ updatedInput: toolInput as Record<string, unknown>,
400
+ };
401
+ }
402
+
403
+ export async function canUseTool(
404
+ context: ToolHandlerContext,
405
+ ): Promise<ToolPermissionResult> {
406
+ const { toolName, toolInput, session } = context;
407
+
408
+ if (isToolAllowedForMode(toolName, session.permissionMode)) {
409
+ return {
410
+ behavior: "allow",
411
+ updatedInput: toolInput as Record<string, unknown>,
412
+ };
413
+ }
414
+
415
+ if (toolName === "EnterPlanMode") {
416
+ return handleEnterPlanModeTool(context);
417
+ }
418
+
419
+ if (toolName === "ExitPlanMode") {
420
+ return handleExitPlanModeTool(context);
421
+ }
422
+
423
+ if (toolName === "AskUserQuestion") {
424
+ return handleAskUserQuestionTool(context);
425
+ }
426
+
427
+ const planFileResult = handlePlanFileException(context);
428
+ if (planFileResult) {
429
+ return planFileResult;
430
+ }
431
+
432
+ return handleDefaultPermissionFlow(context);
433
+ }
@@ -0,0 +1,103 @@
1
+ import { BASH_TOOLS, READ_TOOLS, SEARCH_TOOLS, WRITE_TOOLS } from "../tools.js";
2
+
3
+ export interface PermissionOption {
4
+ kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
5
+ name: string;
6
+ optionId: string;
7
+ _meta?: { description?: string; customInput?: boolean };
8
+ }
9
+
10
+ function permissionOptions(allowAlwaysLabel: string): PermissionOption[] {
11
+ return [
12
+ { kind: "allow_once", name: "Yes", optionId: "allow" },
13
+ { kind: "allow_always", name: allowAlwaysLabel, optionId: "allow_always" },
14
+ {
15
+ kind: "reject_once",
16
+ name: "No, and tell the agent what to do differently",
17
+ optionId: "reject",
18
+ _meta: { customInput: true },
19
+ },
20
+ ];
21
+ }
22
+
23
+ export function buildPermissionOptions(
24
+ toolName: string,
25
+ toolInput: Record<string, unknown>,
26
+ cwd?: string,
27
+ ): PermissionOption[] {
28
+ if (BASH_TOOLS.has(toolName)) {
29
+ const command = toolInput?.command as string | undefined;
30
+ const cmdName = command?.split(/\s+/)[0] ?? "this command";
31
+ const cwdLabel = cwd ? ` in ${cwd}` : "";
32
+ return permissionOptions(
33
+ `Yes, and don't ask again for \`${cmdName}\` commands${cwdLabel}`,
34
+ );
35
+ }
36
+
37
+ if (toolName === "BashOutput") {
38
+ return permissionOptions("Yes, allow all background process reads");
39
+ }
40
+
41
+ if (toolName === "KillShell") {
42
+ return permissionOptions("Yes, allow killing processes");
43
+ }
44
+
45
+ if (WRITE_TOOLS.has(toolName)) {
46
+ return permissionOptions("Yes, allow all edits during this session");
47
+ }
48
+
49
+ if (READ_TOOLS.has(toolName)) {
50
+ return permissionOptions("Yes, allow all reads during this session");
51
+ }
52
+
53
+ if (SEARCH_TOOLS.has(toolName)) {
54
+ return permissionOptions("Yes, allow all searches during this session");
55
+ }
56
+
57
+ if (toolName === "WebFetch") {
58
+ const url = toolInput?.url as string | undefined;
59
+ let domain = "";
60
+ try {
61
+ domain = url ? new URL(url).hostname : "";
62
+ } catch {}
63
+ return permissionOptions(
64
+ domain
65
+ ? `Yes, allow all fetches from ${domain}`
66
+ : "Yes, allow all fetches",
67
+ );
68
+ }
69
+
70
+ if (toolName === "WebSearch") {
71
+ return permissionOptions("Yes, allow all web searches");
72
+ }
73
+
74
+ if (toolName === "Task") {
75
+ return permissionOptions("Yes, allow all sub-tasks");
76
+ }
77
+
78
+ if (toolName === "TodoWrite") {
79
+ return permissionOptions("Yes, allow all todo updates");
80
+ }
81
+
82
+ return permissionOptions("Yes, always allow");
83
+ }
84
+
85
+ export function buildExitPlanModePermissionOptions(): PermissionOption[] {
86
+ return [
87
+ {
88
+ kind: "allow_always",
89
+ name: "Yes, and auto-accept edits",
90
+ optionId: "acceptEdits",
91
+ },
92
+ {
93
+ kind: "allow_once",
94
+ name: "Yes, and manually approve edits",
95
+ optionId: "default",
96
+ },
97
+ {
98
+ kind: "reject_once",
99
+ name: "No, keep planning",
100
+ optionId: "plan",
101
+ },
102
+ ];
103
+ }
@@ -0,0 +1,56 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import type { SessionNotification } from "@agentclientprotocol/sdk";
4
+
5
+ function getClaudeConfigDir(): string {
6
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
7
+ }
8
+
9
+ export function getClaudePlansDir(): string {
10
+ return path.join(getClaudeConfigDir(), "plans");
11
+ }
12
+
13
+ export function isClaudePlanFilePath(filePath: string | undefined): boolean {
14
+ if (!filePath) return false;
15
+ const resolved = path.resolve(filePath);
16
+ const plansDir = path.resolve(getClaudePlansDir());
17
+ return resolved === plansDir || resolved.startsWith(plansDir + path.sep);
18
+ }
19
+
20
+ export function isPlanReady(plan: string | undefined): boolean {
21
+ if (!plan) return false;
22
+ const trimmed = plan.trim();
23
+ if (trimmed.length < 40) return false;
24
+ return /(^|\n)#{1,6}\s+\S/.test(trimmed);
25
+ }
26
+
27
+ export function getLatestAssistantText(
28
+ notifications: SessionNotification[],
29
+ ): string | null {
30
+ const chunks: string[] = [];
31
+ let started = false;
32
+
33
+ for (let i = notifications.length - 1; i >= 0; i -= 1) {
34
+ const update = notifications[i]?.update;
35
+ if (!update) continue;
36
+
37
+ if (update.sessionUpdate === "agent_message_chunk") {
38
+ started = true;
39
+ const content = update.content as {
40
+ type?: string;
41
+ text?: string;
42
+ } | null;
43
+ if (content?.type === "text" && content.text) {
44
+ chunks.push(content.text);
45
+ }
46
+ continue;
47
+ }
48
+
49
+ if (started) {
50
+ break;
51
+ }
52
+ }
53
+
54
+ if (chunks.length === 0) return null;
55
+ return chunks.reverse().join("");
56
+ }