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
package/src/extension.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { realpathSync } from 'node:fs';
1
3
  import type {
2
4
  ExtensionAPI,
3
5
  ExtensionCommandContext,
@@ -28,26 +30,32 @@ import {
28
30
  type BgStatusDetails,
29
31
  type BgTask,
30
32
  type BgTaskSnapshot,
33
+ type ReloadShellActivationClaimV1,
34
+ type ReloadShellActivationLeaseV1,
35
+ type ReloadShellIdentityV1,
31
36
  type StartAttestedPiTaskOptions,
32
37
  type StartTaskOptions,
38
+ ReloadSurvivalError,
39
+ rejectSurvivalForTaskKind,
33
40
  } from './core/common.js';
34
41
  import {
35
42
  fetchLatestVersion,
36
43
  readPackageInfo,
37
44
  type FetchLatestVersionOptions,
38
45
  } from './core/update-check.js';
39
- import { BackgroundTaskRegistry } from './core/registry.js';
46
+ import { BackgroundTaskRegistry, type BackgroundTaskContext } from './core/registry.js';
47
+ import {
48
+ getProcessReloadShellOwnerV1,
49
+ makeReloadShellIdentity,
50
+ } from './core/reload-shell-owner.js';
51
+ import { createShellPolicyGuidanceHandler, initializeShellPolicy } from './core/shell-policy.js';
40
52
  import {
41
53
  installBackgroundTaskExtensionApi,
42
54
  type BackgroundTaskExtensionService,
43
55
  } from './core/extension-api.js';
44
- import {
45
- BackgroundTasksManager,
46
- type BackgroundTaskForUi,
47
- type TaskManagerResult,
48
- } from './ui/background-tasks-manager.js';
49
- import { registerFusionExtension } from './fusion-extension.js';
50
- import { registerDelegateExtension } from './delegate-extension.js';
56
+ import type { BackgroundTaskForUi, TaskManagerResult } from './ui/background-tasks-manager.js';
57
+ import { dockShortcutFooterHint, parseBackgroundTasksConfig } from './core/config.js';
58
+ import { LazyModule, SynchronousActivationCloseFence } from './core/lazy-module.js';
51
59
 
52
60
  /**
53
61
  * Project-local Pi background task manager.
@@ -55,8 +63,9 @@ import { registerDelegateExtension } from './delegate-extension.js';
55
63
  * Scope:
56
64
  * - Explicit background shell jobs only: /bg and bg_run spawn commands directly.
57
65
  * - No Ctrl+B support for backgrounding an already-running built-in bash tool.
58
- * - No detached/restart reattachment: live child processes belong to this Pi
59
- * extension runtime and are killed on session shutdown/reload.
66
+ * - Opted ordinary shell jobs can hand their same live process ownership to a
67
+ * fresh extension activation on real same-process reload only.
68
+ * - No PID/file adoption, process-restart survival, or crash recovery.
60
69
  */
61
70
 
62
71
  const STATUS_INTERVAL_MS = 1000;
@@ -71,7 +80,6 @@ const PACKAGE_VERSION = packageInfo.version;
71
80
  const LIGHT_BLUE_BG = '\x1b[48;2;183;223;255m';
72
81
  const LIGHT_BLUE_FG = '\x1b[38;2;11;70;110m';
73
82
  const ANSI_RESET = '\x1b[0m';
74
-
75
83
  function lightBlue(value: string): string {
76
84
  return `${LIGHT_BLUE_BG}${LIGHT_BLUE_FG}${value}${ANSI_RESET}`;
77
85
  }
@@ -92,6 +100,7 @@ interface BgToolArgumentRecord {
92
100
  readonly timeoutSeconds?: unknown;
93
101
  readonly notifyOnCompletion?: unknown;
94
102
  readonly triggerOnCompletion?: unknown;
103
+ readonly surviveReload?: unknown;
95
104
  }
96
105
 
97
106
  interface BgPiAttestedArgumentRecord {
@@ -103,6 +112,7 @@ interface BgPiAttestedArgumentRecord {
103
112
  readonly extraPiArgs?: unknown;
104
113
  readonly thinking?: unknown;
105
114
  readonly timeoutSeconds?: unknown;
115
+ readonly surviveReload?: unknown;
106
116
  }
107
117
 
108
118
  function optionalTrimmed(value: string): string | undefined {
@@ -138,6 +148,12 @@ const BgRunParams = Type.Object({
138
148
  'Whether that notification should automatically trigger a follow-up agent turn. Default: true for bg_run; requires notifyOnCompletion.',
139
149
  }),
140
150
  ),
151
+ surviveReload: Type.Optional(
152
+ Type.Boolean({
153
+ description:
154
+ 'Opt in to retaining this exact ordinary isAgent:false shell execution across a real same-process Pi reload. Default: false. Unsupported for agent, managed, delegate, Fusion, and attested tasks.',
155
+ }),
156
+ ),
141
157
  });
