@posthog/agent 2.3.678 → 2.3.696

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.678",
3
+ "version": "2.3.696",
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": {
@@ -106,9 +106,9 @@
106
106
  "tsx": "^4.20.6",
107
107
  "typescript": "^5.5.0",
108
108
  "vitest": "^2.1.8",
109
- "@posthog/git": "1.0.0",
109
+ "@posthog/enricher": "1.0.0",
110
110
  "@posthog/shared": "1.0.0",
111
- "@posthog/enricher": "1.0.0"
111
+ "@posthog/git": "1.0.0"
112
112
  },
113
113
  "dependencies": {
114
114
  "@agentclientprotocol/sdk": "0.19.0",
@@ -68,6 +68,14 @@ import { Pushable } from "../../utils/streams";
68
68
  import { BaseAcpAgent } from "../base-acp-agent";
69
69
  import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
70
70
  import { resolveTaskId } from "../session-meta";
71
+ import {
72
+ buildBreakdown,
73
+ emptyBaseline,
74
+ estimateMcpTokens,
75
+ estimateRulesTokens,
76
+ estimateSkillsTokens,
77
+ estimateSystemPrompt,
78
+ } from "./context-breakdown";
71
79
  import { promptToClaude } from "./conversion/acp-to-sdk";
72
80
  import {
73
81
  handleResultMessage,
@@ -79,6 +87,7 @@ import type { EnrichedReadCache } from "./hooks";
79
87
  import { createLocalToolsMcpServer } from "./mcp/local-tools";
80
88
  import {
81
89
  fetchMcpToolMetadata,
90
+ getCachedMcpTools,
82
91
  getConnectedMcpServerNames,
83
92
  setMcpToolApprovalStates,
84
93
  } from "./mcp/tool-metadata";
@@ -118,6 +127,22 @@ const SESSION_VALIDATION_TIMEOUT_MS = 30_000;
118
127
  const MAX_TITLE_LENGTH = 256;
119
128
  const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
120
129
 
130
+ // Best-effort: silent on ENOENT, logs other errors so permission failures
131
+ // aren't masked.
132
+ function readClaudeMdQuietly(cwd: string, logger: Logger): string | undefined {
133
+ try {
134
+ return fs.readFileSync(path.join(cwd, "CLAUDE.md"), "utf-8");
135
+ } catch (err) {
136
+ if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
137
+ logger.warn("Failed to read CLAUDE.md for context breakdown", {
138
+ cwd,
139
+ error: err instanceof Error ? err.message : String(err),
140
+ });
141
+ }
142
+ return undefined;
143
+ }
144
+ }
145
+
121
146
  function sanitizeTitle(text: string): string {
122
147
  const sanitized = text
123
148
  .replace(/[\r\n]+/g, " ")
@@ -556,6 +581,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
556
581
  });
557
582
  }
558
583
 
584
+ // `result.usage` is cumulative across the agentic loop; the
585
+ // outermost-model stream snapshot is what's actually resident.
586
+ const breakdownInputTokens =
587
+ lastStreamUsage.input_tokens +
588
+ lastStreamUsage.cache_read_input_tokens +
589
+ lastStreamUsage.cache_creation_input_tokens;
559
590
  await this.client.extNotification(
560
591
  POSTHOG_NOTIFICATIONS.USAGE_UPDATE,
561
592
  {
@@ -567,6 +598,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
567
598
  cachedWriteTokens: message.usage.cache_creation_input_tokens,
568
599
  },
569
600
  cost: message.total_cost_usd,
601
+ breakdown: buildBreakdown(
602
+ this.session.contextBreakdownBaseline ?? emptyBaseline(),
603
+ breakdownInputTokens,
604
+ ),
570
605
  },
571
606
  );
572
607
 
@@ -1221,6 +1256,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1221
1256
  pendingMessages: new Map(),
1222
1257
  nextPendingOrder: 0,
1223
1258
  emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages ?? false,
1259
+ contextBreakdownBaseline: {
1260
+ ...emptyBaseline(),
1261
+ systemPrompt: estimateSystemPrompt(systemPrompt),
1262
+ rules: estimateRulesTokens(readClaudeMdQuietly(cwd, this.logger)),
1263
+ },
1224
1264
 
