@ferris1225/pi-subagents 4.1.7 → 4.1.8

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
@@ -207,10 +207,10 @@ subagent({
207
207
  });
208
208
  ```
209
209
 
210
- Independent tasks run up to `maxConcurrency` (default `4`). One parallel call may
211
- contain at most that many tasks and is rejected if it exceeds the limit. Accepted
212
- background work from separate calls waits in the shared queue when all slots are
213
- busy.
210
+ Independent tasks run up to a fixed limit of `4` concurrent sub-agent processes.
211
+ One parallel call may contain at most that many tasks and is rejected if it
212
+ exceeds the limit. Accepted background work from separate calls waits in the
213
+ shared queue when all slots are busy.
214
214
 
215
215
  ### Run an independent quality gate
216
216
 
@@ -245,8 +245,9 @@ cannot recursively start another chain. Gate reviewers keep documentation drift
245
245
  out of the code verdict while `documenter` is enabled by recording it under
246
246
  `## Documentation notes`; with documenter disabled, drift is a normal finding.
247
247
 
248
- `maxFixRounds` limits worker fix attempts only. The post-writer review gate still
249
- runs when it is `0`, and only a terminal `REVIEW_PASS` can decide whether docs
248
+ The loop is fixed at two worker fix rounds (each fix is re-reviewed); disabling
249
+ the `worker` agent is the way to turn fixes off. The post-writer review gate still
250
+ runs regardless, and only a terminal `REVIEW_PASS` can decide whether docs
250
251
  sync is needed. Generic audits and read-only reviews are advisory: they omit
251
252
  `VERDICT` and documentation machine markers, remain read-only, and never trigger
252
253
  edits.
