@alisio/plugin-subagents 0.1.0-alpha.10

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/dist/index.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Built-in subagents plugin. Everything delegation-specific lives here; the core only offers
3
+ * generic child sessions, a tree panel, a select prompt and concurrent tools.
4
+ */
5
+ import { isAbsolute, join, resolve } from "node:path";
6
+ import { definePlugin, textResult } from "@alisio/sdk";
7
+ import { subagentsConfigSchema } from "./config.js";
8
+ import { discoverAgents } from "./definitions.js";
9
+ import * as gitOps from "./git.js";
10
+ import { SubagentManager } from "./manager.js";
11
+ import { loadVersion } from "./version.js";
12
+ export { BUILTIN_AGENTS, discoverAgents, parseAgentDefinition } from "./definitions.js";
13
+ export { SubagentManager } from "./manager.js";
14
+ /** Plugin-state key under which the plugin publishes main-session-capable (mode primary/all) agents. */
15
+ export const MAIN_AGENTS_STATE_KEY = "mainAgents";
16
+ const schema = (properties, required) => ({
17
+ type: "object",
18
+ properties,
19
+ required,
20
+ additionalProperties: false,
21
+ });
22
+ const str = { type: "string", minLength: 1 };
23
+ function describeAgents(agents) {
24
+ return [...agents.values()]
25
+ .filter((a) => a.mode !== "primary" && !a.hidden)
26
+ .map((a) => `- ${a.name}: ${a.description}${a.readOnly ? " (read-only)" : ""}`)
27
+ .join("\n");
28
+ }
29
+ export function createSubagentsPlugin(rawOptions, context) {
30
+ const config = subagentsConfigSchema.parse(rawOptions ?? {});
31
+ if (config.worktreeDir && !isAbsolute(config.worktreeDir))
32
+ config.worktreeDir = resolve(context.configDir, config.worktreeDir);
33
+ let manager;
34
+ let warnings = [];
35
+ return definePlugin({
36
+ id: "subagents",
37
+ name: "Subagents",
38
+ description: "Delegation to specialized agents in isolated child sessions",
39
+ categories: ["subagents"],
40
+ version: loadVersion(import.meta.url),
41
+ apiVersion: 1,
42
+ async setup(api) {
43
+ const found = await discoverAgents({
44
+ workspace: context.workspace,
45
+ home: context.home,
46
+ configHome: context.configHome,
47
+ trusted: context.trusted,
48
+ cli: config.agents,
49
+ plugins: api.resources.list("agents"),
50
+ });
51
+ warnings = found.warnings;
52
+ // Publish the main-session-capable definitions (mode `primary`/`all`) for the TUI's `/agents`
53
+ // picker. The host reads them through `pluginState`, so the TUI never imports this package.
54
+ // Only the fields the ACTIVE agent needs are persisted; prompts stay bounded.
55
+ api.state.set(MAIN_AGENTS_STATE_KEY, [...found.agents.values()]
56
+ .filter((a) => a.mode !== "subagent" && !a.hidden)
57
+ .map((a) => ({
58
+ name: a.name,
59
+ description: a.description,
60
+ prompt: a.prompt.slice(0, 24_000),
61
+ ...(a.model ? { model: a.model } : {}),
62
+ ...(a.readOnly ? { readOnly: true } : {}),
63
+ source: a.source,
64
+ ...(a.path ? { path: a.path } : {}),
65
+ })));
66
+ const m = new SubagentManager(api, config, context, found.agents);
67
+ manager = m;
68
+ const status = () => {
69
+ const all = [...m.tasks.values()];
70
+ const running = all.filter((t) => t.status === "running").length;
71
+ const queued = all.filter((t) => t.status === "queued").length;
72
+ api.ui.status("agents", running || queued ? `agents ${running}▶${queued ? ` ${queued}⧗` : ""}` : undefined, `${all.length} subagent task(s) this process · ${running} running · ${queued} queued · ${found.agents.size} definitions`);
73
+ };
74
+ m.refresh = status;
75
+ api.events.on((event) => {
76
+ m.observe(event);
77
+ });
78
+ api.tools.register({
79
+ name: "task",
80
+ effect: "internal",
81
+ concurrent: true,
82
+ description: `Delegate a task to a specialized subagent that works in a separate conversation (it does not see this conversation) and returns only its final report. Launch several in one turn to work in parallel. Use model to select a configured provider/model for only the child. Use background=true for long work: you will get a <task-notification> when it finishes (do not poll). Pass task_id to continue an earlier task with its full history.\nAvailable subagent_type values:\n${describeAgents(found.agents)}`,
83
+ inputSchema: schema({
84
+ description: { type: "string", minLength: 1, maxLength: 120 },
85
+ prompt: { type: "string", minLength: 1, maxLength: 100_000 },
86
+ subagent_type: str,
87
+ task_id: str,
88
+ background: { type: "boolean" },
89
+ model: { type: "string", minLength: 1 },
90
+ }, ["description", "prompt", "subagent_type"]),
91
+ async execute(input, ctx) {
92
+ if (!ctx.session)
93
+ return textResult("task requires a session", true);
94
+ try {
95
+ const result = await m.startTask({
96
+ caller: ctx.session,
97
+ agentName: String(input.subagent_type),
98
+ title: String(input.description),
99
+ prompt: String(input.prompt),
100
+ ...(typeof input.task_id === "string" ? { taskId: input.task_id } : {}),
101
+ ...(typeof input.background === "boolean" ? { background: input.background } : {}),
102
+ ...(typeof input.model === "string" ? { model: input.model } : {}),
103
+ signal: ctx.signal,
104
+ });
105
+ return textResult(result.text, result.isError);
106
+ }
107
+ catch (error) {
108
+ return textResult(`<task state="failed">Error: ${error instanceof Error ? error.message : String(error)}</task>`, true);
109
+ }
110
+ },
111
+ });
112
+ api.tools.register({
113
+ name: "task_status",
114
+ effect: "internal",
115
+ concurrent: true,
116
+ description: "Status of one of your subagent tasks (does not wait).",
117
+ inputSchema: schema({ task_id: str }, ["task_id"]),
118
+ async execute(input, ctx) {
119
+ try {
120
+ const task = m.assertDescendant(ctx.session ?? "", String(input.task_id));
121
+ const info = api.sessions.get(task.id);
122
+ return textResult(JSON.stringify({
123
+ task_id: task.id,
124
+ agent: task.agent.name,
125
+ status: info?.status ?? task.status,
126
+ background: task.background,
127
+ tokens: task.tokens,
128
+ }));
129
+ }
130
+ catch (error) {
131
+ return textResult(String(error instanceof Error ? error.message : error), true);
132
+ }
133
+ },
134
+ });
135
+ api.tools.register({
136
+ name: "task_wait",
137
+ effect: "internal",
138
+ concurrent: true,
139
+ description: `Wait (bounded, at most ${config.waitMaxMs}ms) for one of your subagent tasks and return its result, or its status on timeout.`,
140
+ inputSchema: schema({ task_id: str, timeout_ms: { type: "integer", minimum: 0, maximum: config.waitMaxMs } }, ["task_id"]),
141
+ async execute(input, ctx) {
142
+ try {
143
+ const r = await m.wait(ctx.session ?? "", String(input.task_id), Number(input.timeout_ms ?? 60_000));
144
+ return textResult(r.text, r.isError);
145
+ }
146
+ catch (error) {
147
+ return textResult(String(error instanceof Error ? error.message : error), true);
148
+ }
149
+ },
150
+ });
151
+ api.tools.register({
152
+ name: "send_message",
153
+ effect: "internal",
154
+ concurrent: true,
155
+ description: "One-way message to one of your subagent tasks: queued for its next turn while it runs, or resumes it (in the background) when finished.",
156
+ inputSchema: schema({ task_id: str, text: { type: "string", minLength: 1, maxLength: 50_000 } }, ["task_id", "text"]),
157
+ async execute(input, ctx) {
158
+ try {
159
+ return textResult(m.sendMessage(ctx.session ?? "", String(input.task_id), String(input.text)));
160
+ }
161
+ catch (error) {
162
+ return textResult(String(error instanceof Error ? error.message : error), true);
163
+ }
164
+ },
165
+ });
166
+ api.ui.panel("agents", {
167
+ title: "Agents",
168
+ nodes: ({ sessionId }) => m.nodes(sessionId),
169
+ action: (action, nodeId, { sessionId }) => {
170
+ if (action === "cancel" && nodeId)
171
+ m.cancel(nodeId);
172
+ if (action === "background")
173
+ m.backgroundAll(sessionId);
174
+ },
175
+ });
176
+ const worktreeTask = (id) => {
177
+ const task = m.tasks.get(id);
178
+ if (!task?.worktree)
179
+ throw new Error(`Task ${id} has no worktree in this process.`);
180
+ return { task, wt: task.worktree };
181
+ };
182
+ api.commands.register("agents", async (args, commandContext) => {
183
+ const [verb = "", target = "", ...rest] = args.trim().split(/\s+/);
184
+ const root = commandContext?.sessionId ?? "";
185
+ const resolveId = (prefix) => {
186
+ const matches = [...m.tasks.keys()].filter((k) => k.startsWith(prefix));
187
+ if (matches.length !== 1)
188
+ throw new Error(matches.length ? `Ambiguous id ${prefix}` : `Unknown task ${prefix}`);
189
+ return matches[0];
190
+ };
191
+ try {
192
+ switch (verb) {
193
+ case "":
194
+ case "list": {
195
+ const nodes = root ? m.nodes(root) : [];
196
+ if (!nodes.length)
197
+ return "No subagent tasks in this session.";
198
+ const depth = (id) => id ? 1 + depth(nodes.find((n) => n.id === id)?.parentId) : 0;
199
+ return [
200
+ "**Subagents**",
201
+ "",
202
+ ...nodes.map((n) => `${" ".repeat(depth(n.parentId))}- \`${n.id.slice(0, 8)}\` **${n.label}** ${n.status} · ${n.tokens ?? 0} tok · ${n.detail ?? ""}`),
203
+ "",
204
+ "`/agents open|cancel|kill|resume <id>` · `/agents merge|discard <id>` (worktrees) · `/agents defs`",
205
+ ].join("\n");
206
+ }
207
+ case "defs":
208
+ return [
209
+ "**Agent definitions**",
210
+ "",
211
+ ...[...m.agents.values()].map((a) => `- \`${a.name}\` (${a.source}${a.path ? `: ${a.path}` : ""})${a.readOnly ? " read-only" : ""} — ${a.description}`),
212
+ ...(warnings.length
213
+ ? ["", "**Warnings**", "", ...warnings.map((w) => `- ${w}`)]
214
+ : []),
215
+ ].join("\n");
216
+ case "open": {
217
+ const id = resolveId(target);
218
+ return api.ui.open(id)
219
+ ? `Opened ${id}.`
220
+ : "Opening a session view needs the interactive terminal.";
221
+ }
222
+ case "cancel":
223
+ case "kill": {
224
+ const id = resolveId(target);
225
+ const n = m.cancel(id);
226
+ return `Cancelled ${id} (${n} running session${n === 1 ? "" : "s"} stopped; tool processes get SIGTERM, then SIGKILL after the grace period).`;
227
+ }
228
+ case "resume": {
229
+ const id = resolveId(target);
230
+ const task = m.tasks.get(id);
231
+ if (!task)
232
+ throw new Error(`Unknown task ${id}`);
233
+ return m.sendMessage(task.parentSession, id, rest.join(" ") || "Continue where you left off.");
234
+ }
235
+ case "merge": {
236
+ const { task, wt } = worktreeTask(resolveId(target));
237
+ const result = await gitOps.merge(wt.root, wt.branch, `Merge ${wt.branch} (alisio task: ${task.title})`);
238
+ if (!result.ok)
239
+ return `Merge of ${wt.branch} did not complete: ${result.message}${result.conflicts.length ? `\nConflicting files: ${result.conflicts.join(", ")}` : ""}\nThe repository was left unchanged (merge aborted). Resolve manually with: git merge ${wt.branch}`;
240
+ await gitOps.removeWorktree(wt.root, wt.path, wt.branch, true);
241
+ task.worktree = undefined;
242
+ return `Merged ${wt.branch} into the current branch with --no-ff and removed its worktree.`;
243
+ }
244
+ case "discard": {
245
+ const { task, wt } = worktreeTask(resolveId(target));
246
+ await gitOps.removeWorktree(wt.root, wt.path, wt.branch, true);
247
+ task.worktree = undefined;
248
+ return `Discarded ${wt.branch} and removed its worktree.`;
249
+ }
250
+ default:
251
+ return "Usage: /agents [list|defs|open|cancel|kill|resume|merge|discard] [id]";
252
+ }
253
+ }
254
+ catch (error) {
255
+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
256
+ }
257
+ }, {
258
+ description: "List and manage subagents (open, cancel, resume, merge, discard, defs)",
259
+ argumentHint: "[open|cancel|kill|resume|merge|discard|defs] [id]",
260
+ });
261
+ status();
262
+ },
263
+ dispose() {
264
+ if (manager)
265
+ for (const task of manager.tasks.values())
266
+ if (task.status === "running")
267
+ manager.cancel(task.id);
268
+ manager = undefined;
269
+ },
270
+ });
271
+ }
272
+ export const subagentsWorktreeDir = (stateHome) => join(stateHome, "worktrees");
@@ -0,0 +1,89 @@
1
+ import type { PanelNode, PluginAPI, RunEvent, SessionStatus } from "@alisio/sdk";
2
+ import type { SubagentsConfig } from "./config.ts";
3
+ import type { AgentDefinition } from "./definitions.ts";
4
+ export declare const DELEGATION_TOOLS: string[];
5
+ export interface TaskRecord {
6
+ id: string;
7
+ parentSession: string;
8
+ rootSession: string;
9
+ agent: AgentDefinition;
10
+ title: string;
11
+ background: boolean;
12
+ status: SessionStatus;
13
+ startedAt?: number;
14
+ endedAt?: number;
15
+ tokens: number;
16
+ detail: string;
17
+ snippet?: string;
18
+ writeCapable: boolean;
19
+ worktree?: {
20
+ path: string;
21
+ branch: string;
22
+ base: string;
23
+ root: string;
24
+ };
25
+ notes: string[];
26
+ result?: string;
27
+ isError?: boolean;
28
+ /** Resolves the pending foreground tool call early (Ctrl+B). */
29
+ detach?: () => void;
30
+ done?: Promise<void>;
31
+ }
32
+ export declare class SubagentManager {
33
+ private api;
34
+ private config;
35
+ private env;
36
+ agents: Map<string, AgentDefinition>;
37
+ tasks: Map<string, TaskRecord>;
38
+ private running;
39
+ private runningTotal;
40
+ private waiting;
41
+ private modes;
42
+ private asking;
43
+ private writers;
44
+ private locks;
45
+ constructor(api: PluginAPI, config: SubagentsConfig, env: {
46
+ stateHome: string;
47
+ workspace: string;
48
+ }, agents: Map<string, AgentDefinition>);
49
+ rootOf(session: string): string;
50
+ depthOf(session: string): number;
51
+ /** Delegation tools granted to a child created by `caller` with this definition. */
52
+ private grantsDelegation;
53
+ private isWriteCapable;
54
+ private canStart;
55
+ private admit;
56
+ private decideMode;
57
+ private instructions;
58
+ wrap(task: TaskRecord, state: string, body: string): string;
59
+ /** Validates that `target` is a descendant of `caller` (no waiting on ancestors: no cycles). */
60
+ assertDescendant(caller: string, target: string): TaskRecord;
61
+ startTask(input: {
62
+ caller: string;
63
+ agentName: string;
64
+ title: string;
65
+ prompt: string;
66
+ taskId?: string;
67
+ model?: string;
68
+ background?: boolean;
69
+ signal?: AbortSignal;
70
+ }): Promise<{
71
+ text: string;
72
+ isError: boolean;
73
+ }>;
74
+ private execute;
75
+ private lockFor;
76
+ private summarizeWorktree;
77
+ /** Bounded synthetic completion message delivered to the parent's next turn. */
78
+ private notifyParent;
79
+ wait(caller: string, target: string, timeoutMs: number): Promise<{
80
+ text: string;
81
+ isError: boolean;
82
+ }>;
83
+ sendMessage(caller: string, target: string, text: string): string;
84
+ cancel(id: string): number;
85
+ backgroundAll(root: string): number;
86
+ observe(event: Readonly<RunEvent>): void;
87
+ nodes(root: string): PanelNode[];
88
+ refresh: () => void;
89
+ }