@oh-my-pi/pi-coding-agent 15.0.1 → 15.0.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +8 -8
  3. package/src/commands/commit.ts +10 -0
  4. package/src/config/model-registry.ts +31 -1
  5. package/src/config/settings-schema.ts +11 -0
  6. package/src/discovery/claude-plugins.ts +19 -7
  7. package/src/eval/py/runner.py +42 -11
  8. package/src/eval/py/runtime.ts +1 -0
  9. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  10. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  11. package/src/hashline/input.ts +2 -1
  12. package/src/hashline/parser.ts +27 -3
  13. package/src/internal-urls/docs-index.generated.ts +8 -8
  14. package/src/internal-urls/router.ts +8 -0
  15. package/src/internal-urls/types.ts +21 -0
  16. package/src/lsp/config.ts +15 -6
  17. package/src/lsp/defaults.json +6 -2
  18. package/src/modes/acp/acp-agent.ts +248 -50
  19. package/src/modes/components/status-line/segments.ts +38 -4
  20. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  21. package/src/modes/rpc/host-uris.ts +235 -0
  22. package/src/modes/rpc/rpc-mode.ts +27 -1
  23. package/src/modes/rpc/rpc-types.ts +57 -0
  24. package/src/modes/runtime-init.ts +2 -1
  25. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  26. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  27. package/src/modes/theme/theme.ts +6 -0
  28. package/src/prompts/tools/github.md +4 -4
  29. package/src/prompts/tools/hashline.md +22 -26
  30. package/src/prompts/tools/read.md +55 -37
  31. package/src/task/discovery.ts +5 -2
  32. package/src/task/executor.ts +2 -1
  33. package/src/tools/bash-command-fixup.ts +47 -0
  34. package/src/tools/bash.ts +39 -15
  35. package/src/tools/browser/render.ts +2 -2
  36. package/src/tools/eval.ts +10 -2
  37. package/src/tools/gh.ts +37 -4
  38. package/src/tools/job.ts +16 -7
  39. package/src/tools/output-meta.ts +26 -0
  40. package/src/tools/read.ts +32 -4
  41. package/src/tools/ssh.ts +3 -2
  42. package/src/tools/write.ts +20 -0
  43. package/src/web/search/providers/anthropic.ts +5 -0
  44. package/src/web/search/providers/exa.ts +3 -0
  45. package/src/web/search/providers/gemini.ts +5 -0
  46. package/src/web/search/providers/jina.ts +5 -2
  47. package/src/web/search/providers/zai.ts +5 -2
package/src/tools/read.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  formatStyledTruncationWarning,
61
61
  type OutputMeta,
62
62
  resolveOutputMaxColumns,
63
+ stripOutputNotice,
63
64
  } from "./output-meta";
64
65
  import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
65
66
  import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
@@ -172,6 +173,19 @@ function countTextLines(text: string): number {
172
173
  if (text.length === 0) return 0;
173
174
  return text.split("\n").length;
174
175
  }
176
+
177
+ /**
178
+ * Footer appended to summarized reads telling the model how to recover the
179
+ * elided body. Without this hint, agents either ignore the `...`/`{ .. }`
180
+ * markers or burn a turn guessing the right selector (see issue #1046).
181
+ */
182
+ function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elidedLines: number): string {
183
+ if (elidedSpans <= 0) return "";
184
+ const spanWord = elidedSpans === 1 ? "region" : "regions";
185
+ const lineWord = elidedLines === 1 ? "line" : "lines";
186
+ const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : "";
187
+ return `[${linePart}${elidedSpans} elided ${spanWord}; read ${readPath}:raw or a line range like ${readPath}:1-9999 for verbatim content]`;
188
+ }
175
189
  const READ_CHUNK_SIZE = 8 * 1024;
176
190
 
