@howarewoo/pi-subagents 0.0.0-stage → 0.1.1
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 +31 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/docs/advanced-policies.md +76 -0
- package/docs/delegation-contract.md +268 -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 +148 -0
- package/docs/verification.md +430 -0
- package/package.json +64 -4
- package/src/capabilities.ts +39 -0
- package/src/coordinator.ts +1432 -0
- package/src/hub.ts +841 -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 +779 -0
- package/src/profile-viewer.ts +175 -0
- package/src/profiles.ts +225 -0
- package/src/scheduling.ts +73 -0
- package/src/task-input.ts +73 -0
- package/src/thread.ts +268 -0
- package/src/workspace.ts +46 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Skill, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
matchesKey,
|
|
4
|
+
truncateToWidth,
|
|
5
|
+
visibleWidth,
|
|
6
|
+
wrapTextWithAnsi,
|
|
7
|
+
type Component,
|
|
8
|
+
type KeybindingsManager,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { safe } from "./thread.ts";
|
|
11
|
+
import { bundledProfiles, resolveProfile, type Profile } from "./profiles.ts";
|
|
12
|
+
|
|
13
|
+
export const profileToolPolicy =
|
|
14
|
+
"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.";
|
|
15
|
+
|
|
16
|
+
/** Resolve through the same trust/config/skill validation boundary used by admission. */
|
|
17
|
+
export async function resolvedProfiles(
|
|
18
|
+
cwd: string,
|
|
19
|
+
trusted: boolean,
|
|
20
|
+
operatorDir?: string,
|
|
21
|
+
availableSkills: readonly Skill[] = [],
|
|
22
|
+
): Promise<Profile[]> {
|
|
23
|
+
return Promise.all(
|
|
24
|
+
bundledProfiles().map(
|
|
25
|
+
async ({ name }) =>
|
|
26
|
+
(await resolveProfile(name, cwd, trusted, operatorDir, availableSkills)).profile,
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function details(profile: Profile): string[] {
|
|
32
|
+
const list = (items: readonly string[] | undefined) =>
|
|
33
|
+
items?.length ? items.join(", ") : "none";
|
|
34
|
+
const known = new Set([
|
|
35
|
+
"name",
|
|
36
|
+
"description",
|
|
37
|
+
"prompt",
|
|
38
|
+
"tools",
|
|
39
|
+
"skills",
|
|
40
|
+
"leafTools",
|
|
41
|
+
"spawn",
|
|
42
|
+
"coordination",
|
|
43
|
+
"models",
|
|
44
|
+
"model",
|
|
45
|
+
"prewalk",
|
|
46
|
+
]);
|
|
47
|
+
return [
|
|
48
|
+
profile.name,
|
|
49
|
+
"Description",
|
|
50
|
+
profile.description,
|
|
51
|
+
"Prompt",
|
|
52
|
+
profile.prompt,
|
|
53
|
+
`Tools ceiling: ${list(profile.tools)}`,
|
|
54
|
+
"Callable tools: unknown in command context; inspect admitted task tools.",
|
|
55
|
+
`Skills (exact paths): ${list(profile.skills)}`,
|
|
56
|
+
...(profile.name === "general"
|
|
57
|
+
? [
|
|
58
|
+
"Inheritance: available parent skills and callable builtins; no parent instructions/history.",
|
|
59
|
+
]
|
|
60
|
+
: []),
|
|
61
|
+
`Reviewed leaf ceiling: ${list(profile.leafTools)} (writer lease; trusted code, not sandboxed)`,
|
|
62
|
+
`Spawn ceiling: ${list(profile.spawn)}`,
|
|
63
|
+
`Coordination: ${list(profile.coordination)}`,
|
|
64
|
+
`Model default: ${profile.model ? JSON.stringify(profile.model) : "selected parent model"}`,
|
|
65
|
+
`Models grant: ${profile.models === undefined ? "no explicit override grant" : JSON.stringify(profile.models)}`,
|
|
66
|
+
`Prewalk: ${profile.prewalk ? JSON.stringify(profile.prewalk) : "off"}`,
|
|
67
|
+
...Object.entries(profile)
|
|
68
|
+
.filter(([key]) => !known.has(key))
|
|
69
|
+
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`),
|
|
70
|
+
"Admission also applies parent/ancestry, model availability, capacity and writer checks. This viewer grants nothing.",
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Read-only snapshot consumer; no fixed role count or policy defaults in the renderer. */
|
|
75
|
+
export function createProfileViewer(
|
|
76
|
+
profiles: readonly Profile[],
|
|
77
|
+
theme: Pick<Theme, "fg" | "style"> & { colors: Pick<Theme["colors"], "customMessageBg"> },
|
|
78
|
+
keys: Pick<KeybindingsManager, "matches" | "getKeys">,
|
|
79
|
+
requestRender: () => void,
|
|
80
|
+
done: () => void,
|
|
81
|
+
availableRows: () => number,
|
|
82
|
+
): Component {
|
|
83
|
+
let selected = 0;
|
|
84
|
+
let offset = 0;
|
|
85
|
+
let viewport = 1;
|
|
86
|
+
let maximum = 0;
|
|
87
|
+
const cell = (text: string, width: number) => {
|
|
88
|
+
const clipped = truncateToWidth(text, width);
|
|
89
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
render(width) {
|
|
93
|
+
width = Math.max(0, width);
|
|
94
|
+
const height = Math.max(1, Math.floor(availableRows()));
|
|
95
|
+
const close = keys.getKeys("tui.select.cancel").join("/") || "Esc";
|
|
96
|
+
if (width < 24 || height < 7)
|
|
97
|
+
return [`Profiles · ${close}: close`, "Resize for details"]
|
|
98
|
+
.slice(0, height)
|
|
99
|
+
.map((line) => truncateToWidth(line, width));
|
|
100
|
+
const inner = width - 4;
|
|
101
|
+
const wide = width >= 88;
|
|
102
|
+
const leftWidth = wide ? Math.min(28, Math.floor(inner / 3)) : inner;
|
|
103
|
+
const rightWidth = wide ? inner - leftWidth - 3 : inner;
|
|
104
|
+
const bodyHeight = height - 4;
|
|
105
|
+
const listHeight = wide ? bodyHeight : Math.min(3, Math.max(1, Math.floor(bodyHeight / 3)));
|
|
106
|
+
viewport = wide ? bodyHeight : Math.max(1, bodyHeight - listHeight - 1);
|
|
107
|
+
const start = Math.max(0, selected - listHeight + 1);
|
|
108
|
+
const roster = profiles
|
|
109
|
+
.slice(start, start + listHeight)
|
|
110
|
+
.map((profile, index) =>
|
|
111
|
+
theme.fg(
|
|
112
|
+
start + index === selected ? "accent" : "text",
|
|
113
|
+
`${start + index === selected ? ">" : " "} ${safe(profile.name).replaceAll("\n", " ")}`,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
const lines = (
|
|
117
|
+
profiles[selected] ? details(profiles[selected]) : ["No resolved profiles"]
|
|
118
|
+
).flatMap((text) =>
|
|
119
|
+
safe(text)
|
|
120
|
+
.split("\n")
|
|
121
|
+
.flatMap((line) => wrapTextWithAnsi(line, rightWidth)),
|
|
122
|
+
);
|
|
123
|
+
maximum = Math.max(0, lines.length - viewport);
|
|
124
|
+
offset = Math.min(maximum, offset);
|
|
125
|
+
const detail = lines.slice(offset, offset + viewport);
|
|
126
|
+
const body = wide
|
|
127
|
+
? Array.from(
|
|
128
|
+
{ length: bodyHeight },
|
|
129
|
+
(_, index) =>
|
|
130
|
+
cell(roster[index] ?? "", leftWidth) +
|
|
131
|
+
theme.fg("border", " │ ") +
|
|
132
|
+
cell(detail[index] ?? "", rightWidth),
|
|
133
|
+
)
|
|
134
|
+
: [
|
|
135
|
+
...roster,
|
|
136
|
+
...Array(Math.max(0, listHeight - roster.length)).fill(""),
|
|
137
|
+
"─".repeat(inner),
|
|
138
|
+
...detail,
|
|
139
|
+
];
|
|
140
|
+
const row = (text: string) =>
|
|
141
|
+
theme.style(`│ ${cell(text, inner)} │`, { bg: theme.colors.customMessageBg });
|
|
142
|
+
return [
|
|
143
|
+
row(theme.fg("accent", "Subagent Profiles · resolved · read-only")),
|
|
144
|
+
...body.map(row),
|
|
145
|
+
row(
|
|
146
|
+
`${selected + (profiles.length ? 1 : 0)}/${profiles.length} · detail ${offset + 1}–${Math.min(lines.length, offset + viewport)}/${lines.length}`,
|
|
147
|
+
),
|
|
148
|
+
row("↑↓ select · j/k scroll · PgUp/Dn · Home/End"),
|
|
149
|
+
row(`${close}: close · callable tools unknown here`),
|
|
150
|
+
].slice(0, height);
|
|
151
|
+
},
|
|
152
|
+
handleInput(data) {
|
|
153
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
154
|
+
done();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (keys.matches(data, "tui.select.up") || keys.matches(data, "tui.select.down")) {
|
|
158
|
+
selected = Math.max(
|
|
159
|
+
0,
|
|
160
|
+
Math.min(profiles.length - 1, selected + (keys.matches(data, "tui.select.up") ? -1 : 1)),
|
|
161
|
+
);
|
|
162
|
+
offset = 0;
|
|
163
|
+
} else if (data === "j") offset = Math.min(maximum, offset + 1);
|
|
164
|
+
else if (data === "k") offset = Math.max(0, offset - 1);
|
|
165
|
+
else if (keys.matches(data, "tui.select.pageDown"))
|
|
166
|
+
offset = Math.min(maximum, offset + viewport);
|
|
167
|
+
else if (keys.matches(data, "tui.select.pageUp")) offset = Math.max(0, offset - viewport);
|
|
168
|
+
else if (matchesKey(data, "home")) offset = 0;
|
|
169
|
+
else if (matchesKey(data, "end")) offset = maximum;
|
|
170
|
+
else return;
|
|
171
|
+
requestRender();
|
|
172
|
+
},
|
|
173
|
+
invalidate() {},
|
|
174
|
+
};
|
|
175
|
+
}
|
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: 600000,
|
|
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,73 @@
|
|
|
1
|
+
import { stat, realpath } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import type { ExtensionToolContext, ReadToolDetails } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
function hasDeniedTaskComponent(path: string): boolean {
|
|
6
|
+
return path.split(/[\\/]/).some((part) => part.startsWith(".") || part === "node_modules");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Content is obtained only through the effective public read dispatcher, never raw filesystem reads. */
|
|
10
|
+
export async function readTaskInput(
|
|
11
|
+
path: string,
|
|
12
|
+
ctx: ExtensionToolContext,
|
|
13
|
+
signal?: AbortSignal,
|
|
14
|
+
beforeRead?: () => void,
|
|
15
|
+
cwd = ctx.cwd,
|
|
16
|
+
): Promise<string> {
|
|
17
|
+
if (!path || isAbsolute(path) || path.includes("\0") || hasDeniedTaskComponent(path))
|
|
18
|
+
throw new Error(
|
|
19
|
+
"taskFile must be an explicit non-hidden workspace-relative path without traversal",
|
|
20
|
+
);
|
|
21
|
+
if (!ctx.tools.some((tool) => tool.name === "read"))
|
|
22
|
+
throw new Error("taskFile requires a callable parent read tool");
|
|
23
|
+
const base = await realpath(cwd);
|
|
24
|
+
const target = resolve(base, path);
|
|
25
|
+
const checkedPath = await realpath(target);
|
|
26
|
+
const inside = relative(base, checkedPath);
|
|
27
|
+
if (!inside || inside === ".." || inside.startsWith(".." + sep) || isAbsolute(inside))
|
|
28
|
+
throw new Error("taskFile escapes workspace");
|
|
29
|
+
if (hasDeniedTaskComponent(inside))
|
|
30
|
+
throw new Error("taskFile resolved target must be non-hidden and outside node_modules");
|
|
31
|
+
const before = await stat(target);
|
|
32
|
+
if (!before.isFile() || before.size === 0 || before.size > 50000)
|
|
33
|
+
throw new Error("taskFile must be a nonempty regular text file of at most 50000 bytes");
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
beforeRead?.();
|
|
36
|
+
const outcome = await ctx.executeTool("read", { path: target }, { signal });
|
|
37
|
+
if (outcome.isError)
|
|
38
|
+
throw new Error(
|
|
39
|
+
"taskFile read denied or failed: " +
|
|
40
|
+
outcome.result.content
|
|
41
|
+
.filter((block) => block.type === "text")
|
|
42
|
+
.map((block) => block.text)
|
|
43
|
+
.join("\n"),
|
|
44
|
+
);
|
|
45
|
+
const details = outcome.result.details as ReadToolDetails | undefined;
|
|
46
|
+
if (
|
|
47
|
+
details?.truncation?.truncated ||
|
|
48
|
+
outcome.result.content.length !== 1 ||
|
|
49
|
+
outcome.result.content[0].type !== "text"
|
|
50
|
+
)
|
|
51
|
+
throw new Error("taskFile must be complete untruncated text");
|
|
52
|
+
const text = outcome.result.content[0].text;
|
|
53
|
+
if (
|
|
54
|
+
!text.trim() ||
|
|
55
|
+
text.includes("\0") ||
|
|
56
|
+
text.includes("\ufffd") ||
|
|
57
|
+
Buffer.byteLength(text) > 50000
|
|
58
|
+
)
|
|
59
|
+
throw new Error(
|
|
60
|
+
"taskFile must contain nonempty UTF-8 text without replacement characters or NUL",
|
|
61
|
+
);
|
|
62
|
+
const after = await stat(target);
|
|
63
|
+
if (
|
|
64
|
+
(await realpath(target)) !== checkedPath ||
|
|
65
|
+
before.dev !== after.dev ||
|
|
66
|
+
before.ino !== after.ino ||
|
|
67
|
+
before.size !== after.size ||
|
|
68
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
69
|
+
before.ctimeMs !== after.ctimeMs
|
|
70
|
+
)
|
|
71
|
+
throw new Error("taskFile changed during preflight; retry");
|
|
72
|
+
return text;
|
|
73
|
+
}
|