@@ -503,8 +504,8 @@ subagent({
503
504
 
504
505
  ## Configuration
505
506
 
506
- The wizard covers enabled agents, per-agent models and thinking, concurrency,
507
- auto-fix rounds, and the idle timeout:
507
+ The wizard covers enabled agents, per-agent models and thinking, and the idle
508
+ timeout:
508
509
 
509
510
  ```text
510
511
  /subagents-setup
@@ -534,8 +535,6 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json` and follows
534
535
  "maxResultLines": 80,
535
536
  "proactiveInjection": true,
536
537
  "agentScope": "user",
537
- "maxConcurrency": 4,
538
- "maxFixRounds": 2,
539
538
  "idleTimeoutSec": 90
540
539
  }
541
540
  ```
@@ -549,16 +548,15 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json` and follows
549
548
  | `maxResultLines` | Lines kept in a completion message before the full result moves to a temporary artifact. Default `80`. |
550
549
  | `proactiveInjection` | Teach the main model when and how to delegate. Default `true`. |
551
550
  | `agentScope` | Discover `user`, `project`, or `both` agent directories. Default `user`. |
552
- | `maxConcurrency` | Running process limit and maximum tasks in one parallel call, from `1` to `16`. Default `4`. |
553
- | `maxFixRounds` | Maximum worker fixes after `REVIEW_FAIL`; each fix is re-reviewed. After terminal `REVIEW_PASS`, docs sync runs only for `DOCUMENTATION: NEEDED` or a missing marker. `0` disables fixes but not the post-writer gate or conditional/reviewer-disabled docs behavior. Default `2`. |
554
551
  | `idleTimeoutSec` | Seconds without RPC output before termination. `0` disables the watchdog. Default `90`. |
555
552
 
556
- Invalid values fall back safely. Older configs are normalized automatically. The
557
- former built-in name `explore` migrates to `explorer`, and pre-cleaner non-empty
558
- agent lists receive `cleaner` once. Existing non-empty configs also receive
559
- `documenter` once, inserted before `reviewer`, with any configured `explorer`
560
- model and thinking copied across. Fresh installs do not enable `documenter`
561
- until the user selects it. Later deliberate disables are respected.
553
+ Invalid values fall back safely. Keys from older versions (including the former
554
+ `maxConcurrency` and `maxFixRounds` tuning options, now fixed at `4` concurrent
555
+ processes and `2` fix rounds) are dropped automatically and the normalized
556
+ shape is saved back. At session start, per-agent model overrides that Pi no
557
+ longer reports as available are removed with a one-time notice; those agents
558
+ fall back to the current main model until you re-pick them in
559
+ `/subagents-setup`.
562
560
 
563
561
  ## Custom and overridden agents
564
562
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "4.1.7",
3
+ "version": "4.1.8",
4
4
  "description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,82 +1,37 @@
1
- /** Session-start recovery and one-time feature announcements. */
1
+ /** Session-start recovery, stale-config migration, and widget installation. */
2
2
 
3
- import { stat } from "node:fs/promises";
4
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import {
6
- CLEANER_AUTO_ENABLED_FEATURE,
7
- CLEANER_INHERITED_FEATURE,
8
- DOCUMENTER_AUTO_ENABLED_FEATURE,
9
- DOCUMENTER_INHERITED_FEATURE,
10
- loadConfig,
11
- saveConfig,
12
- } from "./config.ts";
4
+ import { loadConfig, saveConfig } from "./config.ts";
5
+ import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
13
6
  import { announceRecoveryRecords } from "./recovery.ts";
14
7
  import type { SubagentRuntime } from "./runtime.ts";
15
8
  import { pruneResultArtifacts } from "./spawn.ts";
16
9
  import { installActiveRunsWidget } from "./widget.ts";
17
10
 
18
- const ANNOUNCEMENTS: Array<{
19
- key: string;
20
- condition: (config: Awaited<ReturnType<typeof loadConfig>>) => boolean;
21
- message: (config: Awaited<ReturnType<typeof loadConfig>>) => string;
22
- }> = [
23
- {
24
- // Fires once after the load-time upgrade injected cleaner into an older
25
- // config (the injection stamp only exists in that case). The extra
26
- // enabledAgents check keeps the notice silent when the user already
27
- // disabled cleaner (e.g. via full setup) before it could fire.
28
- key: "cleanerAutoEnabledNotice",
29
- condition: (config) =>
30
- config.announcedFeatures.includes(CLEANER_AUTO_ENABLED_FEATURE) &&
31
- config.enabledAgents.includes("cleaner"),
32
- // The inheritance clause matches reality: its stamp is only set when the
33
- // upgrade actually copied reviewer model/thinking settings.
34
- message: (config) =>
35
- config.announcedFeatures.includes(CLEANER_INHERITED_FEATURE)
36
- ? "pi-subagents: the built-in cleaner agent was enabled by default and inherited your reviewer model/thinking settings. Run /subagents-setup to adjust or disable it."
37
- : "pi-subagents: the built-in cleaner agent was enabled by default. Run /subagents-setup to adjust or disable it.",
38
- },
39
- {
40
- key: "documenterAutoEnabledNotice",
41
- condition: (config) =>
42
- config.announcedFeatures.includes(DOCUMENTER_AUTO_ENABLED_FEATURE) &&
43
- config.enabledAgents.includes("documenter"),
44
- message: (config) =>
45
- config.announcedFeatures.includes(DOCUMENTER_INHERITED_FEATURE)
46
- ? "pi-subagents: the new documenter agent was enabled for your existing config and inherited your explorer model/thinking settings. It synchronizes comments and README/docs before commit; run /subagents-setup to adjust or disable it."
47
- : "pi-subagents: the new documenter agent was enabled for your existing config. It synchronizes comments and README/docs before commit; run /subagents-setup to adjust or disable it.",
48
- },
49
- ];
50
-
51
- async function announceNewFeatures(
52
- ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } },
11
+ /**
12
+ * One-time-per-stale-override migration: keep agent model selections Pi still
13
+ * reports as available, drop the rest back to dynamic main-model routing, and
14
+ * tell the user what was removed. Saving the cleaned config is what makes it
15
+ * one-time — the dropped refs no longer exist to re-trigger the notice.
16
+ */
17
+ async function migrateUnavailableAgentModels(
18
+ ctx: { ui: { notify: (message: string, kind: "info" | "warning" | "error") => void } } & Parameters<typeof availableModelsInScope>[0],
53
19
  runtime: SubagentRuntime,
54
20
  ): Promise<void> {
55
21
  try {
56
- let configExists = true;
57
- try {
58
- await stat(runtime.configPath);
59
- } catch {
60
- configExists = false;
61
- }
62
- if (!configExists) return;
63
-
64
22
  const config = await loadConfig(runtime.configPath);
65
- const pending = ANNOUNCEMENTS.filter(
66
- (announcement) =>
67
- announcement.condition(config) && !config.announcedFeatures.includes(announcement.key),
68
- );
69
- if (pending.length === 0) return;
70
- await saveConfig(
71
- {
72
- ...config,
73
- announcedFeatures: [...config.announcedFeatures, ...pending.map((announcement) => announcement.key)],
74
- },
75
- runtime.configPath,
23
+ const overrides = Object.entries(config.agentModels);
24
+ if (overrides.length === 0) return;
25
+ const { kept, dropped } = filterUnavailableModelOverrides(config.agentModels, availableModelsInScope(ctx));
26
+ if (dropped.length === 0) return;
27
+ await saveConfig({ ...config, agentModels: kept }, runtime.configPath);
28
+ const list = dropped.map(({ agent, ref }) => `${agent}: ${ref}`).join(", ");
29
+ ctx.ui.notify(
30
+ `pi-subagents: removed stale agent model overrides that are no longer available (${list}). Those agents now follow the current main model; run /subagents-setup to re-pick.`,
31
+ "warning",
76
32
  );
77
- for (const announcement of pending) ctx.ui.notify(announcement.message(config), "info");
78
33
  } catch {
79
- /* announcement failures are non-fatal */
34
+ /* migration failures are non-fatal */
80
35
  }
81
36
  }
82
37
 
@@ -84,8 +39,8 @@ export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime
84
39
  pi.on("session_start", async (_event, ctx) => {
85
40
  pruneResultArtifacts();
86
41
  await announceRecoveryRecords(runtime.configPath, ctx);
42
+ await migrateUnavailableAgentModels(ctx, runtime);
87
43
  if (ctx.mode !== "tui") return;
88
44
  installActiveRunsWidget(ctx);
89
- await announceNewFeatures(ctx, runtime);
90
45
  });
91
46
  }
package/src/background.ts CHANGED
@@ -27,6 +27,11 @@ interface PendingTask {
27
27
  onError?: (error: unknown) => void | Promise<void>;
28
28
  }
29
29
 
