pi-background-tasks 0.6.0 → 0.7.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.
@@ -0,0 +1,632 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ ExtensionContext,
6
+ Theme,
7
+ ToolRenderResultOptions,
8
+ } from '@earendil-works/pi-coding-agent';
9
+ import { BorderedLoader, getMarkdownTheme } from '@earendil-works/pi-coding-agent';
10
+ import { Container, Markdown, Text } from '@earendil-works/pi-tui';
11
+ import { Type, type Static } from 'typebox';
12
+ import {
13
+ CURRENT_MODEL_SELECTION,
14
+ fusionModelConfigPath,
15
+ loadFusionModelConfig,
16
+ resolveFusionModels,
17
+ saveFusionModelConfig,
18
+ } from './core/fusion/config.js';
19
+ import {
20
+ FUSION_BRAINSTORM_TOOL_NAME,
21
+ buildFusionCanonicalInput,
22
+ normalizeFusionCommandRequest,
23
+ } from './core/fusion/context.js';
24
+ import type { JsonObject } from './core/common.js';
25
+ import { FusionOrchestrator } from './core/fusion/orchestrator.js';
26
+ import {
27
+ FUSION_RESULT_SCHEMA_VERSION,
28
+ FusionError,
29
+ type FusionModelConfigV1,
30
+ type FusionModelSelection,
31
+ type FusionProgressEvent,
32
+ type FusionResultDetails,
33
+ type FusionRunResult,
34
+ } from './core/fusion/types.js';
35
+ import {
36
+ FusionModelSelector,
37
+ type FusionModelChoice,
38
+ type FusionModelSelectorResult,
39
+ } from './ui/fusion-model-selector.js';
40
+
41
+ const FUSION_STATUS_KEY = 'fusion';
42
+ const FUSION_RESULT_MESSAGE_TYPE = 'fusion-result';
43
+ const FUSION_REQUEST_MESSAGE_TYPE = 'fusion-request';
44
+ const FUSION_PROGRESS_SCHEMA_VERSION = 'pi-background-tasks.fusion-progress.v1';
45
+ const FUSION_REQUEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-request.v1';
46
+ const FUSION_COMMAND_USAGE =
47
+ 'Usage: /fusion <prompt> (or run /fusion with no arguments to open the multiline editor).';
48
+ const FUSION_MODEL_COMMAND_NAME = 'fusion-models';
49
+
50
+ type FusionToolDetails = FusionResultDetails | FusionProgressDetails;
51
+ type FusionToolResultWithUsage = AgentToolResult<FusionToolDetails> & {
52
+ usage: FusionResultDetails['usage'];
53
+ };
54
+
55
+ type CommandDialogResult =
56
+ | { type: 'completed'; result: FusionRunResult }
57
+ | { type: 'failed'; error: unknown };
58
+
59
+ interface FusionProgressDetails {
60
+ schema_version: typeof FUSION_PROGRESS_SCHEMA_VERSION;
61
+ status: string;
62
+ event: FusionProgressEvent;
63
+ }
64
+
65
+ interface ActiveFusionRun {
66
+ controller: AbortController;
67
+ settled: Promise<void>;
68
+ }
69
+
70
+ interface FusionRunRequest {
71
+ source: 'command' | 'tool';
72
+ ctx: ExtensionContext;
73
+ request: string;
74
+ signal?: AbortSignal | undefined;
75
+ toolCallId?: string | undefined;
76
+ onProgress?: ((event: FusionProgressEvent) => void) | undefined;
77
+ }
78
+
79
+ interface FusionRequestDetails {
80
+ schema_version: typeof FUSION_REQUEST_SCHEMA_VERSION;
81
+ run_id: string;
82
+ source: 'command';
83
+ }
84
+
85
+ const FusionBrainstormParams = Type.Object(
86
+ {
87
+ prompt: Type.String({ description: 'Prompt to run through the five-model fusion workflow.' }),
88
+ },
89
+ { additionalProperties: false },
90
+ );
91
+
92
+ type FusionBrainstormParamsValue = Static<typeof FusionBrainstormParams>;
93
+
94
+ function textContent(text: string) {
95
+ return [{ type: 'text' as const, text }];
96
+ }
97
+
98
+ function isRecord(value: unknown): value is JsonObject {
99
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
100
+ }
101
+
102
+ function contextMode(ctx: object): string | undefined {
103
+ const mode = Reflect.get(ctx, 'mode');
104
+ return typeof mode === 'string' ? mode : undefined;
105
+ }
106
+
107
+ function isTuiContext(ctx: ExtensionContext): boolean {
108
+ const mode = contextMode(ctx);
109
+ if (mode === undefined) return ctx.hasUI && ctx.ui.custom.length > 0;
110
+ return mode === 'tui';
111
+ }
112
+
113
+ function qualifiedModelKey(model: { provider: string; id: string }): string {
114
+ return `${model.provider}/${model.id}`;
115
+ }
116
+
117
+ function errorMessage(error: unknown): string {
118
+ return error instanceof Error ? error.message : String(error);
119
+ }
120
+
121
+ function errorArtifactSuffix(error: unknown): string {
122
+ return error instanceof FusionError && error.artifactDir !== undefined
123
+ ? `\nArtifacts: ${error.artifactDir}`
124
+ : '';
125
+ }
126
+
127
+ function toolFailureMessage(error: unknown): string {
128
+ const coordinates: string[] = [];
129
+ if (error instanceof FusionError) {
130
+ if (error.stage !== undefined) coordinates.push(`stage=${error.stage}`);
131
+ if (error.slot !== undefined) coordinates.push(`slot=${String(error.slot)}`);
132
+ if (error.attempt !== undefined) coordinates.push(`attempt=${String(error.attempt)}`);
133
+ }
134
+ const location = coordinates.length === 0 ? '' : ` (${coordinates.join(', ')})`;
135
+ return `Fusion failed${location}: ${errorMessage(error)}${errorArtifactSuffix(error)}`;
136
+ }
137
+
138
+ function progressText(event: FusionProgressEvent): string {
139
+ if (event.type === 'state') return `fusion: ${event.state.replace(/_/g, ' ')}`;
140
+ if (event.type === 'candidate_started') return `fusion: candidate ${String(event.slot)} starting`;
141
+ if (event.type === 'candidate_completed')
142
+ return `fusion: candidates ${String(event.completed)}/${String(event.total)} complete`;
143
+ if (event.type === 'evaluation_started')
144
+ return event.repair ? 'fusion: repairing evaluator JSON' : 'fusion: evaluating candidates';
145
+ if (event.type === 'evaluation_retry')
146
+ return `fusion: evaluator schema retry (${String(event.errors.length)} issue${event.errors.length === 1 ? '' : 's'})`;
147
+ if (event.type === 'merge_started') return 'fusion: merging final answer';
148
+ if (event.type === 'completed') return 'fusion: completed';
149
+ if (event.type === 'cancelled') return `fusion: cancelled (${event.reason})`;
150
+ return `fusion: failed (${event.error})`;
151
+ }
152
+
153
+ function makeProgressDetails(event: FusionProgressEvent): FusionProgressDetails {
154
+ return {
155
+ schema_version: FUSION_PROGRESS_SCHEMA_VERSION,
156
+ status: progressText(event),
157
+ event,
158
+ };
159
+ }
160
+
161
+ function usageSummary(details: FusionResultDetails): string {
162
+ const tokens = details.usage.totalTokens;
163
+ const cost =
164
+ details.usage.costTotal === undefined ? '' : ` · $${details.usage.costTotal.toFixed(4)}`;
165
+ return `${String(tokens)} tokens${cost}`;
166
+ }
167
+
168
+ function extractMessageText(content: unknown): string {
169
+ if (typeof content === 'string') return content;
170
+ if (!Array.isArray(content)) return '';
171
+ return content
172
+ .map((part) =>
173
+ isRecord(part) && part['type'] === 'text' && typeof part['text'] === 'string'
174
+ ? part['text']
175
+ : '',
176
+ )
177
+ .join('');
178
+ }
179
+
180
+ function renderFusionResultText(
181
+ mergedText: string,
182
+ details: FusionResultDetails,
183
+ options: ToolRenderResultOptions,
184
+ theme: Theme,
185
+ ) {
186
+ if (options.expanded) {
187
+ const container = new Container();
188
+ container.addChild(
189
+ new Text(
190
+ `${theme.fg('success', '✓ fusion complete')} ${theme.fg('dim', details.run_id)}\n${theme.fg('dim', `Artifacts: ${details.artifact_dir} · ${usageSummary(details)}`)}`,
191
+ 0,
192
+ 0,
193
+ ),
194
+ );
195
+ container.addChild(new Markdown(mergedText, 0, 0, getMarkdownTheme()));
196
+ return container;
197
+ }
198
+ const preview = mergedText.replace(/\s+/g, ' ').trim();
199
+ return new Text(
200
+ `${theme.fg('success', '✓ fusion')} ${theme.fg('dim', details.run_id)} ${theme.fg('muted', usageSummary(details))}\n${preview}`,
201
+ 0,
202
+ 0,
203
+ );
204
+ }
205
+
206
+ function renderProgressResult(details: FusionProgressDetails, theme: Theme) {
207
+ return new Text(theme.fg('warning', details.status), 0, 0);
208
+ }
209
+
210
+ function isFusionResultDetails(value: unknown): value is FusionResultDetails {
211
+ if (!isRecord(value)) return false;
212
+ return (
213
+ value['schema_version'] === FUSION_RESULT_SCHEMA_VERSION &&
214
+ typeof value['run_id'] === 'string' &&
215
+ (value['source'] === 'command' || value['source'] === 'tool') &&
216
+ value['status'] === 'completed' &&
217
+ typeof value['artifact_dir'] === 'string' &&
218
+ isRecord(value['models']) &&
219
+ typeof value['evaluator_attempts'] === 'number' &&
220
+ isRecord(value['usage'])
221
+ );
222
+ }
223
+
224
+ function isFusionProgressDetails(value: unknown): value is FusionProgressDetails {
225
+ return (
226
+ isRecord(value) &&
227
+ value['schema_version'] === FUSION_PROGRESS_SCHEMA_VERSION &&
228
+ typeof value['status'] === 'string'
229
+ );
230
+ }
231
+
232
+ function choicesForSelector(
233
+ ctx: ExtensionContext,
234
+ config: FusionModelConfigV1,
235
+ ): FusionModelChoice[] {
236
+ const choices: FusionModelChoice[] = [];
237
+ const current = ctx.model === undefined ? undefined : qualifiedModelKey(ctx.model);
238
+ choices.push({
239
+ value: CURRENT_MODEL_SELECTION,
240
+ label: CURRENT_MODEL_SELECTION,
241
+ description: current === undefined ? 'no current model selected' : `currently ${current}`,
242
+ available: current !== undefined,
243
+ });
244
+ const seen = new Set<FusionModelSelection>([CURRENT_MODEL_SELECTION]);
245
+ const available = ctx.modelRegistry
246
+ .getAvailable()
247
+ .map((model) => ({ key: qualifiedModelKey(model), name: model.name }))
248
+ .sort((left, right) => left.key.localeCompare(right.key));
249
+ for (const model of available) {
250
+ if (seen.has(model.key)) continue;
251
+ seen.add(model.key);
252
+ choices.push({ value: model.key, label: model.key, description: model.name, available: true });
253
+ }
254
+ for (const selection of [...config.candidates, config.evaluator, config.merger]) {
255
+ if (seen.has(selection)) continue;
256
+ seen.add(selection);
257
+ choices.push({
258
+ value: selection,
259
+ label: selection,
260
+ description: 'configured but not currently available',
261
+ available: false,
262
+ });
263
+ }
264
+ return choices;
265
+ }
266
+
267
+ function normalizeToolPrompt(value: unknown): string {
268
+ if (typeof value !== 'string') throw new Error('fusion_brainstorm requires prompt string');
269
+ const prompt = value.trim();
270
+ if (prompt.length === 0) throw new Error('fusion_brainstorm prompt must not be blank');
271
+ return prompt;
272
+ }
273
+
274
+ function prepareFusionArguments(args: unknown): FusionBrainstormParamsValue {
275
+ if (!isRecord(args)) throw new Error('fusion_brainstorm arguments must be an object');
276
+ const keys = Object.keys(args);
277
+ if (keys.length !== 1 || keys[0] !== 'prompt') {
278
+ throw new Error('fusion_brainstorm arguments must contain only prompt');
279
+ }
280
+ return { prompt: normalizeToolPrompt(args['prompt']) };
281
+ }
282
+
283
+ function linkSignal(source: AbortSignal | undefined, target: AbortController): () => void {
284
+ if (source === undefined) return () => undefined;
285
+ if (source.aborted) {
286
+ target.abort();
287
+ return () => undefined;
288
+ }
289
+ const listener = () => {
290
+ target.abort();
291
+ };
292
+ source.addEventListener('abort', listener, { once: true });
293
+ return () => {
294
+ source.removeEventListener('abort', listener);
295
+ };
296
+ }
297
+
298
+ export function registerFusionExtension(pi: ExtensionAPI): void {
299
+ const orchestrator = new FusionOrchestrator();
300
+ const activeRuns = new Set<ActiveFusionRun>();
301
+ let shuttingDown = false;
302
+ let lifecycleGeneration = 0;
303
+
304
+ async function runFusion(request: FusionRunRequest): Promise<FusionRunResult> {
305
+ if (shuttingDown) throw new Error('fusion extension is shutting down');
306
+ const generation = lifecycleGeneration;
307
+ const controller = new AbortController();
308
+ let resolveSettled: () => void = () => undefined;
309
+ const settled = new Promise<void>((resolve) => {
310
+ resolveSettled = resolve;
311
+ });
312
+ const active: ActiveFusionRun = { controller, settled };
313
+ activeRuns.add(active);
314
+ const unlink = linkSignal(request.signal, controller);
315
+ const assertActive = () => {
316
+ if (controller.signal.aborted)
317
+ throw new FusionError('fusion run cancelled before child launch', {
318
+ code: 'child_cancelled',
319
+ childCreated: false,
320
+ });
321
+ if (shuttingDown || lifecycleGeneration !== generation)
322
+ throw new Error('fusion extension is shutting down');
323
+ };
324
+ try {
325
+ assertActive();
326
+ const contextOptions =
327
+ request.toolCallId === undefined
328
+ ? {
329
+ source: request.source,
330
+ request: request.request,
331
+ toolName: FUSION_BRAINSTORM_TOOL_NAME,
332
+ }
333
+ : {
334
+ source: request.source,
335
+ request: request.request,
336
+ toolCallId: request.toolCallId,
337
+ toolName: FUSION_BRAINSTORM_TOOL_NAME,
338
+ };
339
+ const built = buildFusionCanonicalInput(request.ctx, contextOptions);
340
+ const cwd = request.ctx.cwd;
341
+ const sessionId = request.ctx.sessionManager.getSessionId();
342
+ const modelRegistry = request.ctx.modelRegistry;
343
+ const currentModel = request.ctx.model;
344
+ const thinkingLevel = pi.getThinkingLevel();
345
+ const loaded = await loadFusionModelConfig();
346
+ assertActive();
347
+ const models = resolveFusionModels({
348
+ config: loaded.config,
349
+ modelRegistry,
350
+ currentModel,
351
+ thinkingLevel,
352
+ });
353
+ assertActive();
354
+ return await orchestrator.run({
355
+ source: request.source,
356
+ cwd,
357
+ sessionId,
358
+ canonicalInput: built.input,
359
+ canonicalInputSerialized: built.serialized,
360
+ config: loaded.config,
361
+ models,
362
+ signal: controller.signal,
363
+ onProgress: request.onProgress,
364
+ });
365
+ } finally {
366
+ unlink();
367
+ activeRuns.delete(active);
368
+ resolveSettled();
369
+ }
370
+ }
371
+
372
+ function publishCommandResult(piRequest: string, result: FusionRunResult): void {
373
+ const requestDetails: FusionRequestDetails = {
374
+ schema_version: FUSION_REQUEST_SCHEMA_VERSION,
375
+ run_id: result.details.run_id,
376
+ source: 'command',
377
+ };
378
+ pi.sendMessage(
379
+ {
380
+ customType: FUSION_REQUEST_MESSAGE_TYPE,
381
+ content: piRequest,
382
+ display: false,
383
+ details: requestDetails,
384
+ },
385
+ { triggerTurn: false },
386
+ );
387
+ pi.sendMessage(
388
+ {
389
+ customType: FUSION_RESULT_MESSAGE_TYPE,
390
+ content: result.mergedText,
391
+ display: true,
392
+ details: result.details,
393
+ },
394
+ { triggerTurn: false },
395
+ );
396
+ }
397
+
398
+ async function promptFromCommandArgs(
399
+ args: string,
400
+ ctx: ExtensionCommandContext,
401
+ ): Promise<string | undefined> {
402
+ const direct = normalizeFusionCommandRequest(args);
403
+ if (direct.length > 0) return direct;
404
+ if (!ctx.hasUI) throw new Error(FUSION_COMMAND_USAGE);
405
+ const edited = await ctx.ui.editor('Fusion prompt', '');
406
+ if (edited === undefined) return undefined;
407
+ const prompt = edited.trim();
408
+ return prompt.length > 0 ? prompt : undefined;
409
+ }
410
+
411
+ function commandProgress(ctx: ExtensionCommandContext): (event: FusionProgressEvent) => void {
412
+ return (event) => {
413
+ if (ctx.hasUI) ctx.ui.setStatus(FUSION_STATUS_KEY, progressText(event));
414
+ };
415
+ }
416
+
417
+ async function runCommandWithoutLoader(
418
+ ctx: ExtensionCommandContext,
419
+ request: string,
420
+ onProgress: (event: FusionProgressEvent) => void,
421
+ ): Promise<FusionRunResult> {
422
+ return runFusion({ source: 'command', ctx, request, onProgress });
423
+ }
424
+
425
+ async function runCommandWithLoader(
426
+ ctx: ExtensionCommandContext,
427
+ request: string,
428
+ onProgress: (event: FusionProgressEvent) => void,
429
+ ): Promise<FusionRunResult> {
430
+ if (!ctx.hasUI || !isTuiContext(ctx)) return runCommandWithoutLoader(ctx, request, onProgress);
431
+ const dialog = await ctx.ui.custom<CommandDialogResult>(
432
+ (tui, theme, _keybindings, done) => {
433
+ const controller = new AbortController();
434
+ const loader = new BorderedLoader(tui, theme, 'Fusion is running…', { cancellable: true });
435
+ loader.onAbort = () => {
436
+ controller.abort();
437
+ };
438
+ void runFusion({ source: 'command', ctx, request, signal: controller.signal, onProgress })
439
+ .then((result) => {
440
+ done({ type: 'completed', result });
441
+ })
442
+ .catch((error: unknown) => {
443
+ done({ type: 'failed', error });
444
+ });
445
+ return loader;
446
+ },
447
+ {
448
+ overlay: true,
449
+ overlayOptions: {
450
+ anchor: 'center',
451
+ width: '70%',
452
+ minWidth: 48,
453
+ maxHeight: '40%',
454
+ },
455
+ },
456
+ );
457
+ if (dialog.type === 'completed') return dialog.result;
458
+ throw dialog.error;
459
+ }
460
+
461
+ pi.registerMessageRenderer<FusionResultDetails>(
462
+ FUSION_RESULT_MESSAGE_TYPE,
463
+ (message, options, theme) => {
464
+ if (!isFusionResultDetails(message.details)) {
465
+ return new Text(theme.fg('error', 'Invalid fusion result details'), 0, 0);
466
+ }
467
+ return renderFusionResultText(
468
+ extractMessageText(message.content),
469
+ message.details,
470
+ { expanded: options.expanded, isPartial: false },
471
+ theme,
472
+ );
473
+ },
474
+ );
475
+
476
+ pi.registerCommand('fusion', {
477
+ description: 'Run a five-model fusion workflow and append the merged result directly.',
478
+ handler: async (args, ctx) => {
479
+ let request: string | undefined;
480
+ try {
481
+ request = await promptFromCommandArgs(args, ctx);
482
+ if (request === undefined) return;
483
+ await ctx.waitForIdle();
484
+ const onProgress = commandProgress(ctx);
485
+ if (ctx.hasUI) ctx.ui.setStatus(FUSION_STATUS_KEY, 'fusion: starting');
486
+ const result = await runCommandWithLoader(ctx, request, onProgress);
487
+ publishCommandResult(request, result);
488
+ } catch (error) {
489
+ const message = `Fusion failed: ${errorMessage(error)}${errorArtifactSuffix(error)}`;
490
+ if (!ctx.hasUI) throw new Error(message);
491
+ ctx.ui.notify(message, 'error');
492
+ } finally {
493
+ if (ctx.hasUI) ctx.ui.setStatus(FUSION_STATUS_KEY, undefined);
494
+ }
495
+ },
496
+ });
497
+
498
+ pi.registerCommand(FUSION_MODEL_COMMAND_NAME, {
499
+ description: 'Open the five-slot global fusion model selector.',
500
+ handler: async (_args, ctx) => {
501
+ const modeError =
502
+ '/fusion-models requires Pi TUI mode; it is unavailable in RPC, JSON, and print modes.';
503
+ if (!ctx.hasUI) throw new Error(modeError);
504
+ if (!isTuiContext(ctx)) {
505
+ ctx.ui.notify(modeError, 'error');
506
+ return;
507
+ }
508
+ const path = fusionModelConfigPath();
509
+ let loaded: Awaited<ReturnType<typeof loadFusionModelConfig>>;
510
+ try {
511
+ loaded = await loadFusionModelConfig(path);
512
+ } catch (error) {
513
+ ctx.ui.notify(`Cannot open ${path}: ${errorMessage(error)}`, 'error');
514
+ return;
515
+ }
516
+ const choices = choicesForSelector(ctx, loaded.config);
517
+ const result = await ctx.ui.custom<FusionModelSelectorResult>(
518
+ (tui, theme, _keybindings, done) =>
519
+ new FusionModelSelector({
520
+ initialConfig: loaded.config,
521
+ choices,
522
+ theme,
523
+ onSave: async (config) => {
524
+ await saveFusionModelConfig(path, config, loaded.revision);
525
+ },
526
+ onDone: done,
527
+ onRenderRequest: () => {
528
+ tui.requestRender();
529
+ },
530
+ }),
531
+ {
532
+ overlay: true,
533
+ overlayOptions: {
534
+ anchor: 'center',
535
+ width: '82%',
536
+ minWidth: 64,
537
+ maxHeight: '75%',
538
+ },
539
+ },
540
+ );
541
+ if (result.type === 'saved')
542
+ ctx.ui.notify(`Saved fusion model configuration to ${path}`, 'info');
543
+ },
544
+ });
545
+
546
+ pi.registerTool<typeof FusionBrainstormParams, FusionToolDetails>({
547
+ name: FUSION_BRAINSTORM_TOOL_NAME,
548
+ label: 'Fusion Brainstorm',
549
+ description: 'Run a five-model fusion workflow for a prompt and return the merged answer.',
550
+ promptSnippet:
551
+ 'Use fusion_brainstorm to get a merged answer from the five-model fusion workflow',
552
+ promptGuidelines: [
553
+ 'fusion_brainstorm is always available; call fusion_brainstorm({prompt}) whenever a merged multi-model answer would help.',
554
+ 'fusion_brainstorm has no eligibility, quota, routine, or justification gate; provide only the prompt string.',
555
+ ],
556
+ parameters: FusionBrainstormParams,
557
+ prepareArguments: prepareFusionArguments,
558
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
559
+ const prompt = normalizeToolPrompt(params.prompt);
560
+ let result: FusionRunResult;
561
+ try {
562
+ result = await runFusion({
563
+ source: 'tool',
564
+ ctx,
565
+ request: prompt,
566
+ signal,
567
+ toolCallId,
568
+ onProgress: (event) => {
569
+ onUpdate?.({
570
+ content: textContent(progressText(event)),
571
+ details: makeProgressDetails(event),
572
+ });
573
+ },
574
+ });
575
+ } catch (error) {
576
+ throw new Error(toolFailureMessage(error), { cause: error });
577
+ }
578
+ const toolResult: FusionToolResultWithUsage = {
579
+ content: textContent(result.mergedText),
580
+ details: result.details,
581
+ usage: result.details.usage,
582
+ };
583
+ return toolResult;
584
+ },
585
+ renderCall(args, theme) {
586
+ const preview = args.prompt.replace(/\s+/g, ' ').trim();
587
+ return new Text(
588
+ `${theme.fg('toolTitle', theme.bold('fusion_brainstorm '))}${theme.fg('muted', preview)}`,
589
+ 0,
590
+ 0,
591
+ );
592
+ },
593
+ renderResult(result, options, theme) {
594
+ if (isFusionProgressDetails(result.details))
595
+ return renderProgressResult(result.details, theme);
596
+ if (!isFusionResultDetails(result.details))
597
+ return new Text(theme.fg('error', 'Invalid fusion tool details'), 0, 0);
598
+ const mergedText = result.content
599
+ .map((part) => (part.type === 'text' ? part.text : ''))
600
+ .join('\n');
601
+ return renderFusionResultText(mergedText, result.details, options, theme);
602
+ },
603
+ });
604
+
605
+ pi.on('session_start', () => {
606
+ shuttingDown = false;
607
+ lifecycleGeneration += 1;
608
+ const active = pi.getActiveTools();
609
+ if (!active.includes(FUSION_BRAINSTORM_TOOL_NAME)) {
610
+ pi.setActiveTools([...active, FUSION_BRAINSTORM_TOOL_NAME]);
611
+ }
612
+ });
613
+
614
+ pi.on('session_shutdown', async (_event, ctx) => {
615
+ shuttingDown = true;
616
+ lifecycleGeneration += 1;
617
+ const runs = [...activeRuns];
618
+ for (const run of runs) run.controller.abort();
619
+ const settled = await Promise.allSettled(runs.map((run) => run.settled));
620
+ const failures = settled.flatMap((result) =>
621
+ result.status === 'rejected' ? [errorMessage(result.reason)] : [],
622
+ );
623
+ activeRuns.clear();
624
+ if (failures.length > 0) {
625
+ const message = `Fusion shutdown cleanup failed:\n${failures.join('\n')}`;
626
+ console.error(`[fusion] ${message}`);
627
+ if (ctx.hasUI) ctx.ui.notify(message, 'error');
628
+ }
629
+ });
630
+ }
631
+
632
+ export default registerFusionExtension;
@@ -1,3 +1,22 @@
1
- export const isolatedTestEnv = { PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0", CI: "1" } as const;
2
- export function stripAnsi(value: string): string { return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ""); }
3
- export function normalizeVolatile(value: string): string { return value.replace(/b[0-9a-f]{8}/g,"<TASK_ID>").replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi,"<UUID>").replace(/pid=?\s*\d+/gi,"pid=<PID>").replace(/\.pi\/tasks\/[^\s)]+/g,".pi/tasks/<RUN>/<FILE>").replace(/\/tmp\/[^\s)]+/g,"/tmp/<TEMP>"); }
1
+ export const isolatedTestEnv = {
2
+ PI_OFFLINE: '1',
3
+ PI_SKIP_VERSION_CHECK: '1',
4
+ PI_TELEMETRY: '0',
5
+ CI: '1',
6
+ } as const;
7
+
8
+ const ESCAPE = String.fromCharCode(27);
9
+ const ANSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-?]*[ -/]*[@-~]`, 'g');
10
+
11
+ export function stripAnsi(value: string): string {
12
+ return value.replace(ANSI_PATTERN, '');
13
+ }
14
+
15
+ export function normalizeVolatile(value: string): string {
16
+ return value
17
+ .replace(/b[0-9a-f]{8}/g, '<TASK_ID>')
18
+ .replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '<UUID>')
19
+ .replace(/pid=?\s*\d+/gi, 'pid=<PID>')
20
+ .replace(/\.pi\/tasks\/[^\s)]+/g, '.pi/tasks/<RUN>/<FILE>')
21
+ .replace(/\/tmp\/[^\s)]+/g, '/tmp/<TEMP>');
22
+ }