@alexeiled/pi-fusion 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,6 +142,34 @@ Do not use it for trivial edits, formatting, or obvious one-step fixes.
142
142
  /fusion init
143
143
  ```
144
144
 
145
+ ## Plan execution RPC
146
+
147
+ Other Pi extensions can control Fusion through the versioned event-bus contract
148
+ `fusion:rpc:v1`:
149
+
150
+ - emit requests on `fusion:rpc:v1:request`
151
+ - listen for the response on `fusion:rpc:v1:reply:<requestId>` before emitting
152
+ - send `{ "version": 1, "requestId": "...", "method": "...", "params": {} }`
153
+ - receive `{ "version": 1, "requestId": "...", "method": "...", "success": true, "data": {} }` or a failure with a typed `error`
154
+
155
+ Methods:
156
+
157
+ - `ping` — return the RPC version and supported methods
158
+ - `start` — requires `prompt` and a non-empty `operationId`; accepts optional `profile`. Reusing an operation ID returns the original run instead of starting another, including after Fusion restores the Pi session history.
159
+ - `status` — return structured run state by `operationId`, `runId`, or the current/last run
160
+ - `result` — return a terminal run and report; active runs return `not_ready`
161
+ - `cancel` — cancel the selected active run, or report that the selected terminal run was not cancelled
162
+ - `adopt` — confirm and return a run from restored session history by `runId`
163
+
164
+ `start` returns `{ operationId, replayed, run }`. `status` and `result`
165
+ return `{ run }`. `cancel` returns `{ cancelled, run? }`. `adopt` returns
166
+ `{ adopted: true, run }`. Run state contains `runId`, optional `operationId`,
167
+ `phase`, `terminal`, and optional `report` or `error`.
168
+
169
+ Failure codes are `invalid_request`, `unsupported_method`, `busy`, `not_found`,
170
+ `not_ready`, `unavailable`, `start_failed`, `cancel_failed`, and `internal`.
171
+ `busy`, `not_ready`, and lookup failures include structured details when available.
172
+
145
173
  ## Quick start
146
174
 
147
175
  Requirements:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
 
@@ -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
  }
@@ -21,6 +21,7 @@ import {
21
21
  clearFusionUi,
22
22
  extractFusionProgressCounts,
23
23
  formatProgressCounts,
24
+ isTerminalFusionProgress,
24
25
  publishFusionStatus,
25
26
  type FusionProgressCounts,
26
27
  type FusionUi,
@@ -165,6 +166,9 @@ export class FusionOrchestrator {
165
166
  run = this.runStore.startRun({
166
167
  prompt: args.prompt,
167
168
  profileName: resolved.name,
169
+ ...(args.operationId !== undefined
170
+ ? { operationId: args.operationId }
171
+ : {}),
168
172
  phase: "panel",
169
173
  });
170
174
  } catch (error: unknown) {
@@ -187,6 +191,8 @@ export class FusionOrchestrator {
187
191
  const spawnResult = await this.rpc.spawn(
188
192
  buildPanelSpawnParams(resolved.profile, args.prompt),
189
193
  );
194
+ const spawnError = extractSubagentFailure(spawnResult);
195
+ if (spawnError) throw new FusionArgsError(spawnError);
190
196
  const panelRunId = extractSubagentRunId(spawnResult);
191
197
  if (!panelRunId) {
192
198
  throw new FusionArgsError(
@@ -516,13 +522,17 @@ export class FusionOrchestrator {
516
522
  return { status: "ignored" };
517
523
  }
518
524
 
519
- const extracted = extractPanelResults(
520
- snapshot.resultPayload ?? snapshot.statusPayload ?? payload,
521
- {
522
- panel: profile.panel,
523
- limit: profile.panel.length,
524
- },
525
- );
525
+ const lifecyclePayload =
526
+ snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
527
+ const lifecycleError = extractSubagentFailure(lifecyclePayload);
528
+ if (!hasLifecycleResults(lifecyclePayload) && lifecycleError) {
529
+ return this.failActiveRun(lifecycleError);
530
+ }
531
+
532
+ const extracted = extractPanelResults(lifecyclePayload, {
533
+ panel: profile.panel,
534
+ limit: profile.panel.length,
535
+ });
526
536
  if (!extracted.ok) {
527
537
  return this.failActiveRun(
528
538
  `${extracted.error.message} (${extracted.error.path})`,
@@ -627,16 +637,20 @@ export class FusionOrchestrator {
627
637
  return { status: "ignored" };
628
638
  }
629
639
 
630
- const extracted = extractPanelResults(
631
- snapshot.resultPayload ?? snapshot.statusPayload ?? payload,
632
- {
633
- panel: profile.panel,
634
- limit: profile.panel.length,
635
- ...(active.panelStoppedIndices
636
- ? { stoppedPanelIndices: active.panelStoppedIndices }
637
- : {}),
638
- },
639
- );
640
+ const lifecyclePayload =
641
+ snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
642
+ const lifecycleError = extractSubagentFailure(lifecyclePayload);
643
+ if (!hasLifecycleResults(lifecyclePayload) && lifecycleError) {
644
+ return this.failActiveRun(lifecycleError);
645
+ }
646
+
647
+ const extracted = extractPanelResults(lifecyclePayload, {
648
+ panel: profile.panel,
649
+ limit: profile.panel.length,
650
+ ...(active.panelStoppedIndices
651
+ ? { stoppedPanelIndices: active.panelStoppedIndices }
652
+ : {}),
653
+ });
640
654
  if (!extracted.ok) {
641
655
  return this.failActiveRun(
642
656
  `${extracted.error.message} (${extracted.error.path})`,
@@ -741,6 +755,8 @@ export class FusionOrchestrator {
741
755
 
742
756
  try {
743
757
  const spawnResult = await this.rpc.spawn(decision.params);
758
+ const spawnError = extractSubagentFailure(spawnResult);
759
+ if (spawnError) throw new FusionArgsError(spawnError);
744
760
  const judgeRunId = extractSubagentRunId(spawnResult);
745
761
  if (!judgeRunId) {
746
762
  throw new FusionArgsError(decision.missingRunIdError);
@@ -788,9 +804,14 @@ export class FusionOrchestrator {
788
804
  return { status: "ignored" };
789
805
  }
790
806
 
791
- const output = extractJudgeOutput(
792
- snapshot.resultPayload ?? snapshot.statusPayload ?? payload,
793
- );
807
+ const lifecyclePayload =
808
+ snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
809
+ const lifecycleError = extractSubagentFailure(lifecyclePayload);
810
+ if (!hasLifecycleResults(lifecyclePayload) && lifecycleError) {
811
+ return this.failActiveRun(lifecycleError);
812
+ }
813
+
814
+ const output = extractJudgeOutput(lifecyclePayload);
794
815
  if (!output.ok) return this.failActiveRun(output.error);
795
816
 
796
817
  const judgeModel = this.activeProfile
@@ -849,12 +870,18 @@ export class FusionOrchestrator {
849
870
  );
850
871
  }
851
872
 
873
+ const statusIsTerminal =
874
+ isTerminalSubagentState(extractSubagentState(statusPayload)) ||
875
+ isTerminalFusionProgress(statusPayload);
876
+ const eventIsTerminal =
877
+ isTerminalSubagentState(extractSubagentState(input.eventPayload)) ||
878
+ isTerminalFusionProgress(input.eventPayload);
852
879
  const eventHasResults = hasResultsArray(input.eventPayload);
853
880
  if (eventPayloadMatches && eventHasResults) {
854
881
  return {
855
882
  statusPayload,
856
883
  resultPayload: input.eventPayload,
857
- resultIsTerminal: true,
884
+ resultIsTerminal: eventIsTerminal || statusIsTerminal,
858
885
  };
859
886
  }
860
887
 
@@ -871,17 +898,14 @@ export class FusionOrchestrator {
871
898
  }
872
899
 
873
900
  if (hasResultsArray(statusPayload)) {
874
- const resultIsTerminal =
875
- eventPayloadMatches ||
876
- isTerminalSubagentState(extractSubagentState(statusPayload));
877
901
  return {
878
902
  statusPayload,
879
903
  resultPayload: statusPayload,
880
- resultIsTerminal,
904
+ resultIsTerminal: statusIsTerminal,
881
905
  };
882
906
  }
883
907
 
884
- if (isTerminalSubagentState(extractSubagentState(statusPayload))) {
908
+ if (statusIsTerminal) {
885
909
  return {
886
910
  statusPayload,
887
911
  resultPayload: statusPayload,
@@ -893,9 +917,7 @@ export class FusionOrchestrator {
893
917
  return {
894
918
  statusPayload,
895
919
  resultPayload: input.eventPayload,
896
- resultIsTerminal: isTerminalSubagentState(
897
- extractSubagentState(input.eventPayload),
898
- ),
920
+ resultIsTerminal: eventIsTerminal,
899
921
  };
900
922
  }
901
923
 
@@ -1525,6 +1547,38 @@ function isTerminalSubagentState(state: string | undefined): boolean {
1525
1547
  );
1526
1548
  }
1527
1549
 
1550
+ function hasLifecycleResults(payload: unknown): boolean {
1551
+ return hasResultsArray(payload) || findStepsArray(payload).length > 0;
1552
+ }
1553
+
1554
+ function extractSubagentFailure(payload: unknown): string | undefined {
1555
+ if (!isRecord(payload)) return undefined;
1556
+ const direct = firstNonBlankString(payload.error, payload.errorMessage);
1557
+ if (direct) return direct;
1558
+ if (isRecord(payload.details)) {
1559
+ const detailsError = firstNonBlankString(
1560
+ payload.details.error,
1561
+ payload.details.errorMessage,
1562
+ );
1563
+ if (detailsError) return detailsError;
1564
+ }
1565
+ if (payload.isError === true && Array.isArray(payload.content)) {
1566
+ for (const item of payload.content) {
1567
+ const contentText = isRecord(item)
1568
+ ? firstNonBlankString(item.text)
1569
+ : undefined;
1570
+ if (contentText) return contentText;
1571
+ }
1572
+ }
1573
+ const text = firstNonBlankString(
1574
+ payload.text,
1575
+ isRecord(payload.details) ? payload.details.text : undefined,
1576
+ );
1577
+ if (text && /^error(?:\s|:)/i.test(text)) return text;
1578
+ if (isRecord(payload.data)) return extractSubagentFailure(payload.data);
1579
+ return undefined;
1580
+ }
1581
+
1528
1582
  function firstNonBlankStringFromPayload(payload: unknown): string | undefined {
1529
1583
  if (!isRecord(payload)) return undefined;
1530
1584
  const direct = firstNonBlankString(
package/src/run-store.ts CHANGED
@@ -23,6 +23,7 @@ export type FusionRunSummary = Omit<
23
23
  | "id"
24
24
  | "prompt"
25
25
  | "profileName"
26
+ | "operationId"
26
27
  | "phase"
27
28
  | "createdAt"
28
29
  | "updatedAt"
@@ -39,6 +40,7 @@ export interface FusionRunStartInput {
39
40
  id?: string;
40
41
  prompt: string;
41
42
  profileName: string;
43
+ operationId?: string;
42
44
  phase?: Exclude<FusionPhase, FusionTerminalPhase>;
43
45
  createdAt?: number;
44
46
  }
@@ -96,6 +98,8 @@ export class FusionRunStoreError extends Error {
96
98
  export class FusionRunStore {
97
99
  private activeRun: FusionRun | undefined;
98
100
  private lastRunSummary: FusionRunSummary | undefined;
101
+ private readonly runsById = new Map<string, FusionRun>();
102
+ private readonly runIdsByOperationId = new Map<string, string>();
99
103
  private readonly now: () => number;
100
104
  private readonly idFactory: () => string;
101
105
  private readonly persistence: FusionRunStorePersistence | undefined;
@@ -116,22 +120,44 @@ export class FusionRunStore {
116
120
  : undefined;
117
121
  }
118
122
 
123
+ getRunById(id: string): FusionRun | undefined {
124
+ const run = this.runsById.get(id);
125
+ return run ? cloneRun(run) : undefined;
126
+ }
127
+
128
+ getRunByOperationId(operationId: string): FusionRun | undefined {
129
+ const runId = this.runIdsByOperationId.get(operationId);
130
+ return runId ? this.getRunById(runId) : undefined;
131
+ }
132
+
119
133
  startRun(input: FusionRunStartInput): FusionRun {
120
134
  if (this.activeRun) {
121
135
  throw new FusionRunStoreError(
122
136
  `Fusion run ${this.activeRun.id} is already active.`,
123
137
  );
124
138
  }
139
+ if (
140
+ input.operationId !== undefined &&
141
+ this.runIdsByOperationId.has(input.operationId)
142
+ ) {
143
+ throw new FusionRunStoreError(
144
+ `Fusion operation ${input.operationId} already has a run.`,
145
+ );
146
+ }
125
147
  const createdAt = input.createdAt ?? this.now();
126
148
  const run: FusionRun = {
127
149
  id: input.id ?? this.idFactory(),
128
150
  prompt: input.prompt,
129
151
  profileName: input.profileName,
152
+ ...(input.operationId !== undefined
153
+ ? { operationId: input.operationId }
154
+ : {}),
130
155
  phase: input.phase ?? "chain",
131
156
  createdAt,
132
157
  updatedAt: createdAt,
133
158
  };
134
159
  this.activeRun = run;
160
+ this.rememberRun(run);
135
161
  this.persistRun(run);
136
162
  return cloneRun(run);
137
163
  }
@@ -140,6 +166,7 @@ export class FusionRunStore {
140
166
  const active = this.requireActiveRun(id);
141
167
  const updated = applyPatch(active, patch, patch.updatedAt ?? this.now());
142
168
  this.activeRun = updated;
169
+ this.rememberRun(updated);
143
170
  this.persistRun(updated);
144
171
  return cloneRun(updated);
145
172
  }
@@ -171,6 +198,7 @@ export class FusionRunStore {
171
198
  const summary = toRunSummary(finished);
172
199
  this.activeRun = undefined;
173
200
  this.lastRunSummary = summary;
201
+ this.rememberRun(finished);
174
202
  this.persistRun(summary);
175
203
  return cloneRun(finished);
176
204
  }
@@ -178,7 +206,12 @@ export class FusionRunStore {
178
206
  restoreFromEntries(
179
207
  entries: readonly unknown[],
180
208
  ): FusionRunSummary | undefined {
181
- const latestState = readLastFusionRunState(entries);
209
+ const states = readFusionRunStates(entries);
210
+ this.runsById.clear();
211
+ this.runIdsByOperationId.clear();
212
+ for (const state of states) this.rememberRun(state);
213
+
214
+ const latestState = states.at(-1);
182
215
  const summary = readLastFusionRunSummary(entries);
183
216
  this.activeRun =
184
217
  latestState && !isTerminalPhase(latestState.phase)
@@ -208,6 +241,16 @@ export class FusionRunStore {
208
241
  this.persistence?.appendEntry(FUSION_RUN_ENTRY_TYPE, cloneRun(run));
209
242
  }
210
243
 
244
+ private rememberRun(run: FusionRun): void {
245
+ this.runsById.set(run.id, cloneRun(run));
246
+ if (
247
+ run.operationId !== undefined &&
248
+ !this.runIdsByOperationId.has(run.operationId)
249
+ ) {
250
+ this.runIdsByOperationId.set(run.operationId, run.id);
251
+ }
252
+ }
253
+
211
254
  private requireActiveRun(id: string): FusionRun {
212
255
  if (!this.activeRun) {
213
256
  throw new FusionRunStoreError("No active fusion run.");
@@ -325,6 +368,7 @@ function toRunSummary(
325
368
  id: run.id,
326
369
  prompt: run.prompt,
327
370
  profileName: run.profileName,
371
+ ...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
328
372
  phase: run.phase,
329
373
  createdAt: run.createdAt,
330
374
  updatedAt: run.updatedAt,
@@ -341,6 +385,7 @@ function cloneRun(run: FusionRun): FusionRun {
341
385
  id: run.id,
342
386
  prompt: run.prompt,
343
387
  profileName: run.profileName,
388
+ ...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
344
389
  phase: run.phase,
345
390
  createdAt: run.createdAt,
346
391
  updatedAt: run.updatedAt,
@@ -396,6 +441,9 @@ function isFusionRunState(value: unknown): value is FusionRun {
396
441
  if (!isNonEmptyString(value.id)) return false;
397
442
  if (typeof value.prompt !== "string") return false;
398
443
  if (!isNonEmptyString(value.profileName)) return false;
444
+ if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) {
445
+ return false;
446
+ }
399
447
  if (!isFusionPhase(value.phase)) return false;
400
448
  if (!isFiniteNumber(value.createdAt)) return false;
401
449
  if (!isFiniteNumber(value.updatedAt)) return false;
package/src/status.ts CHANGED
@@ -92,6 +92,20 @@ export function formatProgressCounts(progress: FusionProgressCounts): string {
92
92
  return `${progress.completed}/${total} done, ${progress.running} running, ${progress.failed} failed`;
93
93
  }
94
94
 
95
+ export function isTerminalFusionProgress(payload: unknown): boolean {
96
+ const progressPayload = findTerminalProgressPayload(payload);
97
+ const progress = progressPayload
98
+ ? extractFusionProgressCounts(progressPayload)
99
+ : undefined;
100
+ return Boolean(
101
+ progress &&
102
+ progress.total !== undefined &&
103
+ progress.total > 0 &&
104
+ progress.pending === 0 &&
105
+ progress.running === 0,
106
+ );
107
+ }
108
+
95
109
  type ProgressStatus = "pending" | "running" | "completed" | "failed";
96
110
 
97
111
  function findProgressContainer(
@@ -116,6 +130,23 @@ function findProgressContainer(
116
130
  return undefined;
117
131
  }
118
132
 
133
+ function findTerminalProgressPayload(payload: unknown): unknown {
134
+ if (!isRecord(payload)) return undefined;
135
+ if (Array.isArray(payload.progress)) return { progress: payload.progress };
136
+ if (Array.isArray(payload.steps)) return { steps: payload.steps };
137
+ if (isRecord(payload.details)) {
138
+ if (Array.isArray(payload.details.progress)) {
139
+ return { progress: payload.details.progress };
140
+ }
141
+ if (Array.isArray(payload.details.steps)) {
142
+ return { steps: payload.details.steps };
143
+ }
144
+ }
145
+ return isRecord(payload.data)
146
+ ? findTerminalProgressPayload(payload.data)
147
+ : undefined;
148
+ }
149
+
119
150
  function classifyProgressItem(value: unknown): ProgressStatus {
120
151
  if (!isRecord(value)) return "failed";
121
152
  if (value.success === true) return "completed";
package/src/types.ts CHANGED
@@ -78,6 +78,7 @@ export interface FusionConfig {
78
78
  export interface ParsedFusionArgs {
79
79
  prompt: string;
80
80
  profile?: string;
81
+ operationId?: string;
81
82
  }
82
83
 
83
84
  export interface PanelOutput {
@@ -120,6 +121,7 @@ export interface FusionRun {
120
121
  id: string;
121
122
  prompt: string;
122
123
  profileName: string;
124
+ operationId?: string;
123
125
  phase: FusionPhase;
124
126
  createdAt: number;
125
127
  updatedAt: number;