@gotgenes/pi-subagents 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,7 @@ import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
13
  import { debugLog } from "./debug.js";
14
+ import type { RunConfig } from "./runtime.js";
14
15
  import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
15
16
  import { addUsage } from "./usage.js";
16
17
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
@@ -72,6 +73,7 @@ export class AgentManager {
72
73
  private onStart?: OnAgentStart;
73
74
  private onCompact?: OnAgentCompact;
74
75
  private maxConcurrent: number;
76
+ private getRunConfig?: () => RunConfig;
75
77
 
76
78
  /** Queue of background agents waiting to start. */
77
79
  private queue: { id: string; args: SpawnArgs }[] = [];
@@ -83,10 +85,12 @@ export class AgentManager {
83
85
  maxConcurrent = DEFAULT_MAX_CONCURRENT,
84
86
  onStart?: OnAgentStart,
85
87
  onCompact?: OnAgentCompact,
88
+ getRunConfig?: () => RunConfig,
86
89
  ) {
87
90
  this.onComplete = onComplete;
88
91
  this.onStart = onStart;
89
92
  this.onCompact = onCompact;
93
+ this.getRunConfig = getRunConfig;
90
94
  this.maxConcurrent = maxConcurrent;
91
95
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
92
96
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
@@ -182,10 +186,13 @@ export class AgentManager {
182
186
  }
183
187
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
184
188
 
189
+ const runConfig = this.getRunConfig?.();
185
190
  const promise = runAgent(ctx, type, prompt, {
186
191
  pi,
187
192
  model: options.model,
188
193
  maxTurns: options.maxTurns,
194
+ defaultMaxTurns: runConfig?.defaultMaxTurns,
195
+ graceTurns: runConfig?.graceTurns,
189
196
  isolated: options.isolated,
190
197
  inheritContext: options.inheritContext,
191
198
  thinkingLevel: options.thinkingLevel,
@@ -14,19 +14,9 @@ import {
14
14
  SessionManager,
15
15
  SettingsManager,
16
16
  } from "@earendil-works/pi-coding-agent";
17
- import {
18
- getAgentConfig,
19
- getConfig,
20
- getMemoryToolNames,
21
- getReadOnlyMemoryToolNames,
22
- getToolNamesForType,
23
- } from "./agent-types.js";
24
17
  import { buildParentContext, extractText } from "./context.js";
25
- import { DEFAULT_AGENTS } from "./default-agents.js";
26
18
  import { detectEnv } from "./env.js";
27
- import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
28
- import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
29
- import { preloadSkills } from "./skill-loader.js";
19
+ import { assembleSessionConfig } from "./session-config.js";
30
20
  import type { SubagentType, ThinkingLevel } from "./types.js";
31
21
 
32
22
  /** Names of tools registered by this extension that subagents must NOT inherit. */
@@ -68,69 +58,12 @@ function filterActiveTools(
68
58
  });
69
59
  }
70
60
 
71
- /** Default max turns. undefined = unlimited (no turn limit). */
72
- let defaultMaxTurns: number | undefined;
73
-
74
61
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
75
62
  export function normalizeMaxTurns(n: number | undefined): number | undefined {
76
63
  if (n == null || n === 0) return undefined;
77
64
  return Math.max(1, n);
78
65
  }
79
66
 
80
- /** Get the default max turns value. undefined = unlimited. */
81
- export function getDefaultMaxTurns(): number | undefined {
82
- return defaultMaxTurns;
83
- }
84
- /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
85
- export function setDefaultMaxTurns(n: number | undefined): void {
86
- defaultMaxTurns = normalizeMaxTurns(n);
87
- }
88
-
89
- /** Additional turns allowed after the soft limit steer message. */
90
- let graceTurns = 5;
91
-
92
- /** Get the grace turns value. */
93
- export function getGraceTurns(): number {
94
- return graceTurns;
95
- }
96
- /** Set the grace turns value (minimum 1). */
97
- export function setGraceTurns(n: number): void {
98
- graceTurns = Math.max(1, n);
99
- }
100
-
101
- /**
102
- * Try to find the right model for an agent type.
103
- * Priority: explicit option > config.model > parent model.
104
- */
105
- function resolveDefaultModel(
106
- parentModel: Model<any> | undefined,
107
- registry: {
108
- find(provider: string, modelId: string): Model<any> | undefined;
109
- getAvailable?(): Model<any>[];
110
- },
111
- configModel?: string,
112
- ): Model<any> | undefined {
113
- if (configModel) {
114
- const slashIdx = configModel.indexOf("/");
115
- if (slashIdx !== -1) {
116
- const provider = configModel.slice(0, slashIdx);
117
- const modelId = configModel.slice(slashIdx + 1);
118
-
119
- // Build a set of available model keys for fast lookup
120
- const available = registry.getAvailable?.();
121
- const availableKeys = available
122
- ? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
123
- : undefined;
124
- const isAvailable = (p: string, id: string) =>
125
- !availableKeys || availableKeys.has(`${p}/${id}`);
126
-
127
- const found = registry.find(provider, modelId);
128
- if (found && isAvailable(provider, modelId)) return found;
129
- }
130
- }
131
-
132
- return parentModel;
133
- }
134
67
 
135
68
  /** Info about a tool event in the subagent. */
136
69
  export interface ToolActivity {
@@ -174,6 +107,17 @@ export interface RunOptions {
174
107
  reason: "manual" | "threshold" | "overflow";
175
108
  tokensBefore: number;
176
109
  }) => void;
110
+ /**
111
+ * Default max turns from runtime config. Falls back to the module-scope
112
+ * `defaultMaxTurns` during the lift-and-shift migration; superseded by
113
+ * per-call `maxTurns` and per-agent `agentConfig.maxTurns`.
114
+ */
115
+ defaultMaxTurns?: number;
116
+ /**
117
+ * Grace turns after the soft-limit steer message. Falls back to the
118
+ * module-scope `graceTurns` during migration.
119
+ */
120
+ graceTurns?: number;
177
121
  }
178
122
 
179
123
  export interface RunResult {
@@ -236,96 +180,27 @@ export async function runAgent(
236
180
  prompt: string,
237
181
  options: RunOptions,
238
182
  ): Promise<RunResult> {
239
- const config = getConfig(type);
240
- const agentConfig = getAgentConfig(type);
241
-
242
- // Resolve working directory: worktree override > parent cwd
183
+ // Resolve working directory upfront — needed for detectEnv before assembly.
243
184
  const effectiveCwd = options.cwd ?? ctx.cwd;
244
-
245
185
  const env = await detectEnv(options.pi, effectiveCwd);
246
186
 
247
- // Get parent system prompt for append-mode agents
248
- const parentSystemPrompt = ctx.getSystemPrompt();
249
-
250
- // Build prompt extras (memory, skill preloading)
251
- const extras: PromptExtras = {};
252
-
253
- // Resolve extensions/skills: isolated overrides to false
254
- const extensions = options.isolated ? false : config.extensions;
255
- const skills = options.isolated ? false : config.skills;
256
-
257
- // Skill preloading: when skills is string[], preload their content into prompt
258
- if (Array.isArray(skills)) {
259
- const loaded = preloadSkills(skills, effectiveCwd);
260
- if (loaded.length > 0) {
261
- extras.skillBlocks = loaded;
262
- }
263
- }
264
-
265
- let toolNames = getToolNamesForType(type);
266
-
267
- // Persistent memory: detect write capability and branch accordingly.
268
- // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
269
- if (agentConfig?.memory) {
270
- const existingNames = new Set(toolNames);
271
- const denied = agentConfig.disallowedTools
272
- ? new Set(agentConfig.disallowedTools)
273
- : undefined;
274
- const effectivelyHas = (name: string) =>
275
- existingNames.has(name) && !denied?.has(name);
276
- const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
277
-
278
- if (hasWriteTools) {
279
- // Read-write memory: add any missing memory tool names (read/write/edit)
280
- const extraNames = getMemoryToolNames(existingNames);
281
- if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
282
- extras.memoryBlock = buildMemoryBlock(
283
- agentConfig.name,
284
- agentConfig.memory,
285
- effectiveCwd,
286
- );
287
- } else {
288
- // Read-only memory: only add read tool name, use read-only prompt
289
- const extraNames = getReadOnlyMemoryToolNames(existingNames);
290
- if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
291
- extras.memoryBlock = buildReadOnlyMemoryBlock(
292
- agentConfig.name,
293
- agentConfig.memory,
294
- effectiveCwd,
295
- );
296
- }
297
- }
298
-
299
- // Build system prompt from agent config
300
- let systemPrompt: string;
301
- if (agentConfig) {
302
- systemPrompt = buildAgentPrompt(
303
- agentConfig,
304
- effectiveCwd,
305
- env,
306
- parentSystemPrompt,
307
- extras,
308
- );
309
- } else {
310
- // Unknown type fallback: spread the canonical general-purpose config (defensive —
311
- // unreachable in practice since index.ts resolves unknown types before calling runAgent).
312
- const fallback = DEFAULT_AGENTS.get("general-purpose");
313
- if (!fallback)
314
- throw new Error(
315
- `No fallback config available for unknown type "${type}"`,
316
- );
317
- systemPrompt = buildAgentPrompt(
318
- { ...fallback, name: type },
319
- effectiveCwd,
320
- env,
321
- parentSystemPrompt,
322
- extras,
323
- );
324
- }
325
-
326
- // When skills is string[], we've already preloaded them into the prompt.
327
- // Still pass noSkills: true since we don't need the skill loader to load them again.
328
- const noSkills = skills === false || Array.isArray(skills);
187
+ // Assemble session configuration (synchronous, no SDK objects).
188
+ const cfg = assembleSessionConfig(
189
+ type,
190
+ {
191
+ cwd: ctx.cwd,
192
+ parentSystemPrompt: ctx.getSystemPrompt(),
193
+ parentModel: ctx.model,
194
+ modelRegistry: ctx.modelRegistry,
195
+ },
196
+ {
197
+ cwd: options.cwd,
198
+ isolated: options.isolated,
199
+ model: options.model,
200
+ thinkingLevel: options.thinkingLevel,
201
+ },
202
+ env,
203
+ );
329
204
 
330
205
  const agentDir = getAgentDir();
331
206
 
@@ -336,56 +211,43 @@ export async function runAgent(
336
211
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
337
212
  // is embedded in systemPromptOverride) or inherit_context (conversation).
338
213
  const loader = new DefaultResourceLoader({
339
- cwd: effectiveCwd,
214
+ cwd: cfg.effectiveCwd,
340
215
  agentDir,
341
- noExtensions: extensions === false,
342
- noSkills,
216
+ noExtensions: cfg.extensions === false,
217
+ noSkills: cfg.noSkills,
343
218
  noPromptTemplates: true,
344
219
  noThemes: true,
345
220
  noContextFiles: true,
346
- systemPromptOverride: () => systemPrompt,
221
+ systemPromptOverride: () => cfg.systemPrompt,
347
222
  appendSystemPromptOverride: () => [],
348
223
  });
349
224
  await loader.reload();
350
225
 
351
- // Resolve model: explicit option > config.model > parent model
352
- const model =
353
- options.model ??
354
- resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model);
355
-
356
- // Resolve thinking level: explicit option > agent config > undefined (inherit)
357
- const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
358
-
359
226
  const sessionOpts: Parameters<typeof createAgentSession>[0] = {
360
- cwd: effectiveCwd,
227
+ cwd: cfg.effectiveCwd,
361
228
  agentDir,
362
- sessionManager: SessionManager.inMemory(effectiveCwd),
363
- settingsManager: SettingsManager.create(effectiveCwd, agentDir),
229
+ sessionManager: SessionManager.inMemory(cfg.effectiveCwd),
230
+ settingsManager: SettingsManager.create(cfg.effectiveCwd, agentDir),
364
231
  modelRegistry: ctx.modelRegistry,
365
- model,
366
- tools: toolNames,
232
+ model: cfg.model as Model<any> | undefined,
233
+ tools: cfg.toolNames,
367
234
  resourceLoader: loader,
368
235
  };
369
- if (thinkingLevel) {
370
- sessionOpts.thinkingLevel = thinkingLevel;
236
+ if (cfg.thinkingLevel) {
237
+ sessionOpts.thinkingLevel = cfg.thinkingLevel;
371
238
  }
372
239
 
373
240
  const { session } = await createAgentSession(sessionOpts);
374
241
 
375
- // Build disallowed tools set from agent config
376
- const disallowedSet = agentConfig?.disallowedTools
377
- ? new Set(agentConfig.disallowedTools)
378
- : undefined;
379
-
380
242
  // Filter active tools: remove our own tools to prevent nesting,
381
243
  // apply extension allowlist if specified, and apply disallowedTools denylist.
382
244
  // First pass — over built-in tools, before bindExtensions registers extension tools.
383
- if (extensions !== false || disallowedSet) {
245
+ if (cfg.extensions !== false || cfg.disallowedSet) {
384
246
  const filtered = filterActiveTools(
385
247
  session.getActiveToolNames(),
386
- toolNames,
387
- extensions,
388
- disallowedSet,
248
+ cfg.toolNames,
249
+ cfg.extensions,
250
+ cfg.disallowedSet,
389
251
  );
390
252
  session.setActiveToolsByName(filtered);
391
253
  }
@@ -409,12 +271,12 @@ export async function runAgent(
409
271
  // re-filter, the `extensions: string[]` allowlist branch never matches any
410
272
  // extension tools and `extensions: true` lets non-allowlisted denylist
411
273
  // entries slip in. Run the same filter against the post-bind active set.
412
- if (extensions !== false || disallowedSet) {
274
+ if (cfg.extensions !== false || cfg.disallowedSet) {
413
275
  const refiltered = filterActiveTools(
414
276
  session.getActiveToolNames(),
415
- toolNames,
416
- extensions,
417
- disallowedSet,
277
+ cfg.toolNames,
278
+ cfg.extensions,
279
+ cfg.disallowedSet,
418
280
  );
419
281
  session.setActiveToolsByName(refiltered);
420
282
  }
@@ -424,7 +286,7 @@ export async function runAgent(
424
286
  // Track turns for graceful max_turns enforcement
425
287
  let turnCount = 0;
426
288
  const maxTurns = normalizeMaxTurns(
427
- options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns,
289
+ options.maxTurns ?? cfg.agentMaxTurns ?? options.defaultMaxTurns,
428
290
  );
429
291
  let softLimitReached = false;
430
292
  let aborted = false;
@@ -440,7 +302,7 @@ export async function runAgent(
440
302
  session.steer(
441
303
  "You have reached your turn limit. Wrap up immediately — provide your final answer now.",
442
304
  );
443
- } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
305
+ } else if (softLimitReached && turnCount >= maxTurns + (options.graceTurns ?? 5)) {
444
306
  aborted = true;
445
307
  session.abort();
446
308
  }
package/src/debug.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  * throughout the package. Production behavior is unchanged when unset.
6
6
  */
7
7
 
8
- export const DEBUG = process.env.PI_SUBAGENTS_DEBUG === "1";
8
+ export function isDebug(): boolean {
9
+ return process.env.PI_SUBAGENTS_DEBUG === "1";
10
+ }
9
11
 
10
12
  export function debugLog(context: string, err: unknown): void {
11
- if (DEBUG) console.warn(`[pi-subagents:debug] ${context}:`, err);
13
+ if (isDebug()) console.warn(`[pi-subagents:debug] ${context}:`, err);
12
14
  }
package/src/index.ts CHANGED
@@ -13,12 +13,13 @@
13
13
  import { join } from "node:path";
14
14
  import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
15
15
  import { AgentManager } from "./agent-manager.js";
16
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
16
+ import { getAgentConversation, normalizeMaxTurns, steerAgent } from "./agent-runner.js";
17
17
  import { getAgentConfig, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, } from "./agent-types.js";
18
18
  import { loadCustomAgents } from "./custom-agents.js";
19
19
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
20
20
  import { buildEventData, createNotificationSystem } from "./notification.js";
21
21
  import { createNotificationRenderer } from "./renderer.js";
22
+ import { createSubagentRuntime } from "./runtime.js";
22
23
  import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
23
24
  import { createSubagentsService } from "./service-adapter.js";
24
25
  import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
@@ -29,7 +30,6 @@ import { createSteerTool } from "./tools/steer-tool.js";
29
30
  import { type NotificationDetails } from "./types.js";
30
31
  import { createAgentsMenuHandler } from "./ui/agent-menu.js";
31
32
  import {
32
- type AgentActivity,
33
33
  AgentWidget,
34
34
  type UICtx,
35
35
  } from "./ui/agent-widget.js";
@@ -47,17 +47,17 @@ export default function (pi: ExtensionAPI) {
47
47
  // Initial load
48
48
  reloadCustomAgents();
49
49
 
50
- // ---- Agent activity tracking ----
51
- const agentActivity = new Map<string, AgentActivity>();
50
+ // ---- Runtime: all mutable extension state in one place ----
51
+ const runtime = createSubagentRuntime();
52
52
 
53
53
  // ---- Notification system ----
54
- // Widget assigned after AgentManager construction; arrow closures capture by reference.
55
- let widget: AgentWidget;
54
+ // runtime.widget is assigned after AgentManager construction; arrow closures
55
+ // capture `runtime` by reference so they always read the current value.
56
56
  const notifications = createNotificationSystem({
57
57
  sendMessage: (msg, opts) => pi.sendMessage(msg as any, opts as any),
58
- agentActivity,
59
- markFinished: (id) => widget.markFinished(id),
60
- updateWidget: () => widget.update(),
58
+ agentActivity: runtime.agentActivity,
59
+ markFinished: (id) => runtime.widget!.markFinished(id),
60
+ updateWidget: () => runtime.widget!.update(),
61
61
  });
62
62
 
63
63
  // Background completion: emit lifecycle event and delegate to notification system
@@ -102,21 +102,21 @@ export default function (pi: ExtensionAPI) {
102
102
  tokensBefore: info.tokensBefore,
103
103
  compactionCount: record.compactionCount,
104
104
  });
105
- });
105
+ },
106
+ () => ({ defaultMaxTurns: runtime.defaultMaxTurns, graceTurns: runtime.graceTurns }));
106
107
 
107
108
  // Typed service published via Symbol.for() for cross-extension access.
108
109
  // Consumers: const { getSubagentsService } = await import("@gotgenes/pi-subagents");
109
- let currentCtx: { pi: unknown; ctx: unknown } | undefined;
110
110
  const service = createSubagentsService({
111
111
  manager,
112
112
  resolveModel,
113
- getCtx: () => currentCtx,
114
- getModelRegistry: () => (currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
113
+ getCtx: () => runtime.currentCtx,
114
+ getModelRegistry: () => (runtime.currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
115
115
  });
116
116
  publishSubagentsService(service);
117
117
 
118
118
  pi.on("session_start", async (_event, ctx) => {
119
- currentCtx = { pi, ctx };
119
+ runtime.currentCtx = { pi, ctx };
120
120
  manager.clearCompleted();
121
121
  });
122
122
 
@@ -128,19 +128,19 @@ export default function (pi: ExtensionAPI) {
128
128
  // If the session is going down, there's nothing left to consume agent results.
129
129
  pi.on("session_shutdown", async () => {
130
130
  unpublishSubagentsService();
131
- currentCtx = undefined;
131
+ runtime.currentCtx = undefined;
132
132
  manager.abortAll();
133
133
  notifications.dispose();
134
134
  manager.dispose();
135
135
  });
136
136
 
137
137
  // Live widget: show running agents above editor
138
- widget = new AgentWidget(manager, agentActivity);
138
+ runtime.widget = new AgentWidget(manager, runtime.agentActivity);
139
139
 
140
140
  // Grab UI context from first tool execution + clear lingering widget on new turn
141
141
  pi.on("tool_execution_start", async (_event, ctx) => {
142
- widget.setUICtx(ctx.ui as UICtx);
143
- widget.onTurnStart();
142
+ runtime.widget!.setUICtx(ctx.ui as UICtx);
143
+ runtime.widget!.onTurnStart();
144
144
  });
145
145
 
146
146
  /** Build the full type list text dynamically from the unified registry. */
@@ -176,8 +176,8 @@ export default function (pi: ExtensionAPI) {
176
176
  applyAndEmitLoaded(
177
177
  {
178
178
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
179
- setDefaultMaxTurns,
180
- setGraceTurns,
179
+ setDefaultMaxTurns: (n) => { runtime.defaultMaxTurns = normalizeMaxTurns(n); },
180
+ setGraceTurns: (n) => { runtime.graceTurns = Math.max(1, n); },
181
181
  },
182
182
  (event, payload) => pi.events.emit(event, payload),
183
183
  );
@@ -194,17 +194,18 @@ export default function (pi: ExtensionAPI) {
194
194
  listAgents: () => manager.listAgents(),
195
195
  },
196
196
  widget: {
197
- setUICtx: (ctx) => widget.setUICtx(ctx as UICtx),
198
- ensureTimer: () => widget.ensureTimer(),
199
- update: () => widget.update(),
200
- markFinished: (id) => widget.markFinished(id),
197
+ setUICtx: (ctx) => runtime.widget!.setUICtx(ctx as UICtx),
198
+ ensureTimer: () => runtime.widget!.ensureTimer(),
199
+ update: () => runtime.widget!.update(),
200
+ markFinished: (id) => runtime.widget!.markFinished(id),
201
201
  },
202
- agentActivity,
202
+ agentActivity: runtime.agentActivity,
203
203
  emitEvent: (name, data) => pi.events.emit(name, data),
204
204
  reloadCustomAgents,
205
205
  typeListText,
206
206
  availableTypesText: getAvailableTypes().join(", "),
207
207
  agentDir: getAgentDir(),
208
+ getDefaultMaxTurns: () => runtime.defaultMaxTurns,
208
209
  }) as any));
209
210
 
210
211
  // ---- get_subagent_result tool ----
@@ -234,7 +235,7 @@ export default function (pi: ExtensionAPI) {
234
235
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
235
236
  },
236
237
  reloadCustomAgents,
237
- agentActivity,
238
+ agentActivity: runtime.agentActivity,
238
239
  getModelLabel: (type, registry) => {
239
240
  const cfg = getAgentConfig(type);
240
241
  if (!cfg?.model) return 'inherit';
@@ -246,9 +247,17 @@ export default function (pi: ExtensionAPI) {
246
247
  },
247
248
  snapshotSettings: () => ({
248
249
  maxConcurrent: manager.getMaxConcurrent(),
249
- defaultMaxTurns: getDefaultMaxTurns() ?? 0,
250
- graceTurns: getGraceTurns(),
250
+ defaultMaxTurns: runtime.defaultMaxTurns ?? 0,
251
+ graceTurns: runtime.graceTurns,
251
252
  }),
253
+ getDefaultMaxTurns: () => runtime.defaultMaxTurns,
254
+ getGraceTurns: () => runtime.graceTurns,
255
+ setDefaultMaxTurns: (n) => {
256
+ runtime.defaultMaxTurns = normalizeMaxTurns(n);
257
+ },
258
+ setGraceTurns: (n) => {
259
+ runtime.graceTurns = Math.max(1, n);
260
+ },
252
261
  saveSettings: (settings, successMsg) => saveAndEmitChanged(
253
262
  settings,
254
263
  successMsg,
package/src/runtime.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * runtime.ts — SubagentRuntime: composition root for all mutable extension state.
3
+ *
4
+ * Eliminates module-scope state in agent-runner.ts and closure-scoped state
5
+ * in index.ts by consolidating them into a single, testable object.
6
+ * Follows the same pattern as pi-permission-system's ExtensionRuntime.
7
+ */
8
+
9
+ import type { AgentActivity, AgentWidget } from "./ui/agent-widget.js";
10
+
11
+ /**
12
+ * Narrow config subset read by AgentManager when constructing RunOptions.
13
+ * Kept separate so callers can satisfy it without depending on the full runtime.
14
+ */
15
+ export interface RunConfig {
16
+ readonly defaultMaxTurns: number | undefined;
17
+ readonly graceTurns: number;
18
+ }
19
+
20
+ /**
21
+ * All mutable state owned by the pi-subagents extension.
22
+ *
23
+ * Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`.
24
+ * Tests construct a fresh runtime per test for full isolation.
25
+ */
26
+ export interface SubagentRuntime {
27
+ // ── Execution config (was module-scope in agent-runner.ts) ──────────────
28
+ /** Default max turns for all agents. undefined = unlimited. */
29
+ defaultMaxTurns: number | undefined;
30
+ /** Additional turns allowed after the soft-limit steer message. */
31
+ graceTurns: number;
32
+
33
+ // ── Session state (was closure-scoped in index.ts) ───────────────────────
34
+ /** Active Pi session context — set on session_start, cleared on session_shutdown. */
35
+ currentCtx: { pi: unknown; ctx: unknown } | undefined;
36
+ /**
37
+ * Per-agent live activity state shared across the notification system,
38
+ * widget, and tool handlers. The Map itself is never replaced.
39
+ */
40
+ readonly agentActivity: Map<string, AgentActivity>;
41
+ /**
42
+ * Persistent widget reference. Null until constructed after AgentManager.
43
+ * Notification closures use `runtime.widget!` — safe because agents always
44
+ * complete after widget construction.
45
+ */
46
+ widget: AgentWidget | null;
47
+ }
48
+
49
+ /**
50
+ * Create a fully-initialized SubagentRuntime with default values.
51
+ *
52
+ * Call once at extension startup; pass the result to factories and handlers.
53
+ */
54
+ export function createSubagentRuntime(): SubagentRuntime {
55
+ return {
56
+ defaultMaxTurns: undefined,
57
+ graceTurns: 5,
58
+ currentCtx: undefined,
59
+ agentActivity: new Map(),
60
+ widget: null,
61
+ };
62
+ }