pi-subagents-lite 1.3.0 → 1.4.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/README.md +184 -235
- package/package.json +1 -1
- package/src/{agent-discovery.ts → agents/agent-discovery.ts} +10 -7
- package/src/{agent-manager.ts → agents/agent-manager.ts} +34 -74
- package/src/{agent-runner.ts → agents/agent-runner.ts} +130 -181
- package/src/{agent-status.ts → agents/agent-status.ts} +4 -4
- package/src/agents/agent-types.ts +339 -0
- package/src/{default-agents.ts → agents/default-agents.ts} +2 -5
- package/src/{output-file.ts → agents/output-file.ts} +68 -1
- package/src/{tool-execution.ts → agents/tool-execution.ts} +60 -222
- package/src/agents/types.ts +54 -0
- package/src/{usage.ts → agents/usage.ts} +7 -0
- package/src/{config-io.ts → config/config-io.ts} +20 -3
- package/src/config/config-store.ts +472 -0
- package/src/config/types.ts +26 -0
- package/src/events.ts +185 -0
- package/src/index.ts +8 -281
- package/src/{model-precedence.ts → models/model-precedence.ts} +33 -0
- package/src/{model-selector.ts → models/model-selector.ts} +1 -1
- package/src/{context.ts → prompt/context.ts} +1 -1
- package/src/prompt/prompts.ts +180 -0
- package/src/prompt/skill-loader.ts +195 -0
- package/src/registration.ts +101 -0
- package/src/shell.ts +101 -0
- package/src/spawn/spawn-coordinator.ts +232 -0
- package/src/status-note.ts +10 -0
- package/src/types.ts +47 -71
- package/src/ui/agent-widget.ts +61 -49
- package/src/{format.ts → ui/format.ts} +64 -26
- package/src/ui/menu/helpers.ts +93 -0
- package/src/ui/menu/menu-concurrency.ts +192 -0
- package/src/ui/menu/menu-debug.ts +125 -0
- package/src/ui/menu/menu-model-settings.ts +208 -0
- package/src/ui/menu/menu-running-agents.ts +224 -0
- package/src/ui/menu/menu-spawn-options.ts +87 -0
- package/src/ui/menu/menu-spawn-wizard.ts +418 -0
- package/src/ui/menu/menu-system-prompt.ts +109 -0
- package/src/ui/menu/menu-widget-settings.ts +130 -0
- package/src/ui/menu/menus.ts +101 -0
- package/src/ui/menu/submenus/confirm.ts +47 -0
- package/src/ui/menu/submenus/model-select.ts +70 -0
- package/src/ui/menu/submenus/numeric-input.ts +98 -0
- package/src/ui/menu/wrappers/settings-list.ts +205 -0
- package/src/{renderer.ts → ui/renderer.ts} +7 -6
- package/src/{result-viewer.ts → ui/result-viewer.ts} +7 -2
- package/src/ui/types.ts +11 -0
- package/src/agent-types.ts +0 -184
- package/src/config-mutator.ts +0 -183
- package/src/menus.ts +0 -1333
- package/src/prompts.ts +0 -94
- package/src/skill-loader.ts +0 -178
- package/src/state.ts +0 -83
- /package/src/{worktree-validator.ts → spawn/worktree-validator.ts} +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-loader.ts — Load skills using Pi's exported APIs.
|
|
3
|
+
*
|
|
4
|
+
* Aligns skill discovery with Pi so subagents see the same skills as the parent session.
|
|
5
|
+
*
|
|
6
|
+
* Roots, in precedence order (first match wins by name):
|
|
7
|
+
* 1. Ancestor .agents/skills (cwd → git root, root .md files filtered out)
|
|
8
|
+
* 2. ~/.agents/skills (root .md files filtered out)
|
|
9
|
+
* 3. ~/.pi/agent/skills (Pi's user default)
|
|
10
|
+
* 4. <cwd>/.pi/skills (Pi's project default)
|
|
11
|
+
*
|
|
12
|
+
* Pi's loadSkills handles: .gitignore/.ignore/.fdignore, symlinks (follow +
|
|
13
|
+
* canonical-path dedup), YAML frontmatter, name validation.
|
|
14
|
+
*
|
|
15
|
+
* loadSkillsFromDir handles the same for individual .agents/skills directories.
|
|
16
|
+
* Root .md files from .agents/skills are filtered out because Pi's "agents"
|
|
17
|
+
* mode (no root files) is not exported.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync, realpathSync, readdirSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
loadSkills,
|
|
25
|
+
loadSkillsFromDir,
|
|
26
|
+
type Skill,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { isUnsafeName } from "../utils.js";
|
|
29
|
+
|
|
30
|
+
export interface PreloadedSkill {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
content: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SkillMeta {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
location: string;
|
|
40
|
+
/** Whether the skill should be excluded from the <available_skills> prompt block. */
|
|
41
|
+
disableModelInvocation: boolean;
|
|
42
|
+
/** Full skill content — present when the skill is preloaded. */
|
|
43
|
+
content?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load all skills in correct precedence order.
|
|
48
|
+
*
|
|
49
|
+
* Precedence (first match wins by name):
|
|
50
|
+
* 1. Ancestor .agents/skills directories (cwd → git root)
|
|
51
|
+
* 2. ~/.agents/skills
|
|
52
|
+
* 3. Pi defaults: ~/.pi/agent/skills, <cwd>/.pi/skills
|
|
53
|
+
*
|
|
54
|
+
* Deduplication: by canonical path (symlink dedup) and by name (first match wins).
|
|
55
|
+
*/
|
|
56
|
+
export function loadAllSkills(cwd: string): Skill[] {
|
|
57
|
+
const resolvedCwd = resolve(cwd);
|
|
58
|
+
|
|
59
|
+
// Ancestor .agents/skills (highest precedence)
|
|
60
|
+
const ancestorsSkills = loadAncestorAgentsSkills(resolvedCwd);
|
|
61
|
+
|
|
62
|
+
// ~/.agents/skills
|
|
63
|
+
const homeAgentsResult = loadSkillsFromDir({
|
|
64
|
+
dir: join(homedir(), ".agents", "skills"),
|
|
65
|
+
source: "agents",
|
|
66
|
+
});
|
|
67
|
+
const homeAgentsSkills = filterRootMdFiles(
|
|
68
|
+
homeAgentsResult.skills,
|
|
69
|
+
join(homedir(), ".agents", "skills"),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Pi defaults: ~/.pi/agent/skills and <cwd>/.pi/skills
|
|
73
|
+
const defaultsResult = loadSkills({
|
|
74
|
+
cwd: resolvedCwd,
|
|
75
|
+
agentDir: join(homedir(), ".pi", "agent"),
|
|
76
|
+
skillPaths: [],
|
|
77
|
+
includeDefaults: true,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Merge in precedence order: ancestors first, then home, then defaults.
|
|
81
|
+
// First match wins by name and by canonical path.
|
|
82
|
+
const nameSet = new Set<string>();
|
|
83
|
+
const realPathSet = new Set<string>();
|
|
84
|
+
const result: Skill[] = [];
|
|
85
|
+
|
|
86
|
+
for (const skill of [...ancestorsSkills, ...homeAgentsSkills, ...defaultsResult.skills]) {
|
|
87
|
+
const realPath = canonicalizePath(skill.filePath);
|
|
88
|
+
if (realPathSet.has(realPath) || nameSet.has(skill.name)) continue;
|
|
89
|
+
nameSet.add(skill.name);
|
|
90
|
+
realPathSet.add(realPath);
|
|
91
|
+
result.push(skill);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Walk from cwd up to git root, loading skills from each .agents/skills directory.
|
|
99
|
+
* Filters out root .md files (Pi's exported API doesn't support "agents" mode).
|
|
100
|
+
*/
|
|
101
|
+
function loadAncestorAgentsSkills(resolvedCwd: string): Skill[] {
|
|
102
|
+
const gitRoot = findGitRoot(resolvedCwd);
|
|
103
|
+
const result: Skill[] = [];
|
|
104
|
+
let dir = resolvedCwd;
|
|
105
|
+
|
|
106
|
+
while (true) {
|
|
107
|
+
const agentsSkillsDir = join(dir, ".agents", "skills");
|
|
108
|
+
const dirResult = loadSkillsFromDir({
|
|
109
|
+
dir: agentsSkillsDir,
|
|
110
|
+
source: "agents",
|
|
111
|
+
});
|
|
112
|
+
result.push(...filterRootMdFiles(dirResult.skills, agentsSkillsDir));
|
|
113
|
+
|
|
114
|
+
if (dir === gitRoot) break;
|
|
115
|
+
const parent = resolve(dir, "..");
|
|
116
|
+
if (parent === dir) break; // filesystem root
|
|
117
|
+
dir = parent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Filter out root .md files from .agents/skills directories.
|
|
125
|
+
*
|
|
126
|
+
* loadSkillsFromDir always includes root .md files (includeRootFiles: true),
|
|
127
|
+
* but .agents/skills directories should only contain subdirectory skills.
|
|
128
|
+
* A root .md skill has a filePath whose parent is the skills root itself.
|
|
129
|
+
*/
|
|
130
|
+
function filterRootMdFiles(skills: Skill[], skillsRoot: string): Skill[] {
|
|
131
|
+
const normalizedRoot = resolve(skillsRoot);
|
|
132
|
+
return skills.filter((skill) => {
|
|
133
|
+
const parent = resolve(skill.filePath, "..");
|
|
134
|
+
return parent !== normalizedRoot;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Walk up from dir to find the git root (directory containing .git). */
|
|
139
|
+
function findGitRoot(dir: string): string {
|
|
140
|
+
let current = resolve(dir);
|
|
141
|
+
while (true) {
|
|
142
|
+
try {
|
|
143
|
+
const entries = readdirSync(current);
|
|
144
|
+
if (entries.includes(".git")) return current;
|
|
145
|
+
} catch { /* ignore */ }
|
|
146
|
+
const parent = resolve(current, "..");
|
|
147
|
+
if (parent === current) return current; // filesystem root
|
|
148
|
+
current = parent;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Resolve path to canonical form, following symlinks. Falls back to raw path. */
|
|
153
|
+
function canonicalizePath(filePath: string): string {
|
|
154
|
+
try { return realpathSync(filePath); } catch { return filePath; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
158
|
+
const skills = loadAllSkills(cwd);
|
|
159
|
+
return skillNames.map((name) => {
|
|
160
|
+
if (isUnsafeName(name)) {
|
|
161
|
+
return { name, description: "", content: `(Skill "${name}" skipped: name contains path traversal characters)` };
|
|
162
|
+
}
|
|
163
|
+
const match = skills.find((s) => s.name === name);
|
|
164
|
+
if (!match) {
|
|
165
|
+
return { name, description: "", content: `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)` };
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return { name, description: match.description, content: readFileSync(match.filePath, "utf-8").trim() };
|
|
169
|
+
} catch {
|
|
170
|
+
return { name, description: "", content: `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)` };
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Load skill metadata only (name, description, location) without full content.
|
|
177
|
+
* Used for the skills whitelist — agent can read full content on-demand.
|
|
178
|
+
*/
|
|
179
|
+
export function loadSkillMeta(skillNames: string[], cwd: string): SkillMeta[] {
|
|
180
|
+
const skills = loadAllSkills(cwd);
|
|
181
|
+
return skillNames.map((name) => {
|
|
182
|
+
const match = skills.find((s) => s.name === name);
|
|
183
|
+
if (!match) {
|
|
184
|
+
return { name, description: `(Skill "${name}" not found)`, location: "", disableModelInvocation: false };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
name,
|
|
188
|
+
description: match.description,
|
|
189
|
+
location: match.filePath,
|
|
190
|
+
disableModelInvocation: match.disableModelInvocation,
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { getAvailableTypes } from "./agents/agent-types.js";
|
|
4
|
+
import { executeAgentTool, executeStopAgentTool } from "./agents/tool-execution.js";
|
|
5
|
+
import { executeAgentStatusTool } from "./agents/agent-status.js";
|
|
6
|
+
import { renderAgentToolCall, renderAgentToolResult, renderSubagentResult } from "./ui/renderer.js";
|
|
7
|
+
import { showAgentsMainMenu } from "./ui/menu/menus.js";
|
|
8
|
+
import { getPiInstance, getStore } from "./shell.js";
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Agent tool registration helper — dynamic enum for agent types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register (or re-register) the Agent tool with current agent types.
|
|
16
|
+
* At init time only defaults exist; call again from session_start after
|
|
17
|
+
* user/project agents are loaded to update the enum.
|
|
18
|
+
*/
|
|
19
|
+
export function registerAgentTool(pi: ExtensionAPI): void {
|
|
20
|
+
const types = getAvailableTypes();
|
|
21
|
+
// Use plain string to avoid verbose anyOf in prompt.
|
|
22
|
+
// Available types are listed in description for discoverability.
|
|
23
|
+
const agentParam = types.length > 0
|
|
24
|
+
? Type.Optional(Type.String({ description: types.join(",") }))
|
|
25
|
+
: Type.Optional(Type.String());
|
|
26
|
+
// @ts-expect-error — description removed to save prompt tokens
|
|
27
|
+
pi.registerTool({
|
|
28
|
+
name: "Agent",
|
|
29
|
+
label: "Agent",
|
|
30
|
+
parameters: Type.Object({
|
|
31
|
+
prompt: Type.String(),
|
|
32
|
+
description: Type.Optional(Type.String()),
|
|
33
|
+
agent: agentParam,
|
|
34
|
+
run_in_background: Type.Optional(Type.Boolean()),
|
|
35
|
+
worktree_path: Type.Optional(Type.String()),
|
|
36
|
+
}),
|
|
37
|
+
execute: executeAgentTool,
|
|
38
|
+
|
|
39
|
+
renderCall: (args, theme) => renderAgentToolCall(args as Record<string, unknown>, theme),
|
|
40
|
+
|
|
41
|
+
renderResult: (result, options, theme) => {
|
|
42
|
+
const showCost = getStore().agent.showCost;
|
|
43
|
+
return renderAgentToolResult(
|
|
44
|
+
result as { content: Array<{ type: string; text?: string }>; details?: Record<string, unknown>; isError?: boolean },
|
|
45
|
+
options as { expanded?: boolean },
|
|
46
|
+
theme,
|
|
47
|
+
showCost,
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Tool/Command/Message registration
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
/** Register all tools, commands, and message renderers. */
|
|
58
|
+
export function registerTools(pi: ExtensionAPI): void {
|
|
59
|
+
// Agent tool — stealth schema with dynamic agent type enum
|
|
60
|
+
registerAgentTool(pi);
|
|
61
|
+
|
|
62
|
+
// StopAgent tool — stealth schema, stop a running agent by ID
|
|
63
|
+
// @ts-expect-error — description removed to save prompt tokens
|
|
64
|
+
pi.registerTool({
|
|
65
|
+
name: "StopAgent",
|
|
66
|
+
label: "StopAgent",
|
|
67
|
+
parameters: Type.Object({
|
|
68
|
+
agent_id: Type.String(),
|
|
69
|
+
}),
|
|
70
|
+
execute: executeStopAgentTool,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// AgentStatus tool — stealth schema, list all agents and their statuses
|
|
74
|
+
// @ts-expect-error — description removed to save prompt tokens
|
|
75
|
+
pi.registerTool({
|
|
76
|
+
name: "AgentStatus",
|
|
77
|
+
label: "AgentStatus",
|
|
78
|
+
parameters: Type.Object({}),
|
|
79
|
+
execute: executeAgentStatusTool,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Message renderer — subagent-result (background agent completion)
|
|
83
|
+
pi.registerMessageRenderer("subagent-result", (message, options, theme) => {
|
|
84
|
+
const showCost = getStore().agent.showCost;
|
|
85
|
+
return renderSubagentResult(
|
|
86
|
+
message as { content?: string; details?: Record<string, unknown> },
|
|
87
|
+
options as { expanded?: boolean },
|
|
88
|
+
theme,
|
|
89
|
+
showCost,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Command registration
|
|
94
|
+
pi.registerCommand("agents", {
|
|
95
|
+
description: "Manage subagents: agent briefing, model settings, concurrency, running agents, agent types",
|
|
96
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
97
|
+
const modelOptions = ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`);
|
|
98
|
+
await showAgentsMainMenu(ctx, modelOptions);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
package/src/shell.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shell.ts — Composition root shell.
|
|
3
|
+
*
|
|
4
|
+
* Per ADR 0004, the Shell is the single mutable container for all per-session
|
|
5
|
+
* state. Created at session_start, disposed at session_shutdown. Handler
|
|
6
|
+
* modules read from shell via the getter functions — no module-level mutable
|
|
7
|
+
* globals.
|
|
8
|
+
*
|
|
9
|
+
* index.ts populates the shell at session_start; handler modules import
|
|
10
|
+
* getManager() / getWidget() / etc.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { AgentManager } from "./agents/agent-manager.js";
|
|
15
|
+
import type { AgentWidget } from "./ui/agent-widget.js";
|
|
16
|
+
import type { SpawnCoordinator } from "./spawn/spawn-coordinator.js";
|
|
17
|
+
import { ConfigStore } from "./config/config-store.js";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Shell type
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
interface Shell {
|
|
24
|
+
pi: ExtensionAPI;
|
|
25
|
+
sessionCtx: ExtensionContext;
|
|
26
|
+
manager: AgentManager | null;
|
|
27
|
+
widget: AgentWidget | null;
|
|
28
|
+
store: ConfigStore;
|
|
29
|
+
coordinator: SpawnCoordinator | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Mutable module-level shell (populated by index.ts at session_start)
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
const shell: Shell = {
|
|
37
|
+
pi: null!,
|
|
38
|
+
sessionCtx: null!,
|
|
39
|
+
manager: null,
|
|
40
|
+
widget: null,
|
|
41
|
+
store: new ConfigStore(),
|
|
42
|
+
coordinator: null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Getter functions (read current state at call time)
|
|
47
|
+
// ============================================================================
|
|
48
|
+
|
|
49
|
+
/** The PI extension API instance. Set at init time. */
|
|
50
|
+
export function getPiInstance(): ExtensionAPI {
|
|
51
|
+
return shell.pi;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The current session context. Set at session_start. */
|
|
55
|
+
export function getSessionCtx(): ExtensionContext {
|
|
56
|
+
return shell.sessionCtx;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The current AgentManager, or null if not yet created. */
|
|
60
|
+
export function getManager(): AgentManager | null {
|
|
61
|
+
return shell.manager;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The current AgentWidget, or null if not yet created. */
|
|
65
|
+
export function getWidget(): AgentWidget | null {
|
|
66
|
+
return shell.widget;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The ConfigStore (lives for the lifetime of the extension). */
|
|
70
|
+
export function getStore(): ConfigStore {
|
|
71
|
+
return shell.store;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The current SpawnCoordinator, or null if not yet created. */
|
|
75
|
+
export function getCoordinator(): SpawnCoordinator | null {
|
|
76
|
+
return shell.coordinator;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// Setter functions (called by index.ts to populate the shell)
|
|
81
|
+
// ============================================================================
|
|
82
|
+
|
|
83
|
+
export function setPiInstance(pi: ExtensionAPI): void {
|
|
84
|
+
shell.pi = pi;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function setSessionCtx(ctx: ExtensionContext): void {
|
|
88
|
+
shell.sessionCtx = ctx;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function setManager(m: AgentManager | null): void {
|
|
92
|
+
shell.manager = m;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function setWidget(w: AgentWidget | null): void {
|
|
96
|
+
shell.widget = w;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setCoordinator(c: SpawnCoordinator | null): void {
|
|
100
|
+
shell.coordinator = c;
|
|
101
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { getStatusNote } from "../status-note.js";
|
|
2
|
+
/**
|
|
3
|
+
* spawn-coordinator.ts — Spawn-and-track coordination for subagents.
|
|
4
|
+
*
|
|
5
|
+
* Single entry point for both LLM tool and menu spawn paths.
|
|
6
|
+
* Owns: LiveView store, Nudge system (schedule/batch/emit), background agent tracking.
|
|
7
|
+
* Delegates concurrency and record lifecycle to AgentManager (peers, not ownership).
|
|
8
|
+
*
|
|
9
|
+
* Decision refs: D3 (forward events to live-view), D4 (stats on record only),
|
|
10
|
+
* D6 (Nudge owned here), D2 (peers with AgentManager).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { AgentRecord, SpawnConfig, ToolActivity } from "../types.js";
|
|
15
|
+
import type { AgentManager, SpawnOptions } from "../agents/agent-manager.js";
|
|
16
|
+
import { buildAgentDetails } from "../agents/tool-execution.js";
|
|
17
|
+
import { getWidget } from "../shell.js";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Types
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/** Coordinator-owned per-agent live display state. Only transient UI state. */
|
|
24
|
+
export interface LiveView {
|
|
25
|
+
activeTools: Map<string, string>; // keyed by toolName_timestamp
|
|
26
|
+
responseText: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Input for spawn(). Built by each caller from its own validation. */
|
|
30
|
+
export interface SpawnIntent extends SpawnConfig {
|
|
31
|
+
type: string;
|
|
32
|
+
prompt: string;
|
|
33
|
+
runInBackground: boolean;
|
|
34
|
+
/** Narrowed to required — all callers resolve this before spawn. */
|
|
35
|
+
graceTurns: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SpawnResult {
|
|
39
|
+
agentId: string;
|
|
40
|
+
record: AgentRecord;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Constants
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/** Batch delay for nudges — only emit one update per batch window (ms). */
|
|
48
|
+
const NUDGE_DELAY_MS = 200;
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// SpawnCoordinator
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
export class SpawnCoordinator {
|
|
55
|
+
/** Per-agent live display state. Widget reads from here + record for stats. */
|
|
56
|
+
private liveViews = new Map<string, LiveView>();
|
|
57
|
+
|
|
58
|
+
/** Agent IDs spawned as background — only these trigger a nudge on completion. */
|
|
59
|
+
private backgroundAgentIds = new Set<string>();
|
|
60
|
+
|
|
61
|
+
/** Pending nudge agent IDs, batched within the delay window. */
|
|
62
|
+
private pendingNudges = new Set<string>();
|
|
63
|
+
|
|
64
|
+
/** Active nudge timer. */
|
|
65
|
+
private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
|
|
67
|
+
/** The pi API reference for sending messages. */
|
|
68
|
+
private pi: ExtensionAPI | null = null;
|
|
69
|
+
|
|
70
|
+
constructor(private manager: AgentManager, pi?: ExtensionAPI) {
|
|
71
|
+
if (pi) this.pi = pi;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Spawn + wire tracking + (foreground) await.
|
|
76
|
+
* Single entry point for LLM tool executor and menu wizard.
|
|
77
|
+
*/
|
|
78
|
+
async spawn(
|
|
79
|
+
pi: ExtensionAPI,
|
|
80
|
+
ctx: ExtensionContext,
|
|
81
|
+
intent: SpawnIntent,
|
|
82
|
+
): Promise<SpawnResult> {
|
|
83
|
+
// Store pi for nudge emission
|
|
84
|
+
this.pi = pi;
|
|
85
|
+
|
|
86
|
+
// Create live view BEFORE spawn so callbacks can close over it
|
|
87
|
+
const liveView: LiveView = {
|
|
88
|
+
activeTools: new Map(),
|
|
89
|
+
responseText: "",
|
|
90
|
+
};
|
|
91
|
+
const liveViewCallbacks = this.createLiveViewCallbacks(liveView);
|
|
92
|
+
|
|
93
|
+
// Shared config fields (SpawnConfig) pass through unchanged; only the
|
|
94
|
+
// intent-only fields (type/prompt/runInBackground) need translation.
|
|
95
|
+
const { type, prompt, runInBackground, ...config } = intent;
|
|
96
|
+
const spawnOptions: SpawnOptions = {
|
|
97
|
+
...config,
|
|
98
|
+
isBackground: runInBackground,
|
|
99
|
+
...liveViewCallbacks,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const agentId = this.manager.spawn(pi, ctx, type, prompt, spawnOptions);
|
|
103
|
+
|
|
104
|
+
// Register live view
|
|
105
|
+
this.liveViews.set(agentId, liveView);
|
|
106
|
+
|
|
107
|
+
// Ensure widget timer is running so it displays the new agent
|
|
108
|
+
// (menu path calls this explicitly, but tool path doesn't)
|
|
109
|
+
const widget = getWidget();
|
|
110
|
+
if (widget) {
|
|
111
|
+
widget.ensureTimer();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Track background agents
|
|
115
|
+
if (intent.runInBackground) {
|
|
116
|
+
this.backgroundAgentIds.add(agentId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const record = this.manager.getRecord(agentId)!;
|
|
120
|
+
|
|
121
|
+
if (!intent.runInBackground) {
|
|
122
|
+
// Foreground: await completion
|
|
123
|
+
await record.execution.promise;
|
|
124
|
+
|
|
125
|
+
// Clean up live view (foreground completion handled inline)
|
|
126
|
+
this.liveViews.delete(agentId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { agentId, record };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Read the live view for an agent. Widget calls this. */
|
|
133
|
+
liveView(id: string): LiveView | undefined {
|
|
134
|
+
return this.liveViews.get(id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Check if an agent was spawned as background. */
|
|
138
|
+
isBackground(agentId: string): boolean {
|
|
139
|
+
return this.backgroundAgentIds.has(agentId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Schedule a nudge for a background agent.
|
|
144
|
+
* Batches with NUDGE_DELAY_MS window to coalesce rapid completions.
|
|
145
|
+
*/
|
|
146
|
+
scheduleNudge(agentId: string): void {
|
|
147
|
+
this.pendingNudges.add(agentId);
|
|
148
|
+
|
|
149
|
+
if (this.nudgeTimer) return;
|
|
150
|
+
|
|
151
|
+
this.nudgeTimer = setTimeout(() => {
|
|
152
|
+
this.nudgeTimer = null;
|
|
153
|
+
const batch = [...this.pendingNudges];
|
|
154
|
+
this.pendingNudges.clear();
|
|
155
|
+
|
|
156
|
+
for (const id of batch) {
|
|
157
|
+
this.emitIndividualNudge(id);
|
|
158
|
+
}
|
|
159
|
+
}, NUDGE_DELAY_MS);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Called by AgentManager's onComplete callback (wired at session_start).
|
|
164
|
+
* Owns the completion side-effects: nudge scheduling, live-view cleanup.
|
|
165
|
+
*/
|
|
166
|
+
onAgentComplete(record: AgentRecord): void {
|
|
167
|
+
// Schedule nudge for background agents
|
|
168
|
+
if (this.backgroundAgentIds.has(record.id)) {
|
|
169
|
+
this.scheduleNudge(record.id);
|
|
170
|
+
this.backgroundAgentIds.delete(record.id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Clean up live view
|
|
174
|
+
this.liveViews.delete(record.id);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Dispose: clear timer, live views, and background tracking. */
|
|
178
|
+
dispose(): void {
|
|
179
|
+
if (this.nudgeTimer) {
|
|
180
|
+
clearTimeout(this.nudgeTimer);
|
|
181
|
+
this.nudgeTimer = null;
|
|
182
|
+
}
|
|
183
|
+
this.pendingNudges.clear();
|
|
184
|
+
this.liveViews.clear();
|
|
185
|
+
this.backgroundAgentIds.clear();
|
|
186
|
+
this.pi = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Private ──
|
|
190
|
+
|
|
191
|
+
/** Create callbacks that bridge manager events to a specific live view. */
|
|
192
|
+
private createLiveViewCallbacks(view: LiveView): Pick<SpawnOptions, "onToolActivity" | "onTextDelta"> {
|
|
193
|
+
return {
|
|
194
|
+
onToolActivity: (activity: ToolActivity) => {
|
|
195
|
+
if (activity.type === "start") {
|
|
196
|
+
view.activeTools.set(`${activity.toolName}_${Date.now()}`, activity.toolName);
|
|
197
|
+
} else {
|
|
198
|
+
for (const [key, name] of view.activeTools) {
|
|
199
|
+
if (name === activity.toolName) { view.activeTools.delete(key); break; }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
onTextDelta: (_delta: string, fullText: string) => {
|
|
204
|
+
view.responseText = fullText;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Emit an individual nudge for a completed background agent. */
|
|
210
|
+
private emitIndividualNudge(agentId: string): void {
|
|
211
|
+
const record = this.manager.getRecord(agentId);
|
|
212
|
+
if (!record || !this.pi) return;
|
|
213
|
+
|
|
214
|
+
const details = buildAgentDetails(record, {
|
|
215
|
+
includeStats: true,
|
|
216
|
+
includeStatus: true,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.pi.sendMessage(
|
|
220
|
+
{
|
|
221
|
+
customType: "subagent-result",
|
|
222
|
+
content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""} ${getStatusNote(record.lifecycle.status)}`,
|
|
223
|
+
details,
|
|
224
|
+
display: true,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
deliverAs: "steer",
|
|
228
|
+
triggerTurn: true,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const NOTES: Record<string, string> = {
|
|
2
|
+
stopped: "STOPPED BY THE USER before completion — output is partial; the task was NOT finished",
|
|
3
|
+
aborted: "hit the turn limit before completion; output may be incomplete",
|
|
4
|
+
turn_limited: "wrapped up at the turn limit — output may be partial",
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function getStatusNote(status: string): string {
|
|
8
|
+
const note = NOTES[status];
|
|
9
|
+
return note ? ` (${note})` : "";
|
|
10
|
+
}
|