pi-better-background-tasks 0.2.9 → 0.2.11

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/README.md CHANGED
@@ -15,6 +15,7 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
15
15
  - Start long-running commands without blocking the current turn.
16
16
  - Watch commands until success, failure, or timeout.
17
17
  - Keep task metadata and logs available across reloads.
18
+ - Retain completed task artifacts for seven days, then remove them during rate-limited maintenance.
18
19
  - Show active work in Pi's background-work navigator.
19
20
  - Flag running tasks with no observable output or completed poll as stalled.
20
21
  - Confine local task writes to the project directory when `pi-better-sandbox` is enabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { listMetas } from "./registry.js";
2
+ import { listMetas, listMetasForOrigin, onMetaChanged } from "./registry.js";
3
3
  import type { BackgroundTaskMeta } from "./types.js";
4
4
  import { isTerminalStatus } from "./types.js";
5
5
  import { backgroundTaskProgressAt, observeBackgroundTaskStall } from "./stall.js";
@@ -46,7 +46,8 @@ export function registerBackgroundTasksGoalProvider(pi: ExtensionAPI): void {
46
46
  pi.events?.emit(GOAL_REGISTER_PROVIDER_EVENT, {
47
47
  id: "background-tasks",
48
48
  label: "Background Tasks",
49
- getActivity: (ctx: ExtensionContext) => collectBackgroundTaskGoalActivity(listMetas(), ctx),
49
+ getActivity: (ctx: ExtensionContext) => collectBackgroundTaskGoalActivity(listMetasForOrigin(getGoalActivityOrigin(ctx)), ctx),
50
+ onActivityChanged: onMetaChanged,
50
51
  });
51
52
  };
