pi-crew 0.1.38 → 0.1.40

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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.40
6
+
7
+ ### Added
8
+
9
+ - Added owner-session generation guards for background subagents, async run notifications, result watchers, and live-session callbacks so stale sessions do not receive completions.
10
+ - Added `runtime.requirePlanApproval` with approve/cancel API support to gate mutating adaptive implementation tasks behind an explicit planner artifact approval.
11
+ - Added shared secret redaction for event logs, mailbox persistence, artifacts, JSONL streams, agent records, notifications, metrics, and diagnostics.
12
+
13
+ ### Changed
14
+
15
+ - Project-local agents, teams, and workflows can no longer shadow builtin or user resources with the same name.
16
+ - Project-level sensitive config such as worker execution, runtime mode, autonomy, agent overrides, worktree setup hooks, and OTLP headers is ignored with warnings unless configured in trusted user scope.
17
+
18
+ ### Fixed
19
+
20
+ - Fixed lost async completion notifications after auto-compaction/session restart by continuing to track active runs across notifier restarts.
21
+ - Fixed stale background subagent wakeups after session switch/shutdown while preserving terminal results for explicit joins.
22
+ - Fixed resume bypasses in plan approval by re-gating persisted mutating adaptive tasks when approval state is missing or pending.
23
+ - Restricted plan approval and cancellation to non-read-only roles and rejected cancel/approve after the approval state is no longer pending.
24
+
25
+ ## 0.1.39
26
+
27
+ ### Fixed
28
+
29
+ - Made CI test execution deterministic across Node 22/macOS/Linux/Windows by running Node test files sequentially to avoid cross-file environment races.
30
+ - Fixed live-agent durable control symlink-file rejection to return an API error instead of throwing from the tool handler.
31
+ - Tightened symlink artifact security assertions so tests check leaked file contents rather than safe metadata paths.
32
+
5
33
  ## 0.1.38
6
34
 
7
35
  ### Added
package/README.md CHANGED
@@ -25,13 +25,14 @@ Current highlights:
25
25
  - metadata-aware `recommend` action for routing, decomposition, fanout hints, async/worktree suggestions
26
26
  - configurable autonomy profiles: `manual`, `suggested`, `assisted`, `aggressive`
27
27
  - builtin agents, teams, and workflows
28
- - user/project/builtin resource discovery with priority `builtin < user < project`
28
+ - user/project/builtin resource discovery where user resources override builtin resources, and project resources cannot shadow trusted user/builtin names
29
29
  - resource format support for routing metadata: `triggers`, `useWhen`, `avoidWhen`, `cost`, `category`
30
30
  - durable run state: manifest, tasks, events, artifacts, imports/exports
31
31
  - foreground workflow scheduler
32
32
  - detached async/background runner
33
33
  - stale async PID detection
34
34
  - active run summary and async completion notifications in Pi sessions
35
+ - owner-session delivery guards so stale sessions do not receive background subagent/result/live-session completions
35
36
  - real child Pi worker execution by default, with explicit scaffold/dry-run opt-out
36
37
  - child Pi JSON output parsing for final text, usage, and event counts
37
38
  - retryable model fallback attempts per task
@@ -58,6 +59,8 @@ Current highlights:
58
59
  - observability metrics: per-session Counter/Gauge/Histogram registry, JSONL sink, `/team-metrics`, dashboard metrics pane, Prometheus/OTLP exporters (OTLP opt-in)
59
60
  - reliability hardening: heartbeat gradient watcher, opt-in retry executor with attempt trace, crash-recovery detection, deadletter queue
60
61
  - background `Agent`/`crew_agent` completion wake-up so parent sessions can automatically join completed subagent results
62
+ - optional `runtime.requirePlanApproval` gate for planner-first approval before mutating adaptive implementation workers run
63
+ - shared redaction for common secrets before durable event/log/mailbox/artifact/metric/diagnostic persistence
61
64
  - package polish: `schema.json`, TypeScript semantic check, strip-types import smoke, cross-platform CI workflow, dry-run package verification
62
65
 
63
66
  ## Install
@@ -146,9 +149,13 @@ The project root is auto-detected by walking up from the current directory and s
146
149
  Config merge priority:
147
150
 
148
151
  ```text
149
- user < project
152
+ user < project for ordinary presentation/UX settings
150
153
  ```
151
154
 
