@posthog/agent 2.1.131 → 2.1.137

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 (32) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +14 -28
  2. package/dist/adapters/claude/conversion/tool-use-to-acp.js +116 -164
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  4. package/dist/adapters/claude/permissions/permission-options.js +33 -0
  5. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  6. package/dist/adapters/claude/tools.js +21 -11
  7. package/dist/adapters/claude/tools.js.map +1 -1
  8. package/dist/agent.js +1251 -606
  9. package/dist/agent.js.map +1 -1
  10. package/dist/posthog-api.js +2 -2
  11. package/dist/posthog-api.js.map +1 -1
  12. package/dist/server/agent-server.js +1300 -655
  13. package/dist/server/agent-server.js.map +1 -1
  14. package/dist/server/bin.cjs +1278 -635
  15. package/dist/server/bin.cjs.map +1 -1
  16. package/package.json +2 -2
  17. package/src/adapters/base-acp-agent.ts +6 -3
  18. package/src/adapters/claude/UPSTREAM.md +63 -0
  19. package/src/adapters/claude/claude-agent.ts +682 -421
  20. package/src/adapters/claude/conversion/sdk-to-acp.ts +249 -85
  21. package/src/adapters/claude/conversion/tool-use-to-acp.ts +174 -149
  22. package/src/adapters/claude/hooks.ts +53 -1
  23. package/src/adapters/claude/permissions/permission-handlers.ts +39 -21
  24. package/src/adapters/claude/session/commands.ts +13 -9
  25. package/src/adapters/claude/session/mcp-config.ts +2 -5
  26. package/src/adapters/claude/session/options.ts +58 -6
  27. package/src/adapters/claude/session/settings.ts +326 -0
  28. package/src/adapters/claude/tools.ts +1 -0
  29. package/src/adapters/claude/types.ts +38 -0
  30. package/src/execution-mode.ts +26 -10
  31. package/src/server/agent-server.test.ts +41 -1
  32. package/src/utils/common.ts +1 -1
@@ -1,13 +1,17 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import {
6
+ type ModelInfo as AcpModelInfo,
5
7
  type AgentSideConnection,
6
- type AuthenticateRequest,
7
- type AvailableCommand,
8
8
  type ClientCapabilities,
9
+ type ForkSessionRequest,
10
+ type ForkSessionResponse,
9
11
  type InitializeRequest,
10
12
  type InitializeResponse,
13
+ type ListSessionsRequest,
14
+ type ListSessionsResponse,
11
15
  type LoadSessionRequest,
12
16
  type LoadSessionResponse,
13
17
  type NewSessionRequest,
@@ -15,18 +19,27 @@ import {
15
19
  type PromptRequest,
16
20
  type PromptResponse,
17
21
  RequestError,
22
+ type ResumeSessionRequest,
23
+ type ResumeSessionResponse,
18
24
  type SessionConfigOption,
19
25
  type SessionConfigOptionCategory,
20
26
  type SessionConfigSelectOption,
27
+ type SessionModelState,
28
+ type SessionModeState,
21
29
  type SetSessionConfigOptionRequest,
22
30
  type SetSessionConfigOptionResponse,
31
+ type SetSessionModelRequest,
32
+ type SetSessionModelResponse,
33
+ type SetSessionModeRequest,
34
+ type SetSessionModeResponse,
35
+ type Usage,
23
36
  } from "@agentclientprotocol/sdk";
24
37
  import {
25
38
  type CanUseTool,
26
- type Options,
39
+ getSessionMessages,
40
+ listSessions,
27
41
  type Query,
28
42
  query,
29
- type SDKMessage,
30
43
  type SDKUserMessage,
31
44
  } from "@anthropic-ai/claude-agent-sdk";
32
45
  import { v7 as uuidv7 } from "uuid";
@@ -52,6 +65,7 @@ import {
52
65
  buildSystemPrompt,
53
66
  type ProcessSpawnedInfo,
54
67
  } from "./session/options.js";
68
+ import { SettingsManager } from "./session/settings.js";
55
69
  import {
56
70
  getAvailableModes,
57
71
  TWIG_EXECUTION_MODES,
@@ -65,6 +79,18 @@ import type {
65
79
  } from "./types.js";
66
80
 
67
81
  const SESSION_VALIDATION_TIMEOUT_MS = 10_000;
82
+ const MAX_TITLE_LENGTH = 256;
83
+
84
+ function sanitizeTitle(text: string): string {
85
+ const sanitized = text
86
+ .replace(/[\r\n]+/g, " ")
87
+ .replace(/\s+/g, " ")
88
+ .trim();
89
+ if (sanitized.length <= MAX_TITLE_LENGTH) {
90
+ return sanitized;
91
+ }
92
+ return `${sanitized.slice(0, MAX_TITLE_LENGTH - 1)}…`;
93
+ }
68
94
 
69
95
  export interface ClaudeAcpAgentOptions {
70
96
  onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
@@ -78,7 +104,6 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
78
104
  backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
79
105
  clientCapabilities?: ClientCapabilities;
80
106
  private options?: ClaudeAcpAgentOptions;
81
- private lastSentConfigOptions?: SessionConfigOption[];
82
107
 
83
108
  constructor(client: AgentSideConnection, options?: ClaudeAcpAgentOptions) {
84
109
  super(client);
@@ -102,165 +127,571 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
102
127
  sse: true,
103
128
  },
104
129
  loadSession: true,
130
+ sessionCapabilities: {
131
+ list: {},
132
+ fork: {},
133
+ resume: {},
134
+ },
105
135
  _meta: {
106
136
  posthog: {
107
137
  resumeSession: true,
108
138
  },
139
+ claudeCode: {
140
+ promptQueueing: true,
141
+ },
109
142
  },
110
143
  },
111
144
  agentInfo: {
112
145
  name: packageJson.name,
113
- title: "Claude Code",
146
+ title: "Claude Agent",
114
147
  version: packageJson.version,
115
148
  },
116
- authMethods: [
117
- {
118
- id: "claude-login",
119
- name: "Log in with Claude Code",
120
- description: "Run `claude /login` in the terminal",
121
- },
122
- ],
149
+ authMethods: [],
123
150
  };
124
151
  }
