@oh-my-pi/pi-coding-agent 16.5.1 → 16.5.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.
- package/CHANGELOG.md +65 -0
- package/dist/cli.js +3442 -3408
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
- package/dist/types/eval/backend.d.ts +3 -3
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
- package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
- package/dist/types/goals/guided-setup.d.ts +12 -0
- package/dist/types/internal-urls/history-protocol.d.ts +3 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -0
- package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
- package/dist/types/modes/components/agent-hub.d.ts +10 -0
- package/dist/types/modes/components/dynamic-border.d.ts +5 -3
- package/dist/types/modes/components/login-dialog.d.ts +2 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +0 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/session/messages.d.ts +15 -0
- package/dist/types/tools/grep.d.ts +0 -2
- package/dist/types/tools/read.d.ts +0 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +136 -49
- package/src/advisor/runtime.ts +16 -34
- package/src/autoresearch/dashboard.ts +2 -2
- package/src/cli/config-cli.ts +15 -3
- package/src/config/settings-schema.ts +10 -0
- package/src/cursor.ts +2 -0
- package/src/discovery/claude-plugins.ts +9 -3
- package/src/discovery/omp-plugins.ts +6 -2
- package/src/discovery/substitute-plugin-root.ts +32 -0
- package/src/eval/__tests__/prelude-agent.test.ts +20 -0
- package/src/eval/backend.ts +3 -3
- package/src/eval/py/__tests__/prelude.test.ts +72 -0
- package/src/eval/py/prelude.py +28 -1
- package/src/exec/bash-executor.ts +30 -43
- package/src/extensibility/extensions/wrapper.ts +18 -18
- package/src/extensibility/plugins/bun-git-cache.ts +91 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
- package/src/extensibility/plugins/manager.ts +7 -7
- package/src/goals/guided-setup.ts +29 -1
- package/src/internal-urls/history-protocol.ts +95 -15
- package/src/internal-urls/registry-helpers.ts +50 -1
- package/src/launch/broker.ts +38 -25
- package/src/mcp/oauth-discovery.ts +20 -1
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
- package/src/modes/components/agent-dashboard.ts +2 -2
- package/src/modes/components/agent-hub.ts +15 -2
- package/src/modes/components/agent-transcript-viewer.ts +2 -2
- package/src/modes/components/chat-transcript-builder.ts +4 -3
- package/src/modes/components/dynamic-border.ts +9 -6
- package/src/modes/components/extensions/extension-list.ts +2 -2
- package/src/modes/components/hook-selector.ts +10 -4
- package/src/modes/components/login-dialog.ts +5 -0
- package/src/modes/components/mcp-add-wizard.ts +5 -0
- package/src/modes/components/plan-review-overlay.ts +11 -11
- package/src/modes/components/read-tool-group.ts +1 -8
- package/src/modes/controllers/input-controller.ts +4 -2
- package/src/modes/controllers/mcp-command-controller.ts +6 -7
- package/src/modes/controllers/selector-controller.ts +9 -2
- package/src/modes/controllers/todo-command-controller.ts +18 -14
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/prompt-action-autocomplete.ts +6 -1
- package/src/modes/types.ts +1 -1
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/grep.md +1 -2
- package/src/prompts/tools/read.md +2 -4
- package/src/sdk.ts +28 -31
- package/src/session/agent-session.ts +44 -22
- package/src/session/messages.test.ts +66 -0
- package/src/session/messages.ts +37 -0
- package/src/system-prompt.test.ts +36 -0
- package/src/system-prompt.ts +1 -1
- package/src/tools/browser/registry.ts +17 -3
- package/src/tools/eval.ts +14 -9
- package/src/tools/gh.ts +3 -1
- package/src/tools/grep.ts +5 -45
- package/src/tools/path-utils.ts +7 -1
- package/src/tools/read.ts +23 -74
- package/src/utils/title-generator.ts +10 -6
- package/src/web/search/providers/perplexity-auth.ts +20 -11
- package/src/web/search/providers/perplexity.ts +14 -2
package/src/advisor/runtime.ts
CHANGED
|
@@ -532,10 +532,13 @@ export class AdvisorRuntime {
|
|
|
532
532
|
// as a failed turn so endpoint rejections trip the retry path.
|
|
533
533
|
const promptError = this.agent.state.error;
|
|
534
534
|
if (promptError) throw new Error(promptError);
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
535
|
+
// A content-less stop is a deliberate silent review — the documented
|
|
536
|
+
// verifier behavior ("prefer silence when the agent is on track") — and
|
|
537
|
+
// completes the turn. Sessions can legitimately have nothing to advise
|
|
538
|
+
// on for any number of consecutive turns, so silence is never warned
|
|
539
|
+
// about (#5216 did, spamming "Advisor unavailable" at quiet models).
|
|
540
|
+
const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
|
|
541
|
+
if (turnError) throw turnError;
|
|
539
542
|
success = true;
|
|
540
543
|
this.#consecutiveFailures = 0;
|
|
541
544
|
this.#failureNotified = false;
|
|
@@ -594,36 +597,15 @@ export class AdvisorRuntime {
|
|
|
594
597
|
}
|
|
595
598
|
}
|
|
596
599
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
if (messages.length > 0) return new Error("Advisor turn ended without an assistant response");
|
|
607
|
-
return undefined;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function hasAdvisorResponseContent(message: AssistantMessage): boolean {
|
|
611
|
-
return message.content.some(block => {
|
|
612
|
-
switch (block.type) {
|
|
613
|
-
case "text":
|
|
614
|
-
return block.text.trim().length > 0;
|
|
615
|
-
case "thinking":
|
|
616
|
-
return block.thinking.trim().length > 0;
|
|
617
|
-
case "redactedThinking":
|
|
618
|
-
return block.data.length > 0;
|
|
619
|
-
case "toolCall":
|
|
620
|
-
return block.name.trim().length > 0;
|
|
621
|
-
case "fallback":
|
|
622
|
-
return false;
|
|
623
|
-
default:
|
|
624
|
-
return false;
|
|
625
|
-
}
|
|
626
|
-
});
|
|
600
|
+
/**
|
|
601
|
+
* The only malformed advisor turn shape: the prompt resolved but produced no
|
|
602
|
+
* assistant response at all. Everything an assistant message carries — advice,
|
|
603
|
+
* reasoning, or deliberate silence (empty `stop`) — is a completed review.
|
|
604
|
+
*/
|
|
605
|
+
function getAdvisorTurnError(messages: readonly AgentMessage[]): Error | undefined {
|
|
606
|
+
if (messages.length === 0) return undefined;
|
|
607
|
+
if (messages.some(message => message.role === "assistant")) return undefined;
|
|
608
|
+
return new Error("Advisor turn ended without an assistant response");
|
|
627
609
|
}
|
|
628
610
|
|
|
629
611
|
type TextualContent = string | readonly (TextContent | ImageContent)[];
|
|
@@ -95,9 +95,9 @@ export function createDashboardController(): DashboardController {
|
|
|
95
95
|
done(undefined);
|
|
96
96
|
return;
|
|
97
97
|
}
|
|
98
|
-
if (matchesKey(data, "up") || data
|
|
98
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
99
99
|
scrollOffset = Math.max(0, scrollOffset - 1);
|
|
100
|
-
} else if (matchesKey(data, "down") || data
|
|
100
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
101
101
|
scrollOffset = Math.min(maxScroll, scrollOffset + 1);
|
|
102
102
|
} else if (matchesKey(data, "pageUp")) {
|
|
103
103
|
scrollOffset = Math.max(0, scrollOffset - viewportRows);
|
package/src/cli/config-cli.ts
CHANGED
|
@@ -241,7 +241,7 @@ export async function runConfigCommand(cmd: ConfigCommandArgs): Promise<void> {
|
|
|
241
241
|
|
|
242
242
|
switch (cmd.action) {
|
|
243
243
|
case "list":
|
|
244
|
-
handleList(cmd.flags);
|
|
244
|
+
await handleList(cmd.flags);
|
|
245
245
|
break;
|
|
246
246
|
case "get":
|
|
247
247
|
handleGet(cmd.key, cmd.flags);
|
|
@@ -261,7 +261,19 @@ export async function runConfigCommand(cmd: ConfigCommandArgs): Promise<void> {
|
|
|
261
261
|
}
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
function
|
|
264
|
+
async function writeStdout(text: string): Promise<void> {
|
|
265
|
+
const pending = Promise.withResolvers<void>();
|
|
266
|
+
process.stdout.write(text, error => {
|
|
267
|
+
if (error) {
|
|
268
|
+
pending.reject(error);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
pending.resolve();
|
|
272
|
+
});
|
|
273
|
+
await pending.promise;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function handleList(flags: { json?: boolean }): Promise<void> {
|
|
265
277
|
const defs = ALL_SETTING_PATHS.map(path => findSettingDef(path)).filter((def): def is CliSettingDef => !!def);
|
|
266
278
|
|
|
267
279
|
if (flags.json) {
|
|
@@ -273,7 +285,7 @@ function handleList(flags: { json?: boolean }): void {
|
|
|
273
285
|
description: def.description,
|
|
274
286
|
};
|
|
275
287
|
}
|
|
276
|
-
|
|
288
|
+
await writeStdout(`${JSON.stringify(result, null, 2)}\n`);
|
|
277
289
|
return;
|
|
278
290
|
}
|
|
279
291
|
|
|
@@ -3592,6 +3592,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
3592
3592
|
description: "Enable the tts tool for on-device (Kokoro) or xAI Grok Voice speech-file synthesis",
|
|
3593
3593
|
},
|
|
3594
3594
|
},
|
|
3595
|
+
"generate_image.enabled": {
|
|
3596
|
+
type: "boolean",
|
|
3597
|
+
default: true,
|
|
3598
|
+
ui: {
|
|
3599
|
+
tab: "tools",
|
|
3600
|
+
group: "Available Tools",
|
|
3601
|
+
label: "Generate Image",
|
|
3602
|
+
description: "Enable the generate_image tool for text-to-image generation and editing",
|
|
3603
|
+
},
|
|
3604
|
+
},
|
|
3595
3605
|
|
|
3596
3606
|
"inspect_image.enabled": {
|
|
3597
3607
|
type: "boolean",
|
package/src/cursor.ts
CHANGED
|
@@ -93,6 +93,7 @@ async function executeTool(
|
|
|
93
93
|
result = buildToolErrorResult(message);
|
|
94
94
|
isError = true;
|
|
95
95
|
}
|
|
96
|
+
isError ||= result.isError === true;
|
|
96
97
|
|
|
97
98
|
const sanitizedFinalResult: AgentToolResult<unknown> = {
|
|
98
99
|
content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
@@ -283,6 +284,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
283
284
|
result = buildToolErrorResult(message);
|
|
284
285
|
isError = true;
|
|
285
286
|
}
|
|
287
|
+
isError ||= result.isError === true;
|
|
286
288
|
|
|
287
289
|
// onUpdate may not fire for every chunk — flush any remaining output
|
|
288
290
|
// from the final result that wasn't already streamed.
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
scanSkillsFromDir,
|
|
25
25
|
} from "./helpers";
|
|
26
26
|
|
|
27
|
-
import { substitutePluginRoot } from "./substitute-plugin-root";
|
|
27
|
+
import { resolvePluginStdioPaths, substitutePluginRoot } from "./substitute-plugin-root";
|
|
28
28
|
|
|
29
29
|
const PROVIDER_ID = "claude-plugins";
|
|
30
30
|
const DISPLAY_NAME = "Claude Code Marketplace";
|
|
@@ -441,14 +441,20 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
441
441
|
continue;
|
|
442
442
|
}
|
|
443
443
|
const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
|
|
444
|
+
const substitutedCommand =
|
|
445
|
+
raw.command !== undefined ? substitutePluginRoot(raw.command, root.path) : undefined;
|
|
446
|
+
const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
|
|
447
|
+
// Root relative command/cwd at the plugin's config directory, not the
|
|
448
|
+
// session cwd (MCP stdio spawning resolves relative values there).
|
|
449
|
+
const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path);
|
|
444
450
|
const server: MCPServer = {
|
|
445
451
|
name: namespacedName,
|
|
446
452
|
...(raw.enabled !== undefined && { enabled: raw.enabled }),
|
|
447
453
|
...(raw.timeout !== undefined && { timeout: raw.timeout }),
|
|
448
|
-
...(
|
|
454
|
+
...(rooted.command !== undefined && { command: rooted.command }),
|
|
449
455
|
...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }),
|
|
450
456
|
...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }),
|
|
451
|
-
...(
|
|
457
|
+
...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
|
|
452
458
|
...(raw.url !== undefined && { url: expandEnvVarsDeep(raw.url) }),
|
|
453
459
|
...(raw.headers !== undefined && { headers: expandEnvVarsDeep(raw.headers) }),
|
|
454
460
|
...(raw.auth !== undefined && { auth: raw.auth }),
|
|
@@ -30,6 +30,7 @@ import { type CustomTool, toolCapability } from "../capability/tool";
|
|
|
30
30
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
31
31
|
import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
|
|
32
32
|
import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
|
|
33
|
+
import { resolvePluginStdioPaths } from "./substitute-plugin-root";
|
|
33
34
|
|
|
34
35
|
const PROVIDER_ID = "omp-plugins";
|
|
35
36
|
const DISPLAY_NAME = "OMP Extension Packages";
|
|
@@ -301,14 +302,17 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
301
302
|
warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
|
|
302
303
|
continue;
|
|
303
304
|
}
|
|
305
|
+
// Root relative command/cwd at the plugin's config directory, not the
|
|
306
|
+
// session cwd (MCP stdio spawning resolves relative values there).
|
|
307
|
+
const rooted = resolvePluginStdioPaths({ command: cfg.command, cwd: cfg.cwd }, root.path);
|
|
304
308
|
items.push({
|
|
305
309
|
name: serverName,
|
|
306
310
|
...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
|
|
307
311
|
...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
|
|
308
|
-
...(
|
|
312
|
+
...(rooted.command !== undefined && { command: rooted.command }),
|
|
309
313
|
...(cfg.args !== undefined && { args: cfg.args }),
|
|
310
314
|
...(cfg.env !== undefined && { env: cfg.env }),
|
|
311
|
-
...(
|
|
315
|
+
...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
|
|
312
316
|
...(cfg.url !== undefined && { url: cfg.url }),
|
|
313
317
|
...(cfg.headers !== undefined && { headers: cfg.headers }),
|
|
314
318
|
...(cfg.auth !== undefined && { auth: cfg.auth }),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT}
|
|
3
5
|
* with the actual plugin root path in strings, arrays, and plain objects.
|
|
@@ -27,3 +29,33 @@ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
|
|
|
27
29
|
}
|
|
28
30
|
return value;
|
|
29
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rebase relative filesystem values in a discovered plugin stdio config against
|
|
35
|
+
* the directory of the `.mcp.json` that declared them.
|
|
36
|
+
*
|
|
37
|
+
* External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace
|
|
38
|
+
* plugins) express `command`/`cwd` relative to their own config file, but MCP
|
|
39
|
+
* stdio spawning roots relative values at the session cwd — so a plugin shipping
|
|
40
|
+
* `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
|
|
41
|
+
* fails with ENOENT. This resolves those against `configDir` instead:
|
|
42
|
+
*
|
|
43
|
+
* - relative `cwd` → resolved against `configDir`;
|
|
44
|
+
* - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
|
|
45
|
+
* resolved against `configDir`;
|
|
46
|
+
* - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
|
|
47
|
+
*/
|
|
48
|
+
export function resolvePluginStdioPaths(
|
|
49
|
+
config: { command?: string; cwd?: string },
|
|
50
|
+
configDir: string,
|
|
51
|
+
): { command?: string; cwd?: string } {
|
|
52
|
+
const resolved: { command?: string; cwd?: string } = {};
|
|
53
|
+
if (typeof config.cwd === "string") {
|
|
54
|
+
resolved.cwd = path.isAbsolute(config.cwd) ? config.cwd : path.resolve(configDir, config.cwd);
|
|
55
|
+
}
|
|
56
|
+
if (config.command !== undefined) {
|
|
57
|
+
const isPathLike = /^\.\.?[/\\]/.test(config.command);
|
|
58
|
+
resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command;
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
@@ -105,3 +105,23 @@ describe("eval js agent() handle", () => {
|
|
|
105
105
|
expect("branchName" in node).toBe(false);
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
describe("eval js read() URI delegation", () => {
|
|
110
|
+
it("appends line selectors to delegated URI paths", async () => {
|
|
111
|
+
const calls: Array<{ name: string; args: unknown }> = [];
|
|
112
|
+
const sandbox = loadPrelude(async (name, args) => {
|
|
113
|
+
calls.push({ name, args });
|
|
114
|
+
return { text: "resource contents" };
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await vm.runInContext(`read("mcp://server/resource", { offset: 10, limit: 5 })`, sandbox);
|
|
118
|
+
|
|
119
|
+
expect(result).toBe("resource contents");
|
|
120
|
+
expect(calls).toEqual([
|
|
121
|
+
{
|
|
122
|
+
name: "read",
|
|
123
|
+
args: { path: "mcp://server/resource:10-14" },
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/eval/backend.ts
CHANGED
|
@@ -15,10 +15,10 @@ export interface ExecutorBackendExecOptions {
|
|
|
15
15
|
* driven entirely by `signal`, which the eval tool arms as a watchdog that
|
|
16
16
|
* pauses on bridge timeout-control status events and fires a `TimeoutError`
|
|
17
17
|
* reason only while the Python/JS runtime owns control. Backends use this
|
|
18
|
-
* value only for timeout-annotation text and as cold-start headroom;
|
|
19
|
-
* NOT derive a competing wall-clock timer from it.
|
|
18
|
+
* value only for timeout-annotation text and as cold-start headroom; undefined
|
|
19
|
+
* disables the cell timeout. Backends MUST NOT derive a competing wall-clock timer from it.
|
|
20
20
|
*/
|
|
21
|
-
idleTimeoutMs
|
|
21
|
+
idleTimeoutMs?: number;
|
|
22
22
|
reset: boolean;
|
|
23
23
|
onChunk: (chunk: string) => void;
|
|
24
24
|
/**
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { PYTHON_PRELUDE } from "../prelude";
|
|
3
3
|
|
|
4
|
+
const pythonPath = Bun.env.PYTHON ?? "python3";
|
|
5
|
+
|
|
6
|
+
async function runPrelude(
|
|
7
|
+
code: string,
|
|
8
|
+
env: Record<string, string>,
|
|
9
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
10
|
+
const prelude = PYTHON_PRELUDE.replace(
|
|
11
|
+
"from __future__ import annotations",
|
|
12
|
+
"from __future__ import annotations\n__omp_display = lambda *args, **kwargs: None",
|
|
13
|
+
);
|
|
14
|
+
const script = `${prelude}\n${code}`;
|
|
15
|
+
const proc = Bun.spawn([pythonPath, "-c", script], {
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
env: { ...process.env, ...env },
|
|
19
|
+
});
|
|
20
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
21
|
+
new Response(proc.stdout).text(),
|
|
22
|
+
new Response(proc.stderr).text(),
|
|
23
|
+
proc.exited,
|
|
24
|
+
]);
|
|
25
|
+
return { stdout, stderr, exitCode };
|
|
26
|
+
}
|
|
27
|
+
|
|
4
28
|
describe("python prelude", () => {
|
|
5
29
|
it("exposes read(path, offset?, limit?) with positional optional args", () => {
|
|
6
30
|
// The eval docs advertise `read(path, offset?=1, limit?=None)`. A
|
|
@@ -17,6 +41,54 @@ describe("python prelude", () => {
|
|
|
17
41
|
expect(signature).toContain("limit");
|
|
18
42
|
});
|
|
19
43
|
|
|
44
|
+
it("appends line selectors to delegated URI paths", async () => {
|
|
45
|
+
const requests: unknown[] = [];
|
|
46
|
+
const server = Bun.serve({
|
|
47
|
+
hostname: "127.0.0.1",
|
|
48
|
+
port: 0,
|
|
49
|
+
fetch: async request => {
|
|
50
|
+
requests.push(await request.json());
|
|
51
|
+
return Response.json({
|
|
52
|
+
ok: true,
|
|
53
|
+
value: { text: "resource contents", details: { resolvedPath: "/tmp/resource.txt" } },
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const result = await runPrelude(
|
|
60
|
+
[`print(read("artifact://21", 3, 2))`, `print(read("mcp://server/resource", 10, 5))`].join("\n"),
|
|
61
|
+
{
|
|
62
|
+
PI_TOOL_BRIDGE_URL: server.url.toString(),
|
|
63
|
+
PI_TOOL_BRIDGE_TOKEN: "test-token",
|
|
64
|
+
PI_TOOL_BRIDGE_SESSION: "test-session",
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
stdout: "resource contents\nresource contents\n",
|
|
70
|
+
stderr: "",
|
|
71
|
+
exitCode: 0,
|
|
72
|
+
});
|
|
73
|
+
expect(requests).toEqual([
|
|
74
|
+
{
|
|
75
|
+
session: "test-session",
|
|
76
|
+
run: null,
|
|
77
|
+
name: "read",
|
|
78
|
+
args: { path: "artifact://21:3-4" },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
session: "test-session",
|
|
82
|
+
run: null,
|
|
83
|
+
name: "read",
|
|
84
|
+
args: { path: "mcp://server/resource:10-14" },
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
} finally {
|
|
88
|
+
server.stop(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
20
92
|
it("exposes isolation artifacts on the agent() handle node", () => {
|
|
21
93
|
// agent(..., handle=True) is the only escape hatch for
|
|
22
94
|
// recovering apply=False patch/branch/nested artifacts (the bare
|
package/src/eval/py/prelude.py
CHANGED
|
@@ -57,6 +57,27 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
57
57
|
|
|
58
58
|
_OMP_INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE)
|
|
59
59
|
|
|
60
|
+
def _should_delegate_read(path: str | Path) -> bool:
|
|
61
|
+
return (
|
|
62
|
+
isinstance(path, str)
|
|
63
|
+
and _OMP_INTERNAL_URL_RE.match(path) is not None
|
|
64
|
+
and not path.lower().startswith("local://")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def _read_line_selector(offset: int, limit: int | None) -> str | None:
|
|
68
|
+
if offset <= 1 and limit is None:
|
|
69
|
+
return None
|
|
70
|
+
start = max(1, offset)
|
|
71
|
+
if limit is None:
|
|
72
|
+
return f"{start}-"
|
|
73
|
+
return f"{start}-{start + limit - 1}"
|
|
74
|
+
|
|
75
|
+
def _read_tool_text(path: str) -> str:
|
|
76
|
+
result = _bridge_call("read", {"path": path})
|
|
77
|
+
if isinstance(result, dict) and "text" in result:
|
|
78
|
+
return result["text"]
|
|
79
|
+
return result
|
|
80
|
+
|
|
60
81
|
def _resolve_omp_path(path: str | Path) -> Path:
|
|
61
82
|
"""Map a helper path to a real filesystem Path.
|
|
62
83
|
|
|
@@ -94,7 +115,13 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
94
115
|
return Path(resolved)
|
|
95
116
|
|
|
96
117
|
def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
|
|
97
|
-
"""Read file contents. offset/limit are 1-indexed
|
|
118
|
+
"""Read file or read-tool URI contents. offset/limit are 1-indexed lines."""
|
|
119
|
+
if _should_delegate_read(path):
|
|
120
|
+
if limit is not None and limit <= 0:
|
|
121
|
+
return ""
|
|
122
|
+
selector = _read_line_selector(offset, limit)
|
|
123
|
+
tool_path = path if selector is None else f"{path}:{selector}"
|
|
124
|
+
return _read_tool_text(tool_path)
|
|
98
125
|
p = _resolve_omp_path(path)
|
|
99
126
|
data = p.read_text(encoding="utf-8")
|
|
100
127
|
lines = data.splitlines(keepends=True)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Uses brush-core via native bindings for shell execution.
|
|
5
5
|
*/
|
|
6
6
|
import { ExponentialYield } from "@oh-my-pi/pi-agent-core/utils/yield";
|
|
7
|
-
import {
|
|
7
|
+
import { type MinimizerOptions, Shell, type ShellRunResult } from "@oh-my-pi/pi-natives";
|
|
8
8
|
import { isExecutable, type ShellConfig } from "@oh-my-pi/pi-utils/procmgr";
|
|
9
9
|
import { Settings, type ShellMinimizerSettings } from "../config/settings";
|
|
10
10
|
import { OutputSink } from "../session/streaming-output";
|
|
@@ -250,6 +250,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
250
250
|
};
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
const shellOptions = {
|
|
254
|
+
sessionEnv: shellEnv,
|
|
255
|
+
snapshotPath: snapshotPath ?? undefined,
|
|
256
|
+
minimizer,
|
|
257
|
+
};
|
|
253
258
|
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
|
|
254
259
|
const persistentSessionBroken = brokenShellSessions.has(sessionKey);
|
|
255
260
|
if (persistentSessionBroken) {
|
|
@@ -264,13 +269,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
264
269
|
const sessionBusy = shellSessionsInUse.has(sessionKey);
|
|
265
270
|
let shellSession = persistentSessionBroken || sessionBusy ? undefined : shellSessions.get(sessionKey);
|
|
266
271
|
if (!shellSession && !persistentSessionBroken && !sessionBusy) {
|
|
267
|
-
shellSession = new Shell(
|
|
268
|
-
sessionEnv: shellEnv,
|
|
269
|
-
snapshotPath: snapshotPath ?? undefined,
|
|
270
|
-
minimizer,
|
|
271
|
-
});
|
|
272
|
+
shellSession = new Shell(shellOptions);
|
|
272
273
|
shellSessions.set(sessionKey, shellSession);
|
|
273
274
|
}
|
|
275
|
+
const executionShell = shellSession ?? new Shell(shellOptions);
|
|
274
276
|
const ownsPersistentSession = shellSession !== undefined;
|
|
275
277
|
if (ownsPersistentSession) {
|
|
276
278
|
shellSessionsInUse.add(sessionKey);
|
|
@@ -278,13 +280,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
278
280
|
const userSignal = options?.signal;
|
|
279
281
|
const runAbortController = new AbortController();
|
|
280
282
|
let abortCleanupPromise: Promise<void> | undefined;
|
|
283
|
+
const abortShell = (): Promise<void> => {
|
|
284
|
+
abortCleanupPromise ??= executionShell.abort().catch(() => undefined);
|
|
285
|
+
return abortCleanupPromise;
|
|
286
|
+
};
|
|
281
287
|
const abortCurrentExecution = () => {
|
|
282
288
|
if (!runAbortController.signal.aborted) {
|
|
283
289
|
runAbortController.abort();
|
|
284
290
|
}
|
|
285
|
-
|
|
286
|
-
abortCleanupPromise = shellSession.abort().catch(() => undefined);
|
|
287
|
-
}
|
|
291
|
+
void abortShell();
|
|
288
292
|
};
|
|
289
293
|
const abortDeferred = Promise.withResolvers<"abort">();
|
|
290
294
|
const abortHandler = () => {
|
|
@@ -317,38 +321,20 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
317
321
|
let resetSession = false;
|
|
318
322
|
|
|
319
323
|
try {
|
|
320
|
-
const runPromise =
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
)
|
|
335
|
-
: executeShell(
|
|
336
|
-
{
|
|
337
|
-
command: finalCommand,
|
|
338
|
-
cwd: commandCwd,
|
|
339
|
-
env: commandEnv,
|
|
340
|
-
sessionEnv: shellEnv,
|
|
341
|
-
snapshotPath: snapshotPath ?? undefined,
|
|
342
|
-
minimizer,
|
|
343
|
-
timeoutMs: nativeTimeoutMs,
|
|
344
|
-
signal: runAbortController.signal,
|
|
345
|
-
},
|
|
346
|
-
(err, chunk) => {
|
|
347
|
-
if (!err) {
|
|
348
|
-
enqueueChunk(chunk);
|
|
349
|
-
}
|
|
350
|
-
},
|
|
351
|
-
);
|
|
324
|
+
const runPromise = executionShell.run(
|
|
325
|
+
{
|
|
326
|
+
command: finalCommand,
|
|
327
|
+
cwd: commandCwd,
|
|
328
|
+
env: commandEnv,
|
|
329
|
+
timeoutMs: nativeTimeoutMs,
|
|
330
|
+
signal: runAbortController.signal,
|
|
331
|
+
},
|
|
332
|
+
(err, chunk) => {
|
|
333
|
+
if (!err) {
|
|
334
|
+
enqueueChunk(chunk);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
);
|
|
352
338
|
|
|
353
339
|
const ey = new ExponentialYield();
|
|
354
340
|
const winner = await ey.race<
|
|
@@ -361,11 +347,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
361
347
|
|
|
362
348
|
if (winner.kind === "timeout" || winner.kind === "abort") {
|
|
363
349
|
acceptingChunks = false;
|
|
350
|
+
const cleanupPromise = abortShell();
|
|
364
351
|
if (shellSession) {
|
|
365
352
|
resetSession = true;
|
|
366
|
-
quarantineShellSession(sessionKey, runPromise,
|
|
353
|
+
quarantineShellSession(sessionKey, runPromise, cleanupPromise);
|
|
367
354
|
} else {
|
|
368
|
-
void
|
|
355
|
+
void Promise.allSettled([runPromise, cleanupPromise]);
|
|
369
356
|
}
|
|
370
357
|
return {
|
|
371
358
|
exitCode: undefined,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool wrappers for extensions.
|
|
3
3
|
*/
|
|
4
|
-
import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
4
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
5
|
import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
|
|
6
6
|
import type { Settings } from "../../config/settings";
|
|
7
7
|
import type { Theme } from "../../modes/theme/theme";
|
|
@@ -110,7 +110,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
110
110
|
signal?: AbortSignal,
|
|
111
111
|
onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
|
|
112
112
|
context?: AgentToolContext,
|
|
113
|
-
) {
|
|
113
|
+
): Promise<AgentToolResult<TDetails, TParameters>> {
|
|
114
114
|
// 1. Check approval policy (before extension handlers).
|
|
115
115
|
// CLI `--auto-approve` / `--yolo` sets approval mode to yolo.
|
|
116
116
|
// User `tools.approval.<tool>` policies are still applied in all modes.
|
|
@@ -237,23 +237,23 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
237
237
|
const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content;
|
|
238
238
|
const modifiedDetails = (resultResult.details ?? result.details) as TDetails;
|
|
239
239
|
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
throw new Error(errorText);
|
|
246
|
-
}
|
|
247
|
-
if (resultResult.isError === false && executionError) {
|
|
248
|
-
// Extension clears the error - return success
|
|
249
|
-
return { content: modifiedContent, details: modifiedDetails };
|
|
250
|
-
}
|
|
240
|
+
// Effective error state: an explicit handler override wins; otherwise the
|
|
241
|
+
// original execution outcome stands. This lets a handler rewrite a failed
|
|
242
|
+
// call's model-visible content/details while keeping it an error, flip a
|
|
243
|
+
// failure to success, or flag a success as an error.
|
|
244
|
+
const effectiveError = resultResult.isError ?? !!executionError;
|
|
251
245
|
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
246
|
+
// Return the (possibly modified) result carrying the error flag rather than
|
|
247
|
+
// rethrowing the original exception. The agent loop honors
|
|
248
|
+
// `AgentToolResult.isError` and surfaces it as a tool error on the wire (see
|
|
249
|
+
// `coerceToolResult` in agent-loop), so replacement failure content reaches
|
|
250
|
+
// the model while the call remains an error — the original exception text is
|
|
251
|
+
// no longer forced through, which previously discarded the replacement.
|
|
252
|
+
return {
|
|
253
|
+
content: modifiedContent,
|
|
254
|
+
details: modifiedDetails,
|
|
255
|
+
...(effectiveError ? { isError: true } : {}),
|
|
256
|
+
};
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
|