155
+ Trust-boundary exception: project config is intentionally not trusted for sensitive execution controls. Project-level values such as `executeWorkers`, `asyncByDefault`, runtime mode/live-session inheritance, autonomy mode, `agents.disableBuiltins`, `agents.overrides`, `worktree.setupHook`, and `otlp.headers` are ignored with warnings. Set those in user config when you want to trust them explicitly.
156
+
157
+ Resource discovery trust boundary: project-local agents, teams, and workflows may add new names, but cannot shadow builtin or user resources with the same name.
158
+
152
159
  Supported config:
153
160
 
154
161
  ```json
@@ -167,6 +174,10 @@ Supported config:
167
174
  "review": ["review", "audit", "inspect"]
168
175
  }
169
176
  },
177
+ "runtime": {
178
+ "mode": "auto",
179
+ "requirePlanApproval": false
180
+ },
170
181
  "limits": {
171
182
  "maxConcurrentWorkers": 3,
172
183
  "maxTaskDepth": 2,
@@ -222,9 +233,11 @@ Supported config:
222
233
  Safety notes:
223
234
 
224
235
  - Foreground child-process runs continue in the Pi extension process and return control to chat immediately, so large workflows do not block the interactive session. They are interrupted on session shutdown. Use `async: true` only for intentionally detached runs that may survive the current session.
236
+ - Async completion notifications survive extension reload/auto-compaction: active runs are not marked consumed just because the notifier restarts, while stale owner-session callbacks are suppressed after session switches.
225
237
  - Background `Agent`/`crew_agent` runs notify the parent session when they reach a terminal state; the parent can then call `get_subagent_result`/`crew_agent_result` and continue the original task.
226
238
  - `tools.terminateOnForeground` is an opt-in power-user setting. When true, foreground `Agent`/`crew_agent` calls return with `terminate: true` after the child result is available, saving one follow-up LLM turn. Default is false so the assistant can still summarize raw worker output.
227
239
  - Runtime state paths are treated as untrusted data: run ids, import bundles, artifact/transcript paths, mailbox files, and agent control/log files are validated with containment checks before reads or writes.
240
+ - Common secret patterns (`token=`, `apiKey=`, `Authorization: Bearer ...`, private keys, etc.) are redacted before durable logs/events/mailbox/artifacts/metrics/diagnostics are written.
228
241
  - `observability.enabled` defaults to true for in-memory metrics and heartbeat watching. Metric JSONL snapshots are gated by `telemetry.enabled`; set `telemetry.enabled=false` to opt out of local telemetry files.
229
242
  - `reliability.autoRetry` and `reliability.autoRecover` default to false. Enabling retry may execute an idempotent task more than once; each attempt is recorded in `task.attempts`, and exhausted retries append a deadletter entry.
230
243
  - `otlp.enabled` defaults to false. Configure `otlp.endpoint` only when you want to push metrics to an OTLP HTTP collector.
@@ -320,7 +333,7 @@ Supported actions:
320
333
  | `config` | Show/update config |
321
334
  | `init` | Create project `.pi` layout and update `.gitignore` |
322
335
  | `autonomy` | Show/update autonomous delegation settings |
323
- | `api` | Safe interop for run/task/event/heartbeat/claim/mailbox state |
336
+ | `api` | Safe interop for run/task/event/heartbeat/claim/mailbox state, including plan approval/cancel operations |
324
337
  | `help` | Show help text |
325
338
 
326
339
  ## Example tool calls
@@ -358,6 +371,30 @@ Run with worktrees:
358
371
  }
359
372
  ```
360
373
 
374
+ Require explicit approval after the adaptive planner writes a plan artifact and before mutating workers run:
375
+
376
+ ```json
377
+ {
378
+ "action": "run",
379
+ "team": "implementation",
380
+ "workflow": "implementation",
381
+ "goal": "Refactor auth and update tests",
382
+ "config": {
383
+ "runtime": { "requirePlanApproval": true }
384
+ }
385
+ }
386
+ ```
387
+
388
+ Approve or cancel the pending plan:
389
+
390
+ ```json
391
+ {
392
+ "action": "api",
393
+ "runId": "team_...",
394
+ "config": { "operation": "approve-plan" }
395
+ }
396
+ ```
397
+
361
398
  Inspect a run:
362
399
 
