@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
@@ -1,46 +1,58 @@
1
- Reads the content at the specified path or URL.
1
+ Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.
2
2
 
3
3
  <instruction>
4
- The `read` tool is multi-purpose and more capable than it looks — inspects files, directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
5
- - You MUST parallelize reads when exploring related files
6
- - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
4
+ - One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).
5
+ - You SHOULD parallelize independent reads when exploring related files.
6
+ - You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.
7
+ </instruction>
7
8
 
8
9
  ## Parameters
9
- - `path` — file path or URL (required). Append `:<sel>` for line ranges or raw mode (for example `src/foo.ts:50-200` or `src/foo.ts:raw`).
10
+
11
+ - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `mcp://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
10
12
 
11
13
  ## Selectors
12
14
 
13
- |`path` suffix|Behavior|
14
- |---|---|
15
- |_(omitted)_|For parseable code files, return a structural summary. Otherwise read from the start (up to {{DEFAULT_LIMIT}} lines).|
16
- |`:50`|Read from line 50 onward|
17
- |`:50-200`|Read lines 50-200|
18
- |`:50+150`|Read 150 lines starting at line 50|
19
- |`:20+1`|Read exactly one line|
20
- |`:5-16,960-973`|Read multiple ranges in one call (comma-separated; ranges sort and merge automatically)|
21
- |`:raw`|Read verbatim text without anchors or summarization|
22
- |`:conflicts`|Return a one-line-per-block index of every merge conflict in the file|
15
+ Append `:<sel>` to `path`. The bare path falls back to the default mode.
16
+
17
+ - _(none)_ parseable code → structural summary (signatures kept, bodies elided); other files read from the start (up to {{DEFAULT_LIMIT}} lines).
18
+ - `:50` — read from line 50 onward.
19
+ - `:50-200` lines 50200 inclusive.
20
+ - `:50+150` 150 lines starting at line 50.
21
+ - `:20+1` exactly one line.
22
+ - `:5-16,960-973` multiple ranges in one call (sorted, overlaps merged).
23
+ - `:raw` verbatim text; no anchors, no summary, no line prefixes.
24
+ - `:2-4:raw` or `:raw:2-4` range AND verbatim; the two compose in either order.
25
+ - `:conflicts` — one-line-per-block index of every unresolved git merge conflict.
26
+
27
+ # Files
28
+
29
+ - Reading a directory path returns a depth-limited dirent listing.
30
+ {{#if IS_HL_MODE}}
31
+ - Reading a file with an explicit selector returns lines prefixed with `line+hash` anchors: `41th|def alpha():`. The 2-char hash is a content fingerprint that `edit` / `apply_patch` consume — copy it verbatim, NEVER fabricate.
32
+ {{else}}
33
+ {{#if IS_LINE_NUMBER_MODE}}
34
+ - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
35
+ {{/if}}
36
+ {{/if}}
37
+ - Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer of the form:
38
+
39
+ `[NN lines across MM elided regions; read <path>:raw or a line range like <path>:1-9999 for verbatim content]`
23
40
 
24
- # Filesystem
25
- - Reading a directory path returns a list of dirents.
26
- {{#if IS_HL_MODE}}
27
- - Reading a file with an explicit selector returns lines prefixed with anchors (line+hash): `41th|def alpha():`
28
- {{else}}
29
- {{#if IS_LINE_NUMBER_MODE}}
30
- - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`
31
- {{/if}}
32
- {{/if}}
33
- - Reading a parseable code file without a selector returns a structural summary with signatures/declarations kept and large bodies collapsed to `…`. Use `:raw` or an explicit range such as `:1-9999` for verbatim content.
41
+ If the elided body is what you actually need, re-issue the **exact selector the footer names**. NEVER guess what's inside `..` / `…` — those markers carry no content.
34
42
 
35
- # Inspection
43
+ # Documents & Notebooks
36
44
 
37
- Extracts text from PDF, Word, PowerPoint, Excel, RTF, EPUB, and Jupyter notebook files. Notebooks are shown as editable `# %% [type] cell:N` text; edits to that text are applied back to the underlying `.ipynb` JSON while preserving notebook metadata where possible. Can inspect images.
45
+ Extracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.
38
46
 
