@henryqw/pi-subagent 22.0.0 → 22.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/README.md +2 -0
- package/dist/direct-herdr.d.ts +2 -0
- package/dist/direct-herdr.js +49 -0
- package/dist/git-runtime.js +2 -4
- package/dist/launch-runtime.js +1 -1
- package/dist/store.js +2 -2
- package/extensions/admission.ts +1 -1
- package/extensions/isolated.ts +41 -18
- package/extensions/result-transport.ts +1 -5
- package/extensions/subagent.ts +51 -44
- package/extensions/workflow.ts +19 -7
- package/package.json +1 -1
- package/extensions/task-name.ts +0 -28
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@ Ask Main to delegate a bounded task, such as “Have a scout trace sign-in witho
|
|
|
27
27
|
|
|
28
28
|
Commands are for you; tools and the packaged skill are for Main. You do not need to call agent tools or manage candidate identities yourself.
|
|
29
29
|
|
|
30
|
+
In the TUI, the compact widget uses one row per visible agent/task: `D` or `I` marks direct or isolated work, and `[S]` or `[I3]` identifies the Role initial (plus the isolated model class: `1` fast, `2` balanced, `3` frontier, `*` fav). Direct rows show the launched model, thinking level, elapsed time, and measured session tokens (`— tok` until usage is available). Isolated rows show the recorded task state and any retained workspace; their status glyphs use the active TUI theme, while the text still names the status without color. Herdr does not currently provide reliable live model, thinking, or token readings for those rows. Attention appears before ordinary work, with a `+N more` summary when direct rows overflow. A ready isolated candidate is **not promoted** to Main. Use `/subagent` or `subagent_status` for exact identities, failures, and recovery.
|
|
31
|
+
|
|
30
32
|
| Surface | Type | Purpose |
|
|
31
33
|
| --- | --- | --- |
|
|
32
34
|
| `/subagent` | command | Pick direct or isolated work to inspect; send or edit pending instructions for a locally active isolated changeset. Requires interactive TUI or RPC. |
|
package/dist/direct-herdr.d.ts
CHANGED
|
@@ -8,9 +8,11 @@ export interface DirectHandle {
|
|
|
8
8
|
sessionFile: string;
|
|
9
9
|
prompt: string;
|
|
10
10
|
answer(maxBytes: number): Promise<string>;
|
|
11
|
+
usageTokens(): Promise<number | undefined>;
|
|
11
12
|
cancel(): Promise<void>;
|
|
12
13
|
}
|
|
13
14
|
export declare function exactDirectAnswer(jsonl: string, prompt: string, maxBytes?: number): string;
|
|
15
|
+
export declare function directSessionTokens(jsonl: string, prompt: string): number | undefined;
|
|
14
16
|
export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile">;
|
|
15
17
|
export declare function createDirectHerdr(pi: Pick<ExtensionAPI, "exec">, cwd: string, idleMs: number): {
|
|
16
18
|
start(launch: ResolvedRoleLaunch, name: string, label: string, task: string, signal: AbortSignal, onTab: (tab: DirectTab) => void): Promise<DirectHandle>;
|
package/dist/direct-herdr.js
CHANGED
|
@@ -70,6 +70,43 @@ export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT) {
|
|
|
70
70
|
throw new Error(`Pi final answer exceeds the ${maxBytes}-byte workflow limit; read the private session file for recovery.`);
|
|
71
71
|
return text;
|
|
72
72
|
}
|
|
73
|
+
// Ignore a partial trailing JSONL entry while the child is writing it.
|
|
74
|
+
export function directSessionTokens(jsonl, prompt) {
|
|
75
|
+
let active = false;
|
|
76
|
+
let total = 0;
|
|
77
|
+
let observed = false;
|
|
78
|
+
for (const line of jsonl.split("\n")) {
|
|
79
|
+
if (!line.trim())
|
|
80
|
+
continue;
|
|
81
|
+
let entry;
|
|
82
|
+
try {
|
|
83
|
+
entry = JSON.parse(line);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (entry.type !== "message" || !entry.message || typeof entry.message !== "object")
|
|
89
|
+
continue;
|
|
90
|
+
const message = entry.message;
|
|
91
|
+
if (message.role === "user") {
|
|
92
|
+
active = Array.isArray(message.content) && message.content.length === 1
|
|
93
|
+
&& message.content[0]?.text === prompt;
|
|
94
|
+
if (active) {
|
|
95
|
+
total = 0;
|
|
96
|
+
observed = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (active && message.role === "assistant" && message.usage && typeof message.usage === "object") {
|
|
100
|
+
const usage = message.usage;
|
|
101
|
+
const values = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
|
|
102
|
+
if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
|
|
103
|
+
total += values.reduce((sum, value) => sum + value, 0);
|
|
104
|
+
observed = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return observed ? total : undefined;
|
|
109
|
+
}
|
|
73
110
|
export function createDirectHerdr(pi, cwd, idleMs) {
|
|
74
111
|
if (process.env.HERDR_ENV !== "1")
|
|
75
112
|
throw new Error("Direct delegation requires a Herdr-managed Pi pane (HERDR_ENV=1).");
|
|
@@ -131,8 +168,20 @@ export function createDirectHerdr(pi, cwd, idleMs) {
|
|
|
131
168
|
const state = inspect(accepted, "agent_prompted", name, paneId, tabId);
|
|
132
169
|
if (state === "blocked" || state === "unknown")
|
|
133
170
|
throw new Error(`Herdr agent became ${state}; inspect it before retrying.`);
|
|
171
|
+
let lastUsageSize = -1;
|
|
172
|
+
let lastTokens;
|
|
134
173
|
return {
|
|
135
174
|
name, tabId, paneId, sessionFile, prompt,
|
|
175
|
+
async usageTokens() {
|
|
176
|
+
const info = await stat(sessionFile);
|
|
177
|
+
if (info.size > SESSION_LIMIT)
|
|
178
|
+
return undefined;
|
|
179
|
+
if (info.size !== lastUsageSize) {
|
|
180
|
+
lastTokens = directSessionTokens(await readFile(sessionFile, "utf8"), prompt);
|
|
181
|
+
lastUsageSize = info.size;
|
|
182
|
+
}
|
|
183
|
+
return lastTokens;
|
|
184
|
+
},
|
|
136
185
|
async answer(maxBytes) {
|
|
137
186
|
const readAnswer = async () => {
|
|
138
187
|
const info = await stat(sessionFile);
|
package/dist/git-runtime.js
CHANGED
|
@@ -321,10 +321,8 @@ export class CheckedGitRuntime {
|
|
|
321
321
|
if (!await this.isAncestor(requiredBase, identity.head, worktree.cwd, context)) {
|
|
322
322
|
throw new Error(`Task ${task.id} tip does not descend from its recorded base ${requiredBase}.`);
|
|
323
323
|
}
|
|
324
|
-
if (requireChange) {
|
|
325
|
-
|
|
326
|
-
if (!Number.isSafeInteger(count) || count < 1)
|
|
327
|
-
throw new Error(`Task ${task.id} has no committed change from its recorded base.`);
|
|
324
|
+
if (requireChange && identity.head === requiredBase) {
|
|
325
|
+
throw new Error(`Task ${task.id} has no committed change from its recorded base.`);
|
|
328
326
|
}
|
|
329
327
|
return identity;
|
|
330
328
|
}
|
package/dist/launch-runtime.js
CHANGED
|
@@ -157,7 +157,7 @@ export class RoleLaunchRuntime {
|
|
|
157
157
|
if (typeof resolvedRoot !== "string" || !isAbsolute(resolvedRoot) || resolvedRoot.includes("\0")) {
|
|
158
158
|
throw new Error("Pi Subagent root resolver must return an absolute canonical path.");
|
|
159
159
|
}
|
|
160
|
-
const root =
|
|
160
|
+
const root = await realpath(resolvedRoot);
|
|
161
161
|
if (root !== resolvedRoot)
|
|
162
162
|
throw new Error("Pi Subagent root resolver returned a non-canonical path.");
|
|
163
163
|
const rootInfo = await lstat(root);
|
package/dist/store.js
CHANGED
|
@@ -189,7 +189,7 @@ export class FileRunStore {
|
|
|
189
189
|
throw new Error(`Pi Subagent request ${state.request.id} already exists.`);
|
|
190
190
|
throw error;
|
|
191
191
|
}
|
|
192
|
-
const saved =
|
|
192
|
+
const saved = JSON.parse(contents);
|
|
193
193
|
this.onStateSaved?.(structuredClone(saved));
|
|
194
194
|
return this.handle(saved, path);
|
|
195
195
|
}
|
|
@@ -282,7 +282,7 @@ export class FileRunStore {
|
|
|
282
282
|
return new RunStateHandle(state, path, async (contents) => {
|
|
283
283
|
await this.assertSafeDestination(state.root, path);
|
|
284
284
|
await writePrivateTextFileAtomically(path, contents);
|
|
285
|
-
this.onStateSaved?.(
|
|
285
|
+
this.onStateSaved?.(JSON.parse(contents));
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
288
|
}
|
package/extensions/admission.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import type { Role } from "../
|
|
3
|
+
import type { Role } from "../dist/index.js";
|
|
4
4
|
|
|
5
5
|
const READ_ONLY_TOOLS = new Set([
|
|
6
6
|
"read", "grep", "find", "ffgrep", "fffind", "ls", "codegraph_explore", "subagent_status",
|
package/extensions/isolated.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { EphemeralSubagentExecutor } from "../dist/ephemeral.js";
|
|
4
|
+
import { ROLE_TOOL_POLICY_FLAG } from "../dist/index.js";
|
|
4
5
|
import type { EffectiveExecutionPolicy } from "./config.ts";
|
|
5
6
|
import {
|
|
6
7
|
createCanonicalGitRootResolver,
|
|
7
8
|
createExactJudgmentExecutor,
|
|
8
|
-
} from "../
|
|
9
|
+
} from "../dist/composition.js";
|
|
9
10
|
import {
|
|
10
11
|
CheckedGitRuntime,
|
|
11
12
|
type DirectProcessRunner,
|
|
12
|
-
} from "../
|
|
13
|
-
import { HerdrHostRuntime } from "../
|
|
14
|
-
import { runProcess as directRunProcess } from "../
|
|
15
|
-
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../
|
|
16
|
-
import { IsolatedRunner, type OperationContext, type RunResponse } from "../
|
|
13
|
+
} from "../dist/git-runtime.js";
|
|
14
|
+
import { HerdrHostRuntime } from "../dist/herdr-runtime.js";
|
|
15
|
+
import { runProcess as directRunProcess } from "../dist/process.js";
|
|
16
|
+
import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../dist/launch-runtime.js";
|
|
17
|
+
import { IsolatedRunner, type OperationContext, type RunResponse } from "../dist/runner.js";
|
|
17
18
|
import {
|
|
18
19
|
IdOnlySchema,
|
|
19
20
|
ResumeRequestSchema,
|
|
@@ -31,14 +32,15 @@ import {
|
|
|
31
32
|
type RunState,
|
|
32
33
|
type WorktreeAllocationIntent,
|
|
33
34
|
type WorkspaceAllocationIntent,
|
|
34
|
-
} from "../
|
|
35
|
-
import { FileRunStore } from "../
|
|
35
|
+
} from "../dist/schema.js";
|
|
36
|
+
import { FileRunStore } from "../dist/store.js";
|
|
36
37
|
import type { IsolatedInventory } from "./subagent-command.ts";
|
|
37
38
|
|
|
38
39
|
const LOOKUP_ROOT_TIMEOUT_MS = 5_000;
|
|
39
40
|
const PUBLIC_EVIDENCE_MAX_BYTES = 512;
|
|
40
41
|
const WORKSPACE_WIDGET_KEY = "pi-subagent-workspaces";
|
|
41
42
|
const WIDGET_FIELD_MAX_CHARS = 32;
|
|
43
|
+
const MAX_WORKSPACE_WIDGET_LINES = 6;
|
|
42
44
|
|
|
43
45
|
export interface IsolatedExtensionComponents {
|
|
44
46
|
runner: IsolatedRunner;
|
|
@@ -112,18 +114,26 @@ function workspaceStatus(status: RunState["tasks"][number]["status"]): string {
|
|
|
112
114
|
|
|
113
115
|
export function workspaceWidgetLines(state: RunState): string[] | undefined {
|
|
114
116
|
const rows = state.tasks.flatMap((taskState) => {
|
|
115
|
-
|
|
117
|
+
const task = state.request.tasks.find((candidate) => candidate.id === taskState.taskId);
|
|
118
|
+
if (!task) return [];
|
|
119
|
+
const status = state.status === "aborted" ? "aborted" : workspaceStatus(taskState.status);
|
|
120
|
+
if (taskState.kind === "text") {
|
|
121
|
+
return taskState.status === "running" || taskState.status === "needs_attention"
|
|
122
|
+
? [`${status === "attention" ? "!" : status === "aborted" ? "■" : "◌"} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? status)}` : status}`]
|
|
123
|
+
: [];
|
|
124
|
+
}
|
|
116
125
|
const attempt = taskState.attempts.at(-1);
|
|
117
126
|
const allocation = [...(attempt?.allocations ?? [])].reverse().find(
|
|
118
127
|
(candidate): candidate is WorkspaceAllocationIntent =>
|
|
119
128
|
candidate.kind === "workspace" && candidate.status !== "absent",
|
|
120
129
|
);
|
|
121
130
|
const workspaceCleanup = attempt?.cleanup.find((step) => step.kind === "workspace");
|
|
122
|
-
if (
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
131
|
+
if (workspaceCleanup?.status === "completed" || (!allocation && !["allocating", "working", "attention"].includes(status))) return [];
|
|
132
|
+
const symbol = status === "attention" ? "!" : status === "ready" || status === "completed" ? "✓"
|
|
133
|
+
: ["working", "allocating", "integrating", "cleanup"].includes(status) ? "◌" : "■";
|
|
134
|
+
const detail = status === "ready" ? "candidate ready · Main not promoted"
|
|
135
|
+
: status === "attention" ? `attention · ${compactWidgetField(taskState.failure ?? "needs attention")}` : status;
|
|
136
|
+
return `${symbol} I ${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(task.id)} · ${detail}${allocation ? ` · ${compactWidgetField(allocation.label)}` : ""}`;
|
|
127
137
|
});
|
|
128
138
|
return rows.length ? rows : undefined;
|
|
129
139
|
}
|
|
@@ -134,8 +144,21 @@ function updateWorkspaceWidget(ctx: ExtensionContext, state: RunState, rowsByReq
|
|
|
134
144
|
if (rows) rowsByRequest.set(key, rows);
|
|
135
145
|
else rowsByRequest.delete(key);
|
|
136
146
|
if (!ctx.hasUI) return;
|
|
137
|
-
const allRows = [...rowsByRequest.values()].flat()
|
|
138
|
-
|
|
147
|
+
const allRows = [...rowsByRequest.values()].flat().sort((a, b) =>
|
|
148
|
+
(a.startsWith("!") ? 0 : a.startsWith("◌") ? 1 : 2) - (b.startsWith("!") ? 0 : b.startsWith("◌") ? 1 : 2));
|
|
149
|
+
const shown = allRows.length > MAX_WORKSPACE_WIDGET_LINES
|
|
150
|
+
? [...allRows.slice(0, MAX_WORKSPACE_WIDGET_LINES - 1), `+${allRows.length - MAX_WORKSPACE_WIDGET_LINES + 1} more · /subagent`]
|
|
151
|
+
: allRows;
|
|
152
|
+
ctx.ui.setWidget(WORKSPACE_WIDGET_KEY, shown.length ? (_tui, theme) => ({
|
|
153
|
+
invalidate() {},
|
|
154
|
+
render(width) {
|
|
155
|
+
return shown.map((line) => {
|
|
156
|
+
const color = line.startsWith("!") ? "error" : line.startsWith("◌") ? "accent"
|
|
157
|
+
: line.startsWith("✓") ? "success" : line.startsWith("■") ? "warning" : undefined;
|
|
158
|
+
return truncateToWidth(color ? theme.fg(color, line[0]!) + line.slice(1) : line, width);
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
}) : undefined);
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
function updateWorkspaceWidgetSafely(ctx: ExtensionContext, state: RunState, rowsByRequest: Map<string, string[]>): void {
|
|
@@ -49,10 +49,6 @@ export type WorkflowTransport = {
|
|
|
49
49
|
|
|
50
50
|
type Evidence = { heading: string; preview: string; remainder: string };
|
|
51
51
|
|
|
52
|
-
function compareEntries(left: WorkflowTransportEntry, right: WorkflowTransportEntry): number {
|
|
53
|
-
return left.index - right.index || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
function splitEvidence(text: string): [string, string] {
|
|
57
53
|
const preview = Array.from(text).slice(0, EVIDENCE_PREVIEW_CODE_POINTS).join("");
|
|
58
54
|
return [preview, text.slice(preview.length)];
|
|
@@ -114,7 +110,7 @@ function evidenceFor(entry: WorkflowTransportEntry, position: number, total: num
|
|
|
114
110
|
}
|
|
115
111
|
|
|
116
112
|
export function formatWorkflowResult(mode: WorkflowMode, entries: readonly WorkflowTransportEntry[]): WorkflowTransport {
|
|
117
|
-
const ordered = [...entries].sort(
|
|
113
|
+
const ordered = [...entries].sort((left, right) => left.index - right.index);
|
|
118
114
|
if (ordered.some(({ status }) => status === "pending" || status === "running")) {
|
|
119
115
|
throw new TypeError("Final workflow transport requires terminal entry states.");
|
|
120
116
|
}
|
package/extensions/subagent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { type Component, Text, type TUI, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { type Component, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import { availableTaskModels, loadTaskModelsConfig, modelReference, registerModelTask, resolveAvailableModel, type ResolvedTaskRoute, taskThinkingLevels } from "@henryqw/pi-task-models";
|
|
5
5
|
import { capEphemeralSubagentOutput as capOutput, createEphemeralSubagentExecutor, DELEGATE_TASK, formatDuration, loadRoles, prepareRoleLaunch, ROLE_TOOL_POLICY_FLAG, type EphemeralSubagentTimeout, type Role } from "@henryqw/pi-subagent";
|
|
6
6
|
import { DEFAULT_EXECUTION_POLICY, DEFAULT_TIMEOUT_CONFIG, readSubagentConfig, resolveExecutionPolicy, type EffectiveExecutionPolicy } from "./config.ts";
|
|
@@ -10,13 +10,12 @@ import { registerSubagentCommand, type DirectTask } from "./subagent-command.ts"
|
|
|
10
10
|
import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
|
|
11
11
|
import { formatWorkflowResult, presentWorkflowEntryStatus, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
|
|
12
12
|
import { DelegateTaskSchema, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, type Delegation, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
|
|
13
|
-
import { createDirectHerdr, type DirectHandle, type DirectTab } from "../
|
|
14
|
-
import { materializeTransientLaunch } from "../
|
|
13
|
+
import { createDirectHerdr, type DirectHandle, type DirectTab } from "../dist/direct-herdr.js";
|
|
14
|
+
import { materializeTransientLaunch } from "../dist/launch-runtime.js";
|
|
15
15
|
const WIDGET_KEY = "subagent-status";
|
|
16
|
-
const WIDGET_INTERVAL_MS =
|
|
16
|
+
const WIDGET_INTERVAL_MS = 1_000;
|
|
17
17
|
const MAX_WIDGET_ITEMS = 8;
|
|
18
18
|
const MAX_WIDGET_LINES = 6;
|
|
19
|
-
const MAX_WIDGET_GROUP_ROWS = 3;
|
|
20
19
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
21
20
|
|
|
22
21
|
type TimeoutPolicy = EphemeralSubagentTimeout;
|
|
@@ -30,6 +29,7 @@ type WidgetItem = {
|
|
|
30
29
|
startedAt: number;
|
|
31
30
|
status: WidgetStatus;
|
|
32
31
|
finishedAt?: number;
|
|
32
|
+
tokens?: number;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
function roleBadge(role: string): string {
|
|
@@ -55,15 +55,8 @@ function statusLabel(status: WidgetStatus): string {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
if (item.status === "failure") return "Failed";
|
|
61
|
-
if (item.status === "aborted") return "Stopped";
|
|
62
|
-
return "working…";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function activityMetrics(item: WidgetItem, now: number): string {
|
|
66
|
-
return `${item.model}·${item.thinkingLevel} · ${formatDuration((item.finishedAt ?? now) - item.startedAt)}`;
|
|
58
|
+
function tokenLabel(tokens: number | undefined): string {
|
|
59
|
+
return tokens === undefined ? "— tok" : `${tokens < 1_000 ? tokens : `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`} tok`;
|
|
67
60
|
}
|
|
68
61
|
|
|
69
62
|
function renderWidgetRows(
|
|
@@ -73,36 +66,26 @@ function renderWidgetRows(
|
|
|
73
66
|
spinnerIndex: number,
|
|
74
67
|
theme: Theme,
|
|
75
68
|
): string[] {
|
|
76
|
-
const ordered = [...items.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
for (const group of groups.values()) {
|
|
91
|
-
const working = group.items.some(({ status }) => status === "working");
|
|
92
|
-
if (working && !visibleWorkingGroups.has(group)) continue;
|
|
93
|
-
const reservedLines = working ? --remainingWorkingGroups * 2 : 0;
|
|
94
|
-
const childCount = Math.min(MAX_WIDGET_GROUP_ROWS, group.items.length, maxVisibleLines - lines.length - reservedLines - 1);
|
|
95
|
-
if (childCount < 1) continue;
|
|
96
|
-
lines.push(truncateToWidth(theme.fg("text", group.name), width));
|
|
97
|
-
for (const item of group.items.slice(0, childCount)) {
|
|
98
|
-
visible.add(item);
|
|
99
|
-
lines.push(truncateToWidth(
|
|
100
|
-
` ${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", item.role)} ${theme.fg("text", activityLabel(item))} · ${theme.fg("muted", activityMetrics(item, now))}`,
|
|
101
|
-
width,
|
|
102
|
-
));
|
|
69
|
+
const ordered = [...items].sort((a, b) =>
|
|
70
|
+
(a.status === "failure" ? 0 : a.status === "working" ? 1 : 2)
|
|
71
|
+
- (b.status === "failure" ? 0 : b.status === "working" ? 1 : 2));
|
|
72
|
+
const visible = ordered.slice(0, ordered.length > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES);
|
|
73
|
+
const lines = visible.map((item) => {
|
|
74
|
+
const prefix = `${statusGlyph(item.status, spinnerIndex, theme)} ${theme.fg("accent", `D ${item.role}`)} ${item.status === "working" ? "" : `${statusLabel(item.status)} · `}`;
|
|
75
|
+
const metrics = [
|
|
76
|
+
`${item.model}/${item.thinkingLevel}`,
|
|
77
|
+
tokenLabel(item.tokens),
|
|
78
|
+
formatDuration((item.finishedAt ?? now) - item.startedAt),
|
|
79
|
+
];
|
|
80
|
+
while (metrics.length && width - visibleWidth(prefix) - visibleWidth(` · ${metrics.join(" · ")}`) < 8) {
|
|
81
|
+
if (metrics.length === 2) metrics.shift(); // Preserve measured tokens ahead of route details.
|
|
82
|
+
else metrics.pop();
|
|
103
83
|
}
|
|
104
|
-
|
|
105
|
-
|
|
84
|
+
const suffix = metrics.length ? ` · ${theme.fg("muted", metrics.join(" · "))}` : "";
|
|
85
|
+
const name = truncateToWidth(theme.fg("text", item.name), Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix)));
|
|
86
|
+
return truncateToWidth(`${prefix}${name}${suffix}`, width);
|
|
87
|
+
});
|
|
88
|
+
const hidden = ordered.slice(visible.length);
|
|
106
89
|
if (hidden.length) {
|
|
107
90
|
const counts: Record<WidgetStatus, number> = { working: 0, success: 0, failure: 0, aborted: 0 };
|
|
108
91
|
for (const { status } of hidden) counts[status] += 1;
|
|
@@ -177,6 +160,8 @@ export default function subagentExtension(
|
|
|
177
160
|
].join("\n"), outputPad, 0);
|
|
178
161
|
});
|
|
179
162
|
const widgetItems = new Map<string, WidgetItem>();
|
|
163
|
+
const tokenHandles = new Map<string, DirectHandle>();
|
|
164
|
+
const samplingTokens = new Set<string>();
|
|
180
165
|
const loadedConfig = readSubagentConfig();
|
|
181
166
|
let initialPolicy: EffectiveExecutionPolicy;
|
|
182
167
|
try {
|
|
@@ -252,6 +237,7 @@ export default function subagentExtension(
|
|
|
252
237
|
let widgetInstalled = false;
|
|
253
238
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
254
239
|
let spinnerIndex = 0;
|
|
240
|
+
let widgetTicks = 0;
|
|
255
241
|
let activeTui: TUI | undefined;
|
|
256
242
|
const stopWidgetTimer = () => {
|
|
257
243
|
if (!widgetTimer) return;
|
|
@@ -266,6 +252,19 @@ export default function subagentExtension(
|
|
|
266
252
|
widgetTimer = setInterval(() => {
|
|
267
253
|
spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length;
|
|
268
254
|
requestWidgetRender();
|
|
255
|
+
if (++widgetTicks % 2 !== 0) return;
|
|
256
|
+
for (const [id, handle] of tokenHandles) {
|
|
257
|
+
if (samplingTokens.has(id)) continue;
|
|
258
|
+
samplingTokens.add(id);
|
|
259
|
+
void handle.usageTokens().then((tokens) => {
|
|
260
|
+
const item = widgetItems.get(id);
|
|
261
|
+
if (tokens !== undefined && item?.status === "working" && tokenHandles.get(id) === handle) {
|
|
262
|
+
item.tokens = tokens;
|
|
263
|
+
requestWidgetRender();
|
|
264
|
+
}
|
|
265
|
+
}).catch(() => { /* Usage is optional; never interrupt work for telemetry. */ })
|
|
266
|
+
.finally(() => samplingTokens.delete(id));
|
|
267
|
+
}
|
|
269
268
|
}, WIDGET_INTERVAL_MS);
|
|
270
269
|
widgetTimer.unref();
|
|
271
270
|
};
|
|
@@ -316,6 +315,7 @@ export default function subagentExtension(
|
|
|
316
315
|
const finishWidgetItem = (id: string, status: Exclude<WidgetStatus, "working">) => {
|
|
317
316
|
const item = widgetItems.get(id);
|
|
318
317
|
if (!item) return;
|
|
318
|
+
tokenHandles.delete(id);
|
|
319
319
|
item.status = status;
|
|
320
320
|
item.finishedAt = Date.now();
|
|
321
321
|
if (![...widgetItems.values()].some(({ status }) => status === "working")) stopWidgetTimer();
|
|
@@ -357,6 +357,8 @@ export default function subagentExtension(
|
|
|
357
357
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
358
358
|
stopWidgetTimer();
|
|
359
359
|
widgetItems.clear();
|
|
360
|
+
tokenHandles.clear();
|
|
361
|
+
samplingTokens.clear();
|
|
360
362
|
activeTui = undefined;
|
|
361
363
|
widgetInstalled = false;
|
|
362
364
|
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
@@ -506,6 +508,7 @@ export default function subagentExtension(
|
|
|
506
508
|
}
|
|
507
509
|
await transient.cleanup();
|
|
508
510
|
handles.push(handle);
|
|
511
|
+
if (widgetItems.has(entry.id)) tokenHandles.set(entry.id, handle);
|
|
509
512
|
return handle;
|
|
510
513
|
};
|
|
511
514
|
// The first tab is verified before returning a handle; subsequent independent
|
|
@@ -538,12 +541,16 @@ export default function subagentExtension(
|
|
|
538
541
|
active++;
|
|
539
542
|
return () => { active--; queue.shift()?.(); };
|
|
540
543
|
};
|
|
541
|
-
await runForegroundWorkflow(
|
|
544
|
+
await runForegroundWorkflow(workflow.mode, entries, async (entry) => {
|
|
542
545
|
const release = await permit();
|
|
543
546
|
try {
|
|
544
547
|
controller.signal.throwIfAborted();
|
|
545
548
|
const handle = entry.index === 0 ? first : await launch(entry, controller.signal);
|
|
546
549
|
const answer = await handle.answer(Math.floor(40 * 1024 / entries.length));
|
|
550
|
+
try {
|
|
551
|
+
const tokens = await handle.usageTokens();
|
|
552
|
+
if (tokens !== undefined && widgetItems.has(entry.id)) widgetItems.get(entry.id)!.tokens = tokens;
|
|
553
|
+
} catch { /* Optional telemetry. */ }
|
|
547
554
|
const base = states.get(entry.id)!;
|
|
548
555
|
states.set(entry.id, { id: base.id, index: base.index, name: base.name, role: base.role,
|
|
549
556
|
...(base.model ? { model: base.model } : {}), ...(base.thinkingLevel ? { thinkingLevel: base.thinkingLevel } : {}),
|
package/extensions/workflow.ts
CHANGED
|
@@ -3,11 +3,18 @@ import { DISPLAY_TEXT_CONTRACT } from "@henryqw/pi-subagent";
|
|
|
3
3
|
import { PROFILE_NAMES } from "@henryqw/pi-task-models";
|
|
4
4
|
import { Type, type Static } from "typebox";
|
|
5
5
|
import { Check, Errors } from "typebox/value";
|
|
6
|
-
import { ExecuteRequestSchema, parseExecuteRequest, type ExecuteRequest } from "../
|
|
7
|
-
import { TaskNameSchema, normalizeTaskName } from "./task-name.ts";
|
|
6
|
+
import { ExecuteRequestSchema, parseExecuteRequest, type ExecuteRequest } from "../dist/schema.js";
|
|
8
7
|
|
|
9
8
|
export const MAX_WORKFLOW_ENTRIES = 8;
|
|
10
9
|
|
|
10
|
+
const TASK_NAME_MAX_LENGTH = 29;
|
|
11
|
+
const TaskNameSchema = Type.String({
|
|
12
|
+
minLength: 1,
|
|
13
|
+
maxLength: TASK_NAME_MAX_LENGTH,
|
|
14
|
+
description: `Short descriptive task name, about five words and fewer than ${TASK_NAME_MAX_LENGTH + 1} characters; C0/C1 control characters are rejected.`,
|
|
15
|
+
pattern: DISPLAY_TEXT_CONTRACT.pattern,
|
|
16
|
+
});
|
|
17
|
+
|
|
11
18
|
const RoleSchema = Type.String({ minLength: 1, pattern: DISPLAY_TEXT_CONTRACT.pattern, description: "Configured Subagent role name" });
|
|
12
19
|
const TaskSchema = Type.String({ minLength: 1, description: "Bounded task packet" });
|
|
13
20
|
const ModelSchema = Type.String({ minLength: 1, pattern: DISPLAY_TEXT_CONTRACT.pattern, description: "Designated model as provider/modelId; replaces the selected route model" });
|
|
@@ -55,6 +62,12 @@ function text(value: string, path: string): string {
|
|
|
55
62
|
return normalized;
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
function normalizeTaskName(value: string, path: string): string {
|
|
66
|
+
const normalized = value.trim();
|
|
67
|
+
if (!normalized) throw new Error(`${path} must be non-empty text.`);
|
|
68
|
+
return normalized;
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
function normalizeDelegation(value: Static<typeof DelegationSchema>, path: string): Delegation {
|
|
59
72
|
return {
|
|
60
73
|
role: text(value.role, `${path}.role`),
|
|
@@ -118,19 +131,18 @@ async function runEntry(entry: WorkflowEntry, run: DelegationRunner): Promise<st
|
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
export async function runForegroundWorkflow(
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
mode: WorkflowMode,
|
|
135
|
+
entries: readonly WorkflowEntry[],
|
|
123
136
|
run: DelegationRunner,
|
|
124
137
|
signal?: AbortSignal,
|
|
125
138
|
): Promise<void> {
|
|
126
139
|
signal?.throwIfAborted();
|
|
127
|
-
|
|
128
|
-
if (workflow.mode === "single") {
|
|
140
|
+
if (mode === "single") {
|
|
129
141
|
await runEntry(entries[0]!, run);
|
|
130
142
|
signal?.throwIfAborted();
|
|
131
143
|
return;
|
|
132
144
|
}
|
|
133
|
-
if (
|
|
145
|
+
if (mode === "parallel") {
|
|
134
146
|
await Promise.all(entries.map((entry) => runEntry(entry, run)));
|
|
135
147
|
signal?.throwIfAborted();
|
|
136
148
|
return;
|
package/package.json
CHANGED
package/extensions/task-name.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { DISPLAY_TEXT_CONTRACT } from "@henryqw/pi-subagent";
|
|
2
|
-
import { Type, type Static } from "typebox";
|
|
3
|
-
|
|
4
|
-
const TASK_NAME_MAX_LENGTH = 29 as const;
|
|
5
|
-
const TASK_NAME_LIMIT_WORDING = `about five words and fewer than ${TASK_NAME_MAX_LENGTH + 1} characters`;
|
|
6
|
-
|
|
7
|
-
export const TASK_NAME_CONTRACT = {
|
|
8
|
-
minLength: 1,
|
|
9
|
-
maxLength: TASK_NAME_MAX_LENGTH,
|
|
10
|
-
description: `Short descriptive task name, ${TASK_NAME_LIMIT_WORDING}; C0/C1 control characters are rejected.`,
|
|
11
|
-
controlRanges: DISPLAY_TEXT_CONTRACT.controlRanges,
|
|
12
|
-
pattern: DISPLAY_TEXT_CONTRACT.pattern,
|
|
13
|
-
} as const;
|
|
14
|
-
|
|
15
|
-
export const TaskNameSchema = Type.String({
|
|
16
|
-
minLength: TASK_NAME_CONTRACT.minLength,
|
|
17
|
-
maxLength: TASK_NAME_CONTRACT.maxLength,
|
|
18
|
-
description: TASK_NAME_CONTRACT.description,
|
|
19
|
-
pattern: TASK_NAME_CONTRACT.pattern,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
export type TaskName = Static<typeof TaskNameSchema>;
|
|
23
|
-
|
|
24
|
-
export function normalizeTaskName(value: TaskName, path: string): TaskName {
|
|
25
|
-
const normalized = value.trim();
|
|
26
|
-
if (!normalized) throw new Error(`${path} must be non-empty text.`);
|
|
27
|
-
return normalized;
|
|
28
|
-
}
|