@pi-archimedes/subagent 0.8.0 → 0.9.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/src/agents.ts CHANGED
@@ -10,12 +10,14 @@ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
  export interface AgentConfig {
11
11
  name: string;
12
12
  description: string;
13
+ tools?: string[];
13
14
  model?: string;
14
15
  thinking?: string;
15
- tools?: string[];
16
16
  systemPrompt: string;
17
17
  source: "user" | "project";
18
18
  filePath: string;
19
+ // Extra fields preserved from frontmatter but not editable in TUI
20
+ extraFields?: Record<string, unknown>;
19
21
  }
20
22
 
21
23
  function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
@@ -46,9 +48,26 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
46
48
 
47
49
  if (!frontmatter.name || !frontmatter.description) continue;
48
50
 
49
- const tools = typeof frontmatter.tools === "string"
50
- ? frontmatter.tools.split(",").map((t: string) => t.trim()).filter(Boolean)
51
- : undefined;
51
+ // Handle tools specially preserve non-string values in extraFields
52
+ let parsedTools: string[] | undefined;
53
+ if (typeof frontmatter.tools === "string") {
54
+ parsedTools = frontmatter.tools.split(",").map((t: string) => t.trim()).filter(Boolean);
55
+ }
56
+
57
+ const knownKeys = new Set(["name", "description", "model", "thinking"]);
58
+ const extraFields: Record<string, unknown> = {};
59
+ for (const [key, value] of Object.entries(frontmatter)) {
60
+ if (key === "tools") continue; // tools is handled separately above/below
61
+ if (!knownKeys.has(key) && value != null && typeof value !== "object") {
62
+ extraFields[key] = String(value);
63
+ } else if (!knownKeys.has(key) && value != null) {
64
+ extraFields[key] = value;
65
+ }
66
+ }
67
+ // If tools was non-string, store it in extraFields for round-trip preservation
68
+ if (frontmatter.tools !== undefined && !parsedTools && "tools" in frontmatter) {
69
+ extraFields.tools = frontmatter.tools;
70
+ }
52
71
 
53
72
  const config: AgentConfig = {
54
73
  name: frontmatter.name as string,
@@ -59,7 +78,8 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
59
78
  };
60
79
  if (frontmatter.model) config.model = frontmatter.model as string;
61
80
  if (frontmatter.thinking) config.thinking = frontmatter.thinking as string;
62
- if (tools && tools.length > 0) config.tools = tools;
81
+ if (parsedTools && parsedTools.length > 0) config.tools = parsedTools;
82
+ if (Object.keys(extraFields).length > 0) config.extraFields = extraFields;
63
83
 
64
84
  agents.push(config);
65
85
  }
@@ -67,19 +87,14 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
67
87
  return agents;
68
88
  }
69
89
 