39
- # Directories & Archives
47
+ # Images
40
48
 
41
- Directories and archive roots return a list of entries. Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read contents, and append a selector to the archive entry such as `archive.zip:dir/file.ts:50-60`.
49
+ Reading an image path returns metadata (mime, bytes, dimensions, channels, alpha). For actual visual analysis, call `inspect_image` with the path and a question describing what to inspect.
42
50
 
43
- # SQLite Databases
51
+ # Archives
52
+
53
+ Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.
54
+
55
+ # SQLite
44
56
 
45
57
  For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
46
58
  - `file.db` — list tables with row counts
@@ -52,13 +64,19 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
52
64
 
53
65
  # URLs
54
66
 
55
- Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use a `:raw` suffix for untouched HTML. URL line selectors mirror the file form (`:50`, `:50-100`, `:50+150`, `:raw`). If a URL would otherwise look like `host:port`, add a trailing slash before the selector (e.g. `https://example.com/:80`).
56
- </instruction>
67
+ - Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs clean text/markdown.
68
+ - `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.
69
+ - Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.
70
+
71
+ # Internal URIs
72
+
73
+ `skill://<name>`, `agent://<id>`, `artifact://<id>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `mcp://<uri>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated.
57
74
 
58
75
  <critical>
59
- - You MUST use `read` for every file, directory, archive, and URL read. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, and `wget` are **FORBIDDEN** for inspection — any such Bash call is a bug, regardless of how short or convenient it looks.
60
- - You MUST prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser if `read` fails to deliver reasonable content.
61
- - You MUST always include the `path` parameter — never call `read` with an empty argument object `{}`.
62
- - For specific line ranges, append the selector to `path` (e.g. `path="src/foo.ts:50-200"`, `path="src/foo.ts:50+150"`) NEVER reach for `sed -n`, `awk NR`, or `head`/`tail` pipelines.
63
- - You MAY use path suffix selectors with URL reads; the tool paginates cached fetched output.
76
+ - You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN — any such bash call is a bug, regardless of how short or convenient it looks.
77
+ - You MUST prefer `read` over a browser/puppeteer tool for URL content; only reach for a browser when `read` cannot deliver reasonable content.
78
+ - You MUST always include `path`. NEVER call `read` with `{}`.
79
+ - For line ranges, append the selector to `path` (`path="src/foo.ts:50-200"`, `path="src/foo.ts:50+150"`). NEVER substitute `sed -n`, `awk NR`, or `head`/`tail` pipelines.
80
+ - Summary footer says `read <path>:raw …`? Re-issue the exact selector it names. NEVER guess what's inside `..` / `…` markers — they carry no content.
81
+ - You MAY combine selectors with URL reads and internal URIs; both paginate the cached resolved output.
64
82
  </critical>
@@ -15,6 +15,7 @@ import * as fs from "node:fs/promises";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
17
  import { logger } from "@oh-my-pi/pi-utils";
18
+ import { isProviderEnabled } from "../capability";
18
19
  import { findAllNearestProjectConfigDirs, getConfigDirs } from "../config";
19
20
  import { listClaudePluginRoots } from "../discovery/helpers";
20
21
  import { loadBundledAgents, parseAgent } from "./agents";
@@ -87,8 +88,10 @@ export async function discoverAgents(cwd: string, home: string = os.homedir()):
87
88
  if (user) orderedDirs.push({ dir: user.path, source: "user" });
88
89
  }
89
90
 
