@mlx-node/agent 0.0.12 → 0.0.15
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/dist/catalog.d.ts +10 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +11 -2
- package/dist/delegate.d.ts +29 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +106 -0
- package/dist/extensions/delegation.d.ts +15 -0
- package/dist/extensions/delegation.d.ts.map +1 -0
- package/dist/extensions/delegation.js +93 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +16 -0
- package/dist/provider/chat-config.d.ts +6 -5
- package/dist/provider/chat-config.d.ts.map +1 -1
- package/dist/provider/chat-config.js +21 -7
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +8 -1
- package/dist/provider/model-host.d.ts +1 -1
- package/dist/provider/model-host.d.ts.map +1 -1
- package/dist/provider/model-host.js +25 -7
- package/dist/provider/models.d.ts +3 -14
- package/dist/provider/models.d.ts.map +1 -1
- package/dist/provider/models.js +17 -239
- package/dist/provider/stream-adapter.d.ts +2 -2
- package/dist/provider/stream-adapter.d.ts.map +1 -1
- package/dist/provider/stream-adapter.js +8 -5
- package/dist/run-agent.d.ts +4 -0
- package/dist/run-agent.d.ts.map +1 -1
- package/dist/run-agent.js +8 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -5
- package/src/catalog.ts +194 -0
- package/src/cold-tier.ts +152 -0
- package/src/delegate.ts +136 -0
- package/src/extensions/approval-detail.ts +57 -0
- package/src/extensions/delegation.ts +109 -0
- package/src/extensions/local-image-input.ts +132 -0
- package/src/extensions/permission-gate.ts +347 -0
- package/src/extensions/subagent.ts +743 -0
- package/src/extensions/terminal-title.ts +53 -0
- package/src/extensions/trace-notice.ts +37 -0
- package/src/index.ts +23 -0
- package/src/paths.ts +36 -0
- package/src/provider/chat-config.ts +132 -0
- package/src/provider/convert-messages.ts +273 -0
- package/src/provider/error-coercion.ts +36 -0
- package/src/provider/events.ts +341 -0
- package/src/provider/index.ts +255 -0
- package/src/provider/metrics-trace.ts +380 -0
- package/src/provider/mlx-identity.ts +16 -0
- package/src/provider/model-host.ts +276 -0
- package/src/provider/model-registry-filter.ts +336 -0
- package/src/provider/models.ts +48 -0
- package/src/provider/performance-status.ts +112 -0
- package/src/provider/reasoning-tag-buffer.ts +67 -0
- package/src/provider/stream-adapter.ts +515 -0
- package/src/provider/tool-call-buffer.ts +82 -0
- package/src/provider/warm-reuse.ts +125 -0
- package/src/run-agent.ts +178 -0
- package/src/types.ts +10 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach the local image path inserted by Pi's interactive clipboard handler.
|
|
3
|
+
*
|
|
4
|
+
* Pi 0.81.1 writes a pasted clipboard image to a temporary file and inserts
|
|
5
|
+
* only that absolute path into the editor. AgentSession's `input` event is the
|
|
6
|
+
* last seam where the path can be upgraded to `ImageContent` before the user
|
|
7
|
+
* message is committed. Keep this deliberately narrow:
|
|
8
|
+
*
|
|
9
|
+
* - TUI + interactive input only;
|
|
10
|
+
* - the entire input must be one absolute image path;
|
|
11
|
+
* - only `\ ` shell escapes (the macOS drag/paste shape) are decoded;
|
|
12
|
+
* - extension and magic bytes must both identify a supported image.
|
|
13
|
+
*
|
|
14
|
+
* Every failed check returns `continue`, preserving the original text exactly.
|
|
15
|
+
* Capability is intentionally not checked here: discovery advertises every
|
|
16
|
+
* local model as text-only until its first resident load. The stream adapter's
|
|
17
|
+
* authoritative `session.supportsImages()` decides whether native bytes or the
|
|
18
|
+
* existing text-model placeholder reaches inference.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { open } from 'node:fs/promises';
|
|
22
|
+
import { extname, isAbsolute } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
ExtensionAPI,
|
|
26
|
+
ExtensionContext,
|
|
27
|
+
InlineExtension,
|
|
28
|
+
InputEvent,
|
|
29
|
+
InputEventResult,
|
|
30
|
+
} from '@earendil-works/pi-coding-agent';
|
|
31
|
+
|
|
32
|
+
const MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
33
|
+
const IMAGE_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.png', '.webp']);
|
|
34
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
|
|
35
|
+
|
|
36
|
+
function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
|
|
37
|
+
return (
|
|
38
|
+
bytes.byteLength >= offset + signature.length && signature.every((byte, index) => bytes[offset + index] === byte)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startsWithAscii(bytes: Uint8Array, text: string, offset = 0): boolean {
|
|
43
|
+
return startsWith(
|
|
44
|
+
bytes,
|
|
45
|
+
Array.from(text, (character) => character.charCodeAt(0)),
|
|
46
|
+
offset,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectImageMimeType(bytes: Uint8Array): string | undefined {
|
|
51
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff]) && bytes[3] !== 0xf7) return 'image/jpeg';
|
|
52
|
+
if (startsWith(bytes, PNG_SIGNATURE) && startsWithAscii(bytes, 'IHDR', 12)) return 'image/png';
|
|
53
|
+
if (startsWithAscii(bytes, 'GIF87a') || startsWithAscii(bytes, 'GIF89a')) return 'image/gif';
|
|
54
|
+
if (startsWithAscii(bytes, 'RIFF') && startsWithAscii(bytes, 'WEBP', 8)) return 'image/webp';
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve only the standalone path shape produced by Pi/macOS paste and drag.
|
|
60
|
+
* This is not a shell parser: no quoting, expansion, commands, or general
|
|
61
|
+
* backslash processing is performed.
|
|
62
|
+
*/
|
|
63
|
+
function standaloneImagePath(text: string): string | undefined {
|
|
64
|
+
const trimmed = text.trim();
|
|
65
|
+
if (trimmed.length === 0 || trimmed.includes('\n') || trimmed.includes('\r')) return undefined;
|
|
66
|
+
|
|
67
|
+
const path = trimmed.replaceAll('\\ ', ' ');
|
|
68
|
+
if (!isAbsolute(path) || !IMAGE_EXTENSIONS.has(extname(path).toLowerCase())) return undefined;
|
|
69
|
+
return path;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @internal Exported for focused tests of Pi's input-transform contract. */
|
|
73
|
+
export async function attachStandaloneLocalImage(event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult> {
|
|
74
|
+
if (ctx.mode !== 'tui' || event.source !== 'interactive' || event.images?.length) {
|
|
75
|
+
return { action: 'continue' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const path = standaloneImagePath(event.text);
|
|
79
|
+
if (path === undefined) return { action: 'continue' };
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Open once so validation and reading stay bound to the same inode if
|
|
83
|
+
// the pathname is replaced. FileHandle.readFile() is intentionally not
|
|
84
|
+
// used: it may allocate from a file that grew after stat(). The exact,
|
|
85
|
+
// positional loop below caps allocation at the validated size.
|
|
86
|
+
const file = await open(path, 'r');
|
|
87
|
+
try {
|
|
88
|
+
const metadata = await file.stat();
|
|
89
|
+
if (!metadata.isFile() || metadata.size === 0 || metadata.size > MAX_LOCAL_IMAGE_BYTES) {
|
|
90
|
+
return { action: 'continue' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const bytes = Buffer.allocUnsafe(metadata.size);
|
|
94
|
+
let offset = 0;
|
|
95
|
+
while (offset < bytes.byteLength) {
|
|
96
|
+
const { bytesRead } = await file.read(bytes, offset, bytes.byteLength - offset, offset);
|
|
97
|
+
if (bytesRead === 0) return { action: 'continue' };
|
|
98
|
+
offset += bytesRead;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reject same-inode growth after fstat without ever allocating or
|
|
102
|
+
// reading more than one byte beyond the validated size.
|
|
103
|
+
const overflowProbe = Buffer.allocUnsafe(1);
|
|
104
|
+
if ((await file.read(overflowProbe, 0, 1, metadata.size)).bytesRead !== 0) {
|
|
105
|
+
return { action: 'continue' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const mimeType = detectImageMimeType(bytes);
|
|
109
|
+
if (mimeType === undefined) return { action: 'continue' };
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
action: 'transform',
|
|
113
|
+
text: event.text,
|
|
114
|
+
images: [{ type: 'image', mimeType, data: bytes.toString('base64') }],
|
|
115
|
+
};
|
|
116
|
+
} finally {
|
|
117
|
+
await file.close();
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// Missing, unreadable, or racing files remain ordinary user text.
|
|
121
|
+
return { action: 'continue' };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createLocalImageInputExtension(): InlineExtension {
|
|
126
|
+
return {
|
|
127
|
+
name: 'mlx-local-image-input',
|
|
128
|
+
factory: (pi: ExtensionAPI) => {
|
|
129
|
+
pi.on('input', attachStandaloneLocalImage);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPermissionGateExtension` — pi has no permission system of its
|
|
3
|
+
* own, so this inline extension is the product's v1 safety layer: every
|
|
4
|
+
* `bash` / `write` / `edit` / delegated `subagent` tool call must be approved before pi
|
|
5
|
+
* executes it.
|
|
6
|
+
*
|
|
7
|
+
* Behavior (settled design):
|
|
8
|
+
* - Interactive (`ctx.hasUI`): prompt via `ctx.ui.select` with the
|
|
9
|
+
* command (bash), file path (write/edit), or delegated task (subagent) as the detail line, passed
|
|
10
|
+
* through `sanitizeApprovalDetail` (control-byte encoding + length cap).
|
|
11
|
+
* "Always (this session)" allow-lists the tool name in memory for the
|
|
12
|
+
* lifetime of this extension instance.
|
|
13
|
+
* - Non-interactive: allow only when `MLX_AGENT_AUTO_APPROVE=1`,
|
|
14
|
+
* otherwise block with a reason naming the env var. Fail closed.
|
|
15
|
+
*
|
|
16
|
+
* Import discipline (load-bearing, same as the provider extension): pi
|
|
17
|
+
* is import-order sensitive to its config env vars, so this module must
|
|
18
|
+
* not runtime-import `@earendil-works/pi-coding-agent` at module top
|
|
19
|
+
* level — type-only imports appear here, and the event input is
|
|
20
|
+
* narrowed defensively by hand instead of via `isToolCallEventType`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import type { ExtensionAPI, ExtensionContext, InlineExtension, ToolCallEvent } from '@earendil-works/pi-coding-agent';
|
|
27
|
+
|
|
28
|
+
import { sanitizeApprovalDetail } from './approval-detail.js';
|
|
29
|
+
import { normalizeSubagentMode } from './subagent.js';
|
|
30
|
+
|
|
31
|
+
const GATED_TOOLS: ReadonlySet<string> = new Set(['bash', 'write', 'edit', 'subagent']);
|
|
32
|
+
|
|
33
|
+
const AUTO_APPROVE_ENV = 'MLX_AGENT_AUTO_APPROVE';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-layer snapshot of pi's `shellCommandPrefix`: the RAW value contributed by
|
|
37
|
+
* the global (`<agentDir>/settings.json`) and project (`<cwd>/.pi/settings.json`)
|
|
38
|
+
* settings layers. pi does NO type validation — `getShellCommandPrefix()` just
|
|
39
|
+
* returns the merged `settings.shellCommandPrefix` verbatim and `bash.js:213`
|
|
40
|
+
* bakes `commandPrefix ? \`${commandPrefix}\n${command}\` : command` — so the
|
|
41
|
+
* value can be ANY JSON type (`123`, `true`, `{…}`, `["a","b"]`, `""`, `null`).
|
|
42
|
+
* Each field is the layer's raw value, or `undefined` when that layer sets no
|
|
43
|
+
* prefix (absent key / cleared / dropped). `undefined` can only mean "layer
|
|
44
|
+
* absent" — a JSON value is never `undefined` — which is exactly what the
|
|
45
|
+
* presence-based merge below keys on. Kept per-layer (not pre-merged) so a
|
|
46
|
+
* `/reload` can update each layer with pi's exact per-layer RETENTION semantics
|
|
47
|
+
* before re-merging.
|
|
48
|
+
*/
|
|
49
|
+
interface ShellPrefixLayers {
|
|
50
|
+
global: unknown;
|
|
51
|
+
project: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Recompute ONE settings layer's `shellCommandPrefix` the way pi's
|
|
56
|
+
* `SettingsManager.reload()` does (`tryLoadFromStorage` → `loadFromStorage` +
|
|
57
|
+
* `withLock`, `dist/core/settings-manager.js`), given the layer's PRIOR value:
|
|
58
|
+
* - `!active` (untrusted project) → `undefined` (pi's `loadFromStorage` returns
|
|
59
|
+
* `{}` for an untrusted project, no error → the layer is CLEARED).
|
|
60
|
+
* - file ABSENT → `undefined` (`withLock` yields `current=undefined` → `{}`, no
|
|
61
|
+
* error → CLEARED).
|
|
62
|
+
* - file present + parseable object → its RAW `shellCommandPrefix` value if the
|
|
63
|
+
* key is present (ANY JSON type — pi does no validation), else `undefined`
|
|
64
|
+
* (REPLACE with the new value). Presence, not string-ness, decides: pi's
|
|
65
|
+
* merge keys on key-presence, and pi bakes any truthy value's `String(...)`.
|
|
66
|
+
* - file present but malformed / unreadable / a non-object (pi's
|
|
67
|
+
* `migrateSettings` does `"key" in settings`, which throws for a non-object) →
|
|
68
|
+
* `tryLoadFromStorage` returns an error → reload RETAINS the prior value.
|
|
69
|
+
*
|
|
70
|
+
* The retention branch is what closes the reload under-disclosure: on a FAILED
|
|
71
|
+
* reload pi keeps baking the prior prefix into BashTool, so the gate must keep
|
|
72
|
+
* showing it rather than degrade to empty. Whether `prior` is a real prior
|
|
73
|
+
* snapshot (reload) or a blank `undefined` baseline (a fresh lifecycle, which
|
|
74
|
+
* builds a NEW `SettingsManager` with boot semantics — bad file → empty, no
|
|
75
|
+
* retain) is decided by the CALLER (the `session_start` handler, on `reason`);
|
|
76
|
+
* this per-layer rule is identical either way.
|
|
77
|
+
*/
|
|
78
|
+
function resolveLayerPrefix(prior: unknown, path: string, active: boolean): unknown {
|
|
79
|
+
if (!active) {
|
|
80
|
+
return undefined; // untrusted project layer → dropped
|
|
81
|
+
}
|
|
82
|
+
if (!existsSync(path)) {
|
|
83
|
+
return undefined; // absent → pi clears the layer
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const content = readFileSync(path, 'utf-8');
|
|
87
|
+
if (!content) {
|
|
88
|
+
// Zero-byte file → pi's `loadFromStorage` short-circuits (`if (!content)
|
|
89
|
+
// return {}`, no error) → the layer is CLEARED (not retained). `!content`
|
|
90
|
+
// matches pi's exact truthiness, so whitespace-only (`" "`) is NOT empty:
|
|
91
|
+
// it reaches JSON.parse, throws, and falls to the retain branch below —
|
|
92
|
+
// exactly as pi errors and retains for it.
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const parsed: unknown = JSON.parse(content);
|
|
96
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
97
|
+
// pi's migrateSettings throws on a non-object → reload retains the prior.
|
|
98
|
+
return prior;
|
|
99
|
+
}
|
|
100
|
+
// Presence, NOT string-ness, decides — pi does no type validation and its
|
|
101
|
+
// key-presence merge treats a present non-string / falsy value as "set".
|
|
102
|
+
// Return the RAW value (any JSON type) when the key is present, else
|
|
103
|
+
// `undefined` so the presence-based merge sees this layer as unset.
|
|
104
|
+
return 'shellCommandPrefix' in parsed ? (parsed as Record<string, unknown>)['shellCommandPrefix'] : undefined;
|
|
105
|
+
} catch {
|
|
106
|
+
// Malformed JSON / unreadable (EACCES) → pi retains the prior layer value.
|
|
107
|
+
return prior;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Recompute both settings layers for one context, applying {@link
|
|
113
|
+
* resolveLayerPrefix} per layer with a DIRECT, lock-free read — deliberately
|
|
114
|
+
* NOT via pi's `SettingsManager`, whose load takes a proper-lockfile lock
|
|
115
|
+
* (creating/removing `<file>.lock`) and, under contention or a non-writable
|
|
116
|
+
* dir, degrades to `{}`. This reader never takes a lock and MIRRORS pi's reload
|
|
117
|
+
* retention, so it can never show empty where pi is still baking a non-empty
|
|
118
|
+
* prefix. `prior` carries the last snapshot so the retain branch is faithful.
|
|
119
|
+
*
|
|
120
|
+
* `getAgentDir`/`CONFIG_DIR_NAME` are imported at call time (deferred), so this
|
|
121
|
+
* module keeps its "no pi runtime import before env seeding" discipline; both
|
|
122
|
+
* are pure (env read lazily inside `getAgentDir`). NEVER throws: a hostile
|
|
123
|
+
* `ctx.isProjectTrusted` getter or a failed deferred import retains `prior`.
|
|
124
|
+
*
|
|
125
|
+
* ACCEPTED RESIDUAL (do NOT try to fix): pi's LOCKED read also retains on lock
|
|
126
|
+
* CONTENTION, which this lock-free reader cannot detect. So a `/reload` that
|
|
127
|
+
* simultaneously (i) contends pi's settings lock and (ii) sees the file
|
|
128
|
+
* concurrently reduced to empty could make pi retain a non-empty prefix while
|
|
129
|
+
* this reader observes empty and under-discloses. That needs a concurrent
|
|
130
|
+
* lock-holder emptying the file at the reload instant — an actor with settings
|
|
131
|
+
* write access who could instead just inject a (disclosed) prefix directly, so
|
|
132
|
+
* it grants no escalation. Left as an extreme-adversarial residual.
|
|
133
|
+
*/
|
|
134
|
+
async function resolveShellPrefixLayers(prior: ShellPrefixLayers, ctx: ExtensionContext): Promise<ShellPrefixLayers> {
|
|
135
|
+
try {
|
|
136
|
+
const { getAgentDir, CONFIG_DIR_NAME } = await import('@earendil-works/pi-coding-agent');
|
|
137
|
+
const trusted = ctx.isProjectTrusted();
|
|
138
|
+
return {
|
|
139
|
+
global: resolveLayerPrefix(prior.global, join(getAgentDir(), 'settings.json'), true),
|
|
140
|
+
project: resolveLayerPrefix(prior.project, join(ctx.cwd, CONFIG_DIR_NAME, 'settings.json'), trusted),
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return prior;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge the two layers exactly as pi's `deepMergeSettings(global, project)` +
|
|
149
|
+
* `getShellCommandPrefix()` do, returning the RAW merged value (any JSON type or
|
|
150
|
+
* `undefined`). pi's `deepMergeSettings` overrides on KEY-PRESENCE: it iterates
|
|
151
|
+
* `Object.keys(project)` and, for any `shellCommandPrefix` whose value is not
|
|
152
|
+
* `undefined`, the project value wins — so a present project `""` / `0` /
|
|
153
|
+
* `false` / `null` overrides the global one, and only an ABSENT project key
|
|
154
|
+
* (our `undefined`) falls through to global. A `??` merge would be WRONG here:
|
|
155
|
+
* it would let a present project `null` / `""` / `0` / `false` fall through to
|
|
156
|
+
* global, disagreeing with pi. Coercion to a string (and the `''` empty default)
|
|
157
|
+
* happens once at the call site, mirroring `bash.js:213`.
|
|
158
|
+
*/
|
|
159
|
+
function mergedPrefixRaw(layers: ShellPrefixLayers): unknown {
|
|
160
|
+
return layers.project !== undefined ? layers.project : layers.global;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `session_start` reasons that build a FRESH `SettingsManager` (boot semantics:
|
|
165
|
+
* a bad/malformed file → empty, NEVER a retain). pi's `SettingsManager.reload()`
|
|
166
|
+
* — reason `'reload'` — is its ONLY retain-on-error path, so ONLY a reload may
|
|
167
|
+
* inherit the prior snapshot; these fresh lifecycles must restart from a blank
|
|
168
|
+
* baseline or the gate would show a prior session's prefix that pi won't
|
|
169
|
+
* execute (over-disclosure). Any unknown/missing reason falls OUTSIDE this set
|
|
170
|
+
* and therefore retains — over-disclosing a stale prefix is safe; under-
|
|
171
|
+
* disclosing an executed one is not. Verified against pi 0.80.6 `SessionStartEvent`.
|
|
172
|
+
*/
|
|
173
|
+
const FRESH_SESSION_REASONS: ReadonlySet<string> = new Set(['startup', 'new', 'resume', 'fork']);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Derive the human-readable detail line for the approval prompt.
|
|
177
|
+
* Defensive on purpose: a malformed or missing `event.input` must never
|
|
178
|
+
* throw — a handler error would fail closed upstream, but the prompt
|
|
179
|
+
* should still render and let the user decide.
|
|
180
|
+
*/
|
|
181
|
+
function describeToolCall(toolName: string, event: ToolCallEvent, defaultCwd?: string): string {
|
|
182
|
+
const rawInput: unknown = (event as { input?: unknown }).input;
|
|
183
|
+
const input: Record<string, unknown> =
|
|
184
|
+
typeof rawInput === 'object' && rawInput !== null ? (rawInput as Record<string, unknown>) : {};
|
|
185
|
+
if (toolName === 'bash') {
|
|
186
|
+
const command = input['command'];
|
|
187
|
+
return typeof command === 'string' && command.length > 0 ? command : '(unknown command)';
|
|
188
|
+
}
|
|
189
|
+
if (toolName === 'subagent') {
|
|
190
|
+
const agent = typeof input['agent'] === 'string' ? input['agent'] : undefined;
|
|
191
|
+
const task = typeof input['task'] === 'string' ? input['task'] : undefined;
|
|
192
|
+
const scope = typeof input['agentScope'] === 'string' ? input['agentScope'] : 'user';
|
|
193
|
+
const tasks = Array.isArray(input['tasks']) ? input['tasks'] : [];
|
|
194
|
+
const chainItems = Array.isArray(input['chain']) ? input['chain'] : [];
|
|
195
|
+
const { mode } = normalizeSubagentMode({ agent, task, tasks, chain: chainItems });
|
|
196
|
+
const describeItems = (value: unknown): string[] =>
|
|
197
|
+
Array.isArray(value)
|
|
198
|
+
? value.map((raw, index) => {
|
|
199
|
+
const item = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {};
|
|
200
|
+
const itemAgent = typeof item['agent'] === 'string' ? item['agent'] : '(unknown agent)';
|
|
201
|
+
const itemTask = typeof item['task'] === 'string' ? item['task'] : '(unknown task)';
|
|
202
|
+
const resolvedCwd = typeof item['cwd'] === 'string' ? item['cwd'] : defaultCwd;
|
|
203
|
+
const cwd = resolvedCwd ? ` [cwd: ${resolvedCwd}]` : '';
|
|
204
|
+
return `${index + 1}. ${itemAgent}: ${itemTask}${cwd}`;
|
|
205
|
+
})
|
|
206
|
+
: [];
|
|
207
|
+
const items = describeItems(tasks);
|
|
208
|
+
const chain = describeItems(chainItems);
|
|
209
|
+
const singleCwd = typeof input['cwd'] === 'string' ? input['cwd'] : defaultCwd;
|
|
210
|
+
const single = `${agent ?? '(unknown agent)'}: ${task ?? '(unknown task)'}${singleCwd ? ` [cwd: ${singleCwd}]` : ''}`;
|
|
211
|
+
const summary =
|
|
212
|
+
mode === 'chain'
|
|
213
|
+
? `Chain: ${chain.join(' | ')}`
|
|
214
|
+
: mode === 'parallel'
|
|
215
|
+
? `Queued tasks: ${items.join(' | ')}`
|
|
216
|
+
: single;
|
|
217
|
+
return [
|
|
218
|
+
'Delegated agent sessions may use bash/write/edit without further prompts.',
|
|
219
|
+
`Execution mode: ${mode}`,
|
|
220
|
+
`Agent scope: ${scope}`,
|
|
221
|
+
summary,
|
|
222
|
+
].join('\n');
|
|
223
|
+
}
|
|
224
|
+
// write/edit: pi's canonical field is `path`; `file_path` is the
|
|
225
|
+
// compat alias pi's own renderers also accept.
|
|
226
|
+
const path = typeof input['path'] === 'string' ? input['path'] : input['file_path'];
|
|
227
|
+
return typeof path === 'string' && path.length > 0 ? path : '(unknown path)';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the `mlx-permission-gate` inline extension. The per-session allow list
|
|
232
|
+
* lives in the `factory` closure, so every extension load (session start or
|
|
233
|
+
* `/reload`) starts with a clean slate. The bash-prefix snapshot, by contrast,
|
|
234
|
+
* lives in THIS outer closure so it PERSISTS across factory reinvocations.
|
|
235
|
+
*/
|
|
236
|
+
export function createPermissionGateExtension(): InlineExtension {
|
|
237
|
+
// Per-layer snapshot of pi's bash `shellCommandPrefix`, recomputed at each
|
|
238
|
+
// `session_start` — which fires at boot AFTER pi bakes the prefix into
|
|
239
|
+
// BashTool and again on `/reload` AFTER the rebuild, i.e. the SAME lifecycle
|
|
240
|
+
// instant pi bakes it. Kept per-layer (not pre-merged) so each reload applies
|
|
241
|
+
// pi's exact RETENTION rule: a failed layer reload keeps baking the prior
|
|
242
|
+
// value, so we keep showing it. `snapshotted` tells a real empty snapshot
|
|
243
|
+
// apart from "no session_start yet" (which falls back to a one-shot on-demand
|
|
244
|
+
// read). Snapshot-primary is FAITHFUL: it shows exactly what pi baked, even
|
|
245
|
+
// after an edit-without-reload where an on-demand re-read would drift.
|
|
246
|
+
//
|
|
247
|
+
// MUST live here, not in `factory`: pi re-invokes the inline extension factory
|
|
248
|
+
// on every `/reload` (resource-loader `loadExtensionFactories`) BEFORE
|
|
249
|
+
// emitting the reload `session_start`. If this state were reset per factory
|
|
250
|
+
// run, a failed reload (malformed/unreadable file) would `retain` against a
|
|
251
|
+
// freshly-reset `undefined` and drop pi's still-baked prefix — the exact
|
|
252
|
+
// under-disclosure the retain rule exists to prevent.
|
|
253
|
+
let layers: ShellPrefixLayers = { global: undefined, project: undefined };
|
|
254
|
+
let snapshotted = false;
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
name: 'mlx-permission-gate',
|
|
258
|
+
factory: (pi: ExtensionAPI) => {
|
|
259
|
+
const sessionAllowed = new Set<string>();
|
|
260
|
+
|
|
261
|
+
pi.on('session_start', async (event, ctx) => {
|
|
262
|
+
// Only a /reload (pi's sole retain-on-error path) may inherit the prior
|
|
263
|
+
// snapshot. A fresh lifecycle (startup/new/resume/fork) builds a NEW
|
|
264
|
+
// SettingsManager with boot semantics (bad file → empty), so it must
|
|
265
|
+
// start from a blank baseline or we'd retain a prefix pi won't execute.
|
|
266
|
+
// Unknown/missing reason → retain (over-disclose is safe; under-disclose
|
|
267
|
+
// is not); read defensively since a hostile event need not be well-typed.
|
|
268
|
+
const reason: unknown = (event as { reason?: unknown }).reason;
|
|
269
|
+
const fresh = typeof reason === 'string' && FRESH_SESSION_REASONS.has(reason);
|
|
270
|
+
const prior: ShellPrefixLayers = fresh ? { global: undefined, project: undefined } : layers;
|
|
271
|
+
layers = await resolveShellPrefixLayers(prior, ctx);
|
|
272
|
+
snapshotted = true;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
276
|
+
const toolName: unknown = (event as { toolName?: unknown }).toolName;
|
|
277
|
+
if (typeof toolName !== 'string' || !GATED_TOOLS.has(toolName)) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
if (sessionAllowed.has(toolName)) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!ctx.hasUI) {
|
|
285
|
+
if (process.env[AUTO_APPROVE_ENV] === '1') {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
block: true,
|
|
290
|
+
reason: `Blocked ${toolName}: no interactive UI to approve it (set ${AUTO_APPROVE_ENV}=1 to auto-approve)`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Defense in depth: the detail is model-controlled text and this
|
|
295
|
+
// title is rendered by a third-party TUI that passes ANSI through.
|
|
296
|
+
// For bash, prepend pi's effective `shellCommandPrefix` so the prompt
|
|
297
|
+
// shows the full program pi will execute, not just the model's arg.
|
|
298
|
+
const command = describeToolCall(toolName, event, ctx.cwd);
|
|
299
|
+
let detailSource = command;
|
|
300
|
+
if (toolName === 'bash') {
|
|
301
|
+
// Snapshot is primary (faithful to pi's baked value, incl. reload
|
|
302
|
+
// retention). Only if a bash approval somehow precedes the first
|
|
303
|
+
// session_start do we fall back to a one-shot on-demand read from a
|
|
304
|
+
// clean baseline (lock-free, never throws). A snapshotted empty prefix
|
|
305
|
+
// is authoritative — it is NOT treated as "missing" — so we never
|
|
306
|
+
// re-read over a deliberate empty bake.
|
|
307
|
+
//
|
|
308
|
+
// Coerce ONCE here, byte-identical to pi's `bash.js:213`
|
|
309
|
+
// (`commandPrefix ? \`${commandPrefix}\n${command}\` : command`): the
|
|
310
|
+
// `raw ?` truthiness gate mirrors pi (falsy `0`/`""`/`false`/`null`/
|
|
311
|
+
// absent → bare command), and `String(raw)` mirrors the template
|
|
312
|
+
// coercion pi applies to any truthy value (`123`→`123`, `true`→`true`,
|
|
313
|
+
// `{…}`→`[object Object]`, `["a","b"]`→`a,b`). Using `String(raw)`
|
|
314
|
+
// rather than a bare `${raw}` keeps this well-typed on `unknown`.
|
|
315
|
+
const raw = snapshotted
|
|
316
|
+
? mergedPrefixRaw(layers)
|
|
317
|
+
: mergedPrefixRaw(await resolveShellPrefixLayers({ global: undefined, project: undefined }, ctx));
|
|
318
|
+
// The `[object Object]` / `a,b` default stringification is DELIBERATE
|
|
319
|
+
// here — it is exactly what pi's `${commandPrefix}` template bakes for a
|
|
320
|
+
// non-string prefix, and disclosing pi's actual bytes is the whole point.
|
|
321
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
322
|
+
detailSource = raw ? `${String(raw)}\n${command}` : command;
|
|
323
|
+
}
|
|
324
|
+
const detail = sanitizeApprovalDetail(detailSource);
|
|
325
|
+
const title = toolName === 'subagent' ? 'Allow delegated subagent tool access?' : `Allow ${toolName}?`;
|
|
326
|
+
// Bind the dialog to the active agent operation. Pi's selector only
|
|
327
|
+
// resolves on Ctrl+C/abort when the extension forwards this signal;
|
|
328
|
+
// otherwise the UI can disappear while this awaited tool_call hook
|
|
329
|
+
// remains pending and keeps the whole tool batch suspended.
|
|
330
|
+
const choice = await ctx.ui.select(`${title}\n\n ${detail}`, ['Yes', 'Always (this session)', 'No'], {
|
|
331
|
+
signal: ctx.signal,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (choice === 'Yes') {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
if (choice === 'Always (this session)') {
|
|
338
|
+
sessionAllowed.add(toolName);
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
// 'No', a dismissed dialog (undefined), or anything unexpected:
|
|
342
|
+
// fail closed.
|
|
343
|
+
return { block: true, reason: 'Blocked by user' };
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|