363
400
  ```json
@@ -438,6 +475,8 @@ Manual slash commands are ops/debug controls. Autonomous tool use via policy/rec
438
475
  /team-api team_... ack-message messageId=msg_...
439
476
  /team-api team_... read-delivery
440
477
  /team-api team_... validate-mailbox repair=true
478
+ /team-api team_... approve-plan
479
+ /team-api team_... cancel-plan
441
480
  ```
442
481
 
443
482
  Use `/team-metrics` for a current metrics snapshot. The optional argument is a glob-style metric filter:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -47,8 +47,8 @@
47
47
  "ci": "npm run typecheck && npm test && npm pack --dry-run",
48
48
  "typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
49
49
  "test": "npm run test:unit && npm run test:integration",
50
- "test:unit": "node --experimental-strip-types --test --test-timeout=30000 test/unit/*.test.ts",
51
- "test:integration": "node --experimental-strip-types --test --test-timeout=120000 test/integration/*.test.ts",
50
+ "test:unit": "node --experimental-strip-types --test --test-concurrency=1 --test-timeout=30000 test/unit/*.test.ts",
51
+ "test:integration": "node --experimental-strip-types --test --test-concurrency=1 --test-timeout=120000 test/integration/*.test.ts",
52
52
  "smoke:pi": "pi install ."
53
53
  },
54
54
  "exports": {
package/schema.json CHANGED
@@ -68,7 +68,8 @@
68
68
  "graceTurns": { "type": "integer", "minimum": 1 },
69
69
  "inheritContext": { "type": "boolean" },
70
70
  "promptMode": { "type": "string", "enum": ["replace", "append"] },
71
- "groupJoin": { "type": "string", "enum": ["off", "group", "smart"] }
71
+ "groupJoin": { "type": "string", "enum": ["off", "group", "smart"] },
72
+ "requirePlanApproval": { "type": "boolean" }
72
73
  }
73
74
  },