142
158
 
143
159
  const BgPiAttestedParams = Type.Object({
@@ -203,13 +219,38 @@ function renderPlainResult(result: TextToolResult, options: ToolRenderResultOpti
203
219
  return new Text(text, 0, 0);
204
220
  }
205
221
 
206
- export default function backgroundTasksExtension(pi: ExtensionAPI): void {
222
+ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promise<void> {
223
+ const config = parseBackgroundTasksConfig();
224
+ const dockEntryHint = dockShortcutFooterHint(config.dockShortcut);
225
+ const shellPolicy = initializeShellPolicy();
226
+ const reloadShellOwner = getProcessReloadShellOwnerV1();
227
+ pi.on('before_agent_start', createShellPolicyGuidanceHandler(shellPolicy));
207
228
  const seenTaskIds = new Set<string>();
208
229
  let currentCtx: ExtensionContext | undefined;
230
+ let currentRegistryCtx: BackgroundTaskContext | undefined;
231
+ let activationLease: ReloadShellActivationLeaseV1 | undefined;
232
+ let activationIdentity: ReloadShellIdentityV1 | undefined;
233
+ let pendingActivationClaim: ReloadShellActivationClaimV1 | undefined;
209
234
  let dockOpen = false;
210
235
  let statusInterval: NodeJS.Timeout | undefined;
211
236
  let latestKnownVersion: string | undefined;
212
237
  let updateCheckStarted = false;
238
+ let disposed = false;
239
+ let shutdownCleanupStarted = false;
240
+ let reloadHandoffFailed = false;
241
+ let shutdownReason = 'shutdown';
242
+ const activationCloseFence = new SynchronousActivationCloseFence();
243
+ const taskManagerLoader = new LazyModule(
244
+ 'background-task-manager',
245
+ () => import('./ui/background-tasks-manager.js'),
246
+ );
247
+
248
+ const registryContext = (ctx: ExtensionContext): BackgroundTaskContext => ({
249
+ cwd: ctx.cwd,
250
+ sessionId: ctx.sessionManager.getSessionId(),
251
+ modelRegistry: ctx.modelRegistry,
252
+ model: ctx.model,
253
+ });
213
254
 
214
255
  const registry = new BackgroundTaskRegistry({
215
256
  onChange: () => {
@@ -221,33 +262,187 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
221
262
  publishTerminal: (task) => {
222
263
  eventService.publishTerminal(task);
223
264
  },
265
+ shellPolicy,
266
+ reloadShellOwner,
224
267
  });
225
268
  const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
226
269
  events: pi.events,
227
270
  registry,
228
- getContext: () => currentCtx,
271
+ getContext: () => currentRegistryCtx,
229
272
  isShuttingDown: () => registry.isShuttingDown(),
230
273
  });
231
274
 
232
- registerFusionExtension(pi, {
233
- startManagedTask: async (ctx, options) => {
234
- currentCtx = ctx;
235
- return registry.startManagedTask(ctx, options);
236
- },
237
- snapshot: (task) => registry.snapshot(task),
238
- updateManagedTask: (task, state, line) => registry.updateManagedTask(task, state, line),
275
+ const beginSessionShutdown = (reason: string): void => {
276
+ let handoffError: unknown;
277
+ if (!disposed) {
278
+ disposed = true;
279
+ registry.closeTaskAdmissions();
280
+ if (pendingActivationClaim !== undefined) {
281
+ const claim = pendingActivationClaim;
282
+ pendingActivationClaim = undefined;
283
+ registry.abortReloadActivation(claim);
284
+ try {
285
+ reloadShellOwner.abortActivation(
286
+ claim,
287
+ new ReloadSurvivalError(
288
+ 'pi_bg_reload_owner_stale_claim',
289
+ 'activation shut down while its reload claim was staging',
290
+ ),
291
+ );
292
+ } catch (error) {
293
+ handoffError = error;
294
+ }
295
+ } else if (reason === 'reload' && activationLease !== undefined) {
296
+ const lease = activationLease;
297
+ try {
298
+ registry.prepareReloadHandoff(lease);
299
+ activationLease = undefined;
300
+ activationIdentity = undefined;
301
+ } catch (error) {
302
+ reloadHandoffFailed = true;
303
+ handoffError = error;
304
+ }
305
+ }
306
+ registry.setShuttingDown(true);
307
+ eventService.close();
308
+ }
309
+ // Teardown remains active on repeated calls so even a handle assigned by a
310
+ // racing continuation is still disposed rather than hidden by idempotence.
311
+ currentCtx = undefined;
312
+ currentRegistryCtx = undefined;
313
+ if (statusInterval !== undefined) {
314
+ clearInterval(statusInterval);
315
+ statusInterval = undefined;
316
+ }
317
+ if (handoffError !== undefined) throw handoffError;
318
+ };
319
+
320
+ // Join the one synchronous all-lane barrier before any facade can register
321
+ // asynchronous cleanup. The core callback performs an eligible reload
322
+ // handoff first; every lazy lane then closes on the same call stack before
323
+ // Pi can await any later cleanup handler.
324
+ activationCloseFence.add(() => {
325
+ beginSessionShutdown(shutdownReason);
326
+ });
327
+ activationCloseFence.add(() => {
328
+ taskManagerLoader.close('session shutdown');
329
+ });
330
+ pi.on('session_shutdown', (event) => {
331
+ shutdownReason = event.reason;
332
+ activationCloseFence.close();
239
333
  });
240
334
 
241
- registerDelegateExtension(pi, {
242
- startDelegateTask: async (ctx, options) => {
335
+ // This is the first session_start callback. Claim synchronously before the
336
+ // first await, stage/import durably, then expose the fresh host adapter.
337
+ pi.on('session_start', async (event, ctx) => {
338
+ if (disposed) return;
339
+ const nextRegistryCtx = registryContext(ctx);
340
+ const identity = makeReloadShellIdentity(
341
+ nextRegistryCtx.sessionId ?? '',
342
+ realpathSync(ctx.cwd),
343
+ );
344
+ if (activationLease !== undefined && registry.hasCurrentReloadLease()) {
345
+ if (
346
+ activationIdentity?.hostPid !== identity.hostPid ||
347
+ activationIdentity.sessionId !== identity.sessionId ||
348
+ activationIdentity.cwdRealpath !== identity.cwdRealpath
349
+ ) {
350
+ throw new ReloadSurvivalError(
351
+ 'pi_bg_reload_owner_activation_conflict',
352
+ 'a repeated session_start changed the bound reload owner identity without shutdown',
353
+ );
354
+ }
243
355
  currentCtx = ctx;
244
- return registry.startDelegateTask(ctx, options);
245
- },
246
- snapshot: (task) => registry.snapshot(task),
247
- resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
248
- claimFusionUsage: (task) => registry.claimFusionUsage(task),
356
+ currentRegistryCtx = nextRegistryCtx;
357
+ return;
358
+ }
359
+ const activationNonce = randomBytes(16).toString('hex');
360
+ const claim = reloadShellOwner.beginActivation(identity, event.reason, activationNonce);
361
+ pendingActivationClaim = claim;
362
+ try {
363
+ const adapter = await registry.stageReloadActivation(claim);
364
+ if (pendingActivationClaim !== claim) {
365
+ // The synchronous shutdown barrier already aborted this claim and
366
+ // detached staged records while the durable import awaited.
367
+ registry.abortReloadActivation(claim);
368
+ return;
369
+ }
370
+ if (disposed) {
371
+ registry.abortReloadActivation(claim);
372
+ pendingActivationClaim = undefined;
373
+ reloadShellOwner.abortActivation(
374
+ claim,
375
+ new ReloadSurvivalError(
376
+ 'pi_bg_reload_owner_stale_claim',
377
+ 'activation was disposed before reload claim commit',
378
+ ),
379
+ );
380
+ return;
381
+ }
382
+ const lease = reloadShellOwner.commitActivation(claim, adapter);
383
+ pendingActivationClaim = undefined;
384
+ activationLease = lease;
385
+ activationIdentity = identity;
386
+ currentCtx = ctx;
387
+ currentRegistryCtx = nextRegistryCtx;
388
+ } catch (error) {
389
+ registry.abortReloadActivation(claim);
390
+ if (pendingActivationClaim === claim) pendingActivationClaim = undefined;
391
+ try {
392
+ reloadShellOwner.abortActivation(claim, error);
393
+ } catch (abortError) {
394
+ if (
395
+ typeof abortError !== 'object' ||
396
+ abortError === null ||
397
+ Reflect.get(abortError, 'code') !== 'pi_bg_reload_owner_stale_claim'
398
+ ) {
399
+ throw new AggregateError([error, abortError], 'Reload activation claim and abort failed');
400
+ }
401
+ }
402
+ throw error;
403
+ }
249
404
  });
250
405
 
406
+ if (config.features.fusion) {
407
+ const { registerFusionExtension } = await import('./fusion-extension.js');
408
+ registerFusionExtension(pi, {
409
+ startManagedTask: async (ctx, options) => {
410
+ currentCtx = ctx;
411
+ const nextRegistryCtx = registryContext(ctx);
412
+ currentRegistryCtx = nextRegistryCtx;
413
+ return registry.startManagedTask(nextRegistryCtx, options);
414
+ },
415
+ snapshot: (task) => registry.snapshot(task),
416
+ updateManagedTask: (task, state, line) => registry.updateManagedTask(task, state, line),
417
+ activationCloseFence,
418
+ });
419
+ }
420
+
421
+ if (config.features.delegate) {
422
+ const { registerDelegateExtension } = await import('./delegate-extension.js');
423
+ registerDelegateExtension(pi, {
424
+ startDelegateTask: async (ctx, options) => {
425
+ currentCtx = ctx;
426
+ const nextRegistryCtx = registryContext(ctx);
427
+ currentRegistryCtx = nextRegistryCtx;
428
+ return registry.startDelegateTask(nextRegistryCtx, options);
429
+ },
430
+ snapshot: (task) => registry.snapshot(task),
431
+ isDelegateTaskRegistered: (taskId) =>
432
+ registry.allTasks().some((task) => task.id === taskId && task.delegate !== undefined),
433
+ activationCloseFence,
434
+ });
435
+ }
436
+
437
+ if (config.features.delegate || config.features.fusion) {
438
+ const { registerBackgroundResultExtension } = await import('./delegate-extension.js');
439
+ registerBackgroundResultExtension(pi, {
440
+ resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
441
+ claimFusionUsage: (task) => registry.claimFusionUsage(task),
442
+ activationCloseFence,
443
+ });
444
+ }
445
+
251
446
  function unseenFinishedTasks(): BgTask[] {
252
447
  return registry
253
448
  .allTasks()
@@ -306,7 +501,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
306
501
  if (unseenDone.length > 0) parts.push(`${String(unseenDone.length)} done`);
307
502
  const entryHint = dockOpen
308
503
  ? 'focused'
309
- : `Shift↓${unseenFinishedCount > 0 ? ' · /bg-clear' : ''}`;
504
+ : `${dockEntryHint}${unseenFinishedCount > 0 ? ' · /bg-clear' : ''}`;
310
505
  const segments = [...parts, entryHint];
311
506
  if (updateSegment) segments.push(updateSegment);
312
507
  const label = ` bg ${segments.join(' · ')} `;
@@ -325,7 +520,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
325
520
  options: StartTaskOptions = {},
326
521
  ): Promise<BgTask> {
327
522
  currentCtx = ctx;
328
- return registry.startTask(ctx, command, options);
523
+ const nextRegistryCtx = registryContext(ctx);
524
+ currentRegistryCtx = nextRegistryCtx;
525
+ return registry.startTask(nextRegistryCtx, command, options);
329
526
  }
330
527
 
331
528
  async function startAttestedPiTask(
@@ -333,7 +530,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
333
530
  options: StartAttestedPiTaskOptions,
334
531
  ): Promise<BgTask> {
335
532
  currentCtx = ctx;
336
- return registry.startAttestedPiTask(ctx, options);
533
+ const nextRegistryCtx = registryContext(ctx);
534
+ currentRegistryCtx = nextRegistryCtx;
535
+ return registry.startAttestedPiTask(nextRegistryCtx, options);
337
536
  }
338
537
 
339
538
  async function openTaskManager(
@@ -348,6 +547,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
348
547
  );
349
548
  return;
350
549
  }
550
+ const { BackgroundTasksManager } = await taskManagerLoader.run((runtime) => runtime);
351
551
  dockOpen = true;
352
552
  updateUi(ctx);
353
553
  try {
@@ -373,6 +573,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
373
573
  const rerunOptions: StartTaskOptions = {
374
574
  name: taskDisplayName(task),
375
575
  isAgent: task.isAgent,
576
+ surviveReload: task.surviveReload,
376
577
  notifyOnCompletion: true,
377
578
  triggerOnCompletion: false,
378
579
  };
@@ -449,7 +650,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
449
650
  );
450
651
 
451
652
  async function scheduleUpdateCheck(ctx: ExtensionContext): Promise<void> {
452
- if (updateCheckStarted) return;
653
+ if (disposed || updateCheckStarted) return;
453
654
  updateCheckStarted = true;
454
655
  const env = process.env;
455
656
  if (env['PI_BG_DISABLE_UPDATE_CHECK'] === '1') return;
@@ -464,6 +665,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
464
665
  const registryUrl = env['PI_BG_REGISTRY_URL'];
465
666
  if (registryUrl) options.registryUrl = registryUrl;
466
667
  const latest = await fetchLatestVersion(options);
668
+ if (disposed) return;
467
669
  if (latest && isNewerVersion(latest, PACKAGE_VERSION)) {
468
670
  latestKnownVersion = latest;
469
671
  updateUi(ctx);
@@ -471,26 +673,40 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
471
673
  }
472
674
 
473
675
  pi.on('session_start', async (_event, ctx) => {
676
+ // Pi replacement binds a fresh extension instance. Never revive this old
677
+ // activation if a late lifecycle dispatch reaches it after shutdown.
678
+ if (disposed) return;
474
679
  registry.setShuttingDown(false);
475
680
  currentCtx = ctx;
476
- await registry.ensureRuntimeDir(ctx);
681
+ const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
682
+ currentRegistryCtx = nextRegistryCtx;
683
+ await registry.ensureRuntimeDir(nextRegistryCtx);
684
+ if (disposed) return;
477
685
  updateUi(ctx);
478
- if (statusInterval) clearInterval(statusInterval);
479
- statusInterval = setInterval(() => {
686
+ if (disposed) return;
687
+ if (statusInterval !== undefined) clearInterval(statusInterval);
688
+ if (disposed) return;
689
+ const nextStatusInterval = setInterval(() => {
480
690
  updateUi();
481
691
  }, STATUS_INTERVAL_MS);
692
+ if (disposed) {
693
+ clearInterval(nextStatusInterval);
694
+ return;
695
+ }
696
+ statusInterval = nextStatusInterval;
482
697
  // One-shot, non-blocking: never awaited on the session-start path or the status tick.
483
- void scheduleUpdateCheck(ctx);
698
+ if (!disposed) void scheduleUpdateCheck(ctx);
484
699
  });
485
700
 
486
- pi.on('session_shutdown', async (_event, ctx) => {
487
- registry.setShuttingDown(true);
488
- currentCtx = undefined;
489
- if (statusInterval) {
490
- clearInterval(statusInterval);
491
- statusInterval = undefined;
492
- }
701
+ pi.on('session_shutdown', async (event, ctx) => {
702
+ beginSessionShutdown(event.reason);
703
+ if (shutdownCleanupStarted) return;
704
+ shutdownCleanupStarted = true;
493
705
  try {
706
+ // Admission closure aborts cooperative preflight and the drain retains
707
+ // ownership until subprocess/file/managed cleanup settles. Any admitted
708
+ // child is inserted synchronously before spawn and is visible below.
709
+ await registry.waitForTaskAdmissions();
494
710
  const running = registry.allTasks().filter((task) => task.status === 'running');
495
711
  if (running.length === 0) return;
496
712
 
@@ -498,7 +714,11 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
498
714
  await Promise.all(
499
715
  running.map(async (task) => {
500
716
  try {
501
- await registry.stopTask(task, 'shutdown', 'Killed during Pi session shutdown/reload');
717
+ await registry.stopTask(
718
+ task,
719
+ 'shutdown',
720
+ `Killed during Pi session shutdown (${event.reason})`,
721
+ );
502
722
  } catch (error) {
503
723
  const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
504
724
  failures.push(message);
@@ -511,17 +731,31 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
511
731
  }
512
732
  } finally {
513
733
  eventService.close();
734
+ if ((event.reason !== 'reload' || reloadHandoffFailed) && activationLease !== undefined) {
735
+ try {
736
+ await registry.waitForReloadHostSettlement();
737
+ } catch (error) {
738
+ console.error(
739
+ '[background-tasks] reload owner host settlement failed during shutdown:',
740
+ error,
741
+ );
742
+ }
743
+ registry.releaseReloadActivation(activationLease);
744
+ activationLease = undefined;
745
+ activationIdentity = undefined;
746
+ }
514
747
  }
515
748
  });
516
749
 
517
750
  pi.registerCommand('bg', {
518
751
  description:
519
- 'Start a shell command as a tracked background task: /bg [--agent] [--name "Task name"] <command>',
752
+ 'Start a tracked shell command: /bg [--survive-reload] [--agent] [--name "Task name"] <command>',
520
753
  handler: async (args, ctx) => {
521
754
  try {
522
755
  const parsed = parseBgCommandArgs(args);
523
756
  const taskOptions: StartTaskOptions = {
524
757
  isAgent: parsed.isAgent,
758
+ surviveReload: parsed.surviveReload,
525
759
  notifyOnCompletion: true,
526
760
  triggerOnCompletion: false,
527
761
  };
@@ -587,12 +821,23 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
587
821
  },
588
822
  });
589
823
 
590
- pi.registerShortcut('shift+down' satisfies KeyId, {
591
- description: 'Open focused background task footer dock',
592
- handler: async (ctx) => {
593
- await openTaskManager(ctx);
594
- },
595
- });
824
+ if (config.dockShortcut === 'shift+down') {
825
+ pi.registerShortcut('shift+down' satisfies KeyId, {
826
+ description: 'Open focused background task footer dock',
827
+ handler: async (ctx) => {
828
+ await openTaskManager(ctx);
829
+ },
830
+ });
831
+ }
832
+
833
+ if (config.dockShortcut === 'ctrl+alt+b') {
834
+ pi.registerShortcut('ctrl+alt+b' satisfies KeyId, {
835
+ description: 'Open focused background task footer dock',
836
+ handler: async (ctx) => {
837
+ await openTaskManager(ctx);
838
+ },
839
+ });
840
+ }
596
841
 
597
842
  pi.registerShortcut('ctrl+alt+c' satisfies KeyId, {
598
843
  description:
@@ -705,6 +950,21 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
705
950
  'bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.',
706
951
  );
707
952
  }
953
+ if (
954
+ Object.prototype.hasOwnProperty.call(input, 'surviveReload') &&
955
+ typeof input.surviveReload !== 'boolean'
956
+ ) {
957
+ throw new ReloadSurvivalError(
958
+ 'pi_bg_survive_reload_invalid',
959
+ 'bg_run surviveReload must be boolean when present',
960
+ );
961
+ }
962
+ if (input.surviveReload === true && input.isAgent) {
963
+ throw new ReloadSurvivalError(
964
+ 'pi_bg_survive_reload_requires_non_agent',
965
+ 'bg_run surviveReload requires isAgent:false',
966
+ );
967
+ }
708
968
  const prepared: BgRunParamsValue = {
709
969
  command: input.command,
710
970
  name:
@@ -719,6 +979,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
719
979
  prepared.notifyOnCompletion = input.notifyOnCompletion;
720
980
  if (typeof input.triggerOnCompletion === 'boolean')
721
981
  prepared.triggerOnCompletion = input.triggerOnCompletion;
982
+ if (typeof input.surviveReload === 'boolean') prepared.surviveReload = input.surviveReload;
722
983
  return prepared;
723
984
  },
724
985
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -727,9 +988,16 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
727
988
  'bg_run requires isAgent boolean. Set true only for LLM/agent tasks; set false for scripts, tests, servers, sleeps, and ordinary shell commands.',
728
989
  );
729
990
  }
991
+ if (params.surviveReload === true && params.isAgent) {
992
+ throw new ReloadSurvivalError(
993
+ 'pi_bg_survive_reload_requires_non_agent',
994
+ 'bg_run surviveReload requires isAgent:false',
995
+ );
996
+ }
730
997
  const taskOptions: StartTaskOptions = {
731
998
  name: params.name,
732
999
  isAgent: params.isAgent,
1000
+ surviveReload: params.surviveReload ?? false,
733
1001
  notifyOnCompletion: params.notifyOnCompletion ?? true,
734
1002
  triggerOnCompletion: params.triggerOnCompletion ?? true,
735
1003
  };
@@ -764,70 +1032,75 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
764
1032
  },
765
1033
  });
766
1034
 
767
- pi.registerTool<typeof BgPiAttestedParams, BgRunDetails>({
768
- name: 'bg_run_pi_attested',
769
- label: 'Attested Pi Run',
770
- description:
771
- 'Opt-in evidence-oriented direct Pi spawn. Launches exactly one `pi --mode json` child, records raw Pi events/stderr, hashes prompt/report/output, observes OAuth through ModelRegistry, and emits a strict attestation sidecar only after successful completion.',
772
- promptSnippet: 'Start an attested direct Pi agent task and return its task ID plus output path',
773
- promptGuidelines: [
774
- 'Use only when the user explicitly asks for an attested Pi evidence-producing task; ordinary background work should use bg_run unchanged.',
775
- 'Provide provider/model as structured fields and a relative reportPath that the child Pi prompt will write before exit.',
776
- 'Do not provide channel, auth, route, or hash claims; the producer observes those facts itself and fails loudly if it cannot attest them.',
777
- ],
778
- parameters: BgPiAttestedParams,
779
- prepareArguments(args): BgPiAttestedParamsValue {
780
- if (!args || typeof args !== 'object')
781
- throw new Error('bg_run_pi_attested arguments must be an object');
782
- const input = args as BgPiAttestedArgumentRecord;
783
- if (typeof input.name !== 'string') throw new Error('bg_run_pi_attested requires name');
784
- if (typeof input.provider !== 'string')
785
- throw new Error('bg_run_pi_attested requires provider');
786
- if (typeof input.model !== 'string') throw new Error('bg_run_pi_attested requires model');
787
- if (typeof input.prompt !== 'string') throw new Error('bg_run_pi_attested requires prompt');
788
- if (typeof input.reportPath !== 'string')
789
- throw new Error('bg_run_pi_attested requires reportPath');
790
- const prepared: BgPiAttestedParamsValue = {
791
- name: input.name,
792
- provider: input.provider,
793
- model: input.model,
794
- prompt: input.prompt,
795
- reportPath: input.reportPath,
796
- };
797
- if (Array.isArray(input.extraPiArgs)) {
798
- if (!input.extraPiArgs.every((entry) => typeof entry === 'string'))
799
- throw new Error('bg_run_pi_attested extraPiArgs entries must be strings');
800
- prepared.extraPiArgs = input.extraPiArgs;
801
- }
802
- if (typeof input.thinking === 'string') prepared.thinking = input.thinking;
803
- if (typeof input.timeoutSeconds === 'number') prepared.timeoutSeconds = input.timeoutSeconds;
804
- return prepared;
805
- },
806
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
807
- const task = await startAttestedPiTask(ctx, params);
808
- return {
809
- content: textContent(
810
- `Started attested Pi task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}\nAttestation: ${task.attestationPath ?? 'pending until completion'}`,
811
- ),
812
- details: { task: registry.snapshot(task) },
813
- };
814
- },
815
- renderCall(args, theme) {
816
- return new Text(
817
- `${theme.fg('toolTitle', theme.bold('bg_run_pi_attested '))}${theme.fg('muted', truncateChars(args.name, COMMAND_PREVIEW_CHARS))}`,
818
- 0,
819
- 0,
820
- );
821
- },
822
- renderResult(result, _options, theme) {
823
- const { task } = result.details;
824
- return new Text(
825
- `${theme.fg('success', '✓ started')} ${theme.fg('accent', taskDisplayName(task))} ${theme.fg('dim', `(${task.id})`)}\n${theme.fg('dim', `Output: ${task.outputPath}`)}\n${theme.fg('dim', `Attestation: ${task.attestationPath ?? 'pending'}`)}`,
826
- 0,
827
- 0,
828
- );
829
- },
830
- });
1035
+ if (config.features.attested) {
1036
+ pi.registerTool<typeof BgPiAttestedParams, BgRunDetails>({
1037
+ name: 'bg_run_pi_attested',
1038
+ label: 'Attested Pi Run',
1039
+ description:
1040
+ 'Opt-in evidence-oriented direct Pi spawn. Launches exactly one `pi --mode json` child, records raw Pi events/stderr, hashes prompt/report/output, observes OAuth through ModelRegistry, and emits a strict attestation sidecar only after successful completion.',
1041
+ promptSnippet:
1042
+ 'Start an attested direct Pi agent task and return its task ID plus output path',
1043
+ promptGuidelines: [
1044
+ 'Use only when the user explicitly asks for an attested Pi evidence-producing task; ordinary background work should use bg_run unchanged.',
1045
+ 'Provide provider/model as structured fields and a relative reportPath that the child Pi prompt will write before exit.',
1046
+ 'Do not provide channel, auth, route, or hash claims; the producer observes those facts itself and fails loudly if it cannot attest them.',
1047
+ ],
1048
+ parameters: BgPiAttestedParams,
1049
+ prepareArguments(args): BgPiAttestedParamsValue {
1050
+ if (!args || typeof args !== 'object')
1051
+ throw new Error('bg_run_pi_attested arguments must be an object');
1052
+ const input = args as BgPiAttestedArgumentRecord;
1053
+ rejectSurvivalForTaskKind(input, 'attested Pi tasks');
1054
+ if (typeof input.name !== 'string') throw new Error('bg_run_pi_attested requires name');
1055
+ if (typeof input.provider !== 'string')
1056
+ throw new Error('bg_run_pi_attested requires provider');
1057
+ if (typeof input.model !== 'string') throw new Error('bg_run_pi_attested requires model');
1058
+ if (typeof input.prompt !== 'string') throw new Error('bg_run_pi_attested requires prompt');
1059
+ if (typeof input.reportPath !== 'string')
1060
+ throw new Error('bg_run_pi_attested requires reportPath');
1061
+ const prepared: BgPiAttestedParamsValue = {
1062
+ name: input.name,
1063
+ provider: input.provider,
1064
+ model: input.model,
1065
+ prompt: input.prompt,
1066
+ reportPath: input.reportPath,
1067
+ };
1068
+ if (Array.isArray(input.extraPiArgs)) {
1069
+ if (!input.extraPiArgs.every((entry) => typeof entry === 'string'))
1070
+ throw new Error('bg_run_pi_attested extraPiArgs entries must be strings');
1071
+ prepared.extraPiArgs = input.extraPiArgs;
1072
+ }
1073
+ if (typeof input.thinking === 'string') prepared.thinking = input.thinking;
1074
+ if (typeof input.timeoutSeconds === 'number')
1075
+ prepared.timeoutSeconds = input.timeoutSeconds;
1076
+ return prepared;
1077
+ },
1078
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1079
+ const task = await startAttestedPiTask(ctx, params);
1080
+ return {
1081
+ content: textContent(
1082
+ `Started attested Pi task ${taskDisplayName(task)} (${task.id})\nStatus: ${task.status}\nPID: ${String(task.pid ?? 'unknown')}\nOutput: ${task.outputPath}\nAttestation: ${task.attestationPath ?? 'pending until completion'}`,
1083
+ ),
1084
+ details: { task: registry.snapshot(task) },
1085
+ };
1086
+ },
1087
+ renderCall(args, theme) {
1088
+ return new Text(
1089
+ `${theme.fg('toolTitle', theme.bold('bg_run_pi_attested '))}${theme.fg('muted', truncateChars(args.name, COMMAND_PREVIEW_CHARS))}`,
1090
+ 0,
1091
+ 0,
1092
+ );
1093
+ },
1094
+ renderResult(result, _options, theme) {
1095
+ const { task } = result.details;
1096
+ return new Text(
1097
+ `${theme.fg('success', '✓ started')} ${theme.fg('accent', taskDisplayName(task))} ${theme.fg('dim', `(${task.id})`)}\n${theme.fg('dim', `Output: ${task.outputPath}`)}\n${theme.fg('dim', `Attestation: ${task.attestationPath ?? 'pending'}`)}`,
1098
+ 0,
1099
+ 0,
1100
+ );
1101
+ },
1102
+ });
1103
+ }
831
1104
 
832
1105
  pi.registerTool<typeof BgStatusParams, BgStatusDetails>({
833
1106
  name: 'bg_status',