pi-open-agents 0.1.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/ATTRIBUTION.md +38 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/index.ts +8 -0
- package/package.json +61 -0
- package/src/config/frontmatter.ts +247 -0
- package/src/config/paths.ts +120 -0
- package/src/config/schema.ts +313 -0
- package/src/discovery/loader.ts +180 -0
- package/src/index.ts +204 -0
- package/src/permission/evaluator.ts +160 -0
- package/src/permission/matcher.ts +99 -0
- package/src/permission/parser.ts +81 -0
- package/src/primary/executor.ts +167 -0
- package/src/primary/manager.ts +147 -0
- package/src/subagent/env.ts +116 -0
- package/src/subagent/executor.ts +679 -0
- package/src/subagent/prompt.ts +79 -0
- package/src/subagent/render.ts +251 -0
- package/src/subagent/skills.ts +274 -0
- package/src/subagent/tool.ts +299 -0
- package/src/tui/banner.ts +49 -0
- package/src/tui/selector.ts +331 -0
- package/src/tui/tools.ts +147 -0
- package/src/types.ts +152 -0
- package/src/utils/format.ts +99 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission evaluator.
|
|
3
|
+
*
|
|
4
|
+
* Implements last-match-wins evaluation (like CSS specificity).
|
|
5
|
+
*
|
|
6
|
+
* Given a tool name and its argument, walks the rule list in order.
|
|
7
|
+
* The last matching rule determines the action.
|
|
8
|
+
* If no rule matches, the default action is "ask".
|
|
9
|
+
*
|
|
10
|
+
* Also provides:
|
|
11
|
+
* - Tool → permission category mapping (write → edit, etc.)
|
|
12
|
+
* - disabled() — tools that should never be sent to the LLM
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { PermissionAction, PermissionRule } from "../types.ts";
|
|
16
|
+
import { matchPattern } from "./matcher.ts";
|
|
17
|
+
|
|
18
|
+
// ─── Tool → Permission Mapping ───────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Map a tool name to its permission category.
|
|
22
|
+
*
|
|
23
|
+
* Multiple tools can share a permission category:
|
|
24
|
+
* write, edit, apply_patch → "edit"
|
|
25
|
+
* list_mcp_resources, read_mcp_resource → "read"
|
|
26
|
+
*
|
|
27
|
+
* Everything else maps by tool name directly.
|
|
28
|
+
*/
|
|
29
|
+
export function toolToPermission(toolName: string): string {
|
|
30
|
+
// File-editing tools all map to "edit"
|
|
31
|
+
if (toolName === "write" || toolName === "edit" || toolName === "apply_patch") {
|
|
32
|
+
return "edit";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// MCP read tools map to "read"
|
|
36
|
+
if (toolName === "list_mcp_resources" || toolName === "read_mcp_resource") {
|
|
37
|
+
return "read";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Everything else uses its own name as the permission key
|
|
41
|
+
return toolName;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Evaluation ──────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface EvalResult {
|
|
47
|
+
/** The action to take: allow, deny, or ask */
|
|
48
|
+
action: PermissionAction;
|
|
49
|
+
/** The permission category that was matched */
|
|
50
|
+
permission: string;
|
|
51
|
+
/** The pattern that was matched, or "*" if no rule matched */
|
|
52
|
+
pattern: string;
|
|
53
|
+
/** Whether any rule matched (false = default action applied) */
|
|
54
|
+
matched: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Evaluate permission for a tool call.
|
|
59
|
+
*
|
|
60
|
+
* @param toolName The tool being called (e.g. "edit", "bash")
|
|
61
|
+
* @param arg The primary argument (file path, command, etc.)
|
|
62
|
+
* @param rules Ordered permission rules (last-match-wins)
|
|
63
|
+
* @returns Evaluation result
|
|
64
|
+
*/
|
|
65
|
+
export function evaluate(
|
|
66
|
+
toolName: string,
|
|
67
|
+
arg: string | undefined,
|
|
68
|
+
rules: PermissionRule[],
|
|
69
|
+
): EvalResult {
|
|
70
|
+
const permission = toolToPermission(toolName);
|
|
71
|
+
const value = arg ?? "";
|
|
72
|
+
|
|
73
|
+
// Walk all rules, keep track of the last match
|
|
74
|
+
let lastMatch: PermissionRule | undefined;
|
|
75
|
+
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
// Permission must match (with wildcard support on the permission key too)
|
|
78
|
+
if (!matchPermissionKey(rule.permission, permission)) continue;
|
|
79
|
+
|
|
80
|
+
// Pattern must match the argument
|
|
81
|
+
if (!matchPattern(rule.pattern, value)) continue;
|
|
82
|
+
|
|
83
|
+
lastMatch = rule;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (lastMatch) {
|
|
87
|
+
return {
|
|
88
|
+
action: lastMatch.action,
|
|
89
|
+
permission: lastMatch.permission,
|
|
90
|
+
pattern: lastMatch.pattern,
|
|
91
|
+
matched: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// No rule matched — default to "ask"
|
|
96
|
+
return {
|
|
97
|
+
action: "ask",
|
|
98
|
+
permission,
|
|
99
|
+
pattern: "*",
|
|
100
|
+
matched: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Match a permission key, with wildcard support.
|
|
106
|
+
*
|
|
107
|
+
* "edit" matches "edit"
|
|
108
|
+
* "*" matches anything
|
|
109
|
+
* "edit" matches "e*" (not typical, but supported)
|
|
110
|
+
*/
|
|
111
|
+
function matchPermissionKey(rulePermission: string, toolPermission: string): boolean {
|
|
112
|
+
if (rulePermission === "*") return true;
|
|
113
|
+
return matchPattern(rulePermission, toolPermission);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Disabled Tools ──────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Determine which tools are disabled (should never be sent to the LLM).
|
|
120
|
+
*
|
|
121
|
+
* A tool is disabled if the last matching rule for it (with pattern "*")
|
|
122
|
+
* is "deny". This means the tool is globally forbidden for this agent.
|
|
123
|
+
*
|
|
124
|
+
* Tools that are "ask" or "allow" with pattern "*" are NOT disabled —
|
|
125
|
+
* they may be used, just with or without confirmation.
|
|
126
|
+
*
|
|
127
|
+
* @param toolNames All available tool names
|
|
128
|
+
* @param rules Permission rules to evaluate against
|
|
129
|
+
* @returns Set of tool names that should be disabled
|
|
130
|
+
*/
|
|
131
|
+
export function getDisabledTools(
|
|
132
|
+
toolNames: string[],
|
|
133
|
+
rules: PermissionRule[],
|
|
134
|
+
): Set<string> {
|
|
135
|
+
const disabled = new Set<string>();
|
|
136
|
+
|
|
137
|
+
for (const toolName of toolNames) {
|
|
138
|
+
const result = evaluate(toolName, undefined, rules);
|
|
139
|
+
if (result.action === "deny" && result.pattern === "*") {
|
|
140
|
+
disabled.add(toolName);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return disabled;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Filter a list of tools, removing disabled ones.
|
|
149
|
+
*/
|
|
150
|
+
export function filterDisabledTools<T extends { name: string }>(
|
|
151
|
+
tools: T[],
|
|
152
|
+
rules: PermissionRule[],
|
|
153
|
+
): T[] {
|
|
154
|
+
const disabled = getDisabledTools(
|
|
155
|
+
tools.map((t) => t.name),
|
|
156
|
+
rules,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return tools.filter((t) => !disabled.has(t.name));
|
|
160
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Glob/wildcard pattern matching for permission evaluation.
|
|
3
|
+
//
|
|
4
|
+
// Supports:
|
|
5
|
+
// - star: matches any number of characters (except /)
|
|
6
|
+
// - double-star: matches any number of characters including /
|
|
7
|
+
// - ?: matches exactly one character
|
|
8
|
+
// - Literal characters match themselves
|
|
9
|
+
//
|
|
10
|
+
// Examples:
|
|
11
|
+
// match("star.env", "secret.env") → true (star matches)
|
|
12
|
+
// match("star.env", "dir/secret.env") → false (star doesn't cross /)
|
|
13
|
+
// match("double-star.env", "a/b.env") → true (double-star crosses /)
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
//
|
|
17
|
+
// Match a glob pattern against a string.
|
|
18
|
+
//
|
|
19
|
+
// @param pattern Glob pattern (e.g. star.env, double-star.ts, exact-match)
|
|
20
|
+
// @param value String to test (e.g. a file path, tool argument)
|
|
21
|
+
// @returns true if the pattern matches the entire string
|
|
22
|
+
//
|
|
23
|
+
export function matchPattern(pattern: string, value: string): boolean {
|
|
24
|
+
// Fast path: * matches everything
|
|
25
|
+
if (pattern === "*") return true;
|
|
26
|
+
|
|
27
|
+
// Fast path: exact match
|
|
28
|
+
if (pattern === value) return true;
|
|
29
|
+
|
|
30
|
+
// No wildcard characters — must be exact
|
|
31
|
+
if (!pattern.includes("*") && !pattern.includes("?")) {
|
|
32
|
+
return pattern === value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Convert glob to regex
|
|
36
|
+
const regexStr = globToRegex(pattern);
|
|
37
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
38
|
+
return regex.test(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert a glob pattern to a regex string.
|
|
43
|
+
*
|
|
44
|
+
* Handles * (non-greedy, no /), ** (greedy, includes /), ? (single char).
|
|
45
|
+
*/
|
|
46
|
+
function globToRegex(pattern: string): string {
|
|
47
|
+
let result = "";
|
|
48
|
+
let i = 0;
|
|
49
|
+
|
|
50
|
+
while (i < pattern.length) {
|
|
51
|
+
const char = pattern[i];
|
|
52
|
+
|
|
53
|
+
// Check for ** (must come before single *)
|
|
54
|
+
if (char === "*" && pattern[i + 1] === "*") {
|
|
55
|
+
// ** matches everything including /
|
|
56
|
+
// Also handle **/ (match zero or more path segments)
|
|
57
|
+
if (pattern[i + 2] === "/") {
|
|
58
|
+
result += "(?:.*/)?"; // match "a/b/" or ""
|
|
59
|
+
i += 3;
|
|
60
|
+
} else {
|
|
61
|
+
result += ".*";
|
|
62
|
+
i += 2;
|
|
63
|
+
}
|
|
64
|
+
} else if (char === "*") {
|
|
65
|
+
// * matches any chars except /
|
|
66
|
+
result += "[^/]*";
|
|
67
|
+
i++;
|
|
68
|
+
} else if (char === "?") {
|
|
69
|
+
// ? matches exactly one char (except /)
|
|
70
|
+
result += "[^/]";
|
|
71
|
+
i++;
|
|
72
|
+
} else {
|
|
73
|
+
// Escape regex special characters
|
|
74
|
+
result += escapeRegexChar(char);
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Escape a character for use in a regex.
|
|
84
|
+
* Only escapes characters that have special meaning.
|
|
85
|
+
*/
|
|
86
|
+
function escapeRegexChar(char: string): string {
|
|
87
|
+
const special = ".+^${}()|[]\\";
|
|
88
|
+
if (special.includes(char)) {
|
|
89
|
+
return "\\" + char;
|
|
90
|
+
}
|
|
91
|
+
return char;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if a pattern contains wildcards.
|
|
96
|
+
*/
|
|
97
|
+
export function isWildcardPattern(pattern: string): boolean {
|
|
98
|
+
return pattern.includes("*") || pattern.includes("?");
|
|
99
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission config parser.
|
|
3
|
+
*
|
|
4
|
+
* Flattens a PermissionConfig (from frontmatter) into a flat list of
|
|
5
|
+
* PermissionRule objects that the evaluator can process.
|
|
6
|
+
*
|
|
7
|
+
* Input:
|
|
8
|
+
* bash: allow
|
|
9
|
+
* edit:
|
|
10
|
+
* "*.env": deny
|
|
11
|
+
* "*.md": allow
|
|
12
|
+
* read: allow
|
|
13
|
+
*
|
|
14
|
+
* Output:
|
|
15
|
+
* [
|
|
16
|
+
* { permission: "bash", pattern: "*", action: "allow" },
|
|
17
|
+
* { permission: "edit", pattern: "*.env", action: "deny" },
|
|
18
|
+
* { permission: "edit", pattern: "*.md", action: "allow" },
|
|
19
|
+
* { permission: "read", pattern: "*", action: "allow" },
|
|
20
|
+
* ]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { PermissionConfig, PermissionRule } from "../types.ts";
|
|
24
|
+
import { toolToPermission } from "./evaluator.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Flatten a PermissionConfig into ordered PermissionRule[].
|
|
28
|
+
*
|
|
29
|
+
* Rules from nested objects are expanded with their pattern.
|
|
30
|
+
* Simple string values get pattern "*".
|
|
31
|
+
*
|
|
32
|
+
* Permission keys are normalized via toolToPermission (write → edit, etc.)
|
|
33
|
+
* so that both "write: deny" and "edit: deny" affect the edit tool.
|
|
34
|
+
*
|
|
35
|
+
* Order is preserved as written (object key order).
|
|
36
|
+
*/
|
|
37
|
+
export function parsePermissionRules(config: PermissionConfig): PermissionRule[] {
|
|
38
|
+
const rules: PermissionRule[] = [];
|
|
39
|
+
|
|
40
|
+
for (const [permission, value] of Object.entries(config)) {
|
|
41
|
+
// Normalize tool name to permission category (write -> edit, etc.)
|
|
42
|
+
const normalizedPerm = toolToPermission(permission);
|
|
43
|
+
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
// Simple form: bash: allow
|
|
46
|
+
rules.push({
|
|
47
|
+
permission: normalizedPerm,
|
|
48
|
+
pattern: "*",
|
|
49
|
+
action: value,
|
|
50
|
+
});
|
|
51
|
+
} else if (typeof value === "object" && value !== null) {
|
|
52
|
+
// Pattern form: edit: { "*.env": deny, "*.md": allow }
|
|
53
|
+
for (const [pattern, action] of Object.entries(value)) {
|
|
54
|
+
if (typeof action === "string") {
|
|
55
|
+
rules.push({
|
|
56
|
+
permission: normalizedPerm,
|
|
57
|
+
pattern,
|
|
58
|
+
action,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return rules;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Merge multiple permission configs into a single rule list.
|
|
70
|
+
* Later configs take precedence (appended last, last-match-wins).
|
|
71
|
+
*/
|
|
72
|
+
export function mergePermissionConfigs(...configs: (PermissionConfig | undefined)[]): PermissionRule[] {
|
|
73
|
+
const rules: PermissionRule[] = [];
|
|
74
|
+
|
|
75
|
+
for (const config of configs) {
|
|
76
|
+
if (!config) continue;
|
|
77
|
+
rules.push(...parsePermissionRules(config));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return rules;
|
|
81
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent executor — applies agent configuration to the current session.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Model switching (provider/model-id resolution)
|
|
6
|
+
* - Thinking level (per-agent, with fallback to session default)
|
|
7
|
+
* - Tool filtering via permission engine
|
|
8
|
+
* - System prompt injection (via before_agent_start hook)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
13
|
+
import type { AgentDefinition, ModelRef, PermissionRule } from "../types.ts";
|
|
14
|
+
import { parsePermissionRules } from "../permission/parser.ts";
|
|
15
|
+
import { getDisabledTools } from "../permission/evaluator.ts";
|
|
16
|
+
|
|
17
|
+
// ─── Model Resolution ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a model reference string "provider/model-id" into components.
|
|
21
|
+
*/
|
|
22
|
+
export function parseModelRef(ref: string): ModelRef | undefined {
|
|
23
|
+
const slashIdx = ref.indexOf("/");
|
|
24
|
+
if (slashIdx === -1) return undefined;
|
|
25
|
+
return {
|
|
26
|
+
provider: ref.slice(0, slashIdx),
|
|
27
|
+
modelId: ref.slice(slashIdx + 1),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply model override if the agent specifies one.
|
|
33
|
+
*/
|
|
34
|
+
async function applyModel(agent: AgentDefinition, pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
35
|
+
if (!agent.model) return;
|
|
36
|
+
|
|
37
|
+
const ref = parseModelRef(agent.model);
|
|
38
|
+
if (!ref) return;
|
|
39
|
+
|
|
40
|
+
const model = ctx.modelRegistry.find(ref.provider, ref.modelId);
|
|
41
|
+
if (!model) return;
|
|
42
|
+
|
|
43
|
+
const success = await pi.setModel(model);
|
|
44
|
+
if (!success) {
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
`Agent "${agent.name}": No API key for ${ref.provider}/${ref.modelId}`,
|
|
47
|
+
"warning",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Thinking Level ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Apply thinking level override.
|
|
56
|
+
* Falls back to the session's current thinking level if agent doesn't specify one.
|
|
57
|
+
*/
|
|
58
|
+
function applyThinking(agent: AgentDefinition, pi: ExtensionAPI): void {
|
|
59
|
+
pi.setThinkingLevel(agent.thinking);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Tool Filtering ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply tool restrictions based on agent configuration.
|
|
66
|
+
*
|
|
67
|
+
* Logic:
|
|
68
|
+
* 1. If agent has permission rules → filter via permission engine
|
|
69
|
+
* 2. Else if agent has tools whitelist → filter to only those (builtin only)
|
|
70
|
+
* 3. Otherwise → all tools active
|
|
71
|
+
*
|
|
72
|
+
* Extension-provided tools (MCP, SDK) always remain active.
|
|
73
|
+
*/
|
|
74
|
+
function applyTools(agent: AgentDefinition, pi: ExtensionAPI): void {
|
|
75
|
+
const allTools = pi.getAllTools();
|
|
76
|
+
|
|
77
|
+
// Case 1: Permission-based filtering
|
|
78
|
+
if (agent.permission) {
|
|
79
|
+
const rules = parsePermissionRules(agent.permission);
|
|
80
|
+
const disabled = getDisabledTools(
|
|
81
|
+
allTools.map((t) => t.name),
|
|
82
|
+
rules,
|
|
83
|
+
);
|
|
84
|
+
const filtered = allTools
|
|
85
|
+
.filter((t) => {
|
|
86
|
+
// Always keep non-builtin tools (extensions, MCP)
|
|
87
|
+
if (t.sourceInfo?.source !== "builtin") return true;
|
|
88
|
+
return !disabled.has(t.name);
|
|
89
|
+
})
|
|
90
|
+
.map((t) => t.name);
|
|
91
|
+
pi.setActiveTools(filtered);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Case 2: Simple tools whitelist (builtin only)
|
|
96
|
+
if (agent.tools && agent.tools.length > 0) {
|
|
97
|
+
const whitelist = new Set(agent.tools.map((t) => t.toLowerCase()));
|
|
98
|
+
const filtered = allTools
|
|
99
|
+
.filter((t) => {
|
|
100
|
+
if (t.sourceInfo?.source !== "builtin") return true;
|
|
101
|
+
return whitelist.has(t.name.toLowerCase());
|
|
102
|
+
})
|
|
103
|
+
.map((t) => t.name);
|
|
104
|
+
pi.setActiveTools(filtered);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Case 3: No restrictions — keep all tools
|
|
109
|
+
pi.setActiveTools(allTools.map((t) => t.name));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Main Entry Point ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Apply an agent's model, thinking, and tools configuration.
|
|
116
|
+
* System prompt injection happens in the before_agent_start hook.
|
|
117
|
+
*/
|
|
118
|
+
export async function applyAgentConfig(
|
|
119
|
+
agent: AgentDefinition,
|
|
120
|
+
pi: ExtensionAPI,
|
|
121
|
+
ctx: ExtensionContext,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
await applyModel(agent, pi, ctx);
|
|
124
|
+
applyThinking(agent, pi);
|
|
125
|
+
applyTools(agent, pi);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Re-evaluate tool restrictions.
|
|
130
|
+
* Called from before_agent_start to catch async-registered tools.
|
|
131
|
+
*/
|
|
132
|
+
export function refreshAgentTools(agent: AgentDefinition, pi: ExtensionAPI): void {
|
|
133
|
+
applyTools(agent, pi);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the system prompt injection for the active agent.
|
|
138
|
+
*
|
|
139
|
+
* Returns the modified system prompt (agent body prepended),
|
|
140
|
+
* or undefined if no injection is needed.
|
|
141
|
+
*/
|
|
142
|
+
export function buildSystemPrompt(
|
|
143
|
+
agent: AgentDefinition,
|
|
144
|
+
currentPrompt: string,
|
|
145
|
+
): string | undefined {
|
|
146
|
+
if (!agent.prompt) return undefined;
|
|
147
|
+
|
|
148
|
+
switch (agent.systemPrompt) {
|
|
149
|
+
case "append":
|
|
150
|
+
// Prepend agent body to system prompt
|
|
151
|
+
return `${agent.prompt}\n\n${currentPrompt}`;
|
|
152
|
+
|
|
153
|
+
case "replace":
|
|
154
|
+
// Replace the provider prompt but keep context files
|
|
155
|
+
// In inline mode, we can only append — full replace requires
|
|
156
|
+
// the subprocess executor (subagent path)
|
|
157
|
+
// For primary agents, "replace" behaves like append for now
|
|
158
|
+
return `${agent.prompt}\n\n${currentPrompt}`;
|
|
159
|
+
|
|
160
|
+
case "replace-all":
|
|
161
|
+
// Full replacement — only agent body
|
|
162
|
+
return agent.prompt;
|
|
163
|
+
|
|
164
|
+
default:
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent state management.
|
|
3
|
+
*
|
|
4
|
+
* Tracks the active agent, handles apply/clear/restore lifecycle,
|
|
5
|
+
* and coordinates with the executor for model/thinking/tools/prompt.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { AgentDefinition, ThinkingLevel } from "../types.ts";
|
|
11
|
+
import { applyAgentConfig } from "./executor.ts";
|
|
12
|
+
|
|
13
|
+
interface OriginalState {
|
|
14
|
+
model: Model<any> | undefined;
|
|
15
|
+
thinkingLevel: ThinkingLevel;
|
|
16
|
+
toolNames: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class AgentManager {
|
|
20
|
+
private agents: Map<string, AgentDefinition> = new Map();
|
|
21
|
+
private activeName: string | undefined;
|
|
22
|
+
private active: AgentDefinition | undefined;
|
|
23
|
+
private originalState: OriginalState | undefined;
|
|
24
|
+
private lastPersistedName: string | undefined;
|
|
25
|
+
|
|
26
|
+
/** Reload agent definitions from the registry */
|
|
27
|
+
setAgents(agents: AgentDefinition[]): void {
|
|
28
|
+
this.agents = new Map(agents.map((a) => [a.name, a]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getAgents(): AgentDefinition[] {
|
|
32
|
+
return [...this.agents.values()];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getAgent(name: string): AgentDefinition | undefined {
|
|
36
|
+
return this.agents.get(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getActive(): AgentDefinition | undefined {
|
|
40
|
+
return this.active;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getActiveName(): string | undefined {
|
|
44
|
+
return this.activeName;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
isActive(name: string): boolean {
|
|
48
|
+
return this.activeName === name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply an agent configuration to the current session.
|
|
53
|
+
* Applies model, thinking level, tools, and stores the active agent.
|
|
54
|
+
*/
|
|
55
|
+
async apply(name: string, pi: ExtensionAPI, ctx: ExtensionContext): Promise<boolean> {
|
|
56
|
+
const agent = this.agents.get(name);
|
|
57
|
+
if (!agent) return false;
|
|
58
|
+
|
|
59
|
+
// Snapshot state on first apply (for restore on clear)
|
|
60
|
+
if (this.activeName === undefined) {
|
|
61
|
+
this.originalState = {
|
|
62
|
+
model: ctx.model,
|
|
63
|
+
thinkingLevel: pi.getThinkingLevel() as ThinkingLevel,
|
|
64
|
+
toolNames: pi.getAllTools().map((t) => t.name),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.activeName = name;
|
|
69
|
+
this.active = agent;
|
|
70
|
+
|
|
71
|
+
await applyAgentConfig(agent, pi, ctx);
|
|
72
|
+
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Clear the active agent and restore original state.
|
|
78
|
+
*/
|
|
79
|
+
async clear(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
80
|
+
if (this.originalState) {
|
|
81
|
+
// Restore model
|
|
82
|
+
if (this.originalState.model) {
|
|
83
|
+
await pi.setModel(this.originalState.model);
|
|
84
|
+
}
|
|
85
|
+
// Restore thinking level
|
|
86
|
+
pi.setThinkingLevel(this.originalState.thinkingLevel);
|
|
87
|
+
// Restore tools
|
|
88
|
+
pi.setActiveTools(this.originalState.toolNames);
|
|
89
|
+
} else {
|
|
90
|
+
// No snapshot — just restore all tools
|
|
91
|
+
pi.setActiveTools(pi.getAllTools().map((t) => t.name));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.activeName = undefined;
|
|
95
|
+
this.active = undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Cycle to the next selectable agent (primary|all only).
|
|
100
|
+
* Includes "(none)" to allow clearing.
|
|
101
|
+
*/
|
|
102
|
+
async cycle(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
103
|
+
const selectable = [...this.agents.values()]
|
|
104
|
+
.filter((a) => !a.hidden && (a.mode === "primary" || a.mode === "all"))
|
|
105
|
+
.map((a) => a.name)
|
|
106
|
+
.sort();
|
|
107
|
+
|
|
108
|
+
if (selectable.length === 0) return;
|
|
109
|
+
|
|
110
|
+
const cycleList = ["(none)", ...selectable];
|
|
111
|
+
const current = this.activeName ?? "(none)";
|
|
112
|
+
const idx = cycleList.indexOf(current);
|
|
113
|
+
const next = cycleList[idx === -1 ? 0 : (idx + 1) % cycleList.length];
|
|
114
|
+
|
|
115
|
+
if (next === "(none)") {
|
|
116
|
+
await this.clear(pi, ctx);
|
|
117
|
+
} else {
|
|
118
|
+
await this.apply(next, pi, ctx);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Persistence ───────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check if agent state should be persisted (only on change).
|
|
126
|
+
*/
|
|
127
|
+
shouldPersist(): boolean {
|
|
128
|
+
return this.activeName !== undefined && this.activeName !== this.lastPersistedName;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
markPersisted(): void {
|
|
132
|
+
this.lastPersistedName = this.activeName;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Restore agent from session state (without re-applying model/tools).
|
|
137
|
+
* Used on session resume.
|
|
138
|
+
*/
|
|
139
|
+
restoreFromSession(name: string): void {
|
|
140
|
+
const agent = this.agents.get(name);
|
|
141
|
+
if (agent) {
|
|
142
|
+
this.activeName = name;
|
|
143
|
+
this.active = agent;
|
|
144
|
+
this.lastPersistedName = name;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|