@visual-z/xeno 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +55 -16
  3. package/docs/app-server.md +86 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +20 -6
  6. package/package.json +3 -2
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/cli.ts +25 -10
  9. package/src/apps/commands.ts +10 -6
  10. package/src/apps/completions.ts +15 -13
  11. package/src/apps/control-transport.ts +89 -0
  12. package/src/apps/model-catalog.ts +2 -1
  13. package/src/apps/preferences.ts +20 -0
  14. package/src/apps/provider-connect.ts +4 -4
  15. package/src/apps/shell.ts +73 -50
  16. package/src/apps/startup-input.ts +7 -2
  17. package/src/apps/startup.ts +52 -19
  18. package/src/apps/ui/app.ts +210 -56
  19. package/src/apps/ui/control-model.ts +26 -0
  20. package/src/apps/ui/surface.ts +2 -0
  21. package/src/core/completion-action.ts +20 -0
  22. package/src/core/completion-review.ts +38 -29
  23. package/src/core/control-protocol.ts +62 -0
  24. package/src/core/control-view.ts +42 -0
  25. package/src/core/event-validation.ts +1 -1
  26. package/src/core/events.ts +2 -0
  27. package/src/core/harness-diagnostics.ts +206 -0
  28. package/src/core/model-preferences.ts +28 -0
  29. package/src/core/oauth-fields.ts +44 -0
  30. package/src/core/permissions.ts +13 -7
  31. package/src/core/program-status.ts +31 -0
  32. package/src/core/project.ts +25 -1
  33. package/src/core/request-progress.ts +87 -0
  34. package/src/core/request-state.ts +114 -0
  35. package/src/core/settings.ts +8 -1
  36. package/src/core/tool-input.ts +6 -0
  37. package/src/runtime/auth.ts +10 -3
  38. package/src/runtime/catalog.ts +21 -4
  39. package/src/runtime/control-connection.ts +104 -0
  40. package/src/runtime/harness-eval.ts +3 -1
  41. package/src/runtime/loop.ts +72 -19
  42. package/src/runtime/model.ts +9 -0
  43. package/src/runtime/native-eval.ts +6 -3
  44. package/src/runtime/oauth.ts +80 -3
  45. package/src/runtime/pi-provider.ts +169 -12
  46. package/src/runtime/program-status.ts +38 -0
  47. package/src/runtime/provider-connect.ts +2 -2
  48. package/src/runtime/provider-presets.ts +1 -1
  49. package/src/runtime/provider-request.ts +13 -2
  50. package/src/runtime/provider.ts +23 -9
  51. package/src/runtime/session-control.ts +198 -0
  52. package/src/runtime/tools.ts +11 -9
  53. package/src/store/resources.ts +13 -22
  54. package/src/store/user-settings.ts +30 -0
  55. package/src/tui/ui/composer.ts +3 -2
  56. package/src/tui/ui/control-panel.ts +25 -0
  57. package/src/tui/ui/control-sidebar.ts +49 -0
  58. package/src/tui/ui/mascot.ts +1 -1
  59. package/src/tui/ui/screen.ts +107 -11
  60. package/src/tui/ui/status.ts +8 -0
@@ -25,7 +25,7 @@ import type { Handoff } from "../core/handoff.ts";
25
25
  import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
26
26
  import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
27
27
  import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
28
- import { projectContext, PROJECTION_VERSION } from "../core/project.ts";
28
+ import { projectContext, PROJECTION_VERSION, toolCallIdentityViolations } from "../core/project.ts";
29
29
  import { deriveHandoff, carryForward } from "../core/derive.ts";
30
30
  import { latestObservedFileVersion } from "../core/file-precondition.ts";
31
31
  import { latestProcessReceipt } from "../core/process-receipt.ts";
@@ -59,7 +59,8 @@ import { branchStatus, processStatus, deriveProcesses, type ProcessObservation }
59
59
  import { schedulingWindow } from "../core/scheduling.ts";
60
60
  import { estimateTokens, anchoredCount, scaledCounter, type TokenCounter, type Anchor } from "../core/tokens.ts";
61
61
  import { isContextOverflow } from "../core/overflow.ts";
