@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 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. |
@@ -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>;
@@ -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);
@@ -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
- const count = Number.parseInt(oneLine(await this.requireGit(["rev-list", "--count", `${requiredBase}..${identity.head}`], worktree.cwd, context), "task commit count"), 10);
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
  }
@@ -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 = normalize(await realpath(resolvedRoot));
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 = parseRunState(structuredClone(state));
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?.(parseRunState(JSON.parse(contents)));
285
+ this.onStateSaved?.(JSON.parse(contents));
286
286
  });
287
287
  }
288
288
  }
@@ -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 "../src/index.ts";
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",
@@ -1,19 +1,20 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { EphemeralSubagentExecutor } from "../src/ephemeral.ts";
3
- import { ROLE_TOOL_POLICY_FLAG } from "../src/index.ts";
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 "../src/composition.ts";
9
+ } from "../dist/composition.js";
9
10
  import {
10
11
  CheckedGitRuntime,
11
12
  type DirectProcessRunner,
12
- } from "../src/git-runtime.ts";
13
- import { HerdrHostRuntime } from "../src/herdr-runtime.ts";
14
- import { runProcess as directRunProcess } from "../src/process.ts";
15
- import { RoleLaunchRuntime, type LaunchRuntimeOptions } from "../src/launch-runtime.ts";
16
- import { IsolatedRunner, type OperationContext, type RunResponse } from "../src/runner.ts";
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 "../src/schema.ts";
35
- import { FileRunStore } from "../src/store.ts";
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
- if (taskState.kind !== "changeset") return [];
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 (!allocation || workspaceCleanup?.status === "completed") return [];
123
- const task = state.request.tasks.find((candidate) => candidate.id === taskState.taskId);
124
- if (!task || task.kind !== "changeset") return [];
125
- const status = state.status === "aborted" ? "aborted" : workspaceStatus(taskState.status);
126
- return `${workspaceBadge(task.role, task.modelClass)} ${compactWidgetField(allocation.label)} · ${status} · ${compactWidgetField(task.id)}`;
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
- ctx.ui.setWidget(WORKSPACE_WIDGET_KEY, allRows.length ? allRows : undefined);
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(compareEntries);
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
  }
@@ -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 "../src/direct-herdr.ts";
14
- import { materializeTransientLaunch } from "../src/launch-runtime.ts";
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 = 80;
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 activityLabel(item: WidgetItem): string {
59
- if (item.status === "success") return "Done";
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.filter(({ status }) => status === "working"), ...items.filter(({ status }) => status !== "working")];
77
- if (!ordered.length) return [];
78
- const groups = new Map<string, { name: string; items: WidgetItem[] }>();
79
- for (const item of ordered) {
80
- const group = groups.get(item.taskId);
81
- if (group) group.items.push(item);
82
- else groups.set(item.taskId, { name: item.name, items: [item] });
83
- }
84
- const maxVisibleLines = ordered.length + groups.size > MAX_WIDGET_LINES ? MAX_WIDGET_LINES - 1 : MAX_WIDGET_LINES;
85
- const workingGroups = [...groups.values()].filter(({ items }) => items.some(({ status }) => status === "working"));
86
- const visibleWorkingGroups = new Set(workingGroups.slice(0, Math.floor(maxVisibleLines / 2)));
87
- let remainingWorkingGroups = visibleWorkingGroups.size;
88
- const visible = new Set<WidgetItem>();
89
- const lines: string[] = [];
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
- const hidden = ordered.filter((item) => !visible.has(item));
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(toolCallId, workflow, async (entry) => {
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 } : {}),
@@ -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 "../src/schema.ts";
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
- toolCallId: string,
122
- workflow: ParsedWorkflow,
134
+ mode: WorkflowMode,
135
+ entries: readonly WorkflowEntry[],
123
136
  run: DelegationRunner,
124
137
  signal?: AbortSignal,
125
138
  ): Promise<void> {
126
139
  signal?.throwIfAborted();
127
- const entries = identifyWorkflowEntries(toolCallId, workflow);
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 (workflow.mode === "parallel") {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "22.0.0",
3
+ "version": "22.1.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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
- }