pi-fast-subagent 0.2.0 → 0.4.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/README.md CHANGED
@@ -8,6 +8,7 @@ Runs subagents with `createAgentSession()` in same process instead of spawning `
8
8
 
9
9
  - Single mode: `{ agent, task }`
10
10
  - Parallel mode: `{ tasks: [...] }`
11
+ - Background mode: `{ agent, task, background: true }` — fire-and-forget with poll/cancel
11
12
  - Per-call model override
12
13
  - User + project agent discovery
13
14
  - Project agents override user agents
@@ -59,6 +60,24 @@ model: anthropic/claude-haiku-4-5
59
60
  You are code exploration specialist. Read relevant files, trace data flow, summarize findings clearly.
60
61
  ```
61
62
 
63
+ ## Slash Commands
64
+
65
+ ### `/agent`
66
+
67
+ List all available agents:
68
+
69
+ ```
70
+ /agent
71
+ ```
72
+
73
+ Show details for a specific agent (description, file path, model, tools, system prompt):
74
+
75
+ ```
76
+ /agent scout
77
+ ```
78
+
79
+ Tab-completion is supported for agent names.
80
+
62
81
  ## Usage
63
82
 
64
83
  ### List agents
@@ -0,0 +1,178 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ BackgroundHandleLike,
4
+ BackgroundJobManagerOptions,
5
+ BackgroundJobResult,
6
+ BackgroundSubagentJob,
7
+ } from "./background-types.js";
8
+
9
+ export type { BackgroundSubagentJob } from "./background-types.js";
10
+
11
+ export class BackgroundJobManager {
12
+ private jobs = new Map<string, BackgroundSubagentJob>();
13
+ private evictionTimers = new Map<string, ReturnType<typeof setTimeout>>();
14
+ private maxRunning: number;
15
+ private maxTotal: number;
16
+ private evictionMs: number;
17
+ private onJobComplete?: (job: BackgroundSubagentJob) => void;
18
+ private isShutdown = false;
19
+
20
+ constructor(options: BackgroundJobManagerOptions = {}) {
21
+ this.maxRunning = options.maxRunning ?? 10;
22
+ this.maxTotal = options.maxTotal ?? 50;
23
+ this.evictionMs = options.evictionMs ?? 5 * 60 * 1000;
24
+ this.onJobComplete = options.onJobComplete;
25
+ }
26
+
27
+ adoptHandle(
28
+ agentName: string,
29
+ task: string,
30
+ cwd: string,
31
+ handle: BackgroundHandleLike,
32
+ resultPromise: Promise<BackgroundJobResult>,
33
+ ): string {
34
+ handle.detach?.();
35
+
36
+ const abortController = new AbortController();
37
+ const onAbort = () => handle.abort();
38
+ abortController.signal.addEventListener("abort", onAbort, { once: true });
39
+
40
+ return this.attachJob(agentName, task, cwd, abortController, resultPromise.finally(() => {
41
+ abortController.signal.removeEventListener("abort", onAbort);
42
+ }));
43
+ }
44
+
45
+ cancel(id: string): "cancelled" | "not_found" | "already_done" {
46
+ const job = this.jobs.get(id);
47
+ if (!job) return "not_found";
48
+ if (job.status !== "running") return "already_done";
49
+
50
+ job.status = "cancelled";
51
+ job.completedAt = Date.now();
52
+ job.abortController.abort();
53
+ this.scheduleEviction(id);
54
+ return "cancelled";
55
+ }
56
+
57
+ getJob(id: string): BackgroundSubagentJob | undefined {
58
+ return this.jobs.get(id);
59
+ }
60
+
61
+ getRunningJobs(): BackgroundSubagentJob[] {
62
+ return [...this.jobs.values()].filter((job) => job.status === "running");
63
+ }
64
+
65
+ getAllJobs(): BackgroundSubagentJob[] {
66
+ return [...this.jobs.values()];
67
+ }
68
+
69
+ shutdown(): void {
70
+ this.isShutdown = true;
71
+ for (const timer of this.evictionTimers.values()) {
72
+ clearTimeout(timer);
73
+ }
74
+ this.evictionTimers.clear();
75
+
76
+ for (const job of this.jobs.values()) {
77
+ if (job.status === "running") {
78
+ job.status = "cancelled";
79
+ job.completedAt = Date.now();
80
+ job.abortController.abort();
81
+ }
82
+ }
83
+ }
84
+
85
+ private attachJob(
86
+ agentName: string,
87
+ task: string,
88
+ cwd: string,
89
+ abortController: AbortController,
90
+ resultPromise: Promise<BackgroundJobResult>,
91
+ ): string {
92
+ if (this.getRunningJobs().length >= this.maxRunning) {
93
+ throw new Error(`Maximum concurrent background subagents reached (${this.maxRunning}).`);
94
+ }
95
+
96
+ if (this.jobs.size >= this.maxTotal) {
97
+ this.evictOldestCompleted();
98
+ if (this.jobs.size >= this.maxTotal) {
99
+ throw new Error(`Maximum total background subagent jobs reached (${this.maxTotal}).`);
100
+ }
101
+ }
102
+
103
+ const id = `sa_${randomUUID().slice(0, 8)}`;
104
+ const job: BackgroundSubagentJob = {
105
+ id,
106
+ agentName,
107
+ task,
108
+ cwd,
109
+ status: "running",
110
+ startedAt: Date.now(),
111
+ abortController,
112
+ promise: undefined as unknown as Promise<void>,
113
+ };
114
+
115
+ job.promise = resultPromise
116
+ .then((result) => {
117
+ if (job.status === "cancelled") {
118
+ this.scheduleEviction(id);
119
+ return;
120
+ }
121
+ job.status = result.exitCode === 0 ? "completed" : "failed";
122
+ job.completedAt = Date.now();
123
+ job.exitCode = result.exitCode;
124
+ job.resultSummary = result.summary;
125
+ job.error = result.error;
126
+ job.model = result.model;
127
+ this.scheduleEviction(id);
128
+ this.deliverResult(job);
129
+ })
130
+ .catch((error) => {
131
+ if (job.status === "cancelled") {
132
+ this.scheduleEviction(id);
133
+ return;
134
+ }
135
+ job.status = "failed";
136
+ job.completedAt = Date.now();
137
+ job.exitCode = 1;
138
+ job.error = error instanceof Error ? error.message : String(error);
139
+ this.scheduleEviction(id);
140
+ this.deliverResult(job);
141
+ });
142
+
143
+ this.jobs.set(id, job);
144
+ return id;
145
+ }
146
+
147
+ private deliverResult(job: BackgroundSubagentJob): void {
148
+ if (!this.onJobComplete) return;
149
+ queueMicrotask(() => this.onJobComplete?.(job));
150
+ }
151
+
152
+ private scheduleEviction(id: string): void {
153
+ if (this.isShutdown) return;
154
+ const existing = this.evictionTimers.get(id);
155
+ if (existing) clearTimeout(existing);
156
+
157
+ const timer = setTimeout(() => {
158
+ this.evictionTimers.delete(id);
159
+ this.jobs.delete(id);
160
+ }, this.evictionMs);
161
+
162
+ this.evictionTimers.set(id, timer);
163
+ }
164
+
165
+ private evictOldestCompleted(): void {
166
+ let oldest: BackgroundSubagentJob | undefined;
167
+ for (const job of this.jobs.values()) {
168
+ if (job.status === "running") continue;
169
+ if (!oldest || job.startedAt < oldest.startedAt) oldest = job;
170
+ }
171
+ if (!oldest) return;
172
+
173
+ const timer = this.evictionTimers.get(oldest.id);
174
+ if (timer) clearTimeout(timer);
175
+ this.evictionTimers.delete(oldest.id);
176
+ this.jobs.delete(oldest.id);
177
+ }
178
+ }
@@ -0,0 +1,36 @@
1
+ export type BackgroundJobStatus = "running" | "completed" | "failed" | "cancelled";
2
+
3
+ export interface BackgroundSubagentJob {
4
+ id: string;
5
+ agentName: string;
6
+ task: string;
7
+ cwd: string;
8
+ status: BackgroundJobStatus;
9
+ startedAt: number;
10
+ completedAt?: number;
11
+ exitCode?: number;
12
+ resultSummary?: string;
13
+ error?: string;
14
+ model?: string;
15
+ abortController: AbortController;
16
+ promise: Promise<void>;
17
+ }
18
+
19
+ export interface BackgroundJobManagerOptions {
20
+ maxRunning?: number;
21
+ maxTotal?: number;
22
+ evictionMs?: number;
23
+ onJobComplete?: (job: BackgroundSubagentJob) => void;
24
+ }
25
+
26
+ export interface BackgroundJobResult {
27
+ summary: string;
28
+ exitCode: number;
29
+ error?: string;
30
+ model?: string;
31
+ }
32
+
33
+ export interface BackgroundHandleLike {
34
+ abort: () => void;
35
+ detach?: () => void;
36
+ }
package/index.ts CHANGED
@@ -15,6 +15,8 @@ import type {
15
15
  ExtensionContext,
16
16
  ToolRenderResultOptions,
17
17
  } from "@mariozechner/pi-coding-agent";
