@lucascouts/claude-agent-acp-plus 0.12.0 → 0.13.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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * `/usage` rendered as Markdown, and the bounded wait that decides whether it
3
+ * renders at all (story 011, R2.3/R2.4, design D5).
4
+ *
5
+ * The command ALWAYS runs through Claude Code. What this module produces is a
6
+ * best-effort overlay on top of an answer the user already has, so every
7
+ * failure here costs the overlay and nothing else — the caller forwards Claude
8
+ * Code's own text byte-for-byte instead. Three ways out, all of them ending in
9
+ * `null` from {@link structuredUsageMarkdown}:
10
+ *
11
+ * 1. unavailable — the control request rejects, or the method is gone
12
+ * 2. incompatible — the response does not match the shape the renderer reads
13
+ * 3. slow — the response does not arrive inside the bound below
14
+ *
15
+ * THE BOUND IS LOAD-BEARING, NOT DEFENSIVE. Control requests on a fresh session
16
+ * are not serviced until the first turn runs (SDK issues #886/#880), and
17
+ * `/usage` is frequently that first turn — so an unbounded wait would withhold
18
+ * an answer the CLI had already printed, forever. That is why the race below
19
+ * has three legs and not one: the report, a timeout, and the turn's abort
20
+ * signal.
21
+ *
22
+ * ONE READER FOR THE REPORT'S NUMBERS. `account-usage.ts` already maps this same
23
+ * `SDKControlGetUsageResponse` onto the client's rate-limit `_meta`, and it owns
24
+ * the two scalar readings the report needs: `normalizeUtilization` (0..100 ->
25
+ * 0..1, clamped) and `toEpochSeconds` (ISO 8601 -> Unix seconds). Both are
26
+ * imported here rather than re-derived, so the bar this module draws and the bar
27
+ * the client's usage panel draws cannot disagree about what `utilization: 3`
28
+ * means. What is NOT shared is validation: `account-usage.ts` reads defensively
29
+ * and never rejects a report, because a missing field there costs one window,
30
+ * while here an unreadable report has to become a hard "no" so way out 2 can
31
+ * fire. Different questions, so different code — but the same answers to the
32
+ * two questions both of them ask.
33
+ *
34
+ * Kept self-contained — no import from `acp-agent.ts`, mirroring
35
+ * `thinking-option.ts` and `context-compaction.ts`. The reason is merge cost:
36
+ * `acp-agent.ts` is a ~9,700-line file that changes upstream almost daily, so
37
+ * every coupling to it is paid again at each sync. `acp-agent.ts` wires this in
38
+ * at the `/usage` turn: it decides which turn owns a structured render, and
39
+ * publishes the result at most once across the message shapes the SDK can
40
+ * deliver one local command through.
41
+ */
42
+ import { z } from "zod";
43
+ import { normalizeUtilization, toEpochSeconds } from "./account-usage.js";
44
+ /**
45
+ * How long the structured report may take before the original output wins.
46
+ *
47
+ * Not a guess at network latency: the request may never be serviced at all (see
48
+ * the header), so this is the ceiling on how long a user waits for a decoration
49
+ * on an answer that is already sitting in the buffer.
50
+ */
51
+ export const STRUCTURED_USAGE_TIMEOUT_MS = 5_000;
52
+ const countSchema = z.number().finite().nonnegative();
53
+ const percentSchema = countSchema.max(100);
54
+ const usageWindowSchema = z
55
+ .object({ utilization: percentSchema.nullable(), resets_at: z.string().nullable() })
56
+ .nullable()
57
+ .optional();
58
+ const contributionSchema = z.object({ name: z.string(), pct: percentSchema });
59
+ const behaviorPeriodSchema = z.object({
60
+ request_count: countSchema,
61
+ session_count: countSchema,
62
+ mcp_servers: z.array(contributionSchema),
63
+ });
64
+ const modelUsageSchema = z.object({
65
+ inputTokens: countSchema,
66
+ outputTokens: countSchema,
67
+ cacheReadInputTokens: countSchema,
68
+ cacheCreationInputTokens: countSchema,
69
+ });
70
+ const usageResponseSchema = z.object({
71
+ session: z.object({
72
+ total_cost_usd: countSchema,
73
+ total_api_duration_ms: countSchema,
74
+ total_duration_ms: countSchema,
75
+ model_usage: z.record(z.string(), modelUsageSchema),
76
+ }),
77
+ subscription_type: z.string().nullable(),
78
+ rate_limits_available: z.boolean(),
79
+ rate_limits: z
80
+ .object({
81
+ five_hour: usageWindowSchema,
82
+ seven_day: usageWindowSchema,
83
+ seven_day_oauth_apps: usageWindowSchema,
84
+ seven_day_opus: usageWindowSchema,
85
+ seven_day_sonnet: usageWindowSchema,
86
+ model_scoped: z
87
+ .array(z.object({
88
+ display_name: z.string(),
89
+ utilization: percentSchema.nullable(),
90
+ resets_at: z.string().nullable(),
91
+ }))
92
+ .optional(),
93
+ extra_usage: z
94
+ .object({
95
+ is_enabled: z.boolean(),
96
+ monthly_limit: countSchema.nullable(),
97
+ used_credits: countSchema.nullable(),
98
+ utilization: percentSchema.nullable(),
99
+ currency: z.string().nullable().optional(),
100
+ })
101
+ .nullable()
102
+ .optional(),
103
+ })
104
+ .nullable(),
105
+ behaviors: z.object({ day: behaviorPeriodSchema, week: behaviorPeriodSchema }).nullable(),
106
+ });
107
+ /**
108
+ * Validate the experimental SDK response at the runtime boundary — way out 2.
109
+ * Null means "this is not a report this renderer can read", which the caller
110
+ * turns into Claude Code's original text.
111
+ */
112
+ export function parseUsageResponse(value) {
113
+ const parsed = usageResponseSchema.safeParse(value);
114
+ // Validate only the fields the renderer reads, but preserve the COMPLETE
115
+ // response rather than the schema's stripped output. The API's own doc
116
+ // comment says the shape may change; a value added to a subtree nobody reads
117
+ // must not cost the already-validated part that renders fine.
118
+ return parsed.success ? value : null;
119
+ }
120
+ /**
121
+ * Whether a prompt is exactly the local `/usage` command.
122
+ *
123
+ * Exact, not a prefix: `/usage now` is an argument form this renderer has no
124
+ * mapping for, and prose that merely mentions the word is a model turn.
125
+ */
126
+ export function isUsageCommandText(text) {
127
+ return text.trim() === "/usage";
128
+ }
129
+ /** Cells in a progress bar. Fixed width, so bars line up under one another. */
130
+ const USAGE_BAR_CELLS = 20;
131
+ /**
132
+ * A bar for a utilization already normalised to 0..1 by
133
+ * {@link normalizeUtilization} — which is what keeps this bar and the client's
134
+ * own rate-limit bar on the same scale.
135
+ *
136
+ * A non-zero fraction always fills at least one cell: rounding 0.6% to an empty
137
+ * bar would render "some usage" and "none" identically.
138
+ */
139
+ function usageBar(fraction) {
140
+ const filled = fraction === 0 ? 0 : Math.max(1, Math.round(fraction * USAGE_BAR_CELLS));
141
+ return `${"\u2588".repeat(filled)}${"\u2591".repeat(USAGE_BAR_CELLS - filled)}`;
142
+ }
143
+ /** Server-supplied text is DATA, not Markdown: neutralise it before inlining. */
144
+ function escapeMarkdown(value) {
145
+ return value.replace(/([\\`*_[\]<>|])/g, "\\$1").replace(/[\r\n]+/g, " ");
146
+ }
147
+ /** Milliseconds per second — {@link toEpochSeconds} answers in the latter. */
148
+ const MILLISECONDS_PER_SECOND = 1000;
149
+ const SECONDS_PER_MINUTE = 60;
150
+ function formatDuration(milliseconds) {
151
+ const seconds = Math.max(0, Math.round(milliseconds / MILLISECONDS_PER_SECOND));
152
+ if (seconds < SECONDS_PER_MINUTE) {
153
+ return `${seconds}s`;
154
+ }
155
+ const minutes = Math.floor(seconds / SECONDS_PER_MINUTE);
156
+ const remainder = seconds % SECONDS_PER_MINUTE;
157
+ return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`;
158
+ }
159
+ const COUNT_FORMAT = new Intl.NumberFormat("en", {
160
+ notation: "compact",
161
+ maximumFractionDigits: 1,
162
+ });
163
+ const RESET_FORMAT = new Intl.DateTimeFormat("en", {
164
+ month: "short",
165
+ day: "numeric",
166
+ hour: "numeric",
167
+ minute: "2-digit",
168
+ timeZoneName: "short",
169
+ });
170
+ function formatCount(value) {
171
+ return COUNT_FORMAT.format(value);
172
+ }
173
+ /**
174
+ * The reset suffix for a limit row, or empty when the report carried no instant.
175
+ *
176
+ * Parsed through {@link toEpochSeconds} so this module and the quota-window
177
+ * transport agree on which instants are readable at all. An instant that reader
178
+ * rejects is still SHOWN, verbatim and escaped: the report said something, and
179
+ * printing it is more useful than silently dropping it.
180
+ */
181
+ function formatReset(value) {
182
+ if (!value) {
183
+ return "";
184
+ }
185
+ const seconds = toEpochSeconds(value);
186
+ if (seconds === undefined) {
187
+ return ` \u00b7 Resets ${escapeMarkdown(value)}`;
188
+ }
189
+ return ` \u00b7 Resets ${RESET_FORMAT.format(new Date(seconds * MILLISECONDS_PER_SECOND))}`;
190
+ }
191
+ /**
192
+ * One limit row — heading, then its bar — or nothing when the report supplied no
193
+ * usable utilization for that window.
194
+ *
195
+ * The percentage is printed as the report stated it while the BAR is drawn from
196
+ * the normalised fraction. That split is deliberate: the number is the report's
197
+ * own claim, the bar is the shared 0..1 scale, and `normalizeUtilization` is the
198
+ * single place that decides whether a value is usable at all (null, absent and
199
+ * non-finite all collapse to "no row").
200
+ */
201
+ function appendLimit(lines, label, window) {
202
+ const fraction = normalizeUtilization(window?.utilization);
203
+ if (!window || fraction === undefined) {
204
+ return;
205
+ }
206
+ lines.push(`**${escapeMarkdown(label)}** \u2014 **${window.utilization}%**${formatReset(window.resets_at)}`, "", `\`${usageBar(fraction)}\``, "");
207
+ }
208
+ /** How many MCP servers one contribution table lists. */
209
+ const TOP_CONTRIBUTIONS = 3;
210
+ function appendContributions(lines, label, period) {
211
+ lines.push("", `**${label}** \u00b7 ${period.request_count} requests \u00b7 ${period.session_count} sessions`);
212
+ if (period.mcp_servers.length === 0) {
213
+ return;
214
+ }
215
+ lines.push("", "| MCP server | Usage |", "|:--|--:|");
216
+ // Copied before sorting: the report is the caller's object, not ours.
217
+ const ranked = [...period.mcp_servers].sort((a, b) => b.pct - a.pct);
218
+ for (const server of ranked.slice(0, TOP_CONTRIBUTIONS)) {
219
+ const bar = usageBar(normalizeUtilization(server.pct) ?? 0);
220
+ lines.push(`| ${escapeMarkdown(server.name)} | \`${bar}\` ${server.pct}% |`);
221
+ }
222
+ }
223
+ /**
224
+ * The session's own spend: one row of wall-clock facts, then the token
225
+ * breakdown summed across every model the session used.
226
+ *
227
+ * Summed rather than listed per model: the question `/usage` answers is what
228
+ * this session cost, and a per-model split of four counters is a table nobody
229
+ * reads to answer it.
230
+ */
231
+ function appendSessionTotals(lines, session) {
232
+ const totals = Object.values(session.model_usage).reduce((sum, model) => ({
233
+ input: sum.input + model.inputTokens,
234
+ output: sum.output + model.outputTokens,
235
+ cacheRead: sum.cacheRead + model.cacheReadInputTokens,
236
+ cacheWrite: sum.cacheWrite + model.cacheCreationInputTokens,
237
+ }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
238
+ lines.push("", "---", "", "### This session", "", "| Cost | API time | Active |", "|:--|:--|:--|", `| $${session.total_cost_usd.toFixed(2)} | ${formatDuration(session.total_api_duration_ms)} | ${formatDuration(session.total_duration_ms)} |`, "", "| Breakdown | Tokens |", "|:--|--:|", `| Input | ${formatCount(totals.input)} |`, `| Output | ${formatCount(totals.output)} |`, `| Cache read | ${formatCount(totals.cacheRead)} |`, `| Cache write | ${formatCount(totals.cacheWrite)} |`);
239
+ }
240
+ /**
241
+ * The plan's rate-limit windows as labelled bars, or nothing at all.
242
+ *
243
+ * `rate_limits_available` GOVERNS, not the presence of data: an API-key,
244
+ * Bedrock or Vertex session can carry a populated `rate_limits` while declaring
245
+ * plan limits inapplicable, and a section of bars would then claim the account
246
+ * is unused rather than unmeasured. Same rule `account-usage.ts` applies.
247
+ */
248
+ function appendLimits(lines, usage) {
249
+ if (!usage.rate_limits_available || !usage.rate_limits) {
250
+ return;
251
+ }
252
+ const limitLines = [];
253
+ appendLimit(limitLines, "5-hour limit", usage.rate_limits.five_hour);
254
+ appendLimit(limitLines, "Weekly \u00b7 all models", usage.rate_limits.seven_day);
255
+ // The server's own per-model buckets when it emits them; the two fixed fields
256
+ // are the older shape and would double-report the same windows.
257
+ const modelWindows = usage.rate_limits.model_scoped ?? [];
258
+ for (const model of modelWindows) {
259
+ appendLimit(limitLines, `Weekly \u00b7 ${model.display_name}`, model);
260
+ }
261
+ if (modelWindows.length === 0) {
262
+ appendLimit(limitLines, "Weekly \u00b7 Opus", usage.rate_limits.seven_day_opus);
263
+ appendLimit(limitLines, "Weekly \u00b7 Sonnet", usage.rate_limits.seven_day_sonnet);
264
+ }
265
+ // A heading with no rows under it claims the limits are unmeasured rather
266
+ // than unavailable, so the section only exists once a row does.
267
+ if (limitLines.length === 0) {
268
+ return;
269
+ }
270
+ if (limitLines.at(-1) === "") {
271
+ limitLines.pop();
272
+ }
273
+ lines.push("", "### Limits", "", ...limitLines);
274
+ }
275
+ /**
276
+ * What is consuming the plan's limits, as the CLI's own dialog reports it.
277
+ *
278
+ * Null for a session with no claude.ai subscription, or when the local
279
+ * transcript scan behind it failed — in both cases the section is simply
280
+ * absent rather than shown empty.
281
+ */
282
+ function appendBehaviors(lines, usage) {
283
+ if (!usage.behaviors) {
284
+ return;
285
+ }
286
+ lines.push("", "---", "", "### What\u2019s using your limits?", "", "> Approximate, overlapping measures \u00b7 this machine only \u00b7 excludes claude.ai");
287
+ appendContributions(lines, "Last 24h", usage.behaviors.day);
288
+ appendContributions(lines, "Last 7d", usage.behaviors.week);
289
+ }
290
+ /** Render the SDK's structured `/usage` response as Markdown (R2.3). */
291
+ export function formatUsageResponse(usage) {
292
+ const lines = ["## Usage"];
293
+ if (usage.subscription_type) {
294
+ lines.push("", `> Claude ${escapeMarkdown(usage.subscription_type)} subscription usage`);
295
+ }
296
+ appendLimits(lines, usage);
297
+ appendSessionTotals(lines, usage.session);
298
+ appendBehaviors(lines, usage);
299
+ return lines.join("\n");
300
+ }
301
+ /**
302
+ * What the race yields when no report arrived. A symbol rather than `null`, so
303
+ * "the wait ended" can never be confused with a value the SDK sent.
304
+ */
305
+ const NO_REPORT = Symbol("structured-usage-unavailable");
306
+ /**
307
+ * The structured `/usage` render for one turn, or null when any of the three
308
+ * ways out fired (R2.4). Never throws and never resolves late: the caller is
309
+ * holding a completed local command's output while it awaits this.
310
+ *
311
+ * Raced against BOTH a timeout and the turn's abort signal. The timeout covers
312
+ * a request that is never serviced (see the header); the signal covers a turn
313
+ * that ended — cancelled, or settled — while the request was still in flight,
314
+ * so a dead turn's overlay is abandoned rather than merely ignored.
315
+ */
316
+ export async function structuredUsageMarkdown(query, signal, logger) {
317
+ if (signal.aborted) {
318
+ return null;
319
+ }
320
+ let timer;
321
+ let onAbort;
322
+ try {
323
+ const response = await Promise.race([
324
+ // Written out rather than hidden behind a helper, for the same reason
325
+ // `publishAccountUsage` writes it out: this awful spelling is the only
326
+ // name the real `Query` answers to, and `UsageReportQuery` above pins the
327
+ // same literal.
328
+ query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
329
+ new Promise((resolve) => {
330
+ timer = setTimeout(() => resolve(NO_REPORT), STRUCTURED_USAGE_TIMEOUT_MS);
331
+ // The overlay must never be the reason the process stays alive.
332
+ timer.unref?.();
333
+ }),
334
+ new Promise((resolve) => {
335
+ onAbort = () => resolve(NO_REPORT);
336
+ // `addEventListener` never fires on an already-aborted signal, so an
337
+ // abort raised between the guard above and this line would otherwise
338
+ // leave this leg permanently pending.
339
+ if (signal.aborted) {
340
+ onAbort();
341
+ }
342
+ else {
343
+ signal.addEventListener("abort", onAbort, { once: true });
344
+ }
345
+ }),
346
+ ]);
347
+ if (response === NO_REPORT) {
348
+ // An aborted turn is not a fault: nobody is waiting for this any more.
349
+ if (!signal.aborted) {
350
+ logger.error("Structured /usage timed out; preserving Claude Code output");
351
+ }
352
+ return null;
353
+ }
354
+ const usage = parseUsageResponse(response);
355
+ if (usage === null) {
356
+ logger.error("Structured /usage returned an incompatible response; preserving Claude Code output");
357
+ return null;
358
+ }
359
+ return formatUsageResponse(usage);
360
+ }
361
+ catch (error) {
362
+ logger.error(`Structured /usage failed; preserving Claude Code output: ${error}`);
363
+ return null;
364
+ }
365
+ finally {
366
+ if (timer !== undefined) {
367
+ clearTimeout(timer);
368
+ }
369
+ if (onAbort !== undefined) {
370
+ signal.removeEventListener("abort", onAbort);
371
+ }
372
+ }
373
+ }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.12.0",
6
+ "version": "0.13.0",
7
7
  "description": "ACP adapter for the Claude Agent SDK with VS Code-parity UX for Zed and other ACP clients — checkbox multi-select questions, refusal consent dialog, dynamic agent name. Fork of claude-agent-acp.",
8
8
  "main": "dist/lib.js",
9
9
  "types": "dist/lib.d.ts",