shariq-pi-extensions 0.2.26 → 0.2.27
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/docs/ARCHITECTURE.md +1 -1
- package/docs/EXTENSIONS.md +4 -4
- package/extensions/background-terminals/README.md +1 -1
- package/extensions/background-terminals/index.ts +68 -4
- package/extensions/git-info/index.ts +4 -6
- package/extensions/orchestration/README.md +2 -2
- package/extensions/orchestration/dashboard.ts +56 -30
- package/extensions/orchestration/index.ts +80 -33
- package/extensions/performance-status/index.ts +23 -0
- package/extensions/pi-memory/index.ts +2 -4
- package/extensions/shared/README.md +2 -0
- package/extensions/shared/activity-dock.ts +100 -0
- package/extensions/shared/tool-card.ts +39 -0
- package/extensions/smart-compaction/index.ts +5 -2
- package/extensions/subagents/README.md +1 -1
- package/extensions/subagents/index.ts +114 -6
- package/extensions/subagents/src/ui/takeover.ts +17 -2
- package/extensions/task-list/README.md +1 -1
- package/extensions/task-list/index.ts +21 -30
- package/package.json +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -17,7 +17,7 @@ docs/ package-level maintenance documentation
|
|
|
17
17
|
scripts/ repository validation helpers
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Extension entrypoints register tools, commands, providers, events, or UI. Shared code stays in `extensions/shared` only when more than one extension owns the behavior. Provider-specific protocol and authentication code remains inside its provider directory. The background-terminal, orchestration, and subagent skills ship beside their extensions so a new machine receives the required lifecycle guidance with the tools.
|
|
20
|
+
Extension entrypoints register tools, commands, providers, events, or UI. Shared code stays in `extensions/shared` only when more than one extension owns the behavior. The shared activity dock combines active Task List, terminal, subagent, and orchestration rows under one bounded widget; shared tool cards keep lifecycle operations compact in the timeline while preserving expanded output. Provider-specific protocol and authentication code remains inside its provider directory. The background-terminal, orchestration, and subagent skills ship beside their extensions so a new machine receives the required lifecycle guidance with the tools.
|
|
21
21
|
|
|
22
22
|
## Dependency model
|
|
23
23
|
|
package/docs/EXTENSIONS.md
CHANGED
|
@@ -44,13 +44,13 @@ State is stored in Pi session entries and reconstructed on resume, reload, and t
|
|
|
44
44
|
|
|
45
45
|
The subagent extension runs flat Pi child agents with profiles, capability policies, continuation, result delivery, optional worktrees, pre-warmed task dispatch, instant cascading cancellation, cross-session persistence, and a dashboard. Configuration lives in `<agent-dir>/subagents.json`; trusted projects may override it through their Pi config directory. The configured concurrency ceiling is 50.
|
|
46
46
|
|
|
47
|
-
The extension supplies tools including `spawn_agent`, `task`, `check_agent`, `list_agents`, `wait_agent`, `send_message`, `close_agent`, `reply_question`, and `apply_agent_changes`. All completed subagent snapshots and transcripts persist across Pi restarts (`<agent-dir>/subagents/runs/`), allowing `resume_from` to resume completed workers at any point. Interruption immediately cascades across all child fibers in `<10ms`. Child settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with the summary guaranteed in model context and never rendered as user-authored or follow-up input; status tools are for explicit inspection, not waiting.
|
|
47
|
+
The extension supplies tools including `spawn_agent`, `task`, `check_agent`, `list_agents`, `wait_agent`, `send_message`, `close_agent`, `reply_question`, and `apply_agent_changes`. These lifecycle operations render as compact expandable main-chat cards; running children share the bounded Active work dock and completed counts no longer occupy footer space after settlement. All completed subagent snapshots and transcripts persist across Pi restarts (`<agent-dir>/subagents/runs/`), allowing `resume_from` to resume completed workers at any point. Interruption immediately cascades across all child fibers in `<10ms`. Child settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with the summary guaranteed in model context and never rendered as user-authored or follow-up input; status tools are for explicit inspection, not waiting.
|
|
48
48
|
|
|
49
49
|
### [Orchestration](../extensions/orchestration/README.md)
|
|
50
50
|
|
|
51
51
|
The Orchestration extension coordinates explicitly requested large tasks through a dedicated orchestrator plus configurable explorer, frontend, backend, general-worker, and reviewer models. `/orchestration` opens the dashboard and `/orchestration settings` configures global role models. It reuses the canonical Subagents runtime, maintains a 10-worker pool per project, isolates Git writers in task worktrees, resumes the original worker for substantial review fixes, and applies final reviewed changes without committing or pushing.
|
|
52
52
|
|
|
53
|
-
The model-facing `create_orchestration` tool starts planning only after an explicit orchestration request. `get_orchestration` reports status without advancing work. Interrupted runs recover paused under `<agent-dir>/orchestration/`.
|
|
53
|
+
The model-facing `create_orchestration` tool starts planning only after an explicit orchestration request. `get_orchestration` reports status without advancing work. Both use compact expandable timeline cards, active runs share the Active work dock, and footer states distinguish plan-ready, blocked, running, and paused work. Dashboard editors and confirmations run outside the live overlay to avoid nested-input stalls. Interrupted runs recover paused under `<agent-dir>/orchestration/`.
|
|
54
54
|
|
|
55
55
|
### [Smart Compaction](../extensions/smart-compaction/README.md)
|
|
56
56
|
|
|
@@ -71,7 +71,7 @@ Key capabilities include:
|
|
|
71
71
|
|
|
72
72
|
Managed PTYs support servers, watchers, long builds, downloads, and interactive processes. The extension tracks up to eight concurrent terminals, retains bounded output, stores full logs in restrictive temporary directories, and stops process groups during shutdown or reload.
|
|
73
73
|
|
|
74
|
-
Its tools are `start_terminal`, `read_terminal`, `write_terminal`, `list_terminals`, and `stop_terminal`. A model-started terminal keeps completion or failure in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input. Reading a terminal does not suppress that delivery; agents should inspect only for explicit progress requests or immediate interaction.
|
|
74
|
+
Its tools are `start_terminal`, `read_terminal`, `write_terminal`, `list_terminals`, and `stop_terminal`. All use compact expandable main-chat cards, while running PTYs share the bounded Active work dock with elapsed time and a latest-output preview. A model-started terminal keeps completion or failure in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input. Reading a terminal does not suppress that delivery; agents should inspect only for explicit progress requests or immediate interaction.
|
|
75
75
|
|
|
76
76
|
## Web access
|
|
77
77
|
|
|
@@ -91,7 +91,7 @@ Displays the estimated prompt and session contribution to the active context win
|
|
|
91
91
|
|
|
92
92
|
### [Performance status](../extensions/performance-status/README.md)
|
|
93
93
|
|
|
94
|
-
Adds a responsive footer-area status row
|
|
94
|
+
Adds a responsive footer-area status row while each assistant message is active and for an eight-second completion linger, then clears it to avoid permanent footer noise. Live TPS/output are marked as estimates; final TPS uses conventional decode throughput from first streamed token to message completion. TTFT and total elapsed time separately expose provider latency, prefill, and hidden reasoning; output tokens and active tools remain separate.
|
|
95
95
|
|
|
96
96
|
### [Git info](../extensions/git-info/README.md)
|
|
97
97
|
|
|
@@ -10,7 +10,7 @@ Session-scoped background pseudo-terminals for Pi. The extension combines Codex-
|
|
|
10
10
|
- `list_terminals` — list running and settled terminals.
|
|
11
11
|
- `stop_terminal` — stop complete process groups with TERM-to-KILL escalation.
|
|
12
12
|
|
|
13
|
-
Each output response carries a byte cursor. Pass it to the next read/write operation to avoid repeating output. Long or uncertain commands should use `start_terminal` instead of a large blocking `bash` timeout. Settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input, so the parent can continue other work or end its turn rather than poll.
|
|
13
|
+
Each output response carries a byte cursor. Pass it to the next read/write operation to avoid repeating output. Terminal lifecycle tools render as compact main-chat cards with expandable output, and running terminals appear in the shared bounded **Active work** dock with elapsed time and the latest output line. Long or uncertain commands should use `start_terminal` instead of a large blocking `bash` timeout. Settlement stays in a private extension queue while the parent is active, then starts one custom-result turn at Pi's safe idle edge with bounded output guaranteed in model context and never rendered as user-authored or follow-up input, so the parent can continue other work or end its turn rather than poll.
|
|
14
14
|
|
|
15
15
|
## User interface
|
|
16
16
|
|
|
@@ -3,23 +3,27 @@ import * as path from "node:path";
|
|
|
3
3
|
import type {
|
|
4
4
|
ExtensionAPI,
|
|
5
5
|
ExtensionCommandContext,
|
|
6
|
+
ExtensionContext,
|
|
6
7
|
ExtensionUIContext,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { formatSize, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
10
11
|
import { Type } from "typebox";
|
|
12
|
+
import { clearActivitySource, setActivitySource } from "../shared/activity-dock.ts";
|
|
11
13
|
import { settlementDelivery } from "../shared/settlement-delivery.ts";
|
|
12
|
-
import {
|
|
14
|
+
import { toolCallCard, toolResultCard } from "../shared/tool-card.ts";
|
|
15
|
+
import { formatDurationMs, oneLine, sanitizeTerminalText, stateLabel } from "../shared/tui-dashboard.ts";
|
|
13
16
|
import { TerminalManager, MAX_RUNNING_TERMINALS } from "./src/manager.ts";
|
|
14
17
|
import {
|
|
15
18
|
formatCompletion,
|
|
16
19
|
formatReadResult,
|
|
17
20
|
formatTerminal,
|
|
18
21
|
} from "./src/presentation.ts";
|
|
19
|
-
import type
|
|
22
|
+
import { terminalElapsedMs, type TerminalReadResult, type TerminalSnapshot } from "./src/types.ts";
|
|
20
23
|
import { openTerminalDashboard } from "./src/ui.ts";
|
|
21
24
|
|
|
22
25
|
const STATUS_KEY = "background-terminals";
|
|
26
|
+
const ACTIVITY_SOURCE = "background-terminals";
|
|
23
27
|
|
|
24
28
|
function titleFrom(command: string, title?: string): string {
|
|
25
29
|
const requested = oneLine(title ?? "").slice(0, 100);
|
|
@@ -39,6 +43,7 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
39
43
|
const deliverSettlement = settlementDelivery(pi);
|
|
40
44
|
let manager: TerminalManager | undefined;
|
|
41
45
|
let ui: ExtensionUIContext | undefined;
|
|
46
|
+
let activityContext: ExtensionContext | undefined;
|
|
42
47
|
let unsubscribe: (() => void) | undefined;
|
|
43
48
|
let lastStatus = "";
|
|
44
49
|
const pendingResults = new Map<string, TerminalSnapshot>();
|
|
@@ -79,6 +84,21 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
79
84
|
const next = running > 0
|
|
80
85
|
? `${running} background terminal${running === 1 ? "" : "s"} · /term`
|
|
81
86
|
: "";
|
|
87
|
+
if (activityContext) {
|
|
88
|
+
setActivitySource(activityContext, ACTIVITY_SOURCE, terminals
|
|
89
|
+
.filter((terminal) => terminal.status === "running")
|
|
90
|
+
.map((terminal) => {
|
|
91
|
+
const latest = sanitizeTerminalText(terminal.output.text).split(/\r?\n/).filter(Boolean).at(-1);
|
|
92
|
+
return {
|
|
93
|
+
id: terminal.id,
|
|
94
|
+
label: "Terminal",
|
|
95
|
+
title: terminal.title,
|
|
96
|
+
detail: `${formatDurationMs(terminalElapsedMs(terminal))}${latest ? ` · ${oneLine(latest)}` : ""}`,
|
|
97
|
+
state: "active" as const,
|
|
98
|
+
priority: 50,
|
|
99
|
+
};
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
82
102
|
if (next === lastStatus) return;
|
|
83
103
|
lastStatus = next;
|
|
84
104
|
if (!next) ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -108,7 +128,11 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
pi.on("session_start", (_event, ctx) => {
|
|
111
|
-
if (ctx.hasUI)
|
|
131
|
+
if (ctx.hasUI) {
|
|
132
|
+
ui = ctx.ui;
|
|
133
|
+
activityContext = ctx;
|
|
134
|
+
updateStatus();
|
|
135
|
+
}
|
|
112
136
|
});
|
|
113
137
|
|
|
114
138
|
pi.on("session_shutdown", async () => {
|
|
@@ -118,6 +142,8 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
118
142
|
unsubscribe?.();
|
|
119
143
|
unsubscribe = undefined;
|
|
120
144
|
ui?.setStatus(STATUS_KEY, undefined);
|
|
145
|
+
if (activityContext) clearActivitySource(activityContext, ACTIVITY_SOURCE);
|
|
146
|
+
activityContext = undefined;
|
|
121
147
|
ui = undefined;
|
|
122
148
|
lastStatus = "";
|
|
123
149
|
const closing = manager;
|
|
@@ -236,6 +262,15 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
236
262
|
},
|
|
237
263
|
};
|
|
238
264
|
},
|
|
265
|
+
renderCall(args, theme) {
|
|
266
|
+
return new Text(toolCallCard(theme, "terminal read", args.id, `cursor ${args.cursor ?? "tail"}`), 0, 0);
|
|
267
|
+
},
|
|
268
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
269
|
+
if (isPartial) return new Text(theme.fg("warning", "Reading terminal…"), 0, 0);
|
|
270
|
+
const details = result.details as { id?: string; status?: string; cursor?: number; omittedBytes?: number } | undefined;
|
|
271
|
+
const state = details?.status === "failed" ? "error" : details?.status === "running" ? "active" : "success";
|
|
272
|
+
return new Text(toolResultCard(result, expanded, theme, state, details?.id ?? "terminal", `${details?.status ?? "unknown"} · cursor ${details?.cursor ?? "?"}${details?.omittedBytes ? ` · ${formatSize(details.omittedBytes)} omitted` : ""}`), 0, 0);
|
|
273
|
+
},
|
|
239
274
|
});
|
|
240
275
|
|
|
241
276
|
pi.registerTool({
|
|
@@ -273,6 +308,16 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
273
308
|
},
|
|
274
309
|
};
|
|
275
310
|
},
|
|
311
|
+
renderCall(args, theme) {
|
|
312
|
+
const input = args.input === "\u0003" ? "Ctrl+C" : `${Array.from(args.input ?? "").length} chars${args.press_enter === false ? "" : " + Enter"}`;
|
|
313
|
+
return new Text(toolCallCard(theme, "terminal input", args.id, input), 0, 0);
|
|
314
|
+
},
|
|
315
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
316
|
+
if (isPartial) return new Text(theme.fg("warning", "Sending terminal input…"), 0, 0);
|
|
317
|
+
const details = result.details as { id?: string; status?: string; cursor?: number } | undefined;
|
|
318
|
+
const state = details?.status === "failed" ? "error" : details?.status === "running" ? "active" : "success";
|
|
319
|
+
return new Text(toolResultCard(result, expanded, theme, state, details?.id ?? "terminal", `${details?.status ?? "unknown"} · cursor ${details?.cursor ?? "?"}`), 0, 0);
|
|
320
|
+
},
|
|
276
321
|
});
|
|
277
322
|
|
|
278
323
|
pi.registerTool({
|
|
@@ -287,6 +332,15 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
287
332
|
details: { terminals: terminals.map((terminal) => ({ id: terminal.id, title: terminal.title, status: terminal.status, pid: terminal.pid })) },
|
|
288
333
|
};
|
|
289
334
|
},
|
|
335
|
+
renderCall(_args, theme) {
|
|
336
|
+
return new Text(toolCallCard(theme, "terminals", "list managed PTYs"), 0, 0);
|
|
337
|
+
},
|
|
338
|
+
renderResult(result, { expanded }, theme) {
|
|
339
|
+
const details = result.details as { terminals?: Array<{ status?: string }> } | undefined;
|
|
340
|
+
const terminals = details?.terminals ?? [];
|
|
341
|
+
const running = terminals.filter((terminal) => terminal.status === "running").length;
|
|
342
|
+
return new Text(toolResultCard(result, expanded, theme, running ? "active" : "muted", "terminals", `${terminals.length} tracked · ${running} running`), 0, 0);
|
|
343
|
+
},
|
|
290
344
|
});
|
|
291
345
|
|
|
292
346
|
pi.registerTool({
|
|
@@ -305,6 +359,16 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
|
|
|
305
359
|
details: { terminals: terminals.map((terminal) => ({ id: terminal.id, status: terminal.status, exitCode: terminal.exitCode })) },
|
|
306
360
|
};
|
|
307
361
|
},
|
|
362
|
+
renderCall(args, theme) {
|
|
363
|
+
return new Text(toolCallCard(theme, "terminal stop", `${args.ids?.length ?? 0} terminal${args.ids?.length === 1 ? "" : "s"}`, (args.ids ?? []).join(", ")), 0, 0);
|
|
364
|
+
},
|
|
365
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
366
|
+
if (isPartial) return new Text(theme.fg("warning", "Stopping terminals…"), 0, 0);
|
|
367
|
+
const details = result.details as { terminals?: Array<{ status?: string }> } | undefined;
|
|
368
|
+
const terminals = details?.terminals ?? [];
|
|
369
|
+
const failed = terminals.some((terminal) => terminal.status === "failed");
|
|
370
|
+
return new Text(toolResultCard(result, expanded, theme, failed ? "error" : "success", "terminals stopped", `${terminals.length} terminal${terminals.length === 1 ? "" : "s"}`), 0, 0);
|
|
371
|
+
},
|
|
308
372
|
});
|
|
309
373
|
|
|
310
374
|
pi.registerMessageRenderer(
|
|
@@ -18,15 +18,13 @@ const REFRESH_DEBOUNCE_MS = 150;
|
|
|
18
18
|
function statusText(ui: ExtensionUIContext, summary: GitSummary): string | undefined {
|
|
19
19
|
if (!summary.isRepository || !summary.branch) return undefined;
|
|
20
20
|
const changed = summary.changedFiles === 0
|
|
21
|
-
? ui.theme.fg("muted", "
|
|
22
|
-
: ui.theme.fg(
|
|
23
|
-
"warning",
|
|
24
|
-
`${summary.changedFiles} changed ${summary.changedFiles === 1 ? "file" : "files"}`,
|
|
25
|
-
);
|
|
21
|
+
? ui.theme.fg("muted", "✓")
|
|
22
|
+
: ui.theme.fg("warning", `+${summary.changedFiles}`);
|
|
26
23
|
const pr = summary.pullRequest
|
|
27
24
|
? ` · ${ui.theme.fg("accent", `PR #${summary.pullRequest.number}${summary.pullRequest.draft ? " draft" : ""}`)}`
|
|
28
25
|
: "";
|
|
29
|
-
|
|
26
|
+
const command = summary.changedFiles > 0 || summary.pullRequest ? ` · ${ui.theme.fg("dim", "/lg")}` : "";
|
|
27
|
+
return `${ui.theme.fg("muted", "git")} ${ui.theme.fg("accent", summary.branch)} ${changed}${pr}${command}`;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export default function gitInfoExtension(pi: ExtensionAPI) {
|
|
@@ -31,7 +31,7 @@ Each project has up to 10 concurrent worker, explorer, or reviewer sessions. The
|
|
|
31
31
|
|
|
32
32
|
## Dashboard
|
|
33
33
|
|
|
34
|
-
`/orchestration` opens the full-screen operations dashboard.
|
|
34
|
+
Orchestration creation and inspection render as compact main-chat cards with expandable detail. Active runs appear in the shared bounded **Active work** dock; plan-ready and blocked runs are prioritized as attention states. `/orchestration` opens the full-screen operations dashboard. Dashboard actions close the overlay before opening editors, settings, or confirmation prompts, then return to the live dashboard so nested UI cannot stall.
|
|
35
35
|
|
|
36
36
|
- `j` / `k`: select a run
|
|
37
37
|
- `Enter`: inspect tasks and reviews
|
|
@@ -40,7 +40,7 @@ Each project has up to 10 concurrent worker, explorer, or reviewer sessions. The
|
|
|
40
40
|
- `a`: approve the initial plan
|
|
41
41
|
- `f`: give plan feedback
|
|
42
42
|
- `p`: pause, resume, or recover
|
|
43
|
-
- `x`:
|
|
43
|
+
- `x`: leave the dashboard and confirm cancellation while preserving artifacts
|
|
44
44
|
- `Esc`: go back or close
|
|
45
45
|
|
|
46
46
|
Interrupted runs recover in a paused state and require explicit resume. Run state is machine-local under Pi's active agent directory at `orchestration/runs/<run-id>/`.
|
|
@@ -12,81 +12,96 @@ import {
|
|
|
12
12
|
import type { OrchestrationEngine } from "./engine.ts";
|
|
13
13
|
import type { OrchestrationRun } from "./types.ts";
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
15
|
+
export type OrchestrationDashboardAction =
|
|
16
|
+
| { kind: "close" }
|
|
17
|
+
| { kind: "create" }
|
|
18
|
+
| { kind: "settings" }
|
|
19
|
+
| { kind: "feedback"; id: string }
|
|
20
|
+
| { kind: "approve"; id: string }
|
|
21
|
+
| { kind: "toggle-pause"; id: string }
|
|
22
|
+
| { kind: "cancel"; id: string };
|
|
23
23
|
|
|
24
24
|
type Theme = ExtensionContext["ui"]["theme"];
|
|
25
25
|
|
|
26
26
|
class OrchestrationDashboard {
|
|
27
27
|
private selected = 0;
|
|
28
|
+
private selectedId?: string;
|
|
28
29
|
private detail = false;
|
|
29
30
|
private unsubscribe: () => void;
|
|
30
31
|
private readonly tui: TUI;
|
|
31
32
|
private readonly theme: Theme;
|
|
32
33
|
private readonly keys: KeybindingsManager;
|
|
33
34
|
private readonly engine: OrchestrationEngine;
|
|
34
|
-
private readonly
|
|
35
|
-
private
|
|
35
|
+
private readonly done: (action: OrchestrationDashboardAction) => void;
|
|
36
|
+
private closed = false;
|
|
36
37
|
|
|
37
38
|
constructor(
|
|
38
39
|
tui: TUI,
|
|
39
40
|
theme: Theme,
|
|
40
41
|
keys: KeybindingsManager,
|
|
41
42
|
engine: OrchestrationEngine,
|
|
42
|
-
|
|
43
|
-
close: () => void,
|
|
43
|
+
done: (action: OrchestrationDashboardAction) => void,
|
|
44
44
|
) {
|
|
45
45
|
this.tui = tui;
|
|
46
46
|
this.theme = theme;
|
|
47
47
|
this.keys = keys;
|
|
48
48
|
this.engine = engine;
|
|
49
|
-
this.
|
|
50
|
-
this.close = close;
|
|
49
|
+
this.done = done;
|
|
51
50
|
this.unsubscribe = engineChangeSubscription(engine, () => tui.requestRender());
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
private close(action: OrchestrationDashboardAction) {
|
|
54
|
+
if (this.closed) return;
|
|
55
|
+
this.closed = true;
|
|
55
56
|
this.unsubscribe();
|
|
57
|
+
this.done(action);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dispose() {
|
|
61
|
+
if (!this.closed) this.unsubscribe();
|
|
62
|
+
this.closed = true;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
invalidate() {}
|
|
59
66
|
|
|
67
|
+
private reconcileSelection(runs: OrchestrationRun[]) {
|
|
68
|
+
const stable = this.selectedId ? runs.findIndex((run) => run.id === this.selectedId) : -1;
|
|
69
|
+
this.selected = stable >= 0 ? stable : Math.min(this.selected, Math.max(0, runs.length - 1));
|
|
70
|
+
this.selectedId = runs[this.selected]?.id;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
handleInput(data: string) {
|
|
61
74
|
const runs = this.engine.list();
|
|
75
|
+
this.reconcileSelection(runs);
|
|
62
76
|
if (matchesKey(data, Key.escape)) {
|
|
63
77
|
if (this.detail) this.detail = false;
|
|
64
78
|
else {
|
|
65
|
-
this.
|
|
66
|
-
this.close();
|
|
79
|
+
this.close({ kind: "close" });
|
|
67
80
|
return;
|
|
68
81
|
}
|
|
69
82
|
} else if (!this.detail && (this.keys.matches(data, "tui.select.up") || data === "k")) {
|
|
70
83
|
this.selected = Math.max(0, this.selected - 1);
|
|
84
|
+
this.selectedId = runs[this.selected]?.id;
|
|
71
85
|
} else if (!this.detail && (this.keys.matches(data, "tui.select.down") || data === "j")) {
|
|
72
86
|
this.selected = Math.min(Math.max(0, runs.length - 1), this.selected + 1);
|
|
87
|
+
this.selectedId = runs[this.selected]?.id;
|
|
73
88
|
} else if (!this.detail && this.keys.matches(data, "tui.select.confirm") && runs[this.selected]) {
|
|
74
89
|
this.detail = true;
|
|
75
|
-
} else if (data === "n") this.
|
|
76
|
-
else if (data === "s") this.
|
|
90
|
+
} else if (data === "n") this.close({ kind: "create" });
|
|
91
|
+
else if (data === "s") this.close({ kind: "settings" });
|
|
77
92
|
else if (this.detail && runs[this.selected]) {
|
|
78
93
|
const run = runs[this.selected];
|
|
79
|
-
if (data === "a" && run.status === "awaiting-approval") this.
|
|
80
|
-
else if (data === "f" && run.status === "awaiting-approval") this.
|
|
81
|
-
else if (data === "p") this.
|
|
82
|
-
else if (data === "x") this.
|
|
94
|
+
if (data === "a" && run.status === "awaiting-approval") this.close({ kind: "approve", id: run.id });
|
|
95
|
+
else if (data === "f" && run.status === "awaiting-approval") this.close({ kind: "feedback", id: run.id });
|
|
96
|
+
else if (data === "p") this.close({ kind: "toggle-pause", id: run.id });
|
|
97
|
+
else if (data === "x") this.close({ kind: "cancel", id: run.id });
|
|
83
98
|
}
|
|
84
99
|
this.tui.requestRender();
|
|
85
100
|
}
|
|
86
101
|
|
|
87
102
|
render(width: number) {
|
|
88
103
|
const runs = this.engine.list();
|
|
89
|
-
this.
|
|
104
|
+
this.reconcileSelection(runs);
|
|
90
105
|
const lines = this.detail && runs[this.selected]
|
|
91
106
|
? this.renderDetail(runs[this.selected], width)
|
|
92
107
|
: this.renderList(runs, width);
|
|
@@ -109,7 +124,12 @@ class OrchestrationDashboard {
|
|
|
109
124
|
this.theme.fg("borderMuted", "─".repeat(Math.max(0, width))),
|
|
110
125
|
];
|
|
111
126
|
if (!runs.length) lines.push(this.theme.fg("muted", "No runs yet. Press n to create one."));
|
|
112
|
-
|
|
127
|
+
const rows = this.tui.terminal.rows || 30;
|
|
128
|
+
const visibleCount = Math.max(1, Math.floor((rows - 5) / 2));
|
|
129
|
+
const start = Math.min(Math.max(0, this.selected - Math.floor(visibleCount / 2)), Math.max(0, runs.length - visibleCount));
|
|
130
|
+
const visible = runs.slice(start, start + visibleCount);
|
|
131
|
+
for (const [offset, run] of visible.entries()) {
|
|
132
|
+
const index = start + offset;
|
|
113
133
|
const marker = index === this.selected ? this.theme.fg("accent", "❯") : " ";
|
|
114
134
|
const complete = run.tasks.filter((task) => task.status === "completed").length;
|
|
115
135
|
const color = run.status === "completed" ? "success" : run.status === "blocked" ? "error" : run.status === "running" ? "warning" : "muted";
|
|
@@ -122,8 +142,13 @@ class OrchestrationDashboard {
|
|
|
122
142
|
);
|
|
123
143
|
lines.push(` ${this.theme.fg("dim", `${run.id} · ${run.cwd}`)}`);
|
|
124
144
|
}
|
|
145
|
+
if (start > 0) lines.splice(2, 0, this.theme.fg("dim", ` ↑ ${start} more`));
|
|
146
|
+
const below = runs.length - start - visible.length;
|
|
147
|
+
if (below > 0) lines.push(this.theme.fg("dim", ` ↓ ${below} more`));
|
|
125
148
|
lines.push("", this.theme.fg("dim", "j/k select · enter open · n new · s settings · esc close"));
|
|
126
|
-
|
|
149
|
+
const maxRows = Math.max(8, rows);
|
|
150
|
+
if (lines.length <= maxRows) return lines;
|
|
151
|
+
return [...lines.slice(0, maxRows - 2), this.theme.fg("dim", "… more runs"), lines.at(-1)!];
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
private renderDetail(run: OrchestrationRun, width: number) {
|
|
@@ -161,7 +186,9 @@ class OrchestrationDashboard {
|
|
|
161
186
|
"esc back",
|
|
162
187
|
].filter(Boolean).join(" · ");
|
|
163
188
|
lines.push("", this.theme.fg("dim", controls));
|
|
164
|
-
|
|
189
|
+
const maxRows = Math.max(8, (this.tui.terminal.rows || 30) - 2);
|
|
190
|
+
if (lines.length <= maxRows) return lines;
|
|
191
|
+
return [...lines.slice(0, maxRows - 2), this.theme.fg("dim", "… more tasks in run state"), lines.at(-1)!];
|
|
165
192
|
}
|
|
166
193
|
}
|
|
167
194
|
|
|
@@ -179,11 +206,10 @@ function engineChangeSubscription(engine: OrchestrationEngine, listener: () => v
|
|
|
179
206
|
export async function openOrchestrationDashboard(
|
|
180
207
|
ctx: ExtensionContext,
|
|
181
208
|
engine: OrchestrationEngine,
|
|
182
|
-
actions: DashboardActions,
|
|
183
209
|
) {
|
|
184
|
-
|
|
210
|
+
return ctx.ui.custom<OrchestrationDashboardAction>(
|
|
185
211
|
(tui, theme, keys, done) =>
|
|
186
|
-
new OrchestrationDashboard(tui, theme, keys, engine,
|
|
212
|
+
new OrchestrationDashboard(tui, theme, keys, engine, done),
|
|
187
213
|
{
|
|
188
214
|
overlay: true,
|
|
189
215
|
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
|
|
@@ -3,7 +3,11 @@ import type {
|
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
ExtensionUIContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
6
7
|
import { Type } from "typebox";
|
|
8
|
+
import { clearActivitySource, setActivitySource } from "../shared/activity-dock.ts";
|
|
9
|
+
import { toolCallCard, toolResultCard } from "../shared/tool-card.ts";
|
|
10
|
+
import { oneLine, stateLabel } from "../shared/tui-dashboard.ts";
|
|
7
11
|
import {
|
|
8
12
|
requestSubagentCoordinator,
|
|
9
13
|
type SubagentCoordinator,
|
|
@@ -33,16 +37,36 @@ export default function orchestration(pi: ExtensionAPI) {
|
|
|
33
37
|
const updateStatus = () => {
|
|
34
38
|
if (!ui || !engine) return;
|
|
35
39
|
const active = engine.list().filter((run) => !["completed", "cancelled"].includes(run.status));
|
|
40
|
+
if (context) {
|
|
41
|
+
setActivitySource(context, "orchestration", active.map((run) => {
|
|
42
|
+
const done = run.tasks.filter((task) => task.status === "completed").length;
|
|
43
|
+
const attention = run.status === "awaiting-approval";
|
|
44
|
+
return {
|
|
45
|
+
id: run.id,
|
|
46
|
+
label: "Orchestration",
|
|
47
|
+
title: run.objective,
|
|
48
|
+
detail: `${attention ? "plan ready for review" : run.status} · ${done}/${run.tasks.length}`,
|
|
49
|
+
state: run.status === "blocked" ? "error" as const : attention ? "warning" as const : ["planning", "running"].includes(run.status) ? "active" as const : "muted" as const,
|
|
50
|
+
priority: attention ? 120 : run.status === "blocked" ? 110 : 45,
|
|
51
|
+
};
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
36
54
|
if (!active.length) {
|
|
37
55
|
ui.setStatus("orchestration", undefined);
|
|
38
56
|
return;
|
|
39
57
|
}
|
|
40
|
-
const
|
|
58
|
+
const awaiting = active.filter((run) => run.status === "awaiting-approval").length;
|
|
41
59
|
const blocked = active.filter((run) => run.status === "blocked").length;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
const running = active.filter((run) => run.status === "running" || run.status === "planning").length;
|
|
61
|
+
const paused = active.filter((run) => ["paused", "interrupted"].includes(run.status)).length;
|
|
62
|
+
const text = awaiting
|
|
63
|
+
? `${awaiting} plan${awaiting === 1 ? "" : "s"} ready · /orchestration`
|
|
64
|
+
: blocked
|
|
65
|
+
? `${blocked} blocked · /orchestration`
|
|
66
|
+
: running
|
|
67
|
+
? `${running} running · /orchestration`
|
|
68
|
+
: `${paused} paused · /orchestration`;
|
|
69
|
+
ui.setStatus("orchestration", stateLabel(ui.theme, awaiting || blocked ? "warning" : running ? "active" : "muted", `Orchestration ${text}`));
|
|
46
70
|
};
|
|
47
71
|
|
|
48
72
|
const requireEngine = () => {
|
|
@@ -81,38 +105,41 @@ export default function orchestration(pi: ExtensionAPI) {
|
|
|
81
105
|
}
|
|
82
106
|
};
|
|
83
107
|
|
|
84
|
-
const runAction = (operation: () => Promise<unknown>) => {
|
|
85
|
-
void operation().catch((error) =>
|
|
86
|
-
ui?.notify(error instanceof Error ? error.message : String(error), "error"),
|
|
87
|
-
);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
108
|
const openDashboard = async (ctx: ExtensionContext) => {
|
|
91
109
|
const current = requireEngine();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
while (true) {
|
|
111
|
+
const action = await openOrchestrationDashboard(ctx, current);
|
|
112
|
+
if (action.kind === "close") return;
|
|
113
|
+
try {
|
|
114
|
+
if (action.kind === "create") {
|
|
115
|
+
await createFromUi(ctx);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (action.kind === "settings") {
|
|
119
|
+
await openOrchestrationSettings(ctx);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const run = current.get(action.id);
|
|
123
|
+
if (!run) {
|
|
124
|
+
ctx.ui.notify(`Orchestration ${action.id} is no longer available.`, "warning");
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (action.kind === "feedback") {
|
|
97
128
|
const feedback = await ctx.ui.editor("Plan feedback", "Tell the orchestrator what to change…");
|
|
98
129
|
if (feedback?.trim()) await current.feedback(run.id, feedback);
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
),
|
|
107
|
-
cancel: (run) =>
|
|
108
|
-
runAction(async () => {
|
|
109
|
-
const confirmed = await ctx.ui.confirm(
|
|
110
|
-
"Cancel orchestration?",
|
|
111
|
-
`Stop ${run.id} and preserve its run artifacts?`,
|
|
112
|
-
);
|
|
130
|
+
} else if (action.kind === "approve") {
|
|
131
|
+
await current.approve(run.id);
|
|
132
|
+
} else if (action.kind === "toggle-pause") {
|
|
133
|
+
if (["paused", "interrupted", "blocked"].includes(run.status)) await current.resume(run.id);
|
|
134
|
+
else await current.pause(run.id);
|
|
135
|
+
} else if (action.kind === "cancel") {
|
|
136
|
+
const confirmed = await ctx.ui.confirm("Cancel orchestration?", `Stop ${run.id} and preserve its run artifacts?`);
|
|
113
137
|
if (confirmed) await current.cancel(run.id);
|
|
114
|
-
}
|
|
115
|
-
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
116
143
|
};
|
|
117
144
|
|
|
118
145
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -150,8 +177,9 @@ export default function orchestration(pi: ExtensionAPI) {
|
|
|
150
177
|
engine?.stop();
|
|
151
178
|
engine = undefined;
|
|
152
179
|
coordinator = undefined;
|
|
153
|
-
context = undefined;
|
|
154
180
|
ui?.setStatus("orchestration", undefined);
|
|
181
|
+
if (context) clearActivitySource(context, "orchestration");
|
|
182
|
+
context = undefined;
|
|
155
183
|
ui = undefined;
|
|
156
184
|
});
|
|
157
185
|
|
|
@@ -209,6 +237,14 @@ export default function orchestration(pi: ExtensionAPI) {
|
|
|
209
237
|
details: { id: run.id, status: run.status, cwd: run.cwd },
|
|
210
238
|
};
|
|
211
239
|
},
|
|
240
|
+
renderCall(args, theme) {
|
|
241
|
+
return new Text(toolCallCard(theme, "orchestration create", oneLine(args.objective).slice(0, 100), args.cwd ?? "current workspace"), 0, 0);
|
|
242
|
+
},
|
|
243
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
244
|
+
if (isPartial) return new Text(theme.fg("warning", "Starting orchestration…"), 0, 0);
|
|
245
|
+
const details = result.details as { id?: string; status?: string; cwd?: string } | undefined;
|
|
246
|
+
return new Text(toolResultCard(result, expanded, theme, "active", details?.id ?? "orchestration", `${details?.status ?? "planning"} · ${details?.cwd ?? "workspace"} · /orchestration`), 0, 0);
|
|
247
|
+
},
|
|
212
248
|
});
|
|
213
249
|
|
|
214
250
|
pi.registerTool({
|
|
@@ -239,5 +275,16 @@ export default function orchestration(pi: ExtensionAPI) {
|
|
|
239
275
|
details: { runs },
|
|
240
276
|
};
|
|
241
277
|
},
|
|
278
|
+
renderCall(args, theme) {
|
|
279
|
+
return new Text(toolCallCard(theme, "orchestration inspect", args.id ?? "all runs"), 0, 0);
|
|
280
|
+
},
|
|
281
|
+
renderResult(result, { expanded }, theme) {
|
|
282
|
+
const details = result.details as { runs?: Array<{ status?: string; tasks?: Array<{ status?: string }> }> } | undefined;
|
|
283
|
+
const runs = details?.runs ?? [];
|
|
284
|
+
const attention = runs.filter((run) => run.status === "awaiting-approval" || run.status === "blocked").length;
|
|
285
|
+
const done = runs.reduce((total, run) => total + (run.tasks?.filter((task) => task.status === "completed").length ?? 0), 0);
|
|
286
|
+
const total = runs.reduce((count, run) => count + (run.tasks?.length ?? 0), 0);
|
|
287
|
+
return new Text(toolResultCard(result, expanded, theme, attention ? "warning" : runs.some((run) => run.status === "running" || run.status === "planning") ? "active" : "muted", "orchestration", `${runs.length} run${runs.length === 1 ? "" : "s"} · ${done}/${total} tasks${attention ? ` · ${attention} need attention` : ""}`), 0, 0);
|
|
288
|
+
},
|
|
242
289
|
});
|
|
243
290
|
}
|
|
@@ -15,6 +15,7 @@ interface GenerationMetrics {
|
|
|
15
15
|
|
|
16
16
|
const STATUS_ID = "generation-performance";
|
|
17
17
|
const RENDER_INTERVAL_MS = 100;
|
|
18
|
+
const DONE_LINGER_MS = 8_000;
|
|
18
19
|
|
|
19
20
|
function duration(seconds: number) {
|
|
20
21
|
if (!Number.isFinite(seconds) || seconds < 0) return "—";
|
|
@@ -86,8 +87,24 @@ export function formatPerformanceStatus(
|
|
|
86
87
|
export default function performanceStatus(pi: ExtensionAPI) {
|
|
87
88
|
const metrics: GenerationMetrics = { streamedChars: 0, phase: "idle" };
|
|
88
89
|
let renderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
let doneTimer: ReturnType<typeof setTimeout> | undefined;
|
|
89
91
|
let lastRenderAt = 0;
|
|
90
92
|
|
|
93
|
+
const cancelDoneTimer = () => {
|
|
94
|
+
if (!doneTimer) return;
|
|
95
|
+
clearTimeout(doneTimer);
|
|
96
|
+
doneTimer = undefined;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const scheduleDoneHide = (ctx: ExtensionContext) => {
|
|
100
|
+
cancelDoneTimer();
|
|
101
|
+
doneTimer = setTimeout(() => {
|
|
102
|
+
doneTimer = undefined;
|
|
103
|
+
if (metrics.phase === "done" && ctx.hasUI) ctx.ui.setStatus(STATUS_ID, undefined);
|
|
104
|
+
}, DONE_LINGER_MS);
|
|
105
|
+
doneTimer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
|
|
91
108
|
const render = (ctx: ExtensionContext, immediate = false) => {
|
|
92
109
|
if (!ctx.hasUI || metrics.phase === "idle") return;
|
|
93
110
|
const now = Date.now();
|
|
@@ -110,6 +127,7 @@ export default function performanceStatus(pi: ExtensionAPI) {
|
|
|
110
127
|
};
|
|
111
128
|
|
|
112
129
|
pi.on("turn_start", (_event, ctx) => {
|
|
130
|
+
cancelDoneTimer();
|
|
113
131
|
metrics.requestStartedAt = Date.now();
|
|
114
132
|
metrics.firstTokenAt = undefined;
|
|
115
133
|
metrics.completedAt = undefined;
|
|
@@ -136,6 +154,7 @@ export default function performanceStatus(pi: ExtensionAPI) {
|
|
|
136
154
|
metrics.exactOutputTokens = update.message.usage.output;
|
|
137
155
|
metrics.phase = "done";
|
|
138
156
|
render(ctx, true);
|
|
157
|
+
scheduleDoneHide(ctx);
|
|
139
158
|
}
|
|
140
159
|
});
|
|
141
160
|
|
|
@@ -145,9 +164,11 @@ export default function performanceStatus(pi: ExtensionAPI) {
|
|
|
145
164
|
metrics.exactOutputTokens = event.message.usage.output;
|
|
146
165
|
metrics.phase = "done";
|
|
147
166
|
render(ctx, true);
|
|
167
|
+
scheduleDoneHide(ctx);
|
|
148
168
|
});
|
|
149
169
|
|
|
150
170
|
pi.on("tool_execution_start", (event, ctx) => {
|
|
171
|
+
cancelDoneTimer();
|
|
151
172
|
metrics.activeTool = event.toolName;
|
|
152
173
|
metrics.phase = "tool";
|
|
153
174
|
render(ctx, true);
|
|
@@ -157,6 +178,7 @@ export default function performanceStatus(pi: ExtensionAPI) {
|
|
|
157
178
|
metrics.activeTool = undefined;
|
|
158
179
|
metrics.phase = metrics.completedAt ? "done" : "generating";
|
|
159
180
|
render(ctx, true);
|
|
181
|
+
if (metrics.phase === "done") scheduleDoneHide(ctx);
|
|
160
182
|
});
|
|
161
183
|
|
|
162
184
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -166,6 +188,7 @@ export default function performanceStatus(pi: ExtensionAPI) {
|
|
|
166
188
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
167
189
|
if (renderTimer) clearTimeout(renderTimer);
|
|
168
190
|
renderTimer = undefined;
|
|
191
|
+
cancelDoneTimer();
|
|
169
192
|
if (ctx.hasUI) ctx.ui.setStatus(STATUS_ID, undefined);
|
|
170
193
|
});
|
|
171
194
|
}
|
|
@@ -288,7 +288,7 @@ export default function piMemory(pi: ExtensionAPI) {
|
|
|
288
288
|
return;
|
|
289
289
|
}
|
|
290
290
|
service.initialize(ctx.cwd);
|
|
291
|
-
ctx.ui.setStatus("pi-memory",
|
|
291
|
+
ctx.ui.setStatus("pi-memory", undefined);
|
|
292
292
|
void service.processPending(ctx, 1);
|
|
293
293
|
});
|
|
294
294
|
|
|
@@ -307,9 +307,7 @@ export default function piMemory(pi: ExtensionAPI) {
|
|
|
307
307
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
308
308
|
if (!active(ctx)) return;
|
|
309
309
|
service.enqueueCurrentSession(ctx, projectFor(ctx));
|
|
310
|
-
void service.processPending(ctx, 1)
|
|
311
|
-
if (ctx.hasUI) ctx.ui.setStatus("pi-memory", `memory ${service.status().memories}`);
|
|
312
|
-
});
|
|
310
|
+
void service.processPending(ctx, 1);
|
|
313
311
|
});
|
|
314
312
|
|
|
315
313
|
pi.on("session_before_compact", async (_event, ctx) => {
|
|
@@ -4,12 +4,14 @@ Internal runtime utilities used by more than one extension. This directory is no
|
|
|
4
4
|
|
|
5
5
|
## Modules
|
|
6
6
|
|
|
7
|
+
- `activity-dock.ts` aggregates bounded urgency-ordered live rows from Task List, Background Terminals, Subagents, and Orchestration into one automatic widget.
|
|
7
8
|
- `activity-status.ts` formats compact running/completed/failed status counts.
|
|
8
9
|
- `child-session.ts` owns trust-aware child resources and bounded session shutdown.
|
|
9
10
|
- `context-utilization.ts` formats model-context usage and capacity.
|
|
10
11
|
- `dashboard-state.ts` keeps list selection stable as live rows change.
|
|
11
12
|
- `settlement-delivery.ts` coordinates asynchronous output in a private package-wide queue and starts one custom-result turn at Pi's safe idle edge, guaranteeing model-visible context without user-authored or follow-up rendering.
|
|
12
13
|
- `tool-call-timeout.ts` applies cancellation-aware execution limits to registered tools.
|
|
14
|
+
- `tool-card.ts` provides compact lifecycle call/result cards with expandable tool output.
|
|
13
15
|
- `tui-dashboard.ts` provides bounded, sanitized terminal-dashboard rendering helpers.
|
|
14
16
|
|
|
15
17
|
Keep extension-specific behavior in its owning `extensions/<name>` directory. Move code here only when multiple extensions genuinely share the same runtime contract.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { oneLine } from "./tui-dashboard.ts";
|
|
4
|
+
|
|
5
|
+
const WIDGET_ID = "active-work";
|
|
6
|
+
const MAX_VISIBLE_ITEMS = 5;
|
|
7
|
+
|
|
8
|
+
export type ActivityState = "active" | "success" | "warning" | "error" | "muted";
|
|
9
|
+
|
|
10
|
+
export type ActivityItem = {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
title: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
state: ActivityState;
|
|
16
|
+
priority?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let activeUi: ExtensionContext["ui"] | undefined;
|
|
20
|
+
const sources = new Map<string, ActivityItem[]>();
|
|
21
|
+
|
|
22
|
+
function color(state: ActivityState): "accent" | "success" | "warning" | "error" | "muted" {
|
|
23
|
+
if (state === "active") return "accent";
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function glyph(state: ActivityState) {
|
|
28
|
+
if (state === "active") return "◆";
|
|
29
|
+
if (state === "success") return "✓";
|
|
30
|
+
if (state === "warning") return "!";
|
|
31
|
+
if (state === "error") return "×";
|
|
32
|
+
return "○";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bind(ctx: ExtensionContext) {
|
|
36
|
+
if (activeUi === ctx.ui) return;
|
|
37
|
+
activeUi?.setWidget(WIDGET_ID, undefined);
|
|
38
|
+
activeUi = ctx.ui;
|
|
39
|
+
sources.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function allItems() {
|
|
43
|
+
return [...sources.entries()]
|
|
44
|
+
.flatMap(([source, items]) => items.map((item, index) => ({ source, index, item })))
|
|
45
|
+
.sort((left, right) =>
|
|
46
|
+
(right.item.priority ?? 0) - (left.item.priority ?? 0) ||
|
|
47
|
+
left.source.localeCompare(right.source) ||
|
|
48
|
+
left.index - right.index,
|
|
49
|
+
)
|
|
50
|
+
.map(({ item }) => item);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function publish(ctx: ExtensionContext) {
|
|
54
|
+
bind(ctx);
|
|
55
|
+
const items = allItems();
|
|
56
|
+
if (!ctx.hasUI || items.length === 0) {
|
|
57
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({
|
|
61
|
+
render(width: number) {
|
|
62
|
+
const current = allItems();
|
|
63
|
+
const shown = current.slice(0, MAX_VISIBLE_ITEMS);
|
|
64
|
+
const lines = [truncateToWidth(
|
|
65
|
+
`${theme.fg("accent", theme.bold("Active work"))} ${theme.fg("muted", `${current.length}`)}`,
|
|
66
|
+
width,
|
|
67
|
+
)];
|
|
68
|
+
for (const item of shown) {
|
|
69
|
+
const tone = color(item.state);
|
|
70
|
+
const detail = item.detail ? theme.fg("dim", ` · ${oneLine(item.detail)}`) : "";
|
|
71
|
+
lines.push(truncateToWidth(
|
|
72
|
+
`${theme.fg(tone, glyph(item.state))} ${theme.fg("muted", item.label)} ${theme.fg(tone === "muted" ? "muted" : "text", oneLine(item.title))}${detail}`,
|
|
73
|
+
width,
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
if (current.length > shown.length) {
|
|
77
|
+
lines.push(truncateToWidth(theme.fg("dim", `… +${current.length - shown.length} more · open the relevant dashboard`), width));
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
},
|
|
81
|
+
invalidate() {},
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function setActivitySource(ctx: ExtensionContext, source: string, items: ActivityItem[]) {
|
|
86
|
+
bind(ctx);
|
|
87
|
+
if (items.length) sources.set(source, items.map((item) => ({ ...item })));
|
|
88
|
+
else sources.delete(source);
|
|
89
|
+
publish(ctx);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function clearActivitySource(ctx: ExtensionContext, source: string) {
|
|
93
|
+
bind(ctx);
|
|
94
|
+
sources.delete(source);
|
|
95
|
+
publish(ctx);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function activityDockSnapshot() {
|
|
99
|
+
return allItems().map((item) => ({ ...item }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { oneLine, stateLabel } from "./tui-dashboard.ts";
|
|
2
|
+
|
|
3
|
+
export type ToolCardState = "active" | "success" | "warning" | "error" | "muted";
|
|
4
|
+
|
|
5
|
+
type ThemeLike = {
|
|
6
|
+
fg(color: string, text: string): string;
|
|
7
|
+
bold(text: string): string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ToolResultLike = {
|
|
11
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function toolCallCard(
|
|
15
|
+
theme: ThemeLike,
|
|
16
|
+
label: string,
|
|
17
|
+
title: string,
|
|
18
|
+
detail?: string,
|
|
19
|
+
) {
|
|
20
|
+
return `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", oneLine(title))}${detail ? `\n${theme.fg("dim", oneLine(detail))}` : ""}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toolResultCard(
|
|
24
|
+
result: ToolResultLike,
|
|
25
|
+
expanded: boolean,
|
|
26
|
+
theme: ThemeLike,
|
|
27
|
+
state: ToolCardState,
|
|
28
|
+
label: string,
|
|
29
|
+
summary: string,
|
|
30
|
+
) {
|
|
31
|
+
const header = `${stateLabel(theme as never, state, label)} ${theme.fg("muted", oneLine(summary))}`;
|
|
32
|
+
if (!expanded) return `${header}\n${theme.fg("dim", "ctrl+o to expand")}`;
|
|
33
|
+
const body = result.content
|
|
34
|
+
?.filter((item) => item.type === "text" && item.text)
|
|
35
|
+
.map((item) => item.text ?? "")
|
|
36
|
+
.join("\n")
|
|
37
|
+
.trim();
|
|
38
|
+
return body ? `${header}\n${theme.fg("toolOutput", body)}` : header;
|
|
39
|
+
}
|
|
@@ -25,11 +25,11 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
|
|
|
25
25
|
|
|
26
26
|
const updateStatus = () => {
|
|
27
27
|
if (!ui) return;
|
|
28
|
-
if (!config.enabled) {
|
|
28
|
+
if (!config.enabled || config.model === "inherit") {
|
|
29
29
|
ui.setStatus(STATUS_KEY, undefined);
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
const modelLabel = config.model
|
|
32
|
+
const modelLabel = config.model.split("/").pop() ?? config.model;
|
|
33
33
|
ui.setStatus(STATUS_KEY, `compact: ${modelLabel}`);
|
|
34
34
|
};
|
|
35
35
|
|
|
@@ -43,6 +43,7 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
|
|
|
43
43
|
return undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, "compacting…");
|
|
46
47
|
try {
|
|
47
48
|
const compaction = await runSmartCompaction({
|
|
48
49
|
event,
|
|
@@ -57,6 +58,8 @@ export function createSmartCompactionExtension(options: SmartCompactionExtension
|
|
|
57
58
|
const message = error instanceof Error ? error.message : String(error);
|
|
58
59
|
ctx.ui?.notify(`Smart Compaction failed: ${message}. Falling back to default compactor.`, "warning");
|
|
59
60
|
return undefined;
|
|
61
|
+
} finally {
|
|
62
|
+
updateStatus();
|
|
60
63
|
}
|
|
61
64
|
});
|
|
62
65
|
|
|
@@ -80,7 +80,7 @@ Children share the requested workspace by default. Use `isolation: "worktree"` o
|
|
|
80
80
|
|
|
81
81
|
## UI
|
|
82
82
|
|
|
83
|
-
`/subagents` or `/subagents agents` opens the live operations dashboard and takeover UI. `/btw <question>` starts a read-only, context-aware side investigation owned by the user; it opens directly in takeover view and records its answer without waking the parent model. Wide terminals use a split-pane agent list and selected-agent inspector with running/completed/failed counts, model/profile/access metadata, a context meter, current tool activity, queue state, elapsed time, turns, cwd, and latest output. The takeover view adds a live transcript, context meter, active-tool state, scrolling, child interruption, and follow-up input. `/subagents peers` shows the persistent peer-message audit trail. `/subagents profiles` browses profiles and personas, and `/subagents config` edits validated configuration.
|
|
83
|
+
Subagent lifecycle tools render as compact main-chat cards with expandable detail. Running children appear in the shared bounded **Active work** dock with elapsed time and their current tool, then disappear when settled because the result card becomes the durable timeline record. `/subagents` or `/subagents agents` opens the live operations dashboard and takeover UI. `/btw <question>` starts a read-only, context-aware side investigation owned by the user; it opens directly in takeover view and records its answer without waking the parent model. Wide terminals use a split-pane agent list and selected-agent inspector with running/completed/failed counts, model/profile/access metadata, a context meter, current tool activity, queue state, elapsed time, turns, cwd, and latest output. Aborting a running child from the dashboard requires pressing `x` twice; Escape or navigation cancels an armed abort. The takeover view adds a live transcript, context meter, active-tool state, scrolling, child interruption, and follow-up input. `/subagents peers` shows the persistent peer-message audit trail. `/subagents profiles` browses profiles and personas, and `/subagents config` edits validated configuration.
|
|
84
84
|
|
|
85
85
|
## Architecture
|
|
86
86
|
|
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
} from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
28
28
|
import { Type } from "typebox";
|
|
29
|
+
import { clearActivitySource, setActivitySource } from "../shared/activity-dock.ts";
|
|
29
30
|
import { settlementDelivery } from "../shared/settlement-delivery.ts";
|
|
31
|
+
import { toolCallCard, toolResultCard } from "../shared/tool-card.ts";
|
|
32
|
+
import { oneLine } from "../shared/tui-dashboard.ts";
|
|
30
33
|
import {
|
|
31
34
|
formatElapsed,
|
|
32
35
|
latestText,
|
|
@@ -199,6 +202,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
199
202
|
let runtime: SubagentRuntime | undefined;
|
|
200
203
|
let managerPromise: Promise<SubagentManagerShape> | undefined;
|
|
201
204
|
let ui: ExtensionUIContext | undefined;
|
|
205
|
+
let activityContext: ExtensionContext | undefined;
|
|
202
206
|
let unsubStatus: (() => void) | undefined;
|
|
203
207
|
const archived = new Map<string, ArchivedSubagent>();
|
|
204
208
|
const peerHistory: PeerMessage[] = [];
|
|
@@ -509,16 +513,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
509
513
|
const updateStatus = (manager: SubagentManagerShape) => {
|
|
510
514
|
if (!ui) return;
|
|
511
515
|
const subs = manager.view.list();
|
|
512
|
-
|
|
516
|
+
const runningSnapshots = subs.filter((snap) => snap.status === "running");
|
|
517
|
+
const failed = subs.filter((snap) => snap.status === "error").length;
|
|
518
|
+
if (activityContext) {
|
|
519
|
+
setActivitySource(activityContext, "subagents", runningSnapshots.map((snap) => {
|
|
520
|
+
const activeTool = snap.liveTools.find((tool) => !tool.done)?.name;
|
|
521
|
+
return {
|
|
522
|
+
id: snap.id,
|
|
523
|
+
label: "Subagent",
|
|
524
|
+
title: snap.title,
|
|
525
|
+
detail: activeTool ? `${activeTool} · ${formatElapsed(snap)}` : `thinking · ${formatElapsed(snap)}`,
|
|
526
|
+
state: "active" as const,
|
|
527
|
+
priority: 55,
|
|
528
|
+
};
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
if (runningSnapshots.length === 0 && failed === 0) {
|
|
513
532
|
ui.setStatus("subagents", undefined);
|
|
514
533
|
return;
|
|
515
534
|
}
|
|
516
|
-
const running = subs.filter((snap) => snap.status === "running").length;
|
|
517
|
-
const failed = subs.filter((snap) => snap.status === "error").length;
|
|
518
|
-
const done = subs.length - running - failed;
|
|
519
535
|
ui.setStatus(
|
|
520
536
|
"subagents",
|
|
521
|
-
formatActivityStatus(ui.theme, { running, done, failed }),
|
|
537
|
+
formatActivityStatus(ui.theme, { running: runningSnapshots.length, done: 0, failed }),
|
|
522
538
|
);
|
|
523
539
|
};
|
|
524
540
|
|
|
@@ -640,7 +656,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
640
656
|
}
|
|
641
657
|
}
|
|
642
658
|
if (peerHistory.length > 256) peerHistory.splice(0, peerHistory.length - 256);
|
|
643
|
-
if (ctx.hasUI)
|
|
659
|
+
if (ctx.hasUI) {
|
|
660
|
+
ui = ctx.ui;
|
|
661
|
+
activityContext = ctx;
|
|
662
|
+
if (managerPromise) void managerPromise.then((manager) => updateStatus(manager));
|
|
663
|
+
}
|
|
644
664
|
});
|
|
645
665
|
|
|
646
666
|
pi.on("session_shutdown", async () => {
|
|
@@ -652,6 +672,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
672
|
unsubStatus?.();
|
|
653
673
|
unsubStatus = undefined;
|
|
654
674
|
ui?.setStatus("subagents", undefined);
|
|
675
|
+
if (activityContext) clearActivitySource(activityContext, "subagents");
|
|
676
|
+
activityContext = undefined;
|
|
655
677
|
const closing = runtime;
|
|
656
678
|
runtime = undefined;
|
|
657
679
|
managerPromise = undefined;
|
|
@@ -764,6 +786,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
764
786
|
},
|
|
765
787
|
};
|
|
766
788
|
},
|
|
789
|
+
renderCall(args, theme) {
|
|
790
|
+
return new Text(toolCallCard(theme, args.resume_from ? "subagent resume" : "subagent spawn", args.task_name ?? oneLine(args.message).slice(0, 72), `${args.agent_type ?? "general-purpose"} · ${args.capability ?? (args.readonly ? "read-only" : "all")} · ${args.isolation ?? "shared"}`), 0, 0);
|
|
791
|
+
},
|
|
792
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
793
|
+
if (isPartial) return new Text(theme.fg("warning", "Starting subagent…"), 0, 0);
|
|
794
|
+
const details = result.details as { id?: string; title?: string; model?: string; capability?: string; isolation?: string } | undefined;
|
|
795
|
+
return new Text(toolResultCard(result, expanded, theme, "active", details?.id ?? "subagent", `${details?.title ?? "started"} · ${details?.model ?? "inherit"} · ${details?.capability ?? "all"} · ${details?.isolation ?? "shared"}`), 0, 0);
|
|
796
|
+
},
|
|
767
797
|
});
|
|
768
798
|
|
|
769
799
|
pi.registerTool({
|
|
@@ -859,6 +889,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
859
889
|
}),
|
|
860
890
|
};
|
|
861
891
|
},
|
|
892
|
+
renderCall(args, theme) {
|
|
893
|
+
return new Text(toolCallCard(theme, "subagent collect", args.ids?.length ? `${args.ids.length} selected` : "available results", args.ids?.join(", ")), 0, 0);
|
|
894
|
+
},
|
|
895
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
896
|
+
if (isPartial) return new Text(theme.fg("warning", "Collecting subagent results…"), 0, 0);
|
|
897
|
+
const details = result.details as { results?: Array<{ status?: string }>; pending?: string[]; pendingQuestions?: string[] } | undefined;
|
|
898
|
+
const results = details?.results ?? [];
|
|
899
|
+
const failed = results.filter((item) => item.status === "error").length;
|
|
900
|
+
const pending = details?.pending?.length ?? 0;
|
|
901
|
+
const questions = details?.pendingQuestions?.length ?? 0;
|
|
902
|
+
return new Text(toolResultCard(result, expanded, theme, failed ? "error" : questions ? "warning" : pending ? "active" : "success", "subagents", `${results.length} result${results.length === 1 ? "" : "s"} · ${pending} running${questions ? ` · ${questions} need guidance` : ""}`), 0, 0);
|
|
903
|
+
},
|
|
862
904
|
});
|
|
863
905
|
|
|
864
906
|
pi.registerTool({
|
|
@@ -903,6 +945,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
903
945
|
},
|
|
904
946
|
};
|
|
905
947
|
},
|
|
948
|
+
renderCall(args, theme) {
|
|
949
|
+
return new Text(toolCallCard(theme, "subagent close", `${args.ids?.length ?? 0} agent${args.ids?.length === 1 ? "" : "s"}`, args.ids?.join(", ")), 0, 0);
|
|
950
|
+
},
|
|
951
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
952
|
+
if (isPartial) return new Text(theme.fg("warning", "Closing subagents…"), 0, 0);
|
|
953
|
+
const details = result.details as { results?: unknown[] } | undefined;
|
|
954
|
+
return new Text(toolResultCard(result, expanded, theme, "success", "subagents closed", `${details?.results?.length ?? 0} processed`), 0, 0);
|
|
955
|
+
},
|
|
906
956
|
});
|
|
907
957
|
|
|
908
958
|
pi.registerTool({
|
|
@@ -941,6 +991,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
941
991
|
details: { id: snap.id, status: snap.status, turns: snap.turns },
|
|
942
992
|
};
|
|
943
993
|
},
|
|
994
|
+
renderCall(args, theme) {
|
|
995
|
+
return new Text(toolCallCard(theme, "subagent inspect", args.id), 0, 0);
|
|
996
|
+
},
|
|
997
|
+
renderResult(result, { expanded }, theme) {
|
|
998
|
+
const details = result.details as { id?: string; status?: string; turns?: number } | undefined;
|
|
999
|
+
const state = details?.status === "error" ? "error" : details?.status === "running" ? "active" : "success";
|
|
1000
|
+
return new Text(toolResultCard(result, expanded, theme, state, details?.id ?? "subagent", `${details?.status ?? "unknown"} · ${details?.turns ?? 0} turns`), 0, 0);
|
|
1001
|
+
},
|
|
944
1002
|
});
|
|
945
1003
|
|
|
946
1004
|
pi.registerTool({
|
|
@@ -965,6 +1023,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
965
1023
|
details: { maxConcurrent: config.maxConcurrent, profiles: config.profiles, personas: config.personas },
|
|
966
1024
|
};
|
|
967
1025
|
},
|
|
1026
|
+
renderCall(_args, theme) {
|
|
1027
|
+
return new Text(toolCallCard(theme, "subagent profiles", "available roles and policies"), 0, 0);
|
|
1028
|
+
},
|
|
1029
|
+
renderResult(result, { expanded }, theme) {
|
|
1030
|
+
const details = result.details as { maxConcurrent?: number; profiles?: object; personas?: object } | undefined;
|
|
1031
|
+
return new Text(toolResultCard(result, expanded, theme, "muted", "subagent profiles", `${Object.keys(details?.profiles ?? {}).length} profiles · ${Object.keys(details?.personas ?? {}).length} personas · max ${details?.maxConcurrent ?? "?"}`), 0, 0);
|
|
1032
|
+
},
|
|
968
1033
|
});
|
|
969
1034
|
|
|
970
1035
|
pi.registerTool({
|
|
@@ -989,6 +1054,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
989
1054
|
},
|
|
990
1055
|
};
|
|
991
1056
|
},
|
|
1057
|
+
renderCall(_args, theme) {
|
|
1058
|
+
return new Text(toolCallCard(theme, "subagents", "list tracked agents"), 0, 0);
|
|
1059
|
+
},
|
|
1060
|
+
renderResult(result, { expanded }, theme) {
|
|
1061
|
+
const details = result.details as { subagents?: Array<{ status?: string }> } | undefined;
|
|
1062
|
+
const agents = details?.subagents ?? [];
|
|
1063
|
+
const running = agents.filter((agent) => agent.status === "running").length;
|
|
1064
|
+
const failed = agents.filter((agent) => agent.status === "error").length;
|
|
1065
|
+
return new Text(toolResultCard(result, expanded, theme, failed ? "error" : running ? "active" : "muted", "subagents", `${agents.length} tracked · ${running} running · ${failed} failed`), 0, 0);
|
|
1066
|
+
},
|
|
992
1067
|
});
|
|
993
1068
|
|
|
994
1069
|
pi.registerTool({
|
|
@@ -1009,6 +1084,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1009
1084
|
details: { id: params.id, status: manager.view.get(params.id)?.status },
|
|
1010
1085
|
};
|
|
1011
1086
|
},
|
|
1087
|
+
renderCall(args, theme) {
|
|
1088
|
+
return new Text(toolCallCard(theme, "subagent message", args.id, oneLine(args.message).slice(0, 100)), 0, 0);
|
|
1089
|
+
},
|
|
1090
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
1091
|
+
if (isPartial) return new Text(theme.fg("warning", "Messaging subagent…"), 0, 0);
|
|
1092
|
+
const details = result.details as { id?: string; status?: string } | undefined;
|
|
1093
|
+
return new Text(toolResultCard(result, expanded, theme, details?.status === "error" ? "error" : "success", details?.id ?? "subagent", `message delivered · ${details?.status ?? "unknown"}`), 0, 0);
|
|
1094
|
+
},
|
|
1012
1095
|
});
|
|
1013
1096
|
|
|
1014
1097
|
pi.registerTool({
|
|
@@ -1068,6 +1151,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1068
1151
|
: `${params.id} has no changes to ${action}.`;
|
|
1069
1152
|
return { content: [{ type: "text", text }], details: { id: params.id, ...result, worktree } };
|
|
1070
1153
|
},
|
|
1154
|
+
renderCall(args, theme) {
|
|
1155
|
+
return new Text(toolCallCard(theme, "subagent changes", args.id, args.action ?? "patch"), 0, 0);
|
|
1156
|
+
},
|
|
1157
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
1158
|
+
if (isPartial) return new Text(theme.fg("warning", "Managing subagent changes…"), 0, 0);
|
|
1159
|
+
const details = result.details as { id?: string; action?: string; changed?: boolean; discarded?: boolean; files?: string[] } | undefined;
|
|
1160
|
+
const summary = details?.discarded ? "worktree discarded" : `${details?.action ?? "inspect"} · ${details?.files?.length ?? 0} files${details?.changed ? " changed" : ""}`;
|
|
1161
|
+
return new Text(toolResultCard(result, expanded, theme, details?.changed || details?.discarded ? "success" : "muted", details?.id ?? "subagent", summary), 0, 0);
|
|
1162
|
+
},
|
|
1071
1163
|
});
|
|
1072
1164
|
|
|
1073
1165
|
pi.registerTool({
|
|
@@ -1090,6 +1182,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1090
1182
|
details: { questionId: id, title: pending.title },
|
|
1091
1183
|
};
|
|
1092
1184
|
},
|
|
1185
|
+
renderCall(args, theme) {
|
|
1186
|
+
return new Text(toolCallCard(theme, "subagent reply", args.question_id ?? "pending question", oneLine(args.reply).slice(0, 100)), 0, 0);
|
|
1187
|
+
},
|
|
1188
|
+
renderResult(result, { expanded }, theme) {
|
|
1189
|
+
const details = result.details as { title?: string; questionId?: string } | undefined;
|
|
1190
|
+
return new Text(toolResultCard(result, expanded, theme, "success", "reply delivered", `${details?.title ?? "subagent"} · ${details?.questionId ?? "question"}`), 0, 0);
|
|
1191
|
+
},
|
|
1093
1192
|
});
|
|
1094
1193
|
|
|
1095
1194
|
pi.registerTool({
|
|
@@ -1173,6 +1272,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1173
1272
|
},
|
|
1174
1273
|
};
|
|
1175
1274
|
},
|
|
1275
|
+
renderCall(args, theme) {
|
|
1276
|
+
return new Text(toolCallCard(theme, "subagent batch", `${args.tasks?.length ?? 0} task${args.tasks?.length === 1 ? "" : "s"}`, (args.tasks ?? []).slice(0, 3).map((task: { task_name?: string; message?: string }) => task.task_name ?? oneLine(task.message ?? "").slice(0, 40)).join(" · ")), 0, 0);
|
|
1277
|
+
},
|
|
1278
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
1279
|
+
if (isPartial) return new Text(theme.fg("warning", "Starting subagent batch…"), 0, 0);
|
|
1280
|
+
const details = result.details as { agents?: Array<{ status?: string }> } | undefined;
|
|
1281
|
+
const agents = details?.agents ?? [];
|
|
1282
|
+
return new Text(toolResultCard(result, expanded, theme, "active", "subagent batch", `${agents.length} started · ${agents.filter((agent) => agent.status === "running").length} running`), 0, 0);
|
|
1283
|
+
},
|
|
1176
1284
|
});
|
|
1177
1285
|
|
|
1178
1286
|
// --- Result message rendering ------------------------------------------
|
|
@@ -190,6 +190,7 @@ export class SubagentDashboard implements Component {
|
|
|
190
190
|
private done: (value: string | null) => void;
|
|
191
191
|
|
|
192
192
|
private closed = false;
|
|
193
|
+
private abortArmed?: string;
|
|
193
194
|
private ticker: ReturnType<typeof setInterval>;
|
|
194
195
|
private unsubChange: () => void;
|
|
195
196
|
|
|
@@ -238,6 +239,11 @@ export class SubagentDashboard implements Component {
|
|
|
238
239
|
reconcileDashboardSelection(this.selection, subs);
|
|
239
240
|
|
|
240
241
|
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
242
|
+
if (this.abortArmed) {
|
|
243
|
+
this.abortArmed = undefined;
|
|
244
|
+
this.tui.requestRender();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
241
247
|
this.close(null);
|
|
242
248
|
return;
|
|
243
249
|
}
|
|
@@ -251,6 +257,7 @@ export class SubagentDashboard implements Component {
|
|
|
251
257
|
this.selection.index =
|
|
252
258
|
(this.selection.index - 1 + subs.length) % subs.length;
|
|
253
259
|
this.selection.id = subs[this.selection.index]?.id;
|
|
260
|
+
this.abortArmed = undefined;
|
|
254
261
|
this.tui.requestRender();
|
|
255
262
|
}
|
|
256
263
|
return;
|
|
@@ -259,13 +266,21 @@ export class SubagentDashboard implements Component {
|
|
|
259
266
|
if (subs.length > 0) {
|
|
260
267
|
this.selection.index = (this.selection.index + 1) % subs.length;
|
|
261
268
|
this.selection.id = subs[this.selection.index]?.id;
|
|
269
|
+
this.abortArmed = undefined;
|
|
262
270
|
this.tui.requestRender();
|
|
263
271
|
}
|
|
264
272
|
return;
|
|
265
273
|
}
|
|
266
274
|
if (data === "x") {
|
|
267
275
|
const snap = subs[this.selection.index];
|
|
268
|
-
if (snap
|
|
276
|
+
if (!snap || snap.status !== "running") return;
|
|
277
|
+
if (this.abortArmed === snap.id) {
|
|
278
|
+
this.abortArmed = undefined;
|
|
279
|
+
this.view.requestAbort(snap.id);
|
|
280
|
+
} else {
|
|
281
|
+
this.abortArmed = snap.id;
|
|
282
|
+
}
|
|
283
|
+
this.tui.requestRender();
|
|
269
284
|
return;
|
|
270
285
|
}
|
|
271
286
|
}
|
|
@@ -346,7 +361,7 @@ export class SubagentDashboard implements Component {
|
|
|
346
361
|
truncateToWidth(
|
|
347
362
|
theme.fg(
|
|
348
363
|
"dim",
|
|
349
|
-
` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort
|
|
364
|
+
` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · ${this.abortArmed ? "x again to abort · esc cancel" : "x abort (confirm)"} · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
|
|
350
365
|
),
|
|
351
366
|
width,
|
|
352
367
|
),
|
|
@@ -64,7 +64,7 @@ Run:
|
|
|
64
64
|
/tasks
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
While work is active,
|
|
67
|
+
While work is active, the shared bounded **Active work** dock above the editor shows progress and current items alongside any live terminals, subagents, or orchestration runs without stacking separate widgets. A finished list lingers for four seconds so the final checkmark is visible, then clears from the live chrome while remaining available in session history.
|
|
68
68
|
|
|
69
69
|
`/tasks` opens the full-width interactive dashboard:
|
|
70
70
|
|
|
@@ -5,8 +5,9 @@ import type {
|
|
|
5
5
|
ExtensionCommandContext,
|
|
6
6
|
ExtensionContext,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Text
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
+
import { clearActivitySource, setActivitySource, type ActivityState } from "../shared/activity-dock.ts";
|
|
10
11
|
import { oneLine } from "../shared/tui-dashboard.ts";
|
|
11
12
|
import {
|
|
12
13
|
TASK_LIST_ENTRY,
|
|
@@ -34,7 +35,7 @@ import type {
|
|
|
34
35
|
import { TASK_PRIORITIES, TASK_STATUSES } from "./types.ts";
|
|
35
36
|
import { openTaskDashboard, taskGlyph } from "./ui.ts";
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
+
const ACTIVITY_SOURCE = "task-list";
|
|
38
39
|
const FINISHED_LINGER_MS = 4_000;
|
|
39
40
|
const WORK_TOOL_EXCLUSIONS = new Set([
|
|
40
41
|
TASK_LIST_TOOL,
|
|
@@ -110,49 +111,39 @@ export default function taskListExtension(pi: ExtensionAPI) {
|
|
|
110
111
|
lastCtx = ctx;
|
|
111
112
|
cancelFinishedTimer();
|
|
112
113
|
if (!ctx.hasUI || state.tasks.length === 0) {
|
|
113
|
-
ctx
|
|
114
|
-
ctx.ui.setStatus(
|
|
114
|
+
clearActivitySource(ctx, ACTIVITY_SOURCE);
|
|
115
|
+
ctx.ui.setStatus(ACTIVITY_SOURCE, undefined);
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
const counts = taskCounts(state.tasks);
|
|
119
120
|
const active = hasActiveTasks(state);
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const label = `${theme.fg(color, taskGlyph(task.status))} ${theme.fg(terminal(task.status) ? "muted" : "text", oneLine(task.content))}`;
|
|
133
|
-
return truncateToWidth(label, width);
|
|
134
|
-
});
|
|
135
|
-
const hidden = source.length - shown.length;
|
|
136
|
-
if (hidden > 0) lines.push(truncateToWidth(theme.fg("dim", `… +${hidden} more · /tasks`), width));
|
|
137
|
-
return [header, ...lines];
|
|
138
|
-
},
|
|
139
|
-
invalidate() {},
|
|
140
|
-
});
|
|
141
|
-
ctx.ui.setWidget(WIDGET_ID, renderWidget);
|
|
121
|
+
const finished = counts.completed + counts.cancelled;
|
|
122
|
+
const visible = (active
|
|
123
|
+
? state.tasks.filter((task) => !terminal(task.status))
|
|
124
|
+
: state.tasks.slice(-3));
|
|
125
|
+
setActivitySource(ctx, ACTIVITY_SOURCE, visible.map((task) => ({
|
|
126
|
+
id: task.id,
|
|
127
|
+
label: `Tasks ${finished}/${counts.total}`,
|
|
128
|
+
title: task.content,
|
|
129
|
+
detail: task.status.replaceAll("_", " "),
|
|
130
|
+
state: (task.status === "in_progress" ? "active" : task.status === "completed" ? "success" : task.status === "blocked" ? "error" : "muted") as ActivityState,
|
|
131
|
+
priority: task.status === "blocked" ? 100 : task.status === "in_progress" ? 60 : 10,
|
|
132
|
+
})));
|
|
142
133
|
if (active) {
|
|
143
134
|
const status = counts.blocked > 0
|
|
144
135
|
? `Tasks ${counts.completed}/${counts.total} · ${counts.blocked} blocked`
|
|
145
136
|
: `Tasks ${counts.completed}/${counts.total} · ${counts.inProgress} active`;
|
|
146
|
-
ctx.ui.setStatus(
|
|
137
|
+
ctx.ui.setStatus(ACTIVITY_SOURCE, ctx.ui.theme.fg(counts.blocked > 0 ? "warning" : "accent", status));
|
|
147
138
|
return;
|
|
148
139
|
}
|
|
149
140
|
|
|
150
|
-
ctx.ui.setStatus(
|
|
141
|
+
ctx.ui.setStatus(ACTIVITY_SOURCE, ctx.ui.theme.fg("success", `Tasks complete ${counts.completed}/${counts.total}`));
|
|
151
142
|
finishedTimer = setTimeout(() => {
|
|
152
143
|
finishedTimer = undefined;
|
|
153
144
|
if (lastCtx !== ctx || hasActiveTasks(state)) return;
|
|
154
|
-
ctx
|
|
155
|
-
ctx.ui.setStatus(
|
|
145
|
+
clearActivitySource(ctx, ACTIVITY_SOURCE);
|
|
146
|
+
ctx.ui.setStatus(ACTIVITY_SOURCE, undefined);
|
|
156
147
|
}, FINISHED_LINGER_MS);
|
|
157
148
|
finishedTimer.unref?.();
|
|
158
149
|
}
|