177
191
  /**
@@ -484,7 +498,7 @@ export interface ReadToolDetails {
484
498
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
485
499
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
486
500
  displayContent?: { text: string; startLine: number };
487
- summary?: { lines: number; elidedSpans: number };
501
+ summary?: { lines: number; elidedSpans: number; elidedLines: number };
488
502
  /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
489
503
  conflictCount?: number;
490
504
  }
@@ -1317,6 +1331,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1317
1331
  text: string;
1318
1332
  displayText: string;
1319
1333
  elidedSpans: number;
1334
+ elidedLines: number;
1320
1335
  } {
1321
1336
  const displayMode = resolveFileDisplayMode(this.session);
1322
1337
  const shouldAddHashLines = displayMode.hashLines;
@@ -1377,11 +1392,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1377
1392
  const modelParts: string[] = [];
1378
1393
  const displayParts: string[] = [];
1379
1394
  let elidedSpans = 0;
1395
+ let elidedLines = 0;
1380
1396
  for (const unit of units) {
1381
1397
  if (unit.kind === "elided") {
1382
1398
  modelParts.push("...");
1383
1399
  displayParts.push("...");
1384
1400
  elidedSpans++;
1401
+ elidedLines += unit.endLine - unit.startLine + 1;
1385
1402
  continue;
1386
1403
  }
1387
1404
  if (unit.kind === "merged") {
@@ -1396,13 +1413,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1396
1413
  modelParts.push(formatted.model);
1397
1414
  displayParts.push(formatted.display);
1398
1415
  elidedSpans++;
1416
+ // Merged brace pair encloses (start+1)..(end-1) as elided.
1417
+ elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
1399
1418
  continue;
1400
1419
  }
1401
1420
  modelParts.push(formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers));
1402
1421
  displayParts.push(unit.text);
1403
1422
  }
1404
1423
 
1405
- return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans };
1424
+ return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines };
1406
1425
  }
1407
1426
 
1408
1427
  async execute(
@@ -1646,16 +1665,23 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1646
1665
  const summary = await this.#trySummarize(absolutePath, fileSize, signal);
1647
1666
  if (summary?.parsed && summary.elided) {
1648
1667
  const renderedSummary = this.#renderSummary(summary);
1668
+ const footer = formatSummaryElisionFooter(
1669
+ localReadPath,
1670
+ renderedSummary.elidedSpans,
1671
+ renderedSummary.elidedLines,
1672
+ );
1673
+ const modelText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
1649
1674
  details = {
1650
1675
  displayContent: { text: renderedSummary.displayText, startLine: 1 },
1651
1676
  summary: {
1652
1677
  lines: countTextLines(renderedSummary.text),
1653
1678
  elidedSpans: renderedSummary.elidedSpans,
1679
+ elidedLines: renderedSummary.elidedLines,
1654
1680
  },
1655
1681
  };
1656
1682
 
1657
1683
  sourcePath = absolutePath;
1658
- content = [{ type: "text", text: renderedSummary.text }];
1684
+ content = [{ type: "text", text: modelText }];
1659
1685
  }
1660
1686
  }
1661
1687
 
@@ -2169,7 +2195,9 @@ export const readToolRenderer = {
2169
2195
  const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
2170
2196
  // Prefer structured `displayContent` from details when available so the TUI
2171
2197
  // shows clean file content (no model-only hashline anchors) without parsing the formatted text.
2172
- const contentText = details?.displayContent?.text ?? rawText;
2198
+ // Fall back to the raw text, but strip the LLM-facing notice so it doesn't
2199
+ // echo next to the styled warning line below.
2200
+ const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
2173
2201
  const imageContent = result.content?.find(c => c.type === "image");
2174
2202
  const rawPath = args?.file_path || args?.path || "";
2175
2203
  const filePath = shortenPath(rawPath);
package/src/tools/ssh.ts CHANGED
@@ -16,7 +16,7 @@ import { executeSSH } from "../ssh/ssh-executor";
16
16
  import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import type { ToolSession } from ".";
19
- import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
19
+ import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
20
20
  import { ToolError } from "./tool-errors";
21
21
  import { toolResult } from "./tool-result";
22
22
  import { clampTimeout } from "./tool-timeouts";
@@ -253,7 +253,8 @@ export const sshToolRenderer = {
253
253
  render: (width: number): string[] => {
254
254
  // REACTIVE: read mutable options at render time
255
255
  const { expanded, renderContext } = options;
256
- const output = textContent.trimEnd();
256
+ // Strip LLM-facing notice so we don't echo it next to the styled warning.
257
+ const output = stripOutputNotice(textContent, details?.meta).trimEnd();
257
258
  const outputLines: string[] = [];
258
259
 
259
260
  if (output) {
@@ -8,6 +8,8 @@ import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import { stripHashlinePrefixes } from "../edit";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
+ import { InternalUrlRouter } from "../internal-urls";
12
+ import { parseInternalUrl } from "../internal-urls/parse";
11
13
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
12
14
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
13
15
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
@@ -658,6 +660,24 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
658
660
  return untilAborted(signal, async () => {
659
661
  // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
660
662
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
663
+ const internalRouter = InternalUrlRouter.instance();
664
+ if (internalRouter.canHandle(path)) {
665
+ const parsed = parseInternalUrl(path);
666
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
667
+ const handler = internalRouter.getHandler(scheme);
668
+ if (handler?.write) {
669
+ await handler.write(parsed, cleanContent, { cwd: this.session.cwd, signal });
670
+ let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`;
671
+ if (stripped) {
672
+ resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
673
+ }
674
+ return { content: [{ type: "text", text: resultText }], details: {} };
675
+ }
676
+ // Schemes without a `write` hook fall through to existing logic
677
+ // (local:// resolves to a backing file via plan-mode-guard) or are
678
+ // rejected downstream when no backing file exists.
679
+ }
680
+
661
681
  const conflictUri = parseConflictUri(path);
662
682
  if (conflictUri) {
663
683
  if (conflictUri.scope) {
@@ -38,6 +38,7 @@ export interface AnthropicSearchParams {
38
38
  max_tokens?: number;
39
39
  /** Sampling temperature (0–1). Lower = more focused/factual. */