70
- function isDirectory(p: string): boolean {
71
- try {
72
- return fs.statSync(p).isDirectory();
73
- } catch {
74
- return false;
75
- }
76
- }
77
-
78
90
  function findNearestProjectAgentsDir(cwd: string): string | null {
91
+ // Walk up looking for a git repo, even if .pi/agents doesn't exist yet
79
92
  let currentDir = cwd;
80
93
  while (true) {
81
- const candidate = path.join(currentDir, ".pi", "agents");
82
- if (isDirectory(candidate)) return candidate;
94
+ const gitPath = path.join(currentDir, ".git");
95
+ if (fs.existsSync(gitPath)) {
96
+ return path.join(currentDir, ".pi", "agents");
97
+ }
83
98
 
84
99
  const parentDir = path.dirname(currentDir);
85
100
  if (parentDir === currentDir) return null;
@@ -87,6 +102,16 @@ function findNearestProjectAgentsDir(cwd: string): string | null {
87
102
  }
88
103
  }
89
104
 
105
+ /**
106
+ * Result of discovering agents — separated by source with directory paths.
107
+ */
108
+ export interface AgentsDiscoveryResult {
109
+ user: AgentConfig[];
110
+ project: AgentConfig[];
111
+ userDir: string; // e.g., ~/.pi/agent/agents
112
+ projectDir: string | null; // e.g., .pi/agents or null if not found
113
+ }
114
+
90
115
  /**
91
116
  * Discover all available agents from user and/or project directories.
92
117
  */
@@ -105,6 +130,24 @@ export function discoverAgents(cwd: string): AgentConfig[] {
105
130
  return Array.from(agentMap.values());
106
131
  }
107
132
 
133
+ /**
134
+ * Discover agents and return them separated by source with directory paths.
135
+ */
136
+ export function discoverAgentsAll(cwd: string): AgentsDiscoveryResult {
137
+ const userDir = path.join(getAgentDir(), "agents");
138
+ const projectDir = findNearestProjectAgentsDir(cwd);
139
+
140
+ const userAgents = loadAgentsFromDir(userDir, "user");
141
+ const projectAgents = projectDir ? loadAgentsFromDir(projectDir, "project") : [];
142
+
143
+ return {
144
+ user: userAgents,
145
+ project: projectAgents,
146
+ userDir,
147
+ projectDir,
148
+ };
149
+ }
150
+
108
151
  /**
109
152
  * Look up an agent config by name.
110
153
  */
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Frontmatter serialization and validation for agent config files.
3
+ */
4
+
5
+ import type { AgentConfig } from "./agents.js";
6
+
7
+ /**
8
+ * Regex for valid agentspec names: lowercase alphanumeric + hyphens, 3-50 chars.
9
+ * Alphanumeric start/end. Also allows single-char names.
10
+ */
11
+ export const AGENT_NAME_REGEX = /^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$/;
12
+
13
+ const SINGLE_CHAR_NAME_REGEX = /^[a-z0-9]$/;
14
+
15
+ /**
16
+ * Validate an agent name against agentspec format rules.
17
+ * Returns an error message string if invalid, null if valid.
18
+ */
19
+ export function validateAgentName(name: string): string | null {
20
+ if (!name || name.length === 0) {
21
+ return "Name is required";
22
+ }
23
+ if (SINGLE_CHAR_NAME_REGEX.test(name)) {
24
+ return null;
25
+ }
26
+ if (AGENT_NAME_REGEX.test(name)) {
27
+ return null;
28
+ }
29
+ return "Name must be 3-50 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric";
30
+ }
31
+
32
+ function needsYamlQuoting(value: string): boolean {
33
+ if (value === "") return true;
34
+ // Special YAML characters at start
35
+ if (/^["'#\{\[\|>&!*%?@`-]/.test(value)) return true;
36
+ // Contains colon (could be mistaken for mapping)
37
+ if (value.includes(":")) return true;
38
+ // Contains # (inline comment marker) anywhere
39
+ if (value.includes("#")) return true;
40
+ // Contains newline
41
+ if (value.includes("\n")) return true;
42
+ // Leading/trailing whitespace
43
+ if (value !== value.trim()) return true;
44
+ // YAML booleans/nulls
45
+ if (/^(true|false|yes|no|on|off|null|True|False|Yes|No|On|Off|NULL|Null)$/i.test(value)) return true;
46
+ return false;
47
+ }
48
+
49
+ function quoteYamlValue(value: string): string {
50
+ if (!needsYamlQuoting(value)) return value;
51
+ // Escape backslashes and double quotes, wrap in double quotes
52
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
53
+ return `"${escaped}"`;
54
+ }
55
+
56
+ /**
57
+ * Serialize an AgentConfig back to a valid .md file with frontmatter.
58
+ * Produces output that parseFrontmatter can re-parse faithfully.
59
+ */
60
+ export function serializeAgent(config: AgentConfig): string {
61
+ const lines: string[] = ["---"];
62
+
63
+ // Required fields
64
+ lines.push(`name: ${quoteYamlValue(config.name)}`);
65
+ lines.push(`description: ${quoteYamlValue(config.description)}`);
66
+
67
+ // Optional known fields
68
+ if (config.tools && config.tools.length > 0) {
69
+ lines.push(`tools: ${quoteYamlValue(config.tools.join(", "))}`);
70
+ }
71
+ if (config.model) {
72
+ lines.push(`model: ${quoteYamlValue(config.model)}`);
73
+ }
74
+ if (config.thinking) {
75
+ lines.push(`thinking: ${quoteYamlValue(config.thinking)}`);
76
+ }
77
+
78
+ // Extra fields (sorted alphabetically)
79
+ if (config.extraFields) {
80
+ for (const key of Object.keys(config.extraFields).sort()) {
81
+ const value = config.extraFields[key]!;
82
+ if (typeof value === "string") {
83
+ lines.push(`${key}: ${quoteYamlValue(value)}`);
84
+ } else if (Array.isArray(value)) {
85
+ // Serialize array as YAML block sequence
86
+ lines.push(`${key}:`);
87
+ for (const item of value) {
88
+ lines.push(` - ${quoteYamlValue(String(item))}`);
89
+ }
90
+ } else {
91
+ // Fallback: JSON stringify for objects
92
+ lines.push(`${key}: ${JSON.stringify(value)}`);
93
+ }
94
+ }
95
+ }
96
+
97
+ lines.push("---");
98
+ lines.push("");
99
+ lines.push(config.systemPrompt);
100
+ lines.push("");
101
+
102
+ return lines.join("\n");
103
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text, TUI } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import { executeSubagent, executeParallel } from "./execute.js";
5
5
  import { renderSubagentResult } from "./render.js";
6
- import { discoverAgents, findAgent } from "./agents.js";
6
+ import { discoverAgents, discoverAgentsAll, findAgent } from "./agents.js";
7
+ import { createAgentManager } from "./agent-manager.js";
7
8
  import type {
8
9
  SubagentDetails,
9
10
  SubagentProgress,
@@ -96,7 +97,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
96
97
  agent: t.agent ?? undefined,
97
98
  agentConfig: t.agent ? findAgent(agents, t.agent) : undefined,
98
99
  task: t.task,
99
- model: t.model ?? undefined,
100
+ // Fall back to the parent's currently-selected model when the caller
101
+ // (and the agent frontmatter) didn't specify one.
102
+ model: t.model ?? ctx.model?.id,
100
103
  cwd: t.cwd ?? undefined,
101
104
  })),
102
105
  signal: signal ?? undefined,
@@ -141,7 +144,9 @@ export function registerSubagent(pi: ExtensionAPI): void {
141
144
  agent: params.agent ?? undefined,
142
145
  agentConfig,
143
146
  task: params.task,
144
- model: params.model ?? undefined,
147
+ // Fall back to the parent's currently-selected model when the caller
148
+ // (and the agent frontmatter) didn't specify one.
149
+ model: params.model ?? ctx.model?.id,
145
150
  cwd: params.cwd ?? undefined,
146
151
  signal: signal ?? undefined,
147
152
  onUpdate: (progress: SubagentProgress) => {
@@ -255,6 +260,35 @@ function formatResultsSummary(results: SubagentResult[]): string {
255
260
  return lines.join("\n");
256
261
  }
257
262
 
263
+ // ── Command registration ────────────────────────────────────────────────────
264
+
265
+ export function registerAgentsCommand(pi: ExtensionAPI): void {
266
+ pi.registerCommand("agents", {
267
+ description: "Open the Agents Manager",
268
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
269
+ const { user, project, userDir, projectDir } = discoverAgentsAll(ctx.cwd);
270
+
271
+ const availableModels = ctx.modelRegistry.getAvailable().map((m) => ({
272
+ id: m.id,
273
+ provider: m.provider,
274
+ fullId: `${m.provider}/${m.id}`,
275
+ }));
276
+
277
+ const availableTools = pi.getAllTools().map((t) => ({
278
+ name: t.name,
279
+ description: t.description ?? "",
280
+ }));
281
+
282
+ await ctx.ui.custom<void>(
283
+ (tui: TUI, theme: Theme, _keybindings, done: () => void) => {
284
+ return createAgentManager(user, project, userDir, projectDir, tui, theme, done, availableModels, availableTools);
285
+ },
286
+ { overlay: true, overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" } },
287
+ );
288
+ },
289
+ });
290
+ }
291
+
258
292
  // ── Default export ──────────────────────────────────────────────────────────
259
293
 
260
294
  export default function (pi: ExtensionAPI): void {
package/src/spawn.ts CHANGED
@@ -101,7 +101,8 @@ export function spawnSubagent(options: SpawnOptions): ChildProcess {
101
101
  args.push("--tools", agent.tools.join(","));
102
102
  }
103
103
  } else if (options.model) {
104
- // Fallback: use model from tool call params (legacy)
104
+ // Fallback: tool-call model (which itself falls back to the parent's
105
+ // currently-selected model via ctx.model?.id at the call site).
105
106
  args.push("--model", options.model);
106
107
  }
107
108