@howarewoo/pi-subagents 0.0.0-stage → 0.2.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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.md +499 -2
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/docs/advanced-policies.md +78 -0
- package/docs/delegation-contract.md +297 -0
- package/docs/images/subagent-hub.svg +1 -0
- package/docs/images/subagent-profiles.svg +1 -0
- package/docs/images/subagent-widget.svg +1 -0
- package/docs/parity-inventory.md +330 -0
- package/docs/provenance.md +13 -0
- package/docs/releases.md +236 -0
- package/docs/verification.md +430 -0
- package/package.json +64 -4
- package/src/capabilities.ts +39 -0
- package/src/child-execution.ts +459 -0
- package/src/coordinator.ts +1376 -0
- package/src/delegation-types.ts +111 -0
- package/src/hub.ts +612 -0
- package/src/model-adapter.ts +77 -0
- package/src/model-policy.ts +100 -0
- package/src/outcomes.ts +110 -0
- package/src/pi-extended-subagents.ts +673 -0
- package/src/profile-viewer.ts +246 -0
- package/src/profiles.ts +225 -0
- package/src/scheduling.ts +73 -0
- package/src/subagent-widget.ts +115 -0
- package/src/supported-host.ts +26 -0
- package/src/task-input.ts +74 -0
- package/src/task-presentation.ts +165 -0
- package/src/thread.ts +268 -0
- package/src/tool-schemas.ts +165 -0
- package/src/workspace.ts +46 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { Skill, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
colorToOklch,
|
|
4
|
+
oklchColor,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
wrapTextWithAnsi,
|
|
9
|
+
type Component,
|
|
10
|
+
type KeybindingsManager,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import { safe } from "./thread.ts";
|
|
13
|
+
import { bundledProfiles, resolveProfile, type Profile } from "./profiles.ts";
|
|
14
|
+
|
|
15
|
+
export const profileToolPolicy =
|
|
16
|
+
"Profile tools are maximum allowlists, not requirements. Admission intersects callable parent builtins without activating tools. Command context has no callable-tool dispatcher: effective tools are unknown here. Active declarations are not proof of callability. General inherits available parent skills and callable builtins within its ceiling, not parent instructions/history; only operator-reviewed leaf tools may be forwarded and all require a writer lease. Leaf grants assert trusted code, not a sandbox. Inspect task snapshot tools, leafTools, skills and capabilityDenials for admitted grants.";
|
|
17
|
+
|
|
18
|
+
/** Resolve through the same trust/config/skill validation boundary used by admission. */
|
|
19
|
+
export async function resolvedProfiles(
|
|
20
|
+
cwd: string,
|
|
21
|
+
trusted: boolean,
|
|
22
|
+
operatorDir?: string,
|
|
23
|
+
availableSkills: readonly Skill[] = [],
|
|
24
|
+
): Promise<Profile[]> {
|
|
25
|
+
return Promise.all(
|
|
26
|
+
bundledProfiles().map(
|
|
27
|
+
async ({ name }) =>
|
|
28
|
+
(await resolveProfile(name, cwd, trusted, operatorDir, availableSkills)).profile,
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type DetailLine = { text: string; heading?: boolean };
|
|
34
|
+
|
|
35
|
+
function details(profile: Profile): DetailLine[] {
|
|
36
|
+
const list = (items: readonly string[] | undefined) =>
|
|
37
|
+
items?.length ? items.join(", ") : "none";
|
|
38
|
+
const known = new Set([
|
|
39
|
+
"name",
|
|
40
|
+
"description",
|
|
41
|
+
"prompt",
|
|
42
|
+
"tools",
|
|
43
|
+
"skills",
|
|
44
|
+
"leafTools",
|
|
45
|
+
"spawn",
|
|
46
|
+
"coordination",
|
|
47
|
+
"models",
|
|
48
|
+
"model",
|
|
49
|
+
"prewalk",
|
|
50
|
+
]);
|
|
51
|
+
const heading = (text: string): DetailLine => ({ text, heading: true });
|
|
52
|
+
const value = (text: string): DetailLine => ({ text });
|
|
53
|
+
const structured = (label: string, item: unknown, indent = ""): DetailLine[] => {
|
|
54
|
+
if (Array.isArray(item))
|
|
55
|
+
return [
|
|
56
|
+
value(`${indent}${label}:${item.length ? "" : " none (empty grant)"}`),
|
|
57
|
+
...item.flatMap((entry, index) =>
|
|
58
|
+
structured(`Selection ${index + 1}`, entry, indent + " "),
|
|
59
|
+
),
|
|
60
|
+
];
|
|
61
|
+
if (item && typeof item === "object")
|
|
62
|
+
return [
|
|
63
|
+
value(`${indent}${label}:`),
|
|
64
|
+
...Object.entries(item).flatMap(([key, entry]) => structured(key, entry, indent + " ")),
|
|
65
|
+
];
|
|
66
|
+
return [value(`${indent}${label}: ${String(item)}`)];
|
|
67
|
+
};
|
|
68
|
+
return [
|
|
69
|
+
heading("Description"),
|
|
70
|
+
value(profile.description),
|
|
71
|
+
value(""),
|
|
72
|
+
heading("Prompt"),
|
|
73
|
+
value(profile.prompt),
|
|
74
|
+
value(""),
|
|
75
|
+
heading("Tools and permissions"),
|
|
76
|
+
value(`Tools ceiling: ${list(profile.tools)}`),
|
|
77
|
+
value("Callable tools: unknown in command context; inspect admitted task tools."),
|
|
78
|
+
...(profile.name === "general"
|
|
79
|
+
? [
|
|
80
|
+
value(
|
|
81
|
+
"Inheritance: available parent skills and callable builtins; no parent instructions/history.",
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
: []),
|
|
85
|
+
value(
|
|
86
|
+
`Reviewed leaf ceiling: ${list(profile.leafTools)} (writer lease; trusted code, not sandboxed)`,
|
|
87
|
+
),
|
|
88
|
+
value(`Spawn ceiling: ${list(profile.spawn)}`),
|
|
89
|
+
value(`Coordination: ${list(profile.coordination)}`),
|
|
90
|
+
value(""),
|
|
91
|
+
heading("Skills (exact paths)"),
|
|
92
|
+
...(profile.skills.length ? profile.skills.map(value) : [value("none")]),
|
|
93
|
+
value(""),
|
|
94
|
+
heading("Model policy"),
|
|
95
|
+
...structured("Model default", profile.model ?? "selected parent model"),
|
|
96
|
+
...structured("Models grant", profile.models ?? "no explicit override grant"),
|
|
97
|
+
...structured("Prewalk", profile.prewalk ?? "off"),
|
|
98
|
+
...Object.entries(profile)
|
|
99
|
+
.filter(([key]) => !known.has(key))
|
|
100
|
+
.map(([key, item]) => value(`${key}: ${JSON.stringify(item)}`)),
|
|
101
|
+
value(""),
|
|
102
|
+
value(
|
|
103
|
+
"Admission also applies parent/ancestry, model availability, capacity and writer checks. This viewer grants nothing.",
|
|
104
|
+
),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read-only snapshot consumer; no fixed role count or policy defaults in the renderer. */
|
|
109
|
+
export function createProfileViewer(
|
|
110
|
+
profiles: readonly Profile[],
|
|
111
|
+
theme: Pick<Theme, "fg" | "style"> & { colors: Pick<Theme["colors"], "customMessageBg"> },
|
|
112
|
+
keys: Pick<KeybindingsManager, "matches" | "getKeys">,
|
|
113
|
+
requestRender: () => void,
|
|
114
|
+
done: () => void,
|
|
115
|
+
availableRows: () => number,
|
|
116
|
+
): Component {
|
|
117
|
+
let selected = 0;
|
|
118
|
+
let offset = 0;
|
|
119
|
+
let viewport = 1;
|
|
120
|
+
let maximum = 0;
|
|
121
|
+
const cell = (text: string, width: number) => {
|
|
122
|
+
const clipped = truncateToWidth(text, width);
|
|
123
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
render(width) {
|
|
127
|
+
width = Math.max(0, width);
|
|
128
|
+
const height = Math.max(1, Math.floor(availableRows()));
|
|
129
|
+
const close = keys.getKeys("tui.select.cancel").join("/") || "Esc";
|
|
130
|
+
const panelBg = oklchColor(colorToOklch(theme.colors.customMessageBg).l, 0, 0);
|
|
131
|
+
// Match the Hub: foreground helpers may reset both colors inside a painted line.
|
|
132
|
+
const paint = (text: string) => {
|
|
133
|
+
const padded = cell(text, width);
|
|
134
|
+
const restoreBg = theme.style("", { bg: panelBg }).replace("\x1b[49m", "");
|
|
135
|
+
return padded
|
|
136
|
+
.split("\x1b[0m")
|
|
137
|
+
.map((part) => theme.style(part.replaceAll("\x1b[49m", restoreBg), { bg: panelBg }))
|
|
138
|
+
.join("\x1b[0m");
|
|
139
|
+
};
|
|
140
|
+
const fallback = () =>
|
|
141
|
+
[`Profiles · ${safe(close)}: close`, "Resize for details"].slice(0, height).map(paint);
|
|
142
|
+
if (width < 24) return fallback();
|
|
143
|
+
const inner = width - 4;
|
|
144
|
+
const wide = width >= 88;
|
|
145
|
+
const leftWidth = wide ? Math.min(28, Math.floor(inner / 3)) : inner;
|
|
146
|
+
const rightWidth = wide ? inner - leftWidth - 3 : inner;
|
|
147
|
+
const footer = [
|
|
148
|
+
"↑↓ select · j/k scroll · PgUp/Dn · Home/End",
|
|
149
|
+
`${safe(close)}: close · callable tools unknown here`,
|
|
150
|
+
].flatMap((line) => wrapTextWithAnsi(line, inner));
|
|
151
|
+
// Frames, divider and hints are reserved before assigning any viewport rows.
|
|
152
|
+
const bodyHeight = height - 3 - footer.length;
|
|
153
|
+
const listHeight = wide ? bodyHeight : Math.min(3, Math.max(1, Math.floor(bodyHeight / 3)));
|
|
154
|
+
const detailHeight = wide ? bodyHeight : bodyHeight - listHeight - 1;
|
|
155
|
+
if (detailHeight < 4) return fallback();
|
|
156
|
+
viewport = detailHeight - 3;
|
|
157
|
+
const start = Math.max(0, selected - listHeight + 1);
|
|
158
|
+
const roster = profiles
|
|
159
|
+
.slice(start, start + listHeight)
|
|
160
|
+
.map((profile, index) =>
|
|
161
|
+
theme.fg(
|
|
162
|
+
start + index === selected ? "accent" : "text",
|
|
163
|
+
`${start + index === selected ? ">" : " "} ${safe(profile.name).replaceAll("\n", " ")}`,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
const profile = profiles[selected];
|
|
167
|
+
const name = profile ? safe(profile.name).replaceAll("\n", " ") : "No selection";
|
|
168
|
+
const content = profile ? details(profile) : [{ text: "No resolved profiles" }];
|
|
169
|
+
// A clipped pinned name still has a complete, scrollable representation.
|
|
170
|
+
if (profile && (visibleWidth(name) > rightWidth || profile.name.includes("\n")))
|
|
171
|
+
content.unshift({ text: profile.name });
|
|
172
|
+
const lines = content.flatMap(({ text, heading }) =>
|
|
173
|
+
safe(text)
|
|
174
|
+
.split("\n")
|
|
175
|
+
.flatMap((line) =>
|
|
176
|
+
wrapTextWithAnsi(line, rightWidth).map((wrapped) =>
|
|
177
|
+
theme.fg(heading ? "accent" : "text", wrapped),
|
|
178
|
+
),
|
|
179
|
+
),
|
|
180
|
+
);
|
|
181
|
+
maximum = Math.max(0, lines.length - viewport);
|
|
182
|
+
offset = Math.min(maximum, offset);
|
|
183
|
+
const detail = [
|
|
184
|
+
theme.fg("accent", name),
|
|
185
|
+
theme.fg("muted", "resolved · read-only"),
|
|
186
|
+
...lines.slice(offset, offset + viewport),
|
|
187
|
+
...Array(Math.max(0, viewport - (lines.length - offset))).fill(""),
|
|
188
|
+
theme.fg(
|
|
189
|
+
"muted",
|
|
190
|
+
`${profiles.length ? selected + 1 : 0}/${profiles.length} · detail ${offset + 1}–${Math.min(lines.length, offset + viewport)}/${lines.length}`,
|
|
191
|
+
),
|
|
192
|
+
];
|
|
193
|
+
const body = wide
|
|
194
|
+
? Array.from(
|
|
195
|
+
{ length: bodyHeight },
|
|
196
|
+
(_, index) =>
|
|
197
|
+
cell(roster[index] ?? "", leftWidth) +
|
|
198
|
+
theme.fg("border", " │ ") +
|
|
199
|
+
cell(detail[index] ?? "", rightWidth),
|
|
200
|
+
)
|
|
201
|
+
: [
|
|
202
|
+
...roster,
|
|
203
|
+
...Array(Math.max(0, listHeight - roster.length)).fill(""),
|
|
204
|
+
theme.fg("border", "─".repeat(inner)),
|
|
205
|
+
...detail,
|
|
206
|
+
];
|
|
207
|
+
const row = (text: string) =>
|
|
208
|
+
paint(theme.fg("border", "│") + ` ${cell(text, inner)} ` + theme.fg("border", "│"));
|
|
209
|
+
const frame = (left: string, title: string, right: string) =>
|
|
210
|
+
paint(
|
|
211
|
+
theme.fg("border", left) +
|
|
212
|
+
theme.fg("accent", title) +
|
|
213
|
+
theme.fg("border", "─".repeat(Math.max(0, width - 2 - visibleWidth(title))) + right),
|
|
214
|
+
);
|
|
215
|
+
return [
|
|
216
|
+
frame("╭", " Subagent Profiles ", "╮"),
|
|
217
|
+
...body.map(row),
|
|
218
|
+
frame("├", "", "┤"),
|
|
219
|
+
...footer.map((line) => row(theme.fg("muted", line))),
|
|
220
|
+
frame("╰", "", "╯"),
|
|
221
|
+
];
|
|
222
|
+
},
|
|
223
|
+
handleInput(data) {
|
|
224
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
225
|
+
done();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (keys.matches(data, "tui.select.up") || keys.matches(data, "tui.select.down")) {
|
|
229
|
+
selected = Math.max(
|
|
230
|
+
0,
|
|
231
|
+
Math.min(profiles.length - 1, selected + (keys.matches(data, "tui.select.up") ? -1 : 1)),
|
|
232
|
+
);
|
|
233
|
+
offset = 0;
|
|
234
|
+
} else if (data === "j") offset = Math.min(maximum, offset + 1);
|
|
235
|
+
else if (data === "k") offset = Math.max(0, offset - 1);
|
|
236
|
+
else if (keys.matches(data, "tui.select.pageDown"))
|
|
237
|
+
offset = Math.min(maximum, offset + viewport);
|
|
238
|
+
else if (keys.matches(data, "tui.select.pageUp")) offset = Math.max(0, offset - viewport);
|
|
239
|
+
else if (matchesKey(data, "home")) offset = 0;
|
|
240
|
+
else if (matchesKey(data, "end")) offset = maximum;
|
|
241
|
+
else return;
|
|
242
|
+
requestRender();
|
|
243
|
+
},
|
|
244
|
+
invalidate() {},
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
CONFIG_DIR_NAME,
|
|
5
|
+
getAgentDir,
|
|
6
|
+
loadSkills,
|
|
7
|
+
type Skill,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import { narrowModelPolicy, allowsModel, sameSelection, type ModelPolicy } from "./model-policy.ts";
|
|
11
|
+
|
|
12
|
+
export const READ_TOOLS = ["read", "grep", "find", "ls"] as const;
|
|
13
|
+
export const WRITE_TOOLS = [...READ_TOOLS, "write", "edit", "bash"] as const;
|
|
14
|
+
export type BuiltinTool = (typeof WRITE_TOOLS)[number];
|
|
15
|
+
export const COORDINATION_ACTIONS = ["message", "inbox", "wait"] as const;
|
|
16
|
+
export type CoordinationAction = (typeof COORDINATION_ACTIONS)[number];
|
|
17
|
+
/** Tool arrays are ceilings; admission intersects callable parent builtins before writer/ancestry checks. */
|
|
18
|
+
export type Profile = Readonly<
|
|
19
|
+
ModelPolicy & {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
prompt: string;
|
|
23
|
+
tools: readonly BuiltinTool[];
|
|
24
|
+
skills: readonly string[];
|
|
25
|
+
/** Operator-reviewed leaf tools; never a tool-to-tool forwarding grant. */
|
|
26
|
+
leafTools?: readonly string[];
|
|
27
|
+
spawn?: readonly string[];
|
|
28
|
+
coordination?: readonly CoordinationAction[];
|
|
29
|
+
}
|
|
30
|
+
>;
|
|
31
|
+
const bundled: Record<string, Profile> = {
|
|
32
|
+
general: {
|
|
33
|
+
name: "general",
|
|
34
|
+
description: "General delegation with restricted parent capabilities",
|
|
35
|
+
prompt: "Complete the assigned analysis and report the evidence and limitations.",
|
|
36
|
+
tools: WRITE_TOOLS,
|
|
37
|
+
skills: [],
|
|
38
|
+
spawn: ["scout", "review"],
|
|
39
|
+
},
|
|
40
|
+
scout: {
|
|
41
|
+
name: "scout",
|
|
42
|
+
description: "Explore and locate evidence",
|
|
43
|
+
prompt:
|
|
44
|
+
"Explore the assignment using read-only tools. Report precise source locations and findings.",
|
|
45
|
+
tools: READ_TOOLS,
|
|
46
|
+
skills: [],
|
|
47
|
+
spawn: ["scout", "review"],
|
|
48
|
+
},
|
|
49
|
+
review: {
|
|
50
|
+
name: "review",
|
|
51
|
+
description: "Review without modifying files",
|
|
52
|
+
prompt:
|
|
53
|
+
"Review the assigned work. Report actionable findings with source locations; do not edit files.",
|
|
54
|
+
tools: READ_TOOLS,
|
|
55
|
+
skills: [],
|
|
56
|
+
spawn: ["scout", "review"],
|
|
57
|
+
},
|
|
58
|
+
implementation: {
|
|
59
|
+
name: "implementation",
|
|
60
|
+
description:
|
|
61
|
+
"Implement changes in the supplied workspace (exclusive model-tool writer; manual shells are uncoordinated)",
|
|
62
|
+
prompt:
|
|
63
|
+
"Implement the assigned bounded change, verify it and report changed files and remaining risks. Work in your supplied cwd; root permissions and global model-tool writer coordination still apply.",
|
|
64
|
+
tools: WRITE_TOOLS,
|
|
65
|
+
skills: [],
|
|
66
|
+
spawn: ["scout", "review"],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function config(path: string): Promise<Record<string, unknown>> {
|
|
71
|
+
try {
|
|
72
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
73
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
74
|
+
throw new Error("expected object");
|
|
75
|
+
return value as Record<string, unknown>;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
78
|
+
throw new Error(`Invalid delegation config ${path}: ${String(error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function strings(value: unknown, field: string): string[] {
|
|
82
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string"))
|
|
83
|
+
throw new Error(`${field} must be a string array`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
function specialize(base: Profile, value: unknown, operator: boolean): Profile {
|
|
87
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
88
|
+
throw new Error(`Invalid profile ${base.name}`);
|
|
89
|
+
const input = value as Record<string, unknown>;
|
|
90
|
+
for (const key of Object.keys(input))
|
|
91
|
+
if (
|
|
92
|
+
![
|
|
93
|
+
"description",
|
|
94
|
+
"prompt",
|
|
95
|
+
"tools",
|
|
96
|
+
"skills",
|
|
97
|
+
"leafTools",
|
|
98
|
+
"spawn",
|
|
99
|
+
"coordination",
|
|
100
|
+
"models",
|
|
101
|
+
"model",
|
|
102
|
+
"prewalk",
|
|
103
|
+
].includes(key)
|
|
104
|
+
)
|
|
105
|
+
throw new Error(`Unsupported profile field: ${key}`);
|
|
106
|
+
const tools = input.tools === undefined ? [...base.tools] : strings(input.tools, "tools");
|
|
107
|
+
if (tools.some((tool) => !base.tools.includes(tool as BuiltinTool)))
|
|
108
|
+
throw new Error(`Profile ${base.name} cannot broaden tool grants`);
|
|
109
|
+
const skills = input.skills === undefined ? [...base.skills] : strings(input.skills, "skills");
|
|
110
|
+
if (skills.some((path) => !isAbsolute(path) || (!operator && !base.skills.includes(path))))
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Profile ${base.name} cannot mint skill grants; operator paths must be absolute`,
|
|
113
|
+
);
|
|
114
|
+
const leafTools =
|
|
115
|
+
input.leafTools === undefined
|
|
116
|
+
? [...(base.leafTools ?? [])]
|
|
117
|
+
: strings(input.leafTools, "leafTools");
|
|
118
|
+
if (
|
|
119
|
+
leafTools.some(
|
|
120
|
+
(name) => !name || base.name !== "general" || (!operator && !base.leafTools?.includes(name)),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
throw new Error("Cannot mint leaf tool grants");
|
|
124
|
+
for (const field of ["description", "prompt"] as const)
|
|
125
|
+
if (input[field] !== undefined && typeof input[field] !== "string")
|
|
126
|
+
throw new Error(`${field} must be text`);
|
|
127
|
+
const spawn = input.spawn === undefined ? [...(base.spawn ?? [])] : strings(input.spawn, "spawn");
|
|
128
|
+
if (
|
|
129
|
+
spawn.some(
|
|
130
|
+
(name) => !Object.hasOwn(bundled, name) || (!operator && !base.spawn?.includes(name)),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
throw new Error("Cannot mint spawn grants");
|
|
134
|
+
const coordination =
|
|
135
|
+
input.coordination === undefined
|
|
136
|
+
? [...(base.coordination ?? [])]
|
|
137
|
+
: strings(input.coordination, "coordination");
|
|
138
|
+
if (
|
|
139
|
+
coordination.some(
|
|
140
|
+
(action) =>
|
|
141
|
+
!COORDINATION_ACTIONS.includes(action as CoordinationAction) ||
|
|
142
|
+
(!operator && !base.coordination?.includes(action as CoordinationAction)),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
throw new Error("Cannot mint coordination grants");
|
|
146
|
+
return {
|
|
147
|
+
...base,
|
|
148
|
+
...narrowModelPolicy(base, input, operator),
|
|
149
|
+
coordination: coordination as CoordinationAction[],
|
|
150
|
+
spawn,
|
|
151
|
+
description: (input.description as string) ?? base.description,
|
|
152
|
+
prompt: (input.prompt as string) ?? base.prompt,
|
|
153
|
+
tools: tools as BuiltinTool[],
|
|
154
|
+
skills,
|
|
155
|
+
leafTools,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Operator config can grant exact skill paths; trusted project config can only narrow those grants. */
|
|
160
|
+
export async function resolveProfile(
|
|
161
|
+
name: string,
|
|
162
|
+
cwd: string,
|
|
163
|
+
trusted: boolean,
|
|
164
|
+
operatorDir = getAgentDir(),
|
|
165
|
+
availableSkills: readonly Skill[] = [],
|
|
166
|
+
): Promise<{ profile: Profile; skills: Skill[] }> {
|
|
167
|
+
let profile = bundled[name];
|
|
168
|
+
if (!Object.hasOwn(bundled, name)) throw new Error(`Unknown delegation profile: ${name}`);
|
|
169
|
+
if (name === "general")
|
|
170
|
+
profile = { ...profile, skills: availableSkills.map((skill) => skill.filePath) };
|
|
171
|
+
const user = await config(join(operatorDir, "delegation.json"));
|
|
172
|
+
for (const key of Object.keys(user))
|
|
173
|
+
if (key !== "scheduling" && !Object.hasOwn(bundled, key))
|
|
174
|
+
throw new Error(`Unknown operator profile: ${key}`);
|
|
175
|
+
if (user[name] !== undefined) profile = specialize(profile, user[name], true);
|
|
176
|
+
if (trusted) {
|
|
177
|
+
const project = await config(join(cwd, CONFIG_DIR_NAME, "delegation.json"));
|
|
178
|
+
for (const key of Object.keys(project))
|
|
179
|
+
if (key !== "scheduling" && !Object.hasOwn(bundled, key))
|
|
180
|
+
throw new Error(`Unknown project profile: ${key}`);
|
|
181
|
+
if (project[name] !== undefined) profile = specialize(profile, project[name], false);
|
|
182
|
+
}
|
|
183
|
+
for (const path of profile.skills) await readFile(path, "utf8");
|
|
184
|
+
const loaded = loadSkills({
|
|
185
|
+
cwd,
|
|
186
|
+
agentDir: operatorDir,
|
|
187
|
+
skillPaths: [...profile.skills],
|
|
188
|
+
includeDefaults: false,
|
|
189
|
+
});
|
|
190
|
+
if (loaded.diagnostics.length || loaded.skills.length !== profile.skills.length)
|
|
191
|
+
throw new Error(
|
|
192
|
+
"Granted skills must be valid, distinct skill files (not discovery directories)",
|
|
193
|
+
);
|
|
194
|
+
return { profile, skills: loaded.skills };
|
|
195
|
+
}
|
|
196
|
+
export function isWriter(profile: Profile): boolean {
|
|
197
|
+
return (
|
|
198
|
+
!!profile.leafTools?.length ||
|
|
199
|
+
profile.tools.some((tool) => !READ_TOOLS.includes(tool as (typeof READ_TOOLS)[number]))
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function bundledProfiles(): Profile[] {
|
|
204
|
+
return structuredClone(Object.values(bundled));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Keep current definitions/prompts but intersect executable grants with an admitted ceiling. */
|
|
208
|
+
export function intersectProfileGrants(profile: Profile, maximum: Profile): Profile {
|
|
209
|
+
return {
|
|
210
|
+
...profile,
|
|
211
|
+
models:
|
|
212
|
+
maximum.models === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: (profile.models ?? []).filter((selection) => allowsModel(maximum, selection)),
|
|
215
|
+
prewalk:
|
|
216
|
+
profile.prewalk && maximum.prewalk && sameSelection(profile.prewalk, maximum.prewalk)
|
|
217
|
+
? profile.prewalk
|
|
218
|
+
: undefined,
|
|
219
|
+
tools: profile.tools.filter((tool) => maximum.tools.includes(tool)),
|
|
220
|
+
leafTools: profile.leafTools?.filter((name) => maximum.leafTools?.includes(name)),
|
|
221
|
+
skills: profile.skills.filter((skill) => maximum.skills.includes(skill)),
|
|
222
|
+
spawn: profile.spawn?.filter((role) => maximum.spawn?.includes(role)),
|
|
223
|
+
coordination: profile.coordination?.filter((action) => maximum.coordination?.includes(action)),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type ExecutionLimits = {
|
|
6
|
+
deadlineMs: number;
|
|
7
|
+
requests: number;
|
|
8
|
+
tools: number;
|
|
9
|
+
depth: number;
|
|
10
|
+
spawns: number;
|
|
11
|
+
};
|
|
12
|
+
export type SchedulingPolicy = ExecutionLimits & {
|
|
13
|
+
concurrency: number;
|
|
14
|
+
queue: number;
|
|
15
|
+
batch: number;
|
|
16
|
+
};
|
|
17
|
+
export const DEFAULT_POLICY: Readonly<SchedulingPolicy> = {
|
|
18
|
+
concurrency: 16,
|
|
19
|
+
queue: 32,
|
|
20
|
+
batch: 16,
|
|
21
|
+
depth: 2,
|
|
22
|
+
spawns: 8,
|
|
23
|
+
deadlineMs: 1800000,
|
|
24
|
+
requests: 100,
|
|
25
|
+
tools: 100,
|
|
26
|
+
};
|
|
27
|
+
const ceilings: SchedulingPolicy = {
|
|
28
|
+
concurrency: 16,
|
|
29
|
+
queue: 256,
|
|
30
|
+
batch: 64,
|
|
31
|
+
depth: 4,
|
|
32
|
+
spawns: 32,
|
|
33
|
+
deadlineMs: 3600000,
|
|
34
|
+
requests: 1000,
|
|
35
|
+
tools: 1000,
|
|
36
|
+
};
|
|
37
|
+
export function narrowLimits<T extends object>(base: T, value: unknown, maximum: T = base): T {
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
39
|
+
throw new Error("Limits must be an object");
|
|
40
|
+
const result = { ...base };
|
|
41
|
+
for (const [key, number] of Object.entries(value)) {
|
|
42
|
+
if (
|
|
43
|
+
!Object.hasOwn(base, key) ||
|
|
44
|
+
!Number.isSafeInteger(number) ||
|
|
45
|
+
(number as number) < (key === "depth" || key === "spawns" || key === "queue" ? 0 : 1) ||
|
|
46
|
+
(number as number) > (maximum as Record<string, number>)[key]
|
|
47
|
+
)
|
|
48
|
+
throw new Error(`Invalid or broadened limit: ${key}`);
|
|
49
|
+
(result as Record<string, unknown>)[key] = number;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
export function validatePolicy(value: unknown): SchedulingPolicy {
|
|
54
|
+
return narrowLimits({ ...DEFAULT_POLICY }, value, ceilings);
|
|
55
|
+
}
|
|
56
|
+
export async function resolveScheduling(
|
|
57
|
+
cwd: string,
|
|
58
|
+
trusted: boolean,
|
|
59
|
+
operatorDir = getAgentDir(),
|
|
60
|
+
): Promise<SchedulingPolicy> {
|
|
61
|
+
async function load(path: string): Promise<unknown> {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(await readFile(path, "utf8")).scheduling ?? {};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const operator = validatePolicy(await load(join(operatorDir, "delegation.json")));
|
|
70
|
+
return trusted
|
|
71
|
+
? narrowLimits(operator, await load(join(cwd, CONFIG_DIR_NAME, "delegation.json")))
|
|
72
|
+
: operator;
|
|
73
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sliceByColumn,
|
|
3
|
+
truncateToWidth,
|
|
4
|
+
visibleWidth,
|
|
5
|
+
type Component,
|
|
6
|
+
} from "@earendil-works/pi-tui";
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { ChildSnapshot } from "./coordinator.ts";
|
|
9
|
+
import { active, activity, assignmentHeadline, lineText, statuses } from "./task-presentation.ts";
|
|
10
|
+
|
|
11
|
+
/** Event-driven, bounded current-agent tree; history and full metrics remain in the Hub. */
|
|
12
|
+
export function createChildWidget(
|
|
13
|
+
snapshot: () => ChildSnapshot[],
|
|
14
|
+
theme: Pick<Theme, "fg"> & Partial<Pick<Theme, "bold">>,
|
|
15
|
+
availableRows = () => 30,
|
|
16
|
+
): Component {
|
|
17
|
+
return {
|
|
18
|
+
render(width) {
|
|
19
|
+
width = Math.max(0, width);
|
|
20
|
+
const children = snapshot();
|
|
21
|
+
if (!children.length) return [];
|
|
22
|
+
const queued = children.filter((child) => child.status === "queued").length;
|
|
23
|
+
const priority = (child: ChildSnapshot) =>
|
|
24
|
+
child.status === "draining" || child.status === "pausing"
|
|
25
|
+
? 0
|
|
26
|
+
: child.status === "running"
|
|
27
|
+
? 1
|
|
28
|
+
: child.status === "setup"
|
|
29
|
+
? 2
|
|
30
|
+
: 3;
|
|
31
|
+
const live = children
|
|
32
|
+
.filter(
|
|
33
|
+
(child) =>
|
|
34
|
+
!child.disposed &&
|
|
35
|
+
(child.status === "setup" ||
|
|
36
|
+
child.status === "running" ||
|
|
37
|
+
child.status === "draining" ||
|
|
38
|
+
child.status === "pausing"),
|
|
39
|
+
)
|
|
40
|
+
.sort((a, b) => priority(a) - priority(b));
|
|
41
|
+
const budget = Math.max(1, Math.min(5, Math.floor(availableRows() / 4)));
|
|
42
|
+
const counts =
|
|
43
|
+
width < 60
|
|
44
|
+
? `${live.filter(active).length} active · ${children.filter((child) => child.status === "paused").length} paused · ${queued} queued`
|
|
45
|
+
: statuses
|
|
46
|
+
.flatMap((status) => {
|
|
47
|
+
const count = children.filter((child) => child.status === status).length;
|
|
48
|
+
return count ? [`${count} ${status}`] : [];
|
|
49
|
+
})
|
|
50
|
+
.join(" · ");
|
|
51
|
+
const rows = [
|
|
52
|
+
theme.fg("accent", `● Subagents ${children.length}`) + theme.fg("muted", ` · ${counts}`),
|
|
53
|
+
];
|
|
54
|
+
const room = Math.max(0, budget - 1);
|
|
55
|
+
const activityRows = live.length && room >= (live.length > 1 ? 3 : 2) ? 1 : 0;
|
|
56
|
+
const slots = room - activityRows;
|
|
57
|
+
const overflow = live.length > slots;
|
|
58
|
+
const shown = live.slice(0, Math.max(0, slots - (overflow ? 1 : 0)));
|
|
59
|
+
shown.forEach((child, index) => {
|
|
60
|
+
const last = index === shown.length - 1 && shown.length === live.length;
|
|
61
|
+
const color =
|
|
62
|
+
child.status === "draining" || child.status === "pausing" ? "warning" : "accent";
|
|
63
|
+
const role = lineText(child.profile);
|
|
64
|
+
const indent = " ".repeat(Math.min(3, Math.max(0, child.depth - 1)));
|
|
65
|
+
const prefix =
|
|
66
|
+
theme.fg("muted", `${indent}${last ? "└─" : "├─"} `) +
|
|
67
|
+
theme.fg(color, "● ") +
|
|
68
|
+
(theme.bold?.(role) ?? role) +
|
|
69
|
+
" ";
|
|
70
|
+
const elapsed = Math.max(
|
|
71
|
+
0,
|
|
72
|
+
Math.floor(((child.endedAt ?? Date.now()) - child.createdAt) / 1000),
|
|
73
|
+
);
|
|
74
|
+
const stats = [
|
|
75
|
+
child.status,
|
|
76
|
+
`${elapsed}s`,
|
|
77
|
+
`${child.toolCalls} tools`,
|
|
78
|
+
...(child.telemetry.requests === null ? [] : [`${child.telemetry.requests} req`]),
|
|
79
|
+
...(child.telemetry.tokens ? [`${child.telemetry.tokens.total} tok`] : []),
|
|
80
|
+
...(child.telemetry.cost === null ? [] : [`$${child.telemetry.cost.toFixed(3)}`]),
|
|
81
|
+
].join(" · ");
|
|
82
|
+
const metric = truncateToWidth(stats, Math.max(8, Math.floor(width / 2)));
|
|
83
|
+
const headline = assignmentHeadline(child);
|
|
84
|
+
rows.push(
|
|
85
|
+
prefix +
|
|
86
|
+
theme.fg(
|
|
87
|
+
"text",
|
|
88
|
+
truncateToWidth(
|
|
89
|
+
headline,
|
|
90
|
+
Math.max(4, width - visibleWidth(prefix) - visibleWidth(metric) - 3),
|
|
91
|
+
),
|
|
92
|
+
) +
|
|
93
|
+
theme.fg("muted", " · " + metric),
|
|
94
|
+
);
|
|
95
|
+
if (!index && activityRows) {
|
|
96
|
+
const prefix = `${indent}${last ? " " : "│ "} └ `;
|
|
97
|
+
const text = lineText(activity(child));
|
|
98
|
+
const columns = Math.max(0, width - visibleWidth(prefix));
|
|
99
|
+
const length = visibleWidth(text);
|
|
100
|
+
// Progress is an accumulated tail; keep its newest text, not a frozen opening paragraph.
|
|
101
|
+
const preview =
|
|
102
|
+
length > columns
|
|
103
|
+
? "…" +
|
|
104
|
+
sliceByColumn(text, length - Math.max(0, columns - 1), Math.max(0, columns - 1))
|
|
105
|
+
: text;
|
|
106
|
+
rows.push(theme.fg("muted", prefix + preview));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
if (shown.length < live.length && room)
|
|
110
|
+
rows.push(theme.fg("muted", `└─ ${live.length - shown.length} more · /subagent-hub`));
|
|
111
|
+
return rows.slice(0, budget).map((line) => truncateToWidth(line, width));
|
|
112
|
+
},
|
|
113
|
+
invalidate() {},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const MINIMUM_PI_VERSION = "1.5.2";
|
|
4
|
+
const minimum = MINIMUM_PI_VERSION.split(".").map(BigInt);
|
|
5
|
+
|
|
6
|
+
/** Gate execution, not registration: root diagnostics and cleanup must remain usable. */
|
|
7
|
+
export function requireSupportedHost(): void {
|
|
8
|
+
// Only stable SemVer, with optional build metadata (which has no precedence).
|
|
9
|
+
const stable =
|
|
10
|
+
typeof VERSION === "string"
|
|
11
|
+
? /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
|
|
12
|
+
VERSION,
|
|
13
|
+
)
|
|
14
|
+
: null;
|
|
15
|
+
if (stable && stable[0] === VERSION) {
|
|
16
|
+
// BigInt keeps numeric comparison exact without a version-component size limit.
|
|
17
|
+
const difference = stable
|
|
18
|
+
.slice(1, 4)
|
|
19
|
+
.map((part, index) => BigInt(part) - minimum[index]!)
|
|
20
|
+
.find((part) => part !== 0n);
|
|
21
|
+
if (difference === undefined || difference > 0n) return;
|
|
22
|
+
}
|
|
23
|
+
throw new Error(
|
|
24
|
+
`Delegated execution requires supported stable Pi >=${MINIMUM_PI_VERSION}; detected ${VERSION || "unknown"}. Install stable Pi ${MINIMUM_PI_VERSION} or newer and restart Pi after draining active tasks.`,
|
|
25
|
+
);
|
|
26
|
+
}
|