pi-fast-subagent 0.3.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
@@ -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(
@@ -721,7 +740,7 @@ export default function (pi: ExtensionAPI) {
721
740
  };
722
741
 
723
742
  // โ”€โ”€ Management: list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
724
- if (params.action === "list" || (!params.agent && !params.tasks)) {
743
+ if (params.action === "list" || (!params.action && !params.agent && !params.tasks)) {
725
744
  if (agents.length === 0) {
726
745
  return {
727
746
  content: [{
@@ -750,11 +769,56 @@ export default function (pi: ExtensionAPI) {
750
769
  return { content: [{ type: "text", text: info }] };
751
770
  }
752
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
+
753
806
  // โ”€โ”€ Single mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
754
807
  if (params.agent && params.task) {
755
808
  const { agent, error } = findAgent(params.agent);
756
809
  if (error || !agent) return { content: [{ type: "text", text: error ?? "Not found" }] };
757
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
+
758
822
  const result = await runAgent(
759
823
  agent,
760
824
  params.task,
package/package.json CHANGED
@@ -1,14 +1,27 @@
1
1
  {
2
2
  "name": "pi-fast-subagent",
3
- "version": "0.3.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
  ],