40
40
  temperature?: number;
41
+ signal?: AbortSignal;
41
42
  }
42
43
 
43
44
  /**
@@ -86,6 +87,7 @@ async function callSearch(
86
87
  systemPrompt?: string,
87
88
  maxTokens?: number,
88
89
  temperature?: number,
90
+ signal?: AbortSignal,
89
91
  ): Promise<AnthropicApiResponse> {
90
92
  const url = buildAnthropicUrl(auth);
91
93
  const headers = buildAnthropicSearchHeaders(auth);
@@ -116,6 +118,7 @@ async function callSearch(
116
118
  method: "POST",
117
119
  headers,
118
120
  body: JSON.stringify(body),
121
+ signal,
119
122
  });
120
123
 
121
124
  if (!response.ok) {
@@ -253,6 +256,7 @@ export async function searchAnthropic(params: AnthropicSearchParams): Promise<Se
253
256
  params.system_prompt,
254
257
  params.max_tokens,
255
258
  params.temperature,
259
+ params.signal,
256
260
  );
257
261
 
258
262
  const result = parseResponse(response);
@@ -281,6 +285,7 @@ export class AnthropicProvider extends SearchProvider {
281
285
  num_results: params.numSearchResults ?? params.limit,
282
286
  max_tokens: params.maxOutputTokens,
283
287
  temperature: params.temperature,
288
+ signal: params.signal,
284
289
  });
285
290
  }
286
291
  }
@@ -29,6 +29,7 @@ export interface ExaSearchParams {
29
29
  exclude_domains?: string[];
30
30
  start_published_date?: string;
31
31
  end_published_date?: string;
32
+ signal?: AbortSignal;
32
33
  }
33
34
 
34
35
  interface ExaSearchResult {
@@ -179,6 +180,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
179
180
  "x-api-key": apiKey,
180
181
  },
181
182
  body: JSON.stringify(body),
183
+ signal: params.signal,
182
184
  });
183
185
 
184
186
  if (!response.ok) {
@@ -259,6 +261,7 @@ export class ExaProvider extends SearchProvider {
259
261
  return searchExa({
260
262
  query: params.query,
261
263
  num_results: params.numSearchResults ?? params.limit,
264
+ signal: params.signal,
262
265
  });
263
266
  }
264
267
  }
@@ -39,6 +39,7 @@ export interface GeminiSearchParams extends GeminiToolParams {
39
39
  max_output_tokens?: number;
40
40
  /** Sampling temperature (0–1). Lower = more focused/factual. */
41
41
  temperature?: number;
42
+ signal?: AbortSignal;
42
43
  }
43
44
 
44
45
  export function buildGeminiRequestTools(params: GeminiToolParams): Array<Record<string, Record<string, unknown>>> {
@@ -235,6 +236,7 @@ async function callGeminiSearch(
235
236
  maxOutputTokens?: number,
236
237
  temperature?: number,
237
238
  toolParams: GeminiToolParams = {},
239
+ signal?: AbortSignal,
238
240
  ): Promise<{
239
241
  answer: string;
240
242
  sources: SearchSource[];
@@ -308,6 +310,7 @@ async function callGeminiSearch(
308
310
  ...headers,
309
311
  },
310
312
  body: JSON.stringify(requestBody),
313
+ signal,
311
314
  });
