tau-coding-agent 0.1.4 → 0.1.5

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +48 -42
  3. package/extensions/branch-term.ts +8 -13
  4. package/extensions/btw.ts +4 -4
  5. package/extensions/ghostty.ts +64 -11
  6. package/extensions/insights.ts +4 -3
  7. package/extensions/loop.ts +37 -9
  8. package/extensions/memory.ts +5 -10
  9. package/extensions/notify.ts +24 -5
  10. package/extensions/openai-fast.ts +6 -4
  11. package/extensions/openai-verbosity.ts +6 -4
  12. package/extensions/review/fix.ts +239 -0
  13. package/extensions/review/git.ts +517 -0
  14. package/extensions/review/index.ts +232 -0
  15. package/extensions/review/message-queue.ts +249 -0
  16. package/extensions/review/models.ts +589 -0
  17. package/extensions/review/prompts.ts +337 -0
  18. package/extensions/review/renderers/inline.ts +126 -0
  19. package/extensions/review/request.ts +504 -0
  20. package/extensions/review/review.ts +1305 -0
  21. package/extensions/review/runner.ts +522 -0
  22. package/extensions/review/runtime.ts +275 -0
  23. package/extensions/review/schema.ts +160 -0
  24. package/extensions/review/submit-review-tool.ts +66 -0
  25. package/extensions/review/submit-triage-tool.ts +59 -0
  26. package/extensions/review/triage.ts +746 -0
  27. package/extensions/sandbox/index.ts +399 -102
  28. package/extensions/{interlude.ts → stash.ts} +13 -11
  29. package/extensions/tool-display-mode.ts +2 -3
  30. package/extensions/usage/index.ts +8 -4
  31. package/extensions/websearch/README.md +1 -1
  32. package/extensions/websearch/browser/chromium.ts +42 -31
  33. package/extensions/websearch/browser/discovery.ts +11 -9
  34. package/extensions/websearch/browser/firefox.ts +10 -7
  35. package/extensions/websearch/browser/fs.ts +10 -0
  36. package/extensions/websearch/browser/sqlite.ts +22 -15
  37. package/extensions/websearch/config.ts +2 -2
  38. package/extensions/websearch/index.ts +6 -6
  39. package/extensions/worktree.ts +33 -33
  40. package/package.json +3 -3
  41. package/skills/git-clean-history/SKILL.md +1 -0
  42. package/skills/git-commit/SKILL.md +2 -2
  43. package/skills/oracle/scripts/oracle +28 -11
  44. package/themes/tau-dark.json +2 -1
  45. package/extensions/review.ts +0 -4420
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Unified /review + /triage + /fix extension.
3
+ *
4
+ * What this extension does:
5
+ * - /review runs up to 6 parallel focuses (general, security, reuse, quality, testing, efficiency),
6
+ * then emits a single findings report.
7
+ * - /triage fetches PR feedback from GitHub, inspects the checked-out PR diff,
8
+ * and classifies each item as address, push_back, research, or ignore.
9
+ * - /fix applies findings from the review report. It reuses the last message only
10
+ * when that message is a matching review payload, even if stale. Otherwise it
11
+ * runs a fresh review first. If that fresh review becomes stale while running,
12
+ * /fix shows the findings and waits for an explicit rerun before applying fixes.
13
+ * context=... guides the fix pass without invalidating reusable review payloads.
14
+ * /fix loop repeats review + fix until the review produces no findings.
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
18
+
19
+ import { prepareFixReviewDetails, runFixLoop, runFixPassFromReview } from "./fix.js";
20
+ import { createReviewMessageQueue, type ReviewMessageQueue } from "./message-queue.js";
21
+ import {
22
+ getFixArgumentCompletions,
23
+ getReviewArgumentCompletions,
24
+ parseCommandRequest,
25
+ parseFixCommandRequest,
26
+ parseTriagePrRef,
27
+ showReviewHelp,
28
+ type ParseFailure,
29
+ } from "./request.js";
30
+ import { runReviewPipeline } from "./review.js";
31
+ import {
32
+ acquireReviewRunLock,
33
+ createAgentRunTracker,
34
+ handleReviewSessionShutdown,
35
+ handleReviewSessionStart,
36
+ notify,
37
+ recordPromptEnd,
38
+ recordPromptStart,
39
+ releaseReviewRunLock,
40
+ type AgentEndMessages,
41
+ } from "./runtime.js";
42
+ import { runTriagePipeline } from "./triage.js";
43
+
44
+ function handleParseFailure(
45
+ pi: ExtensionAPI,
46
+ ctx: ExtensionCommandContext,
47
+ result: ParseFailure,
48
+ ): void {
49
+ if (result.help) {
50
+ showReviewHelp(pi);
51
+ return;
52
+ }
53
+ if (result.error) {
54
+ notify(ctx, result.error, "error");
55
+ }
56
+ }
57
+
58
+ function stopAndFlushReviewQueue(
59
+ ctx: ExtensionCommandContext,
60
+ reviewMessageQueue: ReviewMessageQueue,
61
+ stopReviewQueue: () => void,
62
+ ): void {
63
+ stopReviewQueue();
64
+ reviewMessageQueue.flushAll(ctx, { forceFollowUp: true });
65
+ }
66
+
67
+ type BackgroundRunResult = { ok: true } | { ok: false; error: string };
68
+
69
+ function startQueuedBackgroundRun(
70
+ ctx: ExtensionCommandContext,
71
+ reviewMessageQueue: ReviewMessageQueue,
72
+ options: {
73
+ busyMessage: string;
74
+ startMessage: string;
75
+ failurePrefix: string;
76
+ run: () => Promise<BackgroundRunResult>;
77
+ },
78
+ ): void {
79
+ const sessionKey = acquireReviewRunLock(ctx, options.busyMessage);
80
+ if (!sessionKey) return;
81
+
82
+ const stopReviewQueue = reviewMessageQueue.start(ctx);
83
+ notify(ctx, options.startMessage, "info");
84
+ void (async () => {
85
+ try {
86
+ const result = await options.run();
87
+ if (!result.ok) {
88
+ notify(ctx, result.error, "error");
89
+ }
90
+ } catch (error) {
91
+ notify(
92
+ ctx,
93
+ `${options.failurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
94
+ "error",
95
+ );
96
+ } finally {
97
+ releaseReviewRunLock(sessionKey);
98
+ stopAndFlushReviewQueue(ctx, reviewMessageQueue, stopReviewQueue);
99
+ }
100
+ })();
101
+ }
102
+
103
+ export default function reviewExtension(pi: ExtensionAPI) {
104
+ const reviewMessageQueue = createReviewMessageQueue(pi);
105
+ const agentTracker = createAgentRunTracker();
106
+
107
+ pi.events.on("ui:prompt_start", () => {
108
+ recordPromptStart();
109
+ });
110
+
111
+ pi.events.on("ui:prompt_end", () => {
112
+ recordPromptEnd();
113
+ });
114
+
115
+ pi.on("input", async (event, ctx) => {
116
+ if (!reviewMessageQueue.handleInput(event, ctx)) return { action: "continue" };
117
+ return { action: "handled" };
118
+ });
119
+
120
+ pi.on("agent_start", async () => {
121
+ agentTracker.handleStart();
122
+ });
123
+
124
+ pi.on("session_start", async (_event, ctx) => {
125
+ handleReviewSessionStart(ctx);
126
+ agentTracker.reset();
127
+ });
128
+
129
+ pi.on("session_shutdown", async (_event, ctx) => {
130
+ handleReviewSessionShutdown(ctx);
131
+ reviewMessageQueue.clear(ctx);
132
+ agentTracker.reset();
133
+ });
134
+
135
+ pi.on("agent_end", async (event) => {
136
+ agentTracker.handleEnd({
137
+ messages: event.messages as AgentEndMessages,
138
+ });
139
+ });
140
+
141
+ pi.on("agent_settled", async () => {
142
+ agentTracker.handleSettled();
143
+ });
144
+
145
+ pi.registerCommand("review", {
146
+ description:
147
+ "Run findings-only review across up to 6 focuses (general/security/reuse/quality/testing/efficiency). Use /review help for full usage.",
148
+ getArgumentCompletions: getReviewArgumentCompletions,
149
+ handler: async (args, ctx) => {
150
+ const parsed = parseCommandRequest(args);
151
+ if (!parsed.ok) {
152
+ handleParseFailure(pi, ctx, parsed);
153
+ return;
154
+ }
155
+
156
+ startQueuedBackgroundRun(ctx, reviewMessageQueue, {
157
+ busyMessage: "A /review run is already active in this session.",
158
+ startMessage: "Starting review in background...",
159
+ failurePrefix: "Review run failed",
160
+ run: () => runReviewPipeline(pi, ctx, parsed.value, "review"),
161
+ });
162
+ },
163
+ });
164
+
165
+ pi.registerCommand("triage", {
166
+ description:
167
+ "Fetch PR feedback from GitHub, inspect the checked-out PR diff, and classify each item as address, push_back, research, or ignore.",
168
+ handler: async (args, ctx) => {
169
+ const parsed = parseTriagePrRef(args);
170
+ if (!parsed.ok) {
171
+ handleParseFailure(pi, ctx, parsed);
172
+ return;
173
+ }
174
+
175
+ startQueuedBackgroundRun(ctx, reviewMessageQueue, {
176
+ busyMessage:
177
+ "A review-related run is already active in this session. Wait for it to finish before /triage.",
178
+ startMessage: "Starting PR triage in background...",
179
+ failurePrefix: "PR triage failed",
180
+ run: () => runTriagePipeline(pi, ctx, parsed.value),
181
+ });
182
+ },
183
+ });
184
+
185
+ pi.registerCommand("fix", {
186
+ description:
187
+ "Fix findings when the last message is a matching /review result. Otherwise, runs review first. Use /fix loop to repeat review + fix until clean. context=... guides the fix pass without forcing a fresh review. If the last review is stale, /fix warns and continues; if a /fix-started review goes stale mid-run, /fix shows the findings and applies no fixes. Use /review help for modes/options.",
188
+ getArgumentCompletions: getFixArgumentCompletions,
189
+ handler: async (args, ctx) => {
190
+ const parsed = parseFixCommandRequest(args);
191
+ if (!parsed.ok) {
192
+ handleParseFailure(pi, ctx, parsed);
193
+ return;
194
+ }
195
+
196
+ const { loop, request } = parsed.value;
197
+ if (loop && (!ctx.isIdle() || ctx.hasPendingMessages())) {
198
+ notify(ctx, "Wait for the agent to become idle before starting /fix loop.", "warning");
199
+ return;
200
+ }
201
+
202
+ const sessionKey = acquireReviewRunLock(
203
+ ctx,
204
+ "A /review run is active in this session. Wait for it to finish before /fix.",
205
+ );
206
+ if (!sessionKey) return;
207
+
208
+ const stopReviewQueue = reviewMessageQueue.start(ctx);
209
+ try {
210
+ if (loop) {
211
+ notify(ctx, "Starting fix loop...", "info");
212
+ await runFixLoop(pi, ctx, request, agentTracker, reviewMessageQueue);
213
+ return;
214
+ }
215
+
216
+ const reviewDetails = await prepareFixReviewDetails(pi, ctx, request);
217
+ if (!reviewDetails) return;
218
+ await runFixPassFromReview(
219
+ pi,
220
+ ctx,
221
+ reviewDetails,
222
+ request.additionalContext,
223
+ agentTracker,
224
+ reviewMessageQueue,
225
+ );
226
+ } finally {
227
+ releaseReviewRunLock(sessionKey);
228
+ stopAndFlushReviewQueue(ctx, reviewMessageQueue, stopReviewQueue);
229
+ }
230
+ },
231
+ });
232
+ }
@@ -0,0 +1,249 @@
1
+ import {
2
+ keyText,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ type InputEvent,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { getKeybindings } from "@earendil-works/pi-tui";
8
+
9
+ import { getReviewSessionKey } from "./runtime.js";
10
+
11
+ const WIDGET_KEY = "review-message-queue";
12
+ const PREVIEW_MAX_LENGTH = 180;
13
+
14
+ type QueueMode = "steer" | "followUp";
15
+ type FlushOptions = { forceFollowUp?: boolean };
16
+ type QueuedReviewMessage = {
17
+ mode: QueueMode;
18
+ text: string;
19
+ images?: NonNullable<InputEvent["images"]>;
20
+ };
21
+ type QueueState = {
22
+ active: boolean;
23
+ messages: QueuedReviewMessage[];
24
+ unsubscribeFollowUpShortcut?: () => void;
25
+ };
26
+
27
+ export type ReviewMessageQueue = ReturnType<typeof createReviewMessageQueue>;
28
+
29
+ export function createReviewMessageQueue(pi: ExtensionAPI) {
30
+ const states = new Map<string, QueueState>();
31
+
32
+ function start(ctx: ExtensionContext): () => void {
33
+ const sessionKey = getReviewSessionKey(ctx);
34
+ const state = getState(sessionKey);
35
+ state.active = true;
36
+
37
+ if (ctx.hasUI && !state.unsubscribeFollowUpShortcut) {
38
+ state.unsubscribeFollowUpShortcut = ctx.ui.onTerminalInput((data) => {
39
+ if (!isActive(sessionKey)) return undefined;
40
+
41
+ if (matchesConfiguredKey(data, "app.message.dequeue")) {
42
+ return restoreMessagesToEditor(ctx) ? { consume: true } : undefined;
43
+ }
44
+
45
+ if (!matchesConfiguredKey(data, "app.message.followUp")) return undefined;
46
+
47
+ const text = ctx.ui.getEditorText().trim();
48
+ if (!text) return { consume: true };
49
+ if (isImmediateCommand(text)) return undefined;
50
+
51
+ queueMessage(ctx, "followUp", { text });
52
+ ctx.ui.setEditorText("");
53
+ return { consume: true };
54
+ });
55
+ }
56
+
57
+ render(ctx);
58
+
59
+ let stopped = false;
60
+ return () => {
61
+ if (stopped) return;
62
+ stopped = true;
63
+ stop(ctx);
64
+ };
65
+ }
66
+
67
+ function handleInput(event: InputEvent, ctx: ExtensionContext): boolean {
68
+ const sessionKey = getReviewSessionKey(ctx);
69
+ if (!isActive(sessionKey)) return false;
70
+ if (event.source === "extension") return false;
71
+
72
+ // When the main agent is already streaming, Pi's built-in steering/follow-up
73
+ // queues are available. Only provide the review-owned queue while review work
74
+ // is running in the background and the main session is idle.
75
+ if (event.streamingBehavior) return false;
76
+ if (!ctx.isIdle()) return false;
77
+ if (isImmediateCommand(event.text)) return false;
78
+ if (!event.text.trim() && !event.images?.length) return false;
79
+
80
+ queueMessage(ctx, "steer", {
81
+ text: event.text,
82
+ images: event.images?.length ? [...event.images] : undefined,
83
+ });
84
+ return true;
85
+ }
86
+
87
+ function flushSteering(ctx: ExtensionContext, options: FlushOptions = {}): boolean {
88
+ return flush(ctx, (message) => message.mode === "steer", options);
89
+ }
90
+
91
+ function flushAll(ctx: ExtensionContext, options: FlushOptions = {}): boolean {
92
+ return flush(ctx, () => true, options);
93
+ }
94
+
95
+ function clear(ctx: ExtensionContext): void {
96
+ const sessionKey = getReviewSessionKey(ctx);
97
+ const state = states.get(sessionKey);
98
+ state?.unsubscribeFollowUpShortcut?.();
99
+ states.delete(sessionKey);
100
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
101
+ }
102
+
103
+ function stop(ctx: ExtensionContext): void {
104
+ const sessionKey = getReviewSessionKey(ctx);
105
+ const state = states.get(sessionKey);
106
+ if (!state) return;
107
+
108
+ state.active = false;
109
+ state.unsubscribeFollowUpShortcut?.();
110
+ state.unsubscribeFollowUpShortcut = undefined;
111
+ render(ctx);
112
+ }
113
+
114
+ function queueMessage(
115
+ ctx: ExtensionContext,
116
+ mode: QueueMode,
117
+ message: Omit<QueuedReviewMessage, "mode">,
118
+ ): void {
119
+ const sessionKey = getReviewSessionKey(ctx);
120
+ const state = getState(sessionKey);
121
+ state.messages.push({ mode, ...message });
122
+ render(ctx);
123
+ }
124
+
125
+ function restoreMessagesToEditor(ctx: ExtensionContext): boolean {
126
+ const state = states.get(getReviewSessionKey(ctx));
127
+ if (!state?.messages.length) return false;
128
+
129
+ const queuedText = state.messages.map((message) => message.text).join("\n\n");
130
+ const currentText = ctx.ui.getEditorText();
131
+ const combinedText = [queuedText, currentText].filter((text) => text.trim()).join("\n\n");
132
+ state.messages = [];
133
+ ctx.ui.setEditorText(combinedText);
134
+ render(ctx);
135
+ return true;
136
+ }
137
+
138
+ function flush(
139
+ ctx: ExtensionContext,
140
+ predicate: (message: QueuedReviewMessage) => boolean,
141
+ options: FlushOptions = {},
142
+ ): boolean {
143
+ const sessionKey = getReviewSessionKey(ctx);
144
+ const state = states.get(sessionKey);
145
+ if (!state?.messages.length) return false;
146
+
147
+ const selected: QueuedReviewMessage[] = [];
148
+ const remaining: QueuedReviewMessage[] = [];
149
+ for (const message of state.messages) {
150
+ if (predicate(message)) selected.push(message);
151
+ else remaining.push(message);
152
+ }
153
+ if (selected.length === 0) return false;
154
+
155
+ state.messages = remaining;
156
+ render(ctx);
157
+
158
+ const text = selected
159
+ .map((message) => message.text.trim())
160
+ .filter(Boolean)
161
+ .join("\n\n");
162
+ const images = selected.flatMap((message) => message.images ?? []);
163
+ const content = images.length ? [{ type: "text" as const, text }, ...images] : text;
164
+ const deliveryMode = options.forceFollowUp ? "followUp" : selected[0]?.mode;
165
+ const delivery =
166
+ !options.forceFollowUp && ctx.isIdle() && !ctx.hasPendingMessages()
167
+ ? undefined
168
+ : { deliverAs: deliveryMode ?? "followUp" };
169
+ pi.sendUserMessage(content, delivery);
170
+ return true;
171
+ }
172
+
173
+ function getState(sessionKey: string): QueueState {
174
+ let state = states.get(sessionKey);
175
+ if (!state) {
176
+ state = { active: false, messages: [] };
177
+ states.set(sessionKey, state);
178
+ }
179
+ return state;
180
+ }
181
+
182
+ function isActive(sessionKey: string): boolean {
183
+ return states.get(sessionKey)?.active ?? false;
184
+ }
185
+
186
+ function render(ctx: ExtensionContext): void {
187
+ if (!ctx.hasUI) return;
188
+
189
+ const messages = states.get(getReviewSessionKey(ctx))?.messages ?? [];
190
+ if (messages.length === 0) {
191
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
192
+ return;
193
+ }
194
+
195
+ const lines = messages.map((message) => {
196
+ const label = message.mode === "steer" ? "Steering" : "Follow-up";
197
+ return ctx.ui.theme.fg("dim", `${label}: ${formatPreview(message)}`);
198
+ });
199
+ lines.push(
200
+ ctx.ui.theme.fg(
201
+ "dim",
202
+ `↳ ${appKeyDisplay("app.message.dequeue")} to edit all queued messages`,
203
+ ),
204
+ );
205
+ ctx.ui.setWidget(WIDGET_KEY, lines);
206
+ }
207
+
208
+ return {
209
+ start,
210
+ handleInput,
211
+ flushSteering,
212
+ flushAll,
213
+ clear,
214
+ };
215
+ }
216
+
217
+ function matchesConfiguredKey(data: string, keybinding: Parameters<typeof keyText>[0]): boolean {
218
+ return getKeybindings().matches(data, keybinding);
219
+ }
220
+
221
+ function isImmediateCommand(text: string): boolean {
222
+ return text.trimStart().startsWith("!");
223
+ }
224
+
225
+ function appKeyDisplay(keybinding: Parameters<typeof keyText>[0]): string {
226
+ return capitalizeKey(keyText(keybinding));
227
+ }
228
+
229
+ function capitalizeKey(key: string): string {
230
+ return key
231
+ .split("/")
232
+ .map((k) =>
233
+ k
234
+ .split("+")
235
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
236
+ .join("+"),
237
+ )
238
+ .join("/");
239
+ }
240
+
241
+ function formatPreview(message: QueuedReviewMessage): string {
242
+ const text = message.text.replace(/\s+/g, " ").trim();
243
+ const truncated =
244
+ text.length > PREVIEW_MAX_LENGTH ? `${text.slice(0, PREVIEW_MAX_LENGTH - 1)}…` : text;
245
+ const imageCount = message.images?.length ?? 0;
246
+ if (imageCount === 0) return truncated || "[empty message]";
247
+ const imageLabel = `${imageCount} image${imageCount === 1 ? "" : "s"}`;
248
+ return truncated ? `${truncated} [${imageLabel}]` : `[${imageLabel}]`;
249
+ }