@wolido/async-subagent-isolation 1.3.0 → 1.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.en.md CHANGED
@@ -120,7 +120,7 @@ Results arrive automatically — **no polling**. In-flight task information is p
120
120
 
121
121
  ### 5. Read the full result (`/subagent-result`)
122
122
 
123
- The notification card shows only a summary. Use `/subagent-result <taskId>` to read the full output in a full-screen viewer: `↑↓`/`jk` scroll, `Space`/`b` page, `g`/`G` top/bottom, `Enter`/`Esc`/`q` close.
123
+ The notification card shows only a summary. Use `/subagent-result <taskId>` to read the full output in a full-screen viewer: `↑↓`/`jk` scroll, `Space`/`b` page, `g`/`G` top/bottom, `Enter`/`Esc`/`q` close. With no argument (TUI mode), an interactive picker lists the 5 most recently finished tasks and `Enter` opens the selected one.
124
124
 
125
125
  ### The flow at a glance
126
126
 
@@ -154,9 +154,9 @@ User runs /subagent-result <taskId> to read the full output
154
154
 
155
155
  | Command | Purpose |
156
156
  |---------|---------|
157
- | `/subagent-cancel <taskId>` | Cancel one running background task (lists running tasks with no argument) |
157
+ | `/subagent-cancel <taskId>` | Cancel one running background task (no argument opens an interactive picker of running tasks; Enter cancels the selection) |
158
158
  | `/subagent-cancel-all` | Cancel all running background tasks at once |
159
- | `/subagent-result <taskId>` | Read a task's full result in a full-screen viewer |
159
+ | `/subagent-result <taskId>` | Read a task's full result in a full-screen viewer (no argument opens an interactive picker of the 5 most recent finished tasks) |
160
160
 
161
161
  ---
162
162
 
package/README.md CHANGED
@@ -120,7 +120,7 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
120
120
 
121
121
  ### 5. 查看全文(`/subagent-result`)
122
122
 
123
- 通知卡片只显示摘要。用 `/subagent-result <taskId>` 在全屏查看器中阅读完整返回:`↑↓`/`jk` 滚动、`Space`/`b` 翻页、`g`/`G` 首尾、`Enter`/`Esc`/`q` 关闭。
123
+ 通知卡片只显示摘要。用 `/subagent-result <taskId>` 在全屏查看器中阅读完整返回:`↑↓`/`jk` 滚动、`Space`/`b` 翻页、`g`/`G` 首尾、`Enter`/`Esc`/`q` 关闭。不带参数时(TUI 模式)弹出选择列表,列出最近 5 个已结束的任务,`Enter` 打开所选任务。
124
124
 
125
125
  ### 完整流程一览
126
126
 
@@ -154,9 +154,9 @@ TUI 模式下 `subagent` **立即返回派发回执**,不阻塞:
154
154
 
155
155
  | 命令 | 作用 |
156
156
  |------|------|
157
- | `/subagent-cancel <taskId>` | 取消单个运行中的后台任务(不带参数时列出运行中任务) |
157
+ | `/subagent-cancel <taskId>` | 取消单个运行中的后台任务(不带参数时弹出运行中任务的交互选择列表,Enter 取消所选) |
158
158
  | `/subagent-cancel-all` | 一键取消全部运行中的后台任务 |
159
- | `/subagent-result <taskId>` | 全屏查看某任务的完整返回 |
159
+ | `/subagent-result <taskId>` | 全屏查看某任务的完整返回(不带参数时弹出最近 5 个已结束任务的交互选择列表) |
160
160
 
161
161
  ---
162
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolido/async-subagent-isolation",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A pi extension that asynchronously delegates tasks to specialized subagents running in isolated pi processes.",
5
5
  "license": "MIT",
6
6
  "author": "Wolido",
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  getAgentDir,
28
28
  parseFrontmatter,
29
29
  } from "@earendil-works/pi-coding-agent";
30
- import { Box, Container, Key, Markdown, matchesKey, Spacer, Text, truncateToWidth, visibleWidth, sliceByColumn } from "@earendil-works/pi-tui";
30
+ import { Box, Container, Key, Markdown, matchesKey, SelectList, type SelectItem, Spacer, Text, truncateToWidth, visibleWidth, sliceByColumn } from "@earendil-works/pi-tui";
31
31
  import { Type } from "typebox";
32
32
 
33
33
  // ===== UUID v7 helper =====
@@ -1546,6 +1546,109 @@ export function formatActiveTasks(): string {
1546
1546
  return `在途任务: ${running.length}\n${lines.join("\n")}`;
1547
1547
  }
1548
1548
 
