@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,73 @@
1
+ /**
2
+ * The versioned `_meta` extension that carries context-compaction facts on the
3
+ * synthetic ACP tool call (story 010, R2.1/D3).
4
+ *
5
+ * Why an extension rather than the protocol's own variant: ACP 1.4 does define
6
+ * a `compaction_update`, but the Rust crate the packaged Zed links
7
+ * (`agent-client-protocol 2.0.0`) carries no `compaction` symbol at all, so
8
+ * that variant cannot be deserialised no matter what this adapter sends. The
9
+ * reachable pattern is the one downstream patch `0011` already proves: emit a
10
+ * `_meta` key no ACP variant claims, and let a downstream patch read it. The
11
+ * tool call renders with `ToolKind::Think`'s icon even with no patch present,
12
+ * so R2.1 ships and works on its own.
13
+ *
14
+ * Why the version field is not optional (D3): it is what makes the two halves
15
+ * independently releasable in BOTH directions — a newer adapter against an
16
+ * older Zed degrades to generic tool rendering, and an older adapter against a
17
+ * newer Zed simply never sends the key. A payload with no version is a payload
18
+ * a reader cannot degrade from, so it can only be guessed at.
19
+ *
20
+ * Kept self-contained — no import from `acp-agent.ts`, mirroring
21
+ * `thinking-option.ts` and `rewind-command.ts`. The reason is merge cost:
22
+ * `acp-agent.ts` is a ~9,700-line file that changes upstream almost daily, so
23
+ * every coupling to it is paid again at each sync.
24
+ */
25
+ /** The `_meta` key the downstream Zed patch looks for. */
26
+ export declare const CONTEXT_COMPACTION_META_KEY = "contextCompaction";
27
+ /**
28
+ * Schema version of the payload under {@link CONTEXT_COMPACTION_META_KEY}.
29
+ * Bump it whenever a field's meaning changes; a reader that does not know the
30
+ * value it receives must fall back to generic tool rendering rather than
31
+ * interpret unknown fields.
32
+ */
33
+ export declare const CONTEXT_COMPACTION_META_VERSION = 1;
34
+ /**
35
+ * Why the compaction ran: `"manual"` for a user-issued `/compact`,
36
+ * `"automatic"` when the SDK compacted on its own to stay inside the window.
37
+ * Provider-neutral by design — the SDK spells the automatic case `"auto"`.
38
+ */
39
+ export type ContextCompactionTrigger = "manual" | "automatic";
40
+ /**
41
+ * Compaction-specific facts carried alongside the tool call. Every field
42
+ * except `version` is optional because the SDK frame that produced the event
43
+ * may not carry it: a terminal-only `status` message has no token counts, and
44
+ * an older CLI's `compact_boundary` omits `post_tokens`/`duration_ms`.
45
+ */
46
+ export interface ContextCompactionMetadata {
47
+ /** Always {@link CONTEXT_COMPACTION_META_VERSION}; see D3 above. */
48
+ version: typeof CONTEXT_COMPACTION_META_VERSION;
49
+ /** Manual `/compact` or an automatic window-pressure compaction. */
50
+ trigger?: ContextCompactionTrigger;
51
+ /** Tokens occupying the context window before compaction. */
52
+ preTokens?: number;
53
+ /** Tokens occupying it after — absent on CLIs that don't report it. */
54
+ postTokens?: number;
55
+ /** How long the compaction took, in milliseconds. */
56
+ durationMs?: number;
57
+ /** Failure reason, present only on a `failed` lifecycle. */
58
+ error?: string;
59
+ }
60
+ /**
61
+ * Provider-neutral metadata for a synthetic ACP context-compaction tool call.
62
+ *
63
+ * The standard `toolCallId` and `status` fields own lifecycle identity and
64
+ * phase; this extension deliberately carries only compaction-specific facts,
65
+ * so a client that ignores `_meta` entirely still sees a correct tool
66
+ * lifecycle.
67
+ *
68
+ * @param metadata The facts known at this point in the lifecycle; the version
69
+ * is stamped here rather than by each call site, so no caller can emit an
70
+ * unversioned payload.
71
+ */
72
+ export declare function createContextCompactionMeta(metadata?: Omit<ContextCompactionMetadata, "version">): Record<typeof CONTEXT_COMPACTION_META_KEY, ContextCompactionMetadata>;
73
+ //# sourceMappingURL=context-compaction-meta.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-compaction-meta.d.ts","sourceRoot":"","sources":["../src/context-compaction-meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,0DAA0D;AAC1D,eAAO,MAAM,2BAA2B,sBAAsB,CAAC;AAE/D;;;;;GAKG;AACH,eAAO,MAAM,+BAA+B,IAAI,CAAC;AAEjD;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,WAAW,yBAAyB;IACxC,oEAAoE;IACpE,OAAO,EAAE,OAAO,+BAA+B,CAAC;IAChD,oEAAoE;IACpE,OAAO,CAAC,EAAE,wBAAwB,CAAC;IACnC,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,GAAE,IAAI,CAAC,yBAAyB,EAAE,SAAS,CAAM,GACxD,MAAM,CAAC,OAAO,2BAA2B,EAAE,yBAAyB,CAAC,CAOvE"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The versioned `_meta` extension that carries context-compaction facts on the
3
+ * synthetic ACP tool call (story 010, R2.1/D3).
4
+ *
5
+ * Why an extension rather than the protocol's own variant: ACP 1.4 does define
6
+ * a `compaction_update`, but the Rust crate the packaged Zed links
7
+ * (`agent-client-protocol 2.0.0`) carries no `compaction` symbol at all, so
8
+ * that variant cannot be deserialised no matter what this adapter sends. The
9
+ * reachable pattern is the one downstream patch `0011` already proves: emit a
10
+ * `_meta` key no ACP variant claims, and let a downstream patch read it. The
11
+ * tool call renders with `ToolKind::Think`'s icon even with no patch present,
12
+ * so R2.1 ships and works on its own.
13
+ *
14
+ * Why the version field is not optional (D3): it is what makes the two halves
15
+ * independently releasable in BOTH directions — a newer adapter against an
16
+ * older Zed degrades to generic tool rendering, and an older adapter against a
17
+ * newer Zed simply never sends the key. A payload with no version is a payload
18
+ * a reader cannot degrade from, so it can only be guessed at.
19
+ *
20
+ * Kept self-contained — no import from `acp-agent.ts`, mirroring
21
+ * `thinking-option.ts` and `rewind-command.ts`. The reason is merge cost:
22
+ * `acp-agent.ts` is a ~9,700-line file that changes upstream almost daily, so
23
+ * every coupling to it is paid again at each sync.
24
+ */
25
+ /** The `_meta` key the downstream Zed patch looks for. */
26
+ export const CONTEXT_COMPACTION_META_KEY = "contextCompaction";
27
+ /**
28
+ * Schema version of the payload under {@link CONTEXT_COMPACTION_META_KEY}.
29
+ * Bump it whenever a field's meaning changes; a reader that does not know the
30
+ * value it receives must fall back to generic tool rendering rather than
31
+ * interpret unknown fields.
32
+ */
33
+ export const CONTEXT_COMPACTION_META_VERSION = 1;
34
+ /**
35
+ * Provider-neutral metadata for a synthetic ACP context-compaction tool call.
36
+ *
37
+ * The standard `toolCallId` and `status` fields own lifecycle identity and
38
+ * phase; this extension deliberately carries only compaction-specific facts,
39
+ * so a client that ignores `_meta` entirely still sees a correct tool
40
+ * lifecycle.
41
+ *
42
+ * @param metadata The facts known at this point in the lifecycle; the version
43
+ * is stamped here rather than by each call site, so no caller can emit an
44
+ * unversioned payload.
45
+ */
46
+ export function createContextCompactionMeta(metadata = {}) {
47
+ return {
48
+ [CONTEXT_COMPACTION_META_KEY]: {
49
+ version: CONTEXT_COMPACTION_META_VERSION,
50
+ ...metadata,
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Context compaction as ONE idempotent ACP tool lifecycle (story 010, R2.1/R2.2).
3
+ *
4
+ * What this replaces: the adapter used to infer compaction from a
5
+ * `compactionInProgress` boolean and narrate it as assistant text
6
+ * ("Compacting…", "Compacting completed."). The flag existed only because, in
7
+ * the adapter's own comment, the SDK's two terminal `status` messages were
8
+ * "indistinguishable" — a guess, not a signal. Once the lifecycle is explicit
9
+ * the guess is not merely redundant, it double-reports: the tool call says the
10
+ * compaction finished and the banner says so again. So the inference is
11
+ * DELETED, not left inert (D4) — a flag that no longer decides anything is a
12
+ * thing the next reader has to prove is inert.
13
+ *
14
+ * What replaces it: a small state machine that folds every event shape the SDK
15
+ * can produce onto a single tool call.
16
+ *
17
+ * SDK event → lifecycle call
18
+ * ------------------------------------------- -----------------------------
19
+ * `status` = "compacting" → start() tool_call, in_progress
20
+ * `stream_event` compaction block/delta → heartbeat() tool_call_update, in_progress
21
+ * `status` with compact_result success/failed → finish() tool_call_update, terminal
22
+ * `compact_boundary` (token counts) → finish(enrich) tool_call_update, metadata only
23
+ * a terminal with no preceding "compacting" → finish() tool_call, terminal (standalone)
24
+ * a DUPLICATED terminal → finish() nothing
25
+ *
26
+ * The two hard cases pull in opposite directions and both are real:
27
+ *
28
+ * - The SDK duplicates the terminal `compact_result` message for a single
29
+ * failed compaction. Two terminals for one compaction must stay ONE report.
30
+ * - One model turn can legitimately compact more than once. Two genuine
31
+ * compactions must stay TWO distinguishable reports.
32
+ *
33
+ * The state machine separates them by phase rather than by identity: a
34
+ * terminal that arrives when the lifecycle has already terminated is a
35
+ * duplicate and changes nothing, while a fresh `compacting` status AFTER a
36
+ * terminal opens a new lifecycle with a new tool call id. A rule that instead
37
+ * suppressed by matching ids or payloads would satisfy one case by breaking
38
+ * the other.
39
+ *
40
+ * State lives until the owning turn's `result` (or an abort) rather than being
41
+ * cleared at each terminal, because the SDK can also omit the opening status
42
+ * on replay — the terminal has to be able to stand alone.
43
+ *
44
+ * Kept self-contained — no import from `acp-agent.ts`, mirroring
45
+ * `thinking-option.ts` and `rewind-command.ts`; what it needs from the adapter
46
+ * (the send chokepoint) is injected, which also leaves the whole state machine
47
+ * unit-testable without a live SDK session. The reason is merge cost:
48
+ * `acp-agent.ts` is a ~9,700-line file that changes upstream almost daily, so
49
+ * every coupling to it is paid again at each sync.
50
+ */
51
+ import { SessionNotification } from "@agentclientprotocol/sdk";
52
+ import { ContextCompactionMetadata } from "./context-compaction-meta.js";
53
+ /** The phases a compaction can END in. `in_progress` is not terminal. */
54
+ type CompactionStatus = "completed" | "failed";
55
+ /** One compaction's identity and phase. */
56
+ type CompactionState = {
57
+ /** The ACP tool call id every update for this compaction carries. */
58
+ toolCallId: string;
59
+ /** Set once the lifecycle has reported an outcome; the duplicate guard. */
60
+ terminalStatus?: CompactionStatus;
61
+ /** Stream heartbeats are collapsed to one keep-alive per lifecycle. */
62
+ heartbeatSent: boolean;
63
+ };
64
+ /**
65
+ * The adapter's send chokepoint, injected. Deliberately the ACP SDK's
66
+ * `SessionNotification` rather than an adapter-local alias, so this module
67
+ * needs nothing from `acp-agent.ts`.
68
+ */
69
+ type SendUpdate = (notification: SessionNotification) => Promise<void>;
70
+ /**
71
+ * Translates Claude's compaction signals into one idempotent ACP tool
72
+ * lifecycle. One instance per consumer; `reset()` at each turn boundary.
73
+ */
74
+ export declare class ContextCompactionLifecycle {
75
+ private readonly sendUpdate;
76
+ private activeCompaction;
77
+ private outputDelivered;
78
+ private duplicateErrorOutput;
79
+ constructor(sendUpdate: SendUpdate);
80
+ /**
81
+ * Whether this turn already showed the user something about a compaction.
82
+ *
83
+ * The adapter reads it for two decisions the deleted banners used to make
84
+ * implicitly, by counting as delivered assistant text: an echo-less turn
85
+ * that only compacted (e.g. `/compact`) must not have its result text
86
+ * re-emitted by the issue-#453 fallback, and a replayed synthetic
87
+ * local-command frame from an earlier compact attempt must not be forwarded
88
+ * on top of the lifecycle.
89
+ */
90
+ get hasDeliveredOutput(): boolean;
91
+ /** Return to the unstarted state; call at each turn boundary. */
92
+ reset(): void;
93
+ /**
94
+ * Claude also emits a failed manual compaction's error as local-command
95
+ * stdout. Consume that one duplicate after the tool lifecycle already
96
+ * carried it, without hiding unrelated command output.
97
+ *
98
+ * Matching is by exact (trimmed) text and consumes at most once, so a
99
+ * command that genuinely prints the same string later still reaches the
100
+ * client.
101
+ *
102
+ * @returns `true` when the caller should drop this content.
103
+ */
104
+ consumeDuplicateErrorOutput(content: string): boolean;
105
+ /**
106
+ * Open the tool call the client renders. Idempotent while a compaction is
107
+ * still running (a repeated `compacting` status re-uses the open call); a
108
+ * `compacting` that arrives AFTER a terminal starts a fresh lifecycle, which
109
+ * is how two real compactions in one turn stay two reports.
110
+ *
111
+ * @param toolCallId The SDK message uuid — replay-stable, so a re-delivered
112
+ * frame addresses the same tool call rather than inventing one.
113
+ */
114
+ start(sessionId: string, toolCallId: string): Promise<CompactionState>;
115
+ /**
116
+ * Keep the open call alive while compaction streams. Sent at most once per
117
+ * lifecycle: the deltas carry the generated summary, which stays internal to
118
+ * the agent, so repeating them would add noise and no information.
119
+ *
120
+ * @param fallbackId Used only when the opening status never arrived (replay).
121
+ */
122
+ heartbeat(sessionId: string, fallbackId: string): Promise<void>;
123
+ /**
124
+ * Report the outcome — exactly once per compaction.
125
+ *
126
+ * Three shapes converge here:
127
+ * - no lifecycle open (the SDK omitted the opening status on replay): emit a
128
+ * standalone terminal `tool_call`, so the event is reported rather than
129
+ * dropped for lack of a start;
130
+ * - the first terminal for an open lifecycle: a `tool_call_update` carrying
131
+ * the status;
132
+ * - a duplicate terminal: nothing at all.
133
+ *
134
+ * @param fallbackId Tool call id for the standalone shape.
135
+ * @param metadata Compaction facts; also emitted as `rawOutput` when non-empty,
136
+ * so a client that ignores `_meta` can still show something.
137
+ * @param enrichTerminal Allow a SECOND update on an already-terminal
138
+ * lifecycle that adds facts without re-reporting the outcome. Used by
139
+ * `compact_boundary`, which arrives after the terminal `status` and is the
140
+ * only frame carrying the token counts. The status field is omitted on that
141
+ * update, so the client still sees exactly one terminal transition.
142
+ */
143
+ finish(sessionId: string, fallbackId: string, status: CompactionStatus, metadata?: Omit<ContextCompactionMetadata, "version">, enrichTerminal?: boolean): Promise<void>;
144
+ /** Arm {@link consumeDuplicateErrorOutput} for the stdout copy Claude emits. */
145
+ private rememberDuplicateErrorOutput;
146
+ }
147
+ /**
148
+ * Map the SDK's `compact_boundary` metadata onto the provider-neutral names of
149
+ * {@link ContextCompactionMetadata}. Fields an older CLI omits stay omitted
150
+ * rather than becoming `undefined` keys, so `rawOutput` and `_meta` never
151
+ * advertise a number nobody reported.
152
+ */
153
+ export declare function contextCompactionMetadataFromBoundary(compactMetadata: {
154
+ trigger: "manual" | "auto";
155
+ pre_tokens: number;
156
+ post_tokens?: number;
157
+ duration_ms?: number;
158
+ }): Omit<ContextCompactionMetadata, "version">;
159
+ export {};
160
+ //# sourceMappingURL=context-compaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-compaction.d.ts","sourceRoot":"","sources":["../src/context-compaction.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,EACL,yBAAyB,EAE1B,MAAM,8BAA8B,CAAC;AAEtC,yEAAyE;AACzE,KAAK,gBAAgB,GAAG,WAAW,GAAG,QAAQ,CAAC;AAE/C,2CAA2C;AAC3C,KAAK,eAAe,GAAG;IACrB,qEAAqE;IACrE,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC,uEAAuE;IACvE,aAAa,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF;;;;GAIG;AACH,KAAK,UAAU,GAAG,CAAC,YAAY,EAAE,mBAAmB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAEvE;;;GAGG;AACH,qBAAa,0BAA0B;IAKzB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAJvC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,oBAAoB,CAAqB;gBAEpB,UAAU,EAAE,UAAU;IAEnD;;;;;;;;;OASG;IACH,IAAI,kBAAkB,IAAI,OAAO,CAEhC;IAED,iEAAiE;IACjE,KAAK,IAAI,IAAI;IAMb;;;;;;;;;;OAUG;IACH,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAWrD;;;;;;;;OAQG;IACG,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAqB5E;;;;;;OAMG;IACG,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBrE;;;;;;;;;;;;;;;;;;;OAmBG;IACG,MAAM,CACV,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,gBAAgB,EACxB,QAAQ,GAAE,IAAI,CAAC,yBAAyB,EAAE,SAAS,CAAM,EACzD,cAAc,UAAQ,GACrB,OAAO,CAAC,IAAI,CAAC;IAgDhB,gFAAgF;IAChF,OAAO,CAAC,4BAA4B;CAQrC;AAED;;;;;GAKG;AACH,wBAAgB,qCAAqC,CAAC,eAAe,EAAE;IACrE,OAAO,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,IAAI,CAAC,yBAAyB,EAAE,SAAS,CAAC,CAW7C"}
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Context compaction as ONE idempotent ACP tool lifecycle (story 010, R2.1/R2.2).
3
+ *
4
+ * What this replaces: the adapter used to infer compaction from a
5
+ * `compactionInProgress` boolean and narrate it as assistant text
6
+ * ("Compacting…", "Compacting completed."). The flag existed only because, in
7
+ * the adapter's own comment, the SDK's two terminal `status` messages were
8
+ * "indistinguishable" — a guess, not a signal. Once the lifecycle is explicit
9
+ * the guess is not merely redundant, it double-reports: the tool call says the
10
+ * compaction finished and the banner says so again. So the inference is
11
+ * DELETED, not left inert (D4) — a flag that no longer decides anything is a
12
+ * thing the next reader has to prove is inert.
13
+ *
14
+ * What replaces it: a small state machine that folds every event shape the SDK
15
+ * can produce onto a single tool call.
16
+ *
17
+ * SDK event → lifecycle call
18
+ * ------------------------------------------- -----------------------------
19
+ * `status` = "compacting" → start() tool_call, in_progress
20
+ * `stream_event` compaction block/delta → heartbeat() tool_call_update, in_progress
21
+ * `status` with compact_result success/failed → finish() tool_call_update, terminal
22
+ * `compact_boundary` (token counts) → finish(enrich) tool_call_update, metadata only
23
+ * a terminal with no preceding "compacting" → finish() tool_call, terminal (standalone)
24
+ * a DUPLICATED terminal → finish() nothing
25
+ *
26
+ * The two hard cases pull in opposite directions and both are real:
27
+ *
28
+ * - The SDK duplicates the terminal `compact_result` message for a single
29
+ * failed compaction. Two terminals for one compaction must stay ONE report.
30
+ * - One model turn can legitimately compact more than once. Two genuine
31
+ * compactions must stay TWO distinguishable reports.
32
+ *
33
+ * The state machine separates them by phase rather than by identity: a
34
+ * terminal that arrives when the lifecycle has already terminated is a
35
+ * duplicate and changes nothing, while a fresh `compacting` status AFTER a
36
+ * terminal opens a new lifecycle with a new tool call id. A rule that instead
37
+ * suppressed by matching ids or payloads would satisfy one case by breaking
38
+ * the other.
39
+ *
40
+ * State lives until the owning turn's `result` (or an abort) rather than being
41
+ * cleared at each terminal, because the SDK can also omit the opening status
42
+ * on replay — the terminal has to be able to stand alone.
43
+ *
44
+ * Kept self-contained — no import from `acp-agent.ts`, mirroring
45
+ * `thinking-option.ts` and `rewind-command.ts`; what it needs from the adapter
46
+ * (the send chokepoint) is injected, which also leaves the whole state machine
47
+ * unit-testable without a live SDK session. The reason is merge cost:
48
+ * `acp-agent.ts` is a ~9,700-line file that changes upstream almost daily, so
49
+ * every coupling to it is paid again at each sync.
50
+ */
51
+ import { createContextCompactionMeta, } from "./context-compaction-meta.js";
52
+ /**
53
+ * Translates Claude's compaction signals into one idempotent ACP tool
54
+ * lifecycle. One instance per consumer; `reset()` at each turn boundary.
55
+ */
56
+ export class ContextCompactionLifecycle {
57
+ sendUpdate;
58
+ activeCompaction;
59
+ outputDelivered = false;
60
+ duplicateErrorOutput;
61
+ constructor(sendUpdate) {
62
+ this.sendUpdate = sendUpdate;
63
+ }
64
+ /**
65
+ * Whether this turn already showed the user something about a compaction.
66
+ *
67
+ * The adapter reads it for two decisions the deleted banners used to make
68
+ * implicitly, by counting as delivered assistant text: an echo-less turn
69
+ * that only compacted (e.g. `/compact`) must not have its result text
70
+ * re-emitted by the issue-#453 fallback, and a replayed synthetic
71
+ * local-command frame from an earlier compact attempt must not be forwarded
72
+ * on top of the lifecycle.
73
+ */
74
+ get hasDeliveredOutput() {
75
+ return this.outputDelivered;
76
+ }
77
+ /** Return to the unstarted state; call at each turn boundary. */
78
+ reset() {
79
+ this.activeCompaction = undefined;
80
+ this.outputDelivered = false;
81
+ this.duplicateErrorOutput = undefined;
82
+ }
83
+ /**
84
+ * Claude also emits a failed manual compaction's error as local-command
85
+ * stdout. Consume that one duplicate after the tool lifecycle already
86
+ * carried it, without hiding unrelated command output.
87
+ *
88
+ * Matching is by exact (trimmed) text and consumes at most once, so a
89
+ * command that genuinely prints the same string later still reaches the
90
+ * client.
91
+ *
92
+ * @returns `true` when the caller should drop this content.
93
+ */
94
+ consumeDuplicateErrorOutput(content) {
95
+ if (this.duplicateErrorOutput === undefined ||
96
+ content.trim() !== this.duplicateErrorOutput.trim()) {
97
+ return false;
98
+ }
99
+ this.duplicateErrorOutput = undefined;
100
+ return true;
101
+ }
102
+ /**
103
+ * Open the tool call the client renders. Idempotent while a compaction is
104
+ * still running (a repeated `compacting` status re-uses the open call); a
105
+ * `compacting` that arrives AFTER a terminal starts a fresh lifecycle, which
106
+ * is how two real compactions in one turn stay two reports.
107
+ *
108
+ * @param toolCallId The SDK message uuid — replay-stable, so a re-delivered
109
+ * frame addresses the same tool call rather than inventing one.
110
+ */
111
+ async start(sessionId, toolCallId) {
112
+ if (this.activeCompaction && !this.activeCompaction.terminalStatus) {
113
+ return this.activeCompaction;
114
+ }
115
+ this.activeCompaction = { toolCallId, heartbeatSent: false };
116
+ this.outputDelivered = true;
117
+ await this.sendUpdate({
118
+ sessionId,
119
+ update: {
120
+ sessionUpdate: "tool_call",
121
+ toolCallId,
122
+ title: COMPACTION_TOOL_TITLE,
123
+ kind: "think",
124
+ status: "in_progress",
125
+ _meta: compactionToolMeta(),
126
+ },
127
+ });
128
+ return this.activeCompaction;
129
+ }
130
+ /**
131
+ * Keep the open call alive while compaction streams. Sent at most once per
132
+ * lifecycle: the deltas carry the generated summary, which stays internal to
133
+ * the agent, so repeating them would add noise and no information.
134
+ *
135
+ * @param fallbackId Used only when the opening status never arrived (replay).
136
+ */
137
+ async heartbeat(sessionId, fallbackId) {
138
+ const state = this.activeCompaction ?? (await this.start(sessionId, fallbackId));
139
+ if (state.terminalStatus || state.heartbeatSent)
140
+ return;
141
+ state.heartbeatSent = true;
142
+ await this.sendUpdate({
143
+ sessionId,
144
+ update: {
145
+ sessionUpdate: "tool_call_update",
146
+ toolCallId: state.toolCallId,
147
+ status: "in_progress",
148
+ _meta: compactionToolMeta(),
149
+ },
150
+ });
151
+ }
152
+ /**
153
+ * Report the outcome — exactly once per compaction.
154
+ *
155
+ * Three shapes converge here:
156
+ * - no lifecycle open (the SDK omitted the opening status on replay): emit a
157
+ * standalone terminal `tool_call`, so the event is reported rather than
158
+ * dropped for lack of a start;
159
+ * - the first terminal for an open lifecycle: a `tool_call_update` carrying
160
+ * the status;
161
+ * - a duplicate terminal: nothing at all.
162
+ *
163
+ * @param fallbackId Tool call id for the standalone shape.
164
+ * @param metadata Compaction facts; also emitted as `rawOutput` when non-empty,
165
+ * so a client that ignores `_meta` can still show something.
166
+ * @param enrichTerminal Allow a SECOND update on an already-terminal
167
+ * lifecycle that adds facts without re-reporting the outcome. Used by
168
+ * `compact_boundary`, which arrives after the terminal `status` and is the
169
+ * only frame carrying the token counts. The status field is omitted on that
170
+ * update, so the client still sees exactly one terminal transition.
171
+ */
172
+ async finish(sessionId, fallbackId, status, metadata = {}, enrichTerminal = false) {
173
+ const rawOutput = Object.keys(metadata).length > 0 ? metadata : undefined;
174
+ if (!this.activeCompaction) {
175
+ this.activeCompaction = {
176
+ toolCallId: fallbackId,
177
+ heartbeatSent: false,
178
+ terminalStatus: status,
179
+ };
180
+ this.outputDelivered = true;
181
+ this.rememberDuplicateErrorOutput(status, metadata);
182
+ await this.sendUpdate({
183
+ sessionId,
184
+ update: {
185
+ sessionUpdate: "tool_call",
186
+ toolCallId: fallbackId,
187
+ title: COMPACTION_TOOL_TITLE,
188
+ kind: "think",
189
+ status,
190
+ ...compactionErrorFields(status, metadata),
191
+ ...(rawOutput ? { rawOutput } : {}),
192
+ _meta: compactionToolMeta(metadata),
193
+ },
194
+ });
195
+ return;
196
+ }
197
+ const state = this.activeCompaction;
198
+ // The duplicate guard: a terminal on an already-terminal lifecycle is the
199
+ // SDK repeating itself, and must change nothing the client can see.
200
+ if (state.terminalStatus && !enrichTerminal)
201
+ return;
202
+ const firstTerminal = state.terminalStatus === undefined;
203
+ if (firstTerminal)
204
+ state.terminalStatus = status;
205
+ this.rememberDuplicateErrorOutput(status, metadata);
206
+ await this.sendUpdate({
207
+ sessionId,
208
+ update: {
209
+ sessionUpdate: "tool_call_update",
210
+ toolCallId: state.toolCallId,
211
+ ...(firstTerminal ? { status } : {}),
212
+ ...compactionErrorFields(status, metadata),
213
+ ...(rawOutput ? { rawOutput } : {}),
214
+ _meta: compactionToolMeta(metadata),
215
+ },
216
+ });
217
+ }
218
+ /** Arm {@link consumeDuplicateErrorOutput} for the stdout copy Claude emits. */
219
+ rememberDuplicateErrorOutput(status, metadata) {
220
+ if (status === "failed" && metadata.error) {
221
+ this.duplicateErrorOutput = metadata.error;
222
+ }
223
+ }
224
+ }
225
+ /**
226
+ * Map the SDK's `compact_boundary` metadata onto the provider-neutral names of
227
+ * {@link ContextCompactionMetadata}. Fields an older CLI omits stay omitted
228
+ * rather than becoming `undefined` keys, so `rawOutput` and `_meta` never
229
+ * advertise a number nobody reported.
230
+ */
231
+ export function contextCompactionMetadataFromBoundary(compactMetadata) {
232
+ return {
233
+ trigger: compactMetadata.trigger === "auto" ? "automatic" : "manual",
234
+ preTokens: compactMetadata.pre_tokens,
235
+ ...(compactMetadata.post_tokens !== undefined
236
+ ? { postTokens: compactMetadata.post_tokens }
237
+ : {}),
238
+ ...(compactMetadata.duration_ms !== undefined
239
+ ? { durationMs: compactMetadata.duration_ms }
240
+ : {}),
241
+ };
242
+ }
243
+ /** The title every compaction tool call carries; part of the R2.1 contract. */
244
+ const COMPACTION_TOOL_TITLE = "Compact conversation";
245
+ /**
246
+ * The `_meta` payload for a compaction tool update: the versioned extension
247
+ * plus the `claudeCode.toolName` every other tool call in this adapter carries,
248
+ * so a client keying off it treats the synthetic call like any real one.
249
+ *
250
+ * Returns a plain record rather than the adapter's `ToolUpdateMeta` to keep the
251
+ * module free of `acp-agent.ts` imports; the adapter widens `ToolUpdateMeta`
252
+ * with the matching optional field for its own call sites.
253
+ */
254
+ function compactionToolMeta(metadata = {}) {
255
+ return {
256
+ ...createContextCompactionMeta(metadata),
257
+ claudeCode: { toolName: "compact" },
258
+ };
259
+ }
260
+ /**
261
+ * The failure reason as renderable tool content — `_meta` is optional for a
262
+ * client, the reason for a failure is not.
263
+ */
264
+ function compactionErrorFields(status, metadata) {
265
+ if (status !== "failed" || !metadata.error)
266
+ return {};
267
+ return {
268
+ content: [
269
+ {
270
+ type: "content",
271
+ content: { type: "text", text: `Compaction failed: ${metadata.error}` },
272
+ },
273
+ ],
274
+ };
275
+ }
@@ -0,0 +1,92 @@
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 type { Query, SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk";
43
+ /**
44
+ * How long the structured report may take before the original output wins.
45
+ *
46
+ * Not a guess at network latency: the request may never be serviced at all (see
47
+ * the header), so this is the ceiling on how long a user waits for a decoration
48
+ * on an answer that is already sitting in the buffer.
49
+ */
50
+ export declare const STRUCTURED_USAGE_TIMEOUT_MS = 5000;
51
+ /**
52
+ * Validate the experimental SDK response at the runtime boundary — way out 2.
53
+ * Null means "this is not a report this renderer can read", which the caller
54
+ * turns into Claude Code's original text.
55
+ */
56
+ export declare function parseUsageResponse(value: unknown): SDKControlGetUsageResponse | null;
57
+ /**
58
+ * Whether a prompt is exactly the local `/usage` command.
59
+ *
60
+ * Exact, not a prefix: `/usage now` is an argument form this renderer has no
61
+ * mapping for, and prose that merely mentions the word is a model turn.
62
+ */
63
+ export declare function isUsageCommandText(text: string): boolean;
64
+ /** Render the SDK's structured `/usage` response as Markdown (R2.3). */
65
+ export declare function formatUsageResponse(usage: SDKControlGetUsageResponse): string;
66
+ /**
67
+ * The one SDK surface this module needs.
68
+ *
69
+ * `Pick` on the literal method name is the compile-time tripwire the ToDo asks
70
+ * for: when the SDK stabilises this API and renames the method, the key stops
71
+ * being a `keyof Query` and the BUILD fails here. Without it, a rename would
72
+ * only ever surface as `/usage` degrading to way out 1 forever — a silent loss
73
+ * of the whole feature, indistinguishable from a session where the report is
74
+ * genuinely unavailable.
75
+ */
76
+ export type UsageReportQuery = Pick<Query, "usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET">;
77
+ /** The slice of the adapter's logger this module uses. */
78
+ export type UsageMarkdownLogger = {
79
+ error: (message: string) => void;
80
+ };
81
+ /**
82
+ * The structured `/usage` render for one turn, or null when any of the three
83
+ * ways out fired (R2.4). Never throws and never resolves late: the caller is
84
+ * holding a completed local command's output while it awaits this.
85
+ *
86
+ * Raced against BOTH a timeout and the turn's abort signal. The timeout covers
87
+ * a request that is never serviced (see the header); the signal covers a turn
88
+ * that ended — cancelled, or settled — while the request was still in flight,
89
+ * so a dead turn's overlay is abandoned rather than merely ignored.
90
+ */
91
+ export declare function structuredUsageMarkdown(query: UsageReportQuery, signal: AbortSignal, logger: UsageMarkdownLogger): Promise<string | null>;
92
+ //# sourceMappingURL=usage-markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usage-markdown.d.ts","sourceRoot":"","sources":["../src/usage-markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,0BAA0B,EAAE,MAAM,gCAAgC,CAAC;AAKxF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,OAAQ,CAAC;AA6DjD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,0BAA0B,GAAG,IAAI,CAOpF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AA0OD,wEAAwE;AACxE,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,0BAA0B,GAAG,MAAM,CAS7E;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,gBAAgB,GAAG,IAAI,CACjC,KAAK,EACL,2DAA2D,CAC5D,CAAC;AAEF,0DAA0D;AAC1D,MAAM,MAAM,mBAAmB,GAAG;IAAE,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,CAAC;AAQvE;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,KAAK,EAAE,gBAAgB,EACvB,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,mBAAmB,GAC1B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwDxB"}