codeep 2.13.1 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@ import { ApiError } from '../api/index.js';
18
18
  import { PROVIDERS } from '../config/providers.js';
19
19
  import { getCurrentVersion } from '../utils/update.js';
20
20
  import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
21
- import { getCostBreakdown, resetTokenTracking } from '../utils/tokenTracker.js';
21
+ import { getCostBreakdown, getRecordCount, createTokenScope, runWithTokenScope } from '../utils/tokenTracker.js';
22
22
  import { isGitRepository } from '../utils/git.js';
23
23
  import { getProjectContext } from '../utils/project.js';
24
24
  // ─── Slash commands advertised to Zed ────────────────────────────────────────
@@ -141,10 +141,18 @@ export function formatToolInputForPermission(tool, params) {
141
141
  ? lines.slice(0, MAX_CONTENT_LINES).join('\n') + `\n… (${lines.length - MAX_CONTENT_LINES} more lines)`
142
142
  : params.content;
143
143
  }
144
- if (typeof params.old_string === 'string' && typeof params.new_string === 'string') {
145
- out.changes = `replace ${params.old_string.split('\n').length} line(s)`;
146
- out.old_string = truncateDiff(params.old_string);
147
- out.new_string = truncateDiff(params.new_string);
144
+ // The built-in edit_file tool schema emits old_text / new_text (see
145
+ // utils/tools.ts); accept the old_string/new_string spelling too for
146
+ // robustness. Without this the permission dialog dropped the diff and
147
+ // showed only { file, path } — the user approved edits blind.
148
+ const oldText = typeof params.old_text === 'string' ? params.old_text
149
+ : (typeof params.old_string === 'string' ? params.old_string : undefined);
150
+ const newText = typeof params.new_text === 'string' ? params.new_text
151
+ : (typeof params.new_string === 'string' ? params.new_string : undefined);
152
+ if (oldText !== undefined && newText !== undefined) {
153
+ out.changes = `replace ${oldText.split('\n').length} line(s)`;
154
+ out.old_string = truncateDiff(oldText);
155
+ out.new_string = truncateDiff(newText);
148
156
  }
149
157
  return out;
150
158
  }
@@ -860,7 +868,6 @@ export function startAcpServer() {
860
868
  },
861
869
  });
862
870
  };
863
- resetTokenTracking();
864
871
  // Manual mode gates write/edit for THIS run via a per-call option passed to
865
872
  // runAgentSession (extraDangerousTools, below) — NOT by mutating the global
866
873
  // `agentConfirmWriteFile` config, which leaked the session's mode into the
@@ -876,255 +883,283 @@ export function startAcpServer() {
876
883
  },
877
884
  });
878
885
  };
