@oh-my-pi/pi-coding-agent 13.13.2 → 13.14.2

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.
@@ -6,7 +6,7 @@ import * as path from "node:path";
6
6
  import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
7
  import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
8
8
  import type { Component, SlashCommand } from "@oh-my-pi/pi-tui";
9
- import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
9
+ import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
10
10
  import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
11
11
  import chalk from "chalk";
12
12
  import { KeybindingsManager } from "../config/keybindings";
@@ -1111,8 +1111,7 @@ export class InteractiveMode implements InteractiveModeContext {
1111
1111
  this.#startMicAnimation();
1112
1112
  } else if (state === "transcribing") {
1113
1113
  this.#stopMicAnimation();
1114
- this.editor.cursorOverride = `\x1b[38;2;200;200;200m${theme.icon.mic}\x1b[0m`;
1115
- this.editor.cursorOverrideWidth = 1;
1114
+ this.#setMicCursor({ r: 200, g: 200, b: 200 });
1116
1115
  } else {
1117
1116
  this.#cleanupMicAnimation();
1118
1117
  }
@@ -1122,10 +1121,15 @@ export class InteractiveMode implements InteractiveModeContext {
1122
1121
  });
1123
1122
  }
1124
1123
 
1124
+ #setMicCursor(color: { r: number; g: number; b: number }): void {
1125
+ this.editor.cursorOverride = `\x1b[38;2;${color.r};${color.g};${color.b}m${theme.icon.mic}\x1b[0m`;
1126
+ // Theme symbols can be wide (for example, 🎤), so measure the rendered override.
1127
+ this.editor.cursorOverrideWidth = visibleWidth(this.editor.cursorOverride);
1128
+ }
1129
+
1125
1130
  #updateMicIcon(): void {
