1import type * as EffectAcpSchema from "effect-acp/compat";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 type ProviderApprovalOption,
5 ProviderDriverKind,
6 type RuntimeMode,
7} from "@t3tools/contracts";
8import type { GrokSettings } from "../settings.ts";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Scope from "effect/Scope";
13import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14import * as EffectAcpErrors from "effect-acp/errors";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16import { normalizeModelSlug } from "@t3tools/shared/model";
17
18import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
19import { makeXAiPromptCompletionRuntime } from "./xaiAcpExtension.ts";
20
21const GROK_API_KEY_ENV = "XAI_API_KEY";
22const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER";
23const T3_CODE_OAUTH_REFERRER = "t3code";
24const GROK_AUTH_METHOD_API_KEY = "xai.api_key";
25const GROK_AUTH_METHOD_CACHED_TOKEN = "cached_token";
26const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
27
28type GrokAcpRuntimeGrokSettings = Pick<GrokSettings, "binaryPath">;
29
30interface GrokAcpRuntimeInput extends Omit<
31 AcpSessionRuntime.AcpSessionRuntimeOptions,
32 "authMethodId" | "clientCapabilities" | "spawn"
33> {
34 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
35 readonly environment?: NodeJS.ProcessEnv;
36 readonly runtimeMode?: RuntimeMode;
37}
38
39/**
40 * The runtime modes `grok agent` can launch in: ask, its auto classifier, and
41 * always-approve. It has no Auto-accept edits: `acceptEdits` only exists as a
42 * settings-file `permissions.defaultMode`, and `grok agent` treats it as ask.
43 */
44export const GROK_SUPPORTED_RUNTIME_MODES = [
45 "approval-required",
46 "auto",
47 "full-access",
48] as const satisfies ReadonlyArray<RuntimeMode>;
49
50/**
51 * Launch argv for a runtime mode. `--permission-mode` on the argv beats the
52 * user's Grok config, so Supervised cannot inherit a configured always-approve.
53 * A mode Grok does not offer launches asking.
54 */
55export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> {
56 switch (runtimeMode) {
57 case undefined:
58 return ["agent", "stdio"];
59 case "auto":
60 return ["--permission-mode", "auto", "agent", "stdio"];
61 case "full-access":
62 return ["agent", "--always-approve", "stdio"];
63 default:
64 return ["--permission-mode", "default", "agent", "stdio"];
65 }
66}
67
68export function buildGrokAcpSpawnInput(
69 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
70 cwd: string,
71 environment?: NodeJS.ProcessEnv,
72 runtimeMode?: RuntimeMode,
73): AcpSessionRuntime.AcpSpawnInput {
74 return {
75 command: grokSettings?.binaryPath || "grok",
76 args: [...grokAcpSpawnArgs(runtimeMode)],
77 cwd,
78 env: {
79 ...environment,
80 [GROK_OAUTH2_REFERRER_ENV]: T3_CODE_OAUTH_REFERRER,
81 },
82 };
83}
84
85function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): string {
86 return environment?.[GROK_API_KEY_ENV]?.trim()
87 ? GROK_AUTH_METHOD_API_KEY
88 : GROK_AUTH_METHOD_CACHED_TOKEN;
89}
90
91export function grokAcpRuntimeProcessOwnership(
92 processGroupPlatform: NodeJS.Platform,
93): Pick<
94 AcpSessionRuntime.AcpSessionRuntimeOptions,
95 "ownDescendantProcessGroups" | "ownDetachedProcessGroup" | "processGroupPlatform"
96> {
97 return {
98 // macOS keeps the prior provider-group teardown until a stable libproc
99 // identity provider can cover Grok's nested detached tool groups.
100 ownDescendantProcessGroups: processGroupPlatform === "linux",
101 ownDetachedProcessGroup: true,
102 processGroupPlatform,
103 };
104}
105
106/**
107 * Current Grok treats Ctrl+C cancellation as a barrier against stale
108 * background-task wake prompts until the next genuine user turn. Replay sends
109 * the same metadata so recorded cancels match.
110 */
111export const GROK_ACP_CANCEL_META = { cancelTrigger: "ctrl_c" } as const;
112
113/**
114 * Grok's Auto mode asks the client about an action its classifier blocks only
115 * when the client declares a type that can show a prompt; the default
116 * (`generic`) gets a silent denial instead. `extension` is the prompting type
117 * that keeps the permission options T3 already maps (no always-approve row,
118 * no per-command persistent grants).
119 */
120export const GROK_ACP_INITIALIZE_META = { clientType: "extension" } as const;
121
122/**
123 * Grok's only session-scoped `allow_always` answer: "Yes, allow all edits
124 * during this session" on an edit prompt. Its bash, monitor and MCP
125 * `always-allow` rows instead save a grant for the whole project that outlives
126 * the session (grok-build `crates/codegen/xai-grok-workspace/src/permission/`
127 * `prompter.rs` `ALLOW_EDITS_SESSION_OPTION_ID`, `grants.rs`
128 * `record_prompt_outcome`).
129 */
130const GROK_ALLOW_EDITS_SESSION_OPTION_ID = "allow-edits-session";
131
132/**
133 * The approval choices a Grok permission prompt can honor. The session choice
134 * appears only where Grok's answer lasts for the session.
135 */
136export function grokApprovalOptions(
137 request: EffectAcpSchema.RequestPermissionRequest,
138): ReadonlyArray<ProviderApprovalOption> {
139 const has = (kind: EffectAcpSchema.PermissionOption["kind"], optionId?: string) =>
140 request.options.some(
141 (option) =>
142 option.kind === kind && (optionId === undefined || option.optionId.trim() === optionId),
143 );
144 return [
145 { decision: "cancel", label: "Cancel" },
146 ...(has("reject_once") ? [{ decision: "decline", label: "Decline" } as const] : []),
147 ...(has("allow_always", GROK_ALLOW_EDITS_SESSION_OPTION_ID)
148 ? [{ decision: "acceptForSession", label: "Allow all edits this session" } as const]
149 : []),
150 ...(has("allow_once") ? [{ decision: "accept", label: "Approve" } as const] : []),
151 ];
152}
153
154export const makeGrokAcpRuntime = (
155 input: GrokAcpRuntimeInput,
156): Effect.Effect<
157 AcpSessionRuntime.AcpSessionRuntime["Service"],
158 EffectAcpErrors.AcpError,
159 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope
160> =>
161 Effect.gen(function* () {
162 const processGroupPlatform = yield* HostProcess.Platform.pipe(
163 Effect.provide(NodeServices.layer),
164 );
165 const acpContext = yield* Layer.build(
166 AcpSessionRuntime.layer({
167 ...input,
168 spawn: buildGrokAcpSpawnInput(
169 input.grokSettings,
170 input.cwd,
171 input.environment,
172 input.runtimeMode,
173 ),
174 authMethodId: resolveGrokAuthMethodId(input.environment),
175 cancelMeta: { ...input.cancelMeta, ...GROK_ACP_CANCEL_META },
176 initializeMeta: GROK_ACP_INITIALIZE_META,
177 ...grokAcpRuntimeProcessOwnership(processGroupPlatform),
178 }),
179 );
180 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
181 Effect.provide(acpContext),
182 );
183 return yield* makeXAiPromptCompletionRuntime(runtime);
184 });
185
186/**
187 * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts,
188 * so selecting it means "use whatever model the Grok session currently runs on".
189 */
190export const GROK_DEFAULT_MODEL_SLUG = "grok-build";
191
192export function resolveGrokAcpBaseModelId(model: string | null | undefined): string {
193 const trimmed = model?.trim();
194 const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG;
195 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
196}
197
198const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
199
200export function isValidGrokReasoningEffortToken(value: string): boolean {
201 return GROK_REASONING_EFFORT_TOKEN.test(value);
202}
203
204function normalizeGrokReasoningEffort(value: string | undefined): string | undefined {
205 const effort = value?.trim();
206 return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined;
207}
208
209export function currentGrokModelIdFromSessionSetup(
210 sessionSetupResult:
211 | EffectAcpSchema.LoadSessionResponse
212 | EffectAcpSchema.NewSessionResponse
213 | EffectAcpSchema.ResumeSessionResponse,
214): string | undefined {
215 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
216}
217
218export function currentGrokReasoningEffortFromSessionSetup(
219 sessionSetupResult:
220 | EffectAcpSchema.LoadSessionResponse
221 | EffectAcpSchema.NewSessionResponse
222 | EffectAcpSchema.ResumeSessionResponse,
223): string | undefined {
224 const modelState = sessionSetupResult.models;
225 if (!modelState) {
226 return undefined;
227 }
228 const currentModelId = modelState.currentModelId.trim();
229 if (currentModelId.length === 0) {
230 return undefined;
231 }
232 const currentModel = modelState.availableModels.find(
233 (model) => model.modelId.trim() === currentModelId,
234 );
235 const reasoningEffort = currentModel?._meta?.reasoningEffort;
236 return typeof reasoningEffort === "string"
237 ? normalizeGrokReasoningEffort(reasoningEffort)
238 : undefined;
239}
240
241export function applyGrokAcpModelSelection<E>(input: {
242 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
243 readonly currentModelId: string | undefined;
244 readonly currentReasoningEffort?: string | undefined;
245 readonly requestedModelId: string | undefined;
246 readonly requestedReasoningEffort?: string | undefined;
247 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
248}): Effect.Effect<string | undefined, E> {
249 // The product slug is never sent over the wire; it keeps the session's current model.
250 const requestedModelId =
251 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
252 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
253 const reasoningProvided = input.requestedReasoningEffort !== undefined;
254 const reasoningEffort = reasoningProvided
255 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
256 : undefined;
257 const reasoningEffortChanged =
258 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
259 const targetModelId = requestedModelId ?? input.currentModelId;
260 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
261 return Effect.succeed(input.currentModelId);
262 }
263 const reasoningMeta =
264 reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined;
265 // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately
266 // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not
267 // provided at all, we also send no meta, but we only reach this call when the model itself
268 // changed - an omitted reasoning preference must not be treated as an explicit clear of the
269 // CLI-advertised default (e.g. Extra High) on same-model reselections.
270 return input.runtime
271 .setSessionModel(targetModelId, reasoningMeta)
272 .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));
273}