@shanepadgett/tau-agent 0.14.0 → 0.16.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.
@@ -4,7 +4,7 @@ Sends a terminal-driven attention notification when Tau is ready for input, fini
4
4
 
5
5
  ## Behavior
6
6
 
7
- - Emits an attention notification on `agent_end`.
7
+ - Emits an attention notification after the agent settles with no automatic continuation pending.
8
8
  - Emits an attention notification on `session_compact`.
9
9
  - Emits an attention notification on `session_tree` when it includes a branch summary.
10
10
  - Listens for shared event `tau:agent.blocked` when Tau is waiting on user input.
@@ -66,7 +66,7 @@ export default function attentionExtension(pi: ExtensionAPI): void {
66
66
 
67
67
  onTauEvent(pi, "attention.agent-blocked", "tau:agent.blocked", notify);
68
68
 
69
- pi.on("agent_end", (_event, ctx) => {
69
+ pi.on("agent_settled", (_event, ctx) => {
70
70
  if (ctx.mode === "print") return;
71
71
  notify({ title: DEFAULT_TITLE, body: DEFAULT_BODY });
72
72
  });
@@ -8,7 +8,6 @@ import { createGitRunner, loadRepoStatus, type GitRunner } from "../../shared/gi
8
8
  import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
9
9
  import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
10
10
  import { truncAt } from "../../shared/text.ts";
11
- import { XAI_CHAT_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
12
11
  import {
13
12
  contextEntryPaths,
14
13
  isContextEligiblePath,
@@ -32,7 +31,7 @@ const CONTEXT_SYNC_MODELS: ReadonlyArray<{ provider: string; model: string; reas
32
31
  { provider: "openai-codex", model: "gpt-5.6-terra", reasoning: "medium" },
33
32
  { provider: "openai-codex", model: "gpt-5.6-sol", reasoning: "low" },
34
33
  { provider: "anthropic", model: "claude-sonnet-5", reasoning: "low" },
35
- { provider: XAI_PROVIDER, model: XAI_CHAT_MODEL, reasoning: "high" },
34
+ { provider: "xai", model: "grok-4.5", reasoning: "high" },
36
35
  ];
37
36
 
38
37
  const SUBMIT_TOOL = {
@@ -2,10 +2,10 @@
2
2
 
3
3
  `image_gen` generates raster images and edits up to three local raster images with Grok Imagine. It uses `grok-imagine-image-quality` and saves results under `~/.local/share/tau-agent/images/` by default. Pass an explicit path with the expected image extension when the image should be saved in the current repository or another chosen location.
4
4
 
5
- Use `/login` and select xAI (Grok subscription OAuth) before invoking the tool. No xAI API key is used.
5
+ Run `/login xai` and choose either a subscription or API-key login before invoking the tool.
6
6
 
7
7
  Run `/reload` after installing or changing the extension.
8
8
 
9
9
  The model invokes `image_gen` with a prompt. For edits, it also supplies one to three local PNG, JPEG, or WebP paths. Successful images up to 12 MiB are returned inline for inspection; larger results remain available at the saved path.
10
10
 
11
- This extension uses xAI's undocumented subscription OAuth access. xAI may change its availability, entitlement rules, or protocol without notice.
11
+ xAI controls model availability and subscription entitlements. A successful login does not guarantee that every account can use Grok Imagine.
@@ -1,4 +1,4 @@
1
- import { XAI_API_BASE_URL, XAI_IMAGE_MODEL } from "../xai/constants.ts";
1
+ import { XAI_API_BASE_URL, XAI_IMAGE_MODEL } from "./constants.ts";
2
2
 
3
3
  const MAX_ERROR_BODY_BYTES = 8192;
4
4
  const MAX_ERROR_MESSAGE_LENGTH = 2000;
@@ -0,0 +1,3 @@
1
+ export const XAI_PROVIDER = "xai";
2
+ export const XAI_IMAGE_MODEL = "grok-imagine-image-quality";
3
+ export const XAI_API_BASE_URL = "https://api.x.ai/v1";
@@ -4,8 +4,8 @@ import { link, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
6
  import { type Static, Type } from "typebox";
7
- import { XAI_IMAGE_MODEL, XAI_PROVIDER } from "../xai/constants.ts";
8
7
  import { detectImageMimeType, editImage, generateImage, type EditImage, type GeneratedImage } from "./client.ts";
8
+ import { XAI_IMAGE_MODEL, XAI_PROVIDER } from "./constants.ts";
9
9
 
10
10
  const MAX_INPUT_BYTES = 50 * 1024 * 1024;
11
11
  const MAX_INLINE_BYTES = 12 * 1024 * 1024;
@@ -41,7 +41,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
41
41
  name: "image_gen",
42
42
  label: "Image Generation",
43
43
  description:
44
- "Generate a requested raster image or AI-edit existing images with the xAI Grok subscription OAuth login. Omit referenced_image_paths to generate; pass one to three local paths to edit or compose. Omit path to use Tau's external image store; pass path only when the user explicitly requests a repository file or other destination. Returns the image for inspection.",
44
+ "Generate a requested raster image or AI-edit existing images with configured xAI authentication. Omit referenced_image_paths to generate; pass one to three local paths to edit or compose. Omit path to use Tau's external image store; pass path only when the user explicitly requests a repository file or other destination. Returns the image for inspection.",
45
45
  parameters: imageGenSchema,
46
46
  async execute(_toolCallId, params: ImageGenParams, signal, onUpdate, ctx) {
47
47
  signal?.throwIfAborted();
@@ -63,7 +63,7 @@ export default function imageGenExtension(pi: ExtensionAPI): void {
63
63
 
64
64
  const token = await ctx.modelRegistry.getApiKeyForProvider(XAI_PROVIDER);
65
65
  if (!token) {
66
- throw new Error("xAI OAuth is unavailable. Run /login and select xAI (Grok subscription OAuth).");
66
+ throw new Error("xAI authentication is unavailable. Run /login xai and choose a login method.");
67
67
  }
68
68
  const images: EditImage[] = [];
69
69
  for (const path of params.referenced_image_paths ?? []) {
@@ -1,6 +1,6 @@
1
1
  # patch
2
2
 
3
- Replaces the built-in `edit` and `write` tools with one multi-file patch tool. The agent applies structured patches to create, edit, move, and delete files while this extension is active.
3
+ Replaces the built-in `edit` and `write` tools with one multi-file patch tool. The agent applies structured patches to create, edit, move, and delete files while this extension is active. For xAI and Grok models, Tau disables `patch` and leaves `edit` and `write` active because those models are unreliable with the patch format.
4
4
 
5
5
  ## What it does
6
6
 
@@ -135,6 +135,19 @@ export default function patchExtension(pi: ExtensionAPI): void {
135
135
  const rowState = createToolRowStateStore(pi, "patch.tool-row-state");
136
136
  pi.registerTool(createPatchTool(rowState));
137
137
 
138
+ function configureMutationTools(model: { provider: string; id: string } | undefined): void {
139
+ const active = new Set(pi.getActiveTools());
140
+ const usesGrok = model?.provider.toLowerCase() === "xai" || model?.id.toLowerCase().includes("grok") === true;
141
+ if (usesGrok) {
142
+ active.delete("patch");
143
+ for (const tool of SUPPRESSED_TOOLS) active.add(tool);
144
+ } else {
145
+ active.add("patch");
146
+ for (const tool of SUPPRESSED_TOOLS) active.delete(tool);
147
+ }
148
+ pi.setActiveTools([...active]);
149
+ }
150
+
138
151
  // AgentToolResult has no isError field, so execute returns are always treated as success.
139
152
  // Override via tool_result to flag partial/failed patches as errors for the model and UI.
140
153
  pi.on("tool_result", async (event, ctx) => {
@@ -160,11 +173,12 @@ export default function patchExtension(pi: ExtensionAPI): void {
160
173
  return { isError: true };
161
174
  });
162
175
 
163
- pi.on("session_start", () => {
176
+ pi.on("session_start", (_event, ctx) => {
164
177
  rowState.clear();
165
- const active = new Set(pi.getActiveTools());
166
- active.add("patch");
167
- for (const tool of SUPPRESSED_TOOLS) active.delete(tool);
168
- pi.setActiveTools([...active]);
178
+ configureMutationTools(ctx.model);
179
+ });
180
+
181
+ pi.on("model_select", (event) => {
182
+ configureMutationTools(event.model);
169
183
  });
170
184
  }
@@ -1,5 +1,5 @@
1
1
  # Run Summary
2
2
 
3
- Run Summary adds a compact marker after each agent run. It shows wall time, direct run cost, delegated subagent cost, and their combined total.
3
+ Run Summary adds a compact marker after the agent settles with no automatic continuation pending. It shows wall time, direct run cost, delegated subagent cost, and their combined total across the full continuation chain.
4
4
 
5
5
  The marker is stored as a display-only session entry. It does not enter model context or trigger another agent turn.
@@ -13,6 +13,8 @@ interface RunSummary {
13
13
 
14
14
  export default function runSummaryExtension(pi: ExtensionAPI): void {
15
15
  let startedAt: number | undefined;
16
+ let runCost = 0;
17
+ let subagentCost = 0;
16
18
 
17
19
  pi.registerEntryRenderer<RunSummary>(ENTRY_TYPE, (entry, _options, theme) => {
18
20
  const summary = readRunSummary(entry.data);
@@ -32,18 +34,15 @@ export default function runSummaryExtension(pi: ExtensionAPI): void {
32
34
 
33
35
  pi.on("session_start", () => {
34
36
  startedAt = undefined;
37
+ runCost = 0;
38
+ subagentCost = 0;
35
39
  });
36
40
 
37
41
  pi.on("agent_start", () => {
38
- startedAt = performance.now();
42
+ startedAt ??= performance.now();
39
43
  });
40
44
 
41
45
  pi.on("agent_end", (event) => {
42
- if (startedAt === undefined) return;
43
- const wallMs = Math.max(0, performance.now() - startedAt);
44
- startedAt = undefined;
45
- let runCost = 0;
46
- let subagentCost = 0;
47
46
  for (const message of event.messages) {
48
47
  if (message.role === "assistant") {
49
48
  runCost += finiteNonNegative((message as AssistantMessage).usage.cost.total);
@@ -52,12 +51,20 @@ export default function runSummaryExtension(pi: ExtensionAPI): void {
52
51
  if (message.role !== "toolResult" || message.toolName !== "subagent") continue;
53
52
  subagentCost += readSubagentCost(message.details);
54
53
  }
54
+ });
55
+
56
+ pi.on("agent_settled", () => {
57
+ if (startedAt === undefined) return;
58
+ const wallMs = Math.max(0, performance.now() - startedAt);
59
+ startedAt = undefined;
55
60
  pi.appendEntry<RunSummary>(ENTRY_TYPE, {
56
61
  wallMs,
57
62
  runCost,
58
63
  subagentCost,
59
64
  totalCost: runCost + subagentCost,
60
65
  });
66
+ runCost = 0;
67
+ subagentCost = 0;
61
68
  });
62
69
  }
63
70
 
@@ -80,6 +80,8 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
80
80
  let turnPaths = new Set<string>();
81
81
  let run: Promise<void> | undefined;
82
82
  let abortController: AbortController | undefined;
83
+ let lastRunAborted = false;
84
+ let chainActive = false;
83
85
 
84
86
  pi.registerMessageRenderer<FailureDetails>(MESSAGE_TYPE, (message, { expanded }, theme) =>
85
87
  renderFailure(asFailureDetails(message.details), expanded, theme),
@@ -89,6 +91,8 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
89
91
  settings = normalizeSettings(await loadTauExtensionSettings(ctx, silentCommandRunnerSettings));
90
92
  turnStart = Date.now();
91
93
  turnPaths = new Set();
94
+ lastRunAborted = false;
95
+ chainActive = false;
92
96
  });
93
97
 
94
98
  pi.on("before_agent_start", async (event, ctx) => {
@@ -98,6 +102,9 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
98
102
  });
99
103
 
100
104
  pi.on("agent_start", async (_event, ctx) => {
105
+ lastRunAborted = false;
106
+ if (chainActive) return;
107
+ chainActive = true;
101
108
  turnStart = Date.now();
102
109
  if (!settings.enabled || settings.commands.length === 0) {
103
110
  turnPaths = new Set();
@@ -107,8 +114,13 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
107
114
  turnPaths = new Set(await walkFiles(projectRoot));
108
115
  });
109
116
 
110
- pi.on("agent_end", async (event, ctx) => {
111
- if (hasAbortedAssistantMessage(event.messages)) return;
117
+ pi.on("agent_end", (event) => {
118
+ lastRunAborted = hasAbortedAssistantMessage(event.messages);
119
+ });
120
+
121
+ pi.on("agent_settled", async (_event, ctx) => {
122
+ chainActive = false;
123
+ if (lastRunAborted) return;
112
124
  if (run) return;
113
125
  run = runChangedCommands(ctx.cwd, turnStart, ctx.ui.notify)
114
126
  .catch((error: unknown) => {
@@ -125,6 +137,8 @@ export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
125
137
  abortController = undefined;
126
138
  run = undefined;
127
139
  turnPaths = new Set();
140
+ lastRunAborted = false;
141
+ chainActive = false;
128
142
  });
129
143
 
130
144
  async function runChangedCommands(
@@ -232,7 +232,6 @@ export async function createSubagentThread(options: {
232
232
  cwd: ctx.cwd,
233
233
  model,
234
234
  thinkingLevel: thinkingLevel as "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
235
- modelRegistry: ctx.modelRegistry,
236
235
  tools: definition.tools,
237
236
  excludeTools: ["subagent"],
238
237
  resourceLoader,
@@ -44,11 +44,7 @@ Adds `/ideas` to log rough ideas or open the ideas browser.
44
44
 
45
45
  ## image-gen
46
46
 
47
- Gives the agent an image-generation tool using the configured image service. Generated images are saved for inspection.
48
-
49
- ## xai
50
-
51
- Adds Grok 4.5 and Grok Imagine through an xAI Grok subscription OAuth login. Run `/login` and select xAI before use.
47
+ Gives the agent a Grok Imagine generation and editing tool. Run `/login xai` and choose a subscription or API-key login before use. Generated images are saved for inspection.
52
48
 
53
49
  ## manage-sessions
54
50
 
@@ -56,7 +52,7 @@ Adds `/manage-sessions` to browse saved sessions and `/sweep` to archive or dele
56
52
 
57
53
  ## patch
58
54
 
59
- Replaces separate edit/write operations with one multi-file `patch` tool. It can create, rewrite, edit, move, and delete files in one structured call. Fewer tool calls means fewer turns, and each avoided turn prevents the full chat context from being sent again.
55
+ Replaces separate edit/write operations with one multi-file `patch` tool. It can create, rewrite, edit, move, and delete files in one structured call. Fewer tool calls means fewer turns, and each avoided turn prevents the full chat context from being sent again. Tau keeps `patch` disabled and uses `edit` and `write` for xAI and Grok models.
60
56
 
61
57
  ## publish
62
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@shanepadgett/tau-tui": "0.14.0",
31
+ "@shanepadgett/tau-tui": "0.16.0",
32
32
  "@toon-format/toon": "2.3.0",
33
33
  "smol-toml": "1.7.0"
34
34
  },
@@ -1,7 +0,0 @@
1
- # xAI OAuth
2
-
3
- Adds Grok 4.5 using an xAI Grok subscription login. Run `/login`, select **xAI (Grok subscription OAuth)**, then authorize xAI in the browser. An existing official Grok CLI login can also be reused.
4
-
5
- The same login powers Tau's `image_gen` tool through Grok Imagine. No xAI API key is used.
6
-
7
- This integration uses xAI's undocumented subscription OAuth access. xAI may change its availability, entitlement rules, or protocol without notice.
@@ -1,40 +0,0 @@
1
- import type { OAuthCredentials } from "@earendil-works/pi-ai";
2
- import { readFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { XAI_OAUTH_CLIENT_ID, XAI_OAUTH_ISSUER } from "./constants.ts";
6
-
7
- function expiry(value: unknown): number | undefined {
8
- if (typeof value === "number" && Number.isInteger(value) && value >= 1_000_000_000_000) return value;
9
- if (typeof value !== "string") return undefined;
10
- const parsed = Date.parse(value);
11
- return Number.isFinite(parsed) ? parsed : undefined;
12
- }
13
-
14
- export function parseGrokCredentials(value: unknown): OAuthCredentials | undefined {
15
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
16
- const entry = (value as Record<string, unknown>)[`${XAI_OAUTH_ISSUER}::${XAI_OAUTH_CLIENT_ID}`];
17
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return undefined;
18
- const record = entry as Record<string, unknown>;
19
- const expires = expiry(record.expires_at);
20
- if (
21
- typeof record.key !== "string" ||
22
- !record.key ||
23
- typeof record.refresh_token !== "string" ||
24
- !record.refresh_token ||
25
- record.oidc_issuer !== XAI_OAUTH_ISSUER ||
26
- record.oidc_client_id !== XAI_OAUTH_CLIENT_ID ||
27
- expires === undefined
28
- ) {
29
- return undefined;
30
- }
31
- return { access: record.key, refresh: record.refresh_token, expires };
32
- }
33
-
34
- export async function readGrokCredentials(): Promise<OAuthCredentials | undefined> {
35
- try {
36
- return parseGrokCredentials(JSON.parse(await readFile(join(homedir(), ".grok", "auth.json"), "utf8")));
37
- } catch {
38
- return undefined;
39
- }
40
- }
@@ -1,11 +0,0 @@
1
- export const XAI_PROVIDER = "xai-oauth";
2
- export const XAI_CHAT_MODEL = "grok-4.5";
3
- export const XAI_IMAGE_MODEL = "grok-imagine-image-quality";
4
- export const XAI_API_BASE_URL = "https://api.x.ai/v1";
5
-
6
- export const XAI_OAUTH_ISSUER = "https://auth.x.ai";
7
- export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
8
- export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
9
- export const XAI_OAUTH_CALLBACK_HOST = "127.0.0.1";
10
- export const XAI_OAUTH_CALLBACK_PORT = 56121;
11
- export const XAI_OAUTH_CALLBACK_PATH = "/callback";
@@ -1,38 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { XAI_API_BASE_URL, XAI_CHAT_MODEL, XAI_PROVIDER } from "./constants.ts";
3
- import { xaiOAuth } from "./oauth.ts";
4
- import { rewriteXaiPayload } from "./payload.ts";
5
-
6
- export default function xaiExtension(pi: ExtensionAPI): void {
7
- pi.registerProvider(XAI_PROVIDER, {
8
- name: "xAI (Grok subscription OAuth)",
9
- baseUrl: XAI_API_BASE_URL,
10
- api: "openai-responses",
11
- authHeader: true,
12
- oauth: xaiOAuth,
13
- models: [
14
- {
15
- id: XAI_CHAT_MODEL,
16
- name: "Grok 4.5",
17
- reasoning: true,
18
- input: ["text", "image"],
19
- cost: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 },
20
- contextWindow: 500_000,
21
- maxTokens: 131_072,
22
- thinkingLevelMap: {
23
- off: null,
24
- minimal: "low",
25
- low: "low",
26
- medium: "medium",
27
- high: "high",
28
- xhigh: null,
29
- max: null,
30
- },
31
- },
32
- ],
33
- });
34
- pi.on("before_provider_request", (event, ctx) => {
35
- if (ctx.model?.provider !== XAI_PROVIDER) return;
36
- return rewriteXaiPayload(event.payload);
37
- });
38
- }
@@ -1,342 +0,0 @@
1
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
2
- import { createHash, randomBytes } from "node:crypto";
3
- import { createServer, type Server } from "node:http";
4
- import { readGrokCredentials } from "./auth.ts";
5
- import {
6
- XAI_OAUTH_CALLBACK_HOST,
7
- XAI_OAUTH_CALLBACK_PATH,
8
- XAI_OAUTH_CALLBACK_PORT,
9
- XAI_OAUTH_CLIENT_ID,
10
- XAI_OAUTH_ISSUER,
11
- XAI_OAUTH_SCOPE,
12
- } from "./constants.ts";
13
-
14
- const DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
15
- const REQUEST_TIMEOUT_MS = 30_000;
16
- const LOGIN_TIMEOUT_MS = 180_000;
17
- const REFRESH_SKEW_MS = 120_000;
18
-
19
- interface Discovery {
20
- authorization_endpoint: string;
21
- token_endpoint: string;
22
- }
23
-
24
- interface TokenPayload {
25
- access_token?: unknown;
26
- refresh_token?: unknown;
27
- id_token?: unknown;
28
- expires_in?: unknown;
29
- token_type?: unknown;
30
- }
31
-
32
- interface HttpResponse {
33
- ok: boolean;
34
- status: number;
35
- body: { cancel(): Promise<void> } | null;
36
- json(): Promise<unknown>;
37
- }
38
-
39
- interface CallbackResult {
40
- code?: string;
41
- error?: string;
42
- errorDescription?: string;
43
- }
44
-
45
- function validatedEndpoint(value: unknown, field: string): string {
46
- if (typeof value !== "string") throw new Error(`xAI OAuth discovery omitted ${field}`);
47
- const url = new URL(value);
48
- const host = url.hostname.toLowerCase();
49
- if (url.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
50
- throw new Error(`xAI OAuth discovery returned an unexpected ${field}`);
51
- }
52
- return url.toString();
53
- }
54
-
55
- function requestSignal(parent?: AbortSignal): AbortSignal {
56
- const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
57
- return parent ? AbortSignal.any([parent, timeout]) : timeout;
58
- }
59
-
60
- async function discover(signal?: AbortSignal): Promise<Discovery> {
61
- const response = (await fetch(DISCOVERY_URL, {
62
- headers: { Accept: "application/json" },
63
- signal: requestSignal(signal),
64
- })) as HttpResponse;
65
- if (!response.ok) throw new Error(`xAI OAuth discovery failed with status ${response.status}`);
66
- const value: unknown = await response.json();
67
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
68
- throw new Error("xAI OAuth discovery returned invalid JSON");
69
- }
70
- const record = value as Record<string, unknown>;
71
- return {
72
- authorization_endpoint: validatedEndpoint(record.authorization_endpoint, "authorization_endpoint"),
73
- token_endpoint: validatedEndpoint(record.token_endpoint, "token_endpoint"),
74
- };
75
- }
76
-
77
- async function tokenRequest(endpoint: string, body: URLSearchParams, signal?: AbortSignal): Promise<TokenPayload> {
78
- const response = (await fetch(validatedEndpoint(endpoint, "token_endpoint"), {
79
- method: "POST",
80
- headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
81
- body,
82
- signal: requestSignal(signal),
83
- })) as HttpResponse;
84
- if (!response.ok) {
85
- await response.body?.cancel().catch(() => undefined);
86
- throw new Error(`xAI OAuth token request failed with status ${response.status}`);
87
- }
88
- return (await response.json()) as TokenPayload;
89
- }
90
-
91
- function jwtClaims(token: string): Record<string, unknown> {
92
- const segments = token.split(".");
93
- if (segments.length !== 3 || !segments[1]) throw new Error("xAI OAuth returned an invalid ID token");
94
- try {
95
- const value: unknown = JSON.parse(Buffer.from(segments[1], "base64url").toString("utf8"));
96
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error();
97
- return value as Record<string, unknown>;
98
- } catch {
99
- throw new Error("xAI OAuth returned an invalid ID token");
100
- }
101
- }
102
-
103
- function credentials(payload: TokenPayload, endpoint: string, fallbackRefresh = "", nonce?: string): OAuthCredentials {
104
- if (typeof payload.access_token !== "string" || !payload.access_token) {
105
- throw new Error("xAI OAuth token response omitted the access token");
106
- }
107
- const refresh =
108
- typeof payload.refresh_token === "string" && payload.refresh_token ? payload.refresh_token : fallbackRefresh;
109
- if (!refresh) throw new Error("xAI OAuth token response omitted the refresh token");
110
- if (nonce !== undefined) {
111
- if (typeof payload.id_token !== "string" || !payload.id_token)
112
- throw new Error("xAI OAuth token response omitted the ID token");
113
- const claims = jwtClaims(payload.id_token);
114
- const audience = claims.aud;
115
- const validAudience =
116
- audience === XAI_OAUTH_CLIENT_ID || (Array.isArray(audience) && audience.includes(XAI_OAUTH_CLIENT_ID));
117
- if (claims.iss !== XAI_OAUTH_ISSUER || !validAudience || claims.nonce !== nonce) {
118
- throw new Error("xAI OAuth ID token validation failed");
119
- }
120
- if (typeof claims.exp !== "number" || claims.exp * 1000 <= Date.now()) {
121
- throw new Error("xAI OAuth returned an expired ID token");
122
- }
123
- }
124
- const expiresIn = typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : 3600;
125
- return {
126
- access: payload.access_token,
127
- refresh,
128
- expires: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS,
129
- tokenEndpoint: endpoint,
130
- };
131
- }
132
-
133
- async function closeServer(server: Server): Promise<void> {
134
- if (!server.listening) return;
135
- await new Promise<void>((resolve) => server.close(() => resolve()));
136
- }
137
-
138
- async function callbackServer(expectedState: string): Promise<{
139
- redirectUri: string;
140
- wait(signal?: AbortSignal): Promise<CallbackResult>;
141
- acceptManual(input: string): string | undefined;
142
- close(): Promise<void>;
143
- }> {
144
- let settle: ((result: CallbackResult) => void) | undefined;
145
- let reject: ((error: Error) => void) | undefined;
146
- let settled = false;
147
- const result = new Promise<CallbackResult>((resolve, rejectResult) => {
148
- settle = resolve;
149
- reject = rejectResult;
150
- });
151
- const accept = (value: CallbackResult) => {
152
- if (settled) return;
153
- settled = true;
154
- settle?.(value);
155
- };
156
- const parse = (params: URLSearchParams): CallbackResult | undefined => {
157
- if (params.get("state") !== expectedState) return undefined;
158
- const code = params.get("code") || undefined;
159
- const error = params.get("error") || undefined;
160
- if (!code && !error) return undefined;
161
- return { code, error, errorDescription: params.get("error_description") || undefined };
162
- };
163
- const server = createServer((request, response) => {
164
- const origin = request.headers.origin;
165
- if (origin === "https://accounts.x.ai" || origin === "https://auth.x.ai") {
166
- response.setHeader("Access-Control-Allow-Origin", origin);
167
- response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
168
- response.setHeader("Access-Control-Allow-Headers", "Content-Type");
169
- response.setHeader("Access-Control-Allow-Private-Network", "true");
170
- response.setHeader("Vary", "Origin");
171
- }
172
- if (request.method === "OPTIONS") {
173
- response.writeHead(204).end();
174
- return;
175
- }
176
- const url = new URL(request.url ?? "/", `http://${XAI_OAUTH_CALLBACK_HOST}`);
177
- if (request.method !== "GET" || url.pathname !== XAI_OAUTH_CALLBACK_PATH) {
178
- response.writeHead(404).end("Not found");
179
- return;
180
- }
181
- const parsed = parse(url.searchParams);
182
- if (!parsed) {
183
- response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Invalid OAuth callback");
184
- return;
185
- }
186
- response
187
- .writeHead(parsed.error ? 400 : 200, { "Content-Type": "text/html; charset=utf-8" })
188
- .end("<html><body><h1>xAI authorization received.</h1>You can close this tab.</body></html>", () =>
189
- accept(parsed),
190
- );
191
- });
192
- const listen = (port: number) =>
193
- new Promise<number>((resolve, rejectListen) => {
194
- server.once("error", rejectListen);
195
- server.listen(port, XAI_OAUTH_CALLBACK_HOST, () => {
196
- server.removeListener("error", rejectListen);
197
- const address = server.address();
198
- if (!address || typeof address === "string") rejectListen(new Error("Could not determine callback port"));
199
- else resolve(address.port);
200
- });
201
- });
202
- let port: number;
203
- try {
204
- port = await listen(XAI_OAUTH_CALLBACK_PORT);
205
- } catch {
206
- port = await listen(0);
207
- }
208
- return {
209
- redirectUri: `http://${XAI_OAUTH_CALLBACK_HOST}:${port}${XAI_OAUTH_CALLBACK_PATH}`,
210
- acceptManual(input) {
211
- try {
212
- const value = input.trim();
213
- const url = value.startsWith("http")
214
- ? new URL(value)
215
- : new URL(`http://${XAI_OAUTH_CALLBACK_HOST}${XAI_OAUTH_CALLBACK_PATH}?${value.replace(/^\?/, "")}`);
216
- if (url.pathname !== XAI_OAUTH_CALLBACK_PATH) return "Callback URL path was not recognized";
217
- const parsed = parse(url.searchParams);
218
- if (!parsed) return "Callback state did not match";
219
- accept(parsed);
220
- return undefined;
221
- } catch {
222
- return "Callback URL was invalid";
223
- }
224
- },
225
- async wait(signal) {
226
- const timeout = setTimeout(() => {
227
- if (!settled) {
228
- settled = true;
229
- reject?.(new Error("Timed out waiting for xAI OAuth callback"));
230
- }
231
- }, LOGIN_TIMEOUT_MS);
232
- const onAbort = () => {
233
- if (!settled) {
234
- settled = true;
235
- reject?.(new Error("xAI OAuth login was cancelled"));
236
- }
237
- };
238
- signal?.addEventListener("abort", onAbort, { once: true });
239
- try {
240
- return await result;
241
- } finally {
242
- clearTimeout(timeout);
243
- signal?.removeEventListener("abort", onAbort);
244
- await closeServer(server);
245
- }
246
- },
247
- close: () => closeServer(server),
248
- };
249
- }
250
-
251
- async function refreshXaiCredentials(value: OAuthCredentials): Promise<OAuthCredentials> {
252
- if (!value.refresh) throw new Error("xAI OAuth credential cannot be refreshed; run /login again");
253
- const endpoint =
254
- typeof value.tokenEndpoint === "string" && value.tokenEndpoint
255
- ? validatedEndpoint(value.tokenEndpoint, "token_endpoint")
256
- : (await discover()).token_endpoint;
257
- const payload = await tokenRequest(
258
- endpoint,
259
- new URLSearchParams({
260
- grant_type: "refresh_token",
261
- client_id: XAI_OAUTH_CLIENT_ID,
262
- refresh_token: value.refresh,
263
- }),
264
- );
265
- return credentials(payload, endpoint, value.refresh);
266
- }
267
-
268
- export const xaiOAuth = {
269
- name: "xAI (Grok subscription)",
270
- usesCallbackServer: true,
271
- async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
272
- const existing = await readGrokCredentials();
273
- if (existing) {
274
- const method = await callbacks.onSelect({
275
- message: "Select xAI login method:",
276
- options: [
277
- { id: "browser", label: "Browser login" },
278
- { id: "existing", label: "Use existing Grok CLI login" },
279
- ],
280
- });
281
- if (!method) throw new Error("Login cancelled");
282
- if (method === "existing") {
283
- if (existing.expires > Date.now()) return existing;
284
- try {
285
- return await refreshXaiCredentials(existing);
286
- } catch {
287
- callbacks.onProgress?.("The existing Grok CLI login could not be refreshed. Starting browser login.");
288
- }
289
- }
290
- }
291
- const discovery = await discover(callbacks.signal);
292
- const verifier = randomBytes(32).toString("base64url");
293
- const challenge = createHash("sha256").update(verifier).digest("base64url");
294
- const state = randomBytes(24).toString("base64url");
295
- const nonce = randomBytes(24).toString("base64url");
296
- const callback = await callbackServer(state);
297
- try {
298
- const url = new URL(discovery.authorization_endpoint);
299
- url.search = new URLSearchParams({
300
- response_type: "code",
301
- client_id: XAI_OAUTH_CLIENT_ID,
302
- redirect_uri: callback.redirectUri,
303
- scope: XAI_OAUTH_SCOPE,
304
- code_challenge: challenge,
305
- code_challenge_method: "S256",
306
- state,
307
- nonce,
308
- }).toString();
309
- callbacks.onAuth({ url: url.toString(), instructions: "Authorize xAI in your browser, then return to Tau." });
310
- if (callbacks.onManualCodeInput) {
311
- void callbacks
312
- .onManualCodeInput()
313
- .then((input) => {
314
- const error = callback.acceptManual(input);
315
- if (error) callbacks.onProgress?.(`Ignored pasted callback: ${error}`);
316
- })
317
- .catch(() => undefined);
318
- }
319
- const result = await callback.wait(callbacks.signal);
320
- if (result.error) throw new Error(`xAI authorization failed: ${result.errorDescription ?? result.error}`);
321
- if (!result.code) throw new Error("xAI authorization did not return a code");
322
- const payload = await tokenRequest(
323
- discovery.token_endpoint,
324
- new URLSearchParams({
325
- grant_type: "authorization_code",
326
- client_id: XAI_OAUTH_CLIENT_ID,
327
- code: result.code,
328
- redirect_uri: callback.redirectUri,
329
- code_verifier: verifier,
330
- }),
331
- callbacks.signal,
332
- );
333
- return credentials(payload, discovery.token_endpoint, "", nonce);
334
- } finally {
335
- await callback.close();
336
- }
337
- },
338
- refreshToken: refreshXaiCredentials,
339
- getApiKey(value: OAuthCredentials): string {
340
- return value.access;
341
- },
342
- };
@@ -1,68 +0,0 @@
1
- function isRecord(value: unknown): value is Record<string, unknown> {
2
- return typeof value === "object" && value !== null && !Array.isArray(value);
3
- }
4
-
5
- function contentText(value: unknown): string {
6
- if (typeof value === "string") return value;
7
- if (!Array.isArray(value)) return "";
8
- return value
9
- .map((part) => {
10
- if (!isRecord(part)) return "";
11
- return typeof part.text === "string" ? part.text : "";
12
- })
13
- .filter(Boolean)
14
- .join("\n");
15
- }
16
-
17
- function normalizeToolOutput(item: Record<string, unknown>): unknown[] {
18
- if (item.type !== "function_call_output" || !Array.isArray(item.output)) return [item];
19
- const images = item.output.filter((part) => isRecord(part) && part.type === "input_image");
20
- if (images.length === 0) return [item];
21
- const text = contentText(item.output) || "(tool returned image output)";
22
- return [
23
- { ...item, output: text },
24
- {
25
- role: "user",
26
- content: [
27
- { type: "input_text", text: "The previous tool result included image output. Use the attached image." },
28
- ...images,
29
- ],
30
- },
31
- ];
32
- }
33
-
34
- export function rewriteXaiPayload(value: unknown): unknown {
35
- if (!isRecord(value)) return value;
36
- const body = { ...value };
37
- delete body.prompt_cache_retention;
38
- if (isRecord(body.reasoning)) {
39
- const effort = body.reasoning.effort;
40
- body.reasoning =
41
- typeof effort === "string" && effort !== "none"
42
- ? { effort: effort === "minimal" ? "low" : effort }
43
- : undefined;
44
- }
45
- if (Array.isArray(body.input)) {
46
- const instructions: string[] = [];
47
- const input: unknown[] = [];
48
- for (const raw of body.input) {
49
- if (!isRecord(raw)) {
50
- input.push(raw);
51
- continue;
52
- }
53
- if ((raw.role === "developer" || raw.role === "system") && input.length === 0) {
54
- const text = contentText(raw.content).trim();
55
- if (text) instructions.push(text);
56
- continue;
57
- }
58
- input.push(...normalizeToolOutput(raw));
59
- }
60
- body.input = input;
61
- if (instructions.length > 0) {
62
- body.instructions = [typeof body.instructions === "string" ? body.instructions : "", ...instructions]
63
- .filter(Boolean)
64
- .join("\n\n");
65
- }
66
- }
67
- return body;
68
- }