viberoom 0.3.0 → 0.4.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.
Files changed (64) hide show
  1. package/NOTICE +12 -0
  2. package/README.md +119 -96
  3. package/dist/hub.js +18 -0
  4. package/dist/launcher.js +22 -0
  5. package/dist/main.js +4 -3
  6. package/dist/persona.js +2 -2
  7. package/dist/recipes.js +37 -14
  8. package/dist/room.js +3 -2
  9. package/dist/server.js +16 -3
  10. package/package.json +8 -5
  11. package/scripts/vendor-acp.mjs +85 -0
  12. package/ui/app.css +7 -0
  13. package/ui/app.js +116 -26
  14. package/ui/index.html +2 -0
  15. package/vendor/acp/claude-agent-acp/LICENSE +191 -0
  16. package/vendor/acp/claude-agent-acp/dist/acp-agent.js +7694 -0
  17. package/vendor/acp/claude-agent-acp/dist/acp-subagents.js +13 -0
  18. package/vendor/acp/claude-agent-acp/dist/air-extension.js +63 -0
  19. package/vendor/acp/claude-agent-acp/dist/async-tasks.js +613 -0
  20. package/vendor/acp/claude-agent-acp/dist/clear-context-coordinator.js +80 -0
  21. package/vendor/acp/claude-agent-acp/dist/elicitation.js +304 -0
  22. package/vendor/acp/claude-agent-acp/dist/exit-plan.js +154 -0
  23. package/vendor/acp/claude-agent-acp/dist/file-change-audit.js +350 -0
  24. package/vendor/acp/claude-agent-acp/dist/fork-session.js +41 -0
  25. package/vendor/acp/claude-agent-acp/dist/goal-extension.js +50 -0
  26. package/vendor/acp/claude-agent-acp/dist/index.js +98 -0
  27. package/vendor/acp/claude-agent-acp/dist/lib.js +5 -0
  28. package/vendor/acp/claude-agent-acp/dist/native-subagents.js +422 -0
  29. package/vendor/acp/claude-agent-acp/dist/permissions/effects.js +166 -0
  30. package/vendor/acp/claude-agent-acp/dist/permissions/modes.js +41 -0
  31. package/vendor/acp/claude-agent-acp/dist/permissions/normalization.js +100 -0
  32. package/vendor/acp/claude-agent-acp/dist/permissions/options/filesystem.js +124 -0
  33. package/vendor/acp/claude-agent-acp/dist/permissions/options/shared.js +64 -0
  34. package/vendor/acp/claude-agent-acp/dist/permissions/options/shell.js +100 -0
  35. package/vendor/acp/claude-agent-acp/dist/permissions/options/tools.js +135 -0
  36. package/vendor/acp/claude-agent-acp/dist/permissions/options.js +60 -0
  37. package/vendor/acp/claude-agent-acp/dist/permissions/presentation.js +83 -0
  38. package/vendor/acp/claude-agent-acp/dist/permissions/response.js +19 -0
  39. package/vendor/acp/claude-agent-acp/dist/session-config-ids.js +4 -0
  40. package/vendor/acp/claude-agent-acp/dist/session-failure-extension.js +324 -0
  41. package/vendor/acp/claude-agent-acp/dist/session-mode.js +234 -0
  42. package/vendor/acp/claude-agent-acp/dist/session-titles.js +199 -0
  43. package/vendor/acp/claude-agent-acp/dist/settings.js +185 -0
  44. package/vendor/acp/claude-agent-acp/dist/tool-result-meta.js +19 -0
  45. package/vendor/acp/claude-agent-acp/dist/tools.js +1235 -0
  46. package/vendor/acp/claude-agent-acp/dist/utils.js +81 -0
  47. package/vendor/acp/claude-agent-acp/package.json +7 -0
  48. package/vendor/acp/claude-agent-sdk/LICENSE.md +1 -0
  49. package/vendor/acp/claude-agent-sdk/agentSdkTypes.d.ts +1 -0
  50. package/vendor/acp/claude-agent-sdk/bridge.d.ts +378 -0
  51. package/vendor/acp/claude-agent-sdk/bridge.mjs +221 -0
  52. package/vendor/acp/claude-agent-sdk/browser-sdk.d.ts +107 -0
  53. package/vendor/acp/claude-agent-sdk/browser-sdk.js +185 -0
  54. package/vendor/acp/claude-agent-sdk/extractFromBunfs.d.ts +1 -0
  55. package/vendor/acp/claude-agent-sdk/extractFromBunfs.js +156 -0
  56. package/vendor/acp/claude-agent-sdk/manifest.json +65 -0
  57. package/vendor/acp/claude-agent-sdk/manifest.zst.json +73 -0
  58. package/vendor/acp/claude-agent-sdk/package.json +7 -0
  59. package/vendor/acp/claude-agent-sdk/sdk-tools.d.ts +4129 -0
  60. package/vendor/acp/claude-agent-sdk/sdk.d.ts +8687 -0
  61. package/vendor/acp/claude-agent-sdk/sdk.mjs +204 -0
  62. package/vendor/acp/codex-acp/LICENSE +190 -0
  63. package/vendor/acp/codex-acp/dist/index.js +34238 -0
  64. package/vendor/acp/codex-acp/package.json +7 -0
