@alexeiled/pi-fusion 0.3.1 → 0.5.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.
@@ -0,0 +1,619 @@
1
+ import type {
2
+ FusionCommandContext,
3
+ FusionCommandResult,
4
+ } from "./orchestrator.js";
5
+ import type { FusionRunStore } from "./run-store.js";
6
+ import type { FusionPhase, FusionRun, ParsedFusionArgs } from "./types.js";
7
+ import { isNonEmptyString, isRecord } from "./utils.js";
8
+
9
+ export const FUSION_RPC_VERSION = 1;
10
+ export const FUSION_RPC_REQUEST_EVENT = "fusion:rpc:v1:request";
11
+ export const FUSION_RPC_REPLY_EVENT_PREFIX = "fusion:rpc:v1:reply:";
12
+
13
+ export const FUSION_RPC_METHODS = [
14
+ "ping",
15
+ "start",
16
+ "status",
17
+ "result",
18
+ "cancel",
19
+ "adopt",
20
+ ] as const;
21
+
22
+ export type FusionRpcMethod = (typeof FUSION_RPC_METHODS)[number];
23
+
24
+ export type FusionRpcErrorCode =
25
+ | "invalid_request"
26
+ | "unsupported_method"
27
+ | "busy"
28
+ | "not_found"
29
+ | "not_ready"
30
+ | "unavailable"
31
+ | "start_failed"
32
+ | "cancel_failed"
33
+ | "internal";
34
+
35
+ export interface FusionRpcRequestEnvelope {
36
+ version: typeof FUSION_RPC_VERSION;
37
+ requestId: string;
38
+ method: FusionRpcMethod;
39
+ params?: unknown;
40
+ }
41
+
42
+ export interface FusionRpcError {
43
+ code: FusionRpcErrorCode;
44
+ message: string;
45
+ details?: unknown;
46
+ }
47
+
48
+ export interface FusionRunState {
49
+ runId: string;
50
+ operationId?: string;
51
+ phase: FusionPhase;
52
+ terminal: boolean;
53
+ report?: string;
54
+ error?: string;
55
+ }
56
+
57
+ export interface FusionRpcPingData {
58
+ pong: true;
59
+ version: typeof FUSION_RPC_VERSION;
60
+ methods: readonly FusionRpcMethod[];
61
+ }
62
+
63
+ export interface FusionRpcStartData {
64
+ operationId: string;
65
+ replayed: boolean;
66
+ run: FusionRunState;
67
+ }
68
+
69
+ export interface FusionRpcStatusData {
70
+ run: FusionRunState;
71
+ }
72
+
73
+ export interface FusionRpcResultData {
74
+ run: FusionRunState;
75
+ }
76
+
77
+ export interface FusionRpcCancelData {
78
+ cancelled: boolean;
79
+ run?: FusionRunState;
80
+ }
81
+
82
+ export interface FusionRpcAdoptData {
83
+ adopted: true;
84
+ run: FusionRunState;
85
+ }
86
+
87
+ export type FusionRpcReplyEnvelope =
88
+ | {
89
+ version: typeof FUSION_RPC_VERSION;
90
+ requestId: string;
91
+ method?: FusionRpcMethod;
92
+ success: true;
93
+ data: unknown;
94
+ }
95
+ | {
96
+ version: typeof FUSION_RPC_VERSION;
97
+ requestId: string;
98
+ method?: FusionRpcMethod;
99
+ success: false;
100
+ error: FusionRpcError;
101
+ };
102
+
103
+ export interface FusionRpcEventBus {
104
+ on(event: string, handler: (payload: unknown) => void): (() => void) | void;
105
+ emit(event: string, payload: unknown): void;
106
+ }
107
+
108
+ export interface FusionRpcOrchestrator {
109
+ startRun(
110
+ input: ParsedFusionArgs,
111
+ ctx: FusionCommandContext,
112
+ ): Promise<FusionCommandResult>;
113
+ cancelActiveRun(ctx: FusionCommandContext): Promise<FusionCommandResult>;
114
+ }
115
+
116
+ type FusionRpcRunStore = Pick<
117
+ FusionRunStore,
118
+ "getActiveRun" | "getLastRunSummary" | "getRunById" | "getRunByOperationId"
119
+ >;
120
+
121
+ export interface FusionRpcDependencies {
122
+ events: FusionRpcEventBus;
123
+ orchestrator: FusionRpcOrchestrator;
124
+ store: FusionRpcRunStore;
125
+ getContext: () => FusionCommandContext | undefined;
126
+ }
127
+
128
+ interface OperationRecord {
129
+ pending?: Promise<FusionRpcStartData>;
130
+ runId?: string;
131
+ }
132
+
133
+ interface StartParams {
134
+ prompt: string;
135
+ profile?: string;
136
+ operationId: string;
137
+ }
138
+
139
+ interface RunParams {
140
+ runId?: string;
141
+ operationId?: string;
142
+ }
143
+
144
+ type ObservableRun = Pick<
145
+ FusionRun,
146
+ "id" | "operationId" | "phase" | "report" | "error"
147
+ >;
148
+
149
+ const TERMINAL_PHASES = new Set<FusionPhase>(["done", "failed", "cancelled"]);
150
+
151
+ export function fusionRpcReplyEvent(requestId: string): string {
152
+ return `${FUSION_RPC_REPLY_EVENT_PREFIX}${requestId}`;
153
+ }
154
+
155
+ export function registerFusionRpc({
156
+ events,
157
+ orchestrator,
158
+ store,
159
+ getContext,
160
+ }: FusionRpcDependencies): () => void {
161
+ const operations = new Map<string, OperationRecord>();
162
+ const unsubscribe = events.on(FUSION_RPC_REQUEST_EVENT, (event) => {
163
+ void handleRequest(event);
164
+ });
165
+
166
+ return typeof unsubscribe === "function" ? unsubscribe : () => undefined;
167
+
168
+ async function handleRequest(event: unknown): Promise<void> {
169
+ const request = parseRequest(event);
170
+ if (request instanceof RpcRequestFailure) {
171
+ if (request.requestId) {
172
+ replyFailure(request.requestId, request.method, request.error);
173
+ }
174
+ return;
175
+ }
176
+
177
+ try {
178
+ const data = await dispatch(request);
179
+ replySuccess(request.requestId, request.method, data);
180
+ } catch (error: unknown) {
181
+ replyFailure(request.requestId, request.method, normalizeError(error));
182
+ }
183
+ }
184
+
185
+ async function dispatch(request: FusionRpcRequestEnvelope): Promise<unknown> {
186
+ switch (request.method) {
187
+ case "ping":
188
+ return {
189
+ pong: true,
190
+ version: FUSION_RPC_VERSION,
191
+ methods: FUSION_RPC_METHODS,
192
+ } satisfies FusionRpcPingData;
193
+ case "start":
194
+ return start(request.params);
195
+ case "status":
196
+ return {
197
+ run: stateFor(findRun(request.params, operations, store, "status")),
198
+ } satisfies FusionRpcStatusData;
199
+ case "result":
200
+ return result(request.params);
201
+ case "cancel":
202
+ return cancel(request.params);
203
+ case "adopt":
204
+ return adopt(request.params);
205
+ }
206
+ }
207
+
208
+ async function start(params: unknown): Promise<FusionRpcStartData> {
209
+ const input = parseStartParams(params);
210
+ const persisted = store.getRunByOperationId(input.operationId);
211
+ if (persisted) return startData(input.operationId, persisted, true);
212
+
213
+ const known = operations.get(input.operationId);
214
+ if (known?.runId) {
215
+ const run = store.getRunById(known.runId);
216
+ if (run) return startData(input.operationId, run, true);
217
+ operations.delete(input.operationId);
218
+ } else if (known?.pending) {
219
+ const response = await known.pending;
220
+ return { ...response, replayed: true };
221
+ }
222
+
223
+ const context = requireContext(getContext());
224
+ const pending = orchestrator
225
+ .startRun(toParsedFusionArgs(input), context)
226
+ .then((result) =>
227
+ startData(
228
+ input.operationId,
229
+ runFromStartResult(result, input.operationId, store),
230
+ false,
231
+ ),
232
+ );
233
+ operations.set(input.operationId, { pending });
234
+
235
+ try {
236
+ const response = await pending;
237
+ operations.set(input.operationId, { runId: response.run.runId });
238
+ return response;
239
+ } catch (error: unknown) {
240
+ operations.delete(input.operationId);
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ function result(params: unknown): FusionRpcResultData {
246
+ const run = findRun(params, operations, store, "result");
247
+ const state = stateFor(run);
248
+ if (!state.terminal) {
249
+ throw new RpcFailure({
250
+ code: "not_ready",
251
+ message: `Fusion run ${state.runId} is not terminal.`,
252
+ details: { run: state },
253
+ });
254
+ }
255
+ return { run: state };
256
+ }
257
+
258
+ async function cancel(params: unknown): Promise<FusionRpcCancelData> {
259
+ const selector = parseRunParams(params, "cancel");
260
+ const selected = hasRunSelector(selector)
261
+ ? findRun(params, operations, store, "cancel")
262
+ : undefined;
263
+ const active = store.getActiveRun();
264
+
265
+ if (selected && TERMINAL_PHASES.has(selected.phase)) {
266
+ return { cancelled: false, run: stateFor(selected) };
267
+ }
268
+ if (!active) {
269
+ const last = selected ?? store.getLastRunSummary();
270
+ return last
271
+ ? { cancelled: false, run: stateFor(last) }
272
+ : { cancelled: false };
273
+ }
274
+ if (selected && selected.id !== active.id) {
275
+ return { cancelled: false, run: stateFor(selected) };
276
+ }
277
+
278
+ const context = requireContext(getContext());
279
+ const cancellation = await orchestrator.cancelActiveRun(context);
280
+ if (cancellation.status === "cancelled") {
281
+ return { cancelled: true, run: stateFor(cancellation.run) };
282
+ }
283
+ if (cancellation.status === "failed") {
284
+ throw new RpcFailure({
285
+ code: "cancel_failed",
286
+ message: cancellation.error,
287
+ details: { run: stateFor(active) },
288
+ });
289
+ }
290
+
291
+ const current = store.getRunById(active.id);
292
+ if (!current) return { cancelled: false };
293
+ return {
294
+ cancelled: current.phase === "cancelled",
295
+ run: stateFor(current),
296
+ };
297
+ }
298
+
299
+ function adopt(params: unknown): FusionRpcAdoptData {
300
+ const runId = parseAdoptParams(params);
301
+ const run = store.getRunById(runId);
302
+ if (!run) {
303
+ throw new RpcFailure({
304
+ code: "not_found",
305
+ message: "Fusion run was not found in this session history.",
306
+ details: { runId },
307
+ });
308
+ }
309
+ return { adopted: true, run: stateFor(run) };
310
+ }
311
+
312
+ function replySuccess(
313
+ requestId: string,
314
+ method: FusionRpcMethod,
315
+ data: unknown,
316
+ ): void {
317
+ events.emit(fusionRpcReplyEvent(requestId), {
318
+ version: FUSION_RPC_VERSION,
319
+ requestId,
320
+ method,
321
+ success: true,
322
+ data,
323
+ } satisfies FusionRpcReplyEnvelope);
324
+ }
325
+
326
+ function replyFailure(
327
+ requestId: string,
328
+ method: FusionRpcMethod | undefined,
329
+ error: FusionRpcError,
330
+ ): void {
331
+ events.emit(fusionRpcReplyEvent(requestId), {
332
+ version: FUSION_RPC_VERSION,
333
+ requestId,
334
+ ...(method === undefined ? {} : { method }),
335
+ success: false,
336
+ error,
337
+ } satisfies FusionRpcReplyEnvelope);
338
+ }
339
+ }
340
+
341
+ function parseRequest(
342
+ input: unknown,
343
+ ): FusionRpcRequestEnvelope | RpcRequestFailure {
344
+ if (!isRecord(input)) {
345
+ return new RpcRequestFailure(undefined, undefined, {
346
+ code: "invalid_request",
347
+ message: "RPC request must be an object.",
348
+ });
349
+ }
350
+
351
+ const requestId = input.requestId;
352
+ const method = input.method;
353
+ if (!isNonEmptyString(requestId)) {
354
+ return new RpcRequestFailure(undefined, undefined, {
355
+ code: "invalid_request",
356
+ message: "RPC requestId must be a non-empty string.",
357
+ });
358
+ }
359
+ if (!isMethod(method)) {
360
+ return new RpcRequestFailure(requestId, undefined, {
361
+ code: "unsupported_method",
362
+ message: "RPC method is unsupported.",
363
+ });
364
+ }
365
+ if (input.version !== FUSION_RPC_VERSION) {
366
+ return new RpcRequestFailure(requestId, method, {
367
+ code: "invalid_request",
368
+ message: `RPC version must be ${FUSION_RPC_VERSION}.`,
369
+ });
370
+ }
371
+
372
+ return input.params === undefined
373
+ ? { version: FUSION_RPC_VERSION, requestId, method }
374
+ : {
375
+ version: FUSION_RPC_VERSION,
376
+ requestId,
377
+ method,
378
+ params: input.params,
379
+ };
380
+ }
381
+
382
+ function parseStartParams(input: unknown): StartParams {
383
+ if (!isRecord(input)) {
384
+ throw invalidParams("start parameters must be an object.");
385
+ }
386
+
387
+ const prompt = input.prompt;
388
+ if (!isNonEmptyString(prompt)) {
389
+ throw invalidParams("start prompt must be a non-empty string.");
390
+ }
391
+
392
+ const operationId = input.operationId;
393
+ if (!isNonEmptyString(operationId)) {
394
+ throw invalidParams("start operationId must be a non-empty string.");
395
+ }
396
+
397
+ const profile = input.profile;
398
+ if (profile !== undefined && !isNonEmptyString(profile)) {
399
+ throw invalidParams(
400
+ "start profile must be a non-empty string when provided.",
401
+ );
402
+ }
403
+
404
+ return profile === undefined
405
+ ? { prompt, operationId }
406
+ : { prompt, operationId, profile };
407
+ }
408
+
409
+ function toParsedFusionArgs(input: StartParams): ParsedFusionArgs {
410
+ return input.profile === undefined
411
+ ? { prompt: input.prompt, operationId: input.operationId }
412
+ : {
413
+ prompt: input.prompt,
414
+ profile: input.profile,
415
+ operationId: input.operationId,
416
+ };
417
+ }
418
+
419
+ function findRun(
420
+ input: unknown,
421
+ operations: ReadonlyMap<string, OperationRecord>,
422
+ store: FusionRpcRunStore,
423
+ method: "status" | "result" | "cancel",
424
+ ): ObservableRun {
425
+ const params = parseRunParams(input, method);
426
+ if (params.operationId) {
427
+ const persisted = store.getRunByOperationId(params.operationId);
428
+ if (persisted) return persisted;
429
+
430
+ const operation = operations.get(params.operationId);
431
+ if (operation?.runId) {
432
+ const run = store.getRunById(operation.runId);
433
+ if (run) return run;
434
+ }
435
+ if (operation?.pending) {
436
+ const active = store.getActiveRun();
437
+ if (active?.operationId === params.operationId) return active;
438
+ throw new RpcFailure({
439
+ code: "not_ready",
440
+ message: `Fusion operation ${params.operationId} has not produced a run yet.`,
441
+ details: { operationId: params.operationId },
442
+ });
443
+ }
444
+ throw notFound({ operationId: params.operationId });
445
+ }
446
+ if (params.runId) {
447
+ const run = store.getRunById(params.runId);
448
+ if (!run) throw notFound({ runId: params.runId });
449
+ return run;
450
+ }
451
+
452
+ const run = store.getActiveRun() ?? store.getLastRunSummary();
453
+ if (!run) throw notFound();
454
+ return run;
455
+ }
456
+
457
+ function parseRunParams(
458
+ input: unknown,
459
+ method: "status" | "result" | "cancel",
460
+ ): RunParams {
461
+ if (input === undefined) return {};
462
+ if (!isRecord(input)) {
463
+ throw invalidParams(`${method} parameters must be an object.`);
464
+ }
465
+
466
+ const { operationId, runId } = input;
467
+ if (operationId !== undefined && !isNonEmptyString(operationId)) {
468
+ throw invalidParams(
469
+ "operationId must be a non-empty string when provided.",
470
+ );
471
+ }
472
+ if (runId !== undefined && !isNonEmptyString(runId)) {
473
+ throw invalidParams("runId must be a non-empty string when provided.");
474
+ }
475
+ if (operationId !== undefined && runId !== undefined) {
476
+ throw invalidParams("Specify either operationId or runId, not both.");
477
+ }
478
+
479
+ if (operationId !== undefined) return { operationId };
480
+ return runId === undefined ? {} : { runId };
481
+ }
482
+
483
+ function hasRunSelector(params: RunParams): boolean {
484
+ return params.operationId !== undefined || params.runId !== undefined;
485
+ }
486
+
487
+ function parseAdoptParams(input: unknown): string {
488
+ if (!isRecord(input) || !isNonEmptyString(input.runId)) {
489
+ throw invalidParams("adopt runId must be a non-empty string.");
490
+ }
491
+ return input.runId;
492
+ }
493
+
494
+ function runFromStartResult(
495
+ result: FusionCommandResult,
496
+ operationId: string,
497
+ store: FusionRpcRunStore,
498
+ ): ObservableRun {
499
+ switch (result.status) {
500
+ case "started":
501
+ case "done":
502
+ case "cancelled":
503
+ return result.run;
504
+ case "conflict": {
505
+ const active = store.getRunById(result.activeRunId);
506
+ throw new RpcFailure({
507
+ code: "busy",
508
+ message: `Fusion run ${result.activeRunId} is already active.`,
509
+ details: {
510
+ activeRunId: result.activeRunId,
511
+ ...(active ? { run: stateFor(active) } : {}),
512
+ },
513
+ });
514
+ }
515
+ case "failed":
516
+ throw startFailure(result.error, store.getRunByOperationId(operationId));
517
+ case "ignored": {
518
+ const persisted = store.getRunByOperationId(operationId);
519
+ if (persisted) return persisted;
520
+ throw new RpcFailure({
521
+ code: "internal",
522
+ message: "Fusion run did not start.",
523
+ });
524
+ }
525
+ }
526
+ }
527
+
528
+ function startData(
529
+ operationId: string,
530
+ run: ObservableRun,
531
+ replayed: boolean,
532
+ ): FusionRpcStartData {
533
+ if (run.phase === "failed") {
534
+ throw startFailure(run.error ?? "Fusion run failed to start.", run);
535
+ }
536
+ return {
537
+ operationId,
538
+ replayed,
539
+ run: stateFor(run),
540
+ };
541
+ }
542
+
543
+ function startFailure(message: string, run?: ObservableRun): RpcFailure {
544
+ return new RpcFailure({
545
+ code: "start_failed",
546
+ message,
547
+ ...(run ? { details: { run: stateFor(run) } } : {}),
548
+ });
549
+ }
550
+
551
+ function stateFor(run: ObservableRun): FusionRunState {
552
+ const state: FusionRunState = {
553
+ runId: run.id,
554
+ ...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
555
+ phase: run.phase,
556
+ terminal: TERMINAL_PHASES.has(run.phase),
557
+ };
558
+ if (run.report !== undefined) state.report = run.report;
559
+ if (run.error !== undefined) state.error = run.error;
560
+ return state;
561
+ }
562
+
563
+ function requireContext(
564
+ context: FusionCommandContext | undefined,
565
+ ): FusionCommandContext {
566
+ if (context) return context;
567
+ throw new RpcFailure({
568
+ code: "unavailable",
569
+ message: "Fusion session context is unavailable.",
570
+ });
571
+ }
572
+
573
+ function isMethod(value: unknown): value is FusionRpcMethod {
574
+ return (
575
+ value === "ping" ||
576
+ value === "start" ||
577
+ value === "status" ||
578
+ value === "result" ||
579
+ value === "cancel" ||
580
+ value === "adopt"
581
+ );
582
+ }
583
+
584
+ function invalidParams(message: string): RpcFailure {
585
+ return new RpcFailure({ code: "invalid_request", message });
586
+ }
587
+
588
+ function notFound(details?: unknown): RpcFailure {
589
+ return new RpcFailure({
590
+ code: "not_found",
591
+ message: "Fusion run was not found.",
592
+ ...(details === undefined ? {} : { details }),
593
+ });
594
+ }
595
+
596
+ function normalizeError(error: unknown): FusionRpcError {
597
+ if (error instanceof RpcFailure) return error.error;
598
+ return {
599
+ code: "internal",
600
+ message:
601
+ error instanceof Error ? error.message : "Unexpected Fusion RPC error.",
602
+ };
603
+ }
604
+
605
+ class RpcFailure extends Error {
606
+ constructor(readonly error: FusionRpcError) {
607
+ super(error.message);
608
+ }
609
+ }
610
+
611
+ class RpcRequestFailure extends Error {
612
+ constructor(
613
+ readonly requestId: string | undefined,
614
+ readonly method: FusionRpcMethod | undefined,
615
+ readonly error: FusionRpcError,
616
+ ) {
617
+ super(error.message);
618
+ }
619
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,9 @@ import { registerFusionCommands } from "./commands.js";
4
4
  import {
5
5
  FusionOrchestrator,
6
6
  SUBAGENT_ASYNC_COMPLETE_EVENT,
7
+ type FusionCommandContext,
7
8
  } from "./orchestrator.js";
9
+ import { registerFusionRpc } from "./fusion-rpc.js";
8
10
  import { FusionRunStore } from "./run-store.js";
9
11
  import { SubagentsRpcClient } from "./subagents-rpc.js";
10
12
 
@@ -16,10 +18,10 @@ function registerFusionTool(
16
18
  name: "start_fusion_review",
17
19
  label: "Fusion Review",
18
20
  description:
19
- "Start a pi-fusion multi-model panel review. Use when the user asks to invoke fusion, run a panel review, get multi-model opinions, or discuss something through the fusion panel.",
21
+ "Start a pi-fusion multi-model panel review for a hard decision, design tradeoff, risk review, tricky debugging question, or research-heavy topic. Do not use for routine edits, formatting, or obvious one-step fixes.",
20
22
  promptSnippet: "Start a fusion panel review for a topic or code",
21
23
  promptGuidelines: [
22
- "Use start_fusion_review when the user says 'invoke fusion', 'run fusion', 'fusion panel', 'multi-model review', 'panel review', or similar.",
24
+ "Use start_fusion_review only for hard decisions, design tradeoffs, risk review, tricky debugging, or research-heavy questions. Do not use it for routine edits, formatting, or obvious one-step fixes.",
23
25
  ],
24
26
  parameters: Type.Object({
25
27
  prompt: Type.String({ description: "What to review or discuss" }),
@@ -54,9 +56,11 @@ function registerFusionTool(
54
56
  }
55
57
 
56
58
  export default function fusionExtension(pi: ExtensionAPI): void {
59
+ const store = new FusionRunStore({ persistence: pi });
60
+ let sessionContext: FusionCommandContext | undefined;
57
61
  const orchestrator = new FusionOrchestrator({
58
62
  rpc: new SubagentsRpcClient({ events: pi.events }),
59
- runStore: new FusionRunStore({ persistence: pi }),
63
+ runStore: store,
60
64
  sendMessage: (message) => pi.sendMessage(message),
61
65
  });
62
66
 
@@ -69,14 +73,23 @@ export default function fusionExtension(pi: ExtensionAPI): void {
69
73
  void orchestrator.handleSubagentComplete(payload);
70
74
  },
71
75
  );
76
+ const unsubscribeRpc = registerFusionRpc({
77
+ events: pi.events,
78
+ orchestrator,
79
+ store,
80
+ getContext: () => sessionContext,
81
+ });
72
82
 
73
83
  pi.on("session_start", async (_event, ctx) => {
84
+ sessionContext = ctx;
74
85
  await orchestrator.restore(ctx);
75
86
  });
76
87
 
77
88
  pi.on("session_shutdown", () => {
89
+ sessionContext = undefined;
78
90
  orchestrator.clearUi();
79
91
  orchestrator.dispose();
80
92
  if (typeof unsubscribeComplete === "function") unsubscribeComplete();
93
+ unsubscribeRpc();
81
94
  });
82
95
  }