pi-better-harness 0.41.1 → 0.41.3

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 (31) hide show
  1. package/README.md +3 -2
  2. package/extensions/task-context/index.ts +8 -7
  3. package/node_modules/pi-better-background-tasks/package.json +1 -1
  4. package/node_modules/pi-better-background-tasks/src/batch.ts +5 -2
  5. package/node_modules/pi-better-background-tasks/src/failures.ts +14 -6
  6. package/node_modules/pi-better-background-tasks/src/index.ts +14 -13
  7. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +37 -10
  8. package/node_modules/pi-better-background-tasks/src/runtime.ts +99 -89
  9. package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +24 -3
  10. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts +3 -1
  11. package/node_modules/pi-better-background-tasks/src/shared-ssh-core/index.ts +30 -1
  12. package/node_modules/pi-better-background-tasks/src/supervisor/generated/store.mjs +4 -2
  13. package/node_modules/pi-better-background-tasks/src/supervisor/store.ts +4 -2
  14. package/node_modules/pi-better-background-tasks/src/tools.ts +32 -24
  15. package/node_modules/pi-better-goal/README.md +2 -1
  16. package/node_modules/pi-better-goal/package.json +1 -1
  17. package/node_modules/pi-better-goal/src/goal-state.ts +12 -2
  18. package/node_modules/pi-better-goal/src/index.ts +20 -85
  19. package/node_modules/pi-better-goal/src/permission-hold.ts +13 -26
  20. package/node_modules/pi-better-goal/src/types.ts +4 -5
  21. package/node_modules/pi-better-ssh/README.md +2 -0
  22. package/node_modules/pi-better-ssh/package.json +1 -1
  23. package/node_modules/pi-better-ssh/src/index.ts +4 -1
  24. package/node_modules/pi-better-ssh/src/mux-registry.ts +3 -1
  25. package/node_modules/pi-better-ssh/src/remote-bash.ts +4 -2
  26. package/node_modules/pi-better-ssh/src/shared-ssh-core/index.ts +30 -1
  27. package/node_modules/pi-better-subagents/index.ts +14 -10
  28. package/node_modules/pi-better-subagents/package.json +1 -1
  29. package/node_modules/pi-better-subagents/shared-navigator.ts +24 -3
  30. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/validate.ts +3 -1
  31. package/package.json +5 -5
package/README.md CHANGED
@@ -45,8 +45,9 @@ existing saved labels remain unchanged until deliberately updated.
45
45
  Use `newTask: true` with a new
46
46
  outcome for unrelated work; a correction to the current task keeps its binding.
47
47
  Hooks enable `update_todo`, `get_todo`, capture, matching Goal lifecycle bridge
48
- writes, reminders, and automated cleanup. While hooks are enabled and the outcome is active, Harness
49
- includes a short reminder before each model call. Turning hooks off stops those
48
+ writes, outcome updates, and automated cleanup. With hooks enabled, Harness records
49
+ a hidden, persistent outcome update at the next agent start only when its content
50
+ changes. It does not rebuild reminders before each model call. Turning hooks off stops those
50
51
  hooks without deleting saved state or starting a turn. `/goal` and skills never
51
52
  enable them.
52
53
 
