pi-fast-subagent 0.3.0 → 0.5.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,8 @@ 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
12
+ - Slash commands for background job status + cancellation via selector UI
11
13
  - Per-call model override
12
14
  - User + project agent discovery
13
15
  - Project agents override user agents
@@ -61,22 +63,64 @@ You are code exploration specialist. Read relevant files, trace data flow, summa
61
63
 
62
64
  ## Slash Commands
63
65
 
64
- ### `/agent`
66
+ ### `/fast-subagent:agent`
65
67
 
66
68
  List all available agents:
67
69
 
68
70
  ```
69
- /agent
71
+ /fast-subagent:agent
70
72
  ```
71
73
 
72
74
  Show details for a specific agent (description, file path, model, tools, system prompt):
73
75
 
74
76
  ```
75
- /agent scout
77
+ /fast-subagent:agent scout
76
78
  ```
77
79
 
78
80
  Tab-completion is supported for agent names.
79
81
 
82
+ ### `/fast-subagent:bg`
83
+
84
+ Detach running foreground subagent to background:
85
+
86
+ ```
87
+ /fast-subagent:bg fg_ab12cd34
88
+ ```
89
+
90
+ Omit job id to list active foreground jobs:
91
+
92
+ ```
93
+ /fast-subagent:bg
94
+ ```
95
+
96
+ ### `/fast-subagent:bg-status`
97
+
98
+ Show active background subagents in selector UI. Arrow keys move selection. Enter shows full details for selected job.
99
+
100
+ ```
101
+ /fast-subagent:bg-status
102
+ ```
103
+
104
+ Show details for specific background job:
105
+
106
+ ```
107
+ /fast-subagent:bg-status sa_ab12cd34
108
+ ```
109
+
110
+ ### `/fast-subagent:bg-cancel`
111
+
112
+ Cancel running background subagent. Omit job id to open selector UI, then choose job with arrow keys.
113
+
114
+ ```
115
+ /fast-subagent:bg-cancel
116
+ ```
117
+
118
+ Cancel specific background job directly:
119
+
120
+ ```
121
+ /fast-subagent:bg-cancel sa_ab12cd34
122
+ ```
123
+
80
124
  ## Usage
81
125
 
82
126
  ### List agents
@@ -125,6 +169,33 @@ subagent({
125
169
  })
