@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.
- package/CHANGELOG.md +35 -0
- package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-vt8ene9g.md} +35 -0
- package/dist/cli.js +4087 -7122
- package/dist/template-dys3vk5b.js +1671 -0
- package/dist/template-f8wx9vfn.css +1355 -0
- package/dist/template-qat058wr.html +55 -0
- package/dist/tool-views.generated-jdfmzwmn.js +35 -0
- package/dist/types/advisor/advise-tool.d.ts +0 -7
- package/dist/types/advisor/runtime.d.ts +0 -9
- package/dist/types/async/job-manager.d.ts +11 -0
- package/dist/types/cleanse/agent.d.ts +19 -0
- package/dist/types/cleanse/balance.d.ts +7 -0
- package/dist/types/cleanse/checkers.d.ts +13 -0
- package/dist/types/cleanse/index.d.ts +16 -0
- package/dist/types/cleanse/loop.d.ts +16 -0
- package/dist/types/cleanse/parsers.d.ts +13 -0
- package/dist/types/cleanse/progress.d.ts +14 -0
- package/dist/types/cleanse/types.d.ts +64 -0
- package/dist/types/commands/cleanse.d.ts +23 -0
- package/dist/types/export/html/index.d.ts +2 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
- package/dist/types/modes/interactive-mode.d.ts +4 -3
- package/dist/types/session/async-job-delivery.d.ts +8 -0
- package/dist/types/session/model-controls.d.ts +3 -3
- package/dist/types/task/isolation-ownership.d.ts +34 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
- package/dist/types/tools/browser/tab-worker.d.ts +1 -4
- package/dist/types/tui/output-block.d.ts +5 -5
- package/package.json +16 -12
- package/scripts/bundle-dist.ts +3 -1
- package/src/advisor/advise-tool.ts +0 -11
- package/src/advisor/runtime.ts +0 -12
- package/src/async/job-manager.ts +30 -7
- package/src/cleanse/agent.ts +226 -0
- package/src/cleanse/balance.ts +79 -0
- package/src/cleanse/checkers.ts +996 -0
- package/src/cleanse/index.ts +190 -0
- package/src/cleanse/loop.ts +51 -0
- package/src/cleanse/parsers.ts +726 -0
- package/src/cleanse/progress.ts +50 -0
- package/src/cleanse/prompts/assignment.md +47 -0
- package/src/cleanse/types.ts +72 -0
- package/src/cli/update-cli.ts +3 -0
- package/src/cli/usage-cli.ts +29 -4
- package/src/cli/worktree-cli.ts +28 -11
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +2 -1
- package/src/commands/cleanse.ts +45 -0
- package/src/discovery/claude-plugins.ts +144 -34
- package/src/export/html/index.ts +17 -10
- package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
- package/src/launch/broker.ts +14 -4
- package/src/modes/acp/acp-agent.ts +12 -9
- package/src/modes/acp/acp-event-mapper.ts +38 -4
- package/src/modes/components/settings-selector.ts +9 -1
- package/src/modes/interactive-mode.ts +36 -47
- package/src/modes/prompt-action-autocomplete.ts +5 -3
- package/src/prompts/goals/guided-goal-interview.md +41 -6
- package/src/prompts/system/vibe-mode-active.md +4 -1
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/goal.md +1 -1
- package/src/session/agent-session.ts +28 -4
- package/src/session/async-job-delivery.ts +8 -0
- package/src/session/model-controls.ts +4 -4
- package/src/session/session-advisors.ts +2 -7
- package/src/session/session-history-format.ts +31 -6
- package/src/slash-commands/builtin-registry.ts +5 -2
- package/src/task/isolation-ownership.ts +106 -0
- package/src/task/worktree.ts +8 -0
- package/src/tools/ask.ts +3 -3
- package/src/tools/ast-edit.ts +9 -2
- package/src/tools/browser/cmux/cmux-tab.ts +9 -14
- package/src/tools/browser/tab-worker.ts +12 -35
- package/src/tools/browser.ts +2 -2
- package/src/tools/gh-renderer.ts +3 -3
- package/src/tools/index.ts +10 -1
- package/src/tools/write.ts +35 -0
- package/src/tui/code-cell.ts +4 -4
- package/src/tui/output-block.ts +25 -8
- package/src/utils/shell-snapshot-fn-env.sh +5 -2
- package/src/web/search/render.ts +2 -2
- package/dist/types/goals/guided-setup.d.ts +0 -30
- package/src/goals/guided-setup.ts +0 -171
- 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 `→
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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: "
|
|
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
|
|
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
|
+
}
|
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
|
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
|
|
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];
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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<
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
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<
|
|
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<
|
|
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
|
-
|
|
1546
|
-
|
|
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 = !!
|
|
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
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
|
1596
|
+
return dest;
|
|
1620
1597
|
}
|
|
1621
1598
|
|
|
1622
1599
|
async #drag(from: DragTarget, to: DragTarget, signal: AbortSignal): Promise<void> {
|
package/src/tools/browser.ts
CHANGED
|
@@ -163,11 +163,11 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
|
|
|
163
163
|
},
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
|
-
caption: "
|
|
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
|
{
|
package/src/tools/gh-renderer.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
467
|
+
const innerWidth = outputBlockContentWidth(width || FALLBACK_WIDTH);
|
|
468
468
|
const sections = buildWatchSections(watch, uiTheme, options, innerWidth);
|
|
469
469
|
return {
|
|
470
470
|
header,
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/tools/write.ts
CHANGED
|
@@ -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;
|
package/src/tui/code-cell.ts
CHANGED
|
@@ -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
|
|
234
|
-
// `renderOutputBlock
|
|
235
|
-
const innerWidth = Math.max(20, width
|
|
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);
|
package/src/tui/output-block.ts
CHANGED
|
@@ -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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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(
|
|
57
|
-
|
|
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
|
|
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 =>
|
|
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
|
|
26
|
-
__omp_qbuf=${__omp_qbuf
|
|
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
|
}
|