pi-simple-background-tasks 0.1.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.
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,
@@ -9,7 +7,13 @@ import type {
9
7
  ToolRenderResultOptions,
10
8
  } from '@earendil-works/pi-coding-agent';
11
9
  import { formatSize } from '@earendil-works/pi-coding-agent';
12
- import { type KeyId, Text } from '@earendil-works/pi-tui';
10
+ import {
11
+ type KeyId,
12
+ type OverlayHandle,
13
+ type OverlayOptions,
14
+ Text,
15
+ type TUI,
16
+ } from '@earendil-works/pi-tui';
13
17
  import { type Static, Type } from 'typebox';
14
18
  import {
15
19
  type BgKillDetails,
@@ -26,10 +30,6 @@ import {
26
30
  normalizeMaxBytes,
27
31
  normalizeTaskName,
28
32
  parseBgCommandArgs,
29
- type ReloadShellActivationClaimV1,
30
- type ReloadShellActivationLeaseV1,
31
- type ReloadShellIdentityV1,
32
- ReloadSurvivalError,
33
33
  type StartTaskOptions,
34
34
  taskDisplayName,
35
35
  truncateChars,
@@ -41,13 +41,14 @@ import {
41
41
  } from './core/extension-api.js';
42
42
  import { LazyModule, SynchronousActivationCloseFence } from './core/lazy-module.js';
43
43
  import { type BackgroundTaskContext, BackgroundTaskRegistry } from './core/registry.js';
44
- import {
45
- getProcessReloadShellOwnerV1,
46
- makeReloadShellIdentity,
47
- } from './core/reload-shell-owner.js';
48
44
  import { createShellPolicyGuidanceHandler, initializeShellPolicy } from './core/shell-policy.js';
49
45
  import type { CleanupScope, CleanupSummary } from './core/task-cleanup.js';
50
- import type { BackgroundTaskForUi, TaskManagerResult } from './ui/background-tasks-manager.js';
46
+ import {
47
+ type BackgroundTaskForUi,
48
+ type BackgroundTasksManager,
49
+ FULLSCREEN_MARGINS,
50
+ type TaskManagerOptions,
51
+ } from './ui/background-tasks-manager.js';
51
52
 
52
53
  /**
53
54
  * Project-local Pi background task manager.
@@ -55,9 +56,11 @@ import type { BackgroundTaskForUi, TaskManagerResult } from './ui/background-tas
55
56
  * Scope:
56
57
  * - Explicit background shell jobs only: /bg and bg_run spawn commands directly.
57
58
  * - No Ctrl+B support for backgrounding an already-running built-in bash tool.
58
- * - Opted ordinary shell jobs can hand their same live process ownership to a
59
- * fresh extension activation on real same-process reload only.
60
- * - 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.
61
64
  */
62
65
 
63
66
  const COMMAND_PREVIEW_CHARS = 90;
@@ -77,6 +80,7 @@ interface BgToolArgumentRecord {
77
80
  readonly timeoutSeconds?: unknown;
78
81
  readonly notifyOnCompletion?: unknown;
79
82
  readonly triggerOnCompletion?: unknown;
83
+ /** Legacy argument, silently ignored: survival is now unconditional. */
80
84
  readonly surviveReload?: unknown;
81
85
  }
82
86
 
@@ -108,12 +112,6 @@ const BgRunParams = Type.Object({
108
112
  'Whether that notification should automatically trigger a follow-up agent turn. Default: true for bg_run; requires notifyOnCompletion.',
109
113
  }),
110
114
  ),
111
- surviveReload: Type.Optional(
112
- Type.Boolean({
113
- description:
114
- 'Opt in to retaining this exact shell execution across a real same-process Pi reload. Default: false.',
115
- }),
116
- ),
117
115
  });
118
116
 
