tau-coding-agent 0.1.5 → 0.2.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.
Files changed (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,4 +1,4 @@
1
- import { spawn, type ChildProcess } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
@@ -20,9 +20,9 @@ export type TaskErrorKind =
20
20
  | "unsupported_reasoning"
21
21
  | "other";
22
22
 
23
- export type TaskExecutionControl = {
24
- isCancelled: () => boolean;
25
- registerProcess: (proc: ChildProcess) => () => void;
23
+ export type ReviewCommandContext = {
24
+ cwd: string;
25
+ signal?: AbortSignal;
26
26
  };
27
27
 
28
28
  export type PiTaskStatus =
@@ -47,7 +47,7 @@ export type PiTaskOptions = {
47
47
  prompt: string;
48
48
  cwd: string;
49
49
  timeoutMs: number;
50
- control?: TaskExecutionControl;
50
+ signal: AbortSignal;
51
51
  submitTool?: string;
52
52
  };
53
53
 
@@ -347,12 +347,12 @@ export async function runPiSubmitToolTask(options: PiSubmitToolTaskOptions): Pro
347
347
  if (
348
348
  firstResult.status !== "ok" ||
349
349
  firstResult.submittedPayloads.length > 0 ||
350
- options.control?.isCancelled()
350
+ options.signal.aborted
351
351
  ) {
352
352
  return firstResult;
353
353
  }
354
354
 
355
- return runPiOneShotTask({
355
+ return await runPiOneShotTask({
356
356
  ...options,
357
357
  args,
358
358
  prompt: SUBMIT_TOOL_RETRY_PROMPT.replaceAll("{SUBMIT_TOOL}", options.submitTool),
@@ -367,156 +367,148 @@ export async function runPiOneShotTask({
367
367
  prompt,
368
368
  cwd,
369
369
  timeoutMs,
370
- control,
370
+ signal,
371
371
  submitTool,
372
372
  }: PiTaskOptions): Promise<PiTaskResult> {
373
- if (control?.isCancelled()) {
374
- return {
375
- status: "cancelled",
376
- assistantOutput: "",
377
- stderr: "",
378
- submittedPayloads: [],
379
- };
373
+ if (signal.aborted) {
374
+ return { status: "cancelled", assistantOutput: "", stderr: "", submittedPayloads: [] };
380
375
  }
381
376
 
382
- return new Promise<PiTaskResult>((resolve) => {
383
- const proc = spawn("pi", [...args, prompt], {
384
- cwd,
385
- shell: false,
386
- stdio: ["ignore", "pipe", "pipe"],
387
- env: process.env,
388
- });
389
- const unregisterProcess = control?.registerProcess(proc);
390
-
391
- let stdoutBuffer = "";
392
- let latestAssistantOutput = "";
393
- let latestAssistantError = "";
394
- const submittedPayloads: unknown[] = [];
395
- let stderr = "";
396
- let settled = false;
397
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
398
-
399
- const finish = (result: Omit<PiTaskResult, "submittedPayloads">) => {
400
- if (settled) return;
401
- settled = true;
402
- if (timeoutId) {
403
- clearTimeout(timeoutId);
404
- timeoutId = undefined;
405
- }
406
- unregisterProcess?.();
407
- resolve({
408
- ...result,
409
- submittedPayloads,
410
- });
411
- };
412
-
413
- const processLine = (line: string) => {
414
- const trimmed = line.trim();
415
- if (!trimmed) return;
416
- try {
417
- const event = JSON.parse(trimmed);
418
- const submittedPayload = submitTool
419
- ? extractSubmitToolPayloadFromEvent(event, submitTool)
420
- : null;
421
- if (submittedPayload !== null) {
422
- submittedPayloads.push(submittedPayload);
423
- }
424
-
425
- const message = extractAssistantMessageFromEvent(event);
426
- if (message) {
427
- const text = extractTextContent(message.content);
428
- if (text) latestAssistantOutput = text;
429
- latestAssistantError =
430
- message.stopReason === "error" && typeof message.errorMessage === "string"
431
- ? message.errorMessage
432
- : "";
433
- }
434
- } catch {
435
- // Ignore non-JSON lines.
436
- }
437
- };
377
+ const child = spawnReviewProcess("pi", [...args, prompt], { cwd, signal });
378
+ let stdoutBuffer = "";
379
+ let latestAssistantOutput = "";
380
+ let latestAssistantError = "";
381
+ const submittedPayloads: unknown[] = [];
382
+ let stderr = "";
383
+ let timedOut = false;
384
+ const timeout = setTimeout(() => {
385
+ timedOut = true;
386
+ child.stop();
387
+ }, timeoutMs);
388
+
389
+ child.proc.stdout.on("data", (chunk: string) => {
390
+ stdoutBuffer += chunk;
391
+ const lines = stdoutBuffer.split("\n");
392
+ stdoutBuffer = lines.pop() ?? "";
393
+ for (const line of lines) processLine(line);
394
+ });
395
+ child.proc.stderr.on("data", (chunk: string) => {
396
+ stderr += chunk;
397
+ });
398
+ child.proc.stdin.end();
438
399
 
439
- if (control?.isCancelled()) {
440
- try {
441
- proc.kill("SIGKILL");
442
- } catch {
443
- // Best effort.
444
- }
445
- finish({
446
- status: "cancelled",
447
- assistantOutput: latestAssistantOutput,
448
- stderr,
449
- });
450
- return;
400
+ try {
401
+ const { code, error } = await child.completion;
402
+ if (stdoutBuffer.trim()) processLine(stdoutBuffer);
403
+ const output = { assistantOutput: latestAssistantOutput, stderr, submittedPayloads };
404
+ if (signal.aborted) return { ...output, status: "cancelled" };
405
+ if (timedOut) return { ...output, status: "timeout" };
406
+ if (error) return { ...output, status: "spawn_error", error: error.message };
407
+ if (code !== 0) return { ...output, status: "non_zero_exit", exitCode: code ?? 1 };
408
+ if (latestAssistantError) {
409
+ return { ...output, status: "assistant_error", error: latestAssistantError, exitCode: 0 };
451
410
  }
411
+ return { ...output, status: "ok", exitCode: 0 };
412
+ } finally {
413
+ clearTimeout(timeout);
414
+ }
452
415
 
453
- timeoutId = setTimeout(() => {
454
- try {
455
- proc.kill("SIGKILL");
456
- } catch {
457
- // Best effort.
458
- }
459
- finish({
460
- status: "timeout",
461
- assistantOutput: latestAssistantOutput,
462
- stderr,
463
- });
464
- }, timeoutMs);
465
-
466
- proc.stdout.on("data", (chunk) => {
467
- stdoutBuffer += chunk.toString();
468
- const lines = stdoutBuffer.split("\n");
469
- stdoutBuffer = lines.pop() ?? "";
470
- for (const line of lines) {
471
- processLine(line);
472
- }
473
- });
474
-
475
- proc.stderr.on("data", (chunk) => {
476
- stderr += chunk.toString();
477
- });
478
-
479
- proc.on("error", (error) => {
480
- finish({
481
- status: "spawn_error",
482
- assistantOutput: latestAssistantOutput,
483
- stderr,
484
- error: error.message,
485
- });
486
- });
487
-
488
- proc.on("close", (code) => {
489
- if (stdoutBuffer.trim()) {
490
- processLine(stdoutBuffer);
491
- }
492
-
493
- if ((code ?? 1) !== 0) {
494
- finish({
495
- status: "non_zero_exit",
496
- assistantOutput: latestAssistantOutput,
497
- stderr,
498
- exitCode: code ?? 1,
499
- });
500
- return;
416
+ function processLine(line: string) {
417
+ const trimmed = line.trim();
418
+ if (!trimmed) return;
419
+ try {
420
+ const event = JSON.parse(trimmed);
421
+ const submittedPayload = submitTool
422
+ ? extractSubmitToolPayloadFromEvent(event, submitTool)
423
+ : null;
424
+ if (submittedPayload !== null) submittedPayloads.push(submittedPayload);
425
+ const message = extractAssistantMessageFromEvent(event);
426
+ if (message) {
427
+ const text = extractTextContent(message.content);
428
+ if (text) latestAssistantOutput = text;
429
+ latestAssistantError =
430
+ message.stopReason === "error" && typeof message.errorMessage === "string"
431
+ ? message.errorMessage
432
+ : "";
501
433
  }
434
+ } catch {
435
+ // Ignore non-JSON lines.
436
+ }
437
+ }
438
+ }
502
439
 
503
- if (latestAssistantError) {
504
- finish({
505
- status: "assistant_error",
506
- assistantOutput: latestAssistantOutput,
507
- stderr,
508
- error: latestAssistantError,
509
- exitCode: 0,
510
- });
511
- return;
512
- }
440
+ export async function runReviewCommand(
441
+ context: ReviewCommandContext,
442
+ command: string,
443
+ args: string[],
444
+ input?: string,
445
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
446
+ const child = spawnReviewProcess(command, args, context);
447
+ let stdout = "";
448
+ let stderr = "";
449
+ child.proc.stdout.on("data", (chunk: string) => {
450
+ stdout += chunk;
451
+ });
452
+ child.proc.stderr.on("data", (chunk: string) => {
453
+ stderr += chunk;
454
+ });
455
+ child.proc.stdin.end(input);
456
+ const { code, error } = await child.completion;
457
+ context.signal?.throwIfAborted();
458
+ return { stdout, stderr: error?.message ?? stderr, code: error ? 1 : (code ?? 1) };
459
+ }
513
460
 
514
- finish({
515
- status: "ok",
516
- assistantOutput: latestAssistantOutput,
517
- stderr,
518
- exitCode: 0,
519
- });
461
+ /** Join process and pipes after graceful cancellation, escalating only if they have not closed. */
462
+ function spawnReviewProcess(
463
+ command: string,
464
+ args: string[],
465
+ { cwd, signal }: ReviewCommandContext,
466
+ ) {
467
+ signal?.throwIfAborted();
468
+ const proc = spawn(command, args, {
469
+ cwd,
470
+ detached: true,
471
+ stdio: ["pipe", "pipe", "pipe"],
472
+ env: process.env,
473
+ });
474
+ let closed = false;
475
+ let stopping = false;
476
+ let error: Error | undefined;
477
+ let escalation: NodeJS.Timeout | undefined;
478
+ proc.stdout.setEncoding("utf8");
479
+ proc.stderr.setEncoding("utf8");
480
+ proc.on("error", (cause: Error) => {
481
+ error = cause;
482
+ });
483
+ proc.stdin.on("error", () => {
484
+ /* A cancelled child may close stdin before it drains. */
485
+ });
486
+ const completion = new Promise<{ code: number | null; error?: Error }>((resolve) => {
487
+ proc.once("close", (code) => {
488
+ closed = true;
489
+ clearTimeout(escalation);
490
+ signal?.removeEventListener("abort", stop);
491
+ resolve({ code, error });
520
492
  });
521
493
  });
494
+ signal?.addEventListener("abort", stop, { once: true });
495
+ if (signal?.aborted) stop();
496
+ return { proc, completion, stop };
497
+
498
+ function stop() {
499
+ if (closed || stopping) return;
500
+ stopping = true;
501
+ // Pi handles SIGTERM by cancelling its agent and cleaning up detached Bash tools.
502
+ sendSignal("SIGTERM");
503
+ escalation = setTimeout(() => sendSignal("SIGKILL"), 1_000);
504
+ }
505
+
506
+ function sendSignal(value: "SIGTERM" | "SIGKILL") {
507
+ if (closed || proc.pid === undefined) return;
508
+ try {
509
+ process.kill(-proc.pid, value);
510
+ } catch (cause) {
511
+ if ((cause as NodeJS.ErrnoException).code !== "ESRCH") proc.kill(value);
512
+ }
513
+ }
522
514
  }
@@ -3,9 +3,8 @@ import type {
3
3
  ExtensionCommandContext,
4
4
  ExtensionContext,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import { matchesKey } from "@earendil-works/pi-tui";
7
- import type { ChildProcess } from "node:child_process";
8
-
6
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import { createInterruptGuard } from "./interrupt.js";
9
8
  import type { ReviewRunOutcome, ReviewRunSource } from "./schema.js";
10
9
 
11
10
  export const REVIEW_CANCELLED_ERROR = "Review aborted";
@@ -13,23 +12,12 @@ export const REVIEW_CANCELLED_ERROR = "Review aborted";
13
12
  export const REVIEW_EVENT_START = "review:start";
14
13
  export const REVIEW_EVENT_END = "review:end";
15
14
 
16
- export const REVIEW_STATUS_KEY = "0-review";
15
+ export const REVIEW_PROGRESS_WIDGET_KEY = "review-progress";
17
16
 
18
17
  export const STATUS_SPINNER_INTERVAL_MS = 80;
19
18
 
20
19
  export const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
21
20
 
22
- export type ReviewExecutionControl = {
23
- isCancelled: () => boolean;
24
- registerProcess: (proc: ChildProcess) => () => void;
25
- };
26
-
27
- export type ManagedReviewRun = {
28
- control: ReviewExecutionControl;
29
- markSuccessful: () => void;
30
- markCancelled: () => void;
31
- };
32
-
33
21
  export type AgentEndMessage = {
34
22
  role?: string;
35
23
  stopReason?: string;
@@ -56,79 +44,125 @@ export type AgentRunTracker = FixPassAgentTracker & {
56
44
  reset: () => void;
57
45
  };
58
46
 
59
- const runtimeState = {
60
- activeReviewRuns: new Set<string>(),
61
- activeReviewCancels: new Map<string, () => void>(),
62
- activePromptCount: 0,
63
- };
47
+ export type ReviewRuntime = ReturnType<typeof createReviewRuntime>;
64
48
 
65
- export async function withManagedReviewRun<T>(
49
+ export function createReviewRuntime(
66
50
  pi: ExtensionAPI,
67
- ctx: ExtensionCommandContext,
68
- source: ReviewRunSource,
69
- run: (managed: ManagedReviewRun) => Promise<T>,
70
- ): Promise<T> {
71
- const sessionKey = getReviewSessionKey(ctx);
72
- const activeProcesses = new Set<ChildProcess>();
73
- let cancelRequested = false;
74
- let outcome: ReviewRunOutcome = "failed";
51
+ onInterrupt: (ctx: ExtensionContext) => void,
52
+ ) {
53
+ let busy = false;
54
+ let closed = false;
55
+ let promptActive = false;
56
+ let active: { controller: AbortController; completion: Promise<void> } | undefined;
75
57
 
76
- const cancelActiveProcesses = () => {
77
- for (const proc of activeProcesses) {
58
+ return {
59
+ get closed() {
60
+ return closed;
61
+ },
62
+ acquire(ctx: ExtensionContext, busyMessage: string): boolean {
63
+ if (closed) return false;
64
+ if (busy) {
65
+ notify(ctx, busyMessage, "warning");
66
+ return false;
67
+ }
68
+ busy = true;
69
+ return true;
70
+ },
71
+ release() {
72
+ busy = false;
73
+ },
74
+ setPromptActive(value: boolean) {
75
+ promptActive = value;
76
+ },
77
+ async run<T extends { ok: boolean }>(
78
+ ctx: ExtensionCommandContext,
79
+ source: ReviewRunSource,
80
+ run: (signal: AbortSignal) => Promise<T>,
81
+ ): Promise<T | { ok: false; error: string }> {
82
+ if (closed) return { ok: false, error: REVIEW_CANCELLED_ERROR };
83
+ const sessionKey = getReviewSessionKey(ctx);
84
+ const controller = new AbortController();
85
+ const { signal } = controller;
86
+ let resolveCompletion!: () => void;
87
+ const completion = new Promise<void>((resolve) => {
88
+ resolveCompletion = resolve;
89
+ });
90
+ active = { controller, completion };
91
+ let outcome: ReviewRunOutcome = "failed";
92
+ let started = false;
93
+ let interrupt: ReturnType<typeof createInterruptGuard> | undefined;
78
94
  try {
79
- proc.kill("SIGKILL");
80
- } catch {
81
- // Best effort.
95
+ publishReviewActivity(pi, ctx, "review preparing");
96
+ if (ctx.mode === "tui") {
97
+ ctx.ui.setWidget(
98
+ REVIEW_PROGRESS_WIDGET_KEY,
99
+ (tui, theme) => {
100
+ interrupt = createInterruptGuard(
101
+ pi,
102
+ ctx,
103
+ tui,
104
+ () => !signal.aborted,
105
+ () => {
106
+ onInterrupt(ctx);
107
+ controller.abort();
108
+ },
109
+ () => promptActive,
110
+ );
111
+ return {
112
+ invalidate() {},
113
+ render: (width) => {
114
+ const handled = publishReviewActivity(pi, ctx, "review preparing");
115
+ return !ctx.ui.getToolsExpanded() && handled
116
+ ? []
117
+ : [renderReviewProgressHeader("preparing", theme, width)];
118
+ },
119
+ };
120
+ },
121
+ { placement: "aboveEditor" },
122
+ );
123
+ }
124
+ started = true;
125
+ pi.events.emit(REVIEW_EVENT_START, { sessionKey, source });
126
+ signal.throwIfAborted();
127
+ const result = await run(signal);
128
+ signal.throwIfAborted();
129
+ outcome = result.ok ? "success" : "failed";
130
+ return result;
131
+ } catch (error) {
132
+ if (!signal.aborted) throw error;
133
+ outcome = "cancelled";
134
+ return { ok: false, error: REVIEW_CANCELLED_ERROR };
135
+ } finally {
136
+ try {
137
+ controller.abort();
138
+ await interrupt?.dispose();
139
+ if (ctx.mode === "tui") ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined);
140
+ active = undefined;
141
+ if (started) pi.events.emit(REVIEW_EVENT_END, { sessionKey, source, outcome });
142
+ } finally {
143
+ publishReviewActivity(pi, ctx, undefined);
144
+ resolveCompletion();
145
+ }
82
146
  }
83
- }
84
- activeProcesses.clear();
85
- };
86
-
87
- const requestCancellation = () => {
88
- if (cancelRequested) return;
89
- cancelRequested = true;
90
- cancelActiveProcesses();
91
- };
92
-
93
- const control: ReviewExecutionControl = {
94
- isCancelled: () => cancelRequested,
95
- registerProcess: (proc) => {
96
- activeProcesses.add(proc);
97
- return () => {
98
- activeProcesses.delete(proc);
99
- };
147
+ },
148
+ async shutdown() {
149
+ closed = true;
150
+ const current = active;
151
+ current?.controller.abort();
152
+ await current?.completion;
100
153
  },
101
154
  };
155
+ }
102
156
 
103
- pi.events.emit(REVIEW_EVENT_START, { sessionKey, source });
104
- runtimeState.activeReviewCancels.set(sessionKey, requestCancellation);
105
- const unsubscribeInterrupt = ctx.hasUI
106
- ? ctx.ui.onTerminalInput((data) => {
107
- if (!matchesKey(data, "escape")) return undefined;
108
- if (runtimeState.activePromptCount > 0) return undefined;
109
- requestCancellation();
110
- return { consume: true };
111
- })
112
- : undefined;
113
-
114
- try {
115
- return await run({
116
- control,
117
- markSuccessful: () => {
118
- outcome = "success";
119
- },
120
- markCancelled: () => {
121
- outcome = "cancelled";
122
- },
123
- });
124
- } finally {
125
- unsubscribeInterrupt?.();
126
- cancelActiveProcesses();
127
- if (runtimeState.activeReviewCancels.get(sessionKey) === requestCancellation) {
128
- runtimeState.activeReviewCancels.delete(sessionKey);
129
- }
130
- pi.events.emit(REVIEW_EVENT_END, { sessionKey, source, outcome });
131
- }
157
+ /** Join every sibling before propagating failure, so cancellation cannot leave background work behind. */
158
+ export async function joinAll<T extends readonly unknown[] | []>(
159
+ tasks: T,
160
+ ): Promise<{ -readonly [P in keyof T]: Awaited<T[P]> }> {
161
+ const results = await Promise.allSettled(tasks);
162
+ return results.map((result) => {
163
+ if (result.status === "rejected") throw result.reason;
164
+ return result.value;
165
+ }) as { -readonly [P in keyof T]: Awaited<T[P]> };
132
166
  }
133
167
 
134
168
  export function notify(
@@ -144,7 +178,19 @@ export function getReviewSessionKey(ctx: ExtensionContext): string {
144
178
  return ctx.sessionManager.getSessionFile() ?? `session:${ctx.sessionManager.getSessionId()}`;
145
179
  }
146
180
 
181
+ export function publishReviewActivity(
182
+ pi: ExtensionAPI,
183
+ ctx: ExtensionContext,
184
+ text: string | undefined,
185
+ ): boolean {
186
+ if (ctx.mode !== "tui") return false;
187
+ const request = { sessionKey: getReviewSessionKey(ctx), source: "review", text, handled: false };
188
+ pi.events.emit("tau:activity", request);
189
+ return request.handled === true;
190
+ }
191
+
147
192
  export async function withSpinner<T>(
193
+ pi: ExtensionAPI,
148
194
  ctx: ExtensionContext,
149
195
  buildStatusText: () => string,
150
196
  run: () => Promise<T>,
@@ -152,9 +198,39 @@ export async function withSpinner<T>(
152
198
  if (!ctx.hasUI) return run();
153
199
 
154
200
  let frame = 0;
201
+ let requestRender: (() => void) | undefined;
202
+ if (ctx.mode === "tui") {
203
+ ctx.ui.setWidget(
204
+ REVIEW_PROGRESS_WIDGET_KEY,
205
+ (tui, theme) => {
206
+ requestRender = () => tui.requestRender();
207
+ return {
208
+ invalidate() {},
209
+ render(width) {
210
+ const handled = publishReviewActivity(pi, ctx, `review ${buildStatusText()}`);
211
+ if (!ctx.ui.getToolsExpanded() && handled) return [];
212
+ const spinner = theme.fg("accent", STATUS_SPINNER_FRAMES[frame]!);
213
+ return [
214
+ renderReviewProgressHeader(
215
+ `${spinner} ${theme.fg("muted", buildStatusText())}`,
216
+ theme,
217
+ width,
218
+ ),
219
+ ];
220
+ },
221
+ };
222
+ },
223
+ { placement: "aboveEditor" },
224
+ );
225
+ }
155
226
  const render = () => {
156
- const spinner = STATUS_SPINNER_FRAMES[frame % STATUS_SPINNER_FRAMES.length];
157
- ctx.ui.setStatus(REVIEW_STATUS_KEY, `${spinner} ${buildStatusText()}`);
227
+ if (ctx.mode === "tui") {
228
+ publishReviewActivity(pi, ctx, `review ${buildStatusText()}`);
229
+ requestRender?.();
230
+ } else
231
+ ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, [`Review · ${buildStatusText()}`], {
232
+ placement: "aboveEditor",
233
+ });
158
234
  };
159
235
 
160
236
  render();
@@ -167,23 +243,24 @@ export async function withSpinner<T>(
167
243
  return await run();
168
244
  } finally {
169
245
  clearInterval(timer);
170
- ctx.ui.setStatus(REVIEW_STATUS_KEY, undefined);
246
+ publishReviewActivity(pi, ctx, "review");
247
+ ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined);
171
248
  }
172
249
  }
173
250
 
174
- export function acquireReviewRunLock(ctx: ExtensionContext, busyMessage: string): string | null {
175
- const sessionKey = getReviewSessionKey(ctx);
176
- if (runtimeState.activeReviewRuns.has(sessionKey)) {
177
- notify(ctx, busyMessage, "warning");
178
- return null;
179
- }
180
-
181
- runtimeState.activeReviewRuns.add(sessionKey);
182
- return sessionKey;
183
- }
184
-
185
- export function releaseReviewRunLock(sessionKey: string): void {
186
- runtimeState.activeReviewRuns.delete(sessionKey);
251
+ export function renderReviewProgressHeader(
252
+ status: string,
253
+ theme: ExtensionContext["ui"]["theme"],
254
+ width: number,
255
+ hint = "",
256
+ ): string {
257
+ const label = `Review · ${status}`;
258
+ const innerWidth = Math.max(0, width - 2);
259
+ const gap = innerWidth - visibleWidth(label) - visibleWidth(hint);
260
+ return truncateToWidth(
261
+ ` ${theme.fg("muted", truncateToWidth(label, innerWidth))}${hint && gap >= 2 ? " ".repeat(gap) + theme.fg("dim", hint) : ""}`,
262
+ width,
263
+ );
187
264
  }
188
265
 
189
266
  export function createAgentRunTracker(): AgentRunTracker {
@@ -249,27 +326,3 @@ export function createAgentRunTracker(): AgentRunTracker {
249
326
  },
250
327
  };
251
328
  }
252
-
253
- export function recordPromptStart(): void {
254
- runtimeState.activePromptCount += 1;
255
- }
256
-
257
- export function recordPromptEnd(): void {
258
- runtimeState.activePromptCount = Math.max(0, runtimeState.activePromptCount - 1);
259
- }
260
-
261
- export function handleReviewSessionStart(ctx: ExtensionContext): void {
262
- runtimeState.activePromptCount = 0;
263
- const sessionKey = getReviewSessionKey(ctx);
264
- for (const [key, cancel] of runtimeState.activeReviewCancels) {
265
- if (key !== sessionKey) cancel();
266
- }
267
- }
268
-
269
- export function handleReviewSessionShutdown(ctx: ExtensionContext): void {
270
- const sessionKey = getReviewSessionKey(ctx);
271
- runtimeState.activeReviewCancels.get(sessionKey)?.();
272
- runtimeState.activeReviewCancels.delete(sessionKey);
273
- runtimeState.activeReviewRuns.delete(sessionKey);
274
- runtimeState.activePromptCount = 0;
275
- }