tau-coding-agent 0.1.3 → 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 (60) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +49 -43
  3. package/extensions/branch-term.ts +11 -16
  4. package/extensions/btw.ts +8 -8
  5. package/extensions/ghostty.ts +65 -12
  6. package/extensions/git-diff-stats.ts +1 -1
  7. package/extensions/git-pr-status.ts +1 -1
  8. package/extensions/insights.ts +6 -5
  9. package/extensions/loop.ts +39 -11
  10. package/extensions/memory.ts +6 -5
  11. package/extensions/notify.ts +25 -6
  12. package/extensions/openai-fast.ts +6 -4
  13. package/extensions/openai-verbosity.ts +6 -4
  14. package/extensions/review/fix.ts +239 -0
  15. package/extensions/review/git.ts +517 -0
  16. package/extensions/review/index.ts +232 -0
  17. package/extensions/review/message-queue.ts +249 -0
  18. package/extensions/review/models.ts +589 -0
  19. package/extensions/review/prompts.ts +337 -0
  20. package/extensions/review/renderers/inline.ts +126 -0
  21. package/extensions/review/request.ts +504 -0
  22. package/extensions/review/review.ts +1305 -0
  23. package/extensions/review/runner.ts +522 -0
  24. package/extensions/review/runtime.ts +275 -0
  25. package/extensions/review/schema.ts +160 -0
  26. package/extensions/review/submit-review-tool.ts +66 -0
  27. package/extensions/review/submit-triage-tool.ts +59 -0
  28. package/extensions/review/triage.ts +746 -0
  29. package/extensions/sandbox/index.ts +428 -103
  30. package/extensions/{interlude.ts → stash.ts} +13 -11
  31. package/extensions/tool-display-mode.ts +173 -17
  32. package/extensions/usage/anthropic.ts +1 -1
  33. package/extensions/usage/github-copilot.ts +1 -1
  34. package/extensions/usage/index.ts +9 -5
  35. package/extensions/usage/minimax.ts +1 -1
  36. package/extensions/usage/openai-codex.ts +1 -1
  37. package/extensions/usage/openrouter.ts +1 -1
  38. package/extensions/usage/providers.ts +0 -2
  39. package/extensions/usage/types.ts +1 -2
  40. package/extensions/usage/zai.ts +1 -1
  41. package/extensions/websearch/README.md +1 -1
  42. package/extensions/websearch/browser/chromium.ts +42 -31
  43. package/extensions/websearch/browser/discovery.ts +11 -9
  44. package/extensions/websearch/browser/firefox.ts +10 -7
  45. package/extensions/websearch/browser/fs.ts +10 -0
  46. package/extensions/websearch/browser/sqlite.ts +22 -15
  47. package/extensions/websearch/config.ts +2 -2
  48. package/extensions/websearch/index.ts +9 -9
  49. package/extensions/websearch/providers/anthropic.pi.ts +1 -1
  50. package/extensions/websearch/providers/gemini.pi.ts +1 -1
  51. package/extensions/websearch/providers/openai-codex.pi.ts +1 -1
  52. package/extensions/websearch/providers/pi-model.shared.ts +2 -2
  53. package/extensions/worktree.ts +36 -36
  54. package/package.json +7 -7
  55. package/skills/git-clean-history/SKILL.md +1 -0
  56. package/skills/git-commit/SKILL.md +2 -2
  57. package/skills/oracle/scripts/oracle +28 -12
  58. package/themes/tau-dark.json +2 -1
  59. package/extensions/review.ts +0 -4347
  60. package/extensions/usage/google-gemini-cli.ts +0 -232
@@ -7,15 +7,16 @@
7
7
  */
8
8
 
9
9
  import { Type } from "typebox";
