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/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
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
|
+
}
|