125
152
 
126
- async authenticate(_params: AuthenticateRequest): Promise<void> {
127
- throw new Error("Method not implemented.");
128
- }
129
-
130
153
  async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
131
- this.checkAuthStatus();
154
+ // Upstream Claude Code renames .claude.json to .claude.json.backup on logout.
155
+ // If the backup exists but the original doesn't, the user is logged out.
156
+ if (
157
+ fs.existsSync(path.resolve(os.homedir(), ".claude.json.backup")) &&
158
+ !fs.existsSync(path.resolve(os.homedir(), ".claude.json"))
159
+ ) {
160
+ throw RequestError.authRequired();
161
+ }
132
162
 
133
- const meta = params._meta as NewSessionMeta | undefined;
134
- const taskId = meta?.persistence?.taskId;
135
- const sessionId = uuidv7();
136
- this.logger.info("Creating new session", {
137
- sessionId,
138
- taskId,
139
- taskRunId: meta?.taskRunId,
140
- cwd: params.cwd,
163
+ const response = await this.createSession(params, {
164
+ // Revisit these meta values once we support resume
165
+ resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options
166
+ ?.resume as string | undefined,
141
167
  });
142
- const permissionMode: TwigExecutionMode =
143
- meta?.permissionMode &&
144
- TWIG_EXECUTION_MODES.includes(meta.permissionMode as TwigExecutionMode)
145
- ? (meta.permissionMode as TwigExecutionMode)
146
- : "default";
147
168
 
148
- const mcpServers = parseMcpServers(params);
169
+ return response;
170
+ }
149
171
 
150
- const options = buildSessionOptions({
151
- cwd: params.cwd,
152
- mcpServers,
153
- permissionMode,
154
- canUseTool: this.createCanUseTool(sessionId),
155
- logger: this.logger,
156
- systemPrompt: buildSystemPrompt(meta?.systemPrompt),
157
- userProvidedOptions: meta?.claudeCode?.options,
158
- sessionId,
159
- isResume: false,
160
- onModeChange: this.createOnModeChange(sessionId),
161
- onProcessSpawned: this.options?.onProcessSpawned,
162
- onProcessExited: this.options?.onProcessExited,
163
- });
172
+ async unstable_forkSession(
173
+ params: ForkSessionRequest,
174
+ ): Promise<ForkSessionResponse> {
175
+ return this.createSession(
176
+ {
177
+ cwd: params.cwd,
178
+ mcpServers: params.mcpServers ?? [],
179
+ _meta: params._meta,
180
+ },
181
+ { resume: params.sessionId, forkSession: true },
182
+ );
183
+ }
164
184
 
165
- const input = new Pushable<SDKUserMessage>();
166
- // Pass default model at construction to avoid expensive post-hoc setModel IPC
167
- options.model = DEFAULT_MODEL;
168
- const q = query({ prompt: input, options });
185
+ async unstable_resumeSession(
186
+ params: ResumeSessionRequest,
187
+ ): Promise<ResumeSessionResponse> {
188
+ const response = await this.createSession(
189
+ {
190
+ cwd: params.cwd,
191
+ mcpServers: params.mcpServers ?? [],
192
+ _meta: params._meta,
193
+ },
194
+ {
195
+ resume: params.sessionId,
196
+ },
197
+ );
169
198
 
170
- const session = this.createSession(
171
- sessionId,
172
- q,
173
- input,
174
- permissionMode,
175
- params.cwd,
176
- options.abortController as AbortController,
199
+ return response;
200
+ }
201
+
202
+ async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
203
+ const response = await this.createSession(
204
+ {
205
+ cwd: params.cwd,
206
+ mcpServers: params.mcpServers ?? [],
207
+ _meta: params._meta,
208
+ },
209
+ { resume: params.sessionId, skipBackgroundFetches: true },
177
210
  );
178
- session.taskRunId = meta?.taskRunId;
179
211
 
180
- if (meta?.taskRunId) {
181
- await this.client.extNotification("_posthog/sdk_session", {
182
- taskRunId: meta.taskRunId,
183
- sessionId,
184
- adapter: "claude",
212
+ await this.replaySessionHistory(params.sessionId);
213
+
214
+ // Send available commands after replay so they don't interleave with history
215
+ this.deferBackgroundFetches(this.session.query);
216
+
217
+ return {
218
+ modes: response.modes,
219
+ models: response.models,
220
+ configOptions: response.configOptions,
221
+ };
222
+ }
223
+
224
+ async unstable_listSessions(
225
+ params: ListSessionsRequest,
226
+ ): Promise<ListSessionsResponse> {
227
+ const sdkSessions = await listSessions({ dir: params.cwd ?? undefined });
228
+ const sessions = [];
229
+
230
+ for (const session of sdkSessions) {
231
+ if (!session.cwd) continue;
232
+ sessions.push({
233
+ sessionId: session.sessionId,
234
+ cwd: session.cwd,
235
+ title: sanitizeTitle(session.customTitle || session.summary || ""),
236
+ updatedAt: new Date(session.lastModified).toISOString(),
185
237
  });
186
238
  }
239
+ return {
240
+ sessions,
241
+ };
242
+ }
187
243
 
188
- // Only await model config — slash commands and MCP metadata are deferred
189
- // since they're not needed to return configOptions to the client.
190
- const modelOptions = await this.getModelConfigOptions();
244
+ async prompt(params: PromptRequest): Promise<PromptResponse> {
245
+ this.session.cancelled = false;
246
+ this.session.interruptReason = undefined;
247
+ this.session.accumulatedUsage = {
248
+ inputTokens: 0,
249
+ outputTokens: 0,
250
+ cachedReadTokens: 0,
251
+ cachedWriteTokens: 0,
252
+ };
191
253
 
192
- // Deferred: slash commands + MCP metadata (not needed to return configOptions)
193
- this.deferBackgroundFetches(q, sessionId);
254
+ const userMessage = promptToClaude(params);
194
255
 
195
- session.modelId = modelOptions.currentModelId;
196
- // Only call setModel if the resolved model differs from the default we
197
- // already baked into the query options — avoids a ~2s IPC round-trip.
198
- const resolvedSdkModel = toSdkModelId(modelOptions.currentModelId);
199
- if (resolvedSdkModel !== DEFAULT_MODEL) {
200
- await this.trySetModel(q, modelOptions.currentModelId);
256
+ if (this.session.promptRunning) {
257
+ const uuid = randomUUID();
258
+ userMessage.uuid = uuid;
259
+ this.session.input.push(userMessage);
260
+ const order = this.session.nextPendingOrder++;
261
+ const cancelled = await new Promise<boolean>((resolve) => {
262
+ this.session.pendingMessages.set(uuid, { resolve, order });
263
+ });
264
+ if (cancelled) {
265
+ return { stopReason: "cancelled" };
266
+ }
267
+ } else {
268
+ this.session.input.push(userMessage);
201
269
  }
202
270
 
203
- const configOptions = await this.buildConfigOptions(modelOptions);
271
+ // Broadcast user message to client
272
+ await this.broadcastUserMessage(params);
273
+
274
+ this.session.promptRunning = true;
275
+ let handedOff = false;
276
+ let lastAssistantTotalUsage: number | null = null;
204
277
 
205
- return {
206
- sessionId,
207
- configOptions,
278
+ const supportsTerminalOutput =
279
+ (
280
+ this.clientCapabilities?._meta as
281
+ | ClientCapabilities["_meta"]
282
+ | undefined
283
+ )?.terminal_output === true;
284
+
285
+ const context = {
286
+ session: this.session,
287
+ sessionId: params.sessionId,
288
+ client: this.client,
289
+ toolUseCache: this.toolUseCache,
290
+ fileContentCache: this.fileContentCache,
291
+ logger: this.logger,
292
+ supportsTerminalOutput,
208
293
  };
294
+
295
+ try {
296
+ while (true) {
297
+ const { value: message, done } = await this.session.query.next();
298
+
299
+ if (done || !message) {
300
+ if (this.session.cancelled) {
301
+ return {
302
+ stopReason: "cancelled",
303
+ _meta: this.session.interruptReason
304
+ ? { interruptReason: this.session.interruptReason }
305
+ : undefined,
306
+ };
307
+ }
308
+ break;
309
+ }
310
+
311
+ switch (message.type) {
312
+ case "system":
313
+ if (message.subtype === "compact_boundary") {
314
+ lastAssistantTotalUsage = 0;
315
+ }
316
+ await handleSystemMessage(message, context);
317
+ break;
318
+
319
+ case "result": {
320
+ if (this.session.cancelled) {
321
+ return { stopReason: "cancelled" };
322
+ }
323
+
324
+ // Accumulate usage from this result
325
+ this.session.accumulatedUsage.inputTokens +=
326
+ message.usage.input_tokens;
327
+ this.session.accumulatedUsage.outputTokens +=
328
+ message.usage.output_tokens;
329
+ this.session.accumulatedUsage.cachedReadTokens +=
330
+ message.usage.cache_read_input_tokens;
331
+ this.session.accumulatedUsage.cachedWriteTokens +=
332
+ message.usage.cache_creation_input_tokens;
333
+
334
+ // Calculate context window size from modelUsage (minimum across all models used)
335
+ const contextWindows = Object.values(message.modelUsage).map(
336
+ (m) => m.contextWindow,
337
+ );
338
+ const contextWindowSize =
339
+ contextWindows.length > 0 ? Math.min(...contextWindows) : 200000;
340
+
341
+ // Send usage_update notification
342
+ if (lastAssistantTotalUsage !== null) {
343
+ await this.client.sessionUpdate({
344
+ sessionId: params.sessionId,
345
+ update: {
346
+ sessionUpdate: "usage_update",
347
+ used: lastAssistantTotalUsage as unknown as bigint,
348
+ size: contextWindowSize as unknown as bigint,
349
+ cost: {
350
+ amount: message.total_cost_usd,
351
+ currency: "USD",
352
+ },
353
+ },
354
+ });
355
+ }
356
+
357
+ await this.client.extNotification("_posthog/usage_update", {
358
+ sessionId: params.sessionId,
359
+ used: {
360
+ inputTokens: message.usage.input_tokens,
361
+ outputTokens: message.usage.output_tokens,
362
+ cachedReadTokens: message.usage.cache_read_input_tokens,
363
+ cachedWriteTokens: message.usage.cache_creation_input_tokens,
364
+ },
365
+ cost: message.total_cost_usd,
366
+ });
367
+
368
+ // Build usage for PromptResponse
369
+ // ACP SDK types declare these as bigint but JSON.stringify can't
370
+ // serialize BigInt. Token counts never exceed MAX_SAFE_INTEGER so
371
+ // we pass plain numbers and cast to satisfy the type system.
372
+ const usage = {
373
+ inputTokens: this.session.accumulatedUsage.inputTokens,
374
+ outputTokens: this.session.accumulatedUsage.outputTokens,
375
+ cachedReadTokens: this.session.accumulatedUsage.cachedReadTokens,
376
+ cachedWriteTokens:
377
+ this.session.accumulatedUsage.cachedWriteTokens,
378
+ totalTokens:
379
+ this.session.accumulatedUsage.inputTokens +
380
+ this.session.accumulatedUsage.outputTokens +
381
+ this.session.accumulatedUsage.cachedReadTokens +
382
+ this.session.accumulatedUsage.cachedWriteTokens,
383
+ } as unknown as Usage;
384
+
385
+ const result = handleResultMessage(message);
386
+ if (result.error) throw result.error;
387
+
388
+ switch (message.subtype) {
389
+ case "error_max_budget_usd":
390
+ case "error_max_turns":
391
+ case "error_max_structured_output_retries":
392
+ return { stopReason: "max_turn_requests", usage };
393
+ default:
394
+ return { stopReason: "end_turn", usage };
395
+ }
396
+ }
397
+
398
+ case "stream_event":
399
+ await handleStreamEvent(message, context);
400
+ break;
401
+
402
+ case "user":
403
+ case "assistant": {
404
+ if (this.session.cancelled) {
405
+ break;
406
+ }
407
+
408
+ // Check for queued prompt replay
409
+ if (message.type === "user" && "uuid" in message && message.uuid) {
410
+ const pending = this.session.pendingMessages.get(
411
+ message.uuid as string,
412
+ );
413
+ if (pending) {
414
+ pending.resolve(false);
415
+ this.session.pendingMessages.delete(message.uuid as string);
416
+ handedOff = true;
417
+ // the current loop stops with end_turn,
418
+ // the loop of the next prompt continues running
419
+ return { stopReason: "end_turn" };
420
+ }
421
+ }
422
+
423
+ // Store latest assistant usage (excluding subagents)
424
+ if (
425
+ "usage" in message.message &&
426
+ message.parent_tool_use_id === null
427
+ ) {
428
+ const usage = (
429
+ message.message as unknown as Record<string, unknown>
430
+ ).usage as {
431
+ input_tokens: number;
432
+ output_tokens: number;
433
+ cache_read_input_tokens: number;
434
+ cache_creation_input_tokens: number;
435
+ };
436
+ lastAssistantTotalUsage =
437
+ usage.input_tokens +
438
+ usage.output_tokens +
439
+ usage.cache_read_input_tokens +
440
+ usage.cache_creation_input_tokens;
441
+ }
442
+
443
+ const result = await handleUserAssistantMessage(message, context);
444
+ if (result.error) throw result.error;
445
+ if (result.shouldStop) {
446
+ return { stopReason: "end_turn" };
447
+ }
448
+ break;
449
+ }
450
+
451
+ case "tool_progress":
452
+ case "auth_status":
453
+ case "tool_use_summary":
454
+ break;
455
+
456
+ default:
457
+ unreachable(message as never, this.logger);
458
+ break;
459
+ }
460
+ }
461
+ throw new Error("Session did not end in result");
462
+ } finally {
463
+ if (!handedOff) {
464
+ this.session.promptRunning = false;
465
+ // Resolve all remaining pending prompts so no callers get stuck.
466
+ for (const [key, pending] of this.session.pendingMessages) {
467
+ pending.resolve(true);
468
+ this.session.pendingMessages.delete(key);
469
+ }
470
+ }
471
+ }
209
472
  }
210
473
 
211
- async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
212
- return this.resumeSession(params);
474
+ // Called by BaseAcpAgent#cancel() to interrupt the session
475
+ protected async interrupt(): Promise<void> {
476
+ this.session.cancelled = true;
477
+ for (const [, pending] of this.session.pendingMessages) {
478
+ pending.resolve(true);
479
+ }
480
+ this.session.pendingMessages.clear();
481
+ await this.session.query.interrupt();
482
+ }
483
+
484
+ async unstable_setSessionModel(
485
+ params: SetSessionModelRequest,
486
+ ): Promise<SetSessionModelResponse | undefined> {
487
+ const sdkModelId = toSdkModelId(params.modelId);
488
+ await this.session.query.setModel(sdkModelId);
489
+ this.session.modelId = params.modelId;
490
+ await this.updateConfigOption("model", params.modelId);
491
+ return {};
492
+ }
493
+
494
+ async setSessionMode(
495
+ params: SetSessionModeRequest,
496
+ ): Promise<SetSessionModeResponse> {
497
+ await this.applySessionMode(params.modeId);
498
+ await this.updateConfigOption("mode", params.modeId);
499
+ return {};
213
500
  }
214
501
 
215
- async resumeSession(
216
- params: LoadSessionRequest,
217
- ): Promise<LoadSessionResponse> {
502
+ async setSessionConfigOption(
503
+ params: SetSessionConfigOptionRequest,
504
+ ): Promise<SetSessionConfigOptionResponse> {
505
+ const option = this.session.configOptions.find(
506
+ (o) => o.id === params.configId,
507
+ );
508
+ if (!option) {
509
+ throw new Error(`Unknown config option: ${params.configId}`);
510
+ }
511
+
512
+ const allValues: { value: string }[] =
513
+ "options" in option && Array.isArray(option.options)
514
+ ? (option.options as Array<Record<string, unknown>>).flatMap((o) =>
515
+ "options" in o && Array.isArray(o.options)
516
+ ? (o.options as { value: string }[])
517
+ : [o as { value: string }],
518
+ )
519
+ : [];
520
+ const validValue = allValues.find((o) => o.value === params.value);
521
+ if (!validValue) {
522
+ throw new Error(
523
+ `Invalid value for config option ${params.configId}: ${params.value}`,
524
+ );
525
+ }
526
+
527
+ if (params.configId === "mode") {
528
+ await this.applySessionMode(params.value);
529
+ await this.client.sessionUpdate({
530
+ sessionId: this.sessionId,
531
+ update: {
532
+ sessionUpdate: "current_mode_update",
533
+ currentModeId: params.value,
534
+ },
535
+ });
536
+ } else if (params.configId === "model") {
537
+ const sdkModelId = toSdkModelId(params.value);
538
+ await this.session.query.setModel(sdkModelId);
539
+ this.session.modelId = params.value;
540
+ }
541
+
542
+ this.session.configOptions = this.session.configOptions.map((o) =>
543
+ o.id === params.configId ? { ...o, currentValue: params.value } : o,
544
+ );
545
+
546
+ return { configOptions: this.session.configOptions };
547
+ }
548
+
549
+ private async updateConfigOption(
550
+ configId: string,
551
+ value: string,
552
+ ): Promise<void> {
553
+ this.session.configOptions = this.session.configOptions.map((o) =>
554
+ o.id === configId ? { ...o, currentValue: value } : o,
555
+ );
556
+
557
+ await this.client.sessionUpdate({
558
+ sessionId: this.sessionId,
559
+ update: {
560
+ sessionUpdate: "config_option_update",
561
+ configOptions: this.session.configOptions,
562
+ },
563
+ });
564
+ }
565
+
566
+ private async applySessionMode(modeId: string): Promise<void> {
567
+ if (!TWIG_EXECUTION_MODES.includes(modeId as TwigExecutionMode)) {
568
+ throw new Error("Invalid Mode");
569
+ }
570
+ const previousMode = this.session.permissionMode;
571
+ this.session.permissionMode = modeId as TwigExecutionMode;
572
+ try {
573
+ await this.session.query.setPermissionMode(modeId as TwigExecutionMode);
574
+ } catch (error) {
575
+ this.session.permissionMode = previousMode;
576
+ if (error instanceof Error) {
577
+ if (!error.message) {
578
+ error.message = "Invalid Mode";
579
+ }
580
+ throw error;
581
+ }
582
+ throw new Error("Invalid Mode");
583
+ }
584
+ }
585
+
586
+ private async createSession(
587
+ params: {
588
+ cwd: string;
589
+ mcpServers: NewSessionRequest["mcpServers"];
590
+ _meta?: unknown;
591
+ },
592
+ creationOpts: {
593
+ resume?: string;
594
+ forkSession?: boolean;
595
+ skipBackgroundFetches?: boolean;
596
+ } = {},
597
+ ): Promise<NewSessionResponse> {
598
+ const { cwd } = params;
599
+ const { resume, forkSession } = creationOpts;
600
+
601
+ const isResume = !!resume;
602
+
218
603
  const meta = params._meta as NewSessionMeta | undefined;
219
604
  const taskId = meta?.persistence?.taskId;
220
- const sessionId = meta?.sessionId;
221
- if (!sessionId) {
222
- throw new Error("Cannot resume session without sessionId");
223
- }
224
- if (this.sessionId === sessionId) {
225
- return {};
605
+
606
+ // We want to create a new session id unless it is resume,
607
+ // but not resume + forkSession.
608
+ let sessionId: string;
609
+ if (forkSession) {
610
+ sessionId = uuidv7();
611
+ } else if (isResume) {
612
+ sessionId = resume;
613
+ } else {
614
+ sessionId = uuidv7();
226
615
  }
227
616
 
228
- this.logger.info("Resuming session", {
617
+ const input = new Pushable<SDKUserMessage>();
618
+
619
+ const settingsManager = new SettingsManager(cwd);
620
+ await settingsManager.initialize();
621
+
622
+ const mcpServers = parseMcpServers(params);
623
+ const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
624
+
625
+ this.logger.info(isResume ? "Resuming session" : "Creating new session", {
229
626
  sessionId,
230
627
  taskId,
231
628
  taskRunId: meta?.taskRunId,
232
- cwd: params.cwd,
629
+ cwd,
233
630
  });
234
631
 
235
- const mcpServers = parseMcpServers(params);
236
-
237
632
  const permissionMode: TwigExecutionMode =
238
633
  meta?.permissionMode &&
239
634
  TWIG_EXECUTION_MODES.includes(meta.permissionMode as TwigExecutionMode)
240
635
  ? (meta.permissionMode as TwigExecutionMode)
241
636
  : "default";
242
637
 
243
- const { query: q, session } = await this.initializeQuery({
244
- cwd: params.cwd,
245
- permissionMode,
638
+ const options = buildSessionOptions({
639
+ cwd,
246
640
  mcpServers,
247
- systemPrompt: buildSystemPrompt(meta?.systemPrompt),
641
+ permissionMode,
642
+ canUseTool: this.createCanUseTool(sessionId),
643
+ logger: this.logger,
644
+ systemPrompt,
248
645
  userProvidedOptions: meta?.claudeCode?.options,
249
646
  sessionId,
250
- isResume: true,
647
+ isResume,
648
+ forkSession,
251
649
  additionalDirectories: meta?.claudeCode?.options?.additionalDirectories,
650
+ disableBuiltInTools: meta?.disableBuiltInTools,
651
+ settingsManager,
652
+ onModeChange: this.createOnModeChange(),
653
+ onProcessSpawned: this.options?.onProcessSpawned,
654
+ onProcessExited: this.options?.onProcessExited,
252
655
  });
253
656
 
254
- this.logger.info("Session query initialized, awaiting resumption", {
255
- sessionId,
256
- taskId,
657
+ // Use the same abort controller that buildSessionOptions gave to the query
658
+ const abortController = options.abortController as AbortController;
659
+
660
+ const q = query({ prompt: input, options });
661
+
662
+ const session: Session = {
663
+ query: q,
664
+ input,
665
+ cancelled: false,
666
+ settingsManager,
667
+ permissionMode,
668
+ abortController,
669
+ accumulatedUsage: {
670
+ inputTokens: 0,
671
+ outputTokens: 0,
672
+ cachedReadTokens: 0,
673
+ cachedWriteTokens: 0,
674
+ },
675
+ configOptions: [],
676
+ promptRunning: false,
677
+ pendingMessages: new Map(),
678
+ nextPendingOrder: 0,
679
+
680
+ // Custom properties
681
+ cwd,
682
+ notificationHistory: [],
257
683
  taskRunId: meta?.taskRunId,
258
- });
684
+ };
685
+ this.session = session;
686
+ this.sessionId = sessionId;
259
687
 
260
- session.taskRunId = meta?.taskRunId;
688
+ this.logger.info(
689
+ isResume
690
+ ? "Session query initialized, awaiting resumption"
691
+ : "Session query initialized, awaiting initialization",
692
+ { sessionId, taskId, taskRunId: meta?.taskRunId },
693
+ );
261
694
 
262
- // Check the resumed session is alive. For stale sessions this throws
263
- // (e.g. "No conversation found"), preventing a broken session.
264
695
  try {
265
696
  const result = await withTimeout(
266
697
  q.initializationResult(),
@@ -268,306 +699,219 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
268
699
  );
269
700
  if (result.result === "timeout") {
270
701
  throw new Error(
271
- `Session resumption timed out for sessionId=${sessionId}`,
702
+ `Session ${isResume ? (forkSession ? "fork" : "resumption") : "initialization"} timed out for sessionId=${sessionId}`,
272
703
  );
273
704
  }
274
705
  } catch (err) {
275
- this.logger.error("Session resumption failed", {
276
- sessionId,
277
- taskId,
278
- taskRunId: meta?.taskRunId,
279
- error: err instanceof Error ? err.message : String(err),
280
- });
706
+ settingsManager.dispose();
707
+ this.logger.error(
708
+ isResume
709
+ ? forkSession
710
+ ? "Session fork failed"
711
+ : "Session resumption failed"
712
+ : "Session initialization failed",
713
+ {
714
+ sessionId,
715
+ taskId,
716
+ taskRunId: meta?.taskRunId,
717
+ error: err instanceof Error ? err.message : String(err),
718
+ },
719
+ );
281
720
  throw err;
282
721
  }
283
722
 
284
- this.logger.info("Session resumed successfully", {
285
- sessionId,
286
- taskId,
287
- taskRunId: meta?.taskRunId,
288
- });
289
-
290
- // Deferred: slash commands + MCP metadata (not needed to return configOptions)
291
- this.deferBackgroundFetches(q, sessionId);
292
-
293
- const configOptions = await this.buildConfigOptions();
294
-
295
- return { configOptions };
296
- }
297
-
298
- async prompt(params: PromptRequest): Promise<PromptResponse> {
299
- this.session.cancelled = false;
300
- this.session.interruptReason = undefined;
301
-
302
- await this.broadcastUserMessage(params);
303
- this.session.input.push(promptToClaude(params));
304
-
305
- return this.processMessages(params.sessionId);
306
- }
307
-
308
- async setSessionConfigOption(
309
- params: SetSessionConfigOptionRequest,
310
- ): Promise<SetSessionConfigOptionResponse> {
311
- const configId = params.configId;
312
- const value = params.value;
313
-
314
- if (configId === "mode") {
315
- const modeId = value as TwigExecutionMode;
316
- if (!TWIG_EXECUTION_MODES.includes(modeId)) {
317
- throw new Error("Invalid Mode");
318
- }
319
- this.session.permissionMode = modeId;
320
- await this.session.query.setPermissionMode(modeId);
321
- } else if (configId === "model") {
322
- await this.setModelWithFallback(this.session.query, value);
323
- this.session.modelId = value;
324
- } else {
325
- throw new Error("Unsupported config option");
723
+ if (meta?.taskRunId) {
724
+ await this.client.extNotification("_posthog/sdk_session", {
725
+ taskRunId: meta.taskRunId,
726
+ sessionId,
727
+ adapter: "claude",
728
+ });
326
729
  }
327
730
 
328
- await this.emitConfigOptionsUpdate();
329
- return { configOptions: await this.buildConfigOptions() };
330
- }
331
-
332
- protected async interruptSession(): Promise<void> {
333
- await this.session.query.interrupt();
334
- }
731
+ // Resolve model: settings model takes priority, then gateway
732
+ const settingsModel = settingsManager.getSettings().model;
733
+ const modelOptions = await this.getModelConfigOptions();
734
+ const resolvedModelId = settingsModel || modelOptions.currentModelId;
735
+ session.modelId = resolvedModelId;
335
736
 
336
- async extMethod(
337
- method: string,
338
- params: Record<string, unknown>,
339
- ): Promise<Record<string, unknown>> {
340
- if (method === "_posthog/session/resume") {
341
- const result = await this.resumeSession(
342
- params as unknown as LoadSessionRequest,
343
- );
344
- return {
345
- _meta: {
346
- configOptions: result.configOptions,
347
- },
348
- };
737
+ if (!isResume) {
738
+ const resolvedSdkModel = toSdkModelId(resolvedModelId);
739
+ if (resolvedSdkModel !== DEFAULT_MODEL) {
740
+ await this.session.query.setModel(resolvedSdkModel);
741
+ }
349
742
  }
350
743
 
351
- throw RequestError.methodNotFound(method);
352
- }
353
-
354
- private createSession(
355
- sessionId: string,
356
- q: Query,
357
- input: Pushable<SDKUserMessage>,
358
- permissionMode: TwigExecutionMode,
359
- cwd: string,
360
- abortController: AbortController,
361
- ): Session {
362
- const session: Session = {
363
- query: q,
364
- input,
365
- cancelled: false,
366
- permissionMode,
367
- cwd,
368
- notificationHistory: [],
369
- abortController,
744
+ const availableModes = getAvailableModes();
745
+ const modes: SessionModeState = {
746
+ currentModeId: permissionMode,
747
+ availableModes: availableModes.map((mode) => ({
748
+ id: mode.id,
749
+ name: mode.name,
750
+ description: mode.description ?? undefined,
751
+ })),
370
752
  };
371
- this.session = session;
372
- this.sessionId = sessionId;
373
- return session;
374
- }
375
753
 
376
- private async initializeQuery(config: {
377
- cwd: string;
378
- permissionMode: TwigExecutionMode;
379
- mcpServers: ReturnType<typeof parseMcpServers>;
380
- userProvidedOptions?: Options;
381
- systemPrompt?: Options["systemPrompt"];
382
- sessionId: string;
383
- isResume: boolean;
384
- additionalDirectories?: string[];
385
- }): Promise<{
386
- query: Query;
387
- input: Pushable<SDKUserMessage>;
388
- session: Session;
389
- }> {
390
- const input = new Pushable<SDKUserMessage>();
754
+ const models: SessionModelState = {
755
+ currentModelId: resolvedModelId,
756
+ availableModels: modelOptions.options.map(
757
+ (opt): AcpModelInfo => ({
758
+ modelId: opt.value,
759
+ name: opt.name,
760
+ description: opt.description,
761
+ }),
762
+ ),
763
+ };
391
764
 
392
- const options = buildSessionOptions({
393
- cwd: config.cwd,
394
- mcpServers: config.mcpServers,
395
- permissionMode: config.permissionMode,
396
- canUseTool: this.createCanUseTool(config.sessionId),
397
- logger: this.logger,
398
- systemPrompt: config.systemPrompt,
399
- userProvidedOptions: config.userProvidedOptions,
400
- sessionId: config.sessionId,
401
- isResume: config.isResume,
402
- additionalDirectories: config.additionalDirectories,
403
- onModeChange: this.createOnModeChange(config.sessionId),
404
- onProcessSpawned: this.options?.onProcessSpawned,
405
- onProcessExited: this.options?.onProcessExited,
406
- });
765
+ const configOptions = this.buildConfigOptions(permissionMode, modelOptions);
766
+ session.configOptions = configOptions;
407
767
 
408
- const q = query({ prompt: input, options });
409
- const abortController = options.abortController as AbortController;
768
+ if (!creationOpts.skipBackgroundFetches) {
769
+ this.deferBackgroundFetches(q);
770
+ }
410
771
 
411
- const session = this.createSession(
412
- config.sessionId,
413
- q,
414
- input,
415
- config.permissionMode,
416
- config.cwd,
417
- abortController,
772
+ this.logger.info(
773
+ isResume
774
+ ? "Session resumed successfully"
775
+ : "Session created successfully",
776
+ {
777
+ sessionId,
778
+ taskId,
779
+ taskRunId: meta?.taskRunId,
780
+ },
418
781
  );
419
782
 
420
- return { query: q, input, session };
783
+ return { sessionId, modes, models, configOptions };
421
784
  }
422
785
 
423
786
  private createCanUseTool(sessionId: string): CanUseTool {
424
- return async (toolName, toolInput, { suggestions, toolUseID }) =>
787
+ return async (toolName, toolInput, { suggestions, toolUseID, signal }) =>
425
788
  canUseTool({
426
789
  session: this.session,
427
790
  toolName,
428
791
  toolInput: toolInput as Record<string, unknown>,
429
792
  toolUseID,
430
793
  suggestions,
794
+ signal,
431
795
  client: this.client,
432
796
  sessionId,
433
797
  fileContentCache: this.fileContentCache,
434
798
  logger: this.logger,
435
- emitConfigOptionsUpdate: () => this.emitConfigOptionsUpdate(sessionId),
799
+ updateConfigOption: (configId: string, value: string) =>
800
+ this.updateConfigOption(configId, value),
436
801
  });
437
802
  }
438
803
 
439
- private createOnModeChange(sessionId: string) {
804
+ private createOnModeChange() {
440
805
  return async (newMode: TwigExecutionMode) => {
441
806
  if (this.session) {
442
807
  this.session.permissionMode = newMode;
443
808
  }
444
- await this.emitConfigOptionsUpdate(sessionId);
809
+ await this.updateConfigOption("mode", newMode);
445
810
  };
446
811
  }
447
812
 
448
- private async buildConfigOptions(modelOptionsOverride?: {
449
- currentModelId: string;
450
- options: SessionConfigSelectOption[];
451
- }): Promise<SessionConfigOption[]> {
452
- const options: SessionConfigOption[] = [];
453
-
813
+ private buildConfigOptions(
814
+ currentModeId: string,
815
+ modelOptions: {
816
+ currentModelId: string;
817
+ options: SessionConfigSelectOption[];
818
+ },
819
+ ): SessionConfigOption[] {
454
820
  const modeOptions = getAvailableModes().map((mode) => ({
455
821
  value: mode.id,
456
822
  name: mode.name,
457
823
  description: mode.description ?? undefined,
458
824
  }));
459
825
 
460
- options.push({
461
- id: "mode",
462
- name: "Approval Preset",
463
- type: "select",
464
- currentValue: this.session.permissionMode,
465
- options: modeOptions,
466
- category: "mode" as SessionConfigOptionCategory,
467
- description: "Choose an approval and sandboxing preset for your session",
468
- });
469
-
470
- const modelOptions =
471
- modelOptionsOverride ??
472
- (await this.getModelConfigOptions(this.session.modelId));
473
- this.session.modelId = modelOptions.currentModelId;
474
-
475
- options.push({
476
- id: "model",
477
- name: "Model",
478
- type: "select",
479
- currentValue: modelOptions.currentModelId,
480
- options: modelOptions.options,
481
- category: "model" as SessionConfigOptionCategory,
482
- description: "Choose which model Claude should use",
483
- });
484
-
485
- return options;
826
+ return [
827
+ {
828
+ id: "mode",
829
+ name: "Approval Preset",
830
+ type: "select",
831
+ currentValue: currentModeId,
832
+ options: modeOptions,
833
+ category: "mode" as SessionConfigOptionCategory,
834
+ description:
835
+ "Choose an approval and sandboxing preset for your session",
836
+ },
837
+ {
838
+ id: "model",
839
+ name: "Model",
840
+ type: "select",
841
+ currentValue: modelOptions.currentModelId,
842
+ options: modelOptions.options,
843
+ category: "model" as SessionConfigOptionCategory,
844
+ description: "Choose which model Claude should use",
845
+ },
846
+ ];
486
847
  }
487
848
 
488
- private async emitConfigOptionsUpdate(sessionId?: string): Promise<void> {
489
- const configOptions = await this.buildConfigOptions();
490
- const serialized = JSON.stringify(configOptions);
491
- if (
492
- this.lastSentConfigOptions &&
493
- JSON.stringify(this.lastSentConfigOptions) === serialized
494
- ) {
495
- return;
496
- }
497
-
498
- this.lastSentConfigOptions = configOptions;
849
+ private async sendAvailableCommandsUpdate(): Promise<void> {
850
+ const commands = await this.session.query.supportedCommands();
499
851
  await this.client.sessionUpdate({
500
- sessionId: sessionId ?? this.sessionId,
852
+ sessionId: this.sessionId,
501
853
  update: {
502
- sessionUpdate: "config_option_update",
503
- configOptions,
854
+ sessionUpdate: "available_commands_update",
855
+ availableCommands: getAvailableSlashCommands(commands),
504
856
  },
505
857
  });
506
858
  }
507
859
 
508
- private checkAuthStatus() {
509
- const backupExists = fs.existsSync(
510
- path.resolve(os.homedir(), ".claude.json.backup"),
511
- );
512
- const configExists = fs.existsSync(
513
- path.resolve(os.homedir(), ".claude.json"),
514
- );
515
- if (backupExists && !configExists) {
516
- throw RequestError.authRequired();
517
- }
518
- }
519
-
520
- private async trySetModel(q: Query, modelId: string) {
860
+ private async replaySessionHistory(sessionId: string): Promise<void> {
521
861
  try {
522
- await this.setModelWithFallback(q, modelId);
523
- } catch (err) {
524
- this.logger.warn("Failed to set model", { modelId, error: err });
525
- }
526
- }
862
+ const messages = await getSessionMessages(sessionId, {
863
+ dir: this.session.cwd,
864
+ });
527
865
 
528
- private async setModelWithFallback(q: Query, modelId: string): Promise<void> {
529
- const sdkModelId = toSdkModelId(modelId);
530
- try {
531
- await q.setModel(sdkModelId);
532
- } catch (err) {
533
- if (sdkModelId === modelId) {
534
- throw err;
866
+ const replayContext = {
867
+ session: this.session,
868
+ sessionId,
869
+ client: this.client,
870
+ toolUseCache: this.toolUseCache,
871
+ fileContentCache: this.fileContentCache,
872
+ logger: this.logger,
873
+ registerHooks: false,
874
+ };
875
+
876
+ for (const msg of messages) {
877
+ const sdkMessage = {
878
+ type: msg.type,
879
+ message: msg.message as {
880
+ content: string | Array<{ type: string; text?: string }>;
881
+ role: typeof msg.type;
882
+ },
883
+ parent_tool_use_id: msg.parent_tool_use_id,
884
+ };
885
+ await handleUserAssistantMessage(
886
+ sdkMessage as Parameters<typeof handleUserAssistantMessage>[0],
887
+ replayContext,
888
+ );
535
889
  }
536
- // Fallback to raw gateway ID if SDK model ID failed
537
- await q.setModel(modelId);
890
+ } catch (err) {
891
+ this.logger.warn("Failed to replay session history", {
892
+ sessionId,
893
+ error: err instanceof Error ? err.message : String(err),
894
+ });
538
895
  }
539
896
  }
540
897
 
898
+ // ================================
899
+ // EXTENSION METHODS
900
+ // ================================
901
+
541
902
  /**
542
903
  * Fire-and-forget: fetch slash commands and MCP tool metadata in parallel.
543
904
  * Both populate caches used later — neither is needed to return configOptions.
544
905
  */
545
- private deferBackgroundFetches(q: Query, sessionId: string): void {
906
+ private deferBackgroundFetches(q: Query): void {
546
907
  Promise.all([
547
- getAvailableSlashCommands(q),
908
+ new Promise<void>((resolve) => setTimeout(resolve, 10)).then(() =>
909
+ this.sendAvailableCommandsUpdate(),
910
+ ),
548
911
  fetchMcpToolMetadata(q, this.logger),
549
- ])
550
- .then(([slashCommands]) => {
551
- this.sendAvailableCommandsUpdate(sessionId, slashCommands);
552
- })
553
- .catch((err) => {
554
- this.logger.warn("Failed to fetch deferred session data", { err });
555
- });
556
- }
557
-
558
- private sendAvailableCommandsUpdate(
559
- sessionId: string,
560
- availableCommands: AvailableCommand[],
561
- ) {
562
- setTimeout(() => {
563
- this.client.sessionUpdate({
564
- sessionId,
565
- update: {
566
- sessionUpdate: "available_commands_update",
567
- availableCommands,
568
- },
569
- });
570
- }, 0);
912
+ ]).catch((err) =>
913
+ this.logger.error("Background fetch failed", { error: err }),
914
+ );
571
915
  }
572
916
 
573
917
  private async broadcastUserMessage(params: PromptRequest): Promise<void> {
@@ -583,87 +927,4 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
583
927
  this.appendNotification(params.sessionId, notification);
584
928
  }
585
929
  }
586
-
587
- private async processMessages(sessionId: string): Promise<PromptResponse> {
588
- const context = {
589
- session: this.session,
590
- sessionId,
591
- client: this.client,
592
- toolUseCache: this.toolUseCache,
593
- fileContentCache: this.fileContentCache,
594
- logger: this.logger,
595
- };
596
-
597
- while (true) {
598
- const { value: message, done } = await this.session.query.next();
599
-
600
- if (done || !message) {
601
- return this.handleSessionEnd();
602
- }
603
-
604
- const response = await this.handleMessage(message, context);
605
- if (response) {
606
- return response;
607
- }
608
- }
609
- }
610
-
611
- private handleSessionEnd(): PromptResponse {
612
- if (this.session.cancelled) {
613
- return {
614
- stopReason: "cancelled",
615
- _meta: this.session.interruptReason
616
- ? { interruptReason: this.session.interruptReason }
617
- : undefined,
618
- };
619
- }
620
- throw new Error("Session did not end in result");
621
- }
622
-
623
- private async handleMessage(
624
- message: SDKMessage,
625
- context: Parameters<typeof handleSystemMessage>[1],
626
- ): Promise<PromptResponse | null> {
627
- switch (message.type) {
628
- case "system":
629
- await handleSystemMessage(message, context);
630
- return null;
631
-
632
- case "result": {
633
- const result = handleResultMessage(message, context);
634
- if (result.error) throw result.error;
635
- if (result.shouldStop) {
636
- return {
637
- stopReason: result.stopReason as "end_turn" | "max_turn_requests",
638
- };
639
- }
640
- return null;
641
- }
642
-
643
- case "stream_event":
644
- await handleStreamEvent(message, context);
645
- return null;
646
-
647
- case "user":
648
- case "assistant": {
649
- const result = await handleUserAssistantMessage(message, context);
650
- if (result.error) throw result.error;
651
- if (result.shouldStop) {
652
- return { stopReason: "end_turn" };
653
- }
654
- return null;
655
- }
656
-
657
- case "tool_progress":
658
- case "auth_status":
659
- case "tool_use_summary":
660
- return null;
661
-
662
- default:
663
- // SDKMessage union includes undefined types (SDKRateLimitEvent, SDKPromptSuggestionMessage)
664
- // that resolve to `any`, preventing exhaustive narrowing
665
- unreachable(message as never, this.logger);
666
- return null;
667
- }
668
- }
669
930
  }