@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
package/src/catalog.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Curated model catalog for `mlx agent`.
3
+ *
4
+ * The first-run download wizard offers `visibleCatalog()` and feeds the chosen
5
+ * entry's `catalogRepo()` to `mlx download model`. Slugs are verified against the Brooooooklyn
6
+ * HF account — use them verbatim.
7
+ */
8
+
9
+ import { QWEN38_DFLASH2 } from '@mlx-node/lm/draft-companion';
10
+
11
+ export interface CatalogEntry {
12
+ /** Wizard display name. */
13
+ label: string;
14
+ /** HF slug for `mlx download model` on Apple Silicon — the MXFP4 build. */
15
+ hfRepo: string;
16
+ /**
17
+ * HF slug on Linux + NVIDIA CUDA — the NVFP4 build.
18
+ *
19
+ * Absent means the entry has no CUDA-specific build and {@link hfRepo}
20
+ * serves both. Resolve with {@link catalogRepo}, never by reading the field.
21
+ */
22
+ hfRepoCuda?: string;
23
+ /** Approximate download size in GB, for display. */
24
+ sizeGb: number;
25
+ /** Optional companion, downloaded separately and never offered as a chat model. */
26
+ draft?: { label: string; hfRepo: string; sizeGb: number };
27
+ /** One line for the wizard. */
28
+ description: string;
29
+ /** Exactly one entry carries this. */
30
+ isDefault?: boolean;
31
+ /**
32
+ * The repo is not published yet, so no UI may offer it as a download.
33
+ *
34
+ * Two consumers honour this: the agent wizard via {@link visibleCatalog},
35
+ * and the dashboard Models page, which filters `!item.hidden` before
36
+ * rendering cards (`packages/dashboard/ui/src/pages/models.tsx`).
37
+ * `catalogWithState()` deliberately keeps hidden entries so the UI, not the
38
+ * dashboard core, decides.
39
+ *
40
+ * Also honoured by the download allowlist: `DownloadManager.start`
41
+ * (`packages/dashboard/src/download.ts`) refuses a hidden repo up front
42
+ * rather than allocating a job that fails mid-download with a 401 from
43
+ * Hugging Face. No UI reaches that path for a hidden entry, but a direct
44
+ * API call does.
45
+ */
46
+ hidden?: boolean;
47
+ }
48
+
49
+ export const MODEL_CATALOG: readonly CatalogEntry[] = [
50
+ {
51
+ label: 'Qwen3.8-27B',
52
+ hfRepo: 'Brooooooklyn/Qwen3.8-27B-MXFP4-mlx',
53
+ hfRepoCuda: 'Brooooooklyn/Qwen3.8-27B-NVFP4-mlx',
54
+ sizeGb: 23.3,
55
+ description: 'Best tool use — recommended default',
56
+ isDefault: true,
57
+ draft: QWEN38_DFLASH2,
58
+ },
59
+ {
60
+ label: 'Qwen-AgentWorld-35B-A3B',
61
+ hfRepo: 'Brooooooklyn/Qwen-AgentWorld-35B-A3B-mxfp4-mlx',
62
+ hfRepoCuda: 'Brooooooklyn/Qwen-AgentWorld-35B-A3B-nvfp4-mlx',
63
+ sizeGb: 23.3,
64
+ description: 'Agent-tuned MoE, fast decode',
65
+ },
66
+ {
67
+ label: 'Gemma-4-26B-A4B',
68
+ hfRepo: 'Brooooooklyn/Gemma-4-26B-A4B-Unsloth-MXFP4-mlx',
69
+ hfRepoCuda: 'Brooooooklyn/Gemma-4-26B-A4B-Unsloth-NVFP4-mlx',
70
+ sizeGb: 16.2,
71
+ description: 'MoE, fast decode',
72
+ },
73
+ {
74
+ // Produced + validated locally as mxfp4 (MLP) + mxfp8 (attention) via
75
+ // `mlx convert --q-recipe nvidia` on gemma-4-12b-it (coherent + tool
76
+ // calling through `mlx agent`). Provisional slug — the user finalizes it
77
+ // on HF upload; entry stays hidden until the repo exists.
78
+ label: 'Gemma-4-12B',
79
+ hfRepo: 'Brooooooklyn/Gemma-4-12B-IT-mxfp-mlx',
80
+ sizeGb: 8.6,
81
+ description: 'Compact (mxfp4 MLP + mxfp8 attention), fits smaller machines',
82
+ hidden: true,
83
+ },
84
+ {
85
+ // NOT DOWNLOADABLE. This repo does not exist: the slug below is a
86
+ // placeholder the user has not uploaded to, and Hugging Face answers 401
87
+ // for it. Nothing may offer it as an install until the upload happens and
88
+ // `hidden` is dropped — and do not guess a substitute slug, because a
89
+ // wrong-but-live repo would install the wrong weights silently.
90
+ //
91
+ // The only route to this model today is local conversion from NVIDIA's
92
+ // modelopt checkpoint:
93
+ // mlx convert -m nemotron_h \
94
+ // -i <nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4> \
95
+ // -o <models>/nemotron-3.5-lightning-30b-a3b-nvfp4-mlx
96
+ // (NVFP4 preserved byte-for-byte; the FP8 Mamba-2 projections are
97
+ // re-quantized. See docs/cli.md "modelopt NVFP4 ingest".)
98
+ //
99
+ // The entry is kept — rather than deleted — so the wizard, the dashboard
100
+ // catalog state, and `catalogSlug()` recognize a locally converted
101
+ // checkpoint sitting at the canonical slug.
102
+ label: 'Nemotron-3.5-Lightning-30B-A3B',
103
+ hfRepo: 'Brooooooklyn/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-nvfp4-mlx',
104
+ sizeGb: 23,
105
+ description: 'Hybrid Mamba-2 + MoE, native MTP, 1M context',
106
+ hidden: true,
107
+ },
108
+ ];
109
+
110
+ /** Visible target repos plus their optional companion downloads. */
111
+ export function catalogDownloadRepos(): string[] {
112
+ return [
113
+ ...new Set(
114
+ MODEL_CATALOG.filter((entry) => !entry.hidden).flatMap((entry) =>
115
+ entry.draft === undefined ? [catalogRepo(entry)] : [catalogRepo(entry), entry.draft.hfRepo],
116
+ ),
117
+ ),
118
+ ];
119
+ }
120
+
121
+ /**
122
+ * The repo THIS platform installs for `entry`. Linux is the CUDA preview
123
+ * target (README "Platform Support"); everything else is Apple Silicon.
124
+ *
125
+ * NOT because Metal cannot run NVFP4 — it can. MLX ships the same 234
126
+ * quantized Metal kernels for `nvfp4` as for `mxfp4`, NAX variants included,
127
+ * and only `fp8_e4m3` reconstructs BF16 at load. The MSL 4.1 hardware
128
+ * block-scale format (`metal_fp8_ue8m0_format`) appears nowhere in MLX's Metal
129
+ * backend, so it constrains neither format here.
130
+ *
131
+ * The split is:
132
+ * - CUDA takes NVFP4 through `CublasQQMM` (`nvfp4` -> `CUDA_R_4F_E2M1`), a
133
+ * native path with no MXFP4 equivalent.
134
+ * - Metal has no such native-path advantage either way, so prefer the format
135
+ * that survives quantization better. NVFP4 stores a block scale as `amax/6`
136
+ * in E4M3, and real FFN blocks land in its subnormal band;
137
+ * `apply_nvfp4_pow2_lift` repairs that for dense SwiGLU FFNs but SKIPS MoE
138
+ * experts by design (`NVFP4_LIFT_MOE_MARKERS`, `crates/mlx-core/src/
139
+ * convert.rs`), because the norm there also drives the router and the
140
+ * shared-expert gate, neither scale-invariant. Two of the three visible
141
+ * entries are MoE. MXFP4's E8M0 block scales have no such failure.
142
+ *
143
+ * Unmeasured: whether nvfp4 or mxfp4 decodes faster on Metal. The choice above
144
+ * is made on quantization quality, not throughput.
145
+ *
146
+ * Every consumer that turns a catalog entry into a download, a slug, or an
147
+ * allowlist check must go through here. Reading `entry.hfRepo` directly
148
+ * installs the macOS build on a CUDA box.
149
+ */
150
+ export function catalogRepo(entry: CatalogEntry): string {
151
+ return catalogRepoFor(entry, process.platform);
152
+ }
153
+
154
+ /**
155
+ * {@link catalogRepo} with the platform passed in — the pure half.
156
+ *
157
+ * Exists so both branches can be asserted without touching `process.platform`.
158
+ * Mutating that global leaks across test files sharing a worker: it made
159
+ * `catalogRepo` disagree with a sibling suite's module-level constant and fail
160
+ * a download allowlist check that has nothing to do with the catalog.
161
+ */
162
+ export function catalogRepoFor(entry: CatalogEntry, platform: NodeJS.Platform): string {
163
+ return platform === 'linux' && entry.hfRepoCuda !== undefined ? entry.hfRepoCuda : entry.hfRepo;
164
+ }
165
+
166
+ /** Catalog entries the wizard offers (hidden entries filtered out). */
167
+ export function visibleCatalog(): CatalogEntry[] {
168
+ return MODEL_CATALOG.filter((entry) => !entry.hidden);
169
+ }
170
+
171
+ /**
172
+ * Cold-tier facts and family registration data, re-exported through this
173
+ * subpath.
174
+ *
175
+ * `@mlx-node/agent/catalog` is a NATIVE-FREE entry point, alongside the
176
+ * `delegate` client and `models` discovery subpaths:
177
+ * the package root re-exports `provider/index.ts`, which value-imports
178
+ * `@mlx-node/core`. The dashboard is a separate viewer process that must never
179
+ * link the addon (docs/dashboard.md: "no Metal init, instant start"), and
180
+ * `mlx agent --help` must print without loading weights — so both reach the
181
+ * cold-tier allowlist, the cache-root canonicalizer, and the family detection
182
+ * data through here.
183
+ *
184
+ * Every module reachable from here must therefore stay free of runtime addon
185
+ * imports. `packages/agent/__test__/catalog-native-free.test.ts` gates that in a
186
+ * real subprocess.
187
+ */
188
+ export { COLD_TIER_RESTORE_FAMILIES, canonicalCacheRoot, coldTierRestoreFamilyList } from './cold-tier.js';
189
+ export {
190
+ CHAT_FAMILY_IDS,
191
+ matchFamily,
192
+ NON_GENERATIVE_FAMILY_IDS,
193
+ rawModelTypeToCanonical,
194
+ } from '@mlx-node/lm/family-data';
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Cold-tier facts shared by the agent (writer), the CLI (help text) and the
3
+ * dashboard (reader), kept in a NATIVE-FREE leaf module.
4
+ *
5
+ * `provider/model-host.ts` value-imports `@mlx-node/lm`, which loads the native
6
+ * addon; the dashboard is a separate viewer process that must never link it
7
+ * ("no Metal init, instant start" — docs/dashboard.md), and `mlx agent --help`
8
+ * must print without loading weights. This module therefore has NO runtime
9
+ * imports beyond `node:` builtins, and reaches those consumers through the
10
+ * existing native-free `@mlx-node/agent/catalog` subpath, which re-exports it.
11
+ * There is deliberately no `./cold-tier` entry in `package.json`: a second
12
+ * published subpath would be a second thing to keep native-free, and `catalog`
13
+ * already carries that guarantee.
14
+ *
15
+ * Everything here is a single source of truth. Do not copy the family list or
16
+ * re-implement the root canonicalization at a call site; import them.
17
+ */
18
+
19
+ import { realpathSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { basename, dirname, join, resolve } from 'node:path';
22
+
23
+ import type { ModelType } from '@mlx-node/lm';
24
+
25
+ /**
26
+ * Model families whose paged prefix blocks can be restored from the SSD cold
27
+ * tier soundly. The single source of truth on the TypeScript side, mirroring
28
+ * `COLD_RESTORE_FAMILIES` in `crates/mlx-core/src/cold_tier.rs`; the two gate
29
+ * the same decision from opposite ends, and
30
+ * `packages/agent/__test__/cold-tier-families.test.ts` asserts they never
31
+ * drift.
32
+ *
33
+ * A family belongs here only when EVERY piece of per-token state its forward
34
+ * pass carries between turns is reconstructible from the tier — either because
35
+ * it all lives inside the paged pool, or because the part that does not is
36
+ * persisted alongside as a cold-tier sidecar and the restore reconciles down to
37
+ * a boundary that sidecar actually backs:
38
+ *
39
+ * - `qwen3` (dense) sizes its pool over all layers, so the pool holds the
40
+ * complete KV for the prefix and needs no sidecar.
41
+ * - `gemma4` owns full and sliding attention in distinct paged groups. The
42
+ * full-attention chain is authoritative and one grouped sliding sidecar
43
+ * restores every sliding group at the same exact boundary, atomically.
44
+ * - `muse_glimmer` uses the same hybrid contract for its 13 full-attention
45
+ * and 39 sliding-attention layers.
46
+ * - `qwen3_5` (dense) sizes its pool over the full-attention layers only, but
47
+ * persists its out-of-pool GDN recurrent state (conv + recurrent) as a
48
+ * `ColdGroup::GdnState` sidecar, and its `ColdSidecarPolicy` reconciles the
49
+ * native restore down to the deepest block-aligned boundary a validated
50
+ * sidecar backs (a recurrent state is valid only at its exact prefix length).
51
+ * - `qwen3_5_moe` keeps the SAME GDN recurrent state outside the pool — same
52
+ * shapes, same dtype, same layer mapping — so it shares the dense family's
53
+ * sidecar codec and `ColdSidecarPolicy` verbatim. Its restart-parity gate has
54
+ * since been run on `Qwen3.6-35B-A3B-mxfp4-mlx`: the restore reconciled
55
+ * onto ladder rung 304 of `[16, 64, 304, 1248]` with `hits=42` and
56
+ * `corruptions=0`, and the text matched a no-persist baseline.
57
+ * - `lfm2` / `lfm2_moe` keep ShortConv state outside the full-attention pool.
58
+ * A `ColdGroup::ConvState` sidecar persists that state at the same exact
59
+ * boundary; missing or malformed state reconciles down or restarts at zero.
60
+ *
61
+ * Widening this set is a correctness decision, never a perf one, and it is
62
+ * authorized by exactly one thing: the family's native restart-parity gate
63
+ * passing on real weights with `hits > 0` and `corruptions == 0`.
64
+ *
65
+ * `mlx agent --no-persist-cache` applies to EVERY family in this set, not to
66
+ * one of them: it is a SINGLE process-wide boolean handed to every load whose
67
+ * family is on the allowlist (`MlxModelHost.runWithResident` →
68
+ * `resolveModelPathFn(entry, { persistPagedCache })`). Families off the
69
+ * allowlist are handed no policy at all — not because the flag spares them, but
70
+ * because `cold_tier::resolve_persist_cold` refuses to persist them under ANY
71
+ * setting. The help text says so in exactly one place, built from
72
+ * {@link coldTierRestoreFamilyList} in `packages/cli/src/commands/agent/index.ts`
73
+ * and asserted by `packages/cli/__test__/agent-cmd.test.ts`.
74
+ */
75
+ export const COLD_TIER_RESTORE_FAMILIES: ReadonlySet<string> = new Set<ModelType>([
76
+ 'qwen3',
77
+ 'qwen3_5',
78
+ 'qwen3_5_moe',
79
+ 'gemma4',
80
+ 'muse_glimmer',
81
+ 'lfm2',
82
+ 'lfm2_moe',
83
+ ]);
84
+
85
+ /** Allowlisted families in a stable, human-facing order (help text, API payload). */
86
+ export function coldTierRestoreFamilyList(): string[] {
87
+ return [...COLD_TIER_RESTORE_FAMILIES].sort();
88
+ }
89
+
90
+ /**
91
+ * Expand a leading `~` / `~/…` against the current home directory. A bare `~`
92
+ * prefix that is part of a longer name (`~cache`) is left alone — only the
93
+ * shell's own two spellings are expanded.
94
+ */
95
+ function expandTilde(path: string): string {
96
+ if (path === '~') return homedir();
97
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2));
98
+ return path;
99
+ }
100
+
101
+ /**
102
+ * Canonical identity for a cold-cache root, used as the JOIN KEY between the
103
+ * agent that wrote a trace and the dashboard that reads it back.
104
+ *
105
+ * The two live in DIFFERENT PROCESSES resolving the root from their own
106
+ * environments: the native tier reports `manager.root().display()` (the path as
107
+ * constructed, never realpath'd) while the dashboard builds its own from
108
+ * `MLX_COLD_CACHE_DIR` or the `~/.mlx-node` default. On macOS `/tmp` is a
109
+ * symlink to `/private/tmp`, so two identical-looking spellings canonicalize
110
+ * differently — a raw string compare is a silent-zero trap, not a loud failure.
111
+ * Both sides MUST route through this one function.
112
+ *
113
+ * Resolution order: expand `~`, make absolute, then `realpath` the DEEPEST
114
+ * EXISTING ANCESTOR and re-attach every missing segment lexically. A tier that
115
+ * has never been opened has no root directory — that is the normal state, not
116
+ * an edge case — and when `MLX_COLD_CACHE_DIR` itself has never been created
117
+ * the parent is missing too. Stopping after one level returned a wholly
118
+ * UNcanonicalized path, so under a symlinked ancestor (`/tmp` → `/private/tmp`
119
+ * on macOS) the reader's key diverged from the writer's and rows from the SAME
120
+ * cache were reported as "a different cache directory". Only a path with no
121
+ * resolvable ancestor at all falls back to the lexical absolute form; this is
122
+ * an identity key, not a security boundary, so it fails OPEN to a stable string
123
+ * rather than to `undefined`.
124
+ *
125
+ * An empty input stays empty: the native struct reports `root: ''` while the
126
+ * tier is disabled, and that must never be recorded as a real cache identity.
127
+ */
128
+ export function canonicalCacheRoot(root: string): string {
129
+ const expanded = expandTilde(root.trim());
130
+ if (expanded.length === 0) return '';
131
+ const absolute = resolve(expanded);
132
+ const missing: string[] = [];
133
+ let cursor = absolute;
134
+ for (;;) {
135
+ try {
136
+ // `realpathSync.native` (realpath(3)), not Node's JS walk: on a
137
+ // case-insensitive volume — APFS's default — `~/CacheDir` and `~/cachedir`
138
+ // name the SAME directory, but `fs.realpathSync` returns whichever spelling
139
+ // the caller typed. `.native` folds each existing component to its on-disk
140
+ // spelling, so both env spellings produce one join key.
141
+ const resolved = realpathSync.native(cursor);
142
+ return missing.length === 0 ? resolved : join(resolved, ...missing);
143
+ } catch {
144
+ const parent = dirname(cursor);
145
+ // `dirname` is a fixpoint at the filesystem root, which is the loop's
146
+ // termination condition: nothing above it is left to canonicalize.
147
+ if (parent === cursor) return absolute;
148
+ missing.unshift(basename(cursor));
149
+ cursor = parent;
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,136 @@
1
+ /** Addon-free client for the desktop's global instruction installation checks. */
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { expandPiAgentDir } from './paths.js';
7
+
8
+ export const DELEGATION_PROMPT =
9
+ 'Delegate GitHub investigation to `mlx delegate github --caller-approved --repo OWNER/REPO "TASK"`. Approve the bounded task and its tool execution before invoking. Include the PR, issue, or run number. Wait for the final handoff and read it once; do not poll by dumping worker transcripts. Use its findings and evidence for implementation; address specific gaps with a focused follow-up or relevant evidence excerpt. Add `--allow-write` only for GitHub changes already authorized by the user. If delegation fails or reports incomplete work, continue from its handoff.';
10
+
11
+ /** Absolute paths avoid dependence on each coding agent's shell startup/PATH. */
12
+ export function delegationCommand(path: string): string {
13
+ if (/[\0\r\n`]/.test(path)) throw new Error('The delegation command path cannot be represented in instructions.');
14
+ return `'${path.replaceAll("'", "'\\''")}'`;
15
+ }
16
+
17
+ export function delegationPrompt(path: string): string {
18
+ return DELEGATION_PROMPT.replace('`mlx delegate', `\`${delegationCommand(path)} delegate`);
19
+ }
20
+
21
+ export interface LocalInferenceConnection {
22
+ url: string;
23
+ token?: string;
24
+ model: string;
25
+ }
26
+
27
+ export interface LocalMessage {
28
+ role: 'user' | 'assistant';
29
+ content: string;
30
+ }
31
+
32
+ /** Shared by setup requests and their verdict cache key. Includes thinking and final output. */
33
+ export const INSTALL_CHECK_GENERATION = Object.freeze({
34
+ reasoning: Object.freeze({ effort: 'medium' as const }),
35
+ max_output_tokens: 16384,
36
+ temperature: 0,
37
+ });
38
+ export const INSTALL_CHECK_TIMEOUT_MS = 600_000;
39
+
40
+ export function expandHome(path: string, home = homedir()): string {
41
+ return path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path;
42
+ }
43
+
44
+ /** Match the persisted default used by `mlx agent`; never select a cloud provider. */
45
+ export async function preferredLocalModel(home = homedir(), env = process.env): Promise<string | undefined> {
46
+ try {
47
+ const dir = env.PI_CODING_AGENT_DIR
48
+ ? expandPiAgentDir(env.PI_CODING_AGENT_DIR, home)
49
+ : join(home, '.mlx-node', 'agent');
50
+ const value = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
51
+ if (value.defaultProvider === 'mlx' && typeof value.defaultModel === 'string') {
52
+ return value.defaultModel.replace(/^mlx\//, '');
53
+ }
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
56
+ throw new Error('Could not read the default local model. Open mlx agent and select a model again.');
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Refuse remote endpoints even if a caller accidentally supplies a cloud API URL. */
63
+ export async function localCompletion(
64
+ connection: LocalInferenceConnection,
65
+ system: string,
66
+ messages: LocalMessage[],
67
+ signal?: AbortSignal,
68
+ maxTokens: number = INSTALL_CHECK_GENERATION.max_output_tokens,
69
+ ): Promise<string> {
70
+ const url = new URL(connection.url);
71
+ if (url.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) {
72
+ throw new Error('Delegation requires a local inference server.');
73
+ }
74
+ const controller = new AbortController();
75
+ const cancel = (): void => controller.abort(signal?.reason);
76
+ if (signal?.aborted) cancel();
77
+ else signal?.addEventListener('abort', cancel, { once: true });
78
+ const timer = setTimeout(
79
+ () => controller.abort(new Error('The local model request timed out.')),
80
+ INSTALL_CHECK_TIMEOUT_MS,
81
+ );
82
+ try {
83
+ const headers = {
84
+ 'content-type': 'application/json',
85
+ ...(connection.token ? { 'x-api-key': connection.token } : {}),
86
+ };
87
+ const catalog = await fetch(new URL('/v1/models', url), { headers, signal: controller.signal, redirect: 'error' });
88
+ if (!catalog.ok) throw new Error('The local model service is unavailable. Restart it and try again.');
89
+ const models = (await catalog.json()) as { data?: { id: string }[] };
90
+ if (!models.data?.some((model) => model.id === connection.model)) {
91
+ throw new Error(
92
+ 'The default model is not available in the running service. Restart the local model service and try again.',
93
+ );
94
+ }
95
+ const response = await fetch(new URL('/v1/responses', url), {
96
+ method: 'POST',
97
+ headers,
98
+ body: JSON.stringify({
99
+ model: connection.model,
100
+ instructions: system,
101
+ input: messages,
102
+ ...INSTALL_CHECK_GENERATION,
103
+ max_output_tokens: maxTokens,
104
+ store: false,
105
+ stream: false,
106
+ }),
107
+ signal: controller.signal,
108
+ redirect: 'error',
109
+ });
110
+ if (!response.ok) throw new Error(`The local model could not complete the request (HTTP ${response.status}).`);
111
+ const body = (await response.json()) as {
112
+ status?: string;
113
+ output_text?: string;
114
+ incomplete_details?: { reason?: string } | null;
115
+ };
116
+ if (body.incomplete_details?.reason === 'max_output_tokens')
117
+ throw new Error('The local model ran out of output space. Try checking again.');
118
+ if (body.status !== 'completed')
119
+ throw new Error('The local model did not complete its answer. Try checking again.');
120
+ const text = body.output_text?.trim();
121
+ if (!text) throw new Error('The local model returned no answer. Try checking again.');
122
+ return text;
123
+ } finally {
124
+ clearTimeout(timer);
125
+ signal?.removeEventListener('abort', cancel);
126
+ }
127
+ }
128
+
129
+ export function parseLocalJson(text: string): unknown {
130
+ return JSON.parse(
131
+ text
132
+ .trim()
133
+ .replace(/^```(?:json)?\s*\n?/, '')
134
+ .replace(/\n?```$/, ''),
135
+ );
136
+ }
@@ -0,0 +1,57 @@
1
+ /** Longest detail line (in chars) shown in an approval prompt. */
2
+ const DETAIL_MAX_CHARS = 500;
3
+ /** Most detail lines shown before truncation kicks in. */
4
+ const DETAIL_MAX_LINES = 6;
5
+ const TRUNCATION_MARKER = '… [truncated]';
6
+
7
+ /**
8
+ * Every character that must be rendered visibly instead of reaching the
9
+ * terminal: C0 controls except `\n` and `\t`, DEL, and the C1 range
10
+ * U+0080–U+009F (which contains the raw CSI/OSC/ST bytes U+009B, U+009D
11
+ * and U+009C). Matched one character at a time — deliberately NOT as
12
+ * multi-character escape "sequences": the printable bytes inside those
13
+ * sequences still belong to the approval detail and must remain visible.
14
+ */
15
+ // eslint-disable-next-line no-control-regex
16
+ const CONTROL_CHAR_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
17
+
18
+ /** Render one control character as visible `\xNN` text (e.g. ESC → `\x1b`). */
19
+ function encodeControlChar(ch: string): string {
20
+ return `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`;
21
+ }
22
+
23
+ /**
24
+ * Sanitize untrusted text before embedding it in an approval prompt.
25
+ *
26
+ * Pi's TUI preserves ANSI, so every terminal control byte is encoded rather
27
+ * than deleted. Printable text is retained verbatim, and the rendered detail
28
+ * is capped by line and character count with a visible truncation marker.
29
+ */
30
+ export function sanitizeApprovalDetail(text: string): string {
31
+ let out = text.replace(CONTROL_CHAR_RE, encodeControlChar);
32
+ let truncated = false;
33
+
34
+ const lines = out.split('\n');
35
+ if (lines.length > DETAIL_MAX_LINES) {
36
+ out = lines.slice(0, DETAIL_MAX_LINES).join('\n');
37
+ truncated = true;
38
+ }
39
+ if (out.length > DETAIL_MAX_CHARS) {
40
+ out = out.slice(0, DETAIL_MAX_CHARS);
41
+ // Do not leave a lone high surrogate behind after the hard cut.
42
+ const last = out.charCodeAt(out.length - 1);
43
+ if (last >= 0xd800 && last <= 0xdbff) {
44
+ out = out.slice(0, -1);
45
+ }
46
+ truncated = true;
47
+ }
48
+ if (truncated) {
49
+ out += ` ${TRUNCATION_MARKER}`;
50
+ }
51
+ if (out.trim().length === 0 && text.length > 0) {
52
+ // Control characters always encode to visible text, so this only fires
53
+ // for whitespace-only input.
54
+ return '(unprintable content)';
55
+ }
56
+ return out;
57
+ }
@@ -0,0 +1,109 @@
1
+ import type { ExtensionAPI, ExtensionContext, InlineExtension } from '@earendil-works/pi-coding-agent';
2
+
3
+ export interface DelegateCallerPermissions {
4
+ source: 'codex';
5
+ /** Opaque Codex profile/backend when supplied; otherwise just inherited process permissions. */
6
+ profile: string;
7
+ networkDisabled: boolean;
8
+ }
9
+
10
+ /** Child processes stay inside Codex's actual sandbox; this metadata grants no OS access. */
11
+ export function delegateCallerPermissions(env: NodeJS.ProcessEnv = process.env): DelegateCallerPermissions | undefined {
12
+ if (!env.CODEX_THREAD_ID?.trim()) return undefined;
13
+ const profile = env.CODEX_PERMISSION_PROFILE?.trim();
14
+ // Permission metadata is optional in Codex launches. The thread identifies
15
+ // the caller; subprocesses inherit its real sandbox regardless of these labels.
16
+ const sandbox = env.CODEX_SANDBOX;
17
+ return {
18
+ source: 'codex',
19
+ profile: profile || (sandbox === 'seatbelt' || sandbox === 'landlock' ? `sandbox:${sandbox}` : 'inherited'),
20
+ networkDisabled: env.CODEX_SANDBOX_NETWORK_DISABLED === '1',
21
+ };
22
+ }
23
+
24
+ const READ_TOOLS = new Set(['read', 'grep', 'find', 'ls']);
25
+
26
+ /** Only failed tool executions qualify; quoted CI logs in a successful result do not. */
27
+ export function delegationBlocker(text: string): boolean {
28
+ return /permission denied|operation not permitted|\bEACCES\b|\bEPERM\b|network access was denied|sandbox.*(?:denied|blocked)|(?:HTTP\s+|status(?: code)?[: ]+)(?:401|403)\b|gh auth login|could not resolve host|could not resolve proxy|failed to connect|error connecting to api\.github\.com/i.test(
29
+ text,
30
+ );
31
+ }
32
+
33
+ export function createDelegationExtension(options: { callerApproved?: boolean } = {}): InlineExtension {
34
+ // Capture before any model/tool code runs. Never consult model-written settings.
35
+ const caller = delegateCallerPermissions();
36
+ const explicitlyApproved = options.callerApproved === true || process.env.MLX_AGENT_AUTO_APPROVE === '1';
37
+ return {
38
+ name: 'mlx-delegation',
39
+ factory: (pi: ExtensionAPI) => {
40
+ let handoff: string | undefined;
41
+
42
+ const stop = (reason: string, ctx: ExtensionContext): string => {
43
+ if (!handoff) {
44
+ const sessionFile = ctx.sessionManager.getSessionFile();
45
+ handoff = `Delegation incomplete: ${reason}\nContinue this task in the calling agent. Do not retry with changed permissions or another agent.${sessionFile ? `\nSaved evidence and tool results: ${sessionFile}` : ''}`;
46
+ pi.appendEntry('mlx-delegate-handoff', { reason, caller, sessionFile });
47
+ // Print mode otherwise shows only the final assistant message. A deterministic
48
+ // diagnostic and failure status let the caller recover without another inference.
49
+ process.stderr.write(`${handoff}\n`);
50
+ process.exitCode = 1;
51
+ }
52
+ ctx.abort();
53
+ return handoff;
54
+ };
55
+
56
+ pi.on('before_agent_start', (event, ctx) => {
57
+ handoff = undefined;
58
+ // A boundary per invocation keeps ordinary history/resumes out of the
59
+ // delegate estimate. Custom entries are persisted, never model context.
60
+ pi.appendEntry('mlx-delegate-session', { version: 1, sessionId: ctx.sessionManager.getSessionId() });
61
+ const permissions = caller
62
+ ? `Tool execution inherits the calling Codex process's permissions (${caller.profile}).${caller.networkDisabled ? ' The caller disables network access.' : ''} Additional approval must be handled by the calling agent; this worker cannot request escalation.`
63
+ : explicitlyApproved
64
+ ? "The caller explicitly approved tool execution for this bounded task. Child processes inherit its OS sandbox, environment and credentials; this does not copy the calling agent's tool approval rules or grant additional access. Stay within the task authorization."
65
+ : 'No inherited caller permission context is available. Tools requiring approval will stop this task and return a handoff.';
66
+ return { systemPrompt: `${event.systemPrompt}\n\n${permissions}` };
67
+ });
68
+
69
+ pi.on('tool_call', (event, ctx) => {
70
+ if (handoff) return { block: true, terminate: true, reason: handoff };
71
+ if (event.toolName === 'subagent') {
72
+ return { block: true, terminate: true, reason: stop('This worker cannot create subagents.', ctx) };
73
+ }
74
+ if (READ_TOOLS.has(event.toolName)) return undefined;
75
+ if (!caller && !explicitlyApproved) {
76
+ return {
77
+ block: true,
78
+ terminate: true,
79
+ reason: stop(
80
+ `No caller permission context is available to authorize ${event.toolName}. The calling agent can use --caller-approved after approving this bounded task.`,
81
+ ctx,
82
+ ),
83
+ };
84
+ }
85
+ if (event.toolName === 'bash') {
86
+ const input = event.input as { command?: unknown };
87
+ if (typeof input.command === 'string') {
88
+ // Preserve failures through pipelines such as `gh ... | head`, and
89
+ // stop a multi-command script before later output masks a denial.
90
+ // This changes shell options only; cwd, environment and sandbox remain inherited.
91
+ input.command = `set -e -o pipefail\n${input.command}`;
92
+ }
93
+ }
94
+ return undefined;
95
+ });
96
+
97
+ pi.on('tool_result', (event, ctx) => {
98
+ if (!event.isError) return undefined;
99
+ const text = event.content
100
+ .filter((part) => part.type === 'text')
101
+ .map((part) => part.text)
102
+ .join('\n');
103
+ if (!delegationBlocker(text)) return undefined;
104
+ const reason = stop(`${event.toolName} was blocked. ${text.slice(0, 2000)}`, ctx);
105
+ return { content: [{ type: 'text', text: reason }], isError: true };
106
+ });
107
+ },
108
+ };
109
+ }