@gotgenes/pi-autoformat 0.1.0 → 4.0.3

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 (75) hide show
  1. package/.github/workflows/ci.yml +1 -3
  2. package/.github/workflows/release-please.yml +29 -0
  3. package/.markdownlint-cli2.yaml +14 -2
  4. package/.pi/extensions/pi-autoformat/config.json +3 -6
  5. package/.pi/prompts/README.md +59 -0
  6. package/.pi/prompts/plan-issue.md +64 -0
  7. package/.pi/prompts/retro.md +144 -0
  8. package/.pi/prompts/ship-issue.md +77 -0
  9. package/.pi/prompts/tdd-plan.md +67 -0
  10. package/.pi/skills/pi-extension-lifecycle/SKILL.md +256 -0
  11. package/.release-please-manifest.json +1 -1
  12. package/AGENTS.md +39 -0
  13. package/CHANGELOG.md +365 -0
  14. package/README.md +42 -109
  15. package/biome.json +1 -1
  16. package/docs/assets/logo.png +0 -0
  17. package/docs/assets/logo.svg +533 -0
  18. package/docs/configuration.md +358 -38
  19. package/docs/plans/0001-initial-implementation-plan.md +17 -9
  20. package/docs/plans/0002-richer-tui-formatter-summaries.md +220 -0
  21. package/docs/plans/0003-additional-pi-mutation-tools.md +273 -0
  22. package/docs/plans/0004-shell-driven-mutation-coverage.md +296 -0
  23. package/docs/plans/0010-acceptance-test-coverage.md +240 -0
  24. package/docs/plans/0012-remove-unused-formatter-extensions-field.md +152 -0
  25. package/docs/plans/0013-fallback-chain-step-type.md +280 -0
  26. package/docs/plans/0014-batch-by-default-formatter-dispatch.md +195 -0
  27. package/docs/plans/0015-builtin-treefmt-and-treefmt-nix-support.md +290 -0
  28. package/docs/plans/0016-detailed-formatter-output-on-failure.md +245 -0
  29. package/docs/plans/0022-pi-coding-agent-types.md +201 -0
  30. package/docs/plans/0027-format-before-agent-exit-follow-up-turn.md +355 -0
  31. package/docs/plans/0031-turn-end-flush-with-change-detection.md +365 -0
  32. package/docs/retro/0002-richer-tui-formatter-summaries.md +47 -0
  33. package/docs/retro/0013-fallback-chain-step-type.md +67 -0
  34. package/docs/retro/0015-builtin-treefmt-and-treefmt-nix-support.md +56 -0
  35. package/docs/retro/0016-detailed-formatter-output-on-failure.md +60 -0
  36. package/docs/retro/0022-pi-coding-agent-types.md +62 -0
  37. package/docs/testing.md +95 -0
  38. package/package.json +30 -11
  39. package/prek.toml +2 -2
  40. package/schemas/pi-autoformat.schema.json +145 -21
  41. package/src/builtin-formatters.ts +205 -0
  42. package/src/command-probe.ts +66 -0
  43. package/src/config-loader.ts +829 -90
  44. package/src/custom-mutation-tools.ts +125 -0
  45. package/src/extension.ts +469 -82
  46. package/src/format-scope.ts +118 -0
  47. package/src/formatter-config.ts +73 -36
  48. package/src/formatter-executor.ts +230 -34
  49. package/src/formatter-output-report.ts +149 -0
  50. package/src/formatter-registry.ts +139 -30
  51. package/src/index.ts +26 -5
  52. package/src/prompt-autoformatter.ts +148 -23
  53. package/src/shell-mutation-detector.ts +572 -0
  54. package/src/touched-files-queue.ts +72 -11
  55. package/test/acceptance-event-bus.test.ts +138 -0
  56. package/test/acceptance.test.ts +69 -0
  57. package/test/builtin-formatters.test.ts +382 -0
  58. package/test/command-probe.test.ts +79 -0
  59. package/test/config-loader.test.ts +640 -21
  60. package/test/custom-mutation-tools.test.ts +190 -0
  61. package/test/extension.test.ts +1535 -158
  62. package/test/fallback-acceptance.test.ts +98 -0
  63. package/test/fixtures/event-bus-emitter.ts +26 -0
  64. package/test/fixtures/formatter-recorder.mjs +25 -0
  65. package/test/format-scope.test.ts +139 -0
  66. package/test/formatter-config.test.ts +56 -5
  67. package/test/formatter-executor.test.ts +555 -35
  68. package/test/formatter-output-report.test.ts +178 -0
  69. package/test/formatter-registry.test.ts +330 -37
  70. package/test/helpers/rpc.ts +146 -0
  71. package/test/prompt-autoformatter.test.ts +315 -22
  72. package/test/schema.test.ts +149 -0
  73. package/test/shell-mutation-detector.test.ts +221 -0
  74. package/test/touched-files-queue.test.ts +40 -1
  75. package/test/types/theme-stub.test-d.ts +42 -0