312
315
  const urlFor = (attempt: number) =>
313
316
  `${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
@@ -500,6 +503,7 @@ export async function searchGemini(params: GeminiSearchParams): Promise<SearchRe
500
503
  code_execution: params.code_execution,
501
504
  url_context: params.url_context,
502
505
  },
506
+ params.signal,
503
507
  );
504
508
 
505
509
  let sources = result.sources;
@@ -539,6 +543,7 @@ export class GeminiProvider extends SearchProvider {
539
543
  google_search: params.googleSearch,
540
544
  code_execution: params.codeExecution,
541
545
  url_context: params.urlContext,
546
+ signal: params.signal,
542
547
  });
543
548
  }
544
549
  }
@@ -17,6 +17,7 @@ const JINA_SEARCH_URL = "https://s.jina.ai";
17
17
  export interface JinaSearchParams {
18
18
  query: string;
19
19
  num_results?: number;
20
+ signal?: AbortSignal;
20
21
  }
21
22
 
22
23
  interface JinaSearchResult {
@@ -33,13 +34,14 @@ export function findApiKey(): string | null {
33
34
  }
34
35
 
35
36
  /** Call Jina Reader search API. */
36
- async function callJinaSearch(apiKey: string, query: string): Promise<JinaSearchResponse> {
37
+ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSignal): Promise<JinaSearchResponse> {
37
38
  const requestUrl = `${JINA_SEARCH_URL}/${encodeURIComponent(query)}`;
38
39
  const response = await fetch(requestUrl, {
39
40
  headers: {
40
41
  Accept: "application/json",
41
42
  Authorization: `Bearer ${apiKey}`,
42
43
  },
44
+ signal,
43
45
  });
44
46
 
45
47
  if (!response.ok) {
@@ -58,7 +60,7 @@ export async function searchJina(params: JinaSearchParams): Promise<SearchRespon
58
60
  throw new Error("JINA_API_KEY not found. Set it in environment or .env file.");
59
61
  }
60
62
 
61
- const response = await callJinaSearch(apiKey, params.query);
63
+ const response = await callJinaSearch(apiKey, params.query, params.signal);
62
64
  const sources: SearchSource[] = [];
63
65
 
64
66
  for (const result of response) {
@@ -91,6 +93,7 @@ export class JinaProvider extends SearchProvider {
91
93
  return searchJina({
92
94
  query: params.query,
93
95
  num_results: params.numSearchResults ?? params.limit,
96
+ signal: params.signal,
94
97
  });
95
98
  }
96
99
  }
@@ -20,6 +20,7 @@ const DEFAULT_NUM_RESULTS = 10;
20
20
  export interface ZaiSearchParams {
21
21
  query: string;
22
22
  num_results?: number;
23
+ signal?: AbortSignal;
23
24
  }
24
25
 
25
26
  interface ZaiSearchResult {
@@ -55,7 +56,7 @@ export async function findApiKey(): Promise<string | null> {
55
56
  return findCredential(getEnvApiKey("zai"), "zai");
56
57
  }
57
58
 
58
- async function callZaiTool(apiKey: string, args: Record<string, unknown>): Promise<unknown> {
59
+ async function callZaiTool(apiKey: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
59
60
  const response = await fetch(ZAI_MCP_URL, {
60
61
  method: "POST",
61
62
  headers: {
@@ -72,6 +73,7 @@ async function callZaiTool(apiKey: string, args: Record<string, unknown>): Promi
72
73
  arguments: args,
73
74
  },
74
75
  }),
76
+ signal,
75
77
  });
76
78
 
77
79
  if (!response.ok) {
@@ -157,7 +159,7 @@ async function callZaiSearch(apiKey: string, params: ZaiSearchParams): Promise<u
157
159
  let lastError: unknown;
158
160
  for (let i = 0; i < attempts.length; i++) {
159
161
  try {
160
- return await callZaiTool(apiKey, attempts[i]);
162
+ return await callZaiTool(apiKey, attempts[i], params.signal);
161
163
  } catch (error) {
162
164
  lastError = error;
163
165
  const isLastAttempt = i === attempts.length - 1;
@@ -302,6 +304,7 @@ export class ZaiProvider extends SearchProvider {
302
304
  return searchZai({
303
305
  query: params.query,
304
306
  num_results: params.numSearchResults ?? params.limit,
307
+ signal: params.signal,
305
308
  });
306
309
  }
307
310
  }