10
- import { complete, type Api, type Model, type UserMessage } from "@mariozechner/pi-ai";
10
+ import { complete } from "@earendil-works/pi-ai/compat";
11
+ import type { Api, Model, UserMessage } from "@earendil-works/pi-ai";
11
12
  import {
12
13
  compact,
13
14
  defineTool,
14
15
  DynamicBorder,
15
16
  type ExtensionAPI,
16
17
  type ExtensionContext,
17
- } from "@mariozechner/pi-coding-agent";
18
- import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
19
20
 
20
21
  const LOOP_PRESETS = [
21
22
  { value: "tests", label: "Until tests pass", description: "" },
@@ -213,6 +214,8 @@ async function loadState(ctx: ExtensionContext): Promise<LoopStateData> {
213
214
 
214
215
  export default function loopExtension(pi: ExtensionAPI): void {
215
216
  let loopState: LoopStateData = { active: false };
217
+ let lastAgentEndMessages: Array<{ role?: string; stopReason?: string }> = [];
218
+ let pendingLoopPrompt: ReturnType<typeof setImmediate> | undefined;
216
219
 
217
220
  function persistState(state: LoopStateData): void {
218
221
  pi.appendEntry(LOOP_STATE_ENTRY, state);
@@ -271,6 +274,23 @@ export default function loopExtension(pi: ExtensionAPI): void {
271
274
  );
272
275
  }
273
276
 
277
+ function scheduleLoopPrompt(ctx: ExtensionContext): void {
278
+ if (pendingLoopPrompt) return;
279
+
280
+ pendingLoopPrompt = setImmediate(() => {
281
+ pendingLoopPrompt = undefined;
282
+ if (!loopState.active || !ctx.isIdle()) return;
283
+ triggerLoopPrompt(ctx);
284
+ });
285
+ pendingLoopPrompt.unref?.();
286
+ }
287
+
288
+ function cancelScheduledLoopPrompt(): void {
289
+ if (!pendingLoopPrompt) return;
290
+ clearImmediate(pendingLoopPrompt);
291
+ pendingLoopPrompt = undefined;
292
+ }
293
+
274
294
  async function showLoopSelector(ctx: ExtensionContext): Promise<LoopStateData | null> {
275
295
  const items: SelectItem[] = LOOP_PRESETS.map((preset) => ({
276
296
  value: preset.value,
@@ -385,11 +405,11 @@ export default function loopExtension(pi: ExtensionAPI): void {
385
405
  name: "signal_loop_success",
386
406
  label: "Signal Loop Success",
387
407
  description:
388
- "Stop the active loop when the breakout condition is satisfied. Only call this tool when explicitly instructed to do so by the user, tool or system prompt.",
408
+ "Stop the active loop when the breakout condition is satisfied. Only call signal_loop_success when explicitly instructed to do so by the user, tool, or system prompt.",
389
409
  promptSnippet: "Signal that the active /loop breakout condition has been satisfied",
390
410
  promptGuidelines: [
391
- "Call this tool only when the active loop's breakout condition is actually satisfied.",
392
- "Do not call this tool unless a loop is currently active.",
411
+ "Call signal_loop_success only when the active loop's breakout condition is actually satisfied.",
412
+ "Do not call signal_loop_success unless a loop is currently active.",
393
413
  ],
394
414
  parameters: Type.Object({}),
395
415
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
@@ -416,7 +436,7 @@ export default function loopExtension(pi: ExtensionAPI): void {
416
436
  handler: async (args, ctx) => {
417
437
  let nextState = parseArgs(args);
418
438
  if (!nextState) {
419
- if (!ctx.hasUI) {
439
+ if (ctx.mode !== "tui") {
420
440
  ctx.ui.notify("Usage: /loop tests | /loop custom <condition> | /loop self", "warning");
421
441
  return;
422
442
  }
@@ -458,10 +478,14 @@ export default function loopExtension(pi: ExtensionAPI): void {
458
478
  },
459
479
  });
460
480
 
461
- pi.on("agent_end", async (event, ctx) => {
481
+ pi.on("agent_end", async (event) => {
482
+ lastAgentEndMessages = event.messages;
483
+ });
484
+
485
+ pi.on("agent_settled", async (_event, ctx) => {
462
486
  if (!loopState.active) return;
463
487
 
464
- if (ctx.hasUI && wasLastAssistantAborted(event.messages)) {
488
+ if (ctx.hasUI && wasLastAssistantAborted(lastAgentEndMessages)) {
465
489
  const confirm = await withPromptSignal(pi, () =>
466
490
  ctx.ui.confirm("Break active loop?", "Operation aborted. Break out of the loop?"),
467
491
  );
@@ -471,13 +495,13 @@ export default function loopExtension(pi: ExtensionAPI): void {
471
495
  }
472
496
  }
473
497
 
474
- triggerLoopPrompt(ctx);
498
+ scheduleLoopPrompt(ctx);
475
499
  });
476
500
 
477
501
  pi.on("session_before_compact", async (event, ctx) => {
478
502
  if (!loopState.active || !loopState.mode || !ctx.model) return;
479
503
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
480
- if (!auth.ok || !auth.apiKey) return;
504
+ if (!auth.ok) return;
481
505
 
482
506
  const instructionParts = [
483
507
  event.customInstructions,
@@ -526,4 +550,8 @@ export default function loopExtension(pi: ExtensionAPI): void {
526
550
  pi.on("session_start", async (_event, ctx) => {
527
551
  await restoreLoopState(ctx);
528
552
  });
553
+
554
+ pi.on("session_shutdown", async () => {
555
+ cancelScheduledLoopPrompt();
556
+ });
529
557
  }
@@ -1,11 +1,12 @@
1
- import { complete, StringEnum, type Api, type Model, type UserMessage } from "@mariozechner/pi-ai";
1
+ import { complete } from "@earendil-works/pi-ai/compat";
2
+ import { StringEnum, type Api, type Model, type UserMessage } from "@earendil-works/pi-ai";
2
3
  import {
3
4
  defineTool,
4
5
  withFileMutationQueue,
5
6
  type ExtensionAPI,
6
7
  type ExtensionCommandContext,
7
8
  type ExtensionContext,
8
- } from "@mariozechner/pi-coding-agent";
9
+ } from "@earendil-works/pi-coding-agent";
9
10
  import { Type } from "typebox";
10
11
  import fs from "node:fs/promises";
11
12
  import path from "node:path";
@@ -1704,7 +1705,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1704
1705
  description: `Update one .agents/memory/core block while enforcing the shared ${CORE_LINE_CAP}-line and ${CORE_CHAR_CAP}-character caps`,
1705
1706
  promptSnippet: `Update one core memory block in .agents/memory/core with ${CORE_LINE_CAP}-line and ${CORE_CHAR_CAP}-character cap enforcement`,
1706
1707
  promptGuidelines: [
1707
- "Use this tool when updating .agents/memory/core/directives.md, context.md, focus.md, or pending.md.",
1708
+ "Use memory_update_block when updating .agents/memory/core/directives.md, context.md, focus.md, or pending.md.",
1708
1709
  `If a write would exceed the ${CORE_LINE_CAP}-line or ${CORE_CHAR_CAP}-character core cap, run memory_dream or remove content first.`,
1709
1710
  ],
1710
1711
  parameters: MEMORY_UPDATE_BLOCK_PARAMS,
@@ -1735,7 +1736,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1735
1736
  description: "Append an entry to .agents/memory/log.md using the repo memory log format",
1736
1737
  promptSnippet: "Append an entry to the repo memory log at .agents/memory/log.md",
1737
1738
  promptGuidelines: [
1738
- "Use this tool for important decisions, discoveries, plans, experiments, prompt ingests, and attachment additions worth remembering.",
1739
+ "Use memory_append_log for important decisions, discoveries, plans, experiments, prompt ingests, and attachment additions worth remembering.",
1739
1740
  "Use importance labels high, medium, or low.",
1740
1741
  "If this entry replaces or corrects prior memory, set supersedes and/or invalidates links explicitly.",
1741
1742
  "The memory log is append-only. Do not rewrite or truncate older entries.",
@@ -1773,7 +1774,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1773
1774
  promptSnippet: "Consolidate repo memory with dream-based core compression",
1774
1775
  promptGuidelines: [
1775
1776
  "Dream is the only consolidation mechanism for .agents/memory/core.",
1776
- "Use this tool when logs have accumulated, core needs compression, or recent compaction context should be folded into memory.",
1777
+ "Use memory_dream when logs have accumulated, core needs compression, or recent compaction context should be folded into memory.",
1777
1778
  ],
1778
1779
  parameters: MEMORY_DREAM_PARAMS,
1779
1780
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -10,7 +10,8 @@
10
10
  * - Windows toast: Windows Terminal (WSL)
11
11
  */
12
12
 
13
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
13
+ import { execFile } from "node:child_process";
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
15
 
15
16
  const REVIEW_EVENT_START = "review:start";
16
17
  const REVIEW_EVENT_END = "review:end";
@@ -39,7 +40,6 @@ function notifyOSC99(title: string, body: string): void {
39
40
  }
40
41
 
41
42
  function notifyWindows(title: string, body: string): void {
42
- const { execFile } = require("child_process");
43
43
  execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
44
44
  }
45
45
 
@@ -92,13 +92,33 @@ export default function (pi: ExtensionAPI) {
92
92
  let pendingPromptCount = 0;
93
93
  const activeReviewSessions = new Set<string>();
94
94
  let currentSessionKey: string | undefined;
95
+ let pendingReadyNotification: ReturnType<typeof setImmediate> | undefined;
95
96
 
96
97
  const hasCurrentSessionReviewRun = () => {
97
98
  if (!currentSessionKey) return false;
98
99
  return activeReviewSessions.has(currentSessionKey);
99
100
  };
100
101
 
102
+ const cancelReadyNotification = () => {
103
+ if (!pendingReadyNotification) return;
104
+ clearImmediate(pendingReadyNotification);
105
+ pendingReadyNotification = undefined;
106
+ };
107
+
108
+ const scheduleReadyNotification = (ctx: ExtensionContext) => {
109
+ cancelReadyNotification();
110
+ pendingReadyNotification = setImmediate(() => {
111
+ pendingReadyNotification = undefined;
112
+ if (!ctx.isIdle()) return;
113
+ if (pendingPromptCount > 0) return;
114
+ if (hasCurrentSessionReviewRun()) return;
115
+ notify("Pi", "Ready for input");
116
+ });
117
+ pendingReadyNotification.unref?.();
118
+ };
119
+
101
120
  pi.on("session_start", async (_event, ctx) => {
121
+ cancelReadyNotification();
102
122
  currentSessionKey = getSessionKey(ctx);
103
123
  });
104
124
 
@@ -143,14 +163,13 @@ export default function (pi: ExtensionAPI) {
143
163
  notify("Pi", "Review failed");
144
164
  });
145
165
 
146
- pi.on("agent_end", async (_event, ctx) => {
166
+ pi.on("agent_settled", async (_event, ctx) => {
147
167
  currentSessionKey = getSessionKey(ctx);
148
- if (pendingPromptCount > 0) return;
149
- if (hasCurrentSessionReviewRun()) return;
150
- notify("Pi", "Ready for input");
168
+ scheduleReadyNotification(ctx);
151
169
  });
152
170
 
153
171
  pi.on("session_shutdown", async (_event, ctx) => {
172
+ cancelReadyNotification();
154
173
  pendingPromptCount = 0;
155
174
  const sessionKey = getSessionKey(ctx);
156
175
  activeReviewSessions.delete(sessionKey);
@@ -1,7 +1,10 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
5
8
 
6
9
  const STATUS_KEY = "openai-fast";
7
10
  const SUPPORTED_APIS = new Set([
@@ -23,8 +26,7 @@ function emptyConfig(): FastConfig {
23
26
  }
24
27
 
25
28
  function getConfigPath(): string {
26
- const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
27
- return path.join(agentDir, "openai-fast.json");
29
+ return path.join(getAgentDir(), "openai-fast.json");
28
30
  }
29
31
 
30
32
  function isObject(value: unknown): value is JsonObject {
@@ -1,7 +1,10 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import {
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
5
8
 
6
9
  const STATUS_KEY = "openai-verbosity";
7
10
  const SUPPORTED_APIS = new Set([
@@ -23,8 +26,7 @@ function emptyConfig(): VerbosityConfig {
23
26
  }
24
27
 
25
28
  function getConfigPath(): string {
26
- const agentDir = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
27
- return path.join(agentDir, "openai-verbosity.json");
29
+ return path.join(getAgentDir(), "openai-verbosity.json");
28
30
  }
29
31
 
30
32
  function isObject(value: unknown): value is JsonObject {
@@ -0,0 +1,239 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { ReviewMessageQueue } from "./message-queue.js";
8
+ import { buildAdditionalContextSection, FIX_PROMPT } from "./prompts.js";
9
+ import { reviewMatchesFixRequest } from "./request.js";
10
+ import { computeCurrentFingerprint, fingerprintsEqual } from "./git.js";
11
+ import {
12
+ buildStalePayloadStaleness,
13
+ getLastMessageReviewDetails,
14
+ runReviewPipeline,
15
+ } from "./review.js";
16
+ import {
17
+ notify,
18
+ REVIEW_CANCELLED_ERROR,
19
+ type AgentEndMessage,
20
+ type AgentEndMessages,
21
+ type FixPassAgentTracker,
22
+ } from "./runtime.js";
23
+ import type { ParsedRequest, ReviewMessageDetails } from "./schema.js";
24
+
25
+ const FIX_PASS_START_GRACE_MS = 1_000;
26
+
27
+ const REVIEW_STALE_REUSE_WARNING =
28
+ "Last review is stale. Continuing with /fix. Run /review first to refresh it.";
29
+
30
+ function buildFixPrompt(
31
+ reviewMessageDetails: ReviewMessageDetails,
32
+ additionalContext: string | undefined,
33
+ ): string {
34
+ const worklistPayload = JSON.stringify(
35
+ {
36
+ scope: reviewMessageDetails.scope,
37
+ ...(reviewMessageDetails.staleness ? { staleness: reviewMessageDetails.staleness } : {}),
38
+ findings: reviewMessageDetails.findings.map(
39
+ ({ priority, location, finding, suggestion, focus }) => ({
40
+ priority,
41
+ location,
42
+ finding,
43
+ suggestion,
44
+ focus,
45
+ }),
46
+ ),
47
+ },
48
+ null,
49
+ 2,
50
+ );
51
+ const additionalContextSection = buildAdditionalContextSection(additionalContext);
52
+
53
+ return FIX_PROMPT.replace(
54
+ "{FIX_ADDITIONAL_CONTEXT_SECTION}",
55
+ () => additionalContextSection,
56
+ ).replace("{REVIEW_FINDINGS_JSON}", () => worklistPayload);
57
+ }
58
+
59
+ export async function prepareFixReviewDetails(
60
+ pi: ExtensionAPI,
61
+ ctx: ExtensionCommandContext,
62
+ request: ParsedRequest,
63
+ forceFreshReview = false,
64
+ ): Promise<ReviewMessageDetails | null> {
65
+ let reviewDetails = forceFreshReview ? null : getLastMessageReviewDetails(ctx);
66
+
67
+ if (reviewDetails && reviewMatchesFixRequest(reviewDetails, request)) {
68
+ const currentFingerprint = await computeCurrentFingerprint(
69
+ pi,
70
+ ctx.cwd,
71
+ reviewDetails.scope.mode === "working-tree" || reviewDetails.scope.mode === "folder",
72
+ );
73
+ const hasStalePayload = reviewDetails.staleness?.status === "stale";
74
+ const isStaleNow = !fingerprintsEqual(reviewDetails.fingerprint, currentFingerprint);
75
+ if (hasStalePayload || isStaleNow) {
76
+ reviewDetails = {
77
+ ...reviewDetails,
78
+ staleness: reviewDetails.staleness ?? buildStalePayloadStaleness(),
79
+ };
80
+ notify(ctx, REVIEW_STALE_REUSE_WARNING, "warning");
81
+ }
82
+ } else {
83
+ const reviewResult = await runReviewPipeline(pi, ctx, request, "fix");
84
+ if (!reviewResult.ok) {
85
+ if (reviewResult.error === REVIEW_CANCELLED_ERROR) {
86
+ notify(ctx, REVIEW_CANCELLED_ERROR, "error");
87
+ } else {
88
+ notify(ctx, `Cannot continue to /fix: ${reviewResult.error}`, "error");
89
+ }
90
+ return null;
91
+ }
92
+ reviewDetails = reviewResult.details;
93
+ if (reviewDetails.staleness?.status === "stale") {
94
+ notify(ctx, reviewDetails.staleness.nextStep, "warning");
95
+ return null;
96
+ }
97
+ }
98
+
99
+ if (reviewDetails.findings.length === 0) {
100
+ const failedFocusCount = countFailedFocusRuns(reviewDetails);
101
+ if (failedFocusCount > 0) {
102
+ notify(
103
+ ctx,
104
+ `Latest partial review had ${failedFocusCount} failed focus run(s) and no findings. Rerun /review before /fix.`,
105
+ "warning",
106
+ );
107
+ return null;
108
+ }
109
+
110
+ notify(ctx, "Review produced no findings. Nothing to fix.", "info");
111
+ return null;
112
+ }
113
+
114
+ return reviewDetails;
115
+ }
116
+
117
+ function countFailedFocusRuns(reviewDetails: ReviewMessageDetails): number {
118
+ return reviewDetails.focusStatus.filter((focus) => !focus.ok).length;
119
+ }
120
+
121
+ function notifyFixUsedPartialReview(ctx: ExtensionContext, failedFocusCount: number): void {
122
+ if (failedFocusCount === 0) return;
123
+ const failedLabel = `review${failedFocusCount === 1 ? "" : "s"}`;
124
+ notify(
125
+ ctx,
126
+ `Fix pass used partial review results: ${failedFocusCount} ${failedLabel} failed.`,
127
+ "warning",
128
+ );
129
+ }
130
+
131
+ function queueFixPass(
132
+ pi: ExtensionAPI,
133
+ ctx: ExtensionCommandContext,
134
+ reviewDetails: ReviewMessageDetails,
135
+ additionalContext: string | undefined,
136
+ options: { forceFollowUp?: boolean } = {},
137
+ ): { startsImmediately: boolean } {
138
+ const fixPrompt = buildFixPrompt(reviewDetails, additionalContext);
139
+ const startsImmediately = ctx.isIdle();
140
+ const delivery =
141
+ options.forceFollowUp || !startsImmediately || ctx.hasPendingMessages()
142
+ ? { deliverAs: "followUp" as const }
143
+ : undefined;
144
+ pi.sendUserMessage(fixPrompt, delivery);
145
+ notify(ctx, "Queued autonomous fix pass from review findings.", "info");
146
+ return { startsImmediately };
147
+ }
148
+
149
+ function getLastAssistantMessage(messages: AgentEndMessages): AgentEndMessage | undefined {
150
+ return messages.findLast((message) => message?.role === "assistant");
151
+ }
152
+
153
+ function wasLastAssistantAborted(messages: AgentEndMessages): boolean {
154
+ return getLastAssistantMessage(messages)?.stopReason === "aborted";
155
+ }
156
+
157
+ async function waitForPromptStartIfImmediate(
158
+ agentTracker: FixPassAgentTracker,
159
+ startCountBeforePrompt: number,
160
+ startsImmediately: boolean,
161
+ ): Promise<void> {
162
+ if (!startsImmediately) return;
163
+ await agentTracker.waitForStartAfter(startCountBeforePrompt, FIX_PASS_START_GRACE_MS);
164
+ }
165
+
166
+ async function waitForFixPassCompletion(
167
+ agentTracker: FixPassAgentTracker,
168
+ ): Promise<AgentEndMessages> {
169
+ await agentTracker.waitForNextSettled();
170
+ return agentTracker.getLastEnd()?.messages ?? [];
171
+ }
172
+
173
+ export async function runFixPassFromReview(
174
+ pi: ExtensionAPI,
175
+ ctx: ExtensionCommandContext,
176
+ reviewDetails: ReviewMessageDetails,
177
+ additionalContext: string | undefined,
178
+ agentTracker: FixPassAgentTracker,
179
+ reviewMessageQueue: ReviewMessageQueue,
180
+ ): Promise<AgentEndMessages> {
181
+ const failedFocusCount = countFailedFocusRuns(reviewDetails);
182
+ const fixPassFinished = waitForFixPassCompletion(agentTracker);
183
+
184
+ const startCountBeforeSteering = agentTracker.getStartCount();
185
+ const steeringStartsImmediately = ctx.isIdle();
186
+ const sentSteering = reviewMessageQueue.flushSteering(ctx, { forceFollowUp: true });
187
+ await waitForPromptStartIfImmediate(
188
+ agentTracker,
189
+ startCountBeforeSteering,
190
+ sentSteering && steeringStartsImmediately,
191
+ );
192
+
193
+ const startCountBeforeFix = agentTracker.getStartCount();
194
+ const fixPass = queueFixPass(pi, ctx, reviewDetails, additionalContext, {
195
+ forceFollowUp: sentSteering,
196
+ });
197
+ await waitForPromptStartIfImmediate(agentTracker, startCountBeforeFix, fixPass.startsImmediately);
198
+
199
+ const fixMessages = await fixPassFinished;
200
+ if (!wasLastAssistantAborted(fixMessages)) {
201
+ notifyFixUsedPartialReview(ctx, failedFocusCount);
202
+ }
203
+ return fixMessages;
204
+ }
205
+
206
+ export async function runFixLoop(
207
+ pi: ExtensionAPI,
208
+ ctx: ExtensionCommandContext,
209
+ request: ParsedRequest,
210
+ agentTracker: FixPassAgentTracker,
211
+ reviewMessageQueue: ReviewMessageQueue,
212
+ ): Promise<void> {
213
+ for (;;) {
214
+ const reviewDetails = await prepareFixReviewDetails(pi, ctx, request, true);
215
+ if (!reviewDetails) return;
216
+
217
+ const beforeFixFingerprint = reviewDetails.fingerprint;
218
+ const fixMessages = await runFixPassFromReview(
219
+ pi,
220
+ ctx,
221
+ reviewDetails,
222
+ request.additionalContext,
223
+ agentTracker,
224
+ reviewMessageQueue,
225
+ );
226
+ if (wasLastAssistantAborted(fixMessages)) {
227
+ notify(ctx, "Fix loop stopped: fix pass was aborted.", "warning");
228
+ return;
229
+ }
230
+
231
+ const afterFixFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, true);
232
+ if (fingerprintsEqual(beforeFixFingerprint, afterFixFingerprint)) {
233
+ notify(ctx, "Fix loop stopped: fix pass made no repository changes.", "warning");
234
+ return;
235
+ }
236
+
237
+ notify(ctx, "Fix loop continuing with a fresh review...", "info");
238
+ }
239
+ }