30
+ /** How many sub-agent processes may run at once, and how many tasks one
31
+ * parallel `subagent` call may contain. Fixed by design: the queue sheds load
32
+ * by waiting, so the knob bought nothing worth its maintenance. */
33
+ export const MAX_CONCURRENT_SUBAGENTS = 4;
34
+
30
35
  export class BackgroundTaskQueue {
31
36
  private concurrency: number;
32
37
  private readonly pending: PendingTask[] = [];
@@ -39,16 +44,6 @@ export class BackgroundTaskQueue {
39
44
  this.concurrency = Math.max(1, concurrency);
40
45
  }
41
46
 
42
- /**
43
- * Update the concurrency limit (e.g. after a config change). Raising it
44
- * immediately starts more queued work; lowering it takes effect as running
45
- * tasks finish — already-running tasks are never interrupted.
46
- */
47
- setConcurrency(concurrency: number): void {
48
- this.concurrency = Math.max(1, concurrency);
49
- this.drain();
50
- }
51
-
52
47
  enqueue(task: BackgroundTask, onCancelled?: () => void, onError?: (error: unknown) => void | Promise<void>): AbortController {
53
48
  const controller = new AbortController();
54
49
  let complete!: () => void;
package/src/config.ts CHANGED
@@ -4,57 +4,23 @@
4
4
  * Config lives at <agentDir>/pi-subagents.json (agentDir defaults to ~/.pi/agent
5
5
  * and honors PI_CODING_AGENT_DIR). Parsing is defensive: invalid fields fall back
6
6
  * to defaults instead of throwing, so a hand-edited or partially-written file can
7
- * never break the extension at runtime.
8
- *
9
- * Schema upgrades happen transparently on load: a config written by an older
10
- * version (missing newer keys or containing invalid
11
- * values) is normalized and persisted back with the new fields filled in.
7
+ * never break the extension at runtime. Unknown keys from older versions are
8
+ * dropped and the normalized shape persisted back on load.
12
9
  */
13
10
 
14
11
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
15
- import { readFileSync } from "node:fs";
16
12
  import { dirname, join } from "node:path";
17
13
  import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
14
 
19
15
  /** Full catalog of agents shipped with the package (selectable in /subagents-setup). */
20
16
  export const BUILTIN_AGENT_NAMES = ["explorer", "worker", "cleaner", "documenter", "reviewer"] as const;
21
17
 
22
- /** Agents enabled out of the box on a fresh install. Documenter remains an
23
- * explicit setup choice; existing non-empty configs receive it via migration. */
18
+ /** Agents enabled out of the box on a fresh install. */
24
19
  export const DEFAULT_ENABLED_AGENTS: readonly string[] = ["explorer", "worker", "cleaner", "reviewer"];
25
20
 
26
21
  export const AGENT_SCOPE_VALUES = ["user", "project", "both"] as const;
27
22
  export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
28
23
 
29
- const LEGACY_EXPLORER_NAME = "explore";
30
- const EXPLORER_NAME = "explorer";
31
- const CLEANER_NAME = "cleaner";
32
- const DOCUMENTER_NAME = "documenter";
33
- const REVIEWER_NAME = "reviewer";
34
-
35
- /**
36
- * Stamps recorded in `announcedFeatures` by the one-time upgrade that defaults
37
- * cleaner on for configs written before it shipped. The first marks a config as
38
- * processed, so a later deliberate disable is not undone; the second records
39
- * that cleaner was actually injected, so session start can tell the user once;
40
- * the third records that reviewer model/thinking settings were actually copied,
41
- * so that notice never claims an inheritance that did not happen.
42
- */
43
- export const CLEANER_DEFAULTED_FEATURE = "cleanerDefaulted";
44
- export const CLEANER_AUTO_ENABLED_FEATURE = "cleanerAutoEnabled";
45
- export const CLEANER_INHERITED_FEATURE = "cleanerInheritedReviewer";
46
-
47
- /** One-time upgrade stamps for the pre-commit documenter role. Existing
48
- * non-empty configs gain it before reviewer and inherit explorer routing; fresh
49
- * installs keep it off until setup explicitly enables it. */
50
- export const DOCUMENTER_DEFAULTED_FEATURE = "documenterDefaulted";
51
- export const DOCUMENTER_AUTO_ENABLED_FEATURE = "documenterAutoEnabled";
52
- export const DOCUMENTER_INHERITED_FEATURE = "documenterInheritedExplorer";
53
-
54
- function migrateAgentName(name: string): string {
55
- return name === LEGACY_EXPLORER_NAME ? EXPLORER_NAME : name;
56
- }
57
-
58
24
  /** Thinking levels accepted by pi's `--thinking` option. */
59
25
  export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
60
26
  export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
@@ -67,20 +33,6 @@ export const MAX_RESULT_LINES_LIMIT = 2000;
67
33
 
68
34
  const CONFIG_FILE_NAME = "pi-subagents.json";
69
35
 
70
- /** How many sub-agent processes may run at once, and how many tasks one parallel `subagent` call may contain. Default: 4. */
71
- export const DEFAULT_MAX_CONCURRENCY = 4;
72
- /** Upper bound accepted for maxConcurrency (defensive clamp). */
73
- export const MAX_CONCURRENCY_LIMIT = 16;
74
- /**
75
- * Maximum worker fixes after REVIEW_FAIL. Each fix is followed by a reviewer
76
- * re-review; this cap does not suppress the post-writer review gate or its
77
- * conditional/reviewer-disabled documentation fallback. 0 disables fixes.
78
- * Default: 2.
79
- */
80
- export const DEFAULT_MAX_FIX_ROUNDS = 2;
81
- /** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
82
- export const MAX_FIX_ROUNDS_LIMIT = 5;
83
-
84
36
  /**
85
37
  * Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
86
38
  * goes silent for this long is terminated; a selected model then hands the
@@ -112,27 +64,12 @@ export interface SubagentsConfig {
112
64
  proactiveInjection: boolean;
113
65
  /** Which agent directories to discover from. Default: "user". */
114
66
  agentScope: AgentScope;
115
- /** Max sub-agent processes running at once (extra work queues) and the max tasks
116
- * one parallel `subagent` call may contain. Default: 4. */
117
- maxConcurrency: number;
118
- /**
119
- * Maximum worker fixes after REVIEW_FAIL. Every fix receives the full review,
120
- * then a re-review runs; any documentation sync selected after the terminal
121
- * healthy review does not consume this budget. 0 disables fixes. Default: 2.
122
- */
123
- maxFixRounds: number;
124
67
  /**
125
68
  * Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
126
69
  * silent for this long is terminated; a configured agent model then hands
127
70
  * off to the current main model. 0 disables the idle watchdog. Default: 90.
128
71
  */
129
72
  idleTimeoutSec: number;
130
- /**
131
- * One-time feature announcements already shown to the user, plus schema
132
- * upgrade stamps (e.g. the cleaner default-enable upgrade). Persisted so
133
- * notices and migrations never repeat.
134
- */
135
- announcedFeatures: string[];
136
73
  }
137
74
 
138
75
  export const DEFAULT_CONFIG: SubagentsConfig = {
@@ -143,10 +80,7 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
143
80
  maxResultLines: DEFAULT_MAX_RESULT_LINES,
144
81
  proactiveInjection: true,
145
82
  agentScope: "user",
146
- maxConcurrency: DEFAULT_MAX_CONCURRENCY,
147
- maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
148
83
  idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
149
- announcedFeatures: [],
150
84
  };
151
85
 
152
86
  export function getConfigPath(agentDir: string = getAgentDir()): string {
@@ -186,48 +120,30 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
186
120
  const names = raw.enabledAgents.filter(
187
121
  (name): name is string => typeof name === "string" && name.trim().length > 0,
188
122
  );
189
- // An explicitly empty array is honored. Rename the former built-in key and
190
- // deduplicate when a config already contains both spellings.
191
- config.enabledAgents = [...new Set(names.map((name) => migrateAgentName(name.trim())))];
123
+ // An explicitly empty array is honored; duplicates collapse.
124
+ config.enabledAgents = [...new Set(names.map((name) => name.trim()))];
192
125
  }
193
126
 
194
127
  if (isRecord(raw.agentModels)) {
195
- const entries = Object.entries(raw.agentModels);
196
- // A valid explicit new key wins regardless of JSON property order.
197
- for (const [rawKey, value] of entries) {
128
+ for (const [rawKey, value] of Object.entries(raw.agentModels)) {
198
129
  const key = rawKey.trim();
199
- if (key !== LEGACY_EXPLORER_NAME && isModelReference(value)) {
130
+ if (key !== "" && isModelReference(value)) {
200
131
  config.agentModels[key] = value.trim();
201
132
  }
202
133
  }
203
- if (!Object.hasOwn(config.agentModels, EXPLORER_NAME)) {
204
- const legacy = entries.find(([key, value]) =>
205
- key.trim() === LEGACY_EXPLORER_NAME && isModelReference(value)
206
- );
207
- if (legacy && isModelReference(legacy[1])) config.agentModels[EXPLORER_NAME] = legacy[1].trim();
208
- }
209
134
  }
210
135
 
211
136
  if (isRecord(raw.agentThinkingLevels)) {
212
- const entries = Object.entries(raw.agentThinkingLevels);
213
- for (const [rawKey, value] of entries) {
137
+ for (const [rawKey, value] of Object.entries(raw.agentThinkingLevels)) {
214
138
  const key = rawKey.trim();
215
139
  if (
216
- key !== LEGACY_EXPLORER_NAME &&
140
+ key !== "" &&
217
141
  typeof value === "string" &&
218
142
  (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
219
143
  ) {
220
144
  config.agentThinkingLevels[key] = value as ThinkingLevel;
221
145
  }
222
146
  }
223
- if (!Object.hasOwn(config.agentThinkingLevels, EXPLORER_NAME)) {
224
- const legacy = entries.find(([key, value]) =>
225
- key.trim() === LEGACY_EXPLORER_NAME &&
226
- typeof value === "string" &&
227
- (THINKING_LEVEL_VALUES as readonly string[]).includes(value)
228
- );
229
- if (legacy) config.agentThinkingLevels[EXPLORER_NAME] = legacy[1] as ThinkingLevel;
230
- }
231
147
  }
232
148
 
233
149
  if (typeof raw.notifyOnReviewPass === "boolean") {
@@ -245,75 +161,11 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
245
161
  config.agentScope = raw.agentScope;
246
162
  }
247
163
 
248
- const maxConcurrency = clampCount(raw.maxConcurrency, MAX_CONCURRENCY_LIMIT);
249
- if (maxConcurrency !== undefined) config.maxConcurrency = maxConcurrency;
250
-
251
- // 0 disables worker fixes, not the independent post-writer review gate or
252
- // conditional/reviewer-disabled documentation fallback.
253
- if (typeof raw.maxFixRounds === "number" && Number.isFinite(raw.maxFixRounds)) {
254
- config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
255
- }
256
-
257
164
  // 0 disables the idle watchdog; otherwise clamp to [0, upper].
258
165
  if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
259
166
  config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
260
167
  }
261
168
 
262
- if (Array.isArray(raw.announcedFeatures)) {
263
- config.announcedFeatures = raw.announcedFeatures.filter(
264
- (feature): feature is string => typeof feature === "string" && feature.trim().length > 0,
265
- );
266
- }
267
-
268
- // One-time upgrade for configs written before cleaner shipped: a non-empty
269
- // explicit enabledAgents list gets cleaner defaulted on (inserted before
270
- // reviewer, matching the fresh-install order) and inherits the reviewer's
271
- // configured model and thinking level — its closest peer. The stamps make
272
- // the upgrade idempotent and keep a later deliberate disable from being undone.
273
- if (!config.announcedFeatures.includes(CLEANER_DEFAULTED_FEATURE)) {
274
- config.announcedFeatures.push(CLEANER_DEFAULTED_FEATURE);
275
- if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(CLEANER_NAME)) {
276
- const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
277
- config.enabledAgents.splice(reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex, 0, CLEANER_NAME);
278
- config.announcedFeatures.push(CLEANER_AUTO_ENABLED_FEATURE);
279
- let inherited = false;
280
- if (!config.agentModels[CLEANER_NAME] && config.agentModels[REVIEWER_NAME]) {
281
- config.agentModels[CLEANER_NAME] = config.agentModels[REVIEWER_NAME];
282
- inherited = true;
283
- }
284
- if (!config.agentThinkingLevels[CLEANER_NAME] && config.agentThinkingLevels[REVIEWER_NAME]) {
285
- config.agentThinkingLevels[CLEANER_NAME] = config.agentThinkingLevels[REVIEWER_NAME];
286
- inherited = true;
287
- }
288
- // An old explicit list may have no reviewer overrides to copy; only the
289
- // copied case is stamped so the one-time notice stays accurate.
290
- if (inherited) config.announcedFeatures.push(CLEANER_INHERITED_FEATURE);
291
- }
292
- }
293
-
294
- if (!config.announcedFeatures.includes(DOCUMENTER_DEFAULTED_FEATURE)) {
295
- config.announcedFeatures.push(DOCUMENTER_DEFAULTED_FEATURE);
296
- if (config.enabledAgents.length > 0 && !config.enabledAgents.includes(DOCUMENTER_NAME)) {
297
- const reviewerIndex = config.enabledAgents.indexOf(REVIEWER_NAME);
298
- config.enabledAgents.splice(
299
- reviewerIndex === -1 ? config.enabledAgents.length : reviewerIndex,
300
- 0,
301
- DOCUMENTER_NAME,
302
- );
303
- config.announcedFeatures.push(DOCUMENTER_AUTO_ENABLED_FEATURE);
304
- let inherited = false;
305
- if (!config.agentModels[DOCUMENTER_NAME] && config.agentModels[EXPLORER_NAME]) {
306
- config.agentModels[DOCUMENTER_NAME] = config.agentModels[EXPLORER_NAME];
307
- inherited = true;
308
- }
309
- if (!config.agentThinkingLevels[DOCUMENTER_NAME] && config.agentThinkingLevels[EXPLORER_NAME]) {
310
- config.agentThinkingLevels[DOCUMENTER_NAME] = config.agentThinkingLevels[EXPLORER_NAME];
311
- inherited = true;
312
- }
313
- if (inherited) config.announcedFeatures.push(DOCUMENTER_INHERITED_FEATURE);
314
- }
315
- }
316
-
317
169
  return config;
318
170
  }
319
171
 
@@ -323,7 +175,6 @@ function defaultConfig(): SubagentsConfig {
323
175
  enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
324
176
  agentModels: {},
325
177
  agentThinkingLevels: {},
326
- announcedFeatures: [],
327
178
  };
328
179
  }
329
180
 
@@ -364,18 +215,6 @@ export async function loadConfig(configPath: string = getConfigPath()): Promise<
364
215
  return config;
365
216
  }
