pi-famulus 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /tasks — one live overlay for shells, monitors, and subagents.
|
|
3
|
+
*
|
|
4
|
+
* Type to filter, ↑↓ selects, Enter opens, Ctrl+X asks before stopping, Tab
|
|
5
|
+
* switches between active/recent and the manager's complete task history.
|
|
6
|
+
*/
|
|
7
|
+
import { DynamicBorder, keyHint, rawKeyHint, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { realClock, type Clock } from "../clock";
|
|
9
|
+
import type { ManagerClient, TaskRecord } from "../manager-client";
|
|
10
|
+
import { taskOutputPath } from "../config";
|
|
11
|
+
import { formatConversation } from "../subagent/conversation";
|
|
12
|
+
import type { SubagentRegistry } from "../subagent/registry";
|
|
13
|
+
import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
|
|
14
|
+
import { notifyPlainFallback, showScrollDetail } from "./scroll-detail-view";
|
|
15
|
+
import { readTaskFileTailCached, stderrPathFor } from "./task-output-paths";
|
|
16
|
+
import { fitLines, loadPiTui, truncateToWidth } from "./pi-tui-load";
|
|
17
|
+
import { statusGlyph } from "./tool-component";
|
|
18
|
+
|
|
19
|
+
const AGE_TICK_MS = 1000;
|
|
20
|
+
|
|
21
|
+
function isLive(item: WorkItem): boolean {
|
|
22
|
+
return item.status === "pending" || item.status === "running";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Selection follows the item id so a re-sort cannot move the cursor. */
|
|
26
|
+
export function selectedIndex(items: readonly WorkItem[], selectedId: string | undefined): number {
|
|
27
|
+
if (items.length === 0 || !selectedId) return 0;
|
|
28
|
+
const index = items.findIndex((item) => item.id === selectedId);
|
|
29
|
+
return index >= 0 ? index : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function moveSelection(
|
|
33
|
+
items: readonly WorkItem[],
|
|
34
|
+
selectedId: string | undefined,
|
|
35
|
+
dir: -1 | 1,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
if (items.length === 0) return undefined;
|
|
38
|
+
const index = selectedIndex(items, selectedId);
|
|
39
|
+
return items[Math.max(0, Math.min(items.length - 1, index + dir))]?.id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function stopChoice(
|
|
43
|
+
items: readonly WorkItem[],
|
|
44
|
+
selectedId: string | undefined,
|
|
45
|
+
): { action: "stop" | "already-finished" | "none"; id?: string } {
|
|
46
|
+
if (items.length === 0) return { action: "none" };
|
|
47
|
+
const item = items[selectedIndex(items, selectedId)];
|
|
48
|
+
if (!item) return { action: "none" };
|
|
49
|
+
if (!isLive(item)) return { action: "already-finished", id: item.id };
|
|
50
|
+
return { action: "stop", id: item.id };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TasksCommandDeps {
|
|
54
|
+
getRegistry: () => SubagentRegistry | null;
|
|
55
|
+
getIndex: () => WorkIndex | null;
|
|
56
|
+
getClient: () => ManagerClient | null;
|
|
57
|
+
home?: string;
|
|
58
|
+
sessionId?: () => string;
|
|
59
|
+
clock?: Clock;
|
|
60
|
+
syncWithManager?: () => Promise<unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function registerTasksCommand(pi: ExtensionAPI, deps: TasksCommandDeps): void {
|
|
64
|
+
pi.registerCommand("tasks", {
|
|
65
|
+
description: "List and inspect background work",
|
|
66
|
+
handler: async (_args, ctx) => openTasksUi(ctx, deps),
|
|
67
|
+
});
|
|
68
|
+
pi.registerCommand("bashes", {
|
|
69
|
+
description: "Alias for /tasks",
|
|
70
|
+
handler: async (_args, ctx) => openTasksUi(ctx, deps),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type TaskListRow = { type: "run"; runId: string; count: number } | { type: "task"; item: WorkItem; indent: boolean };
|
|
75
|
+
|
|
76
|
+
export function groupTaskRows(items: readonly WorkItem[]): TaskListRow[] {
|
|
77
|
+
const runs = new Map<string, WorkItem[]>();
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
if (item.runId) runs.set(item.runId, [...(runs.get(item.runId) ?? []), item]);
|
|
80
|
+
}
|
|
81
|
+
const emitted = new Set<string>();
|
|
82
|
+
const rows: TaskListRow[] = [];
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
if (!item.runId) {
|
|
85
|
+
rows.push({ type: "task", item, indent: false });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!emitted.has(item.runId)) {
|
|
89
|
+
emitted.add(item.runId);
|
|
90
|
+
rows.push({ type: "run", runId: item.runId, count: runs.get(item.runId)?.length ?? 1 });
|
|
91
|
+
for (const child of runs.get(item.runId) ?? []) rows.push({ type: "task", item: child, indent: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return rows;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function filterTaskItems(items: readonly WorkItem[], query: string): WorkItem[] {
|
|
98
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
99
|
+
if (!needle) return [...items];
|
|
100
|
+
return items.filter((item) =>
|
|
101
|
+
[item.id, item.kind, item.status, item.title, item.runId, item.endReason, item.error]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.some((value) => value!.toLocaleLowerCase().includes(needle)),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function itemFromTask(task: TaskRecord): WorkItem {
|
|
108
|
+
return {
|
|
109
|
+
id: task.task_id,
|
|
110
|
+
kind: task.kind === "monitor" ? "monitor" : "shell",
|
|
111
|
+
status: task.status,
|
|
112
|
+
title: task.command.replace(/\s+/g, " ").trim() || task.task_id,
|
|
113
|
+
command: task.command,
|
|
114
|
+
cwd: task.cwd,
|
|
115
|
+
startedAt: task.started_at,
|
|
116
|
+
...(task.ended_at !== null ? { endedAt: task.ended_at } : {}),
|
|
117
|
+
exitCode: task.exit_code,
|
|
118
|
+
...(task.signal ? { signal: task.signal } : {}),
|
|
119
|
+
...(task.end_reason ? { endReason: task.end_reason } : {}),
|
|
120
|
+
outputPath: task.output_path,
|
|
121
|
+
stderrPath: stderrPathFor(task.output_path),
|
|
122
|
+
countsAsWorker: task.kind === "shell" && task.backgrounded_at !== undefined,
|
|
123
|
+
...(task.origin?.via === "child-bash" ? { runId: task.origin.run_id } : {}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mergeItems(primary: readonly WorkItem[], extra: readonly WorkItem[]): WorkItem[] {
|
|
128
|
+
const merged = new Map(extra.map((item) => [item.id, item]));
|
|
129
|
+
for (const item of primary) merged.set(item.id, item);
|
|
130
|
+
return [...merged.values()].sort((a, b) => {
|
|
131
|
+
const aLive = isLive(a);
|
|
132
|
+
const bLive = isLive(b);
|
|
133
|
+
return aLive !== bLive ? (aLive ? -1 : 1) : b.startedAt - a.startedAt;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function formatWorkRows(
|
|
138
|
+
items: readonly WorkItem[],
|
|
139
|
+
selectedId: string | undefined,
|
|
140
|
+
now: number,
|
|
141
|
+
width: number,
|
|
142
|
+
): string[] {
|
|
143
|
+
if (items.length === 0) return [truncateToWidth("No background tasks.", Math.max(1, width), "")];
|
|
144
|
+
const selected = selectedIndex(items, selectedId);
|
|
145
|
+
return items.map((item, index) => {
|
|
146
|
+
const mark = index === selected ? "▸" : " ";
|
|
147
|
+
const { glyph } = statusGlyph(item.status);
|
|
148
|
+
const age = formatAge(item.startedAt, item.endedAt, now);
|
|
149
|
+
const terminal = item.exitCode !== undefined && item.exitCode !== null
|
|
150
|
+
? ` exit=${item.exitCode}`
|
|
151
|
+
: item.signal ? ` ${item.signal}` : item.endReason ? ` ${item.endReason}` : "";
|
|
152
|
+
const title = item.error ? `${item.title} · ${item.error}` : item.title;
|
|
153
|
+
const kind = width >= 60 ? `${item.kind.padEnd(7)} ` : "";
|
|
154
|
+
return truncateToWidth(`${mark} ${glyph} ${kind}${item.status.padEnd(10)} ${age.padEnd(6)}${terminal} ${title}`, Math.max(1, width), "…");
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function selectorHint(theme: { fg(color: string, text: string): string }): string {
|
|
159
|
+
try {
|
|
160
|
+
return [
|
|
161
|
+
keyHint("tui.select.up", ""),
|
|
162
|
+
keyHint("tui.select.down", ""),
|
|
163
|
+
keyHint("tui.select.confirm", "view"),
|
|
164
|
+
rawKeyHint("ctrl+x", "stop"),
|
|
165
|
+
keyHint("tui.select.cancel", "close"),
|
|
166
|
+
].join(" ");
|
|
167
|
+
} catch {
|
|
168
|
+
return theme.fg("dim", "↑↓ select · Enter view · Ctrl+X stop · Esc close · type to filter");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface TaskListChoice {
|
|
173
|
+
action: "close" | "view" | "stop" | "already-finished";
|
|
174
|
+
id?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function openTasksUi(ctx: ExtensionContext, deps: TasksCommandDeps): Promise<void> {
|
|
178
|
+
await deps.syncWithManager?.();
|
|
179
|
+
const index = deps.getIndex();
|
|
180
|
+
const clock = deps.clock ?? realClock;
|
|
181
|
+
const currentItems = () => index?.list(clock.now()) ?? [];
|
|
182
|
+
const loadAll = async (): Promise<WorkItem[]> => {
|
|
183
|
+
const client = deps.getClient();
|
|
184
|
+
if (!client || !(await client.ensureAvailable())) throw new Error("pi-famulus is unavailable");
|
|
185
|
+
return (await client.list(true)).map(itemFromTask);
|
|
186
|
+
};
|
|
187
|
+
if (!ctx.hasUI) {
|
|
188
|
+
const list = currentItems();
|
|
189
|
+
ctx.ui.notify(list.length ? formatWorkRows(list, undefined, clock.now(), 100).join("\n") : "No background tasks.", "info");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!index) {
|
|
193
|
+
ctx.ui.notify("No background tasks.", "info");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const manager = deps.getClient();
|
|
197
|
+
if (currentItems().length === 0 && !(manager?.isAvailable() ?? false)) {
|
|
198
|
+
ctx.ui.notify(
|
|
199
|
+
"No background tasks. pi-famulus is unavailable, so bash runs locally and nothing is backgrounded (see the startup warning).",
|
|
200
|
+
"warning",
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let selectedId: string | undefined;
|
|
206
|
+
for (;;) {
|
|
207
|
+
const choice = await showTaskList(
|
|
208
|
+
ctx,
|
|
209
|
+
currentItems,
|
|
210
|
+
(cb) => index.onChange(cb),
|
|
211
|
+
selectedId,
|
|
212
|
+
clock,
|
|
213
|
+
loadAll,
|
|
214
|
+
deps,
|
|
215
|
+
);
|
|
216
|
+
if (!choice || choice.action === "close") return;
|
|
217
|
+
selectedId = choice.id ?? selectedId;
|
|
218
|
+
if (!choice.id) continue;
|
|
219
|
+
const item = index.get(choice.id);
|
|
220
|
+
if (choice.action === "already-finished") {
|
|
221
|
+
ctx.ui.notify(`${item?.id ?? choice.id} already finished`, "info");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (choice.action === "stop") {
|
|
225
|
+
try {
|
|
226
|
+
if (item) await stopItem(item, deps);
|
|
227
|
+
ctx.ui.notify(`Stopped ${choice.id}`, "info");
|
|
228
|
+
} catch (err) {
|
|
229
|
+
ctx.ui.notify(err instanceof Error ? err.message : String(err), "error");
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (item) await viewItem(ctx, item, deps);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function showTaskList(
|
|
238
|
+
ctx: ExtensionContext,
|
|
239
|
+
getItems: () => WorkItem[],
|
|
240
|
+
subscribe: (cb: () => void) => () => void,
|
|
241
|
+
initialSelectedId: string | undefined,
|
|
242
|
+
clock: Clock,
|
|
243
|
+
loadAll: () => Promise<WorkItem[]>,
|
|
244
|
+
deps: TasksCommandDeps,
|
|
245
|
+
): Promise<TaskListChoice | undefined> {
|
|
246
|
+
const piTui = loadPiTui();
|
|
247
|
+
if (!piTui) {
|
|
248
|
+
const items = getItems();
|
|
249
|
+
notifyPlainFallback(ctx.ui.notify.bind(ctx.ui), "Background tasks", items.length ? formatWorkRows(items, initialSelectedId, clock.now(), 100).join("\n") : "No background tasks.");
|
|
250
|
+
return { action: "close" };
|
|
251
|
+
}
|
|
252
|
+
const { matchesKey } = piTui;
|
|
253
|
+
|
|
254
|
+
return ctx.ui.custom<TaskListChoice | undefined>(
|
|
255
|
+
(tui, theme, kb, done) => {
|
|
256
|
+
let selectedId = initialSelectedId;
|
|
257
|
+
let filter = "";
|
|
258
|
+
let scope: "active-recent" | "all" = "active-recent";
|
|
259
|
+
let allItems: WorkItem[] = [];
|
|
260
|
+
let allLoaded = false;
|
|
261
|
+
let loadingAll = false;
|
|
262
|
+
let confirmId: string | undefined;
|
|
263
|
+
let hint = "";
|
|
264
|
+
const unsub = subscribe(() => tui.requestRender());
|
|
265
|
+
const ageTimer = clock.setInterval(() => tui.requestRender(), AGE_TICK_MS);
|
|
266
|
+
clock.unref?.(ageTimer);
|
|
267
|
+
const border = new DynamicBorder((text) => theme.fg("border", text));
|
|
268
|
+
const pageSize = () => Math.max(1, (tui.terminal?.rows ?? 24) - 8);
|
|
269
|
+
const matches = (data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel") => {
|
|
270
|
+
try { return kb.matches(data, id); } catch { return false; }
|
|
271
|
+
};
|
|
272
|
+
const visibleItems = () => filterTaskItems(
|
|
273
|
+
scope === "all" ? mergeItems(getItems(), allItems) : getItems(),
|
|
274
|
+
filter,
|
|
275
|
+
);
|
|
276
|
+
const choose = (id: string | undefined) => {
|
|
277
|
+
selectedId = id;
|
|
278
|
+
hint = "";
|
|
279
|
+
tui.requestRender();
|
|
280
|
+
};
|
|
281
|
+
const requestStop = async () => {
|
|
282
|
+
const item = visibleItems().find((candidate) => candidate.id === confirmId);
|
|
283
|
+
if (!item || !isLive(item)) {
|
|
284
|
+
hint = "Already finished — select another task.";
|
|
285
|
+
confirmId = undefined;
|
|
286
|
+
tui.requestRender();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
await stopItem(item, deps);
|
|
291
|
+
hint = `Stop requested for ${item.id}.`;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
hint = err instanceof Error ? err.message : String(err);
|
|
294
|
+
}
|
|
295
|
+
confirmId = undefined;
|
|
296
|
+
tui.requestRender();
|
|
297
|
+
};
|
|
298
|
+
const startLoadingAll = () => {
|
|
299
|
+
if (allLoaded || loadingAll) return;
|
|
300
|
+
loadingAll = true;
|
|
301
|
+
hint = "Loading manager history…";
|
|
302
|
+
tui.requestRender();
|
|
303
|
+
void loadAll().then((items) => {
|
|
304
|
+
allItems = items;
|
|
305
|
+
allLoaded = true;
|
|
306
|
+
hint = "";
|
|
307
|
+
}).catch((err) => {
|
|
308
|
+
hint = `Manager unavailable: ${err instanceof Error ? err.message : String(err)}`;
|
|
309
|
+
}).finally(() => {
|
|
310
|
+
loadingAll = false;
|
|
311
|
+
tui.requestRender();
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
return {
|
|
315
|
+
render(width: number) {
|
|
316
|
+
const items = visibleItems();
|
|
317
|
+
if (!items.some((item) => item.id === selectedId)) selectedId = items[0]?.id;
|
|
318
|
+
const rows = groupTaskRows(items);
|
|
319
|
+
const selectedRow = Math.max(0, rows.findIndex((row) => row.type === "task" && row.item.id === selectedId));
|
|
320
|
+
const selectedTask = selectedIndex(items, selectedId);
|
|
321
|
+
const height = pageSize();
|
|
322
|
+
const start = Math.max(0, Math.min(selectedRow, rows.length - height));
|
|
323
|
+
const window = rows.slice(start, start + height);
|
|
324
|
+
const inner = Math.max(1, width);
|
|
325
|
+
const active = items.filter(isLive).length;
|
|
326
|
+
const scopeLabel = scope === "all" ? "All" : "Active + recent";
|
|
327
|
+
const titleText = `Tasks ${selectedTask + (items.length ? 1 : 0)}/${items.length} · ${active} active · ${scopeLabel}`;
|
|
328
|
+
const title = truncateToWidth(theme.fg("accent", titleText), inner, "…");
|
|
329
|
+
const filterLine = truncateToWidth(theme.fg("dim", `Filter: ${filter || "(type to filter)"} · Tab scope`), inner, "…");
|
|
330
|
+
const hintLine = hint ? truncateToWidth(theme.fg(hint.startsWith("Already") ? "dim" : "warning", hint), inner, "…") : "";
|
|
331
|
+
const confirmLine = confirmId
|
|
332
|
+
? truncateToWidth(theme.fg("error", `Stop ${confirmId}? Enter confirm · Esc cancel`), inner, "…")
|
|
333
|
+
: "";
|
|
334
|
+
const body = window.map((row) => {
|
|
335
|
+
if (row.type === "run") return truncateToWidth(theme.fg("muted", `Run ${row.runId} · ${row.count} child${row.count === 1 ? "" : "ren"}`), inner, "…");
|
|
336
|
+
const selected = row.item.id === selectedId;
|
|
337
|
+
const marker = selected ? "▸" : " ";
|
|
338
|
+
const { color, glyph } = statusGlyph(row.item.status);
|
|
339
|
+
const age = formatAge(row.item.startedAt, row.item.endedAt, clock.now());
|
|
340
|
+
const terminal = row.item.exitCode !== undefined && row.item.exitCode !== null
|
|
341
|
+
? ` exit=${row.item.exitCode}`
|
|
342
|
+
: row.item.signal ? ` ${row.item.signal}` : row.item.endReason ? ` ${row.item.endReason}` : "";
|
|
343
|
+
const kind = width >= 60 ? `${row.item.kind.padEnd(7)} ` : "";
|
|
344
|
+
const title = row.item.error ? `${row.item.title} · ${row.item.error}` : row.item.title;
|
|
345
|
+
const prefix = row.indent ? " " : "";
|
|
346
|
+
const text = `${prefix}${marker} ${theme.fg(color as never, glyph)} ${kind}${row.item.status.padEnd(10)} ${age.padEnd(6)}${terminal} ${title}`;
|
|
347
|
+
return fitLines(truncateToWidth(text, inner, "…"), inner);
|
|
348
|
+
}).flat();
|
|
349
|
+
const page = theme.fg("dim", `(${items.length ? selectedTask + 1 : 0}/${items.length}) PgUp/PgDn Home/End · Ctrl+X stop · Enter view`);
|
|
350
|
+
return [...border.render(inner), title, filterLine, ...(confirmLine ? [confirmLine] : []), ...(hintLine ? [hintLine] : []), ...body, page, ...border.render(inner)];
|
|
351
|
+
},
|
|
352
|
+
invalidate() { border.invalidate(); },
|
|
353
|
+
handleInput(data: string) {
|
|
354
|
+
const items = visibleItems();
|
|
355
|
+
const current = items[selectedIndex(items, selectedId)];
|
|
356
|
+
if (matchesKey(data, "escape") || data === "\u001b") {
|
|
357
|
+
if (confirmId) { confirmId = undefined; hint = "Stop cancelled."; tui.requestRender(); return; }
|
|
358
|
+
if (filter) { filter = ""; hint = ""; tui.requestRender(); return; }
|
|
359
|
+
done({ action: "close", id: selectedId });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (confirmId) {
|
|
363
|
+
if (matchesKey(data, "tui.select.confirm") || data === "\r" || data === "\n") { void requestStop(); return; }
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (matches(data, "tui.select.up")) { choose(moveSelection(items, selectedId, -1) ?? selectedId); return; }
|
|
367
|
+
if (matches(data, "tui.select.down")) { choose(moveSelection(items, selectedId, 1) ?? selectedId); return; }
|
|
368
|
+
if (matches(data, "tui.select.confirm") || data === "\r" || data === "\n") {
|
|
369
|
+
if (current) done({ action: "view", id: current.id });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (matchesKey(data, "ctrl+x") || data === "\u0018") {
|
|
373
|
+
if (current && !isLive(current)) { hint = "Already finished — select another task."; tui.requestRender(); return; }
|
|
374
|
+
if (current) { confirmId = current.id; hint = ""; tui.requestRender(); }
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (matchesKey(data, "tab") || data === "\t") {
|
|
378
|
+
scope = scope === "active-recent" ? "all" : "active-recent";
|
|
379
|
+
if (scope === "all") startLoadingAll();
|
|
380
|
+
selectedId = visibleItems()[0]?.id;
|
|
381
|
+
tui.requestRender();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (matchesKey(data, "pageUp")) { choose(items[Math.max(0, selectedIndex(items, selectedId) - Math.max(1, pageSize() - 2))]?.id); return; }
|
|
385
|
+
if (matchesKey(data, "pageDown")) { choose(items[Math.min(items.length - 1, selectedIndex(items, selectedId) + Math.max(1, pageSize() - 2))]?.id); return; }
|
|
386
|
+
if (matchesKey(data, "home")) { choose(items[0]?.id); return; }
|
|
387
|
+
if (matchesKey(data, "end")) { choose(items.at(-1)?.id); return; }
|
|
388
|
+
if (data === "\u007f" || data === "\b") { filter = filter.slice(0, -1); selectedId = visibleItems()[0]?.id; hint = ""; tui.requestRender(); return; }
|
|
389
|
+
if (data === "\u0015") { filter = ""; selectedId = visibleItems()[0]?.id; hint = ""; tui.requestRender(); return; }
|
|
390
|
+
if (!data.startsWith("\u001b") && [...data].every((char) => char >= " " && char !== "\u007f")) {
|
|
391
|
+
filter += data;
|
|
392
|
+
selectedId = visibleItems()[0]?.id;
|
|
393
|
+
hint = "";
|
|
394
|
+
tui.requestRender();
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
dispose() { clock.clearInterval(ageTimer); unsub(); },
|
|
398
|
+
};
|
|
399
|
+
},
|
|
400
|
+
// Bottom sheet over the editor, where the user typed /tasks; the list is
|
|
401
|
+
// content-sized, so a top anchor left it at the far end of the screen.
|
|
402
|
+
{ overlay: true, overlayOptions: { anchor: "bottom-left", width: "100%", maxHeight: "100%", margin: 0 } },
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function stopItem(item: WorkItem, deps: TasksCommandDeps): Promise<void> {
|
|
407
|
+
if (item.kind === "agent") {
|
|
408
|
+
const handle = deps.getRegistry()?.handle(item.id);
|
|
409
|
+
if (!handle) throw new Error(`no live subagent ${item.id}`);
|
|
410
|
+
await handle.interrupt();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const client = deps.getClient();
|
|
414
|
+
if (!client || !(await client.ensureAvailable())) throw new Error("pi-famulus is not available");
|
|
415
|
+
await client.stop(item.id, "tui");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function taskDetailHeader(item: WorkItem, now: number): string {
|
|
419
|
+
const outcome = item.exitCode !== undefined && item.exitCode !== null
|
|
420
|
+
? `exit ${item.exitCode}`
|
|
421
|
+
: item.signal ?? item.endReason ?? item.status;
|
|
422
|
+
return `${item.id} · ${item.kind} · ${outcome} · ${formatAge(item.startedAt, item.endedAt, now)} · ${item.cwd ?? "cwd unavailable"}\n$ ${item.command ?? item.title}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function resolveTaskOutputPath(item: WorkItem, home: string | undefined, sessionId: string | undefined): string {
|
|
426
|
+
return item.outputPath || (home && sessionId ? taskOutputPath(home, sessionId, item.id) : "");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function taskDetailInfo(item: WorkItem, now: number): string {
|
|
430
|
+
const lines = [taskDetailHeader(item, now)];
|
|
431
|
+
if (item.kind === "monitor" && item.command && item.title !== item.command) lines.push(`Watching: ${item.title}`);
|
|
432
|
+
if (item.endReason) lines.push(`Ended: ${item.endReason}`);
|
|
433
|
+
if (item.outputPath) lines.push(`Output: ${item.outputPath}`);
|
|
434
|
+
if (item.stderrPath) lines.push(`Stderr: ${item.stderrPath}`);
|
|
435
|
+
if (item.runId) lines.push(`Run: ${item.runId}`);
|
|
436
|
+
if (item.agent) lines.push(`Agent: ${item.agent}`);
|
|
437
|
+
if (item.model) lines.push(`Model: ${item.model}`);
|
|
438
|
+
if (item.prompt) lines.push(`Task prompt (user-authored):\n${item.prompt}`);
|
|
439
|
+
if (item.preamble) lines.push(`Agent preamble (injected):\n${item.preamble}`);
|
|
440
|
+
if (item.error) lines.push(`Error: ${item.error}`);
|
|
441
|
+
return lines.join("\n");
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function isLiveStatus(status: string): boolean {
|
|
445
|
+
return status === "running" || status === "pending";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Tab bodies for a shell or monitor. `getItem` is read on every poll so the
|
|
450
|
+
* header follows the task (a view opened while it ran must not keep saying
|
|
451
|
+
* "running"), and an empty file says why it is empty.
|
|
452
|
+
*/
|
|
453
|
+
export function taskDetailTabs(
|
|
454
|
+
getItem: () => WorkItem,
|
|
455
|
+
paths: { outputPath: string; stderrPath: string },
|
|
456
|
+
now: () => number,
|
|
457
|
+
): { output: () => string; stderr: () => string; info: () => string } {
|
|
458
|
+
const body = (path: string, emptyLive: string, emptyDone: string) => {
|
|
459
|
+
const text = readTaskFileTailCached(path);
|
|
460
|
+
if (text !== "(empty)") return text;
|
|
461
|
+
return isLiveStatus(getItem().status) ? emptyLive : emptyDone;
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
output: () => `${taskDetailHeader(getItem(), now())}\n\n${body(paths.outputPath, "(no output yet)", "(no output; the command printed nothing)")}`,
|
|
465
|
+
stderr: () => `${taskDetailHeader(getItem(), now())}\n\n${body(paths.stderrPath, "(nothing on stderr yet)", "(nothing on stderr)")}`,
|
|
466
|
+
info: () => taskDetailInfo({ ...getItem(), ...paths }, now()),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function viewItem(ctx: ExtensionContext, item: WorkItem, deps: TasksCommandDeps): Promise<void> {
|
|
471
|
+
if (!ctx.hasUI) return;
|
|
472
|
+
const clock = deps.clock ?? realClock;
|
|
473
|
+
if (item.kind === "agent") {
|
|
474
|
+
const handle = () => deps.getRegistry()?.handle(item.id);
|
|
475
|
+
const conversation = () => handle()?.conversation() ?? [];
|
|
476
|
+
const conversationText = () => {
|
|
477
|
+
const turns = conversation();
|
|
478
|
+
return turns.length > 0 ? formatConversation(turns) : item.prompt ? `Task prompt:\n${item.prompt}` : "(no conversation yet)";
|
|
479
|
+
};
|
|
480
|
+
const resultText = () => {
|
|
481
|
+
const latest = [...conversation()].reverse().find((turn) => turn.role === "assistant")?.text;
|
|
482
|
+
return latest ?? item.text ?? "(no result yet)";
|
|
483
|
+
};
|
|
484
|
+
const infoText = () => taskDetailInfo(deps.getIndex()?.get(item.id) ?? item, clock.now());
|
|
485
|
+
try {
|
|
486
|
+
await showScrollDetail(ctx.ui, {
|
|
487
|
+
title: `subagent ${item.title}`,
|
|
488
|
+
tabs: {
|
|
489
|
+
conversation: conversationText,
|
|
490
|
+
result: resultText,
|
|
491
|
+
info: infoText,
|
|
492
|
+
},
|
|
493
|
+
pollMs: 500,
|
|
494
|
+
clock: deps.clock,
|
|
495
|
+
});
|
|
496
|
+
} catch {
|
|
497
|
+
notifyPlainFallback(ctx.ui.notify.bind(ctx.ui), item.title, `${infoText()}\n\n${conversationText()}`);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const outputPath = resolveTaskOutputPath(item, deps.home, deps.sessionId?.());
|
|
502
|
+
const stderrPath = item.stderrPath || stderrPathFor(outputPath);
|
|
503
|
+
const tabs = taskDetailTabs(() => deps.getIndex()?.get(item.id) ?? item, { outputPath, stderrPath }, () => clock.now());
|
|
504
|
+
const infoText = tabs.info;
|
|
505
|
+
try {
|
|
506
|
+
await showScrollDetail(ctx.ui, {
|
|
507
|
+
title: `${item.kind} ${item.title}`,
|
|
508
|
+
tabs,
|
|
509
|
+
pollMs: 500,
|
|
510
|
+
clock: deps.clock,
|
|
511
|
+
});
|
|
512
|
+
} catch {
|
|
513
|
+
notifyPlainFallback(ctx.ui.notify.bind(ctx.ui), item.title, `${infoText()}\n\n${readTaskFileTailCached(outputPath)}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fitLines } from "./pi-tui-load";
|
|
2
|
+
|
|
3
|
+
export function renderToolLines(lines: string[], width: number): string[] {
|
|
4
|
+
const limit = Math.max(1, width);
|
|
5
|
+
const out: string[] = [];
|
|
6
|
+
for (const line of lines) out.push(...fitLines(line, limit));
|
|
7
|
+
return out.length > 0 ? out : [""];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function toolComponent(lines: string[]): { render(width: number): string[]; invalidate(): void } {
|
|
11
|
+
return {
|
|
12
|
+
render(width: number) {
|
|
13
|
+
return renderToolLines(lines, width);
|
|
14
|
+
},
|
|
15
|
+
invalidate() {},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function statusGlyph(status: string | undefined, isError = false): { color: string; glyph: string } {
|
|
20
|
+
if (isError || status === "failed" || status === "killed" || status === "orphaned" || status === "interrupted") {
|
|
21
|
+
return { color: "error", glyph: "✗" };
|
|
22
|
+
}
|
|
23
|
+
if (status === "running") return { color: "accent", glyph: "●" };
|
|
24
|
+
if (status === "pending") return { color: "dim", glyph: "○" };
|
|
25
|
+
if (status === "partial" || status === "timeout" || status === "stopped") {
|
|
26
|
+
return { color: "warning", glyph: "■" };
|
|
27
|
+
}
|
|
28
|
+
return { color: "success", glyph: "✓" };
|
|
29
|
+
}
|