@ryan_nookpi/pi-extension-subagent 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 +242 -0
- package/above-widget.ts +20 -0
- package/agents.ts +233 -0
- package/claude-args.ts +103 -0
- package/claude-sdk-runner.ts +223 -0
- package/claude-sdk-stream-adapter.ts +49 -0
- package/claude-sidecar-writer.ts +107 -0
- package/claude-stream-parser.ts +400 -0
- package/cli.ts +549 -0
- package/commands.ts +2295 -0
- package/config.ts +119 -0
- package/constants.ts +115 -0
- package/context-limits.ts +132 -0
- package/display-task.ts +98 -0
- package/escalation.ts +159 -0
- package/format.ts +188 -0
- package/group-pending.ts +90 -0
- package/index.ts +200 -0
- package/invocation-queue.ts +29 -0
- package/live-preview.ts +39 -0
- package/package.json +95 -0
- package/persisted-session.ts +185 -0
- package/replay.ts +461 -0
- package/retry.ts +164 -0
- package/run-utils.ts +203 -0
- package/runner.ts +979 -0
- package/session.ts +319 -0
- package/store.ts +143 -0
- package/tool-execute.ts +1932 -0
- package/tool-render.ts +145 -0
- package/types.ts +193 -0
- package/utils/agent-utils.ts +315 -0
- package/utils/format-utils.ts +255 -0
- package/utils/short-label.ts +78 -0
- package/utils/string-utils.ts +229 -0
- package/utils/time-utils.ts +105 -0
- package/widget.ts +294 -0
package/widget.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent run status widget — renders per-run status boxes above the editor.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Box, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { HANG_WARNING_IDLE_MS, PARENT_HINT } from "./constants.js";
|
|
8
|
+
import {
|
|
9
|
+
AGENT_NAME_PALETTE,
|
|
10
|
+
agentBgIndex,
|
|
11
|
+
getContextBarColorByRemaining,
|
|
12
|
+
getRemainingContextPercent,
|
|
13
|
+
getUsedContextPercent,
|
|
14
|
+
resolveContextWindow,
|
|
15
|
+
truncatePlainToWidth,
|
|
16
|
+
} from "./format.js";
|
|
17
|
+
|
|
18
|
+
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
|
19
|
+
const SPINNER_INTERVAL_MS = 120;
|
|
20
|
+
const SPINNER_REFRESH_MS = 150;
|
|
21
|
+
|
|
22
|
+
const MAX_VISIBLE_RUNS = 3;
|
|
23
|
+
const MAX_TASK_LABEL_CHARS = 144;
|
|
24
|
+
|
|
25
|
+
import { type SubagentStore, truncateText } from "./store.js";
|
|
26
|
+
import type { CommandRunState } from "./types.js";
|
|
27
|
+
|
|
28
|
+
type ThemeBg = Parameters<Theme["bg"]>[0];
|
|
29
|
+
type WidgetTheme = {
|
|
30
|
+
fg: (color: ThemeColor, text: string) => string;
|
|
31
|
+
bold: (text: string) => string;
|
|
32
|
+
bg: (color: ThemeBg, text: string) => string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type WidgetFactory = (
|
|
36
|
+
tui: unknown,
|
|
37
|
+
theme: WidgetTheme,
|
|
38
|
+
) => {
|
|
39
|
+
render(width: number): string[];
|
|
40
|
+
invalidate?(): void;
|
|
41
|
+
dispose?(): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type WidgetPlacementOptions = { placement?: "aboveEditor" | "belowEditor" };
|
|
45
|
+
|
|
46
|
+
type WidgetSetWidget = {
|
|
47
|
+
(key: string, content: string[] | undefined, options?: WidgetPlacementOptions): void;
|
|
48
|
+
(key: string, content: WidgetFactory | undefined, options?: WidgetPlacementOptions): void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type WidgetRenderCtx = {
|
|
52
|
+
hasUI?: boolean;
|
|
53
|
+
ui?: {
|
|
54
|
+
setWidget: WidgetSetWidget;
|
|
55
|
+
};
|
|
56
|
+
model?: { contextWindow?: number };
|
|
57
|
+
modelRegistry?: {
|
|
58
|
+
getAll: () => Array<{ provider: string; id: string; contextWindow?: number }>;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Fast timer that drives spinner animation while any run is active. */
|
|
63
|
+
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
64
|
+
|
|
65
|
+
function manageSpinnerTimer(store: SubagentStore): void {
|
|
66
|
+
const hasRunning = Array.from(store.commandRuns.values()).some((r) => r.status === "running");
|
|
67
|
+
if (hasRunning && !spinnerTimer) {
|
|
68
|
+
spinnerTimer = setInterval(() => updateCommandRunsWidget(store), SPINNER_REFRESH_MS);
|
|
69
|
+
} else if (!hasRunning && spinnerTimer) {
|
|
70
|
+
clearInterval(spinnerTimer);
|
|
71
|
+
spinnerTimer = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function cleanupCommandRunsWidgetTimer(): void {
|
|
76
|
+
if (!spinnerTimer) return;
|
|
77
|
+
clearInterval(spinnerTimer);
|
|
78
|
+
spinnerTimer = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getStatusVisual(run: CommandRunState): { statusColor: ThemeColor; statusIcon: string } {
|
|
82
|
+
const statusColor: ThemeColor = run.status === "running" ? "warning" : run.status === "done" ? "success" : "error";
|
|
83
|
+
const spinnerFrame = SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length];
|
|
84
|
+
const statusIcon = run.status === "running" ? spinnerFrame : run.status === "done" ? "✓" : "✗";
|
|
85
|
+
return { statusColor, statusIcon };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatCompactDuration(ms: number): string {
|
|
89
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
90
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
91
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
92
|
+
const seconds = totalSeconds % 60;
|
|
93
|
+
if (hours > 0) return `${hours}h${minutes}m${seconds}s`;
|
|
94
|
+
if (minutes > 0) return `${minutes}m${seconds}s`;
|
|
95
|
+
return `${seconds}s`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getIdleLabel(run: CommandRunState, theme: WidgetTheme): string {
|
|
99
|
+
if (run.status !== "running" || !run.lastActivityAt) return "";
|
|
100
|
+
const idleMs = Date.now() - run.lastActivityAt;
|
|
101
|
+
if (idleMs < 60_000) return "";
|
|
102
|
+
const idleColor: ThemeColor = idleMs >= HANG_WARNING_IDLE_MS ? "error" : "dim";
|
|
103
|
+
return theme.fg(idleColor, `idle:${formatCompactDuration(idleMs)}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const WIDGET_BAR_WIDTH = 5;
|
|
107
|
+
|
|
108
|
+
function formatCompactContextBar(percent: number): string {
|
|
109
|
+
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
|
110
|
+
const filled = Math.round((clamped / 100) * WIDGET_BAR_WIDTH);
|
|
111
|
+
if (filled <= 0) return "";
|
|
112
|
+
return `[${"■".repeat(filled)}${"□".repeat(WIDGET_BAR_WIDTH - filled)}]`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getContextShort(run: CommandRunState, ctx: WidgetRenderCtx, theme: WidgetTheme): string {
|
|
116
|
+
const contextWindow = resolveContextWindow(ctx, run.model);
|
|
117
|
+
const usedContextPercent = getUsedContextPercent(run.usage?.contextTokens, contextWindow);
|
|
118
|
+
const remainingContextPercent = getRemainingContextPercent(usedContextPercent);
|
|
119
|
+
if (usedContextPercent === undefined) return "";
|
|
120
|
+
const contextBar = formatCompactContextBar(usedContextPercent);
|
|
121
|
+
if (!contextBar) return "";
|
|
122
|
+
const contextBarColor =
|
|
123
|
+
remainingContextPercent !== undefined ? getContextBarColorByRemaining(remainingContextPercent) : undefined;
|
|
124
|
+
return contextBarColor ? theme.fg(contextBarColor, contextBar) : theme.fg("dim", contextBar);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildPrimaryLabelText(run: CommandRunState): string {
|
|
128
|
+
const lastLine = run.lastLine
|
|
129
|
+
?.replace(/\s*\n+\s*/g, " ")
|
|
130
|
+
.replace(/\s{2,}/g, " ")
|
|
131
|
+
.trim();
|
|
132
|
+
if (lastLine) return truncateText(lastLine, MAX_TASK_LABEL_CHARS);
|
|
133
|
+
|
|
134
|
+
const displayTask = run.displayTask ?? run.task;
|
|
135
|
+
return truncateText(displayTask.replace(/\s+/g, " ").trim(), MAX_TASK_LABEL_CHARS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function joinParts(parts: string[], delimiter: string): string {
|
|
139
|
+
return parts.filter(Boolean).join(delimiter);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getPartsWidth(parts: Array<{ width: number }>, delimiterWidth: number): number {
|
|
143
|
+
if (parts.length === 0) return 0;
|
|
144
|
+
return parts.reduce((sum, part) => sum + part.width, 0) + delimiterWidth * (parts.length - 1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildStatusLeft(run: CommandRunState, theme: WidgetTheme, maxWidth: number): string {
|
|
148
|
+
if (maxWidth <= 0) return "";
|
|
149
|
+
|
|
150
|
+
const { statusColor, statusIcon } = getStatusVisual(run);
|
|
151
|
+
const delimiter = theme.fg("muted", " · ");
|
|
152
|
+
const delimiterWidth = visibleWidth(" · ");
|
|
153
|
+
const taskText = buildPrimaryLabelText(run);
|
|
154
|
+
const idleLabel = getIdleLabel(run, theme);
|
|
155
|
+
const statusLabel = theme.fg(statusColor, `${statusIcon} #${run.id}`);
|
|
156
|
+
const isolatedBadge = run.contextMode === "sub" ? `${theme.fg("accent", "[I]")} ` : "";
|
|
157
|
+
const agentLabel = `${isolatedBadge}\x1b[38;5;${AGENT_NAME_PALETTE[agentBgIndex(run.agent)]}m${run.agent}\x1b[39m`;
|
|
158
|
+
const elapsedLabel = theme.fg("dim", formatCompactDuration(run.elapsedMs));
|
|
159
|
+
|
|
160
|
+
const statusPart = { text: statusLabel, width: visibleWidth(statusLabel) };
|
|
161
|
+
const agentPart = { text: agentLabel, width: visibleWidth(agentLabel) };
|
|
162
|
+
const elapsedPart = { text: elapsedLabel, width: visibleWidth(elapsedLabel) };
|
|
163
|
+
const idlePart = idleLabel ? { text: idleLabel, width: visibleWidth(idleLabel) } : undefined;
|
|
164
|
+
|
|
165
|
+
const fixedLayouts = [
|
|
166
|
+
[statusPart, agentPart, elapsedPart, idlePart].filter(Boolean),
|
|
167
|
+
[statusPart, agentPart, elapsedPart],
|
|
168
|
+
[statusPart, agentPart],
|
|
169
|
+
[statusPart],
|
|
170
|
+
] as Array<Array<{ text: string; width: number }>>;
|
|
171
|
+
|
|
172
|
+
for (const fixedParts of fixedLayouts) {
|
|
173
|
+
const baseWidth = getPartsWidth(fixedParts, delimiterWidth);
|
|
174
|
+
if (!taskText) {
|
|
175
|
+
if (baseWidth <= maxWidth)
|
|
176
|
+
return joinParts(
|
|
177
|
+
fixedParts.map((part) => part.text),
|
|
178
|
+
delimiter,
|
|
179
|
+
);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const taskBudget = maxWidth - baseWidth - (fixedParts.length > 0 ? delimiterWidth : 0);
|
|
184
|
+
if (taskBudget <= 0) continue;
|
|
185
|
+
const truncatedTask = truncateText(taskText, Math.min(MAX_TASK_LABEL_CHARS, taskBudget));
|
|
186
|
+
if (!truncatedTask) continue;
|
|
187
|
+
return joinParts([...fixedParts.map((part) => part.text), theme.fg("dim", truncatedTask)], delimiter);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (taskText) return theme.fg("dim", truncateText(taskText, maxWidth));
|
|
191
|
+
return theme.fg(statusColor, truncateText(`${statusIcon} #${run.id}`, maxWidth));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function composeRunLine(run: CommandRunState, ctx: WidgetRenderCtx, theme: WidgetTheme, innerWidth: number): string {
|
|
195
|
+
const right = getContextShort(run, ctx, theme);
|
|
196
|
+
if (!right) return buildStatusLeft(run, theme, innerWidth);
|
|
197
|
+
|
|
198
|
+
const fittedRight = truncateToWidth(right, innerWidth, theme.fg("dim", "..."));
|
|
199
|
+
const rightWidth = visibleWidth(fittedRight);
|
|
200
|
+
const leftBudget = Math.max(0, innerWidth - rightWidth - (innerWidth > rightWidth ? 1 : 0));
|
|
201
|
+
const left = buildStatusLeft(run, theme, leftBudget);
|
|
202
|
+
if (!left) return `${" ".repeat(Math.max(0, innerWidth - rightWidth))}${fittedRight}`;
|
|
203
|
+
|
|
204
|
+
const gapWidth = Math.max(1, innerWidth - visibleWidth(left) - rightWidth);
|
|
205
|
+
return `${left}${" ".repeat(gapWidth)}${fittedRight}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function updateCommandRunsWidget(store: SubagentStore, ctx?: WidgetRenderCtx): void {
|
|
209
|
+
const activeCtx = ctx ?? store.commandWidgetCtx;
|
|
210
|
+
if (!activeCtx?.hasUI || !activeCtx.ui) return;
|
|
211
|
+
store.commandWidgetCtx = activeCtx;
|
|
212
|
+
const { ui } = activeCtx;
|
|
213
|
+
|
|
214
|
+
// Parent session hint — visible when inside a child session (persistent parent link exists)
|
|
215
|
+
if (store.currentParentSessionFile) {
|
|
216
|
+
ui.setWidget(
|
|
217
|
+
"sub-parent",
|
|
218
|
+
(_tui: unknown, theme: WidgetTheme) => {
|
|
219
|
+
const box = new Box(1, 0);
|
|
220
|
+
const content = new Text("", 0, 0);
|
|
221
|
+
box.addChild(content);
|
|
222
|
+
return {
|
|
223
|
+
render(width: number): string[] {
|
|
224
|
+
const innerWidth = Math.max(1, width - 2);
|
|
225
|
+
content.setText(theme.fg("accent", truncatePlainToWidth(PARENT_HINT, innerWidth)));
|
|
226
|
+
return box.render(width);
|
|
227
|
+
},
|
|
228
|
+
invalidate() {
|
|
229
|
+
box.invalidate();
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
{ placement: "aboveEditor" },
|
|
234
|
+
);
|
|
235
|
+
} else {
|
|
236
|
+
ui.setWidget("sub-parent", undefined);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const statusPriority = (status: "running" | "done" | "error") =>
|
|
240
|
+
status === "running" ? 0 : status === "done" ? 1 : 2;
|
|
241
|
+
// Show all subagent runs in the aboveEditor widget, regardless of how they were launched.
|
|
242
|
+
const runs = Array.from(store.commandRuns.values())
|
|
243
|
+
.filter((r) => !r.removed)
|
|
244
|
+
.sort((a, b) => {
|
|
245
|
+
const priorityDiff = statusPriority(a.status) - statusPriority(b.status);
|
|
246
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
247
|
+
const startedDiff = (b.startedAt ?? 0) - (a.startedAt ?? 0);
|
|
248
|
+
if (startedDiff !== 0) return startedDiff;
|
|
249
|
+
return b.id - a.id;
|
|
250
|
+
})
|
|
251
|
+
.slice(0, MAX_VISIBLE_RUNS);
|
|
252
|
+
const visibleRunIds = new Set<number>(runs.map((run) => run.id));
|
|
253
|
+
|
|
254
|
+
for (const id of Array.from(store.renderedRunWidgetIds)) {
|
|
255
|
+
if (!visibleRunIds.has(id)) {
|
|
256
|
+
ui.setWidget(`sub-${id}`, undefined);
|
|
257
|
+
store.renderedRunWidgetIds.delete(id);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (runs.length === 0) {
|
|
262
|
+
ui.setWidget("subagent-runs", undefined);
|
|
263
|
+
manageSpinnerTimer(store);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ui.setWidget("subagent-runs", undefined);
|
|
268
|
+
|
|
269
|
+
for (const run of runs) {
|
|
270
|
+
store.renderedRunWidgetIds.add(run.id);
|
|
271
|
+
ui.setWidget(
|
|
272
|
+
`sub-${run.id}`,
|
|
273
|
+
(_tui: unknown, theme: WidgetTheme) => {
|
|
274
|
+
const box = new Box(1, 0);
|
|
275
|
+
const content = new Text("", 0, 0);
|
|
276
|
+
box.addChild(content);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
render(width: number): string[] {
|
|
280
|
+
const innerWidth = Math.max(1, width - 2);
|
|
281
|
+
content.setText(composeRunLine(run, activeCtx, theme, innerWidth));
|
|
282
|
+
return box.render(width);
|
|
283
|
+
},
|
|
284
|
+
invalidate() {
|
|
285
|
+
box.invalidate();
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
{ placement: "aboveEditor" },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
manageSpinnerTimer(store);
|
|
294
|
+
}
|