@gavin-hu/my-pi-agent 0.16.0 → 0.18.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.
Files changed (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -1,46 +1,93 @@
1
1
  /**
2
2
  * Pure formatting helpers shared by the transcript renderers.
3
+ *
4
+ * The usage line keeps tokens, turns, model, and context — the facts the child
5
+ * reported — and never money: a price needs a currency and a tariff, which the
6
+ * bytefly footer owns for the whole session. See `usageLine` / `totalUsageLine`.
3
7
  */
4
8
 
5
9
  import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
7
11
  import { formatDuration, formatTokens, sanitizeLine, shortenPath } from "../../lib/format.ts";
12
+ import { USAGE_COUNTERS, zeroUsage } from "../../lib/usage.ts";
8
13
  import type { BackgroundRun } from "./background.ts";
9
- import type { UsageStats } from "./types.ts";
14
+ import type { UsageSource, UsageStats } from "./types.ts";
10
15
 
11
- /** Dollar cost, keeping four decimals for the sub-cent amounts these runs produce. */
12
- function formatCost(cost: number): string {
13
- if (cost >= 1) return `$${cost.toFixed(2)}`;
14
- if (cost >= 0.0001) return `$${cost.toFixed(4)}`;
15
- return `$${cost.toPrecision(2)}`;
16
+ /** `2 turns` / `1 turn`, or "" when no turn was counted. */
17
+ function turnPart(turns: number | undefined): string {
18
+ if (!turns) return "";
19
+ return `${turns} turn${turns > 1 ? "s" : ""}`;
20
+ }
21
+
22
+ /** A prefix and a compact count, or "" when the counter is zero. */
23
+ function tokenPart(prefix: string, count: number | undefined): string {
24
+ return count ? `${prefix}${formatTokens(count)}` : "";
25
+ }
26
+
27
+ /** `ctx:18.2k`, or "" when no context was reported. */
28
+ function contextPart(tokens: number | undefined): string {
29
+ const count = tokens ?? 0;
30
+ return count > 0 ? `ctx:${formatTokens(count)}` : "";
16
31
  }
17
32
 
18
33
  /** A space-separated usage line, omitting zero fields. */
19
34
  export function formatUsageStats(usage: Partial<UsageStats>, model?: string): string {
20
- const parts: string[] = [];
21
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
22
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
23
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
24
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
25
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
26
- if (usage.cost) parts.push(formatCost(usage.cost));
27
- if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
28
- if (model) parts.push(model);
29
- return parts.join(" ");
35
+ return [
36
+ turnPart(usage.turns),
37
+ tokenPart("↑", usage.input),
38
+ tokenPart("↓", usage.output),
39
+ tokenPart("R", usage.cacheRead),
40
+ tokenPart("W", usage.cacheWrite),
41
+ contextPart(usage.contextTokens),
42
+ model ?? "",
43
+ ]
44
+ .filter(Boolean)
45
+ .join(" ");
30
46
  }
31
47
 
32
48
  /** Sum usage across results, for the chain/parallel totals line. */
33
- export function aggregateUsage(results: { usage: UsageStats }[]): Partial<UsageStats> {
34
- const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
49
+ export function aggregateUsage(results: readonly { usage: UsageStats }[]): Partial<UsageStats> {
50
+ const counters = zeroUsage();
51
+ let turns = 0;
35
52
  for (const { usage } of results) {
36
- total.input += usage.input;
37
- total.output += usage.output;
38
- total.cacheRead += usage.cacheRead;
39
- total.cacheWrite += usage.cacheWrite;
40
- total.cost += usage.cost;
41
- total.turns += usage.turns;
53
+ for (const counter of USAGE_COUNTERS) counters[counter] += usage[counter];
54
+ turns += usage.turns;
42
55
  }
43
- return total;
56
+ return { ...counters, turns };
57
+ }
58
+
59
+ /** "2 tool errors", "1 tool error", or "" when the run hit none. */
60
+ function toolErrorsLabel(count: number | undefined): string {
61
+ if (!count) return "";
62
+ return `${count} tool error${count > 1 ? "s" : ""}`;
63
+ }
64
+
65
+ /**
66
+ * The one model every result ran, or undefined when they differ.
67
+ *
68
+ * A total may name a model only when the name is true of all of it: a batch whose
69
+ * tasks pinned different models gets no label rather than one task's.
70
+ */
71
+ function sharedModel(sources: readonly UsageSource[]): string | undefined {
72
+ const models = new Set(sources.map((source) => source.model).filter((model): model is string => Boolean(model)));
73
+ return models.size === 1 ? [...models][0] : undefined;
74
+ }
75
+
76
+ /** One result's usage line, with its tool-error count when it hit any. */
77
+ export function usageLine(source: UsageSource): string {
78
+ return [formatUsageStats(source.usage, source.model), toolErrorsLabel(source.toolErrors)].filter(Boolean).join(" ");
79
+ }
80
+
81
+ /**
82
+ * The `Total:` line for a multi run: summed counters, the shared model when there
83
+ * is one, and summed tool errors. A total carries no `ctx:` — every child has its
84
+ * own context window, so a sum of them would mean nothing.
85
+ */
86
+ export function totalUsageLine(sources: readonly UsageSource[]): string {
87
+ const usage = formatUsageStats(aggregateUsage(sources), sharedModel(sources));
88
+ const errors = sources.reduce((sum, source) => sum + (source.toolErrors ?? 0), 0);
89
+ const line = [usage, toolErrorsLabel(errors)].filter(Boolean).join(" ");
90
+ return line ? `Total: ${line}` : "";
44
91
  }
45
92
 
46
93
  /** Display-width-aware truncation with a trailing ellipsis (keeps wide chars/surrogates intact). */
@@ -17,6 +17,7 @@
17
17
 
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
  import { isExtensionEnabled, resolveEffectiveCwd } from "../../lib/env.ts";
20
+ import { textResult } from "../../lib/tool-result.ts";
20
21
  import {
21
22
  type AgentConfig,
22
23
  type AgentScope,
@@ -158,12 +159,10 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
158
159
  const resolution = resolveMode(args);
159
160
  if ("error" in resolution) {
160
161
  const roster = discoverAgents(root, args.agentScope ?? "user").agents;
161
- return {
162
- content: [
163
- { type: "text" as const, text: `${resolution.error} Available agents: ${formatAgentList(roster)}` },
164
- ],
165
- details: { mode: "single", results: [] } satisfies SubagentDetails,
166
- };
162
+ return textResult(`${resolution.error} Available agents: ${formatAgentList(roster)}`, {
163
+ mode: "single",
164
+ results: [],
165
+ } satisfies SubagentDetails);
167
166
  }
168
167
 
169
168
  const mode = resolution.mode;
@@ -195,17 +194,11 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
195
194
  .filter((agent): agent is AgentConfig => agent?.source === "project");
196
195
  if (projectAgents.length > 0) {
197
196
  if (!ctx.hasUI) {
198
- return {
199
- content: [
200
- {
201
- type: "text" as const,
202
- text:
203
- "Refusing to run project-local agents without confirmation. " +
204
- 'Trust the project or use agentScope: "user".',
205
- },
206
- ],
207
- details: makeDetails([]),
208
- };
197
+ return textResult(
198
+ "Refusing to run project-local agents without confirmation. " +
199
+ 'Trust the project or use agentScope: "user".',
200
+ makeDetails([]),
201
+ );
209
202
  }
210
203
  const names = projectAgents.map((agent) => agent.name).join(", ");
211
204
  const dir = discovery.projectAgentsDir ?? "(unknown)";
@@ -214,31 +207,20 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
214
207
  `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
215
208
  );
216
209
  if (!approved) {
217
- return {
218
- content: [{ type: "text" as const, text: "Canceled: project-local agents not approved." }],
219
- details: makeDetails([]),
220
- };
210
+ return textResult("Canceled: project-local agents not approved.", makeDetails([]));
221
211
  }
222
212
  }
223
213
  }
224
214
 
225
215
  if (args.background) {
226
216
  if (mode !== "single") {
227
- return {
228
- content: [{ type: "text" as const, text: "`background` supports single mode only." }],
229
- details: makeDetails([]),
230
- };
217
+ return textResult("`background` supports single mode only.", makeDetails([]));
231
218
  }
232
219
  if (!agents.some((candidate) => candidate.name === args.agent)) {
233
- return {
234
- content: [
235
- {
236
- type: "text" as const,
237
- text: `Unknown agent: "${args.agent ?? ""}". Available agents: ${formatAgentList(agents)}`,
238
- },
239
- ],
240
- details: makeDetails([]),
241
- };
220
+ return textResult(
221
+ `Unknown agent: "${args.agent ?? ""}". Available agents: ${formatAgentList(agents)}`,
222
+ makeDetails([]),
223
+ );
242
224
  }
243
225
  const started = background.start(
244
226
  {
@@ -259,19 +241,11 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
259
241
  },
260
242
  );
261
243
  if (!started.ok) {
262
- return {
263
- content: [{ type: "text" as const, text: started.error }],
264
- details: makeDetails([]),
265
- };
244
+ return textResult(started.error, makeDetails([]));
266
245
  }
267
- return {
268
- content: [
269
- {
270
- type: "text" as const,
271
- text: `Started ${started.run.agent} as ${started.run.id} in the background. Use /agents to inspect, collect, or cancel it.`,
272
- },
273
- ],
274
- details: {
246
+ return textResult(
247
+ `Started ${started.run.agent} as ${started.run.id} in the background. Use /agents to inspect, collect, or cancel it.`,
248
+ {
275
249
  ...makeDetails([]),
276
250
  background: {
277
251
  runId: started.run.id,
@@ -281,7 +255,7 @@ export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): voi
281
255
  startedAt: started.run.startedAt,
282
256
  },
283
257
  } satisfies SubagentDetails,
284
- };
258
+ );
285
259
  }
286
260
 
287
261
  const context: ModeContext = {
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
8
+ import { failedResult, textResult } from "../../lib/tool-result.ts";
8
9
  import type { AgentConfig } from "./agents.ts";
9
10
  import type { TranscriptMode } from "./config.ts";
10
11
  import type { RunOptions } from "./run.ts";
@@ -73,25 +74,16 @@ export async function runChainMode(ctx: ModeContext): Promise<AgentToolResult<Su
73
74
  results.push(result);
74
75
 
75
76
  if (isFailedResult(result)) {
76
- return {
77
- content: [
78
- {
79
- type: "text" as const,
80
- text: `Chain stopped at step ${i + 1} (${step.agent}): ${getResultOutput(result)}`,
81
- },
82
- ],
83
- details: makeDetails(results),
84
- isError: true,
85
- };
77
+ return failedResult(
78
+ `Chain stopped at step ${i + 1} (${step.agent}): ${getResultOutput(result)}`,
79
+ makeDetails(results),
80
+ );
86
81
  }
87
82
  previous = getFinalOutput(result.messages);
88
83
  }
89
84
 
90
85
  const last = results[results.length - 1];
91
- return {
92
- content: [{ type: "text" as const, text: last ? getFinalOutput(last.messages) || "(no output)" : "(no output)" }],
93
- details: makeDetails(results),
94
- };
86
+ return textResult(last ? getFinalOutput(last.messages) || "(no output)" : "(no output)", makeDetails(results));
95
87
  }
96
88
 
97
89
  /** Independent tasks with bounded concurrency, reporting running/done progress. */
@@ -104,10 +96,9 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
104
96
  if (!onUpdate) return;
105
97
  const running = allResults.filter((r) => r.exitCode === -1).length;
106
98
  const done = allResults.length - running;
107
- onUpdate({
108
- content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running…` }],
109
- details: makeDetails([...allResults]),
110
- });
99
+ onUpdate(
100
+ textResult(`Parallel: ${done}/${allResults.length} done, ${running} running…`, makeDetails([...allResults])),
101
+ );
111
102
  };