package/src/extension.ts CHANGED
@@ -1,56 +1,84 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
 
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionContext,
7
+ ToolCallEvent,
8
+ ToolResultEvent,
9
+ } from "@mariozechner/pi-coding-agent";
10
+
4
11
  import {
5
12
  AUTOFORMAT_EXTENSION_ID,
6
13
  type ConfigValidationIssue,
7
14
  type LoadConfigResult,
8
15
  loadAutoformatConfig,
9
16
  } from "./config-loader.js";
17
+ import {
18
+ createCustomToolHandlers,
19
+ parseTouchedPayload,
20
+ } from "./custom-mutation-tools.js";
21
+ import { resolveFormatScope } from "./format-scope.js";
10
22
  import type { AutoformatConfig } from "./formatter-config.js";
11
23
  import type { CommandRunner, CommandRunResult } from "./formatter-executor.js";
24
+ import { formatRunOutputBlock } from "./formatter-output-report.js";
12
25
  import {
13
26
  PromptAutoformatter,
14
27
  type PromptAutoformatterResult,
15
28
  } from "./prompt-autoformatter.js";
29
+ import {
30
+ matchWrapper,
31
+ parseKnownCommand,
32
+ SnapshotTracker,
33
+ } from "./shell-mutation-detector.js";
34
+ import {
35
+ type MutationSourceHandler,
36
+ writeOrEditHandler,
37
+ } from "./touched-files-queue.js";
16
38
 
17
39
  const execFileAsync = promisify(execFile);
18
40
  const COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
19
41
 
20
42
  type NotificationType = "info" | "warning" | "error";
21
43
 
22
- type ExtensionContextLike = {
23
- cwd: string;
24
- hasUI: boolean;
25
- ui: {
26
- notify(message: string, type?: NotificationType): void;
27
- };
28
- };
44
+ /**
45
+ * Narrowed view of Pi's real `ExtensionContext`, restricted to the surface
46
+ * this extension actually consumes. Pi's full `ExtensionContext` requires
47
+ * `sessionManager`, `modelRegistry`, `model`, `signal`, `isIdle`, etc., none
48
+ * of which the autoformatter uses. Internal helpers take this narrow alias
49
+ * so test stubs do not have to fabricate the unused fields, while top-level
50
+ * `pi.on(...)` handlers still receive the real `ExtensionContext` from Pi.
51
+ */
52
+ type AutoformatExtensionContext = Pick<
53
+ ExtensionContext,
54
+ "cwd" | "hasUI" | "ui"
55
+ >;
29
56
 
30
- type ToolResultEventLike = {
31
- toolName: string;
32
- input: unknown;
33
- isError: boolean;
34
- };
57
+ /**
58
+ * Re-export of Pi's real `ExtensionAPI` under the legacy `ExtensionApiLike`
59
+ * name so any downstream importer that pinned to the old alias keeps working.
60
+ * Internal usage prefers `ExtensionAPI` directly.
61
+ */
62
+ export type ExtensionApiLike = ExtensionAPI;
35
63
 
