@pi-unipi/unipi 3.0.0-alpha.43 → 3.0.0-alpha.45

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 (43) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +23 -23
  3. package/packages/app-bridge/package.json +2 -2
  4. package/packages/app-bridge/src/bridge.ts +244 -47
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/autocomplete/package.json +1 -1
  7. package/packages/background-tasks/package.json +2 -2
  8. package/packages/background-tasks/src/index.ts +12 -0
  9. package/packages/btw/package.json +2 -2
  10. package/packages/compactor/package.json +3 -3
  11. package/packages/core/index.ts +1 -0
  12. package/packages/core/package.json +1 -1
  13. package/packages/core/src/child-agent/runtime.ts +21 -4
  14. package/packages/core/src/child-usage.ts +131 -0
  15. package/packages/core/src/tui/kit.ts +62 -0
  16. package/packages/dream/package.json +2 -2
  17. package/packages/footer/package.json +3 -3
  18. package/packages/footer/src/session-scan.ts +13 -0
  19. package/packages/fusion/package.json +4 -4
  20. package/packages/fusion/src/index.ts +3 -1
  21. package/packages/info-screen/package.json +2 -2
  22. package/packages/info-screen/usage-parser.ts +30 -2
  23. package/packages/input-shortcuts/package.json +2 -2
  24. package/packages/kanboard/package.json +7 -7
  25. package/packages/long-horizon/package.json +2 -2
  26. package/packages/long-horizon/src/progress-mermaid.ts +1 -1
  27. package/packages/long-horizon/src/progress-snapshot.ts +1 -1
  28. package/packages/long-horizon/src/progress-view.ts +6 -4
  29. package/packages/long-horizon/src/visualize.ts +1 -1
  30. package/packages/mcp/package.json +2 -2
  31. package/packages/memory/package.json +3 -3
  32. package/packages/notify/package.json +2 -2
  33. package/packages/skill-registry/package.json +2 -2
  34. package/packages/subagents/package.json +3 -3
  35. package/packages/subagents/src/index.ts +3 -1
  36. package/packages/subagents/src/manager.ts +13 -0
  37. package/packages/unipi/bundled.js +378 -87
  38. package/packages/updater/package.json +2 -2
  39. package/packages/utility/package.json +2 -2
  40. package/packages/utility/skills/cron/SKILL.md +6 -0
  41. package/packages/watchdog/package.json +3 -3
  42. package/packages/web-api/package.json +2 -2
  43. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.0.0-alpha.45] — 2026-10-24
10
+
11
+ Includes the never-published 3.0.0-alpha.44 changes below.
12
+
13
+ ### Changed
14
+ - Graph and swarm progress bars count what's settled. The number is done + failed over the total (1 done, 1 failed, 1 running, 1 not started reads `2/4`). The bar shows done in green, failed in red and running in the accent colour, with a highlight sweeping across the running part while the run is live. Items not started yet stay grey. The same change applies to `/unipi:visualize-progress`, the app's Progress sheet and mode popover, the chart title and the end-of-run log line.
15
+
16
+ ### Fixed
17
+ - Session cost now includes what child agents spend. Graph and swarm work, subagents, the Fusion sidekick and `bg_run` agent tasks each run as a separate pi process, and the footer, the app's session cost and `/unipi:info` usage used to count only the lead's own messages. So any multi-agent run was under-priced. Each child model reply is now written into the lead session as a `unipi-child-usage` entry. The entry is saved with the session, follows the branch you're on and is never sent to the model. The footer and app add these entries to the cost, and info-screen stats count the subagent and sidekick ones too. `bg_run` agents are already counted from their own session files, so they aren't counted twice. Token counts are still the lead's own. Not covered yet: grandchildren (subagents started by subagents) and runs from before this fix.
18
+
19
+ ## [3.0.0-alpha.44] — 2026-10-19
20
+
21
+ ### Fixed
22
+ - Windows: the app can reach pi chats on a Windows PC. On Windows the bridge now listens on a named pipe (`\\.\pipe\unipi-bridge-<pid>`) instead of a Unix socket file, which Node can't create there (EACCES); Linux and macOS are unchanged. Needs UniPi host 0.5.2 on the Windows PC.
23
+ - Deleting a queued message from the app no longer stops the run. Editing, removing or steer-promoting one of pi's own queued messages used to abort the whole run and re-send what was left (UNI-305) — which meant deleting the queue's only entry killed the chat outright. The bridge now reaches pi's `AgentSession.clearQueue()` (captured with a one-time prototype patch, used only when it provably belongs to the session being edited) to drain and re-queue around the change while pi keeps streaming; the abort-and-resend path remains only as a fallback for older pi builds, and "Send now" still stops the run on purpose. (UNI-320)
24
+
9
25
  ## [3.0.0-alpha.43] — 2026-10-16
