@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.
- package/extensions/attention/README.md +1 -1
- package/extensions/attention/index.ts +1 -1
- package/extensions/context/README.md +3 -2
- package/extensions/context/definitions.ts +19 -3
- package/extensions/context/index.ts +24 -8
- package/extensions/context/panel.ts +31 -14
- package/extensions/context/sync.ts +31 -15
- package/extensions/context/validation.ts +2 -2
- package/extensions/image-gen/README.md +2 -2
- package/extensions/image-gen/client.ts +1 -1
- package/extensions/image-gen/constants.ts +3 -0
- package/extensions/image-gen/index.ts +3 -3
- package/extensions/run-summary/README.md +1 -1
- package/extensions/run-summary/index.ts +13 -6
- package/extensions/silent-command-runner/index.ts +16 -2
- package/extensions/subagent/run.ts +0 -1
- package/extensions/tau-help/help.md +2 -6
- package/package.json +2 -2
- package/extensions/xai/README.md +0 -7
- package/extensions/xai/auth.ts +0 -40
- package/extensions/xai/constants.ts +0 -11
- package/extensions/xai/index.ts +0 -38
- package/extensions/xai/oauth.ts +0 -342
- package/extensions/xai/payload.ts +0 -68
|
@@ -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
|
|
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("
|
|
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
|
|
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.
|
|
140
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
77
|
-
|
|
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 (
|
|
83
|
-
this.fileOffset = Math.min(
|
|
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
|
|
126
|
-
const
|
|
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
|
|
130
|
+
const visible = paths.slice(this.fileOffset, this.fileOffset + pageSize);
|
|
129
131
|
const range =
|
|
130
|
-
|
|
132
|
+
paths.length > pageSize
|
|
131
133
|
? this.theme.fg(
|
|
132
134
|
"dim",
|
|
133
|
-
`${this.fileOffset + 1}-${this.fileOffset +
|
|
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
|
-
...
|
|
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
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
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
|
|
331
|
+
memberships: entries
|
|
332
|
+
.filter((entry) => contextEntryPaths(entry).includes(file.path))
|
|
333
|
+
.map((entry) => entry.id),
|
|
331
334
|
oldMemberships: file.oldPath
|
|
332
|
-
? entries
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
xAI controls model availability and subscription entitlements. A successful login does not guarantee that every account can use Grok Imagine.
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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",
|
|
111
|
-
|
|
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
|
|
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.
|
|
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.
|
|
31
|
+
"@shanepadgett/tau-tui": "0.15.0",
|
|
32
32
|
"@toon-format/toon": "2.3.0",
|
|
33
33
|
"smol-toml": "1.7.0"
|
|
34
34
|
},
|
package/extensions/xai/README.md
DELETED
|
@@ -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.
|
package/extensions/xai/auth.ts
DELETED
|
@@ -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";
|
package/extensions/xai/index.ts
DELETED
|
@@ -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
|
-
}
|
package/extensions/xai/oauth.ts
DELETED
|
@@ -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
|
-
}
|