@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 +3 -3
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/index.ts +145 -13
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 (
|
|
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
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
|
-
|
|
2308
|
+
let taskId = (args ?? "").trim();
|
|
2203
2309
|
if (!taskId) {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
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
|
-
|
|
2364
|
+
let taskId = (args ?? "").trim();
|
|
2248
2365
|
if (!taskId) {
|
|
2249
|
-
|
|
2250
|
-
|
|
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
|
}
|