@mlx-node/agent 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/catalog.d.ts +26 -0
  2. package/dist/catalog.d.ts.map +1 -0
  3. package/dist/catalog.js +44 -0
  4. package/dist/extensions/approval-detail.d.ts +9 -0
  5. package/dist/extensions/approval-detail.d.ts.map +1 -0
  6. package/dist/extensions/approval-detail.js +53 -0
  7. package/dist/extensions/permission-gate.d.ts +30 -0
  8. package/dist/extensions/permission-gate.d.ts.map +1 -0
  9. package/dist/extensions/permission-gate.js +309 -0
  10. package/dist/extensions/subagent.d.ts +82 -0
  11. package/dist/extensions/subagent.d.ts.map +1 -0
  12. package/dist/extensions/subagent.js +539 -0
  13. package/dist/extensions/terminal-title.d.ts +10 -0
  14. package/dist/extensions/terminal-title.d.ts.map +1 -0
  15. package/dist/extensions/terminal-title.js +45 -0
  16. package/dist/extensions/trace-notice.d.ts +11 -0
  17. package/dist/extensions/trace-notice.d.ts.map +1 -0
  18. package/dist/extensions/trace-notice.js +34 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +12 -0
  22. package/dist/provider/chat-config.d.ts +34 -0
  23. package/dist/provider/chat-config.d.ts.map +1 -0
  24. package/dist/provider/chat-config.js +103 -0
  25. package/dist/provider/convert-messages.d.ts +59 -0
  26. package/dist/provider/convert-messages.d.ts.map +1 -0
  27. package/dist/provider/convert-messages.js +248 -0
  28. package/dist/provider/error-coercion.d.ts +19 -0
  29. package/dist/provider/error-coercion.d.ts.map +1 -0
  30. package/dist/provider/error-coercion.js +38 -0
  31. package/dist/provider/events.d.ts +67 -0
  32. package/dist/provider/events.d.ts.map +1 -0
  33. package/dist/provider/events.js +307 -0
  34. package/dist/provider/index.d.ts +28 -0
  35. package/dist/provider/index.d.ts.map +1 -0
  36. package/dist/provider/index.js +64 -0
  37. package/dist/provider/inference-trace.d.ts +58 -0
  38. package/dist/provider/inference-trace.d.ts.map +1 -0
  39. package/dist/provider/inference-trace.js +205 -0
  40. package/dist/provider/model-host.d.ts +94 -0
  41. package/dist/provider/model-host.d.ts.map +1 -0
  42. package/dist/provider/model-host.js +134 -0
  43. package/dist/provider/model-registry-filter.d.ts +36 -0
  44. package/dist/provider/model-registry-filter.d.ts.map +1 -0
  45. package/dist/provider/model-registry-filter.js +82 -0
  46. package/dist/provider/models.d.ts +35 -0
  47. package/dist/provider/models.d.ts.map +1 -0
  48. package/dist/provider/models.js +132 -0
  49. package/dist/provider/performance-status.d.ts +28 -0
  50. package/dist/provider/performance-status.d.ts.map +1 -0
  51. package/dist/provider/performance-status.js +91 -0
  52. package/dist/provider/reasoning-tag-buffer.d.ts +23 -0
  53. package/dist/provider/reasoning-tag-buffer.d.ts.map +1 -0
  54. package/dist/provider/reasoning-tag-buffer.js +60 -0
  55. package/dist/provider/stream-adapter.d.ts +61 -0
  56. package/dist/provider/stream-adapter.d.ts.map +1 -0
  57. package/dist/provider/stream-adapter.js +358 -0
  58. package/dist/provider/tool-call-buffer.d.ts +30 -0
  59. package/dist/provider/tool-call-buffer.d.ts.map +1 -0
  60. package/dist/provider/tool-call-buffer.js +75 -0
  61. package/dist/provider/warm-reuse.d.ts +73 -0
  62. package/dist/provider/warm-reuse.d.ts.map +1 -0
  63. package/dist/provider/warm-reuse.js +88 -0
  64. package/dist/run-agent.d.ts +62 -0
  65. package/dist/run-agent.d.ts.map +1 -0
  66. package/dist/run-agent.js +86 -0
  67. package/dist/types.d.ts +8 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +1 -0
  70. package/package.json +42 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Curated model catalog for `mlx agent`.
