@ferris1225/pi-subagents 4.1.20 → 4.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -28
- package/package.json +1 -1
- package/src/announcements.ts +26 -0
- package/src/dispatch.ts +34 -14
- package/src/monitor.ts +103 -4
- package/src/tools.ts +6 -58
- package/src/widget.ts +223 -106
package/README.md
CHANGED
|
@@ -184,13 +184,12 @@ that delivers without another gate.
|
|
|
184
184
|
where they are in `~/.pi/agent/pi-subagents-recovery.json`. Every later session
|
|
185
185
|
start repeats that notice until you remove the artifacts.
|
|
186
186
|
|
|
187
|
-
## Threads:
|
|
187
|
+
## Threads: resume, stop
|
|
188
188
|
|
|
189
189
|
Every dispatch returns a stable `#id`, which is the handle for the thread tools:
|
|
190
190
|
|
|
191
191
|
| Tool | What it does |
|
|
192
192
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
193
|
-
| `subagent_wait` | Block in-turn until already-dispatched run(s) settle and return their results (`id` or prefix; omit for all active runs). |
|
|
194
193
|
| `subagent_control` | `resume` a parked or settled thread with its full retained context, optionally appending a new `objective`. |
|
|
195
194
|
| `subagent_stop` | Destructively cancel, deliver the partial output, and retire the thread. Steering and follow-up messages still queued in the child are dropped so nothing can revive it later. |
|
|
196
195
|
|
|
@@ -198,14 +197,14 @@ Every dispatch returns a stable `#id`, which is the handle for the thread tools:
|
|
|
198
197
|
subagent_control({ action: "resume", id: 7, objective: "Finish the tests." });
|
|
199
198
|
```
|
|
200
199
|
|
|
201
|
-
There is deliberately no status or
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
timer
|
|
208
|
-
settles, a parked run answers immediately with its resume handle, and
|
|
200
|
+
There is deliberately no status, polling, or wait tool. Every result delivers
|
|
201
|
+
itself as a completion that wakes the main model, so a turn never blocks on a
|
|
202
|
+
running subagent — keep working or end the turn, and the completion continues
|
|
203
|
+
it. The one in-turn block is `wait: true` on a dispatch, which holds that call
|
|
204
|
+
until the runs it started settle: the escape hatch for one-shot `pi -p`
|
|
205
|
+
parents, which exit at end of turn and would otherwise never see them. That
|
|
206
|
+
wait runs on no timer and no timeout the model picks: it resolves the instant
|
|
207
|
+
its run settles, a parked run answers immediately with its resume handle, and
|
|
209
208
|
aborting the turn is the escape hatch. Control operations are all bounded, so
|
|
210
209
|
they never hang on a generation that is still settling.
|
|
211
210
|
|
|
@@ -224,28 +223,41 @@ threads that had already finished keep only their delivered result.
|
|
|
224
223
|
|
|
225
224
|
## Live status and results
|
|
226
225
|
|
|
227
|
-
The TUI widget renders one
|
|
228
|
-
right-aligned `#id`, padded agent name, then the task label — so
|
|
229
|
-
starts at the same column, with the live activity dimmed after
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
226
|
+
The TUI widget renders one line per participant in fixed identity columns —
|
|
227
|
+
status icon, right-aligned `#id`, padded agent name, then the task label — so
|
|
228
|
+
every label starts at the same column, with the live activity dimmed after
|
|
229
|
+
` — ` and the rest of the telemetry flowing inline after ` · `: the worktree
|
|
230
|
+
badge, the token flow in the footer vocabulary (`↑` input, `↓` output,
|
|
231
|
+
`R`/`W` cache read/write), cost, the full `provider/model/thinking` ref, the
|
|
232
|
+
wait state, and an elapsed time that always carries seconds. The first line is
|
|
233
|
+
the parent session itself — what the current model is doing right now while
|
|
234
|
+
its agent loop runs. A managed workflow (the automatic review / fix / re-review
|
|
235
|
+
chain) renders as a tree: the parent line carries the workflow-wide token/cost
|
|
236
|
+
totals and total elapsed, and every stage gets its own `├`/`└`-connected row
|
|
237
|
+
with its own model, token flow, and elapsed — settled stages keep the
|
|
238
|
+
telemetry frozen at settlement, the live stage shows its child's model and
|
|
239
|
+
current activity:
|
|
234
240
|
|
|
235
241
|
```text
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
242
|
+
● pi subagent Implement the login redirect fix · openai/gpt-5/max · 12m06s
|
|
243
|
+
◆ #12 worker src/cache.ts · wt:a91f3c · ↑5.2k ↓41.0k R210.0k W6.1k $1.9400 · 12m06s
|
|
244
|
+
├ ✓ implement · ↑1.0k ↓12.0k R40.0k W1.2k $0.5100 · xai/grok-4/xhigh · 2m41s
|
|
245
|
+
├ ! review · ↑0.9k ↓6.0k R38.0k W0.9k $0.3300 · openai/gpt-5 · 1m12s
|
|
246
|
+
├ ● review fix — edit src/auth.ts · ↑0.2k ↓3.0k R12.0k $0.1200 · openai/gpt-5/medium · 41s
|
|
247
|
+
└ ○ re-review
|
|
248
|
+
● #15 explorer src/models.ts — grep fallback · ↑1.2k ↓8.4k R31.0k W1.1k $0.0900 · openai/gpt-5-mini/low · 3m07s
|
|
249
|
+
○ #23 worker src/config.ts · repo lane
|
|
250
|
+
○ #24 worker ↻ tests/config.test.ts · queued · 5m02s
|
|
241
251
|
```
|
|
242
252
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
Telemetry drops leftmost-first when a row runs out of width (badge, wait
|
|
254
|
+
state, usage, model) while the elapsed survives every width. Queued rows state
|
|
255
|
+
what they actually wait for — `queued` for a free process slot, `repo lane`
|
|
256
|
+
for shared-checkout write serialization, or `starting` — and a resumed thread
|
|
257
|
+
carries a dim `↻` in its agent column with its cumulative time. The widget is
|
|
258
|
+
capped at ten lines: when many runs are live, extra roots collapse into a
|
|
259
|
+
`… +N more` marker, and an oversized stage chain keeps a window anchored on
|
|
260
|
+
the live stage so the editor keeps its space.
|
|
249
261
|
|
|
250
262
|
Completions resume the main agent on their own, with a compact block of at most 40
|
|
251
263
|
lines by default; longer output lands unchanged in a Markdown artifact whose path
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.23",
|
|
4
4
|
"description": "A managed sub-agent team for pi: specialized roles, pre-commit documentation sync, retained threads, auto-fix chains, model fallback, and Git worktree isolation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/announcements.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { loadConfig, saveConfig } from "./config.ts";
|
|
6
6
|
import { availableModelsInScope, filterUnavailableModelOverrides } from "./models.ts";
|
|
7
|
+
import { formatToolActivity, monitor } from "./monitor.ts";
|
|
7
8
|
import { announceRecoveryRecords } from "./recovery.ts";
|
|
8
9
|
import type { SubagentRuntime } from "./runtime.ts";
|
|
9
10
|
import { installActiveRunsWidget } from "./widget.ts";
|
|
@@ -35,7 +36,32 @@ async function migrateUnavailableAgentModels(
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
/** Track the parent pi session itself as the widget's first row: what the
|
|
40
|
+
* current model is doing while its agent loop runs. Same activity vocabulary
|
|
41
|
+
* as subagent rows (thinking / responding / tool + target), fed by the
|
|
42
|
+
* session's own extension events; the row disappears when the loop settles. */
|
|
43
|
+
function trackMainActivity(pi: ExtensionAPI): void {
|
|
44
|
+
pi.on("agent_start", () => monitor.setMainAgentActive(true));
|
|
45
|
+
pi.on("agent_end", () => monitor.setMainAgentActive(false));
|
|
46
|
+
pi.on("agent_settled", () => monitor.setMainAgentActive(false));
|
|
47
|
+
pi.on("model_select", (event) => monitor.setMainModel(event.model?.id));
|
|
48
|
+
pi.on("thinking_level_select", (event) => monitor.setMainThinking(event.level));
|
|
49
|
+
pi.on("message_update", (event) => {
|
|
50
|
+
if (event.message.role !== "assistant") return;
|
|
51
|
+
const kind = event.assistantMessageEvent.type;
|
|
52
|
+
if (kind === "text_start" || kind === "text_delta") monitor.setMainActivity("responding");
|
|
53
|
+
else if (kind === "thinking_start" || kind === "thinking_delta") monitor.setMainActivity("thinking");
|
|
54
|
+
});
|
|
55
|
+
pi.on("tool_execution_start", (event) =>
|
|
56
|
+
monitor.recordMainToolStart(event.toolName, formatToolActivity(event.toolName, event.args)));
|
|
57
|
+
pi.on("tool_execution_end", (event) => monitor.recordMainToolEnd(event.toolName, event.isError));
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
export function registerAnnouncements(pi: ExtensionAPI, runtime: SubagentRuntime): void {
|
|
61
|
+
// Registered at extension load (not session_start) so a model selection
|
|
62
|
+
// made during restore is already captured when the widget appears.
|
|
63
|
+
trackMainActivity(pi);
|
|
64
|
+
|
|
39
65
|
pi.on("session_start", async (_event, ctx) => {
|
|
40
66
|
if (!existsSync(runtime.configPath)) {
|
|
41
67
|
ctx.ui.notify(
|
package/src/dispatch.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
monitor,
|
|
31
31
|
statusIcon,
|
|
32
32
|
type RunChainMeta,
|
|
33
|
+
type RunView,
|
|
33
34
|
type RunWaitReason,
|
|
34
35
|
type WorkflowStage,
|
|
35
36
|
type WorkflowStageStatus,
|
|
@@ -151,12 +152,14 @@ function withReviewerFixStageAgent(agent: AgentConfig): AgentConfig {
|
|
|
151
152
|
};
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
155
|
+
/** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
|
|
156
|
+
* `pi -p` parents that exit at end of turn: hold the call until every run it
|
|
157
|
+
* started settles, then hand back their result blocks. Interactive sessions
|
|
158
|
+
* never take this path; their results arrive as completion wake-ups. No
|
|
159
|
+
* timer: a waiter resolves the moment its run's result registers (children
|
|
160
|
+
* are bounded by the idle watchdog), an already-parked run answers
|
|
161
|
+
* immediately with its resume handle, and the turn's abort signal remains the
|
|
162
|
+
* escape hatch. */
|
|
160
163
|
export async function awaitRunResults(
|
|
161
164
|
runtime: SubagentRuntime,
|
|
162
165
|
runIds: number[],
|
|
@@ -237,18 +240,21 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
237
240
|
const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
|
|
238
241
|
|
|
239
242
|
// Finished runs leave the active monitor immediately. Their final findings
|
|
240
|
-
// are sent as a custom message that starts a follow-up turn.
|
|
243
|
+
// are sent as a custom message that starts a follow-up turn. Returns the
|
|
244
|
+
// removed row so workflow callers can freeze its exact elapsed time onto
|
|
245
|
+
// the stage projection before the row is gone.
|
|
241
246
|
const finishRun = (
|
|
242
247
|
runId: number,
|
|
243
248
|
status: "done" | "failed",
|
|
244
249
|
opts?: { silent?: boolean },
|
|
245
|
-
):
|
|
250
|
+
): RunView | undefined => {
|
|
246
251
|
monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
|
|
247
252
|
const run = monitor.removeRun(runId);
|
|
248
|
-
if (!run) return; // already finished — stay idempotent
|
|
249
|
-
if (opts?.silent || !runtime.sessionActive) return;
|
|
253
|
+
if (!run) return undefined; // already finished — stay idempotent
|
|
254
|
+
if (opts?.silent || !runtime.sessionActive) return run;
|
|
250
255
|
const icon = status === "done" ? "✓" : "✗";
|
|
251
256
|
environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
257
|
+
return run;
|
|
252
258
|
};
|
|
253
259
|
|
|
254
260
|
// Live sub-agent activity → concise one-line status ("thinking",
|
|
@@ -352,7 +358,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
352
358
|
agentOverride?: AgentConfig;
|
|
353
359
|
session?: { sessionId: string; sessionDir: string };
|
|
354
360
|
} = {},
|
|
355
|
-
): Promise<{ runId: number; result: SingleResult }> => {
|
|
361
|
+
): Promise<{ runId: number; result: SingleResult; elapsedMs?: number }> => {
|
|
356
362
|
const discoveredAgent = request.agents.find((candidate) => candidate.name === agentName);
|
|
357
363
|
if (!discoveredAgent) {
|
|
358
364
|
throw new Error(`Managed workflow requires enabled agent "${agentName}", but discovery did not provide it.`);
|
|
@@ -409,11 +415,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
409
415
|
runtime.retainSession(result);
|
|
410
416
|
monitor.setModel(runId, result.model, result.modelFallbackFrom);
|
|
411
417
|
monitor.setThinking(runId, result.thinking);
|
|
412
|
-
finishRun(runId, isFailedResult(result) ? "failed" : "done", { silent: true });
|
|
418
|
+
const finished = finishRun(runId, isFailedResult(result) ? "failed" : "done", { silent: true });
|
|
413
419
|
runtime.registerRunResult(runId, result);
|
|
414
|
-
return { runId, result };
|
|
420
|
+
return { runId, result, elapsedMs: finished?.elapsedMs };
|
|
415
421
|
} catch (error) {
|
|
416
|
-
finishRun(runId, "failed", { silent: true });
|
|
422
|
+
const finished = finishRun(runId, "failed", { silent: true });
|
|
417
423
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
418
424
|
const crashed: SingleResult = {
|
|
419
425
|
...queuedResult(route.agent, task, thinkingLevel),
|
|
@@ -471,6 +477,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
471
477
|
agent: initialStepResult.agent,
|
|
472
478
|
relation: initialStageRelation,
|
|
473
479
|
status: workflowStageStatus(initialStepResult),
|
|
480
|
+
// The initial stage is the parent's own run; freeze its telemetry now,
|
|
481
|
+
// before the reopened parent row starts counting workflow-wide time.
|
|
482
|
+
model: initialStepResult.model,
|
|
483
|
+
usage: initialStepResult.usage,
|
|
484
|
+
elapsedMs: monitor.getElapsedMs(request.parentRunId),
|
|
474
485
|
}];
|
|
475
486
|
let reviewStage: WorkflowStage | undefined;
|
|
476
487
|
if (enabled("reviewer")) {
|
|
@@ -482,6 +493,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
482
493
|
};
|
|
483
494
|
publishWorkflowStages();
|
|
484
495
|
|
|
496
|
+
/** Freeze the settled step's telemetry onto its stage: once the child row
|
|
497
|
+
* leaves the monitor, this snapshot is the only per-stage record. */
|
|
498
|
+
const settleStage = (stage: WorkflowStage, step: { result: SingleResult; elapsedMs?: number }): void => {
|
|
499
|
+
stage.model = step.result.model;
|
|
500
|
+
stage.usage = step.result.usage;
|
|
501
|
+
if (step.elapsedMs !== undefined) stage.elapsedMs = step.elapsedMs;
|
|
502
|
+
};
|
|
503
|
+
|
|
485
504
|
const launchStep = async (
|
|
486
505
|
agentName: string,
|
|
487
506
|
task: string,
|
|
@@ -504,6 +523,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
504
523
|
parentRunId: request.parentRunId,
|
|
505
524
|
}, stageOptions);
|
|
506
525
|
stage.status = workflowStageStatus(step.result, relation);
|
|
526
|
+
settleStage(stage, step);
|
|
507
527
|
publishWorkflowStages();
|
|
508
528
|
request.rememberLatest(step.result);
|
|
509
529
|
steps.push({ ...step, relation });
|
package/src/monitor.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface WorkflowStage {
|
|
|
38
38
|
agent: string;
|
|
39
39
|
relation: string;
|
|
40
40
|
status: WorkflowStageStatus;
|
|
41
|
+
/** Telemetry snapshot frozen when the stage settled (the live child row
|
|
42
|
+
* leaves the monitor at that moment); the active stage reads its live child. */
|
|
43
|
+
model?: string;
|
|
44
|
+
usage?: UsageStats;
|
|
45
|
+
elapsedMs?: number;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export function isRunActiveStatus(status: RunStatus): boolean {
|
|
@@ -111,6 +116,17 @@ export interface RunChainMeta {
|
|
|
111
116
|
waitReason?: RunWaitReason;
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
/** Ephemeral activity of the parent pi model while its agent loop runs: the
|
|
120
|
+
* live model/thinking ref and a one-line "what is it doing now". Not a run —
|
|
121
|
+
* no id, usage, or chain machinery; the view disappears when the loop settles. */
|
|
122
|
+
export interface MainActivity {
|
|
123
|
+
model?: string;
|
|
124
|
+
thinking?: string;
|
|
125
|
+
activity?: string;
|
|
126
|
+
/** Epoch ms when the current agent loop started. */
|
|
127
|
+
activeSince: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
114
130
|
// ---------------------------------------------------------------------------
|
|
115
131
|
// Formatting helpers
|
|
116
132
|
// ---------------------------------------------------------------------------
|
|
@@ -289,14 +305,26 @@ function formatTokens(count: number): string {
|
|
|
289
305
|
return String(count);
|
|
290
306
|
}
|
|
291
307
|
|
|
292
|
-
|
|
308
|
+
/** Token flow in the pi-footer vocabulary: ↑input ↓output, R cache-read,
|
|
309
|
+
* W cache-write; zero components are omitted. No cost — callers place it as
|
|
310
|
+
* its own droppable part. */
|
|
311
|
+
export function formatUsageTokens(usage: UsageStats | undefined): string | undefined {
|
|
312
|
+
if (!usage) return undefined;
|
|
293
313
|
const parts: string[] = [];
|
|
294
314
|
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
295
315
|
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
296
316
|
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
297
317
|
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
298
|
-
|
|
299
|
-
|
|
318
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function formatUsageCompact(usage: UsageStats): string {
|
|
322
|
+
return [formatUsageTokens(usage), usageCostPart(usage)].filter(Boolean).join(" ");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Cost as its own droppable part: `$0.0421`, undefined when nothing accrued. */
|
|
326
|
+
export function usageCostPart(usage: UsageStats | undefined): string | undefined {
|
|
327
|
+
return usage?.cost ? `$${usage.cost.toFixed(4)}` : undefined;
|
|
300
328
|
}
|
|
301
329
|
|
|
302
330
|
/** Aggregate usage across several runs (chain steps or a completion group). */
|
|
@@ -319,9 +347,11 @@ export function formatDuration(ms: number): string {
|
|
|
319
347
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
320
348
|
const minutes = Math.floor(totalSeconds / 60);
|
|
321
349
|
const seconds = totalSeconds % 60;
|
|
350
|
+
// Seconds stay visible at every magnitude: a long-running chain is judged
|
|
351
|
+
// by whether it is still moving, and "5s ago" is exactly that signal.
|
|
322
352
|
if (minutes < 60) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
323
353
|
const hours = Math.floor(minutes / 60);
|
|
324
|
-
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
354
|
+
return `${hours}h${String(minutes % 60).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
|
|
325
355
|
}
|
|
326
356
|
|
|
327
357
|
/** Cumulative active time across generations; parked gaps never count. */
|
|
@@ -453,6 +483,71 @@ export class MonitorStore {
|
|
|
453
483
|
private runs: RunView[] = [];
|
|
454
484
|
private nextId = 1;
|
|
455
485
|
private subscribers = new Set<() => void>();
|
|
486
|
+
private mainModel?: string;
|
|
487
|
+
private mainThinking?: string;
|
|
488
|
+
private mainActivity?: string;
|
|
489
|
+
private mainActiveSince?: number;
|
|
490
|
+
|
|
491
|
+
// --- parent pi model activity ------------------------------------------
|
|
492
|
+
// Fed by the parent session's extension events (agent loop, streaming,
|
|
493
|
+
// tool executions); rendered as the widget's first line. Change-guarded so
|
|
494
|
+
// per-token streaming deltas do not flood subscribers.
|
|
495
|
+
|
|
496
|
+
setMainModel(model?: string): void {
|
|
497
|
+
if (!model || this.mainModel === model) return;
|
|
498
|
+
this.mainModel = model;
|
|
499
|
+
this.notify();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
setMainThinking(thinking?: string): void {
|
|
503
|
+
if (!thinking || this.mainThinking === thinking) return;
|
|
504
|
+
this.mainThinking = thinking;
|
|
505
|
+
this.notify();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
setMainActivity(text: string): void {
|
|
509
|
+
const activity = sanitizeActivityText(text) || undefined;
|
|
510
|
+
if (!activity || this.mainActivity === activity) return;
|
|
511
|
+
this.mainActivity = activity;
|
|
512
|
+
this.notify();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Record the main model starting a tool; the activity shows the tool's
|
|
516
|
+
* most telling argument, same vocabulary as subagent rows. */
|
|
517
|
+
recordMainToolStart(toolName: string, activity: string): void {
|
|
518
|
+
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
519
|
+
this.setMainActivity(activity || safeToolName);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Record a failed main-model tool; successful completions keep their last
|
|
523
|
+
* activity until the next model event supplies a better description. */
|
|
524
|
+
recordMainToolEnd(toolName: string, isError: boolean): void {
|
|
525
|
+
if (isError) this.setMainActivity(`✗ ${sanitizeActivityText(toolName) || "tool"} failed`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Track the parent agent loop: started at agent_start, cleared when the
|
|
529
|
+
* loop settles (agent_end / agent_settled). */
|
|
530
|
+
setMainAgentActive(active: boolean): void {
|
|
531
|
+
if ((this.mainActiveSince !== undefined) === active) return;
|
|
532
|
+
this.mainActiveSince = active ? Date.now() : undefined;
|
|
533
|
+
this.mainActivity = undefined;
|
|
534
|
+
this.notify();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Live view of the parent model while its agent loop runs; undefined when idle. */
|
|
538
|
+
getMainActivity(): MainActivity | undefined {
|
|
539
|
+
if (this.mainActiveSince === undefined) return undefined;
|
|
540
|
+
return {
|
|
541
|
+
...(this.mainModel ? { model: this.mainModel } : {}),
|
|
542
|
+
...(this.mainThinking ? { thinking: this.mainThinking } : {}),
|
|
543
|
+
...(this.mainActivity ? { activity: this.mainActivity } : {}),
|
|
544
|
+
activeSince: this.mainActiveSince,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
isMainAgentActive(): boolean {
|
|
549
|
+
return this.mainActiveSince !== undefined;
|
|
550
|
+
}
|
|
456
551
|
|
|
457
552
|
beginTurn(): void {
|
|
458
553
|
// Clear finished runs from a previous turn, but keep active and parked
|
|
@@ -719,6 +814,10 @@ export class MonitorStore {
|
|
|
719
814
|
* finishRun calls from the old session remain safe no-ops. */
|
|
720
815
|
clear(): void {
|
|
721
816
|
this.runs = [];
|
|
817
|
+
this.mainModel = undefined;
|
|
818
|
+
this.mainThinking = undefined;
|
|
819
|
+
this.mainActivity = undefined;
|
|
820
|
+
this.mainActiveSince = undefined;
|
|
722
821
|
this.notify();
|
|
723
822
|
}
|
|
724
823
|
|
package/src/tools.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Thread controls around the subagent runtime: subagent_control (resume)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* resolve the moment a run settles, never on a timer.
|
|
2
|
+
* Thread controls around the subagent runtime: subagent_control (resume) and
|
|
3
|
+
* destructive subagent_stop. There is no status/poll tool — completions carry
|
|
4
|
+
* each result (with an on-disk artifact when truncated) and wake the main
|
|
5
|
+
* model, so waiting is never a tool call; the only in-turn block is `wait:
|
|
6
|
+
* true` on a dispatch, for one-shot parents that exit at end of turn.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
@@ -13,11 +12,10 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
13
12
|
import { existsSync } from "node:fs";
|
|
14
13
|
import { Type } from "typebox";
|
|
15
14
|
import { DEFAULT_MAX_RESULT_LINES, loadConfig } from "./config.ts";
|
|
16
|
-
import { awaitRunResults } from "./dispatch.ts";
|
|
17
15
|
import { removeThreadRecord } from "./durable.ts";
|
|
18
16
|
import { formatCompletionBlock, matchRunIds } from "./format.ts";
|
|
19
17
|
import { emptyUsage } from "./rpc-run.ts";
|
|
20
|
-
import { formatTaskSummary,
|
|
18
|
+
import { formatTaskSummary, monitor } from "./monitor.ts";
|
|
21
19
|
import { persistRecoveryRecords, recoveryRecordFromFinalization } from "./recovery.ts";
|
|
22
20
|
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
23
21
|
import { CONTROL_QUIESCE_TIMEOUT_MS, projectResultsRoot, quiesced } from "./thread-lifecycle.ts";
|
|
@@ -112,56 +110,6 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
|
|
|
112
110
|
},
|
|
113
111
|
});
|
|
114
112
|
|
|
115
|
-
const SubagentWaitParams = Type.Object({
|
|
116
|
-
id: Type.Optional(
|
|
117
|
-
Type.String({ description: "Run id or prefix to wait for (see dispatch output). Omit to wait for every active run." }),
|
|
118
|
-
),
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
pi.registerTool({
|
|
122
|
-
name: "subagent_wait",
|
|
123
|
-
label: "Subagent Wait",
|
|
124
|
-
description:
|
|
125
|
-
"Block until dispatched background run(s) settle, then return their results — resolves the moment a run settles, never on a timer.",
|
|
126
|
-
promptSnippet: "Block until dispatched runs settle; returns results the moment they finish.",
|
|
127
|
-
parameters: SubagentWaitParams,
|
|
128
|
-
|
|
129
|
-
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
130
|
-
await runtime.durableRestore;
|
|
131
|
-
const config = await loadConfig(runtime.configPath);
|
|
132
|
-
const active = monitor.getRuns()
|
|
133
|
-
.filter((run) => isRunActiveStatus(run.status) || run.status === "parked")
|
|
134
|
-
.map((run) => run.id);
|
|
135
|
-
const requested = params.id?.trim();
|
|
136
|
-
// Settled runs stay addressable: a wait issued just after completion
|
|
137
|
-
// returns that result immediately instead of missing the run.
|
|
138
|
-
const targets = requested
|
|
139
|
-
? matchRunIds([...new Set([...active, ...runtime.settledRuns.keys()])], requested)
|
|
140
|
-
: active;
|
|
141
|
-
if (targets.length === 0) {
|
|
142
|
-
const activeList = active.map((id) => `#${id}`).join(", ");
|
|
143
|
-
return {
|
|
144
|
-
content: [{
|
|
145
|
-
type: "text",
|
|
146
|
-
text: requested
|
|
147
|
-
? `No subagent run matches "${requested}".${activeList ? ` Active runs: ${activeList}.` : ""}`
|
|
148
|
-
: "No active subagent runs to wait for.",
|
|
149
|
-
}],
|
|
150
|
-
details: {},
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
const blocks = await awaitRunResults(runtime, targets, signal, config.maxResultLines, ctx.cwd);
|
|
154
|
-
return { content: [{ type: "text", text: blocks }], details: {} };
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
renderCall(args, theme) {
|
|
158
|
-
return new Text(`${theme.fg("toolTitle", theme.bold("subagent_wait "))}${theme.fg("accent", args.id ? `#${args.id}` : "all")}`, 0, 0);
|
|
159
|
-
},
|
|
160
|
-
renderResult(result, _options, theme) {
|
|
161
|
-
return renderFirstLine(result, "subagent_wait ", theme);
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
|
|
165
113
|
// Cancel one or more active runs: aborts the queue controller, which
|
|
166
114
|
// terminates the child and delivers an aborted result (with whatever partial
|
|
167
115
|
// output it produced) so the main agent always knows the run stopped.
|
package/src/widget.ts
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
* Compact, glanceable active-run widget for interactive Pi sessions.
|
|
3
3
|
*
|
|
4
4
|
* Layout contract (redesign):
|
|
5
|
-
* - Aligned columns: `icon #id agent` pad to the widest displayed
|
|
6
|
-
* agent so every label starts at the same column; a resumed thread
|
|
7
|
-
* a dim `↻` inside the agent column.
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
5
|
+
* - Aligned identity columns: `icon #id agent` pad to the widest displayed
|
|
6
|
+
* id and agent so every label starts at the same column; a resumed thread
|
|
7
|
+
* carries a dim `↻` inside the agent column.
|
|
8
|
+
* - Multi-line rows flow inline: the telemetry (`provider/model/thinking`,
|
|
9
|
+
* token flow in the pi-footer vocabulary `↑in ↓out R/W cache`, cost, wait
|
|
10
|
+
* state, seconds-precision elapsed) follows the content after ` · ` instead
|
|
11
|
+
* of a right-aligned column — no blank padding across a multi-line chain.
|
|
12
|
+
* - Telemetry drops leftmost-first under width pressure (badge, wait, usage,
|
|
13
|
+
* model); the elapsed survives every width.
|
|
14
|
+
* - First line is the parent pi session itself: what the current model is
|
|
15
|
+
* doing right now while its agent loop runs (model/thinking, live activity,
|
|
16
|
+
* loop elapsed), fed by the session's own extension events.
|
|
17
|
+
* - A managed workflow renders as a tree chain under its parent line: one
|
|
18
|
+
* `├`/`└`-connected row per stage, each carrying its own model, token flow,
|
|
19
|
+
* and elapsed — settled stages from the snapshot frozen at settlement, the
|
|
20
|
+
* live stage from its child row. An oversized chain keeps a window anchored
|
|
21
|
+
* on the live stage.
|
|
15
22
|
* - Queued rows say what they actually wait for ("queued" for a process slot,
|
|
16
23
|
* "repo lane" for shared-writer serialization, "starting" while the child
|
|
17
24
|
* process launches) instead of one catch-all "queued".
|
|
@@ -20,14 +27,21 @@
|
|
|
20
27
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
21
28
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
22
29
|
import {
|
|
30
|
+
formatDuration,
|
|
23
31
|
formatElapsed,
|
|
24
32
|
formatTaskSummary,
|
|
33
|
+
formatUsageTokens,
|
|
25
34
|
isRunActiveStatus,
|
|
26
35
|
monitor,
|
|
27
36
|
statusIcon,
|
|
37
|
+
sumUsage,
|
|
38
|
+
usageCostPart,
|
|
39
|
+
type MainActivity,
|
|
28
40
|
type RunView,
|
|
29
41
|
type WorkflowStage,
|
|
42
|
+
type WorkflowStageStatus,
|
|
30
43
|
} from "./monitor.ts";
|
|
44
|
+
import type { UsageStats } from "./rpc-run.ts";
|
|
31
45
|
|
|
32
46
|
export const SUBAGENTS_WIDGET_ID = "pi-subagents";
|
|
33
47
|
|
|
@@ -53,17 +67,13 @@ interface ColumnLayout {
|
|
|
53
67
|
agentWidth: number;
|
|
54
68
|
}
|
|
55
69
|
|
|
56
|
-
/**
|
|
57
|
-
*
|
|
58
|
-
* line
|
|
59
|
-
function
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (rightWidth >= width) return truncateToWidth(right, width, "");
|
|
64
|
-
const leftBudget = width - rightWidth - 1;
|
|
65
|
-
const leftText = visibleWidth(left) > leftBudget ? truncateToWidth(left, leftBudget, "…") : left;
|
|
66
|
-
return `${leftText}${" ".repeat(Math.max(1, width - visibleWidth(leftText) - rightWidth))}${right}`;
|
|
70
|
+
/** Join left content and telemetry inline — `left · telemetry` — so a
|
|
71
|
+
* multi-line chain reads as one flowing sentence instead of leaving blank
|
|
72
|
+
* padding across the width; the whole line truncates as a last resort. */
|
|
73
|
+
function composeLine(left: string, tail: string, theme: Theme, width: number): string {
|
|
74
|
+
if (!tail) return truncateToWidth(left, width, "…");
|
|
75
|
+
const line = `${left}${theme.fg("dim", SEPARATOR)}${theme.fg("dim", tail)}`;
|
|
76
|
+
return visibleWidth(line) <= width ? line : truncateToWidth(line, width, "…");
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
/** Short truthful wait word for a queued row, shown in the telemetry column. */
|
|
@@ -124,27 +134,48 @@ function identitySegment(run: RunView, theme: Theme, layout: ColumnLayout): stri
|
|
|
124
134
|
return `${icon} ${theme.fg("dim", id)} ${name}${resumed}${pad}`;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
|
-
/** One
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
/** One footer-style usage part: token flow plus accrued cost, dropped as a
|
|
138
|
+
* unit before the model under width pressure. */
|
|
139
|
+
function usagePart(usage: UsageStats | undefined): string | undefined {
|
|
140
|
+
return [formatUsageTokens(usage), usageCostPart(usage)].filter(Boolean).join(" ") || undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Telemetry tail parts shared by primary and stage rows: badge and wait word
|
|
144
|
+
* first (dropped first under pressure), then the usage part, the model, and
|
|
145
|
+
* the always-surviving elapsed. `usage` defaults to the run's own; the managed
|
|
146
|
+
* workflow parent overrides it with the workflow-wide aggregate. */
|
|
147
|
+
function telemetryTailParts(run: RunView, now: number, usage: UsageStats = run.usage): Array<string | undefined> {
|
|
137
148
|
// Queued rows omit the model (the route is re-resolved at actual start);
|
|
138
|
-
// workflow parents omit it too (each stage owns its own model).
|
|
139
|
-
|
|
149
|
+
// workflow parents omit it too (each stage row owns its own model). The
|
|
150
|
+
// full provider/model ref is kept — "which provider served this run" is
|
|
151
|
+
// exactly what a multi-provider session needs to see.
|
|
152
|
+
const modelPart = run.status === "queued" || run.managedWorkflow || !run.model
|
|
140
153
|
? undefined
|
|
141
|
-
: `${
|
|
154
|
+
: `${run.model}${run.thinking ? `/${run.thinking}` : ""}`;
|
|
142
155
|
const badge = run.isolation === "worktree" ? worktreeBadge(run) : undefined;
|
|
143
156
|
const wait = run.status === "queued" ? waitWord(run) : undefined;
|
|
144
|
-
|
|
145
|
-
// Drop order under pressure: badge, then model, then wait word; elapsed
|
|
157
|
+
// Drop order under pressure: badge, wait word, usage, model; elapsed
|
|
146
158
|
// survives every width the identity leaves room for.
|
|
147
|
-
|
|
159
|
+
return [badge, wait, usagePart(usage), modelPart, formatElapsed(run, now) || undefined];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** One primary line per run. Left: identity label — activity, where the
|
|
163
|
+
* label stays plain and the live activity is dim. Telemetry flows inline
|
|
164
|
+
* after ` · ` (worktree badge, token flow, cost, model/thinking, wait state,
|
|
165
|
+
* elapsed). The activity outranks the label when space runs out; the identity
|
|
166
|
+
* and elapsed survive every width. */
|
|
167
|
+
function primaryLine(
|
|
168
|
+
run: RunView,
|
|
169
|
+
theme: Theme,
|
|
170
|
+
width: number,
|
|
171
|
+
now: number,
|
|
172
|
+
layout: ColumnLayout,
|
|
173
|
+
usage: UsageStats = run.usage,
|
|
174
|
+
): string {
|
|
175
|
+
const dim = (text: string): string => theme.fg("dim", text);
|
|
176
|
+
const identity = identitySegment(run, theme, layout);
|
|
177
|
+
const tailBudget = Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT);
|
|
178
|
+
const tail = composeTail(telemetryTailParts(run, now, usage), tailBudget);
|
|
148
179
|
|
|
149
180
|
// A chain child rendered at root level (its parent row is gone) keeps its
|
|
150
181
|
// workflow relation; the templated brief itself would only repeat content.
|
|
@@ -159,7 +190,7 @@ function primaryLine(run: RunView, theme: Theme, width: number, now: number, lay
|
|
|
159
190
|
|
|
160
191
|
const contentBudget = width
|
|
161
192
|
- visibleWidth(identity)
|
|
162
|
-
- (tail ? visibleWidth(tail) +
|
|
193
|
+
- (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
|
|
163
194
|
- visibleWidth(IDENTITY_GAP);
|
|
164
195
|
let content = "";
|
|
165
196
|
if (contentBudget > 0) {
|
|
@@ -185,84 +216,156 @@ function primaryLine(run: RunView, theme: Theme, width: number, now: number, lay
|
|
|
185
216
|
}
|
|
186
217
|
|
|
187
218
|
const left = content ? `${identity}${IDENTITY_GAP}${content}` : identity;
|
|
188
|
-
return
|
|
219
|
+
return composeLine(left, tail, theme, width);
|
|
189
220
|
}
|
|
190
221
|
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
switch (stage.status) {
|
|
222
|
+
function stageIcon(status: WorkflowStageStatus, theme: Theme): string {
|
|
223
|
+
switch (status) {
|
|
194
224
|
case "done":
|
|
195
|
-
return theme.fg("success",
|
|
225
|
+
return theme.fg("success", "✓");
|
|
196
226
|
case "active":
|
|
197
|
-
return theme.fg("accent", theme.bold(
|
|
227
|
+
return theme.fg("accent", theme.bold("●"));
|
|
198
228
|
case "changes":
|
|
199
|
-
return theme.fg("warning",
|
|
229
|
+
return theme.fg("warning", "!");
|
|
200
230
|
case "failed":
|
|
201
|
-
return theme.fg("error",
|
|
231
|
+
return theme.fg("error", "✗");
|
|
202
232
|
default:
|
|
203
|
-
return theme.fg("dim",
|
|
233
|
+
return theme.fg("dim", "○");
|
|
204
234
|
}
|
|
205
235
|
}
|
|
206
236
|
|
|
207
|
-
/** Stage
|
|
208
|
-
*
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
/** Stage telemetry source: the live child while the stage runs, the frozen
|
|
238
|
+
* snapshot once it has settled. */
|
|
239
|
+
interface StageTelemetry {
|
|
240
|
+
model?: string;
|
|
241
|
+
thinking?: string;
|
|
242
|
+
usage?: UsageStats;
|
|
243
|
+
elapsed: string;
|
|
244
|
+
activity?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function stageTelemetry(
|
|
248
|
+
stage: WorkflowStage,
|
|
249
|
+
live: RunView | undefined,
|
|
250
|
+
now: number,
|
|
251
|
+
): StageTelemetry {
|
|
252
|
+
if (live) {
|
|
253
|
+
return {
|
|
254
|
+
model: live.model,
|
|
255
|
+
thinking: live.thinking,
|
|
256
|
+
usage: live.usage,
|
|
257
|
+
elapsed: formatElapsed(live, now),
|
|
258
|
+
activity: live.activity?.trim() || undefined,
|
|
259
|
+
};
|
|
218
260
|
}
|
|
219
|
-
|
|
220
|
-
if (focusIndex === -1) focusIndex = stages.length - 1;
|
|
221
|
-
const omittedPrefix = focusIndex > 0 ? theme.fg("dim", "… ─ ") : "";
|
|
222
|
-
return truncateToWidth(`${omittedPrefix}${render(stages.slice(focusIndex))}`, width, "…");
|
|
261
|
+
return { model: stage.model, usage: stage.usage, elapsed: stage.elapsedMs !== undefined ? formatDuration(stage.elapsedMs) : "" };
|
|
223
262
|
}
|
|
224
263
|
|
|
225
|
-
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
function
|
|
230
|
-
|
|
264
|
+
/** The parent pi session's own line — what the current model is doing right
|
|
265
|
+
* now while its agent loop runs: full model/thinking ref, live activity, loop
|
|
266
|
+
* elapsed. Not a run, so it owns no `#id`; it joins the identity layout so its
|
|
267
|
+
* label column lines up with the run rows. */
|
|
268
|
+
function mainLine(main: MainActivity, theme: Theme, width: number, now: number, layout: ColumnLayout): string {
|
|
269
|
+
const dim = (text: string): string => theme.fg("dim", text);
|
|
270
|
+
const icon = theme.fg("accent", "●");
|
|
271
|
+
const name = theme.fg("accent", theme.bold("pi"));
|
|
272
|
+
const pad = " ".repeat(Math.max(0, layout.agentWidth - visibleWidth("pi")));
|
|
273
|
+
const identity = `${icon} ${" ".repeat(layout.idWidth + 1)}${name}${pad}`;
|
|
274
|
+
const modelPart = main.model ? `${main.model}${main.thinking ? `/${main.thinking}` : ""}` : undefined;
|
|
275
|
+
const elapsed = formatDuration(Math.max(0, now - main.activeSince));
|
|
276
|
+
const tail = composeTail([modelPart, elapsed], Math.max(0, width - visibleWidth(identity) - LEFT_MIN_CONTENT));
|
|
277
|
+
const contentBudget = width
|
|
278
|
+
- visibleWidth(identity)
|
|
279
|
+
- (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0)
|
|
280
|
+
- visibleWidth(IDENTITY_GAP);
|
|
281
|
+
let left = identity;
|
|
282
|
+
if (main.activity?.trim() && contentBudget >= ACTIVITY_MIN_WIDTH) {
|
|
283
|
+
left = `${identity}${IDENTITY_GAP}${dim(formatTaskSummary(main.activity.trim(), contentBudget))}`;
|
|
284
|
+
}
|
|
285
|
+
return composeLine(left, tail, theme, width);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** One `├`/`└`-connected row per managed-workflow stage: status icon,
|
|
289
|
+
* relation, live activity for the running stage, and its own model/token/
|
|
290
|
+
* cost/elapsed telemetry flowing inline. The chain hangs off the parent line,
|
|
291
|
+
* so who dispatched what stays visible while the auto-fix workflow progresses. */
|
|
292
|
+
function workflowStageLines(
|
|
293
|
+
stages: readonly WorkflowStage[],
|
|
231
294
|
children: readonly RunView[],
|
|
232
295
|
theme: Theme,
|
|
233
296
|
width: number,
|
|
234
297
|
now: number,
|
|
235
|
-
): string
|
|
236
|
-
const
|
|
237
|
-
const indent = ` ${theme.fg("dim", "└")} `;
|
|
238
|
-
const budget = width - visibleWidth(indent);
|
|
239
|
-
if (!stages || stages.length === 0 || budget <= 0) return undefined;
|
|
240
|
-
const child = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
|
|
298
|
+
): { lines: string[]; activeIndex: number } {
|
|
299
|
+
const live = children.find((candidate) => candidate.status === "running" || candidate.status === "interrupting")
|
|
241
300
|
?? children.at(-1);
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
301
|
+
const activeIndex = stages.findIndex((stage) => stage.status === "active");
|
|
302
|
+
const lines: string[] = [];
|
|
303
|
+
for (const [index, stage] of stages.entries()) {
|
|
304
|
+
const telemetry = stageTelemetry(stage, index === activeIndex ? live : undefined, now);
|
|
305
|
+
const connector = theme.fg("dim", index === stages.length - 1 ? "└" : "├");
|
|
306
|
+
const indent = ` ${connector} `;
|
|
307
|
+
const budget = width - visibleWidth(indent);
|
|
308
|
+
if (budget <= 0) break;
|
|
309
|
+
const icon = stageIcon(stage.status, theme);
|
|
310
|
+
const label = `${icon} ${stage.relation}`;
|
|
311
|
+
const modelPart = telemetry.model
|
|
312
|
+
? `${telemetry.model}${telemetry.thinking ? `/${telemetry.thinking}` : ""}`
|
|
313
|
+
: undefined;
|
|
314
|
+
const parts = [
|
|
315
|
+
usagePart(telemetry.usage),
|
|
316
|
+
modelPart,
|
|
317
|
+
telemetry.elapsed || undefined,
|
|
318
|
+
];
|
|
319
|
+
const tailBudget = Math.max(0, budget - visibleWidth(label) - ACTIVITY_MIN_WIDTH);
|
|
320
|
+
const tail = composeTail(parts, tailBudget);
|
|
321
|
+
const contentBudget = budget - (tail ? visibleWidth(tail) + visibleWidth(SEPARATOR) : 0);
|
|
322
|
+
let left = label;
|
|
323
|
+
if (telemetry.activity && contentBudget - visibleWidth(label) - visibleWidth(ACTIVITY_SEPARATOR) >= ACTIVITY_MIN_WIDTH) {
|
|
324
|
+
const activityBudget = contentBudget - visibleWidth(label) - visibleWidth(ACTIVITY_SEPARATOR);
|
|
325
|
+
left = `${label}${theme.fg("dim", ACTIVITY_SEPARATOR)}${theme.fg("dim", formatTaskSummary(telemetry.activity, activityBudget))}`;
|
|
326
|
+
}
|
|
327
|
+
lines.push(`${indent}${composeLine(left, tail, theme, budget)}`);
|
|
254
328
|
}
|
|
255
|
-
return
|
|
329
|
+
return { lines, activeIndex };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Fit one workflow group into the remaining line budget: the primary line
|
|
333
|
+
* always survives, and the stage window anchors on the live stage — settled
|
|
334
|
+
* stages above the window collapse into one `… +N` marker, never the live
|
|
335
|
+
* stage itself. */
|
|
336
|
+
function fitGroupLines(
|
|
337
|
+
primary: string,
|
|
338
|
+
stages: readonly string[],
|
|
339
|
+
activeIndex: number,
|
|
340
|
+
remaining: number,
|
|
341
|
+
theme: Theme,
|
|
342
|
+
): string[] {
|
|
343
|
+
if (stages.length === 0 || stages.length + 1 <= remaining) return [primary, ...stages];
|
|
344
|
+
const slots = remaining - 1;
|
|
345
|
+
if (slots <= 0) return [primary];
|
|
346
|
+
// Reserve one line for an overflow marker so a cut is always announced.
|
|
347
|
+
const room = Math.max(1, slots - 1);
|
|
348
|
+
const anchor = activeIndex >= 0 ? activeIndex : 0;
|
|
349
|
+
const start = Math.max(0, Math.min(anchor, stages.length - room));
|
|
350
|
+
const window = stages.slice(start, start + room);
|
|
351
|
+
const parts = [
|
|
352
|
+
...(start > 0 ? [theme.fg("dim", `… +${start}`)] : []),
|
|
353
|
+
...window,
|
|
354
|
+
...(start + room < stages.length ? [theme.fg("dim", `… +${stages.length - start - room}`)] : []),
|
|
355
|
+
];
|
|
356
|
+
while (parts.length > slots) parts.pop();
|
|
357
|
+
return [primary, ...parts];
|
|
256
358
|
}
|
|
257
359
|
|
|
258
|
-
/** Render active runs as compact per-run line groups:
|
|
259
|
-
*
|
|
260
|
-
*
|
|
360
|
+
/** Render active runs as compact per-run line groups: the parent model's own
|
|
361
|
+
* line first, then one line per simple run, a tree chain per managed workflow
|
|
362
|
+
* (parent line + one row per stage). All rows share one column layout. */
|
|
261
363
|
export function formatActiveRunLines(
|
|
262
364
|
runs: readonly RunView[],
|
|
263
365
|
theme: Theme,
|
|
264
366
|
width: number,
|
|
265
367
|
now: number = Date.now(),
|
|
368
|
+
main?: MainActivity,
|
|
266
369
|
): string[] {
|
|
267
370
|
const active = runs.filter((run) => isRunActiveStatus(run.status));
|
|
268
371
|
const activeIds = new Set(active.map((run) => run.id));
|
|
@@ -279,24 +382,38 @@ export function formatActiveRunLines(
|
|
|
279
382
|
}
|
|
280
383
|
const layout: ColumnLayout = {
|
|
281
384
|
idWidth: Math.max(...roots.map((root) => visibleWidth(`#${root.id}`)), 0),
|
|
282
|
-
agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), 0),
|
|
385
|
+
agentWidth: Math.max(...roots.map((root) => visibleWidth(agentColumnText(root))), main ? visibleWidth("pi") : 0),
|
|
283
386
|
};
|
|
284
|
-
const groups: string[]
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
387
|
+
const groups: Array<{ lines: string[]; activeIndex: number }> = roots.map((root) => {
|
|
388
|
+
const children = childrenOf.get(root.id) ?? [];
|
|
389
|
+
// Workflow-wide tokens/cost on the parent line: every stage snapshot plus
|
|
390
|
+
// the live child (whose snapshot is frozen only at settlement). Without
|
|
391
|
+
// a stage projection the parent's own usage stands in.
|
|
392
|
+
let usage = root.usage;
|
|
393
|
+
if (root.managedWorkflow && root.workflowStages) {
|
|
394
|
+
const settled = root.workflowStages.map((stage) => stage.usage).filter((u): u is UsageStats => Boolean(u));
|
|
395
|
+
const live = children.find((candidate) => candidate.usage.input || candidate.usage.output || candidate.usage.cost);
|
|
396
|
+
usage = sumUsage([...(settled.length > 0 ? settled : [root.usage]), ...(live ? [live.usage] : [])]);
|
|
397
|
+
}
|
|
398
|
+
const lines = [primaryLine(root, theme, width, now, layout, usage)];
|
|
399
|
+
let activeIndex = -1;
|
|
400
|
+
if (root.managedWorkflow && root.workflowStages && root.workflowStages.length > 0) {
|
|
401
|
+
const rendered = workflowStageLines(root.workflowStages, children, theme, width, now);
|
|
402
|
+
lines.push(...rendered.lines);
|
|
403
|
+
activeIndex = rendered.activeIndex;
|
|
404
|
+
}
|
|
405
|
+
return { lines, activeIndex };
|
|
291
406
|
});
|
|
292
407
|
|
|
293
408
|
const lines: string[] = [];
|
|
409
|
+
if (main && MAX_WIDGET_LINES > 1) {
|
|
410
|
+
lines.push(mainLine(main, theme, width, now, layout));
|
|
411
|
+
}
|
|
294
412
|
let shownRoots = 0;
|
|
295
413
|
for (const group of groups) {
|
|
296
414
|
const remaining = MAX_WIDGET_LINES - 1 - lines.length;
|
|
297
415
|
if (remaining <= 0) break;
|
|
298
|
-
|
|
299
|
-
lines.push(...(group.length <= remaining ? group : group.slice(0, remaining)));
|
|
416
|
+
lines.push(...fitGroupLines(group.lines[0]!, group.lines.slice(1), group.activeIndex, remaining, theme));
|
|
300
417
|
shownRoots++;
|
|
301
418
|
}
|
|
302
419
|
const hiddenRoots = roots.length - shownRoots;
|
|
@@ -307,13 +424,13 @@ export function formatActiveRunLines(
|
|
|
307
424
|
}
|
|
308
425
|
|
|
309
426
|
function hasTickingRun(): boolean {
|
|
310
|
-
return monitor.
|
|
311
|
-
(run) => isRunActiveStatus(run.status) && run.activeSince !== undefined
|
|
312
|
-
);
|
|
427
|
+
return monitor.isMainAgentActive()
|
|
428
|
+
|| monitor.getRuns().some((run) => isRunActiveStatus(run.status) && run.activeSince !== undefined);
|
|
313
429
|
}
|
|
314
430
|
|
|
315
|
-
/** Install the widget for one TUI session. Its timer exists only while at
|
|
316
|
-
* one active run has started
|
|
431
|
+
/** Install the widget for one TUI session. Its timer exists only while at
|
|
432
|
+
* least one active run has started or the parent agent loop is running, and
|
|
433
|
+
* is disposed with the widget. */
|
|
317
434
|
export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui">): void {
|
|
318
435
|
if (ctx.mode !== "tui") return;
|
|
319
436
|
ctx.ui.setWidget(
|
|
@@ -341,7 +458,7 @@ export function installActiveRunsWidget(ctx: Pick<ExtensionContext, "mode" | "ui
|
|
|
341
458
|
syncTimer();
|
|
342
459
|
|
|
343
460
|
return {
|
|
344
|
-
render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width),
|
|
461
|
+
render: (width: number) => formatActiveRunLines(monitor.getRuns(), theme, width, Date.now(), monitor.getMainActivity()),
|
|
345
462
|
invalidate() {},
|
|
346
463
|
dispose() {
|
|
347
464
|
disposed = true;
|