62
- import { completionReview } from "../core/completion-review.ts";
62
+ import { requestCompletion } from "../core/completion-review.ts";
63
+ import { deriveRequestState, type RequestState } from "../core/request-state.ts";
63
64
  import { churnReview } from "../core/churn.ts";
64
65
  import { deriveBranchReceipt } from "../core/branch-receipt.ts";
65
66
  import { skillTools, type Skill } from "../core/skills.ts";
@@ -78,7 +79,7 @@ import { recoverCut } from "../store/recovery.ts";
78
79
  import { runHistoryTool } from "./history-tools.ts";
79
80
  import { prepareWorkingNote } from "./working-notes.ts";
80
81
  import { copyAncestryTo } from "../store/sessions.ts";
81
- import type { Provider, Turn, StreamDelta } from "./provider.ts";
82
+ import { FailedResponseError, type Provider, type Turn, type StreamDelta } from "./provider.ts";
82
83
  import {
83
84
  scanWorkspace, diffScans, reconcile, DEFAULT_IGNORE, workspaceVersion,
84
85
  type FileSet, type RestoreResult,
@@ -150,6 +151,8 @@ export interface LoopEvent {
150
151
  /** branch_start: wall-clock milliseconds, for asserting the stagger. */
151
152
  at?: number;
152
153
  outcome?: ExecutionOutcome;
154
+ /** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
155
+ request?: RequestState;
153
156
  progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
154
157
  }
155
158
 
@@ -331,6 +334,7 @@ type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; an
331
334
  type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
332
335
  type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
333
336
  type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
337
+ export type CommittedItem = { node: number; id: string; type: LogEvent["t"] };
334
338
 
335
339
  export class Session {
336
340
  private readonly execOwner = randomUUID();
@@ -349,6 +353,7 @@ export class Session {
349
353
  private pendingAdoption: number | null = null;
350
354
  private readonly allocatedEventIds = new Set<string>();
351
355
  private recordTail: Promise<void> = Promise.resolve();
356
+ private readonly recordListeners = new Set<(item: CommittedItem) => void>();
352
357
  readonly config: SessionConfig;
353
358
  /**
354
359
  * Replaced by switchModel. Not readonly: a live session can change which
@@ -426,9 +431,17 @@ export class Session {
426
431
  }
427
432
 
428
433
  private async observeEvidence(): Promise<void> {
429
- const proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
430
- const prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
434
+ let proof = this.state.events.findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
435
+ let prior = proof?.t === "tool_result" && proof.effect?.kind === "command" ? proof.effect.workspace_sha256 :
431
436
  this.state.handoff?.state.completed.at(-1)?.evidence.workspace_sha256;
437
+ // A short task need not create a completed note. Its last command still
438
+ // establishes the comparison version when the current segment is empty.
439
+ if (!proof && this.config.tools.workspace) {
440
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
441
+ if (!history.ok) throw new Error(history.error);
442
+ proof = history.nodes.flatMap(part => part.events).findLast(event => event.t === "tool_result" && event.effect?.kind === "command");
443
+ if (proof?.t === "tool_result" && proof.effect?.kind === "command") prior = proof.effect.workspace_sha256;
444
+ }
432
445
  if (proof || prior || this.config.acceptanceContract) await this.config.tools.workspace?.observe(prior);
433
446
  }
434
447
 
@@ -867,6 +880,11 @@ export class Session {
867
880
  });
868
881
  }
869
882
 
883
+ /** Q24: publish identities only after durable append, including non-model user actions. */
884
+ subscribeRecords(listener: (item: CommittedItem) => void): () => void {
885
+ this.recordListeners.add(listener); return () => { this.recordListeners.delete(listener); };
886
+ }
887
+
870
888
  private async record(event: LogEvent): Promise<void> {
871
889
  // D8: runtime callers may retain mutable results. The in-memory projection
872
890
  // must own the same JSON value that was appended, independent of later edits.
@@ -876,6 +894,9 @@ export class Session {
876
894
  this.state.events.push(event);
877
895
  if (this.stall !== null) this.stall = advanceStall(this.stall, [{ node: this.state.node, event }]);
878
896
  if (this.recovery !== null) this.recovery = advanceRecovery(this.recovery, [event], path => resolve(this.config.tools.root, path));
897
+ for (const listener of this.recordListeners) {
898
+ try { listener({ node: this.state.node, id: event.id, type: event.t }); } catch { /* observers never own the append */ }
899
+ }
879
900
  });
880
901
  this.recordTail = saved.catch(() => {});
881
902
  await saved;
@@ -963,6 +984,14 @@ export class Session {
963
984
  return (await this.currentHandoff()).acceptance;
964
985
  }
965
986
 
987
+ async requestState(): Promise<RequestState | undefined> {
988
+ // Use the same idle workspace/target observation boundary as /acceptance.
989
+ if (!this.running) await this.acceptanceState();
990
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
991
+ if (!history.ok) throw new Error(history.error);
992
+ return deriveRequestState(history.nodes);
993
+ }
994
+
966
995
  /** Only called at paired, owned boundaries; probes never enter a pending model tool exchange. */
967
996
  private async refreshRuntimeTargets(): Promise<void> {
968
997
  const state = (await this.currentHandoff()).acceptance;
@@ -1103,6 +1132,7 @@ export class Session {
1103
1132
  userText: UserInput,
1104
1133
  signal?: AbortSignal,
1105
1134
  pull?: (phase: Phase) => Promise<UserInput | null>,
1135
+ requestId?: string,
1106
1136
  ): AsyncGenerator<LoopEvent> {
1107
1137
  if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
1108
1138
  const owned = this.execution.begin(signal);
@@ -1110,7 +1140,7 @@ export class Session {
1110
1140
  this.turnTools = savedTools;
1111
1141
  this.config.tools = { ...savedTools };
1112
1142
  this.installSkillResources();
1113
- const events = this.run(userText, owned.signal, pull);
1143
+ const events = this.run(userText, owned.signal, pull, requestId);
1114
1144
  let terminal = false;
1115
1145
  let exhausted = false;
1116
1146
  try {
@@ -1123,13 +1153,16 @@ export class Session {
1123
1153
  t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
1124
1154
  reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
1125
1155
  });
1156
+ const request = await this.requestState();
1157
+ if (request) item.value.request = request;
1126
1158
  }
1127
1159
  yield item.value;
1128
1160
  }
1129
1161
  if (!terminal) {
1130
1162
  const outcome = executionOutcome("snapshot_failed");
1131
1163
  await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason: "workspace snapshot failed", outcome });
1132
- yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome };
1164
+ const request = await this.requestState();
1165
+ yield { kind: "done", reason: "stopped: workspace snapshot failed", outcome, ...(request ? { request } : {}) };
1133
1166
  }