3
+ *
4
+ * The first-run download wizard (Task 8) offers `visibleCatalog()` and
5
+ * feeds the chosen `hfRepo` to `mlx download model`. Slugs are settled
6
+ * with the user and verified against the Brooooooklyn HF account —
7
+ * use them verbatim.
8
+ */
9
+ export interface CatalogEntry {
10
+ /** Wizard display name. */
11
+ label: string;
12
+ /** HF slug for `mlx download model`. */
13
+ hfRepo: string;
14
+ /** Approximate download size in GB, for display. */
15
+ sizeGb: number;
16
+ /** One line for the wizard. */
17
+ description: string;
18
+ /** Exactly one entry carries this. */
19
+ isDefault?: boolean;
20
+ /** Not offered by the wizard (repo not yet published). */
21
+ hidden?: boolean;
22
+ }
23
+ export declare const MODEL_CATALOG: readonly CatalogEntry[];
24
+ /** Catalog entries the wizard offers (hidden entries filtered out). */
25
+ export declare function visibleCatalog(): CatalogEntry[];
26
+ //# sourceMappingURL=catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog.d.ts","sourceRoot":"","sources":["../src/catalog.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,WAAW,YAAY;IAC3B,2BAA2B;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,MAAM,EAAE,MAAM,CAAC;IACf,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,eAAO,MAAM,aAAa,EAAE,SAAS,YAAY,EA+BhD,CAAC;AAEF,uEAAuE;AACvE,wBAAgB,cAAc,IAAI,YAAY,EAAE,CAE/C"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Curated model catalog for `mlx agent`.
3
+ *
4
+ * The first-run download wizard (Task 8) offers `visibleCatalog()` and
5
+ * feeds the chosen `hfRepo` to `mlx download model`. Slugs are settled
6
+ * with the user and verified against the Brooooooklyn HF account —
7
+ * use them verbatim.
8
+ */
9
+ export const MODEL_CATALOG = [
10
+ {
11
+ label: 'Qwen3.6-27B',
12
+ hfRepo: 'Brooooooklyn/Qwen3.6-27B-NVFP4-mlx',
13
+ sizeGb: 22.2,
14
+ description: 'Best tool use — recommended default',
15
+ isDefault: true,
16
+ },
17
+ {
18
+ label: 'Qwen-AgentWorld-35B',
19
+ hfRepo: 'Brooooooklyn/Qwen-AgentWorld-35B-A3B-nvfp4-mlx',
20
+ sizeGb: 22.7,
21
+ description: 'Agent-tuned MoE, fast decode',
22
+ },
23
+ {
24
+ label: 'Gemma-4-26B-A4B',
25
+ hfRepo: 'Brooooooklyn/Gemma-4-26B-A4B-NVFP4-mlx',
26
+ sizeGb: 18.8,
27
+ description: 'MoE, fast decode',
28
+ },
29
+ {
30
+ // Produced + validated locally as mxfp4 (MLP) + mxfp8 (attention) via
31
+ // `mlx convert --q-recipe nvidia` on gemma-4-12b-it (coherent + tool
32
+ // calling through `mlx agent`). Provisional slug — the user finalizes it
33
+ // on HF upload; entry stays hidden until the repo exists.
34
+ label: 'Gemma-4-12B',
35
+ hfRepo: 'Brooooooklyn/Gemma-4-12B-IT-mxfp-mlx',
36
+ sizeGb: 8.6,
37
+ description: 'Compact (mxfp4 MLP + mxfp8 attention), fits smaller machines',
38
+ hidden: true,
39
+ },
40
+ ];
41
+ /** Catalog entries the wizard offers (hidden entries filtered out). */
42
+ export function visibleCatalog() {
43
+ return MODEL_CATALOG.filter((entry) => !entry.hidden);
44
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Sanitize untrusted text before embedding it in an approval prompt.
3
+ *
4
+ * Pi's TUI preserves ANSI, so every terminal control byte is encoded rather
5
+ * than deleted. Printable text is retained verbatim, and the rendered detail
6
+ * is capped by line and character count with a visible truncation marker.
7
+ */
8
+ export declare function sanitizeApprovalDetail(text: string): string;
9
+ //# sourceMappingURL=approval-detail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-detail.d.ts","sourceRoot":"","sources":["../../src/extensions/approval-detail.ts"],"names":[],"mappings":"AAsBA;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA2B3D"}
@@ -0,0 +1,53 @@
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
+ * Every character that must be rendered visibly instead of reaching the
8
+ * terminal: C0 controls except `\n` and `\t`, DEL, and the C1 range
9
+ * U+0080–U+009F (which contains the raw CSI/OSC/ST bytes U+009B, U+009D
10
+ * and U+009C). Matched one character at a time — deliberately NOT as
11
+ * multi-character escape "sequences": the printable bytes inside those
12
+ * sequences still belong to the approval detail and must remain visible.
13
+ */
14
+ // eslint-disable-next-line no-control-regex
15
+ const CONTROL_CHAR_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
16
+ /** Render one control character as visible `\xNN` text (e.g. ESC → `\x1b`). */
17
+ function encodeControlChar(ch) {
18
+ return `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`;
19
+ }
20
+ /**
21
+ * Sanitize untrusted text before embedding it in an approval prompt.
22
+ *
23
+ * Pi's TUI preserves ANSI, so every terminal control byte is encoded rather
24
+ * than deleted. Printable text is retained verbatim, and the rendered detail
25
+ * is capped by line and character count with a visible truncation marker.
26
+ */
27
+ export function sanitizeApprovalDetail(text) {
28
+ let out = text.replace(CONTROL_CHAR_RE, encodeControlChar);
29
+ let truncated = false;
30
+ const lines = out.split('\n');
31
+ if (lines.length > DETAIL_MAX_LINES) {
32
+ out = lines.slice(0, DETAIL_MAX_LINES).join('\n');
33
+ truncated = true;
34
+ }
35
+ if (out.length > DETAIL_MAX_CHARS) {
36
+ out = out.slice(0, DETAIL_MAX_CHARS);
37
+ // Do not leave a lone high surrogate behind after the hard cut.
38
+ const last = out.charCodeAt(out.length - 1);
39
+ if (last >= 0xd800 && last <= 0xdbff) {
40
+ out = out.slice(0, -1);
41
+ }
42
+ truncated = true;
43
+ }
44
+ if (truncated) {
45
+ out += ` ${TRUNCATION_MARKER}`;
46
+ }
47
+ if (out.trim().length === 0 && text.length > 0) {
48
+ // Control characters always encode to visible text, so this only fires
49
+ // for whitespace-only input.
50
+ return '(unprintable content)';
51
+ }
52
+ return out;
53
+ }
@@ -0,0 +1,30 @@
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
+ import type { InlineExtension } from '@earendil-works/pi-coding-agent';
23
+ /**
24
+ * Build the `mlx-permission-gate` inline extension. The per-session allow list
25
+ * lives in the `factory` closure, so every extension load (session start or
26
+ * `/reload`) starts with a clean slate. The bash-prefix snapshot, by contrast,
27
+ * lives in THIS outer closure so it PERSISTS across factory reinvocations.
28
+ */
29
+ export declare function createPermissionGateExtension(): InlineExtension;
30
+ //# sourceMappingURL=permission-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-gate.d.ts","sourceRoot":"","sources":["../../src/extensions/permission-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAKH,OAAO,KAAK,EAAkC,eAAe,EAAiB,MAAM,iCAAiC,CAAC;AA4MtH;;;;;GAKG;AACH,wBAAgB,6BAA6B,IAAI,eAAe,CA+G/D"}
@@ -0,0 +1,309 @@
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
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { sanitizeApprovalDetail } from './approval-detail.js';
25
+ import { normalizeSubagentMode } from './subagent.js';
26
+ const GATED_TOOLS = new Set(['bash', 'write', 'edit', 'subagent']);
27
+ const AUTO_APPROVE_ENV = 'MLX_AGENT_AUTO_APPROVE';
28
+ /**
29
+ * Recompute ONE settings layer's `shellCommandPrefix` the way pi's
30
+ * `SettingsManager.reload()` does (`tryLoadFromStorage` → `loadFromStorage` +
31
+ * `withLock`, `dist/core/settings-manager.js`), given the layer's PRIOR value:
32
+ * - `!active` (untrusted project) → `undefined` (pi's `loadFromStorage` returns
33
+ * `{}` for an untrusted project, no error → the layer is CLEARED).
34
+ * - file ABSENT → `undefined` (`withLock` yields `current=undefined` → `{}`, no
35
+ * error → CLEARED).
36
+ * - file present + parseable object → its RAW `shellCommandPrefix` value if the
37
+ * key is present (ANY JSON type — pi does no validation), else `undefined`
38
+ * (REPLACE with the new value). Presence, not string-ness, decides: pi's
39
+ * merge keys on key-presence, and pi bakes any truthy value's `String(...)`.
40
+ * - file present but malformed / unreadable / a non-object (pi's
41
+ * `migrateSettings` does `"key" in settings`, which throws for a non-object) →
42
+ * `tryLoadFromStorage` returns an error → reload RETAINS the prior value.
43
+ *
44
+ * The retention branch is what closes the reload under-disclosure: on a FAILED
45
+ * reload pi keeps baking the prior prefix into BashTool, so the gate must keep
46
+ * showing it rather than degrade to empty. Whether `prior` is a real prior
47
+ * snapshot (reload) or a blank `undefined` baseline (a fresh lifecycle, which
48
+ * builds a NEW `SettingsManager` with boot semantics — bad file → empty, no
49
+ * retain) is decided by the CALLER (the `session_start` handler, on `reason`);
50
+ * this per-layer rule is identical either way.
51
+ */
52
+ function resolveLayerPrefix(prior, path, active) {
53
+ if (!active) {
54
+ return undefined; // untrusted project layer → dropped
55
+ }
56
+ if (!existsSync(path)) {
57
+ return undefined; // absent → pi clears the layer
58
+ }
59
+ try {
60
+ const content = readFileSync(path, 'utf-8');
61
+ if (!content) {
62
+ // Zero-byte file → pi's `loadFromStorage` short-circuits (`if (!content)
63
+ // return {}`, no error) → the layer is CLEARED (not retained). `!content`
64
+ // matches pi's exact truthiness, so whitespace-only (`" "`) is NOT empty:
65
+ // it reaches JSON.parse, throws, and falls to the retain branch below —
66
+ // exactly as pi errors and retains for it.
67
+ return undefined;
68
+ }
69
+ const parsed = JSON.parse(content);
70
+ if (typeof parsed !== 'object' || parsed === null) {
71
+ // pi's migrateSettings throws on a non-object → reload retains the prior.
72
+ return prior;
73
+ }
74
+ // Presence, NOT string-ness, decides — pi does no type validation and its
75
+ // key-presence merge treats a present non-string / falsy value as "set".
76
+ // Return the RAW value (any JSON type) when the key is present, else
77
+ // `undefined` so the presence-based merge sees this layer as unset.
78
+ return 'shellCommandPrefix' in parsed ? parsed['shellCommandPrefix'] : undefined;
79
+ }
80
+ catch {
81
+ // Malformed JSON / unreadable (EACCES) → pi retains the prior layer value.
82
+ return prior;
83
+ }
84
+ }
85
+ /**
86
+ * Recompute both settings layers for one context, applying {@link
87
+ * resolveLayerPrefix} per layer with a DIRECT, lock-free read — deliberately
88
+ * NOT via pi's `SettingsManager`, whose load takes a proper-lockfile lock
89
+ * (creating/removing `<file>.lock`) and, under contention or a non-writable
90
+ * dir, degrades to `{}`. This reader never takes a lock and MIRRORS pi's reload
91
+ * retention, so it can never show empty where pi is still baking a non-empty
92
+ * prefix. `prior` carries the last snapshot so the retain branch is faithful.
93
+ *
94
+ * `getAgentDir`/`CONFIG_DIR_NAME` are imported at call time (deferred), so this
95
+ * module keeps its "no pi runtime import before env seeding" discipline; both
96
+ * are pure (env read lazily inside `getAgentDir`). NEVER throws: a hostile
97
+ * `ctx.isProjectTrusted` getter or a failed deferred import retains `prior`.
98
+ *
99
+ * ACCEPTED RESIDUAL (do NOT try to fix): pi's LOCKED read also retains on lock
100
+ * CONTENTION, which this lock-free reader cannot detect. So a `/reload` that
101
+ * simultaneously (i) contends pi's settings lock and (ii) sees the file
102
+ * concurrently reduced to empty could make pi retain a non-empty prefix while
103
+ * this reader observes empty and under-discloses. That needs a concurrent
104
+ * lock-holder emptying the file at the reload instant — an actor with settings
105
+ * write access who could instead just inject a (disclosed) prefix directly, so
106
+ * it grants no escalation. Left as an extreme-adversarial residual.
107
+ */
108
+ async function resolveShellPrefixLayers(prior, ctx) {
109
+ try {
110
+ const { getAgentDir, CONFIG_DIR_NAME } = await import('@earendil-works/pi-coding-agent');
111
+ const trusted = ctx.isProjectTrusted();
112
+ return {
113
+ global: resolveLayerPrefix(prior.global, join(getAgentDir(), 'settings.json'), true),
114
+ project: resolveLayerPrefix(prior.project, join(ctx.cwd, CONFIG_DIR_NAME, 'settings.json'), trusted),
115
+ };
116
+ }
117
+ catch {
118
+ return prior;
119
+ }
120
+ }
121
+ /**
122
+ * Merge the two layers exactly as pi's `deepMergeSettings(global, project)` +
123
+ * `getShellCommandPrefix()` do, returning the RAW merged value (any JSON type or
124
+ * `undefined`). pi's `deepMergeSettings` overrides on KEY-PRESENCE: it iterates
125
+ * `Object.keys(project)` and, for any `shellCommandPrefix` whose value is not
126
+ * `undefined`, the project value wins — so a present project `""` / `0` /
127
+ * `false` / `null` overrides the global one, and only an ABSENT project key
128
+ * (our `undefined`) falls through to global. A `??` merge would be WRONG here:
129
+ * it would let a present project `null` / `""` / `0` / `false` fall through to
130
+ * global, disagreeing with pi. Coercion to a string (and the `''` empty default)
131
+ * happens once at the call site, mirroring `bash.js:213`.
132
+ */
133
+ function mergedPrefixRaw(layers) {
134
+ return layers.project !== undefined ? layers.project : layers.global;
135
+ }
136
+ /**
137
+ * `session_start` reasons that build a FRESH `SettingsManager` (boot semantics:
138
+ * a bad/malformed file → empty, NEVER a retain). pi's `SettingsManager.reload()`
139
+ * — reason `'reload'` — is its ONLY retain-on-error path, so ONLY a reload may
140
+ * inherit the prior snapshot; these fresh lifecycles must restart from a blank
141
+ * baseline or the gate would show a prior session's prefix that pi won't
142
+ * execute (over-disclosure). Any unknown/missing reason falls OUTSIDE this set
143
+ * and therefore retains — over-disclosing a stale prefix is safe; under-
144
+ * disclosing an executed one is not. Verified against pi 0.80.6 `SessionStartEvent`.
145
+ */
146
+ const FRESH_SESSION_REASONS = new Set(['startup', 'new', 'resume', 'fork']);
147
+ /**
148
+ * Derive the human-readable detail line for the approval prompt.
149
+ * Defensive on purpose: a malformed or missing `event.input` must never
150
+ * throw — a handler error would fail closed upstream, but the prompt
151
+ * should still render and let the user decide.
152
+ */
153
+ function describeToolCall(toolName, event, defaultCwd) {
154
+ const rawInput = event.input;
155
+ const input = typeof rawInput === 'object' && rawInput !== null ? rawInput : {};
156
+ if (toolName === 'bash') {
157
+ const command = input['command'];
158
+ return typeof command === 'string' && command.length > 0 ? command : '(unknown command)';
159
+ }
160
+ if (toolName === 'subagent') {
161
+ const agent = typeof input['agent'] === 'string' ? input['agent'] : undefined;
162
+ const task = typeof input['task'] === 'string' ? input['task'] : undefined;
163
+ const scope = typeof input['agentScope'] === 'string' ? input['agentScope'] : 'user';
164
+ const tasks = Array.isArray(input['tasks']) ? input['tasks'] : [];
165
+ const chainItems = Array.isArray(input['chain']) ? input['chain'] : [];
166
+ const { mode } = normalizeSubagentMode({ agent, task, tasks, chain: chainItems });
167
+ const describeItems = (value) => Array.isArray(value)
168
+ ? value.map((raw, index) => {
169
+ const item = typeof raw === 'object' && raw !== null ? raw : {};
170
+ const itemAgent = typeof item['agent'] === 'string' ? item['agent'] : '(unknown agent)';
171
+ const itemTask = typeof item['task'] === 'string' ? item['task'] : '(unknown task)';
172
+ const resolvedCwd = typeof item['cwd'] === 'string' ? item['cwd'] : defaultCwd;
173
+ const cwd = resolvedCwd ? ` [cwd: ${resolvedCwd}]` : '';
174
+ return `${index + 1}. ${itemAgent}: ${itemTask}${cwd}`;
175
+ })
176
+ : [];
177
+ const items = describeItems(tasks);
178
+ const chain = describeItems(chainItems);
179
+ const singleCwd = typeof input['cwd'] === 'string' ? input['cwd'] : defaultCwd;
180
+ const single = `${agent ?? '(unknown agent)'}: ${task ?? '(unknown task)'}${singleCwd ? ` [cwd: ${singleCwd}]` : ''}`;
181
+ const summary = mode === 'chain'
182
+ ? `Chain: ${chain.join(' | ')}`
183
+ : mode === 'parallel'
184
+ ? `Queued tasks: ${items.join(' | ')}`
185
+ : single;
186
+ return [
187
+ 'Delegated agent sessions may use bash/write/edit without further prompts.',
188
+ `Execution mode: ${mode}`,
189
+ `Agent scope: ${scope}`,
190
+ summary,
191
+ ].join('\n');
192
+ }
193
+ // write/edit: pi's canonical field is `path`; `file_path` is the
194
+ // compat alias pi's own renderers also accept.
195
+ const path = typeof input['path'] === 'string' ? input['path'] : input['file_path'];
196
+ return typeof path === 'string' && path.length > 0 ? path : '(unknown path)';
197
+ }
198
+ /**
199
+ * Build the `mlx-permission-gate` inline extension. The per-session allow list
200
+ * lives in the `factory` closure, so every extension load (session start or
201
+ * `/reload`) starts with a clean slate. The bash-prefix snapshot, by contrast,
202
+ * lives in THIS outer closure so it PERSISTS across factory reinvocations.
203
+ */
204
+ export function createPermissionGateExtension() {
205
+ // Per-layer snapshot of pi's bash `shellCommandPrefix`, recomputed at each
206
+ // `session_start` — which fires at boot AFTER pi bakes the prefix into
207
+ // BashTool and again on `/reload` AFTER the rebuild, i.e. the SAME lifecycle
208
+ // instant pi bakes it. Kept per-layer (not pre-merged) so each reload applies
209
+ // pi's exact RETENTION rule: a failed layer reload keeps baking the prior
210
+ // value, so we keep showing it. `snapshotted` tells a real empty snapshot
211
+ // apart from "no session_start yet" (which falls back to a one-shot on-demand
212
+ // read). Snapshot-primary is FAITHFUL: it shows exactly what pi baked, even
213
+ // after an edit-without-reload where an on-demand re-read would drift.
214
+ //
215
+ // MUST live here, not in `factory`: pi re-invokes the inline extension factory
216
+ // on every `/reload` (resource-loader `loadExtensionFactories`) BEFORE
217
+ // emitting the reload `session_start`. If this state were reset per factory
218
+ // run, a failed reload (malformed/unreadable file) would `retain` against a
219
+ // freshly-reset `undefined` and drop pi's still-baked prefix — the exact
220
+ // under-disclosure the retain rule exists to prevent.
221
+ let layers = { global: undefined, project: undefined };
222
+ let snapshotted = false;
223
+ return {
224
+ name: 'mlx-permission-gate',
225
+ factory: (pi) => {
226
+ const sessionAllowed = new Set();
227
+ pi.on('session_start', async (event, ctx) => {
228
+ // Only a /reload (pi's sole retain-on-error path) may inherit the prior
229
+ // snapshot. A fresh lifecycle (startup/new/resume/fork) builds a NEW
230
+ // SettingsManager with boot semantics (bad file → empty), so it must
231
+ // start from a blank baseline or we'd retain a prefix pi won't execute.
232
+ // Unknown/missing reason → retain (over-disclose is safe; under-disclose
233
+ // is not); read defensively since a hostile event need not be well-typed.
234
+ const reason = event.reason;
235
+ const fresh = typeof reason === 'string' && FRESH_SESSION_REASONS.has(reason);
236
+ const prior = fresh ? { global: undefined, project: undefined } : layers;
237
+ layers = await resolveShellPrefixLayers(prior, ctx);
238
+ snapshotted = true;
239
+ });
240
+ pi.on('tool_call', async (event, ctx) => {
241
+ const toolName = event.toolName;
242
+ if (typeof toolName !== 'string' || !GATED_TOOLS.has(toolName)) {
243
+ return undefined;
244
+ }
245
+ if (sessionAllowed.has(toolName)) {
246
+ return undefined;
247
+ }
248
+ if (!ctx.hasUI) {
249
+ if (process.env[AUTO_APPROVE_ENV] === '1') {
250
+ return undefined;
251
+ }
252
+ return {
253
+ block: true,
254
+ reason: `Blocked ${toolName}: no interactive UI to approve it (set ${AUTO_APPROVE_ENV}=1 to auto-approve)`,
255
+ };
256
+ }
257
+ // Defense in depth: the detail is model-controlled text and this
258
+ // title is rendered by a third-party TUI that passes ANSI through.
259
+ // For bash, prepend pi's effective `shellCommandPrefix` so the prompt
260
+ // shows the full program pi will execute, not just the model's arg.
261
+ const command = describeToolCall(toolName, event, ctx.cwd);
262
+ let detailSource = command;
263
+ if (toolName === 'bash') {
264
+ // Snapshot is primary (faithful to pi's baked value, incl. reload
265
+ // retention). Only if a bash approval somehow precedes the first
266
+ // session_start do we fall back to a one-shot on-demand read from a
267
+ // clean baseline (lock-free, never throws). A snapshotted empty prefix
268
+ // is authoritative — it is NOT treated as "missing" — so we never
269
+ // re-read over a deliberate empty bake.
270
+ //
271
+ // Coerce ONCE here, byte-identical to pi's `bash.js:213`
272
+ // (`commandPrefix ? \`${commandPrefix}\n${command}\` : command`): the
273
+ // `raw ?` truthiness gate mirrors pi (falsy `0`/`""`/`false`/`null`/
274
+ // absent → bare command), and `String(raw)` mirrors the template
275
+ // coercion pi applies to any truthy value (`123`→`123`, `true`→`true`,
276
+ // `{…}`→`[object Object]`, `["a","b"]`→`a,b`). Using `String(raw)`
277
+ // rather than a bare `${raw}` keeps this well-typed on `unknown`.
278
+ const raw = snapshotted
279
+ ? mergedPrefixRaw(layers)
280
+ : mergedPrefixRaw(await resolveShellPrefixLayers({ global: undefined, project: undefined }, ctx));
281
+ // The `[object Object]` / `a,b` default stringification is DELIBERATE
282
+ // here — it is exactly what pi's `${commandPrefix}` template bakes for a
283
+ // non-string prefix, and disclosing pi's actual bytes is the whole point.
284
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
285
+ detailSource = raw ? `${String(raw)}\n${command}` : command;
286
+ }
287
+ const detail = sanitizeApprovalDetail(detailSource);
288
+ const title = toolName === 'subagent' ? 'Allow delegated subagent tool access?' : `Allow ${toolName}?`;
289
+ // Bind the dialog to the active agent operation. Pi's selector only
290
+ // resolves on Ctrl+C/abort when the extension forwards this signal;
291
+ // otherwise the UI can disappear while this awaited tool_call hook
292
+ // remains pending and keeps the whole tool batch suspended.
293
+ const choice = await ctx.ui.select(`${title}\n\n ${detail}`, ['Yes', 'Always (this session)', 'No'], {
294
+ signal: ctx.signal,
295
+ });
296
+ if (choice === 'Yes') {
297
+ return undefined;
298
+ }
299
+ if (choice === 'Always (this session)') {
300
+ sessionAllowed.add(toolName);
301
+ return undefined;
302
+ }
303
+ // 'No', a dismissed dialog (undefined), or anything unexpected:
304
+ // fail closed.
305
+ return { block: true, reason: 'Blocked by user' };
306
+ });
307
+ },
308
+ };
309
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * MLX port of pi's official `examples/extensions/subagent` extension.
3
+ *
4
+ * The upstream example starts a fresh pi process per task. MLX instead creates
5
+ * an in-process `AgentSession` for each task so all delegated sessions reuse
6
+ * the parent's registered provider and its single `MlxModelHost`. Each task
7
+ * still has isolated conversation/compaction state and its own cwd/tools:
8
+ *
9
+ * - at most four task loops run concurrently; the host serializes inference,
10
+ * - sessions inherit the parent's current local model/model registry,
11
+ * - context files and skills load, while extensions/prompts/themes do not,
12
+ * - the parent permission gate approves delegated tool access once up front.
13
+ *
14
+ * Upstream source: @earendil-works/pi-coding-agent 0.80.6,
15
+ * examples/extensions/subagent (MIT).
16
+ */
17
+ import type { ExtensionContext, InlineExtension } from '@earendil-works/pi-coding-agent';
18
+ type AgentScope = 'user' | 'project' | 'both';
19
+ type AgentSource = 'builtin' | 'user' | 'project' | 'unknown';
20
+ export type SubagentMode = 'single' | 'parallel' | 'chain';
21
+ export interface SubagentConfig {
22
+ name: string;
23
+ description: string;
24
+ tools?: string[];
25
+ model?: string;
26
+ systemPrompt: string;
27
+ source: Exclude<AgentSource, 'unknown'>;
28
+ filePath: string;
29
+ }
30
+ export interface InProcessSubagentSession {
31
+ subscribe(listener: (event: unknown) => void): () => void;
32
+ prompt(text: string): Promise<void>;
33
+ abort(): Promise<void>;
34
+ dispose(): void;
35
+ }
36
+ export interface SubagentSessionCreateOptions {
37
+ cwd: string;
38
+ model: NonNullable<ExtensionContext['model']>;
39
+ modelRegistry: ExtensionContext['modelRegistry'];
40
+ tools?: string[];
41
+ systemPrompt: string;
42
+ }
43
+ export interface SubagentExtensionOptions {
44
+ /** Test/programmatic seam. Production uses pi's in-process SDK. */
45
+ createSession?: (options: SubagentSessionCreateOptions) => Promise<InProcessSubagentSession>;
46
+ }
47
+ export interface SubagentCompactionSettings {
48
+ enabled: boolean;
49
+ reserveTokens: number;
50
+ keepRecentTokens: number;
51
+ }
52
+ /**
53
+ * Fit pi's compaction budgets to the model's effective physical context.
54
+ *
55
+ * Pi's defaults (16,384 reserved + 20,000 recent) assume a context larger
56
+ * than some dynamically sized MLX KV pools. Preserve explicitly smaller user
57
+ * values, but keep the summary reserve at most 25%, retained history at most
58
+ * 50%, and their combined budget at most 75% of the usable window.
59
+ */
60
+ export declare function scaleSubagentCompactionSettings(contextWindow: number, current: SubagentCompactionSettings): SubagentCompactionSettings;
61
+ export declare function discoverSubagents(cwd: string, scope: AgentScope): {
62
+ agents: SubagentConfig[];
63
+ projectAgentsDir: string | null;
64
+ };
65
+ interface SubagentRequestShape {
66
+ agent?: string;
67
+ task?: string;
68
+ tasks?: unknown[];
69
+ chain?: unknown[];
70
+ }
71
+ /**
72
+ * Select the mode with the same precedence used by execution. Permission UI
73
+ * imports this helper so a stray top-level field can never hide the queued
74
+ * agents that will actually receive delegated tool access.
75
+ */
76
+ export declare function normalizeSubagentMode(input: SubagentRequestShape): {
77
+ mode: SubagentMode;
78
+ modeCount: number;
79
+ };
80
+ export declare function createSubagentExtension(options?: SubagentExtensionOptions): InlineExtension;
81
+ export {};
82
+ //# sourceMappingURL=subagent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent.d.ts","sourceRoot":"","sources":["../../src/extensions/subagent.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAQH,OAAO,KAAK,EAAgB,gBAAgB,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAUvG,KAAK,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC;AAC9C,KAAK,WAAW,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;AAC9D,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,CAAC;AAE3D,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,OAAO,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;IACxC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAiCD,MAAM,WAAW,wBAAwB;IACvC,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC1D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,4BAA4B;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC;IAC9C,aAAa,EAAE,gBAAgB,CAAC,eAAe,CAAC,CAAC;IACjD,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,wBAAwB;IACvC,mEAAmE;IACnE,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,4BAA4B,KAAK,OAAO,CAAC,wBAAwB,CAAC,CAAC;CAC9F;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAMD;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAC7C,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,0BAA0B,GAClC,0BAA0B,CAoB5B;AA6GD,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,UAAU,GAChB;IACD,MAAM,EAAE,cAAc,EAAE,CAAC;IACzB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC,CASA;AAqCD,UAAU,oBAAoB;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,oBAAoB,GAAG;IAClE,IAAI,EAAE,YAAY,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB,CAQA;AAwND,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,wBAA6B,GAAG,eAAe,CAsJ/F"}