@shanepadgett/tau-agent 0.28.1 → 0.30.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/docs/context.md +14 -9
- package/docs/subagents.md +7 -5
- package/extensions/cache-diagnostics/index.ts +4 -5
- package/extensions/context/README.md +8 -5
- package/extensions/context/definitions.ts +28 -17
- package/extensions/context/evidence.ts +4 -3
- package/extensions/context/index.ts +57 -35
- package/extensions/context/panel.ts +10 -9
- package/extensions/context/projection.ts +141 -0
- package/extensions/context/state.ts +30 -0
- package/extensions/explore/README.md +1 -1
- package/extensions/explore/ast/read/hook.ts +5 -35
- package/extensions/explore/ast/read/policy.ts +0 -32
- package/extensions/explore/index.ts +2 -0
- package/extensions/explore/outline-injection.ts +151 -0
- package/extensions/explore/settings.ts +0 -8
- package/extensions/ideas/browser.ts +27 -16
- package/extensions/review/README.md +11 -0
- package/extensions/review/index.ts +135 -0
- package/extensions/review/model.ts +144 -0
- package/extensions/review/panel.ts +128 -0
- package/extensions/review/session.ts +106 -0
- package/extensions/runtime-context/README.md +1 -1
- package/extensions/runtime-context/index.ts +3 -66
- package/extensions/script-runner/README.md +7 -0
- package/extensions/script-runner/index.ts +275 -0
- package/extensions/stash/browser.ts +30 -18
- package/extensions/subagent/README.md +17 -4
- package/extensions/subagent/agents/context-sync.md +2 -2
- package/extensions/subagent/agents/scout.md +49 -52
- package/extensions/subagent/agents.ts +0 -1
- package/extensions/subagent/cmux-dashboard.ts +7 -3
- package/extensions/subagent/index.ts +105 -4
- package/extensions/subagent/panel.ts +124 -0
- package/extensions/subagent/render.ts +2 -1
- package/extensions/subagent/run.ts +9 -9
- package/extensions/subagent/runtime.ts +5 -5
- package/extensions/subagent/session-resource.ts +19 -127
- package/extensions/subagent/settings.ts +18 -0
- package/extensions/tau-help/help.md +12 -8
- package/extensions/working-memory/README.md +9 -0
- package/extensions/working-memory/checkpoint.ts +175 -0
- package/extensions/working-memory/index.ts +338 -0
- package/extensions/working-memory/memory.ts +266 -0
- package/extensions/working-memory/render.ts +178 -0
- package/extensions/working-memory/settings.ts +38 -0
- package/extensions/working-memory/state.ts +152 -0
- package/package.json +2 -2
- package/schemas/tau.schema.json +35 -55
- package/shared/context-messages.ts +19 -0
- package/shared/events.ts +5 -0
- package/shared/full-file-knowledge.ts +0 -1
- package/shared/injected-context.ts +2 -2
- package/shared/isolated-session.ts +151 -0
- package/shared/outline-injection.ts +56 -0
- package/extensions/context-pruning/README.md +0 -39
- package/extensions/context-pruning/index.ts +0 -382
- package/extensions/context-pruning/projection.ts +0 -60
- package/extensions/context-pruning/prune.ts +0 -199
- package/extensions/context-pruning/render.ts +0 -251
- package/extensions/context-pruning/settings.ts +0 -39
- package/extensions/subagent/agents/review.md +0 -68
- package/extensions/turn-budget/README.md +0 -14
- package/extensions/turn-budget/index.ts +0 -116
- package/extensions/turn-budget/settings.ts +0 -35
- package/shared/context-pruning-state.ts +0 -152
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { bindingHint, bindingsHint, rawHint, ToolPanel } from "@shanepadgett/tau-tui";
|
|
2
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Markdown, type Component, type KeybindingsManager, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { formatReviewMarkdown, reviewModeLabel, type ReviewMode, type ReviewRecord } from "./model.ts";
|
|
5
|
+
|
|
6
|
+
export class ReviewProgressPanel implements Component {
|
|
7
|
+
private readonly tui: TUI;
|
|
8
|
+
private readonly panel: ToolPanel;
|
|
9
|
+
private readonly lines: string[] = [];
|
|
10
|
+
|
|
11
|
+
constructor(tui: TUI, theme: Theme, mode: ReviewMode, keys: KeybindingsManager, cancel: () => void) {
|
|
12
|
+
this.tui = tui;
|
|
13
|
+
this.panel = new ToolPanel(theme, {
|
|
14
|
+
title: `${reviewModeLabel(mode)} review`,
|
|
15
|
+
secondary: "Fresh isolated review · hidden from parent context",
|
|
16
|
+
body: {
|
|
17
|
+
render: (width) =>
|
|
18
|
+
(this.lines.length ? this.lines.slice(-5) : ["Starting review"]).map((line) =>
|
|
19
|
+
truncateToWidth(theme.fg("muted", line), width, "…"),
|
|
20
|
+
),
|
|
21
|
+
invalidate() {},
|
|
22
|
+
},
|
|
23
|
+
footer: { kind: "hints", hints: [bindingHint("tui.select.cancel", "cancel")] },
|
|
24
|
+
border: "box",
|
|
25
|
+
});
|
|
26
|
+
this.handleInput = (data) => {
|
|
27
|
+
if (!keys.matches(data, "tui.select.cancel")) return;
|
|
28
|
+
cancel();
|
|
29
|
+
this.update("Cancelling review");
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
handleInput: (data: string) => void;
|
|
34
|
+
|
|
35
|
+
update(status: string): void {
|
|
36
|
+
if (this.lines.at(-1) !== status) {
|
|
37
|
+
this.lines.push(status);
|
|
38
|
+
if (this.lines.length > 5) this.lines.shift();
|
|
39
|
+
}
|
|
40
|
+
this.tui.requestRender();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(width: number): string[] {
|
|
44
|
+
return this.panel.render(width);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
invalidate(): void {
|
|
48
|
+
this.panel.invalidate();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ReviewResultAction = "send" | "export" | "close";
|
|
53
|
+
|
|
54
|
+
export class ReviewResultPanel implements Component {
|
|
55
|
+
private readonly panel: ToolPanel;
|
|
56
|
+
private readonly body: ReviewMarkdownBody;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
tui: TUI,
|
|
60
|
+
theme: Theme,
|
|
61
|
+
keys: KeybindingsManager,
|
|
62
|
+
review: ReviewRecord,
|
|
63
|
+
done: (action: ReviewResultAction) => void,
|
|
64
|
+
) {
|
|
65
|
+
this.body = new ReviewMarkdownBody(tui, formatReviewMarkdown(review));
|
|
66
|
+
this.panel = new ToolPanel(theme, {
|
|
67
|
+
title: `${reviewModeLabel(review.mode)} review · ${review.verdict}`,
|
|
68
|
+
secondary: `${review.findings.length} finding${review.findings.length === 1 ? "" : "s"} · not in parent context`,
|
|
69
|
+
body: this.body,
|
|
70
|
+
footer: {
|
|
71
|
+
kind: "hints",
|
|
72
|
+
hints: [
|
|
73
|
+
bindingsHint(["tui.select.up", "tui.select.down"], "scroll"),
|
|
74
|
+
rawHint("a", "send to agent"),
|
|
75
|
+
rawHint("e", "export"),
|
|
76
|
+
bindingHint("tui.select.cancel", "close"),
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
border: "box",
|
|
80
|
+
});
|
|
81
|
+
this.handleInput = (data) => {
|
|
82
|
+
if (keys.matches(data, "tui.select.up")) this.body.scroll(-1);
|
|
83
|
+
else if (keys.matches(data, "tui.select.down")) this.body.scroll(1);
|
|
84
|
+
else if (data === "a") done("send");
|
|
85
|
+
else if (data === "e") done("export");
|
|
86
|
+
else if (keys.matches(data, "tui.select.cancel")) done("close");
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
handleInput: (data: string) => void;
|
|
91
|
+
|
|
92
|
+
render(width: number): string[] {
|
|
93
|
+
return this.panel.render(width);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
invalidate(): void {
|
|
97
|
+
this.panel.invalidate();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class ReviewMarkdownBody implements Component {
|
|
102
|
+
private readonly tui: TUI;
|
|
103
|
+
private readonly markdown: Markdown;
|
|
104
|
+
private offset = 0;
|
|
105
|
+
private maxOffset = 0;
|
|
106
|
+
|
|
107
|
+
constructor(tui: TUI, content: string) {
|
|
108
|
+
this.tui = tui;
|
|
109
|
+
this.markdown = new Markdown(content, 0, 0, getMarkdownTheme());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
scroll(delta: number): void {
|
|
113
|
+
this.offset = Math.max(0, Math.min(this.maxOffset, this.offset + delta));
|
|
114
|
+
this.tui.requestRender();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
render(width: number): string[] {
|
|
118
|
+
const lines = this.markdown.render(width);
|
|
119
|
+
const height = Math.max(4, Math.floor(this.tui.terminal.rows * 0.9) - 9);
|
|
120
|
+
this.maxOffset = Math.max(0, lines.length - height);
|
|
121
|
+
this.offset = Math.min(this.offset, this.maxOffset);
|
|
122
|
+
return lines.slice(this.offset, this.offset + height);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
invalidate(): void {
|
|
126
|
+
this.markdown.invalidate();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { defineTool, type AgentSessionEvent, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
createIsolatedSessionResource,
|
|
6
|
+
resolveIsolatedSessionModel,
|
|
7
|
+
type IsolatedSessionResource,
|
|
8
|
+
} from "../../shared/isolated-session.ts";
|
|
9
|
+
import { buildReviewPrompt, REVIEW_RESULT_TOOL, type ReviewMode, type ReviewOutput } from "./model.ts";
|
|
10
|
+
|
|
11
|
+
const REVIEW_MODEL = "openai-codex/gpt-5.6-sol";
|
|
12
|
+
const REVIEW_TOOLS = [
|
|
13
|
+
"read",
|
|
14
|
+
"bash",
|
|
15
|
+
"outline",
|
|
16
|
+
"show",
|
|
17
|
+
"discover",
|
|
18
|
+
"ast_search",
|
|
19
|
+
"deps",
|
|
20
|
+
"reverse_deps",
|
|
21
|
+
"callers",
|
|
22
|
+
"callees",
|
|
23
|
+
"references",
|
|
24
|
+
"implementations",
|
|
25
|
+
"impact",
|
|
26
|
+
"context",
|
|
27
|
+
REVIEW_RESULT_TOOL.name,
|
|
28
|
+
];
|
|
29
|
+
const EXPLORE_EXTENSION = join(dirname(fileURLToPath(import.meta.url)), "..", "explore", "index.ts");
|
|
30
|
+
|
|
31
|
+
export async function runReview(options: {
|
|
32
|
+
ctx: ExtensionContext;
|
|
33
|
+
root: string;
|
|
34
|
+
mode: ReviewMode;
|
|
35
|
+
parentThinkingLevel: NonNullable<ExtensionContext["thinkingLevel"]>;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
onProgress: (status: string) => void;
|
|
38
|
+
}): Promise<ReviewOutput> {
|
|
39
|
+
const { ctx, root, mode, signal, onProgress } = options;
|
|
40
|
+
let output: ReviewOutput | undefined;
|
|
41
|
+
const outputTool = defineTool({
|
|
42
|
+
...REVIEW_RESULT_TOOL,
|
|
43
|
+
label: "Review Result",
|
|
44
|
+
async execute(_toolCallId, params) {
|
|
45
|
+
output = params;
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text" as const, text: "Review recorded" }],
|
|
48
|
+
details: params,
|
|
49
|
+
terminate: true,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
let resource: IsolatedSessionResource | undefined;
|
|
54
|
+
let unsubscribe: (() => void) | undefined;
|
|
55
|
+
try {
|
|
56
|
+
const selected = await resolveIsolatedSessionModel({
|
|
57
|
+
label: "Review",
|
|
58
|
+
preferredModel: REVIEW_MODEL,
|
|
59
|
+
preferredThinkingLevel: "high",
|
|
60
|
+
usePreferredThinkingAfterModelFallback: false,
|
|
61
|
+
ctx,
|
|
62
|
+
parentThinkingLevel: options.parentThinkingLevel,
|
|
63
|
+
signal,
|
|
64
|
+
onWarning: onProgress,
|
|
65
|
+
});
|
|
66
|
+
resource = await createIsolatedSessionResource(
|
|
67
|
+
{
|
|
68
|
+
...selected,
|
|
69
|
+
label: "Review",
|
|
70
|
+
extensionPaths: [EXPLORE_EXTENSION],
|
|
71
|
+
cwd: root,
|
|
72
|
+
tools: REVIEW_TOOLS,
|
|
73
|
+
customTools: [outputTool],
|
|
74
|
+
bindTarget: { mode: "print" },
|
|
75
|
+
},
|
|
76
|
+
signal,
|
|
77
|
+
);
|
|
78
|
+
const { session } = resource;
|
|
79
|
+
unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
80
|
+
if (event.type === "tool_execution_start") {
|
|
81
|
+
onProgress(`${event.toolName} ${summarizeArgs(event.args)}`.trim());
|
|
82
|
+
} else if (event.type === "tool_execution_end") {
|
|
83
|
+
onProgress(event.isError ? `${event.toolName} failed` : `${event.toolName} complete`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const abort = () => void session.abort().catch(() => undefined);
|
|
87
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
88
|
+
try {
|
|
89
|
+
onProgress(`Running ${mode} review`);
|
|
90
|
+
await session.prompt(buildReviewPrompt(root, mode), { expandPromptTemplates: false });
|
|
91
|
+
} finally {
|
|
92
|
+
signal.removeEventListener("abort", abort);
|
|
93
|
+
}
|
|
94
|
+
if (signal.aborted) throw new Error("Review cancelled");
|
|
95
|
+
if (!output) throw new Error("Review ended without structured output");
|
|
96
|
+
return output;
|
|
97
|
+
} finally {
|
|
98
|
+
unsubscribe?.();
|
|
99
|
+
await resource?.dispose();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function summarizeArgs(args: unknown): string {
|
|
104
|
+
const text = typeof args === "string" ? args : (JSON.stringify(args) ?? String(args));
|
|
105
|
+
return text.length > 140 ? `${text.slice(0, 139)}…` : text;
|
|
106
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Runtime Context
|
|
2
2
|
|
|
3
|
-
Supplies Tau with
|
|
3
|
+
Supplies Tau with current local date and initial root directory snapshot in each agent run's system prompt. Snapshot remains fixed until extension runtime restarts, while date updates when local day changes.
|
|
4
4
|
|
|
5
5
|
After changing this extension, run `/reload` before testing the new behavior.
|
|
@@ -1,22 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
|
-
fingerprintRuntimeSnapshot,
|
|
4
|
-
formatLocalDateKey,
|
|
5
3
|
formatLocalDisplayDate,
|
|
6
4
|
formatRuntimeContextMessage,
|
|
7
5
|
freezeRuntimeContext,
|
|
8
6
|
type RuntimeContext,
|
|
9
7
|
} from "./context.ts";
|
|
10
8
|
|
|
11
|
-
const RUNTIME_CONTEXT_TYPE = "tau.runtime-context";
|
|
12
|
-
|
|
13
|
-
interface RuntimeContextMessageDetails {
|
|
14
|
-
version: 1;
|
|
15
|
-
dateKey: string;
|
|
16
|
-
snapshotHash: string;
|
|
17
|
-
includesSnapshot: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
9
|
export default function runtimeContextExtension(pi: ExtensionAPI): void {
|
|
21
10
|
let runtimeContext: RuntimeContext | undefined;
|
|
22
11
|
|
|
@@ -24,61 +13,9 @@ export default function runtimeContextExtension(pi: ExtensionAPI): void {
|
|
|
24
13
|
runtimeContext = freezeRuntimeContext(ctx.cwd);
|
|
25
14
|
});
|
|
26
15
|
|
|
27
|
-
pi.on("before_agent_start", (
|
|
16
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
28
17
|
runtimeContext ??= freezeRuntimeContext(ctx.cwd);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const snapshotHash = fingerprintRuntimeSnapshot(runtimeContext);
|
|
32
|
-
let hasDate = false;
|
|
33
|
-
let hasSnapshot = false;
|
|
34
|
-
for (const entry of ctx.sessionManager.buildContextEntries()) {
|
|
35
|
-
const details = runtimeContextDetails(entry);
|
|
36
|
-
if (!details) continue;
|
|
37
|
-
if (details.dateKey === dateKey) hasDate = true;
|
|
38
|
-
if (details.includesSnapshot && details.snapshotHash === snapshotHash) hasSnapshot = true;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (hasDate && hasSnapshot) return undefined;
|
|
42
|
-
const includeSnapshot = !hasSnapshot;
|
|
43
|
-
return {
|
|
44
|
-
message: {
|
|
45
|
-
customType: RUNTIME_CONTEXT_TYPE,
|
|
46
|
-
content: formatRuntimeContextMessage(
|
|
47
|
-
formatLocalDisplayDate(now),
|
|
48
|
-
includeSnapshot ? runtimeContext.rootSnapshot : undefined,
|
|
49
|
-
),
|
|
50
|
-
display: false,
|
|
51
|
-
details: {
|
|
52
|
-
version: 1,
|
|
53
|
-
dateKey,
|
|
54
|
-
snapshotHash,
|
|
55
|
-
includesSnapshot: includeSnapshot,
|
|
56
|
-
} satisfies RuntimeContextMessageDetails,
|
|
57
|
-
},
|
|
58
|
-
};
|
|
18
|
+
const content = formatRuntimeContextMessage(formatLocalDisplayDate(new Date()), runtimeContext.rootSnapshot);
|
|
19
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
|
|
59
20
|
});
|
|
60
21
|
}
|
|
61
|
-
|
|
62
|
-
function runtimeContextDetails(value: unknown): RuntimeContextMessageDetails | undefined {
|
|
63
|
-
if (!value || typeof value !== "object") return undefined;
|
|
64
|
-
const entry = value as Record<string, unknown>;
|
|
65
|
-
if (entry.type !== "custom_message" || entry.customType !== RUNTIME_CONTEXT_TYPE || entry.display !== false) {
|
|
66
|
-
return undefined;
|
|
67
|
-
}
|
|
68
|
-
if (!entry.details || typeof entry.details !== "object") return undefined;
|
|
69
|
-
const details = entry.details as Record<string, unknown>;
|
|
70
|
-
if (
|
|
71
|
-
details.version !== 1 ||
|
|
72
|
-
typeof details.dateKey !== "string" ||
|
|
73
|
-
typeof details.snapshotHash !== "string" ||
|
|
74
|
-
typeof details.includesSnapshot !== "boolean"
|
|
75
|
-
) {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
return {
|
|
79
|
-
version: 1,
|
|
80
|
-
dateKey: details.dateKey,
|
|
81
|
-
snapshotHash: details.snapshotHash,
|
|
82
|
-
includesSnapshot: details.includesSnapshot,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Script Runner
|
|
2
|
+
|
|
3
|
+
Gives the agent a first-class `script_runner` tool for running Python and TypeScript instead of falling back to bash. The agent picks whichever language is more efficient for the task.
|
|
4
|
+
|
|
5
|
+
When a run fails, the tool keeps the script and returns a `scriptId`. The agent retries with targeted `{oldText, newText}` edits against what it just wrote instead of resending the whole script, saving output tokens and keeping duplicate scripts out of context. Only the source the agent already sent is referenced; no file path is exposed.
|
|
6
|
+
|
|
7
|
+
Languages are detected from the environment: Python via `python3`/`python`, TypeScript via Node with `--experimental-strip-types` (Node 22.6 or newer). The tool registers only the languages actually available and is hidden from the prompt entirely when neither is present.
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_MAX_BYTES,
|
|
9
|
+
DEFAULT_MAX_LINES,
|
|
10
|
+
defineTool,
|
|
11
|
+
type ExecResult,
|
|
12
|
+
type ExtensionAPI,
|
|
13
|
+
type Theme,
|
|
14
|
+
truncateTail,
|
|
15
|
+
} from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
|
|
19
|
+
type Language = "python" | "typescript";
|
|
20
|
+
|
|
21
|
+
interface Runtimes {
|
|
22
|
+
python: string | undefined;
|
|
23
|
+
typescript: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StoredScript {
|
|
27
|
+
language: Language;
|
|
28
|
+
source: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TIMEOUT_MS = 120_000;
|
|
32
|
+
const MAX_STORED = 8;
|
|
33
|
+
|
|
34
|
+
function detectRuntimes(): Runtimes {
|
|
35
|
+
let python: string | undefined;
|
|
36
|
+
for (const cmd of ["python3", "python"] as const) {
|
|
37
|
+
try {
|
|
38
|
+
execFileSync(cmd, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
39
|
+
python = cmd;
|
|
40
|
+
break;
|
|
41
|
+
} catch {
|
|
42
|
+
// runtime not installed
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
46
|
+
let typescript: string | undefined;
|
|
47
|
+
if (major > 22 || (major === 22 && minor >= 6)) {
|
|
48
|
+
typescript = process.execPath;
|
|
49
|
+
}
|
|
50
|
+
return { python, typescript };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function capitalize(lang: Language): string {
|
|
54
|
+
return lang === "python" ? "Python" : "TypeScript";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function newScriptId(): string {
|
|
58
|
+
return randomBytes(5).toString("base64url").slice(0, 7);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scrubPath(text: string, file: string, dir: string): string {
|
|
62
|
+
if (!text) return text;
|
|
63
|
+
return text.replaceAll(file, "<script>").replaceAll(dir, "<tmpdir>");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applyEdits(source: string, edits: ReadonlyArray<{ oldText: string; newText: string }>): string {
|
|
67
|
+
let next = source;
|
|
68
|
+
for (const edit of edits) {
|
|
69
|
+
const idx = next.indexOf(edit.oldText);
|
|
70
|
+
if (idx === -1) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"An edits oldText was not found in the script. Copy the exact text from the script you wrote.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
next = next.slice(0, idx) + edit.newText + next.slice(idx + edit.oldText.length);
|
|
76
|
+
}
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderEditsPreview(edits: ReadonlyArray<{ oldText: string; newText: string }>, theme: Theme): string {
|
|
81
|
+
return edits
|
|
82
|
+
.map((edit) => {
|
|
83
|
+
const oldLines = edit.oldText
|
|
84
|
+
.split("\n")
|
|
85
|
+
.map((line) => theme.fg("error", `- ${line}`))
|
|
86
|
+
.join("\n");
|
|
87
|
+
const newLines = edit.newText
|
|
88
|
+
.split("\n")
|
|
89
|
+
.map((line) => theme.fg("success", `+ ${line}`))
|
|
90
|
+
.join("\n");
|
|
91
|
+
return `${oldLines}\n${newLines}`;
|
|
92
|
+
})
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default function scriptRunnerExtension(pi: ExtensionAPI): void {
|
|
97
|
+
const runtimes = detectRuntimes();
|
|
98
|
+
const detected = (["python", "typescript"] as const).filter(
|
|
99
|
+
(lang): lang is Language => (lang === "python" ? runtimes.python : runtimes.typescript) !== undefined,
|
|
100
|
+
);
|
|
101
|
+
if (detected.length === 0) return;
|
|
102
|
+
|
|
103
|
+
const langPhrase = detected.map(capitalize).join(" or ");
|
|
104
|
+
|
|
105
|
+
const scripts = new Map<string, StoredScript>();
|
|
106
|
+
let tempDir: string | undefined;
|
|
107
|
+
|
|
108
|
+
function remember(scriptId: string, script: StoredScript): void {
|
|
109
|
+
scripts.set(scriptId, script);
|
|
110
|
+
while (scripts.size > MAX_STORED) {
|
|
111
|
+
const oldest = scripts.keys().next().value;
|
|
112
|
+
if (oldest === undefined) break;
|
|
113
|
+
scripts.delete(oldest);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveCommand(language: Language): string {
|
|
118
|
+
if (language === "python") {
|
|
119
|
+
const cmd = runtimes.python;
|
|
120
|
+
if (!cmd) throw new Error("Python is not available on this machine.");
|
|
121
|
+
return cmd;
|
|
122
|
+
}
|
|
123
|
+
const cmd = runtimes.typescript;
|
|
124
|
+
if (!cmd) throw new Error("TypeScript is not available (requires Node >= 22.6).");
|
|
125
|
+
return cmd;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function ensureTempDir(): Promise<string> {
|
|
129
|
+
if (tempDir) return tempDir;
|
|
130
|
+
tempDir = await mkdtemp(join(tmpdir(), "tau-script-runner-"));
|
|
131
|
+
return tempDir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function runScript(
|
|
135
|
+
language: Language,
|
|
136
|
+
command: string,
|
|
137
|
+
source: string,
|
|
138
|
+
cwd: string,
|
|
139
|
+
signal: AbortSignal | undefined,
|
|
140
|
+
): Promise<ExecResult> {
|
|
141
|
+
const dir = await ensureTempDir();
|
|
142
|
+
const file = join(dir, language === "python" ? "_run.py" : "_run.ts");
|
|
143
|
+
await writeFile(file, source, "utf8");
|
|
144
|
+
const args = language === "python" ? [file] : ["--experimental-strip-types", file];
|
|
145
|
+
const result = await pi.exec(command, args, { cwd, signal, timeout: TIMEOUT_MS });
|
|
146
|
+
return {
|
|
147
|
+
...result,
|
|
148
|
+
stdout: scrubPath(result.stdout, file, dir),
|
|
149
|
+
stderr: scrubPath(result.stderr, file, dir),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const paramsSchema = Type.Object(
|
|
154
|
+
{
|
|
155
|
+
language: StringEnum(detected, { description: "Execution language available on this machine." }),
|
|
156
|
+
script: Type.Optional(
|
|
157
|
+
Type.String({ description: "Full script source for a new run. Omit when retrying with edits." }),
|
|
158
|
+
),
|
|
159
|
+
scriptId: Type.Optional(
|
|
160
|
+
Type.String({ description: "scriptId returned by a failed run. Required when retrying with edits." }),
|
|
161
|
+
),
|
|
162
|
+
edits: Type.Optional(
|
|
163
|
+
Type.Array(
|
|
164
|
+
Type.Object({
|
|
165
|
+
oldText: Type.String({ description: "Exact text currently in the script." }),
|
|
166
|
+
newText: Type.String({ description: "Replacement text." }),
|
|
167
|
+
}),
|
|
168
|
+
{
|
|
169
|
+
description:
|
|
170
|
+
"Targeted oldText/newText patches applied to the stored script before rerun. Prefer this over resending the whole script.",
|
|
171
|
+
},
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
},
|
|
175
|
+
{ additionalProperties: false },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const tool = defineTool<typeof paramsSchema, undefined>({
|
|
179
|
+
name: "script_runner",
|
|
180
|
+
label: "Script Runner",
|
|
181
|
+
description: `Run a ${langPhrase} script in the project working directory and return its output. On a non-zero exit, returns a scriptId; retry by sending targeted {oldText,newText} edits against the script you already wrote (same language + scriptId) instead of resending the whole script. Available on this machine: ${langPhrase}.`,
|
|
182
|
+
promptSnippet: `Run ${langPhrase} scripts; fix failures with targeted edits instead of rewriting the whole script.`,
|
|
183
|
+
promptGuidelines: [
|
|
184
|
+
`Prefer script_runner over bash for ${langPhrase} when computation, data handling, or bulk file work is cleaner than chaining built-in tools.`,
|
|
185
|
+
`When script_runner fails, do not resend the full script. Call script_runner again with the same language, the returned scriptId, and an edits array of {oldText,newText} patches against the script you just wrote. Only resend a full script if the approach itself was wrong.`,
|
|
186
|
+
`script_runner never exposes the script file path, and you already know the script you sent. Never try to read it back.`,
|
|
187
|
+
],
|
|
188
|
+
parameters: paramsSchema,
|
|
189
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
190
|
+
const language = params.language;
|
|
191
|
+
if (signal?.aborted) {
|
|
192
|
+
return { content: [{ type: "text", text: "Cancelled." }], details: undefined };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const command = resolveCommand(language);
|
|
196
|
+
const edits = params.edits;
|
|
197
|
+
let scriptId: string | undefined = params.scriptId;
|
|
198
|
+
let source: string;
|
|
199
|
+
|
|
200
|
+
if (edits && edits.length > 0) {
|
|
201
|
+
if (!scriptId) throw new Error("edits require the scriptId returned by the failed run.");
|
|
202
|
+
const stored = scripts.get(scriptId);
|
|
203
|
+
if (!stored) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`No stored script for scriptId ${scriptId}. It may have been evicted; resend the full script.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (stored.language !== language) {
|
|
209
|
+
throw new Error(`Language mismatch: scriptId ${scriptId} is ${stored.language}, not ${language}.`);
|
|
210
|
+
}
|
|
211
|
+
source = applyEdits(stored.source, edits);
|
|
212
|
+
} else {
|
|
213
|
+
if (typeof params.script !== "string" || params.script.length === 0) {
|
|
214
|
+
throw new Error("Provide a script for a new run, or edits + scriptId to retry.");
|
|
215
|
+
}
|
|
216
|
+
source = params.script;
|
|
217
|
+
if (!scriptId) scriptId = newScriptId();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
remember(scriptId, { language, source });
|
|
221
|
+
await onUpdate?.({ content: [{ type: "text", text: `Running ${language}...` }], details: undefined });
|
|
222
|
+
|
|
223
|
+
const result = await runScript(language, command, source, ctx.cwd, signal);
|
|
224
|
+
if (result.code === 0 && !result.killed) {
|
|
225
|
+
scripts.delete(scriptId);
|
|
226
|
+
const trunc = truncateTail(result.stdout.trim(), {
|
|
227
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
228
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
229
|
+
});
|
|
230
|
+
const out = trunc.content.trim();
|
|
231
|
+
const note = trunc.truncated
|
|
232
|
+
? `\n\n[output truncated: kept tail ${trunc.outputLines} / ${trunc.totalLines} lines]`
|
|
233
|
+
: "";
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: "text", text: out ? `${out}${note}` : `(no output)${note}` }],
|
|
236
|
+
details: undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const diag = result.stderr.trim() || result.stdout.trim();
|
|
241
|
+
const trunc = truncateTail(diag, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
|
242
|
+
const note = trunc.truncated
|
|
243
|
+
? `\n\n[output truncated: kept tail ${trunc.outputLines} / ${trunc.totalLines} lines]`
|
|
244
|
+
: "";
|
|
245
|
+
const detail = trunc.content ? `${trunc.content}${note}\n\n` : "";
|
|
246
|
+
throw new Error(
|
|
247
|
+
`${detail}scriptId: ${scriptId}\nRetry with edits: [{oldText,newText}] using the same language and scriptId; do not resend the full script.`,
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
renderCall(args, theme, context) {
|
|
251
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
252
|
+
const header = `${theme.fg("toolTitle", theme.bold("script_runner"))} ${theme.fg("muted", args.language)}`;
|
|
253
|
+
const edits = args.edits;
|
|
254
|
+
const body =
|
|
255
|
+
edits && edits.length > 0
|
|
256
|
+
? renderEditsPreview(edits, theme)
|
|
257
|
+
: theme.fg("accent", theme.bold(args.script ?? ""));
|
|
258
|
+
text.setText(body ? `${header}\n${body}` : header);
|
|
259
|
+
return text;
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
pi.registerTool(tool);
|
|
264
|
+
|
|
265
|
+
pi.on("session_shutdown", () => {
|
|
266
|
+
scripts.clear();
|
|
267
|
+
const dir = tempDir;
|
|
268
|
+
tempDir = undefined;
|
|
269
|
+
if (dir) {
|
|
270
|
+
void rm(dir, { recursive: true, force: true }).catch(() => {
|
|
271
|
+
// best-effort cleanup
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
@@ -6,13 +6,14 @@ import {
|
|
|
6
6
|
type TextRecordSelectPanelConfig,
|
|
7
7
|
type TextRecordSelectResult,
|
|
8
8
|
} from "@shanepadgett/tau-tui";
|
|
9
|
+
import { errorText } from "../../shared/text.ts";
|
|
9
10
|
import { loadStashes, removeStash, type Stash, stashFilePath } from "./store.ts";
|
|
10
11
|
|
|
11
|
-
const CONFIG: Omit<TextRecordSelectPanelConfig
|
|
12
|
+
const CONFIG: Omit<TextRecordSelectPanelConfig<Stash>, "path" | "destructiveAction"> = {
|
|
12
13
|
title: "Stash",
|
|
13
14
|
emptyMessage: "No stashed prompts. Use the stash shortcut while typing to stash.",
|
|
14
15
|
primaryLabel: "pop",
|
|
15
|
-
actions: [
|
|
16
|
+
actions: [],
|
|
16
17
|
expandActiveItem: false,
|
|
17
18
|
};
|
|
18
19
|
|
|
@@ -24,20 +25,9 @@ export async function browseStash(ctx: ExtensionCommandContext): Promise<Stash |
|
|
|
24
25
|
|
|
25
26
|
const path = await stashFilePath(ctx.cwd);
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (result.kind === "cancel") return undefined;
|
|
32
|
-
if (result.kind === "primary") return result.item;
|
|
33
|
-
|
|
34
|
-
// discard: drop the stashed prompt without restoring it.
|
|
35
|
-
const ok = await ctx.ui.confirm("Discard stashed prompt?", result.item.text);
|
|
36
|
-
if (ok) {
|
|
37
|
-
await removeStash(ctx.cwd, result.item.id);
|
|
38
|
-
ctx.ui.notify("Stash discarded.", "info");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
28
|
+
const stashes = await loadStashes(ctx.cwd);
|
|
29
|
+
const result = await show(ctx, stashes, path);
|
|
30
|
+
return result.kind === "primary" ? result.item : undefined;
|
|
41
31
|
}
|
|
42
32
|
|
|
43
33
|
async function show(
|
|
@@ -45,7 +35,29 @@ async function show(
|
|
|
45
35
|
stashes: readonly Stash[],
|
|
46
36
|
path: string,
|
|
47
37
|
): Promise<TextRecordSelectResult<Stash>> {
|
|
48
|
-
return ctx.ui.custom<TextRecordSelectResult<Stash>>((
|
|
49
|
-
createTextRecordSelectPanel(
|
|
38
|
+
return ctx.ui.custom<TextRecordSelectResult<Stash>>((tui, theme, _keybindings, done) =>
|
|
39
|
+
createTextRecordSelectPanel(
|
|
40
|
+
tui,
|
|
41
|
+
theme,
|
|
42
|
+
stashes,
|
|
43
|
+
{
|
|
44
|
+
...CONFIG,
|
|
45
|
+
path,
|
|
46
|
+
destructiveAction: {
|
|
47
|
+
id: "discard",
|
|
48
|
+
key: Key.ctrl("d"),
|
|
49
|
+
hint: rawHint("ctrl+d", "discard"),
|
|
50
|
+
confirmLabel: () => "Discard stashed prompt?",
|
|
51
|
+
runningLabel: "Discarding stashed prompt…",
|
|
52
|
+
onConfirm: async (item) => {
|
|
53
|
+
const next = await removeStash(ctx.cwd, item.id);
|
|
54
|
+
ctx.ui.notify("Stash discarded.", "info");
|
|
55
|
+
return next;
|
|
56
|
+
},
|
|
57
|
+
onError: (error) => ctx.ui.notify(`Stash discard failed: ${errorText(error)}`, "error"),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
done,
|
|
61
|
+
),
|
|
50
62
|
);
|
|
51
63
|
}
|