@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -22,7 +22,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
22
  import { claimPathMessage, resolveClaimPaths } from "./resolve.ts";
23
23
  import { realPathOfNearest } from "../../lib/path.ts";
24
24
  import { repoKeyFor } from "../../lib/repo-key.ts";
25
- import { sessionNameOf } from "../../lib/session-label.ts";
25
+ import { sessionNameOf, SESSION_LABEL_CHANNEL } from "../../lib/session-label.ts";
26
26
  import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
27
27
  import {
28
28
  defaultLabel,
@@ -39,7 +39,7 @@ import {
39
39
  } from "./protocol.ts";
40
40
  import { createMailWatch, type WatchingState } from "./mail-watch.ts";
41
41
  import { createBusStore, releaseClaimed, type BusStore } from "./store.ts";
42
- import type { BusMessage, ClaimConflict, SessionRecord, SessionStatus } from "./types.ts";
42
+ import type { BusMessage, ClaimConflict, InboxPeek, SessionRecord, SessionStatus } from "./types.ts";
43
43
  import {
44
44
  BUS_DIR_NAME,
45
45
  type RosterSeams,
@@ -131,9 +131,6 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
131
131
  const interval = options.interval ?? defaultInterval;
132
132
  const pid = options.pid ?? process.pid;
133
133
  const seams: RosterSeams = { alive: options.alive ?? isPidAlive, token: options.startTokenFn ?? startTokenOf };
134
- // Asked once, here: a reused pid is settled from the record afterwards, so no
135
- // heartbeat tick spawns `ps`.
136
- const processToken = (seams.token ?? startTokenOf)(pid);
137
134
 
138
135
  /**
139
136
  * What one record says about its session.
@@ -161,7 +158,8 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
161
158
 
162
159
  /** The watch reads this session's own inbox; nothing is consumed or marked. */
163
160
  const mailWatch = createMailWatch({
164
- peek: async () => (store && record ? store.peek(record.id, MAX_LIMIT) : []),
161
+ peek: async (known): Promise<InboxPeek> =>
162
+ store && record ? store.peek(record.id, { limit: MAX_LIMIT, known }) : { mail: [], unread: new Set() },
165
163
  interval,
166
164
  watchMs,
167
165
  });
@@ -178,6 +176,10 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
178
176
  await store.ensure();
179
177
  const id = context.sessionManager.getSessionId();
180
178
  const mode = sessionMode(context.mode);
179
+ // Asked once, when the bus starts: a reused pid is settled from the record
180
+ // afterwards, so no heartbeat tick spawns `ps`, and a session that never
181
+ // joins pays nothing in its factory.
182
+ const processToken = (seams.token ?? startTokenOf)(pid);
181
183
  record = {
182
184
  id,
183
185
  label: defaultLabel(sessionNameOf(context), cwd, id),
@@ -208,6 +210,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
208
210
  record.lastSeen = now();
209
211
  await store.writeSession(record);
210
212
  await settleLabel();
213
+ publishLabel();
211
214
  joined = true;
212
215
  armHeartbeat();
213
216
  }
@@ -225,7 +228,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
225
228
  await store.writeSession(record);
226
229
  // A heartbeat that resumed after a long pause can find its label taken; the
227
230
  // tick is where a revived session notices and yields, after its own write.
228
- await settleLabel();
231
+ if (await settleLabel()) publishLabel();
229
232
  })().catch(() => {});
230
233
  });
231
234
  }
@@ -248,18 +251,35 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
248
251
  * that keeps the label does not look again, and two of them can each decide that.
249
252
  * Every writer that observes a rival yields, and the record written last always
250
253
  * observes the other, so a collision ends with two distinct labels.
254
+ *
255
+ * Answers whether this call yielded, which is the only thing that can change a label
256
+ * after it is settled — the heartbeat tick is where that happens.
251
257
  */