36
- type ExtensionHandler<TEvent> = (
37
- event: TEvent,
38
- ctx: ExtensionContextLike,
39
- ) => void | Promise<void>;
40
-
41
- type ExtensionApiLike = {
42
- on(eventName: "session_start", handler: ExtensionHandler<unknown>): void;
43
- on(
44
- eventName: "tool_result",
45
- handler: ExtensionHandler<ToolResultEventLike>,
46
- ): void;
47
- on(eventName: "agent_end", handler: ExtensionHandler<unknown>): void;
48
- on(eventName: "session_shutdown", handler: ExtensionHandler<unknown>): void;
49
- };
64
+ const AUTOFORMAT_STATUS_KEY = "autoformat";
65
+
66
+ function setAutoformatStatus(
67
+ ctx: AutoformatExtensionContext,
68
+ text: string | undefined,
69
+ ): void {
70
+ if (!ctx.hasUI) {
71
+ return;
72
+ }
73
+ if (typeof ctx.ui.setStatus !== "function") {
74
+ return;
75
+ }
76
+ ctx.ui.setStatus(AUTOFORMAT_STATUS_KEY, text);
77
+ }
50
78
 
51
79
  type PromptAutoformatterLike = Pick<
52
80
  PromptAutoformatter,
53
- "recordToolResult" | "flushPrompt"
81
+ "recordToolResult" | "flushPrompt" | "addTouchedPath"
54
82
  >;
55
83
 
56
84
  type AutoformatExtensionDependencies = {
@@ -63,13 +91,13 @@ type AutoformatExtensionDependencies = {
63
91
  result: PromptAutoformatterResult,
64
92
  options: {
65
93
  config: AutoformatConfig;
66
- ctx: ExtensionContextLike;
94
+ ctx: AutoformatExtensionContext;
67
95
  },
68
96
  ) => void;
69
97
  reportConfigIssues?: (
70
98
  issues: ConfigValidationIssue[],
71
99
  options: {
72
- ctx: ExtensionContextLike;
100
+ ctx: AutoformatExtensionContext;
73
101
  },
74
102
  ) => void;
75
103
  };
@@ -78,6 +106,8 @@ type SessionState = {
78
106
  cwd: string;
79
107
  loadResult: LoadConfigResult;
80
108
  autoformatter: PromptAutoformatterLike;
109
+ snapshotTracker: SnapshotTracker | undefined;
110
+ unsubscribeEventBus: (() => void) | undefined;
81
111
  };
82
112
 
83
113
  type ExecFileError = Error & {
@@ -141,19 +171,102 @@ function createCommandRunner(commandTimeoutMs: number): CommandRunner {
141
171
  };
142
172
  }
143
173
 