18
+ import { BackgroundJobManager } from "./background-job-manager.js";
19
+ import type { BackgroundHandleLike, BackgroundJobResult } from "./background-types.js";
18
20
  import { Theme } from "@mariozechner/pi-coding-agent";
19
21
  import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
20
22
  import { truncateToVisualLines, keyHint } from "@mariozechner/pi-coding-agent";
@@ -94,6 +96,7 @@ function summarizeToolArgs(toolName: unknown, toolInput: unknown): string {
94
96
 
95
97
  let _authStorage: ReturnType<typeof AuthStorage.create> | null = null;
96
98
  let _modelRegistry: ReturnType<typeof ModelRegistry.create> | null = null;
99
+ let _bgManager: BackgroundJobManager | null = null;
97
100
 
98
101
  function getAuth() {
99
102
  if (!_authStorage) _authStorage = AuthStorage.create();
@@ -101,6 +104,11 @@ function getAuth() {
101
104
  return { authStorage: _authStorage, modelRegistry: _modelRegistry };
102
105
  }
103
106
 
107
+ function getBgManager(): BackgroundJobManager {
108
+ if (!_bgManager) _bgManager = new BackgroundJobManager();
109
+ return _bgManager;
110
+ }
111
+
104
112
  // ─── In-process runner ───────────────────────────────────────────────────────
105
113
 
106
114
  const MAX_DEPTH = 2;
@@ -231,7 +239,10 @@ async function runAgent(
231
239
  const toolCalls: ToolCallEntry[] = [];
232
240
  const toolStartTimes = new Map<string, number>();
233
241
 
242
+ let done = false;
243
+
234
244
  function emitUpdate(): void {
245
+ if (done) return;
235
246
  onUpdate?.({
236
247
  content: [{ type: "text", text: currentDelta || lastOutput || "" }],
237
248
  details: {
@@ -354,6 +365,7 @@ async function runAgent(
354
365
  exitCode = 1;
355
366
  error = signal?.aborted ? "Aborted" : e instanceof Error ? e.message : String(e);
356
367
  } finally {
368
+ done = true;
357
369
  clearInterval(heartbeat);
358
370
  unsubscribe();
359
371
  session.dispose();
@@ -436,14 +448,21 @@ const SubagentParams = Type.Object({
436
448
  Type.Number({ description: "Max parallel concurrency (default: 4)", default: 4 }),
437
449
  ),
438
450
 
451
+ // Background
452
+ background: Type.Optional(Type.Boolean({ description: "Run in background, returns job ID immediately" })),
453
+ jobId: Type.Optional(Type.String({ description: "Job ID for poll/cancel" })),
454
+
439
455
  // Management
440
456
  action: Type.Optional(
441
457
  Type.Union(
442
458
  [
443
459
  Type.Literal("list"),
444
460
  Type.Literal("get"),
461
+ Type.Literal("status"),
462
+ Type.Literal("poll"),
463
+ Type.Literal("cancel"),
445
464
  ],
446
- { description: "'list' to discover agents, 'get' to inspect one agent" },
465
+ { description: "'list'/'get' for agents, 'status' for bg jobs, 'poll'/'cancel' for a specific job" },
447
466
  ),
448
467
  ),
449
468
  agentScope: Type.Optional(
@@ -457,6 +476,72 @@ const SubagentParams = Type.Object({
457
476
  // ─── Extension entry point ────────────────────────────────────────────────────
458
477
 
459
478
  export default function (pi: ExtensionAPI) {
479
+ // ─── /agent slash command ─────────────────────────────────────────────────
480
+ pi.registerCommand("agent", {
481
+ description: "List available subagents. Usage: /agent [name] — show details for a specific agent.",
482
+ getArgumentCompletions(prefix: string) {
483
+ const agents = discoverAgents(process.cwd());
484
+ return agents
485
+ .filter((a) => a.name.startsWith(prefix))
486
+ .map((a) => ({ value: a.name, label: a.name, description: a.description }));
487
+ },
488
+ async handler(args: string, ctx) {
489
+ const agents = discoverAgents(ctx.cwd);
490
+ const name = args.trim();
491
+
492
+ if (name) {
493
+ const agent = agents.find((a) => a.name === name);
494
+ if (!agent) {
495
+ const list = agents.map((a) => a.name).join(", ") || "none";
496
+ ctx.ui.notify(`Unknown agent "${name}". Available: ${list}`, "warning");
497
+ return;
498
+ }
499
+ const lines = [
500
+ `## ${agent.name} [${agent.source}]`,
501
+ `File: ${agent.filePath}`,
502
+ `Description: ${agent.description}`,
503
+ agent.model ? `Model: ${agent.model}` : "",
504
+ agent.tools ? `Tools: ${agent.tools.join(", ")}` : "",
505
+ agent.systemPrompt ? `\nSystem prompt:\n${agent.systemPrompt}` : "",
506
+ ].filter(Boolean).join("\n");
507
+ ctx.ui.notify(lines, "info");
508
+ return;
509
+ }
510
+
511
+ if (agents.length === 0) {
512
+ ctx.ui.notify(
513
+ "No agents found.\n" +
514
+ "Add .md files to:\n" +
515
+ " ~/.pi/agent/agents/ (user-level)\n" +
516
+ " .pi/agents/ (project-level)\n" +
517
+ "\nFrontmatter required: name, description. Optional: model, tools.",
518
+ "info"
519
+ );
520
+ return;
521
+ }
522
+
523
+ const userAgents = agents.filter((a) => a.source === "user");
524
+ const projectAgents = agents.filter((a) => a.source === "project");
525
+
526
+ const lines: string[] = [`Agents (${agents.length}):`];
527
+ if (projectAgents.length) {
528
+ lines.push("\nProject (.pi/agents/):");
529
+ for (const a of projectAgents) {
530
+ lines.push(` ${a.name}${a.model ? ` [${a.model}]` : ""} — ${a.description}`);
531
+ }
532
+ }
533
+ if (userAgents.length) {
534
+ lines.push("\nUser (~/.pi/agent/agents/):");
535
+ for (const a of userAgents) {
536
+ lines.push(` ${a.name}${a.model ? ` [${a.model}]` : ""} — ${a.description}`);
537
+ }
538
+ }
539
+ lines.push("");
540
+ lines.push("Tip: /agent <name> for details · Add .md files to .pi/agents/ to create new agents");
541
+ ctx.ui.notify(lines.join("\n"), "info");
542
+ },
543
+ });
544
+
460
545
  pi.registerTool({
461
546
  name: "subagent",
462
547
  label: "Subagent",
@@ -655,7 +740,7 @@ export default function (pi: ExtensionAPI) {
655
740
  };
656
741
 
657
742
  // ── Management: list ──────────────────────────────────────────────────────
658
- if (params.action === "list" || (!params.agent && !params.tasks)) {
743
+ if (params.action === "list" || (!params.action && !params.agent && !params.tasks)) {
659
744
  if (agents.length === 0) {
660
745
  return {
661
746
  content: [{
@@ -684,11 +769,56 @@ export default function (pi: ExtensionAPI) {
684
769
  return { content: [{ type: "text", text: info }] };
685
770
  }
686
771
 
772
+ // ── Background status ───────────────────────────────────────────────────
773
+ if (params.action === "status") {
774
+ const jobs = getBgManager().getAllJobs();
775
+ if (jobs.length === 0) return { content: [{ type: "text", text: "No background jobs." }] };
776
+ const lines = jobs.map((j) => {
777
+ const dur = j.completedAt ? formatDuration(j.completedAt - j.startedAt) : formatDuration(Date.now() - j.startedAt);
778
+ return `${j.id} [${j.status}] ${j.agentName} · ${dur} · ${j.task.length > 50 ? j.task.slice(0, 47) + "..." : j.task}`;
779
+ });
780
+ return { content: [{ type: "text", text: lines.join("\n") }] };
781
+ }
782
+
783
+ // ── Background poll ────────────────────────────────────────────────────────
784
+ if (params.action === "poll") {
785
+ if (!params.jobId) return { content: [{ type: "text", text: "Provide jobId to poll." }] };
786
+ const job = getBgManager().getJob(params.jobId);
787
+ if (!job) return { content: [{ type: "text", text: `Job ${params.jobId} not found (completed and evicted, or invalid).` }] };
788
+ const dur = job.completedAt ? formatDuration(job.completedAt - job.startedAt) : formatDuration(Date.now() - job.startedAt);
789
+ const parts = [`${job.id} [${job.status}] ${job.agentName} · ${dur}`, `Task: ${job.task}`];
790
+ if (job.status === "completed") parts.push(`\nResult:\n${job.resultSummary ?? "(no output)"}`);
791
+ if (job.status === "failed") parts.push(`\nError: ${job.error ?? "(unknown)"}`);
792
+ if (job.status === "running") parts.push("Still running — poll again later.");
793
+ return { content: [{ type: "text", text: parts.join("\n") }] };
794
+ }
795
+
796
+ // ── Background cancel ──────────────────────────────────────────────────────
797
+ if (params.action === "cancel") {
798
+ if (!params.jobId) return { content: [{ type: "text", text: "Provide jobId to cancel." }] };
799
+ const result = getBgManager().cancel(params.jobId);
800
+ const msg = result === "cancelled" ? `Job ${params.jobId} cancelled.`
801
+ : result === "already_done" ? `Job ${params.jobId} already completed.`
802
+ : `Job ${params.jobId} not found.`;
803
+ return { content: [{ type: "text", text: msg }] };
804
+ }
805
+
687
806
  // ── Single mode ───────────────────────────────────────────────────────────
688
807
  if (params.agent && params.task) {
689
808
  const { agent, error } = findAgent(params.agent);
690
809
  if (error || !agent) return { content: [{ type: "text", text: error ?? "Not found" }] };
691
810
 
811
+ // Background dispatch — fire and forget
812
+ if (params.background) {
813
+ const bgAbort = new AbortController();
814
+ const handle: BackgroundHandleLike = { abort: () => bgAbort.abort() };
815
+ const resultPromise: Promise<BackgroundJobResult> = runAgent(
816
+ agent, params.task, cwd, params.model, bgAbort.signal, undefined
817
+ ).then((r) => ({ summary: r.output, exitCode: r.exitCode, error: r.error, model: r.model }));
818
+ const jobId = getBgManager().adoptHandle(agent.name, params.task, cwd, handle, resultPromise);
819
+ return { content: [{ type: "text", text: `Background job started: ${jobId}\nCheck progress: subagent({ action: "poll", jobId: "${jobId}" })` }] };
820
+ }
821
+
692
822
  const result = await runAgent(
693
823
  agent,
694
824
  params.task,
package/package.json CHANGED
@@ -1,14 +1,27 @@
1
1
  {
2
2
  "name": "pi-fast-subagent",
3
- "version": "0.2.0",
4
- "description": "In-process subagent delegation for pi with single, parallel, and chain modes",
3
+ "version": "0.4.0",
4
+ "description": "In-process subagent delegation for pi with single, parallel, and background modes",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi",
9
+ "pi-extension",
9
10
  "subagent",
10
11
  "agents",
11
- "extension"
12
+ "extension",
13
+ "delegation",
14
+ "parallel",
15
+ "concurrent",
16
+ "in-process",
17
+ "fast",
18
+ "coding-agent",
19
+ "ai-agent",
20
+ "llm",
21
+ "task-runner",
22
+ "scout",
23
+ "workflow",
24
+ "automation"
12
25
  ],
13
26
  "homepage": "https://github.com/tuansondinh/pi-fast-subagent#readme",
14
27
  "repository": {
@@ -21,6 +34,8 @@
21
34
  "files": [
22
35
  "index.ts",
23
36
  "agents.ts",
37
+ "background-job-manager.ts",
38
+ "background-types.ts",
24
39
  "agents/*.md",
25
40
  "README.md"
26
41
  ],