@alisio/plugin-subagents 0.1.0-alpha.3

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.
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Delegation policy on top of the generic `api.sessions` service: admission limits and a
3
+ * bounded queue, write isolation (worktree / serial / shared), foreground and background
4
+ * tasks, result wrapping, one-way messages, bounded waits and the agent tree model.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { join } from "node:path";
8
+ import * as gitOps from "./git.js";
9
+ export const DELEGATION_TOOLS = ["task", "task_status", "task_wait", "send_message"];
10
+ const WRITE_TOOLS = ["write_file", "edit_file"];
11
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
+ const oneLine = (text, max = 80) => {
13
+ const flat = text.replace(/\s+/g, " ").trim();
14
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
15
+ };
16
+ class Semaphore {
17
+ tail = Promise.resolve();
18
+ /** Runs `fn` after every previously queued holder finished (FIFO). */
19
+ async lock(fn) {
20
+ const previous = this.tail;
21
+ let release = () => { };
22
+ this.tail = new Promise((r) => {
23
+ release = r;
24
+ });
25
+ await previous;
26
+ try {
27
+ return await fn();
28
+ }
29
+ finally {
30
+ release();
31
+ }
32
+ }
33
+ }
34
+ export class SubagentManager {
35
+ api;
36
+ config;
37
+ env;
38
+ agents;
39
+ tasks = new Map();
40
+ running = new Map();
41
+ runningTotal = 0;
42
+ waiting = [];
43
+ modes = new Map();
44
+ asking = new Map();
45
+ writers = new Map();
46
+ locks = new Map();
47
+ constructor(api, config, env, agents) {
48
+ this.api = api;
49
+ this.config = config;
50
+ this.env = env;
51
+ this.agents = agents;
52
+ }
53
+ rootOf(session) {
54
+ return this.api.sessions.ancestors(session).at(-1) ?? session;
55
+ }
56
+ depthOf(session) {
57
+ return this.api.sessions.get(session)?.depth ?? 0;
58
+ }
59
+ /** Delegation tools granted to a child created by `caller` with this definition. */
60
+ grantsDelegation(agent, childDepth) {
61
+ if (childDepth >= this.config.maxDepth)
62
+ return false;
63
+ return !!agent.tools && (agent.tools.includes("*") || agent.tools.includes("task"));
64
+ }
65
+ isWriteCapable(agent, caller) {
66
+ if (agent.readOnly || agent.permission?.write === "deny")
67
+ return false;
68
+ const caps = this.api.sessions.capabilities(caller);
69
+ if (!caps.write && !caps.approvals)
70
+ return false;
71
+ if (!agent.tools || agent.tools.includes("*"))
72
+ return true;
73
+ return agent.tools.some((t) => WRITE_TOOLS.includes(t));
74
+ }
75
+ // ---- admission -------------------------------------------------------------------------
76
+ canStart(parent) {
77
+ return ((this.running.get(parent) ?? 0) < this.config.maxConcurrentPerParent &&
78
+ this.runningTotal < this.config.maxConcurrentTotal);
79
+ }
80
+ async admit(parent, signal) {
81
+ if (!this.canStart(parent)) {
82
+ if (this.waiting.length >= this.config.maxQueued)
83
+ throw new Error(`Subagent queue is full (${this.config.maxQueued} waiting; limits ${this.config.maxConcurrentPerParent} per parent, ${this.config.maxConcurrentTotal} total). Retry later or wait for running tasks.`);
84
+ await new Promise((resolve, reject) => {
85
+ const entry = { parent, resolve };
86
+ this.waiting.push(entry);
87
+ signal?.addEventListener("abort", () => {
88
+ this.waiting = this.waiting.filter((w) => w !== entry);
89
+ reject(signal.reason ?? new Error("Cancelled"));
90
+ }, { once: true });
91
+ });
92
+ }
93
+ this.running.set(parent, (this.running.get(parent) ?? 0) + 1);
94
+ this.runningTotal++;
95
+ let released = false;
96
+ return () => {
97
+ if (released)
98
+ return;
99
+ released = true;
100
+ this.running.set(parent, (this.running.get(parent) ?? 1) - 1);
101
+ this.runningTotal--;
102
+ const next = this.waiting.findIndex((w) => this.canStart(w.parent));
103
+ if (next >= 0)
104
+ this.waiting.splice(next, 1)[0]?.resolve();
105
+ };
106
+ }
107
+ // ---- write isolation -------------------------------------------------------------------
108
+ async decideMode(root, repo) {
109
+ const configured = this.config.parallelWrites;
110
+ if (configured !== "ask") {
111
+ if (configured === "worktree" && !repo)
112
+ return {
113
+ mode: "serial",
114
+ note: "parallelWrites=worktree needs a git repository; using serial writes.",
115
+ };
116
+ return { mode: configured };
117
+ }
118
+ const decided = this.modes.get(root);
119
+ if (decided)
120
+ return { mode: decided };
121
+ let pending = this.asking.get(root);
122
+ if (!pending) {
123
+ pending = (async () => {
124
+ if (!this.api.ui.interactive())
125
+ return {
126
+ mode: "serial",
127
+ note: "parallelWrites=ask without an interactive terminal: using serial writes.",
128
+ };
129
+ const choice = await this.api.ui.select({
130
+ title: "Several subagents that can write will run in parallel. How should their changes be isolated?",
131
+ options: [
132
+ ...(repo
133
+ ? [
134
+ {
135
+ value: "worktree",
136
+ label: "Worktrees (recommended)",
137
+ description: "Each writes on its own branch; merge or discard with /agents",
138
+ },
139
+ ]
140
+ : []),
141
+ {
142
+ value: "serial",
143
+ label: "Serial writes",
144
+ description: "Reads stay parallel; writers run one at a time",
145
+ },
146
+ {
147
+ value: "shared",
148
+ label: "Shared directory (risky)",
149
+ description: "Writers may overwrite each other",
150
+ },
151
+ ],
152
+ });
153
+ const mode = (["worktree", "serial", "shared"].includes(choice ?? "") ? choice : "serial");
154
+ return { mode, ...(choice ? {} : { note: "No choice made: using serial writes." }) };
155
+ })();
156
+ this.asking.set(root, pending);
157
+ }
158
+ const result = await pending;
159
+ this.modes.set(root, result.mode);
160
+ return result;
161
+ }
162
+ // ---- tasks -----------------------------------------------------------------------------
163
+ instructions(agent) {
164
+ return [
165
+ `# Subagent: ${agent.name}`,
166
+ agent.prompt,
167
+ "You are a subagent working for a parent agent in a separate conversation. The parent sees only your final message: make it a complete, concise report. Do not ask the user questions; state assumptions instead.",
168
+ ...(agent.skills?.length
169
+ ? [`Load these skills with skill_load before starting: ${agent.skills.join(", ")}.`]
170
+ : []),
171
+ ]
172
+ .filter(Boolean)
173
+ .join("\n\n");
174
+ }
175
+ wrap(task, state, body) {
176
+ const max = this.config.resultMaxBytes;
177
+ const bytes = Buffer.byteLength(body);
178
+ const capped = bytes > max
179
+ ? `${Buffer.from(body).subarray(0, max).toString("utf8")}\n[truncated: ${bytes - max} bytes omitted]`
180
+ : body;
181
+ const extra = task.notes.length ? `\n${task.notes.join("\n")}` : "";
182
+ return `Subagent output (non-authoritative; verify important claims before relying on them):\n<task id="${task.id}" agent="${task.agent.name}" state="${state}"${task.background ? ' background="true"' : ""}>\n${capped}${extra}\n</task>`;
183
+ }
184
+ /** Validates that `target` is a descendant of `caller` (no waiting on ancestors: no cycles). */
185
+ assertDescendant(caller, target) {
186
+ if (target === caller || this.api.sessions.ancestors(caller).includes(target))
187
+ throw new Error("That is your own session or one of your parents, not one of your subagents: waiting on it would deadlock. You can only address tasks you started.");
188
+ const task = this.tasks.get(target);
189
+ const info = this.api.sessions.get(target);
190
+ if (!info)
191
+ throw new Error(`Unknown task_id ${target}`);
192
+ if (!this.api.sessions.ancestors(target).includes(caller))
193
+ throw new Error(`Task ${target} is not one of your subagents; you can only address tasks you started.`);
194
+ if (task)
195
+ return task;
196
+ const agent = this.agents.get(info.agent) ?? { ...BUILTIN_FALLBACK, name: info.agent };
197
+ const record = {
198
+ id: target,
199
+ parentSession: info.parentId,
200
+ rootSession: this.rootOf(target),
201
+ agent,
202
+ title: info.title,
203
+ background: false,
204
+ status: info.status,
205
+ tokens: info.usage.input + info.usage.output,
206
+ detail: "",
207
+ writeCapable: false,
208
+ notes: [],
209
+ };
210
+ this.tasks.set(target, record);
211
+ return record;
212
+ }
213
+ async startTask(input) {
214
+ if (input.taskId) {
215
+ const task = this.assertDescendant(input.caller, input.taskId);
216
+ if (this.api.sessions.isRunning(task.id))
217
+ return {
218
+ text: this.wrap(task, "running", "Task is already running; use send_message or task_wait."),
219
+ isError: true,
220
+ };
221
+ task.background = !!input.background;
222
+ return this.execute(task, input.prompt, input.background ? undefined : input.signal);
223
+ }
224
+ const agent = this.agents.get(input.agentName);
225
+ if (!agent || agent.mode === "primary")
226
+ throw new Error(`Unknown subagent_type "${input.agentName}". Available: ${[...this.agents.values()]
227
+ .filter((a) => a.mode !== "primary" && !a.hidden)
228
+ .map((a) => a.name)
229
+ .join(", ")}`);
230
+ const childDepth = this.depthOf(input.caller) + 1;
231
+ if (childDepth > this.config.maxDepth)
232
+ throw new Error(`Maximum subagent depth ${this.config.maxDepth} reached`);
233
+ const root = this.rootOf(input.caller);
234
+ const writeCapable = this.isWriteCapable(agent, input.caller);
235
+ const notes = [];
236
+ let mode;
237
+ let worktree;
238
+ const id = randomUUID();
239
+ if (writeCapable) {
240
+ // Gather writers spawned in the same turn before deciding whether isolation is needed.
241
+ const group = this.writers.get(root) ?? { pending: 0, active: 0 };
242
+ this.writers.set(root, group);
243
+ group.pending++;
244
+ await sleep(40);
245
+ const concurrent = group.pending + group.active >= 2;
246
+ group.pending--;
247
+ group.active++;
248
+ const workspace = this.api.sessions.workspace(input.caller);
249
+ const repo = await gitOps.repoRoot(workspace);
250
+ const decided = this.config.parallelWrites === "ask" ? this.modes.get(root) : this.config.parallelWrites;
251
+ if (concurrent || decided) {
252
+ const choice = await this.decideMode(root, repo);
253
+ mode = choice.mode;
254
+ if (choice.note)
255
+ notes.push(`Note: ${choice.note}`);
256
+ }
257
+ if (mode === "worktree" && repo && concurrent) {
258
+ if (await gitOps.isDirty(repo))
259
+ notes.push("Warning: the main working tree has uncommitted changes that this worktree does not include.");
260
+ const path = join(this.config.worktreeDir ?? join(this.env.stateHome, "worktrees"), id);
261
+ const branch = `alisio/${id}`;
262
+ const base = await gitOps.addWorktree(repo, path, branch);
263
+ worktree = { path, branch, base, root: repo };
264
+ }
265
+ }
266
+ const grants = this.grantsDelegation(agent, childDepth);
267
+ const deny = [...(agent.disallowedTools ?? []), ...(grants ? [] : DELEGATION_TOOLS)];
268
+ const info = await this.api.sessions.create({
269
+ parentId: input.caller,
270
+ id,
271
+ title: input.title,
272
+ agent: agent.name,
273
+ instructions: this.instructions(agent),
274
+ tools: {
275
+ ...(agent.tools && !agent.tools.includes("*")
276
+ ? { allow: [...agent.tools, ...(grants ? DELEGATION_TOOLS : [])] }
277
+ : {}),
278
+ deny,
279
+ },
280
+ ...((input.model ?? agent.model) ? { model: input.model ?? agent.model } : {}),
281
+ ...(agent.readOnly ? { readOnly: true } : {}),
282
+ ...(agent.permission ? { permission: agent.permission } : {}),
283
+ ...(worktree ? { workspace: worktree.path } : {}),
284
+ maxTurns: agent.maxTurns ?? this.config.maxTurns,
285
+ timeoutMs: this.config.timeoutMs,
286
+ ...(this.config.maxTokensPerChild ? { maxTokens: this.config.maxTokensPerChild } : {}),
287
+ });
288
+ const task = {
289
+ id: info.id,
290
+ parentSession: input.caller,
291
+ rootSession: root,
292
+ agent,
293
+ title: input.title,
294
+ background: !!(input.background ?? agent.background),
295
+ status: "queued",
296
+ tokens: 0,
297
+ detail: "queued",
298
+ writeCapable,
299
+ notes,
300
+ ...(worktree ? { worktree } : {}),
301
+ };
302
+ this.tasks.set(task.id, task);
303
+ const releaseWriter = () => {
304
+ const group = this.writers.get(root);
305
+ if (group && writeCapable)
306
+ group.active = Math.max(0, group.active - 1);
307
+ };
308
+ const lock = writeCapable && mode !== "worktree" && mode !== "shared";
309
+ return this.execute(task, input.prompt, task.background ? undefined : input.signal, {
310
+ lock,
311
+ onFinish: releaseWriter,
312
+ });
313
+ }
314
+ execute(task, prompt, signal, options = {}) {
315
+ let detached = false;
316
+ let detach = () => { };
317
+ const detachedResult = new Promise((resolve) => {
318
+ detach = () => {
319
+ detached = true;
320
+ task.background = true;
321
+ resolve({
322
+ text: this.wrap(task, "running", "Moved to the background by the user. You will receive a <task-notification> when it finishes; do not poll."),
323
+ isError: false,
324
+ });
325
+ };
326
+ });
327
+ task.detach = detach;
328
+ const work = (async () => {
329
+ let release;
330
+ try {
331
+ release = await this.admit(task.parentSession, signal);
332
+ const run = async () => {
333
+ task.status = "running";
334
+ task.startedAt = Date.now();
335
+ task.detail = "starting";
336
+ this.refresh();
337
+ return this.api.sessions.run(task.id, prompt, signal ? { signal } : {});
338
+ };
339
+ const result = options.lock ? await this.lockFor(task.rootSession).lock(run) : await run();
340
+ task.status = result.status;
341
+ task.tokens = Math.max(task.tokens, result.usage.input + result.usage.output);
342
+ if (task.worktree && result.status === "completed")
343
+ await this.summarizeWorktree(task);
344
+ task.result =
345
+ result.status === "completed"
346
+ ? this.wrap(task, "completed", result.text || "(no final message)")
347
+ : this.wrap(task, result.status, `Error: ${result.error ?? result.status}. Resume with task_id="${task.id}".`);
348
+ task.isError = result.status !== "completed";
349
+ }
350
+ catch (error) {
351
+ task.status = signal?.aborted ? "cancelled" : "failed";
352
+ this.api.sessions.setStatus(task.id, task.status);
353
+ task.result = this.wrap(task, task.status, `Error: ${error instanceof Error ? error.message : String(error)}. Resume with task_id="${task.id}".`);
354
+ task.isError = true;
355
+ }
356
+ finally {
357
+ release?.();
358
+ options.onFinish?.();
359
+ task.endedAt = Date.now();
360
+ task.detach = undefined;
361
+ this.refresh();
362
+ }
363
+ if (task.background || detached)
364
+ this.notifyParent(task);
365
+ })();
366
+ task.done = work.then(() => { });
367
+ this.refresh();
368
+ if (task.background)
369
+ return Promise.resolve({
370
+ text: this.wrap(task, "running", "Started in the background. You will receive a <task-notification> when it finishes; do not poll. Use task_status or task_wait only if you truly need to block."),
371
+ isError: false,
372
+ });
373
+ return Promise.race([
374
+ work.then(() => ({ text: task.result ?? "", isError: !!task.isError })),
375
+ detachedResult,
376
+ ]);
377
+ }
378
+ lockFor(root) {
379
+ let lock = this.locks.get(root);
380
+ if (!lock) {
381
+ lock = new Semaphore();
382
+ this.locks.set(root, lock);
383
+ }
384
+ return lock;
385
+ }
386
+ async summarizeWorktree(task) {
387
+ const wt = task.worktree;
388
+ if (!wt)
389
+ return;
390
+ try {
391
+ const committed = await gitOps.commitAll(wt.path, `alisio task ${task.id}: ${task.title}`);
392
+ if (!committed) {
393
+ await gitOps.removeWorktree(wt.root, wt.path, wt.branch, true);
394
+ task.worktree = undefined;
395
+ task.notes.push(`No changes were made; worktree and branch ${wt.branch} were removed.`);
396
+ return;
397
+ }
398
+ const diff = await gitOps.diffSummary(wt.root, wt.base, wt.branch);
399
+ task.notes.push(`Branch: ${wt.branch} (worktree ${wt.path})`, `Changed files: ${diff.files.join(", ") || "(none)"}`, `Diffstat:\n${diff.stat}`, `The user can merge it with /agents merge ${task.id} or drop it with /agents discard ${task.id}.`);
400
+ }
401
+ catch (error) {
402
+ task.notes.push(`Worktree summary failed: ${String(error)}`);
403
+ }
404
+ }
405
+ /** Bounded synthetic completion message delivered to the parent's next turn. */
406
+ notifyParent(task) {
407
+ const body = (task.result ?? "").slice(0, this.config.resultMaxBytes + 2_000);
408
+ this.api.sessions.enqueue(task.parentSession, `<task-notification id="${task.id}" agent="${task.agent.name}" state="${task.status}">\n${body}\n</task-notification>`);
409
+ }
410
+ async wait(caller, target, timeoutMs) {
411
+ const task = this.assertDescendant(caller, target);
412
+ const bounded = Math.max(0, Math.min(timeoutMs, this.config.waitMaxMs));
413
+ if (task.done && !task.result)
414
+ await Promise.race([task.done, sleep(bounded)]);
415
+ if (task.result)
416
+ return { text: task.result, isError: !!task.isError };
417
+ const info = this.api.sessions.get(target);
418
+ return {
419
+ text: this.wrap(task, info?.status ?? task.status, `Still ${info?.status ?? task.status} after ${bounded}ms.`),
420
+ isError: false,
421
+ };
422
+ }
423
+ sendMessage(caller, target, text) {
424
+ const task = this.assertDescendant(caller, target);
425
+ if (this.api.sessions.isRunning(target)) {
426
+ this.api.sessions.enqueue(target, `Message from the parent agent:\n${text}`);
427
+ return `Queued for task ${target}'s next turn.`;
428
+ }
429
+ task.background = true;
430
+ void this.execute(task, `Message from the parent agent:\n${text}`, undefined);
431
+ return `Task ${target} resumed in the background with your message; a <task-notification> will arrive when it finishes.`;
432
+ }
433
+ cancel(id) {
434
+ const n = this.api.sessions.cancel(id);
435
+ const task = this.tasks.get(id);
436
+ if (task && task.status === "queued")
437
+ task.status = "cancelled";
438
+ this.refresh();
439
+ return n;
440
+ }
441
+ backgroundAll(root) {
442
+ let n = 0;
443
+ for (const task of this.tasks.values())
444
+ if (task.rootSession === root &&
445
+ !task.background &&
446
+ task.detach &&
447
+ task.status === "running") {
448
+ task.detach();
449
+ n++;
450
+ }
451
+ return n;
452
+ }
453
+ // ---- live tree ---------------------------------------------------------------------------
454
+ observe(event) {
455
+ const task = this.tasks.get(event.sessionId);
456
+ if (!task)
457
+ return;
458
+ const d = (event.data ?? {});
459
+ if (event.type === "tool_started")
460
+ task.detail = oneLine(`${String(d.name)} ${summarizeArgs(String(d.arguments ?? ""))}`);
461
+ else if (event.type === "text_delta") {
462
+ task.snippet = `${task.snippet ?? ""}${String(d.delta)}`.slice(-160);
463
+ task.detail = `✎ ${oneLine(task.snippet, 78)}`;
464
+ }
465
+ else if (event.type === "tool_completed" || event.type === "turn_completed")
466
+ task.snippet = "";
467
+ if (event.type === "turn_completed") {
468
+ const usage = d.usage;
469
+ if (usage)
470
+ task.tokens += (usage.input ?? 0) + (usage.output ?? 0);
471
+ }
472
+ }
473
+ nodes(root) {
474
+ const out = [];
475
+ const visit = (parent) => {
476
+ for (const task of [...this.tasks.values()]
477
+ .filter((t) => t.parentSession === parent)
478
+ .sort((a, b) => (a.startedAt ?? Number.MAX_SAFE_INTEGER) - (b.startedAt ?? Number.MAX_SAFE_INTEGER))) {
479
+ out.push({
480
+ id: task.id,
481
+ ...(parent !== root ? { parentId: parent } : {}),
482
+ label: task.agent.name,
483
+ ...(task.agent.color ? { color: task.agent.color } : {}),
484
+ status: task.status,
485
+ ...(task.startedAt ? { startedAt: task.startedAt } : {}),
486
+ ...(task.endedAt ? { endedAt: task.endedAt } : {}),
487
+ tokens: task.tokens,
488
+ detail: task.detail || task.title,
489
+ sessionId: task.id,
490
+ });
491
+ visit(task.id);
492
+ }
493
+ };
494
+ visit(root);
495
+ return out;
496
+ }
497
+ refresh = () => { };
498
+ }
499
+ const BUILTIN_FALLBACK = {
500
+ name: "agent",
501
+ description: "",
502
+ prompt: "",
503
+ mode: "subagent",
504
+ hidden: false,
505
+ background: false,
506
+ source: "session",
507
+ };
508
+ function summarizeArgs(args) {
509
+ try {
510
+ const value = JSON.parse(args);
511
+ for (const key of ["path", "command", "pattern", "query", "description", "name"])
512
+ if (typeof value[key] === "string")
513
+ return String(value[key]);
514
+ return "";
515
+ }
516
+ catch {
517
+ return "";
518
+ }
519
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@alisio/plugin-subagents",
3
+ "version": "0.1.0-alpha.3",
4
+ "description": "Subagents for Alisio: delegate tasks to specialized agents in separate child sessions with narrowed permissions, parallel execution, git worktree isolation and a live agent tree. Built-in, disableable plugin.",
5
+ "author": "Gustavo Gutiérrez",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/GustavoGutierrez/alisio.git",
10
+ "directory": "packages/plugin-subagents"
11
+ },
12
+ "homepage": "https://gustavogutierrez.github.io/alisio/",
13
+ "bugs": {
14
+ "url": "https://github.com/GustavoGutierrez/alisio/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "coding-agent",
19
+ "llm",
20
+ "agent-harness",
21
+ "openai-compatible",
22
+ "alisio-plugin",
23
+ "plugins",
24
+ "subagents",
25
+ "multi-agent"
26
+ ],
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "sideEffects": false,
41
+ "engines": {
42
+ "node": ">=22.16"
43
+ },
44
+ "dependencies": {
45
+ "yaml": "2.9.1",
46
+ "zod": "4.6.5"
47
+ },
48
+ "peerDependencies": {
49
+ "@alisio/sdk": "^0.1.0-alpha.2"
50
+ },
51
+ "devDependencies": {
52
+ "@alisio/sdk": "0.1.0-alpha.2"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public",
56
+ "registry": "https://registry.npmjs.com/"
57
+ },
58
+ "scripts": {
59
+ "build": "tsc -p tsconfig.build.json"
60
+ }
61
+ }