@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.
- package/dist/catalog.d.ts +26 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +44 -0
- package/dist/extensions/approval-detail.d.ts +9 -0
- package/dist/extensions/approval-detail.d.ts.map +1 -0
- package/dist/extensions/approval-detail.js +53 -0
- package/dist/extensions/permission-gate.d.ts +30 -0
- package/dist/extensions/permission-gate.d.ts.map +1 -0
- package/dist/extensions/permission-gate.js +309 -0
- package/dist/extensions/subagent.d.ts +82 -0
- package/dist/extensions/subagent.d.ts.map +1 -0
- package/dist/extensions/subagent.js +539 -0
- package/dist/extensions/terminal-title.d.ts +10 -0
- package/dist/extensions/terminal-title.d.ts.map +1 -0
- package/dist/extensions/terminal-title.js +45 -0
- package/dist/extensions/trace-notice.d.ts +11 -0
- package/dist/extensions/trace-notice.d.ts.map +1 -0
- package/dist/extensions/trace-notice.js +34 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/provider/chat-config.d.ts +34 -0
- package/dist/provider/chat-config.d.ts.map +1 -0
- package/dist/provider/chat-config.js +103 -0
- package/dist/provider/convert-messages.d.ts +59 -0
- package/dist/provider/convert-messages.d.ts.map +1 -0
- package/dist/provider/convert-messages.js +248 -0
- package/dist/provider/error-coercion.d.ts +19 -0
- package/dist/provider/error-coercion.d.ts.map +1 -0
- package/dist/provider/error-coercion.js +38 -0
- package/dist/provider/events.d.ts +67 -0
- package/dist/provider/events.d.ts.map +1 -0
- package/dist/provider/events.js +307 -0
- package/dist/provider/index.d.ts +28 -0
- package/dist/provider/index.d.ts.map +1 -0
- package/dist/provider/index.js +64 -0
- package/dist/provider/inference-trace.d.ts +58 -0
- package/dist/provider/inference-trace.d.ts.map +1 -0
- package/dist/provider/inference-trace.js +205 -0
- package/dist/provider/model-host.d.ts +94 -0
- package/dist/provider/model-host.d.ts.map +1 -0
- package/dist/provider/model-host.js +134 -0
- package/dist/provider/model-registry-filter.d.ts +36 -0
- package/dist/provider/model-registry-filter.d.ts.map +1 -0
- package/dist/provider/model-registry-filter.js +82 -0
- package/dist/provider/models.d.ts +35 -0
- package/dist/provider/models.d.ts.map +1 -0
- package/dist/provider/models.js +132 -0
- package/dist/provider/performance-status.d.ts +28 -0
- package/dist/provider/performance-status.d.ts.map +1 -0
- package/dist/provider/performance-status.js +91 -0
- package/dist/provider/reasoning-tag-buffer.d.ts +23 -0
- package/dist/provider/reasoning-tag-buffer.d.ts.map +1 -0
- package/dist/provider/reasoning-tag-buffer.js +60 -0
- package/dist/provider/stream-adapter.d.ts +61 -0
- package/dist/provider/stream-adapter.d.ts.map +1 -0
- package/dist/provider/stream-adapter.js +358 -0
- package/dist/provider/tool-call-buffer.d.ts +30 -0
- package/dist/provider/tool-call-buffer.d.ts.map +1 -0
- package/dist/provider/tool-call-buffer.js +75 -0
- package/dist/provider/warm-reuse.d.ts +73 -0
- package/dist/provider/warm-reuse.d.ts.map +1 -0
- package/dist/provider/warm-reuse.js +88 -0
- package/dist/run-agent.d.ts +62 -0
- package/dist/run-agent.d.ts.map +1 -0
- package/dist/run-agent.js +86 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- 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"}
|
package/dist/catalog.js
ADDED
|
@@ -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"}
|