1225
1265
  // Custom properties
1226
1266
  cwd,
@@ -1547,13 +1587,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1547
1587
 
1548
1588
  private async sendAvailableCommandsUpdate(): Promise<void> {
1549
1589
  const commands = await this.session.query.supportedCommands();
1590
+ const available = getAvailableSlashCommands(commands);
1550
1591
  await this.client.sessionUpdate({
1551
1592
  sessionId: this.sessionId,
1552
1593
  update: {
1553
1594
  sessionUpdate: "available_commands_update",
1554
- availableCommands: getAvailableSlashCommands(commands),
1595
+ availableCommands: available,
1555
1596
  },
1556
1597
  });
1598
+ this.updateBreakdownCategory("skills", estimateSkillsTokens(available));
1599
+ }
1600
+
1601
+ /** Update one category of the context-breakdown baseline so the next
1602
+ * `_posthog/usage_update` carries fresher numbers. No-op when the baseline
1603
+ * hasn't been initialized yet (e.g. in a unit-test session). */
1604
+ private updateBreakdownCategory(
1605
+ key: keyof NonNullable<Session["contextBreakdownBaseline"]>,
1606
+ tokens: number,
1607
+ ): void {
1608
+ if (!this.session?.contextBreakdownBaseline) return;
1609
+ if (this.session.contextBreakdownBaseline[key] === tokens) return;
1610
+ this.session.contextBreakdownBaseline = {
1611
+ ...this.session.contextBreakdownBaseline,
1612
+ [key]: tokens,
1613
+ };
1557
1614
  }
1558
1615
 
1559
1616
  private async replaySessionHistory(sessionId: string): Promise<void> {
@@ -1610,6 +1667,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1610
1667
  this.sendAvailableCommandsUpdate(),
1611
1668
  ),