366
217
 
367
- /**
368
- * Synchronous load for the extension's init-time decisions (e.g. the recursion
369
- * guard). Runs before any async context is available; never migrates or saves.
370
- */
371
- export function loadConfigSync(configPath: string = getConfigPath()): SubagentsConfig {
372
- try {
373
- return normalizeConfig(JSON.parse(readFileSync(configPath, "utf8")));
374
- } catch {
375
- return defaultConfig();
376
- }
377
- }
378
-
379
218
  /**
380
219
  * Save config atomically (temp file + rename) serialized through pi's per-file
381
220
  * mutation queue so concurrent writers cannot interleave.
package/src/dispatch.ts CHANGED
@@ -15,6 +15,7 @@ import { realpath } from "node:fs/promises";
15
15
  import { resolve } from "node:path";
16
16
  import { Type } from "typebox";
17
17
  import { discoverAgents, resolveAgentTools, type AgentConfig } from "./agents.ts";
18
+ import { MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
18
19
  import { loadConfig } from "./config.ts";
19
20
  import { formatUsage, queuedResult } from "./format.ts";
20
21
  import {
@@ -23,6 +24,7 @@ import {
23
24
  buildFixTaskBrief,
24
25
  buildReReviewBrief,
25
26
  documentationDisposition,
27
+ MAX_FIX_ROUNDS,
26
28
  type ChainStep,
27
29
  type ManagedWorkflowOutcome,
28
30
  } from "./fixloop.ts";
@@ -193,8 +195,6 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
193
195
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
194
196
  monitor.beginTurn();
195
197
  const config = await loadConfig(runtime.configPath);
196
- // Pick up concurrency changes from /subagents-setup without a restart.
197
- runtime.backgroundQueue.setConcurrency(config.maxConcurrency);
198
198
 
199
199
  // Finished runs leave the active monitor immediately. Their final findings
200
200
  // are sent as a custom message that starts a follow-up turn.
@@ -493,19 +493,19 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
493
493
  ): Promise<{ lastReview?: SingleResult; lastWorker?: SingleResult }> => {
494
494
  let lastReviewer = triggeringReviewer;
495
495
  const outcome: { lastReview?: SingleResult; lastWorker?: SingleResult } = {};
496
- for (let round = 1; round <= request.config.maxFixRounds; round++) {
496
+ for (let round = 1; round <= MAX_FIX_ROUNDS; round++) {
497
497
  if (!canContinue()) break;
498
- const fixRelation = `fix ${round}/${request.config.maxFixRounds}`;
498
+ const fixRelation = `fix ${round}/${MAX_FIX_ROUNDS}`;
499
499
  const workerResult = await launchStep(
500
500
  "worker",
501
- buildFixTaskBrief(lastReviewer, round, request.config.maxFixRounds),
501
+ buildFixTaskBrief(lastReviewer, round, MAX_FIX_ROUNDS),
502
502
  `fix round ${round}`,
503
503
  { timelineRelation: fixRelation, childRelation: fixRelation },
504
504
  );
505
505
  if (isFailedResult(workerResult) || !canContinue()) break;
506
506
  outcome.lastWorker = workerResult;
507
507
 
508
- const reReviewRelation = `re-review ${round}/${request.config.maxFixRounds}`;
508
+ const reReviewRelation = `re-review ${round}/${MAX_FIX_ROUNDS}`;
509
509
  const reviewResult = await launchStep(
510
510
  "reviewer",
511
511
  buildReReviewBrief(lastReviewer, round, workerResult, {
@@ -586,8 +586,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
586
586
  !isFailedResult(gateReview) &&
587
587
  canContinue() &&
588
588
  reviewVerdict(getResultOutput(gateReview)) === "fail" &&
589
- enabled("worker") &&
590
- request.config.maxFixRounds > 0
589
+ enabled("worker")
591
590
  ) {
592
591
  fixOutcome = await runFixRounds(gateReview);
593
592
  }
@@ -620,12 +619,12 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
620
619
  // Sub-agents intentionally detach from the foreground turn. This makes the
621
620
  // editor available immediately; completion messages later wake the main agent.
622
621
  if (params.tasks && params.tasks.length > 0) {
623
- if (params.tasks.length > config.maxConcurrency) {
622
+ if (params.tasks.length > MAX_CONCURRENT_SUBAGENTS) {
624
623
  return {
625
624
  content: [
626
625
  {
627
626
  type: "text",
628
- text: `Too many parallel tasks (${params.tasks.length}). Max is ${config.maxConcurrency} (configurable via /subagents-setup).`,
627
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_CONCURRENT_SUBAGENTS}.`,
629
628
  },
630
629
  ],
631
630
  details: makeDetails("parallel", true)([]),
package/src/fixloop.ts CHANGED
@@ -18,7 +18,13 @@
18
18
  import { isWriteCapableAgent, type AgentConfig } from "./agents.ts";
19
19
  import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
20
20
  import { formatUsageCompact, sumUsage } from "./monitor.ts";
21
- import type { SubagentsConfig } from "./config.ts";
21
+
22
+ /**
23
+ * Worker fixes allowed after REVIEW_FAIL. Each fix is followed by a reviewer
24
+ * re-review; this cap does not suppress the post-writer review gate or its
25
+ * conditional/reviewer-disabled documentation fallback.
26
+ */
27
+ export const MAX_FIX_ROUNDS = 2;
22
28
 
