@lucascouts/claude-agent-acp-plus 0.1.0 → 0.2.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,30 @@
1
+ import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
2
+ /**
3
+ * Heuristically decide whether a model row is deprecated.
4
+ *
5
+ * Matches {@link DEPRECATION_MARKER} (`/deprecated|legacy/i`) over `displayName`
6
+ * and `description` ONLY. The `value` (model id) is never inspected — an id that
7
+ * merely contains a "legacy"-like slug must NOT flag the row. A missing,
8
+ * `undefined`, or empty `displayName` or `description` is treated as an empty
9
+ * string, so this never throws.
10
+ *
11
+ * See the module doc for WHY this is a heuristic (SDK 0.3.204 `ModelInfo` has no
12
+ * deprecation field).
13
+ *
14
+ * @param info - A single SDK model row.
15
+ * @returns `true` when the display copy marks the model deprecated/legacy.
16
+ */
17
+ export declare function isDeprecatedModel(info: ModelInfo): boolean;
18
+ /**
19
+ * Return a NEW array with deprecated rows removed.
20
+ *
21
+ * Kept rows preserve their original order AND identity — the same object
22
+ * references are returned, not copies. Built on {@link isDeprecatedModel}, so it
23
+ * shares that function's heuristic and field scoping. An empty input yields an
24
+ * empty array.
25
+ *
26
+ * @param infos - The model catalog to filter.
27
+ * @returns A new array containing only the non-deprecated rows.
28
+ */
29
+ export declare function filterDeprecatedModels(infos: ModelInfo[]): ModelInfo[];
30
+ //# sourceMappingURL=model-deprecation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-deprecation.d.ts","sourceRoot":"","sources":["../src/model-deprecation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAC;AA4BhE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAI1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,SAAS,EAAE,CAEtE"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Model deprecation heuristic.
3
+ *
4
+ * The Claude Agent SDK's `ModelInfo` (0.3.204) exposes NO deprecation flag —
5
+ * its only capability fields are `supportsEffort` / `supportedEffortLevels`,
6
+ * `supportsAdaptiveThinking`, `supportsFastMode`, and `supportsAutoMode`. There
7
+ * is therefore no authoritative, machine-readable signal telling a host that a
8
+ * model row has been retired.
9
+ *
10
+ * As a stand-in this module scans the human-facing copy — `displayName` and
11
+ * `description` — for the words "deprecated" or "legacy" (case-insensitive).
12
+ * This is deliberately a HEURISTIC: it can only catch models the SDK happens to
13
+ * label in prose, and it is intentionally scoped to those two fields. The
14
+ * opaque model id in `value` is NEVER inspected, because ids routinely embed
15
+ * version/family slugs (e.g. a "legacy"-like substring) that do not indicate an
16
+ * actually deprecated model and would produce false positives.
17
+ *
18
+ * When the SDK gains a real deprecation flag, replace this heuristic with a
19
+ * direct field read.
20
+ *
21
+ * @module model-deprecation
22
+ */
23
+ /** Case-insensitive marker words the SDK uses in prose for retired models. */
24
+ const DEPRECATION_MARKER = /deprecated|legacy/i;
25
+ /**
26
+ * Heuristically decide whether a model row is deprecated.
27
+ *
28
+ * Matches {@link DEPRECATION_MARKER} (`/deprecated|legacy/i`) over `displayName`
29
+ * and `description` ONLY. The `value` (model id) is never inspected — an id that
30
+ * merely contains a "legacy"-like slug must NOT flag the row. A missing,
31
+ * `undefined`, or empty `displayName` or `description` is treated as an empty
32
+ * string, so this never throws.
33
+ *
34
+ * See the module doc for WHY this is a heuristic (SDK 0.3.204 `ModelInfo` has no
35
+ * deprecation field).
36
+ *
37
+ * @param info - A single SDK model row.
38
+ * @returns `true` when the display copy marks the model deprecated/legacy.
39
+ */
40
+ export function isDeprecatedModel(info) {
41
+ const displayName = info.displayName ?? "";
42
+ const description = info.description ?? "";
43
+ return DEPRECATION_MARKER.test(`${displayName} ${description}`);
44
+ }
45
+ /**
46
+ * Return a NEW array with deprecated rows removed.
47
+ *
48
+ * Kept rows preserve their original order AND identity — the same object
49
+ * references are returned, not copies. Built on {@link isDeprecatedModel}, so it
50
+ * shares that function's heuristic and field scoping. An empty input yields an
51
+ * empty array.
52
+ *
53
+ * @param infos - The model catalog to filter.
54
+ * @returns A new array containing only the non-deprecated rows.
55
+ */
56
+ export function filterDeprecatedModels(infos) {
57
+ return infos.filter((info) => !isDeprecatedModel(info));
58
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * All `/rewind` command logic, isolated from the ACP adapter so it can be unit
3
+ * tested without a live SDK session. The adapter (see acp-agent.ts) wires these
4
+ * helpers in by injecting the ACP client, the SDK `query` handle, and a
5
+ * `getSessionMessages` reader; this module never touches the transport itself.
6
+ *
7
+ * Responsibilities (story 006, R3.2–R3.6):
8
+ * - parse a `/rewind` prompt into a list / restore / invalid intent
9
+ * (`parseRewindInvocation`);
10
+ * - derive the checkpoint list from the session transcript — real user
11
+ * prompts only, most recent first, 1-based (`listCheckpoints`);
12
+ * - format the user-facing strings (`formatCheckpointList`,
13
+ * `formatRewindResult`, `formatRewindError`);
14
+ * - orchestrate one command end to end, emitting every message through the
15
+ * injected client before resolving (`handleRewindCommand`).
16
+ *
17
+ * `stripLocalCommandMetadata` is reused from acp-agent.ts (its single source of
18
+ * truth) rather than duplicated, so the definition of a "local-command marker
19
+ * row" stays in sync; the import is side-effect-free (acp-agent.ts runs nothing
20
+ * at module top level).
21
+ */
22
+ import type { RewindFilesResult, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
23
+ /** A rewindable point in the session: a real user prompt tracked by its SDK uuid. */
24
+ export type Checkpoint = {
25
+ index: number;
26
+ uuid: string;
27
+ excerpt: string;
28
+ };
29
+ /** Parsed intent of a `/rewind` prompt; the parser returns `null` for non-commands. */
30
+ export type RewindInvocation = {
31
+ kind: "list";
32
+ } | {
33
+ kind: "restore";
34
+ index: number;
35
+ } | {
36
+ kind: "invalid";
37
+ raw: string;
38
+ };
39
+ /**
40
+ * Parse a raw prompt into a `/rewind` intent, or `null` when the prompt is not a
41
+ * `/rewind` command at all.
42
+ *
43
+ * The command token is matched exactly, so `"/rewindx"`, `"/compact"` and plain
44
+ * prose all return `null`. A bare `/rewind` (trailing whitespace allowed) is a
45
+ * `list` request; `/rewind <integer>` is a `restore` request; any other argument
46
+ * is `invalid`, carrying the trimmed raw argument text so the caller can echo it.
47
+ *
48
+ * The whole invocation must sit on a single line: any newline surviving the
49
+ * trim makes the prompt NOT a `/rewind` command (`null`). Without this,
50
+ * `\s+` between command and argument would accept a line break, and a pasted
51
+ * two-line snippet like `"/rewind\n2"` would silently restore files — a
52
+ * destructive surprise for what the user meant as plain text.
53
+ *
54
+ * @param promptText Raw prompt text as typed by the user.
55
+ */
56
+ export declare function parseRewindInvocation(promptText: string): RewindInvocation | null;
57
+ /**
58
+ * Build the rewind checkpoint list from a session transcript.
59
+ *
60
+ * Only REAL user prompts are checkpoints: rows carrying a `parent_tool_use_id`,
61
+ * tool_result rows, and local-command marker rows (`<command-name>…`, which
62
+ * `stripLocalCommandMetadata` reduces to null) are all skipped. The result is
63
+ * ordered most recent first with a 1-based `index` (most recent = 1); each
64
+ * `excerpt` is the first 60 code points of the prompt on a single line.
65
+ *
66
+ * @param messages Session transcript in chronological order (SDK messages).
67
+ */
68
+ export declare function listCheckpoints(messages: SDKMessage[]): Checkpoint[];
69
+ /**
70
+ * Render the checkpoint list as a numbered, user-facing message. An empty list
71
+ * yields a "nothing to rewind" notice instead (R3.6).
72
+ *
73
+ * @param cps Checkpoints, already ordered most recent first.
74
+ */
75
+ export declare function formatCheckpointList(cps: Checkpoint[]): string;
76
+ /**
77
+ * Confirm a completed rewind, naming the checkpoint that was restored by its
78
+ * index and excerpt (R3.4).
79
+ *
80
+ * @param cp The checkpoint whose files were restored.
81
+ */
82
+ export declare function formatRewindResult(cp: Checkpoint): string;
83
+ /**
84
+ * Build a `/rewind` usage/error message. Always names the `/rewind` command and,
85
+ * when at least one checkpoint exists, the valid index range (R3.5). An optional
86
+ * `detail` (e.g. the reason a restore failed) is shown ahead of the usage line —
87
+ * this is how the orchestrator surfaces a failure WITHOUT ever claiming success.
88
+ *
89
+ * @param checkpointCount Number of available checkpoints (0 when none/unknown).
90
+ * @param detail Optional human-readable explanation shown before the usage line.
91
+ */
92
+ export declare function formatRewindError(checkpointCount: number, detail?: string): string;
93
+ /** Injected collaborators for {@link handleRewindCommand}. */
94
+ export interface RewindDeps {
95
+ /** ACP session the command was issued in. */
96
+ sessionId: string;
97
+ /** ACP client used to stream user-facing messages back to the editor. */
98
+ client: {
99
+ sessionUpdate(notification: {
100
+ sessionId: string;
101
+ update: {
102
+ sessionUpdate: "agent_message_chunk";
103
+ content: {
104
+ type: "text";
105
+ text: string;
106
+ };
107
+ };
108
+ }): Promise<void>;
109
+ };
110
+ /** SDK query handle exposing file checkpointing. */
111
+ query: {
112
+ rewindFiles(userMessageId: string): Promise<RewindFilesResult>;
113
+ };
114
+ /** Reader for the session transcript (chronological SDK messages). */
115
+ getSessionMessages(sessionId: string): Promise<SDKMessage[]>;
116
+ }
117
+ /**
118
+ * Drive a parsed `/rewind` invocation end to end, streaming every user-facing
119
+ * message through `deps.client` before the returned promise resolves.
120
+ *
121
+ * `list` fetches the transcript and emits the numbered checkpoint list (or the
122
+ * nothing-to-rewind notice). `restore` fetches + rebuilds the list, validates
123
+ * the requested index, and on a hit calls `query.rewindFiles` with the tracked
124
+ * uuid inside a try/catch — a rejection (or a `canRewind: false` result) is
125
+ * reported via {@link formatRewindError} and never claims success. An `invalid`
126
+ * invocation, an out-of-range index, or a failed transcript read each emit an
127
+ * error and leave files untouched (`rewindFiles` is never called); the
128
+ * `invalid` path still reads the transcript so its usage message can name the
129
+ * valid range (R3.5), degrading to the rangeless message if that read fails.
130
+ *
131
+ * Failure handling: `getSessionMessages` and `query.rewindFiles` rejections
132
+ * are caught and surfaced as user-facing messages. A rejection from
133
+ * `deps.client.sessionUpdate` itself (the channel those messages are emitted
134
+ * on) is NOT caught here — it propagates to the caller, which guards it.
135
+ *
136
+ * @param deps Injected client, query handle, transcript reader and session id.
137
+ * @param invocation Parsed `/rewind` intent from {@link parseRewindInvocation}.
138
+ */
139
+ export declare function handleRewindCommand(deps: RewindDeps, invocation: RewindInvocation): Promise<void>;
140
+ //# sourceMappingURL=rewind-command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rewind-command.d.ts","sourceRoot":"","sources":["../src/rewind-command.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAMpF,qFAAqF;AACrF,MAAM,MAAM,UAAU,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,uFAAuF;AACvF,MAAM,MAAM,gBAAgB,GAC1B;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3F;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAkBjF;AAkED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,UAAU,EAAE,GAAG,UAAU,EAAE,CA4BpE;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,MAAM,CAY9D;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,UAAU,GAAG,MAAM,CAEzD;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAIlF;AAED,8DAA8D;AAC9D,MAAM,WAAW,UAAU;IACzB,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,MAAM,EAAE;QACN,aAAa,CAAC,YAAY,EAAE;YAC1B,SAAS,EAAE,MAAM,CAAC;YAClB,MAAM,EAAE;gBAAE,aAAa,EAAE,qBAAqB,CAAC;gBAAC,OAAO,EAAE;oBAAE,IAAI,EAAE,MAAM,CAAC;oBAAC,IAAI,EAAE,MAAM,CAAA;iBAAE,CAAA;aAAE,CAAC;SAC3F,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACnB,CAAC;IACF,oDAAoD;IACpD,KAAK,EAAE;QAAE,WAAW,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAA;KAAE,CAAC;IAC1E,sEAAsE;IACtE,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;CAC9D;AAqBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,UAAU,EAChB,UAAU,EAAE,gBAAgB,GAC3B,OAAO,CAAC,IAAI,CAAC,CA8Df"}
@@ -0,0 +1,278 @@
1
+ /**
2
+ * All `/rewind` command logic, isolated from the ACP adapter so it can be unit
3
+ * tested without a live SDK session. The adapter (see acp-agent.ts) wires these
4
+ * helpers in by injecting the ACP client, the SDK `query` handle, and a
5
+ * `getSessionMessages` reader; this module never touches the transport itself.
6
+ *
7
+ * Responsibilities (story 006, R3.2–R3.6):
8
+ * - parse a `/rewind` prompt into a list / restore / invalid intent
9
+ * (`parseRewindInvocation`);
10
+ * - derive the checkpoint list from the session transcript — real user
11
+ * prompts only, most recent first, 1-based (`listCheckpoints`);
12
+ * - format the user-facing strings (`formatCheckpointList`,
13
+ * `formatRewindResult`, `formatRewindError`);
14
+ * - orchestrate one command end to end, emitting every message through the
15
+ * injected client before resolving (`handleRewindCommand`).
16
+ *
17
+ * `stripLocalCommandMetadata` is reused from acp-agent.ts (its single source of
18
+ * truth) rather than duplicated, so the definition of a "local-command marker
19
+ * row" stays in sync; the import is side-effect-free (acp-agent.ts runs nothing
20
+ * at module top level).
21
+ */
22
+ import { stripLocalCommandMetadata } from "./acp-agent.js";
23
+ /** Maximum Unicode code points kept from a prompt when building its checkpoint excerpt. */
24
+ const EXCERPT_MAX = 60;
25
+ /**
26
+ * Parse a raw prompt into a `/rewind` intent, or `null` when the prompt is not a
27
+ * `/rewind` command at all.
28
+ *
29
+ * The command token is matched exactly, so `"/rewindx"`, `"/compact"` and plain
30
+ * prose all return `null`. A bare `/rewind` (trailing whitespace allowed) is a
31
+ * `list` request; `/rewind <integer>` is a `restore` request; any other argument
32
+ * is `invalid`, carrying the trimmed raw argument text so the caller can echo it.
33
+ *
34
+ * The whole invocation must sit on a single line: any newline surviving the
35
+ * trim makes the prompt NOT a `/rewind` command (`null`). Without this,
36
+ * `\s+` between command and argument would accept a line break, and a pasted
37
+ * two-line snippet like `"/rewind\n2"` would silently restore files — a
38
+ * destructive surprise for what the user meant as plain text.
39
+ *
40
+ * @param promptText Raw prompt text as typed by the user.
41
+ */
42
+ export function parseRewindInvocation(promptText) {
43
+ // Single-line anchor (see doc above): `\r` is included so a CR-separated
44
+ // paste cannot slip past the check either.
45
+ if (/[\r\n]/.test(promptText.trim())) {
46
+ return null;
47
+ }
48
+ const match = /^\/rewind(?:\s+(.*))?$/.exec(promptText.trim());
49
+ if (!match) {
50
+ return null;
51
+ }
52
+ const arg = match[1]?.trim() ?? "";
53
+ if (arg === "") {
54
+ return { kind: "list" };
55
+ }
56
+ if (/^-?\d+$/.test(arg)) {
57
+ return { kind: "restore", index: Number.parseInt(arg, 10) };
58
+ }
59
+ return { kind: "invalid", raw: arg };
60
+ }
61
+ /** Narrow an unknown value to a plain object we can read string-keyed fields off. */
62
+ function isRecord(value) {
63
+ return typeof value === "object" && value !== null;
64
+ }
65
+ /** Read the fields we care about from a transcript row, or `null` if not a user row. */
66
+ function asUserRow(message) {
67
+ const row = message;
68
+ if (row.type !== "user") {
69
+ return null;
70
+ }
71
+ if (typeof row.uuid !== "string" || row.uuid === "") {
72
+ return null;
73
+ }
74
+ const parentToolUseId = typeof row.parent_tool_use_id === "string" ? row.parent_tool_use_id : null;
75
+ return { uuid: row.uuid, content: row.message?.content, parentToolUseId };
76
+ }
77
+ /** Whether `content` carries a tool_result block (a synthetic tool-output row). */
78
+ function hasToolResultBlock(content) {
79
+ if (!Array.isArray(content)) {
80
+ return false;
81
+ }
82
+ return content.some((block) => isRecord(block) && block.type === "tool_result");
83
+ }
84
+ /** Flatten user-message content (a string or a block array) to its plain text. */
85
+ function extractText(content) {
86
+ if (typeof content === "string") {
87
+ return content;
88
+ }
89
+ if (!Array.isArray(content)) {
90
+ return "";
91
+ }
92
+ const parts = [];
93
+ for (const block of content) {
94
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
95
+ parts.push(block.text);
96
+ }
97
+ }
98
+ return parts.join(" ");
99
+ }
100
+ /** Collapse whitespace/newlines to single spaces and clip to the excerpt length.
101
+ * The clip counts Unicode code points, not UTF-16 code units: `String.slice`
102
+ * at a fixed offset can split a surrogate pair (e.g. an emoji sitting on the
103
+ * boundary) and emit a lone surrogate — invalid Unicode that Zed's serde_json
104
+ * rejects, dropping the whole session/update notification carrying the
105
+ * excerpt. `toWellFormed()` additionally scrubs any lone surrogate already
106
+ * present in the transcript text (JSON.parse admits them via `\uD800`-style
107
+ * escapes) for the same reason. */
108
+ function toExcerpt(text) {
109
+ const collapsed = text.replace(/\s+/g, " ").trim();
110
+ return Array.from(collapsed).slice(0, EXCERPT_MAX).join("").toWellFormed();
111
+ }
112
+ /**
113
+ * Build the rewind checkpoint list from a session transcript.
114
+ *
115
+ * Only REAL user prompts are checkpoints: rows carrying a `parent_tool_use_id`,
116
+ * tool_result rows, and local-command marker rows (`<command-name>…`, which
117
+ * `stripLocalCommandMetadata` reduces to null) are all skipped. The result is
118
+ * ordered most recent first with a 1-based `index` (most recent = 1); each
119
+ * `excerpt` is the first 60 code points of the prompt on a single line.
120
+ *
121
+ * @param messages Session transcript in chronological order (SDK messages).
122
+ */
123
+ export function listCheckpoints(messages) {
124
+ const prompts = [];
125
+ for (const message of messages) {
126
+ const row = asUserRow(message);
127
+ if (!row) {
128
+ continue;
129
+ }
130
+ if (row.parentToolUseId !== null) {
131
+ continue;
132
+ }
133
+ if (hasToolResultBlock(row.content)) {
134
+ continue;
135
+ }
136
+ const stripped = stripLocalCommandMetadata(row.content);
137
+ if (stripped === null) {
138
+ continue;
139
+ }
140
+ const excerpt = toExcerpt(extractText(stripped));
141
+ if (excerpt === "") {
142
+ continue;
143
+ }
144
+ prompts.push({ uuid: row.uuid, excerpt });
145
+ }
146
+ return prompts.reverse().map((prompt, position) => ({
147
+ index: position + 1,
148
+ uuid: prompt.uuid,
149
+ excerpt: prompt.excerpt,
150
+ }));
151
+ }
152
+ /**
153
+ * Render the checkpoint list as a numbered, user-facing message. An empty list
154
+ * yields a "nothing to rewind" notice instead (R3.6).
155
+ *
156
+ * @param cps Checkpoints, already ordered most recent first.
157
+ */
158
+ export function formatCheckpointList(cps) {
159
+ if (cps.length === 0) {
160
+ return "There is nothing to rewind — this session has no earlier prompts yet.";
161
+ }
162
+ const lines = cps.map((cp) => `${cp.index}. ${cp.excerpt}`);
163
+ return [
164
+ "Rewind checkpoints (most recent first):",
165
+ "",
166
+ ...lines,
167
+ "",
168
+ "Restore files to one with `/rewind <n>`.",
169
+ ].join("\n");
170
+ }
171
+ /**
172
+ * Confirm a completed rewind, naming the checkpoint that was restored by its
173
+ * index and excerpt (R3.4).
174
+ *
175
+ * @param cp The checkpoint whose files were restored.
176
+ */
177
+ export function formatRewindResult(cp) {
178
+ return `Rewound files to checkpoint ${cp.index}: "${cp.excerpt}".`;
179
+ }
180
+ /**
181
+ * Build a `/rewind` usage/error message. Always names the `/rewind` command and,
182
+ * when at least one checkpoint exists, the valid index range (R3.5). An optional
183
+ * `detail` (e.g. the reason a restore failed) is shown ahead of the usage line —
184
+ * this is how the orchestrator surfaces a failure WITHOUT ever claiming success.
185
+ *
186
+ * @param checkpointCount Number of available checkpoints (0 when none/unknown).
187
+ * @param detail Optional human-readable explanation shown before the usage line.
188
+ */
189
+ export function formatRewindError(checkpointCount, detail) {
190
+ const range = checkpointCount > 0 ? ` (valid indices 1–${checkpointCount})` : "";
191
+ const usage = `Usage: \`/rewind\` to list checkpoints, or \`/rewind <n>\`${range} to restore.`;
192
+ return detail ? `${detail}\n\n${usage}` : usage;
193
+ }
194
+ /** Coerce an unknown thrown value into a short, human-readable reason. */
195
+ function reasonText(error) {
196
+ if (error instanceof Error) {
197
+ return error.message;
198
+ }
199
+ if (typeof error === "string") {
200
+ return error;
201
+ }
202
+ return "unknown error";
203
+ }
204
+ /** Emit one user-facing text chunk through the injected ACP client. */
205
+ async function emit(deps, text) {
206
+ await deps.client.sessionUpdate({
207
+ sessionId: deps.sessionId,
208
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
209
+ });
210
+ }
211
+ /**
212
+ * Drive a parsed `/rewind` invocation end to end, streaming every user-facing
213
+ * message through `deps.client` before the returned promise resolves.
214
+ *
215
+ * `list` fetches the transcript and emits the numbered checkpoint list (or the
216
+ * nothing-to-rewind notice). `restore` fetches + rebuilds the list, validates
217
+ * the requested index, and on a hit calls `query.rewindFiles` with the tracked
218
+ * uuid inside a try/catch — a rejection (or a `canRewind: false` result) is
219
+ * reported via {@link formatRewindError} and never claims success. An `invalid`
220
+ * invocation, an out-of-range index, or a failed transcript read each emit an
221
+ * error and leave files untouched (`rewindFiles` is never called); the
222
+ * `invalid` path still reads the transcript so its usage message can name the
223
+ * valid range (R3.5), degrading to the rangeless message if that read fails.
224
+ *
225
+ * Failure handling: `getSessionMessages` and `query.rewindFiles` rejections
226
+ * are caught and surfaced as user-facing messages. A rejection from
227
+ * `deps.client.sessionUpdate` itself (the channel those messages are emitted
228
+ * on) is NOT caught here — it propagates to the caller, which guards it.
229
+ *
230
+ * @param deps Injected client, query handle, transcript reader and session id.
231
+ * @param invocation Parsed `/rewind` intent from {@link parseRewindInvocation}.
232
+ */
233
+ export async function handleRewindCommand(deps, invocation) {
234
+ if (invocation.kind === "invalid") {
235
+ // R3.5 wants errors to carry usage + the valid range; the range needs the
236
+ // checkpoint count, so read the transcript here too. The invalid argument
237
+ // is the error being reported, though, so a failed read degrades to the
238
+ // rangeless usage message instead of masking it with a transcript error.
239
+ let checkpointCount = 0;
240
+ try {
241
+ checkpointCount = listCheckpoints(await deps.getSessionMessages(deps.sessionId)).length;
242
+ }
243
+ catch {
244
+ // Rangeless fallback: checkpointCount stays 0.
245
+ }
246
+ await emit(deps, formatRewindError(checkpointCount, `"${invocation.raw}" is not a valid checkpoint number.`));
247
+ return;
248
+ }
249
+ let checkpoints;
250
+ try {
251
+ checkpoints = listCheckpoints(await deps.getSessionMessages(deps.sessionId));
252
+ }
253
+ catch (error) {
254
+ await emit(deps, formatRewindError(0, `Could not read the session history: ${reasonText(error)}.`));
255
+ return;
256
+ }
257
+ if (invocation.kind === "list") {
258
+ await emit(deps, formatCheckpointList(checkpoints));
259
+ return;
260
+ }
261
+ const checkpoint = checkpoints.find((cp) => cp.index === invocation.index);
262
+ if (!checkpoint) {
263
+ await emit(deps, formatRewindError(checkpoints.length, `There is no checkpoint ${invocation.index}.`));
264
+ return;
265
+ }
266
+ try {
267
+ const result = await deps.query.rewindFiles(checkpoint.uuid);
268
+ if (result.canRewind === false) {
269
+ await emit(deps, formatRewindError(checkpoints.length, `Rewind failed: ${result.error ?? "the SDK could not rewind these files"}.`));
270
+ return;
271
+ }
272
+ }
273
+ catch (error) {
274
+ await emit(deps, formatRewindError(checkpoints.length, `Rewind failed: ${reasonText(error)}.`));
275
+ return;
276
+ }
277
+ await emit(deps, formatRewindResult(checkpoint));
278
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * "Thinking" session config option (story 006, R1.1/R1.4/R1.5/R1.6): the
3
+ * select-option factory surfaced via `configOptions`, the value resolver for
4
+ * `session/set_config_option`, and the ONE precedence point that combines the
5
+ * session's thinking intent with the legacy `MAX_THINKING_TOKENS` env
6
+ * resolution into the SDK's `thinking` option.
7
+ *
8
+ * Kept self-contained (no import from the daily-churning `acp-agent.ts`,
9
+ * mirroring the `ask-user-question-fallback.ts` precedent) so the logic is
10
+ * unit-testable in isolation; `acp-agent.ts` wires it into
11
+ * `buildConfigOptions`/`setSessionConfigOption`/query creation in sub-tasks
12
+ * 1.2/1.3.
13
+ */
14
+ import type { SessionConfigOption } from "@agentclientprotocol/sdk";
15
+ import type { ThinkingConfig } from "@anthropic-ai/claude-agent-sdk";
16
+ /** Stable id for the Thinking session config option. */
17
+ export declare const THINKING_CONFIG_ID = "thinking";
18
+ /** Select value that turns extended thinking on. */
19
+ export declare const THINKING_ON = "on";
20
+ /** Select value that turns extended thinking off. */
21
+ export declare const THINKING_OFF = "off";
22
+ /**
23
+ * Minimal logging surface this module needs — structurally compatible with
24
+ * acp-agent's `Logger` (`{ log, error }`), declared locally so the module
25
+ * stays free of `acp-agent.ts` imports.
26
+ */
27
+ export interface ThinkingLogger {
28
+ log: (...args: unknown[]) => void;
29
+ error: (...args: unknown[]) => void;
30
+ }
31
+ /**
32
+ * Build the Thinking config option: a two-value "on"/"off" `select` in the
33
+ * exact shape of the Fast mode select fallback (R1.1). Unlike Fast mode there
34
+ * is no boolean variant — the option is select-only from day one.
35
+ *
36
+ * @param enabled Whether extended thinking is currently on for the session;
37
+ * reflected in `currentValue`.
38
+ */
39
+ export declare function createThinkingConfigOption(enabled: boolean): SessionConfigOption;
40
+ /**
41
+ * Resolve a `session/set_config_option` value for the Thinking option into the
42
+ * session's thinking intent. Only the select values are meaningful: `"on"` →
43
+ * `true`, `"off"` → `false`. Anything else — booleans included, since the
44
+ * option never had a legacy boolean shape (unlike Fast mode) — resolves to
45
+ * `null` (unrecognized).
46
+ *
47
+ * @param value Raw value from the request (untrusted; narrowed here).
48
+ */
49
+ export declare function resolveThinkingSelection(value: unknown): boolean | null;
50
+ /**
51
+ * Translate the legacy `MAX_THINKING_TOKENS` env var into the SDK's `thinking`
52
+ * option: unset → `undefined` (SDK default, adaptive on models that support
53
+ * it); `0` → disabled; a positive integer → a fixed token budget. Anything
54
+ * else is ignored with a logged error, i.e. treated as unset.
55
+ *
56
+ * NOTE: duplicated from the unexported `resolveThinkingConfig` in
57
+ * `acp-agent.ts` (behavior and log message identical); sub-task 1.3 makes
58
+ * `acp-agent.ts` consume this export and removes the duplication.
59
+ *
60
+ * @param raw The raw `MAX_THINKING_TOKENS` value (pass
61
+ * `process.env.MAX_THINKING_TOKENS`).
62
+ * @param logger Sink for the invalid-value error.
63
+ */
64
+ export declare function resolveThinkingConfig(raw: string | undefined, logger: ThinkingLogger): ThinkingConfig | undefined;
65
+ /**
66
+ * The ONE precedence point combining the session's Thinking intent with the
67
+ * legacy `MAX_THINKING_TOKENS` env resolution:
68
+ *
69
+ * - `intent === false` → `{ type: "disabled" }`, the SDK's documented
70
+ * no-extended-thinking value — the option beats the env var (R1.5).
71
+ * - `intent === true` → the env resolution when the env var is set (R1.4);
72
+ * with the env var unset — or invalid, which is logged and treated as unset
73
+ * — the SDK's documented enabled default `{ type: "adaptive" }` ("Claude
74
+ * decides when and how much to think").
75
+ * - `intent === undefined` → exactly today's env-driven behavior, including
76
+ * `undefined` (SDK default) when the env var is unset (R1.6).
77
+ *
78
+ * @param intent The session's Thinking selection: `true`/`false` once the
79
+ * client has set the option, `undefined` while untouched.
80
+ * @param env The raw `MAX_THINKING_TOKENS` value (pass
81
+ * `process.env.MAX_THINKING_TOKENS`).
82
+ * @param logger Sink for the invalid-env error.
83
+ */
84
+ export declare function effectiveThinkingConfig(intent: boolean | undefined, env: string | undefined, logger: ThinkingLogger): ThinkingConfig | undefined;
85
+ //# sourceMappingURL=thinking-option.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"thinking-option.d.ts","sourceRoot":"","sources":["../src/thinking-option.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAErE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,aAAa,CAAC;AAC7C,oDAAoD;AACpD,eAAO,MAAM,WAAW,OAAO,CAAC;AAChC,qDAAqD;AACrD,eAAO,MAAM,YAAY,QAAQ,CAAC;AAGlC;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;IAClC,KAAK,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,CAAC;CACrC;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,OAAO,GAAG,mBAAmB,CAahF;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAIvE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,MAAM,EAAE,cAAc,GACrB,cAAc,GAAG,SAAS,CAQ5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,OAAO,GAAG,SAAS,EAC3B,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,MAAM,EAAE,cAAc,GACrB,cAAc,GAAG,SAAS,CAI5B"}