@posthog/agent 2.3.727 → 2.3.736

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 (63) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +2 -10
  2. package/dist/adapters/claude/conversion/tool-use-to-acp.js +24 -10
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  4. package/dist/adapters/claude/permissions/permission-options.js +6 -4
  5. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  6. package/dist/adapters/claude/session/jsonl-hydration.js +1 -0
  7. package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -1
  8. package/dist/adapters/claude/session/models.js +20 -1
  9. package/dist/adapters/claude/session/models.js.map +1 -1
  10. package/dist/adapters/claude/tools.js +4 -2
  11. package/dist/adapters/claude/tools.js.map +1 -1
  12. package/dist/adapters/reasoning-effort.js +3 -1
  13. package/dist/adapters/reasoning-effort.js.map +1 -1
  14. package/dist/agent.js +544 -74
  15. package/dist/agent.js.map +1 -1
  16. package/dist/posthog-api.js +4 -4
  17. package/dist/posthog-api.js.map +1 -1
  18. package/dist/server/agent-server.js +544 -74
  19. package/dist/server/agent-server.js.map +1 -1
  20. package/dist/server/bin.cjs +544 -74
  21. package/dist/server/bin.cjs.map +1 -1
  22. package/package.json +4 -4
  23. package/src/adapters/claude/UPSTREAM.md +81 -3
  24. package/src/adapters/claude/claude-agent.slash-command.test.ts +22 -2
  25. package/src/adapters/claude/claude-agent.ts +232 -19
  26. package/src/adapters/claude/conversion/sdk-to-acp.ts +182 -16
  27. package/src/adapters/claude/conversion/task-state.test.ts +338 -0
  28. package/src/adapters/claude/conversion/task-state.ts +178 -0
  29. package/src/adapters/claude/conversion/tool-use-to-acp.ts +29 -19
  30. package/src/adapters/claude/hooks.test.ts +162 -0
  31. package/src/adapters/claude/hooks.ts +44 -3
  32. package/src/adapters/claude/permissions/permission-options.ts +7 -2
  33. package/src/adapters/claude/session/commands.ts +1 -0
  34. package/src/adapters/claude/session/mcp-config.ts +23 -7
  35. package/src/adapters/claude/session/model-config.test.ts +60 -0
  36. package/src/adapters/claude/session/model-config.ts +56 -0
  37. package/src/adapters/claude/session/models.test.ts +119 -0
  38. package/src/adapters/claude/session/models.ts +31 -0
  39. package/src/adapters/claude/session/options.test.ts +1 -0
  40. package/src/adapters/claude/session/options.ts +29 -1
  41. package/src/adapters/claude/session/settings.test.ts +102 -1
  42. package/src/adapters/claude/session/settings.ts +33 -6
  43. package/src/adapters/claude/tools.ts +4 -2
  44. package/src/adapters/claude/types.ts +15 -0
  45. package/src/adapters/codex/codex-agent.ts +2 -2
  46. package/src/server/agent-server.test.ts +173 -0
  47. package/src/test/mocks/claude-sdk.ts +4 -0
  48. package/src/test/native-binary.test.ts +27 -0
  49. package/dist/claude-cli/cli.js +0 -18412
  50. package/dist/claude-cli/vendor/audio-capture/arm64-darwin/audio-capture.node +0 -0
  51. package/dist/claude-cli/vendor/audio-capture/arm64-linux/audio-capture.node +0 -0
  52. package/dist/claude-cli/vendor/audio-capture/arm64-win32/audio-capture.node +0 -0
  53. package/dist/claude-cli/vendor/audio-capture/x64-darwin/audio-capture.node +0 -0
  54. package/dist/claude-cli/vendor/audio-capture/x64-linux/audio-capture.node +0 -0
  55. package/dist/claude-cli/vendor/audio-capture/x64-win32/audio-capture.node +0 -0
  56. package/dist/claude-cli/vendor/ripgrep/COPYING +0 -3
  57. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  58. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  59. package/dist/claude-cli/vendor/ripgrep/arm64-win32/rg.exe +0 -0
  60. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  61. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  62. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  63. package/dist/claude-cli/yoga.wasm +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.727",
3
+ "version": "2.3.736",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -111,9 +111,9 @@
111
111
  "@posthog/enricher": "1.0.0"
112
112
  },