23
29
  /**
24
30
  * Whether a completed result should trigger the auto-fix loop instead of being
@@ -27,8 +33,7 @@ import type { SubagentsConfig } from "./config.ts";
27
33
  * normally. Loop-internal re-review results never reach this path (they are
28
34
  * awaited inside the loop, not delivered through the completion flow).
29
35
  */
30
- export function shouldTriggerFixLoop(result: SingleResult, config: SubagentsConfig): boolean {
31
- if (config.maxFixRounds <= 0) return false;
36
+ export function shouldTriggerFixLoop(result: SingleResult): boolean {
32
37
  if (result.agent !== "reviewer") return false;
33
38
  if (isFailedResult(result)) return false;
34
39
  // A dispatch crash (spawn infra, delivery API, ...) is never a real review
@@ -97,8 +102,8 @@ export function canStartManagedWorkflow(
97
102
  if (isWriteCapableAgent(agent)) return true;
98
103
  if (agent.name === "reviewer") {
99
104
  // Hold a stable diff snapshot against every discoverable writer even when
100
- // this review is advisory or maxFixRounds=0. Classification happens only
101
- // after the read-only child returns, too late to acquire the lane safely.
105
+ // this review is advisory. Classification happens only after the read-only
106
+ // child returns, too late to acquire the lane safely.
102
107
  return availability.writer;
103
108
  }
104
109
  return false;
@@ -108,7 +113,6 @@ export function canStartManagedWorkflow(
108
113
  * machine verdict is advisory and cannot start any write-capable child. */
109
114
  export function getManagedWorkflowPlan(
110
115
  result: SingleResult,
111
- config: SubagentsConfig,
112
116
  availability: WorkflowAgentAvailability,
113
117
  ): ManagedWorkflowPlan | undefined {
114
118
  if (result.parked || result.dispatchFailed || isFailedResult(result)) return undefined;
@@ -135,7 +139,7 @@ export function getManagedWorkflowPlan(
135
139
  ) {
136
140
  return { kind: "review-pass-sync", initialRelation: "pre-documentation review" };
137
141
  }
138
- if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result, config)) {
142
+ if (verdict === "fail" && availability.worker && shouldTriggerFixLoop(result)) {
139
143
  return { kind: "auto-fix", initialRelation: "initial review" };
140
144
  }
141
145
  return undefined;
package/src/index.ts CHANGED
@@ -86,7 +86,7 @@ export default function (pi: ExtensionAPI): void {
86
86
  enabledNames: config.enabledAgents,
87
87
  projectTrusted: ctx.isProjectTrusted?.() === true,
88
88
  });
89
- const directive = buildDelegationDirective(agents, { maxFixRounds: config.maxFixRounds });
89
+ const directive = buildDelegationDirective(agents);
90
90
  if (!directive) return undefined;
91
91
  return { systemPrompt: `${event.systemPrompt}\n${directive}` };
92
92
  });
package/src/models.ts CHANGED
@@ -86,6 +86,22 @@ export function findModelByRef(
86
86
  return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
87
  }
88
88
 
89
+ /** Split persisted agent model overrides into the ones Pi still reports as
90
+ * available and the stale ones. Stale refs are dropped at session start (with
91
+ * a user notice) so the config never carries models that can no longer run. */
92
+ export function filterUnavailableModelOverrides(
93
+ agentModels: Record<string, string>,
94
+ models: readonly Model<Api>[],
95
+ ): { kept: Record<string, string>; dropped: Array<{ agent: string; ref: string }> } {
96
+ const kept: Record<string, string> = {};
97
+ const dropped: Array<{ agent: string; ref: string }> = [];
98
+ for (const [agent, ref] of Object.entries(agentModels)) {
99
+ if (findModelByRef(models, ref)) kept[agent] = ref;
100
+ else dropped.push({ agent, ref });
101
+ }
102
+ return { kept, dropped };
103
+ }
104
+
89
105
  /**
90
106
  * Resolve one agent's runtime route:
91
107
  *
package/src/prompt.ts CHANGED
@@ -13,7 +13,6 @@ function bullets(lines: readonly string[]): string {
13
13
 
14
14
  export function buildDelegationDirective(
15
15
  agents: AgentConfig[],
16
- options: { maxFixRounds?: number } = {},
17
16
  ): string {
18
17
  if (agents.length === 0) return "";
19
18
 
@@ -24,7 +23,7 @@ export function buildDelegationDirective(
24
23
  const hasDocumenter = agents.some((agent) => agent.name === "documenter");
25
24
  const hasReviewer = agents.some((agent) => agent.name === "reviewer");
26
25
  const hasMultiple = agents.length > 1;
27
- const autoFixEnabled = hasWorker && (options.maxFixRounds ?? 1) > 0;
26
+ const autoFixEnabled = hasWorker;
28
27
  const codeWriterNames = [
29
28
  ...(hasWorker ? ["worker"] : []),
30
29
  ...(hasCleaner ? ["cleaner"] : []),
@@ -101,10 +100,10 @@ export function buildDelegationDirective(
101
100
  ? [
102
101
  ...(hasDocumenter
103
102
  ? [
104
- `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker/fix rounds are disabled."}`,
103
+ `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker is disabled."}`,
105
104
  ]
106
105
  : []),
107
- "Resolve every gate finding; do not bypass the configured auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
106
+ "Resolve every gate finding; do not bypass the auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
108
107
  "Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
109
108
  ]
110
109
  : []),
package/src/runtime.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { rmSync } from "node:fs";
13
- import { BackgroundTaskQueue } from "./background.ts";
13
+ import { BackgroundTaskQueue, MAX_CONCURRENT_SUBAGENTS } from "./background.ts";
14
14
  import {
15
15
  completionGroupTriggersTurn,
16
16
  createCompletionBatcher,
@@ -19,7 +19,7 @@ import {
19
19
  type CompletionBatcher,
20
20
  type CompletionMessageItem,
21
21
  } from "./completion.ts";
22
- import { loadConfigSync, type ThinkingLevel } from "./config.ts";
22
+ import { type ThinkingLevel } from "./config.ts";
23
23
  import { isRunActiveStatus, monitor } from "./monitor.ts";
24
24
  import {
25
25
  persistRecoveryRecords,
@@ -124,10 +124,7 @@ export interface SubagentRuntime {
124
124
  }
125
125
 
126
126
  export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
127
- // Init-time decisions need the config synchronously; the full (migrating)
128
- // async load runs per tool call.
129
- const initialConfig = loadConfigSync(configPath);
130
- const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
127
+ const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENT_SUBAGENTS);
131
128
 
132
129
  const runtime: SubagentRuntime = {
133
130
  configPath,
package/src/setup.ts CHANGED
@@ -13,13 +13,9 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import {
14
14
  AGENT_SCOPE_VALUES,
15
15
  BUILTIN_AGENT_NAMES,
16
- CLEANER_DEFAULTED_FEATURE,
17
- DOCUMENTER_DEFAULTED_FEATURE,
18
16
  DEFAULT_CONFIG,
19
17
  DEFAULT_ENABLED_AGENTS,
20
18
  DEFAULT_IDLE_TIMEOUT_SEC,
21
- DEFAULT_MAX_CONCURRENCY,
22
- DEFAULT_MAX_FIX_ROUNDS,
23
19
  DEFAULT_THINKING_LEVEL,
24
20
  type AgentScope,
25
21
  type SubagentsConfig,
@@ -243,8 +239,6 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
243
239
  return choice.startsWith("On");
244
240
  }
245
241
 
246
- const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
247
- const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
248
242
  const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
249
243
 
250
244
  async function pickCount(
@@ -299,22 +293,6 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
299
293
  if (injection === undefined) return false;
300
294
  const scope = await pickScope(ctx, base.agentScope);
301
295
  if (scope === undefined) return false;
302
- const maxConcurrency = await pickCount(
303
- ctx,
304
- "Max sub-agents running at once?",
305
- CONCURRENCY_STEPS,
306
- base.maxConcurrency,
307
- DEFAULT_MAX_CONCURRENCY,
308
- );
309
- if (maxConcurrency === undefined) return false;
310
- const maxFixRounds = await pickCount(
311
- ctx,
312
- "Reviewer worker-fix rounds? (0 = no automatic fixes)",
313
- FIX_ROUNDS_STEPS,
314
- base.maxFixRounds,
315
- DEFAULT_MAX_FIX_ROUNDS,
316
- );
317
- if (maxFixRounds === undefined) return false;
318
296
  const idleTimeoutSec = await pickCount(
319
297
  ctx,
320
298
  "Idle timeout in seconds? (0 = disabled)",
@@ -333,16 +311,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
333
311
  maxResultLines: base.maxResultLines,
334
312
  proactiveInjection: injection,
335
313
  agentScope: scope,
336
- maxConcurrency,
337
- maxFixRounds,
338
314
  idleTimeoutSec,
339
- // Full setup is an explicit decision point: mark role-enable migrations as
340
- // processed so the user's saved selection is kept as-is.
341
- announcedFeatures: [...new Set([
342
- ...base.announcedFeatures,
343
- CLEANER_DEFAULTED_FEATURE,
344
- DOCUMENTER_DEFAULTED_FEATURE,
345
- ])],
346
315
  };
347
316
  await saveConfig(next, configPath);
348
317
  ctx.ui.notify(`pi-subagents configured with Auto thinking. Saved to ${configPath}`, "info");
@@ -357,8 +326,6 @@ async function updateRuntimeSetting(
357
326
  const choice = await ctx.ui.select("Runtime setting", [
358
327
  "Proactive injection",
359
328
  "Agent scope",
360
- "Max concurrency",
361
- "Reviewer worker-fix rounds",
362
329
  "Idle timeout",
363
330
  ]);
364
331
  if (choice === undefined) return undefined;
@@ -371,14 +338,6 @@ async function updateRuntimeSetting(
371
338
  const value = await pickScope(ctx, config.agentScope);
372
339
  if (value === undefined) continue;
373
340
  next.agentScope = value;
374
- } else if (choice.startsWith("Max concurrency")) {
375
- const value = await pickCount(ctx, "Max sub-agents running at once?", CONCURRENCY_STEPS, config.maxConcurrency, DEFAULT_MAX_CONCURRENCY);
376
- if (value === undefined) continue;
377
- next.maxConcurrency = value;
378
- } else if (choice.startsWith("Reviewer")) {
379
- const value = await pickCount(ctx, "Reviewer worker-fix rounds?", FIX_ROUNDS_STEPS, config.maxFixRounds, DEFAULT_MAX_FIX_ROUNDS);
380
- if (value === undefined) continue;
381
- next.maxFixRounds = value;
382
341
  } else {
383
342
  const value = await pickCount(ctx, "Idle timeout in seconds?", IDLE_TIMEOUT_STEPS, config.idleTimeoutSec, DEFAULT_IDLE_TIMEOUT_SEC);
384
343
  if (value === undefined) continue;
package/src/spawn.ts CHANGED
@@ -272,7 +272,7 @@ export function isRetryableStartupFailure(result: SingleResult, durationMs: numb
272
272
  }
273
273
 
274
274
  export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
275
- return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
275
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child failed before its initial RPC prompt was dispatched and produced no model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or dispatch fewer sub-agents at once.`;
276
276
  }
277
277
 
278
278
  export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
@@ -720,7 +720,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
720
720
  if (!ownsResumeReservation(thread, reservation)) {
721
721
  throw new Error(`Run #${runId} changed while resume configuration was loading.`);
722
722
  }
723
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
724
723
  const currentAgents = discoverAgents(currentCtx.cwd, {
725
724
  scope: currentConfig.agentScope,
726
725
  enabledNames: currentConfig.enabledAgents,
@@ -888,7 +887,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
888
887
  if (!ownsFork()) throw new Error(`Run #${runId} changed while its retained session was being forked.`);
889
888
  const currentConfig = await loadConfig(runtime.configPath);
890
889
  if (!ownsFork()) throw new Error(`Run #${runId} changed while fork configuration was loading.`);
891
- runtime.backgroundQueue.setConcurrency(currentConfig.maxConcurrency);
892
890
  const currentAgents = discoverAgents(currentCtx.cwd, {
893
891
  scope: currentConfig.agentScope,
894
892
  enabledNames: currentConfig.enabledAgents,
@@ -971,7 +969,6 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
971
969
  let activeIdleTimeoutMs = runConfig.idleTimeoutSec * 1000;
972
970
  try {
973
971
  const startConfig = await loadConfig(runtime.configPath);
974
- runtime.backgroundQueue.setConcurrency(startConfig.maxConcurrency);
975
972
  const resolvedStart = resolveDispatchModelRoute(agent, startConfig, runCtx);
976
973
  activeRoute = isolation === "worktree"
977
974
  ? { ...resolvedStart, agent: withWorktreeSystemPrompt(resolvedStart.agent) }
@@ -1064,7 +1061,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
1064
1061
 
1065
1062
  if (thread.retireOnSettle) runtime.retireThreadSession(thread);
1066
1063
  let workflowOutcome: ManagedWorkflowOutcome | undefined;
1067
- const workflowPlan = getManagedWorkflowPlan(result, runConfig, workflowAvailability);
1064
+ const workflowPlan = getManagedWorkflowPlan(result, workflowAvailability);
1068
1065
  if (workflowPlan && runtime.sessionActive) {
1069
1066
  thread.state = "running";
1070
1067
  // The stable parent row now represents workflow ownership, not whichever