879
- // Try slash commands first
880
- handleCommand(prompt, session, sendChunk, abortController.signal)
881
- .then((cmd) => {
882
- if (cmd.handled) {
883
- if (cmd.response)
884
- sendChunk(cmd.response);
885
- // If provider or model changed, push updated config options to Zed
886
- if (cmd.configOptionsChanged) {
887
- transport.notify('session/update', {
888
- sessionId: params.sessionId,
889
- update: {
890
- sessionUpdate: 'config_option_update',
891
- configOptions: buildConfigOptions(),
892
- },
893
- });
894
- }
895
- // Update title with first real prompt if session had no history
896
- if (!session.titleSent && !session.hadHistory) {
897
- session.titleSent = true;
898
- sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
899
- }
900
- transport.respond(msg.id, { stopReason: 'end_turn' });
901
- return;
902
- }
903
- // Not a command — run agent loop
904
- let enrichedPrompt = prompt;
905
- if (session.addedFiles.size > 0) {
906
- const parts = ['[Attached files]'];
907
- for (const [, f] of session.addedFiles) {
908
- parts.push(`\nFile: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``);
909
- }
910
- enrichedPrompt = parts.join('\n') + '\n\n' + prompt;
911
- }
912
- runAgentSession({
913
- prompt: enrichedPrompt,
914
- workspaceRoot: session.workspaceRoot,
915
- conversationId: params.sessionId,
916
- abortSignal: abortController.signal,
917
- onChunk: sendChunk,
918
- onThought: (text) => {
919
- transport.notify('session/update', {
920
- sessionId: params.sessionId,
921
- update: {
922
- sessionUpdate: 'agent_thought_chunk',
923
- content: { type: 'text', text },
924
- },
925
- });
926
- },
927
- onToolCall: (toolCallId, toolName, kind, title, status, locations, rawOutput) => {
928
- if (status === 'running') {
929
- // Initial tool_call notification: spec ToolCall shape
886
+ // Try slash commands first.
887
+ // Run the whole prompt lifecycle inside THIS ACP session's token scope so
888
+ // (a) concurrent sessions on one process can't mix usage totals, and
889
+ // (b) usage accumulates into the session's own buffer across prompts, so
890
+ // `/cost` stays session-cumulative. `tokenReportStart` marks the pre-prompt
891
+ // count so we report only this prompt's delta to cloud telemetry.
892
+ session.tokenRecords ??= createTokenScope();
893
+ runWithTokenScope(session.tokenRecords, () => {
894
+ const tokenReportStart = getRecordCount();
895
+ return handleCommand(prompt, session, sendChunk, abortController.signal)
896
+ .then((cmd) => {
897
+ if (cmd.handled) {
898
+ if (cmd.response)
899
+ sendChunk(cmd.response);
900
+ // If provider or model changed, push updated config options to Zed
901
+ if (cmd.configOptionsChanged) {
930
902
  transport.notify('session/update', {
931
903
  sessionId: params.sessionId,
932
904
  update: {
933
- sessionUpdate: 'tool_call',
934
- toolCallId,
935
- title: title || toolName,
936
- kind: kind || 'other',
937
- status: 'in_progress',
938
- ...(locations && locations.length > 0
939
- ? { locations: locations.map(path => ({ path })) }
940
- : {}),
905
+ sessionUpdate: 'config_option_update',
906
+ configOptions: buildConfigOptions(),
941
907
  },
942
908
  });
943
- // Add to plan as in_progress — only meaningful actions (not reads)
944
- if (kind === 'edit' || kind === 'execute' || kind === 'delete') {
945
- planEntries.set(toolCallId, {
946
- id: toolCallId,
947
- content: title || toolName,
948
- priority: kind === 'execute' ? 'high' : 'medium',
949
- status: 'in_progress',
950
- });
951
- sendPlan();
952
- }
953
909
  }
954
- else {
955
- // tool_call_update: update status to completed/failed, with optional content
910
+ // Update title with first real prompt if session had no history
911
+ if (!session.titleSent && !session.hadHistory) {
912
+ session.titleSent = true;
913
+ sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
914
+ }
915
+ transport.respond(msg.id, { stopReason: 'end_turn' });
916
+ return;
917
+ }
918
+ // Not a command — run agent loop
919
+ let enrichedPrompt = prompt;
920
+ if (session.addedFiles.size > 0) {
921
+ const parts = ['[Attached files]'];
922
+ for (const [, f] of session.addedFiles) {
923
+ parts.push(`\nFile: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``);
924
+ }
925
+ enrichedPrompt = parts.join('\n') + '\n\n' + prompt;
926
+ }
927
+ runAgentSession({
928
+ prompt: enrichedPrompt,
929
+ workspaceRoot: session.workspaceRoot,
930
+ conversationId: params.sessionId,
931
+ abortSignal: abortController.signal,
932
+ onChunk: sendChunk,
933
+ onThought: (text) => {
956
934
  transport.notify('session/update', {
957
935
  sessionId: params.sessionId,
958
936
  update: {
959
- sessionUpdate: 'tool_call_update',
960
- toolCallId,
961
- status: status === 'finished' ? 'completed' : 'failed',
962
- ...(rawOutput !== undefined ? { rawOutput } : {}),
937
+ sessionUpdate: 'agent_thought_chunk',
938
+ content: { type: 'text', text },
963
939
  },
964
940
  });
965
- // Mark plan entry as completed
966
- const entry = planEntries.get(toolCallId);
967
- if (entry) {
968
- entry.status = 'completed';
969
- sendPlan();
941
+ },
942
+ onToolCall: (toolCallId, toolName, kind, title, status, locations, rawOutput) => {
943
+ if (status === 'running') {
944
+ // Initial tool_call notification: spec ToolCall shape
945
+ transport.notify('session/update', {
946
+ sessionId: params.sessionId,
947
+ update: {
948
+ sessionUpdate: 'tool_call',
949
+ toolCallId,
950
+ title: title || toolName,
951
+ kind: kind || 'other',
952
+ status: 'in_progress',
953
+ ...(locations && locations.length > 0
954
+ ? { locations: locations.map(path => ({ path })) }
955
+ : {}),
956
+ },
957
+ });
958
+ // Add to plan as in_progress — only meaningful actions (not reads)
959
+ if (kind === 'edit' || kind === 'execute' || kind === 'delete') {
960
+ planEntries.set(toolCallId, {
961
+ id: toolCallId,
962
+ content: title || toolName,
963
+ priority: kind === 'execute' ? 'high' : 'medium',
964
+ status: 'in_progress',
965
+ });
966
+ sendPlan();
967
+ }
970
968
  }
971
- }
972
- },
973
- // Manual mode gates write_file/edit_file for this run only (per-call,
974
- // no global config mutation).
975
- extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
976
- // Only request permission in Manual mode
977
- onRequestPermission: session.currentModeId === 'manual'
978
- ? async (toolCall) => {
979
- const permToolCallId = `perm_${randomUUID()}`;
980
- const result = await transport.request('session/request_permission', {
981
- sessionId: params.sessionId,
982
- toolCall: {
983
- toolCallId: permToolCallId,
984
- toolName: toolCall.tool,
985
- toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
986
- status: 'pending',
987
- content: [],
988
- },
989
- options: [
990
- { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
991
- { optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
992
- { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
993
- { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
994
- ],
995
- });
996
- // Map ACP outcome back to PermissionOutcome
997
- if (!result || result.outcome.type === 'cancelled')
998
- return 'reject_once';
999
- return result.outcome.optionId;
1000
- }
1001
- : undefined,
1002
- // Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
1003
- // CLIENT methods — only safe to call when the client advertised
1004
- // the capability in `initialize`. Routing through the client
1005
- // means the editor's dirty buffers + undo history stay correct
1006
- // (otherwise an in-editor unsaved change would be invisible to
1007
- // the agent, or worse, silently overwritten).
1008
- fs: {
1009
- readTextFile: clientSupportsFsRead
1010
- ? async (absolutePath) => {
1011
- const result = await transport.request('fs/read_text_file', {
969
+ else {
970
+ // tool_call_update: update status to completed/failed, with optional content
971
+ transport.notify('session/update', {
1012
972
  sessionId: params.sessionId,
1013
- path: absolutePath,
973
+ update: {
974
+ sessionUpdate: 'tool_call_update',
975
+ toolCallId,
976
+ status: status === 'finished' ? 'completed' : 'failed',
977
+ ...(rawOutput !== undefined ? { rawOutput } : {}),
978
+ },
1014
979
  });
1015
- if (!result || typeof result.content !== 'string') {
1016
- throw new Error('fs/read_text_file returned no content');
980
+ // Mark plan entry as completed
981
+ const entry = planEntries.get(toolCallId);
982
+ if (entry) {
983
+ entry.status = 'completed';
984
+ sendPlan();
1017
985
  }
1018
- return result.content;
1019
986
  }
1020
- : undefined,
1021
- writeTextFile: clientSupportsFsWrite
1022
- ? async (absolutePath, content) => {
1023
- await transport.request('fs/write_text_file', {
987
+ },
988
+ // Manual mode gates write_file/edit_file for this run only (per-call,
989
+ // no global config mutation).
990
+ extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
991
+ // Only request permission in Manual mode
992
+ onRequestPermission: session.currentModeId === 'manual'
993
+ ? async (toolCall) => {
994
+ const permToolCallId = `perm_${randomUUID()}`;
995
+ const result = await transport.request('session/request_permission', {
1024
996
  sessionId: params.sessionId,
1025
- path: absolutePath,
1026
- content,
997
+ toolCall: {
998
+ toolCallId: permToolCallId,
999
+ toolName: toolCall.tool,
1000
+ toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1001
+ status: 'pending',
1002
+ content: [],
1003
+ },
1004
+ options: [
1005
+ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1006
+ { optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
1007
+ { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1008
+ { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1009
+ ],
1027
1010
  });
1011
+ // Map ACP outcome back to PermissionOutcome
1012
+ if (!result || result.outcome.type === 'cancelled')
1013
+ return 'reject_once';
1014
+ return result.outcome.optionId;
1028
1015
  }
1029
1016
  : undefined,
1030
- },
1031
- onExecuteCommand: async (command, args, cwd) => {
1032
- // Per ACP spec, only call terminal/* if the client advertised the
1033
- // capability in initialize. Otherwise execute locally.
1034
- if (!clientSupportsTerminal) {
1035
- const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
1036
- return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
1017
+ // Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
1018
+ // CLIENT methods — only safe to call when the client advertised
1019
+ // the capability in `initialize`. Routing through the client
1020
+ // means the editor's dirty buffers + undo history stay correct
1021
+ // (otherwise an in-editor unsaved change would be invisible to
1022
+ // the agent, or worse, silently overwritten).
1023
+ fs: {
1024
+ readTextFile: clientSupportsFsRead
1025
+ ? async (absolutePath) => {
1026
+ const result = await transport.request('fs/read_text_file', {
1027
+ sessionId: params.sessionId,
1028
+ path: absolutePath,
1029
+ });
1030
+ if (!result || typeof result.content !== 'string') {
1031
+ throw new Error('fs/read_text_file returned no content');
1032
+ }
1033
+ return result.content;
1034
+ }
1035
+ : undefined,
1036
+ writeTextFile: clientSupportsFsWrite
1037
+ ? async (absolutePath, content) => {
1038
+ await transport.request('fs/write_text_file', {
1039
+ sessionId: params.sessionId,
1040
+ path: absolutePath,
1041
+ content,
1042
+ });
1043
+ }
1044
+ : undefined,
1045
+ },
1046
+ onExecuteCommand: async (command, args, cwd) => {
1047
+ // Per ACP spec, only call terminal/* if the client advertised the
1048
+ // capability in initialize. Otherwise execute locally.
1049
+ if (!clientSupportsTerminal) {
1050
+ const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
1051
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
1052
+ }
1053
+ try {
1054
+ const createResult = await transport.request('terminal/create', {
1055
+ sessionId: params.sessionId,
1056
+ command,
1057
+ args,
1058
+ cwd,
1059
+ outputByteLimit: 1_000_000,
1060
+ });
1061
+ const { terminalId } = createResult;
1062
+ // Spec method is snake_case `terminal/wait_for_exit` and takes
1063
+ // only { sessionId, terminalId } — no timeoutMs.
1064
+ const waitResult = await transport.request('terminal/wait_for_exit', {
1065
+ sessionId: params.sessionId,
1066
+ terminalId,
1067
+ });
1068
+ const outputResult = await transport.request('terminal/output', {
1069
+ sessionId: params.sessionId,
1070
+ terminalId,
1071
+ });
1072
+ await transport.request('terminal/release', {
1073
+ sessionId: params.sessionId,
1074
+ terminalId,
1075
+ });
1076
+ const exitCode = waitResult.exitStatus.type === 'exited' ? waitResult.exitStatus.code : 1;
1077
+ return { stdout: outputResult.output ?? '', stderr: '', exitCode };
1078
+ }
1079
+ catch (err) {
1080
+ // Client terminal failed — fall back to local execution
1081
+ const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
1082
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
1083
+ }
1084
+ },
1085
+ }).then(() => {
1086
+ session.history.push({ role: 'user', content: prompt });
1087
+ const agentResponse = agentResponseChunks.join('');
1088
+ if (agentResponse) {
1089
+ session.history.push({ role: 'assistant', content: agentResponse });
1037
1090
  }
1038
- try {
1039
- const createResult = await transport.request('terminal/create', {
1040
- sessionId: params.sessionId,
1041
- command,
1042
- args,
1043
- cwd,
1044
- outputByteLimit: 1_000_000,
1045
- });
1046
- const { terminalId } = createResult;
1047
- // Spec method is snake_case `terminal/wait_for_exit` and takes
1048
- // only { sessionId, terminalId } — no timeoutMs.
1049
- const waitResult = await transport.request('terminal/wait_for_exit', {
1050
- sessionId: params.sessionId,
1051
- terminalId,
1052
- });
1053
- const outputResult = await transport.request('terminal/output', {
1054
- sessionId: params.sessionId,
1055
- terminalId,
1056
- });
1057
- await transport.request('terminal/release', {
1058
- sessionId: params.sessionId,
1059
- terminalId,
1060
- });
1061
- const exitCode = waitResult.exitStatus.type === 'exited' ? waitResult.exitStatus.code : 1;
1062
- return { stdout: outputResult.output ?? '', stderr: '', exitCode };
1091
+ autoSaveSession(session.history, session.workspaceRoot);
1092
+ // Report token usage to dashboard
1093
+ const projectCtx = getProjectContext(session.workspaceRoot);
1094
+ const sharedFields = {
1095
+ sessionId: session.codeepSessionId,
1096
+ sessionName: session.codeepSessionId,
1097
+ messageCount: session.history.length,
1098
+ cliVersion: getCurrentVersion(),
1099
+ projectName: projectCtx?.name,
1100
+ projectId: generateProjectId(session.workspaceRoot),
1101
+ language: projectCtx?.type,
1102
+ isGit: isGitRepository(session.workspaceRoot),
1103
+ };
1104
+ const costBreakdown = getCostBreakdown(tokenReportStart);
1105
+ if (costBreakdown.length > 0) {
1106
+ for (const entry of costBreakdown) {
1107
+ reportStats({
1108
+ ...sharedFields,
1109
+ model: entry.model,
1110
+ provider: entry.provider,
1111
+ inputTokens: entry.promptTokens || undefined,
1112
+ outputTokens: entry.completionTokens || undefined,
1113
+ cacheCreationTokens: entry.cacheCreationTokens || undefined,
1114
+ cacheReadTokens: entry.cacheReadTokens || undefined,
1115
+ estimatedCost: entry.estimatedCost || undefined,
1116
+ });
1117
+ }
1063
1118
  }
1064
- catch (err) {
1065
- // Client terminal failed — fall back to local execution
1066
- const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
1067
- return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
1119
+ else {
1120
+ reportStats({ ...sharedFields, model: config.get('model'), provider: config.get('provider') });
1068
1121
  }
1069
- },
1070
- }).then(() => {
1071
- session.history.push({ role: 'user', content: prompt });
1072
- const agentResponse = agentResponseChunks.join('');
1073
- if (agentResponse) {
1074
- session.history.push({ role: 'assistant', content: agentResponse });
1075
- }
1076
- autoSaveSession(session.history, session.workspaceRoot);
1077
- // Report token usage to dashboard
1078
- const projectCtx = getProjectContext(session.workspaceRoot);
1079
- const sharedFields = {
1080
- sessionId: session.codeepSessionId,
1081
- sessionName: session.codeepSessionId,
1082
- messageCount: session.history.length,
1083
- cliVersion: getCurrentVersion(),
1084
- projectName: projectCtx?.name,
1085
- projectId: generateProjectId(session.workspaceRoot),
1086
- language: projectCtx?.type,
1087
- isGit: isGitRepository(session.workspaceRoot),
1088
- };
1089
- const costBreakdown = getCostBreakdown();
1090
- if (costBreakdown.length > 0) {
1091
- for (const entry of costBreakdown) {
1092
- reportStats({
1093
- ...sharedFields,
1094
- model: entry.model,
1095
- provider: entry.provider,
1096
- inputTokens: entry.promptTokens || undefined,
1097
- outputTokens: entry.completionTokens || undefined,
1098
- estimatedCost: entry.estimatedCost || undefined,
1099
- });
1122
+ // Sync session history to dashboard
1123
+ syncSession({
1124
+ sessionId: session.codeepSessionId,
1125
+ projectName: projectCtx?.name,
1126
+ projectId: generateProjectId(session.workspaceRoot),
1127
+ messages: session.history,
1128
+ });
1129
+ // Update title with first real prompt if session had no history
1130
+ if (!session.titleSent && !session.hadHistory) {
1131
+ session.titleSent = true;
1132
+ sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
1100
1133
  }
1101
- }
1102
- else {
1103
- reportStats({ ...sharedFields, model: config.get('model'), provider: config.get('provider') });
1104
- }
1105
- // Sync session history to dashboard
1106
- syncSession({
1107
- sessionId: session.codeepSessionId,
1108
- projectName: projectCtx?.name,
1109
- projectId: generateProjectId(session.workspaceRoot),
1110
- messages: session.history,
1111
- });
1112
- // Update title with first real prompt if session had no history
1113
- if (!session.titleSent && !session.hadHistory) {
1114
- session.titleSent = true;
1115
- sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
1116
- }
1117
- transport.respond(msg.id, { stopReason: 'end_turn' });
1118
- }).catch((err) => {
1119
- if (err.name === 'AbortError') {
1120
- // Clear plan UI on the client side when session is cancelled
1121
- if (planEntries.size > 0) {
1122
- planEntries.clear();
1123
- sendPlan();
1134
+ transport.respond(msg.id, { stopReason: 'end_turn' });
1135
+ }).catch((err) => {
1136
+ if (err.name === 'AbortError') {
1137
+ // Clear plan UI on the client side when session is cancelled
1138
+ if (planEntries.size > 0) {
1139
+ planEntries.clear();
1140
+ sendPlan();
1141
+ }
1142
+ transport.respond(msg.id, { stopReason: 'cancelled' });
1124
1143
  }
1125
- transport.respond(msg.id, { stopReason: 'cancelled' });
1126
- }
1127
- else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1144
+ else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1145
+ sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1146
+ transport.respond(msg.id, { stopReason: 'end_turn' });
1147
+ }
1148
+ else if (err instanceof ApiError && err.status >= 500) {
1149
+ sendChunk(`⚠️ API server error (${err.status}). Please try again.`);
1150
+ transport.respond(msg.id, { stopReason: 'end_turn' });
1151
+ }
1152
+ else {
1153
+ transport.error(msg.id, -32000, err.message);
1154
+ }
1155
+ }).finally(() => {
1156
+ if (session)
1157
+ session.abortController = null;
1158
+ planEntries.clear();
1159
+ });
1160
+ })
1161
+ .catch((err) => {
1162
+ if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1128
1163
  sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1129
1164
  transport.respond(msg.id, { stopReason: 'end_turn' });
1130
1165
  }
@@ -1135,26 +1170,9 @@ export function startAcpServer() {
1135
1170
  else {
1136
1171
  transport.error(msg.id, -32000, err.message);
1137
1172
  }
1138
- }).finally(() => {
1139
1173
  if (session)
1140
1174
  session.abortController = null;
1141
- planEntries.clear();
1142
1175
  });
1143
- })
1144
- .catch((err) => {
1145
- if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
1146
- sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
1147
- transport.respond(msg.id, { stopReason: 'end_turn' });
1148
- }
1149
- else if (err instanceof ApiError && err.status >= 500) {
1150
- sendChunk(`⚠️ API server error (${err.status}). Please try again.`);
1151
- transport.respond(msg.id, { stopReason: 'end_turn' });
1152
- }
1153
- else {
1154
- transport.error(msg.id, -32000, err.message);
1155
- }
1156
- if (session)
1157
- session.abortController = null;
1158
1176
  });
1159
1177
  }
1160
1178
  // Keep process alive until stdin closes (Zed terminates us)
@@ -428,8 +428,9 @@ export const PROVIDERS = {
428
428
  },
429
429
  },
430
430
  models: [
431
+ { id: 'claude-fable-5', name: 'Claude Fable 5', description: 'Most capable — hardest reasoning & long-horizon agentic work' },
431
432
  { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Most capable Opus model' },
432
- { id: 'claude-sonnet-4-6', name: 'Claude Sonnet', description: 'Best balance of speed and intelligence' },
433
+ { id: 'claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Best balance of speed and intelligence' },
433
434
  { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku', description: 'Fastest and most affordable' },
434
435
  ],
435
436
  defaultModel: 'claude-opus-4-8',
@@ -475,8 +476,9 @@ export const PROVIDERS = {
475
476
  // get a working dropdown.
476
477
  models: [
477
478
  { id: 'openrouter/auto', name: 'Auto-route', description: 'OpenRouter picks the best model for the task' },
478
- { id: 'anthropic/claude-opus-4', name: 'Claude Opus 4', description: 'Anthropic — most capable' },
479
- { id: 'anthropic/claude-sonnet-4', name: 'Claude Sonnet 4', description: 'Anthropic — balanced' },
479
+ { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', description: 'Anthropic — most capable' },
480
+ { id: 'anthropic/claude-opus-4', name: 'Claude Opus 4', description: 'Anthropic — Opus tier' },
481
+ { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Anthropic — balanced' },
480
482
  { id: 'openai/gpt-5.5', name: 'GPT-5.5', description: 'OpenAI — flagship' },
481
483
  { id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', description: 'OpenAI — fast/cheap' },
482
484
  { id: 'google/gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Google — multimodal' },
@@ -651,14 +653,15 @@ export function providerNoStreamWithTools(providerId) {
651
653
  }
652
654
  /**
653
655
  * Models that reject sampling parameters (temperature/top_p/top_k) with a 400.
654
- * Anthropic removed them on Fable 5 and Opus 4.7+; older Claude models still
655
- * accept them, so this must be a MODEL-level check, not a provider-level one
656
- * (requiresDefaultTemperature can't express it). Omitting the field is always
657
- * safe — the API treats omission as default. Kimi K2.x code/thinking models
658
- * fix temperature internally and 400 on any custom value, so they're here too.
656
+ * Anthropic removed them on Fable 5 and Opus 4.7+, and Sonnet 5 rejects any
657
+ * non-default value; older Claude models still accept them, so this must be a
658
+ * MODEL-level check, not a provider-level one (requiresDefaultTemperature
659
+ * can't express it). Omitting the field is always safe — the API treats
660
+ * omission as default. Kimi K2.x code/thinking models fix temperature
661
+ * internally and 400 on any custom value, so they're here too.
659
662
  */
660
663
  const SAMPLING_PARAMS_REJECTED = [
661
- 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7',
664
+ 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-5',
662
665
  'kimi-k2.7-code', 'kimi-for-coding',
663
666
  ];
664
667
  export function modelRejectsSamplingParams(model) {
@@ -701,10 +704,10 @@ export function modelSupportsReasoningEffort(providerId, model) {
701
704
  const id = canonicalModelId(model);
702
705
  switch (providerId) {
703
706
  case 'anthropic':
704
- // Effort is GA on Opus 4.5+, Sonnet 4.6, Fable 5 — NOT Haiku or Sonnet 4.5.
707
+ // Effort is GA on Opus 4.5+, Sonnet 4.6/5, Fable 5 — NOT Haiku or Sonnet 4.5.
705
708
  if (idMatches(id, 'claude-haiku-4-5') || idMatches(id, 'claude-sonnet-4-5'))
706
709
  return false;
707
- return /^claude-(opus-4-([5-9]|\d\d)|sonnet-4-6|fable-5)/.test(id);
710
+ return /^claude-(opus-4-([5-9]|\d\d)|sonnet-(4-6|5)|fable-5)/.test(id);
708
711
  case 'openai':
709
712
  // GPT-5.x are reasoning models — reasoning_effort across the family (incl. mini).
710
713
  return id.startsWith('gpt-5');
@@ -722,7 +725,12 @@ export function modelSupportsReasoningEffort(providerId, model) {
722
725
  return idMatches(id, 'glm-5-2');
723
726
  case 'grok':
724
727
  // Grok reasoning models accept reasoning_effort (none/low/medium/high).
725
- // Explicit *-non-reasoning variants don't think → excluded.
728
+ // The coders (grok-code-fast, grok-build — the default) are NON-reasoning
729
+ // and 400 on reasoning_effort; a 400 here silently drops the whole turn
730
+ // into the weaker text-tool fallback (agentChat.ts), so exclude them
731
+ // alongside the explicit *-non-reasoning variants.
732
+ if (id.startsWith('grok-build') || id.startsWith('grok-code'))
733
+ return false;
726
734
  return id.startsWith('grok') && !id.includes('non-reasoning');
727
735
  // Kimi (thinking on/off, not graded) and Qwen coders (non-thinking) have
728
736
  // no graded knob → fall through to default false.
@@ -23,7 +23,7 @@ export interface AppExecutionContext {
23
23
  formatAddedFilesContext: () => string;
24
24
  handleCommand: (command: string, args: string[]) => Promise<void>;
25
25
  sessionDisplayName?: string;
26
- setSessionDisplayName?: (name: string) => void;
26
+ setSessionDisplayName?: (name: string | null) => void;
27
27
  }
28
28
  export declare function isDangerousTool(toolName: string, parameters: Record<string, unknown>): boolean;
29
29
  export declare function requestToolConfirmation(app: App, tool: string, parameters: Record<string, unknown>, onConfirm: () => void, onCancel: () => void): void;
@@ -10,7 +10,7 @@ import { runAgent } from '../utils/agent.js';
10
10
  import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
11
11
  import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
12
12
  import { getGitStatus, isGitRepository } from '../utils/git.js';
13
- import { getCostBreakdown, resetTokenTracking } from '../utils/tokenTracker.js';
13
+ import { getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
14
14
  function getActionType(toolName) {
15
15
  return toolName.includes('write') ? 'write' :
16
16
  toolName.includes('edit') ? 'edit' :
@@ -140,7 +140,9 @@ export async function executeAgentTask(task, dryRun, ctx) {
140
140
  ctx.setAgentRunning(true);
141
141
  const abortController = new AbortController();
142
142
  ctx.setAbortController(abortController);
143
- resetTokenTracking(); // Reset per-run so reportStats sends only this run's tokens
143
+ // Marker for cloud reporting: report only this run's tokens to the dashboard
144
+ // without wiping the session-cumulative store the status bar and `/cost` read.
145
+ const tokenReportStart = getRecordCount();
144
146
  const prefix = dryRun ? '[DRY RUN] ' : '[AGENT] ';
145
147
  app.addMessage({ role: 'user', content: prefix + task });
146
148
  app.setAgentRunning(true);
@@ -378,8 +380,9 @@ export async function executeAgentTask(task, dryRun, ctx) {
378
380
  messages: app.getMessages(),
379
381
  });
380
382
  // Report per-model so tokens are attributed to the correct model/provider
381
- // even if the user switched model mid-session.
382
- const costBreakdown = getCostBreakdown();
383
+ // even if the user switched model mid-session. Only this run's delta
384
+ // (since tokenReportStart) is reported; the cumulative store is preserved.
385
+ const costBreakdown = getCostBreakdown(tokenReportStart);
383
386
  const sharedFields = {
384
387
  sessionId,
385
388
  sessionName: displayName,
@@ -398,6 +401,8 @@ export async function executeAgentTask(task, dryRun, ctx) {
398
401
  provider: entry.provider,
399
402
  inputTokens: entry.promptTokens || undefined,
400
403
  outputTokens: entry.completionTokens || undefined,
404
+ cacheCreationTokens: entry.cacheCreationTokens || undefined,
405
+ cacheReadTokens: entry.cacheReadTokens || undefined,
401
406
  estimatedCost: entry.estimatedCost || undefined,
402
407
  });
403
408
  }
@@ -679,6 +679,9 @@ export async function handleCommand(command, args, ctx) {
679
679
  case 'new': {
680
680
  ctx.app.clearMessages();
681
681
  ctx.setSessionId(startNewSession());
682
+ // Clear the derived display name so the next chat re-derives it — else
683
+ // the new session syncs/reports under the PREVIOUS session's name.
684
+ ctx.setSessionDisplayName?.(null);
682
685
  ctx.app.notify('New session started');
683
686
  break;
684
687
  }
@@ -1071,7 +1074,7 @@ Format: use headers per category, only include categories where you found issues
1071
1074
  return;
1072
1075
  }
1073
1076
  const index = blockNum === -1 ? codeBlocks.length - 1 : blockNum - 1;
1074
- if (index < 0 || index >= codeBlocks.length) {
1077
+ if (Number.isNaN(index) || index < 0 || index >= codeBlocks.length) {
1075
1078
  ctx.app.notify(`Invalid block number. Available: 1-${codeBlocks.length}`);
1076
1079
  return;
1077
1080
  }
@@ -203,6 +203,8 @@ async function handleSubmit(message) {
203
203
  provider: entry.provider,
204
204
  inputTokens: entry.promptTokens || undefined,
205
205
  outputTokens: entry.completionTokens || undefined,
206
+ cacheCreationTokens: entry.cacheCreationTokens || undefined,
207
+ cacheReadTokens: entry.cacheReadTokens || undefined,
206
208
  estimatedCost: entry.estimatedCost || undefined,
207
209
  });
208
210
  }
@@ -888,6 +890,8 @@ async function gracefulShutdown() {
888
890
  projectId,
889
891
  inputTokens: tokenStats.totalPromptTokens || undefined,
890
892
  outputTokens: tokenStats.totalCompletionTokens || undefined,
893
+ cacheCreationTokens: tokenStats.totalCacheCreationTokens || undefined,
894
+ cacheReadTokens: tokenStats.totalCacheReadTokens || undefined,
891
895
  estimatedCost: tokenStats.estimatedCost || undefined,
892
896
  }),
893
897
  ]);
@@ -427,7 +427,11 @@ additionalTools) {
427
427
  model, messages: [{ role: 'system', content: systemPrompt }, ...messages],
428
428
  tools: getOpenAITools(additionalTools), tool_choice: 'auto', stream: useStreaming,
429
429
  ...tempParam, ...tokParam, ...reasoningParam,
430
- ...(useStreaming && providerId === 'openai' ? { stream_options: { include_usage: true } } : {}),
430
+ // Ask ALL OpenAI-compatible providers to emit a usage block in the
431
+ // stream — without this most (DeepSeek/Kimi/Grok/Qwen/GLM/…) send no
432
+ // usage on streamed responses and the whole turn records zero tokens.
433
+ // (OpenRouter also gets usage via openRouterExtras below; both is fine.)
434
+ ...(useStreaming ? { stream_options: { include_usage: true } } : {}),
431
435
  ...openRouterExtras,
432
436
  };
433
437
  }
@@ -25,6 +25,12 @@ export interface StatsPayload {
25
25
  isGit?: boolean;
26
26
  inputTokens?: number;
27
27
  outputTokens?: number;
28
+ /** Anthropic prompt caching: tokens written to cache (billed ~1.25× input).
29
+ * Undefined for providers that don't report caching. */
30
+ cacheCreationTokens?: number;
31
+ /** Anthropic prompt caching: tokens read from cache (billed ~0.1× input).
32
+ * Undefined for providers that don't report caching. */
33
+ cacheReadTokens?: number;
28
34
  estimatedCost?: number;
29
35
  }
30
36
  /**
@@ -19,6 +19,10 @@ export interface SessionTokenStats {
19
19
  totalTokens: number;
20
20
  requestCount: number;
21
21
  estimatedCost: number;
22
+ /** Anthropic prompt caching: total tokens written to cache this session. */
23
+ totalCacheCreationTokens: number;
24
+ /** Anthropic prompt caching: total tokens read from cache this session. */
25
+ totalCacheReadTokens: number;
22
26
  }
23
27
  interface TokenRecord {
24
28
  timestamp: number;
@@ -42,6 +46,18 @@ export declare function getPricingTable(): {
42
46
  inputPer1M: number;
43
47
  outputPer1M: number;
44
48
  }[];
49
+ /** An isolated token-record buffer for one scope (e.g. one ACP session). */
50
+ export type TokenScope = TokenRecord[];
51
+ /** Create a fresh, empty scope buffer (one per ACP session). */
52
+ export declare function createTokenScope(): TokenScope;
53
+ /**
54
+ * Run `fn` with `scope` as the active token-record buffer. Every
55
+ * recordTokenUsage() call made within `fn`'s async flow (including across
56
+ * awaits) accumulates into `scope`, and reads (getCostBreakdown/…) made in the
57
+ * same flow see only `scope`. Used by the ACP server to isolate per-session
58
+ * usage without threading a session id through the deep API layer.
59
+ */
60
+ export declare function runWithTokenScope<T>(scope: TokenScope, fn: () => T): T;
45
61
  /**
46
62
  * Record token usage from an API response. The optional `actualCostUsd`
47
63
  * argument lets aggregator providers (OpenRouter) pass through the
@@ -63,12 +79,23 @@ export interface ProviderCostBreakdown {
63
79
  model: string;
64
80
  promptTokens: number;
65
81
  completionTokens: number;
82
+ /** Anthropic prompt caching: tokens written to cache (billed ~1.25× input).
83
+ * 0 for providers that don't report caching. */
84
+ cacheCreationTokens: number;
85
+ /** Anthropic prompt caching: tokens read from cache (billed ~0.1× input).
86
+ * 0 for providers that don't report caching. */
87
+ cacheReadTokens: number;
66
88
  estimatedCost: number;
67
89
  }
68
90
  /**
69
- * Get cost breakdown grouped by provider/model
91
+ * Get cost breakdown grouped by provider/model.
92
+ *
93
+ * `startIndex` lets callers price only the records appended since a marker (see
94
+ * getRecordCount) — used to report a single run/prompt's delta to cloud
95
+ * telemetry WITHOUT wiping the session-cumulative store the status bar and
96
+ * `/cost` read. Defaults to 0 (the whole current scope).
70
97
  */
71
- export declare function getCostBreakdown(): ProviderCostBreakdown[];
98
+ export declare function getCostBreakdown(startIndex?: number): ProviderCostBreakdown[];
72
99
  /**
73
100
  * Aggregate Anthropic prompt-caching stats for the current session.
74
101
  * Returns the breakdown plus an estimate of what the input billing would
@@ -95,7 +122,17 @@ export declare function getLastUsage(): TokenRecord | null;
95
122
  */
96
123
  export declare function formatTokenCount(tokens: number): string;
97
124
  /**
98
- * Reset session tracking
125
+ * Number of records in the current scope. Capture before a run/prompt and pass
126
+ * it to getCostBreakdown(startIndex) to price just that run's delta (for cloud
127
+ * telemetry) without wiping the cumulative store the status bar and `/cost`
128
+ * read.
129
+ */
130
+ export declare function getRecordCount(): number;
131
+ /**
132
+ * Reset the current scope's tracking. Production run/prompt paths no longer
133
+ * call this (they use getRecordCount + getCostBreakdown(startIndex) so the
134
+ * session-cumulative totals survive); retained for the test suite, which uses
135
+ * it to isolate the process-wide default buffer between cases.
99
136
  */
100
137
  export declare function resetTokenTracking(): void;
101
138
  /**
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Token and cost tracking for API usage
3
3
  */
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
5
  // Context window sizes per model (in tokens).
5
6
  // Keep this table in lockstep with `providers.ts` — entries for models that
6
7
  // aren't in the provider catalogue only show up if a user types an id by hand
@@ -14,8 +15,10 @@ const MODEL_CONTEXT_WINDOWS = {
14
15
  'gpt-5.4': 1_050_000,
15
16
  'gpt-5.4-mini': 400_000,
16
17
  // Anthropic
18
+ 'claude-fable-5': 1_000_000,
17
19
  'claude-opus-4-8': 1_000_000,
18
20
  'claude-sonnet-4-6': 1_000_000,
21
+ 'claude-sonnet-5': 1_000_000,
19
22
  'claude-haiku-4-5-20251001': 200_000,
20
23
  // DeepSeek
21
24
  'deepseek-v4-pro': 1_000_000,
@@ -67,8 +70,10 @@ const MODEL_PRICING = {
67
70
  'gpt-5.4': { inputPer1M: 2.50, outputPer1M: 15.00 },
68
71
  'gpt-5.4-mini': { inputPer1M: 0.75, outputPer1M: 4.50 },
69
72
  // Anthropic
73
+ 'claude-fable-5': { inputPer1M: 10.00, outputPer1M: 50.00 },
70
74
  'claude-opus-4-8': { inputPer1M: 5.00, outputPer1M: 25.00 },
71
75
  'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00 },
76
+ 'claude-sonnet-5': { inputPer1M: 3.00, outputPer1M: 15.00 },
72
77
  'claude-haiku-4-5-20251001': { inputPer1M: 1.00, outputPer1M: 5.00 },
73
78
  // DeepSeek (cache-miss input pricing)
74
79
  'deepseek-v4-pro': { inputPer1M: 1.74, outputPer1M: 3.48 },
@@ -103,8 +108,27 @@ const MODEL_PRICING = {
103
108
  export function getPricingTable() {
104
109
  return Object.entries(MODEL_PRICING).map(([model, p]) => ({ model, ...p }));
105
110
  }
106
- // Session-level accumulator
107
- const records = [];
111
+ const defaultRecords = [];
112
+ const recordsStore = new AsyncLocalStorage();
113
+ /** The record buffer for the current async flow (a scope's buffer inside
114
+ * runWithTokenScope, otherwise the process-wide default). */
115
+ function currentRecords() {
116
+ return recordsStore.getStore() ?? defaultRecords;
117
+ }
118
+ /** Create a fresh, empty scope buffer (one per ACP session). */
119
+ export function createTokenScope() {
120
+ return [];
121
+ }
122
+ /**
123
+ * Run `fn` with `scope` as the active token-record buffer. Every
124
+ * recordTokenUsage() call made within `fn`'s async flow (including across
125
+ * awaits) accumulates into `scope`, and reads (getCostBreakdown/…) made in the
126
+ * same flow see only `scope`. Used by the ACP server to isolate per-session
127
+ * usage without threading a session id through the deep API layer.
128
+ */
129
+ export function runWithTokenScope(scope, fn) {
130
+ return recordsStore.run(scope, fn);
131
+ }
108
132
  /**
109
133
  * Record token usage from an API response. The optional `actualCostUsd`
110
134
  * argument lets aggregator providers (OpenRouter) pass through the
@@ -113,7 +137,7 @@ const records = [];
113
137
  * for every OpenRouter-listed model — there are 100+).
114
138
  */
115
139
  export function recordTokenUsage(usage, model, provider, actualCostUsd) {
116
- records.push({
140
+ currentRecords().push({
117
141
  timestamp: Date.now(),
118
142
  promptTokens: usage.promptTokens,
119
143
  completionTokens: usage.completionTokens,
@@ -130,10 +154,16 @@ export function recordTokenUsage(usage, model, provider, actualCostUsd) {
130
154
  */
131
155
  export function extractOpenAIUsage(data) {
132
156
  if (data?.usage) {
157
+ // OpenAI-protocol `prompt_tokens` is INCLUSIVE of cached prompt tokens
158
+ // (DeepSeek/OpenAI report cache hits in prompt_tokens_details.cached_tokens).
159
+ // Surface them so getCostBreakdown bills cache reads at the discounted
160
+ // rate instead of the full cache-miss input rate.
161
+ const cached = data.usage.prompt_tokens_details?.cached_tokens || 0;
133
162
  return {
134
163
  promptTokens: data.usage.prompt_tokens || 0,
135
164
  completionTokens: data.usage.completion_tokens || 0,
136
165
  totalTokens: data.usage.total_tokens || 0,
166
+ cacheReadTokens: cached || undefined,
137
167
  };
138
168
  }
139
169
  return null;
@@ -161,15 +191,22 @@ export function extractAnthropicUsage(data) {
161
191
  return null;
162
192
  }
163
193
  /**
164
- * Get cost breakdown grouped by provider/model
194
+ * Get cost breakdown grouped by provider/model.
195
+ *
196
+ * `startIndex` lets callers price only the records appended since a marker (see
197
+ * getRecordCount) — used to report a single run/prompt's delta to cloud
198
+ * telemetry WITHOUT wiping the session-cumulative store the status bar and
199
+ * `/cost` read. Defaults to 0 (the whole current scope).
165
200
  */
166
- export function getCostBreakdown() {
201
+ export function getCostBreakdown(startIndex = 0) {
167
202
  const grouped = new Map();
168
- for (const record of records) {
203
+ for (const record of currentRecords().slice(startIndex)) {
169
204
  const key = `${record.provider}/${record.model}`;
170
- const existing = grouped.get(key) ?? { provider: record.provider, model: record.model, promptTokens: 0, completionTokens: 0, estimatedCost: 0 };
205
+ const existing = grouped.get(key) ?? { provider: record.provider, model: record.model, promptTokens: 0, completionTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, estimatedCost: 0 };
171
206
  existing.promptTokens += record.promptTokens;
172
207
  existing.completionTokens += record.completionTokens;
208
+ existing.cacheCreationTokens += record.cacheCreationTokens ?? 0;
209
+ existing.cacheReadTokens += record.cacheReadTokens ?? 0;
173
210
  // Cost source priority:
174
211
  // 1. Provider-reported USD (OpenRouter, MaxiCloud, etc.) — most accurate.
175
212
  // 2. Our MODEL_PRICING table — for built-in providers we maintain rates for.
@@ -201,7 +238,7 @@ export function getCacheStats() {
201
238
  let cacheCreate = 0;
202
239
  let cacheRead = 0;
203
240
  let savings = 0;
204
- for (const record of records) {
241
+ for (const record of currentRecords()) {
205
242
  cacheCreate += record.cacheCreationTokens ?? 0;
206
243
  cacheRead += record.cacheReadTokens ?? 0;
207
244
  // Savings = what cache-read tokens would have cost at full input rate,
@@ -223,24 +260,31 @@ export function getSessionStats() {
223
260
  let totalPromptTokens = 0;
224
261
  let totalCompletionTokens = 0;
225
262
  let totalTokens = 0;
226
- for (const record of records) {
263
+ let totalCacheCreationTokens = 0;
264
+ let totalCacheReadTokens = 0;
265
+ for (const record of currentRecords()) {
227
266
  totalPromptTokens += record.promptTokens;
228
267
  totalCompletionTokens += record.completionTokens;
229
268
  totalTokens += record.totalTokens;
269
+ totalCacheCreationTokens += record.cacheCreationTokens ?? 0;
270
+ totalCacheReadTokens += record.cacheReadTokens ?? 0;
230
271
  }
231
272
  const estimatedCost = getCostBreakdown().reduce((s, b) => s + b.estimatedCost, 0);
232
273
  return {
233
274
  totalPromptTokens,
234
275
  totalCompletionTokens,
235
276
  totalTokens,
236
- requestCount: records.length,
277
+ requestCount: currentRecords().length,
237
278
  estimatedCost,
279
+ totalCacheCreationTokens,
280
+ totalCacheReadTokens,
238
281
  };
239
282
  }
240
283
  /**
241
284
  * Get last request usage
242
285
  */
243
286
  export function getLastUsage() {
287
+ const records = currentRecords();
244
288
  return records.length > 0 ? records[records.length - 1] : null;
245
289
  }
246
290
  /**
@@ -254,10 +298,22 @@ export function formatTokenCount(tokens) {
254
298
  return (tokens / 1000000).toFixed(2) + 'M';
255
299
  }
256
300
  /**
257
- * Reset session tracking
301
+ * Number of records in the current scope. Capture before a run/prompt and pass
302
+ * it to getCostBreakdown(startIndex) to price just that run's delta (for cloud
303
+ * telemetry) without wiping the cumulative store the status bar and `/cost`
304
+ * read.
305
+ */
306
+ export function getRecordCount() {
307
+ return currentRecords().length;
308
+ }
309
+ /**
310
+ * Reset the current scope's tracking. Production run/prompt paths no longer
311
+ * call this (they use getRecordCount + getCostBreakdown(startIndex) so the
312
+ * session-cumulative totals survive); retained for the test suite, which uses
313
+ * it to isolate the process-wide default buffer between cases.
258
314
  */
259
315
  export function resetTokenTracking() {
260
- records.length = 0;
316
+ currentRecords().length = 0;
261
317
  }
262
318
  /**
263
319
  * Format a session cost report as a Markdown block. Used by `/cost` in both
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "2.13.1";
1
+ export declare const VERSION = "2.14.0";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
2
  // Baked from package.json at build time so the bun-compiled binary reports
3
3
  // the right version (it has no package.json on disk to read at runtime).
4
- export const VERSION = '2.13.1';
4
+ export const VERSION = '2.14.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.13.1",
3
+ "version": "2.14.0",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",