252
- async function settleLabel(): Promise<void> {
253
- if (!store || !record) return;
258
+ async function settleLabel(): Promise<boolean> {
259
+ if (!store || !record) return false;
254
260
  const me = record;
255
261
  let yields = 0;
256
262
  while (yields < 3 && (await takenLabels(store, me.id)).has(me.label.toLowerCase())) {
257
263
  me.label = suffixLabel(me.label, me.id);
258
264
  yields += 1;
259
265
  }
260
- if (yields === 0) return;
266
+ if (yields === 0) return false;
261
267
  me.lastSeen = now();
262
268
  await store.writeSession(me);
269
+ return true;
270
+ }
271
+
272
+ /**
273
+ * Tell the footer what peers address this session by.
274
+ *
275
+ * Nothing else can say what a label is: the bus settles it, yields it on a collision, and
276
+ * gives it to a caller that asked for a name. The footer draws it in place of Pi's own
277
+ * session name, which the terminal knows but a card, a handoff, and `/team` do not.
278
+ */
279
+ function publishLabel(): void {
280
+ if (!record) return;
281
+ // A host with no event bus has no listener: the footer it would feed keeps Pi's own name.
282
+ pi.events?.emit(SESSION_LABEL_CHANNEL, { id: record.id, label: record.label });
263
283
  }
264
284
 
265
285
  /**
@@ -376,6 +396,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
376
396
  // A rename that raced another one ends on the label the bus settled on, which
377
397
  // is what the caller hands to Pi as this session's name.
378
398
  await settleLabel();
399
+ publishLabel();
379
400
  joined = true;
380
401
  armHeartbeat();
381
402
  return record.label;
@@ -458,7 +479,7 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
458
479
 
459
480
  async peek() {
460
481
  if (!store || !record) throw new Error("session bus is not started.");
461
- return store.peek(record.id, MAX_LIMIT);
482
+ return (await store.peek(record.id, { limit: MAX_LIMIT })).mail;
462
483
  },
463
484
 
464
485
  /**
@@ -482,7 +503,14 @@ export function createSessionBus(pi: ExtensionAPI, options: SessionBusOptions =
482
503
  if ("error" in text) throw new Error(text.error);
483
504
  const kind = normalizeKind(input.kind);
484
505
  if (input.to.trim() === "*" && kind !== "event") throw new Error('a broadcast needs kind: "event".');
485
- const sessions = await store.sessions();
506
+ // Mail reaches a session that can still answer, by the rule `status` and the prune
507
+ // already use: a record whose process is gone is not a recipient, so naming it is
508
+ // refused instead of confirmed, and a broadcast skips it. This session is always its
509
+ // own recipient, which is what a note to itself is.
510
+ const at = now();
511
+ const sessions = (await store.sessions()).filter(
512
+ (session) => session.id === me.id || stateOf(session, at) !== "gone",
513
+ );
486
514
  const resolved = resolveRecipients(sessions, input.to, me.id);
487
515
  if ("error" in resolved) throw new Error(resolved.error);
488
516
  let paths: string[] | undefined;
@@ -8,11 +8,11 @@
8
8
  */
9
9
 
10
10
  import { createHash, randomBytes } from "node:crypto";
11
- import { mkdir, link, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
11
+ import { mkdir, link, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
12
12
  import { dirname, join } from "node:path";
13
13
  import { asRecord, readJson } from "../../lib/config.ts";
14
14
  import { claimsConflict } from "./overlap.ts";
15
- import type { BusMessage, ClaimConflict, ClaimRecord, SessionRecord } from "./types.ts";
15
+ import type { BusMessage, ClaimConflict, ClaimRecord, InboxPeek, SessionRecord } from "./types.ts";
16
16
  import { INBOX_DIR_NAME, SESSIONS_DIR_NAME } from "../../lib/session-roster.ts";
17
17
 
18
18
  const DIR_MODE = 0o700;
@@ -32,8 +32,11 @@ export interface BusStore {
32
32
  session(id: string): Promise<SessionRecord | undefined>;
33
33
  removeSession(id: string): Promise<void>;
34
34
  writeMessage(target: string, message: BusMessage): Promise<void>;
35
- /** Read a recipient's messages without consuming them, oldest first. */
36
- peek(id: string, limit: number): Promise<BusMessage[]>;
35
+ /**
36
+ * Read up to `limit` messages from the front of a recipient's inbox without consuming
37
+ * them, skipping the ids the caller already has, with every id still unread.
38
+ */
39
+ peek(id: string, options: { limit: number; known?: ReadonlySet<string> }): Promise<InboxPeek>;
37
40
  inbox(id: string, limit: number): Promise<BusMessage[]>;
38
41
  unread(id: string): Promise<number>;
39
42
  claims(): Promise<ClaimRecord[]>;
@@ -42,6 +45,8 @@ export interface BusStore {
42
45
  owner: string,
43
46
  ownerLabel: string,
44
47
  at: number,
48
+ /** Test seam: runs after each path is published, so a suite can interleave a rival. */
49
+ afterClaim?: (path: string) => Promise<void>,
45
50
  ): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }>;
46
51
  release(path: string, owner: string): Promise<boolean>;
47
52
  releaseAll(owner: string): Promise<string[]>;
@@ -148,14 +153,19 @@ async function writeJson(path: string, value: unknown): Promise<void> {
148
153
  }
149
154
  }
150
155
 
156
+ const MESSAGE_SUFFIX = ".json";
157
+
151
158
  async function listFiles(dir: string): Promise<string[]> {
152
159
  try {
153
- return (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
160
+ return (await readdir(dir)).filter((name) => name.endsWith(MESSAGE_SUFFIX)).sort();
154
161
  } catch {
155
162
  return [];
156
163
  }
157
164
  }
158
165
 
166
+ /** The message id a file name holds, which is the id a reader compares against. */
167
+ const idOfFile = (name: string): string => name.slice(0, -MESSAGE_SUFFIX.length);
168
+
159
169
  /**
160
170
  * Drop a recipient's directory once it holds no files, so a bus does not keep
161
171
  * one empty directory for every session that ever joined it. An inbox with mail
@@ -168,6 +178,32 @@ async function dropIfEmpty(dir: string): Promise<void> {
168
178
  await rmdir(dir).catch(() => {});
169
179
  }
170
180
 
181
+ /**
182
+ * How long a writer's temp file may sit before it is debris.
183
+ *
184
+ * A record and a claim are published by renaming or linking a temp file, so the file
185
+ * exists for milliseconds; one left behind is a process that stopped between the
186
+ * write and the publish. The window is measured on the filesystem's own clock, not
187
+ * the bus's, because that is the clock the timestamps came from.
188
+ */
189
+ const TEMP_TTL_MS = 60_000;
190
+
191
+ /**
192
+ * Remove the temp files a crashed writer left in one directory.
193
+ *
194
+ * Readers only ever look at `*.json`, so this debris is invisible and would live
195
+ * forever without a pass of its own.
196
+ */
197
+ async function dropStaleTemps(dir: string): Promise<void> {
198
+ const cut = Date.now() - TEMP_TTL_MS;
199
+ for (const name of await readdir(dir).catch(() => [] as string[])) {
200
+ if (name.endsWith(".json")) continue;
201
+ const path = join(dir, name);
202
+ const info = await stat(path).catch(() => undefined);
203
+ if (info !== undefined && info.mtimeMs < cut) await rm(path, { force: true });
204
+ }
205
+ }
206
+
171
207
  /** Create a store rooted at one repository's bus directory. */
172
208
  export function createBusStore(options: BusStoreOptions): BusStore {
173
209
  const { dir } = options;
@@ -188,17 +224,39 @@ export function createBusStore(options: BusStoreOptions): BusStore {
188
224
  return sessions.sort((a, b) => b.lastSeen - a.lastSeen || a.label.localeCompare(b.label));
189
225
  }
190
226
 
191
- async function readMessages(id: string, limit: number, remove: boolean): Promise<BusMessage[]> {
192
- const directory = inboxDir(id);
227
+ /**
228
+ * Up to `limit` messages from the front of a recipient's inbox, oldest first.
229
+ *
230
+ * The front is the queue, not a detail: consuming starts there so a limit never starves,
231
+ * and so does a read, because the caller names the ids it already has. A file name holds
232
+ * the id, so a skip costs no read — which is what lets a watch page a backlog one poll at
233
+ * a time without ever reading the same file twice.
234
+ *
235
+ * Only the consuming read deletes, and it deletes a file it cannot validate too, so a
236
+ * truncated message cannot linger on the bus. A read that promises not to consume leaves
237
+ * such a file where it is: it may be mail this build cannot read and the next one can, and
238
+ * `inbox` or the prune TTL is what ends it.
239
+ */
240
+ async function readMessages(
241
+ directory: string,
242
+ names: readonly string[],
243
+ options: { limit: number; known?: ReadonlySet<string>; remove: boolean },
244
+ ): Promise<BusMessage[]> {
245
+ const { limit, known, remove } = options;
193
246
  const messages: BusMessage[] = [];
194
- for (const name of await listFiles(directory)) {
247
+ for (const name of names) {
248
+ if (known?.has(idOfFile(name))) continue;
195
249
  const path = join(directory, name);
196
250
  const message = readTyped(path, isMessage);
197
- if (message) messages.push(message);
198
- if (remove || !message) await rm(path, { force: true });
251
+ if (!message) {
252
+ if (remove) await rm(path, { force: true });
253
+ continue;
254
+ }
255
+ messages.push(message);
256
+ if (remove) await rm(path, { force: true });
199
257
  if (messages.length >= limit) break;
200
258
  }
201
- return messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)).slice(0, limit);
259
+ return messages.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
202
260
  }
203
261
 
204
262
  async function readClaims(): Promise<ClaimRecord[]> {
@@ -215,7 +273,7 @@ export function createBusStore(options: BusStoreOptions): BusStore {
215
273
  owner: string,
216
274
  ownerLabel: string,
217
275
  at: number,
218
- ): Promise<{ claim: ClaimRecord; conflict?: ClaimRecord }> {
276
+ ): Promise<{ claim: ClaimRecord; conflict?: ClaimRecord; created: boolean }> {
219
277
  await mkdir(claimsDir, { recursive: true, mode: DIR_MODE });
220
278
  const file = claimFile(path);
221
279
  const claim: ClaimRecord = { path, owner, ownerLabel, at };
@@ -227,19 +285,21 @@ export function createBusStore(options: BusStoreOptions): BusStore {
227
285
  try {
228
286
  try {
229
287
  await link(temp, file);
230
- return { claim };
288
+ return { claim, created: true };
231
289
  } catch (error) {
232
290
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
233
291
  }
234
292
  const existing = readTyped(file, isClaim);
235
293
  if (existing) {
236
- return existing.owner === owner ? { claim: existing } : { claim, conflict: existing };
294
+ return existing.owner === owner
295
+ ? { claim: existing, created: false }
296
+ : { claim, conflict: existing, created: false };
237
297
  }
238
298
  // A claim file that does not parse is debris from before claims were
239
299
  // published whole; clear it once so a stale path cannot block forever.
240
300
  await rm(file, { force: true });
241
301
  await link(temp, file);
242
- return { claim };
302
+ return { claim, created: true };
243
303
  } finally {
244
304
  await rm(temp, { force: true });
245
305
  }
@@ -258,13 +318,19 @@ export function createBusStore(options: BusStoreOptions): BusStore {
258
318
  *
259
319
  * The pre-check reads the claims once and reports every blocking owner, so a
260
320
  * batch that cannot be satisfied touches no file. A create that loses a race
261
- * after the pre-check releases the paths this batch already took.
321
+ * after the pre-check gives back only what this batch published: a path the
322
+ * owner already held carries an older `at` and is not this batch's to release.
323
+ *
324
+ * `afterClaim` is a test seam: it runs after a path is published, which is the
325
+ * only way a suite can put a rival claim in the window between the pre-check and
326
+ * the next create.
262
327
  */
263
328
  async function claimAll(
264
329
  paths: readonly string[],
265
330
  owner: string,
266
331
  ownerLabel: string,
267
332
  at: number,
333
+ afterClaim?: (path: string) => Promise<void>,
268
334
  ): Promise<{ claimed: string[]; conflicts: ClaimConflict[] }> {
269
335
  const existing = await readClaims();
270
336
  const conflicts: ClaimConflict[] = [];
@@ -277,14 +343,17 @@ export function createBusStore(options: BusStoreOptions): BusStore {
277
343
  }
278
344
  if (conflicts.length > 0) return { claimed: [], conflicts };
279
345
  const claimed: string[] = [];
346
+ const created: string[] = [];
280
347
  for (const path of paths) {
281
348
  const result = await claimOne(path, owner, ownerLabel, at);
282
349
  if (result.conflict) {
283
- for (const done of claimed) await releaseOne(done, owner);
350
+ for (const done of created) await releaseOne(done, owner);
284
351
  const { owner: holder, ownerLabel: holderLabel } = result.conflict;
285
352
  return { claimed: [], conflicts: [{ path, owner: holder, ownerLabel: holderLabel }] };
286
353
  }
287
354
  claimed.push(path);
355
+ if (result.created) created.push(path);
356
+ if (afterClaim) await afterClaim(path);
288
357
  }
289
358
  return { claimed, conflicts: [] };
290
359
  }
@@ -318,9 +387,19 @@ export function createBusStore(options: BusStoreOptions): BusStore {
318
387
  await writeJson(join(directory, `${message.id}.json`), message);
319
388
  },
320
389
 
321
- peek: (id, limit) => readMessages(id, limit, false),
390
+ async peek(id, options) {
391
+ const directory = inboxDir(id);
392
+ const names = await listFiles(directory);
393
+ return {
394
+ mail: await readMessages(directory, names, { ...options, remove: false }),
395
+ unread: new Set(names.map(idOfFile)),
396
+ };
397
+ },
322
398
 
323
- inbox: (id, limit) => readMessages(id, limit, true),
399
+ async inbox(id, limit) {
400
+ const directory = inboxDir(id);
401
+ return readMessages(directory, await listFiles(directory), { limit, remove: true });
402
+ },
324
403
 
325
404
  async unread(id) {
326
405
  return (await listFiles(inboxDir(id))).length;
@@ -364,12 +443,15 @@ export function createBusStore(options: BusStoreOptions): BusStore {
364
443
  for (const claim of await readClaims()) {
365
444
  if (!live.has(claim.owner)) await rm(claimFile(claim.path), { force: true });
366
445
  }
446
+ await dropStaleTemps(sessionsDir);
447
+ await dropStaleTemps(claimsDir);
367
448
  for (const name of await readdir(inboxRoot).catch(() => [] as string[])) {
368
449
  const directory = join(inboxRoot, name);
369
450
  for (const file of await listFiles(directory)) {
370
451
  const message = readTyped(join(directory, file), isMessage);
371
452
  if (!message || at - message.at > messageTtlMs) await rm(join(directory, file), { force: true });
372
453
  }
454
+ await dropStaleTemps(directory);
373
455
  await dropIfEmpty(directory);
374
456
  }
375
457
  },
@@ -13,11 +13,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { Type, type Static } from "typebox";
14
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
15
  import { errorResult, textResult } from "../../lib/tool-result.ts";
16
- import { callLine, detailsOrLine } from "../../lib/tui/tool.ts";
16
+ import { callLine, detailsOrLine, isTailResult, TailResult } from "../../lib/tui/tool.ts";
17
17
  import { formatClaim, formatInbox, formatJoin, formatRelease, formatSend, formatStatus } from "./format.ts";
18
18
  import { normalizeAction, TOOL_NAME } from "./protocol.ts";
19
19
  import type { SessionBus } from "./runtime.ts";
20
- import { collapsedLine, resultSections, SessionResult } from "./result.ts";
20
+ import { collapsedLine, resultSections } from "./result.ts";
21
+ import type { BusRow } from "./rows.ts";
21
22
  import type { SessionDetails } from "./types.ts";
22
23
 
23
24
  export { TOOL_NAME };
@@ -183,8 +184,8 @@ export function registerTools(pi: ExtensionAPI, bus: SessionBus): void {
183
184
  text.setText(collapsedLine(theme, value));
184
185
  return text;
185
186
  }
186
- const view =
187
- context?.lastComponent instanceof SessionResult ? context.lastComponent : new SessionResult(sections, theme);
187
+ const previous = context?.lastComponent;
188
+ const view = isTailResult<BusRow>(previous) ? previous : new TailResult<BusRow>(sections, theme);
188
189
  view.setInput(sections, theme);
189
190
  return view;
190
191
  },
@@ -53,6 +53,25 @@ export interface BusMessage {
53
53
  at: number;
54
54
  }
55
55
 
56
+ /**
57
+ * One read of a recipient's inbox.
58
+ *
59
+ * The watch and `inbox` ask two questions of the same directory — which mail is new,
60
+ * and which mail is gone — and one readdir answers both, so a queue's health never
61
+ * depends on what a single page happened to cover.
62
+ */
63
+ export interface InboxPeek {
64
+ /** Up to one page of messages the caller has not seen, oldest first. */
65
+ mail: BusMessage[];
66
+ /**
67
+ * Every id whose file is still in the inbox, whether or not this read returned it.
68
+ *
69
+ * A file this build cannot read counts: it stays where it is, and `inbox` or the prune
70
+ * TTL is what ends it, so its id is never in `mail` while it is still here.
71
+ */
72
+ unread: ReadonlySet<string>;
73
+ }
74
+
56
75
  /** One repository-relative claim. */
57
76
  export interface ClaimRecord {
58
77
  path: string;
@@ -10,14 +10,14 @@
10
10
 
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { matchesKey, Key } from "@earendil-works/pi-tui";
13
- import { messageOf } from "../../lib/errors.ts";
14
- import { formatDuration, sanitizeLine } from "../../lib/format.ts";
15
- import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
16
- import { type DetailContents, DockListScreen } from "../../lib/tui/dock-screen.ts";
17
- import { SEPARATORS } from "../../lib/tui/glyphs.ts";
18
- import { keyValueRow } from "../../lib/tui/rows.ts";
19
- import { paint } from "../../lib/tui/tokens.ts";
20
- import { idSuffix } from "./format.ts";
13
+ import { messageOf } from "../../../lib/errors.ts";
14
+ import { formatDuration, sanitizeLine } from "../../../lib/format.ts";
15
+ import type { ScreenHint, ViewportRowsSource } from "../../../lib/tui/chrome.ts";
16
+ import { type DetailContents, DockListScreen } from "../../../lib/tui/dock-screen.ts";
17
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
18
+ import { keyValueRow } from "../../../lib/tui/rows.ts";
19
+ import { paint } from "../../../lib/tui/tokens.ts";
20
+ import { idSuffix } from "../format.ts";
21
21
  import {
22
22
  type SessionRow,
23
23
  claimRowLine,
@@ -27,8 +27,8 @@ import {
27
27
  sessionRowLine,
28
28
  sessionRows,
29
29
  summaryCells,
30
- } from "./rows.ts";
31
- import type { BoardSnapshot, BusMessage, ClaimRecord } from "./types.ts";
30
+ } from "../rows.ts";
31
+ import type { BoardSnapshot, BusMessage, ClaimRecord } from "../types.ts";
32
32
 
33
33
  /** The scopes `Tab` cycles through. */
34
34
  export type BoardScope = "sessions" | "inbox" | "claims";
@@ -112,8 +112,8 @@ export interface SessionListCallbacks {
112
112
  }
113
113
 
114
114
  interface SessionListOptions {
115
- /** The latest board, or undefined before the first read. */
116
- latest(): BoardSnapshot | undefined;
115
+ /** The board the screen opens on; the command's poll replaces it, and it is never read back. */
116
+ board: BoardSnapshot | undefined;
117
117
  theme: Theme;
118
118
  callbacks: SessionListCallbacks;
119
119
  onClose(): void;
@@ -150,7 +150,7 @@ export class SessionListComponent extends DockListScreen<BoardRow> {
150
150
  });
151
151
  this.callbacks = options.callbacks;
152
152
  this.now = options.now ?? Date.now;
153
- this.board = options.latest();
153
+ this.board = options.board;
154
154
  }
155
155
 
156
156
  /** Replace the board; a poll that changed nothing does not repaint. */
@@ -257,7 +257,7 @@ export class SessionListComponent extends DockListScreen<BoardRow> {
257
257
  hints.push(enter("read"), this.watchHint(), "↑/↓", "PgUp/PgDn");
258
258
  break;
259
259
  case "claims":
260
- hints.push(enter("open"), { text: "x release", color: "warning" }, "↑/↓", "PgUp/PgDn");
260
+ hints.push(enter("open"), { text: "x release", color: "warn" }, "↑/↓", "PgUp/PgDn");
261
261
  break;
262
262
  }
263
263
  return hints;
@@ -265,7 +265,7 @@ export class SessionListComponent extends DockListScreen<BoardRow> {
265
265
 
266
266
  protected detailHints(row: BoardRow | undefined): ScreenHint[] {
267
267
  if (row?.kind === "claim" && row.claim.owner === this.me()) {
268
- return ["Esc back", { text: "x release", color: "warning" }, "↑/↓ scroll", "g/G ends"];
268
+ return ["Esc back", { text: "x release", color: "warn" }, "↑/↓ scroll", "g/G ends"];
269
269
  }
270
270
  return ["Esc back", "↑/↓ scroll", "g/G ends", "PgUp/PgDn"];
271
271
  }
@@ -366,7 +366,7 @@ export class SessionListComponent extends DockListScreen<BoardRow> {
366
366
 
367
367
  private watchHint(): ScreenHint {
368
368
  return this.board?.status.watching
369
- ? { text: "w watch off", color: "warning" }
369
+ ? { text: "w watch off", color: "warn" }
370
370
  : { text: "w watch on", color: "accent" };
371
371
  }
372
372
 
@@ -2,7 +2,7 @@
2
2
  * Agent definitions and discovery.
3
3
  *
4
4
  * Built-in agents ship with the extension. User agents are loaded from
5
- * `<agentDir>/agents` and project agents from the nearest
5
+ * `<agent-dir>/agents` and project agents from the nearest
6
6
  * `<CONFIG_DIR_NAME>/agents` (for example `.pi/agents`). Each external file is
7
7
  * markdown with YAML frontmatter (`name`, `description`, `tools`, `model`) and
8
8
  * the system prompt as the body.
@@ -9,9 +9,7 @@
9
9
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
10
  import { planModeRefusal } from "../../lib/plan-entry.ts";
11
11
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
12
- import { withRailsSuppressed } from "../../lib/rails.ts";
13
12
  import type { BackgroundRuntime } from "./background.ts";
14
- import { AgentsBoardComponent } from "./background-tui.ts";
15
13
  import { formatAgentsList } from "./format.ts";
16
14
 
17
15
  /** Open `/agents`; the TUI gets the board, other modes get the list. */
@@ -35,6 +33,10 @@ async function openAgents(
35
33
  return;
36
34
  }
37
35
 
36
+ // The board is the TUI's: the list above and registration load neither it nor
37
+ // its rails wrapper.
38
+ const { withRailsSuppressed } = await import("../../lib/rails.ts");
39
+ const { AgentsBoardComponent } = await import("./view/agents-board.ts");
38
40
  let collectId: string | undefined;
39
41
  await withRailsSuppressed(pi, async () => {
40
42
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
@@ -9,6 +9,7 @@
9
9
  import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import { formatDuration, formatTokens, sanitizeLine, shortenPath } from "../../lib/format.ts";
12
+ import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
12
13
  import { USAGE_COUNTERS, zeroUsage } from "../../lib/usage.ts";
13
14
  import type { BackgroundRun } from "./background.ts";
14
15
  import type { UsageSource, UsageStats } from "./types.ts";
@@ -116,13 +117,13 @@ export function formatToolCall(
116
117
  theme: Theme,
117
118
  preview: boolean,
118
119
  ): string {
119
- const fg = theme.fg.bind(theme);
120
+ const fg = (role: TuiRole, text: string) => paint(theme, role, text);
120
121
  // Defensive: a malformed tool-call part can carry a null/undefined `arguments`.
121
122
  const a = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
122
123
 
123
124
  switch (toolName) {
124
125
  case "bash":
125
- return fg("muted", "$ ") + fg("toolOutput", clip(sanitizeLine(String(a.command ?? "…")), preview ? 60 : 200));
126
+ return fg("meta", "$ ") + fg("toolOutput", clip(sanitizeLine(String(a.command ?? "…")), preview ? 60 : 200));
126
127
  case "read": {
127
128
  const rawPath = clipPath(shortenPath(sanitizeLine(String(a.file_path ?? a.path ?? "…"))), preview ? 60 : 200);
128
129
  const offset = a.offset as number | undefined;
@@ -131,44 +132,43 @@ export function formatToolCall(
131
132
  if (offset !== undefined || limit !== undefined) {
132
133
  const start = offset ?? 1;
133
134
  const end = limit !== undefined ? start + limit - 1 : "";
134
- text += fg("warning", `:${start}${end ? `-${end}` : ""}`);
135
+ text += fg("warn", `:${start}${end ? `-${end}` : ""}`);
135
136
  }
136
- return fg("muted", "read ") + text;
137
+ return fg("meta", "read ") + text;
137
138
  }
138
139
  case "write": {
139
140
  const rawPath = clipPath(shortenPath(sanitizeLine(String(a.file_path ?? a.path ?? "…"))), preview ? 60 : 200);
140
141
  const content = String(a.content ?? "");
141
142
  const lines = content.split("\n").length;
142
- const text = fg("muted", "write ") + fg("accent", rawPath);
143
- return lines > 1 ? text + fg("dim", ` (${lines} lines)`) : text;
143
+ const text = fg("meta", "write ") + fg("accent", rawPath);
144
+ return lines > 1 ? text + fg("faint", ` (${lines} lines)`) : text;
144
145
  }
145
146
  case "edit":
146
147
  return (
147
- fg("muted", "edit ") +
148
+ fg("meta", "edit ") +
148
149
  fg("accent", clipPath(shortenPath(sanitizeLine(String(a.file_path ?? a.path ?? "…"))), preview ? 60 : 200)) +
149
- fg("dim", " (diff)")
150
+ fg("faint", " (diff)")
150
151
  );
151
152
  case "ls":
152
153
  return (
153
- fg("muted", "ls ") +
154
- fg("accent", clipPath(shortenPath(sanitizeLine(String(a.path ?? "."))), preview ? 60 : 200))
154
+ fg("meta", "ls ") + fg("accent", clipPath(shortenPath(sanitizeLine(String(a.path ?? "."))), preview ? 60 : 200))
155
155
  );
156
156
  case "find":
157
157
  return (
158
- fg("muted", "find ") +
158
+ fg("meta", "find ") +
159
159
  fg("accent", clip(sanitizeLine(String(a.pattern ?? "*")), preview ? 40 : 120)) +
160
- fg("dim", ` in ${clipPath(shortenPath(sanitizeLine(String(a.path ?? "."))), preview ? 40 : 120)}`)
160
+ fg("faint", ` in ${clipPath(shortenPath(sanitizeLine(String(a.path ?? "."))), preview ? 40 : 120)}`)
161
161
  );
162
162
  case "grep":
163
163
  return (
164
- fg("muted", "grep ") +
164
+ fg("meta", "grep ") +
165
165
  fg("accent", `/${clip(sanitizeLine(String(a.pattern ?? "")), preview ? 40 : 120)}/`) +
166
- fg("dim", ` in ${clipPath(shortenPath(sanitizeLine(String(a.path ?? "."))), preview ? 40 : 120)}`)
166
+ fg("faint", ` in ${clipPath(shortenPath(sanitizeLine(String(a.path ?? "."))), preview ? 40 : 120)}`)
167
167
  );
168
168
  default: {
169
169
  const json = JSON.stringify(a);
170
170
  const text = clip(json, preview ? 50 : 200);
171
- return fg("accent", sanitizeLine(toolName)) + fg("dim", ` ${text}`);
171
+ return fg("accent", sanitizeLine(toolName)) + fg("faint", ` ${text}`);
172
172
  }
173
173
  }
174
174
  }
@@ -8,7 +8,7 @@
8
8
  * output).
9
9
  *
10
10
  * Agents are the built-ins in `agents.ts` plus optional user agents loaded from
11
- * `<agentDir>/agents` and project agents from the nearest `.pi/agents`; the
11
+ * `<agent-dir>/agents` and project agents from the nearest `.pi/agents`; the
12
12
  * `agentScope` parameter decides which external directories are consulted.
13
13
  * Subagents inherit the dispatching session's model and thinking level.
14
14
  *
@@ -179,7 +179,7 @@ function indentBody(text: string, theme: Theme): string {
179
179
  return stripControlChars(text)
180
180
  .trim()
181
181
  .split("\n")
182
- .map((line) => indent + theme.fg("toolOutput", line))
182
+ .map((line) => indent + paint(theme, "toolOutput", line))
183
183
  .join("\n");
184
184
  }
185
185
 
@@ -192,7 +192,7 @@ function renderDisplayItems(items: DisplayItem[], theme: Theme, limit?: number,
192
192
  if (item.type === "text") {
193
193
  const safe = stripControlChars(item.text);
194
194
  const text = preview ? safe.split("\n").slice(0, COLLAPSED_TEXT_LINES).join("\n") : safe;
195
- lines.push(theme.fg("toolOutput", text));
195
+ lines.push(paint(theme, "toolOutput", text));
196
196
  } else {
197
197
  lines.push(paint(theme, "meta", "→ ") + formatToolCall(item.name, item.args, theme, preview));
198
198
  }
@@ -419,7 +419,7 @@ function multiHeader(details: SubagentDetails, theme: Theme, now: number): strin
419
419
  : bad > 0
420
420
  ? paint(theme, bad === total ? "bad" : "warn", bad === total ? GLYPHS.jobsFailure : GLYPHS.half)
421
421
  : paint(theme, "ok", GLYPHS.check);
422
- return `${icon} ${theme.fg("toolTitle", theme.bold(`${details.mode} · `))}${paint(theme, "accent", status)}`;
422
+ return `${icon} ${paint(theme, "toolTitle", theme.bold(`${details.mode} · `))}${paint(theme, "accent", status)}`;
423
423
  }
424
424
 
425
425
  function collapsedMultiText(details: SubagentDetails, theme: Theme, now: number): string {