52
53
 
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { baseDir, listMetas, removeMeta, writeMeta } from "./registry.js";
4
+ import { processIdentityAlive as defaultProcessIdentityAlive } from "./process-identity.js";
5
+ import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
6
+ import { isTerminalStatus } from "./types.js";
7
+
8
+ export const DEFAULT_TASK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
9
+ const STALE_MAINTENANCE_LOCK_MS = 10 * 60 * 1000;
10
+
11
+ interface MaintenanceState {
12
+ lastDate?: string;
13
+ }
14
+
15
+ export interface TaskMaintenanceOptions {
16
+ now?: number;
17
+ activeOrigin?: BackgroundTaskCallbackOrigin;
18
+ retentionMs?: number;
19
+ processIdentityAlive?: (pid: number | undefined, token?: string, recordedAt?: number) => boolean;
20
+ metas?: BackgroundTaskMeta[];
21
+ force?: boolean;
22
+ }
23
+
24
+ export interface TaskMaintenanceResult {
25
+ ran: boolean;
26
+ reconciled: number;
27
+ removed: number;
28
+ }
29
+
30
+ export function runTaskMaintenance(options: TaskMaintenanceOptions = {}): TaskMaintenanceResult {
31
+ const now = options.now ?? Date.now();
32
+ const date = new Date(now).toISOString().slice(0, 10);
33
+ const statePath = join(baseDir(), "maintenance-state.json");
34
+ const lockPath = join(baseDir(), "maintenance.lock");
35
+ if (!options.force && readState(statePath).lastDate === date) return { ran: false, reconciled: 0, removed: 0 };
36
+ if (!options.force && !acquireMaintenanceLock(lockPath, now)) return { ran: false, reconciled: 0, removed: 0 };
37
+
38
+ let reconciled = 0;
39
+ let removed = 0;
40
+ try {
41
+ const identityAlive = options.processIdentityAlive ?? defaultProcessIdentityAlive;
42
+ const metas = options.metas ?? listMetas();
43
+ for (const meta of metas) {
44
+ if (meta.status !== "running" || belongsToOrigin(meta, options.activeOrigin)) continue;
45
+ if (identityAlive(meta.spawnPid, meta.spawnPidStartTime, meta.startedAt)) continue;
46
+ if (meta.remote?.session === "tmux") continue;
47
+ if (meta.kind === "process" && identityAlive(meta.pid, meta.pidStartTime, meta.startedAt)) continue;
48
+ meta.status = "failed";
49
+ meta.endedAt = now;
50
+ meta.error = "task supervisor is no longer alive; execution result is unavailable";
51
+ meta.result = { reason: meta.error };
52
+ writeMeta(meta);
53
+ reconciled += 1;
54
+ }
55
+
56
+ const cutoff = now - (options.retentionMs ?? DEFAULT_TASK_RETENTION_MS);
57
+ for (const meta of metas) {
58
+ if (!isTerminalStatus(meta.status)) continue;
59
+ if ((meta.endedAt ?? meta.startedAt) >= cutoff) continue;
60
+ if (removeMeta(meta)) removed += 1;
61
+ }
62
+ if (!options.force) writeFileSync(statePath, JSON.stringify({ lastDate: date } satisfies MaintenanceState));
63
+ return { ran: true, reconciled, removed };
64
+ } finally {
65
+ if (!options.force) rmSync(lockPath, { recursive: true, force: true });
66
+ }
67
+ }
68
+
69
+ function readState(path: string): MaintenanceState {
70
+ try { return JSON.parse(readFileSync(path, "utf8")) as MaintenanceState; } catch { return {}; }
71
+ }
72
+
73
+ function acquireMaintenanceLock(path: string, now: number): boolean {
74
+ mkdirSync(baseDir(), { recursive: true });
75
+ try {
76
+ mkdirSync(path);
77
+ return true;
78
+ } catch {
79
+ try {
80
+ if (now - statSync(path).mtimeMs <= STALE_MAINTENANCE_LOCK_MS) return false;
81
+ rmSync(path, { recursive: true, force: true });
82
+ mkdirSync(path);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ }
89
+
90
+ function belongsToOrigin(meta: BackgroundTaskMeta, active: BackgroundTaskCallbackOrigin | undefined): boolean {
91
+ if (!active) return false;
92
+ const origin = meta.callbackOrigin ?? { cwd: meta.cwd };
93
+ if (origin.cwd !== active.cwd) return false;
94
+ if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
95
+ return true;
96
+ }
@@ -10,7 +10,7 @@ import {
10
10
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
12
  import { readLog } from "./logs.js";
13
- import { listMetas, onMetaChanged, readMeta, writeMeta } from "./registry.js";
13
+ import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
14
14
  import { stopTask } from "./runtime.js";
15
15
  import { observeBackgroundTaskStall } from "./stall.js";
16
16
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
@@ -67,7 +67,9 @@ const provider: BackgroundWorkProvider = {
67
67
  };
68
68
 
69
69
  function visibleMetas(now = Date.now()): BackgroundTaskMeta[] {
70
- return listMetas().filter((meta) => meta.dismissedAt === undefined && belongsToActiveNavigatorSession(meta) && !isExpiredTerminalNavigatorRow(meta, now));
70
+ const active = activeNavigatorOrigin;
71
+ if (!active) return [];
72
+ return listMetasForOrigin(active).filter((meta) => meta.dismissedAt === undefined && belongsToActiveNavigatorSession(meta) && !isExpiredTerminalNavigatorRow(meta, now));
71
73
  }
72
74
 
73
75
  function getNavigatorOrigin(ctx: ExtensionContext): BackgroundTaskCallbackOrigin {
@@ -0,0 +1,41 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ const selfStartToken = readProcessStartToken(process.pid);
4
+
5
+ export function currentProcessStartToken(): string | undefined {
6
+ return selfStartToken;
7
+ }
8
+
9
+ export function readProcessStartToken(pid: number): string | undefined {
10
+ if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return undefined;
11
+ try {
12
+ const output = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
13
+ encoding: "utf8",
14
+ stdio: ["ignore", "pipe", "ignore"],
15
+ timeout: 2_000,
16
+ }).trim();
17
+ return output || undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ export function processIdentityAlive(
24
+ pid: number | undefined,
25
+ recordedStartToken?: string,
26
+ recordedAt?: number,
27
+ ): boolean {
28
+ if (!pid || pid <= 0) return false;
29
+ try {
30
+ process.kill(pid, 0);
31
+ } catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code !== "EPERM") return false;
33
+ }
34
+ const current = readProcessStartToken(pid);
35
+ if (recordedStartToken && current) return recordedStartToken === current;
36
+ if (!recordedStartToken && current && recordedAt !== undefined) {
37
+ const currentStartedAt = Date.parse(current);
38
+ if (Number.isFinite(currentStartedAt) && currentStartedAt > recordedAt + 2_000) return false;
39
+ }
40
+ return true;
41
+ }
package/src/registry.ts CHANGED
@@ -1,12 +1,36 @@
1
- import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
- import type { BackgroundTaskMeta } from "./types.js";
5
+ import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
5
6
  import { isTerminalStatus } from "./types.js";
6
7
 
7
8
  let seq = 0;
8
9
  const metaCache = new Map<string, BackgroundTaskMeta>();
10
+ // Owned snapshots are process-resident. A cheap directory signature catches
11
+ // cross-process index changes before any cached IDs are reused.
12
+ const indexIdsCache = new Map<string, { ids: Set<string>; signature: string }>();
13
+ const initializedIndexes = new Set<string>();
9
14
  const metaChangedListeners = new Set<() => void>();
15
+ const registryIo = { fullDirectoryReads: 0, indexDirectoryReads: 0, metadataFileReads: 0, indexRevisionChecks: 0 };
16
+
17
+ export interface RegistryIoMetrics {
18
+ fullDirectoryReads: number;
19
+ indexDirectoryReads: number;
20
+ metadataFileReads: number;
21
+ indexRevisionChecks: number;
22
+ }
23
+
24
+ export function getRegistryIoMetrics(): RegistryIoMetrics {
25
+ return { ...registryIo };
26
+ }
27
+
28
+ export function resetRegistryIoMetrics(): void {
29
+ registryIo.fullDirectoryReads = 0;
30
+ registryIo.indexDirectoryReads = 0;
31
+ registryIo.metadataFileReads = 0;
32
+ registryIo.indexRevisionChecks = 0;
33
+ }
10
34
 
11
35
  export function baseDir(): string {
12
36
  const vitestPoolId = process.env.VITEST_POOL_ID;
@@ -56,6 +80,7 @@ export function writeMeta(meta: BackgroundTaskMeta): void {
56
80
  ensureTaskDir(meta.id);
57
81
  writeFileSync(metaPathFor(meta.id), JSON.stringify(meta, null, 2));
58
82
  metaCache.set(meta.id, meta);
83
+ indexMeta(meta);
59
84
  for (const listener of metaChangedListeners) {
60
85
  try { listener(); } catch { /* best effort */ }
61
86
  }
@@ -68,6 +93,7 @@ export function onMetaChanged(listener: () => void): () => void {
68
93
 
69
94
  export function readMeta(id: string): BackgroundTaskMeta | undefined {
70
95
  try {
96
+ registryIo.metadataFileReads += 1;
71
97
  const meta = JSON.parse(readFileSync(metaPathFor(id), "utf8")) as BackgroundTaskMeta;
72
98
  metaCache.set(id, meta);
73
99
  return meta;
@@ -77,9 +103,25 @@ export function readMeta(id: string): BackgroundTaskMeta | undefined {
77
103
  }
78
104
  }
79
105
 
106
+ export function removeMeta(meta: BackgroundTaskMeta): boolean {
107
+ try {
108
+ rmSync(taskDir(meta.id), { recursive: true, force: true });
109
+ metaCache.delete(meta.id);
110
+ removeIndexEntry(originIndexDir(originOf(meta)), meta.id);
111
+ removeIndexEntry(originActiveIndexDir(originOf(meta)), meta.id);
112
+ for (const listener of metaChangedListeners) {
113
+ try { listener(); } catch { /* best effort */ }
114
+ }
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
80
121
  export function listMetas(): BackgroundTaskMeta[] {
81
122
  let ids: string[];
82
123
  try {
124
+ registryIo.fullDirectoryReads += 1;
83
125
  ids = readdirSync(tasksDir());
84
126
  } catch {
85
127
  return [];
@@ -94,8 +136,158 @@ export function listMetas(): BackgroundTaskMeta[] {
94
136
  .sort((a, b) => b.startedAt - a.startedAt);
95
137
  }
96
138
 
139
+ export function listMetasForOrigin(origin: BackgroundTaskCallbackOrigin): BackgroundTaskMeta[] {
140
+ const directory = originIndexDir(origin);
141
+ ensureOriginIndex(origin, directory);
142
+ return readIndexIds(directory)
143
+ .map(readOwnedMeta)
144
+ .filter((meta): meta is BackgroundTaskMeta => meta !== undefined && belongsToOrigin(meta, origin))
145
+ .sort((a, b) => b.startedAt - a.startedAt);
146
+ }
147
+
148
+ export function listActiveMetasForOrigin(origin: BackgroundTaskCallbackOrigin): BackgroundTaskMeta[] {
149
+ const directory = originActiveIndexDir(origin);
150
+ ensureOriginActiveIndex(origin, directory);
151
+ return readIndexIds(directory)
152
+ .map(readOwnedMeta)
153
+ .filter((meta): meta is BackgroundTaskMeta => meta !== undefined && meta.status === "running" && belongsToOrigin(meta, origin))
154
+ .sort((a, b) => b.startedAt - a.startedAt);
155
+ }
156
+
97
157
  function readMetaForSweep(id: string): BackgroundTaskMeta | undefined {
98
158
  const cached = metaCache.get(id);
99
159
  if (cached && isTerminalStatus(cached.status)) return cached;
100
160
  return readMeta(id);
101
161
  }
162
+
163
+ function readOwnedMeta(id: string): BackgroundTaskMeta | undefined {
164
+ return metaCache.get(id) ?? readMeta(id);
165
+ }
166
+
167
+ function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
168
+ return meta.callbackOrigin ?? { cwd: meta.cwd };
169
+ }
170
+
171
+ function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
172
+ const candidate = originOf(meta);
173
+ if (candidate.cwd !== origin.cwd) return false;
174
+ if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;
175
+ return true;
176
+ }
177
+
178
+ function originIndexDir(origin: BackgroundTaskCallbackOrigin): string {
179
+ return join(baseDir(), "by-origin", originKey(origin));
180
+ }
181
+
182
+ function originActiveIndexDir(origin: BackgroundTaskCallbackOrigin): string {
183
+ return join(baseDir(), "by-origin-active", originKey(origin));
184
+ }
185
+
186
+ function originKey(origin: BackgroundTaskCallbackOrigin): string {
187
+ return createHash("sha256")
188
+ .update(origin.cwd)
189
+ .update("\0")
190
+ .update(origin.sessionId ?? "")
191
+ .digest("hex")
192
+ .slice(0, 24);
193
+ }
194
+
195
+ function indexMeta(meta: BackgroundTaskMeta): void {
196
+ try {
197
+ const directory = originIndexDir(originOf(meta));
198
+ writeIndexEntry(directory, meta.id);
199
+ const activeDirectory = originActiveIndexDir(originOf(meta));
200
+ if (meta.status === "running") writeIndexEntry(activeDirectory, meta.id);
201
+ else removeIndexEntry(activeDirectory, meta.id);
202
+ } catch {
203
+ // Indexes are accelerators; meta.json remains authoritative.
204
+ }
205
+ }
206
+
207
+ function ensureOriginIndex(origin: BackgroundTaskCallbackOrigin, directory: string): void {
208
+ if (initializedIndexes.has(directory)) return;
209
+ try {
210
+ readFileSync(join(directory, ".initialized"));
211
+ initializedIndexes.add(directory);
212
+ return;
213
+ } catch {
214
+ // Existing registries are backfilled once for each session origin.
215
+ }
216
+ indexIdsCache.delete(directory);
217
+ const owned = listMetas().filter((meta) => belongsToOrigin(meta, origin));
218
+ mkdirSync(directory, { recursive: true });
219
+ for (const meta of owned) writeIndexEntry(directory, meta.id);
220
+ writeFileSync(join(directory, ".initialized"), "1");
221
+ initializedIndexes.add(directory);
222
+ }
223
+
224
+ function ensureOriginActiveIndex(origin: BackgroundTaskCallbackOrigin, directory: string): void {
225
+ if (initializedIndexes.has(directory)) return;
226
+ try {
227
+ readFileSync(join(directory, ".initialized"));
228
+ initializedIndexes.add(directory);
229
+ return;
230
+ } catch {
231
+ // Existing registries are backfilled once for this origin's active set.
232
+ }
233
+ indexIdsCache.delete(directory);
234
+ const owned = listMetasForOrigin(origin).filter((meta) => meta.status === "running");
235
+ mkdirSync(directory, { recursive: true });
236
+ for (const meta of owned) writeIndexEntry(directory, meta.id);
237
+ writeFileSync(join(directory, ".initialized"), "1");
238
+ initializedIndexes.add(directory);
239
+ }
240
+
241
+ function readIndexIds(directory: string): string[] {
242
+ const signature = indexDirectorySignature(directory);
243
+ const cached = indexIdsCache.get(directory);
244
+ if (cached && cached.signature === signature) return [...cached.ids];
245
+ try {
246
+ registryIo.indexDirectoryReads += 1;
247
+ const ids = new Set(readdirSync(directory).filter((id) => id !== ".initialized"));
248
+ indexIdsCache.set(directory, { ids, signature: indexDirectorySignature(directory) });
249
+ return [...ids];
250
+ } catch {
251
+ indexIdsCache.delete(directory);
252
+ return [];
253
+ }
254
+ }
255
+
256
+ function writeIndexEntry(directory: string, id: string): void {
257
+ mkdirSync(directory, { recursive: true });
258
+ const cached = indexIdsCache.get(directory);
259
+ const cacheWasCurrent = cached ? cached.signature === indexDirectorySignature(directory) : false;
260
+ try {
261
+ writeFileSync(join(directory, id), "", { flag: "wx" });
262
+ } catch (error) {
263
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
264
+ }
265
+ if (cached && cacheWasCurrent) {
266
+ cached.ids.add(id);
267
+ cached.signature = indexDirectorySignature(directory);
268
+ } else if (cached) {
269
+ indexIdsCache.delete(directory);
270
+ }
271
+ }
272
+
273
+ function removeIndexEntry(directory: string, id: string): void {
274
+ const cached = indexIdsCache.get(directory);
275
+ const cacheWasCurrent = cached ? cached.signature === indexDirectorySignature(directory) : false;
276
+ try { unlinkSync(join(directory, id)); } catch { /* stale index entries are harmless */ }
277
+ if (cached && cacheWasCurrent) {
278
+ cached.ids.delete(id);
279
+ cached.signature = indexDirectorySignature(directory);
280
+ } else if (cached) {
281
+ indexIdsCache.delete(directory);
282
+ }
283
+ }
284
+
285
+ function indexDirectorySignature(directory: string): string {
286
+ registryIo.indexRevisionChecks += 1;
287
+ try {
288
+ const stat = statSync(directory);
289
+ return `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}`;
290
+ } catch {
291
+ return "missing";
292
+ }
293
+ }
package/src/runtime.ts CHANGED
@@ -3,6 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { appendLine, appendTaskOutput, appendWatchResult, retainLogTail, resolveMaxLogBytes } from "./logs.js";
4
4
  import { evaluateCondition } from "./conditions.js";
5
5
  import { processExists, runCommandOnce, spawnCommand, stopProcessGroup } from "./process.js";
6
+ import { currentProcessStartToken, readProcessStartToken } from "./process-identity.js";
6
7
  import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
7
8
  import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
8
9
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
@@ -122,8 +123,10 @@ export function spawnTask(
122
123
  launchArgv: launchArgvOf(commandSpec, launchSpec),
123
124
  maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
124
125
  pid: spawned?.child.pid,
126
+ pidStartTime: spawned?.child.pid ? readProcessStartToken(spawned.child.pid) : undefined,
125
127
  pgid: spawned?.pgid,
126
128
  spawnPid: process.pid,
129
+ spawnPidStartTime: currentProcessStartToken(),
127
130
  ssh: remoteTask?.metadata.ssh,
128
131
  remote: remoteTask?.metadata.remote,
129
132
  };
@@ -347,6 +350,7 @@ export function startWatchTask(
347
350
  launchArgv: launchArgvOf(commandSpec, launchSpec),
348
351
  maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
349
352
  spawnPid: process.pid,
353
+ spawnPidStartTime: currentProcessStartToken(),
350
354
  successWhen: params.success_when,
351
355
  failureWhen: params.failure_when,
352
356
  notifyOn: "terminal",
@@ -379,6 +383,12 @@ export function resumeRunningTask(
379
383
  return meta;
380
384
  }
381
385
 
386
+ if (meta.spawnPid !== process.pid || meta.spawnPidStartTime !== currentProcessStartToken()) {
387
+ meta.spawnPid = process.pid;
388
+ meta.spawnPidStartTime = currentProcessStartToken();
389
+ writeMeta(meta);
390
+ }
391
+
382
392
  const remoteTask = resolvePersistedRemoteTask(meta, dependencies.remoteRunner);
383
393
  if (meta.kind === "command_watch") {
384
394
  scheduleWatch(pi, meta.id, 0, getActiveSession, remoteTask
@@ -161,7 +161,7 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
161
161
  }
162
162
 
163
163
  export function navigatorFooterHint(count: number): string | null {
164
- return count > 0 ? `← navigate · ${count}` : null;
164
+ return count > 0 ? `← work · ${count}` : null;
165
165
  }
166
166
 
167
167
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
@@ -460,7 +460,9 @@ function buildMainListLines(
460
460
  }
461
461
 
462
462
  function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
463
- const keys = focused ? "↑↓ switch · Enter detail · x stop · Esc unfocus" : "← to navigate";
463
+ const keys = focused
464
+ ? "↑↓ switch · Enter detail · x stop · Esc unfocus"
465
+ : "← work navigator";
464
466
  return dim(keys, fg);
465
467
  }
466
468
 
package/src/tools.ts CHANGED
@@ -3,8 +3,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { readLog } from "./logs.js";
4
4
  import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
5
5
  import { cancelCallbackBatch } from "./shared-callback-batcher.js";
6
- import { listMetas, readMeta, writeMeta } from "./registry.js";
6
+ import { listActiveMetasForOrigin, listMetas, listMetasForOrigin, readMeta, writeMeta } from "./registry.js";
7
7
  import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
8
+ import { runTaskMaintenance } from "./maintenance.js";
8
9
  import { ForegroundSandboxBlockedError } from "./sandbox.js";
9
10
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta } from "./types.js";
10
11
  import { isTerminalStatus } from "./types.js";
@@ -108,7 +109,8 @@ export function registerTools(pi: ExtensionAPI): void {
108
109
 
109
110
  pi.on("session_start", async (_event, ctx) => {
110
111
  activeSession = getCallbackOrigin(ctx);
111
- for (const meta of listMetas()) resumeRunningTask(pi, meta, getActiveSession);
112
+ for (const meta of listActiveMetasForOrigin(activeSession)) resumeRunningTask(pi, meta, getActiveSession);
113
+ runTaskMaintenance({ activeOrigin: activeSession });
112
114
  });
113
115
  pi.on("session_before_switch", () => {
114
116
  activeSession = undefined;
@@ -499,7 +501,7 @@ function formatClear(statuses: string[] | undefined, active: BackgroundTaskCallb
499
501
  const wanted = statuses && statuses.length > 0 ? new Set(statuses) : undefined;
500
502
  const now = Date.now();
501
503
  let cleared = 0;
502
- for (const meta of listMetas()) {
504
+ for (const meta of listMetasForOrigin(active)) {
503
505
  if (meta.dismissedAt !== undefined) continue;
504
506
  if (!isTerminalStatus(meta.status)) continue;
505
507
  if (wanted && !wanted.has(meta.status)) continue;
package/src/types.ts CHANGED
@@ -103,8 +103,12 @@ export interface BackgroundTaskMeta {
103
103
  logDiscardedBytes?: number;
104
104
  logRetentionEvents?: number;
105
105
  pid?: number;
106
+ /** Opaque process-start token used to reject recycled child PIDs. */
107
+ pidStartTime?: string;
106
108
  pgid?: number;
107
109
  spawnPid: number;
110
+ /** Opaque process-start token used to reject recycled supervisor PIDs. */
111
+ spawnPidStartTime?: string;
108
112
  successWhen?: Condition;
109
113
  failureWhen?: Condition;
110
114
  notifyOn?: "terminal";