74
75
  "control": {
@@ -95,7 +95,7 @@ export function discoverAgents(cwd: string): AgentDiscoveryResult {
95
95
 
96
96
  export function allAgents(discovery: AgentDiscoveryResult): AgentConfig[] {
97
97
  const byName = new Map<string, AgentConfig>();
98
- for (const agent of [...discovery.builtin, ...discovery.user, ...discovery.project]) {
98
+ for (const agent of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
99
99
  byName.set(agent.name.toLowerCase(), agent);
100
100
  }
101
101
  return [...byName.values()].filter((agent) => !agent.disabled).sort((a, b) => a.name.localeCompare(b.name));
@@ -39,6 +39,7 @@ export interface CrewRuntimeConfig {
39
39
  inheritContext?: boolean;
40
40
  promptMode?: "replace" | "append";
41
41
  groupJoin?: "off" | "group" | "smart";
42
+ requirePlanApproval?: boolean;
42
43
  }
43
44
 
44
45
  export interface CrewControlConfig {
@@ -206,6 +207,82 @@ function validateConfigWithWarnings(raw: unknown): string[] {
206
207
  return [];
207
208
  }
208
209
 
210
+ function projectOverrideWarning(projectPath: string, dottedPath: string): string {
211
+ return `${projectPath}: project-level sensitive config '${dottedPath}' is ignored; set it in user config to trust it explicitly`;
212
+ }
213
+
214
+ function sanitizeProjectConfig(projectPath: string, userConfig: PiTeamsConfig, config: PiTeamsConfig): ConfigValidationResult {
215
+ const sanitized: PiTeamsConfig = { ...config };
216
+ const warnings: string[] = [];
217
+ const dropTopLevel = (key: keyof PiTeamsConfig): void => {
218
+ if (config[key] === undefined) return;
219
+ delete sanitized[key];
220
+ warnings.push(projectOverrideWarning(projectPath, String(key)));
221
+ };
222
+ dropTopLevel("executeWorkers");
223
+ dropTopLevel("asyncByDefault");
224
+ dropTopLevel("requireCleanWorktreeLeader");
225
+ if (config.runtime) {
226
+ const runtime = { ...config.runtime };
227
+ for (const key of ["mode", "preferLiveSession", "allowChildProcessFallback", "inheritContext"] as const) {
228
+ if (runtime[key] !== undefined) {
229
+ delete runtime[key];
230
+ warnings.push(projectOverrideWarning(projectPath, `runtime.${key}`));
231
+ }
232
+ }
233
+ if (runtime.requirePlanApproval === false) {
234
+ delete runtime.requirePlanApproval;
235
+ warnings.push(projectOverrideWarning(projectPath, "runtime.requirePlanApproval"));
236
+ }
237
+ sanitized.runtime = Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
238
+ }
239
+ if (config.autonomous) {
240
+ const autonomous = { ...config.autonomous };
241
+ for (const key of ["profile", "enabled", "injectPolicy", "preferAsyncForLongTasks", "allowWorktreeSuggestion"] as const) {
242
+ if (autonomous[key] !== undefined) {
243
+ delete autonomous[key];
244
+ warnings.push(projectOverrideWarning(projectPath, `autonomous.${key}`));
245
+ }
246
+ }
247
+ sanitized.autonomous = Object.values(autonomous).some((entry) => entry !== undefined) ? autonomous : undefined;
248
+ }
249
+ if (config.worktree?.setupHook !== undefined) {
250
+ sanitized.worktree = { ...config.worktree, setupHook: undefined };
251
+ if (!Object.values(sanitized.worktree).some((entry) => entry !== undefined)) sanitized.worktree = undefined;
252
+ warnings.push(projectOverrideWarning(projectPath, "worktree.setupHook"));
253
+ }
254
+ if (config.otlp?.headers !== undefined) {
255
+ sanitized.otlp = { ...config.otlp, headers: undefined };
256
+ if (!Object.values(sanitized.otlp).some((entry) => entry !== undefined)) sanitized.otlp = undefined;
257
+ warnings.push(projectOverrideWarning(projectPath, "otlp.headers"));
258
+ }
259
+ if (config.agents?.disableBuiltins !== undefined || config.agents?.overrides !== undefined) {
260
+ const agents = { ...config.agents };
261
+ if (agents.disableBuiltins !== undefined) {
262
+ delete agents.disableBuiltins;
263
+ warnings.push(projectOverrideWarning(projectPath, "agents.disableBuiltins"));
264
+ }
265
+ if (agents.overrides !== undefined) {
266
+ delete agents.overrides;
267
+ warnings.push(projectOverrideWarning(projectPath, "agents.overrides"));
268
+ }
269
+ sanitized.agents = Object.values(agents).some((entry) => entry !== undefined) ? agents : undefined;
270
+ }
271
+ if (config.tools?.enableSteer !== undefined || config.tools?.terminateOnForeground !== undefined) {
272
+ const tools = { ...config.tools };
273
+ if (tools.enableSteer !== undefined) {
274
+ delete tools.enableSteer;
275
+ warnings.push(projectOverrideWarning(projectPath, "tools.enableSteer"));
276
+ }
277
+ if (tools.terminateOnForeground !== undefined) {
278
+ delete tools.terminateOnForeground;
279
+ warnings.push(projectOverrideWarning(projectPath, "tools.terminateOnForeground"));
280
+ }
281
+ sanitized.tools = Object.values(tools).some((entry) => entry !== undefined) ? tools : undefined;
282
+ }
283
+ return { config: sanitized, warnings };
284
+ }
285
+
209
286
  function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfig {
210
287
  const merged: PiTeamsConfig = { ...base, ...withoutUndefined(override as Record<string, unknown>) };
211
288
  if (base.autonomous || override.autonomous) {
@@ -416,6 +493,7 @@ function parseRuntimeConfig(value: unknown): CrewRuntimeConfig | undefined {
416
493
  inheritContext: parseWithSchema(Type.Boolean(), obj.inheritContext),
417
494
  promptMode: parseWithSchema(Type.Union([Type.Literal("replace"), Type.Literal("append")]), obj.promptMode),
418
495
  groupJoin: parseWithSchema(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")]), obj.groupJoin),
496
+ requirePlanApproval: parseWithSchema(Type.Boolean(), obj.requirePlanApproval),
419
497
  };
420
498
  return Object.values(runtime).some((entry) => entry !== undefined) ? runtime : undefined;
421
499
  }
@@ -639,8 +717,9 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
639
717
  if (cwd) {
640
718
  const projectPath = projectConfigPath(cwd);
641
719
  const projectConfig = parseConfigWithWarnings(readConfigRecord(projectPath));
642
- warnings.push(...projectConfig.warnings.map((warning) => `${projectPath}: ${warning}`));
643
- config = mergeConfig(config, projectConfig.config);
720
+ const projectSafeConfig = sanitizeProjectConfig(projectPath, config, projectConfig.config);
721
+ warnings.push(...projectConfig.warnings.map((warning) => `${projectPath}: ${warning}`), ...projectSafeConfig.warnings);
722
+ config = mergeConfig(config, projectSafeConfig.config);
644
723
  }
645
724
  return { path: filePath, paths, config, warnings: warnings.length > 0 ? warnings : undefined };
646
725
  } catch (error) {
@@ -8,6 +8,13 @@ import { listRuns } from "./run-index.ts";
8
8
  export interface AsyncNotifierState {
9
9
  seenFinishedRunIds: Set<string>;
10
10
  interval?: ReturnType<typeof setInterval>;
11
+ generation?: number;
12
+ lastStoppedAtMs?: number;
13
+ }
14
+
15
+ export interface AsyncNotifierOptions {
16
+ generation?: number;
17
+ isCurrent?: (generation: number) => boolean;
11
18
  }
12
19
 
13
20
  function isFinished(status: string): boolean {
@@ -18,6 +25,12 @@ function isAsyncTerminalEvent(event: TeamEvent): boolean {
18
25
  return event.type === "async.completed" || event.type === "async.failed" || event.type === "async.died";
19
26
  }
20
27
 
28
+ function timeMs(value: string | undefined): number | undefined {
29
+ if (!value) return undefined;
30
+ const parsed = new Date(value).getTime();
31
+ return Number.isFinite(parsed) ? parsed : undefined;
32
+ }
33
+
21
34
  function latestEventAgeMs(events: TeamEvent[], now = Date.now()): number {
22
35
  const latest = events.at(-1);
23
36
  if (!latest) return Number.POSITIVE_INFINITY;
@@ -38,14 +51,21 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
38
51
  return failed;
39
52
  }
40
53
 
41
- export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifierState, intervalMs = 5000): void {
54
+ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifierState, intervalMs = 5000, options: AsyncNotifierOptions = {}): void {
42
55
  if (state.interval) clearInterval(state.interval);
56
+ const generation = options.generation ?? ((state.generation ?? 0) + 1);
57
+ state.generation = generation;
58
+ const startedAtMs = Date.now();
59
+ const staleBeforeMs = state.lastStoppedAtMs ?? startedAtMs;
43
60
  for (const run of listRuns(ctx.cwd)) {
44
- // Treat all pre-existing runs as seen. This avoids noisy error toasts when
45
- // an old active/stale run is later inspected and transitions to failed.
46
- state.seenFinishedRunIds.add(run.runId);
61
+ // Suppress only terminal runs that were already finished before this owner
62
+ // session (or before the previous session switch). Active runs must remain
63
+ // un-seen so completions during auto-compaction/session restart are delivered.
64
+ const updatedAtMs = timeMs(run.updatedAt) ?? 0;
65
+ if (isFinished(run.status) && updatedAtMs < staleBeforeMs) state.seenFinishedRunIds.add(run.runId);
47
66
  }
48
67
  state.interval = setInterval(() => {
68
+ if (options.isCurrent && !options.isCurrent(generation)) return;
49
69
  try {
50
70
  for (const run of listRuns(ctx.cwd).slice(0, 20)) {
51
71
  const current = markDeadAsyncRunIfNeeded(run) ?? run;
@@ -64,4 +84,6 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
64
84
  export function stopAsyncRunNotifier(state: AsyncNotifierState): void {
65
85
  if (state.interval) clearInterval(state.interval);
66
86
  state.interval = undefined;
87
+ state.generation = (state.generation ?? 0) + 1;
88
+ state.lastStoppedAtMs = Date.now();
67
89
  }
@@ -22,7 +22,7 @@ function readEntry(root: string, scope: "project" | "user", runId: string): Impo
22
22
  let summaryPath: string;
23
23
  try {
24
24
  const entryRoot = resolveRealContainedPath(root, runId);
25
- bundlePath = resolveRealContainedPath(root, path.join(entryRoot, "run-export.json"));
25
+ bundlePath = resolveRealContainedPath(root, path.join(runId, "run-export.json"));
26
26
  summaryPath = path.join(entryRoot, "README.md");
27
27
  } catch {
28
28
  return undefined;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { NotificationDescriptor } from "./notification-router.ts";
4
- import { redactSecrets } from "../runtime/diagnostic-export.ts";
4
+ import { redactSecrets } from "../utils/redaction.ts";
5
5
  import { logInternalError } from "../utils/internal-error.ts";
6
6
 
7
7
  export interface NotificationSink {
@@ -53,6 +53,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
53
53
  }
54
54
  const notifierState: AsyncNotifierState = { seenFinishedRunIds: new Set() };
55
55
  let currentCtx: ExtensionContext | undefined;
56
+ let sessionGeneration = 0;
56
57
  let rpcHandle: PiCrewRpcHandle | undefined;
57
58
  let cleanedUp = false;
58
59
  let manifestCache = createManifestCache(process.cwd());
@@ -153,6 +154,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
153
154
  sendFollowUp(pi, [notification.title, notification.body].filter((line): line is string => Boolean(line)).join("\n"));
154
155
  }
155
156
  };
157
+ const captureSessionGeneration = (): number => sessionGeneration;
158
+ const isOwnerSessionCurrent = (ownerGeneration: number | undefined): boolean => !cleanedUp && (ownerGeneration === undefined || ownerGeneration === sessionGeneration);
159
+ const isContextCurrent = (ctx: ExtensionContext, ownerGeneration: number): boolean => !cleanedUp && currentCtx === ctx && sessionGeneration === ownerGeneration;
156
160
  const subagentManager = new SubagentManager(
157
161
  4,
158
162
  (record) => {
@@ -170,6 +174,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
170
174
  });
171
175
  }
172
176
  if (!record.background || record.resultConsumed) return;
177
+ if (!isOwnerSessionCurrent(record.ownerSessionGeneration)) return;
173
178
  if (record.status === "completed" || record.status === "failed" || record.status === "cancelled" || record.status === "blocked" || record.status === "error") {
174
179
  const metadata = JSON.stringify({ id: record.id, status: record.status, type: record.type, runId: record.runId, description: record.description }, null, 2);
175
180
  const joinInstruction = [
@@ -186,6 +191,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
186
191
  },
187
192
  1000,
188
193
  (event, payload) => {
194
+ const ownerGeneration = typeof payload.ownerSessionGeneration === "number" ? payload.ownerSessionGeneration : undefined;
195
+ if (ownerGeneration !== undefined && !isOwnerSessionCurrent(ownerGeneration)) return;
189
196
  if (event === "subagent.stuck-blocked") {
190
197
  const id = typeof payload.id === "string" ? payload.id : "unknown";
191
198
  const runId = typeof payload.runId === "string" ? payload.runId : "unknown";
@@ -233,6 +240,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
233
240
  });
234
241
  };
235
242
  const startForegroundRun = (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string): void => {
243
+ const ownerGeneration = captureSessionGeneration();
236
244
  const controller = new AbortController();
237
245
  foregroundControllers.add(controller);
238
246
  if (ctx.hasUI) {
@@ -251,15 +259,16 @@ export function registerPiTeams(pi: ExtensionAPI): void {
251
259
  logInternalError("register.foreground-run-failure", statusError, `runId=${runId}`);
252
260
  }
253
261
  }
254
- ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
262
+ if (isContextCurrent(ctx, ownerGeneration)) ctx.ui.notify(`pi-crew foreground run failed: ${message}`, "error");
255
263
  })
256
264
  .finally(() => {
257
265
  foregroundControllers.delete(controller);
258
- if (ctx.hasUI) {
266
+ const ownerCurrent = isContextCurrent(ctx, ownerGeneration);
267
+ if (ownerCurrent && ctx.hasUI) {
259
268
  setWorkingIndicator(ctx);
260
269
  ctx.ui.setWorkingMessage();
261
270
  }
262
- if (runId) {
271
+ if (ownerCurrent && runId) {
263
272
  const loaded = loadRunManifestById(ctx.cwd, runId);
264
273
  const status = loaded?.manifest.status ?? "finished";
265
274
  const level = status === "failed" || status === "blocked" ? "error" : status === "cancelled" ? "warning" : "info";
@@ -287,7 +296,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
287
296
  });
288
297
  }
289
298
  }
290
- if (currentCtx) {
299
+ if (ownerCurrent && currentCtx) {
291
300
  const config = loadConfig(currentCtx.cwd).config.ui;
292
301
  updateCrewWidget(currentCtx, widgetState, config, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd));
293
302
  updatePiCrewPowerbar(pi.events, currentCtx.cwd, config, getManifestCache(currentCtx.cwd), getRunSnapshotCache(currentCtx.cwd), currentCtx, widgetState.notificationCount ?? 0);
@@ -328,6 +337,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
328
337
  notificationSink = undefined;
329
338
  rpcHandle?.unsubscribe();
330
339
  rpcHandle = undefined;
340
+ sessionGeneration += 1;
331
341
  currentCtx = undefined;
332
342
  if (globalStore[runtimeCleanupStoreKey] === cleanupRuntime) delete globalStore[runtimeCleanupStoreKey];
333
343
  };
@@ -337,6 +347,8 @@ export function registerPiTeams(pi: ExtensionAPI): void {
337
347
  runArtifactCleanup(ctx.cwd);
338
348
  time("register.session-start");
339
349
  cleanedUp = false;
350
+ sessionGeneration++;
351
+ const ownerGeneration = sessionGeneration;
340
352
  currentCtx = ctx;
341
353
  if (widgetState.interval) clearInterval(widgetState.interval);
342
354
  widgetState.interval = undefined;
@@ -346,7 +358,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
346
358
  configureNotifications(ctx);
347
359
  configureObservability(ctx);
348
360
  registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
349
- startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs);
361
+ startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, { generation: ownerGeneration, isCurrent: (generation) => generation === sessionGeneration && currentCtx === ctx && !cleanedUp });
350
362
  const cache = getManifestCache(ctx.cwd);
351
363
  updateCrewWidget(ctx, widgetState, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd));
352
364
  updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui, cache, getRunSnapshotCache(ctx.cwd), ctx, widgetState.notificationCount ?? 0);
@@ -395,7 +407,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
395
407
  onInvalidate: () => getRunSnapshotCache(ctx.cwd).invalidate(),
396
408
  });
397
409
  });
398
- pi.on("session_before_switch", () => stopSessionBoundSubagents());
410
+ pi.on("session_before_switch", () => {
411
+ sessionGeneration++;
412
+ stopAsyncRunNotifier(notifierState);
413
+ stopSessionBoundSubagents();
414
+ });
399
415
  pi.on("session_shutdown", () => cleanupRuntime());
400
416
 
401
417
  registerCompactionGuard(pi, { foregroundControllers });
@@ -417,7 +433,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
417
433
  });
418
434
 
419
435
  registerTeamTool(pi, { foregroundControllers, startForegroundRun, openLiveSidebar, getManifestCache, getRunSnapshotCache, getMetricRegistry: () => metricRegistry, widgetState });
420
- registerSubagentTools(pi, subagentManager);
436
+ registerSubagentTools(pi, subagentManager, { ownerSessionGeneration: captureSessionGeneration });
421
437
  time("register.tools");
422
438
 
423
439
  registerTeamCommands(pi, { startForegroundRun, openLiveSidebar, getManifestCache, getRunSnapshotCache, getMetricRegistry: () => metricRegistry, dismissNotifications: () => {
@@ -8,7 +8,11 @@ import { loadConfig } from "../../config/config.ts";
8
8
  import { logInternalError } from "../../utils/internal-error.ts";
9
9
  import { __test__subagentSpawnParams, formatSubagentRecord, readSubagentRunResult, refreshPersistedSubagentRecord, subagentToolResult } from "./subagent-helpers.ts";
10
10
 
11
- export function registerSubagentTools(pi: ExtensionAPI, subagentManager: SubagentManager): void {
11
+ export interface SubagentToolRegistrationOptions {
12
+ ownerSessionGeneration?: () => number;
13
+ }
14
+
15
+ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: SubagentManager, options: SubagentToolRegistrationOptions = {}): void {
12
16
  const agentTool: ToolDefinition = {
13
17
  name: "Agent",
14
18
  label: "Agent",
@@ -31,11 +35,12 @@ export function registerSubagentTools(pi: ExtensionAPI, subagentManager: Subagen
31
35
  const currentRole = currentCrewRole();
32
36
  const permission = checkSubagentSpawnPermission(currentRole);
33
37
  if (!permission.allowed) return subagentToolResult(permission.reason ?? "Current role cannot spawn subagents.", { role: currentRole, mode: permission.mode }, true);
34
- const options = __test__subagentSpawnParams(params as Record<string, unknown>, ctx);
35
- if (!options.prompt.trim()) return subagentToolResult("Agent requires prompt.", {}, true);
36
- const runner = async (spawnOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => handleTeamTool({ action: "run", agent: spawnOptions.type, goal: spawnOptions.prompt, model: spawnOptions.model, async: spawnOptions.background, config: spawnOptions.maxTurns ? { runtime: { maxTurns: spawnOptions.maxTurns } } : undefined } as TeamToolParamsValue, spawnOptions.background ? { ...ctx, signal: childSignal } : { ...ctx, signal: childSignal });
37
- const record = subagentManager.spawn(options, runner, options.background ? undefined : signal);
38
- if (options.background || record.status === "queued") {
38
+ const spawnOptions = __test__subagentSpawnParams(params as Record<string, unknown>, ctx);
39
+ spawnOptions.ownerSessionGeneration = options.ownerSessionGeneration?.();
40
+ if (!spawnOptions.prompt.trim()) return subagentToolResult("Agent requires prompt.", {}, true);
41
+ const runner = async (currentOptions: SubagentSpawnOptions, childSignal?: AbortSignal) => handleTeamTool({ action: "run", agent: currentOptions.type, goal: currentOptions.prompt, model: currentOptions.model, async: currentOptions.background, config: currentOptions.maxTurns ? { runtime: { maxTurns: currentOptions.maxTurns } } : undefined } as TeamToolParamsValue, currentOptions.background ? { ...ctx, signal: childSignal } : { ...ctx, signal: childSignal });
42
+ const record = subagentManager.spawn(spawnOptions, runner, spawnOptions.background ? undefined : signal);
43
+ if (spawnOptions.background || record.status === "queued") {
39
44
  // Phase 1.1a: Terminate turn for background queued — no LLM follow-up needed.
40
45
  // Phase 1.6: Record was terminated for telemetry.
41
46
  record.terminated = true;
@@ -22,6 +22,7 @@ interface ResultWatcherDependencies {
22
22
  export interface ResultWatcherOptions extends ResultWatcherDependencies {
23
23
  eventName?: string;
24
24
  completionTtlMs?: number;
25
+ isCurrent?: () => boolean;
25
26
  }
26
27
 
27
28
  const RESULT_WATCHER_RESTART_MS = 3000;
@@ -40,10 +41,12 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
40
41
  const eventName = options.eventName ?? "pi-crew:run-result";
41
42
  const completionTtlMs = options.completionTtlMs ?? 5 * 60_000;
42
43
  const watch = options.watch ?? watchWithErrorHandler;
44
+ const isCurrent = options.isCurrent ?? (() => true);
43
45
  const seen = getGlobalSeenMap("pi-crew.result-watcher");
44
46
  let watcher: fs.FSWatcher | null | undefined;
45
47
  let restartTimer: ReturnType<typeof setTimeout> | undefined;
46
48
  const coalescer = createFileCoalescer((file) => {
49
+ if (!isCurrent()) return;
47
50
  const filePath = path.join(resultsDir, file);
48
51
  if (!file.endsWith(".json") || !fs.existsSync(filePath)) return;
49
52
  const payload = readJson(filePath);
@@ -64,6 +67,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
64
67
  restartTimer = setTimeout(() => {
65
68
  restartTimer = undefined;
66
69
  try {
70
+ if (!isCurrent()) return;
67
71
  fs.mkdirSync(resultsDir, { recursive: true });
68
72
  handle.start();
69
73
  } catch (error) {
@@ -74,6 +78,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
74
78
  };
75
79
  const handle: ResultWatcherHandle = {
76
80
  start() {
81
+ if (!isCurrent()) return;
77
82
  fs.mkdirSync(resultsDir, { recursive: true });
78
83
  if (watcher) closeWatcher(watcher);
79
84
  watcher = watch(resultsDir, (event, fileName) => {
@@ -83,7 +88,7 @@ export function createResultWatcher(events: ResultWatcherEvents, resultsDir: str
83
88
  watcher?.unref?.();
84
89
  },
85
90
  prime() {
86
- if (!fs.existsSync(resultsDir)) return;
91
+ if (!isCurrent() || !fs.existsSync(resultsDir)) return;
87
92
  for (const file of fs.readdirSync(resultsDir).filter((entry) => entry.endsWith(".json"))) coalescer.schedule(file, 0);
88
93
  },
89
94
  stop() {