@@ -0,0 +1,80 @@
1
+ import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
2
+ function restartParams(session) {
3
+ const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] };
4
+ const currentEffort = session.configOptions.find((option) => option.id === EFFORT_CONFIG_ID)?.currentValue;
5
+ const originalMeta = originalParams._meta;
6
+ const originalOptions = originalMeta?.claudeCode?.options;
7
+ const unmanagedOptions = { ...(originalOptions ?? {}) };
8
+ delete unmanagedOptions.model;
9
+ delete unmanagedOptions.agent;
10
+ delete unmanagedOptions.effort;
11
+ return {
12
+ ...originalParams,
13
+ _meta: {
14
+ ...(originalMeta ?? {}),
15
+ claudeCode: {
16
+ ...(originalMeta?.claudeCode ?? {}),
17
+ options: {
18
+ ...unmanagedOptions,
19
+ ...(session.models.currentModelId !== DEFAULT_MODEL_ID
20
+ ? { model: session.models.currentModelId }
21
+ : {}),
22
+ ...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}),
23
+ ...(typeof currentEffort === "string" && currentEffort !== "default"
24
+ ? { effort: currentEffort }
25
+ : {}),
26
+ },
27
+ },
28
+ },
29
+ };
30
+ }
31
+ /** Replace Claude's private conversation and attach the still-pending ACP turn
32
+ * to it. The host owns provider-specific creation and stream mechanics; this
33
+ * coordinator owns the ordering and state transfer invariants. */
34
+ export async function continuePlanInFreshContext(sessionId, oldSession, reset, host, signal) {
35
+ const assertRestartActive = (session) => {
36
+ if (signal?.aborted || (session && host.currentSession(sessionId) !== session)) {
37
+ throw new Error("Clear-context restart aborted");
38
+ }
39
+ };
40
+ assertRestartActive();
41
+ const turn = oldSession.activeTurn;
42
+ if (!turn || turn.settled || host.currentSession(sessionId) !== oldSession) {
43
+ throw new Error("Cannot clear context without an active ACP turn");
44
+ }
45
+ const params = restartParams(oldSession);
46
+ host.closeQueryStream(oldSession);
47
+ const freshSession = await host.restartSession(params, {
48
+ publicSessionId: sessionId,
49
+ permissionMode: reset.mode,
50
+ });
51
+ assertRestartActive(freshSession);
52
+ // Do not consume the reset or mutate the turn until a replacement exists.
53
+ // A failed restart must remain distinguishable from a lost query transport.
54
+ turn.carriedUsage = { ...oldSession.accumulatedUsage };
55
+ turn.carriedModelUsage = { ...oldSession.accumulatedModelUsage };
56
+ oldSession.pendingExitPlanContextReset = undefined;
57
+ if (oldSession.fastModeEnabled !== freshSession.fastModeEnabled) {
58
+ try {
59
+ await host.applyFastMode(freshSession, oldSession.fastModeEnabled);
60
+ }
61
+ catch (error) {
62
+ host.logError("Failed to restore Fast mode after clearing context:", error);
63
+ }
64
+ assertRestartActive(freshSession);
65
+ }
66
+ oldSession.activeTurn = null;
67
+ oldSession.turnQueue = [];
68
+ freshSession.turnQueue = [turn];
69
+ freshSession.contextUsedTokens = 0;
70
+ try {
71
+ await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
72
+ }
73
+ catch (error) {
74
+ host.logError("Failed to publish clear-context session state:", error);
75
+ }
76
+ assertRestartActive(freshSession);
77
+ freshSession.input.push(host.continuationMessage(sessionId, reset.plan, turn.promptUuid));
78
+ assertRestartActive(freshSession);
79
+ host.ensureConsumer(freshSession, sessionId);
80
+ }
@@ -0,0 +1,304 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
3
+ /**
4
+ * Convert an MCP elicitation request (from the SDK's `onElicitation` callback)
5
+ * into an ACP `CreateElicitationRequest`. Returns `null` when the request can't
6
+ * be represented (e.g. a url-mode request with no url).
7
+ */
8
+ export function mcpElicitationToCreateRequest(request, sessionId) {
9
+ if (request.mode === "url") {
10
+ if (!request.url) {
11
+ return null;
12
+ }
13
+ return {
14
+ mode: "url",
15
+ sessionId,
16
+ message: request.message,
17
+ url: request.url,
18
+ // URL elicitations need a stable id so the client can correlate the
19
+ // later `session/complete_elicitation` notification. MCP servers usually
20
+ // provide one; fall back to a generated id if not.
21
+ elicitationId: request.elicitationId ?? randomUUID(),
22
+ };
23
+ }
24
+ // Form mode (the default). The MCP `requestedSchema` is already a JSON Schema
25
+ // with primitive-typed properties, which is structurally what ACP expects.
26
+ return {
27
+ mode: "form",
28
+ sessionId,
29
+ message: request.message,
30
+ requestedSchema: normalizeElicitationSchema(request.requestedSchema),
31
+ };
32
+ }
33
+ /**
34
+ * Content of an accepted elicitation response.
35
+ *
36
+ * Uses the SDK's validating guard rather than an `action === "accept"` check:
37
+ * the guard both narrows past the union's custom/future variant and validates
38
+ * the payload, so a malformed accept (right tag, ill-typed content) yields
39
+ * empty content — the same classification the SDK's wire validators apply.
40
+ */
41
+ function acceptedElicitationContent(response) {
42
+ return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
43
+ }
44
+ /**
45
+ * Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
46
+ * to hand back to the requesting server.
47
+ */
48
+ export function createElicitationResponseToElicitResult(response) {
49
+ switch (response.action) {
50
+ case "accept":
51
+ return { action: "accept", content: acceptedElicitationContent(response) };
52
+ case "decline":
53
+ return { action: "decline" };
54
+ case "cancel":
55
+ default:
56
+ return { action: "cancel" };
57
+ }
58
+ }
59
+ /**
60
+ * Pull the well-formed questions out of an AskUserQuestion tool input. Returns
61
+ * `null` when there are no usable questions — including the case where every
62
+ * entry is malformed and filtering leaves an empty list — so callers can treat
63
+ * "nothing to ask" uniformly.
64
+ */
65
+ export function extractAskUserQuestions(input) {
66
+ const questions = input.questions;
67
+ if (!Array.isArray(questions)) {
68
+ return null;
69
+ }
70
+ const valid = questions.filter((q) => !!q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length > 0);
71
+ return valid.length > 0 ? valid : null;
72
+ }
73
+ /** Stable form-field key for the question at the given index. */
74
+ function questionFieldKey(index) {
75
+ return `question_${index}`;
76
+ }
77
+ /**
78
+ * Form-field key for the per-question free-text "custom answer" field that sits
79
+ * alongside `question_<n>`. Mirrors the first-party clients, where every
80
+ * question carries its own "Other" box rather than one form-level field.
81
+ */
82
+ function questionCustomFieldKey(index) {
83
+ return `question_${index}_custom`;
84
+ }
85
+ /**
86
+ * `_meta` key under which a bridged enum option carries its `preview`, the one
87
+ * option field ACP's `EnumOption` still has no slot for (descriptions are
88
+ * first-class as of schema 1.19). Namespaced like the agent's other `_meta`
89
+ * extensions (`_claude/...`).
90
+ */
91
+ const OPTION_META_KEY = "_claude/askUserQuestionOption";
92
+ /**
93
+ * Shared `_meta` key for marking a per-question free-text field as the custom
94
+ * answer companion for a select question. This intentionally has no
95
+ * agent-specific namespace so ACP clients can recognize the same marker across
96
+ * Codex, Claude, and other AskUserQuestion bridges.
97
+ */
98
+ const CUSTOM_ANSWER_META_KEY = "_askUserQuestionCustomAnswer";
99
+ /**
100
+ * Render the AskUserQuestion tool's questions as an ACP form elicitation.
101
+ *
102
+ * Fields are keyed by a short stable id (`question_<n>`) rather than the full
103
+ * question text, so the question text appears in exactly one place per field.
104
+ * Single-select questions use a titled `oneOf` enum; multi-select questions use
105
+ * an array with a titled `anyOf` item enum. The enum `const` is always the
106
+ * option label, since that is what the tool records as the answer; an option's
107
+ * secondary text travels in the enum option's own `description` field.
108
+ *
109
+ * Each question is followed by its own optional free-text "custom answer" field
110
+ * (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
111
+ * user can type their own answer instead of picking an option, scoped to that
112
+ * specific question. Nothing is marked required, so the user can also just skip
113
+ * — matching the built-in tool, which always offers Skip + a free-text box.
114
+ */
115
+ export function askUserQuestionsToCreateRequest(questions, sessionId, toolCallId) {
116
+ const single = questions.length === 1;
117
+ const properties = {};
118
+ questions.forEach((question, index) => {
119
+ const options = question.options.map((option) => {
120
+ const enumOption = {
121
+ const: option.label,
122
+ title: option.label,
123
+ };
124
+ if (option.description) {
125
+ enumOption.description = option.description;
126
+ }
127
+ // The SDK option's `preview` (mockups, code snippets, comparisons shown
128
+ // on focus) still has no structural slot in `EnumOption`, so forward it
129
+ // under ACP's reserved `_meta` extension point for clients that render it.
130
+ if (option.preview) {
131
+ enumOption._meta = { [OPTION_META_KEY]: { preview: option.preview } };
132
+ }
133
+ return enumOption;
134
+ });
135
+ // For a single question the prompt is carried by `message`, so we don't
136
+ // repeat it in the field description. With multiple questions each field
137
+ // needs its own question text.
138
+ const description = single ? undefined : question.question;
139
+ const title = question.header || undefined;
140
+ properties[questionFieldKey(index)] = question.multiSelect
141
+ ? { type: "array", title, description, items: { anyOf: options } }
142
+ : { type: "string", title, description, oneOf: options };
143
+ properties[questionCustomFieldKey(index)] = {
144
+ type: "string",
145
+ title: "Other",
146
+ description: "Type your own answer instead of choosing an option above (optional).",
147
+ _meta: {
148
+ [CUSTOM_ANSWER_META_KEY]: {
149
+ questionId: questionFieldKey(index),
150
+ isCustomAnswer: true,
151
+ },
152
+ },
153
+ };
154
+ });
155
+ const requestedSchema = {
156
+ type: "object",
157
+ properties,
158
+ };
159
+ const message = single ? questions[0].question : "Please answer the following questions.";
160
+ return {
161
+ mode: "form",
162
+ sessionId,
163
+ ...(toolCallId ? { toolCallId } : {}),
164
+ message,
165
+ requestedSchema,
166
+ };
167
+ }
168
+ /**
169
+ * Fold an ACP elicitation response into the AskUserQuestion tool's input.
170
+ *
171
+ * Selected labels are read back from the indexed form fields and written into
172
+ * `answers` as a `{ [questionText]: label }` map (comma-joining multi-selects)
173
+ * — the key shape the tool's own `call()` reads. A non-empty per-question
174
+ * custom-answer field (`question_<n>_custom`) takes precedence over that
175
+ * question's selection, since the user typed their own answer instead of
176
+ * picking one. Decline yields empty answers (the model is told the user skipped
177
+ * rather than the turn aborting); cancel — and any custom/future action we
178
+ * don't understand — aborts the tool call.
179
+ */
180
+ export function applyAskElicitationResponse(response, toolInput, questions) {
181
+ if (response.action === "decline") {
182
+ return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
183
+ }
184
+ if (response.action !== "accept") {
185
+ return { action: "cancel" };
186
+ }
187
+ const content = acceptedElicitationContent(response);
188
+ // Typed against the tool's own output schema so the answer/response shapes
189
+ // stay in sync with what the built-in tool's call() expects to read back.
190
+ const answers = {};
191
+ questions.forEach((question, index) => {
192
+ // A typed custom answer wins over the selection: the user chose to write
193
+ // their own answer for this question instead of picking an option.
194
+ const custom = content[questionCustomFieldKey(index)];
195
+ if (typeof custom === "string" && custom.trim() !== "") {
196
+ answers[question.question] = custom.trim();
197
+ return;
198
+ }
199
+ const value = content[questionFieldKey(index)];
200
+ if (value === undefined || value === null) {
201
+ return;
202
+ }
203
+ const text = Array.isArray(value) ? value.join(", ") : String(value);
204
+ if (text === "") {
205
+ return;
206
+ }
207
+ answers[question.question] = text;
208
+ });
209
+ return { action: "answered", updatedInput: { ...toolInput, answers } };
210
+ }
211
+ /**
212
+ * Coerce an arbitrary MCP `requestedSchema` into an ACP `ElicitationSchema`.
213
+ * The two are structurally compatible JSON Schemas; we just guarantee the
214
+ * `type: "object"` discriminator is present.
215
+ */
216
+ function normalizeElicitationSchema(schema) {
217
+ if (!schema || typeof schema !== "object") {
218
+ return { type: "object", properties: {} };
219
+ }
220
+ return { ...schema, type: "object" };
221
+ }
222
+ /**
223
+ * The `request_user_dialog` kind the CLI emits when a model refusal has a
224
+ * fallback available but needs user consent before retrying (e.g. Claude Fable
225
+ * declining a request with Opus available as the fallback). Declaring this
226
+ * kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
227
+ * emits an undeclared kind — the flow degrades to the classic refusal error
228
+ * ending the turn.
229
+ */
230
+ export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
231
+ /**
232
+ * Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
233
+ * Returns `null` when the required fields are missing or mistyped (a newer CLI
234
+ * may reshape the payload), so the caller can cancel the dialog and let the
235
+ * CLI apply its default behavior instead of rendering something misleading.
236
+ */
237
+ export function extractRefusalFallbackPrompt(payload) {
238
+ const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload;
239
+ if (typeof originalModel !== "string" || typeof fallbackModel !== "string") {
240
+ return null;
241
+ }
242
+ return {
243
+ originalModel,
244
+ fallbackModel,
245
+ apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null,
246
+ ...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}),
247
+ };
248
+ }
249
+ /** Form-field key carrying the user's choice in the refusal-fallback form. */
250
+ const REFUSAL_FALLBACK_CHOICE_KEY = "choice";
251
+ /** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is
252
+ * deliberately not offered: in the CLI it prefills the composer with the
253
+ * refused prompt for edit-and-retry, and ACP has no composer-prefill surface
254
+ * — the user can simply edit and resend on their own. */
255
+ const RETRY_FALLBACK_RESULT = "retry_fallback";
256
+ const KEEP_REFUSAL_RESULT = "cancelled";
257
+ /**
258
+ * Render the refusal-fallback consent prompt as an ACP form elicitation: a
259
+ * single-select between retrying on the fallback model and keeping the
260
+ * refusal. The enum `const`s are the dialog's wire result values, so the
261
+ * response maps back without a translation table.
262
+ */
263
+ export function refusalFallbackToCreateRequest(prompt, sessionId) {
264
+ const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : "";
265
+ const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : "";
266
+ return {
267
+ mode: "form",
268
+ sessionId,
269
+ message: `${prompt.originalModel} declined this request${category}. ` +
270
+ `Retry with ${prompt.fallbackModel}?` +
271
+ guidance,
272
+ requestedSchema: {
273
+ type: "object",
274
+ properties: {
275
+ [REFUSAL_FALLBACK_CHOICE_KEY]: {
276
+ type: "string",
277
+ oneOf: [
278
+ {
279
+ const: RETRY_FALLBACK_RESULT,
280
+ title: `Retry with ${prompt.fallbackModel}`,
281
+ description: `The session continues on ${prompt.fallbackModel}.`,
282
+ },
283
+ {
284
+ const: KEEP_REFUSAL_RESULT,
285
+ title: "Keep the refusal",
286
+ description: "You can send a new message.",
287
+ },
288
+ ],
289
+ },
290
+ },
291
+ },
292
+ };
293
+ }
294
+ /**
295
+ * Map the elicitation response back to the dialog's result enum. Only an
296
+ * explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
297
+ * skipped field, or an unrecognized value all keep the refusal — the dialog's
298
+ * own default — so a dismissed or half-filled form can never trigger a model
299
+ * switch the user didn't ask for.
300
+ */
301
+ export function refusalFallbackResultFromResponse(response) {
302
+ const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
303
+ return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
304
+ }
@@ -0,0 +1,154 @@
1
+ import { continuePlanInFreshContext, } from "./clear-context-coordinator.js";
2
+ import { parseToolResultMeta } from "./tool-result-meta.js";
3
+ export function acceptedPlanToolResult(notification, toolUseId) {
4
+ const update = notification.update;
5
+ if (!toolUseId ||
6
+ update.sessionUpdate !== "tool_call_update" ||
7
+ update.toolCallId !== toolUseId) {
8
+ return notification;
9
+ }
10
+ const completed = { ...update };
11
+ delete completed.rawOutput;
12
+ delete completed.content;
13
+ return { ...notification, update: { ...completed, status: "completed" } };
14
+ }
15
+ function containsToolResultFor(content, toolUseId) {
16
+ return (Array.isArray(content) &&
17
+ content.some((block) => typeof block === "object" &&
18
+ block !== null &&
19
+ block.type === "tool_result" &&
20
+ block.tool_use_id === toolUseId));
21
+ }
22
+ function rejectedExitPlanToolUseId(content, toolUseCache, rawToolResultMeta) {
23
+ if (!Array.isArray(content))
24
+ return undefined;
25
+ const toolResultMeta = parseToolResultMeta(rawToolResultMeta);
26
+ if (!toolResultMeta)
27
+ return undefined;
28
+ for (const block of content) {
29
+ if (typeof block !== "object" || block === null)
30
+ continue;
31
+ const { type, tool_use_id: toolUseId, is_error: isError } = block;
32
+ if (type === "tool_result" &&
33
+ typeof toolUseId === "string" &&
34
+ isError === true &&
35
+ toolResultMeta.get(toolUseId)?.nonExecutionKind === "user-rejected" &&
36
+ toolUseCache[toolUseId]?.name === "ExitPlanMode") {
37
+ return toolUseId;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ /** Reconcile an SDK user message with the two pending ExitPlanMode lanes and
43
+ * return the accepted plan tool id whose rendered update must be completed. */
44
+ export function observeExitPlanToolResults(message, content, state) {
45
+ if (message.type !== "user")
46
+ return undefined;
47
+ const rejectedToolUseId = rejectedExitPlanToolUseId(content, state.toolUseCache, message.tool_result_meta);
48
+ if (rejectedToolUseId) {
49
+ // The stream is authoritative: resumed queries can lose the short-lived
50
+ // marker installed by canUseTool, while metadata preserves correlation.
51
+ state.pendingExitPlanModeInterruption = {
52
+ toolUseId: rejectedToolUseId,
53
+ toolResultSeen: true,
54
+ };
55
+ }
56
+ const pendingInterruption = state.pendingExitPlanModeInterruption;
57
+ if (pendingInterruption && containsToolResultFor(content, pendingInterruption.toolUseId)) {
58
+ pendingInterruption.toolResultSeen = true;
59
+ }
60
+ const pendingReset = state.pendingExitPlanContextReset;
61
+ return pendingReset && containsToolResultFor(content, pendingReset.toolUseId)
62
+ ? pendingReset.toolUseId
63
+ : undefined;
64
+ }
65
+ export function executionDiagnostic(message) {
66
+ if (message.subtype === "success") {
67
+ return message.result.startsWith("[ede_diagnostic]") ? message.result : undefined;
68
+ }
69
+ return message.errors.find((error) => error.startsWith("[ede_diagnostic]"));
70
+ }
71
+ /** Claude wraps a rejected ExitPlanMode explanation in a Markdown code fence.
72
+ * Strip exactly one complete outer fence for that tool only. */
73
+ export function exitPlanModeRawOutput(toolName, content) {
74
+ if (toolName !== "ExitPlanMode" || typeof content !== "string") {
75
+ return content;
76
+ }
77
+ const fenced = /^\s*```[^\r\n]*\r?\n([\s\S]*?)\r?\n```\s*$/.exec(content);
78
+ return fenced?.[1] ?? content;
79
+ }
80
+ /** Owns the lifetime of accepted-plan context replacements. In particular, a
81
+ * session cancellation invalidates an in-progress async restart so a late
82
+ * restartSession result cannot recreate a closed public session. */
83
+ export class ExitPlanCoordinator {
84
+ host;
85
+ restarts = new Map();
86
+ constructor(host) {
87
+ this.host = host;
88
+ }
89
+ cancel(sessionId) {
90
+ this.restarts.get(sessionId)?.abort();
91
+ }
92
+ async restart(sessionId, oldSession, reset) {
93
+ this.cancel(sessionId);
94
+ const controller = new AbortController();
95
+ this.restarts.set(sessionId, controller);
96
+ try {
97
+ const clearContextHost = {
98
+ ...this.host,
99
+ publishSessionState: async (id, mode, configOptions) => {
100
+ await this.host.sessionUpdate({
101
+ sessionId: id,
102
+ update: { sessionUpdate: "current_mode_update", currentModeId: mode },
103
+ });
104
+ await this.host.sessionUpdate({
105
+ sessionId: id,
106
+ update: { sessionUpdate: "config_option_update", configOptions },
107
+ });
108
+ },
109
+ continuationMessage: (id, plan, promptUuid) => ({
110
+ type: "user",
111
+ message: {
112
+ role: "user",
113
+ content: [{ type: "text", text: `Implement the following plan:\n\n${plan}` }],
114
+ },
115
+ session_id: id,
116
+ parent_tool_use_id: null,
117
+ origin: { kind: "human" },
118
+ uuid: promptUuid,
119
+ }),
120
+ };
121
+ await continuePlanInFreshContext(sessionId, oldSession, reset, clearContextHost, controller.signal);
122
+ }
123
+ catch (error) {
124
+ const currentSession = this.host.currentSession(sessionId);
125
+ const replacement = currentSession !== oldSession ? currentSession : undefined;
126
+ if (replacement)
127
+ this.host.destroyReplacement(sessionId, replacement);
128
+ const turn = replacement?.activeTurn ?? oldSession.activeTurn;
129
+ if (turn && !turn.settled) {
130
+ const turnSession = replacement?.activeTurn === turn ? replacement : oldSession;
131
+ if (controller.signal.aborted) {
132
+ this.host.settleCancelledTurn(oldSession, turnSession, turn);
133
+ }
134
+ else {
135
+ // A provider/session-creation failure is turn-scoped. Settling it
136
+ // here keeps it out of the query consumer's transport-loss catch.
137
+ this.host.settleFailedTurn(turnSession, turn, error);
138
+ }
139
+ oldSession.activeTurn = null;
140
+ oldSession.turnQueue = (oldSession.turnQueue ?? []).filter((queued) => queued !== turn);
141
+ if (replacement) {
142
+ replacement.activeTurn = null;
143
+ replacement.turnQueue = (replacement.turnQueue ?? []).filter((queued) => queued !== turn);
144
+ }
145
+ }
146
+ oldSession.pendingExitPlanContextReset = undefined;
147
+ }
148
+ finally {
149
+ if (this.restarts.get(sessionId) === controller) {
150
+ this.restarts.delete(sessionId);
151
+ }
152
+ }
153
+ }
154
+ }