1134
1167
  } catch (error) {
1135
1168
  // Raw provider exceptions can contain credentials; persist only the execution fact.
@@ -1157,6 +1190,7 @@ export class Session {
1157
1190
  userText: UserInput,
1158
1191
  signal?: AbortSignal,
1159
1192
  pull?: (phase: Phase) => Promise<UserInput | null>,
1193
+ requestId?: string,
1160
1194
  ): AsyncGenerator<LoopEvent> {
1161
1195
  // A crash after a discovery receipt but before its cut is recovered from that same receipt.
1162
1196
  if (!signal?.aborted) await this.applyDiscoveredTools();
@@ -1170,9 +1204,8 @@ export class Session {
1170
1204
  if (this.isHalfRound()) throw new Error("Cannot change image transmission while tool results are pending");
1171
1205
  await this.performCut("model_checkpoint");
1172
1206
  }
1173
- await this.record({
1174
- t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(userText, blocked),
1175
- });
1207
+ const request = requestId ?? this.clock.nextId();
1208
+ await this.record({ t: "user", id: request, at: this.clock.now(), ...userInputData(userText, blocked) });
1176
1209
  if (this.config.acceptanceContract) await this.record({ t: "acceptance_contract", id: this.clock.nextId(), at: this.clock.now(),
1177
1210
  request: this.state.events.at(-1)!.id, contract: this.config.acceptanceContract });
1178
1211
  // Idle changes belong to the new prompt. Recording them before `user`
@@ -1245,6 +1278,8 @@ export class Session {
1245
1278
  try {
1246
1279
  result = yield* this.awaitProvider(projected.messages, signal);
1247
1280
  } catch (error) {
1281
+ // D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
1282
+ if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
1248
1283
  if (signal?.aborted) {
1249
1284
  yield* this.closeInterrupted(completedThisTurn);
1250
1285
  return;
@@ -1270,6 +1305,14 @@ export class Session {
1270
1305
 
1271
1306
  const previous = this.state.usage.at(-1);
1272
1307
  this.state.usage.push(result.usage);
1308
+ // Q24: an adapter may finish after abort. Keep measured usage, but cancellation
1309
+ // owns termination and a late response cannot enter context or execute tools.
1310
+ if (signal?.aborted) {
1311
+ yield { kind: "usage", usage: result.usage, ok: true };
1312
+ yield { kind: "discard" };
1313
+ yield* this.closeInterrupted(completedThisTurn);
1314
+ return;
1315
+ }
1273
1316
  const promptTokens =
1274
1317
  result.usage.input_tokens +
1275
1318
  result.usage.cache_creation_input_tokens +
@@ -1308,6 +1351,15 @@ export class Session {
1308
1351
  };
1309
1352
  }
1310
1353
 
1354
+ // D5: ambiguous IDs cannot be persisted or paired safely. Keep sampled
1355
+ // usage, but stop before observer hooks or any tool effect can run.
1356
+ const identityViolations = toolCallIdentityViolations(result.blocks);
1357
+ if (identityViolations.length > 0) {
1358
+ yield { kind: "done", outcome: executionOutcome("provider_error"),
1359
+ reason: `stopped: invalid provider tool call identity (${identityViolations.map(violation => `${violation.code}: ${violation.detail}`).join("; ")})` };
1360
+ return;
1361
+ }
1362
+
1311
1363
  await this.record({
1312
1364
  t: "assistant", id: this.clock.nextId(), at: this.clock.now(), blocks: result.blocks, stop_reason: result.stopReason,
1313
1365
  replay_key: providerReplayKey(this.provider.config.spec),
@@ -1330,7 +1382,7 @@ export class Session {
1330
1382
  }
1331
1383
  const extra = await this.pullSteering(pull);
1332
1384
  if (extra !== null) {
1333
- await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked) });
1385
+ await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked) });
1334
1386
  continue;
1335
1387
  }
1336
1388
  const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
@@ -1353,24 +1405,25 @@ export class Session {
1353
1405
  const extra = await this.pullSteering(pull);
1354
1406
  if (extra !== null) {
1355
1407
  await this.record({
1356
- t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
1408
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
1357
1409
  });
1358
1410
  continue;
1359
1411
  }
1360
1412
  if (this.config.acceptanceContract) await this.refreshRuntimeTargets();
1361
- const acceptance = this.config.acceptanceContract ? await this.acceptanceState() : undefined;
1362
- const review = this.config.guidance === false || this.config.harnessReviews === false ? null
1363
- : completionReview(this.state.events, this.config.maxTurns - turn - 1, {
1413
+ const state = await this.requestState();
1414
+ const decision = !state || this.config.guidance === false || this.config.harnessReviews === false ? { action: "finish" as const }
1415
+ : requestCompletion(state, this.config.maxTurns - turn - 1, {
1364
1416
  ...(this.config.guidanceVersion === 1 ? { taskAware: true } : {}),
1365
1417
  ...(this.config.strictVerification ? { strictVerification: true } : {}),
1366
- ...(acceptance ? { acceptance } : {}),
1367
1418
  });
1368
- if (review) {
1369
- await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...review });
1419
+ if (decision.action === "continue") {
1370
1420
  if (yield* this.maybeCut(counter, true)) {
1371
1421
  yield { kind: "done", reason: "stopped: no verifiable progress", outcome: executionOutcome("no_progress") };
1372
1422
  return;
1373
1423
  }
1424
+ // Q23: a notice appended before a cut can disappear from the next
1425
+ // projection. Append at the destination so the provider sees it once.
1426
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), ...decision.guidance });
1374
1427
  continue;
1375
1428
  }
