@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.7
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 +23 -0
- package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-5k4dq4g6.md} +23 -0
- package/dist/cli.js +3149 -3092
- package/dist/types/config/settings-schema.d.ts +16 -1
- package/dist/types/cursor.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +4 -0
- package/dist/types/extensibility/shared-events.d.ts +18 -1
- package/dist/types/modes/components/custom-editor.d.ts +5 -0
- package/dist/types/session/agent-session-types.d.ts +5 -5
- package/dist/types/session/agent-session.d.ts +26 -1
- package/dist/types/session/model-controls.d.ts +1 -1
- package/dist/types/session/session-tools.d.ts +44 -6
- package/dist/types/session/streaming-output.d.ts +7 -2
- package/dist/types/session/turn-recovery.d.ts +1 -1
- package/dist/types/tools/index.d.ts +8 -3
- package/dist/types/tools/output-meta.d.ts +5 -0
- package/dist/types/tools/read.d.ts +9 -1
- package/dist/types/tools/xdev.d.ts +53 -67
- package/dist/types/utils/cpuprofile.d.ts +51 -0
- package/dist/types/utils/inspect-image-mode.d.ts +29 -0
- package/dist/types/utils/profile-tree.d.ts +47 -0
- package/dist/types/utils/sample-profile.d.ts +67 -0
- package/package.json +12 -12
- package/src/config/settings-schema.ts +15 -1
- package/src/config/settings.ts +35 -0
- package/src/cursor.ts +4 -3
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/wrapper.ts +74 -42
- package/src/extensibility/hooks/tool-wrapper.ts +11 -4
- package/src/extensibility/shared-events.ts +18 -1
- package/src/modes/components/custom-editor.ts +39 -16
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +8 -7
- package/src/modes/controllers/extension-ui-controller.ts +4 -4
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/sdk.ts +47 -50
- package/src/session/agent-session-types.ts +5 -5
- package/src/session/agent-session.ts +123 -7
- package/src/session/model-controls.ts +5 -5
- package/src/session/session-listing.ts +66 -4
- package/src/session/session-tools.ts +158 -52
- package/src/session/streaming-output.ts +18 -6
- package/src/session/turn-recovery.ts +4 -4
- package/src/slash-commands/builtin-registry.ts +69 -0
- package/src/tools/bash.ts +16 -9
- package/src/tools/index.ts +36 -16
- package/src/tools/output-meta.ts +20 -0
- package/src/tools/read.ts +87 -13
- package/src/tools/write.ts +16 -7
- package/src/tools/xdev.ts +198 -210
- package/src/utils/cpuprofile.ts +235 -0
- package/src/utils/inspect-image-mode.ts +39 -0
- package/src/utils/profile-tree.ts +111 -0
- package/src/utils/sample-profile.ts +437 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective-state resolution for the `inspect_image` tool.
|
|
3
|
+
*
|
|
4
|
+
* The tool delegates image understanding to a (possibly different)
|
|
5
|
+
* vision-capable model. That indirection is only useful when the active model
|
|
6
|
+
* cannot consume images itself; when it can (`model.input` includes
|
|
7
|
+
* `"image"`), the tool is redundant and its presence actively degrades the
|
|
8
|
+
* `read` tool, which reduces image reads to metadata-only plus an
|
|
9
|
+
* inspect_image suggestion. `auto` mode therefore exposes the tool only for
|
|
10
|
+
* models without native image input. `on`/`off` force registration regardless
|
|
11
|
+
* of model capability. A session-scoped override (the `/vision` command)
|
|
12
|
+
* takes precedence over the persisted setting for the current session only.
|
|
13
|
+
*/
|
|
14
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
15
|
+
import type { Settings } from "../config/settings.js";
|
|
16
|
+
export type InspectImageMode = "auto" | "on" | "off";
|
|
17
|
+
export declare const INSPECT_IMAGE_MODES: readonly ["auto", "on", "off"];
|
|
18
|
+
/** Minimal session surface needed to resolve the effective inspect_image state. */
|
|
19
|
+
export interface InspectImageModeContext {
|
|
20
|
+
settings: Pick<Settings, "get">;
|
|
21
|
+
getActiveModel?: () => Model | undefined;
|
|
22
|
+
getInspectImageModeOverride?: () => InspectImageMode | undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether the `inspect_image` tool should be registered/active right now.
|
|
26
|
+
* `auto` registers it only when the active model lacks native image input;
|
|
27
|
+
* an unresolved model is treated as text-only so the tool stays available.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isInspectImageToolActive(session: InspectImageModeContext): boolean;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared display-tree machinery for profile summaries (macOS `sample` reports,
|
|
3
|
+
* V8 `.cpuprofile` files). Callers build {@link ProfileNode} trees with a
|
|
4
|
+
* domain-specific metric (on-CPU samples, self microseconds, …); this module
|
|
5
|
+
* merges same-key siblings, flattens direct recursion, collapses pass-through
|
|
6
|
+
* chains, and renders the pruned hot-path tree.
|
|
7
|
+
*/
|
|
8
|
+
/** Display node: merged, recursion-flattened mirror of a profile subtree. */
|
|
9
|
+
export interface ProfileNode {
|
|
10
|
+
/** Merge/recursion identity (demangled symbol, function@call-site, …). */
|
|
11
|
+
key: string;
|
|
12
|
+
/** Human-readable label; recursion and truncation decorations are applied at render time. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Inclusive metric for this subtree (on-CPU samples, self µs, …); drives pruning and display. */
|
|
15
|
+
value: number;
|
|
16
|
+
/** Levels of direct recursion flattened into this node. */
|
|
17
|
+
recursion: number;
|
|
18
|
+
children: ProfileNode[];
|
|
19
|
+
}
|
|
20
|
+
/** Merge `b` into `a` (same key), combining values and child lists. */
|
|
21
|
+
export declare function mergeInto(a: ProfileNode, b: ProfileNode): void;
|
|
22
|
+
/**
|
|
23
|
+
* Flatten direct recursion: children carrying the node's own key are dissolved
|
|
24
|
+
* into it (their children promoted and merged), so a 15-deep recursive spine
|
|
25
|
+
* renders as one annotated node.
|
|
26
|
+
*/
|
|
27
|
+
export declare function flattenRecursion(node: ProfileNode): void;
|
|
28
|
+
/** `n` as a percentage of `total`, one decimal (`"12.3%"`); `"0%"` when total is empty. */
|
|
29
|
+
export declare function formatPct(n: number, total: number): string;
|
|
30
|
+
/** Options for {@link renderProfileNode}. */
|
|
31
|
+
export interface RenderTreeContext {
|
|
32
|
+
out: string[];
|
|
33
|
+
/** Denominator for percentage annotations. */
|
|
34
|
+
total: number;
|
|
35
|
+
/** Minimum subtree value a child needs to stay visible. */
|
|
36
|
+
minValue: number;
|
|
37
|
+
/** Format a metric value for the left column. */
|
|
38
|
+
formatValue: (value: number) => string;
|
|
39
|
+
/** Left-column width (characters) for formatted values. */
|
|
40
|
+
valueWidth: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Render one hot-path subtree: pass-through chains (single kept child, no own
|
|
44
|
+
* contribution above `minValue`) collapse into a single `a › b › c` line, and
|
|
45
|
+
* children below `minValue` are pruned.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderProfileNode(node: ProfileNode, indent: number, ctx: RenderTreeContext): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser and renderer for macOS `/usr/bin/sample` call-tree reports
|
|
3
|
+
* (conventionally saved as `*.sample.txt`).
|
|
4
|
+
*
|
|
5
|
+
* The raw report is a 10k+ line ASCII call tree with mangled symbols —
|
|
6
|
+
* expensive for an agent to digest. `renderSampleProfile` converts it into a
|
|
7
|
+
* compact bottleneck summary:
|
|
8
|
+
*
|
|
9
|
+
* - per-thread hot paths, pruned to frames with meaningful on-CPU samples,
|
|
10
|
+
* with pass-through chains collapsed and direct recursion flattened
|
|
11
|
+
* - blocked/idle threads reduced to a one-line classification
|
|
12
|
+
* - a process-wide "top functions by self samples" table
|
|
13
|
+
* - Rust v0 and legacy symbols demangled (best-effort, path extraction)
|
|
14
|
+
*
|
|
15
|
+
* Consumed by the read tool: `*.sample.txt` reads show the summary, `:raw`
|
|
16
|
+
* returns the original bytes.
|
|
17
|
+
*/
|
|
18
|
+
/** Matches paths the read tool should treat as macOS sample reports. */
|
|
19
|
+
export declare function isSampleProfilePath(filePath: string): boolean;
|
|
20
|
+
/** One frame in the sampled call tree. Counts are sample hits (subtree total). */
|
|
21
|
+
export interface SampleFrame {
|
|
22
|
+
count: number;
|
|
23
|
+
symbol: string;
|
|
24
|
+
module?: string;
|
|
25
|
+
children: SampleFrame[];
|
|
26
|
+
}
|
|
27
|
+
/** One sampled thread: `Thread_<id>` root plus its call tree. */
|
|
28
|
+
export interface SampleThread {
|
|
29
|
+
id: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
total: number;
|
|
32
|
+
roots: SampleFrame[];
|
|
33
|
+
}
|
|
34
|
+
/** Metadata from the report preamble (everything before `Call graph:`). */
|
|
35
|
+
export interface SampleProfileHeader {
|
|
36
|
+
process: string;
|
|
37
|
+
pid: number;
|
|
38
|
+
intervalMs: number;
|
|
39
|
+
path?: string;
|
|
40
|
+
codeType?: string;
|
|
41
|
+
osVersion?: string;
|
|
42
|
+
footprint?: string;
|
|
43
|
+
footprintPeak?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Parsed macOS sample report. */
|
|
46
|
+
export interface SampleProfile {
|
|
47
|
+
header: SampleProfileHeader;
|
|
48
|
+
threads: SampleThread[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse a macOS `sample` report. Returns null when the text does not look
|
|
52
|
+
* like sample output (missing analysis preamble or `Call graph:` section).
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseSampleProfile(text: string): SampleProfile | null;
|
|
55
|
+
/**
|
|
56
|
+
* Best-effort demangle of Rust symbols (v0 `_R…` and legacy `_ZN…E`).
|
|
57
|
+
* For v0 this is a path extractor, not a full demangler: identifiers are
|
|
58
|
+
* pulled out in order and joined with `::`, so generic arguments appear as
|
|
59
|
+
* extra path segments. Non-Rust symbols pass through unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export declare function demangleSymbol(raw: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Render a macOS sample report as an agent-friendly bottleneck summary.
|
|
64
|
+
* Returns null when `text` is not sample output (caller falls back to the
|
|
65
|
+
* plain-text path).
|
|
66
|
+
*/
|
|
67
|
+
export declare function renderSampleProfile(text: string): string | null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "17.1.
|
|
4
|
+
"version": "17.1.7",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -52,17 +52,17 @@
|
|
|
52
52
|
"@agentclientprotocol/sdk": "1.2.1",
|
|
53
53
|
"@babel/parser": "^7.29.7",
|
|
54
54
|
"@mozilla/readability": "^0.6.0",
|
|
55
|
-
"@oh-my-pi/hashline": "17.1.
|
|
56
|
-
"@oh-my-pi/omp-stats": "17.1.
|
|
57
|
-
"@oh-my-pi/pi-agent-core": "17.1.
|
|
58
|
-
"@oh-my-pi/pi-ai": "17.1.
|
|
59
|
-
"@oh-my-pi/pi-catalog": "17.1.
|
|
60
|
-
"@oh-my-pi/pi-mnemopi": "17.1.
|
|
61
|
-
"@oh-my-pi/pi-natives": "17.1.
|
|
62
|
-
"@oh-my-pi/pi-tui": "17.1.
|
|
63
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
64
|
-
"@oh-my-pi/pi-wire": "17.1.
|
|
65
|
-
"@oh-my-pi/snapcompact": "17.1.
|
|
55
|
+
"@oh-my-pi/hashline": "17.1.7",
|
|
56
|
+
"@oh-my-pi/omp-stats": "17.1.7",
|
|
57
|
+
"@oh-my-pi/pi-agent-core": "17.1.7",
|
|
58
|
+
"@oh-my-pi/pi-ai": "17.1.7",
|
|
59
|
+
"@oh-my-pi/pi-catalog": "17.1.7",
|
|
60
|
+
"@oh-my-pi/pi-mnemopi": "17.1.7",
|
|
61
|
+
"@oh-my-pi/pi-natives": "17.1.7",
|
|
62
|
+
"@oh-my-pi/pi-tui": "17.1.7",
|
|
63
|
+
"@oh-my-pi/pi-utils": "17.1.7",
|
|
64
|
+
"@oh-my-pi/pi-wire": "17.1.7",
|
|
65
|
+
"@oh-my-pi/snapcompact": "17.1.7",
|
|
66
66
|
"@opentelemetry/api": "^1.9.1",
|
|
67
67
|
"@opentelemetry/api-logs": "^0.220.0",
|
|
68
68
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
@@ -3835,14 +3835,28 @@ export const SETTINGS_SCHEMA = {
|
|
|
3835
3835
|
},
|
|
3836
3836
|
},
|
|
3837
3837
|
|
|
3838
|
+
// Legacy boolean kept only for back-compat migration to `inspect_image.mode`
|
|
3839
|
+
// (see config/settings.ts). Hidden from UI.
|
|
3838
3840
|
"inspect_image.enabled": {
|
|
3839
3841
|
type: "boolean",
|
|
3840
3842
|
default: false,
|
|
3843
|
+
},
|
|
3844
|
+
|
|
3845
|
+
"inspect_image.mode": {
|
|
3846
|
+
type: "enum",
|
|
3847
|
+
values: ["auto", "on", "off"] as const,
|
|
3848
|
+
default: "auto",
|
|
3841
3849
|
ui: {
|
|
3842
3850
|
tab: "tools",
|
|
3843
3851
|
group: "Available Tools",
|
|
3844
3852
|
label: "Inspect Image",
|
|
3845
|
-
description:
|
|
3853
|
+
description:
|
|
3854
|
+
"Controls the inspect_image tool, which delegates image understanding to a vision-capable model. 'auto' exposes it only when the active model lacks native image input; 'on' always exposes it; 'off' never does.",
|
|
3855
|
+
options: [
|
|
3856
|
+
{ value: "auto", label: "Auto (only for models without vision)" },
|
|
3857
|
+
{ value: "on", label: "On" },
|
|
3858
|
+
{ value: "off", label: "Off" },
|
|
3859
|
+
],
|
|
3846
3860
|
},
|
|
3847
3861
|
},
|
|
3848
3862
|
|
package/src/config/settings.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset }
|
|
|
39
39
|
import { AgentStorage } from "../session/agent-storage";
|
|
40
40
|
import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers";
|
|
41
41
|
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
|
|
42
|
+
import { INSPECT_IMAGE_MODES } from "../utils/inspect-image-mode";
|
|
42
43
|
import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
|
|
43
44
|
import { withFileLock } from "./file-lock";
|
|
44
45
|
import {
|
|
@@ -1366,6 +1367,40 @@ export class Settings {
|
|
|
1366
1367
|
}
|
|
1367
1368
|
}
|
|
1368
1369
|
|
|
1370
|
+
// inspect_image.enabled (boolean) -> inspect_image.mode (enum). Explicit
|
|
1371
|
+
// user choices are preserved: true -> "on", false -> "off". Configs with
|
|
1372
|
+
// no legacy key get the new "auto" default, which hides the tool for
|
|
1373
|
+
// models with native image input. Handles nested and quoted-dotted
|
|
1374
|
+
// ("inspect_image.enabled") sources; the target is always the nested
|
|
1375
|
+
// form, which is the only shape the resolver reads.
|
|
1376
|
+
const inspectImageObj = isRecord(raw.inspect_image) ? (raw.inspect_image as Record<string, unknown>) : undefined;
|
|
1377
|
+
const legacyEnabled =
|
|
1378
|
+
typeof inspectImageObj?.enabled === "boolean"
|
|
1379
|
+
? inspectImageObj.enabled
|
|
1380
|
+
: typeof raw["inspect_image.enabled"] === "boolean"
|
|
1381
|
+
? (raw["inspect_image.enabled"] as boolean)
|
|
1382
|
+
: undefined;
|
|
1383
|
+
if (legacyEnabled !== undefined) {
|
|
1384
|
+
if (!inspectImageObj) {
|
|
1385
|
+
raw.inspect_image = {};
|
|
1386
|
+
}
|
|
1387
|
+
const target = raw.inspect_image as Record<string, unknown>;
|
|
1388
|
+
const flatMode = raw["inspect_image.mode"];
|
|
1389
|
+
if (target.mode === undefined) {
|
|
1390
|
+
// A quoted-dotted explicit mode wins over the legacy boolean but
|
|
1391
|
+
// must be normalized into the nested form the resolver reads.
|
|
1392
|
+
target.mode =
|
|
1393
|
+
typeof flatMode === "string" && (INSPECT_IMAGE_MODES as readonly string[]).includes(flatMode)
|
|
1394
|
+
? flatMode
|
|
1395
|
+
: legacyEnabled
|
|
1396
|
+
? "on"
|
|
1397
|
+
: "off";
|
|
1398
|
+
}
|
|
1399
|
+
delete target.enabled;
|
|
1400
|
+
delete raw["inspect_image.enabled"];
|
|
1401
|
+
delete raw["inspect_image.mode"];
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1369
1404
|
// task.isolation.enabled (boolean) -> task.isolation.mode (enum)
|
|
1370
1405
|
const taskObj = raw.task as Record<string, unknown> | undefined;
|
|
1371
1406
|
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
|
package/src/cursor.ts
CHANGED
|
@@ -25,7 +25,8 @@ interface CursorExecBridgeOptions {
|
|
|
25
25
|
cwd: string;
|
|
26
26
|
getCwd?: () => string;
|
|
27
27
|
tools: Map<string, AgentTool>;
|
|
28
|
-
|
|
28
|
+
/** Resolves execution overrides (mounted-device permission wrappers) before the canonical map. */
|
|
29
|
+
getExecutableTool?: (name: string) => AgentTool | undefined;
|
|
29
30
|
getToolContext?: () => AgentToolContext | undefined;
|
|
30
31
|
emitEvent?: (event: AgentEvent) => void;
|
|
31
32
|
/**
|
|
@@ -81,7 +82,7 @@ async function executeTool(
|
|
|
81
82
|
toolCallId: string,
|
|
82
83
|
args: Record<string, unknown>,
|
|
83
84
|
): Promise<ToolResultMessage> {
|
|
84
|
-
const tool = options.
|
|
85
|
+
const tool = options.getExecutableTool?.(toolName) ?? options.tools.get(toolName);
|
|
85
86
|
if (!tool) {
|
|
86
87
|
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
87
88
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
@@ -497,7 +498,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
497
498
|
async mcp(call: CursorMcpCall) {
|
|
498
499
|
const toolName = call.toolName || call.name;
|
|
499
500
|
const toolCallId = decodeToolCallId(call.toolCallId);
|
|
500
|
-
const tool = this.options.
|
|
501
|
+
const tool = this.options.getExecutableTool?.(toolName) ?? this.options.tools.get(toolName);
|
|
501
502
|
if (!tool) {
|
|
502
503
|
const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
|
|
503
504
|
const message = formatMcpToolErrorMessage(toolName, availableTools);
|
|
@@ -28,6 +28,7 @@ import tsNoAny from "./ts-no-any.md" with { type: "text" };
|
|
|
28
28
|
import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" };
|
|
29
29
|
import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
|
|
30
30
|
import tsNoInlineCastAccess from "./ts-no-inline-cast-access.md" with { type: "text" };
|
|
31
|
+
import tsNoLocalIsRecord from "./ts-no-local-is-record.md" with { type: "text" };
|
|
31
32
|
import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
|
|
32
33
|
import tsNoTestTimers from "./ts-no-test-timers.md" with { type: "text" };
|
|
33
34
|
import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
|
|
@@ -63,6 +64,7 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
|
|
|
63
64
|
{ name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers },
|
|
64
65
|
{ name: "ts-no-dynamic-import", content: tsNoDynamicImport },
|
|
65
66
|
{ name: "ts-no-inline-cast-access", content: tsNoInlineCastAccess },
|
|
67
|
+
{ name: "ts-no-local-is-record", content: tsNoLocalIsRecord },
|
|
66
68
|
{ name: "ts-no-return-type", content: tsNoReturnType },
|
|
67
69
|
{ name: "ts-no-test-timers", content: tsNoTestTimers },
|
|
68
70
|
{ name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Never use isRecord"
|
|
3
|
+
condition:
|
|
4
|
+
- "\\bfunction\\s+isRecord(?:\\s*<[^>]*>)?\\s*\\("
|
|
5
|
+
- "\\b(?:const|let|var)\\s+isRecord\\b\\s*(?::[\\s\\S]{0,300}?)?=\\s*(?:async\\s+)?(?:function\\b|(?:<[^>\\n]*>\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*(?::[\\s\\S]{0,300}?)?=>)"
|
|
6
|
+
scope: "tool:edit(*.{ts,tsx,mts,cts}), tool:write(*.{ts,tsx,mts,cts})"
|
|
7
|
+
interruptMode: never
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Why it's wrong
|
|
11
|
+
|
|
12
|
+
- A `Record<string, unknown>` guard proves only an object, not its fields.
|
|
13
|
+
- It's either unnecessarily complicated, or not strong enough.
|
|
14
|
+
- Repeated guards hide the actual data contract from readers and TypeScript.
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
`isRecord` narrows values to `Record<string, unknown>`; each field remains `unknown`.
|
|
19
|
+
|
|
20
|
+
For network, config, IPC, persisted, or reused data shapes, parse once at the boundary with the project's schema validator and consume its named output type:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
const Config = z.object({ retries: z.number().int().nonnegative() });
|
|
24
|
+
type Config = z.infer<typeof Config>;
|
|
25
|
+
|
|
26
|
+
const config = Config.parse(raw);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If the runtime shape is uncertain, check the properties you use with `typeof`, `Array.isArray`, `in`, or a discriminant. If an existing invariant guarantees the shape, assert the named type at that boundary instead of duplicating a guard:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
const config = value as Config;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Avoid
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
43
|
+
value !== null && typeof value === "object";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Exceptions
|
|
47
|
+
|
|
48
|
+
A standalone package without a shared type-guard module may define its single canonical guard. Export it from the package's type-guard module; never recreate it at individual call sites.
|
|
@@ -356,6 +356,32 @@ export class ExtensionRunner {
|
|
|
356
356
|
#managedTimers = new ManagedTimers((event, error, stack) =>
|
|
357
357
|
this.emitError({ extensionPath: "<timer>", event, error, stack }),
|
|
358
358
|
);
|
|
359
|
+
/**
|
|
360
|
+
* Dedup markers for `tool_call` emission, keyed `${toolCallId}:${toolName}`.
|
|
361
|
+
* The agent loop emits `tool_call` at arg-prep time (before scheduling and
|
|
362
|
+
* `tool_execution_start`) via the session's `beforeToolCall` wiring; the
|
|
363
|
+
* marker tells `ExtensionToolWrapper.execute` not to emit a second event for
|
|
364
|
+
* the same dispatch. Keyed by call id + tool name because a nested xd://
|
|
365
|
+
* device dispatch reuses the model's toolCallId under a different tool name
|
|
366
|
+
* and must still emit its own event. Bounded: markers for calls whose
|
|
367
|
+
* execute path never runs (policy deny, validation failure) would otherwise
|
|
368
|
+
* accumulate for the session's lifetime.
|
|
369
|
+
*/
|
|
370
|
+
#emittedToolCalls = new Set<string>();
|
|
371
|
+
|
|
372
|
+
/** Records that the loop already emitted `tool_call` for this dispatch. */
|
|
373
|
+
markToolCallEmitted(toolCallId: string, toolName: string): void {
|
|
374
|
+
if (this.#emittedToolCalls.size >= 512) {
|
|
375
|
+
const oldest = this.#emittedToolCalls.values().next().value;
|
|
376
|
+
if (oldest !== undefined) this.#emittedToolCalls.delete(oldest);
|
|
377
|
+
}
|
|
378
|
+
this.#emittedToolCalls.add(`${toolCallId}:${toolName}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Consumes a {@link markToolCallEmitted} marker; true when the loop already emitted. */
|
|
382
|
+
consumeToolCallEmitted(toolCallId: string, toolName: string): boolean {
|
|
383
|
+
return this.#emittedToolCalls.delete(`${toolCallId}:${toolName}`);
|
|
384
|
+
}
|
|
359
385
|
|
|
360
386
|
constructor(
|
|
361
387
|
private readonly extensions: Extension[],
|
|
@@ -157,15 +157,70 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
157
157
|
onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
|
|
158
158
|
context?: AgentToolContext,
|
|
159
159
|
): Promise<AgentToolResult<TDetails, TParameters>> {
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
160
|
+
// The agent loop emits `tool_call` at arg-prep time (session
|
|
161
|
+
// `beforeToolCall` wiring) so a handler revision lands before concurrency
|
|
162
|
+
// scheduling and `tool_execution_start`. Consume the marker
|
|
163
|
+
// unconditionally so it cannot go stale; emit here only for dispatches
|
|
164
|
+
// the loop never saw — nested xd:// device dispatches and direct
|
|
165
|
+
// (non-loop) execution such as Cursor exec handlers.
|
|
166
|
+
const loopEmittedToolCall = this.runner.consumeToolCallEmitted(toolCallId, this.tool.name);
|
|
167
|
+
// Resolve approval settings up front. A `deny` on the original input short-circuits before the
|
|
168
|
+
// runner is touched — an already-denied tool never emits `tool_call` — while the full gate below
|
|
169
|
+
// re-resolves against the (possibly revised) input so a handler cannot rewrite into a denied or
|
|
170
|
+
// newly prompt-gated command and have it run unapproved.
|
|
163
171
|
const cliAutoApprove = context?.autoApprove === true;
|
|
164
172
|
const settings: Settings | undefined = context?.settings;
|
|
165
173
|
const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
|
|
166
174
|
const approvalMode: ApprovalMode = cliAutoApprove ? "yolo" : configuredMode;
|
|
167
175
|
const userPolicies = (settings?.get("tools.approval") ?? {}) as Record<string, unknown>;
|
|
168
|
-
|
|
176
|
+
if (resolveApproval(this.tool, approvalArgs(params, context), approvalMode, userPolicies).policy === "deny") {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Tool "${this.tool.name}" is blocked by user policy.\n` +
|
|
179
|
+
`To allow: remove "tools.approval.${this.tool.name}: deny" from config.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 1. Emit tool_call event first - extensions can block execution or revise the input the tool
|
|
184
|
+
// runs with. Doing this BEFORE the approval gate means approval (below) resolves against the
|
|
185
|
+
// input that actually executes, closing the "approve one thing, run another" gap: the prompt
|
|
186
|
+
// text, policy resolution, and provider safety checks all see `effectiveParams`.
|
|
187
|
+
let effectiveParams = params;
|
|
188
|
+
if (!loopEmittedToolCall && this.runner.hasHandlers("tool_call")) {
|
|
189
|
+
try {
|
|
190
|
+
const callResult = (await this.runner.emitToolCall({
|
|
191
|
+
type: "tool_call",
|
|
192
|
+
toolName: this.tool.name,
|
|
193
|
+
toolCallId,
|
|
194
|
+
input: normalizeToolEventInput(
|
|
195
|
+
this.tool.name,
|
|
196
|
+
resolveToolEventInput(this.tool, toolEventArgs(params, context)),
|
|
197
|
+
),
|
|
198
|
+
})) as ToolCallEventResult | undefined;
|
|
199
|
+
|
|
200
|
+
if (callResult?.block) {
|
|
201
|
+
const reason = callResult.reason || "Tool execution was blocked by an extension";
|
|
202
|
+
throw new Error(reason);
|
|
203
|
+
}
|
|
204
|
+
// A non-blocking handler may replace the execution input. The returned object is the raw
|
|
205
|
+
// input passed to `execute` (handler-owned; not re-normalized). Skipped for `computer`
|
|
206
|
+
// tool calls, whose event input is a synthetic {actions,pendingSafetyChecks} view
|
|
207
|
+
// (see toolEventArgs) rather than the real execution params.
|
|
208
|
+
if (callResult?.input !== undefined && context?.toolCall?.providerMetadata?.type !== "computer") {
|
|
209
|
+
effectiveParams = callResult.input as typeof params;
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (err instanceof Error) {
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 2. Full approval gate against the (possibly revised) input that will actually run — resolves
|
|
220
|
+
// policy and prompts on `effectiveParams`, so the user approves exactly what executes. A revised
|
|
221
|
+
// input that newly resolves to `deny` is caught here even though the original passed the
|
|
222
|
+
// short-circuit above.
|
|
223
|
+
const resolvedArgs = approvalArgs(effectiveParams, context);
|
|
169
224
|
const resolved = resolveApproval(this.tool, resolvedArgs, approvalMode, userPolicies);
|
|
170
225
|
if (resolved.policy === "deny") {
|
|
171
226
|
throw new Error(
|
|
@@ -175,15 +230,17 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
175
230
|
}
|
|
176
231
|
const pendingSafetyChecks = computerSafetyChecks(context);
|
|
177
232
|
// An xd:// device dispatch already cleared the write tool's outer gate at
|
|
178
|
-
// this tool's tier — re-prompting would double-ask for one action.
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
233
|
+
// this tool's tier — re-prompting would double-ask for one action. The
|
|
234
|
+
// bypass only holds while the input is exactly what that outer gate
|
|
235
|
+
// approved: a handler revision here may have raised the tier, so revised
|
|
236
|
+
// input always faces the full gate. Explicit per-tool "prompt" policies
|
|
237
|
+
// and tool-demanded overrides still prompt. Provider safety checks are
|
|
238
|
+
// stronger: yolo, per-tool allow, and xdev approval never acknowledge
|
|
239
|
+
// them on the user's behalf.
|
|
182
240
|
const explicitPrompt = resolved.override || Object.hasOwn(userPolicies, this.tool.name);
|
|
241
|
+
const xdevBypass = context?.xdevApproved === true && effectiveParams === params;
|
|
183
242
|
const approvalCheck = {
|
|
184
|
-
required:
|
|
185
|
-
pendingSafetyChecks.length > 0 ||
|
|
186
|
-
(resolved.policy === "prompt" && (explicitPrompt || context?.xdevApproved !== true)),
|
|
243
|
+
required: pendingSafetyChecks.length > 0 || (resolved.policy === "prompt" && (explicitPrompt || !xdevBypass)),
|
|
187
244
|
reason: resolved.reason,
|
|
188
245
|
};
|
|
189
246
|
|
|
@@ -202,7 +259,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
202
259
|
});
|
|
203
260
|
}
|
|
204
261
|
|
|
205
|
-
const
|
|
262
|
+
const emitApprovalResolved = async (approved: boolean, reason?: string) => {
|
|
206
263
|
if (!hasApprovalHandlers) return;
|
|
207
264
|
await this.runner.emit({
|
|
208
265
|
type: "tool_approval_resolved",
|
|
@@ -218,7 +275,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
218
275
|
// ordinary tier approval, no setting or yolo mode may bypass this gate.
|
|
219
276
|
if (!this.runner.hasUI()) {
|
|
220
277
|
const reason = "no interactive UI available";
|
|
221
|
-
await
|
|
278
|
+
await emitApprovalResolved(false, reason);
|
|
222
279
|
if (pendingSafetyChecks.length > 0) {
|
|
223
280
|
throw new Error(
|
|
224
281
|
`Tool "${this.tool.name}" has pending provider safety checks but no interactive UI is available.`,
|
|
@@ -243,11 +300,11 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
243
300
|
try {
|
|
244
301
|
choice = await uiContext.select(safetyPrompt, ["Approve", "Deny"]);
|
|
245
302
|
} catch (err) {
|
|
246
|
-
await
|
|
303
|
+
await emitApprovalResolved(false, err instanceof Error ? err.message : "approval aborted");
|
|
247
304
|
throw err;
|
|
248
305
|
}
|
|
249
306
|
const approved = choice === "Approve";
|
|
250
|
-
await
|
|
307
|
+
await emitApprovalResolved(approved, approved ? undefined : "denied by user");
|
|
251
308
|
if (!approved) {
|
|
252
309
|
throw new Error(`Tool call denied by user: ${this.tool.name}`);
|
|
253
310
|
}
|
|
@@ -257,37 +314,12 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
257
314
|
}
|
|
258
315
|
}
|
|
259
316
|
|
|
260
|
-
// 2. Emit tool_call event - extensions can block execution
|
|
261
|
-
if (this.runner.hasHandlers("tool_call")) {
|
|
262
|
-
try {
|
|
263
|
-
const callResult = (await this.runner.emitToolCall({
|
|
264
|
-
type: "tool_call",
|
|
265
|
-
toolName: this.tool.name,
|
|
266
|
-
toolCallId,
|
|
267
|
-
input: normalizeToolEventInput(
|
|
268
|
-
this.tool.name,
|
|
269
|
-
resolveToolEventInput(this.tool, toolEventArgs(params, context)),
|
|
270
|
-
),
|
|
271
|
-
})) as ToolCallEventResult | undefined;
|
|
272
|
-
|
|
273
|
-
if (callResult?.block) {
|
|
274
|
-
const reason = callResult.reason || "Tool execution was blocked by an extension";
|
|
275
|
-
throw new Error(reason);
|
|
276
|
-
}
|
|
277
|
-
} catch (err) {
|
|
278
|
-
if (err instanceof Error) {
|
|
279
|
-
throw err;
|
|
280
|
-
}
|
|
281
|
-
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
317
|
// Execute the actual tool
|
|
286
318
|
let result: AgentToolResult<TDetails, TParameters>;
|
|
287
319
|
let executionError: Error | undefined;
|
|
288
320
|
|
|
289
321
|
try {
|
|
290
|
-
result = await this.tool.execute(toolCallId,
|
|
322
|
+
result = await this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context);
|
|
291
323
|
} catch (err) {
|
|
292
324
|
executionError = err instanceof Error ? err : new Error(String(err));
|
|
293
325
|
result = {
|
|
@@ -304,7 +336,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
|
|
|
304
336
|
toolCallId,
|
|
305
337
|
input: normalizeToolEventInput(
|
|
306
338
|
this.tool.name,
|
|
307
|
-
resolveToolEventInput(this.tool, toolEventArgs(
|
|
339
|
+
resolveToolEventInput(this.tool, toolEventArgs(effectiveParams, context)),
|
|
308
340
|
),
|
|
309
341
|
content: result.content,
|
|
310
342
|
details: result.details,
|
|
@@ -39,8 +39,9 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
39
39
|
onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
|
|
40
40
|
context?: AgentToolContext,
|
|
41
41
|
) {
|
|
42
|
-
// Emit tool_call event - hooks can block execution
|
|
42
|
+
// Emit tool_call event - hooks can block execution or revise the input the tool runs with.
|
|
43
43
|
// If hook errors/times out, block by default (fail-safe)
|
|
44
|
+
let effectiveParams = params;
|
|
44
45
|
if (this.hookRunner.hasHandlers("tool_call")) {
|
|
45
46
|
try {
|
|
46
47
|
const callResult = (await this.hookRunner.emitToolCall({
|
|
@@ -57,6 +58,12 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
57
58
|
const reason = callResult.reason || "Tool execution was blocked by a hook";
|
|
58
59
|
throw new Error(reason);
|
|
59
60
|
}
|
|
61
|
+
// A non-blocking handler may replace the execution input. The returned object is the raw
|
|
62
|
+
// input the tool runs with (handler-owned); it is not re-normalized. Skipped for `computer`
|
|
63
|
+
// tool calls, whose real parameters are not represented by the event input.
|
|
64
|
+
if (callResult?.input !== undefined && context?.toolCall?.providerMetadata?.type !== "computer") {
|
|
65
|
+
effectiveParams = callResult.input as Static<TParameters>;
|
|
66
|
+
}
|
|
60
67
|
} catch (err) {
|
|
61
68
|
// Hook error or block - throw to mark as error
|
|
62
69
|
if (err instanceof Error) {
|
|
@@ -68,7 +75,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
68
75
|
|
|
69
76
|
// Execute the actual tool, forwarding onUpdate for progress streaming
|
|
70
77
|
try {
|
|
71
|
-
const result = await this.tool.execute(toolCallId,
|
|
78
|
+
const result = await this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context);
|
|
72
79
|
|
|
73
80
|
// Emit tool_result event - hooks can modify the result
|
|
74
81
|
if (this.hookRunner.hasHandlers("tool_result")) {
|
|
@@ -78,7 +85,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
78
85
|
toolCallId,
|
|
79
86
|
input: normalizeToolEventInput(
|
|
80
87
|
this.tool.name,
|
|
81
|
-
resolveToolEventInput(this.tool,
|
|
88
|
+
resolveToolEventInput(this.tool, effectiveParams as Record<string, unknown>),
|
|
82
89
|
),
|
|
83
90
|
content: result.content,
|
|
84
91
|
details: result.details,
|
|
@@ -104,7 +111,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
|
|
|
104
111
|
toolCallId,
|
|
105
112
|
input: normalizeToolEventInput(
|
|
106
113
|
this.tool.name,
|
|
107
|
-
resolveToolEventInput(this.tool,
|
|
114
|
+
resolveToolEventInput(this.tool, effectiveParams as Record<string, unknown>),
|
|
108
115
|
),
|
|
109
116
|
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
|
|
110
117
|
details: undefined,
|
|
@@ -289,13 +289,30 @@ export interface TodoReminderEvent {
|
|
|
289
289
|
|
|
290
290
|
/**
|
|
291
291
|
* Return type for `tool_call` handlers.
|
|
292
|
-
* Allows handlers to block tool execution.
|
|
292
|
+
* Allows handlers to block tool execution or revise the input the tool runs with.
|
|
293
293
|
*/
|
|
294
294
|
export interface ToolCallEventResult {
|
|
295
295
|
/** If true, block the tool from executing */
|
|
296
296
|
block?: boolean;
|
|
297
297
|
/** Reason for blocking (returned to LLM as error) */
|
|
298
298
|
reason?: string;
|
|
299
|
+
/**
|
|
300
|
+
* Replacement input the tool executes with, instead of the original arguments. Ignored when
|
|
301
|
+
* `block` is true. This is the raw execution input passed to the tool's `execute` (the handler
|
|
302
|
+
* owns its correctness) — not the normalized `event.input` view, which may carry derived
|
|
303
|
+
* gate-only fields (e.g. hashline `edit` `path`/`paths`) that are not real parameters. When
|
|
304
|
+
* multiple handlers set `input`, the last one wins; handlers do not observe each other's
|
|
305
|
+
* revisions (each sees the original `event.input`). Not applied to `computer` tool calls.
|
|
306
|
+
*
|
|
307
|
+
* For model-issued tool calls the event fires at arg-prep time in the agent loop, before
|
|
308
|
+
* concurrency scheduling, `tool_execution_start`, and the approval gate: the revision is
|
|
309
|
+
* revalidated against the tool schema and becomes what the loop schedules, displays, persists,
|
|
310
|
+
* and executes — the user always approves what actually runs. For dispatches the loop never
|
|
311
|
+
* sees (nested `write xd://` device calls, Cursor direct execution) the tool wrapper applies
|
|
312
|
+
* the revision before its own approval gate; a revised nested xd:// input forfeits the outer
|
|
313
|
+
* write gate's approval and faces the full prompt again.
|
|
314
|
+
*/
|
|
315
|
+
input?: Record<string, unknown>;
|
|
299
316
|
}
|
|
300
317
|
|
|
301
318
|
/**
|