@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,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MAX_BYTES,
|
|
3
|
+
DEFAULT_MAX_LINES,
|
|
4
|
+
defineTool,
|
|
5
|
+
withFileMutationQueue,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { encode } from "@toon-format/toon";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
12
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
import { type Static, Type } from "typebox";
|
|
14
|
+
import { createToolRowStateStore, formatToolRowTitle } from "../../shared/tool-row-state.js";
|
|
15
|
+
import { createNativeHelper, type RunHelper } from "./native-helper.ts";
|
|
16
|
+
|
|
17
|
+
const MAX_PNG_BYTES = 12 * 1024 * 1024;
|
|
18
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
19
|
+
const listWindowsSchema = Type.Object({}, { additionalProperties: false });
|
|
20
|
+
const screenshotWindowSchema = Type.Object(
|
|
21
|
+
{
|
|
22
|
+
window_id: Type.Integer({ minimum: 1, description: "Window ID returned by list_windows" }),
|
|
23
|
+
path: Type.String({ description: "Destination path for the PNG file (relative or absolute)" }),
|
|
24
|
+
},
|
|
25
|
+
{ additionalProperties: false },
|
|
26
|
+
);
|
|
27
|
+
const activateAppSchema = Type.Object(
|
|
28
|
+
{ pid: Type.Integer({ minimum: 1, description: "Application process ID returned by list_windows" }) },
|
|
29
|
+
{ additionalProperties: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
type ScreenshotWindowParams = Static<typeof screenshotWindowSchema>;
|
|
33
|
+
|
|
34
|
+
interface WindowInfo {
|
|
35
|
+
window_id: number;
|
|
36
|
+
title: string;
|
|
37
|
+
app_name: string;
|
|
38
|
+
bundle_id: string;
|
|
39
|
+
pid: number;
|
|
40
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ScreenshotDetails {
|
|
44
|
+
path: string;
|
|
45
|
+
window_id: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ActivationDetails {
|
|
49
|
+
pid: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isWindowInfo(value: unknown): value is WindowInfo {
|
|
53
|
+
if (typeof value !== "object" || value === null) return false;
|
|
54
|
+
const window = value as Record<string, unknown>;
|
|
55
|
+
if (typeof window.bounds !== "object" || window.bounds === null) return false;
|
|
56
|
+
const bounds = window.bounds as Record<string, unknown>;
|
|
57
|
+
return (
|
|
58
|
+
typeof window.window_id === "number" &&
|
|
59
|
+
Number.isInteger(window.window_id) &&
|
|
60
|
+
typeof window.title === "string" &&
|
|
61
|
+
typeof window.app_name === "string" &&
|
|
62
|
+
typeof window.bundle_id === "string" &&
|
|
63
|
+
typeof window.pid === "number" &&
|
|
64
|
+
Number.isInteger(window.pid) &&
|
|
65
|
+
typeof bounds.x === "number" &&
|
|
66
|
+
Number.isFinite(bounds.x) &&
|
|
67
|
+
typeof bounds.y === "number" &&
|
|
68
|
+
Number.isFinite(bounds.y) &&
|
|
69
|
+
typeof bounds.width === "number" &&
|
|
70
|
+
Number.isFinite(bounds.width) &&
|
|
71
|
+
typeof bounds.height === "number" &&
|
|
72
|
+
Number.isFinite(bounds.height)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function encodeWindowList(windows: WindowInfo[]): string {
|
|
77
|
+
const rows = windows.map((window) => ({
|
|
78
|
+
window_id: window.window_id,
|
|
79
|
+
title: window.title,
|
|
80
|
+
app_name: window.app_name,
|
|
81
|
+
bundle_id: window.bundle_id,
|
|
82
|
+
pid: window.pid,
|
|
83
|
+
x: window.bounds.x,
|
|
84
|
+
y: window.bounds.y,
|
|
85
|
+
width: window.bounds.width,
|
|
86
|
+
height: window.bounds.height,
|
|
87
|
+
}));
|
|
88
|
+
const render = (count: number, omitted: number) =>
|
|
89
|
+
encode(omitted === 0 ? { windows: rows.slice(0, count) } : { windows: rows.slice(0, count), omitted }, {
|
|
90
|
+
indent: 1,
|
|
91
|
+
});
|
|
92
|
+
const fits = (value: string) =>
|
|
93
|
+
Buffer.byteLength(value, "utf8") <= DEFAULT_MAX_BYTES && value.split("\n").length <= DEFAULT_MAX_LINES;
|
|
94
|
+
const maximumRows = Math.max(0, DEFAULT_MAX_LINES - 2);
|
|
95
|
+
|
|
96
|
+
if (rows.length <= maximumRows) {
|
|
97
|
+
const full = render(rows.length, 0);
|
|
98
|
+
if (fits(full)) return full;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let low = 0;
|
|
102
|
+
let high = Math.min(rows.length, maximumRows);
|
|
103
|
+
while (low < high) {
|
|
104
|
+
const middle = Math.ceil((low + high) / 2);
|
|
105
|
+
if (fits(render(middle, rows.length - middle))) low = middle;
|
|
106
|
+
else high = middle - 1;
|
|
107
|
+
}
|
|
108
|
+
return render(low, rows.length - low);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function registerAppshotTools(pi: ExtensionAPI, runHelper: RunHelper): void {
|
|
112
|
+
const rowState = createToolRowStateStore(pi, "appshot.tool-row-state");
|
|
113
|
+
|
|
114
|
+
pi.registerTool(
|
|
115
|
+
defineTool<typeof listWindowsSchema, undefined>({
|
|
116
|
+
name: "list_windows",
|
|
117
|
+
label: "List Windows",
|
|
118
|
+
description:
|
|
119
|
+
"List visible normal macOS windows as compact TOON with window IDs, titles, application identity, process IDs, and bounds. Requires macOS 14 or newer and Screen & System Audio Recording permission.",
|
|
120
|
+
promptSnippet: "Discover visible macOS application windows and their IDs",
|
|
121
|
+
promptGuidelines: [
|
|
122
|
+
"Use list_windows to discover graphical applications and select an exact window before calling screenshot_window.",
|
|
123
|
+
],
|
|
124
|
+
parameters: listWindowsSchema,
|
|
125
|
+
async execute(_toolCallId, _params, signal) {
|
|
126
|
+
if (process.platform !== "darwin") throw new Error("list_windows is only available on macOS");
|
|
127
|
+
const result = await runHelper(["list"], signal, 30_000);
|
|
128
|
+
if (result.code !== 0)
|
|
129
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || "Window listing failed");
|
|
130
|
+
let parsed: unknown;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(result.stdout);
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error("Window listing helper returned invalid data");
|
|
135
|
+
}
|
|
136
|
+
if (!Array.isArray(parsed) || !parsed.every(isWindowInfo)) {
|
|
137
|
+
throw new Error("Window listing helper returned invalid data");
|
|
138
|
+
}
|
|
139
|
+
return { content: [{ type: "text", text: encodeWindowList(parsed) }], details: undefined };
|
|
140
|
+
},
|
|
141
|
+
renderCall(_args, theme, context) {
|
|
142
|
+
rowState.watch(context.toolCallId, context.invalidate);
|
|
143
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
144
|
+
text.setText(formatToolRowTitle(rowState, context.toolCallId, "list_windows", theme));
|
|
145
|
+
return text;
|
|
146
|
+
},
|
|
147
|
+
renderResult(result, _options, _theme, context) {
|
|
148
|
+
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
|
|
149
|
+
const content = result.content.find((item) => item.type === "text");
|
|
150
|
+
text.setText(context.expanded && content?.type === "text" ? content.text : "");
|
|
151
|
+
return text;
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
pi.registerTool(
|
|
157
|
+
defineTool<typeof screenshotWindowSchema, ScreenshotDetails | undefined>({
|
|
158
|
+
name: "screenshot_window",
|
|
159
|
+
label: "Screenshot Window",
|
|
160
|
+
description:
|
|
161
|
+
"Capture one visible macOS window by its list_windows ID, resize it to fit within 1568×1568 pixels, save it as PNG, and inspect the image.",
|
|
162
|
+
promptSnippet: "Capture and inspect an exact visible macOS window by window ID",
|
|
163
|
+
promptGuidelines: ["Call list_windows before screenshot_window; pass its exact window_id and a PNG path."],
|
|
164
|
+
parameters: screenshotWindowSchema,
|
|
165
|
+
async execute(_toolCallId, params: ScreenshotWindowParams, signal, onUpdate, ctx) {
|
|
166
|
+
if (process.platform !== "darwin") throw new Error("screenshot_window is only available on macOS");
|
|
167
|
+
const rawPath = params.path.startsWith("@") ? params.path.slice(1) : params.path;
|
|
168
|
+
if (!rawPath.trim()) throw new Error("Screenshot path cannot be empty");
|
|
169
|
+
const absolutePath = isAbsolute(rawPath) ? rawPath : resolve(ctx.cwd, rawPath);
|
|
170
|
+
if (!absolutePath.toLowerCase().endsWith(".png")) throw new Error("Screenshot path must end in .png");
|
|
171
|
+
|
|
172
|
+
await onUpdate?.({
|
|
173
|
+
content: [{ type: "text", text: `Capturing window ${params.window_id}...` }],
|
|
174
|
+
details: undefined,
|
|
175
|
+
});
|
|
176
|
+
return withFileMutationQueue(absolutePath, async () => {
|
|
177
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
178
|
+
const temporaryPath = join(dirname(absolutePath), `.${basename(absolutePath)}.${randomUUID()}.tmp.png`);
|
|
179
|
+
try {
|
|
180
|
+
const result = await runHelper(["capture", String(params.window_id), temporaryPath], signal, 30_000);
|
|
181
|
+
if (result.code !== 0) {
|
|
182
|
+
throw new Error(result.stderr.trim() || result.stdout.trim() || "Window capture failed");
|
|
183
|
+
}
|
|
184
|
+
let imageSize: number;
|
|
185
|
+
try {
|
|
186
|
+
imageSize = (await stat(temporaryPath)).size;
|
|
187
|
+
} catch {
|
|
188
|
+
throw new Error(result.stderr.trim() || "Window capture produced no PNG file");
|
|
189
|
+
}
|
|
190
|
+
if (imageSize > MAX_PNG_BYTES) {
|
|
191
|
+
throw new Error(`Window capture exceeds the ${MAX_PNG_BYTES / 1024 / 1024} MiB attachment limit`);
|
|
192
|
+
}
|
|
193
|
+
const image = await readFile(temporaryPath);
|
|
194
|
+
if (
|
|
195
|
+
image.length < PNG_SIGNATURE.length ||
|
|
196
|
+
!image.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
|
|
197
|
+
) {
|
|
198
|
+
throw new Error("Window capture produced an invalid PNG file");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await rename(temporaryPath, absolutePath);
|
|
202
|
+
return {
|
|
203
|
+
content: [
|
|
204
|
+
{ type: "text", text: `Captured window ${params.window_id} to ${absolutePath}` },
|
|
205
|
+
{ type: "image", data: image.toString("base64"), mimeType: "image/png" },
|
|
206
|
+
],
|
|
207
|
+
details: { path: absolutePath, window_id: params.window_id },
|
|
208
|
+
};
|
|
209
|
+
} finally {
|
|
210
|
+
await rm(temporaryPath, { force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
pi.registerTool(
|
|
218
|
+
defineTool<typeof activateAppSchema, ActivationDetails>({
|
|
219
|
+
name: "activate_app",
|
|
220
|
+
label: "Activate App",
|
|
221
|
+
description:
|
|
222
|
+
"Bring a running macOS application and its windows to the foreground by process ID. Use a PID returned by list_windows.",
|
|
223
|
+
promptSnippet: "Bring a listed macOS application to the foreground by process ID",
|
|
224
|
+
promptGuidelines: [
|
|
225
|
+
"Use activate_app only when foregrounding a listed application is needed for visual validation because it changes user focus.",
|
|
226
|
+
],
|
|
227
|
+
parameters: activateAppSchema,
|
|
228
|
+
async execute(_toolCallId, params, signal) {
|
|
229
|
+
if (process.platform !== "darwin") throw new Error("activate_app is only available on macOS");
|
|
230
|
+
const result = await runHelper(["activate", String(params.pid)], signal, 5000);
|
|
231
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || "Activation failed");
|
|
232
|
+
return {
|
|
233
|
+
content: [{ type: "text", text: `Activated application PID ${params.pid}` }],
|
|
234
|
+
details: { pid: params.pid },
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
pi.on("session_start", () => rowState.clear());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export default function appshotExtension(pi: ExtensionAPI): void {
|
|
244
|
+
registerAppshotTools(pi, createNativeHelper(pi));
|
|
245
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { access, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
export interface HelperResult {
|
|
10
|
+
code: number;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RunHelper = (args: string[], signal: AbortSignal | undefined, timeout: number) => Promise<HelperResult>;
|
|
16
|
+
|
|
17
|
+
const MACH_O_64_MAGIC = Buffer.from([0xcf, 0xfa, 0xed, 0xfe]);
|
|
18
|
+
|
|
19
|
+
async function validExecutable(path: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await access(path, constants.X_OK);
|
|
22
|
+
const handle = await open(path, "r");
|
|
23
|
+
try {
|
|
24
|
+
const magic = Buffer.alloc(MACH_O_64_MAGIC.length);
|
|
25
|
+
const { bytesRead } = await handle.read(magic, 0, magic.length, 0);
|
|
26
|
+
return bytesRead === magic.length && magic.equals(MACH_O_64_MAGIC);
|
|
27
|
+
} finally {
|
|
28
|
+
await handle.close();
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createNativeHelper(pi: Pick<ExtensionAPI, "exec">): RunHelper {
|
|
36
|
+
let helperPromise: Promise<string> | undefined;
|
|
37
|
+
let resolvedExecutable: string | undefined;
|
|
38
|
+
|
|
39
|
+
async function buildHelper(signal: AbortSignal | undefined): Promise<string> {
|
|
40
|
+
const architecture = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x86_64" : undefined;
|
|
41
|
+
if (!architecture) throw new Error(`Appshot does not support ${process.arch} macOS processes`);
|
|
42
|
+
|
|
43
|
+
const sourcePath = fileURLToPath(new URL("capture.swift", import.meta.url));
|
|
44
|
+
const source = await readFile(sourcePath);
|
|
45
|
+
const sourceHash = createHash("sha256").update(source).digest("hex").slice(0, 16);
|
|
46
|
+
const cacheDirectory = join(homedir(), "Library", "Caches", "tau-agent", "appshot", architecture, sourceHash);
|
|
47
|
+
const executablePath = join(cacheDirectory, "tau-appshot");
|
|
48
|
+
if (await validExecutable(executablePath)) return executablePath;
|
|
49
|
+
|
|
50
|
+
await rm(executablePath, { force: true });
|
|
51
|
+
await mkdir(cacheDirectory, { recursive: true });
|
|
52
|
+
const temporaryPath = `${executablePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
53
|
+
try {
|
|
54
|
+
const result = await pi.exec(
|
|
55
|
+
"xcrun",
|
|
56
|
+
[
|
|
57
|
+
"swiftc",
|
|
58
|
+
"-parse-as-library",
|
|
59
|
+
"-target",
|
|
60
|
+
`${architecture}-apple-macosx14.0`,
|
|
61
|
+
sourcePath,
|
|
62
|
+
"-o",
|
|
63
|
+
temporaryPath,
|
|
64
|
+
],
|
|
65
|
+
{ signal, timeout: 120_000 },
|
|
66
|
+
);
|
|
67
|
+
if (result.code !== 0) {
|
|
68
|
+
throw new Error(`Failed to build macOS screenshot helper: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
69
|
+
}
|
|
70
|
+
await rename(temporaryPath, executablePath);
|
|
71
|
+
return executablePath;
|
|
72
|
+
} finally {
|
|
73
|
+
await rm(temporaryPath, { force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function executable(signal: AbortSignal | undefined): Promise<string> {
|
|
78
|
+
return (helperPromise ??= buildHelper(signal)
|
|
79
|
+
.then((path) => {
|
|
80
|
+
resolvedExecutable = path;
|
|
81
|
+
return path;
|
|
82
|
+
})
|
|
83
|
+
.catch((error: unknown) => {
|
|
84
|
+
helperPromise = undefined;
|
|
85
|
+
throw error;
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return async (args, signal, timeout) => {
|
|
90
|
+
const path = await executable(signal);
|
|
91
|
+
try {
|
|
92
|
+
return await pi.exec(path, args, { signal, timeout });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!signal?.aborted && resolvedExecutable === path) {
|
|
95
|
+
await rm(path, { force: true });
|
|
96
|
+
resolvedExecutable = undefined;
|
|
97
|
+
helperPromise = undefined;
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Attention
|
|
2
|
+
|
|
3
|
+
Sends a terminal-driven attention notification when Tau is ready for input.
|
|
4
|
+
|
|
5
|
+
## Behavior
|
|
6
|
+
|
|
7
|
+
- Emits an attention notification on `agent_end`.
|
|
8
|
+
- Listens for shared event `tau:agent.blocked` when Tau is waiting on user input.
|
|
9
|
+
- Uses the terminal or host OS notification path that best fits the current environment.
|
|
10
|
+
|
|
11
|
+
Other code can call:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
emitAgentBlocked(pi, { source: "my-extension", body: "Waiting for your input" });
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`title`, `body`, and `source` are optional. Notification defaults are `Tau` and `Ready for input`.
|
|
18
|
+
|
|
19
|
+
## Limits
|
|
20
|
+
|
|
21
|
+
- No configuration.
|
|
22
|
+
- No Windows-specific support.
|
|
23
|
+
- Exact notification behavior depends on terminal and OS support.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { onTauEvent } from "../../shared/events.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TITLE = "Tau";
|
|
6
|
+
const DEFAULT_BODY = "Ready for input";
|
|
7
|
+
const MACOS_SOUND = "/System/Library/Sounds/Submarine.aiff";
|
|
8
|
+
|
|
9
|
+
function oscText(value: string): string {
|
|
10
|
+
return [...value]
|
|
11
|
+
.map((char) => {
|
|
12
|
+
const code = char.charCodeAt(0);
|
|
13
|
+
return code < 32 || code === 127 || char === ";" ? " " : char;
|
|
14
|
+
})
|
|
15
|
+
.join("")
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function playMacOSSound(pi: ExtensionAPI): void {
|
|
20
|
+
if (process.platform !== "darwin") return;
|
|
21
|
+
|
|
22
|
+
pi.exec("afplay", [MACOS_SOUND], { timeout: 2000 }).catch(() => undefined);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function attentionExtension(pi: ExtensionAPI): void {
|
|
26
|
+
function notify(data: { title?: string; body?: string }): void {
|
|
27
|
+
const raw: unknown = data;
|
|
28
|
+
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
29
|
+
const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : DEFAULT_TITLE;
|
|
30
|
+
const body = typeof record.body === "string" && record.body.trim() ? record.body.trim() : DEFAULT_BODY;
|
|
31
|
+
const oscTitle = oscText(title);
|
|
32
|
+
const oscBody = oscText(body);
|
|
33
|
+
const osc777 = `\x1b]777;notify;${oscTitle};${oscBody}\x07`;
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
existsSync("/tmp/cmux.sock") ||
|
|
37
|
+
process.env.CMUX ||
|
|
38
|
+
process.env.CMUX_SESSION ||
|
|
39
|
+
Object.keys(process.env).some((key) => key.startsWith("CMUX_"))
|
|
40
|
+
) {
|
|
41
|
+
pi.exec("cmux", ["notify", "--title", title, "--body", body], { timeout: 2000 })
|
|
42
|
+
.then((result) => {
|
|
43
|
+
if (result.code !== 0) {
|
|
44
|
+
process.stdout.write(osc777);
|
|
45
|
+
playMacOSSound(pi);
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
.catch(() => {
|
|
49
|
+
process.stdout.write(osc777);
|
|
50
|
+
playMacOSSound(pi);
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (process.env.KITTY_WINDOW_ID) {
|
|
56
|
+
process.stdout.write(`\x1b]99;i=1:e=1:d=0:p=title;${oscTitle}\x1b\\`);
|
|
57
|
+
process.stdout.write(`\x1b]99;i=1:e=1:d=1:p=body;${oscBody}\x1b\\`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.stdout.write(osc777);
|
|
62
|
+
playMacOSSound(pi);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onTauEvent(pi, "attention.agent-blocked", "tau:agent.blocked", notify);
|
|
66
|
+
|
|
67
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
68
|
+
if (ctx.mode === "print") return;
|
|
69
|
+
notify({ title: DEFAULT_TITLE, body: DEFAULT_BODY });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Auto-Name
|
|
2
|
+
|
|
3
|
+
Names chat sessions automatically from the user's first prompt, in the background, while the agent works.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
New sessions show up as their first message in the session selector. Auto-Name gives them a short, descriptive title without making the user run `/name` or interrupting the agent.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
- On the first user prompt, fires a background model call to summarize it into a short name.
|
|
12
|
+
- Sets the name with `pi.setSessionName()` when it resolves. The agent is never blocked.
|
|
13
|
+
- **Never overwrites an existing name** — a manual `/name` or a prior auto-name always sticks.
|
|
14
|
+
- The model may decline if the prompt is too short, ambiguous, or a bare command.
|
|
15
|
+
- Uses cheap fallback models (shared with `/commit`); a provider that fails is cooled down for both features.
|
|
16
|
+
- Cancels on session switch (`/new`, `/resume`, `/reload`) so a name is never written into the wrong session.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
No invocation needed. Load extension, then reload:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
/reload
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Send a prompt; the session gets named within a few seconds. Rename anytime with `/name`.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { type ThinkingLevel, type Tool, Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
getBranchInjectedContexts,
|
|
5
|
+
getPendingInjectedContexts,
|
|
6
|
+
type InjectedContext,
|
|
7
|
+
} from "../../shared/injected-context.ts";
|
|
8
|
+
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
9
|
+
import { errorText, truncAt } from "../../shared/text.ts";
|
|
10
|
+
|
|
11
|
+
const STATUS_KEY = "auto-name";
|
|
12
|
+
const SENTINEL = "NONE";
|
|
13
|
+
const MAX_NAME_LENGTH = 80;
|
|
14
|
+
const MAX_NAMING_INPUT_CHARS = 120_000;
|
|
15
|
+
const AUTO_NAME_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
|
|
16
|
+
{ provider: "openai-codex", model: "gpt-5.4-mini", reasoning: "medium" },
|
|
17
|
+
{ provider: "openrouter", model: "cohere/north-mini-code:free", reasoning: "high" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const NAMING_PROMPT = [
|
|
21
|
+
"You are naming a chat session based on the user's first message and any injected hidden context.",
|
|
22
|
+
"Your only job is to call the name_session tool exactly once.",
|
|
23
|
+
"Do not write any text before or after the tool call. Do not answer in prose. Do not ask a follow-up question.",
|
|
24
|
+
"Answer the question: what is this chat about?",
|
|
25
|
+
"",
|
|
26
|
+
"If injected context is present, treat it as primary; the user message may be generic command text.",
|
|
27
|
+
"",
|
|
28
|
+
"Rules:",
|
|
29
|
+
"- A brief natural phrase that says what the work is about. Not a URL slug, not a keyword list.",
|
|
30
|
+
"- If the work targets a specific named feature, component, or file in the repository, call it out by name.",
|
|
31
|
+
"- A person scanning a session list a week later should know what this chat was about.",
|
|
32
|
+
"- No quotes, no trailing punctuation, no markdown, no code fences.",
|
|
33
|
+
'- No filler like "task:", "chat about", "working on".',
|
|
34
|
+
"",
|
|
35
|
+
'Good: "Improving auto-name model selection and prompt"',
|
|
36
|
+
'Bad: "automatic naming function and prompt improvement"',
|
|
37
|
+
"",
|
|
38
|
+
'Good: "Cleaning up old plan notes before commit"',
|
|
39
|
+
'Bad: "commit plan note and previous plan"',
|
|
40
|
+
"",
|
|
41
|
+
"The bad examples are keyword slugs — compressed word salads that tell you nothing about the actual work.",
|
|
42
|
+
"",
|
|
43
|
+
`- If the message is too short, ambiguous, a bare command, or has no clear topic, submit exactly: ${SENTINEL}`,
|
|
44
|
+
"- Call name_session with the name only.",
|
|
45
|
+
].join("\n");
|
|
46
|
+
const NAME_SESSION_TOOL = {
|
|
47
|
+
name: "name_session",
|
|
48
|
+
description: "Submit the generated session name. This is the only allowed response.",
|
|
49
|
+
parameters: Type.Object({
|
|
50
|
+
name: Type.String({ description: `Session name, or exactly ${SENTINEL} when there is no clear topic.` }),
|
|
51
|
+
}),
|
|
52
|
+
} satisfies Tool;
|
|
53
|
+
|
|
54
|
+
let generation = 0;
|
|
55
|
+
let activeController: AbortController | undefined;
|
|
56
|
+
|
|
57
|
+
export default function autoNameExtension(pi: ExtensionAPI): void {
|
|
58
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
59
|
+
const prompt = buildNamingInput(event.prompt, [
|
|
60
|
+
...getBranchInjectedContexts(ctx.sessionManager.getBranch()),
|
|
61
|
+
...getPendingInjectedContexts(),
|
|
62
|
+
]);
|
|
63
|
+
if (!prompt || pi.getSessionName()) return;
|
|
64
|
+
|
|
65
|
+
const myGen = ++generation;
|
|
66
|
+
activeController?.abort();
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
activeController = controller;
|
|
69
|
+
|
|
70
|
+
void runAutoName(pi, ctx, controller, myGen, prompt);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
74
|
+
generation++;
|
|
75
|
+
activeController?.abort();
|
|
76
|
+
activeController = undefined;
|
|
77
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runAutoName(
|
|
82
|
+
pi: ExtensionAPI,
|
|
83
|
+
ctx: ExtensionContext,
|
|
84
|
+
controller: AbortController,
|
|
85
|
+
myGen: number,
|
|
86
|
+
prompt: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const ui = ctx.ui;
|
|
89
|
+
try {
|
|
90
|
+
const candidates = await resolveCandidates(ctx, AUTO_NAME_MODELS);
|
|
91
|
+
const result = await generateToolValidated(
|
|
92
|
+
{ ui, signal: controller.signal },
|
|
93
|
+
candidates,
|
|
94
|
+
`${NAMING_PROMPT}\n\n${prompt}`,
|
|
95
|
+
NAME_SESSION_TOOL,
|
|
96
|
+
nameResultFromToolInput,
|
|
97
|
+
(error, text) =>
|
|
98
|
+
[
|
|
99
|
+
`That session name failed validation: ${error.message}`,
|
|
100
|
+
`Call ${NAME_SESSION_TOOL.name} again with corrected arguments only.`,
|
|
101
|
+
"Previous response:",
|
|
102
|
+
text,
|
|
103
|
+
].join("\n"),
|
|
104
|
+
{ statusKey: STATUS_KEY, notifyOnFallback: false, maxAttempts: 3 },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (controller.signal.aborted || myGen !== generation) return;
|
|
108
|
+
if (!result.name) return;
|
|
109
|
+
if (pi.getSessionName()) return;
|
|
110
|
+
|
|
111
|
+
pi.setSessionName(result.name);
|
|
112
|
+
ui.notify(`Session named: ${result.name}`, "info");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (!controller.signal.aborted && myGen === generation) {
|
|
115
|
+
ui.notify(`Session naming failed: ${errorText(error)}`, "error");
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
if (activeController === controller) {
|
|
119
|
+
activeController = undefined;
|
|
120
|
+
ui.setStatus(STATUS_KEY, undefined);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildNamingInput(userMessage: string, contexts: readonly InjectedContext[]): string {
|
|
126
|
+
const prompt = userMessage.trim();
|
|
127
|
+
if (!prompt && contexts.length === 0) return "";
|
|
128
|
+
|
|
129
|
+
const parts = [
|
|
130
|
+
"User message:",
|
|
131
|
+
prompt || "(none)",
|
|
132
|
+
...contexts.flatMap((context, index) => [
|
|
133
|
+
"",
|
|
134
|
+
`Injected context ${index + 1}: ${context.details.title ?? context.details.source}`,
|
|
135
|
+
truncAt(context.content, MAX_NAMING_INPUT_CHARS),
|
|
136
|
+
]),
|
|
137
|
+
];
|
|
138
|
+
return truncAt(parts.join("\n"), MAX_NAMING_INPUT_CHARS);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface NameResult {
|
|
142
|
+
name: string | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function nameResultFromToolInput(input: unknown): NameResult {
|
|
146
|
+
if (!isNameSessionToolInput(input)) throw new Error("Session name tool input is malformed.");
|
|
147
|
+
return validateName(input.name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isNameSessionToolInput(input: unknown): input is { name: string } {
|
|
151
|
+
return typeof input === "object" && input !== null && "name" in input && typeof input.name === "string";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateName(text: string): NameResult {
|
|
155
|
+
const name = text
|
|
156
|
+
.trim()
|
|
157
|
+
.replace(/^["'\s]+|["'\s]+$/g, "")
|
|
158
|
+
.replace(/[.`]+$/g, "")
|
|
159
|
+
.trim()
|
|
160
|
+
.slice(0, MAX_NAME_LENGTH);
|
|
161
|
+
if (!name || name.toUpperCase() === SENTINEL) return { name: null };
|
|
162
|
+
return { name };
|
|
163
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Branch
|
|
2
|
+
|
|
3
|
+
Switch to a recently updated Git branch, or create a typed branch from the
|
|
4
|
+
current `HEAD`.
|
|
5
|
+
|
|
6
|
+
Run `/branch` to open a picker containing local branches and already-fetched
|
|
7
|
+
remote branches. The picker shows up to 10 branches at once, ordered by their
|
|
8
|
+
latest commit with the most recent first.
|
|
9
|
+
|
|
10
|
+
Press `Ctrl+F` in the picker to fetch all configured remotes and refresh the
|
|
11
|
+
open list. Selecting a remote branch creates a local branch that tracks it.
|
|
12
|
+
Cancelling the picker does not switch branches.
|
|
13
|
+
|
|
14
|
+
Run `/branch new`, choose `feature`, `fix`, or `chore`, then enter a branch
|
|
15
|
+
name. The name is lowercased, punctuation and whitespace become hyphens, and
|
|
16
|
+
leading or trailing hyphens are removed.
|
|
17
|
+
|
|
18
|
+
For example, selecting `fix` and entering `Fix login. Please` creates and
|
|
19
|
+
switches to `fix/fix-login-please`.
|
|
20
|
+
|
|
21
|
+
Cancelling either creation prompt creates nothing. Uncommitted changes follow
|
|
22
|
+
normal `git switch` behavior for both switching and creation.
|