pi-better-subagents 0.1.0

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/capacity.mjs ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared capacity admission for subagent_spawn and subagent_spawn_batch.
3
+ *
4
+ * Running metas alone are not enough: between "admit" and "writeMeta" an
5
+ * async yield can let another spawn oversubscribe maxConcurrent. This gate
6
+ * keeps in-process pending reservations that count against the same cap for
7
+ * every spawn path in the parent process.
8
+ *
9
+ * Contract:
10
+ * - tryReserve(n) is all-or-nothing for n slots.
11
+ * - commit(n) after a run becomes "running" (pending → running).
12
+ * - release(n) after a pre-spawn failure (pending freed for backfill).
13
+ * - available = max(0, maxConcurrent - running - pending).
14
+ */
15
+
16
+ /**
17
+ * Free slots after subtracting durable running metas and in-flight reservations.
18
+ */
19
+ export function availableSlots({ runningCount, pendingCount = 0, maxConcurrent }) {
20
+ return Math.max(0, maxConcurrent - runningCount - pendingCount);
21
+ }
22
+
23
+ /**
24
+ * Error text for reject-mode whole-batch capacity failure. Kept identical to
25
+ * planBatchLaunches so callers can throw without re-planning.
26
+ */
27
+ export function formatCapacityRejectMessage({
28
+ jobCount,
29
+ runningCount,
30
+ pendingCount = 0,
31
+ maxConcurrent,
32
+ }) {
33
+ const available = availableSlots({ runningCount, pendingCount, maxConcurrent });
34
+ return (
35
+ `Batch of ${jobCount} jobs exceeds available capacity ` +
36
+ `(${available}/${maxConcurrent} subagent slots free). ` +
37
+ 'Stop some runs or set onCapacity to "launch-available".'
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Process-local capacity gate. One instance is shared by single-spawn and
43
+ * batch-spawn so reservations cannot race each other inside the parent.
44
+ *
45
+ * @param {() => number} countRunning durable running metas owned by this parent
46
+ */
47
+ export function createCapacityGate(countRunning) {
48
+ let pending = 0;
49
+
50
+ return {
51
+ get pending() {
52
+ return pending;
53
+ },
54
+
55
+ /**
56
+ * Reserve `count` slots atomically against current running+pending.
57
+ * Returns false without mutating state when capacity is insufficient.
58
+ */
59
+ tryReserve(count, maxConcurrent) {
60
+ if (count <= 0) return true;
61
+ const available = availableSlots({
62
+ runningCount: countRunning(),
63
+ pendingCount: pending,
64
+ maxConcurrent,
65
+ });
66
+ if (count > available) return false;
67
+ pending += count;
68
+ return true;
69
+ },
70
+
71
+ /** Convert reserved slots into durable running occupancy. */
72
+ commit(count = 1) {
73
+ if (count <= 0) return;
74
+ if (count > pending) {
75
+ throw new Error(
76
+ `capacity commit(${count}) exceeds pending reservations (${pending})`,
77
+ );
78
+ }
79
+ pending -= count;
80
+ },
81
+
82
+ /** Free reserved slots that never became a running run (pre-spawn failure). */
83
+ release(count = 1) {
84
+ if (count <= 0) return;
85
+ if (count > pending) {
86
+ throw new Error(
87
+ `capacity release(${count}) exceeds pending reservations (${pending})`,
88
+ );
89
+ }
90
+ pending -= count;
91
+ },
92
+ };
93
+ }
94
+
95
+ /** Singleton used by the extension so single and batch tools share one ledger. */
96
+ let sharedGate;
97
+
98
+ /**
99
+ * Return the process-wide gate, creating it with `countRunning` on first use.
100
+ * Tests may call `_resetSharedCapacityGateForTests` between cases.
101
+ */
102
+ export function getSharedCapacityGate(countRunning) {
103
+ if (!sharedGate) {
104
+ sharedGate = createCapacityGate(countRunning);
105
+ }
106
+ return sharedGate;
107
+ }
108
+
109
+ /** Test-only: drop the singleton so a suite starts from a clean ledger. */
110
+ export function _resetSharedCapacityGateForTests() {
111
+ sharedGate = undefined;
112
+ }
package/completion.mjs ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Pure formatter functions for subagent completion messages.
3
+ *
4
+ * These functions produce lightweight messages that:
5
+ * - callback=true (trigger): Signal completion and direct to subagent_result
6
+ * - callback=false (quiet): Note completion without auto-posting
7
+ *
8
+ * The actual result is NEVER embedded in the trigger message to avoid
9
+ * double-display (model presents it AND it's embedded).
10
+ */
11
+
12
+ /**
13
+ * Format a callback trigger message for callback:true.
14
+ *
15
+ * This is a SHORT message that:
16
+ * - Announces the subagent finished
17
+ * - Tells the model to call/use subagent_result id="<id>"
18
+ * - Does NOT contain "--- result ---" or any result payload
19
+ * - MAY include label, verdict, stat, and tools list
20
+ *
21
+ * @param p.id - The run id
22
+ * @param p.label - Human-readable label (e.g., "reviewer (abc123)")
23
+ * @param p.verdict - Status line (e.g., "✓ completed" or "✗ failed (exit 1)")
24
+ * @param p.stat - Statistics line (e.g., "45s · 1.2k tok · $0.0034")
25
+ * @param p.tools - Optional tools used (e.g., "read,bash,web_fetch")
26
+ */
27
+ export function formatCallbackTrigger(p) {
28
+ const tools = p.tools ? ` ·${p.tools.replace(/\n/, " ")}` : "";
29
+ const lifecycle = p.lifecycleClassification ? ` · lifecycle ${p.lifecycleClassification}` : "";
30
+ const announcement = p.incomplete
31
+ ? "ATTENTION: a background subagent exited unexpectedly before producing a coherent final result."
32
+ : "A background subagent you launched has returned.";
33
+ const instruction = p.incomplete
34
+ ? `Inspect the diagnostic with subagent_result id="${p.id}" before deciding how to continue.`
35
+ : `Ingest this signal and call subagent_result id="${p.id}" to retrieve the actual result, then use/present it as appropriate.`;
36
+ return `${announcement}\nsubagent: ${p.label} · ${p.verdict} · ${p.stat}${tools}${lifecycle}\n\n${instruction}`;
37
+ }
38
+
39
+ /**
40
+ * Format a quiet completion message for callback:false.
41
+ *
42
+ * This message:
43
+ * - Announces the subagent finished
44
+ * - Explicitly states result is NOT auto-posted
45
+ * - Directs to subagent_result for on-demand retrieval
46
+ *
47
+ * @param p.id - The run id
48
+ * @param p.label - Human-readable label
49
+ * @param p.verdict - Status line
50
+ * @param p.stat - Statistics line
51
+ */
52
+ export function formatCallbackQuiet(p) {
53
+ const lifecycle = p.lifecycleClassification ? ` · lifecycle ${p.lifecycleClassification}` : "";
54
+ if (p.incomplete) {
55
+ return `ATTENTION: background subagent ${p.label} ended unexpectedly · ${p.verdict} · ${p.stat}${lifecycle}. ` +
56
+ `Diagnostic NOT auto-posted (callback:false). Inspect it with subagent_result id="${p.id}".`;
57
+ }
58
+ return (
59
+ `Background subagent ${p.label} ${p.verdict} · ${p.stat}${lifecycle}. ` +
60
+ `Result NOT auto-posted (callback:false). ` +
61
+ `Read it with subagent_result id="${p.id}" when wanted.`
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Build the completion delivery from run outcome.
67
+ *
68
+ * This is the SINGLE place where sendMessage content and options are assembled.
69
+ * `resultText` is accepted so callers can pass it without tests breaking, but
70
+ * it is NEVER placed into `content` for either branch — the actual result is
71
+ * always fetched via subagent_result, not embedded in the trigger/quiet message.
72
+ *
73
+ * @param p.id - Run id
74
+ * @param p.label - Human-readable label
75
+ * @param p.verdict - Status line (e.g. "✓ completed")
76
+ * @param p.stat - Statistics line (e.g. "45s · 1.2k tok")
77
+ * @param p.tools - Optional tools list
78
+ * @param p.callback - Whether to trigger a turn (true) or be quiet (false)
79
+ * @param p.resultText - The parsed final answer; MUST NOT appear in content
80
+ */
81
+ export function buildCompletionDelivery(p) {
82
+ if (p.callback) {
83
+ return {
84
+ content: formatCallbackTrigger({
85
+ id: p.id,
86
+ label: p.label,
87
+ verdict: p.verdict,
88
+ stat: p.stat,
89
+ tools: p.tools,
90
+ incomplete: p.incomplete,
91
+ lifecycleClassification: p.lifecycleClassification,
92
+ }),
93
+ options: { deliverAs: "followUp", triggerTurn: true },
94
+ };
95
+ }
96
+ return {
97
+ content: formatCallbackQuiet({
98
+ id: p.id,
99
+ label: p.label,
100
+ verdict: p.verdict,
101
+ stat: p.stat,
102
+ incomplete: p.incomplete,
103
+ lifecycleClassification: p.lifecycleClassification,
104
+ }),
105
+ options: { deliverAs: "nextTurn" },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Format a health-attention trigger for orphaned/lost transitions (#65).
111
+ *
112
+ * Distinct from completion triggers: this is an ATTENTION signal that
113
+ * supervision broke (or ran out), not that a coherent result is ready.
114
+ * Never embeds artifacts — the coordinator inspects via tools.
115
+ *
116
+ * @param p.id - Run id
117
+ * @param p.label - Human-readable label
118
+ * @param p.status - "orphaned" | "lost"
119
+ */
120
+ export function formatHealthCallbackTrigger(p) {
121
+ const inspect =
122
+ `Inspect with subagent_result id="${p.id}" and subagent_output id="${p.id}". ` +
123
+ `You may wait, stop (subagent_stop id="${p.id}"), or retry from the original task.`;
124
+ if (p.status === "orphaned") {
125
+ return (
126
+ `ATTENTION: a background subagent lost supervision and is now orphaned.\n` +
127
+ `subagent: ${p.label} · status orphaned\n\n` +
128
+ `Supervision was lost; related process-group work may still be alive. ` +
129
+ `This is not a final result.\n\n` +
130
+ inspect
131
+ );
132
+ }
133
+ return (
134
+ `ATTENTION: a background subagent is lost.\n` +
135
+ `subagent: ${p.label} · status lost\n\n` +
136
+ `No related process remains and no coherent terminal completion was observed. ` +
137
+ `This is a terminal unknown outcome, not a normal failure.\n\n` +
138
+ inspect
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Build the model delivery for an orphaned/lost health transition.
144
+ *
145
+ * Uses the same non-interrupting mechanics as completion
146
+ * (`deliverAs: "followUp", triggerTurn: true`) but distinct ATTENTION wording.
147
+ * Returns null when callback is false — model follow-up is suppressed; the
148
+ * caller still owns human ui.notify / TUI-visible state.
149
+ *
150
+ * @param p.id - Run id
151
+ * @param p.label - Human-readable label
152
+ * @param p.status - "orphaned" | "lost"
153
+ * @param p.callback - Whether to trigger a coordinator turn (default true)
154
+ */
155
+ export function buildHealthCallbackDelivery(p) {
156
+ if (p.callback === false) return null;
157
+ return {
158
+ content: formatHealthCallbackTrigger({
159
+ id: p.id,
160
+ label: p.label,
161
+ status: p.status,
162
+ }),
163
+ options: { deliverAs: "followUp", triggerTurn: true },
164
+ };
165
+ }
package/completion.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * TypeScript re-export of completion.mjs formatters.
3
+ * The actual logic lives in completion.mjs for ESM test compatibility.
4
+ */
5
+ export {
6
+ formatCallbackTrigger,
7
+ formatCallbackQuiet,
8
+ buildCompletionDelivery,
9
+ formatHealthCallbackTrigger,
10
+ buildHealthCallbackDelivery,
11
+ } from "./completion.mjs";
package/config.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "defaultModel": null,
3
+ "defaultTools": "read, bash, edit, write, web_search, web_fetch",
4
+ "maxConcurrent": 4,
5
+
6
+ "toolExtensions": {
7
+ "web_search": "npm:@juicesharp/rpiv-web-tools",
8
+ "web_fetch": "npm:@juicesharp/rpiv-web-tools"
9
+ },
10
+
11
+ "providerExtensions": {},
12
+
13
+ "inheritExtensions": false
14
+ }
package/config.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Extension config — a single `config.json` next to this file sets defaults for
3
+ * every subagent, each overridable per `subagent_spawn` call.
4
+ *
5
+ * { "defaultModel": "xai/grok-4.5", "defaultTools": "read, bash, web_fetch" }
6
+ *
7
+ * `defaultModel: null` / absent → inherit the foreground model.
8
+ * `defaultTools` absent → the built-in SAFE_DEFAULT_TOOLS.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, join } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { SELF_SPEC } from "./extensions.ts";
16
+
17
+ export interface SubagentConfig {
18
+ defaultModel?: string | null;
19
+ defaultTools?: string | null;
20
+ /** Max subagents allowed to run at once. */
21
+ maxConcurrent?: number | null;
22
+ /**
23
+ * Tool name → extension package(s) that provide it. Drives which extension
24
+ * CODE loads in a child: only packages backing a requested tool are loaded.
25
+ * Builtins (read/bash/edit/write) need no entry.
26
+ */
27
+ toolExtensions?: Record<string, string | string[]> | null;
28
+ /**
29
+ * Provider → extension package(s) that authenticate it. Model auth is not
30
+ * tool-shaped: `xai/grok-4.5` needs pi-xai-oauth loaded whatever the tools.
31
+ */
32
+ providerExtensions?: Record<string, string | string[]> | null;
33
+ /**
34
+ * OPERATOR-ONLY escape hatch: load every globally-installed extension in
35
+ * children (pre-#17 behavior). Never model-selectable. Re-exposes the
36
+ * mid-turn exit-0 drain if any installed package breaks process lifetime.
37
+ */
38
+ inheritExtensions?: boolean | null;
39
+ /**
40
+ * Optional health-observation thresholds (issue #66). Milliseconds.
41
+ * Override the defaults used by `resolveHealthThresholds` /
42
+ * `loadHealthThresholdsFromConfig` without expanding the spawn tool API.
43
+ */
44
+ healthQuietMs?: number | null;
45
+ healthStaleMs?: number | null;
46
+ healthLongToolMs?: number | null;
47
+ healthLongCompactionMs?: number | null;
48
+ }
49
+
50
+ /** Concurrency cap when config.json sets none. */
51
+ export const DEFAULT_MAX_CONCURRENT = 4;
52
+
53
+ /** Built-in default tool set when config.json sets nothing. */
54
+ export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetch";
55
+ /** Safe default for a hermetic (clean) child where extension tools don't exist. */
56
+ export const SAFE_CLEAN_TOOLS = "read, bash";
57
+
58
+ let cached: SubagentConfig | undefined;
59
+
60
+ /** Load config.json from the extension directory. Missing/invalid → {}. */
61
+ export function loadConfig(): SubagentConfig {
62
+ if (cached) return cached;
63
+ try {
64
+ const dir = dirname(fileURLToPath(import.meta.url));
65
+ cached = JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")) as SubagentConfig;
66
+ } catch {
67
+ cached = {};
68
+ }
69
+ return cached;
70
+ }
71
+
72
+ /** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
73
+ export function normalizeTools(list: string): string {
74
+ return list.split(",").map((t) => t.trim()).filter(Boolean).join(",");
75
+ }
76
+
77
+ /** This package's own root — the extension dir, which is also `self`. */
78
+ export function selfDir(): string {
79
+ return dirname(fileURLToPath(import.meta.url));
80
+ }
81
+
82
+ /** Where pi installs npm packages (honors PI_CODING_AGENT_DIR). */
83
+ function piAgentDir(): string {
84
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
85
+ }
86
+
87
+ /**
88
+ * Materialize an extension spec to an on-disk path pi's `-e` accepts.
89
+ *
90
+ * "self" → this package's dir
91
+ * "npm:<pkg>" → <agentDir>/npm/node_modules/<pkg>
92
+ * "/abs/path" → as-is
93
+ *
94
+ * Returns undefined when the package is not installed, so the caller can report
95
+ * a missing dependency instead of silently launching a child without it.
96
+ * (`-e` accepts either a package directory or an entrypoint file — verified.)
97
+ */
98
+ export function resolveExtensionPath(spec: string): string | undefined {
99
+ if (spec === SELF_SPEC) return selfDir();
100
+ const path = spec.startsWith("npm:")
101
+ ? join(piAgentDir(), "npm", "node_modules", spec.slice(4))
102
+ : spec;
103
+ return existsSync(path) ? path : undefined;
104
+ }
package/extensions.mjs ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Headless extension resolution — decide which extension CODE loads in a child.
3
+ *
4
+ * Why this exists (issue #17): a subagent that loads every globally-installed
5
+ * package inherits their startup side effects. `pi-patty-bg-tasks` replaces the
6
+ * builtin `bash` with a `detached` + `unref()` spawn; in `pi -p` a parallel
7
+ * `bash` + `read` batch then drains the Node event loop and the child EXITS 0
8
+ * mid-turn — no `tool_execution_end`, no `agent_end`. Measured: 30 recorded runs,
9
+ * 17 died this way and every one was reported as "completed".
10
+ *
11
+ * A tool allowlist CANNOT fix this. `--tools` restricts what the model may CALL;
12
+ * the offending package has already overridden builtin `bash` at startup, so the
13
+ * `bash` in your allowlist IS the broken one. Verified: extensions on + exactly
14
+ * `read,bash,edit,write,web_search,web_fetch` still dies 2-start / 1-end.
15
+ *
16
+ * pi also has no "load everything except X" flag — only `--extension/-e <path>`
17
+ * (add one) and `--no-extensions` (all off). So a package DENYLIST is not
18
+ * expressible at the CLI at all. The only mechanism that excludes a package is
19
+ * to stop loading everything and name what you want:
20
+ *
21
+ * pi -p --no-extensions -e <needed> -e <needed>
22
+ *
23
+ * That is what this module computes. The needed set is DERIVED from the tool
24
+ * allowlist the caller already passes, so least privilege falls out with no new
25
+ * vocabulary for the model to get wrong, and every future lifetime-breaking
26
+ * package is excluded by default rather than by name.
27
+ *
28
+ * Pure: no fs, no env. Path materialization lives in index.ts.
29
+ */
30
+
31
+ /**
32
+ * Tools pi provides with `--no-extensions`. Verified empirically:
33
+ * pi -p --no-extensions "list your tools" -> read, bash, edit, write
34
+ * A builtin needs no extension, so it never counts as unmapped.
35
+ */
36
+ export const BUILTIN_TOOLS = ["read", "bash", "edit", "write"];
37
+
38
+ /** Marker spec meaning "this extension's own package" (for allow_nested). */
39
+ export const SELF_SPEC = "self";
40
+
41
+ /** Split "xai/grok-4.5" -> "xai". Returns undefined when there is no provider. */
42
+ export function providerOf(model) {
43
+ if (typeof model !== "string") return undefined;
44
+ const i = model.indexOf("/");
45
+ return i > 0 ? model.slice(0, i) : undefined;
46
+ }
47
+
48
+ /** Normalize a config value that may be a single spec or a list of them. */
49
+ function toSpecList(value) {
50
+ if (typeof value === "string") return value.trim() ? [value.trim()] : [];
51
+ if (Array.isArray(value)) return value.filter((s) => typeof s === "string" && s.trim()).map((s) => s.trim());
52
+ return [];
53
+ }
54
+
55
+ /** Parse a comma/space separated tool allowlist into unique bare names. */
56
+ export function toolList(tools) {
57
+ if (!tools) return [];
58
+ const out = [];
59
+ for (const raw of String(tools).split(",")) {
60
+ const t = raw.trim();
61
+ if (t && !out.includes(t)) out.push(t);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Decide the child's extension set.
68
+ *
69
+ * @param {object} a
70
+ * @param {string} [a.tools] Tool allowlist (comma form) the child will get.
71
+ * @param {string} [a.model] Model as "provider/id" — pulls in provider auth.
72
+ * @param {boolean} [a.clean] Hermetic builtins-only child.
73
+ * @param {boolean} [a.allowNested] Child may spawn its own subagents.
74
+ * @param {object} [a.config] Extension config (toolExtensions, providerExtensions, inheritExtensions).
75
+ * @returns {{mode: "isolated"|"clean"|"inherit", specs: string[], unmapped: string[], reasons: Record<string,string[]>}}
76
+ * mode — "isolated" passes --no-extensions + explicit -e (the default);
77
+ * "clean" passes --no-extensions with no -e;
78
+ * "inherit" passes neither (operator opt-out, NOT model-selectable).
79
+ * specs — extension specs to load, deduped, in a stable order.
80
+ * unmapped — requested tools that are neither builtin nor mapped to an
81
+ * extension. They simply will not exist in the child; surfaced so
82
+ * the human sees it instead of the child silently lacking a tool.
83
+ * reasons — spec -> what pulled it in, for the spawn diagnostic.
84
+ */
85
+ export function resolveExtensions(a = {}) {
86
+ const cfg = a.config ?? {};
87
+ const specs = [];
88
+ const reasons = {};
89
+ const add = (spec, why) => {
90
+ if (!spec) return;
91
+ if (!specs.includes(spec)) specs.push(spec);
92
+ (reasons[spec] ??= []).push(why);
93
+ };
94
+
95
+ // allow_nested is honored in every mode: it is an explicit caller request,
96
+ // and without our own package loaded the child has no subagent tools at all.
97
+ if (a.allowNested) add(SELF_SPEC, "allow_nested");
98
+
99
+ if (cfg.inheritExtensions === true) {
100
+ return { mode: "inherit", specs: [], unmapped: [], reasons: {} };
101
+ }
102
+ if (a.clean === true) {
103
+ return { mode: "clean", specs, unmapped: [], reasons };
104
+ }
105
+
106
+ const toolMap = cfg.toolExtensions ?? {};
107
+ const unmapped = [];
108
+ for (const tool of toolList(a.tools)) {
109
+ if (BUILTIN_TOOLS.includes(tool)) continue;
110
+ const mapped = toSpecList(toolMap[tool]);
111
+ if (mapped.length === 0) {
112
+ if (!unmapped.includes(tool)) unmapped.push(tool);
113
+ continue;
114
+ }
115
+ for (const spec of mapped) add(spec, `tool:${tool}`);
116
+ }
117
+
118
+ // Model auth is not tool-shaped: xai/grok needs pi-xai-oauth loaded or the
119
+ // child cannot authenticate at all, whatever tools it was granted.
120
+ const provider = providerOf(a.model);
121
+ if (provider) {
122
+ for (const spec of toSpecList((cfg.providerExtensions ?? {})[provider])) {
123
+ add(spec, `provider:${provider}`);
124
+ }
125
+ }
126
+
127
+ return { mode: "isolated", specs, unmapped, reasons };
128
+ }
129
+
130
+ /**
131
+ * Build the pi CLI flags for a resolution. `resolvePath` maps a spec to an
132
+ * on-disk path (or undefined when the package is not installed); unresolvable
133
+ * specs are reported rather than silently dropped.
134
+ *
135
+ * @returns {{args: string[], missing: string[]}}
136
+ */
137
+ export function extensionArgs(resolution, resolvePath) {
138
+ if (resolution.mode === "inherit") return { args: [], missing: [] };
139
+ const args = ["--no-extensions"];
140
+ const missing = [];
141
+ for (const spec of resolution.specs) {
142
+ const path = resolvePath(spec);
143
+ if (!path) { missing.push(spec); continue; }
144
+ args.push("--extension", path);
145
+ }
146
+ return { args, missing };
147
+ }
package/extensions.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * TypeScript re-export of extensions.mjs pure helpers.
3
+ * Logic lives in extensions.mjs for ESM unit-test compatibility (mirrors widget.ts).
4
+ */
5
+ export {
6
+ BUILTIN_TOOLS,
7
+ SELF_SPEC,
8
+ providerOf,
9
+ toolList,
10
+ resolveExtensions,
11
+ extensionArgs,
12
+ } from "./extensions.mjs";
13
+
14
+ export interface ExtensionResolution {
15
+ mode: "isolated" | "clean" | "inherit";
16
+ specs: string[];
17
+ unmapped: string[];
18
+ reasons: Record<string, string[]>;
19
+ }