@shanepadgett/tau-agent 0.1.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/README.md +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
|
+
|
|
4
|
+
export type ReferenceEditor = "default" | "code" | "zed";
|
|
5
|
+
|
|
6
|
+
export default defineTauExtensionSettings({
|
|
7
|
+
key: "reference",
|
|
8
|
+
defaults: { editor: "default" as ReferenceEditor, branchChoices: 5 as number },
|
|
9
|
+
schema: Type.Object(
|
|
10
|
+
{
|
|
11
|
+
editor: Type.Optional(
|
|
12
|
+
Type.Union([Type.Literal("default"), Type.Literal("code"), Type.Literal("zed")], {
|
|
13
|
+
default: "default",
|
|
14
|
+
description:
|
|
15
|
+
"Editor used by the reference picker open action. default uses $VISUAL, $EDITOR, then code.",
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
branchChoices: Type.Optional(
|
|
19
|
+
Type.Integer({
|
|
20
|
+
default: 5,
|
|
21
|
+
minimum: 1,
|
|
22
|
+
maximum: 50,
|
|
23
|
+
description: "Maximum branch choices shown when switching a reference branch.",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
{ additionalProperties: false },
|
|
28
|
+
),
|
|
29
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Run Summary
|
|
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.
|
|
4
|
+
|
|
5
|
+
The marker is stored as a display-only session entry. It does not enter model context or trigger another agent turn.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Marker } from "@shanepadgett/tau-tui";
|
|
4
|
+
|
|
5
|
+
const ENTRY_TYPE = "tau.run-summary";
|
|
6
|
+
|
|
7
|
+
interface RunSummary {
|
|
8
|
+
wallMs: number;
|
|
9
|
+
runCost: number;
|
|
10
|
+
subagentCost: number;
|
|
11
|
+
totalCost: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default function runSummaryExtension(pi: ExtensionAPI): void {
|
|
15
|
+
let startedAt: number | undefined;
|
|
16
|
+
|
|
17
|
+
pi.registerEntryRenderer<RunSummary>(ENTRY_TYPE, (entry, _options, theme) => {
|
|
18
|
+
const summary = readRunSummary(entry.data);
|
|
19
|
+
if (!summary) return undefined;
|
|
20
|
+
return new Marker({
|
|
21
|
+
theme,
|
|
22
|
+
state: "muted",
|
|
23
|
+
label: "Run complete:",
|
|
24
|
+
parts: [
|
|
25
|
+
`Wall ${formatDuration(summary.wallMs)}`,
|
|
26
|
+
`Run ${formatCost(summary.runCost)}`,
|
|
27
|
+
`Subagents ${formatCost(summary.subagentCost)}`,
|
|
28
|
+
`Total ${formatCost(summary.totalCost)}`,
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
pi.on("session_start", () => {
|
|
34
|
+
startedAt = undefined;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.on("agent_start", () => {
|
|
38
|
+
startedAt = performance.now();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
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
|
+
for (const message of event.messages) {
|
|
48
|
+
if (message.role === "assistant") {
|
|
49
|
+
runCost += finiteNonNegative((message as AssistantMessage).usage.cost.total);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (message.role !== "toolResult" || message.toolName !== "subagent") continue;
|
|
53
|
+
subagentCost += readSubagentCost(message.details);
|
|
54
|
+
}
|
|
55
|
+
pi.appendEntry<RunSummary>(ENTRY_TYPE, {
|
|
56
|
+
wallMs,
|
|
57
|
+
runCost,
|
|
58
|
+
subagentCost,
|
|
59
|
+
totalCost: runCost + subagentCost,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readSubagentCost(value: unknown): number {
|
|
65
|
+
if (!value || typeof value !== "object") return 0;
|
|
66
|
+
const usage = (value as Record<string, unknown>).usage;
|
|
67
|
+
if (!usage || typeof usage !== "object") return 0;
|
|
68
|
+
return finiteNonNegative((usage as Record<string, unknown>).cost);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readRunSummary(value: unknown): RunSummary | undefined {
|
|
72
|
+
if (!value || typeof value !== "object") return undefined;
|
|
73
|
+
const record = value as Record<string, unknown>;
|
|
74
|
+
if (![record.wallMs, record.runCost, record.subagentCost, record.totalCost].every(isFiniteNonNegative))
|
|
75
|
+
return undefined;
|
|
76
|
+
return {
|
|
77
|
+
wallMs: record.wallMs as number,
|
|
78
|
+
runCost: record.runCost as number,
|
|
79
|
+
subagentCost: record.subagentCost as number,
|
|
80
|
+
totalCost: record.totalCost as number,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isFiniteNonNegative(value: unknown): value is number {
|
|
85
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function finiteNonNegative(value: unknown): number {
|
|
89
|
+
return isFiniteNonNegative(value) ? value : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatDuration(milliseconds: number): string {
|
|
93
|
+
return milliseconds < 1_000 ? `${Math.round(milliseconds)}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatCost(cost: number): string {
|
|
97
|
+
return `$${cost.toFixed(4)}`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Silent Command Runner
|
|
2
|
+
|
|
3
|
+
Runs configured commands after Tau changes matching files.
|
|
4
|
+
|
|
5
|
+
Use it for quiet post-edit checks such as `mise run check`. It stays out of the transcript when commands pass. When a command fails, it posts one Tau message with raw stdout/stderr tail output and starts the agent on the failure.
|
|
6
|
+
|
|
7
|
+
Configure it in Tau settings under `extensions.silentCommandRunner`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"extensions": {
|
|
12
|
+
"silentCommandRunner": {
|
|
13
|
+
"commands": [
|
|
14
|
+
{
|
|
15
|
+
"name": "check",
|
|
16
|
+
"command": "mise run check",
|
|
17
|
+
"includeGlobs": ["packages/**/*.ts", ".pi/extensions/**/*.ts"]
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Passes are notifications only. Failures are shown in chat and sent to the agent.
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
import { type ExecResult, type ExtensionAPI, keyText, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { loadTauExtensionSettings } from "../../shared/settings/load.ts";
|
|
6
|
+
import { resolveProjectRoot } from "../../shared/settings/paths.ts";
|
|
7
|
+
import silentCommandRunnerSettings from "./settings.ts";
|
|
8
|
+
|
|
9
|
+
const MESSAGE_TYPE = "tau:silent-command-runner";
|
|
10
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 51200;
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
12
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
13
|
+
const WALK_SKIP_DIRS = new Set([
|
|
14
|
+
".git",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
"coverage",
|
|
19
|
+
".cache",
|
|
20
|
+
".next",
|
|
21
|
+
".turbo",
|
|
22
|
+
".parcel-cache",
|
|
23
|
+
"out",
|
|
24
|
+
]);
|
|
25
|
+
const DEFAULT_EXCLUDE_GLOBS = [
|
|
26
|
+
".git/**",
|
|
27
|
+
"node_modules/**",
|
|
28
|
+
"dist/**",
|
|
29
|
+
"build/**",
|
|
30
|
+
"coverage/**",
|
|
31
|
+
".cache/**",
|
|
32
|
+
".next/**",
|
|
33
|
+
".turbo/**",
|
|
34
|
+
".parcel-cache/**",
|
|
35
|
+
"out/**",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
interface CommandConfig {
|
|
39
|
+
name: string;
|
|
40
|
+
command: string;
|
|
41
|
+
cwd: string;
|
|
42
|
+
env: Record<string, string>;
|
|
43
|
+
details: string | undefined;
|
|
44
|
+
includeGlobs: string[];
|
|
45
|
+
excludeGlobs: string[];
|
|
46
|
+
timeoutMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Settings {
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
maxOutputBytes: number;
|
|
52
|
+
commands: CommandConfig[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface OutputTail {
|
|
56
|
+
content: string;
|
|
57
|
+
truncated: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FailedCommandDetails {
|
|
61
|
+
name: string;
|
|
62
|
+
command: string;
|
|
63
|
+
cwd: string;
|
|
64
|
+
envKeys: string[];
|
|
65
|
+
code: number;
|
|
66
|
+
killed: boolean;
|
|
67
|
+
durationMs: number;
|
|
68
|
+
stdout: OutputTail;
|
|
69
|
+
stderr: OutputTail;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface FailureDetails {
|
|
73
|
+
failed: FailedCommandDetails[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default function silentCommandRunnerExtension(pi: ExtensionAPI): void {
|
|
77
|
+
let settings: Settings = normalizeSettings(silentCommandRunnerSettings.defaults);
|
|
78
|
+
let turnStart = Date.now();
|
|
79
|
+
let turnPaths = new Set<string>();
|
|
80
|
+
let run: Promise<void> | undefined;
|
|
81
|
+
let abortController: AbortController | undefined;
|
|
82
|
+
|
|
83
|
+
pi.registerMessageRenderer<FailureDetails>(MESSAGE_TYPE, (message, { expanded }, theme) =>
|
|
84
|
+
renderFailure(asFailureDetails(message.details), expanded, theme),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
pi.on("before_agent_start", (event) => {
|
|
88
|
+
if (!settings.enabled || settings.commands.length === 0) return;
|
|
89
|
+
const prompt = formatSilentCheckPrompt(settings.commands);
|
|
90
|
+
event.systemPromptOptions.appendSystemPrompt = appendPrompt(event.systemPromptOptions.appendSystemPrompt, prompt);
|
|
91
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
95
|
+
settings = normalizeSettings(await loadTauExtensionSettings(ctx, silentCommandRunnerSettings));
|
|
96
|
+
turnStart = Date.now();
|
|
97
|
+
turnPaths = new Set();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
101
|
+
turnStart = Date.now();
|
|
102
|
+
settings = normalizeSettings(await loadTauExtensionSettings(ctx, silentCommandRunnerSettings));
|
|
103
|
+
if (!settings.enabled || settings.commands.length === 0) {
|
|
104
|
+
turnPaths = new Set();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const projectRoot = await resolveProjectRoot(ctx.cwd);
|
|
108
|
+
turnPaths = new Set(await walkFiles(projectRoot));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
112
|
+
if (hasAbortedAssistantMessage(event.messages)) return;
|
|
113
|
+
if (run) return;
|
|
114
|
+
run = runChangedCommands(ctx.cwd, turnStart, ctx.ui.notify)
|
|
115
|
+
.catch((error: unknown) => {
|
|
116
|
+
ctx.ui.notify(`silent-command-runner: ${errorMessage(error)}`, "error");
|
|
117
|
+
})
|
|
118
|
+
.finally(() => {
|
|
119
|
+
run = undefined;
|
|
120
|
+
});
|
|
121
|
+
await run;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
pi.on("session_shutdown", () => {
|
|
125
|
+
abortController?.abort();
|
|
126
|
+
abortController = undefined;
|
|
127
|
+
run = undefined;
|
|
128
|
+
turnPaths = new Set();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
async function runChangedCommands(
|
|
132
|
+
cwd: string,
|
|
133
|
+
turnStart: number,
|
|
134
|
+
notify: (message: string, type?: "info" | "warning" | "error") => void,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
if (!settings.enabled || settings.commands.length === 0) return;
|
|
137
|
+
|
|
138
|
+
const projectRoot = await resolveProjectRoot(cwd);
|
|
139
|
+
const paths = await walkFiles(projectRoot);
|
|
140
|
+
const changed = await scanChangedCommands(projectRoot, settings.commands, paths, turnPaths, turnStart);
|
|
141
|
+
if (changed.length === 0) return;
|
|
142
|
+
|
|
143
|
+
notify(
|
|
144
|
+
changed.length === 1
|
|
145
|
+
? `silent-command-runner: running ${changed[0]?.name ?? "command"}`
|
|
146
|
+
: `silent-command-runner: running ${changed.length} commands`,
|
|
147
|
+
"info",
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
abortController = new AbortController();
|
|
151
|
+
const failures: FailedCommandDetails[] = [];
|
|
152
|
+
for (const command of changed) {
|
|
153
|
+
if (changed.length > 1) notify(`silent-command-runner: running ${command.name}`, "info");
|
|
154
|
+
const result = await runCommand(pi, projectRoot, command, abortController.signal, settings.maxOutputBytes);
|
|
155
|
+
if (result.code !== 0 || result.killed) failures.push(result);
|
|
156
|
+
}
|
|
157
|
+
abortController = undefined;
|
|
158
|
+
|
|
159
|
+
const failedNames = new Set(failures.map((failure) => failure.name));
|
|
160
|
+
const passed = changed.filter((command) => !failedNames.has(command.name));
|
|
161
|
+
if (passed.length > 0) notify(`silent-command-runner: passed ${formatCommandNames(passed)}`, "info");
|
|
162
|
+
if (failures.length === 0) return;
|
|
163
|
+
|
|
164
|
+
pi.sendMessage<FailureDetails>(
|
|
165
|
+
{
|
|
166
|
+
customType: MESSAGE_TYPE,
|
|
167
|
+
content: formatAgentMessage(failures),
|
|
168
|
+
display: true,
|
|
169
|
+
details: { failed: failures },
|
|
170
|
+
},
|
|
171
|
+
{ triggerTurn: true },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeSettings(value: typeof silentCommandRunnerSettings.defaults): Settings {
|
|
177
|
+
return {
|
|
178
|
+
enabled: value.enabled ?? true,
|
|
179
|
+
maxOutputBytes: positiveInteger(value.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),
|
|
180
|
+
commands: (value.commands ?? []).flatMap((command) => {
|
|
181
|
+
if (command.enabled === false) return [];
|
|
182
|
+
const name = command.name.trim();
|
|
183
|
+
const commandText = command.command.trim();
|
|
184
|
+
if (!name || !commandText) return [];
|
|
185
|
+
return [
|
|
186
|
+
{
|
|
187
|
+
name,
|
|
188
|
+
command: commandText,
|
|
189
|
+
cwd: command.cwd?.trim() || ".",
|
|
190
|
+
env: command.env ?? {},
|
|
191
|
+
details: command.details?.trim() || undefined,
|
|
192
|
+
includeGlobs: nonEmptyStrings(command.includeGlobs, ["**/*"]),
|
|
193
|
+
excludeGlobs: [...DEFAULT_EXCLUDE_GLOBS, ...nonEmptyStrings(command.excludeGlobs, [])],
|
|
194
|
+
timeoutMs: positiveInteger(command.timeoutMs, DEFAULT_TIMEOUT_MS),
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatSilentCheckPrompt(commands: readonly CommandConfig[]): string {
|
|
202
|
+
return [
|
|
203
|
+
"**NEVER** run the following commands. Ever. Not even following a failure. They will run automatically when you finish and if you just fixed an issue, end the turn and it will run again. Always assume your code is good.",
|
|
204
|
+
"Automatic commands:",
|
|
205
|
+
...commands.map(formatSilentCheckCommand),
|
|
206
|
+
].join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function appendPrompt(current: string | undefined, prompt: string): string {
|
|
210
|
+
return current ? `${current}\n\n${prompt}` : prompt;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function formatSilentCheckCommand(command: CommandConfig): string {
|
|
214
|
+
return [
|
|
215
|
+
`- ${command.name}: ${command.command}`,
|
|
216
|
+
...(command.details ? [` details: ${command.details}`] : []),
|
|
217
|
+
` cwd: ${command.cwd}`,
|
|
218
|
+
` triggers: ${command.includeGlobs.join(", ")}`,
|
|
219
|
+
...(command.excludeGlobs.length ? [` excludes: ${command.excludeGlobs.join(", ")}`] : []),
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function hasAbortedAssistantMessage(messages: readonly unknown[]): boolean {
|
|
224
|
+
return messages.some(
|
|
225
|
+
(message) => isRecord(message) && message.role === "assistant" && message.stopReason === "aborted",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function scanChangedCommands(
|
|
230
|
+
projectRoot: string,
|
|
231
|
+
commands: readonly CommandConfig[],
|
|
232
|
+
paths: readonly string[],
|
|
233
|
+
turnPaths: ReadonlySet<string>,
|
|
234
|
+
turnStart: number,
|
|
235
|
+
): Promise<CommandConfig[]> {
|
|
236
|
+
const currentSet = new Set(paths);
|
|
237
|
+
const deletedPaths = [...turnPaths].filter((path) => !currentSet.has(path));
|
|
238
|
+
const changed: CommandConfig[] = [];
|
|
239
|
+
for (const command of commands) {
|
|
240
|
+
const matched = paths.filter((path) => matchesCommand(path, command));
|
|
241
|
+
const hasDeleted = deletedPaths.some((path) => matchesCommand(path, command));
|
|
242
|
+
if (hasDeleted || (await hasChangedFile(projectRoot, matched, turnStart))) changed.push(command);
|
|
243
|
+
}
|
|
244
|
+
return changed;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function hasChangedFile(projectRoot: string, paths: readonly string[], turnStart: number): Promise<boolean> {
|
|
248
|
+
for (const path of paths) {
|
|
249
|
+
try {
|
|
250
|
+
if ((await stat(resolve(projectRoot, path))).mtimeMs >= turnStart) return true;
|
|
251
|
+
} catch {
|
|
252
|
+
// unreadable file — treat as changed to avoid skipping checks
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function walkFiles(root: string): Promise<string[]> {
|
|
260
|
+
const files: string[] = [];
|
|
261
|
+
await walkDir(root, "", files);
|
|
262
|
+
return files;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function walkDir(root: string, relativeDir: string, files: string[]): Promise<void> {
|
|
266
|
+
const dir = relativeDir ? resolve(root, relativeDir) : root;
|
|
267
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
268
|
+
const relativePath = posixPath(relativeDir ? `${relativeDir}/${entry.name}` : entry.name);
|
|
269
|
+
if (entry.isDirectory()) {
|
|
270
|
+
if (!WALK_SKIP_DIRS.has(entry.name)) await walkDir(root, relativePath, files);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (entry.isFile()) files.push(relativePath);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function matchesCommand(path: string, command: CommandConfig): boolean {
|
|
278
|
+
return (
|
|
279
|
+
command.includeGlobs.some((glob) => matchGlob(glob, path)) &&
|
|
280
|
+
!command.excludeGlobs.some((glob) => matchGlob(glob, path))
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runCommand(
|
|
285
|
+
pi: ExtensionAPI,
|
|
286
|
+
projectRoot: string,
|
|
287
|
+
command: CommandConfig,
|
|
288
|
+
signal: AbortSignal,
|
|
289
|
+
maxOutputBytes: number,
|
|
290
|
+
): Promise<FailedCommandDetails> {
|
|
291
|
+
const start = Date.now();
|
|
292
|
+
const invalidEnvKey = Object.keys(command.env).find((key) => !ENV_KEY.test(key));
|
|
293
|
+
if (invalidEnvKey) {
|
|
294
|
+
return {
|
|
295
|
+
name: command.name,
|
|
296
|
+
command: command.command,
|
|
297
|
+
cwd: command.cwd,
|
|
298
|
+
envKeys: Object.keys(command.env).sort(),
|
|
299
|
+
code: 1,
|
|
300
|
+
killed: false,
|
|
301
|
+
durationMs: 0,
|
|
302
|
+
stdout: { content: "", truncated: false },
|
|
303
|
+
stderr: { content: `Invalid env key: ${invalidEnvKey}`, truncated: false },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const cwd = resolve(projectRoot, command.cwd);
|
|
308
|
+
const envArgs = Object.entries(command.env).map(([key, value]) => `${key}=${value}`);
|
|
309
|
+
const result = await pi.exec(
|
|
310
|
+
envArgs.length ? "env" : "sh",
|
|
311
|
+
envArgs.length ? [...envArgs, "sh", "-lc", command.command] : ["-lc", command.command],
|
|
312
|
+
{
|
|
313
|
+
cwd,
|
|
314
|
+
signal,
|
|
315
|
+
timeout: command.timeoutMs,
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
return commandDetails(command, cwd, result, Date.now() - start, maxOutputBytes);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function formatCommandNames(commands: readonly CommandConfig[]): string {
|
|
322
|
+
return commands.map((command) => command.name).join(", ");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function commandDetails(
|
|
326
|
+
command: CommandConfig,
|
|
327
|
+
cwd: string,
|
|
328
|
+
result: ExecResult,
|
|
329
|
+
durationMs: number,
|
|
330
|
+
maxOutputBytes: number,
|
|
331
|
+
): FailedCommandDetails {
|
|
332
|
+
return {
|
|
333
|
+
name: command.name,
|
|
334
|
+
command: command.command,
|
|
335
|
+
cwd,
|
|
336
|
+
envKeys: Object.keys(command.env).sort(),
|
|
337
|
+
code: result.code,
|
|
338
|
+
killed: result.killed,
|
|
339
|
+
durationMs,
|
|
340
|
+
stdout: tailBytes(result.stdout, maxOutputBytes),
|
|
341
|
+
stderr: tailBytes(result.stderr, maxOutputBytes),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function tailBytes(value: string, maxBytes: number): OutputTail {
|
|
346
|
+
const buffer = Buffer.from(value);
|
|
347
|
+
if (buffer.byteLength <= maxBytes) return { content: value, truncated: false };
|
|
348
|
+
return { content: buffer.subarray(buffer.byteLength - maxBytes).toString("utf8"), truncated: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function formatAgentMessage(failures: readonly FailedCommandDetails[]): string {
|
|
352
|
+
return [
|
|
353
|
+
`silent-command-runner failed: ${failures.length} command${failures.length === 1 ? "" : "s"}`,
|
|
354
|
+
...failures.map(formatAgentFailure),
|
|
355
|
+
"**Do not** rerun these checks. They will run automatically after your fix.",
|
|
356
|
+
].join("\n\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function formatAgentFailure(failure: FailedCommandDetails): string {
|
|
360
|
+
return [
|
|
361
|
+
`## ${failure.name}`,
|
|
362
|
+
`command: ${failure.command}`,
|
|
363
|
+
`cwd: ${failure.cwd}`,
|
|
364
|
+
`exit: ${failure.killed ? "killed" : failure.code}`,
|
|
365
|
+
formatOutput("stdout", failure.stdout),
|
|
366
|
+
formatOutput("stderr", failure.stderr),
|
|
367
|
+
].join("\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function formatOutput(label: string, output: OutputTail): string {
|
|
371
|
+
const note = output.truncated ? " (tail, truncated)" : "";
|
|
372
|
+
return `${label}${note}:\n\`\`\`\n${output.content}\n\`\``;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderFailure(details: FailureDetails, expanded: boolean, theme: Theme): Box {
|
|
376
|
+
const box = new Box(1, 1, (value) => theme.bg("customMessageBg", value));
|
|
377
|
+
const failed = details.failed;
|
|
378
|
+
const header = `${theme.fg("error", theme.bold("silent-command-runner failed"))}${theme.fg(
|
|
379
|
+
"dim",
|
|
380
|
+
` (${failed.length} command${failed.length === 1 ? "" : "s"}, ${keyText("app.tools.expand")} to ${expanded ? "collapse" : "expand"})`,
|
|
381
|
+
)}`;
|
|
382
|
+
const summary = failed.map(
|
|
383
|
+
(failure) => `- ${failure.name}: ${failure.killed ? "killed" : `exit ${failure.code}`} (${failure.durationMs}ms)`,
|
|
384
|
+
);
|
|
385
|
+
const text = expanded
|
|
386
|
+
? [header, ...summary, "", ...failed.map(renderExpandedFailure)].join("\n")
|
|
387
|
+
: [header, ...summary].join("\n");
|
|
388
|
+
box.addChild(new Text(text, 0, 0));
|
|
389
|
+
return box;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderExpandedFailure(failure: FailedCommandDetails): string {
|
|
393
|
+
return [
|
|
394
|
+
`## ${failure.name}`,
|
|
395
|
+
`command: ${failure.command}`,
|
|
396
|
+
`cwd: ${failure.cwd}`,
|
|
397
|
+
`env keys: ${failure.envKeys.length ? failure.envKeys.join(", ") : "none"}`,
|
|
398
|
+
formatOutput("stdout", failure.stdout),
|
|
399
|
+
formatOutput("stderr", failure.stderr),
|
|
400
|
+
].join("\n");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function asFailureDetails(value: unknown): FailureDetails {
|
|
404
|
+
const record = isRecord(value) ? value : {};
|
|
405
|
+
return {
|
|
406
|
+
failed: Array.isArray(record.failed)
|
|
407
|
+
? (record.failed.filter(isFailedCommandDetails) as FailedCommandDetails[])
|
|
408
|
+
: [],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isFailedCommandDetails(value: unknown): value is FailedCommandDetails {
|
|
413
|
+
return isRecord(value) && typeof value.name === "string" && typeof value.command === "string";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function matchGlob(pattern: string, path: string): boolean {
|
|
417
|
+
return matchSegments(normalizeGlob(pattern).split("/"), normalizeGlob(path).split("/"));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function matchSegments(pattern: readonly string[], path: readonly string[]): boolean {
|
|
421
|
+
const [head, ...tail] = pattern;
|
|
422
|
+
if (head === undefined) return path.length === 0;
|
|
423
|
+
if (head === "**") return matchSegments(tail, path) || (path.length > 0 && matchSegments(pattern, path.slice(1)));
|
|
424
|
+
const [pathHead, ...pathTail] = path;
|
|
425
|
+
return pathHead !== undefined && matchSegment(head, pathHead) && matchSegments(tail, pathTail);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function matchSegment(pattern: string, value: string): boolean {
|
|
429
|
+
const source = [...pattern]
|
|
430
|
+
.map((char) => {
|
|
431
|
+
if (char === "*") return "[^/]*";
|
|
432
|
+
if (char === "?") return "[^/]";
|
|
433
|
+
return escapeRegExp(char);
|
|
434
|
+
})
|
|
435
|
+
.join("");
|
|
436
|
+
return new RegExp(`^${source}$`).test(value);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizeGlob(value: string): string {
|
|
440
|
+
return posixPath(value.trim())
|
|
441
|
+
.replace(/^\.\//, "")
|
|
442
|
+
.replace(/^\/+|\/+$/g, "");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function posixPath(value: string): string {
|
|
446
|
+
return sep === "/" ? value : value.split(sep).join("/");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function nonEmptyStrings(value: readonly string[] | undefined, fallback: string[]): string[] {
|
|
450
|
+
const strings = value?.map((item) => item.trim()).filter(Boolean) ?? [];
|
|
451
|
+
return strings.length ? strings : fallback;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function positiveInteger(value: number | undefined, fallback: number): number {
|
|
455
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function escapeRegExp(value: string): string {
|
|
459
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function errorMessage(error: unknown): string {
|
|
463
|
+
return error instanceof Error ? error.message : String(error);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
467
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
468
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../../shared/settings/define.ts";
|
|
3
|
+
|
|
4
|
+
const commandSchema = Type.Object(
|
|
5
|
+
{
|
|
6
|
+
name: Type.String({ description: "Short command name shown in notifications and failure messages." }),
|
|
7
|
+
enabled: Type.Optional(Type.Boolean({ default: true, description: "Enable this command." })),
|
|
8
|
+
command: Type.String({ description: "Shell command run with sh -lc after matching files change." }),
|
|
9
|
+
cwd: Type.Optional(
|
|
10
|
+
Type.String({ default: ".", description: "Command working directory, relative to project root." }),
|
|
11
|
+
),
|
|
12
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Extra environment values." })),
|
|
13
|
+
details: Type.Optional(
|
|
14
|
+
Type.String({ description: "Free-text explanation of what this command checks or runs." }),
|
|
15
|
+
),
|
|
16
|
+
includeGlobs: Type.Optional(
|
|
17
|
+
Type.Array(Type.String(), {
|
|
18
|
+
default: ["**/*"],
|
|
19
|
+
description: "Project-relative files that trigger this command.",
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
excludeGlobs: Type.Optional(
|
|
23
|
+
Type.Array(Type.String(), { default: [], description: "Project-relative files ignored for this command." }),
|
|
24
|
+
),
|
|
25
|
+
timeoutMs: Type.Optional(
|
|
26
|
+
Type.Integer({ default: 120000, minimum: 1, description: "Command timeout in milliseconds." }),
|
|
27
|
+
),
|
|
28
|
+
},
|
|
29
|
+
{ additionalProperties: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export default defineTauExtensionSettings({
|
|
33
|
+
key: "silentCommandRunner",
|
|
34
|
+
defaults: {
|
|
35
|
+
enabled: true as boolean,
|
|
36
|
+
maxOutputBytes: 51200 as number,
|
|
37
|
+
commands: [] as Array<{
|
|
38
|
+
name: string;
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
command: string;
|
|
41
|
+
cwd?: string;
|
|
42
|
+
env?: Record<string, string>;
|
|
43
|
+
details?: string;
|
|
44
|
+
includeGlobs?: string[];
|
|
45
|
+
excludeGlobs?: string[];
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
}>,
|
|
48
|
+
},
|
|
49
|
+
schema: Type.Object(
|
|
50
|
+
{
|
|
51
|
+
enabled: Type.Optional(Type.Boolean({ default: true, description: "Enable silent command runner." })),
|
|
52
|
+
maxOutputBytes: Type.Optional(
|
|
53
|
+
Type.Integer({
|
|
54
|
+
default: 51200,
|
|
55
|
+
minimum: 1,
|
|
56
|
+
description: "Maximum raw stdout/stderr bytes sent to the agent.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
commands: Type.Optional(
|
|
60
|
+
Type.Array(commandSchema, { default: [], description: "Commands run after matched files change." }),
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
{ additionalProperties: false },
|
|
64
|
+
),
|
|
65
|
+
});
|