119
117
  const BgStatusParams = Type.Object({
@@ -202,15 +200,8 @@ function formatCleanMessage(summary: CleanupSummary): string {
202
200
  export default async function backgroundTasksExtension(pi: ExtensionAPI): Promise<void> {
203
201
  const config = resolveBackgroundTasksConfig(process.cwd());
204
202
  const shellPolicy = initializeShellPolicy();
205
- const reloadShellOwner = getProcessReloadShellOwnerV1();
206
203
  pi.on('before_agent_start', createShellPolicyGuidanceHandler(shellPolicy));
207
- let currentRegistryCtx: BackgroundTaskContext | undefined;
208
- let activationLease: ReloadShellActivationLeaseV1 | undefined;
209
- let activationIdentity: ReloadShellIdentityV1 | undefined;
210
- let pendingActivationClaim: ReloadShellActivationClaimV1 | undefined;
211
204
  let disposed = false;
212
- let shutdownCleanupStarted = false;
213
- let reloadHandoffFailed = false;
214
205
  let shutdownReason = 'shutdown';
215
206
  const activationCloseFence = new SynchronousActivationCloseFence();
216
207
  const taskManagerLoader = new LazyModule(() => import('./ui/background-tasks-manager.js'));
@@ -229,59 +220,22 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
229
220
  },
230
221
  shellPolicy,
231
222
  tasksDir: config.tasksDir,
232
- reloadShellOwner,
233
223
  });
234
224
  const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
235
225
  events: pi.events,
236
226
  registry,
237
227
  });
238
228
 
239
- const beginSessionShutdown = (reason: string): void => {
240
- let handoffError: unknown;
241
- if (!disposed) {
242
- disposed = true;
243
- registry.closeTaskAdmissions();
244
- if (pendingActivationClaim !== undefined) {
245
- const claim = pendingActivationClaim;
246
- pendingActivationClaim = undefined;
247
- registry.abortReloadActivation(claim);
248
- try {
249
- reloadShellOwner.abortActivation(
250
- claim,
251
- new ReloadSurvivalError(
252
- 'pi_bg_reload_owner_stale_claim',
253
- 'activation shut down while its reload claim was staging',
254
- ),
255
- );
256
- } catch (error) {
257
- handoffError = error;
258
- }
259
- } else if (reason === 'reload' && activationLease !== undefined) {
260
- const lease = activationLease;
261
- try {
262
- registry.prepareReloadHandoff(lease);
263
- activationLease = undefined;
264
- activationIdentity = undefined;
265
- } catch (error) {
266
- reloadHandoffFailed = true;
267
- handoffError = error;
268
- }
269
- }
270
- registry.setShuttingDown(true);
271
- eventService.close();
272
- }
273
- // Teardown remains active on repeated calls so even a handle assigned by a
274
- // racing continuation is still disposed rather than hidden by idempotence.
275
- currentRegistryCtx = undefined;
276
- if (handoffError !== undefined) throw handoffError;
229
+ const beginSessionShutdown = (): void => {
230
+ if (disposed) return;
231
+ disposed = true;
232
+ registry.setShuttingDown(true);
233
+ eventService.close();
277
234
  };
278
-
279
- // Join the one synchronous all-lane barrier before any facade can register
280
- // asynchronous cleanup. The core callback performs an eligible reload
281
- // handoff first; every lazy lane then closes on the same call stack before
282
- // 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.
283
237
  activationCloseFence.add(() => {
284
- beginSessionShutdown(shutdownReason);
238
+ beginSessionShutdown();
285
239
  });
286
240
  activationCloseFence.add(() => {
287
241
  taskManagerLoader.close();
@@ -291,72 +245,21 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
291
245
  activationCloseFence.close();
292
246
  });
293
247
 
294
- // This is the first session_start callback. Claim synchronously before the
295
- // first await, stage/import durably, then expose the fresh host adapter.
296
- 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));
297
255
  if (disposed) return;
