pi-ui-extend 0.1.36 → 0.1.37

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 (70) hide show
  1. package/dist/app/app.d.ts +7 -0
  2. package/dist/app/app.js +40 -5
  3. package/dist/app/commands/command-controller.js +1 -0
  4. package/dist/app/commands/command-registry.d.ts +1 -0
  5. package/dist/app/commands/command-registry.js +8 -0
  6. package/dist/app/commands/command-session-actions.d.ts +2 -0
  7. package/dist/app/commands/command-session-actions.js +79 -1
  8. package/dist/app/extensions/extension-actions-controller.d.ts +4 -1
  9. package/dist/app/extensions/extension-actions-controller.js +31 -2
  10. package/dist/app/input/input-controller.d.ts +1 -0
  11. package/dist/app/input/input-controller.js +23 -2
  12. package/dist/app/input/terminal-edit-shortcuts.d.ts +1 -0
  13. package/dist/app/input/terminal-edit-shortcuts.js +7 -0
  14. package/dist/app/input/voice-controller.js +1 -1
  15. package/dist/app/popup/popup-action-controller.d.ts +1 -3
  16. package/dist/app/popup/popup-action-controller.js +1 -5
  17. package/dist/app/rendering/message-content.js +4 -3
  18. package/dist/app/rendering/render-controller.js +21 -38
  19. package/dist/app/rendering/status-line-renderer.d.ts +1 -0
  20. package/dist/app/rendering/status-line-renderer.js +14 -2
  21. package/dist/app/runtime.js +12 -2
  22. package/dist/app/screen/mouse-controller.js +2 -0
  23. package/dist/app/session/session-event-controller.d.ts +7 -0
  24. package/dist/app/session/session-event-controller.js +10 -13
  25. package/dist/app/terminal/terminal-controller.js +1 -0
  26. package/dist/app/terminal/terminal-output-buffer.d.ts +8 -6
  27. package/dist/app/terminal/terminal-output-buffer.js +24 -16
  28. package/dist/bundled-extensions/terminal-bell/index.js +118 -33
  29. package/dist/markdown-format.d.ts +1 -0
  30. package/dist/markdown-format.js +30 -16
  31. package/dist/schemas/pi-tools-suite-schema.d.ts +5 -0
  32. package/dist/schemas/pi-tools-suite-schema.js +5 -0
  33. package/dist/tool-renderers/apply-patch.js +6 -1
  34. package/dist/tool-renderers/patch-normalize.d.ts +24 -0
  35. package/dist/tool-renderers/patch-normalize.js +163 -0
  36. package/external/pi-tools-suite/README.md +3 -2
  37. package/external/pi-tools-suite/package.json +3 -3
  38. package/external/pi-tools-suite/src/antigravity-auth/index.ts +15 -2
  39. package/external/pi-tools-suite/src/antigravity-auth/status.ts +36 -19
  40. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +5 -2
  41. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -2
  42. package/external/pi-tools-suite/src/async-subagents/core/config.ts +8 -3
  43. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +63 -28
  44. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +9 -4
  45. package/external/pi-tools-suite/src/comment-checker/config.ts +98 -0
  46. package/external/pi-tools-suite/src/comment-checker/detect.ts +215 -0
  47. package/external/pi-tools-suite/src/comment-checker/index.ts +294 -0
  48. package/external/pi-tools-suite/src/dcp/commands.ts +29 -15
  49. package/external/pi-tools-suite/src/dcp/compress-tool.ts +111 -60
  50. package/external/pi-tools-suite/src/dcp/config.ts +10 -6
  51. package/external/pi-tools-suite/src/dcp/debug-log.ts +235 -0
  52. package/external/pi-tools-suite/src/dcp/index.ts +204 -27
  53. package/external/pi-tools-suite/src/dcp/prompts.ts +25 -28
  54. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +6 -10
  55. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +19 -1
  56. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +36 -58
  57. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +18 -0
  58. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  59. package/external/pi-tools-suite/src/dcp/pruner.ts +4 -2
  60. package/external/pi-tools-suite/src/dcp/state-persistence.ts +31 -2
  61. package/external/pi-tools-suite/src/dcp/state.ts +62 -4
  62. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +18 -0
  63. package/external/pi-tools-suite/src/index.ts +1 -0
  64. package/external/pi-tools-suite/src/model-tools/index.ts +11 -3
  65. package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
  66. package/external/pi-tools-suite/src/todo/index.ts +24 -0
  67. package/external/pi-tools-suite/src/tool-descriptions.ts +3 -3
  68. package/external/pi-tools-suite/src/usage/index.ts +18 -4
  69. package/package.json +4 -4
  70. package/schemas/pi-tools-suite.json +24 -0
@@ -9,6 +9,12 @@ const IDLE_RETRY_DELAY_MS = 100;
9
9
  const MAX_IDLE_RETRIES = 40;
10
10
  const SUBAGENTS_LIVE_COUNT_EVENT = "pi-tools-suite:async-subagents:live-count";
11
11
  const TERMINAL_BELL_ATTENTION_EVENT = "pix:terminal-bell:attention";