90
- // Load agents from Claude Code marketplace plugins
91
- const { roots: pluginRoots } = await listClaudePluginRoots(home, resolvedCwd);
91
+ // Load agents from Claude Code marketplace plugins (respects disabledProviders)
92
+ const { roots: pluginRoots } = isProviderEnabled("claude-plugins")
93
+ ? await listClaudePluginRoots(home, resolvedCwd)
94
+ : { roots: [] };
92
95
  const sortedPluginRoots = [...pluginRoots].sort((a, b) => {
93
96
  if (a.scope === b.scope) return 0;
94
97
  return a.scope === "project" ? -1 : 1;
@@ -16,6 +16,7 @@ import { Settings } from "../config/settings";
16
16
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
17
17
  import type { CustomTool } from "../extensibility/custom-tools/types";
18
18
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
19
+ import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
19
20
  import type { Skill } from "../extensibility/skills";
20
21
  import type { HindsightSessionState } from "../hindsight/state";
21
22
  import type { LocalProtocolOptions } from "../internal-urls";
@@ -1119,7 +1120,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1119
1120
  getAllTools: () => session.getAllToolNames(),
1120
1121
  setActiveTools: (toolNames: string[]) =>
1121
1122
  session.setActiveToolsByName(toolNames.filter(name => !parentOwnedToolNames.has(name))),
1122
- getCommands: () => [],
1123
+ getCommands: () => getSessionSlashCommands(session),
1123
1124
  setModel: model => runExtensionSetModel(session, model),
1124
1125
  getThinkingLevel: () => session.thinkingLevel,
1125
1126
  setThinkingLevel: level => session.setThinkingLevel(level),
@@ -0,0 +1,47 @@
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 "@oh-my-pi/pi-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
+ }
38
+
39
+ /**
40
+ * Human-readable notice for the fixups that fired. Mirrors the shape of
41
+ * `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
42
+ */
43
+ export function formatBashFixupNotice(stripped: readonly string[]): string | undefined {
44
+ if (!stripped.length) return undefined;
45
+ const quoted = stripped.map(s => `\`${s}\``).join(", ");
46
+ return `<system-warning>Stripped redundant ${quoted} — bash output is already truncated and stderr is already merged into stdout. NEVER use these patterns.</system-warning>`;
47
+ }
package/src/tools/bash.ts CHANGED
@@ -17,10 +17,11 @@ import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import { getSixelLineMask } from "../utils/sixel";
19
19
  import type { ToolSession } from ".";
20
+ import { applyBashFixups, formatBashFixupNotice } from "./bash-command-fixup";
20
21
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
21
22
  import { checkBashInterception } from "./bash-interceptor";
22
23
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
23
- import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
24
+ import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
24
25
  import { resolveToCwd } from "./path-utils";
25
26
  import { formatToolWorkingDirectory, replaceTabs } from "./render-utils";
26
27
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -245,6 +246,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
245
246
  readonly #asyncEnabled: boolean;
246
247
  readonly #autoBackgroundEnabled: boolean;
247
248
  readonly #autoBackgroundThresholdMs: number;
249
+ #bashFixupNoticeEmitted = false;
248
250
 
249
251
  constructor(private readonly session: ToolSession) {
250
252
  this.#asyncEnabled = this.session.settings.get("async.enabled");
@@ -291,7 +293,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
291
293
  #buildCompletedResult(
292
294
  result: BashResult | BashInteractiveResult,
293
295
  timeoutSec: number,
294
- options: { requestedTimeoutSec?: number; notices?: string[]; terminalId?: string } = {},
296
+ options: { requestedTimeoutSec?: number; notices?: readonly string[]; terminalId?: string } = {},
295
297
  ): AgentToolResult<BashToolDetails> {
296
298
  const outputLines = [this.#formatResultOutput(result)];
297
299
  const notices = options.notices?.filter(Boolean) ?? [];
@@ -314,7 +316,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
314
316
  label: string,
315
317
  previewText: string,
316
318
  timeoutSec: number,
317
- options: { requestedTimeoutSec?: number; notices?: string[] } = {},
319
+ options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
318
320
  ): AgentToolResult<BashToolDetails> {
319
321
  const details: BashToolDetails = {
320
322
  timeoutSeconds: timeoutSec,
@@ -352,7 +354,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
352
354
  timeoutMs: number;
353
355
  timeoutSec: number;
354
356
  requestedTimeoutSec?: number;
355
- timeoutClampNotice?: string;
357
+ notices?: readonly string[];
356
358
 
357
359
  resolvedEnv?: Record<string, string>;
358
360
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
@@ -392,7 +394,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
392
394
  });
393
395
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
394
396
  requestedTimeoutSec: options.requestedTimeoutSec,
395
- notices: [options.timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
397
+ notices: options.notices ?? [],
396
398
  });
397
399
  const finalText = this.#extractTextResult(finalResult);
398
400
  latestText = finalText;
@@ -483,6 +485,18 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
483
485
  let command = rawCommand;
484
486
  const env = normalizeBashEnv(rawEnv);
485
487
 
488
+ // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
489
+ // `2>&1`). The helper is single-line only and refuses anything that could
490
+ // change semantics.
491
+ let bashFixups: string[] = [];
492
+ if (this.session.settings.get("bash.stripTrailingHeadTail")) {
493
+ const fixup = applyBashFixups(command);
494
+ if (fixup.stripped.length > 0) {
495
+ command = fixup.command;
496
+ bashFixups = fixup.stripped;
497
+ }
498
+ }
499
+
486
500
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
487
501
  // Constrained to a single line so a `&&` that sits on a later line of a multiline
488
502
  // script can't pull the entire script into the "cwd" capture.
@@ -558,7 +572,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
558
572
  const requestedTimeoutSec = rawTimeout;
559
573
  const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
560
574
  const timeoutMs = timeoutSec * 1000;
575
+ const pendingNotices: string[] = [];
561
576
  const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
577
+ if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
578
+ const bashFixupNotice = this.#bashFixupNoticeEmitted ? undefined : formatBashFixupNotice(bashFixups);
579
+ if (bashFixupNotice) {
580
+ pendingNotices.push(bashFixupNotice);
581
+ this.#bashFixupNoticeEmitted = true;
582
+ }
562
583
 
563
584
  if (asyncRequested) {
564
585
  if (!AsyncJobManager.instance()) {
@@ -570,7 +591,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
570
591
  timeoutMs,
571
592
  timeoutSec,
572
593
  requestedTimeoutSec,
573
- timeoutClampNotice,
594
+ notices: pendingNotices,
574
595
 
575
596
  resolvedEnv,
576
597
  onUpdate,
@@ -578,7 +599,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
578
599
  });
579
600
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
580
601
  requestedTimeoutSec,
581
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
602
+ notices: pendingNotices,
582
603
  });
