@oh-my-pi/pi-coding-agent 17.1.7 → 17.1.8

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 (84) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-vt8ene9g.md} +35 -0
  3. package/dist/cli.js +4087 -7122
  4. package/dist/template-dys3vk5b.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +0 -9
  10. package/dist/types/async/job-manager.d.ts +11 -0
  11. package/dist/types/cleanse/agent.d.ts +19 -0
  12. package/dist/types/cleanse/balance.d.ts +7 -0
  13. package/dist/types/cleanse/checkers.d.ts +13 -0
  14. package/dist/types/cleanse/index.d.ts +16 -0
  15. package/dist/types/cleanse/loop.d.ts +16 -0
  16. package/dist/types/cleanse/parsers.d.ts +13 -0
  17. package/dist/types/cleanse/progress.d.ts +14 -0
  18. package/dist/types/cleanse/types.d.ts +64 -0
  19. package/dist/types/commands/cleanse.d.ts +23 -0
  20. package/dist/types/export/html/index.d.ts +2 -0
  21. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  22. package/dist/types/modes/interactive-mode.d.ts +4 -3
  23. package/dist/types/session/async-job-delivery.d.ts +8 -0
  24. package/dist/types/session/model-controls.d.ts +3 -3
  25. package/dist/types/task/isolation-ownership.d.ts +34 -0
  26. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  27. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  28. package/dist/types/tui/output-block.d.ts +5 -5
  29. package/package.json +16 -12
  30. package/scripts/bundle-dist.ts +3 -1
  31. package/src/advisor/advise-tool.ts +0 -11
  32. package/src/advisor/runtime.ts +0 -12
  33. package/src/async/job-manager.ts +30 -7
  34. package/src/cleanse/agent.ts +226 -0
  35. package/src/cleanse/balance.ts +79 -0
  36. package/src/cleanse/checkers.ts +996 -0
  37. package/src/cleanse/index.ts +190 -0
  38. package/src/cleanse/loop.ts +51 -0
  39. package/src/cleanse/parsers.ts +726 -0
  40. package/src/cleanse/progress.ts +50 -0
  41. package/src/cleanse/prompts/assignment.md +47 -0
  42. package/src/cleanse/types.ts +72 -0
  43. package/src/cli/update-cli.ts +3 -0
  44. package/src/cli/usage-cli.ts +29 -4
  45. package/src/cli/worktree-cli.ts +28 -11
  46. package/src/cli-commands.ts +1 -0
  47. package/src/cli.ts +2 -1
  48. package/src/commands/cleanse.ts +45 -0
  49. package/src/discovery/claude-plugins.ts +144 -34
  50. package/src/export/html/index.ts +17 -10
  51. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  52. package/src/launch/broker.ts +14 -4
  53. package/src/modes/acp/acp-agent.ts +12 -9
  54. package/src/modes/acp/acp-event-mapper.ts +38 -4
  55. package/src/modes/components/settings-selector.ts +9 -1
  56. package/src/modes/interactive-mode.ts +36 -47
  57. package/src/modes/prompt-action-autocomplete.ts +5 -3
  58. package/src/prompts/goals/guided-goal-interview.md +41 -6
  59. package/src/prompts/system/vibe-mode-active.md +4 -1
  60. package/src/prompts/tools/browser.md +1 -0
  61. package/src/prompts/tools/goal.md +1 -1
  62. package/src/session/agent-session.ts +28 -4
  63. package/src/session/async-job-delivery.ts +8 -0
  64. package/src/session/model-controls.ts +4 -4
  65. package/src/session/session-advisors.ts +2 -7
  66. package/src/session/session-history-format.ts +31 -6
  67. package/src/slash-commands/builtin-registry.ts +5 -2
  68. package/src/task/isolation-ownership.ts +106 -0
  69. package/src/task/worktree.ts +8 -0
  70. package/src/tools/ask.ts +3 -3
  71. package/src/tools/ast-edit.ts +9 -2
  72. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  73. package/src/tools/browser/tab-worker.ts +12 -35
  74. package/src/tools/browser.ts +2 -2
  75. package/src/tools/gh-renderer.ts +3 -3
  76. package/src/tools/index.ts +10 -1
  77. package/src/tools/write.ts +35 -0
  78. package/src/tui/code-cell.ts +4 -4
  79. package/src/tui/output-block.ts +25 -8
  80. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  81. package/src/web/search/render.ts +2 -2
  82. package/dist/types/goals/guided-setup.d.ts +0 -30
  83. package/src/goals/guided-setup.ts +0 -171
  84. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -218,7 +218,10 @@ function toolCallLine(
218
218
  return base;
219
219
  }
220
220
 
