@tintinweb/pi-subagents 0.8.0 → 0.9.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 +25 -0
- package/README.md +22 -1
- package/dist/default-agents.js +3 -3
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/index.js +224 -84
- package/dist/settings.d.ts +23 -0
- package/dist/settings.js +5 -0
- package/package.json +1 -1
- package/src/default-agents.ts +3 -3
- package/src/enabled-models.ts +180 -0
- package/src/index.ts +247 -85
- package/src/settings.ts +27 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads `enabledModels` from pi's settings (global `<agentDir>/settings.json`
|
|
3
|
+
* + project-local `<cwd>/.pi/settings.json`, project wins) and resolves
|
|
4
|
+
* entries to concrete `provider/modelId` keys for scope validation.
|
|
5
|
+
*
|
|
6
|
+
* **Project overrides global**, mirroring pi's own `SettingsManager`
|
|
7
|
+
* deep-merge behavior and matching the precedence we use for our own
|
|
8
|
+
* `subagents.json` settings (see `src/settings.ts:loadSettings`). If
|
|
9
|
+
* project file has `enabledModels` set, it wholly replaces global's
|
|
10
|
+
* (array fields are replaced, not concatenated).
|
|
11
|
+
*
|
|
12
|
+
* **Limited subset of upstream's resolveModelScope.** We support exact
|
|
13
|
+
* `provider/modelId` matching only. Upstream (pi-coding-agent's
|
|
14
|
+
* `core/model-resolver.ts`) additionally supports glob patterns
|
|
15
|
+
* (`*sonnet*`, `anthropic/*`), bare model IDs without provider, and
|
|
16
|
+
* thinking-level suffixes (`provider/*:high`). Those forms are silently
|
|
17
|
+
* ignored here.
|
|
18
|
+
*
|
|
19
|
+
* In practice, pi's `/scoped-models` picker writes exact `provider/modelId`
|
|
20
|
+
* entries, so the limitation is invisible for users who configure scope
|
|
21
|
+
* through pi's UI. Hand-edited settings using globs or bare IDs will
|
|
22
|
+
* produce an empty allowed set (scope check becomes a no-op).
|
|
23
|
+
*
|
|
24
|
+
* Example:
|
|
25
|
+
* enabledModels = ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"]
|
|
26
|
+
* → resolves to { "anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6" }
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import type { ModelEntry } from "./model-resolver.js";
|
|
33
|
+
|
|
34
|
+
/** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
|
|
35
|
+
export interface ModelRegistryRef {
|
|
36
|
+
getAll(): unknown[];
|
|
37
|
+
getAvailable?(): unknown[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Paths to pi's settings.json files: [project, global] (project takes precedence). */
|
|
41
|
+
function settingsPaths(cwd: string): [project: string, global: string] {
|
|
42
|
+
return [
|
|
43
|
+
join(cwd, ".pi", "settings.json"),
|
|
44
|
+
join(getAgentDir(), "settings.json"),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read `enabledModels` from a single settings.json file. Undefined when missing or absent. */
|
|
49
|
+
function readField(path: string): string[] | undefined {
|
|
50
|
+
if (!existsSync(path)) return undefined;
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
53
|
+
if (Array.isArray(raw?.enabledModels)) return raw.enabledModels as string[];
|
|
54
|
+
} catch {
|
|
55
|
+
/* corrupt file — silent */
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read enabledModels from pi's settings — project-local overrides global.
|
|
62
|
+
* Mirrors pi's SettingsManager deep-merge for the `enabledModels` field
|
|
63
|
+
* (and matches our own loadSettings precedence in src/settings.ts).
|
|
64
|
+
* Returns undefined when neither file has the field.
|
|
65
|
+
*/
|
|
66
|
+
export function readEnabledModels(cwd: string): string[] | undefined {
|
|
67
|
+
const [project, global] = settingsPaths(cwd);
|
|
68
|
+
return readField(project) ?? readField(global);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve enabledModels patterns → Set<"provider/modelId"> (lowercase keys).
|
|
73
|
+
*
|
|
74
|
+
* Only exact `provider/modelId` patterns are matched (case-insensitive).
|
|
75
|
+
* Patterns without a slash, with glob characters, or with a `:thinking`
|
|
76
|
+
* suffix are silently dropped. See module-level docstring for rationale.
|
|
77
|
+
*
|
|
78
|
+
* Cache: keyed on JSON.stringify(patterns) + mtime/size of *both*
|
|
79
|
+
* project and global settings.json files. Re-resolves when either file
|
|
80
|
+
* changes or the patterns argument differs.
|
|
81
|
+
*
|
|
82
|
+
* Returns undefined when no patterns are provided or no patterns match
|
|
83
|
+
* (scope check becomes a no-op at the call site).
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
// Module-level cache — invalidated when either settings.json changes or patterns differ.
|
|
87
|
+
let cachedAllowed: Set<string> | undefined;
|
|
88
|
+
let cachedHash = "";
|
|
89
|
+
let cachedPatternsKey = "";
|
|
90
|
+
|
|
91
|
+
/** mtime+size hash of one file, or "missing" if absent. */
|
|
92
|
+
function hashOf(path: string): string {
|
|
93
|
+
try {
|
|
94
|
+
const s = statSync(path);
|
|
95
|
+
return `${s.mtimeMs}-${s.size}`;
|
|
96
|
+
} catch {
|
|
97
|
+
return "missing";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveEnabledModels(
|
|
102
|
+
patterns: string[] | undefined,
|
|
103
|
+
registry: ModelRegistryRef,
|
|
104
|
+
cwd: string = process.cwd(),
|
|
105
|
+
): Set<string> | undefined {
|
|
106
|
+
// Fast path: check cache (stat both project and global settings.json files)
|
|
107
|
+
const patternsKey = JSON.stringify(patterns);
|
|
108
|
+
const [project, global] = settingsPaths(cwd);
|
|
109
|
+
const fileHash = `${hashOf(project)};${hashOf(global)}`;
|
|
110
|
+
|
|
111
|
+
if (fileHash === cachedHash && patternsKey === cachedPatternsKey) {
|
|
112
|
+
return cachedAllowed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Cache miss — resolve
|
|
116
|
+
if (!patterns || patterns.length === 0) {
|
|
117
|
+
cachedHash = fileHash;
|
|
118
|
+
cachedPatternsKey = patternsKey;
|
|
119
|
+
cachedAllowed = undefined;
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const available = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
|
124
|
+
const allowed = new Set<string>();
|
|
125
|
+
|
|
126
|
+
for (const pattern of patterns) {
|
|
127
|
+
const trimmed = pattern.trim();
|
|
128
|
+
if (!trimmed) continue; // skip empty/whitespace
|
|
129
|
+
resolveExact(trimmed, available, allowed);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const result = allowed.size > 0 ? allowed : undefined;
|
|
133
|
+
cachedHash = fileHash;
|
|
134
|
+
cachedPatternsKey = patternsKey;
|
|
135
|
+
cachedAllowed = result;
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* True when `model` is in the allowed set. Centralizes the key format
|
|
143
|
+
* (`provider/id` lowercase) so callers don't have to reproduce it —
|
|
144
|
+
* both set-building (resolveExact) and lookup go through `modelKey`.
|
|
145
|
+
*/
|
|
146
|
+
export function isModelInScope(
|
|
147
|
+
model: { provider: string; id: string },
|
|
148
|
+
allowed: Set<string>,
|
|
149
|
+
): boolean {
|
|
150
|
+
return allowed.has(modelKey(model));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Canonical lowercase `provider/id` key for the allowed set. */
|
|
154
|
+
function modelKey(model: { provider: string; id: string }): string {
|
|
155
|
+
return `${model.provider}/${model.id}`.toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve exact model pattern. Example: "google/gemma-4-31b-it".
|
|
160
|
+
*/
|
|
161
|
+
function resolveExact(
|
|
162
|
+
pattern: string,
|
|
163
|
+
available: ModelEntry[],
|
|
164
|
+
allowed: Set<string>,
|
|
165
|
+
): void {
|
|
166
|
+
// "provider/modelId" — exact (colon is part of id, not split)
|
|
167
|
+
const slashIdx = pattern.indexOf("/");
|
|
168
|
+
if (slashIdx === -1) return; // bare modelId not supported
|
|
169
|
+
|
|
170
|
+
const provider = pattern.slice(0, slashIdx).toLowerCase();
|
|
171
|
+
const modelId = pattern.slice(slashIdx + 1).toLowerCase();
|
|
172
|
+
const exact = available.find(
|
|
173
|
+
m => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
|
|
174
|
+
);
|
|
175
|
+
if (exact) {
|
|
176
|
+
allowed.add(modelKey(exact));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
package/src/index.ts
CHANGED
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "@sinclair/typebox";
|
|
18
18
|
import { AgentManager } from "./agent-manager.js";
|
|
19
19
|
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
20
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
|
|
21
21
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
22
22
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
23
|
+
import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
23
24
|
import { GroupJoinManager } from "./group-join.js";
|
|
24
25
|
import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
|
25
26
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
@@ -518,6 +519,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
518
519
|
function isSchedulingEnabled(): boolean { return schedulingEnabled; }
|
|
519
520
|
function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
|
|
520
521
|
|
|
522
|
+
// ---- Scope models configuration ----
|
|
523
|
+
// When enabled, subagent model choices are validated against `enabledModels`
|
|
524
|
+
// from pi's settings — both global `<agentDir>/settings.json` and
|
|
525
|
+
// project-local `<cwd>/.pi/settings.json` (project overrides global).
|
|
526
|
+
// Off by default; opt-in via `/agents → Settings`. See docstring on
|
|
527
|
+
// SubagentsSettings.scopeModels for the hard-error vs warn-and-proceed
|
|
528
|
+
// policy and its rationale.
|
|
529
|
+
let scopeModelsEnabled = false;
|
|
530
|
+
function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
531
|
+
function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
532
|
+
|
|
521
533
|
// ---- Batch tracking for smart join mode ----
|
|
522
534
|
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
523
535
|
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
@@ -567,29 +579,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
567
579
|
widget.onTurnStart();
|
|
568
580
|
});
|
|
569
581
|
|
|
582
|
+
/** Format an agent's tool scope: "*" when it has all built-ins, else a comma-separated list. */
|
|
583
|
+
const formatToolsSuffix = (cfg: AgentConfig | undefined): string => {
|
|
584
|
+
const tools = cfg?.builtinToolNames;
|
|
585
|
+
if (!tools || tools.length === 0) return "*";
|
|
586
|
+
const isFullSet =
|
|
587
|
+
tools.length === BUILTIN_TOOL_NAMES.length
|
|
588
|
+
&& BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
|
|
589
|
+
return isFullSet ? "*" : tools.join(", ");
|
|
590
|
+
};
|
|
591
|
+
|
|
570
592
|
/** Build the full type list text dynamically from the unified registry. */
|
|
571
593
|
const buildTypeListText = () => {
|
|
572
|
-
const
|
|
573
|
-
const userNames = getUserAgentNames();
|
|
594
|
+
const allNames = [...getDefaultAgentNames(), ...getUserAgentNames()];
|
|
574
595
|
|
|
575
|
-
|
|
596
|
+
return allNames.map((name) => {
|
|
576
597
|
const cfg = getAgentConfig(name);
|
|
577
598
|
const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const customDescs = userNames.map((name) => {
|
|
582
|
-
const cfg = getAgentConfig(name);
|
|
583
|
-
return `- ${name}: ${cfg?.description ?? name}`;
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
return [
|
|
587
|
-
"Default agents:",
|
|
588
|
-
...defaultDescs,
|
|
589
|
-
...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
|
|
590
|
-
"",
|
|
591
|
-
`Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.`,
|
|
592
|
-
].join("\n");
|
|
599
|
+
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
600
|
+
return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
|
|
601
|
+
}).join("\n");
|
|
593
602
|
};
|
|
594
603
|
|
|
595
604
|
/** Derive a short model label from a model string. */
|
|
@@ -612,6 +621,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
612
621
|
setGraceTurns,
|
|
613
622
|
setDefaultJoinMode,
|
|
614
623
|
setSchedulingEnabled,
|
|
624
|
+
setScopeModels: setScopeModelsEnabled,
|
|
615
625
|
},
|
|
616
626
|
(event, payload) => pi.events.emit(event, payload),
|
|
617
627
|
);
|
|
@@ -643,27 +653,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
643
653
|
pi.registerTool(defineTool({
|
|
644
654
|
name: "Agent",
|
|
645
655
|
label: "Agent",
|
|
646
|
-
description: `Launch a new agent to handle complex, multi-step tasks autonomously.
|
|
647
|
-
|
|
648
|
-
The Agent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
|
656
|
+
description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
649
657
|
|
|
650
|
-
Available agent types:
|
|
658
|
+
Available agent types and the tools they have access to:
|
|
651
659
|
${typeListText}
|
|
652
660
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
661
|
+
Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
|
|
662
|
+
|
|
663
|
+
When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
|
|
664
|
+
|
|
665
|
+
## When not to use
|
|
666
|
+
|
|
667
|
+
If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
|
|
668
|
+
|
|
669
|
+
## Usage notes
|
|
670
|
+
|
|
671
|
+
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
|
672
|
+
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
|
|
673
|
+
- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
|
674
|
+
- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
|
|
675
|
+
- Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
|
|
676
|
+
- Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
|
|
677
|
+
- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
|
|
662
678
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
679
|
+
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
|
|
680
|
+
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
663
681
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
664
682
|
- Use thinking to control extended thinking level.
|
|
665
683
|
- Use inherit_context if the agent needs the parent conversation history.
|
|
666
|
-
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).${scheduleGuideline}
|
|
684
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
|
|
685
|
+
|
|
686
|
+
## Writing the prompt
|
|
687
|
+
|
|
688
|
+
Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
|
689
|
+
- Explain what you're trying to accomplish and why.
|
|
690
|
+
- Describe what you've already learned or ruled out.
|
|
691
|
+
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
|
692
|
+
- If you need a short response, say so ("report in under 200 words").
|
|
693
|
+
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
|
694
|
+
|
|
695
|
+
Terse command-style prompts produce shallow, generic work.
|
|
696
|
+
|
|
697
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`,
|
|
698
|
+
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
699
|
+
promptGuidelines: [
|
|
700
|
+
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
701
|
+
"For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
|
|
702
|
+
"When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
|
|
703
|
+
"Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
|
|
704
|
+
],
|
|
667
705
|
parameters: Type.Object({
|
|
668
706
|
prompt: Type.String({
|
|
669
707
|
description: "The task for the agent to perform.",
|
|
@@ -842,6 +880,35 @@ Guidelines:
|
|
|
842
880
|
}
|
|
843
881
|
}
|
|
844
882
|
|
|
883
|
+
// Scope validation: the effective resolved model is checked against the
|
|
884
|
+
// user's enabledModels list (read in `enabled-models.ts`).
|
|
885
|
+
//
|
|
886
|
+
// Design: scopeModels guards against *runtime* LLM choices, not user-level config.
|
|
887
|
+
// - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
|
|
888
|
+
// out-of-scope choice; surface it so it picks differently).
|
|
889
|
+
// - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
|
|
890
|
+
// user authored/installed this agent or chose the parent's model; trust it).
|
|
891
|
+
// See SubagentsSettings.scopeModels docstring for the full policy.
|
|
892
|
+
if (isScopeModelsEnabled() && model) {
|
|
893
|
+
const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
|
|
894
|
+
if (allowed && !isModelInScope(model, allowed)) {
|
|
895
|
+
if (resolvedConfig.modelFromParams) {
|
|
896
|
+
const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
|
|
897
|
+
return textResult(
|
|
898
|
+
`Model not in scope: "${resolvedConfig.modelInput}".\n\n` +
|
|
899
|
+
`Allowed models (from enabledModels):\n${list}`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
// Frontmatter-pinned or parent-inherited: warn + proceed.
|
|
903
|
+
const agentLabel = customConfig?.displayName ?? subagentType;
|
|
904
|
+
const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
|
|
905
|
+
ctx.ui.notify(
|
|
906
|
+
`Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
|
|
907
|
+
"warning",
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
845
912
|
const thinking = resolvedConfig.thinking;
|
|
846
913
|
const inheritContext = resolvedConfig.inheritContext;
|
|
847
914
|
const runInBackground = resolvedConfig.runInBackground;
|
|
@@ -1125,6 +1192,7 @@ Guidelines:
|
|
|
1125
1192
|
label: "Get Agent Result",
|
|
1126
1193
|
description:
|
|
1127
1194
|
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
1195
|
+
promptSnippet: "Check status and retrieve results from a background agent",
|
|
1128
1196
|
parameters: Type.Object({
|
|
1129
1197
|
agent_id: Type.String({
|
|
1130
1198
|
description: "The agent ID to check.",
|
|
@@ -1205,6 +1273,7 @@ Guidelines:
|
|
|
1205
1273
|
description:
|
|
1206
1274
|
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
1207
1275
|
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
1276
|
+
promptSnippet: "Send a steering message to redirect a running background agent",
|
|
1208
1277
|
parameters: Type.Object({
|
|
1209
1278
|
agent_id: Type.String({
|
|
1210
1279
|
description: "The agent ID to steer (must be currently running).",
|
|
@@ -1515,7 +1584,7 @@ Guidelines:
|
|
|
1515
1584
|
|
|
1516
1585
|
// Build the .md file content
|
|
1517
1586
|
const fmFields: string[] = [];
|
|
1518
|
-
fmFields.push(`description: ${cfg.description}`);
|
|
1587
|
+
fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
|
|
1519
1588
|
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
|
|
1520
1589
|
fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
|
|
1521
1590
|
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
|
|
@@ -1790,76 +1859,91 @@ ${systemPrompt}
|
|
|
1790
1859
|
graceTurns: getGraceTurns(),
|
|
1791
1860
|
defaultJoinMode: getDefaultJoinMode(),
|
|
1792
1861
|
schedulingEnabled: isSchedulingEnabled(),
|
|
1862
|
+
scopeModels: isScopeModelsEnabled(),
|
|
1793
1863
|
};
|
|
1794
1864
|
}
|
|
1795
1865
|
|
|
1866
|
+
const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
|
|
1867
|
+
|
|
1796
1868
|
async function showSettings(ctx: ExtensionCommandContext) {
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1869
|
+
function buildItems(): SettingItem[] {
|
|
1870
|
+
const mc = manager.getMaxConcurrent();
|
|
1871
|
+
const dmt = getDefaultMaxTurns() ?? 0;
|
|
1872
|
+
const gt = getGraceTurns();
|
|
1873
|
+
|
|
1874
|
+
return [
|
|
1875
|
+
{
|
|
1876
|
+
id: "maxConcurrent",
|
|
1877
|
+
label: "Max concurrency",
|
|
1878
|
+
description: "Max concurrent background agents (Enter to type)",
|
|
1879
|
+
currentValue: String(mc),
|
|
1880
|
+
values: [String(mc)],
|
|
1881
|
+
},
|
|
1882
|
+
{
|
|
1883
|
+
id: "defaultMaxTurns",
|
|
1884
|
+
label: "Default max turns",
|
|
1885
|
+
description: "Default max turns before wrap-up (0 = unlimited, Enter to type)",
|
|
1886
|
+
currentValue: String(dmt),
|
|
1887
|
+
values: [String(dmt)],
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
id: "graceTurns",
|
|
1891
|
+
label: "Grace turns",
|
|
1892
|
+
description: "Grace turns after wrap-up steer (Enter to type)",
|
|
1893
|
+
currentValue: String(gt),
|
|
1894
|
+
values: [String(gt)],
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
id: "joinMode",
|
|
1898
|
+
label: "Join mode",
|
|
1899
|
+
description: "Default join mode for background agents",
|
|
1900
|
+
currentValue: getDefaultJoinMode(),
|
|
1901
|
+
values: ["smart", "async", "group"],
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
id: "schedulingEnabled",
|
|
1905
|
+
label: "Scheduling",
|
|
1906
|
+
description: "Schedule subagent feature (off removes `schedule` param from Agent tool spec on next pi session)",
|
|
1907
|
+
currentValue: isSchedulingEnabled() ? "on" : "off",
|
|
1908
|
+
values: ["on", "off"],
|
|
1909
|
+
},
|
|
1910
|
+
{
|
|
1911
|
+
id: "scopeModels",
|
|
1912
|
+
label: "Scope models",
|
|
1913
|
+
description: "Validate subagent models against scoped models (/scoped-models)",
|
|
1914
|
+
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
1915
|
+
values: ["on", "off"],
|
|
1916
|
+
},
|
|
1917
|
+
];
|
|
1918
|
+
}
|
|
1805
1919
|
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
const n = parseInt(val, 10);
|
|
1920
|
+
function applyValue(id: string, value: string) {
|
|
1921
|
+
if (id === "maxConcurrent") {
|
|
1922
|
+
const n = parseInt(value, 10);
|
|
1810
1923
|
if (n >= 1) {
|
|
1811
1924
|
manager.setMaxConcurrent(n);
|
|
1812
1925
|
notifyApplied(ctx, `Max concurrency set to ${n}`);
|
|
1813
|
-
} else {
|
|
1814
|
-
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1815
1926
|
}
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
|
|
1819
|
-
if (val) {
|
|
1820
|
-
const n = parseInt(val, 10);
|
|
1927
|
+
} else if (id === "defaultMaxTurns") {
|
|
1928
|
+
const n = parseInt(value, 10);
|
|
1821
1929
|
if (n === 0) {
|
|
1822
1930
|
setDefaultMaxTurns(undefined);
|
|
1823
1931
|
notifyApplied(ctx, "Default max turns set to unlimited");
|
|
1824
1932
|
} else if (n >= 1) {
|
|
1825
1933
|
setDefaultMaxTurns(n);
|
|
1826
1934
|
notifyApplied(ctx, `Default max turns set to ${n}`);
|
|
1827
|
-
} else {
|
|
1828
|
-
ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
|
|
1829
1935
|
}
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
const val = await ctx.ui.input("Grace turns after wrap-up steer", String(getGraceTurns()));
|
|
1833
|
-
if (val) {
|
|
1834
|
-
const n = parseInt(val, 10);
|
|
1936
|
+
} else if (id === "graceTurns") {
|
|
1937
|
+
const n = parseInt(value, 10);
|
|
1835
1938
|
if (n >= 1) {
|
|
1836
1939
|
setGraceTurns(n);
|
|
1837
1940
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
1838
|
-
} else {
|
|
1839
|
-
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
1840
1941
|
}
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
"group — always group background agents",
|
|
1847
|
-
]);
|
|
1848
|
-
if (val) {
|
|
1849
|
-
const mode = val.split(" ")[0] as JoinMode;
|
|
1850
|
-
setDefaultJoinMode(mode);
|
|
1851
|
-
notifyApplied(ctx, `Default join mode set to ${mode}`);
|
|
1852
|
-
}
|
|
1853
|
-
} else if (choice.startsWith("Scheduling")) {
|
|
1854
|
-
const val = await ctx.ui.select(
|
|
1855
|
-
"Schedule subagent feature",
|
|
1856
|
-
[
|
|
1857
|
-
"enabled — Agent tool accepts a `schedule` param; /agents → Scheduled jobs visible",
|
|
1858
|
-
"disabled — `schedule` removed from Agent tool spec (no LLM-context cost); menu hidden",
|
|
1859
|
-
],
|
|
1860
|
-
);
|
|
1861
|
-
if (val) {
|
|
1862
|
-
const enabled = val.startsWith("enabled");
|
|
1942
|
+
} else if (id === "joinMode") {
|
|
1943
|
+
setDefaultJoinMode(value as JoinMode);
|
|
1944
|
+
notifyApplied(ctx, `Default join mode set to ${value}`);
|
|
1945
|
+
} else if (id === "schedulingEnabled") {
|
|
1946
|
+
const enabled = value === "on";
|
|
1863
1947
|
if (enabled === isSchedulingEnabled()) {
|
|
1864
1948
|
ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info");
|
|
1865
1949
|
} else {
|
|
@@ -1870,6 +1954,84 @@ ${systemPrompt}
|
|
|
1870
1954
|
`Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
|
|
1871
1955
|
);
|
|
1872
1956
|
}
|
|
1957
|
+
} else if (id === "scopeModels") {
|
|
1958
|
+
const enabled = value === "on";
|
|
1959
|
+
setScopeModelsEnabled(enabled);
|
|
1960
|
+
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
let list: SettingsList;
|
|
1965
|
+
// Track current selection index directly (SettingsList doesn't expose it).
|
|
1966
|
+
// Updated on arrow keys so Enter knows which field is selected immediately.
|
|
1967
|
+
let currentIndex = 0;
|
|
1968
|
+
|
|
1969
|
+
const result = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
|
|
1970
|
+
const items = buildItems();
|
|
1971
|
+
|
|
1972
|
+
list = new SettingsList(
|
|
1973
|
+
items,
|
|
1974
|
+
items.length + 2,
|
|
1975
|
+
getSettingsListTheme(),
|
|
1976
|
+
(id, newValue) => {
|
|
1977
|
+
applyValue(id, newValue);
|
|
1978
|
+
},
|
|
1979
|
+
() => done(undefined as undefined),
|
|
1980
|
+
);
|
|
1981
|
+
|
|
1982
|
+
const container = new Container();
|
|
1983
|
+
container.addChild(new Text("⚙ Subagent Settings", 0, 0));
|
|
1984
|
+
container.addChild(new Spacer(1));
|
|
1985
|
+
container.addChild(list);
|
|
1986
|
+
|
|
1987
|
+
return {
|
|
1988
|
+
render: (w: number) => container.render(w),
|
|
1989
|
+
invalidate: () => container.invalidate(),
|
|
1990
|
+
handleInput: (data: string) => {
|
|
1991
|
+
// Track navigation so Enter knows the current field
|
|
1992
|
+
if (matchesKey(data, "up")) {
|
|
1993
|
+
currentIndex = Math.max(0, currentIndex - 1);
|
|
1994
|
+
} else if (matchesKey(data, "down")) {
|
|
1995
|
+
currentIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
// Enter on numeric field → close and prompt for typed input
|
|
1999
|
+
if (matchesKey(data, Key.enter) && NUMERIC_IDS.has(items[currentIndex].id)) {
|
|
2000
|
+
done(items[currentIndex].id);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
list.handleInput?.(data);
|
|
2004
|
+
},
|
|
2005
|
+
};
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
// If a numeric field ID was returned, prompt for typed input
|
|
2009
|
+
if (result && NUMERIC_IDS.has(result)) {
|
|
2010
|
+
const current = result === "maxConcurrent"
|
|
2011
|
+
? String(manager.getMaxConcurrent())
|
|
2012
|
+
: result === "defaultMaxTurns"
|
|
2013
|
+
? String(getDefaultMaxTurns() ?? 0)
|
|
2014
|
+
: String(getGraceTurns());
|
|
2015
|
+
|
|
2016
|
+
const label = result === "maxConcurrent"
|
|
2017
|
+
? "Max concurrency (1+)"
|
|
2018
|
+
: result === "defaultMaxTurns"
|
|
2019
|
+
? "Default max turns (0 = unlimited)"
|
|
2020
|
+
: "Grace turns (1+)";
|
|
2021
|
+
|
|
2022
|
+
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
2023
|
+
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|
|
2024
|
+
let input: string | undefined = await ctx.ui.input(label, current);
|
|
2025
|
+
while (input != null) {
|
|
2026
|
+
const trimmed = input.trim();
|
|
2027
|
+
const n = Number(trimmed);
|
|
2028
|
+
if (trimmed !== "" && Number.isInteger(n)) {
|
|
2029
|
+
applyValue(result, String(n));
|
|
2030
|
+
await showSettings(ctx);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
// Invalid — re-prompt with the user's last entry so they can edit it
|
|
2034
|
+
input = await ctx.ui.input(label, trimmed);
|
|
1873
2035
|
}
|
|
1874
2036
|
}
|
|
1875
2037
|
}
|