583
604
  }
584
605
 
@@ -592,7 +613,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
592
613
  timeoutMs,
593
614
  timeoutSec,
594
615
  requestedTimeoutSec,
595
- timeoutClampNotice,
616
+ notices: pendingNotices,
596
617
 
597
618
  resolvedEnv,
598
619
  onUpdate,
@@ -601,7 +622,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
601
622
  if (startBackgrounded) {
602
623
  return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
603
624
  requestedTimeoutSec,
604
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
625
+ notices: pendingNotices,
605
626
  });
606
627
  }
607
628
  const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
@@ -621,7 +642,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
621
642
  job.setBackgrounded(true);
622
643
  return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
623
644
  requestedTimeoutSec,
624
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
645
+ notices: pendingNotices,
625
646
  });
626
647
  }
627
648
 
@@ -722,7 +743,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
722
743
  };
723
744
  return this.#buildCompletedResult(timedOutResult, timeoutSec, {
724
745
  requestedTimeoutSec,
725
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
746
+ notices: pendingNotices,
726
747
  terminalId: handle.terminalId,
727
748
  });
728
749
  }
@@ -778,7 +799,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
778
799
 
779
800
  const bridgeNotices: string[] = [];
780
801
  if (finalOutput.truncated) bridgeNotices.push("(output truncated)");
781
- if (timeoutClampNotice) bridgeNotices.push(timeoutClampNotice);
802
+ for (const notice of pendingNotices) bridgeNotices.push(notice);
782
803
 
783
804
  return this.#buildCompletedResult(bridgeResult, timeoutSec, {
784
805
  requestedTimeoutSec,
@@ -833,7 +854,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
833
854
  }
834
855
  return this.#buildCompletedResult(result, timeoutSec, {
835
856
  requestedTimeoutSec,