@@ -677,13 +677,14 @@ export default function taskContextExtension(pi: ExtensionAPI): void {
677
677
  pi.on("session_tree", async (_event, ctx) => {
678
678
  restore(ctx);
679
679
  });
680
- pi.on("context", (event) => {
681
- const messages = event.messages.filter(message => message.role !== "custom" || message.customType !== "pi-better-harness:outcome-reminder");
682
- if (!enabled || outcome?.status !== "active") return messages.length === event.messages.length ? undefined : { messages };
683
- const unit = rushPlan?.issues.find(item => item.status === "in-flight") ?? rushPlan?.issues.find(item => item.status === "pending");
684
- const step = currentPlan?.steps.find(item => item.status === "in_progress") ?? currentPlan?.steps.find(item => item.status === "pending");
685
- const focus = rushBinding ? (unit ? `${unit.id}: ${unit.title}` : undefined) : step?.step;
686
- return { messages: [...messages, { role: "custom" as const, customType: "pi-better-harness:outcome-reminder", content: `${formatOutcome(outcome, true)}${focus ? `\nTodo focus: ${focus.slice(0, 300)}` : ""}\nChoose work that advances this outcome or meets a binding requirement. Report unrelated debt without fixing it. Latest user instructions win; questions are not permission to execute. Stop when the agreed outcome and required checks are done.`, display: false, timestamp: Date.now() }] };
680
+ pi.on("before_agent_start", (_event, ctx) => {
681
+ if (!enabled || !outcome) return;
682
+ const content = formatOutcome(outcome);
683
+ // These messages are persisted by Pi. Never rebuild or remove them in context.
684
+ const previous = [...ctx.sessionManager.getBranch()].reverse().find(entry =>
685
+ entry.type === "custom_message" && entry.customType === "pi-better-harness:outcome-update");
686
+ if (previous?.type === "custom_message" && previous.content === content) return;
687
+ return { message: { customType: "pi-better-harness:outcome-update", content, display: false } };
687
688
  });
688
689
  pi.on("session_shutdown", async (_event, ctx) => {
689
690
  cancelCompletedPlanClear();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { readTaskIntent } from "./failures.js";
4
4
  import { validateCommandSpec } from "./process.js";
5
5
  import { resolveSshCommand } from "./shared-ssh-core/index.js";
6
- import { spawnTask, type ActiveSessionProvider, type SpawnTaskParams, type TaskRuntimeDependencies } from "./runtime.js";
6
+ import { spawnTask, type ActiveSessionProvider, type BackgroundTaskRuntime, type SpawnTaskParams, type TaskRuntimeDependencies } from "./runtime.js";
7
7
  import type { BackgroundTaskCallbackOrigin } from "./types.js";
8
8
 
9
9
  export interface SpawnBatchParams {
@@ -31,7 +31,10 @@ export function spawnTaskBatch(
31
31
  callbackOrigin?: BackgroundTaskCallbackOrigin,
32
32
  getActiveSession?: ActiveSessionProvider,
33
33
  dependencies: TaskRuntimeDependencies = {},
34
+ runtime: Pick<BackgroundTaskRuntime, "spawnTask"> = { spawnTask },
34
35
  ): SpawnBatchResult {
36
+ // Jobs launch through the owning factory's runtime, never the default singleton.
37
+ const spawnOne = runtime.spawnTask;
35
38
  if (!Array.isArray(params.jobs) || params.jobs.length < 1 || params.jobs.length > 100) {
36
39
  throw new Error("jobs must contain between 1 and 100 background tasks.");
37
40
  }
@@ -72,7 +75,7 @@ export function spawnTaskBatch(
72
75
  };
73
76
  for (const [index, job] of jobs.entries()) {
74
77
  try {
75
- const meta = spawnTask(pi, { ...job, batchId: result.batchId, batchName: result.batchName }, defaultCwd, callbackOrigin, getActiveSession, dependencies);
78
+ const meta = spawnOne(pi, { ...job, batchId: result.batchId, batchName: result.batchName }, defaultCwd, callbackOrigin, getActiveSession, dependencies);
76
79
  result.launched.push({ job: index + 1, id: meta.id, ...(meta.name !== undefined ? { name: meta.name } : {}) });
77
80
  } catch (error) {
78
81
  result.failed.push({ job: index + 1, ...(job.name !== undefined ? { name: job.name } : {}), error: errorMessage(error) });
@@ -103,18 +103,19 @@ export function recoverDeclaredOperation(meta: BackgroundTaskMeta, at = Date.now
103
103
  }
104
104
  }
105
105
 
106
+ export function createFailureAttention() {
106
107
  const attentionTimers = new Map<string, ReturnType<typeof setTimeout>>();
107
108
  let attentionSuspended = false;
108
109
  /** Session shutdown: stop this instance's attention timers; the next session_start reschedules (#324). */
109
- export function suspendFailureAttention(): void {
110
+ function suspendFailureAttention(): void {
110
111
  attentionSuspended = true;
111
112
  for (const timer of attentionTimers.values()) clearTimeout(timer);
112
113
  attentionTimers.clear();
113
114
  }
114
- export function resumeFailureAttention(): void {
115
+ function resumeFailureAttention(): void {
115
116
  attentionSuspended = false;
116
117
  }
117
- export function stopFailureAttention(id: string): void {
118
+ function stopFailureAttention(id: string): void {
118
119
  const timer = attentionTimers.get(id);
119
120
  if (timer) clearTimeout(timer);
120
121
  attentionTimers.delete(id);
@@ -125,7 +126,7 @@ export function stopFailureAttention(id: string): void {
125
126
  * identity is per incident set; the inspect target is the real task id.
126
127
  * Only the pending incidents are rows; earlier deliveries are counted, never repeated (#315).
127
128
  */
128
- export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureState, pending: { key: string; incidents: string[] }) {
129
+ function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureState, pending: { key: string; incidents: string[] }) {
129
130
  const rows = pendingAttentionRows(state, pending.incidents);
130
131
  const note = pendingAttentionNote(state, pending.incidents);
131
132
  const due = pending.incidents.length;
@@ -144,7 +145,7 @@ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureS
144
145
  }
145
146
 
146
147
  /** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
147
- export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
148
+ function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
148
149
  stopFailureAttention(id);
149
150
  if (attentionSuspended) return;
150
151
  const meta = readMeta(id);
@@ -167,6 +168,7 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
167
168
  return current.status !== "running" || failureAttentionHandled(now, pending.incidents);
168
169
  },
169
170
  getSuppressionReason: () => {
171
+ if (attentionSuspended) throw new Error("Failure attention instance is suspended; defer notification");
170
172
  const current = readMeta(id);
171
173
  if (!current) throw new Error("Task metadata is unavailable; defer failure notification");
172
174
  if (current.status !== "running" || current.callback === false || current.stopRequestedAt) return "task is no longer running";
@@ -179,7 +181,7 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
179
181
  onDelivered: (at) => { markFailureAttentionDelivered(failurePath(id), pending, at); },
180
182
  });
181
183
  void Promise.resolve(delivery).then((sent) => {
182
- if (!sent && (!readMeta(id) || readMeta(id)?.status === "running")) {
184
+ if (!sent && !attentionSuspended && (!readMeta(id) || readMeta(id)?.status === "running")) {
183
185
  const timer = setTimeout(() => scheduleFailureAttention(pi, id, getActiveSession), 1_000);
184
186
  timer.unref();
185
187
  attentionTimers.set(id, timer);
@@ -197,6 +199,12 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
197
199
  }
198
200
  }
199
201
 
202
+ return { suspendFailureAttention, resumeFailureAttention, stopFailureAttention, scheduleFailureAttention, failureAttentionFields };
203
+ }
204
+ /** Shared by the compatibility exports here and the legacy background runtime. */
205
+ export const defaultAttention = createFailureAttention();
206
+ export const { suspendFailureAttention, resumeFailureAttention, stopFailureAttention, scheduleFailureAttention, failureAttentionFields } = defaultAttention;
207
+
200
208
  export function terminalFailureAttention(id: string) {
201
209
  return pendingFailureAttention(readFailureState(failurePath(id)), Date.now(), { terminal: true });
202
210
  }
@@ -1,9 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
3
2
  import { initializeNavigatorPanels } from "./shared-navigator-panels.ts";
4
3
  import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
5
- import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
6
- import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
4
+ import { createBackgroundTasksNavigator } from "./navigator-provider.js";
5
+ import { createBackgroundTaskRuntime } from "./runtime.js";
7
6
  import { createObservedWaitHost } from "./observed-waits.js";
8
7
  import { getCallbackBatcher } from "./shared-callback-batcher.js";
9
8
  import { observeForegroundSandboxPolicy } from "./sandbox.js";
@@ -11,6 +10,10 @@ import { registerTools } from "./tools.js";
11
10
  import { deliverBackgroundTasksPrompt } from "./prompt.js";
12
11
 
13
12
  export default function backgroundTasksExtension(pi: ExtensionAPI): void {
13
+ // Each cached factory invocation owns its runtime, failure timers, navigator
14
+ // projection, and durable host. Nothing here is shared module state (#460).
15
+ const runtime = createBackgroundTaskRuntime();
16
+ const navigator = createBackgroundTasksNavigator(runtime);
14
17
  // Created before registerTools so the tools can resolve it lazily; the durable
15
18
  // store opens on session activation and closes on switch/unload.
16
19
  const observedWaits = createObservedWaitHost(pi, {
@@ -41,29 +44,27 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
41
44
  // would then carry a snapshot, so sections published after it never arrive.
42
45
  pi.on("before_agent_start", (event) => deliverBackgroundTasksPrompt(event));
43
46
  registerBackgroundTasksGoalProvider(pi);
44
- ensureBackgroundTasksNavigatorProvider(pi);
47
+ navigator.ensureBackgroundTasksNavigatorProvider(pi);
45
48
  // Subscribed at load so a sandbox extension that publishes later is heard, and
46
49
  // asked for a snapshot in case one published before this extension existed.
47
50
  observeForegroundSandboxPolicy(pi);
48
51
  // Registered before registerTools(pi), so this runs before running tasks are resumed.
49
52
  pi.on("session_start", async (_event, ctx) => {
50
- resumeScheduledWork();
53
+ runtime.resumeScheduledWork();
51
54
  registerBackgroundTasksGoalProvider(pi);
52
- ensureBackgroundTasksNavigator(ctx);
55
+ navigator.ensureBackgroundTasksNavigator(ctx);
53
56
  });
54
57
  pi.on("session_before_switch", async () => {
55
58
  observedWaits.close();
56
- clearBackgroundTasksNavigatorSession();
57
- disposeBackgroundWorkNavigator();
59
+ navigator.clearBackgroundTasksNavigatorSession();
58
60
  });
59
61
  pi.on("session_shutdown", async (_event, ctx) => {
60
62
  // /reload, session replacement, and quit load a fresh instance; stop this one's timers (#324).
61
63
  observedWaits.close();
62
- suspendScheduledWork();
63
- clearBackgroundTasksNavigatorSession();
64
- disposeBackgroundWorkNavigator(ctx);
64
+ runtime.suspendScheduledWork();
65
+ navigator.clearBackgroundTasksNavigatorSession();
65
66
  });
66
- registerTools(pi);
67
+ registerTools(pi, runtime, navigator.refreshBackgroundTasksNavigator);
67
68
  // Registered after registerTools so the callback batcher has its session
68
69
  // context before a reconciled delivery can enqueue a parent notification.
69
70
  pi.on("session_start", async (_event, ctx) => {
@@ -75,4 +76,4 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
75
76
  pi.on("session_tree", async (_event, ctx) => {
76
77
  observedWaits.noteParentContext(ctx);
77
78
  });
78
- }
79
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
3
  DEFAULT_LOG_TAIL_ROWS as NAVIGATOR_DETAIL_ROWS,
4
+ disposeBackgroundWorkNavigator,
5
+ isNavigatorUiAvailable,
4
6
  ensureBackgroundWorkNavigator,
5
7
  refreshBackgroundWorkNavigator,
6
8
  registerBackgroundWorkProvider,
@@ -15,16 +17,25 @@ import { failurePath, failureView } from "./failures.js";
15
17
  import { readLog } from "./logs.js";
16
18
  import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
17
19
  import { getObservedWaitHost, type DurableTaskProjection } from "./observed-waits.js";
18
- import { stopTask } from "./runtime.js";
20
+ import { stopTask, type BackgroundTaskRuntime } from "./runtime.js";
19
21
  import { formatBackgroundTaskStall, observeBackgroundTaskStall } from "./stall.js";
20
22
  import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
21
23
 
24
+ const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
25
+
26
+ export function createBackgroundTasksNavigator(runtime: Pick<BackgroundTaskRuntime, "stopTask"> = { stopTask }) {
27
+ const owner = Symbol("background-tasks navigator factory");
22
28
  let unregister: (() => void) | undefined;
29
+ let navigatorContext: ExtensionContext | undefined;
23
30
  let piRef: ExtensionAPI | undefined;
24
31
  let activeNavigatorOrigin: BackgroundTaskCallbackOrigin | undefined;
25
- const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
26
-
27
- export function ensureBackgroundTasksNavigatorProvider(pi: ExtensionAPI): void {
32
+ function ensureBackgroundTasksNavigatorProvider(pi: ExtensionAPI): void {
33
+ // Provider registration is deferred until an actual TUI session owns it, so a
34
+ // headless child sharing the cached module cannot replace the parent's provider.
35
+ if (!navigatorContext) {
36
+ piRef = pi;
37
+ return;
38
+ }
28
39
  // A reloaded or replaced extension instance gets a fresh host. Re-register so
29
40
  // the provider's durable-change subscription targets that host, not the one
30
41
  // captured by the previous instance.
@@ -37,22 +48,33 @@ export function ensureBackgroundTasksNavigatorProvider(pi: ExtensionAPI): void {
37
48
  unregister = registerBackgroundWorkProvider(provider);
38
49
  }
39
50
 
40
- export function ensureBackgroundTasksNavigator(ctx: ExtensionContext): void {
51
+ function ensureBackgroundTasksNavigator(ctx: ExtensionContext): void {
52
+ if (!isNavigatorUiAvailable(ctx)) return;
53
+ navigatorContext = ctx;
54
+ if (piRef) ensureBackgroundTasksNavigatorProvider(piRef);
41
55
  activeNavigatorOrigin = getNavigatorOrigin(ctx);
42
56
  ensureBackgroundWorkNavigator(ctx, {
43
57
  createDefaultEditor: (tui, theme, keybindings) => new CustomEditor(tui as never, theme as never, keybindings as never),
44
58
  isOpenTrigger: (data) => matchesKey(data, Key.left),
45
59
  matchKey: (data, keyId) => matchesKey(data, keyId as Parameters<typeof matchesKey>[1]),
46
60
  truncate: truncateToWidth,
47
- });
61
+ }, owner);
48
62
  }
49
63
 
50
- export function clearBackgroundTasksNavigatorSession(): void {
64
+ function clearBackgroundTasksNavigatorSession(): void {
51
65
  activeNavigatorOrigin = undefined;
66
+ unregister?.();
67
+ unregister = undefined;
68
+ if (navigatorContext) disposeBackgroundWorkNavigator(navigatorContext, owner);
69
+ navigatorContext = undefined;
52
70
  }
53
71
 
54
- export function refreshBackgroundTasksNavigator(ctx?: ExtensionContext): void {
55
- refreshBackgroundWorkNavigator(ctx);
72
+ function refreshBackgroundTasksNavigator(ctx?: ExtensionContext): void {
73
+ // This factory owns no navigator until a TUI session installs it; a refresh from
74
+ // a non-TUI tool context then repaints the stored owner context (main #332).
75
+ const ownerCtx = navigatorContext;
76
+ if (!ownerCtx) return;
77
+ refreshBackgroundWorkNavigator(ctx && isNavigatorUiAvailable(ctx) ? ctx : ownerCtx);
56
78
  }
57
79
 
58
80
  const provider: BackgroundWorkProvider = {
@@ -92,7 +114,7 @@ const provider: BackgroundWorkProvider = {
92
114
  const meta = readMeta(id);
93
115
  if (!meta) return { action: "missing", providerId: "background-tasks", id };
94
116
  if (meta.status === "running") {
95
- void stopTask(piRef as ExtensionAPI, id);
117
+ void runtime.stopTask(piRef as ExtensionAPI, id);
96
118
  return { action: "stopped", providerId: "background-tasks", id, status: "stopping" };
97
119
  }
98
120
  meta.dismissedAt = Date.now();
@@ -288,6 +310,11 @@ function belongsToActiveNavigatorSession(meta: BackgroundTaskMeta): boolean {
288
310
  return meta.cwd === active.cwd;
289
311
  }
290
312
 
313
+ return { ensureBackgroundTasksNavigatorProvider, ensureBackgroundTasksNavigator, clearBackgroundTasksNavigatorSession, refreshBackgroundTasksNavigator };
314
+ }
315
+ const defaultNavigator = createBackgroundTasksNavigator();
316
+ export const { ensureBackgroundTasksNavigatorProvider, ensureBackgroundTasksNavigator, clearBackgroundTasksNavigatorSession, refreshBackgroundTasksNavigator } = defaultNavigator;
317
+
291
318
  function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): boolean {
292
319
  if (meta.status === "running") return false;
293
320
  const endedAt = meta.endedAt;
@@ -10,7 +10,7 @@ import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.j
10
10
  import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
11
11
  import { assertBackgroundExecutionAllowed, backgroundExecutionLimits, confineCommandSpec, isCommandTimeoutSeconds, resolveForegroundSandboxPlan, sandboxProvenance } from "./sandbox.js";
12
12
  import { takeRecoverySnapshot } from "./shared-sandbox-core.js";
13
- import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention, terminalFailureAttention } from "./failures.js";
13
+ import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, createFailureAttention, defaultAttention, terminalFailureAttention } from "./failures.js";
14
14
  import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
15
15
  import { getCallbackBatcher } from "./shared-callback-batcher.js";
16
16
  import { formatCallbackFacts } from "./output.js";
@@ -29,6 +29,89 @@ import type {
29
29
  import { observeTaskStallAttention } from "./stall-attention.js";
30
30
  import { isTerminalStatus } from "./types.js";
31
31
 
32
+ export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
33
+
34
+ /** Structured intent (#325), declared by the launching agent and shared by spawns and watchers. */
35
+ export interface TaskIntentParams {
36
+ /** Stable id shared by modified retries of one operation. */
37
+ operation_id?: string | null;
38
+ /** Non-zero exit codes declared intentional before launch. */
39
+ expected_exit_codes?: number[] | null;
40
+ }
41
+
42
+ export interface SpawnTaskParams extends CommandSpec, TaskIntentParams {
43
+ name?: string;
44
+ /**
45
+ * Batch identity assigned by the batch launcher, never declared by a caller.
46
+ * Absent for a single spawn; carried into the persisted metadata so batch
47
+ * membership survives a reload instead of living only in the launch result.
48
+ */
49
+ batchId?: string;
50
+ /** Batch label from the same launch, when the operator gave one. */
51
+ batchName?: string;
52
+ callback?: boolean;
53
+ timeout_seconds?: number;
54
+ max_log_bytes?: number;
55
+ /**
56
+ * Opt-in step gate (#619): run the command under bash errexit, so the first
57
+ * unhandled failing step ends the task with that step's exit code and a later
58
+ * command cannot mask it.
59
+ */
60
+ require_successful_steps?: boolean;
61
+ ssh?: SshConnectionParams;
62
+ remote?: RemoteTaskParams;
63
+ }
64
+
65
+ export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
66
+ name?: string;
67
+ callback?: boolean;
68
+ interval_seconds?: number;
69
+ timeout_seconds?: number;
70
+ max_log_bytes?: number;
71
+ success_when: Condition;
72
+ failure_when?: Condition;
73
+ /**
74
+ * Rejected (#619): a watch check is one polled command whose own exit code is the
75
+ * outcome, so the step gate cannot apply. Accepted in the shared schema only where
76
+ * the same wrapper also spawns.
77
+ */
78
+ require_successful_steps?: boolean;
79
+ /** Consecutive blind checks before the watch is flagged (#359). Default 3; 0 turns it off. */
80
+ blind_checks?: number;
81
+ ssh?: SshConnectionParams;
82
+ remote?: RemoteTaskParams;
83
+ }
84
+
85
+ type WatchPollRunner = (timeoutMs?: number) => Promise<CommandResult>;
86
+
87
+ export interface TaskRuntimeDependencies {
88
+ remoteRunner?: RemoteRunner;
89
+ /**
90
+ * Embedding/test seam for a local watch's poll command. Production polls run
91
+ * the persisted command through `runCommandOnce`; a remote watch keeps using
92
+ * its SSH runner instead.
93
+ */
94
+ watchPollRunner?: WatchPollRunner;
95
+ }
96
+
97
+ export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
98
+ /** Consecutive blind checks (exit 0, stderr, no condition matched) before a watch is flagged (#359). */
99
+ export const DEFAULT_BLIND_CHECKS = 3;
100
+ /** How long bg_task_watch waits for the first check before returning (#359). */
101
+ export const FIRST_WATCH_CHECK_WAIT_MS = 15_000;
102
+ /** How to fix a false alarm: some tools write progress or warnings to stderr on success. */
103
+ export const BLIND_CHECK_HINT = "If the stderr is expected (progress or warnings), redirect it (2>/dev/null) or set blind_checks:0.";
104
+ /**
105
+ * Why the launch stopped waiting before the first check finished (#359): the bounded wait
106
+ * ran out, the tool call was aborted (Esc), or the session shut down.
107
+ */
108
+ export type FirstCheckWaitEnd = "timeout" | "aborted" | "suspended";
109
+ /** A first check's outcome, or why it is not known yet; undefined when the watch ended without one. */
110
+ export type FirstCheckOutcome = FirstWatchCheck | { pending: FirstCheckWaitEnd; waitedMs: number } | undefined;
111
+
112
+ /** Each cached extension factory owns its scheduling and suspension state. */
113
+ export function createBackgroundTaskRuntime(attention = createFailureAttention()) {
114
+ const { resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention } = attention;
32
115
  const watcherTimers = new Map<string, ReturnType<typeof setTimeout>>();
33
116
  const remoteSessionTimers = new Map<string, ReturnType<typeof setTimeout>>();
34
117
  const processTimeoutTimers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -49,22 +132,7 @@ const HANDOFF_LOST_GRACE_MS = 5_000;
49
132
  let scheduledWorkSuspended = false;
50
133
  const REMOTE_SESSION_POLL_MS = 100;
51
134
 
52
- export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
53
- /** Consecutive blind checks (exit 0, stderr, no condition matched) before a watch is flagged (#359). */
54
- export const DEFAULT_BLIND_CHECKS = 3;
55
- /** How long bg_task_watch waits for the first check before returning (#359). */
56
- export const FIRST_WATCH_CHECK_WAIT_MS = 15_000;
57
135
  const BLIND_OPERATION = "watch-blind";
58
- /** How to fix a false alarm: some tools write progress or warnings to stderr on success. */
59
- export const BLIND_CHECK_HINT = "If the stderr is expected (progress or warnings), redirect it (2>/dev/null) or set blind_checks:0.";
60
-
61
- /**
62
- * Why the launch stopped waiting before the first check finished (#359): the bounded wait
63
- * ran out, the tool call was aborted (Esc), or the session shut down.
64
- */
65
- export type FirstCheckWaitEnd = "timeout" | "aborted" | "suspended";
66
- /** A first check's outcome, or why it is not known yet; undefined when the watch ended without one. */
67
- export type FirstCheckOutcome = FirstWatchCheck | { pending: FirstCheckWaitEnd; waitedMs: number } | undefined;
68
136
 
69
137
  /** In-flight first checks of watches launched by this instance, keyed by task id. */
70
138
  const firstCheckWaiters = new Map<string, { promise: Promise<FirstWatchCheck | "suspended" | undefined>; resolve: (check: FirstWatchCheck | "suspended" | undefined) => void }>();
@@ -74,7 +142,7 @@ const firstCheckWaiters = new Map<string, { promise: Promise<FirstWatchCheck | "
74
142
  * outcome when the wait ends first, and undefined when this instance did not launch the watch
75
143
  * or the watch ended without a check.
76
144
  */
77
- export async function awaitFirstWatchCheck(id: string, timeoutMs = FIRST_WATCH_CHECK_WAIT_MS, signal?: AbortSignal): Promise<FirstCheckOutcome> {
145
+ async function awaitFirstWatchCheck(id: string, timeoutMs = FIRST_WATCH_CHECK_WAIT_MS, signal?: AbortSignal): Promise<FirstCheckOutcome> {
78
146
  const waiter = firstCheckWaiters.get(id);
79
147
  if (!waiter) return undefined;
80
148
  const started = Date.now();
@@ -132,7 +200,7 @@ function settleFirstCheck(id: string, check: FirstWatchCheck | "suspended" | und
132
200
  * is recorded as terminal and its callback is delivered when its session resumes;
133
201
  * after Pi quits, a resumed task whose process is gone is marked lost instead.
134
202
  */
135
- export function suspendScheduledWork(): void {
203
+ function suspendScheduledWork(): void {
136
204
  scheduledWorkSuspended = true;
137
205
  for (const timer of watcherTimers.values()) clearTimeout(timer);
138
206
  for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
@@ -150,76 +218,12 @@ export function suspendScheduledWork(): void {
150
218
  }
151
219
 
152
220
  /** Allow scheduling again; called at session_start before running tasks are resumed. */
153
- export function resumeScheduledWork(): void {
221
+ function resumeScheduledWork(): void {
154
222
  scheduledWorkSuspended = false;
155
223
  resumeFailureAttention();
156
224
  }
157
225
 
158
226
 
159
-
160
- export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
161
-
162
- /** Structured intent (#325), declared by the launching agent and shared by spawns and watchers. */
163
- export interface TaskIntentParams {
164
- /** Stable id shared by modified retries of one operation. */
165
- operation_id?: string | null;
166
- /** Non-zero exit codes declared intentional before launch. */
167
- expected_exit_codes?: number[] | null;
168
- }
169
-
170
- export interface SpawnTaskParams extends CommandSpec, TaskIntentParams {
171
- name?: string;
172
- /**
173
- * Batch identity assigned by the batch launcher, never declared by a caller.
174
- * Absent for a single spawn; carried into the persisted metadata so batch
175
- * membership survives a reload instead of living only in the launch result.
176
- */
177
- batchId?: string;
178
- /** Batch label from the same launch, when the operator gave one. */
179
- batchName?: string;
180
- callback?: boolean;
181
- timeout_seconds?: number;
182
- max_log_bytes?: number;
183
- /**
184
- * Opt-in step gate (#619): run the command under bash errexit, so the first
185
- * unhandled failing step ends the task with that step's exit code and a later
186
- * command cannot mask it.
187
- */
188
- require_successful_steps?: boolean;
189
- ssh?: SshConnectionParams;
190
- remote?: RemoteTaskParams;
191
- }
192
-
193
- export interface WatchTaskParams extends CommandSpec, TaskIntentParams {
194
- name?: string;
195
- callback?: boolean;
196
- interval_seconds?: number;
197
- timeout_seconds?: number;
198
- max_log_bytes?: number;
199
- success_when: Condition;
200
- failure_when?: Condition;
201
- /**
202
- * Rejected (#619): a watch check is one polled command whose own exit code is the
203
- * outcome, so the step gate cannot apply. Accepted in the shared schema only where
204
- * the same wrapper also spawns.
205
- */
206
- require_successful_steps?: boolean;
207
- /** Consecutive blind checks before the watch is flagged (#359). Default 3; 0 turns it off. */
208
- blind_checks?: number;
209
- ssh?: SshConnectionParams;
210
- remote?: RemoteTaskParams;
211
- }
212
-
213
- export interface TaskRuntimeDependencies {
214
- remoteRunner?: RemoteRunner;
215
- /**
216
- * Embedding/test seam for a local watch's poll command. Production polls run
217
- * the persisted command through `runCommandOnce`; a remote watch keeps using
218
- * its SSH runner instead.
219
- */
220
- watchPollRunner?: WatchPollRunner;
221
- }
222
-
223
227
  /**
224
228
  * Persist the launch-time per-command cap with the task metadata. Reload and
225
229
  * resume go through `readMeta`, so the value travels as ordinary JSON and the
@@ -286,9 +290,7 @@ function invalidPersistedCommandCap(meta: BackgroundTaskMeta): string | undefine
286
290
  return `Persisted command time limit ${JSON.stringify(meta.commandTimeoutSeconds)} is invalid; the task was not resumed with an unlimited command.`;
287
291
  }
288
292
 
289
- type WatchPollRunner = (timeoutMs?: number) => Promise<CommandResult>;
290
-
291
- export function spawnTask(
293
+ function spawnTask(
292
294
  pi: ExtensionAPI,
293
295
  params: SpawnTaskParams,
294
296
  defaultCwd: string,
@@ -601,7 +603,7 @@ function failRemoteTask(
601
603
  finalize(meta, { status: "failed", reason }, pi, getActiveSession);
602
604
  }
603
605
 
604
- export function startWatchTask(
606
+ function startWatchTask(
605
607
  pi: ExtensionAPI,
606
608
  params: WatchTaskParams,
607
609
  defaultCwd: string,
@@ -698,7 +700,7 @@ function resolveWatchTimeoutSeconds(timeoutSeconds: number | undefined): number
698
700
  return timeoutSeconds;
699
701
  }
700
702
 
701
- export function resumeRunningTask(
703
+ function resumeRunningTask(
702
704
  pi: ExtensionAPI,
703
705
  meta: BackgroundTaskMeta,
704
706
  getActiveSession?: ActiveSessionProvider,
@@ -859,7 +861,7 @@ function resolvePersistedRemoteTask(
859
861
  }, remoteRunner);
860
862
  }
861
863
 
862
- export async function stopTask(
864
+ async function stopTask(
863
865
  pi: ExtensionAPI,
864
866
  id: string,
865
867
  getActiveSession?: ActiveSessionProvider,
@@ -1608,3 +1610,11 @@ function observeWatchOutcome(meta: BackgroundTaskMeta, result: CommandResult): v
1608
1610
  }
1609
1611
  if (prior !== fingerprint) meta.lastProgressAt = meta.lastCheckedAt ?? Date.now();
1610
1612
  }
1613
+
1614
+ return { awaitFirstWatchCheck, suspendScheduledWork, resumeScheduledWork, spawnTask, startWatchTask, resumeRunningTask, stopTask };
1615
+ }
1616
+
1617
+ export type BackgroundTaskRuntime = ReturnType<typeof createBackgroundTaskRuntime>;
1618
+ const defaultRuntime = createBackgroundTaskRuntime(defaultAttention);
1619
+ /** Compatibility for callers using the original singleton API. */
1620
+ export const { awaitFirstWatchCheck, suspendScheduledWork, resumeScheduledWork, spawnTask, startWatchTask, resumeRunningTask, stopTask } = defaultRuntime;
@@ -82,8 +82,10 @@ type HostDeps = {
82
82
 
83
83
  type NavigatorState = {
84
84
  providers: Map<string, BackgroundWorkProvider>;
85
+ registrationTokens?: Map<string, symbol>;
85
86
  unsubscribers: Map<string, () => void>;
86
87
  uiCtx?: ExtensionContext;
88
+ uiOwner?: symbol;
87
89
  deps?: HostDeps;
88
90
  lastHint?: string | null;
89
91
  lastMainListLines?: string[];
@@ -137,6 +139,8 @@ function state(): NavigatorState {
137
139
 
138
140
  export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
139
141
  const s = state();
142
+ const token = Symbol(provider.id);
143
+ (s.registrationTokens ??= new Map()).set(provider.id, token);
140
144
  const previousUnsub = s.unsubscribers.get(provider.id);
141
145
  if (previousUnsub) {
142
146
  try { previousUnsub(); } catch { /* ignore */ }
@@ -152,7 +156,10 @@ export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider)
152
156
  s.unsubscribers.delete(provider.id);
153
157
  }
154
158
  refreshBackgroundWorkNavigator();
155
- return () => unregisterBackgroundWorkProvider(provider.id);
159
+ // A stale factory must not unregister its replacement from another cached module.
160
+ return () => {
161
+ if (state().registrationTokens?.get(provider.id) === token) unregisterBackgroundWorkProvider(provider.id);
162
+ };
156
163
  }
157
164
 
158
165
  export function unregisterBackgroundWorkProvider(id: string): void {
@@ -163,6 +170,7 @@ export function unregisterBackgroundWorkProvider(id: string): void {
163
170
  }
164
171
  s.unsubscribers.delete(id);
165
172
  s.providers.delete(id);
173
+ s.registrationTokens?.delete(id);
166
174
  refreshBackgroundWorkNavigator();
167
175
  }
168
176
 
@@ -188,10 +196,11 @@ export function applyCloseConfirmFooter(ui: { setStatus(key: string, value: stri
188
196
  return hint ?? null;
189
197
  }
190
198
 
191
- export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps): void {
199
+ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostDeps, owner?: symbol): void {
192
200
  if (!isNavigatorUiAvailable(ctx)) return;
193
201
  const s = state();
194
202
  s.uiCtx = ctx;
203
+ s.uiOwner = owner;
195
204
  s.deps = {
196
205
  ...deps,
197
206
  createTranscriptComponent: deps.createTranscriptComponent ?? s.deps?.createTranscriptComponent,
@@ -207,14 +216,26 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
207
216
  refreshMainListWidget();
208
217
  }
209
218
 
210
- export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
219
+ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext, owner?: symbol): void {
211
220
  const s = state();
221
+ if (owner !== undefined) {
222
+ // Only the factory that installed the current navigator may dispose it.
223
+ if (s.uiOwner !== owner) return;
224
+ } else if (s.uiOwner !== undefined) {
225
+ // Ownership is never inferred: an omitted owner must not clear a navigator
226
+ // another cached factory (or a headless child) owns.
227
+ return;
228
+ } else if (ctx !== undefined) {
229
+ // Unowned navigators stay releasable by a live, matching UI context.
230
+ if (!isNavigatorUiAvailable(ctx) || (s.uiCtx && ctx.ui !== s.uiCtx.ui)) return;
231
+ }
212
232
  try { s.dispose?.(); } catch { /* ignore */ }
213
233
  s.dispose = undefined;
214
234
  s.overlay = undefined;
215
235
  stopMainListWidget();
216
236
  const activeCtx = ctx ?? s.uiCtx;
217
237
  s.uiCtx = undefined;
238
+ s.uiOwner = undefined;
218
239
  if (activeCtx && isNavigatorUiAvailable(activeCtx)) {
219
240
  try { setNavigatorPanel(activeCtx, MAIN_LIST_WIDGET_KEY, undefined); } catch { /* ignore */ }
220
241
  }