1549
+ /** A finished async task, recorded when completeAsyncTask removes it from the registry. */
1550
+ interface CompletedTaskRecord {
1551
+ taskId: string;
1552
+ agentName: string;
1553
+ status: SubagentTaskStatus;
1554
+ finishedAt: number;
1555
+ }
1556
+
1557
+ /**
1558
+ * Recently finished async tasks in completion order (latest last), backing the
1559
+ * no-argument /subagent-result picker. Bounded so a long session cannot grow
1560
+ * it without limit; entries whose session file is gone are filtered at read
1561
+ * time by listViewableFinishedTasks.
1562
+ */
1563
+ const completedTasks: CompletedTaskRecord[] = [];
1564
+ const COMPLETED_TASKS_KEEP = 50;
1565
+
1566
+ /** Record a finished task (called once per task from completeAsyncTask). */
1567
+ function recordCompletedTask(task: AsyncSubagentTask, status: SubagentTaskStatus): void {
1568
+ // A reused sessionId finishes repeatedly: drop its older record first so
1569
+ // the latest finish wins and one task cannot occupy multiple slots.
1570
+ for (let i = completedTasks.length - 1; i >= 0; i--) {
1571
+ if (completedTasks[i].taskId === task.taskId) completedTasks.splice(i, 1);
1572
+ }
1573
+ completedTasks.push({ taskId: task.taskId, agentName: task.agentName, status, finishedAt: Date.now() });
1574
+ if (completedTasks.length > COMPLETED_TASKS_KEEP) {
1575
+ completedTasks.splice(0, completedTasks.length - COMPLETED_TASKS_KEEP);
1576
+ }
1577
+ }
1578
+
1579
+ /**
1580
+ * Latest-first finished tasks whose session transcript still exists on disk
1581
+ * (a task without a session file has nothing to show in the result viewer).
1582
+ * completedTasks holds at most one record per taskId (recordCompletedTask
1583
+ * dedupes), so no further deduplication is needed here.
1584
+ */
1585
+ function listViewableFinishedTasks(limit: number): CompletedTaskRecord[] {
1586
+ const result: CompletedTaskRecord[] = [];
1587
+ for (let i = completedTasks.length - 1; i >= 0 && result.length < limit; i--) {
1588
+ const record = completedTasks[i];
1589
+ if (!findSessionFile(record.taskId)) continue;
1590
+ result.push(record);
1591
+ }
1592
+ return result;
1593
+ }
1594
+
1595
+ /** Build a picker item whose label carries the full taskId (a 36-char UUID). */
1596
+ function taskPickerItem(taskId: string, description: string): SelectItem {
1597
+ return { value: taskId, label: taskId, description };
1598
+ }
1599
+
1600
+ /**
1601
+ * Interactive task picker (TUI only): a SelectList in a Container with
1602
+ * DynamicBorder framing (tui.md Pattern 1). Resolves with the selected item's
1603
+ * value (taskId), or undefined on Esc / q. Neither pi's select() nor
1604
+ * SelectList handles "q", so the wrapper's handleInput intercepts it before
1605
+ * delegating to the list.
1606
+ */
1607
+ async function pickTaskInteractively(
1608
+ ui: ExtensionContext["ui"],
1609
+ title: string,
1610
+ items: SelectItem[],
1611
+ ): Promise<string | undefined> {
1612
+ return ui.custom<string | undefined>((tui, theme, _kb, done) => {
1613
+ const container = new Container();
1614
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1615
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
1616
+ const selectList = new SelectList(
1617
+ items,
1618
+ Math.min(items.length, 10),
1619
+ {
1620
+ selectedPrefix: (t) => theme.fg("accent", t),
1621
+ selectedText: (t) => theme.fg("accent", t),
1622
+ description: (t) => theme.fg("muted", t),
1623
+ scrollInfo: (t) => theme.fg("dim", t),
1624
+ noMatch: (t) => theme.fg("warning", t),
1625
+ },
1626
+ // The label is a 36-char UUID taskId; the default 32-char primary
1627
+ // column would truncate it, so widen the column to fit.
1628
+ { minPrimaryColumnWidth: 40, maxPrimaryColumnWidth: 40 },
1629
+ );
1630
+ selectList.onSelect = (item) => done(item.value);
1631
+ selectList.onCancel = () => done(undefined);
1632
+ container.addChild(selectList);
1633
+ container.addChild(new Text(theme.fg("dim", "↑↓ 选择 · Enter 确认 · Esc/q 退出"), 1, 0));
1634
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1635
+ return {
1636
+ render: (w) => container.render(w),
1637
+ invalidate: () => container.invalidate(),
1638
+ handleInput: (data) => {
1639
+ // Key.shift("q") covers Shift+q / Caps Lock "Q"; matchesKey
1640
+ // lowercases its keyId, so "Q" alone would be a no-op alias.
1641
+ if (matchesKey(data, "q") || matchesKey(data, Key.shift("q"))) {
1642
+ done(undefined);
1643
+ return;
1644
+ }
1645
+ selectList.handleInput(data);
1646
+ tui.requestRender();
1647
+ },
1648
+ };
1649
+ });
1650
+ }
1651
+
1549
1652
  /** Derive the envelope status from a finished SingleResult. */
