@alexeiled/pi-fusion 0.5.0 → 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.
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.1",
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
  }
@@ -165,6 +165,7 @@ export class FusionOrchestrator {
165
165
  run = this.runStore.startRun({
166
166
  prompt: args.prompt,
167
167
  profileName: resolved.name,
168
+ ...(args.operationId !== undefined ? { operationId: args.operationId } : {}),
168
169
  phase: "panel",
169
170
  });
170
171
  } catch (error: unknown) {
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/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;