1126
1131
  const { r, g, b } = hsvToRgb({ h: this.#voiceHue, s: 0.9, v: 1.0 });
1127
- this.editor.cursorOverride = `\x1b[38;2;${r};${g};${b}m${theme.icon.mic}\x1b[0m`;
1128
- this.editor.cursorOverrideWidth = 1;
1132
+ this.#setMicCursor({ r, g, b });
1129
1133
  }
1130
1134
 
1131
1135
  #startMicAnimation(): void {
@@ -97,8 +97,12 @@ const patchEditSchema = Type.Object({
97
97
  export type ReplaceParams = Static<typeof replaceEditSchema>;
98
98
  export type PatchParams = Static<typeof patchEditSchema>;
99
99
 
100
- /** Pattern matching hashline display format prefixes: `LINE#ID:CONTENT` and `#ID:CONTENT` */
101
- const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\d+\s*#\s*|#\s*)[ZPMQVRWSNKTXJBYH]{2}:/;
100
+ /**
101
+ * Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`.
102
+ * A plus-prefixed form appears in diff-like output and should be treated as hashline metadata too.
103
+ */
104
+ const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
105
+ const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
102
106
 
103
107
  /** Pattern matching a unified-diff added-line `+` prefix (but not `++`). Does NOT match `-` to avoid corrupting Markdown list items. */
104
108
  const DIFF_PLUS_RE = /^[+](?![+])/;
@@ -111,27 +115,31 @@ const DIFF_PLUS_RE = /^[+](?![+])/;
111
115
  * output file. This strips them heuristically before application.
112
116
  */
113
117
  export function stripNewLinePrefixes(lines: string[]): string[] {
114
- // Hashline prefixes are highly specific to read output and should only be
115
- // stripped when *every* non-empty line carries one.
116
- // Diff '+' markers can be legitimate content less often, so keep majority mode.
118
+ // Hashline prefixes are highly specific to read output and are usually stripped only when
119
+ // *every* non-empty line carries one. If a line is prefixed as `+ID:`, strip that
120
+ // prefix while leaving other `+` lines untouched to avoid corrupting mixed snippets.
117
121
  let hashPrefixCount = 0;
122
+ let diffPlusHashPrefixCount = 0;
118
123
  let diffPlusCount = 0;
119
124
  let nonEmpty = 0;
120
125
  for (const l of lines) {
121
126
  if (l.length === 0) continue;
122
127
  nonEmpty++;
123
128
  if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
129
+ if (HASHLINE_PREFIX_PLUS_RE.test(l)) diffPlusHashPrefixCount++;
124
130
  if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
125
131
  }
126
132
  if (nonEmpty === 0) return lines;
127
133
 
128
134
  const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
129
- const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
130
- if (!stripHash && !stripPlus) return lines;
135
+ const stripPlus =
136
+ !stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
137
+ if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
131
138
 
132
139
  return lines.map(l => {
133
140
  if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
134
141
  if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
142
+ if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(l)) return l.replace(HASHLINE_PREFIX_RE, "");
135
143
  return l;
136
144
  });
137
145
  }
@@ -3,7 +3,7 @@ name: explore
3
3
  description: Fast read-only codebase scout returning compressed context for handoff
4
4
  tools: read, grep, find, fetch, web_search
5
5
  model: pi/smol
6
- thinking-level: off
6
+ thinking-level: med
7
7
  output:
8
8
  properties:
9
9
  summary:
@@ -12,84 +12,21 @@ output:
12
12
  type: string
13
13
  files:
14
14
  metadata:
15
- description: Files examined with exact line ranges
15
+ description: Files examined with relevant code references
16
16
  elements:
17
17
  properties:
18
- path:
18
+ ref:
19
19
  metadata:
20
- description: Absolute path to file
20
+ description: Project-relative path or paths to the most relevant code reference(s), optionally suffixed with line ranges like `:12-34` when relevant
21
21
  type: string
22
- line_start:
23
- metadata:
24
- description: First line read (1-indexed)
25
- type: number
26
- line_end:
27
- metadata:
28
- description: Last line read (1-indexed)
29
- type: number
30
22
  description:
31
23
  metadata:
32
24
  description: Section contents
33
25
  type: string
34
- code:
35
- metadata:
36
- description: Critical types/interfaces/functions extracted verbatim
37
- elements:
38
- properties:
39
- path:
40
- metadata:
41
- description: Absolute path to source file
42
- type: string
43
- line_start:
44
- metadata:
45
- description: Excerpt first line (1-indexed)
46
- type: number
47
- line_end:
48
- metadata:
49
- description: Excerpt last line (1-indexed)
50
- type: number
51
- language:
52
- metadata:
53
- description: Language id for syntax highlighting
54
- type: string
55
- content:
56
- metadata:
57
- description: Verbatim code excerpt
58
- type: string
59
26
  architecture:
60
27
  metadata:
61
28
  description: Brief explanation of how pieces connect
62
29
  type: string
63
- dependencies:
64
- metadata:
65
- description: Key internal and external dependencies relevant to the task
66
- elements:
67
- properties:
68
- name:
69
- metadata:
70
- description: Package or module name
71
- type: string
72
- role:
73
- metadata:
74
- description: What it provides in context of the task
75
- type: string
76
- risks:
77
- metadata:
78
- description: Gotchas, edge cases, or constraints the receiving agent should know
79
- elements:
80
- type: string
81
- start_here:
82
- metadata:
83
- description: Recommended entry point for receiving agent
84
- properties:
85
- path:
86
- metadata:
87
- description: Absolute path to start reading
88
- type: string
89
- reason:
90
- metadata:
91
- description: Why this file best starting point
92
- type: string
93
30
  ---
94
31
 
95
32
  You are a file search specialist and a codebase scout.
@@ -3791,33 +3791,24 @@ export class AgentSession {
3791
3791
  if (calledRequiredTool) {
3792
3792
  return;
3793
3793
  }
3794
-
3795
- const askTool = this.#toolRegistry.get("ask");
3796
- const exitPlanModeTool = this.#toolRegistry.get("exit_plan_mode");
3797
- if (!askTool || !exitPlanModeTool) {
3794
+ const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("exit_plan_mode");
3795
+ if (!hasRequiredTools) {
3798
3796
  logger.warn("Plan mode enforcement skipped because ask/exit tools are unavailable", {
3799
3797
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
3800
3798
  });
3801
3799
  return;
3802
3800
  }
3803
- const forcedTools = [askTool, exitPlanModeTool];
3804
3801
 
3805
3802
  const reminder = renderPromptTemplate(planModeToolDecisionReminderPrompt, {
3806
3803
  askToolName: "ask",
3807
3804
  exitToolName: "exit_plan_mode",
3808
3805
  });
3809
3806
 
3810
- const previousTools = this.agent.state.tools;
3811
- this.agent.setTools(forcedTools);
3812
- try {
3813
- await this.prompt(reminder, {
3814
- synthetic: true,
3815
- expandPromptTemplates: false,
3816
- toolChoice: "required",
3817
- });
3818
- } finally {
3819
- this.agent.setTools(previousTools);
3820
- }
3807
+ await this.prompt(reminder, {
3808
+ synthetic: true,
3809
+ expandPromptTemplates: false,
3810
+ toolChoice: "required",
3811
+ });
3821
3812
  }
3822
3813
 
3823
3814
  #createEagerTodoPrelude(): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
@@ -32,6 +32,8 @@ export interface OutputSinkOptions {
32
32
  artifactId?: string;
33
33
  spillThreshold?: number;
34
34
  onChunk?: (chunk: string) => void;
35
+ /** Minimum ms between onChunk calls. 0 = every chunk (default). */
36
+ chunkThrottleMs?: number;
35
37
  }
36
38
 
37
39
  export interface TruncationResult {
@@ -521,6 +523,7 @@ export class OutputSink {
521
523
  #totalBytes = 0;
522
524
  #sawData = false;
523
525
  #truncated = false;
526
+ #lastChunkTime = 0;
524
527
 
525
528
  #file?: {
526
529
  path: string;
@@ -528,22 +531,46 @@ export class OutputSink {
528
531
  sink: Bun.FileSink;
529
532
  };
530
533
 
534
+ // Queue of chunks waiting for the file sink to be created.
535
+ #pendingFileWrites?: string[];
536
+ #fileReady = false;
537
+
531
538
  readonly #artifactPath?: string;
532
539
  readonly #artifactId?: string;
533
540
  readonly #spillThreshold: number;
534
541
  readonly #onChunk?: (chunk: string) => void;
542
+ readonly #chunkThrottleMs: number;
535
543
 
536
544
  constructor(options?: OutputSinkOptions) {
537
- const { artifactPath, artifactId, spillThreshold = DEFAULT_MAX_BYTES, onChunk } = options ?? {};
545
+ const {
546
+ artifactPath,
547
+ artifactId,
548
+ spillThreshold = DEFAULT_MAX_BYTES,
549
+ onChunk,
550
+ chunkThrottleMs = 0,
551
+ } = options ?? {};
538
552
  this.#artifactPath = artifactPath;
539
553
  this.#artifactId = artifactId;
540
554
  this.#spillThreshold = spillThreshold;
541
555
  this.#onChunk = onChunk;
556
+ this.#chunkThrottleMs = chunkThrottleMs;
542
557
  }
543
558
 
544
- async push(chunk: string): Promise<void> {
559
+ /**
560
+ * Push a chunk of output. The buffer management and onChunk callback run
561
+ * synchronously. File sink writes are deferred and serialized internally.
562
+ */
563
+ push(chunk: string): void {
545
564
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
546
- this.#onChunk?.(chunk);
565
+
566
+ // Throttled onChunk: only call the callback when enough time has passed.
567
+ if (this.#onChunk) {
568
+ const now = Date.now();
569
+ if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
570
+ this.#lastChunkTime = now;
571
+ this.#onChunk(chunk);
572
+ }
573
+ }
547
574
 
548
575
  const dataBytes = Buffer.byteLength(chunk, "utf-8");
549
576
  this.#totalBytes += dataBytes;
@@ -556,10 +583,9 @@ export class OutputSink {
556
583
  const threshold = this.#spillThreshold;
557
584
  const willOverflow = this.#bufferBytes + dataBytes > threshold;
558
585
 
559
- // Write to file if already spilling or about to overflow
560
- if (this.#file != null || willOverflow) {
561
- const sink = await this.#ensureFileSink();
562
- await sink?.write(chunk);
586
+ // Write to artifact file if configured and past the threshold
587
+ if (this.#artifactPath && (this.#file != null || willOverflow)) {
588
+ this.#writeToFile(chunk);
563
589
  }
564
590
 
565
591
  if (!willOverflow) {
@@ -589,14 +615,64 @@ export class OutputSink {
589
615
  if (this.#file) this.#truncated = true;
590
616
  }
591
617
 
618
+ /**
619
+ * Write a chunk to the artifact file. Handles the async file sink creation
620
+ * by queuing writes until the sink is ready, then draining synchronously.
621
+ */
622
+ #writeToFile(chunk: string): void {
623
+ if (this.#fileReady && this.#file) {
624
+ // Fast path: file sink exists, write synchronously
625
+ this.#file.sink.write(chunk);
626
+ return;
627
+ }
628
+ // File sink not yet created — queue this chunk and kick off creation
629
+ if (!this.#pendingFileWrites) {
630
+ this.#pendingFileWrites = [chunk];
631
+ void this.#createFileSink();
632
+ } else {
633
+ this.#pendingFileWrites.push(chunk);
634
+ }
635
+ }
636
+
637
+ async #createFileSink(): Promise<void> {
638
+ if (!this.#artifactPath || this.#fileReady) return;
639
+ try {
640
+ const sink = Bun.file(this.#artifactPath).writer();
641
+ this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
642
+
643
+ // Flush existing buffer to file BEFORE it gets trimmed further.
644
+ if (this.#buffer.length > 0) {
645
+ sink.write(this.#buffer);
646
+ }
647
+
648
+ // Drain any chunks that arrived while the sink was being created
649
+ if (this.#pendingFileWrites) {
650
+ for (const pending of this.#pendingFileWrites) {
651
+ sink.write(pending);
652
+ }
653
+ this.#pendingFileWrites = undefined;
654
+ }
655
+
656
+ this.#fileReady = true;
657
+ } catch {
658
+ try {
659
+ await this.#file?.sink?.end();
660
+ } catch {
661
+ /* ignore */
662
+ }
663
+ this.#file = undefined;
664
+ this.#pendingFileWrites = undefined;
665
+ }
666
+ }
667
+
592
668
  createInput(): WritableStream<Uint8Array | string> {
593
669
  const dec = new TextDecoder("utf-8", { ignoreBOM: true });
594
- const finalize = async () => {
595
- await this.push(dec.decode());
670
+ const finalize = () => {
671
+ this.push(dec.decode());
596
672
  };
597
673
  return new WritableStream({
598
- write: async chunk => {
599
- await this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
674
+ write: chunk => {
675
+ this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
600
676
  },
601
677
  close: finalize,
602
678
  abort: finalize,
@@ -620,32 +696,6 @@ export class OutputSink {
620
696
  artifactId: this.#file?.artifactId,
621
697
  };
622
698
  }
623
-
624
- // -- private ---------------------------------------------------------------
625
-
626
- async #ensureFileSink(): Promise<Bun.FileSink | null> {
627
- if (!this.#artifactPath) return null;
628
- if (this.#file) return this.#file.sink;
629
-
630
- try {
631
- const sink = Bun.file(this.#artifactPath).writer();
632
- this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
633
-
634
- // Flush existing buffer to file BEFORE it gets trimmed further.
635
- if (this.#buffer.length > 0) {
636
- await sink.write(this.#buffer);
637
- }
638
- return sink;
639
- } catch {
640
- try {
641
- await this.#file?.sink?.end();
642
- } catch {
643
- /* ignore */
644
- }
645
- this.#file = undefined;
646
- return null;
647
- }
648
- }
649
699
  }
650
700
 
651
701
  // =============================================================================
@@ -406,6 +406,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
406
406
  },
407
407
  { name: "smithery-login", description: "Login to Smithery and cache API key" },
408
408
  { name: "smithery-logout", description: "Remove cached Smithery API key" },
409
+ { name: "reconnect", description: "Reconnect to a specific MCP server", usage: "<name>" },
409
410
  { name: "reload", description: "Force reload MCP runtime tools" },
410
411
  { name: "resources", description: "List available resources from connected servers" },
411
412
  { name: "prompts", description: "List available prompts from connected servers" },
@@ -295,7 +295,6 @@ export async function runInteractiveBashPty(
295
295
  },
296
296
  ): Promise<BashInteractiveResult> {
297
297
  const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
298
- let pendingChunks = Promise.resolve();
299
298
  const result = await ui.custom<BashInteractiveResult>(
300
299
  (tui, uiTheme, _keybindings, done) => {
301
300
  const session = new PtySession();
@@ -309,7 +308,6 @@ export async function runInteractiveBashPty(
309
308
  tui.requestRender();
310
309
  void (async () => {
311
310
  await component.flushOutput();
312
- await pendingChunks;
313
311
  const summary = await sink.dump();
314
312
  done({
315
313
  exitCode: run.exitCode,
@@ -362,15 +360,13 @@ export async function runInteractiveBashPty(
362
360
  if (finished || err || !chunk) return;
363
361
  component.appendOutput(chunk);
364
362
  const normalizedChunk = normalizeCaptureChunk(chunk);
365
- pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
363
+ sink.push(normalizedChunk);
366
364
  tui.requestRender();
367
365
  },
368
366
  )
369
367
  .then(finalize)
370
368
  .catch(error => {
371
- pendingChunks = pendingChunks
372
- .then(() => sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`))
373
- .catch(() => {});
369
+ sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
374
370
  finalize({ exitCode: undefined, cancelled: false, timedOut: false });
375
371
  });
376
372
  return component;
@@ -289,8 +289,8 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
289
289
  const executorOptions: PythonExecutorOptions = {
290
290
  ...baseExecutorOptions,
291
291
  reset: isFirstCell ? reset : false,
292
- onChunk: async chunk => {
293
- await outputSink!.push(chunk);
292
+ onChunk: chunk => {
293
+ outputSink!.push(chunk);
294
294
  },
295
295
  };
296
296