@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.
Files changed (61) hide show
  1. package/dist/catalog.d.ts +10 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +11 -2
  4. package/dist/delegate.d.ts +29 -0
  5. package/dist/delegate.d.ts.map +1 -0
  6. package/dist/delegate.js +106 -0
  7. package/dist/extensions/delegation.d.ts +15 -0
  8. package/dist/extensions/delegation.d.ts.map +1 -0
  9. package/dist/extensions/delegation.js +93 -0
  10. package/dist/paths.d.ts +6 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +16 -0
  13. package/dist/provider/chat-config.d.ts +6 -5
  14. package/dist/provider/chat-config.d.ts.map +1 -1
  15. package/dist/provider/chat-config.js +21 -7
  16. package/dist/provider/index.d.ts.map +1 -1
  17. package/dist/provider/index.js +8 -1
  18. package/dist/provider/model-host.d.ts +1 -1
  19. package/dist/provider/model-host.d.ts.map +1 -1
  20. package/dist/provider/model-host.js +25 -7
  21. package/dist/provider/models.d.ts +3 -14
  22. package/dist/provider/models.d.ts.map +1 -1
  23. package/dist/provider/models.js +17 -239
  24. package/dist/provider/stream-adapter.d.ts +2 -2
  25. package/dist/provider/stream-adapter.d.ts.map +1 -1
  26. package/dist/provider/stream-adapter.js +8 -5
  27. package/dist/run-agent.d.ts +4 -0
  28. package/dist/run-agent.d.ts.map +1 -1
  29. package/dist/run-agent.js +8 -2
  30. package/dist/types.d.ts +1 -1
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +23 -5
  33. package/src/catalog.ts +194 -0
  34. package/src/cold-tier.ts +152 -0
  35. package/src/delegate.ts +136 -0
  36. package/src/extensions/approval-detail.ts +57 -0
  37. package/src/extensions/delegation.ts +109 -0
  38. package/src/extensions/local-image-input.ts +132 -0
  39. package/src/extensions/permission-gate.ts +347 -0
  40. package/src/extensions/subagent.ts +743 -0
  41. package/src/extensions/terminal-title.ts +53 -0
  42. package/src/extensions/trace-notice.ts +37 -0
  43. package/src/index.ts +23 -0
  44. package/src/paths.ts +36 -0
  45. package/src/provider/chat-config.ts +132 -0
  46. package/src/provider/convert-messages.ts +273 -0
  47. package/src/provider/error-coercion.ts +36 -0
  48. package/src/provider/events.ts +341 -0
  49. package/src/provider/index.ts +255 -0
  50. package/src/provider/metrics-trace.ts +380 -0
  51. package/src/provider/mlx-identity.ts +16 -0
  52. package/src/provider/model-host.ts +276 -0
  53. package/src/provider/model-registry-filter.ts +336 -0
  54. package/src/provider/models.ts +48 -0
  55. package/src/provider/performance-status.ts +112 -0
  56. package/src/provider/reasoning-tag-buffer.ts +67 -0
  57. package/src/provider/stream-adapter.ts +515 -0
  58. package/src/provider/tool-call-buffer.ts +82 -0
  59. package/src/provider/warm-reuse.ts +125 -0
  60. package/src/run-agent.ts +178 -0
  61. 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
+ }