1550
1653
  function getTaskStatus(result: SingleResult): SubagentTaskStatus {
1551
1654
  const stopReason = result.stopReason;
@@ -1703,6 +1806,9 @@ function completeAsyncTask(pi: ExtensionAPI, task: AsyncSubagentTask, result: Si
1703
1806
  : !result
1704
1807
  ? "internal_error"
1705
1808
  : undefined);
1809
+ // Record the finish for the no-argument /subagent-result picker before the
1810
+ // notification goes out; failures of the picker list must not affect this.
1811
+ recordCompletedTask(task, status);
1706
1812
  // Carry the rejection reason into the envelope so internal failures
1707
1813
  // (e.g. the prompt temp-file write failed) are diagnosable instead of
1708
1814
  // showing a bare "(no output)".
@@ -2199,15 +2305,26 @@ export default function (pi: ExtensionAPI) {
2199
2305
  pi.registerCommand?.("subagent-cancel", {
2200
2306
  description: "Cancel a running background subagent task (usage: /subagent-cancel <taskId>)",
2201
2307
  handler: async (args, cmdCtx) => {
2202
- const taskId = (args ?? "").trim();
2308
+ let taskId = (args ?? "").trim();
2203
2309
  if (!taskId) {
2204
- // No argument: list the running tasks so the user knows what to cancel.
2205
- const running = [...taskRegistry.values()]
2206
- .filter((t) => t.status === "running")
2207
- .map((t) => t.taskId);
2208
- const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
2209
- cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
2210
- return;
2310
+ const runningTasks = [...taskRegistry.values()].filter((t) => t.status === "running");
2311
+ // TUI with running tasks: interactive picker (Enter cancels, Esc/q
2312
+ // dismisses without doing anything). Non-TUI and the empty case keep
2313
+ // the original notify fallback.
2314
+ if (cmdCtx.hasUI && cmdCtx.mode === "tui" && runningTasks.length > 0) {
2315
+ const items: SelectItem[] = runningTasks.map((t) =>
2316
+ taskPickerItem(t.taskId, `${t.agentName}: ${truncateTaskDescription(t.task, 60)}`),
2317
+ );
2318
+ const picked = await pickTaskInteractively(cmdCtx.ui, "取消运行中任务 (cancel subagent task)", items);
2319
+ if (picked === undefined) return;
2320
+ taskId = picked;
2321
+ } else {
2322
+ // No argument: list the running tasks so the user knows what to cancel.
2323
+ const running = runningTasks.map((t) => t.taskId);
2324
+ const hint = running.length > 0 ? ` Running tasks: ${running.join(", ")}.` : " No running tasks.";
2325
+ cmdCtx.ui?.notify?.(`No running subagent task with id "(none)".${hint}`, "warning");
2326
+ return;
2327
+ }
2211
2328
  }
2212
2329
  if (!cancelTask(taskId, "user")) {
2213
2330
  cmdCtx.ui?.notify?.(`No running subagent task with id "${taskId}".`, "warning");
@@ -2244,10 +2361,25 @@ export default function (pi: ExtensionAPI) {
2244
2361
  pi.registerCommand?.("subagent-result", {
2245
2362
  description: "Show the full final result of a background subagent task (usage: /subagent-result <taskId>)",
2246
2363
  handler: async (args, cmdCtx) => {
2247
- const taskId = (args ?? "").trim();
2364
+ let taskId = (args ?? "").trim();
2248
2365
  if (!taskId) {
2249
- cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> 查看某子 agent 的完整返回。", "warning");
2250
- return;
2366
+ // TUI: interactive picker over the most recent finished tasks (Enter
2367
+ // opens the same viewer as the with-argument path below, Esc/q
2368
+ // dismisses without doing anything). Non-TUI keeps the usage hint.
2369
+ if (cmdCtx.hasUI && cmdCtx.mode === "tui") {
2370
+ const recent = listViewableFinishedTasks(5);
2371
+ if (recent.length === 0) {
2372
+ cmdCtx.ui?.notify?.("没有已运行结束的子 agent 任务记录 (no finished subagent tasks)。", "warning");
2373
+ return;
2374
+ }
2375
+ const items: SelectItem[] = recent.map((r) => taskPickerItem(r.taskId, `${r.agentName} · ${STATUS_WORDS[r.status]}`));
2376
+ const picked = await pickTaskInteractively(cmdCtx.ui, "查看已结束任务结果 (subagent result)", items);
2377
+ if (picked === undefined) return;
2378
+ taskId = picked;
2379
+ } else {
2380
+ cmdCtx.ui?.notify?.("Usage: /subagent-result <taskId> — 查看某子 agent 的完整返回。", "warning");
2381
+ return;
2382
+ }
2251
2383
  }
2252
2384
  // Refuse mid-flight reads: while the task is in the registry its
2253
2385
  // session file only holds a partial snapshot.
@@ -2300,7 +2432,7 @@ export default function (pi: ExtensionAPI) {
2300
2432
  },
2301
2433
  invalidate: () => md.invalidate(),
2302
2434
  handleInput: (data: string) => {
2303
- if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q")) {
2435
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || matchesKey(data, "q") || matchesKey(data, Key.shift("q"))) {
2304
2436
  done(undefined);
2305
2437
  return;
2306
2438
  }