126
170
  ```
127
171
 
172
+ ### Background (fire-and-forget)
173
+
174
+ ```js
175
+ // Dispatch — returns job ID immediately
176
+ subagent({ agent: "scout", task: "Explore src", background: true })
177
+ // → { jobId: "sa_ab12cd34", status: "running" }
178
+
179
+ // Poll — check result / progress
180
+ subagent({ action: "poll", jobId: "sa_ab12cd34" })
181
+
182
+ // Cancel
183
+ subagent({ action: "cancel", jobId: "sa_ab12cd34" })
184
+
185
+ // List all background jobs
186
+ subagent({ action: "status" })
187
+
188
+ // Detach a running foreground job to background
189
+ subagent({ action: "detach", jobId: "fg_ab12cd34" })
190
+ ```
191
+
192
+ ## Roadmap
193
+
194
+ Goal: keep this extension **small and focused** — aligned with pi's philosophy of minimal, composable tooling. No feature creep. Every addition must earn its place.
195
+
196
+ - **UI/UX polish** — improve visibility of running subagents: clearer status lines, better progress feedback, agent name + task always visible during execution
197
+ - **Background agents** — ala claude code
198
+
128
199
  ## Notes
129
200
 
130
201
  - Async/background isolation not supported in-process
@@ -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
@@ -8,6 +8,7 @@
8
8
  * Agent .md files are compatible with pi-subagents frontmatter format.
9
9
  */
10
10
 
11
+ import { randomUUID } from "node:crypto";
11
12
  import type {
12
13
  AgentToolResult,
13
14
  AgentToolUpdateCallback,
@@ -15,8 +16,10 @@ import type {
15
16
  ExtensionContext,
16
17
  ToolRenderResultOptions,
17
18
  } from "@mariozechner/pi-coding-agent";
19
+ import { BackgroundJobManager } from "./background-job-manager.js";
20
+ import type { BackgroundHandleLike, BackgroundJobResult, BackgroundSubagentJob } from "./background-types.js";
18
21
  import { Theme } from "@mariozechner/pi-coding-agent";
19
- import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
22
+ import { Key, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
20
23
  import { truncateToVisualLines, keyHint } from "@mariozechner/pi-coding-agent";
21
24
  import {
22
25
  AuthStorage,
@@ -94,6 +97,9 @@ function summarizeToolArgs(toolName: unknown, toolInput: unknown): string {
94
97
 
95
98
  let _authStorage: ReturnType<typeof AuthStorage.create> | null = null;
96
99
  let _modelRegistry: ReturnType<typeof ModelRegistry.create> | null = null;
100
+ let _bgManager: BackgroundJobManager | null = null;
101
+ let _onBgJobComplete: ((job: BackgroundSubagentJob) => void) | null = null;
102
+ let _setBgStatus: ((text: string | undefined) => void) | null = null;
97
103
 
98
104
  function getAuth() {
99
105
  if (!_authStorage) _authStorage = AuthStorage.create();
@@ -101,6 +107,27 @@ function getAuth() {
101
107
  return { authStorage: _authStorage, modelRegistry: _modelRegistry };
102
108
  }
103
109
 
110
+ function getBgManager(): BackgroundJobManager {
111
+ if (!_bgManager) _bgManager = new BackgroundJobManager({
112
+ onJobComplete: (job) => _onBgJobComplete?.(job),
113
+ });
114
+ return _bgManager;
115
+ }
116
+
117
+ function refreshBgStatus(): void {
118
+ const running = getBgManager().getRunningJobs();
119
+ _setBgStatus?.(running.length > 0 ? `⧗ ${running.length} bg agent${running.length > 1 ? "s" : ""}` : undefined);
120
+ }
121
+
122
+ // ─── Foreground detach registry ───────────────────────────────────────────────
123
+
124
+ interface ForegroundDetachEntry {
125
+ agentName: string;
126
+ task: string;
127
+ detach: () => string; // returns bg job id
128
+ }
129
+ const _fgJobs = new Map<string, ForegroundDetachEntry>();
130
+
104
131
  // ─── In-process runner ───────────────────────────────────────────────────────
105
132
 
106
133
  const MAX_DEPTH = 2;
@@ -142,6 +169,7 @@ interface SubagentDetails {
142
169
  running: boolean;
143
170
  elapsedMs?: number;
144
171
  model?: string;
172
+ backgroundJobId?: string;
145
173
  toolCalls: ToolCallEntry[];
146
174
  }
147
175
 
@@ -154,6 +182,26 @@ function formatDuration(ms: number): string {
154
182
  return m > 0 ? `${m}m ${rem}s` : `${rem}s`;
155
183
  }
156
184
 
185
+ function summarizeTask(task: string, max = 60): string {
186
+ return task.length > max ? task.slice(0, max - 3) + "..." : task;
187
+ }
188
+
189
+ function formatBgJobSummary(job: BackgroundSubagentJob, now = Date.now()): string {
190
+ const dur = job.completedAt ? formatDuration(job.completedAt - job.startedAt) : formatDuration(now - job.startedAt);
191
+ return `${job.id} [${job.status}] ${job.agentName} · ${dur} · ${summarizeTask(job.task)}`;
192
+ }
193
+
194
+ function formatBgJobDetails(job: BackgroundSubagentJob, now = Date.now()): string {
195
+ const dur = job.completedAt ? formatDuration(job.completedAt - job.startedAt) : formatDuration(now - job.startedAt);
196
+ const lines = [`${job.id} [${job.status}] ${job.agentName} · ${dur}`, `Task: ${job.task}`];
197
+ if (job.model) lines.push(`Model: ${job.model}`);
198
+ if (job.status === "completed") lines.push(`\nResult:\n${job.resultSummary ?? "(no output)"}`);
199
+ if (job.status === "failed") lines.push(`\nError: ${job.error ?? "(unknown)"}`);
200
+ if (job.status === "cancelled") lines.push("\nCancelled.");
201
+ if (job.status === "running") lines.push("\nStill running.");
202
+ return lines.join("\n");
203
+ }
204
+
157
205
  // Module-level depth counter — avoids process.env race conditions in parallel mode
158
206
  let _currentDepth = 0;
159
207
 
@@ -231,7 +279,10 @@ async function runAgent(
231
279
  const toolCalls: ToolCallEntry[] = [];
232
280
  const toolStartTimes = new Map<string, number>();
233
281
 
282
+ let done = false;
283
+
234
284
  function emitUpdate(): void {
285
+ if (done) return;
235
286
  onUpdate?.({
236
287
  content: [{ type: "text", text: currentDelta || lastOutput || "" }],
237
288
  details: {
@@ -354,6 +405,7 @@ async function runAgent(
354
405
  exitCode = 1;
355
406
  error = signal?.aborted ? "Aborted" : e instanceof Error ? e.message : String(e);
356
407
  } finally {
408
+ done = true;
357
409
  clearInterval(heartbeat);
358
410
  unsubscribe();
359
411
  session.dispose();
@@ -436,14 +488,22 @@ const SubagentParams = Type.Object({
436
488
  Type.Number({ description: "Max parallel concurrency (default: 4)", default: 4 }),
437
489
  ),
438
490
 
491
+ // Background
492
+ background: Type.Optional(Type.Boolean({ description: "Run in background, returns job ID immediately" })),
493
+ jobId: Type.Optional(Type.String({ description: "Job ID for poll/cancel" })),
494
+
439
495
  // Management
440
496
  action: Type.Optional(
441
497
  Type.Union(
442
498
  [
443
499
  Type.Literal("list"),
444
500
  Type.Literal("get"),
501
+ Type.Literal("status"),
502
+ Type.Literal("poll"),
503
+ Type.Literal("cancel"),
504
+ Type.Literal("detach"),
445
505
  ],
446
- { description: "'list' to discover agents, 'get' to inspect one agent" },
506
+ { description: "'list'/'get' for agents, 'status' for bg jobs, 'poll'/'cancel' for a specific job, 'detach' to move a foreground job to background" },
447
507
  ),
448
508
  ),
449
509
  agentScope: Type.Optional(
@@ -457,9 +517,65 @@ const SubagentParams = Type.Object({
457
517
  // ─── Extension entry point ────────────────────────────────────────────────────
458
518
 
459
519
  export default function (pi: ExtensionAPI) {
520
+ // ─── Status keys ────────────────────────────────────────────────────────────────────
521
+ const BG_STATUS_KEY = "fast-subagent-bg";
522
+ const FG_STATUS_KEY = "fast-subagent-fg";
523
+
524
+ // ─── Background job lifecycle ─────────────────────────────────────────────────────
525
+ _onBgJobComplete = (job) => {
526
+ refreshBgStatus();
527
+ const elapsed = job.completedAt ? ((job.completedAt - job.startedAt) / 1000).toFixed(1) : "?";
528
+ const statusEmoji = job.status === "completed" ? "✓" : "✗";
529
+ const taskPreview = job.task.length > 80 ? `${job.task.slice(0, 80)}…` : job.task;
530
+ const output = job.status === "completed"
531
+ ? (job.resultSummary ?? "(no output)")
532
+ : `Error: ${job.error ?? "unknown"}`;
533
+ const modelInfo = job.model ? ` · ${job.model}` : "";
534
+ pi.sendUserMessage(
535
+ [
536
+ `**Background subagent ${statusEmoji}: ${job.id}** (${job.agentName}, ${elapsed}s${modelInfo})`,
537
+ `> ${taskPreview}`,
538
+ ``,
539
+ output,
540
+ ].join("\n"),
541
+ { deliverAs: "followUp" },
542
+ );
543
+ };
544
+
545
+ pi.on("session_start", async (_event, ctx) => {
546
+ _setBgStatus = (text) => ctx.ui.setStatus(BG_STATUS_KEY, text);
547
+ });
548
+
549
+ pi.on("session_shutdown", async () => {
550
+ getBgManager().shutdown();
551
+ _bgManager = null;
552
+ _setBgStatus = null;
553
+ });
554
+
555
+ // ─── Ctrl+Shift+B — move foreground subagent to background ─────────────────────────
556
+ pi.registerShortcut(Key.ctrlShift("b"), {
557
+ description: "Move foreground subagent to background",
558
+ handler: async (ctx) => {
559
+ const entry = [..._fgJobs.values()][0];
560
+ if (!entry) {
561
+ ctx.ui.notify("No foreground subagent running.", "info");
562
+ return;
563
+ }
564
+ try {
565
+ const bgJobId = entry.detach();
566
+ ctx.ui.notify(
567
+ `Moved ${entry.agentName} to background as ${bgJobId}. Completion will be announced automatically.`,
568
+ "info",
569
+ );
570
+ } catch (e) {
571
+ ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
572
+ }
573
+ },
574
+ });
575
+
460
576
  // ─── /agent slash command ─────────────────────────────────────────────────
461
- pi.registerCommand("agent", {
462
- description: "List available subagents. Usage: /agent [name] — show details for a specific agent.",
577
+ pi.registerCommand("fast-subagent:agent", {
578
+ description: "List available subagents. Usage: /fast-subagent:agent [name] — show details for a specific agent.",
463
579
  getArgumentCompletions(prefix: string) {
464
580
  const agents = discoverAgents(process.cwd());
465
581
  return agents
@@ -518,11 +634,132 @@ export default function (pi: ExtensionAPI) {
518
634
  }
519
635
  }
520
636
  lines.push("");
521
- lines.push("Tip: /agent <name> for details · Add .md files to .pi/agents/ to create new agents");
637
+ lines.push("Tip: /fast-subagent:agent <name> for details · Add .md files to .pi/agents/ to create new agents");
522
638
  ctx.ui.notify(lines.join("\n"), "info");
523
639
  },
524
640
  });
525
641
 
642
+ // ─── /bg slash command ────────────────────────────────────────────────────
643
+ pi.registerCommand("fast-subagent:bg", {
644
+ description: "Move a running foreground subagent to background. Shortcut: Ctrl+Shift+B. Usage: /fast-subagent:bg [fg-job-id] — omit ID to list active foreground jobs.",
645
+ getArgumentCompletions(_prefix: string) {
646
+ return [..._fgJobs.keys()].map((id) => ({ value: id, label: id }));
647
+ },
648
+ async handler(args: string, ctx) {
649
+ const id = args.trim();
650
+ if (!id) {
651
+ if (_fgJobs.size === 0) {
652
+ ctx.ui.notify("No active foreground subagent jobs.", "info");
653
+ return;
654
+ }
655
+ const lines = ["Active foreground jobs (use /fast-subagent:bg <id> to detach):"];
656
+ for (const [fgId, entry] of _fgJobs) {
657
+ lines.push(` ${fgId} ${entry.agentName}: ${summarizeTask(entry.task)}`);
658
+ }
659
+ ctx.ui.notify(lines.join("\n"), "info");
660
+ return;
661
+ }
662
+ const entry = _fgJobs.get(id);
663
+ if (!entry) {
664
+ ctx.ui.notify(`Foreground job "${id}" not found (already done or invalid).`, "warning");
665
+ return;
666
+ }
667
+ const bgJobId = entry.detach();
668
+ ctx.ui.notify(
669
+ `Moved to background: ${bgJobId}\nTo check status, ask me to poll job ${bgJobId}.`,
670
+ "info",
671
+ );
672
+ },
673
+ });
674
+
675
+ // ─── /bg-status slash command ─────────────────────────────────────────────
676
+ pi.registerCommand("fast-subagent:bg-status", {
677
+ description: "Show active background subagents. Usage: /fast-subagent:bg-status [sa-job-id] — omit ID to open selector.",
678
+ getArgumentCompletions(prefix: string) {
679
+ return getBgManager().getAllJobs()
680
+ .filter((job) => job.id.startsWith(prefix))
681
+ .map((job) => ({ value: job.id, label: formatBgJobSummary(job) }));
682
+ },
683
+ async handler(args: string, ctx) {
684
+ const id = args.trim();
685
+ if (id) {
686
+ const job = getBgManager().getJob(id);
687
+ if (!job) {
688
+ ctx.ui.notify(`Background job "${id}" not found.`, "warning");
689
+ return;
690
+ }
691
+ ctx.ui.notify(formatBgJobDetails(job), "info");
692
+ return;
693
+ }
694
+
695
+ const jobs = getBgManager().getRunningJobs().sort((a, b) => b.startedAt - a.startedAt);
696
+ if (jobs.length === 0) {
697
+ ctx.ui.notify("No active background subagent jobs.", "info");
698
+ return;
699
+ }
700
+
701
+ const options = jobs.map((job) => formatBgJobSummary(job));
702
+ const selected = await ctx.ui.select("Active background subagents", options);
703
+ if (!selected) return;
704
+
705
+ const jobId = selected.split(" ")[0] ?? "";
706
+ const job = getBgManager().getJob(jobId);
707
+ if (!job) {
708
+ ctx.ui.notify(`Background job "${jobId}" not found.`, "warning");
709
+ return;
710
+ }
711
+ ctx.ui.notify(formatBgJobDetails(job), "info");
712
+ },
713
+ });
714
+
715
+ // ─── /bg-cancel slash command ─────────────────────────────────────────────
716
+ pi.registerCommand("fast-subagent:bg-cancel", {
717
+ description: "Cancel running background subagent. Usage: /fast-subagent:bg-cancel [sa-job-id] — omit ID to choose with arrow keys.",
718
+ getArgumentCompletions(prefix: string) {
719
+ return getBgManager().getRunningJobs()
720
+ .filter((job) => job.id.startsWith(prefix))
721
+ .map((job) => ({ value: job.id, label: formatBgJobSummary(job) }));
722
+ },
723
+ async handler(args: string, ctx) {
724
+ let jobId = args.trim();
725
+
726
+ if (!jobId) {
727
+ const jobs = getBgManager().getRunningJobs().sort((a, b) => b.startedAt - a.startedAt);
728
+ if (jobs.length === 0) {
729
+ ctx.ui.notify("No running background subagent jobs to cancel.", "info");
730
+ return;
731
+ }
732
+
733
+ const options = jobs.map((job) => formatBgJobSummary(job));
734
+ const selected = await ctx.ui.select("Cancel background subagent", options);
735
+ if (!selected) return;
736
+ jobId = selected.split(" ")[0] ?? "";
737
+ }
738
+
739
+ const job = getBgManager().getJob(jobId);
740
+ if (!job) {
741
+ ctx.ui.notify(`Background job "${jobId}" not found.`, "warning");
742
+ return;
743
+ }
744
+ if (job.status !== "running") {
745
+ ctx.ui.notify(`Background job "${jobId}" already ${job.status}.`, "info");
746
+ return;
747
+ }
748
+
749
+ const confirmed = await ctx.ui.confirm(
750
+ "Cancel background subagent?",
751
+ `${formatBgJobSummary(job)}\n\nTask:\n${job.task}`,
752
+ );
753
+ if (!confirmed) return;
754
+
755
+ const result = getBgManager().cancel(jobId);
756
+ const msg = result === "cancelled" ? `Background job "${jobId}" cancelled.`
757
+ : result === "already_done" ? `Background job "${jobId}" already completed.`
758
+ : `Background job "${jobId}" not found.`;
759
+ ctx.ui.notify(msg, result === "cancelled" ? "info" : "warning");
760
+ },
761
+ });
762
+
526
763
  pi.registerTool({
527
764
  name: "subagent",
528
765
  label: "Subagent",
@@ -614,6 +851,7 @@ export default function (pi: ExtensionAPI) {
614
851
  }
615
852
 
616
853
  function statusLine(): string {
854
+ if (details.backgroundJobId) return `moved to background · ${details.backgroundJobId}`;
617
855
  if (details.running) {
618
856
  const parts: string[] = ["running"];
619
857
  if (details.usage?.turns) parts.push(`${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}`);
@@ -701,6 +939,8 @@ export default function (pi: ExtensionAPI) {
701
939
  : "";
702
940
  const statusWithHint = [status, expandHint].filter(Boolean).join(" ");
703
941
  if (statusWithHint) out.push(truncateToWidth(statusWithHint, width, "..."));
942
+ if (details.running && !details.backgroundJobId)
943
+ out.push(truncateToWidth(theme.fg("dim", "Ctrl+Shift+B: move to background"), width, "..."));
704
944
 
705
945
  return out;
706
946
  },
@@ -721,7 +961,7 @@ export default function (pi: ExtensionAPI) {
721
961
  };
722
962
 
723
963
  // ── Management: list ──────────────────────────────────────────────────────
724
- if (params.action === "list" || (!params.agent && !params.tasks)) {
964
+ if (params.action === "list" || (!params.action && !params.agent && !params.tasks)) {
725
965
  if (agents.length === 0) {
726
966
  return {
727
967
  content: [{
@@ -750,20 +990,130 @@ export default function (pi: ExtensionAPI) {
750
990
  return { content: [{ type: "text", text: info }] };
751
991
  }
752
992
 
993
+ // ── Background status ───────────────────────────────────────────────────
994
+ if (params.action === "status") {
995
+ const jobs = getBgManager().getAllJobs();
996
+ if (jobs.length === 0) return { content: [{ type: "text", text: "No background jobs." }] };
997
+ const lines = jobs.map((j) => {
998
+ const dur = j.completedAt ? formatDuration(j.completedAt - j.startedAt) : formatDuration(Date.now() - j.startedAt);
999
+ return `${j.id} [${j.status}] ${j.agentName} · ${dur} · ${j.task.length > 50 ? j.task.slice(0, 47) + "..." : j.task}`;
1000
+ });
1001
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1002
+ }
1003
+
1004
+ // ── Background poll ────────────────────────────────────────────────────────
1005
+ if (params.action === "poll") {
1006
+ if (!params.jobId) return { content: [{ type: "text", text: "Provide jobId to poll." }] };
1007
+ const job = getBgManager().getJob(params.jobId);
1008
+ if (!job) return { content: [{ type: "text", text: `Job ${params.jobId} not found (completed and evicted, or invalid).` }] };
1009
+ const dur = job.completedAt ? formatDuration(job.completedAt - job.startedAt) : formatDuration(Date.now() - job.startedAt);
1010
+ const parts = [`${job.id} [${job.status}] ${job.agentName} · ${dur}`, `Task: ${job.task}`];
1011
+ if (job.status === "completed") parts.push(`\nResult:\n${job.resultSummary ?? "(no output)"}`);
1012
+ if (job.status === "failed") parts.push(`\nError: ${job.error ?? "(unknown)"}`);
1013
+ if (job.status === "running") parts.push("Still running — poll again later.");
1014
+ return { content: [{ type: "text", text: parts.join("\n") }] };
1015
+ }
1016
+
1017
+ // ── Background cancel ──────────────────────────────────────────────────────
1018
+ if (params.action === "cancel") {
1019
+ if (!params.jobId) return { content: [{ type: "text", text: "Provide jobId to cancel." }] };
1020
+ const result = getBgManager().cancel(params.jobId);
1021
+ const msg = result === "cancelled" ? `Job ${params.jobId} cancelled.`
1022
+ : result === "already_done" ? `Job ${params.jobId} already completed.`
1023
+ : `Job ${params.jobId} not found.`;
1024
+ return { content: [{ type: "text", text: msg }] };
1025
+ }
1026
+
1027
+ // ── Foreground → background detach ────────────────────────────────────────
1028
+ if (params.action === "detach") {
1029
+ if (!params.jobId) return { content: [{ type: "text", text: "Provide jobId (fg_xxxxx) to detach." }] };
1030
+ const fgEntry = _fgJobs.get(params.jobId);
1031
+ if (!fgEntry) return { content: [{ type: "text", text: `Foreground job "${params.jobId}" not found (already completed or invalid).` }] };
1032
+ const bgJobId = fgEntry.detach();
1033
+ return { content: [{ type: "text", text: `Moved to background: ${bgJobId}\nTo check status, ask me to poll job ${bgJobId}.` }] };
1034
+ }
1035
+
753
1036
  // ── Single mode ───────────────────────────────────────────────────────────
754
1037
  if (params.agent && params.task) {
755
1038
  const { agent, error } = findAgent(params.agent);
756
1039
  if (error || !agent) return { content: [{ type: "text", text: error ?? "Not found" }] };
757
1040
 
758
- const result = await runAgent(
759
- agent,
760
- params.task,
761
- cwd,
762
- params.model,
763
- signal,
764
- onUpdate,
1041
+ // Background dispatch fire and forget
1042
+ if (params.background) {
1043
+ const bgAbort = new AbortController();
1044
+ const handle: BackgroundHandleLike = { abort: () => bgAbort.abort() };
1045
+ const resultPromise: Promise<BackgroundJobResult> = runAgent(
1046
+ agent, params.task, cwd, params.model, bgAbort.signal, undefined
1047
+ ).then((r) => ({ summary: r.output, exitCode: r.exitCode, error: r.error, model: r.model }));
1048
+ const jobId = getBgManager().adoptHandle(agent.name, params.task, cwd, handle, resultPromise);
1049
+ return { content: [{ type: "text", text: `Background job started: ${jobId}\nTo check status, ask me to poll job ${jobId}.` }] };
1050
+ }
1051
+
1052
+ // Foreground run with detach support
1053
+ const fgId = `fg_${randomUUID().slice(0, 8)}`;
1054
+ const agentAbort = new AbortController();
1055
+ const forwardAbort = () => agentAbort.abort();
1056
+ signal?.addEventListener("abort", forwardAbort, { once: true });
1057
+
1058
+ let detachResolveFn: ((bgJobId: string) => void) | null = null;
1059
+ const detachPromise = new Promise<string>((resolve) => { detachResolveFn = resolve; });
1060
+
1061
+ // Wrap onUpdate so detach can stop forwarding updates to the parent
1062
+ // agent's listener (which becomes invalid once execute() returns).
1063
+ let forwardUpdates = true;
1064
+ const wrappedOnUpdate: OnUpdate | undefined = onUpdate
1065
+ ? (partial) => { if (forwardUpdates) onUpdate(partial); }
1066
+ : undefined;
1067
+
1068
+ const agentRunPromise: Promise<RunResult> = runAgent(
1069
+ agent, params.task, cwd, params.model, agentAbort.signal, wrappedOnUpdate,
765
1070
  );
766
1071
 
1072
+ // Derived promise for the bg manager (used only if we detach)
1073
+ const bgResultPromise: Promise<BackgroundJobResult> = agentRunPromise
1074
+ .then((r) => ({ summary: r.output, exitCode: r.exitCode, error: r.error, model: r.model }));
1075
+
1076
+ _fgJobs.set(fgId, {
1077
+ agentName: agent.name,
1078
+ task: params.task,
1079
+ detach: () => {
1080
+ forwardUpdates = false;
1081
+ signal?.removeEventListener("abort", forwardAbort);
1082
+ const bgHandle: BackgroundHandleLike = { abort: () => agentAbort.abort() };
1083
+ const bgJobId = getBgManager().adoptHandle(agent.name, params.task, cwd, bgHandle, bgResultPromise);
1084
+ refreshBgStatus();
1085
+ detachResolveFn?.(bgJobId);
1086
+ return bgJobId;
1087
+ },
1088
+ });
1089
+
1090
+ ctx.ui.setStatus(FG_STATUS_KEY, `${agent.name} running · Ctrl+Shift+B to move to background`);
1091
+
1092
+ let runResult: RunResult | null = null;
1093
+ const outcome = await Promise.race([
1094
+ agentRunPromise.then((r) => { runResult = r; return "done" as const; }),
1095
+ detachPromise.then(() => "detached" as const),
1096
+ ]).finally(() => {
1097
+ _fgJobs.delete(fgId);
1098
+ signal?.removeEventListener("abort", forwardAbort);
1099
+ ctx.ui.setStatus(FG_STATUS_KEY, undefined);
1100
+ });
1101
+
1102
+ if (outcome === "detached") {
1103
+ const bgJobId = await detachPromise; // already resolved — instant
1104
+ return {
1105
+ content: [{ type: "text", text: `Moved to background: ${bgJobId}. Completion will be announced automatically.` }],
1106
+ details: {
1107
+ task: params.task,
1108
+ usage: { input: 0, output: 0, cost: 0, turns: 0 },
1109
+ running: false,
1110
+ backgroundJobId: bgJobId,
1111
+ toolCalls: [],
1112
+ } satisfies SubagentDetails,
1113
+ };
1114
+ }
1115
+
1116
+ const result = runResult!;
767
1117
  return {
768
1118
  content: [{ type: "text", text: getFinalText(result) }],
769
1119
  details: {
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.5.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
  ],