1612
1669
  fetchMcpToolMetadata(q, this.logger).then(() => {
1670
+ this.updateBreakdownCategory(
1671
+ "mcp",
1672
+ estimateMcpTokens(getCachedMcpTools()),
1673
+ );
1613
1674
  const serverNames = getConnectedMcpServerNames();
1614
1675
  if (serverNames.length > 0) {
1615
1676
  this.options?.onMcpServersReady?.(serverNames);
@@ -0,0 +1,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildBreakdown,
4
+ emptyBaseline,
5
+ estimateJsonTokens,
6
+ estimateMcpTokens,
7
+ estimateRulesTokens,
8
+ estimateSkillsTokens,
9
+ estimateSystemPrompt,
10
+ estimateTokens,
11
+ } from "./context-breakdown";
12
+
13
+ describe("estimateTokens", () => {
14
+ it("returns 0 for empty input", () => {
15
+ expect(estimateTokens("")).toBe(0);
16
+ expect(estimateTokens(undefined)).toBe(0);
17
+ expect(estimateTokens(null)).toBe(0);
18
+ });
19
+
20
+ it("scales roughly with input length", () => {
21
+ expect(estimateTokens("a".repeat(40))).toBe(10);
22
+ expect(estimateTokens("a".repeat(400))).toBe(100);
23
+ });
24
+ });
25
+
26
+ describe("estimateJsonTokens", () => {
27
+ it("counts JSON representation of objects", () => {
28
+ const tokens = estimateJsonTokens({ name: "Read", schema: { foo: 1 } });
29
+ expect(tokens).toBeGreaterThan(0);
30
+ });
31
+
32
+ it("returns 0 for non-serializable values", () => {
33
+ const circular: Record<string, unknown> = {};
34
+ circular.self = circular;
35
+ expect(estimateJsonTokens(circular)).toBe(0);
36
+ });
37
+ });
38
+
39
+ describe("estimateSystemPrompt", () => {
40
+ it("includes the Claude preset budget when preset is used", () => {
41
+ const noAppend = estimateSystemPrompt({ type: "preset" });
42
+ expect(noAppend).toBeGreaterThan(0);
43
+ });
44
+
45
+ it("adds the append portion on top of the preset", () => {
46
+ const append = "a".repeat(400);
47
+ const result = estimateSystemPrompt({ type: "preset", append });
48
+ const presetOnly = estimateSystemPrompt({ type: "preset" });
49
+ expect(result - presetOnly).toBe(100);
50
+ });
51
+
52
+ it("counts a raw string verbatim with no preset overhead", () => {
53
+ expect(estimateSystemPrompt("a".repeat(400))).toBe(100);
54
+ });
55
+
56
+ it("treats undefined as the bare preset", () => {
57
+ expect(estimateSystemPrompt(undefined)).toBe(
58
+ estimateSystemPrompt({ type: "preset" }),
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("estimateSkillsTokens", () => {
64
+ it("is 0 for an empty command list", () => {
65
+ expect(estimateSkillsTokens([])).toBe(0);
66
+ });
67
+
68
+ it("counts the JSON of name/description/hint", () => {
69
+ // [{"name":"review","description":"Review a PR","hint":"[pr]"}] ~ 55 chars
70
+ const result = estimateSkillsTokens([
71
+ { name: "review", description: "Review a PR", input: { hint: "[pr]" } },
72
+ ]);
73
+ expect(result).toBeGreaterThan(10);
74
+ expect(result).toBeLessThan(20);
75
+ });
76
+ });
77
+
78
+ describe("estimateMcpTokens", () => {
79
+ it("is 0 for no connected tools", () => {
80
+ expect(estimateMcpTokens([])).toBe(0);
81
+ });
82
+
83
+ it("scales with tool count", () => {
84
+ const one = estimateMcpTokens([{ name: "get_user", description: "x" }]);
85
+ const many = estimateMcpTokens(
86
+ Array.from({ length: 50 }, (_, i) => ({
87
+ name: `tool_${i}`,
88
+ description: "x",
89
+ })),
90
+ );
91
+ expect(many).toBeGreaterThan(one * 10);
92
+ });
93
+ });
94
+
95
+ describe("estimateRulesTokens", () => {
96
+ it("is 0 for missing rules", () => {
97
+ expect(estimateRulesTokens(undefined)).toBe(0);
98
+ expect(estimateRulesTokens("")).toBe(0);
99
+ });
100
+
101
+ it("counts the rules content", () => {
102
+ expect(estimateRulesTokens("a".repeat(400))).toBe(100);
103
+ });
104
+ });
105
+
106
+ describe("buildBreakdown", () => {
107
+ it("derives conversation from input - stable sum", () => {
108
+ const baseline = {
109
+ ...emptyBaseline(),
110
+ systemPrompt: 4000,
111
+ tools: 500,
112
+ };
113
+ const result = buildBreakdown(baseline, 10_000);
114
+ expect(result.systemPrompt).toBe(4000);
115
+ expect(result.tools).toBe(500);
116
+ expect(result.conversation).toBe(5500);
117
+ });
118
+
119
+ it("floors conversation at 0 when stable pieces exceed input", () => {
120
+ const baseline = { ...emptyBaseline(), systemPrompt: 5000 };
121
+ expect(buildBreakdown(baseline, 1000).conversation).toBe(0);
122
+ });
123
+
124
+ it("includes zero categories", () => {
125
+ const result = buildBreakdown(emptyBaseline(), 100);
126
+ expect(result.mcp).toBe(0);
127
+ expect(result.skills).toBe(0);
128
+ expect(result.subagents).toBe(0);
129
+ });
130
+ });
@@ -0,0 +1,127 @@
1
+ // Anthropic doesn't break `input_tokens` down by source; we estimate the bits
2
+ // we control via a chars-per-token heuristic. Indicative, not invoice-grade.
3
+
4
+ export type ContextCategory =
5
+ | "systemPrompt"
6
+ | "tools"
7
+ | "rules"
8
+ | "skills"
9
+ | "mcp"
10
+ | "subagents"
11
+ | "conversation";
12
+
13
+ export type ContextBreakdown = Record<ContextCategory, number>;
14
+
15
+ // The `claude_code` preset prompt is opaque to us; without this constant its
16
+ // tokens would bleed into the Conversation bucket and skew the chart.
17
+ const CLAUDE_PRESET_ESTIMATE_TOKENS = 4000;
18
+
19
+ const CHARS_PER_TOKEN = 4;
20
+
21
+ export function estimateTokens(text: string | undefined | null): number {
22
+ if (!text) return 0;
23
+ return Math.max(0, Math.round(text.length / CHARS_PER_TOKEN));
24
+ }
25
+
26
+ export function estimateJsonTokens(value: unknown): number {
27
+ try {
28
+ return estimateTokens(JSON.stringify(value));
29
+ } catch {
30
+ return 0;
31
+ }
32
+ }
33
+
34
+ interface SlashCommandLike {
35
+ name?: string;
36
+ description?: string;
37
+ input?: { hint?: string } | null;
38
+ }
39
+
40
+ export function estimateSkillsTokens(commands: SlashCommandLike[]): number {
41
+ if (!commands.length) return 0;
42
+ return estimateJsonTokens(
43
+ commands.map((c) => ({
44
+ name: c.name,
45
+ description: c.description,
46
+ hint: c.input?.hint,
47
+ })),
48
+ );
49
+ }
50
+
51
+ interface McpToolLike {
52
+ name?: string;
53
+ description?: string;
54
+ }
55
+
56
+ // The SDK relies on tool search rather than inlining full MCP schemas in the
57
+ // prompt, so name + description is a conservative estimate of what's resident.
58
+ export function estimateMcpTokens(tools: McpToolLike[]): number {
59
+ if (!tools.length) return 0;
60
+ return estimateJsonTokens(
61
+ tools.map((t) => ({ name: t.name, description: t.description })),
62
+ );
63
+ }
64
+
65
+ export function estimateRulesTokens(rules: string | undefined): number {
66
+ return estimateTokens(rules);
67
+ }
68
+
69
+ export interface ContextBreakdownBaseline {
70
+ systemPrompt: number;
71
+ tools: number;
72
+ rules: number;
73
+ skills: number;
74
+ mcp: number;
75
+ subagents: number;
76
+ }
77
+
78
+ export function emptyBaseline(): ContextBreakdownBaseline {
79
+ return {
80
+ systemPrompt: 0,
81
+ tools: 0,
82
+ rules: 0,
83
+ skills: 0,
84
+ mcp: 0,
85
+ subagents: 0,
86
+ };
87
+ }
88
+
89
+ export function estimateSystemPrompt(systemPrompt: unknown): number {
90
+ if (!systemPrompt) return CLAUDE_PRESET_ESTIMATE_TOKENS;
91
+ if (typeof systemPrompt === "string") return estimateTokens(systemPrompt);
92
+ if (typeof systemPrompt === "object") {
93
+ const obj = systemPrompt as { type?: string; append?: unknown };
94
+ const appendTokens =
95
+ typeof obj.append === "string" ? estimateTokens(obj.append) : 0;
96
+ if (obj.type === "preset") {
97
+ return CLAUDE_PRESET_ESTIMATE_TOKENS + appendTokens;
98
+ }
99
+ return appendTokens;
100
+ }
101
+ return 0;
102
+ }
103
+
104
+ // Conversation is floored at 0 so estimation drift in the stable categories
105
+ // can't surface a negative bucket.
106
+ export function buildBreakdown(
107
+ baseline: ContextBreakdownBaseline,
108
+ currentInputTokens: number,
109
+ ): ContextBreakdown {
110
+ const stableSum =
111
+ baseline.systemPrompt +
112
+ baseline.tools +
113
+ baseline.rules +
114
+ baseline.skills +
115
+ baseline.mcp +
116
+ baseline.subagents;
117
+ const conversation = Math.max(0, currentInputTokens - stableSum);
118
+ return {
119
+ systemPrompt: baseline.systemPrompt,
120
+ tools: baseline.tools,
121
+ rules: baseline.rules,
122
+ skills: baseline.skills,
123
+ mcp: baseline.mcp,
124
+ subagents: baseline.subagents,
125
+ conversation,
126
+ };
127
+ }
@@ -95,6 +95,45 @@ describe("promptToClaude", () => {
95
95
  expect(JSON.stringify(attachRes)).toContain("KEEP_ATTACH");
96
96
  });
97
97
 
98
+ it("forwards base64 image blocks with supported MIME types", () => {
99
+ const result = promptToClaude({
100
+ sessionId: "session-1",
101
+ prompt: [
102
+ {
103
+ type: "image",
104
+ data: "ZmFrZQ==",
105
+ mimeType: "image/png",
106
+ } as PromptRequest["prompt"][number],
107
+ ],
108
+ });
109
+
110
+ expect(result.message.content).toEqual([
111
+ {
112
+ type: "image",
113
+ source: { type: "base64", data: "ZmFrZQ==", media_type: "image/png" },
114
+ },
115
+ ]);
116
+ });
117
+
118
+ it("replaces unsupported base64 image MIME types with a text notice", () => {
119
+ const result = promptToClaude({
120
+ sessionId: "session-1",
121
+ prompt: [
122
+ {
123
+ type: "image",
124
+ data: "ZmFrZQ==",
125
+ mimeType: "image/heic",
126
+ } as PromptRequest["prompt"][number],
127
+ ],
128
+ });
129
+
130
+ expect(result.message.content).toHaveLength(1);
131
+ expect(result.message.content[0]).toMatchObject({
132
+ type: "text",
133
+ text: expect.stringContaining("image/heic"),
134
+ });
135
+ });
136
+
98
137
  it("maps file URI-only image blocks to workspace Read prompt text", () => {
99
138
  const req: PromptRequest = {
100
139
  sessionId: "session-1",
@@ -3,24 +3,12 @@ import { fileURLToPath } from "node:url";
3
3
  import type { PromptRequest } from "@agentclientprotocol/sdk";
4
4
  import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
5
5
  import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
6
-
7
- type ImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";
6
+ import { isClaudeImageMimeType, isImageFile } from "@posthog/shared";
8
7
 
9
8
  const PDF_EXTENSIONS = new Set(["pdf"]);
10
9
 
11
- const COMMON_IMAGE_EXTENSIONS = new Set([
12
- "png",
13
- "jpg",
14
- "jpeg",
15
- "gif",
16
- "webp",
17
- "bmp",
18
- "svg",
19
- "heic",
20
- "tif",
21
- "tiff",
22
- ]);
23
-
10
+ // Video-only on purpose: audio formats get the default "large text" hint.
11
+ // Do not replace with AUDIO_VIDEO_EXTENSIONS from @posthog/shared.
24
12
  const VIDEO_EXTENSIONS = new Set([
25
13
  "mp4",
26
14
  "mov",
@@ -53,7 +41,7 @@ export function readToolGuidanceForPath(filePath: string): string {
53
41
  if (PDF_EXTENSIONS.has(ext)) {
54
42
  return 'Optional `pages` string (e.g. "1-5") per Read call instead of loading the entire PDF.';
55
43
  }
56
- if (COMMON_IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)) {
44
+ if (isImageFile(filePath) || VIDEO_EXTENSIONS.has(ext)) {
57
45
  return "Binary file — use Read with `file_path`; prefer bounded reads where supported.";
58
46
  }
59
47
  return "Large text — use multiple Read calls with optional `offset` and `limit`.";
@@ -131,14 +119,22 @@ function processPromptChunk(
131
119
 
132
120
  case "image":
133
121
  if (chunk.data) {
134
- content.push({
135
- type: "image",
136
- source: {
137
- type: "base64",
138
- data: chunk.data,
139
- media_type: chunk.mimeType as ImageMimeType,
140
- },
141
- });
122
+ if (isClaudeImageMimeType(chunk.mimeType)) {
123
+ content.push({
124
+ type: "image",
125
+ source: {
126
+ type: "base64",
127
+ data: chunk.data,
128
+ media_type: chunk.mimeType,
129
+ },
130
+ });
131
+ } else {
132
+ content.push(
133
+ sdkText(
134
+ `[Unsupported image MIME type: ${chunk.mimeType}. Supported: image/jpeg, image/png, image/gif, image/webp.]`,
135
+ ),
136
+ );
137
+ }
142
138
  } else if (chunk.uri?.startsWith("http")) {
143
139
  content.push({
144
140
  type: "image",
@@ -116,6 +116,12 @@ export function getConnectedMcpServerNames(): string[] {
116
116
  return [...names];
117
117
  }
118
118
 
119
+ /** Snapshot of every tool currently in the metadata cache. Used by the
120
+ * context-breakdown estimator to size the MCP category. */
121
+ export function getCachedMcpTools(): McpToolMetadata[] {
122
+ return [...mcpToolMetadataCache.values()];
123
+ }
124
+
119
125
  export function getMcpToolApprovalState(
120
126
  toolName: string,
121
127
  ): McpToolApprovalState | undefined {
@@ -10,6 +10,7 @@ import type {
10
10
  } from "@anthropic-ai/claude-agent-sdk";
11
11
  import type { Pushable } from "../../utils/streams";
12
12
  import type { BaseSession } from "../base-acp-agent";
13
+ import type { ContextBreakdownBaseline } from "./context-breakdown";
13
14
  import type { McpToolApprovals } from "./mcp/tool-metadata";
14
15
  import type { SettingsManager } from "./session/settings";
15
16
  import type { CodeExecutionMode } from "./tools";
@@ -65,6 +66,8 @@ export type Session = BaseSession & {
65
66
  pendingMessages: Map<string, PendingMessage>;
66
67
  nextPendingOrder: number;
67
68
  emitRawSDKMessages: boolean | SDKMessageFilter[];
69
+ /** Refreshed at session init and on MCP/skill changes. */
70
+ contextBreakdownBaseline?: ContextBreakdownBaseline;
68
71
  };
69
72
 
70
73
  export type ToolUseCache = {
@@ -64,6 +64,12 @@ import {
64
64
  nodeWritableToWebWritable,
65
65
  } from "../../utils/streams";
66
66
  import { BaseAcpAgent, type BaseSession } from "../base-acp-agent";
67
+ import {
68
+ buildBreakdown,
69
+ type ContextBreakdownBaseline,
70
+ emptyBaseline,
71
+ estimateTokens,
72
+ } from "../claude/context-breakdown";
67
73
  import { classifyAgentError } from "../error-classification";
68
74
  import {
69
75
  enabledLocalTools,
@@ -76,6 +82,7 @@ import { normalizeCodexConfigOptions } from "./models";
76
82
  import {
77
83
  type CodexSessionState,
78
84
  createSessionState,
85
+ resetSessionState,
79
86
  resetUsage,
80
87
  } from "./session-state";
81
88
  import { CodexSettingsManager } from "./settings";
@@ -161,6 +168,19 @@ function classifyPromptError(error: unknown): unknown {
161
168
  );
162
169
  }
163
170
 
171
+ // codex-rs/protocol/src/protocol.rs BASELINE_TOKENS — the always-resident
172
+ // floor (MCP schemas, skills, preset prompt) we can't attribute per-source.
173
+ const CODEX_BASELINE_TOKENS = 12000;
174
+
175
+ function buildCodexBaseline(
176
+ meta: NewSessionMeta | undefined,
177
+ ): ContextBreakdownBaseline {
178
+ const baseline = emptyBaseline();
179
+ baseline.systemPrompt =
180
+ CODEX_BASELINE_TOKENS + estimateTokens(meta?.systemPrompt);
181
+ return baseline;
182
+ }
183
+
164
184
  const CODEX_NATIVE_MODE: Record<CodeExecutionMode, CodexNativeMode> = {
165
185
  auto: "auto",
166
186
  default: "auto",
@@ -392,8 +412,10 @@ export class CodexAcpAgent extends BaseAcpAgent {
392
412
  response.configOptions,
393
413
  );
394
414
 
395
- // Initialize session state
396
- this.sessionState = createSessionState(response.sessionId, params.cwd, {
415
+ // Initialize session state. Mutate in place — codex-client closure-
416
+ // captured this object in the constructor and writes contextUsed/
417
+ // accumulatedUsage to it on every upstream usage_update.
418
+ resetSessionState(this.sessionState, response.sessionId, params.cwd, {
397
419
  taskRunId: meta?.taskRunId,
398
420
  taskId: resolveTaskId(meta),
399
421
  modeId: response.modes?.currentModeId ?? "auto",
@@ -402,6 +424,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
402
424
  });
403
425
  this.sessionId = response.sessionId;
404
426
  this.sessionState.configOptions = response.configOptions ?? [];
427
+ this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
405
428
 
406
429
  await this.applyInitialPermissionMode(
407
430
  response.sessionId,
@@ -445,7 +468,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
445
468
  // notifications (TURN_COMPLETE, USAGE_UPDATE) after a reload. newSession
446
469
  // and unstable_resumeSession both do this; loadSession historically did
447
470
  // not, which silently broke task-completion tracking on re-attach.
448
- this.sessionState = createSessionState(params.sessionId, params.cwd, {
471
+ resetSessionState(this.sessionState, params.sessionId, params.cwd, {
449
472
  taskRunId: meta?.taskRunId,
450
473
  taskId: resolveTaskId(meta),
451
474
  modeId: response.modes?.currentModeId ?? "auto",
@@ -453,6 +476,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
453
476
  });
454
477
  this.sessionId = params.sessionId;
455
478
  this.sessionState.configOptions = response.configOptions ?? [];
479
+ this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
456
480
 
457
481
  if (meta?.taskRunId) {
458
482
  await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, {
@@ -491,7 +515,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
491
515
  loadResponse.modes?.currentModeId,
492
516
  meta?.permissionMode,
493
517
  );
494
- this.sessionState = createSessionState(params.sessionId, params.cwd, {
518
+ resetSessionState(this.sessionState, params.sessionId, params.cwd, {
495
519
  taskRunId: meta?.taskRunId,
496
520
  taskId: resolveTaskId(meta),
497
521
  modeId: loadResponse.modes?.currentModeId ?? "auto",
@@ -499,6 +523,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
499
523
  });
500
524
  this.sessionId = params.sessionId;
501
525
  this.sessionState.configOptions = loadResponse.configOptions ?? [];
526
+ this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
502
527
 
503
528
  if (meta?.taskRunId) {
504
529
  await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, {
@@ -538,7 +563,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
538
563
  );
539
564
 
540
565
  const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
541
- this.sessionState = createSessionState(newResponse.sessionId, params.cwd, {
566
+ resetSessionState(this.sessionState, newResponse.sessionId, params.cwd, {
542
567
  taskRunId: meta?.taskRunId,
543
568
  taskId: resolveTaskId(meta),
544
569
  modeId: newResponse.modes?.currentModeId ?? "auto",
@@ -546,6 +571,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
546
571
  });
547
572
  this.sessionId = newResponse.sessionId;
548
573
  this.sessionState.configOptions = newResponse.configOptions ?? [];
574
+ this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta);
549
575
 
550
576
  await this.applyInitialPermissionMode(
551
577
  newResponse.sessionId,
@@ -730,6 +756,21 @@ export class CodexAcpAgent extends BaseAcpAgent {
730
756
  }
731
757
  }
732
758
 
759
+ // Emit the per-source breakdown so the renderer's ContextBreakdownPopover
760
+ // has data to show. This fires regardless of `taskRunId` (local sessions
761
+ // need it too) and regardless of `response.usage` (codex-acp doesn't
762
+ // populate it — context size comes from upstream usage_update events,
763
+ // tracked on sessionState.contextUsed).
764
+ if (this.sessionState.contextUsed !== undefined) {
765
+ await this.client.extNotification(POSTHOG_NOTIFICATIONS.USAGE_UPDATE, {
766
+ sessionId: params.sessionId,
767
+ breakdown: buildBreakdown(
768
+ this.sessionState.contextBreakdownBaseline ?? emptyBaseline(),
769
+ this.sessionState.contextUsed,
770
+ ),
771
+ });
772
+ }
773
+
733
774
  return response;
734
775
  }
735
776