113
113
  "dependencies": {
114
- "@agentclientprotocol/sdk": "0.19.0",
115
- "@anthropic-ai/claude-agent-sdk": "0.2.112",
116
- "@anthropic-ai/sdk": "0.89.0",
114
+ "@agentclientprotocol/sdk": "0.22.1",
115
+ "@anthropic-ai/claude-agent-sdk": "0.3.154",
116
+ "@anthropic-ai/sdk": "0.100.0",
117
117
  "@hono/node-server": "^1.19.9",
118
118
  "@opentelemetry/api-logs": "^0.208.0",
119
119
  "@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
@@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
5
5
  ## Fork Point
6
6
 
7
7
  - **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025
8
- - **Last sync**: v0.30.0, commit `e9dd452`, April 20 2026
9
- - **SDK**: `@anthropic-ai/claude-agent-sdk` 0.2.112 (0.2.114 breaks session init, see agentclientprotocol/claude-agent-acp#575), `@agentclientprotocol/sdk` 0.19.0
8
+ - **Last sync**: v0.38.0 + #716, commit `61ebda2`, May 28 2026
9
+ - **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.154, `@agentclientprotocol/sdk` 0.22.1, `@anthropic-ai/sdk` 0.100.0
10
10
 
11
11
  ## File Mapping
12
12
 
@@ -53,6 +53,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
53
53
  | Auth methods | `claude-ai-login` + `console-login` | Returns empty `authMethods` | Auth handled externally |
54
54
  | Session fingerprinting | Implicit teardown on cwd/mcp change | Explicit `refreshSession()` | Caller-initiated is more predictable |
55
55
  | Shutdown on ACP close | Process exits | No standalone process | Agent is embedded in server |
56
+ | Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. |
56
57
 
57
58
  ## Changes Ported in v0.30.0 Sync
58
59
 
@@ -66,6 +67,83 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
66
67
  - **Effort level sync** (v0.25.x): `xhigh` level added, `applyFlagSettings` on effort change
67
68
  - **Auto permission mode** (v0.25.0): Added to `CODE_EXECUTION_MODES`, available modes, ExitPlanMode options
68
69
 
70
+ ## Changes Ported in v0.38.0 Sync
71
+
72
+ - **SDK bumps**: claude-agent-sdk 0.3.144 -> 0.3.154, anthropic SDK 0.96.0 -> 0.100.0 (ACP SDK
73
+ unchanged at 0.22.1).
74
+ - **Compaction state-flag fix** (#716, a172885): SDK 0.3.154 emits the terminal `status` carrying
75
+ `compact_result` twice for failed compactions. Added a per-turn `compactionInProgress` flag in
76
+ `prompt()` so the user sees a single `Compacting completed.` / `Compacting failed: <reason>`
77
+ chunk. Manual `/compact` outcomes now surface here rather than via `compact_boundary` (which only
78
+ fires when there's content to compact).
79
+ - **System-role guard on user/assistant handler** (#716, a172885): Added an early return in
80
+ `handleUserAssistantMessage` for `message.message.role === "system"`, covering both upstream's
81
+ `<local-command-stdout>` strip branch guard and the broader assistant-handler guard. Avoids
82
+ rendering SDK-injected system reminders as user-visible chunks.
83
+ - **New no-op content block types** (#716, a172885): Added `advisor_tool_result` and
84
+ `mid_conv_system` cases to `processContentChunk` so unknown content blocks don't trip the
85
+ `unreachable` default.
86
+ - **Opus 4.8 model entries** (#718, 98b54a0): Added `claude-opus-4-8` to gateway model maps with
87
+ 1M context, effort and xhigh-effort support. MCP injection auto-included (Haiku exclusion only).
88
+
89
+ ## Skipped in v0.38.0 Sync
90
+
91
+ - **Remove hide Claude auth flag** (#707, 7ed1daf): Our fork already returns `authMethods: []`
92
+ unconditionally; no flag to remove.
93
+ - **`thinking_tokens` status case** (#716, a172885): Our `handleSystemMessage` switch on
94
+ `status === "compacting"` is non-exhaustive (no default `unreachable`), so unknown status values
95
+ already no-op harmlessly.
96
+ - **Empty CI-retry commit** (#718, 98b54a0): No code change in the commit itself; the model entries
97
+ it carried are ported above.
98
+ - **`MessageDisplay` hook + `SessionStart` reloadSkills/sessionTitle** (SDK 0.3.152): Available in
99
+ the bumped SDK but not wired into our fork; upstream doesn't consume them in #716 either. Defer
100
+ to a focused PR if we want the capability.
101
+
102
+ ## Changes Ported in v0.37.0 Sync
103
+
104
+ - **SDK bumps**: claude-agent-sdk 0.2.114 -> 0.3.144, ACP SDK 0.19.0 -> 0.22.1, anthropic SDK 0.89.0 -> 0.96.0
105
+ - **TodoWrite -> Task tools migration** (SDK 0.3.142): Replaced TodoWrite snapshot tool with incremental
106
+ TaskCreate/TaskUpdate/TaskGet/TaskList. Added `conversion/task-state.ts` and `createTaskHook` to mirror the
107
+ SDK `TaskCreated`/`TaskCompleted` hook events into a per-session task map; plan entries are derived from
108
+ Map insertion order (preserves upstream ordering semantics).
109
+ - **MCP_CONNECTION_NONBLOCKING=0** (SDK 0.3.142): SDK changed MCP servers to background-connect by default;
110
+ set env to restore blocking-connect behavior so MCP tools are available on first prompt.
111
+ - **ACP SDK 0.22 breaking changes**: Renamed `unstable_resumeSession` -> `resumeSession`; new
112
+ `McpSdkServerConfig` variant (`type: "sdk"`) in the `McpServerConfig` union. Our
113
+ `parseMcpServers` only accepts `http`/`sse`/stdio entries, so `sdk` falls through and is
114
+ implicitly dropped (no explicit filter needed).
115
+ - **Skills option** (SDK 0.2.133): `'Skill'` in `allowedTools` deprecated; replaced with `skills` option.
116
+ - **Memory recall tool calls** (#703, a0bfb98): Emit a `tool_call` for SDK `memory_recall` events so the
117
+ UI shows what memories were surfaced; addresses phantom MEMORY.md read attempts.
118
+ - **Write diff fix** (#618, 8d7e220): `toolUpdateFromEditToolResponse` now also processes `Write` tool
119
+ responses so overwrites show real diffs instead of optimistic "creation" diffs.
120
+ - **Local-command-stdout render** (#649, 3b9b7d5): Strip marker tags from `<local-command-stdout>` content
121
+ and render remaining prose so custom slash commands and skill expansions reach the UI.
122
+ - **Cancelled vs end_turn** (#694, 2414a6f): `session_state_changed: idle` handler now reports
123
+ `stopReason: "cancelled"` when the session was interrupted.
124
+ - **Recover prompt stream** (#706, 2711f50): After a failed turn, drain the trailing
125
+ `session_state_changed: idle` so the next prompt's first `query.next()` doesn't short-circuit.
126
+ - **additionalDirectories field** (#684, f37e9a0): Accept the official ACP field on session lifecycle
127
+ requests; advertise via `sessionCapabilities.additionalDirectories`. Legacy `_meta.additionalRoots` still
128
+ honored as fallback.
129
+ - **availableModels allowlist** (#637, 867a3a0): `ClaudeCodeSettings.availableModels` array merged-and-deduped
130
+ across settings sources, then applied to gateway model options via `applyAvailableModelsAllowlist`.
131
+ - **Model alias version match** (#702, e1e1c69): Refuse cross-version alias matches in `resolveModelPreference`
132
+ so `claude-opus-4-6` doesn't get copied onto the `opus` alias when it resolves to 4.7.
133
+ - **Hide /clear** (#705, cfce130): `/clear` removed from advertised commands; clients should use
134
+ `session/new` for the same effect.
135
+ - **No-op ping events** (#698, 694221a): `streamEventToAcpNotifications` no-ops `ping` keep-alive events
136
+ instead of falling through to `unreachable` and spamming stderr.
137
+
138
+ ## Skipped in v0.37.0 Sync
139
+
140
+ - **Avoid redundant initial model sync** (#704, b275f6f): Our flow already guards `setModel` behind
141
+ `!isResume && resolvedSdkModel !== DEFAULT_MODEL`, so the upstream optimization is redundant.
142
+ - **Default effort option** (#701, 9e259d1): Our effort options are model-class-based rather than
143
+ SDK-supplied; the implicit no-override path already covers the "let SDK decide" case.
144
+ - **Gate auto mode on model support** (#604, ec47d34): Our `auto` mode is gated behind `ALLOW_BYPASS`,
145
+ not per-model `supportsAutoMode`. Per-model gating would be a larger refactor.
146
+
69
147
  ## Skipped in v0.30.0 Sync
70
148
 
71
149
  - **Separate auth methods** (v0.25.0): PostHog returns empty authMethods
@@ -74,7 +152,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
74
152
 
75
153
  ## Next Sync
76
154
 
77
- 1. Check upstream changelog since v0.30.0
155
+ 1. Check upstream changelog since v0.37.0
78
156
  2. Diff upstream source against PostHog Code using the file mapping above
79
157
  3. Port in phases: bug fixes first, then features
80
158
  4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint`
@@ -34,7 +34,11 @@ function makeAgent(): { agent: Agent; client: ClientMocks } {
34
34
  return { agent, client };
35
35
  }
36
36
 
37
- function installFakeSession(agent: Agent, sessionId: string): MockQuery {
37
+ function installFakeSession(
38
+ agent: Agent,
39
+ sessionId: string,
40
+ knownSlashCommands?: Set<string>,
41
+ ): MockQuery {
38
42
  const query = createMockQuery();
39
43
  const input = new Pushable();
40
44
  const abortController = new AbortController();
@@ -63,6 +67,7 @@ function installFakeSession(agent: Agent, sessionId: string): MockQuery {
63
67
  taskRunId: "run-1",
64
68
  lastContextWindowSize: 200_000,
65
69
  modelId: "claude-sonnet-4-6",
70
+ knownSlashCommands,
66
71
  };
67
72
 
68
73
  (agent as unknown as { session: typeof session }).session = session;
@@ -99,6 +104,7 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
99
104
  label: "unsupported slash command surfaces error and ends turn",
100
105
  sessionId: "s-slash",
101
106
  prompt: "/plugin install slack",
107
+ knownCommands: undefined,
102
108
  expectsUnsupportedChunk: true,
103
109
  commandInMessage: "/plugin",
104
110
  },
@@ -106,6 +112,16 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
106
112
  label: "non-slash prompt with early idle is silently skipped",
107
113
  sessionId: "s-regular",
108
114
  prompt: "hello",
115
+ knownCommands: undefined,
116
+ expectsUnsupportedChunk: false,
117
+ commandInMessage: null,
118
+ },
119
+ {
120
+ label:
121
+ "known plugin/skill command with early idle is not flagged as unsupported",
122
+ sessionId: "s-skill",
123
+ prompt: "/skills-store use my address pr review skill",
124
+ knownCommands: new Set(["skills-store"]),
109
125
  expectsUnsupportedChunk: false,
110
126
  commandInMessage: null,
111
127
  },
@@ -113,7 +129,11 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
113
129
 
114
130
  it.each(cases)("$label", async (tc) => {
115
131
  const { agent, client } = makeAgent();
116
- const query = installFakeSession(agent, tc.sessionId);
132
+ const query = installFakeSession(
133
+ agent,
134
+ tc.sessionId,
135
+ tc.knownCommands as Set<string> | undefined,
136
+ );
117
137
 
118
138
  const promptPromise = agent.prompt({
119
139
  sessionId: tc.sessionId,
@@ -43,6 +43,7 @@ import {
43
43
  type Query,
44
44
  query,
45
45
  type SDKUserMessage,
46
+ type SlashCommand,
46
47
  } from "@anthropic-ai/claude-agent-sdk";
47
48
  import { v7 as uuidv7 } from "uuid";
48
49
  import packageJson from "../../../package.json" with { type: "json" };
@@ -83,6 +84,11 @@ import {
83
84
  handleSystemMessage,
84
85
  handleUserAssistantMessage,
85
86
  } from "./conversion/sdk-to-acp";
87
+ import {
88
+ rehydrateTaskState,
89
+ type TaskState,
90
+ taskStateToPlanEntries,
91
+ } from "./conversion/task-state";
86
92
  import type { EnrichedReadCache } from "./hooks";
87
93
  import { createLocalToolsMcpServer } from "./mcp/local-tools";
88
94
  import {
@@ -94,6 +100,10 @@ import {
94
100
  import { canUseTool } from "./permissions/permission-handlers";
95
101
  import { getAvailableSlashCommands } from "./session/commands";
96
102
  import { parseMcpServers } from "./session/mcp-config";
103
+ import {
104
+ applyAvailableModelsAllowlist,
105
+ resolveInitialModelId,
106
+ } from "./session/model-config";
97
107
  import {
98
108
  DEFAULT_MODEL,
99
109
  getEffortOptions,
@@ -143,6 +153,17 @@ function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined {
143
153
  }
144
154
  }
145
155
 
156
+ function collectKnownSlashCommands(
157
+ commands: SlashCommand[] | undefined,
158
+ ): Set<string> {
159
+ const names = new Set<string>();
160
+ if (!commands) return names;
161
+ for (const cmd of commands) {
162
+ if (cmd.name) names.add(cmd.name);
163
+ }
164
+ return names;
165
+ }
166
+
146
167
  function sanitizeTitle(text: string): string {
147
168
  const sanitized = text
148
169
  .replace(/[\r\n]+/g, " ")
@@ -225,6 +246,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
225
246
  },
226
247
  loadSession: true,
227
248
  sessionCapabilities: {
249
+ additionalDirectories: {},
228
250
  list: {},
229
251
  fork: {},
230
252
  resume: {},
@@ -257,11 +279,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
257
279
  throw RequestError.authRequired();
258
280
  }
259
281
 
260
- const response = await this.createSession(params, {
261
- // Revisit these meta values once we support resume
262
- resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options
263
- ?.resume as string | undefined,
264
- });
282
+ const response = await this.createSession(
283
+ {
284
+ cwd: params.cwd,
285
+ mcpServers: params.mcpServers ?? [],
286
+ additionalDirectories: params.additionalDirectories,
287
+ _meta: params._meta,
288
+ },
289
+ {
290
+ // Revisit these meta values once we support resume
291
+ resume: (params._meta as NewSessionMeta | undefined)?.claudeCode
292
+ ?.options?.resume as string | undefined,
293
+ },
294
+ );
265
295
 
266
296
  return response;
267
297
  }
@@ -273,13 +303,14 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
273
303
  {
274
304
  cwd: params.cwd,
275
305
  mcpServers: params.mcpServers ?? [],
306
+ additionalDirectories: params.additionalDirectories,
276
307
  _meta: params._meta,
277
308
  },
278
309
  { resume: params.sessionId, forkSession: true },
279
310
  );
280
311
  }
281
312
 
282
- async unstable_resumeSession(
313
+ async resumeSession(
283
314
  params: ResumeSessionRequest,
284
315
  ): Promise<ResumeSessionResponse> {
285
316
  // Reuse existing session if it matches
@@ -290,6 +321,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
290
321
  {
291
322
  cwd: params.cwd,
292
323
  mcpServers: params.mcpServers ?? [],
324
+ additionalDirectories: params.additionalDirectories,
293
325
  _meta: params._meta,
294
326
  },
295
327
  {
@@ -297,6 +329,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
297
329
  },
298
330
  );
299
331
 
332
+ await this.rehydrateTaskStateFromJsonl(params.sessionId);
333
+
300
334
  return response;
301
335
  }
302
336
 
@@ -309,6 +343,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
309
343
  {
310
344
  cwd: params.cwd,
311
345
  mcpServers: params.mcpServers ?? [],
346
+ additionalDirectories: params.additionalDirectories,
312
347
  _meta: params._meta,
313
348
  },
314
349
  { resume: params.sessionId, skipBackgroundFetches: true },
@@ -409,6 +444,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
409
444
 
410
445
  this.session.promptRunning = true;
411
446
  let handedOff = false;
447
+ let errored = false;
412
448
  let lastAssistantTotalUsage: number | null = null;
413
449
  let lastStreamUsage = {
414
450
  input_tokens: 0,
@@ -416,6 +452,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
416
452
  cache_read_input_tokens: 0,
417
453
  cache_creation_input_tokens: 0,
418
454
  };
455
+ // Tracks whether we're inside a compaction. The SDK emits the terminal
456
+ // `status` (compact_result success/failed) twice for a single failed
457
+ // compaction, and the two messages are indistinguishable, so we report the
458
+ // outcome only while a compaction is in progress, then clear this. A fresh
459
+ // `compacting` status sets it again, so every distinct compaction (e.g.
460
+ // repeated auto-compactions in a long turn) is still shown.
461
+ let compactionInProgress = false;
419
462
  if (this.session.lastContextWindowSize == null) {
420
463
  this.session.lastContextWindowSize = this.getContextWindowForModel(
421
464
  this.session.modelId ?? "",
@@ -491,6 +534,54 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
491
534
  if (message.subtype === "local_command_output") {
492
535
  promptReplayed = true;
493
536
  }
537
+ if (message.subtype === "status") {
538
+ // The SDK signals manual `/compact` completion with a status
539
+ // message carrying `compact_result`, not the `compact_boundary`
540
+ // message (which only fires when there's content to compact).
541
+ // Gate the user-facing outcome on `compactionInProgress` to
542
+ // dedupe the duplicate terminal status the SDK emits for failed
543
+ // compactions.
544
+ if (message.status === "compacting") {
545
+ compactionInProgress = true;
546
+ // Fall through to handleSystemMessage so the COMPACTING
547
+ // extNotification still fires.
548
+ } else if (
549
+ message.compact_result === "success" &&
550
+ compactionInProgress
551
+ ) {
552
+ compactionInProgress = false;
553
+ await this.client.sessionUpdate({
554
+ sessionId: params.sessionId,
555
+ update: {
556
+ sessionUpdate: "agent_message_chunk",
557
+ content: {
558
+ type: "text",
559
+ text: "\n\nCompacting completed.",
560
+ },
561
+ },
562
+ });
563
+ break;
564
+ } else if (
565
+ message.compact_result === "failed" &&
566
+ compactionInProgress
567
+ ) {
568
+ compactionInProgress = false;
569
+ const reason = message.compact_error
570
+ ? `: ${message.compact_error}`
571
+ : ".";
572
+ await this.client.sessionUpdate({
573
+ sessionId: params.sessionId,
574
+ update: {
575
+ sessionUpdate: "agent_message_chunk",
576
+ content: {
577
+ type: "text",
578
+ text: `\n\nCompacting failed${reason}`,
579
+ },
580
+ },
581
+ });
582
+ break;
583
+ }
584
+ }
494
585
  if (
495
586
  message.subtype === "session_state_changed" &&
496
587
  (message as Record<string, unknown>).state === "idle"
@@ -500,7 +591,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
500
591
  // and produced no output (e.g. /plugin in a non-interactive
501
592
  // context). Without this branch we would loop forever waiting
502
593
  // for an echo that never comes; surface a clear error instead.
503
- if (commandMatch) {
594
+ //
595
+ // Only fire for commands the SDK does NOT recognize. Plugin
596
+ // and skill commands (e.g. /skills-store) produce a fresh
597
+ // user-message echo with a new uuid that our replay check
598
+ // can't match, so an early idle here is a race, not a real
599
+ // "unsupported" — fall through and let the loop continue.
600
+ const cmdName = commandMatch?.[1].slice(1);
601
+ const known =
602
+ cmdName !== undefined &&
603
+ this.session.knownSlashCommands?.has(cmdName) === true;
604
+ if (commandMatch && !known) {
504
605
  const cmd = commandMatch[1];
505
606
  this.logger.warn(
506
607
  "Slash command produced no output; treating as unsupported",
@@ -520,6 +621,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
520
621
  }
521
622
  this.logger.debug("Skipping idle state before prompt replay", {
522
623
  sessionId: params.sessionId,
624
+ command: commandMatch?.[1],
625
+ known,
523
626
  });
524
627
  break;
525
628
  }
@@ -540,7 +643,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
540
643
  },
541
644
  });
542
645
 
543
- return { stopReason: "end_turn" };
646
+ return {
647
+ stopReason: this.session.cancelled ? "cancelled" : "end_turn",
648
+ };
544
649
  }
545
650
  await handleSystemMessage(message, context);
546
651
  break;
@@ -814,6 +919,37 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
814
919
  }
815
920
  throw new Error("Session did not end in result");
816
921
  } catch (error) {
922
+ errored = true;
923
+ // A failed turn typically leaves a trailing `session_state_changed: idle`
924
+ // (and possibly more) in the query iterator. If we don't drain it here,
925
+ // the next prompt's first `query.next()` consumes that stale idle and
926
+ // short-circuits to end_turn with zero usage.
927
+ try {
928
+ await this.session.query.interrupt();
929
+ const MAX_DRAIN = 100;
930
+ for (let i = 0; i < MAX_DRAIN; i++) {
931
+ const { value: m, done } = await this.session.query.next();
932
+ if (done || !m) break;
933
+ if (
934
+ m.type === "system" &&
935
+ m.subtype === "session_state_changed" &&
936
+ (m as Record<string, unknown>).state === "idle"
937
+ ) {
938
+ break;
939
+ }
940
+ if (i === MAX_DRAIN - 1) {
941
+ this.logger.error(
942
+ `Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`,
943
+ );
944
+ }
945
+ }
946
+ } catch (drainErr) {
947
+ this.logger.error(
948
+ `Session ${params.sessionId}: failed to drain query after prompt error`,
949
+ { error: drainErr },
950
+ );
951
+ }
952
+
817
953
  if (error instanceof RequestError || !(error instanceof Error)) {
818
954
  throw error;
819
955
  }
@@ -844,10 +980,25 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
844
980
  this.toolUseStreamCache.clear();
845
981
  if (!handedOff) {
846
982
  this.session.promptRunning = false;
847
- // Resolve all remaining pending prompts so no callers get stuck.
848
- for (const [key, pending] of this.session.pendingMessages) {
849
- pending.resolve(true);
850
- this.session.pendingMessages.delete(key);
983
+ if (errored) {
984
+ // The query stream was just drained — handing pending prompts off
985
+ // onto it would let them race with the recovery. Cancel them so
986
+ // each waiting prompt() returns stopReason "cancelled" and the
987
+ // client can decide whether to retry.
988
+ for (const pending of this.session.pendingMessages.values()) {
989
+ pending.resolve(true);
990
+ }
991
+ this.session.pendingMessages.clear();
992
+ } else if (this.session.pendingMessages.size > 0) {
993
+ // Clean exit with queued prompts: hand off the lowest-order one
994
+ // so it can proceed. The rest stay queued for their own turn.
995
+ const next = [...this.session.pendingMessages.entries()].sort(
996
+ (a, b) => a[1].order - b[1].order,
997
+ )[0];
998
+ if (next) {
999
+ next[1].resolve(false);
1000
+ this.session.pendingMessages.delete(next[0]);
1001
+ }
851
1002
  }
852
1003
  }
853
1004
  }
@@ -909,6 +1060,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
909
1060
 
910
1061
  const mcpServers = parseMcpServers(
911
1062
  params as Pick<NewSessionRequest, "mcpServers">,
1063
+ this.logger,
912
1064
  );
913
1065
  await this.refreshSession(mcpServers);
914
1066
  return { refreshed: true };
@@ -1142,6 +1294,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1142
1294
  params: {
1143
1295
  cwd: string;
1144
1296
  mcpServers: NewSessionRequest["mcpServers"];
1297
+ additionalDirectories?: NewSessionRequest["additionalDirectories"];
1145
1298
  _meta?: unknown;
1146
1299
  },
1147
1300
  creationOpts: {
@@ -1181,7 +1334,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1181
1334
  const earlyModelId =
1182
1335
  settingsManager.getSettings().model || meta?.model || "";
1183
1336
  const mcpServers = supportsMcpInjection(earlyModelId)
1184
- ? parseMcpServers(params)
1337
+ ? parseMcpServers(params, this.logger)
1185
1338
  : {};
1186
1339
 
1187
1340
  // Register the in-process general local-tools MCP server. Tools self-gate
@@ -1226,6 +1379,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1226
1379
  ? (meta.permissionMode as CodeExecutionMode)
1227
1380
  : "default";
1228
1381
 
1382
+ const taskState: TaskState = new Map();
1229
1383
  const options = buildSessionOptions({
1230
1384
  cwd,
1231
1385
  mcpServers,
@@ -1239,7 +1393,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1239
1393
  forkSession,
1240
1394
  additionalDirectories: [
1241
1395
  ...(meta?.claudeCode?.options?.additionalDirectories ?? []),
1242
- ...(meta?.additionalRoots ?? []),
1396
+ // Prefer the official ACP `additionalDirectories` field. Fall back
1397
+ // to the legacy `_meta.additionalRoots` extension for clients that
1398
+ // haven't been updated yet.
1399
+ ...(params.additionalDirectories ?? meta?.additionalRoots ?? []),
1243
1400
  ],
1244
1401
  disableBuiltInTools: meta?.disableBuiltInTools,
1245
1402
  outputFormat,
@@ -1251,6 +1408,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1251
1408
  enrichmentDeps: this.enrichment?.deps,
1252
1409
  enrichedReadCache: this.enrichedReadCache,
1253
1410
  cloudMode: cloudRun,
1411
+ taskState,
1412
+ onTaskStateChange: async () => {
1413
+ await this.client.sessionUpdate({
1414
+ sessionId,
1415
+ update: {
1416
+ sessionUpdate: "plan",
1417
+ entries: taskStateToPlanEntries(taskState),
1418
+ },
1419
+ });
1420
+ },
1254
1421
  });
1255
1422
 
1256
1423
  // Use the same abort controller that buildSessionOptions gave to the query
@@ -1283,6 +1450,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1283
1450
  systemPrompt: estimateSystemPrompt(systemPrompt),
1284
1451
  rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)),
1285
1452
  },
1453
+ taskState,
1286
1454
 
1287
1455
  // Custom properties
1288
1456
  cwd,
@@ -1305,6 +1473,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1305
1473
  `Session ${forkSession ? "fork" : "resumption"} timed out for sessionId=${sessionId}`,
1306
1474
  );
1307
1475
  }
1476
+ session.knownSlashCommands = collectKnownSlashCommands(
1477
+ result.value.commands,
1478
+ );
1308
1479
  } catch (err) {
1309
1480
  settingsManager.dispose();
1310
1481
  if (
@@ -1332,7 +1503,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1332
1503
  ? withTimeout(q.initializationResult(), SESSION_VALIDATION_TIMEOUT_MS)
1333
1504
  : undefined;
1334
1505
 
1335
- const [modelOptions] = await Promise.all([
1506
+ const [rawModelOptions] = await Promise.all([
1336
1507
  this.getModelConfigOptions(
1337
1508
  settingsManager.getSettings().model || meta?.model || undefined,
1338
1509
  ),
@@ -1347,6 +1518,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1347
1518
  : []),
1348
1519
  ]);
1349
1520
 
1521
+ // Restrict the model list to the user's `availableModels` allowlist
1522
+ // from settings.json so config UI and downstream resolution stay
1523
+ // consistent with what the user configured. The Default option is
1524
+ // always preserved per the Claude Code docs.
1525
+ const settingsAvailableModels =
1526
+ settingsManager.getSettings().availableModels;
1527
+ const modelOptions = Array.isArray(settingsAvailableModels)
1528
+ ? applyAvailableModelsAllowlist(rawModelOptions, settingsAvailableModels)
1529
+ : rawModelOptions;
1530
+
1350
1531
  if (initPromise) {
1351
1532
  try {
1352
1533
  const initResult = await initPromise;
@@ -1356,6 +1537,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1356
1537
  `Session initialization timed out for sessionId=${sessionId}`,
1357
1538
  );
1358
1539
  }
1540
+ session.knownSlashCommands = collectKnownSlashCommands(
1541
+ initResult.value.commands,
1542
+ );
1359
1543
  } catch (err) {
1360
1544
  settingsManager.dispose();
1361
1545
  this.logger.error("Session initialization failed", {
@@ -1368,10 +1552,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1368
1552
  }
1369
1553
  }
1370
1554
 
1371
- const settingsModel = settingsManager.getSettings().model;
1372
- const metaModel = meta?.model;
1373
- const resolvedModelId =
1374
- settingsModel || metaModel || modelOptions.currentModelId;
1555
+ const resolvedModelId = resolveInitialModelId(modelOptions, [
1556
+ settingsManager.getSettings().model,
1557
+ meta?.model,
1558
+ ]);
1375
1559
  session.modelId = resolvedModelId;
1376
1560
  session.lastContextWindowSize =
1377
1561
  this.getContextWindowForModel(resolvedModelId);
@@ -1635,6 +1819,35 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1635
1819
  };
1636
1820
  }
1637
1821
 
1822
+ /**
1823
+ * Rebuild the in-memory taskState from JSONL and push a plan update so the
1824
+ * client's plan panel reflects pre-resume tasks. `loadSession` already covers
1825
+ * this via the full `replaySessionHistory` notification stream; resume
1826
+ * deliberately stays quiet (the client keeps its own message history) so we
1827
+ * walk the transcript here for state only.
1828
+ */
1829
+ private async rehydrateTaskStateFromJsonl(sessionId: string): Promise<void> {
1830
+ try {
1831
+ const messages = await getSessionMessages(sessionId, {
1832
+ dir: this.session.cwd,
1833
+ });
1834
+ rehydrateTaskState(messages, this.session.taskState);
1835
+ if (this.session.taskState.size === 0) return;
1836
+ await this.client.sessionUpdate({
1837
+ sessionId,
1838
+ update: {
1839
+ sessionUpdate: "plan",
1840
+ entries: taskStateToPlanEntries(this.session.taskState),
1841
+ },
1842
+ });
1843
+ } catch (err) {
1844
+ this.logger.warn("Failed to rehydrate task state", {
1845
+ sessionId,
1846
+ error: err instanceof Error ? err.message : String(err),
1847
+ });
1848
+ }
1849
+ }
1850
+
1638
1851
  private async replaySessionHistory(sessionId: string): Promise<void> {
1639
1852
  try {
1640
1853
  const messages = await getSessionMessages(sessionId, {