@alexeiled/pi-fusion 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/AGENTS.md +7 -0
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/agents/fusion-judge.md +38 -0
- package/agents/fusion-panelist.md +30 -0
- package/docs/user-guide.md +252 -0
- package/package.json +74 -0
- package/src/commands.ts +273 -0
- package/src/config.ts +256 -0
- package/src/errors.ts +13 -0
- package/src/index.ts +34 -0
- package/src/orchestrator.ts +708 -0
- package/src/report.ts +478 -0
- package/src/result-extract.ts +311 -0
- package/src/run-builder.ts +273 -0
- package/src/run-store.ts +372 -0
- package/src/status.ts +136 -0
- package/src/subagents-rpc.ts +407 -0
- package/src/types.ts +54 -0
- package/src/utils.ts +0 -0
- package/tsconfig.json +19 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import {
|
|
7
|
+
getProjectFusionConfigPath,
|
|
8
|
+
writeProjectFusionConfigTemplate,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { FusionArgsError, FusionConfigError } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
const FUSION_USAGE =
|
|
13
|
+
"Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion status | /fusion stop | /fusion init.";
|
|
14
|
+
const FUSION_HELP = [
|
|
15
|
+
"Fusion commands",
|
|
16
|
+
"/fusion <prompt>",
|
|
17
|
+
"/fusion --profile <name> <prompt>",
|
|
18
|
+
"/fusion status",
|
|
19
|
+
"/fusion stop",
|
|
20
|
+
"/fusion init",
|
|
21
|
+
].join("\n");
|
|
22
|
+
|
|
23
|
+
export type FusionInlineCommand = "init" | "status" | "stop";
|
|
24
|
+
|
|
25
|
+
export interface ParsedFusionArgs {
|
|
26
|
+
prompt: string;
|
|
27
|
+
profile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FusionInitContext {
|
|
31
|
+
cwd: string;
|
|
32
|
+
hasUI: boolean;
|
|
33
|
+
isProjectTrusted(): boolean;
|
|
34
|
+
ui: {
|
|
35
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
36
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FusionInitDeps {
|
|
41
|
+
readTextFile?: (path: string) => Promise<string>;
|
|
42
|
+
writeTextFile?: (path: string, content: string) => Promise<void>;
|
|
43
|
+
ensureDir?: (path: string) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type FusionInitResult =
|
|
47
|
+
| { status: "written"; path: string }
|
|
48
|
+
| {
|
|
49
|
+
status: "skipped";
|
|
50
|
+
reason: "untrusted" | "exists" | "cancelled";
|
|
51
|
+
path?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface FusionRuntimeCommandHandler {
|
|
55
|
+
startRun(args: string, ctx: ExtensionCommandContext): Promise<unknown>;
|
|
56
|
+
showStatus(ctx: ExtensionCommandContext): Promise<unknown>;
|
|
57
|
+
cancelActiveRun(ctx: ExtensionCommandContext): Promise<unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerFusionCommands(
|
|
61
|
+
pi: Pick<ExtensionAPI, "registerCommand">,
|
|
62
|
+
handler: FusionRuntimeCommandHandler,
|
|
63
|
+
): void {
|
|
64
|
+
pi.registerCommand("fusion", {
|
|
65
|
+
description: "Run a fusion review, or use status/stop/init",
|
|
66
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
67
|
+
if (args.trim() === "") {
|
|
68
|
+
ctx.ui.notify(FUSION_HELP, "info");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const inlineCommand = parseFusionInlineCommand(args);
|
|
73
|
+
if (inlineCommand === "init") {
|
|
74
|
+
await runFusionInit(ctx);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (inlineCommand === "status") {
|
|
78
|
+
await handler.showStatus(ctx);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (inlineCommand === "stop") {
|
|
82
|
+
await handler.cancelActiveRun(ctx);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
await handler.startRun(args, ctx);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runFusionInit(
|
|
91
|
+
ctx: FusionInitContext,
|
|
92
|
+
deps: FusionInitDeps = {},
|
|
93
|
+
): Promise<FusionInitResult> {
|
|
94
|
+
if (!ctx.isProjectTrusted()) {
|
|
95
|
+
ctx.ui.notify(
|
|
96
|
+
"Project is not trusted. /fusion init did not write .pi/fusion.json.",
|
|
97
|
+
"error",
|
|
98
|
+
);
|
|
99
|
+
return { status: "skipped", reason: "untrusted" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const configPath = getProjectFusionConfigPath(ctx.cwd);
|
|
103
|
+
if (await fileExists(configPath, deps.readTextFile ?? readUtf8File)) {
|
|
104
|
+
if (!ctx.hasUI) {
|
|
105
|
+
ctx.ui.notify(
|
|
106
|
+
`${configPath} already exists. Run /fusion init in UI mode to confirm overwrite.`,
|
|
107
|
+
"warning",
|
|
108
|
+
);
|
|
109
|
+
return { status: "skipped", reason: "exists", path: configPath };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const overwrite = await ctx.ui.confirm(
|
|
113
|
+
"Overwrite fusion config?",
|
|
114
|
+
`${configPath} already exists. Overwrite it?`,
|
|
115
|
+
);
|
|
116
|
+
if (!overwrite) {
|
|
117
|
+
ctx.ui.notify("Kept existing .pi/fusion.json.", "info");
|
|
118
|
+
return { status: "skipped", reason: "cancelled", path: configPath };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const writtenPath = await writeProjectFusionConfigTemplate(ctx.cwd, deps);
|
|
123
|
+
ctx.ui.notify(`Wrote ${writtenPath}.`, "info");
|
|
124
|
+
return { status: "written", path: writtenPath };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseFusionInlineCommand(
|
|
128
|
+
input: string | readonly string[],
|
|
129
|
+
): FusionInlineCommand | undefined {
|
|
130
|
+
const tokens =
|
|
131
|
+
typeof input === "string" ? tokenizeCommandArgs(input) : [...input];
|
|
132
|
+
if (tokens.length !== 1) return undefined;
|
|
133
|
+
const command = tokens[0];
|
|
134
|
+
if (command === "init" || command === "status" || command === "stop") {
|
|
135
|
+
return command;
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function parseFusionArgs(
|
|
141
|
+
input: string | readonly string[],
|
|
142
|
+
): ParsedFusionArgs {
|
|
143
|
+
const tokens =
|
|
144
|
+
typeof input === "string" ? tokenizeCommandArgs(input) : [...input];
|
|
145
|
+
if (tokens[0] === "/fusion" || tokens[0] === "fusion") tokens.shift();
|
|
146
|
+
|
|
147
|
+
let profile: string | undefined;
|
|
148
|
+
const promptTokens: string[] = [];
|
|
149
|
+
|
|
150
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
151
|
+
const token = tokens[index];
|
|
152
|
+
if (!token) continue;
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
promptTokens.length === 0 &&
|
|
156
|
+
(token === "--profile" || token === "-p")
|
|
157
|
+
) {
|
|
158
|
+
const value = tokens[index + 1];
|
|
159
|
+
if (!value || value.startsWith("-")) {
|
|
160
|
+
throw new FusionArgsError(
|
|
161
|
+
`Missing value for ${token}. ${FUSION_USAGE}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (profile)
|
|
165
|
+
throw new FusionArgsError("Profile can only be provided once.");
|
|
166
|
+
profile = value;
|
|
167
|
+
index++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (promptTokens.length === 0 && token.startsWith("--profile=")) {
|
|
172
|
+
const value = token.slice("--profile=".length).trim();
|
|
173
|
+
if (!value)
|
|
174
|
+
throw new FusionArgsError(
|
|
175
|
+
`Missing value for --profile. ${FUSION_USAGE}`,
|
|
176
|
+
);
|
|
177
|
+
if (profile)
|
|
178
|
+
throw new FusionArgsError("Profile can only be provided once.");
|
|
179
|
+
profile = value;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (promptTokens.length === 0 && token.startsWith("-")) {
|
|
184
|
+
throw new FusionArgsError(`Unknown option ${token}. ${FUSION_USAGE}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
promptTokens.push(token, ...tokens.slice(index + 1));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const prompt = promptTokens.join(" ").trim();
|
|
192
|
+
if (!prompt) throw new FusionArgsError(FUSION_USAGE);
|
|
193
|
+
return profile ? { prompt, profile } : { prompt };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function tokenizeCommandArgs(input: string): string[] {
|
|
197
|
+
const tokens: string[] = [];
|
|
198
|
+
let current = "";
|
|
199
|
+
let quote: "'" | '"' | undefined;
|
|
200
|
+
let escaping = false;
|
|
201
|
+
|
|
202
|
+
for (const char of input.trim()) {
|
|
203
|
+
if (escaping) {
|
|
204
|
+
current += char;
|
|
205
|
+
escaping = false;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (char === "\\") {
|
|
210
|
+
escaping = true;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (quote) {
|
|
215
|
+
if (char === quote) {
|
|
216
|
+
quote = undefined;
|
|
217
|
+
} else {
|
|
218
|
+
current += char;
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (char === "'" || char === '"') {
|
|
224
|
+
quote = char;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (/\s/.test(char)) {
|
|
229
|
+
if (current) {
|
|
230
|
+
tokens.push(current);
|
|
231
|
+
current = "";
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
current += char;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (escaping) current += "\\";
|
|
240
|
+
if (quote)
|
|
241
|
+
throw new FusionArgsError(`Unclosed ${quote} quote in /fusion arguments.`);
|
|
242
|
+
if (current) tokens.push(current);
|
|
243
|
+
return tokens;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function fileExists(
|
|
247
|
+
path: string,
|
|
248
|
+
readTextFile: (path: string) => Promise<string>,
|
|
249
|
+
): Promise<boolean> {
|
|
250
|
+
try {
|
|
251
|
+
await readTextFile(path);
|
|
252
|
+
return true;
|
|
253
|
+
} catch (error: unknown) {
|
|
254
|
+
if (isNodeErrorCode(error, "ENOENT")) return false;
|
|
255
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
256
|
+
throw new FusionConfigError(
|
|
257
|
+
`Could not check fusion config at ${path}: ${message}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
263
|
+
return (
|
|
264
|
+
typeof error === "object" &&
|
|
265
|
+
error !== null &&
|
|
266
|
+
"code" in error &&
|
|
267
|
+
error.code === code
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function readUtf8File(path: string): Promise<string> {
|
|
272
|
+
return readFile(path, "utf8");
|
|
273
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { FusionConfigError } from "./errors.js";
|
|
5
|
+
import {
|
|
6
|
+
THINKING_LEVELS,
|
|
7
|
+
type FusionConfig,
|
|
8
|
+
type FusionContextMode,
|
|
9
|
+
type FusionProfile,
|
|
10
|
+
type JudgeConfig,
|
|
11
|
+
type PanelMemberConfig,
|
|
12
|
+
type ThinkingLevel,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
export const FUSION_CONFIG_FILE = "fusion.json";
|
|
16
|
+
export const DEFAULT_PROFILE_NAME = "quality";
|
|
17
|
+
export const PANEL_AGENT = "pi-fusion.fusion-panelist";
|
|
18
|
+
export const JUDGE_AGENT = "pi-fusion.fusion-judge";
|
|
19
|
+
|
|
20
|
+
export interface FusionConfigLoadContext {
|
|
21
|
+
cwd: string;
|
|
22
|
+
isProjectTrusted(): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FileReadDeps {
|
|
26
|
+
readTextFile?: (path: string) => Promise<string>;
|
|
27
|
+
agentDir?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FileWriteDeps {
|
|
31
|
+
writeTextFile?: (path: string, content: string) => Promise<void>;
|
|
32
|
+
ensureDir?: (path: string) => Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResolvedFusionProfile {
|
|
36
|
+
name: string;
|
|
37
|
+
profile: FusionProfile;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDefaultFusionConfig(): FusionConfig {
|
|
41
|
+
return {
|
|
42
|
+
defaultProfile: DEFAULT_PROFILE_NAME,
|
|
43
|
+
profiles: {
|
|
44
|
+
[DEFAULT_PROFILE_NAME]: {
|
|
45
|
+
panel: [
|
|
46
|
+
{
|
|
47
|
+
id: "architect",
|
|
48
|
+
label: "Architect",
|
|
49
|
+
agent: PANEL_AGENT,
|
|
50
|
+
thinking: "high",
|
|
51
|
+
role: "architecture, tradeoffs, and failure modes",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "implementer",
|
|
55
|
+
label: "Implementer",
|
|
56
|
+
agent: PANEL_AGENT,
|
|
57
|
+
thinking: "medium",
|
|
58
|
+
role: "implementation details, API contracts, and edge cases",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "tester",
|
|
62
|
+
label: "Tester",
|
|
63
|
+
agent: PANEL_AGENT,
|
|
64
|
+
thinking: "medium",
|
|
65
|
+
role: "test strategy, regressions, and verification",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
judge: {
|
|
69
|
+
agent: JUDGE_AGENT,
|
|
70
|
+
thinking: "high",
|
|
71
|
+
},
|
|
72
|
+
concurrency: 3,
|
|
73
|
+
timeoutMs: 300_000,
|
|
74
|
+
context: "fresh",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getProjectFusionConfigPath(cwd: string): string {
|
|
81
|
+
return join(cwd, CONFIG_DIR_NAME, FUSION_CONFIG_FILE);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getGlobalFusionConfigPath(agentDir = getAgentDir()): string {
|
|
85
|
+
return join(agentDir, FUSION_CONFIG_FILE);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getFusionConfigTemplate(): string {
|
|
89
|
+
return `${JSON.stringify(createDefaultFusionConfig(), null, 2)}\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function loadFusionConfig(
|
|
93
|
+
ctx: FusionConfigLoadContext,
|
|
94
|
+
deps: FileReadDeps = {},
|
|
95
|
+
): Promise<FusionConfig> {
|
|
96
|
+
const readTextFile = deps.readTextFile ?? readUtf8File;
|
|
97
|
+
|
|
98
|
+
if (ctx.isProjectTrusted()) {
|
|
99
|
+
const projectPath = getProjectFusionConfigPath(ctx.cwd);
|
|
100
|
+
const projectConfig = await readOptionalConfig(projectPath, readTextFile);
|
|
101
|
+
if (projectConfig) return projectConfig;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const globalPath = getGlobalFusionConfigPath(deps.agentDir);
|
|
105
|
+
const globalConfig = await readOptionalConfig(globalPath, readTextFile);
|
|
106
|
+
return globalConfig ?? createDefaultFusionConfig();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resolveProfile(
|
|
110
|
+
config: FusionConfig,
|
|
111
|
+
requested?: string,
|
|
112
|
+
): ResolvedFusionProfile {
|
|
113
|
+
const name = requested?.trim() || config.defaultProfile;
|
|
114
|
+
const profile = config.profiles[name];
|
|
115
|
+
if (!profile) {
|
|
116
|
+
const knownProfiles =
|
|
117
|
+
Object.keys(config.profiles).sort().join(", ") || "none";
|
|
118
|
+
throw new FusionConfigError(
|
|
119
|
+
`Unknown fusion profile "${name}". Known profiles: ${knownProfiles}.`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (profile.panel.length === 0) {
|
|
123
|
+
throw new FusionConfigError(
|
|
124
|
+
`Fusion profile "${name}" must define at least one panel member.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return { name, profile };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function writeProjectFusionConfigTemplate(
|
|
131
|
+
cwd: string,
|
|
132
|
+
deps: FileWriteDeps = {},
|
|
133
|
+
): Promise<string> {
|
|
134
|
+
const configPath = getProjectFusionConfigPath(cwd);
|
|
135
|
+
const ensureDir = deps.ensureDir ?? mkdirRecursive;
|
|
136
|
+
const writeTextFile = deps.writeTextFile ?? writeUtf8File;
|
|
137
|
+
await ensureDir(dirname(configPath));
|
|
138
|
+
await writeTextFile(configPath, getFusionConfigTemplate());
|
|
139
|
+
return configPath;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readOptionalConfig(
|
|
143
|
+
path: string,
|
|
144
|
+
readTextFile: (path: string) => Promise<string>,
|
|
145
|
+
): Promise<FusionConfig | undefined> {
|
|
146
|
+
let raw: string;
|
|
147
|
+
try {
|
|
148
|
+
raw = await readTextFile(path);
|
|
149
|
+
} catch (error: unknown) {
|
|
150
|
+
if (isNodeErrorCode(error, "ENOENT")) return undefined;
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
+
throw new FusionConfigError(
|
|
153
|
+
`Could not read fusion config at ${path}: ${message}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return parseFusionConfig(raw, path);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseFusionConfig(raw: string, source: string): FusionConfig {
|
|
160
|
+
let value: unknown;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(raw);
|
|
163
|
+
} catch (error: unknown) {
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
throw new FusionConfigError(
|
|
166
|
+
`Invalid JSON in fusion config at ${source}: ${message}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (!isFusionConfig(value)) {
|
|
170
|
+
throw new FusionConfigError(
|
|
171
|
+
`Invalid fusion config at ${source}. Expected defaultProfile and profiles.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function isFusionConfig(value: unknown): value is FusionConfig {
|
|
178
|
+
if (!isRecord(value)) return false;
|
|
179
|
+
if (!isNonEmptyString(value.defaultProfile)) return false;
|
|
180
|
+
if (!isRecord(value.profiles)) return false;
|
|
181
|
+
return Object.values(value.profiles).every(isFusionProfile);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isFusionProfile(value: unknown): value is FusionProfile {
|
|
185
|
+
if (!isRecord(value)) return false;
|
|
186
|
+
if (!Array.isArray(value.panel) || !value.panel.every(isPanelMemberConfig))
|
|
187
|
+
return false;
|
|
188
|
+
if (!isJudgeConfig(value.judge)) return false;
|
|
189
|
+
if (value.concurrency !== undefined && !isPositiveInteger(value.concurrency))
|
|
190
|
+
return false;
|
|
191
|
+
if (value.timeoutMs !== undefined && !isPositiveInteger(value.timeoutMs))
|
|
192
|
+
return false;
|
|
193
|
+
if (value.context !== undefined && !isFusionContextMode(value.context))
|
|
194
|
+
return false;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPanelMemberConfig(value: unknown): value is PanelMemberConfig {
|
|
199
|
+
if (!isRecord(value)) return false;
|
|
200
|
+
if (!isNonEmptyString(value.id)) return false;
|
|
201
|
+
if (!isNonEmptyString(value.label)) return false;
|
|
202
|
+
if (!isNonEmptyString(value.agent)) return false;
|
|
203
|
+
if (value.model !== undefined && !isNonEmptyString(value.model)) return false;
|
|
204
|
+
if (value.thinking !== undefined && !isThinkingLevel(value.thinking))
|
|
205
|
+
return false;
|
|
206
|
+
if (value.role !== undefined && typeof value.role !== "string") return false;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isJudgeConfig(value: unknown): value is JudgeConfig {
|
|
211
|
+
if (!isRecord(value)) return false;
|
|
212
|
+
if (!isNonEmptyString(value.agent)) return false;
|
|
213
|
+
if (value.model !== undefined && !isNonEmptyString(value.model)) return false;
|
|
214
|
+
if (value.thinking !== undefined && !isThinkingLevel(value.thinking))
|
|
215
|
+
return false;
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
220
|
+
return (
|
|
221
|
+
typeof value === "string" &&
|
|
222
|
+
(THINKING_LEVELS as readonly string[]).includes(value)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isFusionContextMode(value: unknown): value is FusionContextMode {
|
|
227
|
+
return value === "fresh" || value === "fork";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
231
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
235
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isPositiveInteger(value: unknown): value is number {
|
|
239
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isNodeErrorCode(error: unknown, code: string): boolean {
|
|
243
|
+
return isRecord(error) && error.code === code;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function readUtf8File(path: string): Promise<string> {
|
|
247
|
+
return readFile(path, "utf8");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function writeUtf8File(path: string, content: string): Promise<void> {
|
|
251
|
+
await writeFile(path, content, "utf8");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function mkdirRecursive(path: string): Promise<void> {
|
|
255
|
+
await mkdir(path, { recursive: true });
|
|
256
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class FusionConfigError extends Error {
|
|
2
|
+
constructor(message: string) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "FusionConfigError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class FusionArgsError extends Error {
|
|
9
|
+
constructor(message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "FusionArgsError";
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerFusionCommands } from "./commands.js";
|
|
3
|
+
import {
|
|
4
|
+
FusionOrchestrator,
|
|
5
|
+
SUBAGENT_ASYNC_COMPLETE_EVENT,
|
|
6
|
+
} from "./orchestrator.js";
|
|
7
|
+
import { FusionRunStore } from "./run-store.js";
|
|
8
|
+
import { SubagentsRpcClient } from "./subagents-rpc.js";
|
|
9
|
+
|
|
10
|
+
export default function fusionExtension(pi: ExtensionAPI): void {
|
|
11
|
+
const orchestrator = new FusionOrchestrator({
|
|
12
|
+
rpc: new SubagentsRpcClient({ events: pi.events }),
|
|
13
|
+
runStore: new FusionRunStore({ persistence: pi }),
|
|
14
|
+
sendMessage: (message) => pi.sendMessage(message),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
registerFusionCommands(pi, orchestrator);
|
|
18
|
+
|
|
19
|
+
const unsubscribeComplete = pi.events.on(
|
|
20
|
+
SUBAGENT_ASYNC_COMPLETE_EVENT,
|
|
21
|
+
(payload) => {
|
|
22
|
+
void orchestrator.handleSubagentComplete(payload);
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
27
|
+
await orchestrator.restore(ctx);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
pi.on("session_shutdown", () => {
|
|
31
|
+
orchestrator.clearUi();
|
|
32
|
+
if (typeof unsubscribeComplete === "function") unsubscribeComplete();
|
|
33
|
+
});
|
|
34
|
+
}
|