1376
1429
  const stopped = yield* this.maybeCut(counter, false);
@@ -1450,7 +1503,7 @@ export class Session {
1450
1503
  const extra = await this.pullSteering(pull);
1451
1504
  if (extra !== null) {
1452
1505
  await this.record({
1453
- t: "user", id: this.clock.nextId(), at: this.clock.now(), ...userInputData(extra, blocked),
1506
+ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, ...userInputData(extra, blocked),
1454
1507
  });
1455
1508
  }
1456
1509
  }
@@ -19,6 +19,7 @@
19
19
  import type { ProviderApi } from "./provider-protocols.ts";
20
20
  import { SOURCE_MODELS } from "./model-data.ts";
21
21
  import { mergeCatalogs } from "../core/catalog-merge.ts";
22
+ import { GROK_PROVIDER, GROK_BASE_URL } from "./oauth.ts";
22
23
  export type Api = ProviderApi;
23
24
 
24
25
  /** How hard the model should think. Capability and wire vocabulary are mapped per model. */
@@ -440,6 +441,14 @@ export const CURATED_CATALOG: readonly ModelSpec[] = [
440
441
  DEEPSEEK_V4_PRO,
441
442
  DEEPSEEK_FLASH,
442
443
 
444
+ {
445
+ id: "grok-build", name: "Grok Build", provider: GROK_PROVIDER, baseUrl: GROK_BASE_URL,
446
+ api: "openai-completions", driver: "pi", input: ["text"],
447
+ // The official proxy alias has no published effort/price contract. Do not copy API model rates.
448
+ reasoning: false,
449
+ nativeCompat: { supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" },
450
+ },
451
+
443
452
  completionsModel({
444
453
  id: "grok-4.6",
445
454
  // Input modalities from the existing models.dev catalog source, 2026-09-30.
@@ -21,13 +21,14 @@ export async function nativeWire(request: Request): Promise<Record<string, unkno
21
21
  return JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>;
22
22
  }
23
23
  /** Running the same serializer with a local fetch prevents a second, approximate wire builder. */
24
- export async function nativePayload(config: ProviderConfig, frozen: FrozenPrefix, messages: Message[]): Promise<Record<string, unknown>> {
24
+ export async function nativePayload(config: ProviderConfig, frozen: FrozenPrefix, messages: Message[], sessionId?: string): Promise<Record<string, unknown>> {
25
25
  let payload: Record<string, unknown> | undefined;
26
26
  const capture = (async (input: RequestInfo | URL, init?: RequestInit) => {
27
27
  payload = await nativeWire(new Request(input, init));
28
28
  return new Response(null, { status: 400 });
29
29
  }) as typeof fetch;
30
- const provider = new PiProvider(config, NATIVE_EVAL_TEST_KEY, { oauth: true, effects: noEffects, fetch: capture, retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
30
+ const provider = new PiProvider(config, NATIVE_EVAL_TEST_KEY, { oauth: true, effects: noEffects, fetch: capture,
31
+ ...(sessionId ? { sessionId } : {}), retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
31
32
  await provider.send(frozen, messages, 0).catch(() => {});
32
33
  if (!payload) throw Error("native offline wire was not produced");
33
34
  return payload;
@@ -67,6 +68,7 @@ export function nativeResponseTap(body: ReadableStream<Uint8Array>, terminal: (r
67
68
  export function nativeEvalTransport(options: {
68
69
  config: ProviderConfig; key: string; price: BenchmarkPrice; ledger: ComparisonLedger; maxRequests: number;
69
70
  fetch?: typeof fetch;
71
+ sessionId?: string;
70
72
  record: (row: GatewayRecord) => Promise<void>;
71
73
  wire: (payload: Record<string, unknown>, request: number) => Promise<void>;
72
74
  response: (receipt: NativeResponse, request: number) => Promise<void>;
@@ -105,7 +107,8 @@ export function nativeEvalTransport(options: {
105
107
  state.terminal = terminal; await options.response(terminal, row.request);
106
108
  }), { status: response.status, headers: response.headers });
107
109
  }) as typeof fetch;
108
- const actual = new PiProvider(config, options.key, { oauth: true, effects: noEffects, fetch: observedFetch, retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
110
+ const actual = new PiProvider(config, options.key, { oauth: true, effects: noEffects, fetch: observedFetch,
111
+ ...(options.sessionId ? { sessionId: options.sessionId } : {}), retry: { maxRetries: 0, maxRetryDelayMs: 0 } });
109
112
  const provider: Provider = { config, async send(frozen, messages, node, signal, onDelta) {
110
113
  if (active) throw Error("native eval model requests must be sequential");
111
114
  active = true; current = undefined;
@@ -15,9 +15,10 @@
15
15
 
16
16
  import { createServer, type Server } from "node:http";
17
17
  import { pollDeviceCode } from "./oauth-device.ts";
18
+ import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
18
19
 
19
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
20
- export const XENO_VERSION = "0.3.0";
21
+ export const XENO_VERSION = "0.5.0";
21
22
 
22
23
  /**
23
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -33,6 +34,7 @@ export const SUBSCRIPTION_OPTIONS = [
33
34
  { id: "claude-pro", provider: "anthropic", label: "Claude Pro/Max" },
34
35
  { id: "codex", provider: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
35
36
  { id: "github-copilot", provider: "github-copilot", label: "GitHub Copilot" },
37
+ { id: "grok", provider: "xai-grok", label: "Grok (official account)" },
36
38
  ] as const;
37
39
 
38
40
  export type SubscriptionId = (typeof SUBSCRIPTION_OPTIONS)[number]["id"];
@@ -101,12 +103,83 @@ export const OPENAI_REDIRECT_URI = `http://localhost:${OPENAI_CALLBACK_PORT}${OP
101
103
  const OPENAI_SCOPE = "openid profile email offline_access";
102
104
  const OPENAI_JWT_CLAIM = "https://api.openai.com/auth";
103
105
 
106
+ // Official Grok Build config.rs / device_code.rs, verified 2026-10-07.
107
+ // This is the public first-party client, with xeno attribution retained (D18).
108
+ const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
109
+ const GROK_AUTH_ORIGIN = "https://auth.x.ai";
110
+ const GROK_TOKEN_URL = `${GROK_AUTH_ORIGIN}/oauth2/token`;
111
+ // Only inference and refresh scopes: xeno does not store remote conversations/workspaces.
112
+ const GROK_SCOPE = "openid profile email offline_access grok-cli:access api:access";
113
+ export const GROK_PROVIDER = "xai-grok";
114
+ export const GROK_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
115
+ /** Official HTTP timeout: issuer requests cannot keep a cancelled or expired grant hanging indefinitely. */
116
+ const GROK_REQUEST_TIMEOUT_MS = 15_000;
117
+
118
+ async function grokPost(url: string, fields: Record<string, string>, effects: Pick<RefreshEffects, "fetch" | "signal">): Promise<{ status: number; json: unknown }> {
119
+ const timeout = AbortSignal.timeout(GROK_REQUEST_TIMEOUT_MS);
120
+ const signal = effects.signal ? AbortSignal.any([effects.signal, timeout]) : timeout;
121
+ try {
122
+ const response = await effects.fetch(url, { method: "POST", redirect: "error", signal,
123
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": `xeno/${XENO_VERSION}` },
124
+ body: new URLSearchParams(fields) });
125
+ let json: unknown;
126
+ try { json = await response.json(); } catch { json = undefined; }
127
+ return { status: response.status, json };
128
+ } catch {
129
+ // Network errors can include request details; never expose a credential-bearing body.
130
+ throw new Error(effects.signal?.aborted ? "Login cancelled" : "Grok authentication request failed");
131
+ }
132
+ }
133
+
134
+ export async function loginGrok(interaction: OAuthInteraction, sleep?: Parameters<typeof pollDeviceCode>[0]["sleep"]): Promise<OAuthCredential> {
135
+ const started = await grokPost(`${GROK_AUTH_ORIGIN}/oauth2/device/code`,
136
+ { client_id: GROK_CLIENT_ID, scope: GROK_SCOPE, referrer: "xeno" }, interaction);
137
+ if (started.status < 200 || started.status >= 300) throw new Error(`Grok device code request failed (HTTP ${started.status})`);
138
+ const device = parseDeviceAuthorization(started.json, [GROK_AUTH_ORIGIN, "https://accounts.x.ai"]);
139
+ if (!device) throw new Error("Grok device code response was incomplete or untrusted");
140
+ interaction.signal.throwIfAborted();
141
+ const url = device.verificationUriComplete ?? device.verificationUri;
142
+ interaction.notify(`Open ${url}\nand enter code: ${device.userCode}`);
143
+ tryOpen(url, interaction.openBrowser);
144
+ return pollDeviceCode<OAuthCredential>({
145
+ expiresInSeconds: device.expiresIn, waitBeforeFirstPoll: true, signal: interaction.signal,
146
+ ...(device.interval !== undefined ? { intervalSeconds: device.interval } : {}), ...(sleep ? { sleep } : {}),
147
+ poll: async () => {
148
+ const got = await grokPost(GROK_TOKEN_URL,
149
+ { client_id: GROK_CLIENT_ID, device_code: device.deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }, interaction);
150
+ const body = asRecord(got.json);
151
+ // RFC 8628 errors use HTTP 400; they are polling states, never completed logins.
152
+ if (got.status === 400 && body?.["error"] === "authorization_pending") return { status: "pending" };
153
+ if (got.status === 400 && body?.["error"] === "slow_down") return { status: "slow_down" };
154
+ if (got.status < 200 || got.status >= 300) return { status: "failed", message: `Grok device login failed (HTTP ${got.status})` };
155
+ const fields = parseOAuthTokens(got.json);
156
+ if (!fields) return { status: "failed", message: "Grok token response was incomplete" };
157
+ interaction.signal.throwIfAborted();
158
+ return { status: "complete", value: { type: "oauth", access: fields.access, refresh: fields.refresh,
159
+ expires: expiryFromExpiresIn(fields.expiresIn, (interaction.now ?? Date.now)()) } };
160
+ },
161
+ });
162
+ }
163
+
164
+ async function refreshGrok(refresh: string, effects: RefreshEffects): Promise<{ ok: true; credential: OAuthCredential } | { ok: false; reason: string }> {
165
+ try {
166
+ const got = await grokPost(GROK_TOKEN_URL, { client_id: GROK_CLIENT_ID, grant_type: "refresh_token", refresh_token: refresh }, effects);
167
+ if (effects.signal?.aborted) return { ok: false, reason: "Login cancelled" };
168
+ if (got.status < 200 || got.status >= 300) return { ok: false, reason: `HTTP ${got.status}` };
169
+ const fields = parseOAuthTokens(got.json, refresh);
170
+ if (!fields) return { ok: false, reason: "unexpected body" };
171
+ return { ok: true, credential: { type: "oauth", access: fields.access, refresh: fields.refresh,
172
+ expires: expiryFromExpiresIn(fields.expiresIn, effects.now()) } };
173
+ } catch { return { ok: false, reason: "Grok token refresh failed" }; }
174
+ }
175
+
104
176
  export function isSubscriptionLogin(id: string): boolean {
105
- return SUBSCRIPTION_OPTIONS.some((o) => o.id === id);
177
+ return subscriptionById(id) !== undefined;
106
178
  }
107
179
 
108
180
  export function subscriptionById(id: string): (typeof SUBSCRIPTION_OPTIONS)[number] | undefined {
109
- return SUBSCRIPTION_OPTIONS.find((o) => o.id === id);
181
+ // Separate account providers accept their catalog identity too; anthropic still means an API key.
182
+ return SUBSCRIPTION_OPTIONS.find(o => o.id === id || (o.provider === id && o.provider !== "anthropic"));
110
183
  }
111
184
 
112
185
  export function expiryFromExpiresIn(expiresIn: number, now: number): number {
@@ -505,6 +578,8 @@ export async function refreshOAuthCredential(
505
578
  return refreshOpenAI(credential.refresh, opts);
506
579
  case "github-copilot":
507
580
  return refreshCopilot(credential.refresh, opts, credential.enterpriseUrl);
581
+ case GROK_PROVIDER:
582
+ return refreshGrok(credential.refresh, opts);
508
583
  default:
509
584
  return { ok: false, reason: `no oauth refresh for ${provider}` };
510
585
  }
@@ -752,6 +827,8 @@ export async function loginSubscription(
752
827
  return { provider: "openai-codex", credential: await loginOpenAICodex(interaction) };
753
828
  case "github-copilot":
754
829
  return { provider: "github-copilot", credential: await loginGitHubCopilot(interaction) };
830
+ case "grok":
831
+ return { provider: GROK_PROVIDER, credential: await loginGrok(interaction) };
755
832
  default: {
756
833
  const never: never = id;
757
834
  throw new Error(`unknown subscription login: ${String(never)}`);