836
- notices: [timeoutClampNotice].filter((notice): notice is string => Boolean(notice)),
857
+ notices: pendingNotices,
837
858
  });
838
859
  }
839
860
  }
@@ -960,8 +981,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
960
981
  const expanded = renderContext?.expanded ?? options.expanded;
961
982
  const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
962
983
 
963
- // Get output from context (preferred) or fall back to result content
964
- const output = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
984
+ // Get output from context (preferred) or fall back to result content.
985
+ // Strip the LLM-facing notice appended by wrappedExecute so we don't
986
+ // double-print it alongside the styled warning line below.
987
+ const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
988
+ const output = stripOutputNotice(rawOutput, details?.meta);
965
989
  const displayOutput = output.trimEnd();
966
990
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
967
991
 
@@ -11,7 +11,7 @@ import type { RenderResultOptions } from "../../extensibility/custom-tools/types
11
11
  import type { Theme } from "../../modes/theme/theme";
12
12
  import { Hasher, renderCodeCell, renderStatusLine } from "../../tui";
13
13
  import type { BrowserToolDetails } from "../browser";
14
- import { formatStyledTruncationWarning } from "../output-meta";
14
+ import { formatStyledTruncationWarning, stripOutputNotice } from "../output-meta";
15
15
  import { replaceTabs, shortenPath } from "../render-utils";
16
16
 
17
17
  const BROWSER_DEFAULT_PREVIEW_LINES = 10;
