pi-background-tasks 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +4 -4
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +23 -11
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +293 -113
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -8,15 +8,10 @@ import type {
8
8
  import { getMarkdownTheme } from '@earendil-works/pi-coding-agent';
9
9
  import { Container, Markdown, Text } from '@earendil-works/pi-tui';
10
10
  import { Type, type TSchema } from 'typebox';
11
- import {
12
- CURRENT_MODEL_SELECTION,
13
- fusionModelConfigPath,
14
- loadFusionModelConfig,
15
- resolveFusionModels,
16
- saveFusionModelConfig,
17
- } from './core/fusion/config.js';
18
- import * as FusionContextModule from './core/fusion/context.js';
19
- import type { BuiltFusionCanonicalInput } from './core/fusion/context.js';
11
+ import type {
12
+ BuildFusionCanonicalInputOptions,
13
+ BuiltFusionCanonicalInput,
14
+ } from './core/fusion/context.js';
20
15
  import {
21
16
  FUSION_INVESTIGATE,
22
17
  FUSION_REASON,
@@ -24,12 +19,9 @@ import {
24
19
  FUSION_VALIDATE,
25
20
  type FusionWorkflowProfile,
26
21
  } from './core/fusion/workflows.js';
27
- import {
28
- buildCleanFusionCanonicalInput,
29
- type BuiltFusionCleanTaskCanonicalInput,
30
- } from './core/fusion/clean-context.js';
22
+ import type { BuiltFusionCleanTaskCanonicalInput } from './core/fusion/clean-context.js';
31
23
  import { canonicalizeFusionPublicUrl } from './core/fusion/source-policy.js';
32
- import { canonicalJson } from './core/attested-pi-run.js';
24
+ import { canonicalJson } from './core/canonical-json.js';
33
25
  import type {
34
26
  BgTask,
35
27
  BgTaskSnapshot,
@@ -37,7 +29,7 @@ import type {
37
29
  JsonObject,
38
30
  StartManagedTaskOptions,
39
31
  } from './core/common.js';
40
- import { FusionOrchestrator } from './core/fusion/orchestrator.js';
32
+ import type { FusionOrchestrator } from './core/fusion/orchestrator.js';
41
33
  import {
42
34
  FUSION_LEGACY_RESULT_SCHEMA_VERSION,
43
35
  FUSION_RESULT_SCHEMA_VERSION,
@@ -49,11 +41,9 @@ import {
49
41
  type FusionResultDetails,
50
42
  type FusionRunResult,
51
43
  } from './core/fusion/types.js';
52
- import {
53
- FusionModelSelector,
54
- type FusionModelChoice,
55
- type FusionModelSelectorResult,
56
- } from './ui/fusion-model-selector.js';
44
+ import type { FusionModelChoice, FusionModelSelectorResult } from './ui/fusion-model-selector.js';
45
+ import { CURRENT_MODEL_SELECTION } from './core/fusion/facade-contract.js';
46
+ import { LazyModule, SynchronousActivationCloseFence } from './core/lazy-module.js';
57
47
 
58
48
  const FUSION_RESULT_MESSAGE_TYPE = 'fusion-result';
59
49
  const FUSION_PROGRESS_SCHEMA_VERSION = 'pi-background-tasks.fusion-progress.v1';
@@ -94,10 +84,63 @@ interface ActiveFusionRun {
94
84
  settled: Promise<void>;
95
85
  }
96
86
 
87
+ export interface FusionExecutionRuntime {
88
+ buildFusionCanonicalInput: typeof import('./core/fusion/context.js').buildFusionCanonicalInput;
89
+ buildCleanFusionCanonicalInput: typeof import('./core/fusion/clean-context.js').buildCleanFusionCanonicalInput;
90
+ loadFusionModelConfig: typeof import('./core/fusion/config.js').loadFusionModelConfig;
91
+ resolveFusionModels: typeof import('./core/fusion/config.js').resolveFusionModels;
92
+ orchestrator: Pick<FusionOrchestrator, 'run'>;
93
+ }
94
+
95
+ export interface FusionModelSelectorRuntime {
96
+ fusionModelConfigPath: typeof import('./core/fusion/config.js').fusionModelConfigPath;
97
+ loadFusionModelConfig: typeof import('./core/fusion/config.js').loadFusionModelConfig;
98
+ saveFusionModelConfig: typeof import('./core/fusion/config.js').saveFusionModelConfig;
99
+ createSelector: (
100
+ options: ConstructorParameters<
101
+ typeof import('./ui/fusion-model-selector.js').FusionModelSelector
102
+ >[0],
103
+ ) => InstanceType<typeof import('./ui/fusion-model-selector.js').FusionModelSelector>;
104
+ }
105
+
106
+ async function importFusionExecutionRuntime(): Promise<FusionExecutionRuntime> {
107
+ const [context, cleanContext, config, orchestratorModule] = await Promise.all([
108
+ import('./core/fusion/context.js'),
109
+ import('./core/fusion/clean-context.js'),
110
+ import('./core/fusion/config.js'),
111
+ import('./core/fusion/orchestrator.js'),
112
+ ]);
113
+ return {
114
+ buildFusionCanonicalInput: context.buildFusionCanonicalInput,
115
+ buildCleanFusionCanonicalInput: cleanContext.buildCleanFusionCanonicalInput,
116
+ loadFusionModelConfig: config.loadFusionModelConfig,
117
+ resolveFusionModels: config.resolveFusionModels,
118
+ orchestrator: new orchestratorModule.FusionOrchestrator(),
119
+ };
120
+ }
121
+
122
+ async function importFusionModelSelectorRuntime(): Promise<FusionModelSelectorRuntime> {
123
+ const [config, selector] = await Promise.all([
124
+ import('./core/fusion/config.js'),
125
+ import('./ui/fusion-model-selector.js'),
126
+ ]);
127
+ return {
128
+ fusionModelConfigPath: config.fusionModelConfigPath,
129
+ loadFusionModelConfig: config.loadFusionModelConfig,
130
+ saveFusionModelConfig: config.saveFusionModelConfig,
131
+ createSelector: (options) => new selector.FusionModelSelector(options),
132
+ };
133
+ }
134
+
97
135
  export interface FusionExtensionDependencies {
98
136
  startManagedTask: (ctx: ExtensionContext, options: StartManagedTaskOptions) => Promise<BgTask>;
99
137
  snapshot: (task: BgTask) => BgTaskSnapshot;
100
138
  updateManagedTask: (task: BgTask, state: string, line?: string) => Promise<void>;
139
+ /** Activation-local fence installed synchronously before asynchronous cleanup. */
140
+ activationCloseFence: SynchronousActivationCloseFence;
141
+ /** Internal deterministic deferred-import seams. */
142
+ loadExecutionRuntime?: (() => Promise<FusionExecutionRuntime>) | undefined;
143
+ loadModelSelectorRuntime?: (() => Promise<FusionModelSelectorRuntime>) | undefined;
101
144
  }
102
145
 
103
146
  interface FusionRunRequest {
@@ -720,18 +763,21 @@ function declaredSourcesForRequest(
720
763
  return 'sources' in request ? request.sources : [];
721
764
  }
722
765
 
723
- function buildFusionInput(request: FusionRunRequest): BuiltFusionWorkflowInput {
766
+ function buildFusionInput(
767
+ runtime: FusionExecutionRuntime,
768
+ request: FusionRunRequest,
769
+ ): BuiltFusionWorkflowInput {
724
770
  if (request.profile.contextKind === 'session_projection') {
725
- const options: FusionContextModule.BuildFusionCanonicalInputOptions = {
771
+ const options: BuildFusionCanonicalInputOptions = {
726
772
  source: request.source,
727
773
  request: serializePublicRequest(request.request),
728
774
  workflow: request.profile.id,
729
775
  toolName: request.toolName,
730
776
  };
731
777
  if (request.toolCallId !== undefined) options.toolCallId = request.toolCallId;
732
- return FusionContextModule.buildFusionCanonicalInput(request.ctx, options);
778
+ return runtime.buildFusionCanonicalInput(request.ctx, options);
733
779
  }
734
- return buildCleanFusionCanonicalInput({
780
+ return runtime.buildCleanFusionCanonicalInput({
735
781
  cwd: request.ctx.cwd,
736
782
  source: request.source,
737
783
  request: serializePublicRequest(request.request),
@@ -759,17 +805,46 @@ function renderToolCall(name: string, preview: string, theme: Theme) {
759
805
  }
760
806
 
761
807
  export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionDependencies): void {
762
- const orchestrator = new FusionOrchestrator();
808
+ const executionRuntime = new LazyModule(
809
+ 'fusion-execution',
810
+ deps.loadExecutionRuntime ?? importFusionExecutionRuntime,
811
+ );
812
+ const modelSelectorRuntime = new LazyModule(
813
+ 'fusion-model-selector',
814
+ deps.loadModelSelectorRuntime ?? importFusionModelSelectorRuntime,
815
+ );
763
816
  const activeRuns = new Set<ActiveFusionRun>();
764
817
  let shuttingDown = false;
765
818
  let lifecycleGeneration = 0;
819
+ let shutdownCleanupPromise: Promise<PromiseSettledResult<void>[]> | undefined;
820
+ const closedActivationError = new Error(
821
+ 'lazy_module_closed: Fusion facade belongs to a closed activation (Pi session shutdown)',
822
+ );
823
+ const assertFusionActive = (): void => {
824
+ if (shuttingDown) throw closedActivationError;
825
+ };
826
+
827
+ const beginFusionShutdown = (): void => {
828
+ if (shuttingDown) return;
829
+ // Closure, generation advance, and cancellation all happen before any
830
+ // shutdown handler awaits, including handlers registered by other facades.
831
+ shuttingDown = true;
832
+ lifecycleGeneration += 1;
833
+ executionRuntime.close('Pi session shutdown');
834
+ modelSelectorRuntime.close('Pi session shutdown');
835
+ const runs = [...activeRuns];
836
+ for (const run of runs) run.controller.abort();
837
+ shutdownCleanupPromise = Promise.allSettled(runs.map((run) => run.settled));
838
+ };
839
+
840
+ deps.activationCloseFence.add(beginFusionShutdown);
766
841
 
767
842
  async function runFusion(
768
843
  request: FusionRunRequest,
769
844
  suppliedController?: AbortController,
770
845
  onReady?: Parameters<FusionOrchestrator['run']>[0]['onReady'],
771
846
  ): Promise<FusionRunResult> {
772
- if (shuttingDown) throw new Error('fusion extension is shutting down');
847
+ assertFusionActive();
773
848
  const generation = lifecycleGeneration;
774
849
  const controller = suppliedController ?? new AbortController();
775
850
  let resolveSettled: () => void = () => undefined;
@@ -787,39 +862,51 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
787
862
  });
788
863
  if (shuttingDown || lifecycleGeneration !== generation)
789
864
  throw new Error('fusion extension is shutting down');
865
+ executionRuntime.assertOpen();
790
866
  };
791
867
  try {
792
- assertActive();
793
- const built = buildFusionInput(request);
794
- const cwd = request.ctx.cwd;
795
- const sessionId = request.ctx.sessionManager.getSessionId();
796
- const modelRegistry = request.ctx.modelRegistry;
797
- const currentModel = request.ctx.model;
798
- const thinkingLevel = pi.getThinkingLevel();
799
- const loaded = await loadFusionModelConfig();
800
- assertActive();
801
- const models = resolveFusionModels({
802
- config: loaded.config,
803
- modelRegistry,
804
- currentModel,
805
- thinkingLevel,
868
+ return await executionRuntime.run(async (runtime) => {
869
+ assertActive();
870
+ const built = buildFusionInput(runtime, request);
871
+ const cwd = request.ctx.cwd;
872
+ const sessionId = request.ctx.sessionManager.getSessionId();
873
+ const modelRegistry = request.ctx.modelRegistry;
874
+ const currentModel = request.ctx.model;
875
+ const thinkingLevel = pi.getThinkingLevel();
876
+ const loadedConfig = await runtime.loadFusionModelConfig();
877
+ assertActive();
878
+ const models = runtime.resolveFusionModels({
879
+ config: loadedConfig.config,
880
+ modelRegistry,
881
+ currentModel,
882
+ thinkingLevel,
883
+ });
884
+ assertActive();
885
+ const guardedOnReady =
886
+ onReady === undefined
887
+ ? undefined
888
+ : async (ready: Parameters<NonNullable<typeof onReady>>[0]) => {
889
+ assertActive();
890
+ await onReady(ready);
891
+ assertActive();
892
+ };
893
+ const runInput: Parameters<FusionOrchestrator['run']>[0] = {
894
+ source: request.source,
895
+ cwd,
896
+ sessionId,
897
+ canonicalInput: built.input,
898
+ canonicalInputSerialized: built.serialized,
899
+ config: loadedConfig.config,
900
+ models,
901
+ profile: request.profile,
902
+ signal: controller.signal,
903
+ onProgress: request.onProgress,
904
+ onReady: guardedOnReady,
905
+ };
906
+ if ('ledger' in built) runInput.contextLedger = built.ledger;
907
+ assertActive();
908
+ return runtime.orchestrator.run(runInput);
806
909
  });
807
- assertActive();
808
- const runInput: Parameters<FusionOrchestrator['run']>[0] = {
809
- source: request.source,
810
- cwd,
811
- sessionId,
812
- canonicalInput: built.input,
813
- canonicalInputSerialized: built.serialized,
814
- config: loaded.config,
815
- models,
816
- profile: request.profile,
817
- signal: controller.signal,
818
- onProgress: request.onProgress,
819
- onReady,
820
- };
821
- if ('ledger' in built) runInput.contextLedger = built.ledger;
822
- return await orchestrator.run(runInput);
823
910
  } finally {
824
911
  unlink();
825
912
  activeRuns.delete(active);
@@ -968,7 +1055,7 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
968
1055
  args: string,
969
1056
  ctx: ExtensionCommandContext,
970
1057
  ): Promise<string | undefined> {
971
- const direct = FusionContextModule.normalizeFusionCommandRequest(args);
1058
+ const direct = args.trim();
972
1059
  if (direct.length > 0) return direct;
973
1060
  if (!ctx.hasUI) throw new Error(FUSION_COMMAND_USAGE);
974
1061
  const edited = await ctx.ui.editor('Fusion prompt', '');
@@ -998,10 +1085,13 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
998
1085
  handler: async (args, ctx) => {
999
1086
  let requestText: string | undefined;
1000
1087
  try {
1088
+ assertFusionActive();
1001
1089
  requestText = await promptFromCommandArgs(args, ctx);
1090
+ assertFusionActive();
1002
1091
  if (requestText === undefined) return;
1003
1092
  const request = prepareFusionReasonArguments({ prompt: requestText });
1004
1093
  await ctx.waitForIdle();
1094
+ assertFusionActive();
1005
1095
  const task = await launchFusionTask({
1006
1096
  source: 'command',
1007
1097
  ctx,
@@ -1012,6 +1102,7 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
1012
1102
  const fusion = task.fusion;
1013
1103
  if (fusion === undefined)
1014
1104
  throw new Error('Fusion command task was registered without Fusion facts');
1105
+ assertFusionActive();
1015
1106
  if (ctx.hasUI) {
1016
1107
  ctx.ui.notify(
1017
1108
  `Started fusion reason (${task.id})\nArtifacts: ${fusion.artifactDir}\nIt will notify on completion; retrieve with bg_result.`,
@@ -1019,6 +1110,9 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
1019
1110
  );
1020
1111
  }
1021
1112
  } catch (error) {
1113
+ // Closure wins over command error rendering: never inspect or notify an
1114
+ // old host context after the activation fence has fired.
1115
+ assertFusionActive();
1022
1116
  const message = `Fusion failed: ${errorMessage(error)}${errorArtifactSuffix(error)}`;
1023
1117
  if (!ctx.hasUI) throw new Error(message);
1024
1118
  ctx.ui.notify(message, 'error');
@@ -1029,6 +1123,7 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
1029
1123
  pi.registerCommand(FUSION_MODEL_COMMAND_NAME, {
1030
1124
  description: 'Open the five-slot global fusion model selector.',
1031
1125
  handler: async (_args, ctx) => {
1126
+ assertFusionActive();
1032
1127
  const modeError =
1033
1128
  '/fusion-models requires Pi TUI mode; it is unavailable in RPC, JSON, and print modes.';
1034
1129
  if (!ctx.hasUI) throw new Error(modeError);
@@ -1036,41 +1131,54 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
1036
1131
  ctx.ui.notify(modeError, 'error');
1037
1132
  return;
1038
1133
  }
1039
- const path = fusionModelConfigPath();
1040
- let loaded: Awaited<ReturnType<typeof loadFusionModelConfig>>;
1041
- try {
1042
- loaded = await loadFusionModelConfig(path);
1043
- } catch (error) {
1044
- ctx.ui.notify(`Cannot open ${path}: ${errorMessage(error)}`, 'error');
1045
- return;
1046
- }
1047
- const choices = choicesForSelector(ctx, loaded.config);
1048
- const result = await ctx.ui.custom<FusionModelSelectorResult>(
1049
- (tui, theme, _keybindings, done) =>
1050
- new FusionModelSelector({
1051
- initialConfig: loaded.config,
1052
- choices,
1053
- theme,
1054
- onSave: async (config) => {
1055
- await saveFusionModelConfig(path, config, loaded.revision);
1056
- },
1057
- onDone: done,
1058
- onRenderRequest: () => {
1059
- tui.requestRender();
1134
+ await modelSelectorRuntime.run(async (runtime) => {
1135
+ modelSelectorRuntime.assertOpen();
1136
+ const path = runtime.fusionModelConfigPath();
1137
+ let loaded: Awaited<ReturnType<typeof runtime.loadFusionModelConfig>>;
1138
+ try {
1139
+ loaded = await runtime.loadFusionModelConfig(path);
1140
+ } catch (error) {
1141
+ assertFusionActive();
1142
+ modelSelectorRuntime.assertOpen();
1143
+ ctx.ui.notify(`Cannot open ${path}: ${errorMessage(error)}`, 'error');
1144
+ return;
1145
+ }
1146
+ assertFusionActive();
1147
+ modelSelectorRuntime.assertOpen();
1148
+ const choices = choicesForSelector(ctx, loaded.config);
1149
+ const result = await ctx.ui.custom<FusionModelSelectorResult>(
1150
+ (tui, theme, _keybindings, done) =>
1151
+ runtime.createSelector({
1152
+ initialConfig: loaded.config,
1153
+ choices,
1154
+ theme,
1155
+ onSave: async (config) => {
1156
+ assertFusionActive();
1157
+ modelSelectorRuntime.assertOpen();
1158
+ await runtime.saveFusionModelConfig(path, config, loaded.revision);
1159
+ assertFusionActive();
1160
+ modelSelectorRuntime.assertOpen();
1161
+ },
1162
+ onDone: done,
1163
+ onRenderRequest: () => {
1164
+ tui.requestRender();
1165
+ },
1166
+ }),
1167
+ {
1168
+ overlay: true,
1169
+ overlayOptions: {
1170
+ anchor: 'center',
1171
+ width: '82%',
1172
+ minWidth: 64,
1173
+ maxHeight: '75%',
1060
1174
  },
1061
- }),
1062
- {
1063
- overlay: true,
1064
- overlayOptions: {
1065
- anchor: 'center',
1066
- width: '82%',
1067
- minWidth: 64,
1068
- maxHeight: '75%',
1069
1175
  },
1070
- },
1071
- );
1072
- if (result.type === 'saved')
1073
- ctx.ui.notify(`Saved fusion model configuration to ${path}`, 'info');
1176
+ );
1177
+ assertFusionActive();
1178
+ modelSelectorRuntime.assertOpen();
1179
+ if (result.type === 'saved')
1180
+ ctx.ui.notify(`Saved fusion model configuration to ${path}`, 'info');
1181
+ });
1074
1182
  },
1075
1183
  });
1076
1184
 
@@ -1254,7 +1362,7 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
1254
1362
  });
1255
1363
 
1256
1364
  pi.on('session_start', () => {
1257
- shuttingDown = false;
1365
+ // Reload creates a fresh facade. Never reopen a closed stale activation.
1258
1366
  lifecycleGeneration += 1;
1259
1367
  const next: string[] = [];
1260
1368
  const seen = new Set<string>();
@@ -1274,11 +1382,9 @@ export function registerFusionExtension(pi: ExtensionAPI, deps: FusionExtensionD
1274
1382
  });
1275
1383
 
1276
1384
  pi.on('session_shutdown', async (_event, ctx) => {
1277
- shuttingDown = true;
1278
- lifecycleGeneration += 1;
1279
- const runs = [...activeRuns];
1280
- for (const run of runs) run.controller.abort();
1281
- const settled = await Promise.allSettled(runs.map((run) => run.settled));
1385
+ const cleanup = shutdownCleanupPromise;
1386
+ if (cleanup === undefined) return;
1387
+ const settled = await cleanup;
1282
1388
  const failures = settled.flatMap((result) =>
1283
1389
  result.status === 'rejected' ? [errorMessage(result.reason)] : [],
1284
1390
  );