@shanepadgett/tau-agent 0.13.0 → 0.15.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
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  Context stores reusable repository work scopes in `.pi/contexts`. Folder names become selector tabs, TOML files become concepts, and TOML sections become selectable entries.
4
4
 
5
- Use `/context` to select entries and inject their files through Tau autoread. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
5
+ Use `/context` to select entries. Entry `files` are injected through Tau autoread. Entry `anchors` supply lazy navigation paths that the agent can grep or read in ranges when needed. Use `/context-sync` to reconcile affected scopes from the current Git changes. Tau validates both file classes as context membership after agent turns and asks the agent to sync uncovered changed files or stale references automatically.
6
6
 
7
7
  Validation is disabled by default. Enable it globally or per project in Tau settings:
8
8
 
@@ -30,6 +30,7 @@ files = ["src/player/input.ts"]
30
30
  [movement]
31
31
  description = "Player locomotion and collision"
32
32
  files = ["src/player/movement.ts"]
33
+ anchors = ["src/runtime/fetch-handler.ts"]
33
34
  ```
34
35
 
35
- For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries.
36
+ For example, `.pi/contexts/gameplay/player.toml` appears in the `gameplay` tab as the `Player` concept with `input` and `movement` entries. When `movement` is selected, Tau autoreads `src/player/movement.ts` and lists `src/runtime/fetch-handler.ts` as an unloaded anchor. If one selected entry marks a path as a file and another marks it as an anchor, autoread wins.
@@ -13,6 +13,7 @@ export interface ContextEntry {
13
13
  name: string;
14
14
  description: string;
15
15
  files: string[];
16
+ anchors: string[];
16
17
  path: string;
17
18
  }
18
19
 
@@ -36,6 +37,7 @@ const CONTEXT_IGNORED_FILENAMES = new Set([
36
37
  "uv.lock",
37
38
  "yarn.lock",
38
39
  ]);
40
+ const CONTEXT_ENTRY_FIELDS = new Set(["description", "files", "anchors"]);
39
41
 
40
42
  export function isContextEligiblePath(path: string, ignoreGlobs: readonly string[] = []): boolean {
41
43
  return (
@@ -97,6 +99,10 @@ function sortedUnique(values: readonly string[]): string[] {
97
99
  return [...new Set(values)].sort((a, b) => a.localeCompare(b));
98
100
  }
99
101
 
102
+ export function contextEntryPaths(entry: Pick<ContextEntry, "files" | "anchors">): string[] {
103
+ return sortedUnique([...entry.files, ...entry.anchors]);
104
+ }
105
+
100
106
  export async function requireFiles(root: string, inputs: readonly string[]): Promise<string[]> {
101
107
  const files = sortedUnique(inputs.map((input) => normalizeProjectPath(root, input)));
102
108
  for (const file of files) {
@@ -132,15 +138,24 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
132
138
  if (!value || typeof value !== "object" || Array.isArray(value))
133
139
  throw new Error(`Invalid context entry: ${path} [${name}]`);
134
140
  const record = value as Record<string, unknown>;
141
+ const unknownField = Object.keys(record).find((field) => !CONTEXT_ENTRY_FIELDS.has(field));
142
+ if (unknownField) throw new Error(`Invalid context entry field: ${path} [${name}] ${unknownField}`);
143
+ const anchors = record.anchors ?? [];
135
144
  if (
136
145
  typeof record.description !== "string" ||
137
146
  !record.description.trim() ||
138
147
  !Array.isArray(record.files) ||
139
- record.files.length === 0 ||
140
- record.files.some((item) => typeof item !== "string")
148
+ record.files.some((item) => typeof item !== "string") ||
149
+ !Array.isArray(anchors) ||
150
+ anchors.some((item) => typeof item !== "string") ||
151
+ (record.files.length === 0 && anchors.length === 0)
141
152
  )
142
153
  throw new Error(`Invalid context entry: ${path} [${name}]`);
143
154
  const entry = validSlug(name, "Context entry");
155
+ const entryFiles = sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item)));
156
+ const entryAnchors = sortedUnique((anchors as string[]).map((item) => normalizeProjectPath(root, item)));
157
+ const overlap = entryFiles.find((item) => entryAnchors.includes(item));
158
+ if (overlap) throw new Error(`Context path cannot be both file and anchor: ${path} [${name}] ${overlap}`);
144
159
  result.push({
145
160
  id: `${tab}/${concept}/${entry}`,
146
161
  tab,
@@ -149,7 +164,8 @@ export async function loadContextEntries(root: string): Promise<ContextEntry[]>
149
164
  conceptDescription,
150
165
  name: entry,
151
166
  description: record.description.trim(),
152
- files: sortedUnique((record.files as string[]).map((item) => normalizeProjectPath(root, item))),
167
+ files: entryFiles,
168
+ anchors: entryAnchors,
153
169
  path,
154
170
  });
155
171
  }
@@ -53,19 +53,35 @@ export default function contextExtension(pi: ExtensionAPI): void {
53
53
  );
54
54
  if (!selected?.length) return;
55
55
  const files = [...new Set(selected.flatMap((entry) => entry.files))].sort();
56
+ const fileSet = new Set(files);
57
+ const anchors = [...new Set(selected.flatMap((entry) => entry.anchors))]
58
+ .filter((path) => !fileSet.has(path))
59
+ .sort();
56
60
  pi.sendMessage(
57
61
  createInjectedContext(
58
- "Treat the autoread files as the authoritative project context and current snapshots. Do not reread them or search for coverage around them. Start work from them immediately. Explore outside them only when the user's request or concrete evidence in those files requires missing code or information.",
62
+ [
63
+ "Selected repository context:",
64
+ ...selected.map((entry) => `- ${entry.id}: ${entry.description}`),
65
+ "",
66
+ "Eager snapshots supplied through autoread:",
67
+ ...(files.length ? files.map((path) => `- ${path}`) : ["(none)"]),
68
+ "",
69
+ "Lazy navigation anchors whose contents have not been loaded:",
70
+ ...(anchors.length ? anchors.map((path) => `- ${path}`) : ["(none)"]),
71
+ "",
72
+ "Treat eager snapshots as authoritative current project context. Do not reread them or search for coverage around them. Inspect only the anchors needed for the request, using grep or bounded reads. Explore elsewhere only when the request or concrete evidence requires missing information.",
73
+ ].join("\n"),
59
74
  { source: "context", title: "Project context" },
60
75
  ),
61
76
  );
62
- emitTauEvent(pi, "tau:autoread.requested", {
63
- source: "context",
64
- title: "Project context",
65
- cwd: root,
66
- batchId: randomUUID(),
67
- files: files.map((path) => ({ path })),
68
- });
77
+ if (files.length)
78
+ emitTauEvent(pi, "tau:autoread.requested", {
79
+ source: "context",
80
+ title: "Project context",
81
+ cwd: root,
82
+ batchId: randomUUID(),
83
+ files: files.map((path) => ({ path })),
84
+ });
69
85
  },
70
86
  });
71
87
 
@@ -73,14 +73,15 @@ export class ContextPanel implements Component {
73
73
  const list = this.activeList();
74
74
  if (!list?.isFilterFocused()) {
75
75
  const keys = getKeybindings();
76
- const pageSize = this.filePageSize();
77
- if (this.current.files.length > pageSize && matchesKey(data, Key.alt("up"))) {
76
+ const paths = this.currentPaths();
77
+ const pageSize = this.pathPageSize();
78
+ if (paths.length > pageSize && matchesKey(data, Key.alt("up"))) {
78
79
  this.fileOffset = Math.max(0, this.fileOffset - pageSize);
79
80
  this.sync();
80
81
  return;
81
82
  }
82
- if (this.current.files.length > pageSize && matchesKey(data, Key.alt("down"))) {
83
- this.fileOffset = Math.min(this.current.files.length - pageSize, this.fileOffset + pageSize);
83
+ if (paths.length > pageSize && matchesKey(data, Key.alt("down"))) {
84
+ this.fileOffset = Math.min(paths.length - pageSize, this.fileOffset + pageSize);
84
85
  this.sync();
85
86
  return;
86
87
  }
@@ -122,35 +123,51 @@ export class ContextPanel implements Component {
122
123
  return {
123
124
  render: (width) => {
124
125
  const tabs = this.tabs.render(width);
125
- const pageSize = this.filePageSize(tabs.length);
126
- const maxOffset = Math.max(0, this.current.files.length - pageSize);
126
+ const paths = this.currentPaths();
127
+ const pageSize = this.pathPageSize(tabs.length);
128
+ const maxOffset = Math.max(0, paths.length - pageSize);
127
129
  this.fileOffset = Math.min(this.fileOffset, maxOffset);
128
- const files = this.current.files.slice(this.fileOffset, this.fileOffset + pageSize);
130
+ const visible = paths.slice(this.fileOffset, this.fileOffset + pageSize);
129
131
  const range =
130
- this.current.files.length > pageSize
132
+ paths.length > pageSize
131
133
  ? this.theme.fg(
132
134
  "dim",
133
- `${this.fileOffset + 1}-${this.fileOffset + files.length} of ${this.current.files.length} files`,
135
+ `${this.fileOffset + 1}-${this.fileOffset + visible.length} of ${paths.length} paths`,
134
136
  )
135
137
  : undefined;
136
138
  return [
137
139
  ...tabs,
138
140
  "",
139
141
  ...(range ? [truncateToWidth(range, width, "…")] : []),
140
- ...files.map((file) => truncateToWidth(this.theme.fg("muted", `• ${file}`), width, "…")),
142
+ ...visible.map(({ kind, path }) =>
143
+ truncateToWidth(
144
+ this.theme.fg(
145
+ kind === "read" ? "muted" : "dim",
146
+ `${kind === "read" ? "read" : "anchor"} • ${path}`,
147
+ ),
148
+ width,
149
+ "…",
150
+ ),
151
+ ),
141
152
  ];
142
153
  },
143
154
  invalidate: () => this.tabs.invalidate(),
144
155
  };
145
156
  }
146
- private filePageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
157
+ private currentPaths(): Array<{ kind: "read" | "anchor"; path: string }> {
158
+ return [
159
+ ...this.current.files.map((path) => ({ kind: "read" as const, path })),
160
+ ...this.current.anchors.map((path) => ({ kind: "anchor" as const, path })),
161
+ ];
162
+ }
163
+ private pathPageSize(tabLines = this.tabs.render(this.tui.terminal.columns).length): number {
147
164
  const overlayHeight = Math.floor(this.tui.terminal.rows * 0.8);
148
165
  const available = Math.max(1, overlayHeight - tabLines - 7);
149
166
  const pageSize = Math.min(8, available);
150
- return this.current.files.length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
167
+ return this.currentPaths().length > pageSize ? Math.max(1, pageSize - 1) : pageSize;
151
168
  }
152
169
  private secondary(): string {
153
- return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected`;
170
+ return `${[...this.selected.values()].reduce((sum, items) => sum + items.length, 0)} selected · ${this.current.files.length} read · ${this.current.anchors.length} anchors`;
154
171
  }
