Skip to content
Source
Sign inSign up

status.ts

601 lines20.0 KB

1import {
2 type CustomModelSetting,
3 type ModelCapabilities,
4 type ServerProvider,
5 type ServerProviderAuth,
6 type ServerProviderModel,
7 type ServerProviderSlashCommand,
8} from "@t3tools/contracts";
9import type { GrokSettings } from "../settings.ts";
10import type * as EffectAcpSchema from "effect-acp/compat";
11import { causeErrorTag } from "@t3tools/shared/observability";
12import * as SecureRandom from "effect/crypto/SecureRandom";
13import * as DateTime from "effect/DateTime";
14import * as Effect from "effect/Effect";
15import * as Exit from "effect/Exit";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import * as HttpClient from "effect/http/HttpClient";
20import * as ChildProcess from "effect/process/ChildProcess";
21import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
22import { createModelCapabilities } from "@t3tools/shared/model";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";
24​
25import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
26import {
27 AUTH_PROBE_TIMEOUT_MS,
28 buildServerProvider,
29 COMPACT_SLASH_COMMAND,
30 isCommandMissingCause,
31 parseCliVersionResult,
32 providerModelsFromSettings,
33 spawnAndCollect,
34 type ServerProviderDraft,
35} from "@t3tools/provider-core/server/snapshotProbe";
36import {
37 enrichProviderSnapshotWithVersionAdvisory,
38 type ProviderMaintenanceCapabilities,
39} from "@t3tools/provider-core/server/maintenanceResolver";
40import {
41 GROK_DEFAULT_MODEL_SLUG,
42 GROK_SUPPORTED_RUNTIME_MODES,
43 isValidGrokReasoningEffortToken,
44 makeGrokAcpRuntime,
45 resolveGrokAcpBaseModelId,
46} from "./acpSupport.ts";
47import { sessionModelStateFromInitialize } from "@t3tools/provider-acp/server/runtimeModel";
48import { discoverGrokSkills } from "./skills.ts";
49import { grokProbeEnvironment } from "./probeEnvironment.ts";
50​
51const GROK_PRESENTATION = {
52 displayName: "Grok",
53 supportsConversationRollback: false,
54 showInteractionModeToggle: false,
55 supportedRuntimeModes: GROK_SUPPORTED_RUNTIME_MODES,
56} as const;
57const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({
58 optionDescriptors: [],
59});
60​
61const VERSION_PROBE_TIMEOUT_MS = 10_000;
62// Grok writes the initialize result only after startup work (settings,
63// skill discovery, file watching). From a home directory that work measured
64// 9-14s on CLI 1.0.52, so an 8s cap records a healthy CLI as a failed probe.
65const GROK_ACP_INITIALIZE_TIMEOUT_MS = 20_000;
66const GROK_API_KEY_ENV = "XAI_API_KEY";
67​
68const GROK_BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
69 {
70 slug: GROK_DEFAULT_MODEL_SLUG,
71 name: "Grok Build",
72 isCustom: false,
73 capabilities: EMPTY_CAPABILITIES,
74 },
75];
76​
77export const buildInitialGrokProviderSnapshot = Effect.fn(
78 "GrokProviderStatus.buildInitialGrokProviderSnapshot",
79)(function* (grokSettings: GrokSettings): Effect.fn.Return<ServerProviderDraft> {
80 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
81 const models = grokModelsFromSettings(grokSettings.customModels);
82​
83 if (!grokSettings.enabled) {
84 return buildServerProvider({
85 presentation: GROK_PRESENTATION,
86 enabled: false,
87 checkedAt,
88 models,
89 probe: {
90 installed: false,
91 version: null,
92 status: "warning",
93 auth: { status: "unknown" },
94 message: "Grok is disabled in T3 Code settings.",
95 },
96 });
97 }
98​
99 return buildServerProvider({
100 presentation: GROK_PRESENTATION,
101 enabled: true,
102 checkedAt,
103 models,
104 probe: {
105 installed: true,
106 version: null,
107 status: "warning",
108 auth: { status: "unknown" },
109 message: "Checking Grok CLI availability...",
110 },
111 });
112});
113​
114function grokModelsFromSettings(
115 customModels: ReadonlyArray<CustomModelSetting> | undefined,
116 builtInModels: ReadonlyArray<ServerProviderModel> = GROK_BUILT_IN_MODELS,
117): ReadonlyArray<ServerProviderModel> {
118 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
120​
121function nonEmptyString(value: unknown): string | undefined {
122 return typeof value === "string" ? value.trim() || undefined : undefined;
124​
125function isRecord(value: unknown): value is Record<string, unknown> {
126 return typeof value === "object" && value !== null && !Array.isArray(value);
128​
129const GROK_EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
130​
131export function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
132 readonly options: ReadonlyArray<{
133 value: string;
134 label: string;
135 description?: string;
136 isDefault?: boolean;
137 }>;
138 readonly currentValue: string | undefined;
139} {
140 const meta = model._meta;
141 if (!meta || meta.supportsReasoningEffort === false) {
142 return { options: [], currentValue: undefined };
143 }
144​
145 const currentEffort = nonEmptyString(meta.reasoningEffort);
146 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
147 const seen = new Set<string>();
148 const options: Array<{
149 value: string;
150 label: string;
151 description?: string;
152 advertisedDefault: boolean;
153 }> = [];
154​
155 for (const entry of advertisedOptions) {
156 if (!isRecord(entry)) {
157 continue;
158 }
159 const rawValue = nonEmptyString(entry.value);
160 const rawId = nonEmptyString(entry.id);
161 const value =
162 rawValue && isValidGrokReasoningEffortToken(rawValue)
163 ? rawValue
164 : rawId && isValidGrokReasoningEffortToken(rawId)
165 ? rawId
166 : undefined;
167 if (value === undefined || seen.has(value)) {
168 continue;
169 }
170 seen.add(value);
171 const description = nonEmptyString(entry.description);
172 options.push({
173 value,
174 label: nonEmptyString(entry.label) ?? value,
175 ...(description ? { description } : {}),
176 advertisedDefault: entry.default === true || entry.isDefault === true,
177 });
178 }
179​
180 const currentValue =
181 currentEffort && options.some((option) => option.value === currentEffort)
182 ? currentEffort
183 : undefined;
184 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
185 const selectedDefault =
186 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
187 advertisedDefaults[0]?.value;
188 // Grok advertises its strongest level first; pickers and effort bars read lowest to highest.
189 const rank = (option: { value: string }) => GROK_EFFORT_RANK.indexOf(option.value);
190 if (options.every((option) => rank(option) >= 0)) {
191 options.sort((left, right) => rank(left) - rank(right));
192 }
193 return {
194 options: options.map(({ value, label, description }) => ({
195 value,
196 label,
197 ...(description ? { description } : {}),
198 ...(value === selectedDefault ? { isDefault: true } : {}),
199 })),
200 currentValue: currentValue ?? selectedDefault,
201 };
203​
204export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
205 const reasoning = grokReasoningOptionsFromModel(model);
206 return reasoning.options.length > 0
207 ? createModelCapabilities({
208 optionDescriptors: [
209 {
210 id: "reasoningEffort",
211 label: "Reasoning",
212 type: "select",
213 options: reasoning.options.map((option) => ({
214 id: option.value,
215 label: option.label,
216 ...(option.description ? { description: option.description } : {}),
217 ...(option.isDefault ? { isDefault: true } : {}),
218 })),
219 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
220 },
221 ],
222 })
223 : EMPTY_CAPABILITIES;
225​
226function buildGrokModelsFromSessionModelState(
227 modelState: EffectAcpSchema.SessionModelState | null | undefined,
228): ReadonlyArray<ServerProviderModel> {
229 if (!modelState || modelState.availableModels.length === 0) {
230 return [];
231 }
232 const currentModelId = modelState.currentModelId.trim();
233 const seen = new Set<string>();
234 return modelState.availableModels.flatMap((model): ServerProviderModel[] => {
235 const slug = resolveGrokAcpBaseModelId(model.modelId);
236 if (!slug || seen.has(slug)) {
237 return [];
238 }
239 seen.add(slug);
240 return [
241 {
242 slug,
243 name: model.name.trim() || slug,
244 isCustom: false,
245 ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}),
246 capabilities: buildGrokModelCapabilities(model),
247 },
248 ];
249 });
251​
252export interface GrokModelsCliOutput {
253 readonly authenticated: boolean | null;
254 readonly models: ReadonlyArray<ServerProviderModel>;
256​
257/** Exit status is always 0; only output text indicates login state. */
258export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput {
259 const authenticated = /you are logged in/i.test(output)
260 ? true
261 : /not authenticated|not logged in/i.test(output)
262 ? false
263 : null;
264​
265 const seen = new Set<string>();
266 const models: ServerProviderModel[] = [];
267 for (const line of output.split(/\r?\n/)) {
268 const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/);
269 if (!bullet?.[1]) {
270 continue;
271 }
272 const slug = resolveGrokAcpBaseModelId(bullet[1]);
273 if (seen.has(slug)) {
274 continue;
275 }
276 seen.add(slug);
277 models.push({
278 slug,
279 name: displayNameFromGrokModelSlug(slug),
280 isCustom: false,
281 ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}),
282 capabilities: EMPTY_CAPABILITIES,
283 });
284 }
285 return { authenticated, models };
287​
288function displayNameFromGrokModelSlug(slug: string): string {
289 return slug
290 .split(/[-_]/g)
291 .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part))
292 .join(" ");
294​
295const runGrokCliCommand = Effect.fn("GrokProviderStatus.runGrokCliCommand")(function* (
296 grokSettings: GrokSettings,
297 args: ReadonlyArray<string>,
298 environment: NodeJS.ProcessEnv,
299) {
300 const command = grokSettings.binaryPath || "grok";
301 const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment });
302 return yield* spawnAndCollect(
303 command,
304 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
305 env: environment,
306 shell: spawnCommand.shell,
307 }),
308 );
309});
310​
311const probeGrokModels = Effect.fn("GrokProviderStatus.probeGrokModels")(function* (
312 grokSettings: GrokSettings,
313 environment: NodeJS.ProcessEnv,
314) {
315 const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe(
316 Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS),
317 Effect.result,
318 );
319 // Only a clean exit is parsed. Failed invocations print help or error text that
320 // must not be read as model slugs or as a login verdict.
321 const modelsOutput =
322 Result.isSuccess(modelsResult) &&
323 Option.isSome(modelsResult.success) &&
324 modelsResult.success.value.code === 0
325 ? modelsResult.success.value
326 : undefined;
327 const cliModels: GrokModelsCliOutput = modelsOutput
328 ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`)
329 : { authenticated: null, models: [] };
330 if (!modelsOutput) {
331 yield* Effect.logWarning("Grok CLI model listing failed or timed out.", {
332 errorTag: Result.isFailure(modelsResult)
333 ? modelsResult.failure._tag
334 : Option.isNone(modelsResult.success)
335 ? "Timeout"
336 : `ExitCode${modelsResult.success.value.code}`,
337 });
338 }
339 return cliModels;
340});
341​
342const decodeAvailableCommands = Schema.decodeUnknownOption(Schema.Array(Schema.Unknown));
343const decodeAvailableCommand = Schema.decodeUnknownOption(
344 Schema.Struct({
345 name: Schema.String,
346 description: Schema.String,
347 input: Schema.optional(Schema.NullOr(Schema.Struct({ hint: Schema.String }))),
348 }),
349);
350​
351export function grokSlashCommandsFromInitialize(
352 initialized: EffectAcpSchema.InitializeResponse,
353): ReadonlyArray<ServerProviderSlashCommand> {
354 const commands = decodeAvailableCommands(initialized._meta?.availableCommands);
355 const byName = new Map<string, ServerProviderSlashCommand>([
356 [COMPACT_SLASH_COMMAND.name, COMPACT_SLASH_COMMAND],
357 ]);
358 for (const entry of Option.getOrElse(commands, () => [])) {
359 const decoded = decodeAvailableCommand(entry);
360 if (Option.isNone(decoded)) continue;
361 const command = decoded.value;
362 const name = command.name.trim();
363 if (!name || name.toLowerCase() === "always-approve") continue;
364 // Grok advertises /context, but its ACP handler completes without emitting output.
365 if (name.toLowerCase() === "context") continue;
366 const description = command.description.trim();
367 const hint = command.input?.hint.trim();
368 byName.set(name, {
369 name,
370 ...(description ? { description } : {}),
371 ...(hint ? { input: { hint } } : {}),
372 });
373 }
374 return [...byName.values()];
376​
377/**
378 * This never calls `authenticate` or `session/new`, so it cannot open a browser login or
379 * boot the workspace's MCP servers.
380 */
381const discoverGrokMetadataViaAcpInitialize = Effect.fn(
382 "GrokProviderStatus.discoverGrokMetadataViaAcpInitialize",
383)(function* (grokSettings: GrokSettings, environment: NodeJS.ProcessEnv) {
384 const acp = yield* makeGrokAcpRuntime({
385 grokSettings,
386 environment,
387 cwd: process.cwd(),
388 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
389 });
390 const initialized = yield* acp.initialize();
391 return {
392 models: buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)),
393 slashCommands: grokSlashCommandsFromInitialize(initialized),
394 };
395}, Effect.scoped);
396​
397export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
398 grokSettings: GrokSettings,
399 environment: NodeJS.ProcessEnv = process.env,
400 cwd?: string,
401): Effect.fn.Return<
402 ServerProviderDraft,
403 never,
404 ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom
405> {
406 const checkedAt = DateTime.formatIso(yield* DateTime.now);
407 const fallbackModels = grokModelsFromSettings(grokSettings.customModels);
408​
409 if (!grokSettings.enabled) {
410 return buildServerProvider({
411 presentation: GROK_PRESENTATION,
412 enabled: false,
413 checkedAt,
414 models: fallbackModels,
415 probe: {
416 installed: false,
417 version: null,
418 status: "warning",
419 auth: { status: "unknown" },
420 message: "Grok is disabled in T3 Code settings.",
421 },
422 });
423 }
424​
425 const probeEnvironment = grokProbeEnvironment(environment);
426 const apiKey = environment[GROK_API_KEY_ENV]?.trim();
427​
428 const versionResult = yield* runGrokCliCommand(
429 grokSettings,
430 ["--version"],
431 probeEnvironment,
432 ).pipe(Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result);
433​
434 if (Result.isFailure(versionResult)) {
435 const error = versionResult.failure;
436 yield* Effect.logWarning("Grok CLI health check failed.", {
437 errorTag: error._tag,
438 });
439 return buildServerProvider({
440 presentation: GROK_PRESENTATION,
441 enabled: grokSettings.enabled,
442 checkedAt,
443 models: fallbackModels,
444 probe: {
445 installed: !isCommandMissingCause(error),
446 version: null,
447 status: "error",
448 auth: { status: "unknown" },
449 message: isCommandMissingCause(error)
450 ? "Grok CLI (`grok`) is not installed or not on PATH."
451 : "Failed to execute Grok CLI health check.",
452 },
453 });
454 }
455​
456 if (Option.isNone(versionResult.success)) {
457 return buildServerProvider({
458 presentation: GROK_PRESENTATION,
459 enabled: grokSettings.enabled,
460 checkedAt,
461 models: fallbackModels,
462 probe: {
463 installed: true,
464 version: null,
465 status: "error",
466 auth: { status: "unknown" },
467 message: "Grok CLI is installed but timed out while running `grok --version`.",
468 },
469 });
470 }
471​
472 const versionOutput = versionResult.success.value;
473 const version = parseCliVersionResult(versionOutput);
474 if (versionOutput.code !== 0) {
475 yield* Effect.logWarning("Grok CLI version probe exited with a non-zero status.", {
476 exitCode: versionOutput.code,
477 stdoutLength: versionOutput.stdout.length,
478 stderrLength: versionOutput.stderr.length,
479 });
480 return buildServerProvider({
481 presentation: GROK_PRESENTATION,
482 enabled: grokSettings.enabled,
483 checkedAt,
484 models: fallbackModels,
485 probe: {
486 installed: true,
487 version,
488 status: "error",
489 auth: { status: "unknown" },
490 message: "Grok CLI is installed but failed to run.",
491 },
492 });
493 }
494​
495 // `grok models` reports login state and model slugs without starting the agent.
496 let cliModels = yield* probeGrokModels(grokSettings, probeEnvironment);
497 // An expired access token prints the logged-out line, then the CLI refreshes it in the same
498 // process. Probing again after that process exited tells a refresh from a real logout.
499 if (cliModels.authenticated === false && !apiKey) {
500 const recheck = yield* probeGrokModels(grokSettings, probeEnvironment);
501 if (recheck.authenticated !== null) {
502 cliModels = recheck;
503 }
504 }
505​
506 const auth: ServerProviderAuth = apiKey
507 ? { status: "authenticated", type: "api_key", label: "xAI API key" }
508 : cliModels.authenticated === true
509 ? { status: "authenticated", type: "cached_token", label: "Grok account" }
510 : cliModels.authenticated === false
511 ? { status: "unauthenticated" }
512 : { status: "unknown" };
513​
514 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe(
515 Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })),
516 Effect.orElseSucceed(() => []),
517 );
518​
519 const acpExit = yield* discoverGrokMetadataViaAcpInitialize(grokSettings, probeEnvironment).pipe(
520 Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS),
521 Effect.exit,
522 );
523 const acpMetadata = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined;
524 const acpModels = acpMetadata?.models ?? [];
525 const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value);
526 if (acpFailed) {
527 yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", {
528 errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout",
529 });
530 }
531​
532 const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models;
533 const models =
534 discoveredModels.length > 0
535 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels)
536 : fallbackModels;
537 // The registry replaces cached models on a ready probe, so a fallback-only list must not be ready.
538 const incompleteMessage = acpFailed
539 ? "Grok CLI is installed but ACP initialize failed. Model options may be incomplete."
540 : discoveredModels.length === 0
541 ? "Grok CLI did not list any models. Model options may be incomplete."
542 : undefined;
543​
544 if (auth.status === "unauthenticated") {
545 return buildServerProvider({
546 presentation: GROK_PRESENTATION,
547 enabled: grokSettings.enabled,
548 checkedAt,
549 models,
550 skills,
551 probe: {
552 installed: true,
553 version,
554 status: "error",
555 auth,
556 message: "Grok CLI is installed but not logged in. Run `grok login`.",
557 },
558 });
559 }
560​
561 return buildServerProvider({
562 presentation: GROK_PRESENTATION,
563 enabled: grokSettings.enabled,
564 checkedAt,
565 models,
566 skills,
567 slashCommands: acpMetadata?.slashCommands ?? [COMPACT_SLASH_COMMAND],
568 probe: {
569 installed: true,
570 version,
571 status: incompleteMessage ? "warning" : "ready",
572 auth,
573 ...(incompleteMessage ? { message: incompleteMessage } : {}),
574 },
575 });
576});
577​
578export const enrichGrokSnapshot = (input: {
579 readonly snapshot: ServerProvider;
580 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
581 readonly enableProviderUpdateChecks?: boolean;
582 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
583}): Effect.Effect<
584 void,
585 never,
586 HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions
587> => {
588 const { snapshot, publishSnapshot } = input;
589​
590 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
591 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
592 }).pipe(
593 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
594 Effect.catchCause((cause) =>
595 Effect.logWarning("Grok version advisory enrichment failed", {
596 errorTag: causeErrorTag(cause),
597 }),
598 ),
599 Effect.asVoid,
600 );
601};