10
26
 
11
27
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -82,28 +82,28 @@
82
82
  "typebox": "^1.1.38"
83
83
  },
84
84
  "dependencies": {
85
- "@pi-unipi/app-bridge": "3.0.0-alpha.43",
86
- "@pi-unipi/ask-user": "3.0.0-alpha.43",
87
- "@pi-unipi/background-tasks": "3.0.0-alpha.43",
88
- "@pi-unipi/btw": "3.0.0-alpha.43",
89
- "@pi-unipi/command-enchantment": "3.0.0-alpha.43",
90
- "@pi-unipi/compactor": "3.0.0-alpha.43",
91
- "@pi-unipi/core": "3.0.0-alpha.43",
92
- "@pi-unipi/dream": "3.0.0-alpha.43",
93
- "@pi-unipi/footer": "3.0.0-alpha.43",
94
- "@pi-unipi/fusion": "3.0.0-alpha.43",
95
- "@pi-unipi/info-screen": "3.0.0-alpha.43",
96
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.43",
97
- "@pi-unipi/kanboard": "3.0.0-alpha.43",
98
- "@pi-unipi/mcp": "3.0.0-alpha.43",
99
- "@pi-unipi/memory": "3.0.0-alpha.43",
100
- "@pi-unipi/notify": "3.0.0-alpha.43",
101
- "@pi-unipi/skill-registry": "3.0.0-alpha.43",
102
- "@pi-unipi/subagents": "3.0.0-alpha.43",
103
- "@pi-unipi/updater": "3.0.0-alpha.43",
104
- "@pi-unipi/utility": "3.0.0-alpha.43",
105
- "@pi-unipi/web-api": "3.0.0-alpha.43",
106
- "@pi-unipi/workflow": "3.0.0-alpha.43",
85
+ "@pi-unipi/app-bridge": "3.0.0-alpha.45",
86
+ "@pi-unipi/ask-user": "3.0.0-alpha.45",
87
+ "@pi-unipi/background-tasks": "3.0.0-alpha.45",
88
+ "@pi-unipi/btw": "3.0.0-alpha.45",
89
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.45",
90
+ "@pi-unipi/compactor": "3.0.0-alpha.45",
91
+ "@pi-unipi/core": "3.0.0-alpha.45",
92
+ "@pi-unipi/dream": "3.0.0-alpha.45",
93
+ "@pi-unipi/footer": "3.0.0-alpha.45",
94
+ "@pi-unipi/fusion": "3.0.0-alpha.45",
95
+ "@pi-unipi/info-screen": "3.0.0-alpha.45",
96
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.45",
97
+ "@pi-unipi/kanboard": "3.0.0-alpha.45",
98
+ "@pi-unipi/mcp": "3.0.0-alpha.45",
99
+ "@pi-unipi/memory": "3.0.0-alpha.45",
100
+ "@pi-unipi/notify": "3.0.0-alpha.45",
101
+ "@pi-unipi/skill-registry": "3.0.0-alpha.45",
102
+ "@pi-unipi/subagents": "3.0.0-alpha.45",
103
+ "@pi-unipi/updater": "3.0.0-alpha.45",
104
+ "@pi-unipi/utility": "3.0.0-alpha.45",
105
+ "@pi-unipi/web-api": "3.0.0-alpha.45",
106
+ "@pi-unipi/workflow": "3.0.0-alpha.45",
107
107
  "@resvg/resvg-wasm": "^2.6.2",
108
108
  "beautiful-mermaid": "^1.1.3"
109
109
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/app-bridge",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "Live bridge between a pi TUI session and the UniPi phone app — streaming chat, prompts, steering and dialogs from the phone",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -31,7 +31,7 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.43"
34
+ "@pi-unipi/core": "3.0.0-alpha.45"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -8,11 +8,12 @@
8
8
  * - Every hook body is try/catch: the bridge must never break a turn.
9
9
  */
10
10
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { SessionManager } from "@earendil-works/pi-coding-agent";
11
+ import { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
12
+ import { createRequire } from "node:module";
12
13
  import { createServer, type Server, type Socket } from "node:net";
13
14
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
15
  import { homedir } from "node:os";
15
- import { join, relative, resolve as resolvePath } from "node:path";
16
+ import { dirname, join, relative, resolve as resolvePath } from "node:path";
16
17
  import { DialogHub, wrapUi } from "./dialogs.js";
17
18
  import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, editedAway, wantedEntry, treeGoRestore, type TreeGoRestore } from "./snapshot.js";
18
19
  import { searchSession } from "./search.js";
@@ -45,7 +46,7 @@ import { fileSuggestions } from "./files.js";
45
46
  import { statPaths } from "./paths.js";
46
47
  import { registerPath, resolveMedia } from "./media.js";
47
48
  import { listWorkItems, stopWorkItem, backgroundWorkItem, workLogPage, workTranscript } from "./work.js";
48
- import { bus, callCommandRunner, commandArgCompleters, pendingWorkLabel, silenceCommandEcho, subscribePendingWork, UNIPI_EVENTS } from "@pi-unipi/core";
49
+ import { bus, callCommandRunner, childUsageOf, commandArgCompleters, onChildUsage, pendingWorkLabel, silenceCommandEcho, subscribePendingWork, UNIPI_EVENTS } from "@pi-unipi/core";
49
50
  import type { BtwListPage } from "./wire.js";
50
51
 
51
52
  /** `media_chunk.data` (base64) stays well under the 900 KiB bridge line
@@ -151,7 +152,7 @@ interface TpsTrackerLike {
151
152
  const FOOTER_TPS_KEY = Symbol.for("unipi.footer.shared-tps");
152
153
  const getSharedTps = (): TpsTrackerLike | undefined => (globalThis as unknown as Record<symbol, unknown>)[FOOTER_TPS_KEY] as TpsTrackerLike | undefined;
153
154
 
154
- export const BRIDGE_VERSION = "1.0.0";
155
+ export const BRIDGE_VERSION = "1.1.0";
155
156
 
156
157
  /** The hidden extension command the bridge uses to get a command-capable
157
158
  * context (newSession/fork/navigateTree/switchSession only exist there). */
@@ -197,6 +198,21 @@ export function bridgeDir(): string {
197
198
  return process.env.UNIPI_BRIDGE_DIR || join(homedir(), ".unipi", "bridge");
198
199
  }
199
200
 
201
+ /** The path/pipe name the bridge listens on for a given pid. On win32 this
202
+ * is a named pipe (`\\\\.\\pipe\\unipi-bridge-<pid>`) so unipi-host (Windows)
203
+ * can connect; everywhere else it's a unix socket file inside `dir`.
204
+ * `platform` is injectable so this stays unit-testable on Linux. */
205
+ export function bridgeSocketPath(dir: string, pid: number, platform: NodeJS.Platform = process.platform): string {
206
+ if (platform === "win32") return "\\\\.\\pipe\\unipi-bridge-" + pid;
207
+ return join(dir, `${pid}.sock`);
208
+ }
209
+
210
+ /** Whether `path` is a Windows named pipe (as produced by `bridgeSocketPath`
211
+ * on win32), rather than a unix socket file path. */
212
+ export function isBridgePipe(path: string): boolean {
213
+ return path.startsWith("\\\\.\\pipe\\");
214
+ }
215
+
200
216
  function alive(pid: number): boolean {
201
217
  try {
202
218
  process.kill(pid, 0);
@@ -250,7 +266,107 @@ export interface BridgeDeps {
250
266
  }
251
267
  const defaultDeps: BridgeDeps = { listSessions: SessionManager.list, listAllSessions: SessionManager.listAll as typeof SessionManager.listAll };
252
268
 
269
+ /** UNI-320: pi's AgentSession.clearQueue() drains its own steer/followUp queue
270
+ * WITHOUT touching the running turn — the one primitive the extension API
271
+ * never exposed (the `input`/`sendUserMessage` surface can only ADD queue
272
+ * items, which is why editing/removing a `tui-*` row used to abort the run).
273
+ * The session instance isn't handed to extensions either, so it is captured
274
+ * by a one-time prototype patch (same approach as core's UNI-304 patch):
275
+ * methods pi itself always dispatches through `AgentSession.prototype` —
276
+ * `bindExtensions` on every session bind, `sendUserMessage`/`steer`/
277
+ * `followUp`/`abort` on activity — record the instance they are called on.
278
+ * A capture is only USED when it provably belongs to the context's session
279
+ * (same `sessionManager` object): a stale reference after a session switch
280
+ * would drain the wrong queue. Anything else — an older pi without
281
+ * clearQueue, a fake in tests — answers undefined and the bridge falls back
282
+ * to UNI-305's abort-and-resend. */
283
+ type ClearQueueSession = {
284
+ clearQueue: () => { steering: string[]; followUp: string[] };
285
+ sessionManager?: unknown;
286
+ };
287
+ let capturedSession: ClearQueueSession | undefined;
288
+ let sessionCapturePatched = false;
289
+ /** Wrap the prototype methods pi itself always dispatches through
290
+ * `AgentSession.prototype` — `bindExtensions` on every session bind,
291
+ * `sendUserMessage`/`steer`/`followUp`/`abort` on activity — so the instance
292
+ * records itself. Capture-only: behavior is unchanged, and the __unipiCapture
293
+ * flag keeps repeated patching idempotent. Must run EAGERLY (at bridge
294
+ * creation), before the first prompt: a lazily-installed patch misses every
295
+ * dispatch that already happened, and the first queue edit would find nothing
296
+ * captured and fall back to abort-and-resend. */
297
+ const patchAgentSessionPrototype = (Ctor: new () => unknown): void => {
298
+ const proto = Ctor.prototype as unknown as Record<string, ((...args: never[]) => unknown) | undefined>;
299
+ for (const name of ["bindExtensions", "sendUserMessage", "steer", "followUp", "abort"]) {
300
+ const current = proto[name];
301
+ if (typeof current !== "function" || (current as { __unipiCapture?: boolean }).__unipiCapture) continue;
302
+ const wrapped = function (this: unknown, ...args: never[]) {
303
+ capturedSession = this as ClearQueueSession;
304
+ return current!.apply(this, args);
305
+ } as ((...args: never[]) => unknown) & { __unipiCapture?: boolean };
306
+ wrapped.__unipiCapture = true;
307
+ proto[name] = wrapped;
308
+ }
309
+ };
310
+ /** The installed pi copies whose AgentSession could be the live one. There
311
+ * are SEVERAL in one process and which of them is real depends on how pi was
312
+ * started: pi's npm dist ships two separate module graphs (dist/bundle/* —
313
+ * what the CLI runs — and dist/index.js — the public barrel), and the
314
+ * extension's own static import is yet another copy: pi loads extensions
315
+ * through jiti, which evaluates the aliased pi module separately, so classes
316
+ * from the static import are never the classes the runtime uses. All
317
+ * candidates get the capture-only patch; the copies the runtime never
318
+ * instantiates simply never fire, and liveAgentSession() still validates the
319
+ * capture against this context's sessionManager before any use. */
320
+ const patchAllRuntimeCopies = (): void => {
321
+ patchAgentSessionPrototype(AgentSession as new () => unknown);
322
+ const pkgRoot = join(dirname(dirname(process.execPath)), "lib", "node_modules", "@earendil-works", "pi-coding-agent");
323
+ const candidates = [
324
+ join(pkgRoot, "dist", "bundle", "index.js"),
325
+ join(pkgRoot, "dist", "index.js"),
326
+ process.argv[1]
327
+ ? resolvePath(dirname(process.argv[1]), "..", "lib", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "bundle", "index.js")
328
+ : "",
329
+ ];
330
+ for (const index of candidates) {
331
+ if (!index) continue;
332
+ try {
333
+ const mod = createRequire(index)(index) as { AgentSession?: new () => unknown };
334
+ const Ctor = mod?.AgentSession;
335
+ if (Ctor && typeof (Ctor.prototype as { clearQueue?: unknown }).clearQueue === "function") patchAgentSessionPrototype(Ctor);
336
+ } catch {
337
+ // candidate not present in this install — the others cover it
338
+ }
339
+ }
340
+ };
341
+ /** Test hook: plant a stand-in session (the fake pi in tests has no real
342
+ * AgentSession to capture). Pass undefined to reset. */
343
+ export function setCapturedAgentSessionForTests(session: unknown): void {
344
+ capturedSession = (session ?? undefined) as ClearQueueSession | undefined;
345
+ sessionCapturePatched = true;
346
+ }
347
+ /** The live AgentSession behind `c`, captured through the prototype patch —
348
+ * but only trusted when it provably belongs to THIS session (same
349
+ * sessionManager object as the extension context): a stale reference after a
350
+ * session switch would drain the wrong queue. Anything else — an older pi,
351
+ * a fake in tests — answers undefined and the bridge falls back to
352
+ * UNI-305's abort-and-resend. */
353
+ const liveAgentSession = (c: ExtensionContext): ClearQueueSession | undefined => {
354
+ if (!sessionCapturePatched) {
355
+ patchAllRuntimeCopies();
356
+ sessionCapturePatched = true;
357
+ }
358
+ const session = capturedSession;
359
+ if (!session || typeof session.clearQueue !== "function") return undefined;
360
+ if (session.sessionManager !== (c as unknown as { sessionManager?: unknown }).sessionManager) return undefined;
361
+ return session;
362
+ };
363
+
253
364
  export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
365
+ // UNI-320: install the AgentSession capture now, before any prompt can be
366
+ // sent — a lazily-installed patch misses every dispatch that already
367
+ // happened, and the first queue edit would fall back to abort-and-resend.
368
+ // (Session-bound validation happens later, in liveAgentSession().)
369
+ patchAllRuntimeCopies();
254
370
  let ctx: ExtensionContext | undefined;
255
371
  let server: Server | undefined;
256
372
  let socketPath: string | undefined;
@@ -259,10 +375,13 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
259
375
  /** pi's own queue (steer/follow-up from ANY source — TUI keybindings, the
260
376
  * harness, an extension's `pi.sendUserMessage({deliverAs})`), mirrored from
261
377
  * the `input` event's `streamingBehavior`. UNI-202: editable/removable/
262
- * promotable from the phone too, even though the bridge can't reach pi's
263
- * live queue array directly — an edit/remove/promote on one of these
264
- * aborts the run (same effect as the TUI's Stop) and re-sends what's left
265
- * via `pi.sendUserMessage`, same as the TUI's own dequeue-and-resend. */
378
+ * promotable from the phone too. UNI-305 did that by aborting the run and
379
+ * re-sending what's left; UNI-320 found a real primitive
380
+ * (`AgentSession.clearQueue()`, captured via a prototype patch — see
381
+ * `liveAgentSession`) that drains pi's queue WITHOUT touching the run, so
382
+ * an edit/remove/promote now keeps pi streaming and only falls back to
383
+ * abort-and-resend on older pi builds ("send now" always aborts: that's
384
+ * the point of the button). */
266
385
  const tuiQueue: Array<{ id: string; text: string; mode: "steer" | "followUp"; images?: Array<{ mime: string; data: string } | { mime: string; path: string }> }> = [];
267
386
  /** The bridge's own queue ("after it ends"): phone-only, editable/removable/reorderable/promotable,
268
387
  * delivered one at a time on `agent_end`. Survives phone reconnects (sent in `hello`), not a pi restart. */
@@ -287,6 +406,10 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
287
406
  let deltaTimer: NodeJS.Timeout | undefined;
288
407
  const toolTimers = new Map<string, NodeJS.Timeout>();
289
408
  let sessionCost = 0;
409
+ // Child agents (graph/swarm work, subagents, Fusion sidekick, bg_run
410
+ // agents) record their spend into this session as custom entries; count it
411
+ // live too so the phone's cost isn't lead-only.
412
+ let unsubChildUsage: (() => void) | undefined;
290
413
  /** "Needs you" (UNI-161): an open dialog, or idle right after an
291
414
  * `agent_end` no phone has seen yet. Written into the discovery record so
292
415
  * the host's `chat_list` (crates/host/src/chat.rs) can float it to the top
@@ -478,6 +601,7 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
478
601
  try {
479
602
  for (const e of ctx!.sessionManager.getEntries() as Array<{ type?: string; message?: { role?: string; usage?: { cost?: { total?: number } } } }>) {
480
603
  if (e.type === "message" && e.message?.role === "assistant") sessionCost += e.message.usage?.cost?.total ?? 0;
604
+ else sessionCost += childUsageOf(e)?.cost ?? 0;
481
605
  }
482
606
  } catch {
483
607
  // ignore
@@ -1135,23 +1259,100 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1135
1259
  }
1136
1260
  };
1137
1261
 
1138
- const editPiQueued = async (sock: Socket, id: string, ref: string | undefined, apply: (item: (typeof tuiQueue)[number]) => "drop" | "promote" | void) => {
1262
+ const editPiQueued = async (sock: Socket, id: string, ref: string | undefined, apply: (item: (typeof tuiQueue)[number]) => "drop" | "promote" | void, promoteNow = false) => {
1139
1263
  const c = ctx;
1140
1264
  const ack = () => write(sock, { t: "ack", ref });
1141
1265
  const fail = (message: string) => write(sock, { t: "error", message, ref });
1142
1266
  if (!c) return fail("pi is gone.");
1143
1267
  const i = tuiQueue.findIndex((q) => q.id === id);
1144
1268
  if (i < 0) return fail("That queued message is gone.");
1145
- // UNI-305: pi has no API to drop ONE item from its own live steer/followUp
1146
- // queue — the only way to edit it is to abort the whole run and re-send
1147
- // what's left (see the class comment above `tuiQueue`). That's fine
1148
- // between tool calls (there is nothing running to lose), but aborting
1149
- // WHILE a tool is executing kills it mid-flight — real, unsaved work —
1150
- // and if the removed item was the queue's only entry nothing is left to
1151
- // re-send: the run just dies instead of continuing. So this waits a
1152
- // short beat for the in-flight tool to reach its own end (the next safe
1153
- // boundary) before touching the queue; a tool still running past that
1154
- // is left alone and the edit is refused instead of destroying it.
1269
+ const target = tuiQueue[i];
1270
+ const contentOf = (q: (typeof tuiQueue)[number]) => (q.images?.length ? [{ type: "text" as const, text: q.text }, ...q.images.map(imageContent)] : q.text);
1271
+ /** Re-send survivors once pi's real queue already matches: the first item
1272
+ * starts a fresh run when idle (a deliverAs while idle would be rejected
1273
+ * with "Specify streamingBehavior"), then the rest in pi's own order
1274
+ * (steer drains before followUp). */
1275
+ const resend = async (rest: Array<(typeof tuiQueue)[number]>) => {
1276
+ send({ t: "queue", items: queueView() });
1277
+ const order = [...rest].sort((a, b) => (a === rest[0] ? -1 : b === rest[0] ? 1 : a.mode === b.mode ? 0 : a.mode === "steer" ? -1 : 1));
1278
+ for (const q of order) {
1279
+ if (c.isIdle()) {
1280
+ pi.sendUserMessage(contentOf(q), { expandPromptTemplates: true } as never);
1281
+ const until = Date.now() + 5_000;
1282
+ while (c.isIdle() && Date.now() < until) await sleep(20);
1283
+ } else {
1284
+ pi.sendUserMessage(contentOf(q), { deliverAs: q.mode, expandPromptTemplates: true } as never);
1285
+ }
1286
+ }
1287
+ };
1288
+ // UNI-320: preferred path — drain pi's own queue with clearQueue() (which
1289
+ // leaves the running turn alone), drop/edit/promote the target in the
1290
+ // drained truth, and re-queue the survivors. No abort, no idle-wait, no
1291
+ // hold: a busy pi keeps streaming mid-sentence, and deleting the queue's
1292
+ // ONLY entry no longer kills the run (the UNI-305 hole where nothing was
1293
+ // left to re-send). "Send now" (promoteNow) still aborts on purpose —
1294
+ // stopping the run is what that button says it does.
1295
+ const session = promoteNow ? undefined : liveAgentSession(c);
1296
+ if (session) {
1297
+ tuiQueue.splice(i, 1);
1298
+ // pi's queue holds plain expanded texts; match the target by its
1299
+ // ORIGINAL text (apply() may edit it) so edit keeps working. Images
1300
+ // live only in the mirror, so keep a lookup before emptying it.
1301
+ const original = { text: target!.text, mode: target!.mode };
1302
+ const mirrorImages = new Map(tuiQueue.filter((q) => q.images?.length).map((q) => [`${q.mode}\u0000${q.text}`, q.images]));
1303
+ const imageOf = (text: string, mode: "steer" | "followUp") =>
1304
+ (text === original.text && mode === original.mode ? target!.images : mirrorImages.get(`${mode}\u0000${text}`));
1305
+ const action = apply(target!) ?? undefined;
1306
+ const drained = session.clearQueue();
1307
+ // The mirror must match pi's now-drained queue: empty it (the re-sent
1308
+ // survivors echo back through `input` and re-mirror themselves).
1309
+ tuiQueue.length = 0;
1310
+ const steers = [...drained.steering];
1311
+ const follows = [...drained.followUp];
1312
+ const pool = original.mode === "steer" ? steers : follows;
1313
+ const at = pool.indexOf(original.text);
1314
+ if (at < 0) {
1315
+ // pi delivered it between the phone's tap and now: put everything
1316
+ // back untouched and refuse, like the "already handed to pi" case.
1317
+ try {
1318
+ await resend([
1319
+ ...steers.map((text) => ({ id: "", text, mode: "steer" as const, images: imageOf(text, "steer") })),
1320
+ ...follows.map((text) => ({ id: "", text, mode: "followUp" as const, images: imageOf(text, "followUp") })),
1321
+ ]);
1322
+ } catch {
1323
+ // re-queue best-effort; the refusal still tells the user the truth
1324
+ }
1325
+ return fail("That message was already handed to pi — it can't be taken back now.");
1326
+ }
1327
+ pool.splice(at, 1);
1328
+ const kept = action === "drop" ? undefined : target!;
1329
+ const rest: Array<(typeof tuiQueue)[number]> = [
1330
+ ...steers.map((text) => ({ id: "", text, mode: "steer" as const, images: imageOf(text, "steer") })),
1331
+ ...follows.map((text) => ({ id: "", text, mode: "followUp" as const, images: imageOf(text, "followUp") })),
1332
+ ];
1333
+ if (kept) {
1334
+ if (action === "promote") rest.unshift(kept);
1335
+ else rest.splice(original.mode === "steer" ? Math.min(at, steers.length) : steers.length + Math.min(at, follows.length), 0, kept);
1336
+ }
1337
+ try {
1338
+ await resend(rest);
1339
+ ack();
1340
+ } catch (error) {
1341
+ fail(error instanceof Error ? error.message : String(error));
1342
+ }
1343
+ return;
1344
+ }
1345
+ // Fallback (older pi, or "send now"): UNI-305's abort-and-resend. pi has
1346
+ // no API to drop ONE item from its own live steer/followUp queue there —
1347
+ // the only way to edit it is to abort the whole run and re-send what's
1348
+ // left (see the class comment above `tuiQueue`). That's fine between tool
1349
+ // calls (there is nothing running to lose), but aborting WHILE a tool is
1350
+ // executing kills it mid-flight — real, unsaved work — and if the removed
1351
+ // item was the queue's only entry nothing is left to re-send: the run
1352
+ // just dies instead of continuing. So this waits a short beat for the
1353
+ // in-flight tool to reach its own end (the next safe boundary) before
1354
+ // touching the queue; a tool still running past that is left alone and
1355
+ // the edit is refused instead of destroying it.
1155
1356
  if (tools.size > 0) {
1156
1357
  const deadline = Date.now() + 4_000;
1157
1358
  while (tools.size > 0 && Date.now() < deadline) await sleep(100);
@@ -1162,14 +1363,15 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1162
1363
  // actually sends it) — re-check instead of operating on a stale index.
1163
1364
  const liveIndex = tuiQueue.findIndex((q) => q.id === id);
1164
1365
  if (liveIndex < 0) return fail("That message was already handed to pi — it can't be taken back now.");
1165
- const [target] = tuiQueue.splice(liveIndex, 1);
1166
- const action = apply(target!) ?? undefined;
1366
+ const [liveTarget] = tuiQueue.splice(liveIndex, 1);
1367
+ const action = apply(liveTarget!) ?? undefined;
1167
1368
  // Keep the edited/kept target in its original slot (promote: first).
1168
1369
  const rest = tuiQueue.splice(0, tuiQueue.length);
1169
- if (action === "promote") rest.unshift(target!);
1170
- else if (action !== "drop") rest.splice(liveIndex, 0, target!);
1370
+ if (action === "promote") rest.unshift(liveTarget!);
1371
+ else if (action !== "drop") rest.splice(liveIndex, 0, liveTarget!);
1171
1372
  if (!c.isIdle()) {
1172
1373
  await abortKeepingEditor(c);
1374
+
1173
1375
  const deadline = Date.now() + 10_000;
1174
1376
  while (!c.isIdle() && Date.now() < deadline) await sleep(50);
1175
1377
  // The abort was only the means to edit pi's queue: the re-sent items
@@ -1177,23 +1379,8 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1177
1379
  // re-sent the hold lapses and the "after" queue delivers normally.
1178
1380
  holdUntilNextRun();
1179
1381
  }
1180
- send({ t: "queue", items: queueView() });
1181
- const contentOf = (q: (typeof tuiQueue)[number]) => (q.images?.length ? [{ type: "text" as const, text: q.text }, ...q.images.map(imageContent)] : q.text);
1182
1382
  try {
1183
- // pi is idle now, so a deliverAs message would be rejected ("Specify
1184
- // streamingBehavior"/"already processing"). The first item starts a
1185
- // fresh run; wait until pi is streaming, then queue the rest in pi's
1186
- // own order (steer drains before followUp).
1187
- const order = [...rest].sort((a, b) => (a === rest[0] ? -1 : b === rest[0] ? 1 : a.mode === b.mode ? 0 : a.mode === "steer" ? -1 : 1));
1188
- for (const q of order) {
1189
- if (c.isIdle()) {
1190
- pi.sendUserMessage(contentOf(q), { expandPromptTemplates: true } as never);
1191
- const until = Date.now() + 5_000;
1192
- while (c.isIdle() && Date.now() < until) await sleep(20);
1193
- } else {
1194
- pi.sendUserMessage(contentOf(q), { deliverAs: q.mode, expandPromptTemplates: true } as never);
1195
- }
1196
- }
1383
+ await resend(rest);
1197
1384
  ack();
1198
1385
  } catch (error) {
1199
1386
  fail(error instanceof Error ? error.message : String(error));
@@ -1311,7 +1498,9 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1311
1498
  return;
1312
1499
  }
1313
1500
  case "queue_promote": {
1314
- if (msg.id.startsWith("tui-")) return void (await editPiQueued(sock, msg.id, msg.ref, () => "promote"));
1501
+ // "now" means stop the run on purpose — keep it on the abort path
1502
+ // even when clearQueue surgery is available.
1503
+ if (msg.id.startsWith("tui-")) return void (await editPiQueued(sock, msg.id, msg.ref, () => "promote", msg.to === "now"));
1315
1504
  const i = bridgeQueue.findIndex((q) => q.id === msg.id);
1316
1505
  if (i < 0) return fail("That queued message is gone.");
1317
1506
  const [item] = bridgeQueue.splice(i, 1);
@@ -1766,8 +1955,8 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1766
1955
  return;
1767
1956
  }
1768
1957
  sweepDead(dir);
1769
- socketPath = join(dir, `${process.pid}.sock`);
1770
- rmSync(socketPath, { force: true });
1958
+ socketPath = bridgeSocketPath(dir, process.pid);
1959
+ if (!isBridgePipe(socketPath)) rmSync(socketPath, { force: true });
1771
1960
  const srv = createServer((sock) => {
1772
1961
  clients.add(sock);
1773
1962
  // A phone connecting has now seen the state (it's in `hello`): the
@@ -1808,10 +1997,12 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1808
1997
  // Socket trouble (dir removed…) never matters to pi.
1809
1998
  });
1810
1999
  srv.listen(socketPath, () => {
1811
- try {
1812
- chmodSync(socketPath!, 0o600);
1813
- } catch {
1814
- // ignore
2000
+ if (!isBridgePipe(socketPath!)) {
2001
+ try {
2002
+ chmodSync(socketPath!, 0o600);
2003
+ } catch {
2004
+ // ignore
2005
+ }
1815
2006
  }
1816
2007
  writeRecord();
1817
2008
  });
@@ -1850,7 +2041,7 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1850
2041
  server = undefined;
1851
2042
  const dir = bridgeDir();
1852
2043
  rmSync(join(dir, `${process.pid}.json`), { force: true });
1853
- if (socketPath) rmSync(socketPath, { force: true });
2044
+ if (socketPath && !isBridgePipe(socketPath)) rmSync(socketPath, { force: true });
1854
2045
  socketPath = undefined;
1855
2046
  };
1856
2047
 
@@ -1875,6 +2066,10 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1875
2066
  syncSoon();
1876
2067
  });
1877
2068
  computeCost();
2069
+ unsubChildUsage?.();
2070
+ unsubChildUsage = onChildUsage((data) => {
2071
+ sessionCost += data.cost;
2072
+ });
1878
2073
  setRemoteDialogs(hub);
1879
2074
  listen();
1880
2075
  writeRecord();
@@ -1998,6 +2193,8 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1998
2193
  };
1999
2194
  on("session_start", safe(() => watchDream()));
2000
2195
  on("session_shutdown", safe(() => {
2196
+ unsubChildUsage?.();
2197
+ unsubChildUsage = undefined;
2001
2198
  unsubDream?.();
2002
2199
  unsubDream = undefined;
2003
2200
  if (dreamTimer) clearTimeout(dreamTimer);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@pi-unipi/core": "3.0.0-alpha.43"
45
+ "@pi-unipi/core": "3.0.0-alpha.45"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "Background tasks for UniPi — durable shell jobs and delegated background agents with a footer dock",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.87.1",
13
- "@pi-unipi/core": "3.0.0-alpha.43",
13
+ "@pi-unipi/core": "3.0.0-alpha.45",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -14,6 +14,7 @@ import {
14
14
  isChildProcess,
15
15
  type KitTheme,
16
16
  openWorkTray,
17
+ recordChildUsage,
17
18
  registerWaitSource,
18
19
  registerWorkTrayTab,
19
20
  setBashBackgroundAdopter,
@@ -55,6 +56,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
55
56
  }
56
57
 
57
58
  const seenTaskIds = new Set<string>();
59
+ const costRecorded = new Set<string>();
58
60
  let currentCtx: ExtensionContext | undefined;
59
61
 
60
62
  const registry = new BackgroundTaskRegistry({
@@ -68,6 +70,16 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
68
70
  pi.sendMessage(message as never, options as never);
69
71
  },
70
72
  publishTerminal: (task) => {
73
+ // An agent task's spend belongs in the lead session's cost. Its own pi
74
+ // session already lives under the global sessions dir, so it is not
75
+ // standalone for cross-session stats.
76
+ if (task.isAgent && task.tokenUsage && !costRecorded.has(task.id)) {
77
+ costRecorded.add(task.id);
78
+ const u = task.tokenUsage;
79
+ recordChildUsage((type, data) => pi.appendEntry(type, data), { source: "bg-task", id: task.id, model: task.model, standalone: false }, {
80
+ input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite, cost: u.costTotal,
81
+ });
82
+ }
71
83
  eventService.publishTerminal(task);
72
84
  },
73
85
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.87.1"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.43"
40
+ "@pi-unipi/core": "3.0.0-alpha.45"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "3.0.0-alpha.43",
3
+ "version": "3.0.0-alpha.45",
4
4
  "description": "Compaction for Pi — lossless zero-LLM or model summaries, rebuilt from full history, with session recall",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,8 +33,8 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.43",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.43",
36
+ "@pi-unipi/core": "3.0.0-alpha.45",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.45",
38
38
  "@earendil-works/pi-agent-core": "^0.87.1"
39
39
  },
40
40
  "peerDependencies": {