pi-simple-background-tasks 0.2.0 → 0.3.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.
@@ -1,6 +1,7 @@
1
1
  import { readdir, readFile, rm } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { TERMINAL_TASK_STATUS_VALUES } from './common.js';
4
+ import { EXIT_MARKER_SUFFIX, RUNNER_SCRIPT_SUFFIX } from './detached-spawn.js';
4
5
 
5
6
  export type CleanupScope = 'one' | 'session' | 'all';
6
7
 
@@ -59,6 +60,11 @@ async function readTaskStatus(jsonPath: string): Promise<string | undefined> {
59
60
  async function deleteTaskFiles(runDir: string, id: string): Promise<void> {
60
61
  await rm(join(runDir, `${id}.json`), { force: true });
61
62
  await rm(join(runDir, `${id}.output`), { force: true });
63
+ // Ownership artifacts of a settled task: the wrapper marker and the
64
+ // generated runner script. Both are dead weight once the record is
65
+ // terminal, and they keep the run dir from ever becoming prunable.
66
+ await rm(join(runDir, `${id}${EXIT_MARKER_SUFFIX}`), { force: true });
67
+ await rm(join(runDir, `${id}${RUNNER_SCRIPT_SUFFIX}`), { force: true });
62
68
  }
63
69
 
64
70
  async function removeEmptyRunDir(runDir: string): Promise<void> {
@@ -1,8 +1,6 @@
1
1
  import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
2
2
  import { win32 } from 'node:path';
3
3
 
4
- export type WindowsKillPhase = 'terminate' | 'force';
5
-
6
4
  export interface TaskkillOutcome {
7
5
  readonly exitCode: number | null;
8
6
  readonly signal: NodeJS.Signals | null;
@@ -117,11 +115,7 @@ function validatePid(pid: number): void {
117
115
  }
118
116
  }
119
117
 
120
- function validatePhase(phase: WindowsKillPhase): void {
121
- if (phase !== 'terminate' && phase !== 'force') {
122
- throw new Error(`Invalid Windows taskkill phase ${String(phase)}`);
123
- }
124
- }
118
+ // (the two-phase 'terminate'/'force' split was removed: /F is the only kill)
125
119
 
126
120
  function positiveFiniteInteger(value: number | undefined, fallback: number, label: string): number {
127
121
  const candidate = value ?? fallback;
@@ -157,11 +151,9 @@ function defaultSpawn(
157
151
 
158
152
  export function runWindowsTaskkill(
159
153
  pid: number,
160
- phase: WindowsKillPhase,
161
154
  options: WindowsTaskkillOptions = {},
162
155
  ): Promise<TaskkillOutcome> {
163
156
  validatePid(pid);
164
- validatePhase(phase);
165
157
  const env = options.env ?? process.env;
166
158
  const taskkill = resolveTaskkillPath(env);
167
159
  const timeoutMs = positiveFiniteInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs');
@@ -173,8 +165,9 @@ export function runWindowsTaskkill(
173
165
  const spawn = options.spawn ?? defaultSpawn;
174
166
  const abortSignal = options.signal;
175
167
 
176
- const args = ['/PID', String(pid), '/T'];
177
- if (phase === 'force') args.push('/F');
168
+ // Single tree termination: console applications do not honor window-close
169
+ // termination, so forced termination is the only kill that works.
170
+ const args = ['/PID', String(pid), '/T', '/F'];
178
171
 
179
172
  if (abortSignal?.aborted) {
180
173
  const stdout = new BoundedCapture(maxCaptureBytes);
@@ -0,0 +1,192 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Windows detach helper: the task's outer process is created by the WMI
6
+ * service (`Win32_Process.Create`), not by the extension process.
7
+ *
8
+ * Background-task survival on this platform requires the task to be outside
9
+ * every process job and console session that belongs to the Pi activation:
10
+ * children spawned directly by the Pi process die together with it (job and
11
+ * console-session teardown), no matter how they are detached. Processes
12
+ * created through WMI have `WmiPrvSE.exe` as their service-side parent and
13
+ * outlive the activating process, its console, and its SSH/terminal session
14
+ * entirely — verified end to end on Windows 11.
15
+ *
16
+ * The helper is a short-lived PowerShell one-shot: it performs exactly one
17
+ * `Win32_Process.Create` and prints the new pid. It is spawned directly by the
18
+ * extension and never survives the launch itself, so a dead activation can
19
+ * never leave a launch in flight (failures surface as launch errors).
20
+ */
21
+
22
+ export interface WmiHelperChild {
23
+ readonly pid?: number | undefined;
24
+ readonly stdout: {
25
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown;
26
+ };
27
+ readonly stderr: {
28
+ on(event: 'data', listener: (data: Buffer | string) => void): unknown;
29
+ };
30
+ kill(signal?: NodeJS.Signals): boolean;
31
+ on(event: 'error', listener: (error: Error) => void): unknown;
32
+ on(
33
+ event: 'close',
34
+ listener: (code: number | null, signal: NodeJS.Signals | null) => void,
35
+ ): unknown;
36
+ }
37
+
38
+ export type WmiSpawnFn = (
39
+ file: string,
40
+ args: string[],
41
+ options: { env: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean },
42
+ ) => WmiHelperChild;
43
+
44
+ export interface WindowsWmiSpawnOptions {
45
+ /** Full CreateProcess command line for the new process. */
46
+ readonly commandLine: string;
47
+ readonly currentDirectory: string;
48
+ readonly env?: NodeJS.ProcessEnv | undefined;
49
+ /** Test seam; defaults to PowerShell via SystemRoot. */
50
+ readonly spawn?: WmiSpawnFn | undefined;
51
+ readonly timeoutMs?: number | undefined;
52
+ }
53
+
54
+ export const WMI_CREATE_TIMEOUT_MS = 20_000;
55
+
56
+ const PS_HEADER = ['$ErrorActionPreference = "Stop"'].join('\n');
57
+
58
+ /**
59
+ * Builds the one-shot PowerShell script: creates the process hidden, reports
60
+ * failures as `WMIERR <ReturnValue>` on stdout, otherwise prints the pid.
61
+ */
62
+ export function buildWindowsWmiCreateScript(commandLine: string, currentDirectory: string): string {
63
+ return [
64
+ PS_HEADER,
65
+ `$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{` +
66
+ `CommandLine = '${escapePowerShellSingleQuoted(commandLine)}';` +
67
+ `CurrentDirectory = '${escapePowerShellSingleQuoted(currentDirectory)}' }`,
68
+ 'if ($r.ReturnValue -ne 0) { Write-Output ("WMIERR " + $r.ReturnValue); exit 1 }',
69
+ 'Write-Output $r.ProcessId',
70
+ ].join('\n');
71
+ }
72
+
73
+ function escapePowerShellSingleQuoted(value: string): string {
74
+ return value.replace(/'/g, "''");
75
+ }
76
+
77
+ export function encodeWindowsWmiScript(script: string): string {
78
+ return Buffer.from(script, 'utf16le').toString('base64');
79
+ }
80
+
81
+ export function powershellExecutable(env: NodeJS.ProcessEnv = process.env): string {
82
+ const windir = env.Windir ?? env.SYSTEMROOT ?? env.winDir ?? 'C:\\Windows';
83
+ return join(windir, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
84
+ }
85
+
86
+ /** Parses `WMIERR N` / `<pid>` helper output into either a failure or a pid. */
87
+ export function parseWmiHelperOutput(stdout: string, stderr: string): { pid: number } {
88
+ const lines = stdout
89
+ .split(/\r?\n/)
90
+ .map((line) => line.trim())
91
+ .filter((line) => line.length > 0);
92
+ const errLine = lines.find((line) => line.startsWith('WMIERR'));
93
+ if (errLine !== undefined) {
94
+ const code = errLine.slice('WMIERR'.length).trim();
95
+ throw new Error(
96
+ `Win32_Process.Create failed (ReturnValue ${code || 'unknown'})` +
97
+ (stderr.length > 0 ? `; helper output: ${stderr.trim()}` : ''),
98
+ );
99
+ }
100
+ const candidate = lines[lines.length - 1];
101
+ const pid = candidate === undefined ? Number.NaN : Number(candidate);
102
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
103
+ throw new Error(
104
+ `Windows WMI spawn did not return a pid${stderr.length > 0 ? `; helper output: ${stderr.trim()}` : ''}`,
105
+ );
106
+ }
107
+ return { pid };
108
+ }
109
+
110
+ export async function createProcessViaWmi(
111
+ options: WindowsWmiSpawnOptions,
112
+ ): Promise<{ pid: number }> {
113
+ const spawn = options.spawn ?? defaultWmiSpawn;
114
+ const env = options.env ?? process.env;
115
+ const scriptFile = encodeWindowsWmiScript(
116
+ buildWindowsWmiCreateScript(options.commandLine, options.currentDirectory),
117
+ );
118
+ const timeoutMs = options.timeoutMs ?? WMI_CREATE_TIMEOUT_MS;
119
+
120
+ const child = spawn(
121
+ powershellExecutable(env),
122
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', scriptFile],
123
+ {
124
+ env,
125
+ windowsVerbatimArguments: false,
126
+ },
127
+ );
128
+
129
+ const stdout: string[] = [];
130
+ const stderr: string[] = [];
131
+ child.stdout?.on('data', (chunk) => stdout.push(String(chunk)));
132
+ child.stderr?.on('data', (chunk) => stderr.push(String(chunk)));
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const rejectOnce = (error: Error): void => {
136
+ if (settled) return;
137
+ settled = true;
138
+ clearTimeout(timer);
139
+ reject(error);
140
+ };
141
+ let settled = false;
142
+ let timer: NodeJS.Timeout | undefined = setTimeout(() => {
143
+ timer = undefined;
144
+ rejectOnce(
145
+ new Error(
146
+ `Windows WMI spawn exceeded ${String(Math.round(timeoutMs / 1000))}s timeout; the helper was terminated`,
147
+ ),
148
+ );
149
+ try {
150
+ child.kill('SIGKILL');
151
+ } catch {
152
+ // Rejection is authoritative; the helper may already have exited.
153
+ }
154
+ }, timeoutMs);
155
+
156
+ const close = (code: number | null): void => {
157
+ if (settled) return;
158
+ settled = true;
159
+ if (timer !== undefined) clearTimeout(timer);
160
+ const detail =
161
+ (code === null ? 'exit signal' : `exit ${String(code)}`) + tailOutput(stderr.join(''));
162
+ if (code !== 0) {
163
+ reject(new Error(`Windows WMI helper failed (${detail})`));
164
+ return;
165
+ }
166
+ try {
167
+ resolve(parseWmiHelperOutput(stdout.join(''), stderr.join('')));
168
+ } catch (error) {
169
+ reject(error instanceof Error ? error : new Error(String(error)));
170
+ }
171
+ };
172
+ child.on('error', (error: Error) =>
173
+ rejectOnce(new Error(`Windows WMI helper failed to start: ${error.message}`)),
174
+ );
175
+ child.on('close', (code: number | null) => close(code));
176
+ });
177
+ function tailOutput(stderrText: string): string {
178
+ const normalized = stderrText.trim();
179
+ return normalized.length === 0 ? '' : `; helper output: ${normalized}`;
180
+ }
181
+ }
182
+
183
+ function defaultWmiSpawn(
184
+ file: string,
185
+ args: string[],
186
+ options: { env: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean },
187
+ ): WmiHelperChild {
188
+ return nodeSpawn(file, args, {
189
+ env: options.env,
190
+ windowsVerbatimArguments: false,
191
+ }) as unknown as WmiHelperChild;
192
+ }
package/src/extension.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { realpathSync } from 'node:fs';
3
1
  import type {
4
2
  ExtensionAPI,
5
3
  ExtensionCommandContext,
@@ -32,10 +30,6 @@ import {
32
30
  normalizeMaxBytes,
33
31
  normalizeTaskName,
34
32
  parseBgCommandArgs,
35
- type ReloadShellActivationClaimV1,
36
- type ReloadShellActivationLeaseV1,
37
- type ReloadShellIdentityV1,
38
- ReloadSurvivalError,
39
33
  type StartTaskOptions,
40
34
  taskDisplayName,
41
35
  truncateChars,
@@ -47,10 +41,6 @@ import {
47
41
  } from './core/extension-api.js';
48
42
  import { LazyModule, SynchronousActivationCloseFence } from './core/lazy-module.js';
49
43
  import { type BackgroundTaskContext, BackgroundTaskRegistry } from './core/registry.js';
50
- import {
51
- getProcessReloadShellOwnerV1,
52
- makeReloadShellIdentity,
53
- } from './core/reload-shell-owner.js';
54
44
  import { createShellPolicyGuidanceHandler, initializeShellPolicy } from './core/shell-policy.js';
55
45
  import type { CleanupScope, CleanupSummary } from './core/task-cleanup.js';
56
46
  import {
@@ -66,9 +56,11 @@ import {
66
56
  * Scope:
67
57
  * - Explicit background shell jobs only: /bg and bg_run spawn commands directly.
68
58
  * - No Ctrl+B support for backgrounding an already-running built-in bash tool.
69
- * - Opted ordinary shell jobs can hand their same live process ownership to a
70
- * fresh extension activation on real same-process reload only.
71
- * - No PID/file adoption, process-restart survival, or crash recovery.
59
+ * - Tasks always survive /reload and Pi restarts: every task spawns detached,
60
+ * its ownership facts live in its on-disk metadata, and each activation
61
+ * adopts still-running records from disk. Pi session shutdown kills all
62
+ * running tasks, so exit with care.
63
+ * - No flag negotiates any of this: there is exactly one launch path.
72
64
  */
73
65
 
74
66
  const COMMAND_PREVIEW_CHARS = 90;
@@ -88,6 +80,7 @@ interface BgToolArgumentRecord {
88
80
  readonly timeoutSeconds?: unknown;
89
81
  readonly notifyOnCompletion?: unknown;
90
82
  readonly triggerOnCompletion?: unknown;
83
+ /** Legacy argument, silently ignored: survival is now unconditional. */
91
84
  readonly surviveReload?: unknown;
92
85
  }
93
86
 
@@ -119,12 +112,6 @@ const BgRunParams = Type.Object({
119
112
  'Whether that notification should automatically trigger a follow-up agent turn. Default: true for bg_run; requires notifyOnCompletion.',
120
113
  }),
121
114
  ),
122
- surviveReload: Type.Optional(
123
- Type.Boolean({
124
- description:
125
- 'Opt in to retaining this exact shell execution across a real same-process Pi reload. Default: false.',
126
- }),
127
- ),
128
115
  });
129
116
 
130
117
  const BgStatusParams = Type.Object({
@@ -213,15 +200,8 @@ function formatCleanMessage(summary: CleanupSummary): string {
213
200
  export default async function backgroundTasksExtension(pi: ExtensionAPI): Promise<void> {
214
201
  const config = resolveBackgroundTasksConfig(process.cwd());
215
202
  const shellPolicy = initializeShellPolicy();
216
- const reloadShellOwner = getProcessReloadShellOwnerV1();
217
203
  pi.on('before_agent_start', createShellPolicyGuidanceHandler(shellPolicy));
218
- let currentRegistryCtx: BackgroundTaskContext | undefined;
219
- let activationLease: ReloadShellActivationLeaseV1 | undefined;
220
- let activationIdentity: ReloadShellIdentityV1 | undefined;
221
- let pendingActivationClaim: ReloadShellActivationClaimV1 | undefined;
222
204
  let disposed = false;
223
- let shutdownCleanupStarted = false;
224
- let reloadHandoffFailed = false;
225
205
  let shutdownReason = 'shutdown';
226
206
  const activationCloseFence = new SynchronousActivationCloseFence();
227
207
  const taskManagerLoader = new LazyModule(() => import('./ui/background-tasks-manager.js'));
@@ -240,59 +220,22 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
240
220
  },
241
221
  shellPolicy,
242
222
  tasksDir: config.tasksDir,
243
- reloadShellOwner,
244
223
  });
245
224
  const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
246
225
  events: pi.events,
247
226
  registry,
248
227
  });
249
228
 
250
- const beginSessionShutdown = (reason: string): void => {
251
- let handoffError: unknown;
252
- if (!disposed) {
253
- disposed = true;
254
- registry.closeTaskAdmissions();
255
- if (pendingActivationClaim !== undefined) {
256
- const claim = pendingActivationClaim;
257
- pendingActivationClaim = undefined;
258
- registry.abortReloadActivation(claim);
259
- try {
260
- reloadShellOwner.abortActivation(
261
- claim,
262
- new ReloadSurvivalError(
263
- 'pi_bg_reload_owner_stale_claim',
264
- 'activation shut down while its reload claim was staging',
265
- ),
266
- );
267
- } catch (error) {
268
- handoffError = error;
269
- }
270
- } else if (reason === 'reload' && activationLease !== undefined) {
271
- const lease = activationLease;
272
- try {
273
- registry.prepareReloadHandoff(lease);
274
- activationLease = undefined;
275
- activationIdentity = undefined;
276
- } catch (error) {
277
- reloadHandoffFailed = true;
278
- handoffError = error;
279
- }
280
- }
281
- registry.setShuttingDown(true);
282
- eventService.close();
283
- }
284
- // Teardown remains active on repeated calls so even a handle assigned by a
285
- // racing continuation is still disposed rather than hidden by idempotence.
286
- currentRegistryCtx = undefined;
287
- if (handoffError !== undefined) throw handoffError;
229
+ const beginSessionShutdown = (): void => {
230
+ if (disposed) return;
231
+ disposed = true;
232
+ registry.setShuttingDown(true);
233
+ eventService.close();
288
234
  };
289
-
290
- // Join the one synchronous all-lane barrier before any facade can register
291
- // asynchronous cleanup. The core callback performs an eligible reload
292
- // handoff first; every lazy lane then closes on the same call stack before
293
- // Pi can await any later cleanup handler.
235
+ // Join the one synchronous all-lane barrier: every lazy lane closes on the
236
+ // same call stack before Pi can await any later cleanup handler.
294
237
  activationCloseFence.add(() => {
295
- beginSessionShutdown(shutdownReason);
238
+ beginSessionShutdown();
296
239
  });
297
240
  activationCloseFence.add(() => {
298
241
  taskManagerLoader.close();
@@ -302,72 +245,21 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
302
245
  activationCloseFence.close();
303
246
  });
304
247
 
305
- // This is the first session_start callback. Claim synchronously before the
306
- // first await, stage/import durably, then expose the fresh host adapter.
307
- pi.on('session_start', async (event, ctx) => {
248
+ pi.on('session_start', async (_event, ctx) => {
249
+ // A Pi reload binds a fresh extension instance. Adoptions run before any
250
+ // task can start so notifications continue for tasks running across the
251
+ // reload, and the poller takes over ownership detection from disk records.
252
+ if (disposed) return;
253
+ registry.setShuttingDown(false);
254
+ await registry.ensureRuntimeDir(registryContext(ctx));
308
255
  if (disposed) return;
309
- const nextRegistryCtx = registryContext(ctx);
310
- const identity = makeReloadShellIdentity(
311
- nextRegistryCtx.sessionId ?? '',
312
- realpathSync(ctx.cwd),
313
- );
314
- if (activationLease !== undefined && registry.hasCurrentReloadLease()) {
315
- if (
316
- activationIdentity?.hostPid !== identity.hostPid ||
317
- activationIdentity.sessionId !== identity.sessionId ||
318
- activationIdentity.cwdRealpath !== identity.cwdRealpath
319
- ) {
320
- throw new ReloadSurvivalError(
321
- 'pi_bg_reload_owner_activation_conflict',
322
- 'a repeated session_start changed the bound reload owner identity without shutdown',
323
- );
324
- }
325
- currentRegistryCtx = nextRegistryCtx;
326
- return;
327
- }
328
- const activationNonce = randomBytes(16).toString('hex');
329
- const claim = reloadShellOwner.beginActivation(identity, event.reason, activationNonce);
330
- pendingActivationClaim = claim;
331
256
  try {
332
- const adapter = await registry.stageReloadActivation(claim);
333
- if (pendingActivationClaim !== claim) {
334
- // The synchronous shutdown barrier already aborted this claim and
335
- // detached staged records while the durable import awaited.
336
- registry.abortReloadActivation(claim);
337
- return;
338
- }
339
- if (disposed) {
340
- registry.abortReloadActivation(claim);
341
- pendingActivationClaim = undefined;
342
- reloadShellOwner.abortActivation(
343
- claim,
344
- new ReloadSurvivalError(
345
- 'pi_bg_reload_owner_stale_claim',
346
- 'activation was disposed before reload claim commit',
347
- ),
348
- );
349
- return;
350
- }
351
- const lease = reloadShellOwner.commitActivation(claim, adapter);
352
- pendingActivationClaim = undefined;
353
- activationLease = lease;
354
- activationIdentity = identity;
355
- currentRegistryCtx = nextRegistryCtx;
257
+ await registry.reconcileTasksFromDisk();
356
258
  } catch (error) {
357
- registry.abortReloadActivation(claim);
358
- if (pendingActivationClaim === claim) pendingActivationClaim = undefined;
359
- try {
360
- reloadShellOwner.abortActivation(claim, error);
361
- } catch (abortError) {
362
- if (
363
- typeof abortError !== 'object' ||
364
- abortError === null ||
365
- Reflect.get(abortError, 'code') !== 'pi_bg_reload_owner_stale_claim'
366
- ) {
367
- throw new AggregateError([error, abortError], 'Reload activation claim and abort failed');
368
- }
369
- }
370
- throw error;
259
+ console.error(
260
+ '[background-tasks] failed to reconcile background tasks from disk:',
261
+ error instanceof Error ? error : new Error(String(error)),
262
+ );
371
263
  }
372
264
  });
373
265
 
@@ -376,9 +268,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
376
268
  command: string,
377
269
  options: StartTaskOptions = {},
378
270
  ): Promise<BgTask> {
379
- const nextRegistryCtx = registryContext(ctx);
380
- currentRegistryCtx = nextRegistryCtx;
381
- return registry.startTask(nextRegistryCtx, command, options);
271
+ return registry.startTask(registryContext(ctx), command, options);
382
272
  }
383
273
 
384
274
  async function openTaskManager(
@@ -433,7 +323,6 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
433
323
  rerunTask: async (task: BackgroundTaskForUi) => {
434
324
  const rerunOptions: StartTaskOptions = {
435
325
  name: taskDisplayName(task),
436
- surviveReload: task.surviveReload,
437
326
  notifyOnCompletion: true,
438
327
  triggerOnCompletion: false,
439
328
  };
@@ -504,26 +393,12 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
504
393
  },
505
394
  );
506
395
 
507
- pi.on('session_start', async (_event, ctx) => {
508
- // Pi replacement binds a fresh extension instance. Never revive this old
509
- // activation if a late lifecycle dispatch reaches it after shutdown.
510
- if (disposed) return;
511
- registry.setShuttingDown(false);
512
- const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
513
- currentRegistryCtx = nextRegistryCtx;
514
- await registry.ensureRuntimeDir(nextRegistryCtx);
515
- if (disposed) return;
516
- });
517
-
518
396
  pi.on('session_shutdown', async (event, ctx) => {
519
- beginSessionShutdown(event.reason);
520
- if (shutdownCleanupStarted) return;
521
- shutdownCleanupStarted = true;
397
+ if (shutdownReason.length === 0) shutdownReason = event.reason;
398
+ // Explicitly user-visible sessions always kill running tasks, while /reload,
399
+ // a Pi restart or a crash cannot reach this handler and thus never kill: the
400
+ // tasks survive and the next activation adopts them from disk.
522
401
  try {
523
- // Admission closure aborts cooperative preflight and the drain retains
524
- // ownership until subprocess/file/managed cleanup settles. Any admitted
525
- // child is inserted synchronously before spawn and is visible below.
526
- await registry.waitForTaskAdmissions();
527
402
  const running = registry.allTasks().filter((task) => task.status === 'running');
528
403
  if (running.length === 0) return;
529
404
 
@@ -548,30 +423,15 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
548
423
  }
549
424
  } finally {
550
425
  eventService.close();
551
- if ((event.reason !== 'reload' || reloadHandoffFailed) && activationLease !== undefined) {
552
- try {
553
- await registry.waitForReloadHostSettlement();
554
- } catch (error) {
555
- console.error(
556
- '[background-tasks] reload owner host settlement failed during shutdown:',
557
- error,
558
- );
559
- }
560
- registry.releaseReloadActivation(activationLease);
561
- activationLease = undefined;
562
- activationIdentity = undefined;
563
- }
564
426
  }
565
427
  });
566
428
 
567
429
  pi.registerCommand('bg', {
568
- description:
569
- 'Start a tracked shell command: /bg [--survive-reload] [--name "Task name"] <command>',
430
+ description: 'Start a tracked shell command: /bg [--name "Task name"] <command>',
570
431
  handler: async (args, ctx) => {
571
432
  try {
572
433
  const parsed = parseBgCommandArgs(args);
573
434
  const taskOptions: StartTaskOptions = {
574
- surviveReload: parsed.surviveReload,
575
435
  notifyOnCompletion: true,
576
436
  triggerOnCompletion: false,
577
437
  };
@@ -747,12 +607,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
747
607
  if (!args || typeof args !== 'object') throw new Error('bg_run arguments must be an object');
748
608
  const input = args as BgToolArgumentRecord;
749
609
  if (typeof input.command !== 'string') throw new Error('bg_run requires command string');
750
- if (Object.hasOwn(input, 'surviveReload') && typeof input.surviveReload !== 'boolean') {
751
- throw new ReloadSurvivalError(
752
- 'pi_bg_survive_reload_invalid',
753
- 'bg_run surviveReload must be boolean when present',
754
- );
755
- }
610
+ // Legacy surviveReload values are silently ignored: task survival across
611
+ // /reload is now the only behavior, so the knob has nothing left to set.
756
612
  const prepared: BgRunParamsValue = {
757
613
  command: input.command,
758
614
  name:
@@ -766,13 +622,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
766
622
  prepared.notifyOnCompletion = input.notifyOnCompletion;
767
623
  if (typeof input.triggerOnCompletion === 'boolean')
768
624
  prepared.triggerOnCompletion = input.triggerOnCompletion;
769
- if (typeof input.surviveReload === 'boolean') prepared.surviveReload = input.surviveReload;
770
625
  return prepared;
771
626
  },
772
627
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
773
628
  const taskOptions: StartTaskOptions = {
774
629
  name: params.name,
775
- surviveReload: params.surviveReload ?? false,
776
630
  notifyOnCompletion: params.notifyOnCompletion ?? true,
777
631
  triggerOnCompletion: params.triggerOnCompletion ?? true,
778
632
  };