221
- /** One line for a user-initiated `!`/`$` execution. */
221
+ /** One line for a user-initiated `!`/`$` execution. Always attributed to the
222
+ * user: these roles never carry agent-run commands (the model's bash goes
223
+ * through `toolCall`), so the `user-` prefix makes provenance explicit for the
224
+ * advisor and history readers regardless of render mode. */
222
225
  function executionLine(
223
226
  kind: "bash" | "python",
224
227
  source: string,
@@ -231,7 +234,7 @@ function executionLine(
231
234
  : "ok";
232
235
  const lines = lineCount(msg.output);
233
236
  const sourcePreview = formatExecutionSourcePreview(source);
234
- return `→ ${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
237
+ return `→ user-${kind}! ${sourcePreview} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
235
238
  }
236
239
 
237
240
  /**
@@ -311,6 +314,18 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
311
314
  // every call repeats `**agent**:`). Cleared whenever a
312
315
  // non-role-labeled line is emitted so the next turn re-labels.
313
316
  let lastWatchedLabel: string | undefined;
317
+ // Emit a watched-mode role label, collapsing consecutive same-role turns
318
+ // under one label (matching the user/assistant paths). Used for the
319
+ // user-attributed `!`/`$` execution lines so the advisor never reads them
320
+ // as agent actions.
321
+ const pushWatchedRole = (label: string, body: string): void => {
322
+ if (lastWatchedLabel === label) {
323
+ lines.push(body, "");
324
+ } else {
325
+ lines.push(label, body, "");
326
+ lastWatchedLabel = label;
327
+ }
328
+ };
314
329
 
315
330
  for (const msg of typed) {
316
331
  switch (msg.role) {
@@ -372,15 +387,25 @@ export function formatSessionHistoryMarkdown(messages: unknown[], opts?: History
372
387
  case "bashExecution": {
373
388
  const bashMsg = msg as BashExecutionMessage;
374
389
  if (bashMsg.excludeFromContext) break;
375
- lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
376
- lastWatchedLabel = undefined;
390
+ const bashLine = executionLine("bash", bashMsg.command, bashMsg);
391
+ if (opts?.watchedRoles) {
392
+ pushWatchedRole("**user**:", bashLine);
393
+ } else {
394
+ lines.push(bashLine, "");
395
+ lastWatchedLabel = undefined;
396
+ }
377
397
  break;
378
398
  }
379
399
  case "pythonExecution": {
380
400
  const pythonMsg = msg as PythonExecutionMessage;
381
401
  if (pythonMsg.excludeFromContext) break;
382
- lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
383
- lastWatchedLabel = undefined;
402
+ const pythonLine = executionLine("python", pythonMsg.code, pythonMsg);
403
+ if (opts?.watchedRoles) {
404
+ pushWatchedRole("**user**:", pythonLine);
405
+ } else {
406
+ lines.push(pythonLine, "");
407
+ lastWatchedLabel = undefined;
408
+ }
384
409
  break;
385
410
  }
386
411
  case "custom":
@@ -468,12 +468,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
468
468
  },
469
469
  {
470
470
  name: "guided-goal",
471
- description: "Interview and refine a goal before enabling goal mode",
471
+ description: "Have the agent interview you in chat, then set up goal mode",
472
472
  inlineHint: "[rough objective]",
473
473
  allowArgs: true,
474
474
  handleTui: async (command, runtime) => {
475
- await runtime.ctx.handleGuidedGoalCommand(command.args || undefined);
475
+ // Clear the slash draft BEFORE the await: the handler blocks for the
476
+ // whole kickoff turn, and a post-await clear would wipe an answer the
477
+ // user starts typing while the first interview question streams.
476
478
  runtime.ctx.editor.setText("");
479
+ await runtime.ctx.handleGuidedGoalCommand(command.args || undefined);
477
480
  },
478
481
  },
479
482
  {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Ownership marker for task-isolation sandboxes under `~/.omp/wt/`.
3
+ *
4
+ * Each isolation base dir (`ensureIsolation` in {@link ./worktree}) holds a
5
+ * compact `m` mount plus this marker file naming the omp process that created
6
+ * it. `omp worktree clear` consults the marker so it can distinguish a live
7
+ * subagent's sandbox from a crashed run's leftover instead of deleting both.
8
+ */
9
+ import * as path from "node:path";
10
+ import { $ } from "bun";
11
+
12
+ /** Marker file written into a task-isolation base dir identifying its owner. */
13
+ export const ISOLATION_OWNER_FILE = ".omp-isolation-owner.json";
14
+
15
+ /** Recorded owner of a task-isolation sandbox. */
16
+ export interface IsolationOwner {
17
+ /** PID of the omp process that created and owns the sandbox. */
18
+ pid: number;
19
+ /** Task id the sandbox was materialised for. */
20
+ id: string;
21
+ /**
22
+ * Process-instance start-time token for {@link pid}, when the OS can report
23
+ * it. Distinguishes the owning process from an unrelated process that later
24
+ * inherits a recycled pid, so a crashed sandbox is never pinned live.
25
+ */
26
+ startToken?: string;
27
+ }
28
+
29
+ /**
30
+ * Boot-stable start-time token for `pid`, or `null` when the process is gone or
31
+ * the platform cannot report it. Read from the same source on write and
32
+ * validate so an exact string compare rejects a recycled pid.
33
+ *
34
+ * Linux reads `/proc/<pid>/stat` field 22 (start time in clock ticks since
35
+ * boot); other Unixes shell out to `ps -o lstart`. Platforms that report
36
+ * neither (e.g. Windows) yield `null`, degrading to a pid-only liveness check.
37
+ */
38
+ async function processStartToken(pid: number): Promise<string | null> {
39
+ if (process.platform === "linux") {
40
+ let stat: string;
41
+ try {
42
+ stat = await Bun.file(`/proc/${pid}/stat`).text();
43
+ } catch {
44
+ return null;
45
+ }
46
+ // The comm field (2) may embed spaces and parens, so parse the numeric
47
+ // fields after the final ')'. `starttime` is field 22 overall, i.e. the
48
+ // 20th token once `pid` and `(comm)` are dropped.
49
+ const commEnd = stat.lastIndexOf(")");
50
+ if (commEnd < 0) return null;
51
+ const starttime = stat.slice(commEnd + 2).split(" ")[19];
52
+ return starttime && starttime.length > 0 ? starttime : null;
53
+ }
54
+ const res = await $`ps -o lstart= -p ${pid}`.quiet().nothrow();
55
+ if (res.exitCode !== 0) return null;
56
+ const started = res.text().trim();
57
+ return started.length > 0 ? started : null;
58
+ }
59
+
60
+ /**
61
+ * Record the current process as owner of the sandbox rooted at `baseDir`.
62
+ *
63
+ * Written before the isolation backend materialises `m` so a concurrent
64
+ * `omp worktree clear` never sees an owner-less sandbox mid-creation.
65
+ */
66
+ export async function writeIsolationOwner(baseDir: string, id: string): Promise<void> {
67
+ const startToken = await processStartToken(process.pid);
68
+ const owner: IsolationOwner = { pid: process.pid, id, ...(startToken ? { startToken } : {}) };
69
+ await Bun.write(path.join(baseDir, ISOLATION_OWNER_FILE), JSON.stringify(owner));
70
+ }
71
+
72
+ /**
73
+ * Whether a live omp process still owns the sandbox at `baseDir`.
74
+ *
75
+ * A missing or malformed marker means no verifiable owner — a crashed run or a
76
+ * sandbox from before markers existed, both safe to reclaim. `process.kill(pid,
77
+ * 0)` can fail with `EPERM` even when the process is alive, so only an explicit
78
+ * `ESRCH` ("no such process") counts as dead; any other error is treated as
79
+ * alive to avoid deleting a sandbox that is actually in use. When the marker
80
+ * carries a {@link IsolationOwner.startToken}, a live pid whose current token no
81
+ * longer matches is a recycled pid — a different process — and counts as dead.
82
+ */
83
+ export async function hasLiveIsolationOwner(baseDir: string): Promise<boolean> {
84
+ let decoded: unknown;
85
+ try {
86
+ decoded = await Bun.file(path.join(baseDir, ISOLATION_OWNER_FILE)).json();
87
+ } catch {
88
+ return false;
89
+ }
90
+ if (typeof decoded !== "object" || decoded === null || !("pid" in decoded)) return false;
91
+ const pid = decoded.pid;
92
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
93
+ try {
94
+ process.kill(pid, 0);
95
+ } catch (err) {
96
+ if ((err as NodeJS.ErrnoException).code === "ESRCH") return false;
97
+ }
98
+ // The pid is live (or unknowable via EPERM). Reject a recycled pid: if the
99
+ // marker pinned the owner's start-time token, the process wearing that pid
100
+ // now must still present the same token.
101
+ if ("startToken" in decoded && typeof decoded.startToken === "string" && decoded.startToken.length > 0) {
102
+ const current = await processStartToken(pid);
103
+ if (current !== null && current !== decoded.startToken) return false;
104
+ }
105
+ return true;
106
+ }
@@ -6,6 +6,7 @@ import * as natives from "@oh-my-pi/pi-natives";
6
6
  import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
8
  import * as jj from "../utils/jj";
9
+ import { writeIsolationOwner } from "./isolation-ownership";
9
10
  import { mapWithConcurrencyLimit } from "./parallel";
10
11
 
11
12
  const { IsoBackendKind } = natives;
@@ -434,6 +435,13 @@ export async function ensureIsolation(
434
435
 
435
436
  for (const candidate of candidates) {
436
437
  await fs.rm(baseDir, { recursive: true, force: true });
438
+ // Claim ownership before the backend materialises `m`. Backends only
439
+ // create/replace `mergedDir` (and overlay upper/work), never the base
440
+ // dir, so the marker survives `isoStart` — and a concurrent
441
+ // `omp worktree clear` never sees this sandbox without a live owner,
442
+ // even while a large clone is still in progress.
443
+ await fs.mkdir(baseDir, { recursive: true });
444
+ await writeIsolationOwner(baseDir, id);
437
445
  try {
438
446
  await natives.isoStart(candidate, repoRoot, mergedDir);
439
447
  // Sever the isolation's git metadata from the source checkout. Copy
package/src/tools/ask.ts CHANGED
@@ -36,7 +36,7 @@ import type { ExtensionUISelectItem } from "../extensibility/extensions";
36
36
  import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
37
37
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
38
38
  import { vocalizer } from "../tts/vocalizer";
39
- import { framedBlock, renderStatusLine } from "../tui";
39
+ import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../tui";
40
40
  import type { ToolSession } from ".";
41
41
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
42
42
  import { ToolAbortError } from "./tool-errors";
@@ -1276,7 +1276,7 @@ export const askToolRenderer = {
1276
1276
  const mdTheme = getMarkdownTheme();
1277
1277
  const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
1278
1278
  const md = (text: string, width: number) =>
1279
- new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, width - 3 + 1));
1279
+ new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, outputBlockContentWidth(width) + 1));
1280
1280
 
1281
1281
  // Multi-part questions: one divider-labelled section per question.
1282
1282
  // Call args are untrusted (partially streamed or model-mangled) and a
@@ -1345,7 +1345,7 @@ export const askToolRenderer = {
1345
1345
  const mdTheme = getMarkdownTheme();
1346
1346
  const accentStyle = { color: (t: string) => uiTheme.fg("accent", t) };
1347
1347
  const md = (text: string, width: number) =>
1348
- new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, width - 3 + 1));
1348
+ new Markdown(text, 1, 0, mdTheme, accentStyle).render(Math.max(1, outputBlockContentWidth(width) + 1));
1349
1349
 