298
- const nextRegistryCtx = registryContext(ctx);
299
- const identity = makeReloadShellIdentity(
300
- nextRegistryCtx.sessionId ?? '',
301
- realpathSync(ctx.cwd),
302
- );
303
- if (activationLease !== undefined && registry.hasCurrentReloadLease()) {
304
- if (
305
- activationIdentity?.hostPid !== identity.hostPid ||
306
- activationIdentity.sessionId !== identity.sessionId ||
307
- activationIdentity.cwdRealpath !== identity.cwdRealpath
308
- ) {
309
- throw new ReloadSurvivalError(
310
- 'pi_bg_reload_owner_activation_conflict',
311
- 'a repeated session_start changed the bound reload owner identity without shutdown',
312
- );
313
- }
314
- currentRegistryCtx = nextRegistryCtx;
315
- return;
316
- }
317
- const activationNonce = randomBytes(16).toString('hex');
318
- const claim = reloadShellOwner.beginActivation(identity, event.reason, activationNonce);
319
- pendingActivationClaim = claim;
320
256
  try {
321
- const adapter = await registry.stageReloadActivation(claim);
322
- if (pendingActivationClaim !== claim) {
323
- // The synchronous shutdown barrier already aborted this claim and
324
- // detached staged records while the durable import awaited.
325
- registry.abortReloadActivation(claim);
326
- return;
327
- }
328
- if (disposed) {
329
- registry.abortReloadActivation(claim);
330
- pendingActivationClaim = undefined;
331
- reloadShellOwner.abortActivation(
332
- claim,
333
- new ReloadSurvivalError(
334
- 'pi_bg_reload_owner_stale_claim',
335
- 'activation was disposed before reload claim commit',
336
- ),
337
- );
338
- return;
339
- }
340
- const lease = reloadShellOwner.commitActivation(claim, adapter);
341
- pendingActivationClaim = undefined;
342
- activationLease = lease;
343
- activationIdentity = identity;
344
- currentRegistryCtx = nextRegistryCtx;
257
+ await registry.reconcileTasksFromDisk();
345
258
  } catch (error) {
346
- registry.abortReloadActivation(claim);
347
- if (pendingActivationClaim === claim) pendingActivationClaim = undefined;
348
- try {
349
- reloadShellOwner.abortActivation(claim, error);
350
- } catch (abortError) {
351
- if (
352
- typeof abortError !== 'object' ||
353
- abortError === null ||
354
- Reflect.get(abortError, 'code') !== 'pi_bg_reload_owner_stale_claim'
355
- ) {
356
- throw new AggregateError([error, abortError], 'Reload activation claim and abort failed');
357
- }
358
- }
359
- 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
+ );
360
263
  }
361
264
  });
362
265
 
@@ -365,9 +268,7 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
365
268
  command: string,
366
269
  options: StartTaskOptions = {},
367
270
  ): Promise<BgTask> {
368
- const nextRegistryCtx = registryContext(ctx);
369
- currentRegistryCtx = nextRegistryCtx;
370
- return registry.startTask(nextRegistryCtx, command, options);
271
+ return registry.startTask(registryContext(ctx), command, options);
371
272
  }
372
273
 
373
274
  async function openTaskManager(
@@ -381,55 +282,87 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
381
282
  );
382
283
  return;
383
284
  }
285
+ const normalOptions: OverlayOptions = {
286
+ anchor: 'top-right',
287
+ width: 96,
288
+ minWidth: 64,
289
+ margin: { top: 1, left: 1, right: 1 },
290
+ };
291
+ // Pi resolves overlayOptions once (at overlay creation), so a fullscreen toggle
292
+ // re-creates the overlay with the same manager instance and fresh options.
293
+ const fullscreenOptions = (): OverlayOptions => ({
294
+ anchor: 'top-left',
295
+ width: '100%',
296
+ maxHeight: Math.max(
297
+ 1,
298
+ process.stdout.rows - FULLSCREEN_MARGINS.top - FULLSCREEN_MARGINS.bottom,
299
+ ),
300
+ margin: FULLSCREEN_MARGINS,
301
+ });
302
+
384
303
  const { BackgroundTasksManager } = await taskManagerLoader.run((runtime) => runtime);