144
- function createDefaultAutoformatter(
174
+ export function createDefaultAutoformatter(
145
175
  cwd: string,
146
176
  config: AutoformatConfig,
147
177
  ): PromptAutoformatterLike {
178
+ const scope = resolveFormatScope({ cwd, setting: config.formatScope });
179
+ const handlers: MutationSourceHandler[] = [writeOrEditHandler];
180
+
181
+ if (config.customMutationTools.length > 0) {
182
+ handlers.push(...createCustomToolHandlers(config.customMutationTools));
183
+ }
184
+
185
+ if (config.shellMutationDetection.enabled) {
186
+ handlers.push(createBashMutationHandler(config));
187
+ }
188
+
148
189
  return new PromptAutoformatter(
149
190
  cwd,
150
191
  config,
151
192
  createCommandRunner(config.commandTimeoutMs),
193
+ { scope, mutationHandlers: handlers },
152
194
  );
153
195
  }
154
196
 
197
+ function createBashMutationHandler(
198
+ config: AutoformatConfig,
199
+ ): MutationSourceHandler {
200
+ const detection = config.shellMutationDetection;
201
+ return (toolName, payload, output) => {
202
+ if (toolName !== "bash") {
203
+ return [];
204
+ }
205
+ const command = extractBashCommand(payload);
206
+ if (!command) {
207
+ return [];
208
+ }
209
+ const candidates: string[] = [];
210
+ if (detection.argumentParsing) {
211
+ candidates.push(...parseKnownCommand(command));
212
+ }
213
+ if (detection.wrappers.length > 0) {
214
+ candidates.push(...matchWrapper(command, output, detection.wrappers));
215
+ }
216
+ return candidates;
217
+ };
218
+ }
219
+
220
+ function extractBashCommand(payload: unknown): string | undefined {
221
+ if (
222
+ typeof payload === "object" &&
223
+ payload !== null &&
224
+ "command" in payload &&
225
+ typeof (payload as { command: unknown }).command === "string"
226
+ ) {
227
+ return (payload as { command: string }).command;
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ function subscribeToEventBus(
233
+ pi: ExtensionAPI,
234
+ config: AutoformatConfig,
235
+ autoformatter: PromptAutoformatterLike,
236
+ ): (() => void) | undefined {
237
+ const channelConfig = config.eventBusMutationChannel;
238
+ if (!channelConfig.enabled || !pi.events) {
239
+ return undefined;
240
+ }
241
+ return pi.events.on(channelConfig.channel, (data: unknown) => {
242
+ const paths = parseTouchedPayload(data);
243
+ for (const candidate of paths) {
244
+ autoformatter.addTouchedPath(candidate);
245
+ }
246
+ });
247
+ }
248
+
249
+ function extractToolOutputText(
250
+ content: ToolResultEvent["content"] | undefined,
251
+ ): string {
252
+ if (!content) {
253
+ return "";
254
+ }
255
+ const parts: string[] = [];
256
+ for (const item of content) {
257
+ if (
258
+ item &&
259
+ "text" in item &&
260
+ typeof (item as { text?: unknown }).text === "string"
261
+ ) {
262
+ parts.push((item as { text: string }).text);
263
+ }
264
+ }
265
+ return parts.join("\n");
266
+ }
267
+
155
268
  function reportMessage(
156
- ctx: ExtensionContextLike,
269
+ ctx: AutoformatExtensionContext,
157
270
  message: string,
158
271
  type: NotificationType,
159
272
  ): void {
@@ -173,82 +286,304 @@ function reportMessage(
173
286
 
174
287
  type FailureSummary = {
175
288
  lines: string[];
176
- failedRunCount: number;
289
+ failedBatchCount: number;
177
290
  };
178
291
 
179
- function summarizeFailures(result: PromptAutoformatterResult): FailureSummary {
292
+ function formatterLabel(
293
+ name: string,
294
+ fallbackContext?: { skipped: string[] },
295
+ ): string {
296
+ if (!fallbackContext || fallbackContext.skipped.length === 0) {
297
+ return name;
298
+ }
299
+ return `${name} (fallback after ${fallbackContext.skipped.join(", ")} unavailable)`;
300
+ }
301
+
302
+ function summarizeFailures(
303
+ result: PromptAutoformatterResult,
304
+ config?: AutoformatConfig,
305
+ ): FailureSummary {
180
306
  const lines: string[] = [];
181
- let failedRunCount = 0;
307
+ let failedBatchCount = 0;
308
+
309
+ for (const group of result.groups) {
310
+ for (const run of group.runs) {
311
+ if (run.success) {
312
+ continue;
313
+ }
314
+ failedBatchCount += 1;
315
+ lines.push(
316
+ `${formatterLabel(run.formatterName, run.fallbackContext)} (exit ${run.exitCode}): ${run.files.join(", ")}`,
317
+ );
318
+ if (config) {
319
+ const outputBlock = formatRunOutputBlock(run, config.formatterOutput);
320
+ if (outputBlock) {
321
+ lines.push(outputBlock);
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ return { lines, failedBatchCount };
328
+ }
182
329
 
183
- for (const file of result.files) {
184
- const failures = file.runs.filter((run) => !run.success);
185
- if (failures.length === 0) {
186
- continue;
330
+ function summarizeFallbackUsages(result: PromptAutoformatterResult): string[] {
331
+ const lines: string[] = [];
332
+ for (const group of result.groups) {
333
+ for (const run of group.runs) {
334
+ if (!run.success) {
335
+ continue;
336
+ }
337
+ if (!run.fallbackContext || run.fallbackContext.skipped.length === 0) {
338
+ continue;
339
+ }
340
+ lines.push(formatterLabel(run.formatterName, run.fallbackContext));
187
341
  }
342
+ }
343
+ return lines;
344
+ }
188
345
 
189
- failedRunCount += failures.length;
190
- const details = failures
191
- .map((run) => `${run.formatterName} (exit ${run.exitCode})`)
192
- .join(", ");
193
- lines.push(`${file.path}: ${details}`);
346
+ function collectAllFiles(result: PromptAutoformatterResult): string[] {
347
+ const files: string[] = [];
348
+ for (const group of result.groups) {
349
+ files.push(...group.files);
350
+ }
351
+ return files;
352
+ }
353
+
354
+ function summarizeSuccessPaths(files: string[]): string | undefined {
355
+ if (files.length === 0 || files.length > 3) {
356
+ return undefined;
357
+ }
358
+ return files.join(", ");
359
+ }
360
+
361
+ type FlushSummary = {
362
+ groupCount: number;
363
+ fileCount: number;
364
+ successBatchCount: number;
365
+ failureBatchCount: number;
366
+ failureLines: string[];
367
+ formatterLabels: string[];
368
+ fallbackUsages: string[];
369
+ };
370
+
371
+ function summarizeFlush(
372
+ result: PromptAutoformatterResult,
373
+ config?: AutoformatConfig,
374
+ ): FlushSummary {
375
+ const failureSummary = summarizeFailures(result, config);
376
+ const fallbackUsages = summarizeFallbackUsages(result);
377
+ const fileCount = collectAllFiles(result).length;
378
+
379
+ const seen = new Set<string>();
380
+ const formatterLabels: string[] = [];
381
+ let successBatchCount = 0;
382
+ for (const group of result.groups) {
383
+ for (const run of group.runs) {
384
+ if (run.success) {
385
+ successBatchCount += 1;
386
+ }
387
+ const label = formatterLabel(run.formatterName, run.fallbackContext);
388
+ if (!seen.has(label)) {
389
+ seen.add(label);
390
+ formatterLabels.push(label);
391
+ }
392
+ }
194
393
  }
195
394
 
196
395
  return {
197
- lines,
198
- failedRunCount,
396
+ groupCount: result.groups.length,
397
+ fileCount,
398
+ successBatchCount,
399
+ failureBatchCount: failureSummary.failedBatchCount,
400
+ failureLines: failureSummary.lines,
401
+ formatterLabels,
402
+ fallbackUsages,
199
403
  };
200
404
  }
201
405
 
202
- function summarizeSuccessPaths(
406
+ type ThemeColorName = "success" | "warning" | "error" | "dim" | "accent";
407
+
408
+ function themed(
409
+ ctx: AutoformatExtensionContext,
410
+ color: ThemeColorName,
411
+ text: string,
412
+ ): string {
413
+ const theme = ctx.ui.theme;
414
+ if (!theme || typeof theme.fg !== "function") {
415
+ return text;
416
+ }
417
+ // Call through the theme object so `this` stays bound. Pi's real Theme.fg
418
+ // is an instance method that reads `this.fgColors`; destructuring it would
419
+ // throw "Cannot read properties of undefined (reading 'fgColors')".
420
+ try {
421
+ return theme.fg(color, text);
422
+ } catch {
423
+ // Defensive: a theme that throws on a known color name (e.g. a partial
424
+ // palette) should degrade to plain text rather than break the flush.
425
+ return text;
426
+ }
427
+ }
428
+
429
+ function formatStatusLine(
430
+ summary: FlushSummary,
431
+ ctx: AutoformatExtensionContext,
432
+ ): string {
433
+ const fileWord = summary.fileCount === 1 ? "file" : "files";
434
+ const formatters =
435
+ summary.formatterLabels.length > 0
436
+ ? ` (${summary.formatterLabels.join(", ")})`
437
+ : "";
438
+ const label = themed(ctx, "dim", "autoformat:");
439
+
440
+ if (summary.failureBatchCount > 0) {
441
+ const batchWord = summary.failureBatchCount === 1 ? "batch" : "batches";
442
+ const mark = themed(ctx, "error", "\u2717");
443
+ const failureClause = themed(
444
+ ctx,
445
+ "error",
446
+ `${summary.failureBatchCount} ${batchWord} failed`,
447
+ );
448
+ const okSuffix =
449
+ summary.successBatchCount > 0
450
+ ? themed(ctx, "dim", ` \u2014 ${summary.successBatchCount} ok`)
451
+ : "";
452
+ return `${mark} ${label} ${failureClause}${formatters}${okSuffix}`;
453
+ }
454
+
455
+ const mark = themed(ctx, "success", "\u2713");
456
+ return `${mark} ${label} ${summary.fileCount} ${fileWord}${formatters}`;
457
+ }
458
+
459
+ const STEERING_MAX_FILES = 10;
460
+
461
+ export function buildSteeringMessageContent(
203
462
  result: PromptAutoformatterResult,
204
463
  ): string | undefined {
205
- if (result.files.length === 0 || result.files.length > 3) {
464
+ if (result.groups.length === 0) {
465
+ return undefined;
466
+ }
467
+
468
+ const changedFiles: string[] = [];
469
+ const failureLines: string[] = [];
470
+
471
+ for (const group of result.groups) {
472
+ changedFiles.push(...group.changedFiles);
473
+ for (const run of group.runs) {
474
+ if (run.success) {
475
+ continue;
476
+ }
477
+ const fileList = run.files.join(", ");
478
+ failureLines.push(
479
+ ` ${run.formatterName} (exit ${run.exitCode}) on ${fileList}:`,
480
+ );
481
+ if (run.stderr) {
482
+ failureLines.push(` ${run.stderr}`);
483
+ }
484
+ if (run.stdout) {
485
+ failureLines.push(` ${run.stdout}`);
486
+ }
487
+ }
488
+ }
489
+
490
+ if (changedFiles.length === 0 && failureLines.length === 0) {
206
491
  return undefined;
207
492
  }
208
493
 
209
- return result.files.map((file) => file.path).join(", ");
494
+ const parts: string[] = [];
495
+
496
+ if (changedFiles.length > 0) {
497
+ const shown = changedFiles.slice(0, STEERING_MAX_FILES);
498
+ const remaining = changedFiles.length - shown.length;
499
+ let list = shown.join(", ");
500
+ if (remaining > 0) {
501
+ list += `, \u2026 and ${remaining} more`;
502
+ }
503
+ parts.push(
504
+ `[autoformat] Formatted ${changedFiles.length} file(s): ${list}`,
505
+ );
506
+ }
507
+
508
+ if (failureLines.length > 0) {
509
+ if (changedFiles.length === 0) {
510
+ parts.push(["[autoformat] Failures:", ...failureLines].join("\n"));
511
+ } else {
512
+ parts.push(["Failures:", ...failureLines].join("\n"));
513
+ }
514
+ }
515
+
516
+ return parts.join("\n\n") || undefined;
517
+ }
518
+
519
+ function buildLegacyFailureMessage(summary: FlushSummary): string {
520
+ const batchWord = summary.failureBatchCount === 1 ? "batch" : "batches";
521
+ return [
522
+ `Formatter failures in ${summary.failureBatchCount} ${batchWord}:`,
523
+ ...summary.failureLines,
524
+ ].join("\n");
525
+ }
526
+
527
+ function buildLegacySuccessMessage(
528
+ result: PromptAutoformatterResult,
529
+ summary: FlushSummary,
530
+ ): string {
531
+ const allFiles = collectAllFiles(result);
532
+ const successPaths = summarizeSuccessPaths(allFiles);
533
+ const fileWord = allFiles.length === 1 ? "file" : "files";
534
+ const baseMessage = successPaths
535
+ ? `Autoformatted ${allFiles.length} ${fileWord}: ${successPaths}`
536
+ : `Autoformatted ${allFiles.length} ${fileWord}.`;
537
+
538
+ return summary.fallbackUsages.length > 0
539
+ ? `${baseMessage} [${summary.fallbackUsages.join("; ")}]`
540
+ : baseMessage;
210
541
  }
211
542
 
212
543
  function defaultReportFlushResult(
213
544
  result: PromptAutoformatterResult,
214
545
  options: {
215
546
  config: AutoformatConfig;
216
- ctx: ExtensionContextLike;
547
+ ctx: AutoformatExtensionContext;
217
548
  },
218
549
  ): void {
219
- if (result.files.length === 0) {
550
+ if (result.groups.length === 0) {
551
+ setAutoformatStatus(options.ctx, undefined);
220
552
  return;
221
553
  }
222
554
 
223
- const failureSummary = summarizeFailures(result);
224
- if (failureSummary.lines.length > 0) {
225
- reportMessage(
226
- options.ctx,
227
- [
228
- `Formatter failures in ${failureSummary.lines.length} file${failureSummary.lines.length === 1 ? "" : "s"} (${failureSummary.failedRunCount} failed run${failureSummary.failedRunCount === 1 ? "" : "s"}):`,
229
- ...failureSummary.lines,
230
- ].join("\n"),
231
- "warning",
232
- );
555
+ const summary = summarizeFlush(result, options.config);
556
+
557
+ if (summary.failureBatchCount > 0) {
558
+ const message = buildLegacyFailureMessage(summary);
559
+ if (options.ctx.hasUI) {
560
+ setAutoformatStatus(options.ctx, formatStatusLine(summary, options.ctx));
561
+ }
562
+ reportMessage(options.ctx, message, "warning");
233
563
  return;
234
564
  }
235
565
 
236
566
  if (options.config.hideSummariesInTui && options.ctx.hasUI) {
567
+ setAutoformatStatus(options.ctx, undefined);
237
568
  return;
238
569
  }
239
570
 
240
- const successPaths = summarizeSuccessPaths(result);
241
- const message = successPaths
242
- ? `Autoformatted ${result.files.length} file${result.files.length === 1 ? "" : "s"}: ${successPaths}`
243
- : `Autoformatted ${result.files.length} file${result.files.length === 1 ? "" : "s"}.`;
571
+ if (options.ctx.hasUI) {
572
+ setAutoformatStatus(options.ctx, formatStatusLine(summary, options.ctx));
573
+ return;
574
+ }
244
575
 
245
- reportMessage(options.ctx, message, "info");
576
+ reportMessage(
577
+ options.ctx,
578
+ buildLegacySuccessMessage(result, summary),
579
+ "info",
580
+ );
246
581
  }
247
582
 
248
583
  function defaultReportConfigIssues(
249
584
  issues: ConfigValidationIssue[],
250
585
  options: {
251
- ctx: ExtensionContextLike;
586
+ ctx: AutoformatExtensionContext;
252
587
  },
253
588
  ): void {
254
589
  if (issues.length === 0) {
@@ -277,7 +612,7 @@ function defaultReportConfigIssues(
277
612
  }
278
613
 
279
614
  export function createAutoformatExtension(
280
- pi: ExtensionApiLike,
615
+ pi: ExtensionAPI,
281
616
  dependencies: AutoformatExtensionDependencies = {},
282
617
  ): void {
283
618
  const loadConfig =
@@ -290,7 +625,9 @@ export function createAutoformatExtension(
290
625
  dependencies.reportConfigIssues ?? defaultReportConfigIssues;
291
626
 
292
627
  let state: SessionState | undefined;
293
- let pendingFlush = Promise.resolve();
628
+ let pendingFlush = Promise.resolve<PromptAutoformatterResult | undefined>(
629
+ undefined,
630
+ );
294
631
 
295
632
  function ensureState(cwd: string): SessionState {
296
633
  if (state && state.cwd === cwd) {
@@ -298,15 +635,33 @@ export function createAutoformatExtension(
298
635
  }
299
636
 
300
637
  const loadResult = loadConfig(cwd);
638
+ const detection = loadResult.config.shellMutationDetection;
639
+ const snapshotTracker =
640
+ detection.enabled && detection.snapshotGlobs.length > 0
641
+ ? new SnapshotTracker({
642
+ cwd,
643
+ globs: detection.snapshotGlobs,
644
+ })
645
+ : undefined;
646
+ const autoformatter = createAutoformatter(cwd, loadResult.config);
647
+ const unsubscribeEventBus = subscribeToEventBus(
648
+ pi,
649
+ loadResult.config,
650
+ autoformatter,
651
+ );
301
652
  state = {
302
653
  cwd,
303
654
  loadResult,
304
- autoformatter: createAutoformatter(cwd, loadResult.config),
655
+ autoformatter,
656
+ snapshotTracker,
657
+ unsubscribeEventBus,
305
658
  };
306
659
  return state;
307
660
  }
308
661
 
309
- function queueFlush(ctx: ExtensionContextLike): Promise<void> {
662
+ function queueFlush(
663
+ ctx: AutoformatExtensionContext,
664
+ ): Promise<PromptAutoformatterResult | undefined> {
310
665
  const sessionState = state;
311
666
  if (!sessionState) {
312
667
  return pendingFlush;
@@ -319,10 +674,12 @@ export function createAutoformatExtension(
319
674
  config: sessionState.loadResult.config,
320
675
  ctx,
321
676
  });
677
+ return result;
322
678
  })
323
679
  .catch((error: unknown) => {
324
680
  const message = error instanceof Error ? error.message : String(error);
325
681
  reportMessage(ctx, `Unexpected runtime error: ${message}`, "warning");
682
+ return undefined;
326
683
  });
327
684
 
328
685
  return pendingFlush;
@@ -331,27 +688,55 @@ export function createAutoformatExtension(
331
688
  pi.on("session_start", async (_event, ctx) => {
332
689
  const sessionState = ensureState(ctx.cwd);
333
690
  reportConfigIssues(sessionState.loadResult.issues, { ctx });
691
+ setAutoformatStatus(ctx, undefined);
692
+ });
693
+
694
+ pi.on("tool_call", async (event: ToolCallEvent, ctx) => {
695
+ if (event.toolName !== "bash") {
696
+ return;
697
+ }
698
+ const sessionState = ensureState(ctx.cwd);
699
+ sessionState.snapshotTracker?.before();
334
700
  });
335
701
 
336
- pi.on("tool_result", async (event, ctx) => {
702
+ pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
337
703
  if (event.isError) {
338
704
  return;
339
705
  }
340
706
 
341
707
  const sessionState = ensureState(ctx.cwd);
342
- sessionState.autoformatter.recordToolResult(event.toolName, event.input);
708
+ const output = extractToolOutputText(event.content);
709
+ sessionState.autoformatter.recordToolResult(
710
+ event.toolName,
711
+ event.input,
712
+ output,
713
+ );
343
714
 
344
- if (sessionState.loadResult.config.formatMode === "tool") {
345
- await queueFlush(ctx);
715
+ if (event.toolName === "bash" && sessionState.snapshotTracker) {
716
+ const snapshotTouched = sessionState.snapshotTracker.after();
717
+ for (const touched of snapshotTouched) {
718
+ sessionState.autoformatter.addTouchedPath(touched);
719
+ }
346
720
  }
347
721
  });
348
722
 
349
- pi.on("agent_end", async (_event, ctx) => {
350
- const sessionState = ensureState(ctx.cwd);
351
- if (sessionState.loadResult.config.formatMode !== "prompt") {
352
- return;
723
+ pi.on("turn_end", async (_event, ctx) => {
724
+ const result = await queueFlush(ctx);
725
+ if (result) {
726
+ const content = buildSteeringMessageContent(result);
727
+ if (content) {
728
+ pi.sendMessage({
729
+ customType: "autoformat-steering",
730
+ content,
731
+ display: true,
732
+ });
733
+ }
353
734
  }
735
+ });
354
736
 
737
+ pi.on("agent_end", async (_event, ctx) => {
738
+ // Safety-net flush: in the normal case, turn_end has already drained
739
+ // the queue, so this is a no-op.
355
740
  await queueFlush(ctx);
356
741
  });
357
742
 
@@ -361,14 +746,16 @@ export function createAutoformatExtension(
361
746
  return;
362
747
  }
363
748
 
364
- if (sessionState.loadResult.config.formatMode === "session") {
365
- await queueFlush(ctx);
366
- }
749
+ // Final safety-net flush for any touched files not yet formatted
750
+ // (e.g. files added via EventBus without an agent loop).
751
+ await queueFlush(ctx);
367
752
 
753
+ setAutoformatStatus(ctx, undefined);
754
+ sessionState.unsubscribeEventBus?.();
368
755
  state = undefined;
369
756
  });
370
757
  }
371
758
 
372
- export default function autoformatExtension(pi: ExtensionApiLike): void {
759
+ export default function autoformatExtension(pi: ExtensionAPI): void {
373
760
  createAutoformatExtension(pi);
374
761
  }