@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.
- package/CHANGELOG.md +37 -0
- package/README.md +176 -133
- package/docs/architecture/architecture.md +148 -92
- package/docs/decisions/0001-deferred-patches.md +11 -5
- package/docs/plans/0048-implement-subagents-api.md +2 -1
- package/docs/plans/0049-remove-group-join-output-file-rpc.md +22 -5
- package/docs/plans/0051-update-adr-0001-hard-fork.md +2 -1
- package/docs/plans/0052-remove-scheduled-subagents.md +4 -2
- package/docs/plans/0057-structured-debug-logging.md +22 -52
- package/docs/plans/0069-create-subagent-runtime.md +345 -0
- package/docs/plans/0071-extract-session-config-assembler.md +362 -0
- package/docs/retro/0049-remove-group-join-output-file-rpc.md +15 -4
- package/docs/retro/0051-update-adr-0001-hard-fork.md +7 -3
- package/docs/retro/0053-extract-model-resolution-from-execute.md +14 -4
- package/docs/retro/0054-decompose-index-into-modules.md +20 -5
- package/docs/retro/0057-structured-debug-logging.md +77 -0
- package/docs/retro/0069-create-subagent-runtime.md +43 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +7 -0
- package/src/agent-runner.ts +51 -189
- package/src/debug.ts +4 -2
- package/src/index.ts +37 -28
- package/src/runtime.ts +62 -0
- package/src/session-config.ts +263 -0
- package/src/tools/agent-tool.ts +4 -2
- package/src/ui/agent-menu.ts +16 -13
package/src/agent-manager.ts
CHANGED
|
@@ -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,
|
package/src/agent-runner.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 ??
|
|
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
|
|
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 (
|
|
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,
|
|
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
|
-
// ----
|
|
51
|
-
const
|
|
50
|
+
// ---- Runtime: all mutable extension state in one place ----
|
|
51
|
+
const runtime = createSubagentRuntime();
|
|
52
52
|
|
|
53
53
|
// ---- Notification system ----
|
|
54
|
-
//
|
|
55
|
-
|
|
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
|
|
60
|
-
updateWidget: () => widget
|
|
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
|
|
143
|
-
widget
|
|
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
|
|
198
|
-
ensureTimer: () => widget
|
|
199
|
-
update: () => widget
|
|
200
|
-
markFinished: (id) => widget
|
|
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:
|
|
250
|
-
graceTurns:
|
|
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
|
+
}
|