385
- await ctx.ui.custom<TaskManagerResult>(
386
- (tui, theme, _keybindings, done) => {
387
- const managerOptions = {
388
- getTasks: () => registry.allTasks(),
389
- stopTask: async (task: BackgroundTaskForUi) => {
390
- await registry.stopTask(registry.resolveTask(task.id), 'user');
391
- },
392
- stopAllRunning: async () => {
393
- const result = await registry.stopAllRunning('user');
394
- return result;
395
- },
396
- rerunTask: async (task: BackgroundTaskForUi) => {
397
- const rerunOptions: StartTaskOptions = {
398
- name: taskDisplayName(task),
399
- surviveReload: task.surviveReload,
400
- notifyOnCompletion: true,
401
- triggerOnCompletion: false,
402
- };
403
- if (task.description !== undefined) rerunOptions.description = task.description;
404
- if (task.timeoutSeconds !== undefined)
405
- rerunOptions.timeoutSeconds = task.timeoutSeconds;
406
- const rerun = await startTask(ctx, task.command, rerunOptions);
407
- return rerun;
408
- },
409
- showOutputPath: (task: BackgroundTaskForUi) => {
410
- ctx.ui.notify(
411
- `Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`,
412
- 'info',
413
- );
414
- },
415
- deleteTask: async (task: BackgroundTaskForUi) => {
416
- await registry.cleanupFinishedTasks('one', task.id);
417
- },
304
+ let manager: BackgroundTasksManager | null = null;
305
+ let currentHandle: OverlayHandle | null = null;
306
+ let rawTui: TUI | null = null;
307
+
308
+ const rePresent = (options: OverlayOptions) => {
309
+ if (!manager || !rawTui) return;
310
+ currentHandle?.hide();
311
+ currentHandle = rawTui.showOverlay(manager, options);
312
+ };
313
+
314
+ const managerOptions: TaskManagerOptions = {
315
+ getTasks: () => registry.allTasks(),
316
+ stopTask: async (task: BackgroundTaskForUi) => {
317
+ await registry.stopTask(registry.resolveTask(task.id), 'user');
318
+ },
319
+ stopAllRunning: async () => {
320
+ const result = await registry.stopAllRunning('user');
321
+ return result;
322
+ },
323
+ rerunTask: async (task: BackgroundTaskForUi) => {
324
+ const rerunOptions: StartTaskOptions = {
325
+ name: taskDisplayName(task),
326
+ notifyOnCompletion: true,
327
+ triggerOnCompletion: false,
418
328
  };
419
- if (initialTaskId)
420
- return new BackgroundTasksManager(tui, theme, done, {
421
- ...managerOptions,
422
- initialTaskId,
423
- });
424
- return new BackgroundTasksManager(tui, theme, done, managerOptions);
329
+ if (task.description !== undefined) rerunOptions.description = task.description;
330
+ if (task.timeoutSeconds !== undefined) rerunOptions.timeoutSeconds = task.timeoutSeconds;
331
+ const rerun = await startTask(ctx, task.command, rerunOptions);
332
+ return rerun;
333
+ },
334
+ showOutputPath: (task: BackgroundTaskForUi) => {
335
+ ctx.ui.notify(
336
+ `Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`,
337
+ 'info',
338
+ );
339
+ },
340
+ deleteTask: async (task: BackgroundTaskForUi) => {
341
+ await registry.cleanupFinishedTasks('one', task.id);
342
+ },
343
+ onFullscreenToggle: (fullscreen) =>
344
+ rePresent(fullscreen ? fullscreenOptions() : normalOptions),
345
+ onClose: () => {
346
+ currentHandle?.hide();
347
+ currentHandle = null;
348
+ },
349
+ };
350
+
351
+ await ctx.ui.custom(
352
+ (tui, theme, _keybindings, done) => {
353
+ rawTui = tui;
354
+ const created = new BackgroundTasksManager(tui, theme, done, {
355
+ ...managerOptions,
356
+ ...(initialTaskId ? { initialTaskId } : {}),
357
+ });
358
+ manager = created;
359
+ return created;
425
360
  },
426
361
  {
427
362
  overlay: true,
428
- overlayOptions: {
429
- anchor: 'top-right',
430
- width: 96,
431
- minWidth: 64,
432
- margin: { top: 1, left: 1, right: 1 },
363
+ overlayOptions: normalOptions,
364
+ onHandle: (handle) => {
365
+ currentHandle = handle;
433
366
  },
434
367
  },
435
368
  );
@@ -460,26 +393,12 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
460
393
  },
461
394
  );
462
395
 
463
- pi.on('session_start', async (_event, ctx) => {
464
- // Pi replacement binds a fresh extension instance. Never revive this old
465
- // activation if a late lifecycle dispatch reaches it after shutdown.
466
- if (disposed) return;
467
- registry.setShuttingDown(false);
468
- const nextRegistryCtx = currentRegistryCtx ?? registryContext(ctx);
469
- currentRegistryCtx = nextRegistryCtx;
470
- await registry.ensureRuntimeDir(nextRegistryCtx);
471
- if (disposed) return;
472
- });
473
-
474
396
  pi.on('session_shutdown', async (event, ctx) => {
475
- beginSessionShutdown(event.reason);
476
- if (shutdownCleanupStarted) return;
477
- 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.
478
401
  try {
479
- // Admission closure aborts cooperative preflight and the drain retains
480
- // ownership until subprocess/file/managed cleanup settles. Any admitted
481
- // child is inserted synchronously before spawn and is visible below.
482
- await registry.waitForTaskAdmissions();
483
402
  const running = registry.allTasks().filter((task) => task.status === 'running');
484
403
  if (running.length === 0) return;
485
404
 
@@ -504,30 +423,15 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
504
423
  }
505
424
  } finally {
506
425
  eventService.close();
507
- if ((event.reason !== 'reload' || reloadHandoffFailed) && activationLease !== undefined) {
508
- try {
509
- await registry.waitForReloadHostSettlement();
510
- } catch (error) {
511
- console.error(
512
- '[background-tasks] reload owner host settlement failed during shutdown:',
513
- error,
514
- );
515
- }
516
- registry.releaseReloadActivation(activationLease);
517
- activationLease = undefined;
518
- activationIdentity = undefined;
519
- }
520
426
  }
521
427
  });
522
428
 
523
429
  pi.registerCommand('bg', {
524
- description:
525
- 'Start a tracked shell command: /bg [--survive-reload] [--name "Task name"] <command>',
430
+ description: 'Start a tracked shell command: /bg [--name "Task name"] <command>',
526
431
  handler: async (args, ctx) => {
527
432
  try {
528
433
  const parsed = parseBgCommandArgs(args);
529
434
  const taskOptions: StartTaskOptions = {
530
- surviveReload: parsed.surviveReload,
531
435
  notifyOnCompletion: true,
532
436
  triggerOnCompletion: false,
533
437
  };
@@ -703,12 +607,8 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
703
607
  if (!args || typeof args !== 'object') throw new Error('bg_run arguments must be an object');
704
608
  const input = args as BgToolArgumentRecord;
705
609
  if (typeof input.command !== 'string') throw new Error('bg_run requires command string');
706
- if (Object.hasOwn(input, 'surviveReload') && typeof input.surviveReload !== 'boolean') {
707
- throw new ReloadSurvivalError(
708
- 'pi_bg_survive_reload_invalid',
709
- 'bg_run surviveReload must be boolean when present',
710
- );
711
- }
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.
712
612
  const prepared: BgRunParamsValue = {
713
613
  command: input.command,
714
614
  name:
@@ -722,13 +622,11 @@ export default async function backgroundTasksExtension(pi: ExtensionAPI): Promis
722
622
  prepared.notifyOnCompletion = input.notifyOnCompletion;
723
623
  if (typeof input.triggerOnCompletion === 'boolean')
724
624
  prepared.triggerOnCompletion = input.triggerOnCompletion;
725
- if (typeof input.surviveReload === 'boolean') prepared.surviveReload = input.surviveReload;
726
625
  return prepared;
727
626
  },
728
627
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
729
628
  const taskOptions: StartTaskOptions = {
730
629
  name: params.name,
731
- surviveReload: params.surviveReload ?? false,
732
630
  notifyOnCompletion: params.notifyOnCompletion ?? true,
733
631
  triggerOnCompletion: params.triggerOnCompletion ?? true,
734
632
  };