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
package/README.md CHANGED
@@ -27,7 +27,7 @@ pi install -l npm:tau-coding-agent
27
27
  | `git-diff-stats` | — | Status bar diff stats for local changes in the current repo. |
28
28
  | `git-pr-status` | — | Status bar PR number and link for the current branch. |
29
29
  | `insights` | `/insights` | Analyze Pi sessions and suggest reusable instructions, templates, skills, and extensions. |
30
- | `interlude` | `alt+x` | Stash the current message draft, send one interlude message, then restore the draft. |
30
+ | `stash` | `alt+x` | Stash the current message draft, send one message, then restore it. |
31
31
  | `loop` | `/loop` | Repeat a prompt until the agent signals success. |
32
32
  | `memory` | `/memory` | Opt-in project-local memory for learning and continuity across sessions. |
33
33
  | `notify` | — | Terminal notification when the agent is waiting for input. |
@@ -10,13 +10,14 @@
10
10
  * 4. Submits the compiled answers when done
11
11
  */
12
12
 
13
- import { complete, type Model, type Api, type UserMessage } from "@earendil-works/pi-ai";
14
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import { BorderedLoader } from "@earendil-works/pi-coding-agent";
13
+ import { complete } from "@earendil-works/pi-ai/compat";
14
+ import type { Model, Api, UserMessage } from "@earendil-works/pi-ai";
15
+ import { BorderedLoader, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
16
16
  import {
17
17
  type Component,
18
18
  Editor,
19
19
  type EditorTheme,
20
+ type Focusable,
20
21
  Key,
21
22
  matchesKey,
22
23
  truncateToWidth,
@@ -178,12 +179,13 @@ function parseExtractionResult(text: string): ExtractionResult | null {
178
179
  /**
179
180
  * Interactive Q&A component for answering extracted questions
180
181
  */
181
- class QnAComponent implements Component {
182
+ class QnAComponent implements Component, Focusable {
182
183
  private questions: ExtractedQuestion[];
183
184
  private answers: string[];
184
185
  private currentIndex: number = 0;
185
186
  private editor: Editor;
186
187
  private tui: TUI;
188
+ private theme: Theme;
187
189
  private onDone: (result: string | null) => void;
188
190
  private showingConfirmation: boolean = false;
189
191
 
@@ -191,30 +193,38 @@ class QnAComponent implements Component {
191
193
  private cachedWidth?: number;
192
194
  private cachedLines?: string[];
193
195
 
194
- // Colors - using proper reset sequences
195
- private dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
196
- private bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
197
- private cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
198
- private green = (s: string) => `\x1b[32m${s}\x1b[0m`;
199
- private yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
200
- private gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
196
+ private _focused = false;
201
197
 
202
- constructor(questions: ExtractedQuestion[], tui: TUI, onDone: (result: string | null) => void) {
198
+ get focused(): boolean {
199
+ return this._focused;
200
+ }
201
+
202
+ set focused(value: boolean) {
203
+ this._focused = value;
204
+ this.editor.focused = value;
205
+ }
206
+
207
+ constructor(
208
+ questions: ExtractedQuestion[],
209
+ tui: TUI,
210
+ theme: Theme,
211
+ onDone: (result: string | null) => void,
212
+ ) {
203
213
  this.questions = questions;
204
214
  this.answers = questions.map(() => "");
205
215
  this.tui = tui;
216
+ this.theme = theme;
206
217
  this.onDone = onDone;
207
218
 
208
- // Create a minimal theme for the editor
209
- const selectedLine = (s: string) => `\x1b[44m${s}\x1b[0m`;
219
+ const selectedLine = (s: string) => theme.bg("selectedBg", s);
210
220
  const editorTheme: EditorTheme = {
211
- borderColor: this.dim,
221
+ borderColor: (s) => theme.fg("dim", s),
212
222
  selectList: {
213
223
  selectedPrefix: selectedLine,
214
224
  selectedText: selectedLine,
215
- description: this.gray,
216
- scrollInfo: this.gray,
217
- noMatch: this.gray,
225
+ description: (s) => theme.fg("muted", s),
226
+ scrollInfo: (s) => theme.fg("muted", s),
227
+ noMatch: (s) => theme.fg("warning", s),
218
228
  },
219
229
  };
220
230
 
@@ -228,11 +238,6 @@ class QnAComponent implements Component {
228
238
  };
229
239
  }
230
240
 
231
- private allQuestionsAnswered(): boolean {
232
- this.saveCurrentAnswer();
233
- return this.answers.every((a) => (a?.trim() || "").length > 0);
234
- }
235
-
236
241
  private saveCurrentAnswer(): void {
237
242
  this.answers[this.currentIndex] = this.editor.getText();
238
243
  }
@@ -362,6 +367,7 @@ class QnAComponent implements Component {
362
367
  return this.cachedLines;
363
368
  }
364
369
 
370
+ const theme = this.theme;
365
371
  const lines: string[] = [];
366
372
  const boxWidth = Math.min(width - 4, 120); // Allow wider box
367
373
  const contentWidth = boxWidth - 4; // 2 chars padding on each side
@@ -374,11 +380,11 @@ class QnAComponent implements Component {
374
380
  const paddedContent = " ".repeat(leftPad) + content;
375
381
  const contentLen = visibleWidth(paddedContent);
376
382
  const rightPad = Math.max(0, boxWidth - contentLen - 2);
377
- return this.dim("│") + paddedContent + " ".repeat(rightPad) + this.dim("│");
383
+ return theme.fg("dim", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("dim", "│");
378
384
  };
379
385
 
380
386
  const emptyBoxLine = (): string => {
381
- return this.dim("│") + " ".repeat(boxWidth - 2) + this.dim("│");
387
+ return theme.fg("dim", "│") + " ".repeat(boxWidth - 2) + theme.fg("dim", "│");
382
388
  };
383
389
 
384
390
  const padToWidth = (line: string): string => {
@@ -387,10 +393,10 @@ class QnAComponent implements Component {
387
393
  };
388
394
 
389
395
  // Title
390
- lines.push(padToWidth(this.dim("╭" + horizontalLine(boxWidth - 2) + "╮")));
391
- const title = `${this.bold(this.cyan("Questions"))} ${this.dim(`(${this.currentIndex + 1}/${this.questions.length})`)}`;
396
+ lines.push(padToWidth(theme.fg("dim", "╭" + horizontalLine(boxWidth - 2) + "╮")));
397
+ const title = `${theme.bold(theme.fg("accent", "Questions"))} ${theme.fg("dim", `(${this.currentIndex + 1}/${this.questions.length})`)}`;
392
398
  lines.push(padToWidth(boxLine(title)));
393
- lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
399
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
394
400
 
395
401
  // Progress indicator
396
402
  const progressParts: string[] = [];
@@ -398,11 +404,11 @@ class QnAComponent implements Component {
398
404
  const answered = (this.answers[i]?.trim() || "").length > 0;
399
405
  const current = i === this.currentIndex;
400
406
  if (current) {
401
- progressParts.push(this.cyan("●"));
407
+ progressParts.push(theme.fg("accent", "●"));
402
408
  } else if (answered) {
403
- progressParts.push(this.green("●"));
409
+ progressParts.push(theme.fg("success", "●"));
404
410
  } else {
405
- progressParts.push(this.dim("○"));
411
+ progressParts.push(theme.fg("dim", "○"));
406
412
  }
407
413
  }
408
414
  lines.push(padToWidth(boxLine(progressParts.join(" "))));
@@ -419,7 +425,7 @@ class QnAComponent implements Component {
419
425
  // Context if present
420
426
  if (q.context) {
421
427
  lines.push(padToWidth(emptyBoxLine()));
422
- const contextText = this.gray(`${q.context}`);
428
+ const contextText = theme.fg("muted", `${q.context}`);
423
429
  const wrappedContext = wrapTextWithAnsi(contextText, contentWidth - 2);
424
430
  for (const line of wrappedContext) {
425
431
  lines.push(padToWidth(boxLine(line)));
@@ -430,7 +436,7 @@ class QnAComponent implements Component {
430
436
 
431
437
  // Render the editor component (multi-line input) with padding
432
438
  // Skip the first and last lines (editor's own border lines)
433
- const answerPrefix = this.bold("A: ");
439
+ const answerPrefix = theme.bold("A: ");
434
440
  const editorWidth = contentWidth - 4 - 3; // Extra padding + space for "A: "
435
441
  const editorLines = this.editor.render(editorWidth);
436
442
  for (let i = 1; i < editorLines.length - 1; i++) {
@@ -447,15 +453,15 @@ class QnAComponent implements Component {
447
453
 
448
454
  // Confirmation dialog or footer with controls
449
455
  if (this.showingConfirmation) {
450
- lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
451
- const confirmMsg = `${this.yellow("Submit all answers?")} ${this.dim("(Enter/y to confirm, Esc/n to cancel)")}`;
456
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
457
+ const confirmMsg = `${theme.fg("warning", "Submit all answers?")} ${theme.fg("dim", "(Enter/y to confirm, Esc/n to cancel)")}`;
452
458
  lines.push(padToWidth(boxLine(truncateToWidth(confirmMsg, contentWidth))));
453
459
  } else {
454
- lines.push(padToWidth(this.dim("├" + horizontalLine(boxWidth - 2) + "┤")));
455
- const controls = `${this.dim("Tab/Enter")} next · ${this.dim("Shift+Tab")} prev · ${this.dim("Shift+Enter")} newline · ${this.dim("Esc")} cancel`;
460
+ lines.push(padToWidth(theme.fg("dim", "├" + horizontalLine(boxWidth - 2) + "┤")));
461
+ const controls = `${theme.fg("dim", "Tab/Enter")} next · ${theme.fg("dim", "Shift+Tab")} prev · ${theme.fg("dim", "Shift+Enter")} newline · ${theme.fg("dim", "Esc")} cancel`;
456
462
  lines.push(padToWidth(boxLine(truncateToWidth(controls, contentWidth))));
457
463
  }
458
- lines.push(padToWidth(this.dim("╰" + horizontalLine(boxWidth - 2) + "╯")));
464
+ lines.push(padToWidth(theme.fg("dim", "╰" + horizontalLine(boxWidth - 2) + "╯")));
459
465
 
460
466
  this.cachedWidth = width;
461
467
  this.cachedLines = lines;
@@ -467,8 +473,8 @@ export default function (pi: ExtensionAPI) {
467
473
  pi.registerCommand("answer", {
468
474
  description: "Extract questions from last assistant message into interactive Q&A",
469
475
  handler: async (_args, ctx) => {
470
- if (!ctx.hasUI) {
471
- ctx.ui.notify("answer requires interactive mode", "error");
476
+ if (ctx.mode !== "tui") {
477
+ ctx.ui.notify("answer requires TUI mode", "error");
472
478
  return;
473
479
  }
474
480
 
@@ -571,8 +577,8 @@ export default function (pi: ExtensionAPI) {
571
577
  }
572
578
 
573
579
  // Show the Q&A component
574
- const answersResult = await ctx.ui.custom<string | null>((tui, _theme, _kb, done) => {
575
- return new QnAComponent(extractionResult.questions, tui, done);
580
+ const answersResult = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
581
+ return new QnAComponent(extractionResult.questions, tui, theme, done);
576
582
  });
577
583
 
578
584
  if (answersResult === null) {
@@ -13,7 +13,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
13
13
 
14
14
  const TERMINAL_FLAG = "branch-term";
15
15
  const TMUX_LAYOUT_FLAG = "branch-tmux-layout";
16
- const BRANCH_MESSAGE_TYPE = "branch-term-message";
16
+ const BRANCH_ENTRY_TYPE = "branch-term-message";
17
17
  const MANUAL_RESUME_INTRO =
18
18
  "Branch session ready. Run this command in a separate terminal or tmux pane";
19
19
 
@@ -220,15 +220,10 @@ function showCommandMessage(
220
220
  return;
221
221
  }
222
222
 
223
- pi.sendMessage({
224
- customType: BRANCH_MESSAGE_TYPE,
225
- content: intro,
226
- display: true,
227
- details: {
228
- intro,
229
- command,
230
- copiedToClipboard,
231
- } satisfies CommandMessageDetails,
223
+ pi.appendEntry<CommandMessageDetails>(BRANCH_ENTRY_TYPE, {
224
+ intro,
225
+ command,
226
+ copiedToClipboard,
232
227
  });
233
228
  }
234
229
 
@@ -266,10 +261,10 @@ function createFreshSessionFile(cwd: string, sessionDir: string): string {
266
261
  }
267
262
 
268
263
  export default function (pi: ExtensionAPI) {
269
- pi.registerMessageRenderer(BRANCH_MESSAGE_TYPE, (message, _options, theme) => {
270
- const details = message.details as CommandMessageDetails | undefined;
264
+ pi.registerEntryRenderer<CommandMessageDetails>(BRANCH_ENTRY_TYPE, (entry, _options, theme) => {
265
+ const details = entry.data;
271
266
  if (!details || typeof details.intro !== "string" || typeof details.command !== "string") {
272
- return new Text(typeof message.content === "string" ? message.content : "", 0, 0);
267
+ return undefined;
273
268
  }
274
269
 
275
270
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
package/extensions/btw.ts CHANGED
@@ -116,8 +116,8 @@ export default function btwExtension(pi: ExtensionAPI): void {
116
116
  return;
117
117
  }
118
118
 
119
- if (!ctx.hasUI) {
120
- ctx.ui.notify("btw requires interactive mode", "error");
119
+ if (ctx.mode !== "tui") {
120
+ ctx.ui.notify("btw requires TUI mode", "error");
121
121
  return;
122
122
  }
123
123
 
@@ -278,8 +278,8 @@ function seedSessionManager(sessionManager: SessionManager, messages: AgentMessa
278
278
  }
279
279
 
280
280
  async function showResultDialog(ctx: ExtensionContext, result: BtwResult): Promise<void> {
281
- await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
282
- return new BtwResultComponent(result, tui, ctx.ui.theme, done);
281
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
282
+ return new BtwResultComponent(result, tui, theme, done);
283
283
  });
284
284
  }
285
285
 
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * - Shows project/session in the terminal title
5
5
  * - Shows a braille spinner in the title while the agent is working
6
+ * - Shows the braille spinner backwards while context is compacting
6
7
  * - Shows a ? marker while an extension prompt is waiting for input
7
8
  * - Updates title with the current tool name during tool execution
8
9
  * - Treats background /review runs as working state via review:start/review:end events
@@ -22,6 +23,7 @@ let sessionName: string | undefined;
22
23
  let sessionCwd: string | undefined;
23
24
  let currentTool: string | undefined;
24
25
  let isWorking = false;
26
+ let isCompacting = false;
25
27
  let frameIndex = 0;
26
28
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
27
29
  let pendingPromptCount = 0;
@@ -47,6 +49,15 @@ function currentFrame(): string {
47
49
  return STATUS_SPINNER_FRAMES[frameIndex % STATUS_SPINNER_FRAMES.length];
48
50
  }
49
51
 
52
+ function resetFrame(): void {
53
+ frameIndex = 0;
54
+ }
55
+
56
+ function advanceFrame(): void {
57
+ const step = isCompacting ? -1 : 1;
58
+ frameIndex = (frameIndex + step + STATUS_SPINNER_FRAMES.length) % STATUS_SPINNER_FRAMES.length;
59
+ }
60
+
50
61
  function hasPendingPrompts(): boolean {
51
62
  return pendingPromptCount > 0;
52
63
  }
@@ -69,11 +80,12 @@ function hasActiveReviewRuns(): boolean {
69
80
  }
70
81
 
71
82
  function isBusy(): boolean {
72
- return isWorking || hasActiveReviewRuns();
83
+ return isWorking || isCompacting || hasActiveReviewRuns();
73
84
  }
74
85
 
75
86
  function getWorkingExtra(): string | undefined {
76
87
  if (currentTool) return currentTool;
88
+ if (isCompacting) return "compacting";
77
89
  if (!isWorking && hasActiveReviewRuns()) return "review";
78
90
  return undefined;
79
91
  }
@@ -105,7 +117,7 @@ function startSpinnerTimer(ctx: ExtensionContext): void {
105
117
  clearSpinnerTimer();
106
118
  spinnerTimer = setInterval(() => {
107
119
  if (!isBusy() || hasPendingPrompts()) return;
108
- frameIndex = (frameIndex + 1) % STATUS_SPINNER_FRAMES.length;
120
+ advanceFrame();
109
121
  renderWorkingTitle(ctx);
110
122
  }, STATUS_SPINNER_INTERVAL_MS);
111
123
  }
@@ -114,7 +126,7 @@ function startSpinner(ctx: ExtensionContext): void {
114
126
  clearSpinnerTimer();
115
127
  isWorking = true;
116
128
  currentTool = undefined;
117
- frameIndex = 0;
129
+ resetFrame();
118
130
  renderActiveTitle(ctx);
119
131
 
120
132
  if (!hasPendingPrompts()) {
@@ -132,7 +144,7 @@ function stopSpinner(ctx: ExtensionContext): void {
132
144
  return;
133
145
  }
134
146
 
135
- if (hasActiveReviewRuns()) {
147
+ if (isBusy()) {
136
148
  renderWorkingTitle(ctx);
137
149
  startSpinnerTimer(ctx);
138
150
  return;
@@ -161,6 +173,38 @@ function handlePromptEnd(ctx: ExtensionContext): void {
161
173
  renderActiveTitle(ctx);
162
174
  }
163
175
 
176
+ function startCompaction(ctx: ExtensionContext, signal: AbortSignal): void {
177
+ isWorking = false;
178
+ isCompacting = true;
179
+ currentTool = undefined;
180
+ resetFrame();
181
+ signal.addEventListener("abort", () => stopCompaction(ctx), { once: true });
182
+
183
+ renderActiveTitle(ctx);
184
+ if (!hasPendingPrompts()) {
185
+ startSpinnerTimer(ctx);
186
+ }
187
+ }
188
+
189
+ function stopCompaction(ctx: ExtensionContext, options?: { render?: boolean }): void {
190
+ if (!isCompacting) return;
191
+
192
+ isCompacting = false;
193
+
194
+ if (options?.render === false) return;
195
+
196
+ if (isBusy()) {
197
+ renderActiveTitle(ctx);
198
+ if (!hasPendingPrompts()) {
199
+ startSpinnerTimer(ctx);
200
+ }
201
+ return;
202
+ }
203
+
204
+ clearSpinnerTimer();
205
+ renderActiveTitle(ctx);
206
+ }
207
+
164
208
  function syncSessionTitle(ctx: ExtensionContext): void {
165
209
  renderActiveTitle(ctx);
166
210
  }
@@ -181,17 +225,12 @@ function handleReviewStart(ctx: ExtensionContext): void {
181
225
  }
182
226
 
183
227
  function handleReviewEnd(ctx: ExtensionContext): void {
184
- if (isWorking) {
185
- renderActiveTitle(ctx);
186
- return;
187
- }
188
-
189
228
  if (hasPendingPrompts()) {
190
229
  renderActiveTitle(ctx);
191
230
  return;
192
231
  }
193
232
 
194
- if (hasActiveReviewRuns()) {
233
+ if (isBusy()) {
195
234
  renderActiveTitle(ctx);
196
235
  startSpinnerTimer(ctx);
197
236
  return;
@@ -218,10 +257,11 @@ export default function (pi: ExtensionAPI) {
218
257
  pi.on("agent_start", async (_event, ctx) => {
219
258
  if (!ctx.hasUI) return;
220
259
  latestCtx = ctx;
260
+ stopCompaction(ctx, { render: false });
221
261
  startSpinner(ctx);
222
262
  });
223
263
 
224
- pi.on("agent_end", async (_event, ctx) => {
264
+ pi.on("agent_settled", async (_event, ctx) => {
225
265
  if (!ctx.hasUI) return;
226
266
  latestCtx = ctx;
227
267
  stopSpinner(ctx);
@@ -243,6 +283,18 @@ export default function (pi: ExtensionAPI) {
243
283
  renderActiveTitle(ctx);
244
284
  });
245
285
 
286
+ pi.on("session_before_compact", async (event, ctx) => {
287
+ if (!ctx.hasUI) return;
288
+ latestCtx = ctx;
289
+ startCompaction(ctx, event.signal);
290
+ });
291
+
292
+ pi.on("session_compact", async (_event, ctx) => {
293
+ if (!ctx.hasUI) return;
294
+ latestCtx = ctx;
295
+ stopCompaction(ctx);
296
+ });
297
+
246
298
  pi.events.on("ui:prompt_start", () => {
247
299
  pendingPromptCount += 1;
248
300
 
@@ -287,6 +339,7 @@ export default function (pi: ExtensionAPI) {
287
339
  pi.on("session_shutdown", async (_event, ctx) => {
288
340
  clearSpinnerTimer();
289
341
  isWorking = false;
342
+ isCompacting = false;
290
343
  currentTool = undefined;
291
344
  pendingPromptCount = 0;
292
345
  const sessionKey = getSessionKey(ctx);
@@ -1,4 +1,5 @@
1
- import { complete, type Api, type Model, type UserMessage } from "@earendil-works/pi-ai";
1
+ import { complete } from "@earendil-works/pi-ai/compat";
2
+ import type { Api, Model, UserMessage } from "@earendil-works/pi-ai";
2
3
  import {
3
4
  BorderedLoader,
4
5
  SessionManager,
@@ -480,8 +481,8 @@ async function runInsightsCommand(
480
481
  return;
481
482
  }
482
483
 
483
- if (!ctx.hasUI) {
484
- ctx.ui.notify("/insights currently requires interactive mode", "warning");
484
+ if (ctx.mode !== "tui") {
485
+ ctx.ui.notify("/insights currently requires TUI mode", "warning");
485
486
  return;
486
487
  }
487
488
 
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import { Type } from "typebox";
10
- import { complete, type Api, type Model, type UserMessage } from "@earendil-works/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,
@@ -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,10 +1,5 @@
1
- import {
2
- complete,
3
- StringEnum,
4
- type Api,
5
- type Model,
6
- type UserMessage,
7
- } from "@earendil-works/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";
8
3
  import {
9
4
  defineTool,
10
5
  withFileMutationQueue,
@@ -1710,7 +1705,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1710
1705
  description: `Update one .agents/memory/core block while enforcing the shared ${CORE_LINE_CAP}-line and ${CORE_CHAR_CAP}-character caps`,
1711
1706
  promptSnippet: `Update one core memory block in .agents/memory/core with ${CORE_LINE_CAP}-line and ${CORE_CHAR_CAP}-character cap enforcement`,
1712
1707
  promptGuidelines: [
1713
- "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.",
1714
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.`,
1715
1710
  ],
1716
1711
  parameters: MEMORY_UPDATE_BLOCK_PARAMS,
@@ -1741,7 +1736,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1741
1736
  description: "Append an entry to .agents/memory/log.md using the repo memory log format",
1742
1737
  promptSnippet: "Append an entry to the repo memory log at .agents/memory/log.md",
1743
1738
  promptGuidelines: [
1744
- "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.",
1745
1740
  "Use importance labels high, medium, or low.",
1746
1741
  "If this entry replaces or corrects prior memory, set supersedes and/or invalidates links explicitly.",
1747
1742
  "The memory log is append-only. Do not rewrite or truncate older entries.",
@@ -1779,7 +1774,7 @@ export default function memoryExtension(pi: ExtensionAPI): void {
1779
1774
  promptSnippet: "Consolidate repo memory with dream-based core compression",
1780
1775
  promptGuidelines: [
1781
1776
  "Dream is the only consolidation mechanism for .agents/memory/core.",
1782
- "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.",
1783
1778
  ],
1784
1779
  parameters: MEMORY_DREAM_PARAMS,
1785
1780
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {