jeopi-cli 16.4.3 → 16.4.4

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 (34) hide show
  1. package/dist/cli.js +3094 -3091
  2. package/dist/types/advisor/advise-tool.d.ts +5 -1
  3. package/dist/types/advisor/runtime.d.ts +15 -0
  4. package/dist/types/config/model-registry.d.ts +3 -3
  5. package/dist/types/config/models-config.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +0 -10
  7. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  8. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  9. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  10. package/dist/types/session/agent-session.d.ts +1 -0
  11. package/package.json +12 -12
  12. package/src/advisor/advise-tool.ts +6 -1
  13. package/src/advisor/runtime.ts +127 -0
  14. package/src/autolearn/controller.ts +1 -1
  15. package/src/config/config-file.ts +30 -12
  16. package/src/config/model-registry.ts +7 -7
  17. package/src/config/models-config.ts +1 -1
  18. package/src/config/settings-schema.ts +0 -12
  19. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  20. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  21. package/src/internal-urls/docs-index.generated.txt +1 -1
  22. package/src/modes/components/chat-transcript-builder.ts +31 -11
  23. package/src/modes/controllers/event-controller.ts +41 -12
  24. package/src/modes/rpc/host-tools.ts +13 -0
  25. package/src/modes/rpc/rpc-mode.ts +130 -56
  26. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  27. package/src/modes/utils/ui-helpers.ts +16 -1
  28. package/src/session/agent-session.ts +95 -11
  29. package/src/task/render.ts +51 -44
  30. package/src/tools/bash.ts +0 -11
  31. package/src/tools/path-utils.ts +6 -1
  32. package/src/utils/title-generator.ts +5 -1
  33. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  34. package/src/tools/bash-command-fixup.ts +0 -37
@@ -144,13 +144,16 @@ import {
144
144
  AdvisorEmissionGuard,
145
145
  type AdvisorMessageDetails,
146
146
  type AdvisorNote,
147
+ AdvisorOutputQuarantinedError,
147
148
  AdvisorRuntime,
148
149
  type AdvisorSeverity,
149
150
  AdvisorTranscriptRecorder,
150
151
  advisorTranscriptFilename,
152
+ buildAdvisorQuarantineSourceText,
151
153
  formatAdvisorBatchContent,
152
154
  isAdvisorInterruptImmuneTurnActive,
153
155
  isInterruptingSeverity,
156
+ quarantineAdvisorUnsafeOutput,
154
157
  resolveAdvisorDeliveryChannel,
155
158
  slugifyAdvisorName,
156
159
  } from "../advisor";
@@ -1445,6 +1448,20 @@ function isDisplayableQueuedMessage(message: AgentMessage): boolean {
1445
1448
  function isAdvisorCard(message: AgentMessage): message is CustomMessage {
1446
1449
  return message.role === "custom" && message.customType === "advisor";
1447
1450
  }
1451
+ function isTerminalTextAssistantAnswer(message: AgentMessage | undefined): message is AssistantMessage {
1452
+ if (message?.role !== "assistant" || message.stopReason !== "stop") return false;
1453
+ let hasText = false;
1454
+ for (const part of message.content) {
1455
+ if (part.type === "toolCall") return false;
1456
+ if (part.type === "text") {
1457
+ if (part.text.trim().length > 0) hasText = true;
1458
+ continue;
1459
+ }
1460
+ if (part.type === "thinking") continue;
1461
+ return false;
1462
+ }
1463
+ return hasText;
1464
+ }
1448
1465
 
1449
1466
  /**
1450
1467
  * A queued message the user can restore to the editor / pull back as a draft.
@@ -1887,6 +1904,7 @@ export class AgentSession {
1887
1904
  // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
1888
1905
  // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1889
1906
  #emptyStopRetryCount = 0;
1907
+ #acceptTerminalEmptyStopForPrompt = false;
1890
1908
  #unexpectedStopRetryCount = 0;
1891
1909
  #promptGeneration = 0;
1892
1910
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
@@ -2475,6 +2493,14 @@ export class AgentSession {
2475
2493
 
2476
2494
  const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2477
2495
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2496
+ const availableAdvisorToolNames = new Set<string>();
2497
+ availableAdvisorToolNames.add(adviseTool.name);
2498
+ for (const tool of tools) {
2499
+ availableAdvisorToolNames.add(tool.name);
2500
+ if (tool.customWireName !== undefined) availableAdvisorToolNames.add(tool.customWireName);
2501
+ }
2502
+ let quarantinedAdvisorOutput: string | undefined;
2503
+ let currentAdvisorInput = "";
2478
2504
 
2479
2505
  const advisorSessionId = this.#advisorSessionId(slug);
2480
2506
  const appendOnlyContext = new AppendOnlyContextManager();
@@ -2519,6 +2545,13 @@ export class AgentSession {
2519
2545
  streamFn: this.#advisorStreamFn,
2520
2546
  onPayload: this.#onPayload,
2521
2547
  onResponse: this.#onResponse,
2548
+ transformAssistantMessage: message => {
2549
+ quarantinedAdvisorOutput = quarantineAdvisorUnsafeOutput(
2550
+ message,
2551
+ availableAdvisorToolNames,
2552
+ buildAdvisorQuarantineSourceText(currentAdvisorInput, advisorAgent.state.messages),
2553
+ );
2554
+ },
2522
2555
  onSseEvent: this.#onSseEvent,
2523
2556
  transformProviderContext: this.#transformProviderContext,
2524
2557
  intentTracing: false,
@@ -2529,7 +2562,19 @@ export class AgentSession {
2529
2562
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2530
2563
 
2531
2564
  const advisorAgentFacade: AdvisorAgent = {
2532
- prompt: input => advisorAgent.prompt(input),
2565
+ prompt: async input => {
2566
+ let quarantined: string | undefined;
2567
+ try {
2568
+ quarantinedAdvisorOutput = undefined;
2569
+ currentAdvisorInput = input;
2570
+ await advisorAgent.prompt(input);
2571
+ quarantined = quarantinedAdvisorOutput;
2572
+ } finally {
2573
+ quarantinedAdvisorOutput = undefined;
2574
+ currentAdvisorInput = "";
2575
+ }
2576
+ if (quarantined) throw new AdvisorOutputQuarantinedError(quarantined);
2577
+ },
2533
2578
  abort: reason => advisorAgent.abort(reason),
2534
2579
  reset: () => {
2535
2580
  advisorAgent.reset();
@@ -2635,15 +2680,23 @@ export class AgentSession {
2635
2680
  return slug ? `${this.sessionId}-advisor-${slug}` : `${this.sessionId}-advisor`;
2636
2681
  }
2637
2682
 
2683
+ #hasTerminalTextAnswerWithoutQueuedWork(): boolean {
2684
+ if (this.agent.hasQueuedMessages() || this.#pendingNextTurnMessages.length > 0) return false;
2685
+ return isTerminalTextAssistantAnswer(this.agent.state.messages.at(-1));
2686
+ }
2687
+
2638
2688
  /**
2639
2689
  * Route one accepted advice note from `advisor` to the primary. Concern and
2640
2690
  * blocker interrupt the running agent through the steering channel; once the
2641
- * loop has yielded, `triggerTurn` resumes it. After a deliberate user interrupt
2642
- * auto-resume is suppressed while idle/unwinding (the note becomes a preserved
2643
- * card re-entering on resume); a live-streaming turn is steered in directly. A
2644
- * plain nit always rides the non-interrupting YieldQueue aside. Suppression by
2645
- * the per-advisor emission guard drops the note silently — the model still saw
2646
- * `Recorded.`, so it isn't tempted to rephrase the same note past the dedupe.
2691
+ * loop has yielded, `triggerTurn` resumes it. If the loop already ended with a
2692
+ * terminal text answer and no queued work remains, the note is preserved as an
2693
+ * advisor card instead of waking a duplicate completion turn. After a deliberate
2694
+ * user interrupt auto-resume is suppressed while idle/unwinding (the note
2695
+ * becomes a preserved card re-entering on resume); a live-streaming turn is
2696
+ * steered in directly. A plain nit always rides the non-interrupting YieldQueue
2697
+ * aside. Suppression by the per-advisor emission guard drops the note silently —
2698
+ * the model still saw `Recorded.`, so it isn't tempted to rephrase the same note
2699
+ * past the dedupe.
2647
2700
  */
2648
2701
  #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void {
2649
2702
  if (!advisor.emissionGuard.accept(note)) {
@@ -2663,6 +2716,7 @@ export class AgentSession {
2663
2716
  streaming: this.agent.state.isStreaming,
2664
2717
  aborting: this.#abortInProgress,
2665
2718
  interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
2719
+ terminalAnswerNoQueuedWork: this.#hasTerminalTextAnswerWithoutQueuedWork(),
2666
2720
  });
2667
2721
  if (channel === "aside") {
2668
2722
  this.yieldQueue.enqueue("advisor", { note, severity, advisor: source });
@@ -7571,6 +7625,7 @@ export class AgentSession {
7571
7625
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
7572
7626
  prependMessages?: AgentMessage[];
7573
7627
  skipPostPromptRecoveryWait?: boolean;
7628
+ acceptTerminalEmptyStop?: boolean;
7574
7629
  },
7575
7630
  ): Promise<void> {
7576
7631
  this.#beginInFlight();
@@ -7588,6 +7643,7 @@ export class AgentSession {
7588
7643
  this.#midRunNudgeCount = 0;
7589
7644
  this.#emptyStopRetryCount = 0;
7590
7645
  this.#unexpectedStopRetryCount = 0;
7646
+ this.#acceptTerminalEmptyStopForPrompt = options?.acceptTerminalEmptyStop === true;
7591
7647
  // A new prompt cycle starts: drop any sticky yield-termination from the
7592
7648
  // previous run so empty-stop / unexpected-stop / compaction maintenance
7593
7649
  // can evaluate this turn normally.
@@ -8092,12 +8148,25 @@ export class AgentSession {
8092
8148
  }
8093
8149
  }
8094
8150
 
8095
- async #promptAgentInitiatedMessage(message: CustomMessage): Promise<void> {
8151
+ async #promptAgentInitiatedMessage(
8152
+ message: CustomMessage,
8153
+ options?: { acceptTerminalEmptyStop?: boolean },
8154
+ ): Promise<void> {
8096
8155
  this.#beginInFlight();
8156
+ const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true;
8097
8157
  try {
8158
+ if (acceptTerminalEmptyStop) {
8159
+ this.#emptyStopRetryCount = 0;
8160
+ this.#unexpectedStopRetryCount = 0;
8161
+ this.#yieldTerminationPending = false;
8162
+ this.#acceptTerminalEmptyStopForPrompt = true;
8163
+ } else {
8164
+ this.#acceptTerminalEmptyStopForPrompt = false;
8165
+ }
8098
8166
  await this.agent.prompt(message);
8099
8167
  await this.#waitForPostPromptRecovery();
8100
8168
  } finally {
8169
+ this.#acceptTerminalEmptyStopForPrompt = false;
8101
8170
  this.#endInFlight();
8102
8171
  }
8103
8172
  }
@@ -8152,7 +8221,12 @@ export class AgentSession {
8152
8221
  */
8153
8222
  async sendCustomMessage<T = unknown>(
8154
8223
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
8155
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; queueChipText?: string },
8224
+ options?: {
8225
+ triggerTurn?: boolean;
8226
+ deliverAs?: "steer" | "followUp" | "nextTurn";
8227
+ queueChipText?: string;
8228
+ acceptTerminalEmptyStop?: boolean;
8229
+ },
8156
8230
  ): Promise<boolean> {
8157
8231
  const details =
8158
8232
  options?.queueChipText && options.deliverAs !== "nextTurn"
@@ -8195,7 +8269,9 @@ export class AgentSession {
8195
8269
  this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
8196
8270
  return false;
8197
8271
  }
8198
- await this.#promptAgentInitiatedMessage(normalizedAppMessage);
8272
+ await this.#promptAgentInitiatedMessage(normalizedAppMessage, {
8273
+ acceptTerminalEmptyStop: options.acceptTerminalEmptyStop === true,
8274
+ });
8199
8275
  return true;
8200
8276
  }
8201
8277
  this.agent.appendMessage(normalizedAppMessage);
@@ -8214,7 +8290,9 @@ export class AgentSession {
8214
8290
  this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
8215
8291
  return false;
8216
8292
  }
8217
- await this.#promptAgentInitiatedMessage(normalizedAppMessage);
8293
+ await this.#promptAgentInitiatedMessage(normalizedAppMessage, {
8294
+ acceptTerminalEmptyStop: options.acceptTerminalEmptyStop === true,
8295
+ });
8218
8296
  return true;
8219
8297
  }
8220
8298
 
@@ -10482,6 +10560,12 @@ export class AgentSession {
10482
10560
  return false;
10483
10561
  }
10484
10562
 
10563
+ if (this.#acceptTerminalEmptyStopForPrompt && assistantMessage.stopReason === "stop") {
10564
+ this.#acceptTerminalEmptyStopForPrompt = false;
10565
+ this.#emptyStopRetryCount = 0;
10566
+ return false;
10567
+ }
10568
+
10485
10569
  this.#emptyStopRetryCount++;
10486
10570
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
10487
10571
  const attempts = this.#emptyStopRetryCount - 1;
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { Component } from "jeopi-tui";
9
9
  import { Container, Markdown, Text } from "jeopi-tui";
10
- import { formatNumber } from "jeopi-utils";
10
+ import { formatNumber, sanitizeText } from "jeopi-utils";
11
11
  import { settings } from "../config/settings";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
@@ -249,11 +249,11 @@ function getRenderYieldLabels(type: RenderYieldItem["type"]): string[] {
249
249
  function formatYieldPreview(item: RenderYieldItem): string {
250
250
  if (item.useLastTurn === true && item.data === undefined) return "last assistant turn";
251
251
  if (item.data === undefined) return "last assistant turn";
252
- if (typeof item.data === "string") return previewLine(replaceTabs(item.data), 70);
252
+ if (typeof item.data === "string") return previewLine(replaceTabs(sanitizeText(item.data)), 70);
253
253
  try {
254
- return previewLine(replaceTabs(JSON.stringify(item.data) ?? "null"), 70);
254
+ return previewLine(replaceTabs(sanitizeText(JSON.stringify(item.data) ?? "null")), 70);
255
255
  } catch {
256
- return previewLine(replaceTabs(String(item.data)), 70);
256
+ return previewLine(replaceTabs(sanitizeText(String(item.data))), 70);
257
257
  }
258
258
  }
259
259
 
@@ -281,7 +281,7 @@ function renderTypedYieldSections(value: unknown, continuePrefix: string, expand
281
281
  function formatJsonScalar(value: unknown, _theme: Theme): string {
282
282
  if (value === null) return "null";
283
283
  if (typeof value === "string") {
284
- const trimmed = truncateToWidth(value, 70);
284
+ const trimmed = truncateToWidth(sanitizeText(value), 70);
285
285
  return `"${trimmed}"`;
286
286
  }
287
287
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -291,8 +291,9 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
291
291
  export function formatTaskId(id: string): string {
292
292
  // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels
293
293
  // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb.
294
- const segments = id.split(".");
295
- return segments.length < 2 ? id : segments.join(">");
294
+ const sanitizedId = sanitizeText(id);
295
+ const segments = sanitizedId.split(".");
296
+ return segments.length < 2 ? sanitizedId : segments.join(">");
296
297
  }
297
298
 
298
299
  const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
@@ -347,13 +348,13 @@ function renderJsonTreeLines(
347
348
  const scalar = formatJsonScalar(val, theme);
348
349
 
349
350
  if (scalar) {
350
- const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
351
+ const label = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "value");
351
352
  pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", scalar)}`);
352
353
  return;
353
354
  }
354
355
 
355
356
  if (Array.isArray(val)) {
356
- const header = key ? theme.fg("muted", key) : theme.fg("muted", "array");
357
+ const header = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "array");
357
358
  pushLine(`${prefix}${iconArray} ${header}`);
358
359
  if (val.length === 0) {
359
360
  pushLine(
@@ -385,7 +386,7 @@ function renderJsonTreeLines(
385
386
  }
386
387
 
387
388
  if (val && typeof val === "object") {
388
- const header = key ? theme.fg("muted", key) : theme.fg("muted", "object");
389
+ const header = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "object");
389
390
  pushLine(`${prefix}${iconObject} ${header}`);
390
391
  const entries = Object.entries(val as Record<string, unknown>);
391
392
  if (entries.length === 0) {
@@ -418,8 +419,8 @@ function renderJsonTreeLines(
418
419
  return;
419
420
  }
420
421
 
421
- const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
422
- pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", String(val))}`);
422
+ const label = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "value");
423
+ pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", sanitizeText(String(val)))}`);
423
424
  };
424
425
 
425
426
  const renderRoot = (val: unknown) => {
@@ -466,7 +467,7 @@ function stripRecentOutputNoticeLine(text: string): string {
466
467
  }
467
468
 
468
469
  function sanitizeRecentOutput(output: string): string {
469
- let text = output.trimEnd();
470
+ let text = sanitizeText(output).trimEnd();
470
471
  while (text) {
471
472
  const withoutArtifactNotice = stripRawOutputArtifactNotice(text).text;
472
473
  if (withoutArtifactNotice !== text) {
@@ -498,7 +499,8 @@ function renderOutputSection(
498
499
  warning?: string,
499
500
  ): string[] {
500
501
  const lines: string[] = [];
501
- const trimmedOutput = output.trimEnd();
502
+ const sanitizedOutput = sanitizeText(output);
503
+ const trimmedOutput = sanitizedOutput.trimEnd();
502
504
  if (!trimmedOutput && !warning) return lines;
503
505
 
504
506
  if (warning) {
@@ -506,7 +508,7 @@ function renderOutputSection(
506
508
  lines.push(
507
509
  `${continuePrefix} ${theme.fg("warning", theme.status.warning)} ${theme.fg(
508
510
  "dim",
509
- truncateToWidth(warning, 80),
511
+ truncateToWidth(sanitizeText(warning), 80),
510
512
  )}`,
511
513
  );
512
514
 
@@ -537,8 +539,7 @@ function renderOutputSection(
537
539
  // Fall back to raw output
538
540
  }
539
541
  }
540
-
541
- const outputLines = output.trimEnd().split("\n");
542
+ const outputLines = trimmedOutput.split("\n");
542
543
  const previewCount = expanded ? maxExpanded : maxCollapsed;
543
544
  for (const line of outputLines.slice(0, previewCount)) {
544
545
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
@@ -581,8 +582,7 @@ function renderOutputSection(
581
582
  }
582
583
 
583
584
  lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
584
-
585
- const outputLines = output.trimEnd().split("\n");
585
+ const outputLines = trimmedOutput.split("\n");
586
586
  const previewCount = expanded ? maxExpanded : maxCollapsed;
587
587
  for (const line of outputLines.slice(0, previewCount)) {
588
588
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
@@ -603,7 +603,7 @@ function renderTaskSection(
603
603
  maxExpanded = 20,
604
604
  ): string[] {
605
605
  const lines: string[] = [];
606
- const trimmed = task.trim();
606
+ const trimmed = sanitizeText(task).trim();
607
607
  if (!expanded || !trimmed) return lines;
608
608
 
609
609
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
@@ -624,10 +624,11 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri
624
624
  if (typeof value === "boolean") return String(value);
625
625
  if (typeof value === "number") return String(value);
626
626
  if (typeof value === "string") {
627
- const firstLine = value.split("\n")[0].trim();
628
- if (firstLine.length === 0) return `"" (${value.split("\n").length} lines)`;
627
+ const sanitizedValue = sanitizeText(value);
628
+ const firstLine = sanitizedValue.split("\n")[0].trim();
629
+ if (firstLine.length === 0) return `"" (${sanitizedValue.split("\n").length} lines)`;
629
630
  const preview = truncateToWidth(firstLine, maxLen);
630
- if (value.includes("\n")) return `"${preview}…" (${value.split("\n").length} lines)`;
631
+ if (sanitizedValue.includes("\n")) return `"${preview}…" (${sanitizedValue.split("\n").length} lines)`;
631
632
  return `"${preview}"`;
632
633
  }
633
634
  if (Array.isArray(value)) return `[${value.length} items]`;
@@ -635,7 +636,7 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri
635
636
  const keys = Object.keys(value);
636
637
  return `{${keys.length} keys}`;
637
638
  }
638
- return String(value);
639
+ return sanitizeText(String(value));
639
640
  }
640
641
 
641
642
  function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string {
@@ -662,7 +663,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
662
663
 
663
664
  for (const [key, value] of entries) {
664
665
  const valueStr = formatScalarInline(value, 24, theme);
665
- const pairStr = `${key}=${valueStr}`;
666
+ const pairStr = `${sanitizeText(key)}=${valueStr}`;
666
667
  const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; // +2 for ", "
667
668
 
668
669
  if (totalLen + addLen > maxWidth && pairs.length > 0) {
@@ -760,7 +761,9 @@ function createAssignmentSectionRenderer(
760
761
  // `renderResult` receives the raw tool args (unlike `renderCall`, which is
761
762
  // fed through `repairTaskParams`), so undo any per-field double-encoding
762
763
  // here too. The repair is idempotent on already-clean text.
763
- const assignment = repairDoubleEncodedJsonString(typeof args?.assignment === "string" ? args.assignment : "").trim();
764
+ const assignment = sanitizeText(
765
+ repairDoubleEncodedJsonString(typeof args?.assignment === "string" ? args.assignment : ""),
766
+ ).trim();
764
767
  if (!assignment) return undefined;
765
768
  return createMarkdownSectionRenderer(assignment, theme);
766
769
  }
@@ -774,7 +777,9 @@ function createContextSectionRenderer(
774
777
  args: Partial<TaskParams> | undefined,
775
778
  theme: Theme,
776
779
  ): AssignmentSectionRenderer | undefined {
777
- const context = repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : "").trim();
780
+ const context = sanitizeText(
781
+ repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : ""),
782
+ ).trim();
778
783
  if (!context) return undefined;
779
784
  return createMarkdownSectionRenderer(context, theme);
780
785
  }
@@ -858,7 +863,7 @@ function renderAgentProgress(
858
863
  : "accent";
859
864
 
860
865
  // Main status line: id: description [status] · stats · ⟨agent⟩
861
- const trimmedDescription = progress.description?.trim();
866
+ const trimmedDescription = progress.description ? sanitizeText(progress.description).trim() : undefined;
862
867
  const description = trimmedDescription ? previewLine(trimmedDescription, 64) : undefined;
863
868
  const displayId = formatTaskId(progress.id);
864
869
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
@@ -900,7 +905,7 @@ function renderAgentProgress(
900
905
  const showBadge = settings.get("task.showResolvedModelBadge");
901
906
  if (progress.status === "running") {
902
907
  if (!description) {
903
- const taskPreview = previewLine(progress.assignment ?? progress.task, 40);
908
+ const taskPreview = previewLine(sanitizeText(progress.assignment ?? progress.task), 40);
904
909
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
905
910
  }
906
911
  statusLine = appendAgentStats(statusLine, { ...progress, showResolvedModelBadge: showBadge }, theme);
@@ -915,10 +920,10 @@ function renderAgentProgress(
915
920
  // Current tool (if running) or most recent completed tool
916
921
  if (progress.status === "running") {
917
922
  if (progress.currentTool) {
918
- let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", progress.currentTool)}`;
923
+ let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
919
924
  const toolDetail = progress.lastIntent ?? progress.currentToolArgs;
920
925
  if (toolDetail) {
921
- toolLine += `: ${theme.fg("dim", previewLine(toolDetail, 40))}`;
926
+ toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
922
927
  }
923
928
  if (progress.currentToolStartMs) {
924
929
  const elapsed = Date.now() - progress.currentToolStartMs;
@@ -930,10 +935,10 @@ function renderAgentProgress(
930
935
  } else if (progress.recentTools.length > 0) {
931
936
  // Show most recent completed tool when idle between tools
932
937
  const recent = progress.recentTools[0];
933
- let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", recent.tool)}`;
938
+ let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", sanitizeText(recent.tool))}`;
934
939
  const toolDetail = progress.lastIntent ?? recent.args;
935
940
  if (toolDetail) {
936
- toolLine += `: ${theme.fg("dim", previewLine(toolDetail, 40))}`;
941
+ toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
937
942
  }
938
943
  lines.push(toolLine);
939
944
  }
@@ -947,12 +952,12 @@ function renderAgentProgress(
947
952
  const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
948
953
  const summary =
949
954
  `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
950
- previewLine(progress.retryState.errorMessage, 60);
955
+ previewLine(sanitizeText(progress.retryState.errorMessage), 60);
951
956
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
952
957
  } else if (progress.retryFailure && progress.status !== "running") {
953
958
  const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
954
959
  progress.retryFailure.attempt === 1 ? "" : "s"
955
- }: ${previewLine(progress.retryFailure.errorMessage, 80)}`;
960
+ }: ${previewLine(sanitizeText(progress.retryFailure.errorMessage), 80)}`;
956
961
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("error", summary)}`);
957
962
  }
958
963
 
@@ -1098,13 +1103,13 @@ function renderReviewResult(
1098
1103
  if (summary.explanation) {
1099
1104
  if (expanded) {
1100
1105
  lines.push(`${continuePrefix}${theme.fg("dim", "Summary")}`);
1101
- const explanationLines = summary.explanation.split("\n");
1106
+ const explanationLines = sanitizeText(summary.explanation).split("\n");
1102
1107
  for (const line of explanationLines) {
1103
1108
  lines.push(`${continuePrefix} ${theme.fg("dim", replaceTabs(line))}`);
1104
1109
  }
1105
1110
  } else {
1106
1111
  // Preview: first sentence or ~100 chars (flatten tabs/newlines first)
1107
- const flat = replaceTabs(summary.explanation).replace(/[\r\n]+/g, " ");
1112
+ const flat = replaceTabs(sanitizeText(summary.explanation)).replace(/[\r\n]+/g, " ");
1108
1113
  const firstSentence = flat.split(/[.!?]/)[0].trim();
1109
1114
  const preview = truncateToWidth(`${firstSentence}.`, 100);
1110
1115
  lines.push(`${continuePrefix}${theme.fg("dim", preview)}`);
@@ -1145,9 +1150,9 @@ function renderFindings(
1145
1150
  const findingContinue = isLastFinding ? " " : `${theme.tree.vertical} `;
1146
1151
 
1147
1152
  const { color } = getPriorityInfo(finding.priority);
1148
- const rawTitle = finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled";
1153
+ const rawTitle = sanitizeText(finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled");
1149
1154
  const titleText = replaceTabs(rawTitle).replace(/[\r\n]+/g, " ");
1150
- const loc = `${path.basename(finding.file_path || "<unknown>")}:${finding.line_start}`;
1155
+ const loc = `${path.basename(sanitizeText(finding.file_path || "<unknown>"))}:${finding.line_start}`;
1151
1156
 
1152
1157
  lines.push(
1153
1158
  `${continuePrefix}${findingPrefix} ${theme.fg(color, `[${finding.priority}]`)} ${titleText} ${theme.fg("dim", loc)}`,
@@ -1156,7 +1161,7 @@ function renderFindings(
1156
1161
  // Show body when expanded
1157
1162
  if (expanded && finding.body) {
1158
1163
  // Wrap body text
1159
- const bodyLines = finding.body.split("\n");
1164
+ const bodyLines = sanitizeText(finding.body).split("\n");
1160
1165
  for (const bodyLine of bodyLines) {
1161
1166
  lines.push(`${continuePrefix}${findingContinue}${theme.fg("dim", replaceTabs(bodyLine))}`);
1162
1167
  }
@@ -1208,7 +1213,7 @@ function renderAgentResult(
1208
1213
  : "failed";
1209
1214
 
1210
1215
  // Main status line: id: description [status] · stats · ⟨agent⟩
1211
- const trimmedDescription = result.description?.trim();
1216
+ const trimmedDescription = result.description ? sanitizeText(result.description).trim() : undefined;
1212
1217
  const description = trimmedDescription ? previewLine(trimmedDescription, 64) : undefined;
1213
1218
  const displayId = formatTaskId(result.id);
1214
1219
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
@@ -1242,7 +1247,7 @@ function renderAgentResult(
1242
1247
 
1243
1248
  if (aborted && result.abortReason) {
1244
1249
  lines.push(
1245
- `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", previewLine(result.abortReason, 80))}`,
1250
+ `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", previewLine(sanitizeText(result.abortReason), 80))}`,
1246
1251
  );
1247
1252
  }
1248
1253
  // Check for review result, preferring incremental yield sections and falling
@@ -1346,7 +1351,7 @@ function renderAgentResult(
1346
1351
  lines.push(
1347
1352
  `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg(
1348
1353
  "dim",
1349
- truncateToWidth(missingCompleteWarning, 80),
1354
+ truncateToWidth(sanitizeText(missingCompleteWarning), 80),
1350
1355
  )}`,
1351
1356
  );
1352
1357
  }
@@ -1370,7 +1375,9 @@ function renderAgentResult(
1370
1375
 
1371
1376
  // Error message
1372
1377
  if (result.error && (!success || mergeFailed) && (!aborted || result.error !== result.abortReason)) {
1373
- lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", previewLine(result.error, 70))}`);
1378
+ lines.push(
1379
+ `${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", previewLine(sanitizeText(result.error), 70))}`,
1380
+ );
1374
1381
  }
1375
1382
 
1376
1383
  return lines;
package/src/tools/bash.ts CHANGED
@@ -23,7 +23,6 @@ import { CachedOutputBlock, markFramedBlockComponent, outputBlockContentWidth }
23
23
  import { getSixelLineMask } from "../utils/sixel";
24
24
  import type { ToolSession } from ".";
25
25
  import { truncateForPrompt } from "./approval";
26
- import { applyBashFixups } from "./bash-command-fixup";
27
26
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
28
27
  import { checkBashInterception } from "./bash-interceptor";
29
28
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
@@ -660,16 +659,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
660
659
  let command = rawCommand;
661
660
  const env = normalizeBashEnv(rawEnv);
662
661
 
663
- // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
664
- // `2>&1`). The helper is single-line only and refuses anything that could
665
- // change semantics.
666
- if (this.session.settings.get("bash.stripTrailingHeadTail")) {
667
- const fixup = applyBashFixups(command);
668
- if (fixup.stripped.length > 0) {
669
- command = fixup.command;
670
- }
671
- }
672
-
673
662
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
674
663
  // Constrained to a single line so a `&&` that sits on a later line of a multiline
675
664
  // script can't pull the entire script into the "cwd" capture.
@@ -147,8 +147,13 @@ export function expandTilde(filePath: string, home?: string): string {
147
147
  }
148
148
 
149
149
  export function expandPath(filePath: string): string {
150
+ // Some models intermittently prefix an otherwise-valid path with a stray
151
+ // `:` (e.g. `:/abs/path`, `:../rel`). No real path starts with `:` and it
152
+ // never begins a selector against an absolute/relative path, so strip it
153
+ // before resolution — mirroring the `@`-prefix normalization above.
154
+ const deColoned = /^:(?=[/~]|\.\.?\/)/.test(filePath) ? filePath.slice(1) : filePath;
150
155
  const normalized = stripWindowsExtendedLengthPathPrefix(
151
- stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath))),
156
+ stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(deColoned))),
152
157
  );
153
158
  return expandTilde(normalized);
154
159
  }
@@ -52,6 +52,8 @@ const THINKING_TAG_ENVELOPE_RE = /<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>/
52
52
  const THINKING_FENCE_ENVELOPE_RE = /```(?:thinking|reasoning)\b[\s\S]*?```/gi;
53
53
  const LEADING_THINKING_TAG_RE = /^\s*<(think|thinking|reasoning)>\s*[\s\S]*?<\/\1>\s*/i;
54
54
  const LEADING_THINKING_FENCE_RE = /^\s*```(?:thinking|reasoning)\b[\s\S]*?```\s*/i;
55
+ const LEADING_PROSE_THINKING_PREAMBLE_RE =
56
+ /^[ \t]*(?:(?:here(?:['’]s| is)[ \t]+(?:a|the|my)[ \t]+)|my[ \t]+)?(?:thinking|thought|reasoning)[ \t]+process[ \t]*:?[ \t]*(?:\r?\n|$)/i;
55
57
 
56
58
  /**
57
59
  * Whether the model honors a forced `tool_choice` so the `set_title` tool can be
@@ -300,9 +302,11 @@ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): stri
300
302
  // leaked thinking envelopes plus any stray/unclosed title tag fragment.
301
303
  const markedTitle = extractVisibleMarkedTitle(textTitle);
302
304
  if (markedTitle !== undefined) return markedTitle;
303
- return stripLeadingLeakedThinkingMarkup(textTitle)
305
+ const cleanedTextTitle = stripLeadingLeakedThinkingMarkup(textTitle)
304
306
  .replace(/<\/?title>/gi, "")
305
307
  .trim();
308
+ if (LEADING_PROSE_THINKING_PREAMBLE_RE.test(cleanedTextTitle)) return "";
309
+ return cleanedTextTitle;
306
310
  }
307
311
 
308
312
  function extractVisibleMarkedTitle(text: string): string | undefined {
@@ -1,11 +0,0 @@
1
- export interface BashFixupResult {
2
- /** Possibly-rewritten command. */
3
- command: string;
4
- /** Substrings that were stripped, in the order they were removed. */
5
- stripped: string[];
6
- }
7
- /**
8
- * Apply both fixups to a bash command. On any parse failure, multi-line input,
9
- * or no-op transform, returns the input verbatim with `stripped: []`.
10
- */
11
- export declare function applyBashFixups(command: string): BashFixupResult;
@@ -1,37 +0,0 @@
1
- /**
2
- * Conservative transforms applied to a bash command before execution.
3
- *
4
- * Two fixups are applied, each anchored to the end of a top-level segment
5
- * (segments split on `;`, `&&`, `||`, and background `&`):
6
- *
7
- * 1. Trailing `| head [args]` / `| tail [args]` (and the `|&` variant) — these
8
- * pipes exist purely to limit output length. The harness already truncates
9
- * bash output and exposes the full result via an artifact, so they only
10
- * hide content the agent wanted.
11
- *
12
- * 2. A redundant trailing `2>&1` left on a segment that has no remaining pipe
13
- * or other redirect. The harness already merges stderr into stdout, so the
14
- * duplication is purely cosmetic — and often a leftover after fixup (1)
15
- * drops a downstream pipe.
16
- *
17
- * The heavy lifting (tokenization, quoting, heredoc handling, command
18
- * substitution, nested compound commands) lives in Rust under
19
- * `pi_shell::fixup`, driven by the real `brush-parser` AST. This module is a
20
- * thin sync wrapper plus user-facing notice formatting.
21
- */
22
- import { applyBashFixups as nativeApplyBashFixups } from "jeopi-natives";
23
-
24
- export interface BashFixupResult {
25
- /** Possibly-rewritten command. */
26
- command: string;
27
- /** Substrings that were stripped, in the order they were removed. */
28
- stripped: string[];
29
- }
30
-
31
- /**
32
- * Apply both fixups to a bash command. On any parse failure, multi-line input,
33
- * or no-op transform, returns the input verbatim with `stripped: []`.
34
- */
35
- export function applyBashFixups(command: string): BashFixupResult {
36
- return nativeApplyBashFixups(command);
37
- }