12
+ /**
13
+ * Renderer-relayed signal that the session is in an auto-retry cycle.
14
+ * Payload: `{ active: boolean }`. The SDK does not forward retry state to
15
+ * extensions, so the renderer emits this on the extension event bus.
16
+ */
17
+ const RETRY_ACTIVE_EVENT = "pix:retry-active";
12
18
  const DEFAULT_COMPLETION_NOTIFICATION_TITLE = "Pix - completion";
13
19
  const DEFAULT_ERROR_NOTIFICATION_TITLE = "Pix - error";
14
20
  const DEFAULT_QUESTION_NOTIFICATION_TITLE = "Pix - question";
@@ -154,9 +160,22 @@ function trimmed(value) {
154
160
  const normalized = value?.trim();
155
161
  return normalized ? normalized : undefined;
156
162
  }
163
+ function isStaleExtensionContextError(error) {
164
+ return error instanceof Error && /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
165
+ }
166
+ function safeSessionName(ctx, pi) {
167
+ try {
168
+ return trimmed(pi?.getSessionName?.() ?? ctx.sessionManager.getSessionName?.());
169
+ }
170
+ catch (error) {
171
+ if (isStaleExtensionContextError(error))
172
+ return undefined;
173
+ throw error;
174
+ }
175
+ }
157
176
  function buildNotificationTemplateValues(ctx, pi) {
158
177
  const sessionId = ctx.sessionManager.getSessionId();
159
- const sessionName = trimmed(pi?.getSessionName?.() ?? ctx.sessionManager.getSessionName?.());
178
+ const sessionName = safeSessionName(ctx, pi);
160
179
  const sessionFile = ctx.sessionManager.getSessionFile() ?? "";
161
180
  return {
162
181
  sessionId,
@@ -167,6 +186,19 @@ function buildNotificationTemplateValues(ctx, pi) {
167
186
  cwd: ctx.cwd,
168
187
  };
169
188
  }
189
+ function buildNotificationContextSnapshot(ctx, pi) {
190
+ try {
191
+ return {
192
+ hasUI: ctx.hasUI === true,
193
+ templateValues: buildNotificationTemplateValues(ctx, pi),
194
+ };
195
+ }
196
+ catch (error) {
197
+ if (isStaleExtensionContextError(error))
198
+ return undefined;
199
+ throw error;
200
+ }
201
+ }
170
202
  function renderNotificationTemplate(template, values, appendReasonIfUnused = false) {
171
203
  let usedReason = false;
172
204
  const rendered = template.replace(/\{(sessionId|sessionName|sessionTitle|sessionFile|sessionFileBase|cwd|reason)\}/g, (_match, key) => {
@@ -242,8 +274,8 @@ function detectMacActivationBundleId() {
242
274
  return undefined;
243
275
  return TERM_PROGRAM_BUNDLE_IDS[termProgram];
244
276
  }
245
- function playAttentionSound(ctx) {
246
- if (!terminalBellSoundEnabled(ctx))
277
+ function playAttentionSoundFor(context) {
278
+ if (!terminalBellSoundEnabled(context))
247
279
  return;
248
280
  if (process.platform !== "darwin")
249
281
  return;
@@ -286,13 +318,13 @@ function sendTelegramNotification(title, message) {
286
318
  // fetch may be unavailable or throw synchronously; ignore.
287
319
  }
288
320
  }
289
- function notifySessionStopped(ctx, pi, macActivationBundleId, options) {
290
- const templateValues = buildNotificationTemplateValues(ctx, pi);
321
+ function notifySessionStopped(context, macActivationBundleId, options) {
322
+ const templateValues = context.templateValues;
291
323
  const title = renderNotificationTemplate(notificationTitleTemplate(options.title), templateValues);
292
324
  const renderedMessage = renderNotificationTemplate(options.message ?? process.env.PI_TERMINAL_BELL_NOTIFY_MESSAGE ?? DEFAULT_NOTIFICATION_MESSAGE, templateValues);
293
325
  // Telegram is independent of the bundled desktop sound/notification gate.
294
326
  sendTelegramNotification(title, renderedMessage);
295
- if (!terminalBellNotificationsEnabled(ctx))
327
+ if (!terminalBellNotificationsEnabled(context))
296
328
  return;
297
329
  if (process.platform === "darwin") {
298
330
  const terminalNotifier = findExecutable(process.env.PI_TERMINAL_BELL_NOTIFIER ?? "terminal-notifier");
@@ -351,10 +383,15 @@ export default function terminalBell(pi) {
351
383
  if (extensionDisabled())
352
384
  return;
353
385
  let timer;
354
- let lastCtx;
386
+ let pendingBell;
355
387
  let deferredUntilSubagentsFinish = false;
356
388
  let liveSubagentCount = 0;
357
389
  let lastFailureReason;
390
+ // True while the session is in an auto-retry cycle (relayed via the
391
+ // extension event bus). Suppresses the failure bell on intermediate retry
392
+ // attempts; the final exhausted failure still rings because no retry-start
393
+ // signal precedes it.
394
+ let retryActive = false;
358
395
  const activeSubagentWaitToolCallIds = new Set();
359
396
  const notifiedAskUserToolCallIds = new Set();
360
397
  const idleDelayMs = parseDelayMs(process.env.PI_TERMINAL_BELL_DELAY_MS);
@@ -368,57 +405,91 @@ export default function terminalBell(pi) {
368
405
  function hasSubagentWork() {
369
406
  return liveSubagentCount > 0 || activeSubagentWaitToolCallIds.size > 0;
370
407
  }
371
- function notifyAttention(ctx, message) {
372
- if (canRingTerminal(ctx))
408
+ function notifyAttention(notification, message) {
409
+ if (canRingTerminal(notification))
373
410
  writeBell();
374
- playAttentionSound(ctx);
375
- notifySessionStopped(ctx, pi, macActivationBundleId, {
411
+ playAttentionSoundFor(notification);
412
+ notifySessionStopped(notification, macActivationBundleId, {
376
413
  title: message ? DEFAULT_ERROR_NOTIFICATION_TITLE : DEFAULT_COMPLETION_NOTIFICATION_TITLE,
377
414
  ...(message ? { message } : {}),
378
415
  });
379
416
  pi.events.emit(TERMINAL_BELL_ATTENTION_EVENT, {
380
- cwd: ctx.cwd,
381
- sessionFile: ctx.sessionManager.getSessionFile(),
382
- sessionId: ctx.sessionManager.getSessionId(),
417
+ cwd: notification.templateValues.cwd,
418
+ sessionFile: notification.templateValues.sessionFile,
419
+ sessionId: notification.templateValues.sessionId,
383
420
  });
384
421
  }
385
- function attemptBell(ctx, attempt, message) {
422
+ function attemptBell(pending, attempt) {
386
423
  timer = undefined;
387
- if (!ctx.isIdle()) {
388
- if (attempt < MAX_IDLE_RETRIES)
389
- scheduleBell(ctx, IDLE_RETRY_DELAY_MS, attempt + 1, message);
424
+ const { ctx, notification, message } = pending;
425
+ // Safety net: if a retry-start signal arrives between the agent_end that
426
+ // queued this bell and the timer firing, suppress the bell entirely.
427
+ if (retryActive)
390
428
  return;
429
+ try {
430
+ if (!ctx.isIdle()) {
431
+ if (attempt < MAX_IDLE_RETRIES)
432
+ scheduleBell(ctx, IDLE_RETRY_DELAY_MS, attempt + 1, message, notification);
433
+ return;
434
+ }
435
+ if (ctx.hasPendingMessages())
436
+ return;
437
+ }
438
+ catch (error) {
439
+ if (isStaleExtensionContextError(error)) {
440
+ pendingBell = undefined;
441
+ deferredUntilSubagentsFinish = false;
442
+ return;
443
+ }
444
+ throw error;
391
445
  }
392
- if (ctx.hasPendingMessages())
393
- return;
394
446
  if (hasSubagentWork()) {
395
447
  deferredUntilSubagentsFinish = true;
396
448
  return;
397
449
  }
398
450
  deferredUntilSubagentsFinish = false;
399
- notifyAttention(ctx, message);
451
+ notifyAttention(notification, message);
400
452
  }
401
- function scheduleBell(ctx, delayMs = idleDelayMs, attempt = 0, message) {
402
- lastCtx = ctx;
453
+ function scheduleBell(ctx, delayMs = idleDelayMs, attempt = 0, message, notification = buildNotificationContextSnapshot(ctx, pi)) {
454
+ if (!notification)
455
+ return;
456
+ pendingBell = { ctx, notification, ...(message ? { message } : {}) };
403
457
  clearTimer();
404
- timer = setTimeout(() => attemptBell(ctx, attempt, message), delayMs);
458
+ timer = setTimeout(() => {
459
+ if (!pendingBell)
460
+ return;
461
+ try {
462
+ attemptBell(pendingBell, attempt);
463
+ }
464
+ catch (error) {
465
+ if (isStaleExtensionContextError(error)) {
466
+ pendingBell = undefined;
467
+ deferredUntilSubagentsFinish = false;
468
+ return;
469
+ }
470
+ throw error;
471
+ }
472
+ }, delayMs);
405
473
  timer.unref?.();
406
474
  }
407
475
  function notifyAskUserWaiting(toolCallId, ctx) {
408
476
  if (notifiedAskUserToolCallIds.has(toolCallId))
409
477
  return;
410
478
  notifiedAskUserToolCallIds.add(toolCallId);
411
- if (canRingTerminal(ctx))
479
+ const notification = buildNotificationContextSnapshot(ctx, pi);
480
+ if (!notification)
481
+ return;
482
+ if (canRingTerminal(notification))
412
483
  writeBell();
413
- playAttentionSound(ctx);
414
- notifySessionStopped(ctx, pi, macActivationBundleId, {
484
+ playAttentionSoundFor(notification);
485
+ notifySessionStopped(notification, macActivationBundleId, {
415
486
  title: DEFAULT_QUESTION_NOTIFICATION_TITLE,
416
487
  message: process.env.PI_TERMINAL_BELL_ASK_USER_NOTIFY_MESSAGE ?? DEFAULT_ASK_USER_NOTIFICATION_MESSAGE,
417
488
  });
418
489
  pi.events.emit(TERMINAL_BELL_ATTENTION_EVENT, {
419
- cwd: ctx.cwd,
420
- sessionFile: ctx.sessionManager.getSessionFile(),
421
- sessionId: ctx.sessionManager.getSessionId(),
490
+ cwd: notification.templateValues.cwd,
491
+ sessionFile: notification.templateValues.sessionFile,
492
+ sessionId: notification.templateValues.sessionId,
422
493
  });
423
494
  }
424
495
  pi.events.on(SUBAGENTS_LIVE_COUNT_EVENT, (data) => {
@@ -427,14 +498,27 @@ export default function terminalBell(pi) {
427
498
  if (count === undefined)
428
499
  return;
429
500
  liveSubagentCount = count;
430
- if (count === 0 && deferredUntilSubagentsFinish && lastCtx) {
431
- scheduleBell(lastCtx);
501
+ if (count === 0 && deferredUntilSubagentsFinish && pendingBell) {
502
+ scheduleBell(pendingBell.ctx, idleDelayMs, 0, pendingBell.message, pendingBell.notification);
503
+ }
504
+ });
505
+ pi.events.on(RETRY_ACTIVE_EVENT, (data) => {
506
+ retryActive = data != null && typeof data === "object" && data.active === true;
507
+ if (retryActive) {
508
+ // A retry is starting right after an intermediate agent_end: cancel
509
+ // any bell queued from that attempt so we don't chime on every
510
+ // failed retry attempt. The final exhausted failure rings normally
511
+ // because it is not followed by a retry-start signal.
512
+ clearTimer();
513
+ pendingBell = undefined;
514
+ deferredUntilSubagentsFinish = false;
432
515
  }
433
516
  });
434
517
  pi.on("agent_start", async () => {
435
518
  clearTimer();
436
519
  deferredUntilSubagentsFinish = false;
437
520
  lastFailureReason = undefined;
521
+ retryActive = false;
438
522
  activeSubagentWaitToolCallIds.clear();
439
523
  notifiedAskUserToolCallIds.clear();
440
524
  });
@@ -481,10 +565,11 @@ export default function terminalBell(pi) {
481
565
  });
482
566
  pi.on("session_shutdown", async () => {
483
567
  clearTimer();
484
- lastCtx = undefined;
568
+ pendingBell = undefined;
485
569
  deferredUntilSubagentsFinish = false;
486
570
  liveSubagentCount = 0;
487
571
  lastFailureReason = undefined;
572
+ retryActive = false;
488
573
  activeSubagentWaitToolCallIds.clear();
489
574
  notifiedAskUserToolCallIds.clear();
490
575
  });
@@ -29,4 +29,5 @@ export declare function formatMarkdownTables(text: string, maxWidth?: number): s
29
29
  export declare function renderMarkdownLine(text: string, start?: number): RenderedMarkdownLine;
30
30
  export declare function renderMarkdownTextLines(text: string, width: number, start?: number, options?: RenderMarkdownTextLinesOptions): RenderedMarkdownTextLine[];
31
31
  export declare function markdownSyntaxHighlightsForText(text: string, startColumn?: number): ToolBodySyntaxHighlights;
32
+ export declare function stripDcpControlMetadata(text: string): string;
32
33
  export declare function isOnlyHiddenMetadata(text: string): boolean;
@@ -620,15 +620,40 @@ function markdownLineSyntaxHighlight(fence, fenceDelimiterLine, start) {
620
620
  return { language: "markdown", start };
621
621
  }
622
622
  function sanitizeMarkdownText(text) {
623
- return expandTabs(text.replace(/\x1b/g, "␛").replace(/\r/g, ""));
623
+ return expandTabs(stripDcpControlMetadata(text).replace(/\x1b/g, "␛").replace(/\r/g, ""));
624
+ }
625
+ export function stripDcpControlMetadata(text) {
626
+ if (!text.includes("<dcp-message-ids>"))
627
+ return text;
628
+ const lines = text.split("\n");
629
+ const kept = [];
630
+ let inMessageIds = false;
631
+ let touched = false;
632
+ for (const line of lines) {
633
+ if (inMessageIds) {
634
+ touched = true;
635
+ if (/<\/dcp-message-ids>\s*$/i.test(line))
636
+ inMessageIds = false;
637
+ continue;
638
+ }
639
+ if (/^\s*<dcp-message-ids>/i.test(line)) {
640
+ touched = true;
641
+ if (!/<\/dcp-message-ids>\s*$/i.test(line))
642
+ inMessageIds = true;
643
+ continue;
644
+ }
645
+ kept.push(line);
646
+ }
647
+ return touched ? kept.join("\n").trimEnd() : text;
624
648
  }
625
649
  function isHiddenMarkdownMetadataLine(line) {
626
- return isMarkdownReferenceDefinition(line) || isStreamingDcpMetadataPrefix(line);
650
+ return isMarkdownReferenceDefinition(line);
627
651
  }
628
652
  export function isOnlyHiddenMetadata(text) {
629
- if (!text)
630
- return false;
631
- for (const line of text.split("\n")) {
653
+ const stripped = stripDcpControlMetadata(text);
654
+ if (!stripped)
655
+ return text.length > 0;
656
+ for (const line of stripped.split("\n")) {
632
657
  if (line.length === 0)
633
658
  continue;
634
659
  if (!isHiddenMarkdownMetadataLine(line))
@@ -639,17 +664,6 @@ export function isOnlyHiddenMetadata(text) {
639
664
  function isMarkdownReferenceDefinition(line) {
640
665
  return /^ {0,3}\[[^\]\n]+\]:[ \t]*\S.*$/u.test(line);
641
666
  }
642
- function isStreamingDcpMetadataPrefix(line) {
643
- const content = line.replace(/^ {0,3}/u, "");
644
- if (content.length === 0)
645
- return false;
646
- return isDcpReferencePrefix(content, DCP_MESSAGE_REFERENCE_PREFIX) || isDcpReferencePrefix(content, DCP_BLOCK_REFERENCE_PREFIX);
647
- }
648
- function isDcpReferencePrefix(content, markerPrefix) {
649
- return markerPrefix.startsWith(content) || (content.startsWith(markerPrefix) && /^\d*$/u.test(content.slice(markerPrefix.length)));
650
- }
651
- const DCP_MESSAGE_REFERENCE_PREFIX = "[dcp-id]: # (m";
652
- const DCP_BLOCK_REFERENCE_PREFIX = "[dcp-block-id]: # (b";
653
667
  function markdownFence(line) {
654
668
  const match = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
655
669
  const marker = match?.[1];
@@ -21,6 +21,10 @@ export declare const PiToolsSuiteConfigSchema: Type.TObject<{
21
21
  dcp: Type.TOptional<Type.TObject<{
22
22
  enabled: Type.TOptional<Type.TBoolean>;
23
23
  debug: Type.TOptional<Type.TBoolean>;
24
+ debugLog: Type.TOptional<Type.TObject<{
25
+ maxBytes: Type.TOptional<Type.TNumber>;
26
+ maxBackups: Type.TOptional<Type.TNumber>;
27
+ }>>;
24
28
  manualMode: Type.TOptional<Type.TObject<{
25
29
  enabled: Type.TOptional<Type.TBoolean>;
26
30
  automaticStrategies: Type.TOptional<Type.TBoolean>;
@@ -84,6 +88,7 @@ export declare const PiToolsSuiteConfigSchema: Type.TObject<{
84
88
  routing: Type.TOptional<Type.TObject<{
85
89
  enabled: Type.TOptional<Type.TBoolean>;
86
90
  model: Type.TOptional<Type.TString>;
91
+ fallbackModels: Type.TOptional<Type.TArray<Type.TString>>;
87
92
  maxTaskChars: Type.TOptional<Type.TNumber>;
88
93
  maxTokens: Type.TOptional<Type.TNumber>;
89
94
  maxRetries: Type.TOptional<Type.TNumber>;
@@ -100,6 +100,10 @@ const DcpStrategiesConfig = Type.Object({
100
100
  const DcpConfig = Type.Object({
101
101
  enabled: Type.Optional(Type.Boolean({ description: "Enable DCP (Dynamic Context Pruning)." })),
102
102
  debug: Type.Optional(Type.Boolean({ description: "Enable DCP debug logging." })),
103
+ debugLog: Type.Optional(Type.Object({
104
+ maxBytes: Type.Optional(Type.Number({ description: "Maximum size in bytes of the active debug log before it is rotated. Default 5242880 (5 MB).", minimum: 1024 })),
105
+ maxBackups: Type.Optional(Type.Number({ description: "Number of rotated backups to keep (.1 .. .N). Default 3, minimum 1.", minimum: 1 })),
106
+ }, { description: "Debug log rotation. The JSONL log is written to ~/.pi/agent/dcp-debug.jsonl." })),
103
107
  manualMode: Type.Optional(DcpManualModeConfig),
104
108
  compress: Type.Optional(DcpCompressConfig),
105
109
  strategies: Type.Optional(DcpStrategiesConfig),
@@ -117,6 +121,7 @@ const RetryConfig = Type.Object({
117
121
  const SubagentRoutingConfig = Type.Object({
118
122
  enabled: Type.Optional(Type.Boolean({ description: "Enable LLM-based automatic role routing." })),
119
123
  model: Type.Optional(Type.String({ description: "Router model in provider/model form." })),
124
+ fallbackModels: Type.Optional(Type.Array(Type.String(), { uniqueItems: true, description: "Ordered router model fallbacks tried when the primary routing model is unavailable or fails. The current parent model is always tried last." })),
120
125
  maxTaskChars: Type.Optional(Type.Number({ description: "Max task/scope characters sent to router.", minimum: 100 })),
121
126
  maxTokens: Type.Optional(Type.Number({ description: "Max router response tokens.", minimum: 8 })),
122
127
  maxRetries: Type.Optional(Type.Number({ description: "Router request retries.", minimum: 0 })),
@@ -1,9 +1,14 @@
1
1
  import { isAbsolute, relative, sep } from "node:path";
2
+ import { normalizeBeginPatchForDisplay } from "./patch-normalize.js";
2
3
  import { expandedTextFromParts, resultText, stringArg, summarizePatch } from "./utils.js";
3
4
  export const renderApplyPatchTool = (input) => {
4
5
  const detailsDiff = diffFromDetails(input.details);
5
6
  const argPatch = stringArg(input, ["input", "patch"]);
6
- const patch = argPatch ?? detailsDiff?.text;
7
+ const rawPatch = argPatch ?? detailsDiff?.text;
8
+ // Re-minimize loose `*** Begin Patch` hunks so unchanged neighbor lines are
9
+ // rendered as context instead of spurious `-` deletions. Plain unified diffs
10
+ // and other formats pass through unchanged.
11
+ const patch = rawPatch ? normalizeBeginPatchForDisplay(rawPatch) : rawPatch;
7
12
  const path = pathForDisplay(stringArg(input, ["path", "file_path", "filePath"]), input.cwd);
8
13
  const summary = summarizePatch(patch) ?? "patch";
9
14
  const expanded = expandedTextFromParts({ text: patch }, { text: resultText(input, { empty: !patch }) });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Normalize OpenAI-style `*** Begin Patch` hunks for display.
3
+ *
4
+ * Problem: in the `Begin Patch` format the model often emits a hunk that
5
+ * reproduces a whole block of a file with the old version as `-` lines and the
6
+ * new version as `+` lines, even when most of those lines are identical. This is
7
+ * the "contextless / loose hunk matching" behavior. A patch that effectively
8
+ * only adds one line can therefore look like it deletes several existing rules.
9
+ *
10
+ * The naive per-line renderer (`diffLineStyle`) faithfully colors every `-` red
11
+ * and every `+` green, which misleads the user.
12
+ *
13
+ * Fix: for each `*** Update File:` hunk we reconstruct the old side
14
+ * (context + `-` lines) and the new side (context + `+` lines), compute a
15
+ * minimal LCS line diff between them, and re-emit the hunk so that:
16
+ * - lines present in both sides become plain context (no `-`),
17
+ * - truly removed lines stay `-`,
18
+ * - truly added lines stay `+`.
19
+ *
20
+ * This is a display-only transformation. Well-formed minimal hunks (and any
21
+ * non-`Begin Patch` unified diffs) are left untouched.
22
+ */
23
+ /** Re-emit a `*** Begin Patch` string with loose hunks re-minimized. */
24
+ export declare function normalizeBeginPatchForDisplay(patch: string): string;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Normalize OpenAI-style `*** Begin Patch` hunks for display.
3
+ *
4
+ * Problem: in the `Begin Patch` format the model often emits a hunk that
5
+ * reproduces a whole block of a file with the old version as `-` lines and the
6
+ * new version as `+` lines, even when most of those lines are identical. This is
7
+ * the "contextless / loose hunk matching" behavior. A patch that effectively
8
+ * only adds one line can therefore look like it deletes several existing rules.
9
+ *
10
+ * The naive per-line renderer (`diffLineStyle`) faithfully colors every `-` red
11
+ * and every `+` green, which misleads the user.
12
+ *
13
+ * Fix: for each `*** Update File:` hunk we reconstruct the old side
14
+ * (context + `-` lines) and the new side (context + `+` lines), compute a
15
+ * minimal LCS line diff between them, and re-emit the hunk so that:
16
+ * - lines present in both sides become plain context (no `-`),
17
+ * - truly removed lines stay `-`,
18
+ * - truly added lines stay `+`.
19
+ *
20
+ * This is a display-only transformation. Well-formed minimal hunks (and any
21
+ * non-`Begin Patch` unified diffs) are left untouched.
22
+ */
23
+ const MAX_NORMALIZE_LINES = 4000;
24
+ /** Re-emit a `*** Begin Patch` string with loose hunks re-minimized. */
25
+ export function normalizeBeginPatchForDisplay(patch) {
26
+ if (!patch.includes("*** Begin Patch"))
27
+ return patch;
28
+ const lines = patch.split("\n");
29
+ if (lines.length > MAX_NORMALIZE_LINES)
30
+ return patch;
31
+ const sections = parseBeginPatchSections(lines);
32
+ return sections.map(renderSection).filter((line) => line !== null).join("\n");
33
+ }
34
+ function parseBeginPatchSections(lines) {
35
+ const sections = [];
36
+ let i = 0;
37
+ while (i < lines.length) {
38
+ const line = lines[i] ?? "";
39
+ if (line.startsWith("*** Update File:")) {
40
+ sections.push({ kind: "marker", line });
41
+ i += 1;
42
+ while (i < lines.length) {
43
+ const inner = lines[i] ?? "";
44
+ if (inner.startsWith("*** "))
45
+ break;
46
+ if (inner.startsWith("@@")) {
47
+ sections.push({ kind: "hunk-header", line: inner });
48
+ i += 1;
49
+ const body = [];
50
+ while (i < lines.length) {
51
+ const bodyLine = lines[i] ?? "";
52
+ if (bodyLine.startsWith("@@") || bodyLine.startsWith("*** "))
53
+ break;
54
+ body.push(parseHunkLine(bodyLine));
55
+ i += 1;
56
+ }
57
+ sections.push({ kind: "hunk-body", lines: body });
58
+ }
59
+ else {
60
+ sections.push({ kind: "raw", line: inner });
61
+ i += 1;
62
+ }
63
+ }
64
+ continue;
65
+ }
66
+ if (line.startsWith("*** Add File:") || line.startsWith("*** Delete File:") || line.startsWith("*** Begin Patch") || line.startsWith("*** End Patch")) {
67
+ sections.push({ kind: "marker", line });
68
+ i += 1;
69
+ continue;
70
+ }
71
+ // Stray line outside any file section (e.g. a loose @@ without an Update
72
+ // File header). Keep it verbatim to preserve structure.
73
+ sections.push({ kind: "raw", line });
74
+ i += 1;
75
+ }
76
+ return sections;
77
+ }
78
+ function parseHunkLine(line) {
79
+ if (line.startsWith("+"))
80
+ return { type: "add", text: line.slice(1) };
81
+ if (line.startsWith("-"))
82
+ return { type: "del", text: line.slice(1) };
83
+ if (line.startsWith(" "))
84
+ return { type: "context", text: line.slice(1) };
85
+ // Lines without a prefix inside a Begin Patch hunk body are treated as
86
+ // context (the format uses a leading space for context, but loose patches
87
+ // sometimes omit it).
88
+ return { type: "context", text: line };
89
+ }
90
+ function renderSection(section) {
91
+ switch (section.kind) {
92
+ case "marker":
93
+ case "hunk-header":
94
+ case "raw":
95
+ return section.line;
96
+ case "hunk-body": {
97
+ const oldLines = section.lines.filter((entry) => entry.type !== "add").map((entry) => entry.text);
98
+ const newLines = section.lines.filter((entry) => entry.type !== "del").map((entry) => entry.text);
99
+ const ops = diffLines(oldLines, newLines);
100
+ const rendered = [];
101
+ for (const op of ops) {
102
+ rendered.push(renderDiffOp(op));
103
+ }
104
+ return rendered.length > 0 ? rendered.join("\n") : null;
105
+ }
106
+ }
107
+ }
108
+ function renderDiffOp(op) {
109
+ if (op.type === "delete")
110
+ return `-${op.text}`;
111
+ if (op.type === "insert")
112
+ return `+${op.text}`;
113
+ // Context marker is a leading space. Loose `-`/`+` blocks carry the space
114
+ // that separated the marker from the content (e.g. `- rule one`), so reuse
115
+ // that space instead of emitting a second one.
116
+ return op.text.startsWith(" ") ? op.text : ` ${op.text}`;
117
+ }
118
+ /** Minimal LCS-based line diff. */
119
+ function diffLines(oldLines, newLines) {
120
+ const m = oldLines.length;
121
+ const n = newLines.length;
122
+ if (m === 0 && n === 0)
123
+ return [];
124
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
125
+ for (let i = m - 1; i >= 0; i -= 1) {
126
+ for (let j = n - 1; j >= 0; j -= 1) {
127
+ const oldLine = oldLines[i] ?? "";
128
+ const newLine = newLines[j] ?? "";
129
+ dp[i][j] = oldLine === newLine
130
+ ? (dp[i + 1]?.[j + 1] ?? 0) + 1
131
+ : Math.max(dp[i + 1]?.[j] ?? 0, dp[i]?.[j + 1] ?? 0);
132
+ }
133
+ }
134
+ const result = [];
135
+ let i = 0;
136
+ let j = 0;
137
+ while (i < m && j < n) {
138
+ const oldLine = oldLines[i] ?? "";
139
+ const newLine = newLines[j] ?? "";
140
+ if (oldLine === newLine) {
141
+ result.push({ type: "equal", text: oldLine });
142
+ i += 1;
143
+ j += 1;
144
+ }
145
+ else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
146
+ result.push({ type: "delete", text: oldLine });
147
+ i += 1;
148
+ }
149
+ else {
150
+ result.push({ type: "insert", text: newLine });
151
+ j += 1;
152
+ }
153
+ }
154
+ while (i < m) {
155
+ result.push({ type: "delete", text: oldLines[i] ?? "" });
156
+ i += 1;
157
+ }
158
+ while (j < n) {
159
+ result.push({ type: "insert", text: newLines[j] ?? "" });
160
+ j += 1;
161
+ }
162
+ return result;
163
+ }
@@ -8,6 +8,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
8
8
  - `src/ast-grep` — `ast_grep` / `ast_apply`
9
9
  - `src/async-subagents` — `subagents` tool and sub-agent slash commands, including oh-my-openagent-style `/ultrawork` (`/ulw`) and `/hyperplan` orchestration prompts, plus config-defined sub-agent model/thinking/args presets selected via `/subagent-preset` from `asyncSubagents` in `~/.config/pi/pi-tools-suite.jsonc`; includes the `frontend` profile for Gemini-friendly UI/UX and visual frontend work plus the `vision` profile for screenshot/image description via `openai-codex/gpt-5.4-mini`; enforces a 30-minute per-agent execution timeout, project-wide `maxConcurrent` queueing, optional retry/backoff, and `result.json` structured metadata/chaining fields next to raw `result.md`; stores project-local run files and a registry under `.pi/subagents/` so result/status collection can recover after compaction or reload while the main session remains alive
10
10
  - `src/lsp` — shared LSP diagnostics hook/library that enriches mutating tool results with diagnostics and shuts down language servers on session shutdown
11
+ - `src/comment-checker` — AI-slop comment guard that listens to the `tool_result` event for `write` / `edit` / `apply_patch` mutations, extracts net-new code comment lines, classifies them (filler phrasing, restating code, decorative separators, generic paraphrasing, or — under aggressive strictness — any non-valuable comment), and appends a short nudge to the tool result so the agent removes unnecessary comments on its next turn; TODO/FIXME, license headers, docstrings, pragmas, linter directives, shebangs, and decorators are never flagged; language-agnostic across `//` / `/* */` / `#` / `--` / `<!-- -->` / triple-quote comment styles; per-session deduplication (at most one nudge per 30 s) prevents fix/remark loops; configured via the `commentChecker` section (`enabled`, `strictness`: `conservative` | `balanced` | `aggressive`, default `balanced`) or `PI_COMMENT_CHECKER_ENABLED` / `PI_COMMENT_CHECKER_STRICTNESS`
11
12
  - `src/repo-discovery` — `/idx-init`, `/idx-update`, and indexed-only `repo_architecture` / `repo_structure` / `repo_ast` / `repo_search` / `repo_explain` / `repo_deps`; tools register only when the launch project has `.indexer-cli`
12
13
  - `src/antigravity-auth` — `antigravity` custom provider with Google Antigravity OAuth login, startup account list, auth.json-only runtime account loading, `/antigravity-add-account` OAuth append into rotation, `/antigravity-account` status display, account rotation/failover, Antigravity plus Gemini CLI model registration, and streaming through the Cloud Code Assist unified gateway
13
14
  - `src/todo` — `todo` tool, `/todos`, `/todos-persist`, and `/todos-scope`; supports parent/subtask hierarchy, blockers, ready-task filtering, deferred out-of-scope items, batch operations, JSON/Markdown import/export, automatic clearing when all visible todos are completed, and optional project persistence via `/todos persist on` or `/todos-persist on`; localization/i18n has been removed
@@ -19,7 +20,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
19
20
 
20
21
  `index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
21
22
 
22
- Registration order is preserved in `src/index.ts`: glm-coding-discipline, ast-grep, async-subagents, lsp, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then prompt-commands. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
23
+ Registration order is preserved in `src/index.ts`: glm-coding-discipline, ast-grep, async-subagents, lsp, comment-checker, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then prompt-commands. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
23
24
 
24
25
  ## Disabling modules
25
26
 
@@ -123,7 +124,7 @@ DCP settings are stored only under `dcp` in the user shared config file `~/.conf
123
124
  }
124
125
  ```
125
126
 
126
- `minContextPercent` / `maxContextPercent` accept legacy fractions (`0.25`), percent strings (`"25%"`), or absolute token counts when Pi knows the current model context window. `minContextLimit` / `maxContextLimit` and `modelMinContextLimits` / `modelMaxContextLimits` are explicit absolute-or-percent aliases. `modelOverrides` and the `modelMin*` / `modelMax*` maps support exact model keys plus `*` / `?` wildcard patterns; matching is applied from generic to specific so exact bare-model matches override bare wildcards, and exact `provider/model` matches override provider wildcards. Array fields are union-merged, so model-specific `protectedTools` extend the defaults instead of replacing them. If `compress.protectUserMessages` is enabled, range compression appends selected user messages verbatim instead of rejecting the range; individual message compression still skips protected raw user messages. Protected tool outputs are copied into summaries for tools protected by name or `protectedFilePatterns`; protected `subagents` result reads also try to include the saved `result.md` artifact when available.
127
+ `minContextPercent` / `maxContextPercent` accept legacy fractions (`0.25`), percent strings (`"25%"`), or absolute token counts when Pi knows the current model context window. `minContextLimit` / `maxContextLimit` and `modelMinContextLimits` / `modelMaxContextLimits` are explicit absolute-or-percent aliases. `modelOverrides` and the `modelMin*` / `modelMax*` maps support exact model keys plus `*` / `?` wildcard patterns; matching is applied from generic to specific so exact bare-model matches override bare wildcards, and exact `provider/model` matches override provider wildcards. Array fields are union-merged, so model-specific `protectedTools` extend the defaults instead of replacing them. If `compress.protectUserMessages` is enabled, range compression appends selected user messages verbatim instead of rejecting the range; individual message compression still skips protected raw user messages. Protected tool outputs are copied into summaries for tools protected by name or `protectedFilePatterns`; protected `subagents` result reads also try to include the saved `result.md` artifact when available. Set `dcp.debug: true` to write a JSONL debug log of DCP context/prune/compress events to `~/.pi/agent/dcp-debug.jsonl` (override the path with `PI_DCP_DEBUG_LOG`, or enable without config via `PI_DCP_DEBUG=1`); off by default. The log is size-limited and rotated: once it reaches `dcp.debugLog.maxBytes` (default `5242880` = 5 MB) it is renamed to `.1`, older backups shift down (`.1`→`.2`, …) and the oldest beyond `dcp.debugLog.maxBackups` (default `3`, minimum `1`) is dropped; override either with `PI_DCP_DEBUG_MAX_BYTES` / `PI_DCP_DEBUG_MAX_BACKUPS`.
127
128
 
128
129
  ## LSP setup
129
130
 
@@ -38,9 +38,9 @@
38
38
  "vscode-languageserver-protocol": "^3.17.5"
39
39
  },
40
40
  "peerDependencies": {
41
- "@earendil-works/pi-ai": "0.79.4",
42
- "@earendil-works/pi-coding-agent": "0.79.4",
43
- "@earendil-works/pi-tui": "0.79.4",
41
+ "@earendil-works/pi-ai": "0.79.5",
42
+ "@earendil-works/pi-coding-agent": "0.79.5",
43
+ "@earendil-works/pi-tui": "0.79.5",
44
44
  "typebox": "*"
45
45
  },
46
46
  "devDependencies": {