1350
1350
  if (!details) {
1351
1351
  const txt = result.content[0];
@@ -12,7 +12,14 @@ import { normalizeToLF } from "../edit/normalize";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import type { Theme } from "../modes/theme/theme";
14
14
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
15
- import { Ellipsis, fileHyperlink, framedBlock, renderStatusLine, truncateToWidth } from "../tui";
15
+ import {
16
+ Ellipsis,
17
+ fileHyperlink,
18
+ framedBlock,
19
+ outputBlockContentWidth,
20
+ renderStatusLine,
21
+ truncateToWidth,
22
+ } from "../tui";
16
23
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
17
24
  import type { ToolSession } from ".";
18
25
  import { truncateForPrompt } from "./approval";
@@ -695,7 +702,7 @@ export const astEditToolRenderer = {
695
702
  }
696
703
  return framedBlock(uiTheme, width => {
697
704
  const changeLines = buildChangeBody(changeGroups, Boolean(options.expanded), COLLAPSED_CHANGE_LIMIT, uiTheme);
698
- const innerWidth = Math.max(1, width - 3);
705
+ const innerWidth = outputBlockContentWidth(width);
699
706
  const bodyLines = [...changeLines, ...extraLines].map(l => truncateToWidth(l, innerWidth, Ellipsis.Omit));
700
707
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
701
708
  return {
@@ -37,7 +37,6 @@ import type { CmuxSocketClient } from "./socket-client";
37
37
  interface ScreenshotOptions {
38
38
  selector?: string;
39
39
  fullPage?: boolean;
40
- save?: string;
41
40
  silent?: boolean;
42
41
  encoding?: "base64" | "binary";
43
42
  }
@@ -487,7 +486,7 @@ export class CmuxTab {
487
486
  return content;
488
487
  }
489
488
 
490
- async screenshot(opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
489
+ async screenshot(opts: ScreenshotOptions = {}): Promise<string> {
491
490
  const context = this.#requireRunContext("tab.screenshot()");
492
491
  // The cmux daemon's `browser.screenshot` captures the surface viewport
493
492
  // only — it has no element-clip or full-page mode, and Bun.Image cannot
@@ -518,20 +517,16 @@ export class CmuxTab {
518
517
  excludeWebP: context.session.excludeWebP,
519
518
  },
520
519
  );
521
- const explicitPath = opts.save ? resolveToCwd(opts.save, context.session.cwd) : undefined;
522
- const returnedPath = typeof result.path === "string" && result.path.length > 0 ? result.path : undefined;
523
- const saveFullRes = !!(explicitPath || context.session.browserScreenshotDir || returnedPath);
520
+ const saveFullRes = !!context.session.browserScreenshotDir;
524
521
  const savedBuffer = saveFullRes ? buffer : Buffer.from(resized.buffer);
525
522
  const savedMimeType = saveFullRes ? captureMime : resized.mimeType;
526
523
  const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
527
- const dest =
528
- explicitPath ??
529
- (context.session.browserScreenshotDir
530
- ? path.join(
531
- context.session.browserScreenshotDir,
532
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
533
- )
534
- : (returnedPath ?? path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`)));
524
+ const dest = context.session.browserScreenshotDir
525
+ ? path.join(
526
+ context.session.browserScreenshotDir,
527
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
528
+ )
529
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`);
535
530
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
536
531
  await Bun.write(dest, savedBuffer);
537
532
  const info: ScreenshotResult = {
@@ -556,7 +551,7 @@ export class CmuxTab {
556
551
  context.output.push({ type: "text", text: lines.join("\n") });
557
552
  context.output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
558
553
  }
559
- return info;
554
+ return dest;
560
555
  }
561
556
 
562
557
  async waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string> {
@@ -11,7 +11,6 @@ import type {
11
11
  ElementHandle,
12
12
  ElementScreenshotOptions,
13
13
  HTTPResponse,
14
- ImageFormat,
15
14
  KeyInput,
16
15
  Page,
17
16
  SerializedAXNode,
@@ -217,7 +216,6 @@ export function resolveWaitTimeout(cellTimeoutMs: number, explicit?: number): nu
217
216
  interface ScreenshotOptions {
218
217
  selector?: string;
219
218
  fullPage?: boolean;
220
- save?: string;
221
219
  silent?: boolean;
222
220
  }
223
221
 
@@ -233,7 +231,7 @@ interface TabApi {
233
231
  ): Promise<void>;
234
232
  observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
235
233
  ariaSnapshot(selector?: string, opts?: AriaSnapshotOptions): Promise<string>;
236
- screenshot(opts?: ScreenshotOptions): Promise<ScreenshotResult>;
234
+ screenshot(opts?: ScreenshotOptions): Promise<string>;
237
235
  extract(format?: ReadableFormat): Promise<string>;
238
236
  click(selector: string): Promise<void>;
239
237
  type(selector: string, text: string): Promise<void>;
@@ -714,19 +712,6 @@ export function describeScreenshot(opts?: ScreenshotOptions): string {
714
712
  return "tab.screenshot()";
715
713
  }
716
714
 
717
- /** Map an explicit save path's extension to a puppeteer capture format (default png). */
718
- export function imageFormatForPath(filePath: string): ImageFormat {
719
- switch (path.extname(filePath).toLowerCase()) {
720
- case ".webp":
721
- return "webp";
722
- case ".jpg":
723
- case ".jpeg":
724
- return "jpeg";
725
- default:
726
- return "png";
727
- }
728
- }
729
-
730
715
  /** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */
731
716
  export function describeInflight(inflight: Map<number, InflightOp>): string {
732
717
  const now = Date.now();
@@ -1532,7 +1517,7 @@ export class WorkerCore {
1532
1517
  screenshots: ScreenshotResult[],
1533
1518
  signal: AbortSignal | undefined,
1534
1519
  opts: ScreenshotOptions = {},
1535
- ): Promise<ScreenshotResult> {
1520
+ ): Promise<string> {
1536
1521
  const page = this.#requirePage();
1537
1522
  // Multiple tabs can share one Chromium (sibling headless tabs on a shared
1538
1523
  // endpoint, cdp/app attach). CDP `Page.captureScreenshot` reads the
@@ -1542,12 +1527,8 @@ export class WorkerCore {
1542
1527
  // already-active or freshly-closed target never fails the capture.
1543
1528
  await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
1544
1529
  const fullPage = opts.selector ? false : (opts.fullPage ?? false);
1545
- // An explicit save path picks the full-res capture format: puppeteer encodes
1546
- // png/jpeg/webp natively, so `save: "shot.webp"` gets real WebP bytes instead
1547
- // of PNG bytes hiding behind a .webp name. Unknown/missing extensions stay PNG.
1548
- const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
1549
- const captureType = explicitPath ? imageFormatForPath(explicitPath) : "png";
1550
- const captureMime = `image/${captureType}` as const;
1530
+ const captureType = "png";
1531
+ const captureMime = "image/png" as const;
1551
1532
  let buffer: Buffer;
1552
1533
  if (opts.selector) {
1553
1534
  const handle =
@@ -1581,20 +1562,16 @@ export class WorkerCore {
1581
1562
  { type: "image", data: buffer.toBase64(), mimeType: captureMime },
1582
1563
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70, excludeWebP: session.excludeWebP },
1583
1564
  );
1584
- const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
1565
+ const saveFullRes = !!session.browserScreenshotDir;
1585
1566
  const savedBuffer = saveFullRes ? buffer : resized.buffer;
1586
1567
  const savedMimeType = saveFullRes ? captureMime : resized.mimeType;
1587
- // Names must match the bytes we actually write: full-res follows the capture
1588
- // format, the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
1589
1568
  const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
1590
- const dest =
1591
- explicitPath ??
1592
- (session.browserScreenshotDir
1593
- ? path.join(
1594
- session.browserScreenshotDir,
1595
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
1596
- )
1597
- : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`));
1569
+ const dest = session.browserScreenshotDir
1570
+ ? path.join(
1571
+ session.browserScreenshotDir,
1572
+ `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
1573
+ )
1574
+ : path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`);
1598
1575
  await fs.promises.mkdir(path.dirname(dest), { recursive: true });
1599
1576
  await Bun.write(dest, savedBuffer);
1600
1577
  const info: ScreenshotResult = {
@@ -1616,7 +1593,7 @@ export class WorkerCore {
1616
1593
  output.push({ type: "text", text: lines.join("\n") });
1617
1594
  output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
1618
1595
  }
1619
- return info;
1596
+ return dest;
1620
1597
  }
1621
1598
 
1622
1599
  async #drag(from: DragTarget, to: DragTarget, signal: AbortSignal): Promise<void> {
@@ -163,11 +163,11 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
163
163
  },
164
164
  },
165
165
  {
166
- caption: "Screenshot to look at the page no save path",
166
+ caption: "Capture a screenshot and return its saved path",
167
167
  call: {
168
168
  action: "run",
169
169
  name: "docs",
170
- code: "await tab.screenshot();",
170
+ code: "return await tab.screenshot();",
171
171
  },
172
172
  },
173
173
  {
@@ -1,7 +1,7 @@
1
1
  import { type Component, padding, Text, visibleWidth } from "@oh-my-pi/pi-tui";
2
2
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
3
3
  import type { Theme, ThemeColor } from "../modes/theme/theme";
4
- import { framedBlock, renderStatusLine } from "../tui";
4
+ import { framedBlock, outputBlockContentWidth, renderStatusLine } from "../tui";
5
5
  import type {
6
6
  GhRunWatchFailedLogDetails,
7
7
  GhRunWatchJobDetails,
@@ -364,7 +364,7 @@ function renderFallbackComponent(
364
364
  }
365
365
 
366
366
  return framedBlock(theme, width => {
367
- const lineWidth = Math.max(1, (width || FALLBACK_WIDTH) - 3);
367
+ const lineWidth = outputBlockContentWidth(width || FALLBACK_WIDTH);
368
368
  const expanded = options.expanded;
369
369
  const limit = expanded ? allLines.length : Math.min(allLines.length, PREVIEW_LIMITS.OUTPUT_EXPANDED);
370
370
  const visible = allLines.slice(0, limit);
@@ -464,7 +464,7 @@ export const githubToolRenderer = {
464
464
  uiTheme,
465
465
  );
466
466
  return framedBlock(uiTheme, width => {
467
- const innerWidth = Math.max(1, (width || FALLBACK_WIDTH) - 3);
467
+ const innerWidth = outputBlockContentWidth(width || FALLBACK_WIDTH);
468
468
  const sections = buildWatchSections(watch, uiTheme, options, innerWidth);
469
469
  return {
470
470
  header,
@@ -548,7 +548,16 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
548
548
  }
549
549
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
550
550
  const isToolAllowed = (name: string) => {
551
- if (name === "goal") return goalEnabled && goalModeActive;
551
+ // Never in the default set. Explicitly activatable while goal.enabled and
552
+ // no goal record exists yet — /guided-goal enables it so the agent can
553
+ // finish the interview with `goal create`, which turns goal mode on. Once
554
+ // a goal record exists, only an enabled goal keeps the tool: a completed
555
+ // (exiting) or paused goal must stop advertising it on the next rebuild.
556
+ if (name === "goal") {
557
+ if (!goalEnabled || restrictToolNames) return false;
558
+ const goalState = session.getGoalModeState?.();
559
+ return goalState === undefined || goalState.enabled === true || goalState.goal.status === "dropped";
560
+ }
552
561
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
553
562
  if (name === "bash") return session.settings.get("bash.enabled");
554
563
  if (name === "eval") return allowEval;
@@ -160,7 +160,42 @@ function throwReadSelectorMisfire(target: string, sel: string): never {
160
160
  );
161
161
  }
162
162
 
163
+ /**
164
+ * Recognize a semicolon-joined list of read-tool selectors mis-dispatched as a
165
+ * single write target — the multi-file read expression the scout emitted in
166
+ * issue #6809 (`a.txt:1-2;b/c.txt:3-4`). Every `;`-segment must be non-empty and
167
+ * carry its own read selector ({@link splitPathAndSel} peels a `:N-M`, `:raw`,
168
+ * or `:conflicts` tail). No real call targets such a list: `read` accepts one
169
+ * path, `write` writes one file. Unlike {@link readSelectorForEmptyWrite} this
170
+ * fires regardless of `content` — the non-empty-content escape hatch exists for
171
+ * a lone selector-shaped *filename*, never a `;`-list, and honoring it here
172
+ * silently creates a nested directory tree (`a.txt:1-2;b/`) in the workspace.
173
+ * The caller still probes the literal target first, so an existing POSIX file
174
+ * by that exact name stays writable (same escape as the single-selector guard).
175
+ */
176
+ function readSelectorListMisfire(target: string): number | undefined {
177
+ if (!target.includes(";")) return undefined;
178
+ const segments = target.split(";");
179
+ if (segments.length < 2) return undefined;
180
+ for (const segment of segments) {
181
+ const trimmed = segment.trim();
182
+ if (trimmed.length === 0 || splitPathAndSel(trimmed).sel === undefined) return undefined;
183
+ }
184
+ return segments.length;
185
+ }
186
+
187
+ function throwReadSelectorListMisfire(target: string, count: number): never {
188
+ throw new ToolError(
189
+ `write target '${target}' is a semicolon-joined list of ${count} read-tool selectors, not a filesystem path — refusing to create it. ` +
190
+ `write creates a single file; issue one read() per path to read these ranges (e.g. read({ path: "<one path>:<range>" })).`,
191
+ );
192
+ }
193
+
163
194
  async function assertNotReadSelectorMisfire(target: string, content: string, cwd: string): Promise<void> {
195
+ const listCount = readSelectorListMisfire(target);
196
+ if (listCount !== undefined && (await probeLiteralPathExists(target, cwd)) === "missing") {
197
+ throwReadSelectorListMisfire(target, listCount);
198
+ }
164
199
  const sel = readSelectorForEmptyWrite(target, content);
165
200
  if (sel === undefined) return;
166
201
  if ((await probeLiteralPathExists(target, cwd)) !== "missing") return;
@@ -10,7 +10,7 @@ import {
10
10
  formatStatusIcon,
11
11
  replaceTabs,
12
12
  } from "../tools/render-utils";
13
- import { renderOutputBlock } from "./output-block";
13
+ import { outputBlockContentWidth, renderOutputBlock } from "./output-block";
14
14
  import type { State } from "./types";
15
15
 
16
16
  export interface CodeCellOptions {
@@ -230,9 +230,9 @@ export function renderMarkdownCell(options: MarkdownCellOptions, theme: Theme):
230
230
  const { title, meta } = formatHeader(codeOptions, theme);
231
231
  const state = getState(options.status);
232
232
 
233
- // Markdown component manages its own wrapping at the inner content width.
234
- // `renderOutputBlock` adds a `│ ` prefix + `│` suffix → 3 visible columns.
235
- const innerWidth = Math.max(20, width - 3);
233
+ // Markdown component manages its own wrapping at the same inner width as
234
+ // `renderOutputBlock`, so collapsed row caps are applied after final wrapping.
235
+ const innerWidth = Math.max(20, outputBlockContentWidth(width));
236
236
  const allLines = content.trim() ? new Markdown(content, 0, 0, getMarkdownTheme()).render(innerWidth) : [];
237
237
  const maxContentLines = expanded ? allLines.length : Math.min(allLines.length, contentMaxLines);
238
238
  const contentLines = allLines.slice(0, maxContentLines);
@@ -17,6 +17,7 @@ export interface OutputBlockOptions {
17
17
  width: number;
18
18
  applyBg?: boolean;
19
19
  contentPaddingLeft?: number;
20
+ contentPaddingRight?: number;
20
21
  /** Override the state-derived border color. Used for muted "legacy" tool
21
22
  * frames that should not visually compete with framed-output tools. */
22
23
  borderColor?: ThemeColor;
@@ -48,13 +49,18 @@ function normalizeContentPaddingLeft(value: number | undefined): number {
48
49
 
49
50
  /**
50
51
  * Inner content width that {@link renderOutputBlock} wraps its body to, for a
51
- * given outer `width`: both vertical borders (1 cell each) plus the left
52
- * content padding. Renderers that size a tail window MUST budget visual rows
53
- * against this, not the outer width otherwise the block re-wraps their lines
54
- * into more rows than they counted and the box overflows its intended height.
52
+ * given outer `width`: both vertical borders plus symmetric content padding.
53
+ * An explicit left padding of zero keeps legacy flush blocks flush on both
54
+ * sides unless a right padding is provided separately.
55
55
  */
56
- export function outputBlockContentWidth(width: number, contentPaddingLeft?: number): number {
57
- return Math.max(1, width - 2 - normalizeContentPaddingLeft(contentPaddingLeft));
56
+ export function outputBlockContentWidth(
57
+ width: number,
58
+ contentPaddingLeft?: number,
59
+ contentPaddingRight?: number,
60
+ ): number {
61
+ const left = normalizeContentPaddingLeft(contentPaddingLeft);
62
+ const right = normalizeContentPaddingLeft(contentPaddingRight ?? left);
63
+ return Math.max(1, width - 2 - left - right);
58
64
  }
59
65
 
60
66
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
@@ -88,8 +94,13 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
88
94
  })();
89
95
 
90
96
  const contentPaddingLeft = normalizeContentPaddingLeft(options.contentPaddingLeft);
91
- const contentWidth = Math.max(0, lineWidth - visibleWidth(v) - contentPaddingLeft - visibleWidth(v));
97
+ const contentPaddingRight = normalizeContentPaddingLeft(options.contentPaddingRight ?? contentPaddingLeft);
98
+ const contentWidth = Math.max(
99
+ 0,
100
+ lineWidth - visibleWidth(v) - contentPaddingLeft - contentPaddingRight - visibleWidth(v),
101
+ );
92
102
  const contentLeftPadding = contentPaddingLeft > 0 ? padding(contentPaddingLeft) : "";
103
+ const contentRightPadding = contentPaddingRight > 0 ? padding(contentPaddingRight) : "";
93
104
 
94
105
  // ── Layout pass: collect row descriptors before emitting the bordered lines. ──
95
106
  const rows: BlockRow[] = [];
@@ -170,7 +181,8 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
170
181
  return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
171
182
  };
172
183
 
173
- const renderContent = (inner: string): string => `${border(v)}${contentLeftPadding}${inner}${border(v)}`;
184
+ const renderContent = (inner: string): string =>
185
+ `${border(v)}${contentLeftPadding}${inner}${contentRightPadding}${border(v)}`;
174
186
 
175
187
  const lines: string[] = [];
176
188
  for (let r = 0; r < H; r++) {
@@ -215,6 +227,11 @@ export class CachedOutputBlock {
215
227
  const h = new Hasher();
216
228
  h.u32(options.width);
217
229
  h.u32(normalizeContentPaddingLeft(options.contentPaddingLeft));
230
+ h.u32(
231
+ normalizeContentPaddingLeft(
232
+ options.contentPaddingRight ?? normalizeContentPaddingLeft(options.contentPaddingLeft),
233
+ ),
234
+ );
218
235
  h.optional(options.header);
219
236
  h.optional(options.headerMeta);
220
237
  h.optional(options.state);
@@ -1,3 +1,5 @@
1
+ #!/bin/sh
2
+
1
3
  # Helpers inlined into `generateSnapshotScript` (shell-snapshot.ts).
2
4
  #
3
5
  # Activation idioms like `mise activate` install a shell function whose body
@@ -22,8 +24,8 @@ __omp_sq_quote() {
22
24
  __omp_qout=
23
25
  __omp_sq=\'
24
26
  while case "$__omp_qbuf" in *$__omp_sq*) true ;; *) false ;; esac; do
25
- __omp_qout=$__omp_qout${__omp_qbuf%%$__omp_sq*}"'\\''"
26
- __omp_qbuf=${__omp_qbuf#*$__omp_sq}
27
+ __omp_qout=$__omp_qout${__omp_qbuf%%"$__omp_sq"*}"'\\''"
28
+ __omp_qbuf=${__omp_qbuf#*"$__omp_sq"}
27
29
  done
28
30
  __omp_qout=$__omp_qout$__omp_qbuf
29
31
  printf "'%s'" "$__omp_qout"
@@ -45,6 +47,7 @@ __omp_emit_export_for() {
45
47
  *TOKEN*|*SECRET*|*PASSWORD*|*PASSWD*|*API_KEY*|*PRIVATE_KEY*|*ACCESS_KEY*|*CREDENTIAL*|*SESSION_KEY*) return ;;
46
48
  esac
47
49
  eval "[ \"\${$1+x}\" = x ]" 2>/dev/null || return
50
+ __omp_xv=
48
51
  eval "__omp_xv=\"\${$1}\"" 2>/dev/null || return
49
52
  printf 'export %s=%s\n' "$1" "$(__omp_sq_quote "$__omp_xv")"
50
53
  }