1import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";
8import * as EffectAcpErrors from "effect-acp/errors";
9import type * as EffectAcpSchema from "effect-acp/compat";
10
11import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
12import type { AcpToolCallState } from "@t3tools/provider-acp/server/runtimeModel";
13
14const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
15export const xAiRateLimitedErrorCode = -32003;
16const completedXAiPromptIdLimit = 128;
17
18const XAiPromptCompleteNotification = Schema.Struct({
19 sessionId: Schema.String,
20 promptId: Schema.optional(Schema.String),
21 stopReason: Schema.optional(Schema.String),
22 agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)),
23});
24
25type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
26
27/**
28 * Grok persists root turn completion as an extension session update:
29 * `{ params: { sessionId, update: {
30 * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`.
31 * The open-source runtime forwards it as `x.ai/session_notification` and
32 * stores/replays it as `_x.ai/session/update`; released 0.2.106 forwards the
33 * underscore alias `_x.ai/session_notification`. Current source also emits
34 * the fire-and-forget canonical `x.ai/session/prompt_complete`, while released
35 * builds use the underscore alias.
36 */
37const XAiSessionUpdateNotification = Schema.Struct({
38 sessionId: Schema.String,
39 update: Schema.Struct({
40 sessionUpdate: Schema.String,
41 prompt_id: Schema.optional(Schema.String),
42 promptId: Schema.optional(Schema.String),
43 stop_reason: Schema.optional(Schema.String),
44 stopReason: Schema.optional(Schema.String),
45 agent_result: Schema.optional(Schema.NullOr(Schema.Unknown)),
46 // subagent_finished
47 child_session_id: Schema.optional(Schema.String),
48 status: Schema.optional(Schema.String),
49 output: Schema.optional(Schema.NullOr(Schema.String)),
50 error: Schema.optional(Schema.NullOr(Schema.String)),
51 }),
52 _meta: Schema.optional(Schema.Unknown),
53});
54
55type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type;
56
57const decodeXAiSessionUpdateNotification = Schema.decodeUnknownEffect(XAiSessionUpdateNotification);
58
59const xAiSessionNotificationMethods = [
60 "x.ai/session_notification",
61 "_x.ai/session_notification",
62 "_x.ai/session/update",
63] as const;
64
65const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-";
66
67/**
68 * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it
69 * is a root `turn_completed` for a real prompt id. Returns null for hooks,
70 * background task completions, and other update kinds.
71 */
72export function xAiPromptCompleteFromSessionUpdate(
73 notification: XAiSessionUpdateNotification,
74): XAiPromptCompleteNotification | null {
75 const update = notification.update;
76 if (update.sessionUpdate !== "turn_completed") {
77 return null;
78 }
79 const promptId = nonEmptyString(update.prompt_id) ?? nonEmptyString(update.promptId);
80 // Require prompt id so we never session-fallback match a background task
81 // completion (`task-completed-*`) or an unrelated CLI turn.
82 if (promptId === undefined || promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX)) {
83 return null;
84 }
85 const stopReason = nonEmptyString(update.stop_reason) ?? nonEmptyString(update.stopReason);
86 return {
87 sessionId: notification.sessionId,
88 promptId,
89 ...(stopReason === undefined ? {} : { stopReason }),
90 ...(update.agent_result === undefined ? {} : { agentResult: update.agent_result }),
91 };
92}
93
94/**
95 * Grok answers a finished background command in its own turn, tagging every
96 * frame with a `task-completed-*` prompt id instead of the one T3 sent.
97 */
98export function isXAiTaskCompletedWakeNotification(
99 notification: EffectAcpSchema.SessionNotification,
100): boolean {
101 const promptId = notification._meta?.promptId;
102 return typeof promptId === "string" && promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX);
103}
104
105interface PendingXAiPromptCompletion {
106 readonly sessionId: string;
107 readonly promptId: string;
108 readonly deferred: Deferred.Deferred<
109 EffectAcpSchema.PromptResponse,
110 EffectAcpErrors.AcpRequestError
111 >;
112}
113
114export interface XAiAcpSubagentUpdate {
115 readonly nativeTaskId: string;
116 readonly prompt: string;
117 readonly title: string | null;
118 readonly model: string | null;
119 readonly status: "running" | "completed" | "failed";
120 readonly childSessionId: string | null;
121 readonly result: string | null;
122 /**
123 * When false, the ACP adapter also projects a normal tool turn item for this
124 * tool call (used for get_command_or_subagent_output hydration). Default true.
125 */
126 readonly suppressNormalTool?: boolean;
127}
128
129export interface XAiAcpBackgroundToolMutation {
130 readonly taskId: string;
131 readonly status: "running" | "completed" | "failed";
132 readonly appendOutput: string;
133}
134
135export interface XAiAcpSubagentEndNotice {
136 readonly childSessionId: string;
137 readonly status: "completed" | "failed";
138}
139
140const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}";
141
142function unknownRecord(value: unknown): Record<string, unknown> | undefined {
143 return typeof value === "object" && value !== null && !Array.isArray(value)
144 ? (value as Record<string, unknown>)
145 : undefined;
146}
147
148function nonEmptyString(value: unknown): string | undefined {
149 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
150}
151
152function titleKey(toolCall: AcpToolCallState): string {
153 return (toolCall.title ?? "").trim().toLowerCase();
154}
155
156function decodeByteText(value: unknown): string | undefined {
157 if (typeof value === "string") return nonEmptyString(value);
158 if (!Array.isArray(value)) return undefined;
159 if (value.length === 0) return undefined;
160 if (!value.every((entry) => typeof entry === "number")) return undefined;
161 try {
162 return nonEmptyString(new TextDecoder().decode(Uint8Array.from(value as number[])));
163 } catch {
164 return undefined;
165 }
166}
167
168function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined {
169 const rawOutput = unknownRecord(toolCall.data.rawOutput);
170 if (rawOutput !== undefined) {
171 const direct =
172 nonEmptyString(rawOutput.text) ??
173 nonEmptyString(rawOutput.output_for_prompt) ??
174 decodeByteText(rawOutput.output);
175 if (direct !== undefined) return direct;
176 const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result);
177 if (result !== undefined) {
178 const nested =
179 nonEmptyString(result.output) ??
180 nonEmptyString(result.text) ??
181 decodeByteText(result.output);
182 if (nested !== undefined) return nested;
183 }
184 }
185 if (typeof toolCall.data.rawOutput === "string") {
186 return nonEmptyString(toolCall.data.rawOutput);
187 }
188 const content = toolCall.data.content;
189 if (!Array.isArray(content)) return undefined;
190 const text = content
191 .flatMap((entry) => {
192 const record = unknownRecord(entry);
193 const nested = unknownRecord(record?.content);
194 return nonEmptyString(nested?.text) ?? nonEmptyString(record?.text) ?? [];
195 })
196 .join("\n")
197 .trim();
198 return text.length > 0 ? text : undefined;
199}
200
201function firstUuidMatch(text: string, pattern: RegExp): string | undefined {
202 const match = pattern.exec(text);
203 return match?.[1];
204}
205
206function extractXAiChildSessionId(output: string | undefined): string | null {
207 if (output === undefined) return null;
208 return (
209 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
210 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
211 firstUuidMatch(output, new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i")) ??
212 null
213 );
214}
215
216function isXAiAsyncSpawnAck(output: string | undefined): boolean {
217 if (output === undefined) return false;
218 if (/subagent started in background/i.test(output)) return true;
219 return (
220 new RegExp(`subagent_id:\\s*${XAI_UUID_RE}`, "i").test(output) &&
221 /get_command_or_subagent_output/i.test(output)
222 );
223}
224
225function isXAiMonitorStartAck(output: string | undefined): boolean {
226 if (output === undefined) return false;
227 return /monitor started\s*\(\s*task\s+/i.test(output);
228}
229
230function isXAiSpawnOrTaskTool(
231 toolCall: AcpToolCallState,
232 rawInput: Record<string, unknown> | undefined,
233): boolean {
234 const title = titleKey(toolCall);
235 if (title === "task" || title === "spawn_subagent" || title.includes("spawn subagent")) {
236 return true;
237 }
238 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
239 if (variant === "cursortask" || variant === "task" || variant === "spawn_subagent") {
240 return true;
241 }
242 if (nonEmptyString(rawInput?.subagent_type) !== undefined) return true;
243 if (nonEmptyString(rawInput?.subagentType) !== undefined) return true;
244 return false;
245}
246
247function isXAiGetSubagentOutputTool(
248 toolCall: AcpToolCallState,
249 rawInput: Record<string, unknown> | undefined,
250): boolean {
251 const title = titleKey(toolCall);
252 if (
253 title === "get_command_or_subagent_output" ||
254 title.includes("get_command_or_subagent_output") ||
255 title === "wait_commands_or_subagents"
256 ) {
257 return true;
258 }
259 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
260 if (variant === "taskoutput" || variant === "task_output") return true;
261 const rawOutput = unknownRecord(toolCall.data.rawOutput);
262 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
263 if (outputType === "taskoutput" || outputType === "task_output") return true;
264 // ACP often titles this tool with the finished subagent label.
265 if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true;
266 return false;
267}
268
269export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean {
270 const title = titleKey(toolCall);
271 if (title === "monitor" || title.startsWith("monitor ")) return true;
272 const rawInput = unknownRecord(toolCall.data.rawInput);
273 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
274 if (variant === "monitor") return true;
275 // Live start ACKs often keep a generic title ("Tool") and only mark the
276 // structured envelope as Monitor; title rewrite to description must not
277 // prevent monitor registration / inProgress forcing.
278 const rawOutput = unknownRecord(toolCall.data.rawOutput);
279 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
280 if (outputType === "monitor") return true;
281 // Released clients can wrap the start ACK as plain Text and omit the Monitor
282 // variant. The ACK is still unambiguous and contains the task id required for
283 // background tracking, even after a generic title is rewritten from
284 // rawInput.description.
285 return isXAiMonitorStartAck(xAiToolOutputText(toolCall));
286}
287
288/**
289 * A shell command Grok runs in the background (asked for, or moved there after
290 * its auto-background timeout) acknowledges with
291 * `{ type: "BackgroundTaskStarted", task_id }` and completes its tool call
292 * while the process still runs. Like a monitor, it ends only with
293 * `x.ai/task_completed` for that task id.
294 */
295function xAiBackgroundShellTaskId(toolCall: AcpToolCallState): string | undefined {
296 const rawOutput = unknownRecord(toolCall.data.rawOutput);
297 return nonEmptyString(rawOutput?.type) === "BackgroundTaskStarted"
298 ? nonEmptyString(rawOutput?.task_id)
299 : undefined;
300}
301
302export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined {
303 const backgroundShellTaskId = xAiBackgroundShellTaskId(toolCall);
304 if (backgroundShellTaskId !== undefined) return backgroundShellTaskId;
305 if (!isXAiMonitorTool(toolCall)) return undefined;
306 const rawOutput = unknownRecord(toolCall.data.rawOutput);
307 // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent }
308 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
309 const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id);
310 if (structured !== undefined) return structured;
311 }
312 const output = xAiToolOutputText(toolCall);
313 if (output !== undefined) {
314 const fromAck = firstUuidMatch(
315 output,
316 new RegExp(`monitor started\\s*\\(\\s*task\\s+(${XAI_UUID_RE})\\b`, "i"),
317 );
318 if (fromAck !== undefined) return fromAck;
319 }
320 const rawInput = unknownRecord(toolCall.data.rawInput);
321 return (
322 nonEmptyString(rawInput?.task_id) ??
323 nonEmptyString(rawInput?.taskId) ??
324 (Array.isArray(rawInput?.task_ids) ? nonEmptyString(rawInput.task_ids[0]) : undefined)
325 );
326}
327
328/**
329 * Live Monitor start ACK `{ type: "Monitor", persistent: true }` (or matching
330 * rawInput). Persistent monitors should not hold root-turn deferred finalize.
331 */
332export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean {
333 if (!isXAiMonitorTool(toolCall)) return false;
334 const rawOutput = unknownRecord(toolCall.data.rawOutput);
335 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
336 return rawOutput.persistent === true;
337 }
338 const rawInput = unknownRecord(toolCall.data.rawInput);
339 return rawInput?.persistent === true;
340}
341
342/**
343 * When get_command_or_subagent_output / TaskOutput completes registered monitor
344 * task(s), return hydration for each completed background tool id.
345 */
346export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{
347 readonly taskId: string;
348 readonly status: "running" | "completed" | "failed";
349 readonly appendOutput: string;
350}> {
351 const rawInput = unknownRecord(toolCall.data.rawInput);
352 if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return [];
353 // Caller matches taskId against registered background tools (monitors).
354 // Subagent hydration stays on extractXAiAcpSubagentUpdate.
355 const text = xAiToolOutputText(toolCall);
356 const status = statusFromGetOutputTool(toolCall, text);
357 const appendOutput = resultFromGetOutputTool(toolCall, text) ?? "";
358 // Prefer the completed identity from the result/header over the request list
359 // (one call can poll multiple task_ids).
360 const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text);
361 if (resultTaskId !== undefined) {
362 return [{ taskId: resultTaskId, status, appendOutput }];
363 }
364 const requestIds: string[] = [];
365 const push = (value: unknown) => {
366 const id = nonEmptyString(value);
367 if (id !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(id)) {
368 requestIds.push(id);
369 }
370 };
371 if (Array.isArray(rawInput?.task_ids)) {
372 for (const entry of rawInput.task_ids) push(entry);
373 }
374 push(rawInput?.task_id);
375 push(rawInput?.taskId);
376 const unique = [...new Set(requestIds)];
377 return unique.map((taskId) => ({ taskId, status, appendOutput }));
378}
379
380/**
381 * A finished kill_command_or_subagent call is the genuine end signal for every
382 * task id it names: the Grok CLI emits no `x.ai/task_completed` for a killed
383 * task. A failed kill is not proof the target stopped, so leave the existing
384 * task state unchanged until a genuine terminal signal arrives.
385 */
386export function extractXAiKilledBackgroundTasks(toolCall: AcpToolCallState): ReadonlyArray<{
387 readonly taskId: string;
388 readonly status: "running" | "completed" | "failed";
389 readonly appendOutput: string;
390}> {
391 const title = titleKey(toolCall);
392 const rawInput = unknownRecord(toolCall.data.rawInput);
393 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
394 if (!title.includes("kill_command_or_subagent") && variant !== "kill") return [];
395 if (toolCall.status !== "completed") return [];
396 const taskIds: string[] = [];
397 const push = (value: unknown) => {
398 const id = nonEmptyString(value);
399 if (id !== undefined) taskIds.push(id);
400 };
401 if (Array.isArray(rawInput?.task_ids)) {
402 for (const entry of rawInput.task_ids) push(entry);
403 }
404 push(rawInput?.task_id);
405 push(rawInput?.taskId);
406 return [...new Set(taskIds)].map((taskId) => ({
407 taskId,
408 status: "completed" as const,
409 appendOutput: "",
410 }));
411}
412
413const XAiTaskLifecycleNotification = Schema.Struct({
414 sessionId: Schema.String,
415 update: Schema.Struct({
416 sessionUpdate: Schema.String,
417 task_id: Schema.optional(Schema.String),
418 tool_call_id: Schema.optional(Schema.String),
419 command: Schema.optional(Schema.String),
420 description: Schema.optional(Schema.NullOr(Schema.String)),
421 monitor_description: Schema.optional(Schema.NullOr(Schema.String)),
422 task_snapshot: Schema.optional(
423 Schema.Struct({
424 task_id: Schema.optional(Schema.String),
425 output: Schema.optional(Schema.String),
426 exit_code: Schema.optional(Schema.NullOr(Schema.Number)),
427 command: Schema.optional(Schema.String),
428 display_command: Schema.optional(Schema.NullOr(Schema.String)),
429 description: Schema.optional(Schema.NullOr(Schema.String)),
430 // `TaskKind` in grok-build crates/common/xai-tool-runtime/src/notification.rs.
431 kind: Schema.optional(Schema.String),
432 }),
433 ),
434 }),
435 _meta: Schema.optional(Schema.Unknown),
436});
437
438type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type;
439
440export interface XAiBackgroundTaskLifecycleMutation {
441 readonly sessionId: string;
442 readonly taskId: string;
443 readonly status: "running" | "completed" | "failed";
444 /** Final output from `task_completed.task_snapshot`, when Grok sent one. */
445 readonly output?: string;
446 /** What the task is and how to name it, when Grok said. */
447 readonly report?:
448 | { readonly kind: "command"; readonly label?: string; readonly exitCode?: number }
449 | { readonly kind: "monitor"; readonly label?: string };
450}
451
452function xAiBackgroundTaskReport(
453 update: XAiTaskLifecycleNotification["update"],
454): XAiBackgroundTaskLifecycleMutation["report"] {
455 const snapshot = update.task_snapshot;
456 const monitorDescription = nonEmptyString(update.monitor_description ?? undefined);
457 const displayCommand = nonEmptyString(snapshot?.display_command ?? undefined);
458 const isMonitor =
459 snapshot?.kind === "monitor" ||
460 monitorDescription !== undefined ||
461 displayCommand?.startsWith("[monitor]") === true;
462 // Grok's snapshot defaults `kind` to bash; anything that carries a command is one.
463 const isCommand =
464 snapshot?.kind === "bash" || snapshot?.command !== undefined || update.command !== undefined;
465 if (!isMonitor && !isCommand) return undefined;
466 const label =
467 monitorDescription ??
468 nonEmptyString(snapshot?.description ?? undefined) ??
469 nonEmptyString(update.description ?? undefined) ??
470 displayCommand?.replace(/^\[monitor\]\s*/, "") ??
471 nonEmptyString(snapshot?.command) ??
472 nonEmptyString(update.command);
473 const named = label === undefined ? {} : { label };
474 if (isMonitor) return { kind: "monitor", ...named };
475 const exitCode = snapshot?.exit_code;
476 return { kind: "command", ...named, ...(typeof exitCode === "number" ? { exitCode } : {}) };
477}
478
479export function xAiBackgroundTaskLifecycleMutation(
480 notification: XAiTaskLifecycleNotification,
481 status: "running" | "completed",
482): XAiBackgroundTaskLifecycleMutation | null {
483 const update = notification.update;
484 const taskId =
485 nonEmptyString(update.task_snapshot?.task_id) ??
486 nonEmptyString(update.task_id) ??
487 nonEmptyString(update.tool_call_id);
488 if (taskId === undefined) return null;
489 const exitCode = update.task_snapshot?.exit_code;
490 const output = update.task_snapshot?.output;
491 const report = xAiBackgroundTaskReport(update);
492 return {
493 sessionId: notification.sessionId,
494 taskId,
495 status:
496 status === "completed" && typeof exitCode === "number" && exitCode !== 0 ? "failed" : status,
497 ...(output === undefined ? {} : { output }),
498 ...(report === undefined ? {} : { report }),
499 };
500}
501
502/**
503 * Grok task lifecycle notifications use canonical `x.ai/task_*` methods in
504 * current builds; older 0.2.x builds used `_x.ai/task_*`. Interactive cancel
505 * preserves tasks that were already backgrounded (and some older builds could
506 * background the cancelled foreground command), so both spellings must be
507 * tracked. Residual frames then buffer instead of waking synthetic
508 * continuations, and pending-background-work stays truthful until the task
509 * ends (or the model kills it via kill_command_or_subagent).
510 */
511export const registerXAiBackgroundTaskTracking = (
512 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
513 apply: (mutation: XAiBackgroundTaskLifecycleMutation) => Effect.Effect<void>,
514): Effect.Effect<void, EffectAcpErrors.AcpError> =>
515 Effect.forEach(
516 [
517 ["x.ai/task_backgrounded", "running"],
518 ["_x.ai/task_backgrounded", "running"],
519 ["x.ai/task_completed", "completed"],
520 ["_x.ai/task_completed", "completed"],
521 ] as const,
522 ([method, status]) =>
523 runtime.handleExtNotification(method, XAiTaskLifecycleNotification, (notification) => {
524 const mutation = xAiBackgroundTaskLifecycleMutation(notification, status);
525 return mutation === null ? Effect.void : apply(mutation);
526 }),
527 { discard: true },
528 );
529
530/**
531 * A background subagent's end, sent on its PARENT session as
532 * `{ sessionUpdate: "subagent_finished", child_session_id, status, output, error }`
533 * (grok-build `crates/codegen/xai-grok-shell/src/extensions/notification.rs`
534 * `SessionUpdate::SubagentFinished`). `status` is exactly "completed",
535 * "failed" or "cancelled" (`SubagentResult::status()` in
536 * `crates/codegen/xai-grok-tools/src/implementations/grok_build/task/types.rs`);
537 * `output` is the final text of a completed subagent and `error` the message
538 * of a failed one. Grok follows it with its own `subagent-completed-<id>` wake
539 * turn when `will_wake` is true.
540 */
541export interface XAiSubagentFinishedNotice {
542 readonly sessionId: string;
543 readonly childSessionId: string;
544 readonly status: "completed" | "failed" | "cancelled";
545 readonly result: string | null;
546}
547
548/** Null for other session updates, and for a status Grok does not define. */
549export function xAiSubagentFinishedNotice(
550 notification: XAiSessionUpdateNotification,
551): XAiSubagentFinishedNotice | null {
552 const update = notification.update;
553 const childSessionId = nonEmptyString(update.child_session_id);
554 if (update.sessionUpdate !== "subagent_finished" || childSessionId === undefined) return null;
555 const status = update.status;
556 if (status !== "completed" && status !== "failed" && status !== "cancelled") return null;
557 const text = status === "completed" ? update.output : update.error;
558 return {
559 sessionId: notification.sessionId,
560 childSessionId,
561 status,
562 result: nonEmptyString(text ?? undefined) ?? null,
563 };
564}
565
566/**
567 * Finishes background subagents from Grok's `subagent_finished`. These arrive
568 * on the session notification methods that also carry turn completion; a
569 * runtime from {@link makeXAiPromptCompletionRuntime} settles its prompt from
570 * each notification before passing it to this handler.
571 */
572export const registerXAiSubagentFinished = (
573 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
574 finish: (notice: XAiSubagentFinishedNotice) => Effect.Effect<void>,
575): Effect.Effect<void> =>
576 Effect.forEach(
577 xAiSessionNotificationMethods,
578 (method) =>
579 runtime.handleExtNotification(method, XAiSessionUpdateNotification, (notification) => {
580 const notice = xAiSubagentFinishedNotice(notification);
581 return notice === null ? Effect.void : finish(notice);
582 }),
583 { discard: true },
584 );
585
586/**
587 * Grok ACP often sends `title: "Tool"` even when rawInput has a useful
588 * description or variant (especially Monitor). Prefer description/variant so
589 * the timeline matches Claude-style tool names rather than a generic label.
590 */
591export function isGenericAcpToolTitle(title: string | undefined): boolean {
592 const key = (title ?? "").trim().toLowerCase();
593 return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal";
594}
595
596export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined {
597 if (!isGenericAcpToolTitle(toolCall.title)) {
598 return nonEmptyString(toolCall.title);
599 }
600 const rawInput = unknownRecord(toolCall.data.rawInput);
601 const description = nonEmptyString(rawInput?.description);
602 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
603 if (description !== undefined) {
604 if (variant === "monitor" && !description.toLowerCase().startsWith("monitor")) {
605 return `Monitor: ${description}`;
606 }
607 return description;
608 }
609 if (variant === "monitor") return "Monitor";
610 if (variant === "task" || variant === "cursortask" || variant === "spawn_subagent") {
611 return "Task";
612 }
613 if (variant === "taskoutput" || variant === "task_output") return "Task output";
614 if (variant !== undefined) {
615 return variant;
616 }
617 const command =
618 nonEmptyString(rawInput?.command) ??
619 nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command);
620 if (command !== undefined) {
621 return command.length > 80 ? `${command.slice(0, 77)}...` : command;
622 }
623 return nonEmptyString(toolCall.title);
624}
625
626/**
627 * Normalize Grok ACP tool presentation and monitor lifecycle:
628 * - replace generic titles ("Tool") with description / variant labels
629 * - structured Monitor start ACK stays running
630 * - text start ACK stays running
631 * - structured Bash results with exit_code are terminal, unless Grok itself
632 * still reports the tool running. Grok 1.0.41 streams every monitor tick as
633 * `status: "in_progress"` with a Bash-shaped rawOutput whose exit_code is
634 * already 0, and never sends a completed status for the monitor; its end
635 * arrives as `_x.ai/task_completed`.
636 */
637export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState {
638 const resolvedTitle = resolveXAiAcpToolTitle(toolCall);
639 const withTitle =
640 resolvedTitle !== undefined && resolvedTitle !== toolCall.title
641 ? { ...toolCall, title: resolvedTitle }
642 : toolCall;
643
644 const rawOutput = unknownRecord(withTitle.data.rawOutput);
645 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
646 const reportedRunning = withTitle.status === "inProgress" || withTitle.status === "pending";
647 if (outputType === "bash" && !reportedRunning) {
648 // Same key set as the adapter's command projection (commandExitCode).
649 const exitCode = ["exit_code", "exitCode", "code"]
650 .map((key) => rawOutput?.[key])
651 .find((value) => typeof value === "number" && Number.isInteger(value));
652 if (typeof exitCode === "number") {
653 if (exitCode === 0 && (xAiToolOutputText(withTitle)?.trim().length ?? 0) === 0) {
654 return { ...withTitle, status: "inProgress" };
655 }
656 return {
657 ...withTitle,
658 status: exitCode === 0 ? "completed" : "failed",
659 };
660 }
661 }
662 if (xAiBackgroundShellTaskId(withTitle) !== undefined) {
663 // Start acknowledgement only; the command still runs in the background.
664 return { ...withTitle, status: "inProgress" };
665 }
666 if (!isXAiMonitorTool(withTitle)) {
667 return withTitle;
668 }
669 if (outputType === "monitor") {
670 // Start registration only; process still running in the background.
671 return { ...withTitle, status: "inProgress" };
672 }
673 const output = xAiToolOutputText(withTitle);
674 if (withTitle.status === "completed" && isXAiMonitorStartAck(output)) {
675 return { ...withTitle, status: "inProgress" };
676 }
677 return withTitle;
678}
679
680/**
681 * Parse Grok synthetic monitor traffic that arrives as root text chunks
682 * (`<monitor-event>` lines, batched `<monitor>` event blocks, and
683 * "Monitor ... ended" reminders). Coalesced chunks may contain several events;
684 * return every match so later progress / end notices are not dropped.
685 */
686export function extractXAiAcpBackgroundToolMutation(
687 text: string,
688): ReadonlyArray<XAiAcpBackgroundToolMutation> {
689 const mutations: XAiAcpBackgroundToolMutation[] = [];
690
691 const eventRe = new RegExp(
692 `<monitor-event\\s+task_id=["']?(${XAI_UUID_RE})["']?\\s*>\\s*([\\s\\S]*?)\\s*</monitor-event>`,
693 "gi",
694 );
695 for (const eventMatch of text.matchAll(eventRe)) {
696 if (eventMatch[1] === undefined) continue;
697 const line = (eventMatch[2] ?? "").trim();
698 mutations.push({
699 taskId: eventMatch[1],
700 status: "running",
701 appendOutput: line.length > 0 ? `${line}\n` : "",
702 });
703 }
704
705 // Batched form: "N monitor events from 1 monitor ...:\n<monitor
706 // description="..." task_id="...">\n[1] line\n</monitor>". Still a running
707 // monitor; must track and filter like single-event chatter.
708 const batchedRe = new RegExp(
709 `<monitor\\s[^>]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*</monitor>`,
710 "gi",
711 );
712 for (const batchedMatch of text.matchAll(batchedRe)) {
713 if (batchedMatch[1] === undefined) continue;
714 const lines = (batchedMatch[2] ?? "").trim();
715 mutations.push({
716 taskId: batchedMatch[1],
717 status: "running",
718 appendOutput: lines.length > 0 ? `${lines}\n` : "",
719 });
720 }
721
722 const endedRe = new RegExp(`Monitor\\s+["']?(${XAI_UUID_RE})["']?\\s+ended`, "gi");
723 for (const endedMatch of text.matchAll(endedRe)) {
724 if (endedMatch[1] === undefined) continue;
725 // Only the "Monitor … ended …" clause is outcome text. Description/output
726 // after the header can contain words like "error" without meaning failure.
727 const outcomeClause = (endedMatch[0] ?? "").trim();
728 const afterHeader = text.slice((endedMatch.index ?? 0) + endedMatch[0].length);
729 const outcomeTail = afterHeader.split(/\n/)[0] ?? "";
730 const outcome = `${outcomeClause}${outcomeTail}`;
731 const summary = text.replace(/<\/?system-reminder>/gi, "").trim();
732 mutations.push({
733 taskId: endedMatch[1],
734 status:
735 /exited\s*\(\s*code\s*0\s*\)/i.test(outcome) || /ended cleanly/i.test(outcome)
736 ? "completed"
737 : /exited|failed|error|signal/i.test(outcome)
738 ? "failed"
739 : "completed",
740 appendOutput: summary.length > 0 ? `${summary}\n` : "Monitor ended.\n",
741 });
742 }
743
744 return mutations;
745}
746
747/**
748 * Parse Grok's root-session subagent end reminder ("Background subagent
749 * "<uuid>" (general-purpose: "...") completed successfully."). The agent is
750 * free to skip get_command_or_subagent_output hydration, so this reminder can
751 * be the only terminal signal for the subagent row; a row stuck on running
752 * holds deferred finalize (and the whole run) open until harness timeout.
753 */
754function stripLeadingBalancedParenGroup(text: string): string {
755 const trimmed = text.replace(/^\s+/, "");
756 if (!trimmed.startsWith("(")) return trimmed;
757 let depth = 0;
758 for (let index = 0; index < trimmed.length; index += 1) {
759 const char = trimmed[index];
760 if (char === "(") depth += 1;
761 else if (char === ")") {
762 depth -= 1;
763 if (depth === 0) return trimmed.slice(index + 1).replace(/^\s+/, "");
764 }
765 }
766 return trimmed;
767}
768
769export function extractXAiAcpSubagentEndNotice(text: string): XAiAcpSubagentEndNotice | undefined {
770 const match = text.match(new RegExp(`Background subagent\\s+["']?(${XAI_UUID_RE})["']?`, "i"));
771 if (match?.[1] === undefined) return undefined;
772 // Skip the parenthesized descriptor (subagent type and title) so its words
773 // cannot masquerade as the outcome verb. Balance parens so nested titles
774 // like `(general-purpose: "Check (backend) failed")` do not leak.
775 const tail = stripLeadingBalancedParenGroup(text.slice((match.index ?? 0) + match[0].length));
776 if (/completed successfully/i.test(tail)) {
777 return { childSessionId: match[1], status: "completed" };
778 }
779 if (/\b(failed|errored|error|crashed|cancelled|canceled|interrupted|timed out)\b/i.test(tail)) {
780 return { childSessionId: match[1], status: "failed" };
781 }
782 if (/\b(completed|finished|ended)\b/i.test(tail)) {
783 return { childSessionId: match[1], status: "completed" };
784 }
785 return undefined;
786}
787
788function taskIdsFromGetOutputTool(
789 toolCall: AcpToolCallState,
790 rawInput: Record<string, unknown> | undefined,
791): ReadonlyArray<string> {
792 const ids: string[] = [];
793 const push = (value: unknown) => {
794 const text = nonEmptyString(value);
795 if (text !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(text)) {
796 ids.push(text);
797 }
798 };
799 if (Array.isArray(rawInput?.task_ids)) {
800 for (const entry of rawInput.task_ids) push(entry);
801 }
802 push(rawInput?.task_id);
803 push(rawInput?.taskId);
804 const resultId = resultTaskIdFromGetOutputTool(toolCall, xAiToolOutputText(toolCall));
805 if (resultId !== undefined) ids.push(resultId);
806 return [...new Set(ids)];
807}
808
809/** Identity of the task the get_output envelope actually reports (not the request list). */
810function resultTaskIdFromGetOutputTool(
811 toolCall: AcpToolCallState,
812 output: string | undefined,
813): string | undefined {
814 const rawOutput = unknownRecord(toolCall.data.rawOutput);
815 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
816 const structured =
817 nonEmptyString(result?.task_id) ??
818 nonEmptyString(result?.taskId) ??
819 nonEmptyString(rawOutput?.task_id) ??
820 nonEmptyString(rawOutput?.taskId);
821 if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) {
822 return structured;
823 }
824 if (output === undefined) return undefined;
825 const taskHeader = firstUuidMatch(
826 output,
827 new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i"),
828 );
829 return taskHeader;
830}
831
832function resultFromGetOutputTool(
833 toolCall: AcpToolCallState,
834 output: string | undefined,
835): string | null {
836 const rawOutput = unknownRecord(toolCall.data.rawOutput);
837 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
838 const structured =
839 nonEmptyString(result?.output) ??
840 nonEmptyString(result?.text) ??
841 decodeByteText(result?.output);
842 if (structured !== undefined) {
843 // Drop trailing machine meta blocks when present.
844 const cleaned = structured
845 .replace(/<subagent_meta>[\s\S]*?<\/subagent_meta>/gi, "")
846 .replace(/<subagent_result>[\s\S]*?<\/subagent_result>/gi, "")
847 .trim();
848 if (cleaned.length > 0) return cleaned;
849 return null;
850 }
851 if (output === undefined) return null;
852 const marker = output.match(/=== Output ===\s*([\s\S]*)$/i);
853 const body = (marker?.[1] ?? output).trim();
854 return body.length > 0 ? body : null;
855}
856
857function statusFromGetOutputTool(
858 toolCall: AcpToolCallState,
859 output: string | undefined,
860): "running" | "completed" | "failed" {
861 if (toolCall.status === "failed") return "failed";
862 const rawOutput = unknownRecord(toolCall.data.rawOutput);
863 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
864 const structuredStatus = nonEmptyString(result?.status)?.toLowerCase();
865 if (structuredStatus === "completed" || structuredStatus === "success") return "completed";
866 if (structuredStatus === "failed" || structuredStatus === "error") return "failed";
867 if (structuredStatus === "running" || structuredStatus === "pending") return "running";
868 if (typeof result?.exit_code === "number") {
869 return result.exit_code === 0 ? "completed" : "failed";
870 }
871 if (output !== undefined) {
872 if (/Status:\s*failed/i.test(output) || /Exit Code:\s*-?(?!0\b)\d+/i.test(output)) {
873 return "failed";
874 }
875 if (/Status:\s*completed/i.test(output) || /Status:\s*success/i.test(output)) {
876 return "completed";
877 }
878 if (/Status:\s*running/i.test(output) || /Status:\s*pending/i.test(output)) {
879 return "running";
880 }
881 }
882 // A completed get_output tool call only means the poll RPC returned. Without
883 // a task-level terminal status or exit code, the requested task is still
884 // running and must remain registered for subsequent polling.
885 return "running";
886}
887
888/**
889 * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration
890 * without teaching the generic ACP adapter about xAI tool names.
891 *
892 * Current Grok CLI returns spawn_subagent immediately with a background ACK
893 * (`subagent_id: ...`). That must stay `running` until child work finishes or
894 * get_command_or_subagent_output reports a terminal status.
895 */
896export function extractXAiAcpSubagentUpdate(
897 toolCall: AcpToolCallState,
898): XAiAcpSubagentUpdate | undefined {
899 const rawInput = unknownRecord(toolCall.data.rawInput);
900 const output = xAiToolOutputText(toolCall);
901
902 if (isXAiGetSubagentOutputTool(toolCall, rawInput)) {
903 const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput);
904 // Prefer the completed task identity from the result/header over the first
905 // requested task_ids entry (one call can poll multiple task_ids).
906 const childSessionId =
907 resultTaskIdFromGetOutputTool(toolCall, output) ??
908 taskIds[0] ??
909 extractXAiChildSessionId(output);
910 if (childSessionId === null) return undefined;
911 // Prefer the durable subagent row as the completion surface (Claude/Codex
912 // style). Suppress the noisy TaskOutput tool card; monitor hydration is
913 // handled separately via extractBackgroundTaskCompletion.
914 return {
915 nativeTaskId: toolCall.toolCallId,
916 prompt: "",
917 title: null,
918 model: null,
919 status: statusFromGetOutputTool(toolCall, output),
920 childSessionId,
921 result: resultFromGetOutputTool(toolCall, output),
922 suppressNormalTool: true,
923 };
924 }
925
926 if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined;
927
928 const childSessionId = extractXAiChildSessionId(output);
929 const asyncSpawnAck = isXAiAsyncSpawnAck(output);
930 const legacyResult =
931 output
932 ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n")
933 .replace(
934 new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"),
935 "\n",
936 )
937 .trim() || null;
938
939 let status: "running" | "completed" | "failed";
940 if (toolCall.status === "failed") {
941 status = "failed";
942 } else if (asyncSpawnAck) {
943 // Spawn RPC completed, but the child is still running.
944 status = "running";
945 } else if (toolCall.status === "completed") {
946 status = "completed";
947 } else {
948 status = "running";
949 }
950
951 return {
952 nativeTaskId: toolCall.toolCallId,
953 prompt: nonEmptyString(rawInput?.prompt) ?? "",
954 title: nonEmptyString(rawInput?.description) ?? null,
955 model: nonEmptyString(rawInput?.model) ?? null,
956 status,
957 childSessionId,
958 result: asyncSpawnAck ? null : legacyResult,
959 suppressNormalTool: true,
960 };
961}
962
963const XAiAskUserQuestionOption = Schema.Struct({
964 label: Schema.String,
965 description: Schema.optional(Schema.String),
966 preview: Schema.optional(Schema.String),
967 id: Schema.optional(Schema.String),
968});
969
970const XAiAskUserQuestion = Schema.Struct({
971 id: Schema.optional(Schema.String),
972 question: Schema.String,
973 options: Schema.Array(XAiAskUserQuestionOption),
974 multiSelect: Schema.optional(Schema.NullOr(Schema.Boolean)),
975});
976
977const XAiAskUserQuestionParams = Schema.Struct({
978 sessionId: Schema.String,
979 toolCallId: Schema.String,
980 questions: Schema.Array(XAiAskUserQuestion),
981 mode: Schema.Literals(["default", "plan"]),
982});
983
984const XAiWrappedAskUserQuestionParams = Schema.Struct({
985 method: Schema.Literals(["x.ai/ask_user_question", "_x.ai/ask_user_question"]),
986 params: XAiAskUserQuestionParams,
987});
988
989export const XAiAskUserQuestionRequest = Schema.Union([
990 XAiAskUserQuestionParams,
991 XAiWrappedAskUserQuestionParams,
992]);
993
994type XAiAskUserQuestionRequestParams = typeof XAiAskUserQuestionParams.Type;
995type XAiAskUserQuestionRequest = typeof XAiAskUserQuestionRequest.Type;
996
997// ---------------------------------------------------------------------------
998// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window)
999// ---------------------------------------------------------------------------
1000
1001const XAiExitPlanModeParams = Schema.Struct({
1002 sessionId: Schema.String,
1003 toolCallId: Schema.String,
1004 planContent: Schema.optional(Schema.NullOr(Schema.String)),
1005});
1006
1007const XAiWrappedExitPlanModeParams = Schema.Struct({
1008 method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]),
1009 params: XAiExitPlanModeParams,
1010});
1011
1012export const XAiExitPlanModeRequest = Schema.Union([
1013 XAiExitPlanModeParams,
1014 XAiWrappedExitPlanModeParams,
1015]);
1016
1017type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type;
1018type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type;
1019
1020function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams {
1021 return "params" in params ? params.params : params;
1022}
1023
1024/** Empty-state copy when Grok exits plan mode without a plan file. */
1025export const XAI_EMPTY_PLAN_MARKDOWN =
1026 "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)";
1027
1028export function extractXAiExitPlanMarkdown(
1029 params: XAiExitPlanModeRequest,
1030 fallback?: string | null,
1031): string {
1032 const content = unwrapExitPlanModeParams(params).planContent;
1033 const fromRequest = typeof content === "string" ? trimmed(content) : undefined;
1034 if (fromRequest) {
1035 return fromRequest;
1036 }
1037 const fromFallback = fallback?.trim();
1038 if (fromFallback && fromFallback.length > 0) {
1039 return fromFallback;
1040 }
1041 return XAI_EMPTY_PLAN_MARKDOWN;
1042}
1043
1044export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes";
1045
1046export interface XAiExitPlanModeResponse {
1047 readonly outcome: XAiExitPlanModeOutcome;
1048 readonly feedback?: string;
1049}
1050
1051/**
1052 * Client captured the plan for T3's proposed-plan card. Abandon the native
1053 * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI.
1054 */
1055export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse {
1056 return {
1057 outcome: "abandoned",
1058 feedback:
1059 feedback ??
1060 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
1061 };
1062}
1063
1064function normalizeFsPath(value: string): string {
1065 return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
1066}
1067
1068function pathHasTraversalSegment(normalized: string): boolean {
1069 return normalized.split("/").includes("..");
1070}
1071
1072function addGrokSessionPrefix(
1073 prefixes: Set<string>,
1074 homeOrRoot: string,
1075 nestedGrokDir: boolean,
1076): void {
1077 const root = normalizeFsPath(homeOrRoot);
1078 if (!root) {
1079 return;
1080 }
1081 prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`);
1082}
1083
1084/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */
1085export interface GrokPlanPathHost {
1086 readonly platform: NodeJS.Platform;
1087 readonly environment: NodeJS.ProcessEnv;
1088 readonly homeDirectory: string;
1089}
1090
1091function grokPlanSessionPrefixes(host: GrokPlanPathHost): ReadonlySet<string> {
1092 const { environment } = host;
1093 const prefixes = new Set<string>();
1094 addGrokSessionPrefix(prefixes, host.homeDirectory, true);
1095 addGrokSessionPrefix(prefixes, "~", true);
1096 addGrokSessionPrefix(prefixes, environment.HOME ?? "", true);
1097 addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true);
1098 // ACP mock and isolated Grok spawns use a HOME that is not the server process home.
1099 addGrokSessionPrefix(prefixes, "/tmp/mock-home", true);
1100 const grokHome = environment.GROK_HOME ?? "";
1101 addGrokSessionPrefix(prefixes, grokHome, false);
1102 addGrokSessionPrefix(prefixes, grokHome, true);
1103 return prefixes;
1104}
1105
1106const CANONICAL_HOME_GROK_SESSION_PATH =
1107 /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/;
1108const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp(
1109 CANONICAL_HOME_GROK_SESSION_PATH.source,
1110 "i",
1111);
1112
1113/**
1114 * True when a path is Grok's session plan file under a Grok home
1115 * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`).
1116 * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md
1117 * or a repo-local `.grok/sessions/.../plan.md`).
1118 */
1119export function isGrokPlanMarkdownPath(
1120 path: string | undefined | null,
1121 host: GrokPlanPathHost,
1122): boolean {
1123 if (typeof path !== "string") {
1124 return false;
1125 }
1126 const normalized = path.trim().replace(/\\/g, "/");
1127 const win32 = host.platform === "win32";
1128 const haystack = win32 ? normalized.toLowerCase() : normalized;
1129 if (
1130 normalized.length === 0 ||
1131 !haystack.endsWith("/plan.md") ||
1132 pathHasTraversalSegment(normalized)
1133 ) {
1134 return false;
1135 }
1136 for (const prefix of grokPlanSessionPrefixes(host)) {
1137 const needle = win32 ? prefix.toLowerCase() : prefix;
1138 if (!haystack.startsWith(needle)) {
1139 continue;
1140 }
1141 const rest = haystack.slice(needle.length);
1142 // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md
1143 if (rest !== "plan.md" && rest.endsWith("plan.md")) {
1144 return true;
1145 }
1146 }
1147 return (
1148 win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH
1149 ).test(haystack);
1150}
1151
1152/**
1153 * Extract plan markdown from a Grok write/edit tool call targeting plan.md.
1154 * Used so T3 can show the plan while plan mode is still active (before exit).
1155 */
1156export function extractGrokPlanMarkdownFromToolCallData(
1157 data: Record<string, unknown> | undefined,
1158 host: GrokPlanPathHost,
1159): string | undefined {
1160 if (!data) {
1161 return undefined;
1162 }
1163
1164 let sawPlanWrite = false;
1165 const takePlanText = (
1166 value: string | undefined,
1167 filePath: string | undefined,
1168 ): string | undefined => {
1169 if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) {
1170 return undefined;
1171 }
1172 sawPlanWrite = true;
1173 const trimmed = value.trim();
1174 return trimmed.length > 0 ? trimmed : undefined;
1175 };
1176
1177 const rawInput = data.rawInput;
1178 if (isRecord(rawInput)) {
1179 const filePath =
1180 (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ??
1181 (typeof rawInput.path === "string" ? rawInput.path : undefined);
1182 const content = typeof rawInput.content === "string" ? rawInput.content : undefined;
1183 const fromRaw = takePlanText(content, filePath);
1184 if (fromRaw !== undefined) {
1185 return fromRaw;
1186 }
1187 }
1188
1189 const content = data.content;
1190 if (Array.isArray(content)) {
1191 for (const block of content) {
1192 if (!isRecord(block) || block.type !== "diff") {
1193 continue;
1194 }
1195 const path = typeof block.path === "string" ? block.path : undefined;
1196 const newText = typeof block.newText === "string" ? block.newText : undefined;
1197 const fromDiff = takePlanText(newText, path);
1198 if (fromDiff !== undefined) {
1199 return fromDiff;
1200 }
1201 }
1202 }
1203
1204 return sawPlanWrite ? "" : undefined;
1205}
1206
1207function isRecord(value: unknown): value is Record<string, unknown> {
1208 return typeof value === "object" && value !== null && !Array.isArray(value);
1209}
1210
1211function trimmed(value: string | undefined): string | undefined {
1212 const text = value?.trim();
1213 return text && text.length > 0 ? text : undefined;
1214}
1215
1216function unwrapAskUserQuestionParams(
1217 params: XAiAskUserQuestionRequest,
1218): XAiAskUserQuestionRequestParams {
1219 return "params" in params ? params.params : params;
1220}
1221
1222export function extractXAiAskUserQuestions(
1223 params: XAiAskUserQuestionRequest,
1224): ReadonlyArray<UserInputQuestion> {
1225 return unwrapAskUserQuestionParams(params).questions.map((question) => ({
1226 id: question.id ?? question.question,
1227 header: "Question",
1228 question: question.question,
1229 multiSelect: question.multiSelect === true,
1230 options:
1231 question.options.length > 0
1232 ? question.options.map((option) => ({
1233 label: option.label,
1234 description: option.description ?? option.label,
1235 }))
1236 : [{ label: "OK", description: "Continue" }],
1237 }));
1238}
1239
1240export function extractXAiAskUserQuestionIdentity(params: XAiAskUserQuestionRequest): {
1241 readonly sessionId: string;
1242 readonly toolCallId: string;
1243} {
1244 const unwrapped = unwrapAskUserQuestionParams(params);
1245 return {
1246 sessionId: unwrapped.sessionId,
1247 toolCallId: unwrapped.toolCallId,
1248 };
1249}
1250
1251interface XAiAskUserQuestionAnnotation {
1252 readonly preview?: string;
1253 readonly notes?: string;
1254}
1255
1256interface XAiAskUserQuestionAcceptedResponse {
1257 readonly outcome: "accepted";
1258 readonly answers: Record<string, ReadonlyArray<string>>;
1259 readonly annotations?: Record<string, XAiAskUserQuestionAnnotation>;
1260}
1261
1262interface XAiAskUserQuestionCancelledResponse {
1263 readonly outcome: "cancelled";
1264}
1265
1266interface NormalizedXAiAnswer {
1267 readonly questionText: string;
1268 readonly selectedLabels: ReadonlyArray<string>;
1269 readonly annotation?: XAiAskUserQuestionAnnotation;
1270}
1271
1272function answerValues(answer: unknown): ReadonlyArray<string> {
1273 if (Array.isArray(answer)) {
1274 return answer.flatMap((entry) => {
1275 const text = typeof entry === "string" ? trimmed(entry) : undefined;
1276 return text ? [text] : [];
1277 });
1278 }
1279 const text = typeof answer === "string" ? trimmed(answer) : undefined;
1280 return text ? [text] : [];
1281}
1282
1283function normalizeAnswerForXAi(
1284 question: XAiAskUserQuestionRequestParams["questions"][number],
1285 answer: unknown,
1286): NormalizedXAiAnswer | undefined {
1287 const values = answerValues(answer);
1288 if (values.length === 0) {
1289 return undefined;
1290 }
1291
1292 const optionByLabel = new Map(question.options.map((option) => [option.label, option]));
1293 const resolvedValues = values.map((value) => ({
1294 value,
1295 option: optionByLabel.get(value),
1296 }));
1297 const selectedLabels = resolvedValues.flatMap(({ option }) => (option ? [option.label] : []));
1298 const notes = resolvedValues.flatMap(({ option, value }) => (option ? [] : [value]));
1299 const preview =
1300 question.multiSelect === true
1301 ? undefined
1302 : resolvedValues.map(({ option }) => trimmed(option?.preview)).find((value) => value);
1303
1304 const annotation =
1305 preview || notes.length > 0
1306 ? {
1307 ...(preview ? { preview } : {}),
1308 ...(notes.length > 0 ? { notes: notes.join("\n") } : {}),
1309 }
1310 : undefined;
1311
1312 return {
1313 questionText: question.question,
1314 selectedLabels: selectedLabels.length > 0 ? selectedLabels : ["Other"],
1315 ...(annotation ? { annotation } : {}),
1316 };
1317}
1318
1319function findQuestionAnswer(
1320 answers: ProviderUserInputAnswers,
1321 question: XAiAskUserQuestionRequestParams["questions"][number],
1322): unknown {
1323 const key = question.id ?? question.question;
1324 return answers[key] ?? answers[question.question];
1325}
1326
1327export function makeXAiAskUserQuestionResponse(
1328 params: XAiAskUserQuestionRequest,
1329 answers: ProviderUserInputAnswers,
1330): XAiAskUserQuestionAcceptedResponse {
1331 const questions = unwrapAskUserQuestionParams(params).questions;
1332 const normalized = questions.flatMap((question) => {
1333 const entry = normalizeAnswerForXAi(question, findQuestionAnswer(answers, question));
1334 return entry ? [entry] : [];
1335 });
1336 const annotations = Object.fromEntries(
1337 normalized.flatMap((entry) =>
1338 entry.annotation ? [[entry.questionText, entry.annotation] as const] : [],
1339 ),
1340 );
1341
1342 return {
1343 outcome: "accepted",
1344 answers: Object.fromEntries(
1345 normalized.map((entry) => [entry.questionText, entry.selectedLabels]),
1346 ),
1347 ...(Object.keys(annotations).length > 0 ? { annotations } : {}),
1348 };
1349}
1350
1351export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse {
1352 return { outcome: "cancelled" };
1353}
1354
1355function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
1356 const meta = response._meta;
1357 if (meta === null || typeof meta !== "object") {
1358 return undefined;
1359 }
1360 const promptId = meta.promptId ?? meta.requestId;
1361 return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
1362}
1363
1364function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
1365 switch (value) {
1366 case "cancelled":
1367 case "end_turn":
1368 case "max_tokens":
1369 case "max_turn_requests":
1370 case "refusal":
1371 return value;
1372 default:
1373 return "end_turn";
1374 }
1375}
1376
1377function promptResponseFromXAi(
1378 notification: XAiPromptCompleteNotification,
1379): EffectAcpSchema.PromptResponse {
1380 const stopReason = normalizeXAiStopReason(notification.stopReason);
1381 // Values originate from a decoded JSON-RPC payload, so they are JSON even
1382 // though the x.ai schema types agentResult as unknown.
1383 const meta: Record<string, Schema.Json> = {
1384 sessionId: notification.sessionId,
1385 };
1386 if (notification.stopReason === undefined) {
1387 meta[xAiStopReasonMissingMetaKey] = true;
1388 }
1389 if (notification.promptId !== undefined) {
1390 meta.promptId = notification.promptId;
1391 meta.requestId = notification.promptId;
1392 }
1393 if (notification.agentResult !== undefined) {
1394 meta.agentResult = notification.agentResult as Schema.Json;
1395 }
1396 return {
1397 stopReason,
1398 _meta: meta,
1399 };
1400}
1401
1402/**
1403 * Grok settles a failed prompt with `stopReason: "error"` and the provider's
1404 * message in `agentResult` before its `session/prompt` RPC error arrives. The
1405 * completion wins the race, so it must carry the failure itself.
1406 */
1407function xAiPromptFailure(
1408 notification: XAiPromptCompleteNotification,
1409): EffectAcpErrors.AcpRequestError | null {
1410 if (notification.stopReason === "rate_limit") {
1411 return new EffectAcpErrors.AcpRequestError({
1412 code: xAiRateLimitedErrorCode,
1413 errorMessage: "Grok usage limit reached. Try again later.",
1414 });
1415 }
1416 if (notification.stopReason === "error") {
1417 // Grok's raw result is unbounded provider text: keep it only as the cause.
1418 const agentResult = nonEmptyString(notification.agentResult);
1419 return new EffectAcpErrors.AcpRequestError({
1420 code: -32603,
1421 errorMessage: "Grok ended the turn with an error.",
1422 operation: "receive-response",
1423 ...(agentResult === undefined ? {} : { cause: new XAiPromptFailureText(agentResult) }),
1424 });
1425 }
1426 return null;
1427}
1428
1429/**
1430 * Grok's own text for a failed prompt. A plain Error rather than a schema
1431 * error so the unbounded provider text never becomes a structured attribute;
1432 * it is read back only at the presentation boundary.
1433 */
1434export class XAiPromptFailureText extends Error {}
1435
1436const registerXAiPromptCompletionFallback = (
1437 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1438 sessionId: string,
1439 promptId: string,
1440) =>
1441 Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(
1442 Effect.tap((deferred) =>
1443 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
1444 ),
1445 Effect.map((deferred) => ({ deferred, promptId })),
1446 );
1447
1448const unregisterXAiPromptCompletionFallback = (
1449 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1450 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
1451) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
1452
1453const abortPendingPromptCompletions = (
1454 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
1455 sessionId?: string,
1456) =>
1457 Ref.modify(pendingRef, (pending) => {
1458 const [toAbort, remaining] = pending.reduce<
1459 [ReadonlyArray<PendingXAiPromptCompletion>, ReadonlyArray<PendingXAiPromptCompletion>]
1460 >(
1461 ([aborting, kept], entry) =>
1462 sessionId === undefined || entry.sessionId === sessionId
1463 ? [[...aborting, entry], kept]
1464 : [aborting, [...kept, entry]],
1465 [[], []],
1466 );
1467 if (toAbort.length === 0) {
1468 return [Effect.void, pending] as const;
1469 }
1470 return [
1471 Effect.forEach(
1472 toAbort,
1473 (entry) =>
1474 Deferred.succeed(
1475 entry.deferred,
1476 promptResponseFromXAi({
1477 sessionId: entry.sessionId,
1478 promptId: entry.promptId,
1479 stopReason: "cancelled",
1480 agentResult: null,
1481 }),
1482 ),
1483 { concurrency: "unbounded" },
1484 ).pipe(Effect.asVoid),
1485 remaining,
1486 ] as const;
1487 }).pipe(Effect.flatten);
1488
1489const resolveXAiPromptCompletionFallback = ({
1490 pendingRef,
1491 completedPromptIdsRef,
1492 notification,
1493}: {
1494 readonly pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>;
1495 readonly completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>;
1496 readonly notification: XAiPromptCompleteNotification;
1497}) =>
1498 Ref.get(completedPromptIdsRef).pipe(
1499 Effect.flatMap((completedPromptIds) => {
1500 if (
1501 notification.promptId !== undefined &&
1502 completedPromptIds.includes(notification.promptId)
1503 ) {
1504 return Effect.void;
1505 }
1506 return Ref.modify(pendingRef, (pending) => {
1507 const index =
1508 notification.promptId !== undefined
1509 ? pending.findIndex(
1510 (entry) =>
1511 entry.sessionId === notification.sessionId &&
1512 entry.promptId === notification.promptId,
1513 )
1514 : (() => {
1515 const sessionPendingIndexes = pending.flatMap((entry, entryIndex) =>
1516 entry.sessionId === notification.sessionId ? [entryIndex] : [],
1517 );
1518 if (sessionPendingIndexes.length !== 1) {
1519 return -1;
1520 }
1521 return sessionPendingIndexes[0] ?? -1;
1522 })();
1523 if (index < 0) {
1524 return [Effect.void, pending] as const;
1525 }
1526 const entry = pending[index];
1527 if (!entry) {
1528 return [Effect.void, pending] as const;
1529 }
1530 const failure = xAiPromptFailure(notification);
1531 const settle =
1532 failure !== null
1533 ? Deferred.fail(entry.deferred, failure).pipe(Effect.asVoid)
1534 : Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(
1535 Effect.asVoid,
1536 );
1537 return [settle, [...pending.slice(0, index), ...pending.slice(index + 1)]] as const;
1538 }).pipe(Effect.flatten);
1539 }),
1540 );
1541
1542const rememberCompletedXAiPromptId = (
1543 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
1544 response: EffectAcpSchema.PromptResponse,
1545 fallbackPromptId: string,
1546) => {
1547 const promptId = promptIdFromResponse(response) ?? fallbackPromptId;
1548 if (promptId.length === 0) {
1549 return Effect.void;
1550 }
1551 return Ref.update(completedPromptIdsRef, (completedPromptIds) => {
1552 if (completedPromptIds.includes(promptId)) {
1553 return completedPromptIds;
1554 }
1555 return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit);
1556 });
1557};
1558
1559/**
1560 * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched
1561 * terminal notifications:
1562 * - session notification/update + `turn_completed` + matching `prompt_id`
1563 * - `x.ai/session/prompt_complete` (open-source fire-and-forget signal)
1564 * - `_x.ai/session/prompt_complete` (released-build alias)
1565 *
1566 * Pending entries are keyed by root sessionId + T3-injected promptId, so
1567 * foreign/child sessions and `task-completed-*` ids do not settle the root turn.
1568 */
1569export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(
1570 function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) {
1571 // Grok sends turn completion and other session updates (subagent_finished)
1572 // on the same extension methods, and the client keeps one handler per
1573 // method. This wrapper owns those methods: it settles prompts itself, then
1574 // passes each notification to the handler registered on the wrapped runtime.
1575 const sessionNotificationHandlers = new Map<
1576 string,
1577 (params: unknown) => Effect.Effect<void, EffectAcpErrors.AcpError>
1578 >();
1579 let nextPromptFallbackId = 0;
1580 const allocatePromptFallbackId = Effect.sync(() => {
1581 nextPromptFallbackId += 1;
1582 return `t3-xai-prompt-${nextPromptFallbackId}`;
1583 });
1584 const pendingXAiPromptCompletionsRef = yield* Ref.make<
1585 ReadonlyArray<PendingXAiPromptCompletion>
1586 >([]);
1587 const completedXAiPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]);
1588
1589 const settleFromPromptComplete = (notification: XAiPromptCompleteNotification) =>
1590 resolveXAiPromptCompletionFallback({
1591 pendingRef: pendingXAiPromptCompletionsRef,
1592 completedPromptIdsRef: completedXAiPromptIdsRef,
1593 notification,
1594 }).pipe(Effect.catch(() => Effect.void));
1595
1596 yield* Effect.forEach(
1597 ["x.ai/session/prompt_complete", "_x.ai/session/prompt_complete"] as const,
1598 (method) =>
1599 runtime.handleExtNotification(
1600 method,
1601 XAiPromptCompleteNotification,
1602 settleFromPromptComplete,
1603 ),
1604 { discard: true },
1605 );
1606
1607 yield* Effect.forEach(
1608 xAiSessionNotificationMethods,
1609 (method) =>
1610 runtime.handleExtNotification(method, Schema.Unknown, (params) =>
1611 decodeXAiSessionUpdateNotification(params).pipe(
1612 Effect.flatMap((notification) => {
1613 const complete = xAiPromptCompleteFromSessionUpdate(notification);
1614 return complete === null ? Effect.void : settleFromPromptComplete(complete);
1615 }),
1616 Effect.ignore,
1617 Effect.andThen(
1618 Effect.suspend(
1619 () => sessionNotificationHandlers.get(method)?.(params) ?? Effect.void,
1620 ),
1621 ),
1622 ),
1623 ),
1624 { discard: true },
1625 );
1626
1627 return {
1628 ...runtime,
1629 handleExtNotification: (method, payload, handler) =>
1630 xAiSessionNotificationMethods.some((owned) => owned === method)
1631 ? Effect.sync(() => {
1632 sessionNotificationHandlers.set(method, (params) =>
1633 Schema.decodeUnknownEffect(payload)(params).pipe(
1634 Effect.mapError((error) =>
1635 EffectAcpErrors.AcpProtocolParseError.fromSchemaError(
1636 "decode-notification-payload",
1637 method,
1638 error,
1639 ),
1640 ),
1641 Effect.flatMap(handler),
1642 ),
1643 );
1644 })
1645 : runtime.handleExtNotification(method, payload, handler),
1646 prompt: (payload, promptOptions?) =>
1647 Effect.gen(function* () {
1648 const started = yield* runtime.start();
1649 const promptId = yield* allocatePromptFallbackId;
1650 const fallback = yield* registerXAiPromptCompletionFallback(
1651 pendingXAiPromptCompletionsRef,
1652 started.sessionId,
1653 promptId,
1654 );
1655 const cancelledResponse = promptResponseFromXAi({
1656 sessionId: started.sessionId,
1657 promptId: fallback.promptId,
1658 stopReason: "cancelled",
1659 agentResult: null,
1660 });
1661 const promptRpcFiber = yield* runtime
1662 .prompt(
1663 {
1664 ...payload,
1665 _meta: {
1666 ...payload._meta,
1667 promptId: fallback.promptId,
1668 requestId: fallback.promptId,
1669 },
1670 },
1671 promptOptions,
1672 )
1673 .pipe(Effect.forkChild);
1674 return yield* Effect.raceFirst(
1675 Fiber.join(promptRpcFiber).pipe(
1676 Effect.catchCause((cause) =>
1677 Cause.hasInterruptsOnly(cause)
1678 ? Effect.succeed(cancelledResponse)
1679 : Effect.failCause(cause),
1680 ),
1681 ),
1682 Deferred.await(fallback.deferred),
1683 ).pipe(
1684 Effect.tap((response) =>
1685 rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId),
1686 ),
1687 Effect.ensuring(
1688 Effect.gen(function* () {
1689 yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);
1690 yield* unregisterXAiPromptCompletionFallback(
1691 pendingXAiPromptCompletionsRef,
1692 fallback.deferred,
1693 );
1694 }),
1695 ),
1696 );
1697 }),
1698 cancel: Effect.gen(function* () {
1699 // Do not call start here: cancellation must be able to reach the
1700 // underlying runtime when startup is stalled. Pending entries already
1701 // carry their session ids, and this runtime owns all of them.
1702 yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef);
1703 yield* runtime.cancel;
1704 }),
1705 } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"];
1706 },
1707);
1708
1709function promptResponseHasMissingXAiStopReason(response: EffectAcpSchema.PromptResponse): boolean {
1710 const meta = response._meta;
1711 return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;
1712}