112
103
 
113
104
  const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (task, index) => {
@@ -146,15 +137,10 @@ export async function runParallelMode(ctx: ModeContext): Promise<AgentToolResult
146
137
  return `### [${result.agent}] ${status}\n\n${output}`;
147
138
  });
148
139
 
149
- return {
150
- content: [
151
- {
152
- type: "text" as const,
153
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
154
- },
155
- ],
156
- details: makeDetails(results),
157
- };
140
+ return textResult(
141
+ `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
142
+ makeDetails(results),
143
+ );
158
144
  }
159
145
 
160
146
  /** One agent + task. */
@@ -176,20 +162,11 @@ export async function runSingleMode(ctx: ModeContext): Promise<AgentToolResult<S
176
162
  });
177
163
 
178
164
  if (isFailedResult(result)) {
179
- return {
180
- content: [
181
- {
182
- type: "text" as const,
183
- text: `Agent ${result.agent} ${result.stopReason ?? "failed"}: ${getResultOutput(result)}`,
184
- },
185
- ],
186
- details: makeDetails([result]),
187
- isError: true,
188
- };
165
+ return failedResult(
166
+ `Agent ${result.agent} ${result.stopReason ?? "failed"}: ${getResultOutput(result)}`,
167
+ makeDetails([result]),
168
+ );
189
169
  }
190
170
 
191
- return {
192
- content: [{ type: "text" as const, text: getFinalOutput(result.messages) || "(no output)" }],
193
- details: makeDetails([result]),
194
- };
171
+ return textResult(getFinalOutput(result.messages) || "(no output)", makeDetails([result]));
195
172
  }
@@ -8,6 +8,11 @@
8
8
  *
9
9
  * Status and error rendering is shared across single- and multi-result modes so
10
10
  * a failed task never loses its message, regardless of how many tasks ran.
11
+ *
12
+ * The usage footnote follows the same grammar in every mode: a blank line, the
13
+ * faint metrics row, then the expand hint, so the affordance is always the
14
+ * block's last line. `format.ts` owns the row's text; it carries no money,
15
+ * because a price needs the footer's currency and tariff.
11
16
  */
12
17
 
13
18
  import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
@@ -20,7 +25,7 @@ import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
20
25
  import { centeredRule } from "../../lib/tui/rules.ts";
21
26
  import { BODY_INDENT } from "../../lib/tui/indent.ts";
22
27
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
23
- import { aggregateUsage, clip, formatToolCall, formatUsageStats } from "./format.ts";
28
+ import { clip, formatToolCall, totalUsageLine, usageLine } from "./format.ts";
24
29
  import { COLLAPSED_ERROR_MAX, COLLAPSED_ITEM_COUNT, COLLAPSED_TEXT_LINES, type SubagentArgs } from "./schema.ts";
25
30
  import { getFinalOutput, isFailedResult } from "./stream.ts";
26
31
  import type { SingleResult, SubagentDetails } from "./types.ts";
@@ -132,27 +137,6 @@ function isExpandable(result: SingleResult, status: ResultStatus, items: Display
132
137
  return hasExpandableContent(items) || hasClippedError(result, status);
133
138
  }
134
139
 
135
- /** "2 tool errors" / "1 tool error". */
136
- function toolErrorsLabel(count: number): string {
137
- return `${count} tool error${count > 1 ? "s" : ""}`;
138
- }
139
-
140
- /** Usage plus a tool-error count when the subagent hit any. */
141
- function usageLine(result: SingleResult): string {
142
- const base = formatUsageStats(result.usage, result.model);
143
- if (!result.toolErrors) return base;
144
- const label = toolErrorsLabel(result.toolErrors);
145
- return base ? `${base} ${label}` : label;
146
- }
147
-
148
- /** The `Total:` usage line for a multi run, including summed tool errors. */
149
- function totalUsageLine(results: SingleResult[]): string {
150
- const usage = formatUsageStats(aggregateUsage(results));
151
- const errors = results.reduce((sum, result) => sum + (result.toolErrors ?? 0), 0);
152
- const label = errors ? toolErrorsLabel(errors) : "";
153
- return [usage, label].filter(Boolean).join(" ");
154
- }
155
-
156
140
  /** " 12s" while running or once finished, or "" when no start time was recorded. */
157
141
  function durationSuffix(result: SingleResult, status: ResultStatus, now: number): string {
158
142
  if (!result.startedAt) return "";
@@ -306,6 +290,22 @@ function transcriptLine(result: SingleResult, theme: Theme): string | undefined
306
290
  return paint(theme, "faint", `transcript: ${sanitizeLine(result.artifactPath)}${cut}`);
307
291
  }
308
292
 
293
+ /** A blank line and one faint row: the shape every expanded footnote takes. */
294
+ function addFaintRow(container: Container, theme: Theme, text: string): void {
295
+ container.addChild(new Spacer(1));
296
+ container.addChild(new Text(paint(theme, "faint", text), 0, 0));
297
+ }
298
+
299
+ /**
300
+ * The collapsed block's footnote: a blank line, the faint metrics row, then the
301
+ * expand hint. Both collapsed views end through here, so the metrics row and the
302
+ * affordance cannot swap places again.
303
+ */
304
+ function collapsedFooter(theme: Theme, metrics: string, hinted: boolean): string {
305
+ const rows = metrics ? `\n\n${paint(theme, "faint", metrics)}` : "";
306
+ return hinted ? `${rows}\n${expandHint(theme)}` : rows;
307
+ }
308
+
309
309
  /** Append one result's expanded block: header, error, task, output, usage. */
310
310
  function addExpandedResult(
311
311
  container: Container,
@@ -359,15 +359,12 @@ function addExpandedResult(
359
359
  }
360
360
 
361
361
  const usage = usageLine(result);
362
- if (usage) {
363
- container.addChild(new Spacer(1));
364
- container.addChild(new Text(paint(theme, "faint", usage), 0, 0));
365
- }
362
+ if (usage) addFaintRow(container, theme, usage);
366
363
  const transcript = transcriptLine(result, theme);
367
364
  if (transcript) container.addChild(new Text(transcript, 0, 0));
368
365
  }
369
366
 
370
- /** The collapsed single result: header, a word, body, expand hint, and usage. */
367
+ /** The collapsed single result: header, a word, body, and the shared footer. */
371
368
  function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: boolean, now: number): string {
372
369
  const status = resultStatus(result, isPartial);
373
370
  // The leading blank line separates the result block from the call line, matching
@@ -377,10 +374,7 @@ function collapsedSingleText(result: SingleResult, theme: Theme, isPartial: bool
377
374
  const displayItems = getDisplayItems(result.messages);
378
375
  // A blank line separates the header from the body, matching the multi view.
379
376
  text += `\n\n${collapsedBody(result, status, displayItems, theme)}`;
380
- if (isExpandable(result, status, displayItems)) text += `\n${expandHint(theme)}`;
381
- const usage = usageLine(result);
382
- if (usage) text += `\n${paint(theme, "faint", usage)}`;
383
- return text;
377
+ return text + collapsedFooter(theme, usageLine(result), isExpandable(result, status, displayItems));
384
378
  }
385
379
 
386
380
  function summarizeResults(results: SingleResult[]) {
@@ -445,12 +439,8 @@ function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number)
445
439
  text += statusSuffix(result, status, theme, now);
446
440
  text += `\n${collapsedBody(result, status, displayItems, theme)}`;
447
441
  }
448
- if (running === 0) {
449
- const usage = totalUsageLine(results);
450
- if (usage) text += `\n\n${paint(theme, "faint", `Total: ${usage}`)}`;
451
- }
452
- if (running > 0 || hasHidden) text += `\n${expandHint(theme)}`;
453
- return text;
442
+ const metrics = running === 0 ? totalUsageLine(results) : "";
443
+ return text + collapsedFooter(theme, metrics, running > 0 || hasHidden);
454
444
  }
455
445
 
456
446
  function fillExpandedMulti(container: Container, details: SubagentDetails, theme: Theme, now: number): void {
@@ -464,11 +454,8 @@ function fillExpandedMulti(container: Container, details: SubagentDetails, theme
464
454
 
465
455
  const { running } = summarizeResults(details.results);
466
456
  if (running === 0) {
467
- const usage = totalUsageLine(details.results);
468
- if (usage) {
469
- container.addChild(new Spacer(1));
470
- container.addChild(new Text(paint(theme, "faint", `Total: ${usage}`), 0, 0));
471
- }
457
+ const total = totalUsageLine(details.results);
458
+ if (total) addFaintRow(container, theme, total);
472
459
  }
473
460
  }
474
461
 
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  import type { Message } from "@earendil-works/pi-ai";
9
+ import { asNumber, asRecord } from "../../lib/config.ts";
10
+ import { USAGE_COUNTERS, type UsageCounts, zeroUsage } from "../../lib/usage.ts";
9
11
  import type { SingleResult, UsageStats } from "./types.ts";
10
12
 
11
13
  /** A zeroed usage accumulator. */
12
14
  export function emptyUsage(): UsageStats {
13
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
15
+ return { ...zeroUsage(), contextTokens: 0, turns: 0 };
14
16
  }
15
17
 
16
18
  /** A fresh result for one subagent run; `exitCode: -1` marks it still running. */
@@ -29,6 +31,23 @@ export function parseJsonLine(line: string): Record<string, unknown> | undefined
29
31
  }
30
32
  }
31
33
 
34
+ /**
35
+ * The counters and context size one assistant message reported.
36
+ *
37
+ * The payload is a child process's JSON, so every number is read defensively: a
38
+ * string, `NaN`, or a missing field contributes nothing instead of concatenating
39
+ * into the accumulator or publishing a bogus recorded cost. `contextTokens` is
40
+ * the message's `totalTokens`, the occupancy of the child's window at that turn.
41
+ */
42
+ function readUsage(raw: unknown): { counters: UsageCounts; contextTokens?: number } {
43
+ const usage = asRecord(raw);
44
+ const counters = zeroUsage();
45
+ for (const counter of USAGE_COUNTERS) counters[counter] = asNumber(usage?.[counter]) ?? 0;
46
+ // The wire nests the dollar amount one level down, as `{ total }`.
47
+ counters.cost = asNumber(asRecord(usage?.cost)?.total) ?? 0;
48
+ return { counters, contextTokens: asNumber(usage?.totalTokens) };
49
+ }
50
+
32
51
  /**
33
52
  * Fold one parsed event into `result`.
34
53
  *
@@ -51,25 +70,12 @@ export function applyEvent(result: SingleResult, event: unknown): boolean {
51
70
  model?: string;
52
71
  stopReason?: string;
53
72
  errorMessage?: string;
54
- usage?: {
55
- input?: number;
56
- output?: number;
57
- cacheRead?: number;
58
- cacheWrite?: number;
59
- totalTokens?: number;
60
- cost?: { total?: number };
61
- };
73
+ usage?: unknown;
62
74
  };
63
75
  result.usage.turns++;
64
- const usage = assistant.usage;
65
- if (usage) {
66
- result.usage.input += usage.input ?? 0;
67
- result.usage.output += usage.output ?? 0;
68
- result.usage.cacheRead += usage.cacheRead ?? 0;
69
- result.usage.cacheWrite += usage.cacheWrite ?? 0;
70
- result.usage.cost += usage.cost?.total ?? 0;
71
- result.usage.contextTokens = usage.totalTokens ?? result.usage.contextTokens;
72
- }
76
+ const reported = readUsage(assistant.usage);
77
+ for (const counter of USAGE_COUNTERS) result.usage[counter] += reported.counters[counter];
78
+ if (reported.contextTokens !== undefined) result.usage.contextTokens = reported.contextTokens;
73
79
  if (!result.provider && assistant.provider) result.provider = assistant.provider;
74
80
  if (!result.model && assistant.model) result.model = assistant.model;
75
81
  if (assistant.stopReason) result.stopReason = assistant.stopReason;
@@ -24,6 +24,13 @@ export interface UsageStats {
24
24
  turns: number;
25
25
  }
26
26
 
27
+ /** The fields a usage line reads from one result. */
28
+ export interface UsageSource {
29
+ usage: UsageStats;
30
+ model?: string;
31
+ toolErrors?: number;
32
+ }
33
+
27
34
  /** Everything captured from one subagent subprocess. */
28
35
  export interface SingleResult {
29
36
  agent: string;
@@ -13,6 +13,7 @@
13
13
  import type { JsonValue } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { TODO_TOOL } from "../../lib/tool-names.ts";
16
+ import { failedResult, textResult } from "../../lib/tool-result.ts";
16
17
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
17
18
  import { normalizeTodos } from "../../lib/todo-state.ts";
18
19
  import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
@@ -54,12 +55,10 @@ function toStructuredContent(todos: Todo[], action: "write" | "clear", error?: s
54
55
  */
55
56
  function todoResult(todos: Todo[], action: "write" | "clear", error?: string) {
56
57
  const details: TodoDetails = error === undefined ? { todos, action } : { todos, action, error };
57
- return {
58
- content: [{ type: "text" as const, text: error === undefined ? formatTodoText(todos) : `Error: ${error}` }],
59
- details,
60
- structuredContent: toStructuredContent(todos, action, error),
61
- ...(error === undefined ? {} : { isError: true as const }),
62
- };
58
+ const structuredContent = toStructuredContent(todos, action, error);
59
+ return error === undefined
60
+ ? textResult(formatTodoText(todos), details, structuredContent)
61
+ : failedResult(`Error: ${error}`, details, structuredContent);
63
62
  }
64
63
 
65
64
  export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
@@ -161,13 +161,15 @@ export class TodoListComponent implements Component {
161
161
 
162
162
  if (this.todos.length === 0) {
163
163
  return dockListFrame({
164
+ theme: this.theme,
165
+ width: w,
164
166
  header,
165
167
  body: emptyState(this.theme, w, { label: "No todos yet. Ask the agent to plan some work." }),
166
168
  hint: screenHint(this.theme, w, ["Esc close"]),
167
169
  });
168
170
  }
169
171
 
170
- // header, summary, blank, blank-after-list, hint, blank
172
+ // header, summary, blank, blank-after-list, hint, closing rule
171
173
  const chrome = 6;
172
174
  this.visible = fitRows(this.viewportRowsSource, this.todos.length, chrome, SCREEN_DEFAULT_ITEMS);
173
175
  this.scrollTop = clampScroll(this.scrollTop, this.todos.length, this.visible);
@@ -177,6 +179,8 @@ export class TodoListComponent implements Component {
177
179
  for (let i = this.scrollTop; i < end; i++) body.push(todoRow(this.todos[i], this.theme, w));
178
180
 
179
181
  return dockListFrame({
182
+ theme: this.theme,
183
+ width: w,
180
184
  header,
181
185
  summary: clip(` ${paint(this.theme, "meta", progressSummary(this.todos))}`, w),
182
186
  body,
@@ -3,7 +3,8 @@
3
3
  Tail a file or a command's merged output, match each new line against a regular
4
4
  expression, and keep the matching lines. Matches are sanitized and alerted in
5
5
  the TUI, reported to the model at the next turn, or — when a watcher opts into
6
- `wake` — raise one triggered turn immediately.
6
+ `wake` — raise one triggered turn as soon as the agent is idle, including right
7
+ after a running turn settles.
7
8
 
8
9
  ```
9
10
  pi --extension ./extensions/watch # load just this extension
@@ -29,7 +30,9 @@ How the `watch` call and its result look in the transcript — the call line as
29
30
  - Shows a `⊚ N` running-watcher chip and a `! N` unreported-match chip, raises a
30
31
  `ctx.ui.notify` alert once per unreported burst, and offers a `/watch` screen.
31
32
  - Reports pending matches to the model at the start of the next turn, or wakes a
32
- turn for a watcher added with `wake: true`. A wake also leaves the terminal: the
33
+ turn for a watcher added with `wake: true` as soon as the agent is idle. A wake
34
+ that arrives mid-turn is raised once that run settles. A wake also leaves the
35
+ terminal: the
33
36
  match is announced on `my-pi-agent/watch-match`, which bytefly's
34
37
  [notify](../bytefly/README.md#notifications) capability turns into a desktop
35
38
  notification when `notify.enabled` and `notify.matchFound` are on.
@@ -172,8 +175,8 @@ other.
172
175
  - Matching is per line; a pattern cannot span a newline.
173
176
  - Only a **wake** match raises an OS notification, and only when bytefly's
174
177
  `notify.enabled` and `notify.matchFound` are on. A match collected while the
175
- agent was busy is reported at the next turn without a notification, and `watch`
176
- itself never sends one — it announces the match and bytefly owns delivery.
178
+ agent was busy notifies once that run settles, and `watch` itself never sends
179
+ one — it announces the match and bytefly owns delivery.
177
180
 
178
181
  ## Non-goals
179
182
 
@@ -213,7 +216,8 @@ UTF-8 sequence split across a read boundary is never mangled. State is
213
216
  session-only by design: a watcher is a live process or file position, not
214
217
  transcript state, so it cannot be faithfully rebuilt from a `/tree` fork. Match
215
218
  delivery mirrors `job` — reported at the next turn, with `wake` triggering one
216
- turn — so a match is never dropped when the wake window is busy. Stopping a
219
+ turn as soon as the agent is idle — so a match is never dropped when the wake
220
+ window is busy; a mid-turn wake is raised once that run settles. Stopping a
217
221
  command sends SIGTERM to its process group and escalates to SIGKILL after a 2 s
218
222
  grace; shutdown waits the same grace, so a command that ignores SIGTERM cannot
219
223
  outlive the session.