@tintinweb/pi-subagents 0.5.0 → 0.5.1
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 +11 -0
- package/README.md +3 -5
- package/dist/agent-runner.d.ts +3 -1
- package/dist/agent-runner.js +24 -5
- package/dist/custom-agents.js +7 -7
- package/dist/index.js +24 -30
- package/dist/invocation-config.d.ts +22 -0
- package/dist/invocation-config.js +15 -0
- package/dist/types.d.ts +6 -6
- package/package.json +4 -4
- package/src/agent-runner.ts +23 -5
- package/src/custom-agents.ts +7 -7
- package/src/index.ts +25 -33
- package/src/invocation-config.ts +40 -0
- package/src/types.ts +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.1] - 2026-03-24
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Agent config is authoritative** — frontmatter values for `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, and `isolation` now take precedence over `Agent` tool-call parameters. Tool-call params only fill fields the agent config leaves unspecified.
|
|
14
|
+
- **`join_mode` is now a global setting only** — removed the per-call `join_mode` parameter from the `Agent` tool. Join behavior is configured via `/agents` → Settings → Join mode.
|
|
15
|
+
- **`max_turns: 0` means unlimited** — agent files can now explicitly set `max_turns: 0` to lock unlimited turns. Previously `0` was silently clamped to `1`.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **Final subagent text preserved from non-streaming providers** — agents using providers that return the final message without streaming `text_delta` events no longer return empty results. Falls back to extracting text from the completed session history.
|
|
19
|
+
- **`effectiveMaxTurns` passed to spawn calls** — previously `params.max_turns` was passed raw to both foreground and background spawn, bypassing the agent config entirely.
|
|
20
|
+
|
|
10
21
|
## [0.5.0] - 2026-03-22
|
|
11
22
|
|
|
12
23
|
### Added
|
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
170
170
|
| `isolated` | `false` | No extension/MCP tools, only built-in |
|
|
171
171
|
| `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
|
|
172
172
|
|
|
173
|
-
Frontmatter
|
|
173
|
+
Frontmatter is authoritative. If an agent file sets `model`, `thinking`, `max_turns`, `inherit_context`, `run_in_background`, `isolated`, or `isolation`, those values are locked for that agent. `Agent` tool parameters only fill fields the agent config leaves unspecified.
|
|
174
174
|
|
|
175
175
|
## Tools
|
|
176
176
|
|
|
@@ -191,7 +191,6 @@ Launch a sub-agent.
|
|
|
191
191
|
| `isolated` | boolean | no | No extension/MCP tools |
|
|
192
192
|
| `isolation` | `"worktree"` | no | Run in an isolated git worktree |
|
|
193
193
|
| `inherit_context` | boolean | no | Fork parent conversation into agent |
|
|
194
|
-
| `join_mode` | `"async"` \| `"group"` | no | Override join strategy for background completion notifications (default: smart) |
|
|
195
194
|
|
|
196
195
|
### `get_subagent_result`
|
|
197
196
|
|
|
@@ -260,7 +259,7 @@ Foreground agents bypass the queue — they block the parent anyway.
|
|
|
260
259
|
|
|
261
260
|
## Join Strategies
|
|
262
261
|
|
|
263
|
-
When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered
|
|
262
|
+
When background agents complete, they notify the main agent. The **join mode** controls how these notifications are delivered. It applies only to background agents.
|
|
264
263
|
|
|
265
264
|
| Mode | Behavior |
|
|
266
265
|
|------|----------|
|
|
@@ -271,8 +270,7 @@ When background agents complete, they notify the main agent. The **join mode** c
|
|
|
271
270
|
**Timeout behavior:** When agents are grouped, a 30-second timeout starts after the first agent completes. If not all agents finish in time, a partial notification is sent with completed results and remaining agents continue with a shorter 15-second re-batch window for stragglers.
|
|
272
271
|
|
|
273
272
|
**Configuration:**
|
|
274
|
-
-
|
|
275
|
-
- Global default: `/agents` → Settings → Join mode
|
|
273
|
+
- Configure join mode in `/agents` → Settings → Join mode
|
|
276
274
|
|
|
277
275
|
## Events
|
|
278
276
|
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -5,9 +5,11 @@ import type { Model } from "@mariozechner/pi-ai";
|
|
|
5
5
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
7
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
8
|
+
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
9
|
+
export declare function normalizeMaxTurns(n: number | undefined): number | undefined;
|
|
8
10
|
/** Get the default max turns value. undefined = unlimited. */
|
|
9
11
|
export declare function getDefaultMaxTurns(): number | undefined;
|
|
10
|
-
/** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
|
|
12
|
+
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
11
13
|
export declare function setDefaultMaxTurns(n: number | undefined): void;
|
|
12
14
|
/** Get the grace turns value. */
|
|
13
15
|
export declare function getGraceTurns(): number;
|
package/dist/agent-runner.js
CHANGED
|
@@ -12,10 +12,16 @@ import { preloadSkills } from "./skill-loader.js";
|
|
|
12
12
|
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
|
|
13
13
|
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
14
14
|
let defaultMaxTurns;
|
|
15
|
+
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
16
|
+
export function normalizeMaxTurns(n) {
|
|
17
|
+
if (n == null || n === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
return Math.max(1, n);
|
|
20
|
+
}
|
|
15
21
|
/** Get the default max turns value. undefined = unlimited. */
|
|
16
22
|
export function getDefaultMaxTurns() { return defaultMaxTurns; }
|
|
17
|
-
/** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
|
|
18
|
-
export function setDefaultMaxTurns(n) { defaultMaxTurns =
|
|
23
|
+
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
24
|
+
export function setDefaultMaxTurns(n) { defaultMaxTurns = normalizeMaxTurns(n); }
|
|
19
25
|
/** Additional turns allowed after the soft limit steer message. */
|
|
20
26
|
let graceTurns = 5;
|
|
21
27
|
/** Get the grace turns value. */
|
|
@@ -61,6 +67,18 @@ function collectResponseText(session) {
|
|
|
61
67
|
});
|
|
62
68
|
return { getText: () => text, unsubscribe };
|
|
63
69
|
}
|
|
70
|
+
/** Get the last assistant text from the completed session history. */
|
|
71
|
+
function getLastAssistantText(session) {
|
|
72
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
73
|
+
const msg = session.messages[i];
|
|
74
|
+
if (msg.role !== "assistant")
|
|
75
|
+
continue;
|
|
76
|
+
const text = extractText(msg.content).trim();
|
|
77
|
+
if (text)
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
64
82
|
/**
|
|
65
83
|
* Wire an AbortSignal to abort a session.
|
|
66
84
|
* Returns a cleanup function to remove the listener.
|
|
@@ -198,7 +216,7 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
198
216
|
options.onSessionCreated?.(session);
|
|
199
217
|
// Track turns for graceful max_turns enforcement
|
|
200
218
|
let turnCount = 0;
|
|
201
|
-
const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
|
|
219
|
+
const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
|
|
202
220
|
let softLimitReached = false;
|
|
203
221
|
let aborted = false;
|
|
204
222
|
let currentMessageText = "";
|
|
@@ -249,7 +267,8 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
249
267
|
collector.unsubscribe();
|
|
250
268
|
cleanupAbort();
|
|
251
269
|
}
|
|
252
|
-
|
|
270
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session);
|
|
271
|
+
return { responseText, session, aborted, steered: softLimitReached };
|
|
253
272
|
}
|
|
254
273
|
/**
|
|
255
274
|
* Send a new prompt to an existing session (resume).
|
|
@@ -273,7 +292,7 @@ export async function resumeAgent(session, prompt, options = {}) {
|
|
|
273
292
|
unsubToolUse();
|
|
274
293
|
cleanupAbort();
|
|
275
294
|
}
|
|
276
|
-
return collector.getText();
|
|
295
|
+
return collector.getText().trim() || getLastAssistantText(session);
|
|
277
296
|
}
|
|
278
297
|
/**
|
|
279
298
|
* Send a steering message to a running subagent.
|
package/dist/custom-agents.js
CHANGED
|
@@ -54,12 +54,12 @@ function loadFromDir(dir, agents, source) {
|
|
|
54
54
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
55
55
|
model: str(fm.model),
|
|
56
56
|
thinking: str(fm.thinking),
|
|
57
|
-
maxTurns:
|
|
57
|
+
maxTurns: nonNegativeInt(fm.max_turns),
|
|
58
58
|
systemPrompt: body.trim(),
|
|
59
59
|
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
|
|
60
|
-
inheritContext: fm.inherit_context === true,
|
|
61
|
-
runInBackground: fm.run_in_background === true,
|
|
62
|
-
isolated: fm.isolated === true,
|
|
60
|
+
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
|
|
61
|
+
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
|
|
62
|
+
isolated: fm.isolated != null ? fm.isolated === true : undefined,
|
|
63
63
|
memory: parseMemory(fm.memory),
|
|
64
64
|
isolation: fm.isolation === "worktree" ? "worktree" : undefined,
|
|
65
65
|
enabled: fm.enabled !== false, // default true; explicitly false disables
|
|
@@ -73,9 +73,9 @@ function loadFromDir(dir, agents, source) {
|
|
|
73
73
|
function str(val) {
|
|
74
74
|
return typeof val === "string" ? val : undefined;
|
|
75
75
|
}
|
|
76
|
-
/** Extract a
|
|
77
|
-
function
|
|
78
|
-
return typeof val === "number" && val >=
|
|
76
|
+
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
|
|
77
|
+
function nonNegativeInt(val) {
|
|
78
|
+
return typeof val === "number" && val >= 0 ? val : undefined;
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
81
|
* Parse a raw CSV field value into items, or undefined if absent/empty/"none".
|
package/dist/index.js
CHANGED
|
@@ -15,11 +15,12 @@ import { join } from "node:path";
|
|
|
15
15
|
import { Text } from "@mariozechner/pi-tui";
|
|
16
16
|
import { Type } from "@sinclair/typebox";
|
|
17
17
|
import { AgentManager } from "./agent-manager.js";
|
|
18
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
18
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
19
19
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
|
|
20
20
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
21
21
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
22
22
|
import { GroupJoinManager } from "./group-join.js";
|
|
23
|
+
import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
|
23
24
|
import { resolveModel } from "./model-resolver.js";
|
|
24
25
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
25
26
|
import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
|
|
@@ -511,8 +512,7 @@ Guidelines:
|
|
|
511
512
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
512
513
|
- Use thinking to control extended thinking level.
|
|
513
514
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
514
|
-
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications)
|
|
515
|
-
- Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
|
|
515
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).`,
|
|
516
516
|
parameters: Type.Object({
|
|
517
517
|
prompt: Type.String({
|
|
518
518
|
description: "The task for the agent to perform.",
|
|
@@ -548,10 +548,6 @@ Guidelines:
|
|
|
548
548
|
isolation: Type.Optional(Type.Literal("worktree", {
|
|
549
549
|
description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
|
|
550
550
|
})),
|
|
551
|
-
join_mode: Type.Optional(Type.Union([
|
|
552
|
-
Type.Literal("async"),
|
|
553
|
-
Type.Literal("group"),
|
|
554
|
-
], { description: "Override join behavior for background agents. async: individual nudge on completion. group: hold and send one consolidated notification when all agents in the group complete. Default: smart (auto-groups 2+ background agents spawned in the same turn)." })),
|
|
555
551
|
}),
|
|
556
552
|
// ---- Custom rendering: Claude Code style ----
|
|
557
553
|
renderCall(args, theme) {
|
|
@@ -650,27 +646,25 @@ Guidelines:
|
|
|
650
646
|
const displayName = getDisplayName(subagentType);
|
|
651
647
|
// Get agent config (if any)
|
|
652
648
|
const customConfig = getAgentConfig(subagentType);
|
|
653
|
-
|
|
649
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
|
|
650
|
+
// Resolve model from agent config first; tool-call params only fill gaps.
|
|
654
651
|
let model = ctx.model;
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const resolved = resolveModel(modelInput, ctx.modelRegistry);
|
|
652
|
+
if (resolvedConfig.modelInput) {
|
|
653
|
+
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
658
654
|
if (typeof resolved === "string") {
|
|
659
|
-
if (
|
|
660
|
-
return textResult(resolved);
|
|
655
|
+
if (resolvedConfig.modelFromParams)
|
|
656
|
+
return textResult(resolved);
|
|
661
657
|
// config-specified: silent fallback to parent
|
|
662
658
|
}
|
|
663
659
|
else {
|
|
664
660
|
model = resolved;
|
|
665
661
|
}
|
|
666
662
|
}
|
|
667
|
-
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
const
|
|
671
|
-
const
|
|
672
|
-
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
673
|
-
const isolation = params.isolation ?? customConfig?.isolation;
|
|
663
|
+
const thinking = resolvedConfig.thinking;
|
|
664
|
+
const inheritContext = resolvedConfig.inheritContext;
|
|
665
|
+
const runInBackground = resolvedConfig.runInBackground;
|
|
666
|
+
const isolated = resolvedConfig.isolated;
|
|
667
|
+
const isolation = resolvedConfig.isolation;
|
|
674
668
|
// Build display tags for non-default config
|
|
675
669
|
const parentModelId = ctx.model?.id;
|
|
676
670
|
const effectiveModelId = model?.id;
|
|
@@ -687,7 +681,7 @@ Guidelines:
|
|
|
687
681
|
agentTags.push("isolated");
|
|
688
682
|
if (isolation === "worktree")
|
|
689
683
|
agentTags.push("worktree");
|
|
690
|
-
const effectiveMaxTurns =
|
|
684
|
+
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
691
685
|
// Shared base fields for all AgentDetails in this call
|
|
692
686
|
const detailBase = {
|
|
693
687
|
displayName,
|
|
@@ -709,7 +703,7 @@ Guidelines:
|
|
|
709
703
|
if (!record) {
|
|
710
704
|
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
711
705
|
}
|
|
712
|
-
return textResult(record.result
|
|
706
|
+
return textResult(record.result?.trim() || record.error?.trim() || "No output.", buildDetails(detailBase, record));
|
|
713
707
|
}
|
|
714
708
|
// Background execution
|
|
715
709
|
if (runInBackground) {
|
|
@@ -729,7 +723,7 @@ Guidelines:
|
|
|
729
723
|
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
730
724
|
description: params.description,
|
|
731
725
|
model,
|
|
732
|
-
maxTurns:
|
|
726
|
+
maxTurns: effectiveMaxTurns,
|
|
733
727
|
isolated,
|
|
734
728
|
inheritContext,
|
|
735
729
|
thinkingLevel: thinking,
|
|
@@ -739,16 +733,16 @@ Guidelines:
|
|
|
739
733
|
});
|
|
740
734
|
// Set output file + join mode synchronously after spawn, before the
|
|
741
735
|
// event loop yields — onSessionCreated is async so this is safe.
|
|
742
|
-
const joinMode =
|
|
736
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
743
737
|
const record = manager.getRecord(id);
|
|
744
|
-
if (record) {
|
|
738
|
+
if (record && joinMode) {
|
|
745
739
|
record.joinMode = joinMode;
|
|
746
740
|
record.toolCallId = toolCallId;
|
|
747
741
|
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
748
742
|
writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
|
|
749
743
|
}
|
|
750
|
-
if (joinMode === 'async') {
|
|
751
|
-
//
|
|
744
|
+
if (joinMode == null || joinMode === 'async') {
|
|
745
|
+
// Foreground/no join mode or explicit async — not part of any batch
|
|
752
746
|
}
|
|
753
747
|
else {
|
|
754
748
|
// smart or group — add to current batch
|
|
@@ -824,7 +818,7 @@ Guidelines:
|
|
|
824
818
|
const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
825
819
|
description: params.description,
|
|
826
820
|
model,
|
|
827
|
-
maxTurns:
|
|
821
|
+
maxTurns: effectiveMaxTurns,
|
|
828
822
|
isolated,
|
|
829
823
|
inheritContext,
|
|
830
824
|
thinkingLevel: thinking,
|
|
@@ -851,7 +845,7 @@ Guidelines:
|
|
|
851
845
|
if (tokenText)
|
|
852
846
|
statsParts.push(tokenText);
|
|
853
847
|
return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
854
|
-
(record.result
|
|
848
|
+
(record.result?.trim() || "No output."), details);
|
|
855
849
|
},
|
|
856
850
|
});
|
|
857
851
|
// ---- get_subagent_result tool ----
|
|
@@ -898,7 +892,7 @@ Guidelines:
|
|
|
898
892
|
output += `Error: ${record.error}`;
|
|
899
893
|
}
|
|
900
894
|
else {
|
|
901
|
-
output += record.result
|
|
895
|
+
output += record.result?.trim() || "No output.";
|
|
902
896
|
}
|
|
903
897
|
// Mark result as consumed — suppresses the completion notification
|
|
904
898
|
if (record.status !== "running" && record.status !== "queued") {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
|
|
2
|
+
interface AgentInvocationParams {
|
|
3
|
+
model?: string;
|
|
4
|
+
thinking?: string;
|
|
5
|
+
max_turns?: number;
|
|
6
|
+
run_in_background?: boolean;
|
|
7
|
+
inherit_context?: boolean;
|
|
8
|
+
isolated?: boolean;
|
|
9
|
+
isolation?: IsolationMode;
|
|
10
|
+
}
|
|
11
|
+
export declare function resolveAgentInvocationConfig(agentConfig: AgentConfig | undefined, params: AgentInvocationParams): {
|
|
12
|
+
modelInput?: string;
|
|
13
|
+
modelFromParams: boolean;
|
|
14
|
+
thinking?: ThinkingLevel;
|
|
15
|
+
maxTurns?: number;
|
|
16
|
+
inheritContext: boolean;
|
|
17
|
+
runInBackground: boolean;
|
|
18
|
+
isolated: boolean;
|
|
19
|
+
isolation?: IsolationMode;
|
|
20
|
+
};
|
|
21
|
+
export declare function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function resolveAgentInvocationConfig(agentConfig, params) {
|
|
2
|
+
return {
|
|
3
|
+
modelInput: agentConfig?.model ?? params.model,
|
|
4
|
+
modelFromParams: agentConfig?.model == null && params.model != null,
|
|
5
|
+
thinking: (agentConfig?.thinking ?? params.thinking),
|
|
6
|
+
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
|
7
|
+
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
8
|
+
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
|
|
9
|
+
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
10
|
+
isolation: agentConfig?.isolation ?? params.isolation,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function resolveJoinMode(defaultJoinMode, runInBackground) {
|
|
14
|
+
return runInBackground ? defaultJoinMode : undefined;
|
|
15
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -29,12 +29,12 @@ export interface AgentConfig {
|
|
|
29
29
|
maxTurns?: number;
|
|
30
30
|
systemPrompt: string;
|
|
31
31
|
promptMode: "replace" | "append";
|
|
32
|
-
/** Default for spawn: fork parent conversation */
|
|
33
|
-
inheritContext
|
|
34
|
-
/** Default for spawn: run in background */
|
|
35
|
-
runInBackground
|
|
36
|
-
/** Default for spawn: no extension tools */
|
|
37
|
-
isolated
|
|
32
|
+
/** Default for spawn: fork parent conversation. undefined = caller decides. */
|
|
33
|
+
inheritContext?: boolean;
|
|
34
|
+
/** Default for spawn: run in background. undefined = caller decides. */
|
|
35
|
+
runInBackground?: boolean;
|
|
36
|
+
/** Default for spawn: no extension tools. undefined = caller decides. */
|
|
37
|
+
isolated?: boolean;
|
|
38
38
|
/** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
|
|
39
39
|
memory?: MemoryScope;
|
|
40
40
|
/** Isolation mode — "worktree" runs the agent in a temporary git worktree */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tintinweb/pi-subagents",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
|
|
5
5
|
"author": "tintinweb",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"autonomous"
|
|
22
22
|
],
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@mariozechner/pi-ai": "^0.
|
|
25
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
26
|
-
"@mariozechner/pi-tui": "^0.
|
|
24
|
+
"@mariozechner/pi-ai": "^0.62.0",
|
|
25
|
+
"@mariozechner/pi-coding-agent": "^0.62.0",
|
|
26
|
+
"@mariozechner/pi-tui": "^0.62.0",
|
|
27
27
|
"@sinclair/typebox": "latest"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
package/src/agent-runner.ts
CHANGED
|
@@ -27,10 +27,16 @@ const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
|
|
|
27
27
|
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
28
28
|
let defaultMaxTurns: number | undefined;
|
|
29
29
|
|
|
30
|
+
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
31
|
+
export function normalizeMaxTurns(n: number | undefined): number | undefined {
|
|
32
|
+
if (n == null || n === 0) return undefined;
|
|
33
|
+
return Math.max(1, n);
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
/** Get the default max turns value. undefined = unlimited. */
|
|
31
37
|
export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
|
|
32
|
-
/** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
|
|
33
|
-
export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns =
|
|
38
|
+
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
39
|
+
export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
|
|
34
40
|
|
|
35
41
|
/** Additional turns allowed after the soft limit steer message. */
|
|
36
42
|
let graceTurns = 5;
|
|
@@ -123,6 +129,17 @@ function collectResponseText(session: AgentSession) {
|
|
|
123
129
|
return { getText: () => text, unsubscribe };
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
/** Get the last assistant text from the completed session history. */
|
|
133
|
+
function getLastAssistantText(session: AgentSession): string {
|
|
134
|
+
for (let i = session.messages.length - 1; i >= 0; i--) {
|
|
135
|
+
const msg = session.messages[i];
|
|
136
|
+
if (msg.role !== "assistant") continue;
|
|
137
|
+
const text = extractText(msg.content).trim();
|
|
138
|
+
if (text) return text;
|
|
139
|
+
}
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
/**
|
|
127
144
|
* Wire an AbortSignal to abort a session.
|
|
128
145
|
* Returns a cleanup function to remove the listener.
|
|
@@ -279,7 +296,7 @@ export async function runAgent(
|
|
|
279
296
|
|
|
280
297
|
// Track turns for graceful max_turns enforcement
|
|
281
298
|
let turnCount = 0;
|
|
282
|
-
const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
|
|
299
|
+
const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
|
|
283
300
|
let softLimitReached = false;
|
|
284
301
|
let aborted = false;
|
|
285
302
|
|
|
@@ -333,7 +350,8 @@ export async function runAgent(
|
|
|
333
350
|
cleanupAbort();
|
|
334
351
|
}
|
|
335
352
|
|
|
336
|
-
|
|
353
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session);
|
|
354
|
+
return { responseText, session, aborted, steered: softLimitReached };
|
|
337
355
|
}
|
|
338
356
|
|
|
339
357
|
/**
|
|
@@ -362,7 +380,7 @@ export async function resumeAgent(
|
|
|
362
380
|
cleanupAbort();
|
|
363
381
|
}
|
|
364
382
|
|
|
365
|
-
return collector.getText();
|
|
383
|
+
return collector.getText().trim() || getLastAssistantText(session);
|
|
366
384
|
}
|
|
367
385
|
|
|
368
386
|
/**
|
package/src/custom-agents.ts
CHANGED
|
@@ -61,12 +61,12 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|
|
61
61
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
62
62
|
model: str(fm.model),
|
|
63
63
|
thinking: str(fm.thinking) as ThinkingLevel | undefined,
|
|
64
|
-
maxTurns:
|
|
64
|
+
maxTurns: nonNegativeInt(fm.max_turns),
|
|
65
65
|
systemPrompt: body.trim(),
|
|
66
66
|
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
|
|
67
|
-
inheritContext: fm.inherit_context === true,
|
|
68
|
-
runInBackground: fm.run_in_background === true,
|
|
69
|
-
isolated: fm.isolated === true,
|
|
67
|
+
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
|
|
68
|
+
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
|
|
69
|
+
isolated: fm.isolated != null ? fm.isolated === true : undefined,
|
|
70
70
|
memory: parseMemory(fm.memory),
|
|
71
71
|
isolation: fm.isolation === "worktree" ? "worktree" : undefined,
|
|
72
72
|
enabled: fm.enabled !== false, // default true; explicitly false disables
|
|
@@ -83,9 +83,9 @@ function str(val: unknown): string | undefined {
|
|
|
83
83
|
return typeof val === "string" ? val : undefined;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
/** Extract a
|
|
87
|
-
function
|
|
88
|
-
return typeof val === "number" && val >=
|
|
86
|
+
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
|
|
87
|
+
function nonNegativeInt(val: unknown): number | undefined {
|
|
88
|
+
return typeof val === "number" && val >= 0 ? val : undefined;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
package/src/index.ts
CHANGED
|
@@ -17,14 +17,15 @@ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@m
|
|
|
17
17
|
import { Text } from "@mariozechner/pi-tui";
|
|
18
18
|
import { Type } from "@sinclair/typebox";
|
|
19
19
|
import { AgentManager } from "./agent-manager.js";
|
|
20
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
21
21
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
|
|
22
22
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
23
23
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
24
24
|
import { GroupJoinManager } from "./group-join.js";
|
|
25
|
+
import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
|
25
26
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
26
27
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
27
|
-
import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType
|
|
28
|
+
import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
28
29
|
import {
|
|
29
30
|
type AgentActivity,
|
|
30
31
|
type AgentDetails,
|
|
@@ -572,8 +573,7 @@ Guidelines:
|
|
|
572
573
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
573
574
|
- Use thinking to control extended thinking level.
|
|
574
575
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
575
|
-
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications)
|
|
576
|
-
- Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
|
|
576
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).`,
|
|
577
577
|
parameters: Type.Object({
|
|
578
578
|
prompt: Type.String({
|
|
579
579
|
description: "The task for the agent to perform.",
|
|
@@ -626,12 +626,6 @@ Guidelines:
|
|
|
626
626
|
description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
|
|
627
627
|
}),
|
|
628
628
|
),
|
|
629
|
-
join_mode: Type.Optional(
|
|
630
|
-
Type.Union([
|
|
631
|
-
Type.Literal("async"),
|
|
632
|
-
Type.Literal("group"),
|
|
633
|
-
], { description: "Override join behavior for background agents. async: individual nudge on completion. group: hold and send one consolidated notification when all agents in the group complete. Default: smart (auto-groups 2+ background agents spawned in the same turn)." }),
|
|
634
|
-
),
|
|
635
629
|
}),
|
|
636
630
|
|
|
637
631
|
// ---- Custom rendering: Claude Code style ----
|
|
@@ -743,27 +737,25 @@ Guidelines:
|
|
|
743
737
|
// Get agent config (if any)
|
|
744
738
|
const customConfig = getAgentConfig(subagentType);
|
|
745
739
|
|
|
746
|
-
|
|
740
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
|
|
741
|
+
|
|
742
|
+
// Resolve model from agent config first; tool-call params only fill gaps.
|
|
747
743
|
let model = ctx.model;
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const resolved = resolveModel(modelInput, ctx.modelRegistry);
|
|
744
|
+
if (resolvedConfig.modelInput) {
|
|
745
|
+
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
751
746
|
if (typeof resolved === "string") {
|
|
752
|
-
if (
|
|
747
|
+
if (resolvedConfig.modelFromParams) return textResult(resolved);
|
|
753
748
|
// config-specified: silent fallback to parent
|
|
754
749
|
} else {
|
|
755
750
|
model = resolved;
|
|
756
751
|
}
|
|
757
752
|
}
|
|
758
753
|
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const
|
|
764
|
-
const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
|
|
765
|
-
const isolated = params.isolated ?? customConfig?.isolated ?? false;
|
|
766
|
-
const isolation = params.isolation ?? customConfig?.isolation;
|
|
754
|
+
const thinking = resolvedConfig.thinking;
|
|
755
|
+
const inheritContext = resolvedConfig.inheritContext;
|
|
756
|
+
const runInBackground = resolvedConfig.runInBackground;
|
|
757
|
+
const isolated = resolvedConfig.isolated;
|
|
758
|
+
const isolation = resolvedConfig.isolation;
|
|
767
759
|
|
|
768
760
|
// Build display tags for non-default config
|
|
769
761
|
const parentModelId = ctx.model?.id;
|
|
@@ -777,7 +769,7 @@ Guidelines:
|
|
|
777
769
|
if (thinking) agentTags.push(`thinking: ${thinking}`);
|
|
778
770
|
if (isolated) agentTags.push("isolated");
|
|
779
771
|
if (isolation === "worktree") agentTags.push("worktree");
|
|
780
|
-
const effectiveMaxTurns =
|
|
772
|
+
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
781
773
|
// Shared base fields for all AgentDetails in this call
|
|
782
774
|
const detailBase = {
|
|
783
775
|
displayName,
|
|
@@ -801,7 +793,7 @@ Guidelines:
|
|
|
801
793
|
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
802
794
|
}
|
|
803
795
|
return textResult(
|
|
804
|
-
record.result
|
|
796
|
+
record.result?.trim() || record.error?.trim() || "No output.",
|
|
805
797
|
buildDetails(detailBase, record),
|
|
806
798
|
);
|
|
807
799
|
}
|
|
@@ -826,7 +818,7 @@ Guidelines:
|
|
|
826
818
|
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
827
819
|
description: params.description,
|
|
828
820
|
model,
|
|
829
|
-
maxTurns:
|
|
821
|
+
maxTurns: effectiveMaxTurns,
|
|
830
822
|
isolated,
|
|
831
823
|
inheritContext,
|
|
832
824
|
thinkingLevel: thinking,
|
|
@@ -837,17 +829,17 @@ Guidelines:
|
|
|
837
829
|
|
|
838
830
|
// Set output file + join mode synchronously after spawn, before the
|
|
839
831
|
// event loop yields — onSessionCreated is async so this is safe.
|
|
840
|
-
const joinMode
|
|
832
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
841
833
|
const record = manager.getRecord(id);
|
|
842
|
-
if (record) {
|
|
834
|
+
if (record && joinMode) {
|
|
843
835
|
record.joinMode = joinMode;
|
|
844
836
|
record.toolCallId = toolCallId;
|
|
845
837
|
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
846
838
|
writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
|
|
847
839
|
}
|
|
848
840
|
|
|
849
|
-
if (joinMode === 'async') {
|
|
850
|
-
//
|
|
841
|
+
if (joinMode == null || joinMode === 'async') {
|
|
842
|
+
// Foreground/no join mode or explicit async — not part of any batch
|
|
851
843
|
} else {
|
|
852
844
|
// smart or group — add to current batch
|
|
853
845
|
currentBatchAgents.push({ id, joinMode });
|
|
@@ -934,7 +926,7 @@ Guidelines:
|
|
|
934
926
|
const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
935
927
|
description: params.description,
|
|
936
928
|
model,
|
|
937
|
-
maxTurns:
|
|
929
|
+
maxTurns: effectiveMaxTurns,
|
|
938
930
|
isolated,
|
|
939
931
|
inheritContext,
|
|
940
932
|
thinkingLevel: thinking,
|
|
@@ -968,7 +960,7 @@ Guidelines:
|
|
|
968
960
|
if (tokenText) statsParts.push(tokenText);
|
|
969
961
|
return textResult(
|
|
970
962
|
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
971
|
-
(record.result
|
|
963
|
+
(record.result?.trim() || "No output."),
|
|
972
964
|
details,
|
|
973
965
|
);
|
|
974
966
|
},
|
|
@@ -1027,7 +1019,7 @@ Guidelines:
|
|
|
1027
1019
|
} else if (record.status === "error") {
|
|
1028
1020
|
output += `Error: ${record.error}`;
|
|
1029
1021
|
} else {
|
|
1030
|
-
output += record.result
|
|
1022
|
+
output += record.result?.trim() || "No output.";
|
|
1031
1023
|
}
|
|
1032
1024
|
|
|
1033
1025
|
// Mark result as consumed — suppresses the completion notification
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
|
|
2
|
+
|
|
3
|
+
interface AgentInvocationParams {
|
|
4
|
+
model?: string;
|
|
5
|
+
thinking?: string;
|
|
6
|
+
max_turns?: number;
|
|
7
|
+
run_in_background?: boolean;
|
|
8
|
+
inherit_context?: boolean;
|
|
9
|
+
isolated?: boolean;
|
|
10
|
+
isolation?: IsolationMode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveAgentInvocationConfig(
|
|
14
|
+
agentConfig: AgentConfig | undefined,
|
|
15
|
+
params: AgentInvocationParams,
|
|
16
|
+
): {
|
|
17
|
+
modelInput?: string;
|
|
18
|
+
modelFromParams: boolean;
|
|
19
|
+
thinking?: ThinkingLevel;
|
|
20
|
+
maxTurns?: number;
|
|
21
|
+
inheritContext: boolean;
|
|
22
|
+
runInBackground: boolean;
|
|
23
|
+
isolated: boolean;
|
|
24
|
+
isolation?: IsolationMode;
|
|
25
|
+
} {
|
|
26
|
+
return {
|
|
27
|
+
modelInput: agentConfig?.model ?? params.model,
|
|
28
|
+
modelFromParams: agentConfig?.model == null && params.model != null,
|
|
29
|
+
thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
|
|
30
|
+
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
|
|
31
|
+
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
|
|
32
|
+
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
|
|
33
|
+
isolated: agentConfig?.isolated ?? params.isolated ?? false,
|
|
34
|
+
isolation: agentConfig?.isolation ?? params.isolation,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
|
|
39
|
+
return runInBackground ? defaultJoinMode : undefined;
|
|
40
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -36,12 +36,12 @@ export interface AgentConfig {
|
|
|
36
36
|
maxTurns?: number;
|
|
37
37
|
systemPrompt: string;
|
|
38
38
|
promptMode: "replace" | "append";
|
|
39
|
-
/** Default for spawn: fork parent conversation */
|
|
40
|
-
inheritContext
|
|
41
|
-
/** Default for spawn: run in background */
|
|
42
|
-
runInBackground
|
|
43
|
-
/** Default for spawn: no extension tools */
|
|
44
|
-
isolated
|
|
39
|
+
/** Default for spawn: fork parent conversation. undefined = caller decides. */
|
|
40
|
+
inheritContext?: boolean;
|
|
41
|
+
/** Default for spawn: run in background. undefined = caller decides. */
|
|
42
|
+
runInBackground?: boolean;
|
|
43
|
+
/** Default for spawn: no extension tools. undefined = caller decides. */
|
|
44
|
+
isolated?: boolean;
|
|
45
45
|
/** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
|
|
46
46
|
memory?: MemoryScope;
|
|
47
47
|
/** Isolation mode — "worktree" runs the agent in a temporary git worktree */
|