@@ -195,7 +195,7 @@ export const browserToolRenderer = {
195
195
  const details = result.details;
196
196
  const action = details?.action ?? argsObj.action;
197
197
  const isError = result.isError === true;
198
- const output = extractTextOutput(result.content);
198
+ const output = stripOutputNotice(extractTextOutput(result.content), details?.meta);
199
199
 
200
200
  if (action === "run") {
201
201
  let component = renderRunCell(argsObj, details, options, output, isError, theme);
package/src/tools/eval.ts CHANGED
@@ -16,7 +16,12 @@ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
16
16
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
17
17
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
18
18
  import { resolveEvalBackends, type ToolSession } from ".";
19
- import { formatStyledTruncationWarning, resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
19
+ import {
20
+ formatStyledTruncationWarning,
21
+ resolveOutputMaxColumns,
22
+ resolveOutputSinkHeadBytes,
23
+ stripOutputNotice,
24
+ } from "./output-meta";
20
25
  import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
21
26
  import { ToolAbortError, ToolError } from "./tool-errors";
22
27
  import { toolResult } from "./tool-result";
@@ -922,8 +927,11 @@ export const evalToolRenderer = {
922
927
  ): Component {
923
928
  const details = result.details;
924
929
 
925
- const output =
930
+ const rawOutput =
926
931
  options.renderContext?.output ?? (result.content?.find(c => c.type === "text")?.text ?? "").trimEnd();
932
+ // Strip the LLM-facing notice (appended by wrappedExecute) before display;
933
+ // the styled `warningLine` below carries the same text in ⟨…⟩ form.
934
+ const output = stripOutputNotice(rawOutput, details?.meta).trimEnd();
927
935
 
928
936
  const jsonOutputs = details?.jsonOutputs ?? [];
929
937
  const jsonLines = jsonOutputs.flatMap((value, index) => {
package/src/tools/gh.ts CHANGED
@@ -1774,6 +1774,39 @@ export async function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSign
1774
1774
  return untilAborted(signal, pending);
1775
1775
  }
1776
1776
 
1777
+ /**
1778
+ * Matches search-query qualifiers that already scope to a repository, org, or
1779
+ * user. When present, callers should avoid layering a default `repo:<current>`
1780
+ * on top — the user has already expressed an explicit scope.
1781
+ *
1782
+ * Only the leading `repo:`/`org:`/`user:`/`owner:` token is treated as a
1783
+ * scope marker; arbitrary substrings (e.g. inside quoted text) are ignored.
1784
+ */
1785
+ const REPO_SCOPE_QUALIFIER_PATTERN = /(?:^|\s)-?(?:repo|org|user|owner):\S/i;
1786
+
1787
+ /**
1788
+ * Resolve the effective `repo:` scope for a search op. Returns the explicit
1789
+ * `repo` when set, `undefined` when the query already carries a scoping
1790
+ * qualifier, and otherwise the current checkout's `owner/repo` via
1791
+ * `resolveDefaultRepoMemoized`. Resolution failures (no git/gh context, no
1792
+ * configured remote) silently fall back to `undefined` so the search proceeds
1793
+ * across all of GitHub instead of throwing.
1794
+ */
1795
+ async function resolveSearchRepoScope(
1796
+ cwd: string,
1797
+ repo: string | undefined,
1798
+ query: string | undefined,
1799
+ signal: AbortSignal | undefined,
1800
+ ): Promise<string | undefined> {
1801
+ if (repo) return repo;
1802
+ if (query && REPO_SCOPE_QUALIFIER_PATTERN.test(query)) return undefined;
1803
+ try {
1804
+ return await resolveDefaultRepoMemoized(cwd, signal);
1805
+ } catch {
1806
+ return undefined;
1807
+ }
1808
+ }
1809
+
1777
1810
  async function resolveGitHubBranchHead(
1778
1811
  cwd: string,
1779
1812
  repo: string,
@@ -3267,11 +3300,11 @@ async function executeSearchIssues(
3267
3300
  params: GithubInput,
3268
3301
  signal: AbortSignal | undefined,
3269
3302
  ): Promise<AgentToolResult<GhToolDetails>> {
3270
- const repo = normalizeOptionalString(params.repo);
3271
3303
  const limit = resolveSearchLimit(params.limit);
3272
3304
  const dateField = resolveSearchDateField("issues", params.dateField);
3273
3305
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
3274
3306
  const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3307
+ const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), displayQuery, signal);
3275
3308
  const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:issue"]);
3276
3309
  const args = buildGhApiSearchArgs("issues", apiQuery, limit);
3277
3310
 
@@ -3285,11 +3318,11 @@ async function executeSearchPrs(
3285
3318
  params: GithubInput,
3286
3319
  signal: AbortSignal | undefined,
3287
3320
  ): Promise<AgentToolResult<GhToolDetails>> {
3288
- const repo = normalizeOptionalString(params.repo);
3289
3321
  const limit = resolveSearchLimit(params.limit);
3290
3322
  const dateField = resolveSearchDateField("prs", params.dateField);
3291
3323
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
3292
3324
  const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3325
+ const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), displayQuery, signal);
3293
3326
  const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined, "is:pr"]);
3294
3327
  const args = buildGhApiSearchArgs("issues", apiQuery, limit);
3295
3328
 
@@ -3307,8 +3340,8 @@ async function executeSearchCode(
3307
3340
  if (params.since !== undefined || params.until !== undefined) {
3308
3341
  throw new ToolError("search_code does not support since/until; GitHub code search has no date qualifier.");
3309
3342
  }
3310
- const repo = normalizeOptionalString(params.repo);
3311
3343
  const limit = resolveSearchLimit(params.limit);
3344
+ const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), query, signal);
3312
3345
  const apiQuery = composeSearchQuery([query, repo ? `repo:${repo}` : undefined]);
3313
3346
  const args = buildGhApiSearchArgs("code", apiQuery, limit, ["Accept: application/vnd.github.text-match+json"]);
3314
3347
 
@@ -3322,11 +3355,11 @@ async function executeSearchCommits(
3322
3355
  params: GithubInput,
3323
3356
  signal: AbortSignal | undefined,
3324
3357
  ): Promise<AgentToolResult<GhToolDetails>> {
3325
- const repo = normalizeOptionalString(params.repo);
3326
3358
  const limit = resolveSearchLimit(params.limit);
3327
3359
  const dateField = resolveSearchDateField("commits", params.dateField);
3328
3360
  const dateQualifier = buildSearchDateQualifier(dateField, params.since, params.until);
3329
3361
  const displayQuery = composeSearchQuery([params.query, dateQualifier]);
3362
+ const repo = await resolveSearchRepoScope(session.cwd, normalizeOptionalString(params.repo), displayQuery, signal);
3330
3363
  const apiQuery = composeSearchQuery([displayQuery, repo ? `repo:${repo}` : undefined]);
3331
3364
  const args = buildGhApiSearchArgs("commits", apiQuery, limit);
3332
3365
 
package/src/tools/job.ts CHANGED
@@ -362,6 +362,8 @@ const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
362
362
  const LABEL_MAX_WIDTH = 60;
363
363
  const PREVIEW_LINES_COLLAPSED = 1;
364
364
  const PREVIEW_LINES_EXPANDED = 4;
365
+ const LABEL_LINES_COLLAPSED = 1;
366
+ const LABEL_LINES_EXPANDED = 3;
365
367
  const PREVIEW_LINE_WIDTH = 80;
366
368
 
367
369
  function statusToIcon(status: JobSnapshot["status"]): ToolUIStatus {
@@ -488,14 +490,21 @@ export const jobToolRenderer = {
488
490
  );
489
491
  const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
490
492
  const idText = uiTheme.fg("muted", job.id);
491
- const label = truncateToWidth(
492
- replaceTabs(job.label || "(no label)"),
493
- LABEL_MAX_WIDTH,
494
- Ellipsis.Unicode,
495
- );
496
- const labelText = uiTheme.fg("toolOutput", label);
493
+ const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
494
+ const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
495
+ const visibleLabelLines = rawLabelLines
496
+ .slice(0, maxLabelLines)
497
+ .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
498
+ if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
499
+ const last = visibleLabelLines[visibleLabelLines.length - 1]!;
500
+ visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
501
+ }
497
502
  const durationText = uiTheme.fg("dim", formatDuration(job.durationMs));
498
- lines.push(`${icon} ${idText} ${typeBadge} ${labelText} ${durationText}`);
503
+ const headLabel = uiTheme.fg("toolOutput", visibleLabelLines[0] ?? "");
504
+ lines.push(`${icon} ${idText} ${typeBadge} ${headLabel} ${durationText}`);
505
+ for (let i = 1; i < visibleLabelLines.length; i++) {
506
+ lines.push(` ${uiTheme.fg("toolOutput", visibleLabelLines[i]!)}`);
507
+ }
499
508
 
500
509
  const preview = job.errorText?.trim() || job.resultText?.trim();
501
510
  if (preview) {
@@ -489,6 +489,32 @@ export function formatStyledTruncationWarning(meta: OutputMeta | undefined, them
489
489
  return theme.fg("warning", wrapBrackets(message, theme));
490
490
  }
491
491
 
492
+ /**
493
+ * Strip the trailing notice that {@link appendOutputNotice} bakes into the
494
+ * LLM-facing content body. Renderers should call this before printing
495
+ * `result.content` text in the TUI, because they emit a styled warning line of
496
+ * their own; without this, users see the same `[Showing lines …]` string twice
497
+ * (once verbatim from the body, once as the styled `⟨…⟩` warning).
498
+ *
499
+ * Safe to call eagerly: returns the input unchanged when no notice is present
500
+ * (e.g. during streaming, before {@link wrappedExecute} runs).
501
+ */
502
+ export function stripOutputNotice(text: string, meta: OutputMeta | undefined): string {
503
+ const notice = formatOutputNotice(meta);
504
+ if (!notice) return text;
505
+ // Trim trailing whitespace from `text` and from the notice itself so we
506
+ // match regardless of whether: (a) the caller already trimEnd()'d, (b)
507
+ // extra blank lines slipped in after the notice (diagnostics blocks add
508
+ // `\n\n` between sections, OutputSink may pad), or (c) neither. Returns
509
+ // the prefix before the notice so the caller can re-trim as needed.
510
+ const trimmedText = text.trimEnd();
511
+ const trimmedNotice = notice.trimEnd();
512
+ if (trimmedText.endsWith(trimmedNotice)) {
513
+ return trimmedText.slice(0, -trimmedNotice.length);
514
+ }
515
+ return text;
516
+ }
517
+
492
518
  // =============================================================================
493
519
  // Tool wrapper
494
520
  // =============================================================================