155
172
  private hints() {
156
173
  const list = this.activeList();
@@ -158,7 +175,7 @@ export class ContextPanel implements Component {
158
175
  ? list.getKeyHints()
159
176
  : [
160
177
  ...this.tabs.getKeyHints(),
161
- ...(this.current.files.length > this.filePageSize() ? [rawHint("option+↑/↓", "scroll files")] : []),
178
+ ...(this.currentPaths().length > this.pathPageSize() ? [rawHint("option+↑/↓", "scroll paths")] : []),
162
179
  rawHint("ctrl+c", "clear all"),
163
180
  bindingHint("tui.select.confirm", "inject"),
164
181
  bindingHint("tui.select.cancel", "cancel"),
@@ -8,8 +8,8 @@ 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 {
12
+ contextEntryPaths,
13
13
  isContextEligiblePath,
14
14
  isSensitiveContextPath,
15
15
  loadContextEntries,
@@ -31,7 +31,7 @@ const CONTEXT_SYNC_MODELS: ReadonlyArray<{ provider: string; model: string; reas
31
31
  { provider: "openai-codex", model: "gpt-5.6-terra", reasoning: "medium" },
32
32
  { provider: "openai-codex", model: "gpt-5.6-sol", reasoning: "low" },
33
33
  { provider: "anthropic", model: "claude-sonnet-5", reasoning: "low" },
34
- { provider: XAI_PROVIDER, model: XAI_CHAT_MODEL, reasoning: "high" },
34
+ { provider: "xai", model: "grok-4.5", reasoning: "high" },
35
35
  ];
36
36
 
37
37
  const SUBMIT_TOOL = {
@@ -216,13 +216,14 @@ async function collectSyncEvidence(
216
216
  for (const path of dependencies) if (!isContextEligiblePath(path, ignoreGlobs)) dependencies.delete(path);
217
217
  const missingPaths = new Set<string>();
218
218
  for (const entry of entries)
219
- for (const path of entry.files)
219
+ for (const path of contextEntryPaths(entry))
220
220
  if (isContextEligiblePath(path, ignoreGlobs) && !(await isFile(join(root, path)))) missingPaths.add(path);
221
221
  const affectedIds = new Set<string>();
222
222
  for (const entry of entries) {
223
+ const paths = contextEntryPaths(entry);
223
224
  if (
224
- entry.files.some((path) => missingPaths.has(path)) ||
225
- entry.files.some(
225
+ paths.some((path) => missingPaths.has(path)) ||
226
+ paths.some(
226
227
  (path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
227
228
  )
228
229
  )
@@ -231,7 +232,7 @@ async function collectSyncEvidence(
231
232
  const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
232
233
  const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
233
234
  const siblingFiles = siblingEntries
234
- .flatMap((entry) => entry.files)
235
+ .flatMap((entry) => contextEntryPaths(entry))
235
236
  .filter((path) => !missingPaths.has(path) && isContextEligiblePath(path, ignoreGlobs));
236
237
  const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
237
238
  const structuralPreviews = new Map<string, string>();
@@ -327,9 +328,13 @@ async function collectDirtyFiles(
327
328
  sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
328
329
  ...file,
329
330
  id: offset + inner + 1,
330
- memberships: entries.filter((entry) => entry.files.includes(file.path)).map((entry) => entry.id),
331
+ memberships: entries
332
+ .filter((entry) => contextEntryPaths(entry).includes(file.path))
333
+ .map((entry) => entry.id),
331
334
  oldMemberships: file.oldPath
332
- ? entries.filter((entry) => entry.files.includes(file.oldPath ?? "")).map((entry) => entry.id)
335
+ ? entries
336
+ .filter((entry) => contextEntryPaths(entry).includes(file.oldPath ?? ""))
337
+ .map((entry) => entry.id)
333
338
  : [],
334
339
  evidence: await dirtyEvidence(git, root, file),
335
340
  })),
@@ -431,7 +436,9 @@ async function isFile(path: string): Promise<boolean> {
431
436
 
432
437
  function buildContextSyncPrompt(evidence: SyncEvidence): string {
433
438
  const stale = evidence.entries.flatMap((entry) =>
434
- entry.files.filter((path) => evidence.missingPaths.has(path)).map((path) => `${entry.id}: ${path}`),
439
+ contextEntryPaths(entry)
440
+ .filter((path) => evidence.missingPaths.has(path))
441
+ .map((path) => `${entry.id}: ${path}`),
435
442
  );
436
443
  const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
437
444
  const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
@@ -441,6 +448,7 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
441
448
  "Call submit_context_sync exactly once and produce no prose response.",
442
449
  "Return no-change only when every eligible changed file has context membership and no stale catalog paths remain.",
443
450
  "Context entries are reusable work scopes, not inventories of every touched file. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.",
451
+ "The files field in your changes is desired membership, including eager files and lazy anchors. Paths already classified anywhere in the catalog preserve their loading class; new paths become eager files. Do not change loading policy.",
444
452
  "Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
445
453
  "Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
446
454
  "Every eligible changed file must belong to at least one entry. Remove every stale catalog path. Use only supplied candidate paths.",
@@ -457,8 +465,9 @@ function buildContextSyncPrompt(evidence: SyncEvidence): string {
457
465
  conceptName: entry.conceptName,
458
466
  conceptDescription: entry.conceptDescription,
459
467
  description: entry.description,
460
- files: entry.files,
461
- missingFiles: entry.files.filter((path) => evidence.missingPaths.has(path)),
468
+ eagerFiles: entry.files,
469
+ anchors: entry.anchors,
470
+ missingFiles: contextEntryPaths(entry).filter((path) => evidence.missingPaths.has(path)),
462
471
  })),
463
472
  ),
464
473
  "Changed files with no membership:",
@@ -540,7 +549,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
540
549
  throw new Error(`Entry is unrelated: ${id}`);
541
550
  if (conceptName !== existing.conceptName || conceptDescription !== existing.conceptDescription)
542
551
  throw new Error(`Existing concept metadata cannot change: ${conceptId}`);
543
- if (description === existing.description && files.join("\0") === existing.files.join("\0"))
552
+ if (description === existing.description && files.join("\0") === contextEntryPaths(existing).join("\0"))
544
553
  throw new Error(`Set-entry is identical: ${id}`);
545
554
  } else {
546
555
  const existingConcept = evidence.entries.find((item) => item.tab === tab && item.concept === concept);
@@ -560,7 +569,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
560
569
  }
561
570
  changes.push({ action: "set-entry", tab, concept, conceptName, conceptDescription, entry, description, files });
562
571
  }
563
- const final = new Map(evidence.entries.map((entry) => [entry.id, [...entry.files]]));
572
+ const final = new Map(evidence.entries.map((entry) => [entry.id, contextEntryPaths(entry)]));
564
573
  for (const change of changes) {
565
574
  const id = `${change.tab}/${change.concept}/${change.entry}`;
566
575
  if (change.action === "delete-entry") final.delete(id);
@@ -571,7 +580,7 @@ export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence)
571
580
  )) {
572
581
  const old = current.get(`${change.tab}/${change.concept}/${change.entry}`);
573
582
  if (!old) continue;
574
- for (const path of old.files)
583
+ for (const path of contextEntryPaths(old))
575
584
  if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
576
585
  throw new Error(`Deleting entry would orphan surviving file: ${path}`);
577
586
  }
@@ -640,6 +649,8 @@ export async function applyContextSyncPlan(
640
649
  ): Promise<ContextSyncDetails> {
641
650
  for (const change of plan.changes) if (change.action === "set-entry") await requireFiles(root, change.files);
642
651
  const concepts = [...new Set(plan.changes.map((change) => `${change.tab}/${change.concept}`))].sort();
652
+ const eagerPaths = new Set(entries.flatMap((entry) => entry.files));
653
+ const anchorPaths = new Set(entries.flatMap((entry) => entry.anchors).filter((path) => !eagerPaths.has(path)));
643
654
  const outputs = new Map<string, string | undefined>();
644
655
  for (const key of concepts) {
645
656
  const [tab, concept] = key.split("/");
@@ -653,7 +664,12 @@ export async function applyContextSyncPlan(
653
664
  else {
654
665
  if (raw.name === undefined) raw.name = change.conceptName;
655
666
  if (raw.description === undefined) raw.description = change.conceptDescription;
656
- raw[change.entry] = { description: change.description, files: change.files };
667
+ const anchors = change.files.filter((path) => anchorPaths.has(path));
668
+ raw[change.entry] = {
669
+ description: change.description,
670
+ files: change.files.filter((path) => !anchors.includes(path)),
671
+ ...(anchors.length ? { anchors } : {}),
672
+ };
657
673
  }
658
674
  }
659
675
  outputs.set(
@@ -1,7 +1,7 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import type { GitRunner } from "../../shared/git.ts";
4
- import { isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
4
+ import { contextEntryPaths, isContextEligiblePath, isSensitiveContextPath, loadContextEntries } from "./definitions.ts";
5
5
 
6
6
  export interface ContextValidationResult {
7
7
  stale: Array<{ path: string; ids: string[] }>;
@@ -16,7 +16,7 @@ export async function validateContextCatalog(
16
16
  const entries = await loadContextEntries(root);
17
17
  const memberships = new Map<string, string[]>();
18
18
  for (const entry of entries)
19
- for (const file of entry.files) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
19
+ for (const file of contextEntryPaths(entry)) memberships.set(file, [...(memberships.get(file) ?? []), entry.id]);
20
20
 
21
21
  const stale: Array<{ path: string; ids: string[] }> = [];
22
22
  for (const [path, ids] of memberships) {
@@ -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,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,
@@ -28,7 +28,7 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
28
28
 
29
29
  ## context
30
30
 
31
- Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Context validation is disabled by default; when enabled, Tau checks changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Entry `files` are autoread; entry `anchors` are unloaded navigation paths the agent can inspect when needed. Context validation is disabled by default; when enabled, Tau checks both classes for changed-file membership and stale references after agent turns. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
32
 
33
33
  ## explore
34
34
 
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.13.0",
3
+ "version": "0.15.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.13.0",
31
+ "@shanepadgett/tau-tui": "0.15.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
- }